Add homemaker-fitness: native Python CLI to replace urb-fitness.pl

Scores .dom files using fitness.Fitness.score_with_fails(), writes .score
and .fails side-cars in the same format as urb-fitness.pl, and respects the
same skip-if-up-to-date / FORCE_UPDATE caching semantics.

Closes homemaker-py-g0b.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-14 17:15:33 +01:00
parent 191f603440
commit 896fc48867

View file

@ -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 <dom>.score as a 40-decimal-place float; failures are
written one-per-line to <dom>.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())