diff --git a/src/homemaker_layout/fitness_cmd.py b/src/homemaker_layout/fitness_cmd.py new file mode 100644 index 0000000..d376bfa --- /dev/null +++ b/src/homemaker_layout/fitness_cmd.py @@ -0,0 +1,114 @@ +"""homemaker-fitness — native Python replacement for urb-fitness.pl. + +Scores .dom files using the native fitness engine (fitness.Fitness) and writes +.score / .fails side-car files in the same format as urb-fitness.pl. + +Usage (module): + python -m homemaker_layout.fitness_cmd [dom1.dom dom2.dom ...] + +When installed via pip install -e .: + homemaker-fitness [dom1.dom dom2.dom ...] + +If no arguments are given, all *.dom files in the current directory are scored. + +Scoring is skipped for a file if its .score and .fails side-cars already exist +and are both newer than the .dom file and the config files — set FORCE_UPDATE=1 +to override (matches urb-fitness.pl behaviour). + +Score is written to .score as a 40-decimal-place float; failures are +written one-per-line to .fails. Score is also printed to stderr. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import yaml + +from . import dom as dom_mod +from . import geometry +from .fitness import Fitness, load_config + + +def _config_mtime(directory: Path) -> float: + """Latest mtime of any patterns/costs config file visible to this directory.""" + mtime = 0.0 + for name in ("patterns.config", "costs.config"): + for p in (directory.parent / name, directory / name): + if p.is_file(): + t = p.stat().st_mtime + if t > mtime: + mtime = t + return mtime + + +def _should_score(dom_path: Path, config_mtime: float) -> bool: + """Return True if the dom needs (re-)scoring.""" + if os.environ.get("FORCE_UPDATE") or os.environ.get("DEBUG"): + return True + score_p = Path(f"{dom_path}.score") + fails_p = Path(f"{dom_path}.fails") + if not score_p.exists() or not fails_p.exists(): + return True + dom_mtime = dom_path.stat().st_mtime + score_mtime = score_p.stat().st_mtime + if score_mtime < dom_mtime or score_mtime < config_mtime: + return True + if fails_p.stat().st_mtime < dom_mtime: + return True + return False + + +def score_file(dom_path: Path, fitness: Fitness) -> float: + """Score one .dom file, write side-cars, return score.""" + root = dom_mod.load(str(dom_path)) + geometry.clear_cache() + score, failures = fitness.score_with_fails(root) + + score_str = f"{score:.40f}" + score_p = Path(f"{dom_path}.score") + score_p.unlink(missing_ok=True) + score_p.write_text(score_str + "\n") + + fails_p = Path(f"{dom_path}.fails") + fails_p.unlink(missing_ok=True) + fails_p.write_text("\n".join(failures) + ("\n" if failures else "")) + + return score + + +def main(argv=None) -> int: + args = (argv or sys.argv)[1:] + + cwd = Path.cwd() + conf, cost = load_config(cwd) + fitness = Fitness(conf, cost) + cfg_mtime = _config_mtime(cwd) + + if args: + dom_paths = [Path(a) for a in args if a.endswith(".dom")] + else: + dom_paths = sorted(cwd.glob("*.dom")) + print("Calculating fitness for all files in current folder", file=sys.stderr) + + for dom_path in dom_paths: + dom_path = Path(dom_path) + if not dom_path.exists(): + print(f"File not found, skipping: {dom_path}", file=sys.stderr) + continue + if not _should_score(dom_path, cfg_mtime): + continue + try: + score = score_file(dom_path, fitness) + except Exception as exc: + print(f"Error scoring {dom_path}: {exc}", file=sys.stderr) + continue + print(f"{score:.40f}", file=sys.stderr) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())