Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion docs/howto/compare.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
4 changes: 3 additions & 1 deletion docs/reference/reading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 32 additions & 3 deletions src/mathspec/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"""``python -m mathspec <verb> 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
Expand Down Expand Up @@ -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}')
Expand All @@ -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':
Expand All @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions tests/test_canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading