#!/usr/bin/env python3 """Manage the user-level Kingdee business fact sync timer.""" from __future__ import annotations import argparse import os import shlex import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Sequence REPO_ROOT = Path(__file__).resolve().parent.parent if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from obsidian_wiki.config import resolve_vault_path SERVICE_NAME = "obsidian-wiki-business-sync.service" TIMER_NAME = "obsidian-wiki-business-sync.timer" DEFAULT_CONFIG_RELATIVE_PATH = Path(".runtime") / "kingdee.env" DEFAULT_ON_CALENDAR = "*-*-* 07:10:00" DEFAULT_RANDOMIZED_DELAY_SEC = "15m" DEFAULT_LOOKBACK_HOURS = 48 DEFAULT_INITIAL_DAYS = 7 PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY") @dataclass(frozen=True) class BusinessSyncServicePaths: project_root: Path service_unit_path: Path timer_unit_path: Path config_path: Path def systemd_quote(value: str | Path) -> str: return shlex.quote(str(value)) def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> BusinessSyncServicePaths: root = project_root.expanduser().resolve() base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) unit_dir = base_config.expanduser() / "systemd" / "user" return BusinessSyncServicePaths( project_root=root, service_unit_path=unit_dir / SERVICE_NAME, timer_unit_path=unit_dir / TIMER_NAME, config_path=root / DEFAULT_CONFIG_RELATIVE_PATH, ) def resolve_vault_for_service(project_root: Path, vault_path: Path | str | None = None) -> Path: if vault_path is not None: return Path(vault_path).expanduser().resolve() configured = resolve_vault_path(None, start=project_root) if configured: return Path(configured).expanduser().resolve() return project_root.expanduser().resolve() def render_service_unit( *, project_root: Path, vault_path: Path, config_path: Path, python_path: Path | None = None, lookback_hours: int = DEFAULT_LOOKBACK_HOURS, initial_days: int = DEFAULT_INITIAL_DAYS, ) -> str: resolved_python = python_path or Path(sys.executable) command = " ".join( [ systemd_quote(resolved_python), "-m", "obsidian_wiki", "business", "sync", "kingdee", "--vault", systemd_quote(vault_path), "--config", systemd_quote(config_path), "--incremental", "--lookback-hours", str(lookback_hours), "--initial-days", str(initial_days), ] ) return ( "[Unit]\n" "Description=obsidian-wiki Kingdee business fact sync\n" "After=network-online.target\n" "Wants=network-online.target\n" "\n" "[Service]\n" "Type=oneshot\n" f"WorkingDirectory={systemd_quote(project_root)}\n" f"EnvironmentFile={systemd_quote(config_path)}\n" f"PassEnvironment={' '.join(PROXY_ENV_NAMES)}\n" f"ExecStart={command}\n" ) def render_timer_unit( *, on_calendar: str = DEFAULT_ON_CALENDAR, randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC, ) -> str: return ( "[Unit]\n" "Description=Run obsidian-wiki Kingdee business sync on a schedule\n" "\n" "[Timer]\n" f"OnCalendar={on_calendar}\n" f"RandomizedDelaySec={randomized_delay_sec}\n" "Persistent=true\n" f"Unit={SERVICE_NAME}\n" "\n" "[Install]\n" "WantedBy=timers.target\n" ) def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run(["systemctl", "--user", *args], text=True, check=check) def write_units( paths: BusinessSyncServicePaths, *, vault_path: Path, python_path: Path | None = None, on_calendar: str = DEFAULT_ON_CALENDAR, randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC, lookback_hours: int = DEFAULT_LOOKBACK_HOURS, initial_days: int = DEFAULT_INITIAL_DAYS, ) -> None: paths.service_unit_path.parent.mkdir(parents=True, exist_ok=True) paths.service_unit_path.write_text( render_service_unit( project_root=paths.project_root, vault_path=vault_path, config_path=paths.config_path, python_path=python_path, lookback_hours=lookback_hours, initial_days=initial_days, ), encoding="utf-8", ) paths.timer_unit_path.write_text( render_timer_unit(on_calendar=on_calendar, randomized_delay_sec=randomized_delay_sec), encoding="utf-8", ) def start_timer( *, project_root: Path, vault_path: Path | str | None = None, python_path: Path | None = None, on_calendar: str = DEFAULT_ON_CALENDAR, randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC, lookback_hours: int = DEFAULT_LOOKBACK_HOURS, initial_days: int = DEFAULT_INITIAL_DAYS, ) -> str: paths = resolve_service_paths(project_root) if not paths.config_path.exists(): raise RuntimeError( f"Missing Kingdee config file: {paths.config_path}. " "Run `scripts/obsidian-wiki-ops.py kingdee config` first." ) resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path) write_units( paths, vault_path=resolved_vault, python_path=python_path, on_calendar=on_calendar, randomized_delay_sec=randomized_delay_sec, lookback_hours=lookback_hours, initial_days=initial_days, ) run_systemctl(["daemon-reload"]) run_systemctl(["enable", "--now", TIMER_NAME]) return ( "Business sync timer is enabled.\n" f"Service: {paths.service_unit_path}\n" f"Timer: {paths.timer_unit_path}\n" f"Vault: {resolved_vault}\n" f"Config: {paths.config_path}\n" f"Schedule: {on_calendar} randomized by {randomized_delay_sec}\n" ) def run_now() -> None: run_systemctl(["start", SERVICE_NAME]) def stop_timer() -> None: run_systemctl(["disable", "--now", TIMER_NAME], check=False) def uninstall_timer(project_root: Path) -> None: paths = resolve_service_paths(project_root) stop_timer() if paths.service_unit_path.exists(): paths.service_unit_path.unlink() if paths.timer_unit_path.exists(): paths.timer_unit_path.unlink() run_systemctl(["daemon-reload"], check=False) run_systemctl(["reset-failed", SERVICE_NAME], check=False) run_systemctl(["reset-failed", TIMER_NAME], check=False) def timer_status() -> None: run_systemctl(["status", TIMER_NAME], check=False) def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Manage the Kingdee business sync user timer.") subparsers = parser.add_subparsers(dest="command", required=True) start_parser = subparsers.add_parser("start", help="Install or update the user timer.") start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) start_parser.add_argument("--vault", dest="vault_path", type=Path, help="Vault path to sync into.") start_parser.add_argument("--python", dest="python_path", type=Path, help="Python executable for the systemd service.") start_parser.add_argument("--on-calendar", default=DEFAULT_ON_CALENDAR) start_parser.add_argument("--randomized-delay-sec", default=DEFAULT_RANDOMIZED_DELAY_SEC) start_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS) start_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS) run_now_parser = subparsers.add_parser("run-now", help="Run the sync service immediately.") run_now_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) run_now_parser.add_argument("--vault", dest="vault_path", type=Path) run_now_parser.add_argument("--python", dest="python_path", type=Path) run_now_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS) run_now_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS) subparsers.add_parser("status", help="Show timer status.") subparsers.add_parser("stop", help="Disable and stop the timer.") uninstall_parser = subparsers.add_parser("uninstall", help="Remove managed timer/service unit files.") uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) args = parser.parse_args(argv) if args.command == "start": print( start_timer( project_root=args.project_root, vault_path=args.vault_path, python_path=args.python_path, on_calendar=args.on_calendar, randomized_delay_sec=args.randomized_delay_sec, lookback_hours=args.lookback_hours, initial_days=args.initial_days, ), end="", ) return 0 if args.command == "run-now": paths = resolve_service_paths(args.project_root) if not paths.service_unit_path.exists(): start_timer( project_root=args.project_root, vault_path=args.vault_path, python_path=args.python_path, lookback_hours=args.lookback_hours, initial_days=args.initial_days, ) run_now() print(f"Started {SERVICE_NAME}.") return 0 if args.command == "status": timer_status() return 0 if args.command == "stop": stop_timer() print(f"Stopped {TIMER_NAME}.") return 0 if args.command == "uninstall": uninstall_timer(args.project_root) print(f"Uninstalled {TIMER_NAME}.") return 0 raise AssertionError(f"unhandled command: {args.command}") if __name__ == "__main__": raise SystemExit(main())