"""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] 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 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") 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 == "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())