← Back to Gists

WebDAV Concurrent Synchronization Script

📝 Python
retoor
retoor · Level 54635 ·

User Safety: safe

Python
#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>
"""as.py - asynchronous WebDAV synchronizer.

Walks a local source tree and mirrors it onto a remote WebDAV collection,
uploading many files concurrently. Re-running the script resumes naturally:
files already present remotely with a matching byte size are skipped.

Every individual file or directory failure is caught, logged and skipped so a
single unreadable or unreachable entry never aborts the whole run.
"""

from __future__ import annotations

import argparse
import asyncio
import base64
import logging
import os
import random
import sys
import time
from dataclasses import dataclass, field
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional
from urllib.parse import quote

import aiofiles
import aiohttp
from xml.etree import ElementTree as ET

DAV_NS = "DAV:"

PROG = "as.py"
VERSION = "1.0.0"

DEFAULT_HOST = "xtc-makes-you-free-sub1.your-storagebox.de"
DEFAULT_USER = "lololo-sub1"
DEFAULT_PASSWORD = "lalala*"
DEFAULT_CONCURRENCY = 16
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_CHUNK_BYTES = 1024 * 1024
DEFAULT_RETRIES = 3
DEFAULT_BACKOFF_BASE = 1.0
DEFAULT_BACKOFF_MAX = 30.0
DEFAULT_BACKOFF_JITTER = 0.5
DEFAULT_SCAN_TIMEOUT = 10.0
DEFAULT_LOG_FILE = "as.log"
DEFAULT_LOG_BYTES = 10 * 1024 * 1024
DEFAULT_LOG_BACKUPS = 5
LOG_FORMAT = "%(asctime)s %(levelname)-8s [%(name)s] %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"

logger = logging.getLogger("as")


def _basic_auth_header(user: str, password: str) -> str:
    """Build an HTTP Basic Authorization header value.

    Encoded with base64 directly so it works on every aiohttp version.
    """
    token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
    return f"Basic {token}"


@dataclass
class Stats:
    uploaded: int = 0
    skipped: int = 0
    created_dirs: int = 0
    failed_files: int = 0
    failed_dirs: int = 0
    inaccessible_files: int = 0
    bytes_uploaded: int = 0
    lock: asyncio.Lock = field(default_factory=asyncio.Lock)

    async def bump(self, **kwargs: int) -> None:
        async with self.lock:
            for key, value in kwargs.items():
                setattr(self, key, getattr(self, key) + value)


