Source code for exlab_wizard.sync.manifest

"""Parser for ``rclone lsjson`` output into a run-relative remote manifest.

Sole owner of the lsjson wire shape. Consumers (the NAS sync client's
routine reconcile and cleanup remote-existence probe) depend only on the
:class:`RemoteManifest` value object, never on the raw JSON.
"""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from datetime import datetime

from exlab_wizard.logging import get_logger

__all__ = ["RemoteEntry", "RemoteManifest", "parse_lsjson"]

_log = get_logger(__name__)

# Trim fractional seconds to 6 digits: datetime.fromisoformat rejects the
# 9-digit nanosecond precision rclone can emit (e.g. "...:00.123456789Z").
_FRAC_RE = re.compile(r"\.(\d+)")


[docs] @dataclass(frozen=True, slots=True) class RemoteEntry: """One file on the remote. ``mod_time`` is the RFC3339 string as emitted.""" size: int mod_time: str is_dir: bool
[docs] @dataclass(frozen=True, slots=True) class RemoteManifest: """Run-relative view of a remote subtree. Keyed by POSIX rel-path.""" entries: dict[str, RemoteEntry]
[docs] def size_matches(self, rel_path: str, local_size: int) -> bool: """True iff ``rel_path`` is present and its remote size equals local.""" entry = self.entries.get(rel_path) return entry is not None and entry.size == local_size
[docs] def has(self, rel_path: str) -> bool: """True iff ``rel_path`` is present on the remote (existence probe).""" return rel_path in self.entries
[docs] def matches( self, rel_path: str, local_size: int, local_mtime: float, *, tolerance_s: float ) -> bool: """The single reconcile predicate: present AND size-equal AND modtime close. ``local_mtime`` is the local file's ``st_mtime`` (epoch seconds). A file is credited as synced only when the remote entry exists, its size equals ``local_size``, and its modtime is within ``tolerance_s`` of local (rclone preserves modtime on copy; the window absorbs SFTP/SMB rounding). """ entry = self.entries.get(rel_path) if entry is None or entry.size != local_size: return False remote_epoch = self._to_epoch(entry.mod_time) if remote_epoch is None: return False return abs(remote_epoch - local_mtime) <= tolerance_s
@staticmethod def _to_epoch(mod_time: str) -> float | None: """Parse an RFC3339 ``ModTime`` to epoch seconds, or ``None`` if unparseable.""" s = mod_time.strip() if not s: return None if s.endswith("Z"): s = s[:-1] + "+00:00" s = _FRAC_RE.sub(lambda m: "." + m.group(1)[:6], s, count=1) try: return datetime.fromisoformat(s).timestamp() except ValueError: return None
[docs] def parse_lsjson(raw: str, *, strip_prefix: str = "") -> RemoteManifest: """Parse ``rclone lsjson`` array text into a :class:`RemoteManifest`. Directory entries are dropped. ``strip_prefix`` (e.g. ``<base_root>/<equipment_id>/<run>``) is removed from each ``Path`` so keys are run-relative. Malformed / empty input yields an empty manifest (logged at debug) rather than raising — a listing we can't parse must not crash the sync worker. """ text = raw.strip() if not text: return RemoteManifest(entries={}) try: rows = json.loads(text) except json.JSONDecodeError: _log.debug("lsjson output was not valid JSON (%d chars)", len(text)) return RemoteManifest(entries={}) if not isinstance(rows, list): return RemoteManifest(entries={}) prefix = strip_prefix.strip("/") out: dict[str, RemoteEntry] = {} for row in rows: if not isinstance(row, dict) or row.get("IsDir"): continue path = str(row.get("Path", "")).strip("/") if not path: continue if prefix and path.startswith(prefix + "/"): path = path[len(prefix) + 1 :] try: size = int(row.get("Size", -1)) except (TypeError, ValueError): continue out[path] = RemoteEntry( size=size, mod_time=str(row.get("ModTime", "")), is_dir=False, ) return RemoteManifest(entries=out)