From c70dac04e88f51cb93a245b26be94f4668f15975 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 6 Aug 2026 11:30:00 +0800 Subject: [PATCH] feat(compact): add --dry-run to check file readiness without modifying - CLI: python main.py compact --dry-run [--db FILE] - API: POST /api/compact {"dry_run": true} - Checks .laccdb lock file + exclusive open to detect busy files - Returns per-file [READY] / [BUSY] status with lock reason Co-Authored-By: Claude --- main.py | 24 ++++++--- src/sync/compact.py | 95 ++++++++++++++++++++++++++++++---- src/sync/web/routes/compact.py | 3 +- src/sync/web/schemas.py | 5 ++ 4 files changed, 108 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index 4ff6c11..5ae98a0 100644 --- a/main.py +++ b/main.py @@ -64,6 +64,8 @@ def _parse_args(argv): pcp = sub.add_parser("compact", help="compact & repair Access databases") pcp.add_argument("--db", help="limit to one Access file (by FileMapping.file)") + pcp.add_argument("--dry-run", action="store_true", dest="dry_run", + help="only check file accessibility and lock status, do not compact") return p.parse_args(argv) @@ -104,13 +106,21 @@ def main(argv=None) -> int: return 0 if args.command == "compact": - summary = compact_files(cfg, db_filter=args.db) - for r in summary.results: - if r.ok: - print(f"[OK] {r.file} {_human(r.before_bytes)} -> {_human(r.after_bytes)} ({r.duration_s:.1f}s)") - else: - print(f"[FAIL] {r.file} {r.error}") - print(f"--- {summary.ok} OK, {summary.failed} FAIL, saved {_human(summary.saved_bytes)} ---") + summary = compact_files(cfg, db_filter=args.db, dry_run=args.dry_run) + if args.dry_run: + for r in summary.results: + if r.ok: + print(f"[READY] {r.file} {_human(r.before_bytes)}") + else: + print(f"[BUSY] {r.file} {_human(r.before_bytes)} ({r.error})") + print(f"--- {summary.ok} ready, {summary.failed} busy/missing ---") + else: + for r in summary.results: + if r.ok: + print(f"[OK] {r.file} {_human(r.before_bytes)} -> {_human(r.after_bytes)} ({r.duration_s:.1f}s)") + else: + print(f"[FAIL] {r.file} {r.error}") + print(f"--- {summary.ok} OK, {summary.failed} FAIL, saved {_human(summary.saved_bytes)} ---") return 0 if summary.all_ok else 1 if args.command == "compare": diff --git a/src/sync/compact.py b/src/sync/compact.py index b3da2d2..bee7aee 100644 --- a/src/sync/compact.py +++ b/src/sync/compact.py @@ -236,17 +236,10 @@ def _compact_unc(db_path: str) -> CompactFileResult: return result -def compact_files(cfg: SyncConfig, - db_filter: str | None = None, - file_list: list[str] | None = None) -> CompactSummary: - """Compact every configured Access file (optionally filtered). - - *db_filter* restricts to a single file (CLI ``--db``). *file_list* - restricts to a named subset (API ``files`` body field). Both can be - given — *db_filter* wins when both are set. - - Each file is compacted independently; one failure does not abort the run. - """ +def _filter_files(cfg: SyncConfig, + db_filter: str | None, + file_list: list[str] | None) -> list: + """Return the configured FileMappings after applying optional filters.""" files = cfg.files if db_filter: files = [f for f in files if f.file == db_filter] @@ -255,6 +248,27 @@ def compact_files(cfg: SyncConfig, elif file_list: name_set = set(file_list) files = [f for f in files if f.file in name_set] + return files + + +def compact_files(cfg: SyncConfig, + db_filter: str | None = None, + file_list: list[str] | None = None, + dry_run: bool = False) -> CompactSummary: + """Compact every configured Access file (optionally filtered). + + *db_filter* restricts to a single file (CLI ``--db``). *file_list* + restricts to a named subset (API ``files`` body field). Both can be + given — *db_filter* wins when both are set. + + When *dry_run* is True, only checks file accessibility and lock status + without modifying anything. Each file is compacted independently; one + failure does not abort the run. + """ + if dry_run: + return compact_dry_run(cfg, db_filter=db_filter, file_list=file_list) + + files = _filter_files(cfg, db_filter, file_list) summary = CompactSummary() for fm in files: @@ -311,6 +325,65 @@ def _is_unc(path: str) -> bool: return path.startswith(("\\\\", "//")) +def _check_lock(db_path: str) -> str | None: + """Check whether *db_path* can be opened for exclusive write. + + Returns ``None`` if the file is accessible (not locked). Returns an error + message string describing why the file cannot be compacted right now. + """ + if not os.path.exists(db_path): + return "file not found" + # Access creates a .laccdb / .ldb lock file alongside the database when + # it is open (even from another machine over the share). Its presence is + # a strong signal; its absence does NOT guarantee the file is free, so + # we also try an exclusive open. + laccdb = os.path.splitext(db_path)[0] + ".laccdb" + ldb = os.path.splitext(db_path)[0] + ".ldb" + if os.path.exists(laccdb) or os.path.exists(ldb): + return "locked (Access lock file present)" + try: + fd = os.open(db_path, os.O_RDWR) + os.close(fd) + except OSError as e: + return f"locked ({e})" + return None + + +def compact_dry_run(cfg: SyncConfig, + db_filter: str | None = None, + file_list: list[str] | None = None) -> CompactSummary: + """Check which files are ready for compaction without modifying anything. + + Same filtering as ``compact_files``, but only stats each file and checks + for locks. Returns a ``CompactSummary`` where ``ok=True`` means the file + is ready to compact, and ``error`` contains the lock reason when not ready. + """ + files = _filter_files(cfg, db_filter, file_list) + summary = CompactSummary() + for fm in files: + path = fm.source_path(cfg) + lock_err = _check_lock(path) + if lock_err: + size = 0 + try: + size = os.path.getsize(path) if os.path.exists(path) else 0 + except OSError: + pass + summary.results.append(CompactFileResult( + file=fm.file, source_path=path, ok=False, + before_bytes=size, error=lock_err, + )) + else: + size = os.path.getsize(path) + summary.results.append(CompactFileResult( + file=fm.file, source_path=path, ok=True, + before_bytes=size, + )) + log.info("dry-run: %d ready, %d locked/missing across %d files", + summary.ok, summary.failed, summary.total_files) + return summary + + def _rm_f(path: str) -> None: """Remove *path* if it exists, suppressing any error (best-effort).""" try: diff --git a/src/sync/web/routes/compact.py b/src/sync/web/routes/compact.py index 70bfcd8..7938afb 100644 --- a/src/sync/web/routes/compact.py +++ b/src/sync/web/routes/compact.py @@ -58,7 +58,8 @@ def compact(body: CompactRequest | None = None, everything. """ file_list = body.files if body and body.files else None - summary = compact_files(cfg, file_list=file_list) + dry_run = body.dry_run if body else False + summary = compact_files(cfg, file_list=file_list, dry_run=dry_run) # Build the response dict matching CompactResponse. return { diff --git a/src/sync/web/schemas.py b/src/sync/web/schemas.py index 6f006f2..dad6c15 100644 --- a/src/sync/web/schemas.py +++ b/src/sync/web/schemas.py @@ -221,6 +221,11 @@ class CompactRequest(BaseModel): "All files are compacted when absent or null.", min_length=1, ) + dry_run: bool = Field( + default=False, + description="When true, only check file accessibility and lock status " + "without actually compacting. Returns per-file readiness.", + ) class CompactFileResult(BaseModel):