class WebDavClient:
    """Thin async wrapper around the WebDAV verbs we need."""

    def __init__(self, host: str, user: str, password: str,
                 timeout_seconds: int, session: aiohttp.ClientSession,
                 retries: int = DEFAULT_RETRIES,
                 backoff_base: float = DEFAULT_BACKOFF_BASE,
                 backoff_max: float = DEFAULT_BACKOFF_MAX,
                 backoff_jitter: float = DEFAULT_BACKOFF_JITTER) -> None:
        self.base = f"https://{host}"
        self.auth_header = {
            "Authorization": _basic_auth_header(user, password),
        }
        self.timeout = aiohttp.ClientTimeout(total=timeout_seconds)
        self.session = session
        self.retries = retries
        self.backoff_base = backoff_base
        self.backoff_max = backoff_max
        self.backoff_jitter = backoff_jitter

    def url_for(self, remote_path: str, directory: bool = False) -> str:
        cleaned = remote_path if remote_path.startswith("/") else f"/{remote_path}"
        encoded = "/".join(quote(part, safe="") for part in cleaned.split("/"))
        if directory and not encoded.endswith("/"):
            encoded += "/"
        return f"{self.base}{encoded}"

    @staticmethod
    def _is_retryable_status(status: int) -> bool:
        """HTTP statuses that indicate a transient failure worth retrying."""
        return status in (408, 425, 429, 500, 502, 503, 504)

    @staticmethod
    def _is_retryable_error(exc: BaseException) -> bool:
        """Network-level failures worth retrying on a flaky connection."""
        if isinstance(exc, asyncio.TimeoutError):
            return True
        if isinstance(exc, aiohttp.ClientConnectionError):
            return True
        if isinstance(exc, aiohttp.ServerDisconnectedError):
            return True
        if isinstance(exc, aiohttp.ClientPayloadError):
            return True
        if isinstance(exc, OSError):
            return True
        return False

    def _backoff_delay(self, attempt: int) -> float:
        """Exponential backoff with jitter, capped at a sane maximum."""
        exponent = min(attempt - 1, 10)
        base = min(self.backoff_base * (2 ** exponent), self.backoff_max)
        if self.backoff_jitter <= 0:
            return base
        return random.uniform(base * (1 - self.backoff_jitter),
                              base * (1 + self.backoff_jitter))

    async def _request_with_retry(self, method: str, url: str,
                                  headers: dict, data=None) -> aiohttp.ClientResponse:
        """Issue a WebDAV request, retrying transient failures with backoff.

        Returns the final response. Permanent HTTP errors (4xx other than the
        retryable set) and permanent local errors are surfaced immediately.
        """
        attempts = self.retries + 1
        last_exc: Optional[BaseException] = None
        last_status: Optional[int] = None
        for attempt in range(1, attempts + 1):
            try:
                resp = await self.session.request(
                    method, url, timeout=self.timeout, headers=headers, data=data,
                )
                if not self._is_retryable_status(resp.status):
                    return resp
                last_status = resp.status
                await resp.release()
                logger.warning(
                    "%s %s -> HTTP %s (attempt %d/%d, retrying)",
                    method, url, resp.status, attempt, attempts,
                )
            except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
                if not self._is_retryable_error(exc):
                    raise
                last_exc = exc
                logger.warning(
                    "%s %s failed: %s (attempt %d/%d, retrying)",
                    method, url, exc, attempt, attempts,
                )
            if attempt < attempts:
                await asyncio.sleep(self._backoff_delay(attempt))
        if last_status is not None:
            raise aiohttp.ClientResponseError(
                None, (), status=last_status,
                message=f"HTTP {last_status} after retries",
            )
        raise last_exc if last_exc is not None else RuntimeError("request failed")

    async def propfind(self, remote_path: str) -> Optional[dict]:
        """Return {child_name: size_bytes} for a collection, or None if missing."""
        url = self.url_for(remote_path, directory=True)
        body = (
            '<?xml version="1.0" encoding="utf-8"?>'
            '<d:propfind xmlns:d="DAV:">'
            "<d:prop><d:resourcetype/><d:getcontentlength/></d:prop>"
            "</d:propfind>"
        )
        headers = {"Depth": "1", "Content-Type": "application/xml"}
        headers.update(self.auth_header)
        try:
            resp = await self._request_with_retry(
                "PROPFIND", url, headers=headers, data=body,
            )
        except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
            logger.error("PROPFIND %s failed after retries: %s", remote_path, exc)
            return None
        try:
            async with resp:
                if resp.status in (404, 405):
                    return None
                if resp.status != 207:
                    logger.warning("PROPFIND %s -> HTTP %s", remote_path, resp.status)
                    return None
                text = await resp.text()
        except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
            logger.error("PROPFIND %s body read failed: %s", remote_path, exc)
            return None
        return self._parse_multistatus(text)

    @staticmethod
    def _parse_multistatus(text: str) -> dict:
        children: dict = {}
        try:
            root = ET.fromstring(text)
        except ET.ParseError as exc:
            logger.warning("Could not parse PROPFIND response: %s", exc)
            return children
        for response in root.iter(f"{{{DAV_NS}}}response"):
            href_el = response.find(f"{{{DAV_NS}}}href")
            if href_el is None or not href_el.text:
                continue
            href = href_el.text.strip()
            name = href.rstrip("/").rsplit("/", 1)[-1]
            if not name:
                continue
            is_collection = response.find(
                f".//{{{DAV_NS}}}resourcetype/{{{DAV_NS}}}collection"
            ) is not None
            if is_collection:
                children[name] = -1
                continue
            length_el = response.find(f".//{{{DAV_NS}}}getcontentlength")
            try:
                children[name] = int(length_el.text) if length_el is not None else -1
            except (TypeError, ValueError):
                children[name] = -1
        return children

    async def mkcol(self, remote_path: str) -> bool:
        url = self.url_for(remote_path, directory=True)
        try:
            resp = await self._request_with_retry(
                "MKCOL", url, headers=self.auth_header,
            )
        except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
            logger.error("MKCOL %s failed after retries: %s", remote_path, exc)
            return False
        async with resp:
            if resp.status in (201, 405):
                return True
            logger.warning("MKCOL %s -> HTTP %s", remote_path, resp.status)
            return False

    async def put_file(self, remote_path: str, local_path: Path,
                       chunk_bytes: int) -> bool:
        url = self.url_for(remote_path)
        attempts = self.retries + 1
        for attempt in range(1, attempts + 1):
            try:
                handle = await aiofiles.open(local_path, "rb")
            except (OSError, PermissionError) as exc:
                logger.error("Cannot open local file %s: %s", local_path, exc)
                raise

            async def stream_body():
                try:
                    while True:
                        chunk = await handle.read(chunk_bytes)
                        if not chunk:
                            break
                        yield chunk
                finally:
                    await handle.close()

            retryable = False
            try:
                resp = await self.session.put(
                    url, timeout=self.timeout, data=stream_body(),
                    headers=self.auth_header,
                )
                if resp.status in (200, 201, 204):
                    await resp.release()
                    return True
                retryable = self._is_retryable_status(resp.status)
                logger.warning(
                    "PUT %s -> HTTP %s (attempt %d/%d)",
                    remote_path, resp.status, attempt, attempts,
                )
                await resp.release()
            except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
                retryable = self._is_retryable_error(exc)
                logger.warning(
                    "PUT %s failed: %s (attempt %d/%d)",
                    remote_path, exc, attempt, attempts,
                )
            finally:
                if not handle.closed:
                    await handle.close()
            if not retryable:
                return False
            if attempt < attempts:
                await asyncio.sleep(self._backoff_delay(attempt))
        return False


