diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ebf5f0..1c6d3a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(cli): `canonical --check` fails a model file that is not in the canonical form, and `--write` rewrites it ([#718](https://github.com/energy-models/mathspec/pull/718)) - feat(language): two files that mean the same model write one text ([#530](https://github.com/energy-models/mathspec/pull/530)) ## 0.1.0 (2026-09-25) diff --git a/docs/howto/compare.md b/docs/howto/compare.md index aecf855f..cc2a5a98 100644 --- a/docs/howto/compare.md +++ b/docs/howto/compare.md @@ -50,7 +50,30 @@ lists what the form sorts and what it keeps. Match only model files in `.gitattributes`. Git runs the command on every file the pattern matches, and a YAML file that is not a model fails to load. -4. **Compare from Python** where the comparison is one step of a longer +4. **Keep models in the canonical form in CI.** Then the files themselves + diff the way the form does, with no git setup. `--write` rewrites a file in + the form. The form holds no YAML comments, so `--write` drops them: + + ```bash + python -m mathspec canonical --write model.yaml + ``` + + `--check` writes nothing. It exits with status 1 if the file is not in the + form, and names the rewrite: + + ```text + model.yaml is not in the canonical form. Run `python -m mathspec canonical --write model.yaml` to rewrite it. + ``` + + Check every model, and fail the job if one of them fails: + + ```bash + status=0 + for model in models/*.yaml; do python -m mathspec canonical --check "$model" || status=1; done + exit $status + ``` + +5. **Compare from Python** where the comparison is one step of a longer script. [`to_yaml`](../reference/api.md#mathspec.Spec.to_yaml) writes the same text with `canonical=True`: diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 91eff525..8489eeea 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -271,6 +271,8 @@ The normal form loads to the same model. It does not load to a `Spec` equal to the original: a reprinted expression is a different string. Writing the form out again gives the same text, which is what the line above says. -`python -m mathspec canonical model.yaml` writes it from a shell. +`python -m mathspec canonical model.yaml` writes it from a shell. `--write` +rewrites the file in the form, and `--check` exits with status 1 if the file is +not in the form. The form holds no YAML comments, so `--write` drops them. [Compare two models](../howto/compare.md) shows how to diff two files in this form. diff --git a/src/mathspec/__main__.py b/src/mathspec/__main__.py index 34564248..53a58baf 100644 --- a/src/mathspec/__main__.py +++ b/src/mathspec/__main__.py @@ -5,7 +5,8 @@ """``python -m mathspec model.yaml`` — the shell front. ``check`` loads the file and prints the language's advice, ``canonical`` -writes the model in the form two files that mean the same thing share, and one +writes the model in the form two files that mean the same thing share, or with +``--check`` asks whether the file is already in that form, and one further verb per typeset format, read off :data:`mathspec.typesetting.FORMATS`. Every verb reads the file as written, and nothing here writes a formulation out unasked. The typeset verbs take ``--expand``, because a shell cannot compose @@ -36,7 +37,14 @@ def parser() -> argparse.ArgumentParser: canonical = verbs.add_parser('canonical', help='write the model in the form two files that mean the same share') canonical.add_argument('model', help='path to a mathspec YAML model') - canonical.add_argument('-o', '--out', help='write here instead of stdout') + target = canonical.add_mutually_exclusive_group() + target.add_argument('-o', '--out', help='write here instead of stdout') + target.add_argument( + '--write', action='store_true', help='rewrite the model file in the form, dropping its comments' + ) + target.add_argument( + '--check', action='store_true', help='exit 1 if the model file is not in the form, writing nothing' + ) for name in FORMATS: verb = verbs.add_parser(name, help=f'render a model as {name}') @@ -57,10 +65,26 @@ def parser() -> argparse.ArgumentParser: return front +def _checked(model: str, text: str) -> int: + """Exit status 0 if *model* already holds *text*, the form, and 1 with the rewrite on stderr if not. + + A CI job runs this, so it compares bytes and writes nothing: the job fails, + and the author runs the rewrite the message names. + """ + if Path(model).read_text(encoding='utf-8') == text: + return 0 + sys.stderr.write( + f'{model} is not in the canonical form. Run `python -m mathspec canonical --write {model}` to rewrite it.\n' + ) + return 1 + + def main(argv: list[str] | None = None) -> int: """Run one verb; a refused file is its message on stderr and exit status 1. - Advice is not a refusal: ``check`` prints it and exits 0. + Advice is not a refusal: ``check`` prints it and exits 0. A file that + ``canonical --check`` finds out of the form exits 1, with the rewrite on + stderr. """ args = parser().parse_args(argv) if args.verb == 'check': @@ -77,6 +101,11 @@ def main(argv: list[str] | None = None) -> int: except MathSpecError as e: sys.stderr.write(f'{e}\n') return 1 + if args.check: + return _checked(args.model, text) + if args.write: + Path(args.model).write_text(text, encoding='utf-8') + return 0 else: model = to_spec(args.model).expand() if args.expand else args.model text = typeset( diff --git a/tests/test_canonical.py b/tests/test_canonical.py index 4a2901b6..e156409d 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -259,6 +259,62 @@ def test_the_shell_refuses_a_model_the_language_refuses(tmp_path, capsys): assert capsys.readouterr().err, 'and its message on stderr' +def test_the_check_passes_a_file_in_the_form_and_writes_nothing(tmp_path, capsys): + model = tmp_path / 'model.yaml' + model.write_text(ms.to_spec(DISPATCH_MODEL).to_yaml(canonical=True)) + assert main(['canonical', '--check', str(model)]) == 0, 'a file in the form passes' + assert capsys.readouterr() == ('', ''), 'a check that passes prints nothing on either stream' + + +def test_the_check_fails_a_file_out_of_the_form_and_names_the_rewrite(tmp_path, capsys): + """What a CI job runs: the model loads, so the language accepts it, and the + job still fails, because the file is not the text the form writes.""" + model = tmp_path / 'model.yaml' + written = ms.to_spec(DISPATCH_MODEL).to_yaml() + model.write_text(written) + assert main(['canonical', '--check', str(model)]) == 1, 'a file out of the form is exit status 1' + assert capsys.readouterr().err == ( + f'{model} is not in the canonical form. Run `python -m mathspec canonical --write {model}` to rewrite it.\n' + ) + assert model.read_text() == written, 'the check leaves the file as it found it' + + +def test_the_rewrite_leaves_a_file_the_check_passes(tmp_path, capsys): + model = tmp_path / 'model.yaml' + model.write_text(ms.to_spec(DISPATCH_MODEL).to_yaml()) + assert main(['canonical', '--write', str(model)]) == 0, 'the rewrite of a model the language accepts exits 0' + assert model.read_text() == ms.to_spec(DISPATCH_MODEL).to_yaml(canonical=True), 'the file now holds the form' + assert capsys.readouterr().out == '', 'the form goes into the file, not to stdout' + assert main(['canonical', '--check', str(model)]) == 0, 'and the check passes the file it wrote' + + +@pytest.mark.parametrize('flag', ['--check', '--write']) +def test_a_refused_file_is_neither_checked_nor_rewritten(tmp_path, capsys, flag): + model = tmp_path / 'broken.yaml' + written = 'constraints: {k: {dims: [], expression: "p >= "}}\n' + model.write_text(written) + assert main(['canonical', flag, str(model)]) == 1, 'a refused file is exit status 1' + assert capsys.readouterr().err, 'and its message on stderr' + assert model.read_text() == written, 'the file is left as the author wrote it' + + +@pytest.mark.parametrize( + 'flags', + [ + pytest.param(['--check', '--write'], id='check-and-write'), + pytest.param(['--check', '-o', 'out.yaml'], id='check-and-out'), + pytest.param(['--write', '-o', 'out.yaml'], id='write-and-out'), + ], +) +def test_the_form_goes_to_one_place(tmp_path, flags): + """Each flag names where the form goes, so two of them name two places.""" + model = tmp_path / 'model.yaml' + model.write_text(ms.to_spec(DISPATCH_MODEL).to_yaml()) + with pytest.raises(SystemExit) as refused: + main(['canonical', *flags, str(model)]) + assert refused.value.code == 2, 'argparse refuses the pair before the model is read' + + # --------------------------------------------------------------------------- # What the form promises, over trees nobody wrote by hand # ---------------------------------------------------------------------------