Source code for exlab_wizard.sync.run_delete

"""Keep-local-aware, symlink-safe deletion of a run's staging copy.

Operator-free per-file NAS sync design (2026-05-21). Both the automatic
cleanup reaper (:meth:`exlab_wizard.sync.nas_client.NASSyncClient._delete_local`)
and the operator-facing "Clear" actions
(:func:`exlab_wizard.orchestrator.staging_clear.clear_run_dir`) must delete a
run's staging copy with **identical** semantics:

* files flagged ``keep_local`` in ``sync_state.json`` survive (the spec's
  keep-local guarantee: "excluded from cleanup deletion");
* the ``.exlab-wizard/`` metadata subtree survives so a cleared run still
  renders its files as "On NAS" tombstones;
* directory **symlinks are never descended into or removed** -- a staged run
  containing a symlink to an external directory must not have files deleted
  outside the run tree.

This module is the single shared implementation. It is a pure filesystem
helper -- no ``SyncStateWriter`` / ``api`` / ``orchestrator`` imports -- so it
can be imported from anywhere without circular-import risk. The callers own
reading the ``keep_local`` set and stamping ``cleared_at``.
"""

from __future__ import annotations

import contextlib
import fnmatch
import os
import shutil
from dataclasses import dataclass
from pathlib import Path

from exlab_wizard.constants import CACHE_DIR_NAME
from exlab_wizard.logging import get_logger

__all__ = ["CleanupCandidates", "collect_cleanup_candidates", "delete_run_files"]

_log = get_logger(__name__)


[docs] @dataclass(frozen=True, slots=True) class CleanupCandidates: """Run-relative files selected or retained by cleanup planning.""" delete: tuple[str, ...] retained_ignored: tuple[str, ...] = ()
[docs] def collect_cleanup_candidates( run_path: Path, *, keep_local: set[str], ignore_globs: list[str] | tuple[str, ...] = (), delete_ignored: bool = False, ) -> CleanupCandidates: """Return the run-relative files cleanup would remove. The walk matches :func:`delete_run_files`: the cache subtree and directory symlinks are not descended into, ``keep_local`` files are retained, and ignored files are retained unless ``delete_ignored`` explicitly opts into local discard. """ if not run_path.exists(): return CleanupCandidates(delete=(), retained_ignored=()) delete: list[str] = [] retained_ignored: list[str] = [] for dirpath, dirnames, filenames in os.walk(run_path, followlinks=False): current = Path(dirpath) dirnames[:] = [ d for d in dirnames if d != CACHE_DIR_NAME and not (current / d).is_symlink() ] rel_dir = current.relative_to(run_path) if rel_dir.parts and rel_dir.parts[0] == CACHE_DIR_NAME: continue for name in filenames: rel = (rel_dir / name).as_posix() if rel in keep_local: continue if _matches_any_glob(name, ignore_globs) and not delete_ignored: retained_ignored.append(rel) continue delete.append(rel) return CleanupCandidates( delete=tuple(sorted(delete)), retained_ignored=tuple(sorted(retained_ignored)) )
[docs] def delete_run_files( run_path: Path, *, keep_local: set[str], retain_cache: bool, delete_only: set[str] | None = None, ignore_globs: list[str] | tuple[str, ...] = (), delete_ignored: bool = False, ) -> None: """Delete ``run_path`` data files honoring ``retain_cache`` and ``keep_local``. ``keep_local`` is a set of run-relative POSIX paths (possibly nested) that must survive the sweep. ``retain_cache`` keeps the ``.exlab-wizard/`` subtree when ``True``. ``delete_only`` constrains deletion to a pre-proved set of run-relative files; in that mode no whole-tree recursive data delete is used, so files that appear after the proof survive. The whole-run ``shutil.rmtree`` fast path is used only when ``retain_cache`` is ``False`` **and** there are no ``keep_local`` files; any kept file (or a retained cache) forces the per-file walk so it survives. The walk does not follow directory symlinks -- a symlinked directory inside the run is left entirely untouched (neither its contents deleted nor the link removed). Idempotent: a missing ``run_path`` is a no-op. """ if not run_path.exists(): return if delete_only is None and not retain_cache and not keep_local and not ignore_globs: # No retained files and no cache to preserve: drop the whole run. # ``rmtree`` does not follow the top-level dir if it is itself a # symlink (it raises) -- a run dir is always a real directory here. shutil.rmtree(run_path, ignore_errors=True) return planned = collect_cleanup_candidates( run_path, keep_local=keep_local, ignore_globs=ignore_globs, delete_ignored=delete_ignored, ) delete_set = set(planned.delete) if delete_only is None else set(delete_only) # Per-file walk: delete every file that is neither under # ``.exlab-wizard/`` nor flagged ``keep_local``. ``followlinks=False`` # (the os.walk default, made explicit) keeps the walk inside the run # tree -- a symlinked subdirectory is yielded as a name but never # descended into, so its target's contents are never touched. for dirpath, dirnames, filenames in os.walk(run_path, followlinks=False): current = Path(dirpath) # Do not descend into the cache subtree or any symlinked directory. dirnames[:] = [ d for d in dirnames if d != CACHE_DIR_NAME and not (current / d).is_symlink() ] rel_dir = current.relative_to(run_path) if rel_dir.parts and rel_dir.parts[0] == CACHE_DIR_NAME: continue for name in filenames: entry = current / name rel = (rel_dir / name).as_posix() if rel in keep_local: continue if rel not in delete_set: continue # A file that is itself a symlink: unlink the link only (never # the target). ``unlink`` does exactly that. with contextlib.suppress(OSError): entry.unlink() if not retain_cache: shutil.rmtree(run_path / CACHE_DIR_NAME, ignore_errors=True) _prune_empty_dirs(run_path) if not retain_cache: with contextlib.suppress(OSError): run_path.rmdir()
def _prune_empty_dirs(run_path: Path) -> None: """Remove now-empty real directories under ``run_path`` (deepest first). The run directory itself, the ``.exlab-wizard/`` cache subtree, and any symlinked directory are never removed. A real directory left empty by the delete walk is pruned so cleanup leaves only retained files and metadata. """ cache_dir_path = run_path / CACHE_DIR_NAME real_dirs: list[Path] = [] for dirpath, dirnames, _filenames in os.walk(run_path, followlinks=False): current = Path(dirpath) # Prune symlinked directories from the descent so we never rmdir one. dirnames[:] = [d for d in dirnames if not (current / d).is_symlink()] if current != run_path: real_dirs.append(current) # Deepest first so a parent emptied by pruning its children is itself # prunable in the same pass. for directory in sorted(real_dirs, key=lambda p: len(p.parts), reverse=True): if directory == cache_dir_path or cache_dir_path in directory.parents: continue with contextlib.suppress(OSError): if not any(directory.iterdir()): directory.rmdir() def _matches_any_glob(name: str, globs: list[str] | tuple[str, ...]) -> bool: """Return True if ``name`` matches any configured ignore glob.""" return any(fnmatch.fnmatch(name, pattern) for pattern in globs)