@dataclass
class DirTask:
    rel: str
    priority: int = 0


@dataclass
class FileTask:
    rel: str
    local: Path
    size: Optional[int]
    priority: int = 0


@dataclass
class DirInfo:
    rel: str
    local: Path
    subdirs: list
    files: list
    size: int


UNKNOWN_SIZE = -1
UNKNOWN_PRIORITY = 1 << 62


_STALLED = object()


def _list_entries(path: Path):
    """Return a sorted list of child paths, or None if the dir is unreadable."""
    try:
        return sorted(path.iterdir(), key=lambda p: p.name)
    except (OSError, PermissionError) as exc:
        logger.error("Cannot read local dir %s: %s", path, exc)
        return None


def _inspect_entry(path: Path):
    """Return (kind, size) for an entry, never raising on a broken FS.

    kind is 'dir', 'file' or None; size is the byte count or None when it
    cannot be determined.
    """
    try:
        if path.is_dir():
            return ("dir", None)
    except (OSError, PermissionError):
        pass
    try:
        if path.is_file():
            try:
                return ("file", path.stat().st_size)
            except (OSError, PermissionError, ValueError):
                return ("file", None)
    except (OSError, PermissionError):
        pass
    return (None, None)


async def _run_blocking(fn, timeout: float, *args):
    """Run a blocking filesystem call in a thread, time-boxed.

    Returns the call's result, or the _STALLED sentinel if it does not finish
    within `timeout` seconds. A stalled call never aborts the scan.
    """
    try:
        return await asyncio.wait_for(
            asyncio.to_thread(fn, *args), timeout=timeout,
        )
    except asyncio.TimeoutError:
        return _STALLED


async def _scan_dir(local_dir: Path, rel: str, manifest: dict,
                    op_timeout: float) -> int:
    """Recursively walk a directory, filling the manifest.

    Returns the best-effort total size in bytes of this subtree. Every
    filesystem interaction is individually time-boxed and guarded, so a single
    unreadable or stalled directory is logged and skipped while the rest of the
    tree continues to be scanned.
    """
    result = await _run_blocking(_list_entries, op_timeout, local_dir)
    if result is _STALLED:
        logger.error("Timed out reading dir %s; skipping its subtree",
                     local_dir)
        manifest[rel] = DirInfo(rel=rel, local=local_dir, subdirs=[], files=[],
                                size=0)
        return 0
    if result is None:
        manifest[rel] = DirInfo(rel=rel, local=local_dir, subdirs=[], files=[],
                                size=0)
        return 0
    entries = result

    subdirs: list = []
    files: list = []
    total = 0
    for entry in entries:
        name = entry.name
        child_rel = f"{rel}/{name}" if rel else name
        inspected = await _run_blocking(_inspect_entry, op_timeout, entry)
        if inspected is _STALLED:
            logger.error("Timed out inspecting %s; skipping it", entry)
            continue
        kind, size = inspected
        if kind == "dir":
            child_size = await _scan_dir(entry, child_rel, manifest, op_timeout)
            subdirs.append((name, child_size))
            total += child_size
        elif kind == "file":
            files.append((name, entry, size))
            if size is not None:
                total += size
        else:
            logger.warning("Skipping non-file/non-dir entry %s", entry)
    manifest[rel] = DirInfo(rel=rel, local=local_dir, subdirs=subdirs,
                            files=files, size=total)
    return total


