Files
ProductionDataBaseSync_Data…/main.py
Misaka_Company 42a2f99ed9 feat(compact): Access database compact & repair API + CLI
- API: POST /api/compact (optional body {"files": [...]})
- CLI: python main.py compact [--db FILE]
- Local path: rename → CompactDatabase(bak→src) → delete bak (zero copy)
- UNC path: copy to local temp → compact → copy back (avoids DAO segfault)
- Uses DAO DBEngine.CompactDatabase via pywin32 COM
- new dep: pywin32>=306

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 11:23:43 +08:00

134 lines
5.5 KiB
Python

"""Unified command-line entry point for the Access -> SQL Server sync toolkit.
Run from the project root (no ``-m`` needed)::
python main.py fullsync [--db FILE] [--table NAME] [--clear-change-log]
python main.py incremental [--loop] [--poll-interval N]
python main.py compare [--granularity count|ids] [--db FILE] [--table NAME] [--report PATH]
python main.py compact [--db FILE]
Configuration is hard-coded to ``config.yaml`` next to this script -- it is not
a command-line argument, so all three blocks always use the same config (and
therefore the same target tables).
This file lives at the repo root, outside the ``src/`` package, so it puts
``src`` on ``sys.path`` itself to import ``sync.*`` regardless of how Python
was launched or whether the venv already has ``src`` on its path.
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
# Make the src/ package importable when running this root script directly.
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
from sync.config import load_config
from sync.logging_setup import setup_logging
from sync.fullsync import full_sync
from sync import service
from sync.compare import compare, any_mismatch, format_report, write_report
from sync.compact import compact_files, _human
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
log = logging.getLogger("main")
def _parse_args(argv):
p = argparse.ArgumentParser(
prog="python main.py",
description="Access -> SQL Server sync toolkit (fullsync / incremental / compare).",
)
sub = p.add_subparsers(dest="command", required=True)
pf = sub.add_parser("fullsync", help="one-shot TRUNCATE + bulk INSERT")
pf.add_argument("--db", help="limit to one Access file (by FileMapping.file)")
pf.add_argument("--table", help="limit to one table (applies to all matched files)")
pf.add_argument("--clear-change-log", action="store_true",
help="after loading, clear TableChangeLog on the synced files")
pi = sub.add_parser("incremental", help="capture -> apply -> cleanup")
pi.add_argument("--loop", action="store_true",
help="run continuously (service mode); default is a single pass")
pi.add_argument("--poll-interval", type=int, dest="poll_interval",
help="override runtime.poll_interval_seconds (with --loop)")
pc = sub.add_parser("compare", help="compare Access vs SQL Server data")
pc.add_argument("--granularity", choices=["count", "ids"], default="count",
help="count = row totals (default); ids = ID-set membership diff")
pc.add_argument("--db", help="limit to one Access file")
pc.add_argument("--table", help="limit to one table")
pc.add_argument("--report", help="write the report to this file as well as stdout")
pcp = sub.add_parser("compact", help="compact & repair Access databases")
pcp.add_argument("--db", help="limit to one Access file (by FileMapping.file)")
return p.parse_args(argv)
def _force_utf8_console():
"""Render Chinese table names correctly on a Windows GBK console.
compare prints to stdout by default; without this the default console
codepage mojibakes non-ASCII. No-op when stdout is already UTF-8 or when it
does not support reconfigure (e.g. some test-capture streams).
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
def main(argv=None) -> int:
"""Parse argv, load config, dispatch to the chosen block. Returns exit code."""
_force_utf8_console()
args = _parse_args(argv)
cfg = load_config(CONFIG_PATH)
setup_logging(cfg.logging)
if args.command == "fullsync":
full_sync(cfg, db_filter=args.db, table_filter=args.table,
clear_change_log=args.clear_change_log)
return 0
if args.command == "incremental":
if args.poll_interval is not None:
cfg.runtime.poll_interval_seconds = args.poll_interval
if args.loop:
service.run(cfg)
else:
service.cycle(cfg)
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)} ---")
return 0 if summary.all_ok else 1
if args.command == "compare":
results = compare(cfg, granularity=args.granularity,
db_filter=args.db, table_filter=args.table)
# Persist the report as a dated log file under the logging directory
# (logs/ by default); --report still allows an extra custom path.
log_dir = os.path.dirname((cfg.logging or {}).get("path", "sync.log")) or "."
written = write_report(results, args.granularity, log_dir=log_dir,
extra_path=args.report)
print(format_report(results, args.granularity))
log.info("compare finished (granularity=%s) report=%s mismatch=%s",
args.granularity, written, any_mismatch(results))
return 1 if any_mismatch(results) else 0
return 2 # unreachable: argparse requires a subcommand
if __name__ == "__main__":
sys.exit(main())