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 <noreply@anthropic.com>
This commit is contained in:
24
main.py
24
main.py
@@ -64,6 +64,8 @@ def _parse_args(argv):
|
|||||||
|
|
||||||
pcp = sub.add_parser("compact", help="compact & repair Access databases")
|
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("--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)
|
return p.parse_args(argv)
|
||||||
|
|
||||||
@@ -104,13 +106,21 @@ def main(argv=None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "compact":
|
if args.command == "compact":
|
||||||
summary = compact_files(cfg, db_filter=args.db)
|
summary = compact_files(cfg, db_filter=args.db, dry_run=args.dry_run)
|
||||||
for r in summary.results:
|
if args.dry_run:
|
||||||
if r.ok:
|
for r in summary.results:
|
||||||
print(f"[OK] {r.file} {_human(r.before_bytes)} -> {_human(r.after_bytes)} ({r.duration_s:.1f}s)")
|
if r.ok:
|
||||||
else:
|
print(f"[READY] {r.file} {_human(r.before_bytes)}")
|
||||||
print(f"[FAIL] {r.file} {r.error}")
|
else:
|
||||||
print(f"--- {summary.ok} OK, {summary.failed} FAIL, saved {_human(summary.saved_bytes)} ---")
|
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
|
return 0 if summary.all_ok else 1
|
||||||
|
|
||||||
if args.command == "compare":
|
if args.command == "compare":
|
||||||
|
|||||||
@@ -236,17 +236,10 @@ def _compact_unc(db_path: str) -> CompactFileResult:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def compact_files(cfg: SyncConfig,
|
def _filter_files(cfg: SyncConfig,
|
||||||
db_filter: str | None = None,
|
db_filter: str | None,
|
||||||
file_list: list[str] | None = None) -> CompactSummary:
|
file_list: list[str] | None) -> list:
|
||||||
"""Compact every configured Access file (optionally filtered).
|
"""Return the configured FileMappings after applying optional filters."""
|
||||||
|
|
||||||
*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.
|
|
||||||
"""
|
|
||||||
files = cfg.files
|
files = cfg.files
|
||||||
if db_filter:
|
if db_filter:
|
||||||
files = [f for f in files if f.file == 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:
|
elif file_list:
|
||||||
name_set = set(file_list)
|
name_set = set(file_list)
|
||||||
files = [f for f in files if f.file in name_set]
|
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()
|
summary = CompactSummary()
|
||||||
for fm in files:
|
for fm in files:
|
||||||
@@ -311,6 +325,65 @@ def _is_unc(path: str) -> bool:
|
|||||||
return path.startswith(("\\\\", "//"))
|
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:
|
def _rm_f(path: str) -> None:
|
||||||
"""Remove *path* if it exists, suppressing any error (best-effort)."""
|
"""Remove *path* if it exists, suppressing any error (best-effort)."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ def compact(body: CompactRequest | None = None,
|
|||||||
everything.
|
everything.
|
||||||
"""
|
"""
|
||||||
file_list = body.files if body and body.files else None
|
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.
|
# Build the response dict matching CompactResponse.
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ class CompactRequest(BaseModel):
|
|||||||
"All files are compacted when absent or null.",
|
"All files are compacted when absent or null.",
|
||||||
min_length=1,
|
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):
|
class CompactFileResult(BaseModel):
|
||||||
|
|||||||
Reference in New Issue
Block a user