class Synchronizer:
    def __init__(self, source: Path, remote_root: str, client: WebDavClient,
                 concurrency: int, chunk_bytes: int, dry_run: bool,
                 stats: Stats, scan_timeout: float = 300.0) -> None:
        self.source = source
        self.remote_root = remote_root.rstrip("/")
        self.client = client
        self.concurrency = concurrency
        self.chunk_bytes = chunk_bytes
        self.dry_run = dry_run
        self.stats = stats
        self.scan_timeout = scan_timeout
        self.queue: asyncio.PriorityQueue = asyncio.PriorityQueue()
        self.manifest: dict = {}
        self.seq = 0
        self.pending = 0
        self.pending_lock = asyncio.Lock()
        self.stop_event = asyncio.Event()

    def remote_path(self, rel: str) -> str:
        return f"{self.remote_root}/{rel}" if rel else self.remote_root

    def _next_seq(self) -> int:
        self.seq += 1
        return self.seq

    async def _put(self, task) -> None:
        """Enqueue a task ordered by size (smallest first)."""
        priority = task.priority if task.priority >= 0 else UNKNOWN_PRIORITY
        await self.queue.put((priority, self._next_seq(), task))
        async with self.pending_lock:
            self.pending += 1

    async def ensure_root(self) -> bool:
        parts = [p for p in self.remote_root.split("/") if p]
        current = ""
        for part in parts:
            current = f"{current}/{part}"
            existing = await self.client.propfind(current)
            if existing is None:
                if self.dry_run:
                    logger.info("DRY-RUN would MKCOL %s", current)
                else:
                    ok = await self.client.mkcol(current)
                    if ok:
                        await self.stats.bump(created_dirs=1)
                        logger.info("Created remote dir %s", current)
                    else:
                        logger.error("Failed to create remote dir %s", current)
                        return False
            else:
                logger.debug("Remote dir exists %s", current)
        return True

    async def scan_and_enqueue(self) -> bool:
        """Walk the local tree into a manifest, then enqueue the root dir.

        Returns True if the root task was enqueued, False if the source root
        itself could not be scanned. Individual unreadable or stalled
        subdirectories are logged and skipped without aborting the run.
        """
        logger.info("Scanning source tree for sizes...")
        scanned: dict = {}
        await _scan_dir(self.source, "", scanned, self.scan_timeout)
        self.manifest = scanned
        root = self.manifest.get("")
        if root is None:
            logger.error("Source could not be scanned: %s", self.source)
            return False
        logger.info("Scan complete: %d directories, %d bytes total",
                    len(self.manifest), root.size)
        await self._put(DirTask(rel="", priority=root.size))
        return True

    async def process_dir(self, task: DirTask) -> None:
        info = self.manifest.get(task.rel)
        if info is None:
            logger.error("No manifest entry for dir %s", task.rel)
            await self.stats.bump(failed_dirs=1)
            return
        remote_dir = self.remote_path(task.rel)

        remote_children: dict = {}
        if not self.dry_run:
            remote_children = await self.client.propfind(remote_dir) or {}
        else:
            logger.debug("DRY-RUN skip PROPFIND %s", remote_dir)

        for name, child_size in info.subdirs:
            rel = f"{task.rel}/{name}" if task.rel else name
            if name not in remote_children:
                if self.dry_run:
                    logger.info("DRY-RUN would MKCOL %s", self.remote_path(rel))
                    await self.stats.bump(created_dirs=1)
                else:
                    ok = await self.client.mkcol(self.remote_path(rel))
                    if ok:
                        await self.stats.bump(created_dirs=1)
                        logger.info("Created remote dir %s", self.remote_path(rel))
                    else:
                        logger.error("Failed to create remote dir %s",
                                     self.remote_path(rel))
                        await self.stats.bump(failed_dirs=1)
                        continue
            await self._put(DirTask(rel=rel, priority=child_size))

        for name, local, size in info.files:
            rel = f"{task.rel}/{name}" if task.rel else name
            remote_size = remote_children.get(name)
            if size is not None and remote_size == size:
                await self.stats.bump(skipped=1)
                logger.debug("Skip (size match) %s", rel)
            else:
                priority = size if size is not None else UNKNOWN_SIZE
                await self._put(FileTask(rel=rel, local=local, size=size,
                                         priority=priority))

    async def process_file(self, task: FileTask) -> None:
        remote = self.remote_path(task.rel)
        if self.dry_run:
            label = task.size if task.size is not None else "unknown"
            logger.info("DRY-RUN would upload %s (%s bytes)", task.rel, label)
            await self.stats.bump(uploaded=1)
            if task.size is not None:
                await self.stats.bump(bytes_uploaded=task.size)
            return
        try:
            ok = await self.client.put_file(remote, task.local, self.chunk_bytes)
        except (OSError, PermissionError) as exc:
            logger.error("Cannot read local file %s: %s", task.local, exc)
            await self.stats.bump(inaccessible_files=1)
            return
        if ok:
            await self.stats.bump(uploaded=1)
            if task.size is not None:
                await self.stats.bump(bytes_uploaded=task.size)
            logger.info("Uploaded %s (%s bytes)", task.rel,
                        task.size if task.size is not None else "unknown")
        else:
            await self.stats.bump(failed_files=1)
            logger.error("Failed to upload %s", task.rel)

    async def worker(self, worker_id: int) -> None:
        while True:
            _, _, task = await self.queue.get()
            try:
                if isinstance(task, DirTask):
                    logger.debug("Worker %d processing dir %s", worker_id, task.rel)
                    await self.process_dir(task)
                elif isinstance(task, FileTask):
                    logger.debug("Worker %d processing file %s", worker_id, task.rel)
                    await self.process_file(task)
                elif task is None:
                    return
            except Exception as exc:  # noqa: BLE001 - never let a task kill a worker
                logger.exception("Unexpected error processing %r: %s", task, exc)
            finally:
                self.queue.task_done()
                async with self.pending_lock:
                    self.pending -= 1
                    if self.pending <= 0:
                        self.pending = 0
                        self.stop_event.set()

    async def run(self) -> Stats:
        logger.info("Source: %s", self.source)
        logger.info("Remote root: %s", self.remote_path(""))
        if not self.source.is_dir():
            logger.error("Source is not a readable directory: %s", self.source)
            return self.stats
        if not self.dry_run:
            if not await self.ensure_root():
                logger.error("Could not ensure remote root exists; aborting.")
                return self.stats
        if not await self.scan_and_enqueue():
            logger.error("Source scan failed; aborting.")
            return self.stats
        workers = [
            asyncio.create_task(self.worker(i)) for i in range(self.concurrency)
        ]
        await self.stop_event.wait()
        for _ in workers:
            await self.queue.put((0, self._next_seq(), None))
        await asyncio.gather(*workers)
        return self.stats


def positive_int(value: str) -> int:
    """Argparse type: require an integer strictly greater than zero."""
    try:
        parsed = int(value)
    except ValueError:
        raise argparse.ArgumentTypeError(f"'{value}' is not a valid integer")
    if parsed < 1:
        raise argparse.ArgumentTypeError(f"'{value}' must be >= 1")
    return parsed


def non_negative_float(value: str) -> float:
    """Argparse type: require a non-negative number."""
    try:
        parsed = float(value)
    except ValueError:
        raise argparse.ArgumentTypeError(f"'{value}' is not a valid number")
    if parsed < 0:
        raise argparse.ArgumentTypeError(f"'{value}' must be >= 0")
    return parsed


def jitter_fraction(value: str) -> float:
    """Argparse type: require a fraction between 0.0 and 1.0 inclusive."""
    parsed = non_negative_float(value)
    if parsed > 1.0:
        raise argparse.ArgumentTypeError(f"'{value}' must be <= 1.0")
    return parsed


def log_level(value: str) -> int:
    """Argparse type: accept a logging level name or numeric value."""
    normalized = value.upper()
    level = getattr(logging, normalized, None)
    if isinstance(level, int):
        return level
    try:
        return int(value)
    except ValueError:
        raise argparse.ArgumentTypeError(
            f"'{value}' is not a valid log level "
            "(use DEBUG, INFO, WARNING, ERROR, CRITICAL or a number)"
        )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog=PROG,
        description=(
            "Asynchronously mirror a local directory tree onto a remote WebDAV "
            "collection, uploading many files concurrently.\n\n"
            "The remote destination is created recursively when it does not "
            "exist. Re-running the tool resumes naturally: files already "
            "present remotely with a matching byte size are skipped, so an "
            "interrupted run can simply be started again to finish the job.\n\n"
            "Every individual file or directory failure is caught, logged and "
            "skipped, so a single unreadable or unreachable entry never aborts "
            "the whole run."
        ),
        epilog=(
            "examples:\n"
            "  %(prog)s /home/user/photos --remote /backup/photos\n"
            "      Mirror the local 'photos' directory into /backup/photos.\n\n"
            "  %(prog)s /data --remote /backup/data --concurrency 32 \\\n"
            "      --timeout 120 --verbose\n"
            "      Mirror /data with 32 parallel uploads, a 120s per-request\n"
            "      timeout and DEBUG-level logging.\n\n"
            "  %(prog)s /data --remote /backup/data --dry-run\n"
            "      Report what would be uploaded without touching the remote.\n\n"
            "Credentials default to the configured Hetzner Storage Box and can "
            "be overridden with --host/--user/--password or the AS_HOST, "
            "AS_USER and AS_PASSWORD environment variables."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
        add_help=True,
    )
    parser.add_argument(
        "--version", action="version", version=f"%(prog)s {VERSION}",
        help="Show the program version and exit.",
    )

    source_group = parser.add_argument_group(
        "source and destination",
        "What to copy and where to put it.",
    )
    source_group.add_argument(
        "source", type=Path, metavar="SOURCE",
        help="Local source directory to mirror onto the remote. Must be an "
             "existing, readable directory.",
    )
    source_group.add_argument(
        "--remote", required=True, metavar="PATH",
        help="Remote destination path on the storage box, e.g. /backup/mydata. "
             "Leading slash is optional. Any missing parent collections are "
             "created recursively.",
    )

    connection_group = parser.add_argument_group(
        "connection",
        "WebDAV server credentials and endpoint. All default to the "
        "configured Hetzner Storage Box.",
    )
    connection_group.add_argument(
        "--host", default=DEFAULT_HOST, metavar="HOST",
        help=f"Storage box hostname. (default: {DEFAULT_HOST})",
    )
    connection_group.add_argument(
        "--user", default=DEFAULT_USER, metavar="USER",
        help=f"WebDAV username. (default: {DEFAULT_USER})",
    )
    connection_group.add_argument(
        "--password", default=DEFAULT_PASSWORD, metavar="PASSWORD",
        help="WebDAV password. Prefer the AS_PASSWORD environment variable "
             "over the command line to avoid exposing it in process listings.",
    )

    transfer_group = parser.add_argument_group(
        "transfer",
        "Tuning for throughput, reliability and concurrency.",
    )
    transfer_group.add_argument(
        "--concurrency", type=positive_int, default=DEFAULT_CONCURRENCY,
        metavar="N",
        help=f"Maximum number of files uploaded in parallel. Higher values "
             f"increase throughput but use more connections and memory. "
             f"(default: {DEFAULT_CONCURRENCY})",
    )
    transfer_group.add_argument(
        "--timeout", type=positive_int, default=DEFAULT_TIMEOUT_SECONDS,
        metavar="SECONDS",
        help=f"Per-request timeout in seconds. A request that exceeds this is "
             f"aborted and the file is retried or skipped rather than hanging "
             f"forever. (default: {DEFAULT_TIMEOUT_SECONDS})",
    )
    transfer_group.add_argument(
        "--chunk", type=positive_int, default=DEFAULT_CHUNK_BYTES,
        metavar="BYTES",
        help=f"Size in bytes of each streaming read/write chunk while "
             f"uploading. Files are streamed in chunks, never loaded whole "
             f"into memory. (default: {DEFAULT_CHUNK_BYTES})",
    )
    transfer_group.add_argument(
        "--retries", type=positive_int, default=DEFAULT_RETRIES,
        metavar="N",
        help=f"Number of times to retry a request that fails due to a transient "
             f"network error or a 5xx/429 server response before giving up on "
             f"that item. Applies to directory listings, directory creation and "
             f"file uploads. Permanent errors (e.g. 401, 403, 404) are never "
             f"retried. (default: {DEFAULT_RETRIES})",
    )
    transfer_group.add_argument(
        "--backoff-base", type=non_negative_float, default=DEFAULT_BACKOFF_BASE,
        metavar="SECONDS",
        help=f"Base delay in seconds for the first retry. Each subsequent "
             f"retry doubles the delay, up to --backoff-max. "
             f"(default: {DEFAULT_BACKOFF_BASE})",
    )
    transfer_group.add_argument(
        "--backoff-max", type=non_negative_float, default=DEFAULT_BACKOFF_MAX,
        metavar="SECONDS",
        help=f"Upper bound in seconds for the retry delay. "
             f"(default: {DEFAULT_BACKOFF_MAX})",
    )
    transfer_group.add_argument(
        "--backoff-jitter", type=jitter_fraction, default=DEFAULT_BACKOFF_JITTER,
        metavar="FRACTION",
        help=f"Random jitter applied to each retry delay, as a fraction of the "
             f"delay, to avoid many concurrent workers retrying in lockstep. "
             f"0 disables jitter, 1.0 allows up to double the delay. "
             f"(default: {DEFAULT_BACKOFF_JITTER})",
    )
    transfer_group.add_argument(
        "--scan-timeout", type=non_negative_float, default=DEFAULT_SCAN_TIMEOUT,
        metavar="SECONDS",
        help=f"Maximum seconds to wait on a single filesystem operation "
             f"(listing a directory, classifying an entry, reading a size) "
             f"during the source scan before giving up on that item. A "
             f"stalled or unreadable directory is logged and skipped while "
             f"the rest of the tree is still scanned. "
             f"(default: {DEFAULT_SCAN_TIMEOUT})",
    )

    logging_group = parser.add_argument_group(
        "logging",
        "Control where and how much is logged.",
    )
    logging_group.add_argument(
        "--log", type=Path, default=Path(DEFAULT_LOG_FILE), metavar="FILE",
        help=f"Write a rotating log file to this path. "
             f"(default: {DEFAULT_LOG_FILE})",
    )
    logging_group.add_argument(
        "--log-level", type=log_level, default=None, metavar="LEVEL",
        help="Console log verbosity: DEBUG, INFO, WARNING, ERROR or CRITICAL. "
             "The log file always records DEBUG. (default: INFO)",
    )
    logging_group.add_argument(
        "--verbose", action="store_true",
        help="Shorthand for --log-level DEBUG. Enables per-file and per-worker "
             "diagnostic logging.",
    )
    logging_group.add_argument(
        "--quiet", action="store_true",
        help="Suppress INFO logging on the console; only warnings and errors "
             "are shown.",
    )

    behavior_group = parser.add_argument_group(
        "behavior",
        "Optional runtime behaviour.",
    )
    behavior_group.add_argument(
        "--dry-run", action="store_true",
        help="Do not contact the remote for writes. Walk the source tree and "
             "report which directories would be created and which files would "
             "be uploaded, without uploading anything.",
    )
    return parser


def configure_logging(log_path: Path, console_level: int) -> None:
    root = logging.getLogger()
    root.setLevel(logging.DEBUG)
    formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)

    console = logging.StreamHandler(sys.stdout)
    console.setLevel(console_level)
    console.setFormatter(formatter)
    root.addHandler(console)

    try:
        file_handler = RotatingFileHandler(
            log_path, maxBytes=DEFAULT_LOG_BYTES, backupCount=DEFAULT_LOG_BACKUPS,
            encoding="utf-8",
        )
        file_handler.setLevel(logging.DEBUG)
        file_handler.setFormatter(formatter)
        root.addHandler(file_handler)
    except OSError as exc:
        logger.warning("Could not open log file %s: %s", log_path, exc)


async def amain(args: argparse.Namespace) -> int:
    if args.verbose:
        console_level = logging.DEBUG
    elif args.quiet:
        console_level = logging.WARNING
    else:
        console_level = args.log_level if args.log_level is not None else logging.INFO
    configure_logging(args.log, console_level)
    stats = Stats()
    timeout = aiohttp.ClientTimeout(total=args.timeout)
    connector = aiohttp.TCPConnector(limit=args.concurrency, limit_per_host=args.concurrency)
    async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
        client = WebDavClient(
            args.host, args.user, args.password, args.timeout, session,
            retries=args.retries,
            backoff_base=args.backoff_base,
            backoff_max=args.backoff_max,
            backoff_jitter=args.backoff_jitter,
        )
        sync = Synchronizer(args.source, args.remote, client,
                            args.concurrency, args.chunk, args.dry_run, stats,
                            scan_timeout=args.scan_timeout)
        started = time.monotonic()
        result = await sync.run()
        elapsed = time.monotonic() - started

    logger.info("=" * 60)
    logger.info("Sync finished in %.2fs", elapsed)
    logger.info("Directories created : %d", result.created_dirs)
    logger.info("Files uploaded      : %d", result.uploaded)
    logger.info("Files skipped       : %d", result.skipped)
    logger.info("Bytes uploaded      : %d", result.bytes_uploaded)
    logger.info("Failed files        : %d", result.failed_files)
    logger.info("Failed dirs         : %d", result.failed_dirs)
    logger.info("Inaccessible files  : %d", result.inaccessible_files)
    logger.info("=" * 60)
    return 0


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    if args.host == DEFAULT_HOST and os.environ.get("AS_HOST"):
        args.host = os.environ["AS_HOST"]
    if args.user == DEFAULT_USER and os.environ.get("AS_USER"):
        args.user = os.environ["AS_USER"]
    if args.password == DEFAULT_PASSWORD and os.environ.get("AS_PASSWORD"):
        args.password = os.environ["AS_PASSWORD"]
    try:
        return asyncio.run(amain(args))
    except KeyboardInterrupt:
        print("\nInterrupted by user.", file=sys.stderr)
        return 130


