diff --git a/docs/Reference/cli.md b/docs/Reference/cli.md index e5ed135..d5b1a4b 100644 --- a/docs/Reference/cli.md +++ b/docs/Reference/cli.md @@ -16,7 +16,7 @@ Queries the Zenodo record of every dataset in the manifest and rewrites the regi fwl-io list ``` -Lists every dataset from all installed manifests with its key and consumers. Datasets without a committed registry are flagged `[NO REGISTRY]`. Providers whose manifest fails to load are reported on stderr and the exit status is 1. +Lists every dataset from all installed manifests with its key and consumers. When a manifest gives a dataset a human-readable `name` that differs from its key, that label is printed on the next line below the key. Datasets without a committed registry are flagged `[NO REGISTRY]`. Providers whose manifest fails to load are reported on stderr and the exit status is 1. ## fwl-io fetch diff --git a/src/fwl_io/cli.py b/src/fwl_io/cli.py index 5c4cd46..b26eee7 100644 --- a/src/fwl_io/cli.py +++ b/src/fwl_io/cli.py @@ -33,6 +33,11 @@ def _cmd_list(args: argparse.Namespace) -> int: '' if ds.registry_path and ds.registry_path.is_file() else ' [NO REGISTRY]' ) print(f' {ds.key:50s} required_by: {consumers}{registry_note}') + if ds.name != ds.key: + # A name is a single-line label; drop control characters so a + # manifest cannot inject extra lines into the listing. + label = ''.join(c for c in ds.name if c.isprintable()) + print(f' {label}') for provider, message in sorted(errors.items()): print(f'[{provider}] FAILED TO LOAD: {message}', file=sys.stderr) return 1 if errors else 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index a6a7cd5..2e9fe49 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -73,6 +73,84 @@ def broken(): assert 'badmodel' in captured.err and 'FAILED TO LOAD' in captured.err +@pytest.mark.unit +def test_list_shows_declared_name_but_not_the_key_fallback(tmp_path, capsys, monkeypatch): + """A declared human-readable name is printed; an undeclared one is not repeated.""" + manifest = tmp_path / 'manifest.toml' + manifest.write_text( + '[labelled]\n' + 'name = "Wolf & Bower (2018) MgSiO3 equation of state"\n' + 'zenodo = "10.5281/zenodo.1"\n' + '[unlabelled]\n' + 'zenodo = "10.5281/zenodo.2"\n' + ) + + class _EP: + def __init__(self, name, target): + self.name = name + self._target = target + + def load(self): + return self._target + + monkeypatch.setattr( + 'fwl_io.manifest.entry_points', + lambda group: [_EP('demo', lambda: manifest)], + ) + + code = main(['list']) + out = capsys.readouterr().out + lines = out.splitlines() + name = 'Wolf & Bower (2018) MgSiO3 equation of state' + assert code == 0 + # The declared name prints on the line immediately below its key, not merely + # somewhere in the output: a print-order swap has to fail this. + key_line = next(i for i, line in enumerate(lines) if line.strip().startswith('labelled')) + assert lines[key_line + 1].strip() == name + # The undeclared dataset falls back to its key, so its key line is not + # followed by a repeat of the key. + fallback_line = next(i for i, line in enumerate(lines) if line.strip().startswith('unlabelled')) + following = lines[fallback_line + 1] if fallback_line + 1 < len(lines) else '' + assert following.strip() != 'unlabelled' + assert out.count('unlabelled') == 1 + + +@pytest.mark.unit +def test_list_strips_control_characters_from_a_declared_name(tmp_path, capsys, monkeypatch): + """A name carrying a newline cannot inject an extra line into the listing.""" + manifest = tmp_path / 'manifest.toml' + manifest.write_text( + '[evil]\n' + 'name = "harmless\\n forged.key required_by: victim [NO REGISTRY]"\n' + 'zenodo = "10.5281/zenodo.1"\n' + ) + + class _EP: + def __init__(self, name, target): + self.name = name + self._target = target + + def load(self): + return self._target + + monkeypatch.setattr( + 'fwl_io.manifest.entry_points', + lambda group: [_EP('demo', lambda: manifest)], + ) + + code = main(['list']) + out = capsys.readouterr().out + lines = out.splitlines() + assert code == 0 + # The embedded newline must not produce a fourth line that reads like a + # second dataset: provider header, key line, one name line, nothing else. + assert len(lines) == 3 + assert lines[0] == '[demo]' + assert lines[1].startswith(' evil') + assert lines[2].startswith(' harmless') + assert not any(line.startswith(' forged') for line in lines) + + @pytest.mark.unit def test_fetch_unknown_module_exits_nonzero(capsys, monkeypatch): """Asking for a model no manifest declares is an error, not an empty success."""