if __name__ == "__main__":
    sys.exit(main())

Comments

retoor retoor

Got already stabbed from behind. Fucker, I explicitly stated that the file system was corrupt and now i get this shit, does the thing even listen?

2026-09-03 17:58:00 INFO     [as] Source: .
2026-09-03 17:58:00 INFO     [as] Remote root: chromebook
2026-09-03 17:58:01 INFO     [as] Created remote dir /chromebook
2026-09-03 17:58:01 INFO     [as] Scanning source tree for sizes...
2026-09-03 17:58:07 WARNING  [as] Skipping non-file/non-dir entry .config/chromium/SingletonLock
2026-09-03 17:58:07 WARNING  [as] Skipping non-file/non-dir entry .config/chromium/SingletonSocket
2026-09-03 17:58:07 WARNING  [as] Skipping non-file/non-dir entry .config/chromium/SingletonCookie
2026-09-03 18:03:02 ERROR    [as] Source scan timed out after 300s; the filesystem may be unresponsive. Aborting to avoid an inconsistent run.
2026-09-03 18:03:02 ERROR    [as] Source scan failed; aborting.
2026-09-03 18:03:02 INFO     [as] ============================================================
2026-09-03 18:03:02 INFO     [as] Sync finished in 301.30s
2026-09-03 18:03:02 INFO     [as] Directories created : 1
2026-09-03 18:03:02 INFO     [as] Files uploaded      : 0
2026-09-03 18:03:02 INFO     [as] Files skipped       : 0
2026-09-03 18:03:02 INFO     [as] Bytes uploaded      : 0
2026-09-03 18:03:02 INFO     [as] Failed files        : 0
2026-09-03 18:03:02 INFO     [as] Failed dirs         : 0
2026-09-03 18:03:02 INFO     [as] Inaccessible files  : 0
2026-09-03 18:03:02 INFO     [as] ============================================================
retoor retoor

I just updated the script and hope it will do correctly now. Code you see above is the latest version. You can ignore error above.