From fe617ebdd8361013bd79edf949013bba1fd4d3e1 Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 14:35:11 +0200 Subject: [PATCH 01/12] feat: add parse_module_chain to walk the requires chain --- src/plain_parser/__init__.py | 2 ++ src/plain_parser/plain_file.py | 21 +++++++++++++++++++ tests/data/requires/chain_base.plain | 7 +++++++ tests/data/requires/chain_fork_top.plain | 13 ++++++++++++ tests/data/requires/chain_middle.plain | 12 +++++++++++ tests/data/requires/chain_top.plain | 12 +++++++++++ tests/test_requires.py | 26 ++++++++++++++++++++++++ 7 files changed, 93 insertions(+) create mode 100644 tests/data/requires/chain_base.plain create mode 100644 tests/data/requires/chain_fork_top.plain create mode 100644 tests/data/requires/chain_middle.plain create mode 100644 tests/data/requires/chain_top.plain diff --git a/src/plain_parser/__init__.py b/src/plain_parser/__init__.py index 238a778..c1e8724 100644 --- a/src/plain_parser/__init__.py +++ b/src/plain_parser/__init__.py @@ -6,6 +6,7 @@ get_filename_from_module_name, get_module_name_from_filename, marshall_plain_source, + parse_module_chain, parse_plain_file, parse_plain_source, plain_file_parser, @@ -19,6 +20,7 @@ "get_module_name_from_filename", "loaders", "marshall_plain_source", + "parse_module_chain", "parse_plain_file", "parse_plain_source", "plain_file", diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index d8abb5e..0ee8808 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -859,3 +859,24 @@ def plain_file_parser( # noqa: C901 concept_utils.sort_definitions(marshalled_plain_source[plain_spec.DEFINITIONS]) return module_name, marshalled_plain_source, plain_file_parse_result.required_modules + + +def parse_module_chain(plain_file_name: str, template_dirs: list[str]) -> list[tuple[str, dict]]: + """Parse a module and every module in its ``requires`` chain. + + Returns ``(module name, marshalled plain source tree)`` pairs: every required module + first, deepest ancestors first, then the module itself. A module reached through more + than one ``requires`` path appears once, at its first (deepest) position. + """ + module_name, plain_source_tree, required_module_names = plain_file_parser(plain_file_name, template_dirs) + + chain: list[tuple[str, dict]] = [] + seen = set[str]() + for required_module_name in required_module_names: + for entry in parse_module_chain(get_filename_from_module_name(required_module_name), template_dirs): + if entry[0] not in seen: + seen.add(entry[0]) + chain.append(entry) + + chain.append((module_name, plain_source_tree)) + return chain diff --git a/tests/data/requires/chain_base.plain b/tests/data/requires/chain_base.plain new file mode 100644 index 0000000..90ebe54 --- /dev/null +++ b/tests/data/requires/chain_base.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- Base implementation requirement. + +***functional specs*** + +- A base functionality. diff --git a/tests/data/requires/chain_fork_top.plain b/tests/data/requires/chain_fork_top.plain new file mode 100644 index 0000000..dd14f07 --- /dev/null +++ b/tests/data/requires/chain_fork_top.plain @@ -0,0 +1,13 @@ +--- +requires: + - chain_base + - chain_middle +--- + +***implementation reqs*** + +- Fork implementation requirement. + +***functional specs*** + +- A fork functionality. diff --git a/tests/data/requires/chain_middle.plain b/tests/data/requires/chain_middle.plain new file mode 100644 index 0000000..fbd00fc --- /dev/null +++ b/tests/data/requires/chain_middle.plain @@ -0,0 +1,12 @@ +--- +requires: + - chain_base +--- + +***implementation reqs*** + +- Middle implementation requirement. + +***functional specs*** + +- A middle functionality. diff --git a/tests/data/requires/chain_top.plain b/tests/data/requires/chain_top.plain new file mode 100644 index 0000000..9bbe7a1 --- /dev/null +++ b/tests/data/requires/chain_top.plain @@ -0,0 +1,12 @@ +--- +requires: + - chain_middle +--- + +***implementation reqs*** + +- Top implementation requirement. + +***functional specs*** + +- A top functionality. diff --git a/tests/test_requires.py b/tests/test_requires.py index 86bb61b..6e671ba 100644 --- a/tests/test_requires.py +++ b/tests/test_requires.py @@ -26,3 +26,29 @@ def test_circular_requires(get_test_data_path): def test_normal_requires(get_test_data_path): plain_file.plain_file_parser("normal_requires_main.plain", [get_test_data_path("data/requires")]) + + +def test_parse_module_chain_orders_required_modules_first(get_test_data_path): + chain = plain_file.parse_module_chain("chain_top.plain", [get_test_data_path("data/requires")]) + + assert [module_name for module_name, _ in chain] == ["chain_base", "chain_middle", "chain_top"] + for _, plain_source_tree in chain: + assert isinstance(plain_source_tree, dict) + assert "functional specs" in plain_source_tree + + +def test_parse_module_chain_single_module(get_test_data_path): + chain = plain_file.parse_module_chain("chain_base.plain", [get_test_data_path("data/requires")]) + + assert [module_name for module_name, _ in chain] == ["chain_base"] + + +def test_parse_module_chain_deduplicates_shared_ancestor(get_test_data_path): + chain = plain_file.parse_module_chain("chain_fork_top.plain", [get_test_data_path("data/requires")]) + + assert [module_name for module_name, _ in chain] == ["chain_base", "chain_middle", "chain_fork_top"] + + +def test_parse_module_chain_propagates_required_module_errors(get_test_data_path): + with pytest.raises(ModuleDoesNotExistError, match="Module does not exist"): + plain_file.parse_module_chain("non_existent_require.plain", [get_test_data_path("data/requires")]) From ab4086291743e1bbaeff70970a2c188fb995b143 Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 14:35:56 +0200 Subject: [PATCH 02/12] feat: add plain-parser check command --- pyproject.toml | 3 +++ src/plain_parser/cli.py | 50 +++++++++++++++++++++++++++++++++++ tests/data/cli/valid.plain | 11 ++++++++ tests/test_cli.py | 53 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/plain_parser/cli.py create mode 100644 tests/data/cli/valid.plain create mode 100644 tests/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index 0844f87..abf27ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ dev = [ "mypy==2.3.0", ] +[project.scripts] +plain-parser = "plain_parser.cli:main" + [tool.hatch.version] source = "vcs" diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py new file mode 100644 index 0000000..67c26db --- /dev/null +++ b/src/plain_parser/cli.py @@ -0,0 +1,50 @@ +"""Command-line entry point: ``plain-parser check [--template-dir DIR]``.""" + +import argparse +import os +import sys + +from plain_parser import loaders, plain_file, plain_spec + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="plain-parser", description="Tools for ***plain specification files.") + subparsers = parser.add_subparsers(dest="command", required=True) + + check_parser = subparsers.add_parser("check", help="Validate a .plain module and its requires chain.") + check_parser.add_argument("plain_file", help="Path to the .plain module.") + check_parser.add_argument( + "--template-dir", + help="Directory searched for modules and templates after the .plain file's own directory.", + ) + return parser + + +def check(plain_file_path: str, template_dir: str | None) -> None: + """Raise if the module, its requires chain, or its linked resources are invalid.""" + template_dirs = [os.path.dirname(os.path.abspath(plain_file_path))] + if template_dir is not None: + template_dirs.append(template_dir) + + chain = plain_file.parse_module_chain(os.path.basename(plain_file_path), template_dirs) + + for module_name, plain_source_tree in chain: + resources_list: list[dict] = [] + plain_spec.collect_linked_resources(plain_source_tree, resources_list, None, True) + loaders.load_linked_resources(template_dirs, resources_list, module_name) + + _, top_plain_source_tree = chain[-1] + for frid in plain_spec.get_frids(top_plain_source_tree): + plain_spec.get_specifications_for_frid(top_plain_source_tree, frid) + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + try: + check(args.plain_file, args.template_dir) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + return 0 diff --git a/tests/data/cli/valid.plain b/tests/data/cli/valid.plain new file mode 100644 index 0000000..349be5a --- /dev/null +++ b/tests/data/cli/valid.plain @@ -0,0 +1,11 @@ +***definitions*** + +- :App: is a console application. + +***implementation reqs*** + +- :App: is written in Python. + +***functional specs*** + +- :App: prints "hello, world". diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8307b07 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,53 @@ +import os +import shutil +import subprocess +import sys + +import pytest + +from plain_parser import cli + + +@pytest.fixture +def cli_data_dir(get_test_data_path): + return get_test_data_path("data/cli") + + +def test_check_valid_module_exits_zero_and_prints_nothing(cli_data_dir, capsys): + exit_code = cli.main(["check", os.path.join(cli_data_dir, "valid.plain")]) + + captured = capsys.readouterr() + assert exit_code == 0 + assert captured.out == "" + assert captured.err == "" + + +def test_check_without_file_argument_is_usage_error(capsys): + with pytest.raises(SystemExit) as exc_info: + cli.main(["check"]) + + assert exc_info.value.code == 2 + assert "usage:" in capsys.readouterr().err + + +def test_without_subcommand_is_usage_error(capsys): + with pytest.raises(SystemExit) as exc_info: + cli.main([]) + + assert exc_info.value.code == 2 + + +def test_console_script_is_installed_and_exits_zero(cli_data_dir): + # The venv's script dir may not be on PATH when pytest is run through an absolute interpreter path. + search_path = os.pathsep.join([os.path.dirname(sys.executable), os.environ.get("PATH", "")]) + script = shutil.which("plain-parser", path=search_path) + if script is None: + pytest.skip("plain-parser console script not installed; run: uv pip install -e '.[dev]'") + + result = subprocess.run( + [script, "check", os.path.join(cli_data_dir, "valid.plain")], capture_output=True, text=True + ) + + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" From 1acc9fb409c989ee355129ab7b693ed6e8a947f2 Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 14:36:46 +0200 Subject: [PATCH 03/12] test: cover plain-parser check error paths --- tests/data/cli/acceptance_test_resource.plain | 10 ++ tests/data/cli/base64_resource.plain | 7 ++ tests/data/cli/binary.bin | Bin 0 -> 12 bytes tests/data/cli/binary_resource.plain | 7 ++ tests/data/cli/code_variable_conflict.plain | 9 ++ tests/data/cli/code_variable_template.plain | 1 + tests/data/cli/missing_resource.plain | 7 ++ tests/data/cli/not_plain.md | 1 + tests/data/cli/notes.md | 3 + .../cli/requires_binary_resource_base.plain | 7 ++ .../cli/requires_binary_resource_top.plain | 12 ++ tests/data/cli/subdir/nested.plain | 7 ++ tests/data/cli/undefined_concept.plain | 13 +++ tests/test_cli.py | 110 ++++++++++++++++++ 14 files changed, 194 insertions(+) create mode 100644 tests/data/cli/acceptance_test_resource.plain create mode 100644 tests/data/cli/base64_resource.plain create mode 100644 tests/data/cli/binary.bin create mode 100644 tests/data/cli/binary_resource.plain create mode 100644 tests/data/cli/code_variable_conflict.plain create mode 100644 tests/data/cli/code_variable_template.plain create mode 100644 tests/data/cli/missing_resource.plain create mode 100644 tests/data/cli/not_plain.md create mode 100644 tests/data/cli/notes.md create mode 100644 tests/data/cli/requires_binary_resource_base.plain create mode 100644 tests/data/cli/requires_binary_resource_top.plain create mode 100644 tests/data/cli/subdir/nested.plain create mode 100644 tests/data/cli/undefined_concept.plain diff --git a/tests/data/cli/acceptance_test_resource.plain b/tests/data/cli/acceptance_test_resource.plain new file mode 100644 index 0000000..017bbae --- /dev/null +++ b/tests/data/cli/acceptance_test_resource.plain @@ -0,0 +1,10 @@ +***implementation reqs*** + +- An implementation requirement. + +***functional specs*** + +- A functionality. + + ***acceptance tests*** + - Behaves as described in [the notes](notes.md). diff --git a/tests/data/cli/base64_resource.plain b/tests/data/cli/base64_resource.plain new file mode 100644 index 0000000..a367653 --- /dev/null +++ b/tests/data/cli/base64_resource.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- Follow [the sample](../sample_base64_image.txt). + +***functional specs*** + +- A functionality. diff --git a/tests/data/cli/binary.bin b/tests/data/cli/binary.bin new file mode 100644 index 0000000000000000000000000000000000000000..585c05a906a143062be1213576f0ba0680515f6b GIT binary patch literal 12 TcmezWkAX2MGcU2IvY` Date: Tue, 29 Sep 2026 14:36:47 +0200 Subject: [PATCH 04/12] docs: add plain-parser check usage --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 5294686..ee53bb7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ pip install plain-parser ## Usage +Validate a module and its `requires` chain from the command line (exit `0` when valid; `Error: …` on stderr and exit `1` otherwise): + +```bash +plain-parser check my_spec.plain [--template-dir DIR] +``` + +Or from Python: + ```python from plain_parser import plain_file_parser From ff0fab4ba390635768a466caa7431ac46ce7e50e Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 14:43:00 +0200 Subject: [PATCH 05/12] fix: ignore an empty --template-dir --- src/plain_parser/cli.py | 2 +- .../cli/acceptance_test_binary_resource.plain | 10 ++++++ tests/test_cli.py | 31 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/data/cli/acceptance_test_binary_resource.plain diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 67c26db..45515e1 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -23,7 +23,7 @@ def build_parser() -> argparse.ArgumentParser: def check(plain_file_path: str, template_dir: str | None) -> None: """Raise if the module, its requires chain, or its linked resources are invalid.""" template_dirs = [os.path.dirname(os.path.abspath(plain_file_path))] - if template_dir is not None: + if template_dir: template_dirs.append(template_dir) chain = plain_file.parse_module_chain(os.path.basename(plain_file_path), template_dirs) diff --git a/tests/data/cli/acceptance_test_binary_resource.plain b/tests/data/cli/acceptance_test_binary_resource.plain new file mode 100644 index 0000000..8b7aaa2 --- /dev/null +++ b/tests/data/cli/acceptance_test_binary_resource.plain @@ -0,0 +1,10 @@ +***implementation reqs*** + +- An implementation requirement. + +***functional specs*** + +- A functionality. + + ***acceptance tests*** + - Behaves as described in [the binary](binary.bin). diff --git a/tests/test_cli.py b/tests/test_cli.py index c87df7e..1977deb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -161,3 +161,34 @@ def test_check_wrong_extension(cli_data_dir, capsys): assert exit_code == 1 assert out == "" assert err == "Error: Plain syntax error: Invalid plain file extension: .md. Expected: .plain.\n" + + +def test_check_empty_template_dir_is_ignored(tmp_path, capsys, monkeypatch): + # An empty --template-dir must not add the working directory to the search path (codeplain ignores it). + (tmp_path / "helper.plain").write_text( + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" + ) + spec_dir = tmp_path / "spec" + spec_dir.mkdir() + (spec_dir / "top.plain").write_text( + "---\nrequires:\n - helper\n---\n\n***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" + ) + monkeypatch.chdir(tmp_path) + + exit_code, out, err = _run_check(capsys, os.path.join("spec", "top.plain"), "--template-dir", "") + + assert exit_code == 1 + assert out == "" + assert err == "Error: Module does not exist (helper).\n" + + +def test_check_binary_linked_resource_in_acceptance_test(cli_data_dir, capsys, monkeypatch): + monkeypatch.chdir(cli_data_dir) + + exit_code, out, err = _run_check(capsys, "acceptance_test_binary_resource.plain") + + assert exit_code == 1 + assert out == "" + assert err.startswith( + "Error: Referenced resource 'binary.bin' in module 'acceptance_test_binary_resource' is a binary file." + ) From 1a53db9f8d91fe0973e90fa459fb932fd0d329da Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 14:45:36 +0200 Subject: [PATCH 06/12] fix: exit 130 on Ctrl-C instead of printing a traceback --- src/plain_parser/cli.py | 2 ++ tests/test_cli.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 45515e1..c25819e 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -43,6 +43,8 @@ def main(argv: list[str] | None = None) -> int: try: check(args.plain_file, args.template_dir) + except KeyboardInterrupt: + return 130 except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 1977deb..3b46650 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -192,3 +192,17 @@ def test_check_binary_linked_resource_in_acceptance_test(cli_data_dir, capsys, m assert err.startswith( "Error: Referenced resource 'binary.bin' in module 'acceptance_test_binary_resource' is a binary file." ) + + +def test_check_interrupted_exits_130_without_traceback(cli_data_dir, capsys, monkeypatch): + def interrupt(*_): + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "check", interrupt) + + exit_code = cli.main(["check", os.path.join(cli_data_dir, "valid.plain")]) + + captured = capsys.readouterr() + assert exit_code == 130 + assert captured.out == "" + assert "Traceback" not in captured.err From 1360970b3badbaedfe85ad548f530fc60d857d6b Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 15:07:38 +0200 Subject: [PATCH 07/12] feat: read template-dir from the config file when --template-dir is not given --- README.md | 6 ++- pyproject.toml | 1 + src/plain_parser/cli.py | 71 ++++++++++++++++++++++++++++++-- tests/test_cli.py | 89 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ee53bb7..1775d2d 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,13 @@ pip install plain-parser Validate a module and its `requires` chain from the command line (exit `0` when valid; `Error: …` on stderr and exit `1` otherwise): ```bash -plain-parser check my_spec.plain [--template-dir DIR] +plain-parser check my_spec.plain [--template-dir DIR] [--config-name NAME] ``` +Modules and `{% include %}` templates are looked up in the `.plain` file's directory, then in `--template-dir`. +When `--template-dir` is not given, it is read from the `template-dir` key of `config.yaml` (or the file named by +`--config-name`), found next to the `.plain` file or in the working directory. Other config keys are ignored. + Or from Python: ```python diff --git a/pyproject.toml b/pyproject.toml index abf27ad..38be19a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "mistletoe>=1.6.0,<2", "python-frontmatter>=1.3.0,<2", "networkx>=3.6.1,<4", + "pyyaml>=6,<7", ] [project.optional-dependencies] diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index c25819e..d2b5eb9 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -1,11 +1,20 @@ -"""Command-line entry point: ``plain-parser check [--template-dir DIR]``.""" +"""Command-line entry point: ``plain-parser check [--template-dir DIR] [--config-name NAME]``.""" import argparse import os import sys +import yaml + from plain_parser import loaders, plain_file, plain_spec +DEFAULT_CONFIG_NAME = "config.yaml" +TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir") + + +class AmbiguousConfigFileError(Exception): + pass + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="plain-parser", description="Tools for ***plain specification files.") @@ -15,11 +24,53 @@ def build_parser() -> argparse.ArgumentParser: check_parser.add_argument("plain_file", help="Path to the .plain module.") check_parser.add_argument( "--template-dir", - help="Directory searched for modules and templates after the .plain file's own directory.", + help="Directory searched for modules and templates after the .plain file's own directory. " + "Overrides the template-dir value of the config file.", + ) + check_parser.add_argument( + "--config-name", + default=DEFAULT_CONFIG_NAME, + help="Name of the config file to read template-dir from. Looked up in the .plain file's directory " + "and the current working directory. Defaults to %(default)s.", ) return parser +def resolve_config_file(config_name: str, plain_file_path: str) -> str | None: + """The config file next to the .plain file, else the one in the working directory, else None.""" + plain_file_dir = os.path.dirname(os.path.abspath(plain_file_path)) + plain_dir_config = os.path.normpath(os.path.join(plain_file_dir, config_name)) + cwd_config = os.path.normpath(os.path.join(os.getcwd(), config_name)) + + in_plain_dir = os.path.exists(plain_dir_config) + in_cwd = os.path.exists(cwd_config) + if in_plain_dir and in_cwd and plain_dir_config != cwd_config: + raise AmbiguousConfigFileError( + f"Config file '{config_name}' was found in two locations:\n" + f" - Plain file directory: {plain_file_dir}\n" + f" - Current working directory: {os.getcwd()}\n" + f"Remove the config file from one of these locations to resolve the ambiguity." + ) + if in_plain_dir: + return plain_dir_config + if in_cwd: + return cwd_config + return None + + +def template_dir_from_config(config_file: str) -> str | None: + """The template-dir value of the config file, resolved against the file's directory. Other keys are ignored.""" + with open(config_file) as f: + config = yaml.safe_load(f) or {} + + for key in TEMPLATE_DIR_CONFIG_KEYS: + value = config.get(key) + if value: + value = os.path.expanduser(str(value)) + return value if os.path.isabs(value) else os.path.join(os.path.dirname(config_file), value) + return None + + def check(plain_file_path: str, template_dir: str | None) -> None: """Raise if the module, its requires chain, or its linked resources are invalid.""" template_dirs = [os.path.dirname(os.path.abspath(plain_file_path))] @@ -39,10 +90,22 @@ def check(plain_file_path: str, template_dir: str | None) -> None: def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) + parser = build_parser() + args = parser.parse_args(argv) + + template_dir = args.template_dir + if not template_dir: + try: + config_file = resolve_config_file(args.config_name, args.plain_file) + if config_file is not None: + template_dir = template_dir_from_config(config_file) + except AmbiguousConfigFileError as e: + parser.error(str(e)) + except Exception as e: + parser.error(f"Error reading config file: {e}") try: - check(args.plain_file, args.template_dir) + check(args.plain_file, template_dir) except KeyboardInterrupt: return 130 except Exception as e: diff --git a/tests/test_cli.py b/tests/test_cli.py index 3b46650..8c67cf6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -206,3 +206,92 @@ def interrupt(*_): assert exit_code == 130 assert captured.out == "" assert "Traceback" not in captured.err + + +_MODULE = "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" +_TOP = "---\nrequires:\n - helper\n---\n\n" + _MODULE + + +def _project(tmp_path, config_name=None, config_text=None, template_subdir="template"): + """A spec requiring ``helper``, which lives only in ``//``.""" + spec_dir = tmp_path / "project" + (spec_dir / template_subdir).mkdir(parents=True) + (spec_dir / "top.plain").write_text(_TOP) + (spec_dir / template_subdir / "helper.plain").write_text(_MODULE) + if config_name is not None: + (spec_dir / config_name).write_text(config_text) + return spec_dir + + +def test_check_reads_template_dir_from_config_next_to_spec(tmp_path, capsys, monkeypatch): + spec_dir = _project(tmp_path, "config.yaml", "template-dir: template\n") + monkeypatch.chdir(tmp_path) # not the spec dir: the relative value must resolve against the config file + + assert _run_check(capsys, str(spec_dir / "top.plain")) == (0, "", "") + + +def test_check_accepts_template_dir_key_with_underscore(tmp_path, capsys): + spec_dir = _project(tmp_path, "config.yaml", "template_dir: template\n") + + assert _run_check(capsys, str(spec_dir / "top.plain")) == (0, "", "") + + +def test_check_ignores_other_config_keys(tmp_path, capsys): + spec_dir = _project( + tmp_path, + "config.yaml", + "unittests-script: scripts/x.sh\nbuild-dest: dist\ntemplate-dir: template\nverbose: true\n", + ) + + assert _run_check(capsys, str(spec_dir / "top.plain")) == (0, "", "") + + +def test_check_without_config_does_not_search_template_dir(tmp_path, capsys): + spec_dir = _project(tmp_path) + + exit_code, out, err = _run_check(capsys, str(spec_dir / "top.plain")) + + assert (exit_code, out, err) == (1, "", "Error: Module does not exist (helper).\n") + + +def test_check_config_name_selects_config_file(tmp_path, capsys): + spec_dir = _project(tmp_path, "web.config.yaml", "template-dir: template\n") + + assert _run_check(capsys, str(spec_dir / "top.plain"), "--config-name", "web.config.yaml") == (0, "", "") + assert _run_check(capsys, str(spec_dir / "top.plain"))[0] == 1 + + +def test_check_explicit_template_dir_overrides_config(tmp_path, capsys): + spec_dir = _project(tmp_path, "config.yaml", "template-dir: does_not_exist\n") + + assert _run_check(capsys, str(spec_dir / "top.plain"), "--template-dir", str(spec_dir / "template")) == (0, "", "") + + +def test_check_reads_config_from_working_directory(tmp_path, capsys, monkeypatch): + spec_dir = _project(tmp_path) + (tmp_path / "config.yaml").write_text("template-dir: project/template\n") + monkeypatch.chdir(tmp_path) + + assert _run_check(capsys, os.path.join("project", "top.plain")) == (0, "", "") + + +def test_check_config_in_both_locations_is_usage_error(tmp_path, capsys, monkeypatch): + spec_dir = _project(tmp_path, "config.yaml", "template-dir: template\n") + (tmp_path / "config.yaml").write_text("template-dir: project/template\n") + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc_info: + cli.main(["check", str(spec_dir / "top.plain")]) + + assert exc_info.value.code == 2 + assert "found in two locations" in capsys.readouterr().err + + +def test_check_unreadable_config_is_usage_error(tmp_path, capsys): + spec_dir = _project(tmp_path, "config.yaml", "template-dir: [unclosed\n") + + with pytest.raises(SystemExit) as exc_info: + cli.main(["check", str(spec_dir / "top.plain")]) + + assert exc_info.value.code == 2 + assert "Error reading config file" in capsys.readouterr().err From 881d202610a937fee02842b76d4a93f108494ac3 Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 15:09:41 +0200 Subject: [PATCH 08/12] feat: print ': OK' on a valid spec --- README.md | 2 +- src/plain_parser/cli.py | 1 + tests/test_cli.py | 37 +++++++++++++++++++++++++------------ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1775d2d..e95afeb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ pip install plain-parser ## Usage -Validate a module and its `requires` chain from the command line (exit `0` when valid; `Error: …` on stderr and exit `1` otherwise): +Validate a module and its `requires` chain from the command line (`: OK` and exit `0` when valid; `Error: …` on stderr and exit `1` otherwise): ```bash plain-parser check my_spec.plain [--template-dir DIR] [--config-name NAME] diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index d2b5eb9..30ec145 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -112,4 +112,5 @@ def main(argv: list[str] | None = None) -> int: print(f"Error: {e}", file=sys.stderr) return 1 + print(f"{args.plain_file}: OK") return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c67cf6..2bf58ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,12 +13,11 @@ def cli_data_dir(get_test_data_path): return get_test_data_path("data/cli") -def test_check_valid_module_exits_zero_and_prints_nothing(cli_data_dir, capsys): +def test_check_valid_module_exits_zero(cli_data_dir, capsys): exit_code = cli.main(["check", os.path.join(cli_data_dir, "valid.plain")]) captured = capsys.readouterr() assert exit_code == 0 - assert captured.out == "" assert captured.err == "" @@ -49,7 +48,7 @@ def test_console_script_is_installed_and_exits_zero(cli_data_dir): ) assert result.returncode == 0 - assert result.stdout == "" + assert result.stdout.endswith(": OK\n") assert result.stderr == "" @@ -59,12 +58,18 @@ def _run_check(capsys, *argv): return exit_code, captured.out, captured.err +def _assert_ok(result): + exit_code, out, err = result + assert (exit_code, err) == (0, "") + assert out.endswith(": OK\n"), out + + def test_check_valid_module_in_subdirectory_from_parent(cli_data_dir, capsys, monkeypatch): monkeypatch.chdir(cli_data_dir) exit_code, out, err = _run_check(capsys, os.path.join("subdir", "nested.plain")) - assert (exit_code, out, err) == (0, "", "") + _assert_ok((exit_code, out, err)) def test_check_valid_module_with_linked_resource_in_acceptance_test(cli_data_dir, capsys, monkeypatch): @@ -72,7 +77,7 @@ def test_check_valid_module_with_linked_resource_in_acceptance_test(cli_data_dir exit_code, out, err = _run_check(capsys, "acceptance_test_resource.plain") - assert (exit_code, out, err) == (0, "", "") + _assert_ok((exit_code, out, err)) def test_check_nonexistent_template_dir_is_ignored(cli_data_dir, capsys): @@ -80,7 +85,7 @@ def test_check_nonexistent_template_dir_is_ignored(cli_data_dir, capsys): capsys, os.path.join(cli_data_dir, "valid.plain"), "--template-dir", os.path.join(cli_data_dir, "nope") ) - assert (exit_code, out, err) == (0, "", "") + _assert_ok((exit_code, out, err)) def test_check_undefined_concept(cli_data_dir, capsys): @@ -227,13 +232,13 @@ def test_check_reads_template_dir_from_config_next_to_spec(tmp_path, capsys, mon spec_dir = _project(tmp_path, "config.yaml", "template-dir: template\n") monkeypatch.chdir(tmp_path) # not the spec dir: the relative value must resolve against the config file - assert _run_check(capsys, str(spec_dir / "top.plain")) == (0, "", "") + _assert_ok(_run_check(capsys, str(spec_dir / "top.plain"))) def test_check_accepts_template_dir_key_with_underscore(tmp_path, capsys): spec_dir = _project(tmp_path, "config.yaml", "template_dir: template\n") - assert _run_check(capsys, str(spec_dir / "top.plain")) == (0, "", "") + _assert_ok(_run_check(capsys, str(spec_dir / "top.plain"))) def test_check_ignores_other_config_keys(tmp_path, capsys): @@ -243,7 +248,7 @@ def test_check_ignores_other_config_keys(tmp_path, capsys): "unittests-script: scripts/x.sh\nbuild-dest: dist\ntemplate-dir: template\nverbose: true\n", ) - assert _run_check(capsys, str(spec_dir / "top.plain")) == (0, "", "") + _assert_ok(_run_check(capsys, str(spec_dir / "top.plain"))) def test_check_without_config_does_not_search_template_dir(tmp_path, capsys): @@ -257,14 +262,14 @@ def test_check_without_config_does_not_search_template_dir(tmp_path, capsys): def test_check_config_name_selects_config_file(tmp_path, capsys): spec_dir = _project(tmp_path, "web.config.yaml", "template-dir: template\n") - assert _run_check(capsys, str(spec_dir / "top.plain"), "--config-name", "web.config.yaml") == (0, "", "") + _assert_ok(_run_check(capsys, str(spec_dir / "top.plain"), "--config-name", "web.config.yaml")) assert _run_check(capsys, str(spec_dir / "top.plain"))[0] == 1 def test_check_explicit_template_dir_overrides_config(tmp_path, capsys): spec_dir = _project(tmp_path, "config.yaml", "template-dir: does_not_exist\n") - assert _run_check(capsys, str(spec_dir / "top.plain"), "--template-dir", str(spec_dir / "template")) == (0, "", "") + _assert_ok(_run_check(capsys, str(spec_dir / "top.plain"), "--template-dir", str(spec_dir / "template"))) def test_check_reads_config_from_working_directory(tmp_path, capsys, monkeypatch): @@ -272,7 +277,7 @@ def test_check_reads_config_from_working_directory(tmp_path, capsys, monkeypatch (tmp_path / "config.yaml").write_text("template-dir: project/template\n") monkeypatch.chdir(tmp_path) - assert _run_check(capsys, os.path.join("project", "top.plain")) == (0, "", "") + _assert_ok(_run_check(capsys, os.path.join("project", "top.plain"))) def test_check_config_in_both_locations_is_usage_error(tmp_path, capsys, monkeypatch): @@ -295,3 +300,11 @@ def test_check_unreadable_config_is_usage_error(tmp_path, capsys): assert exc_info.value.code == 2 assert "Error reading config file" in capsys.readouterr().err + + +def test_check_valid_module_prints_ok_line_with_file_as_given(cli_data_dir, capsys, monkeypatch): + monkeypatch.chdir(cli_data_dir) + + exit_code, out, err = _run_check(capsys, "valid.plain") + + assert (exit_code, out, err) == (0, "valid.plain: OK\n", "") From fac25ffdbd364a6c0dc0ea378cf700c1d5b5243d Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 15:40:03 +0200 Subject: [PATCH 09/12] fix: exit 130 on Ctrl-C during config lookup too The config file lookup ran before the KeyboardInterrupt guard, so an interrupt there printed a traceback and exited 1. --- src/plain_parser/cli.py | 22 +++++++++++----------- tests/test_cli.py | 5 +++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 30ec145..ce95789 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -93,18 +93,8 @@ def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) - template_dir = args.template_dir - if not template_dir: - try: - config_file = resolve_config_file(args.config_name, args.plain_file) - if config_file is not None: - template_dir = template_dir_from_config(config_file) - except AmbiguousConfigFileError as e: - parser.error(str(e)) - except Exception as e: - parser.error(f"Error reading config file: {e}") - try: + template_dir = args.template_dir or _template_dir_from_config(parser, args) check(args.plain_file, template_dir) except KeyboardInterrupt: return 130 @@ -114,3 +104,13 @@ def main(argv: list[str] | None = None) -> int: print(f"{args.plain_file}: OK") return 0 + + +def _template_dir_from_config(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str | None: + try: + config_file = resolve_config_file(args.config_name, args.plain_file) + return template_dir_from_config(config_file) if config_file is not None else None + except AmbiguousConfigFileError as e: + parser.error(str(e)) + except Exception as e: + parser.error(f"Error reading config file: {e}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 2bf58ff..ba58eb9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -199,11 +199,12 @@ def test_check_binary_linked_resource_in_acceptance_test(cli_data_dir, capsys, m ) -def test_check_interrupted_exits_130_without_traceback(cli_data_dir, capsys, monkeypatch): +@pytest.mark.parametrize("interrupted_function", ["check", "resolve_config_file"]) +def test_check_interrupted_exits_130_without_traceback(cli_data_dir, capsys, monkeypatch, interrupted_function): def interrupt(*_): raise KeyboardInterrupt - monkeypatch.setattr(cli, "check", interrupt) + monkeypatch.setattr(cli, interrupted_function, interrupt) exit_code = cli.main(["check", os.path.join(cli_data_dir, "valid.plain")]) From 2b4ee214a92b41ea3a4dc161128ad6e20a90dcf5 Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 15:40:05 +0200 Subject: [PATCH 10/12] fix: resolve the config file even when --template-dir is given codeplain always resolves the config file, so a config.yaml in both the spec directory and the working directory is an error regardless of the flag. The flag still takes precedence over the config value. --- src/plain_parser/cli.py | 5 +++-- tests/test_cli.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index ce95789..df4e071 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -94,8 +94,9 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: - template_dir = args.template_dir or _template_dir_from_config(parser, args) - check(args.plain_file, template_dir) + # The config file is resolved even when --template-dir is given, so an ambiguous config is always an error. + config_template_dir = _template_dir_from_config(parser, args) + check(args.plain_file, args.template_dir or config_template_dir) except KeyboardInterrupt: return 130 except Exception as e: diff --git a/tests/test_cli.py b/tests/test_cli.py index ba58eb9..fdcfbfa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -293,6 +293,18 @@ def test_check_config_in_both_locations_is_usage_error(tmp_path, capsys, monkeyp assert "found in two locations" in capsys.readouterr().err +def test_check_config_in_both_locations_is_usage_error_even_with_explicit_template_dir(tmp_path, capsys, monkeypatch): + spec_dir = _project(tmp_path, "config.yaml", "template-dir: template\n") + (tmp_path / "config.yaml").write_text("template-dir: project/template\n") + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc_info: + cli.main(["check", str(spec_dir / "top.plain"), "--template-dir", str(spec_dir / "template")]) + + assert exc_info.value.code == 2 + assert "found in two locations" in capsys.readouterr().err + + def test_check_unreadable_config_is_usage_error(tmp_path, capsys): spec_dir = _project(tmp_path, "config.yaml", "template-dir: [unclosed\n") From c65c8681dfccd43e8eca10abd21cbf678c0567cf Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 15:45:43 +0200 Subject: [PATCH 11/12] test: fail instead of skip when the console script is missing in CI A skip would let a broken [project.scripts] entry pass CI. Also document the two config rules the README left out: ambiguity is a usage error and the config file is read even with --template-dir. --- README.md | 4 +++- tests/test_cli.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e95afeb..d898eb0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ plain-parser check my_spec.plain [--template-dir DIR] [--config-name NAME] Modules and `{% include %}` templates are looked up in the `.plain` file's directory, then in `--template-dir`. When `--template-dir` is not given, it is read from the `template-dir` key of `config.yaml` (or the file named by -`--config-name`), found next to the `.plain` file or in the working directory. Other config keys are ignored. +`--config-name`), found next to the `.plain` file or in the working directory. Finding it in both is a usage error +(exit `2`). The config file is read even when `--template-dir` is given, so a malformed one fails the run either way. +Other config keys are ignored. Or from Python: diff --git a/tests/test_cli.py b/tests/test_cli.py index fdcfbfa..7914cdb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,7 +41,9 @@ def test_console_script_is_installed_and_exits_zero(cli_data_dir): search_path = os.pathsep.join([os.path.dirname(sys.executable), os.environ.get("PATH", "")]) script = shutil.which("plain-parser", path=search_path) if script is None: - pytest.skip("plain-parser console script not installed; run: uv pip install -e '.[dev]'") + message = "plain-parser console script not installed; run: uv pip install -e '.[dev]'" + # CI installs the package, so a missing script there means [project.scripts] is broken. + pytest.fail(message) if os.environ.get("CI") else pytest.skip(message) result = subprocess.run( [script, "check", os.path.join(cli_data_dir, "valid.plain")], capture_output=True, text=True From 58a3a5a4008ac791b6fd3d608656cc247d865606 Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 29 Sep 2026 17:00:22 +0200 Subject: [PATCH 12/12] refactor: fully validate required modules while parsing the top module process_required_modules only parsed each ancestor for its exported concepts and dropped the tree, so parse_module_chain had to run plain_file_parser again on every ancestor to validate it and keep its tree. Now process_required_modules runs the same post-parse validation on each ancestor (validate_and_marshall_module, extracted from plain_file_parser) and collects the marshalled trees; parse_module_chain returns them. Every module is read and parsed once. plain_file_parser alone now rejects an ancestor with an undefined concept, a missing linked resource, or no implementation reqs. The requires fixtures had ancestors without implementation reqs; they are fixed. --- src/plain_parser/plain_file.py | 128 ++++++++++-------- tests/data/requires/diamond_requires_1.plain | 4 + tests/data/requires/diamond_requires_2.plain | 4 + .../requires/independent_requires_1.plain | 4 + .../requires/independent_requires_2.plain | 4 + .../data/requires/invalid_ancestor_base.plain | 7 + .../data/requires/invalid_ancestor_top.plain | 12 ++ tests/data/requires/normal_requires_1.plain | 4 + tests/data/requires/normal_requires_2.plain | 4 + tests/test_requires.py | 6 + 10 files changed, 124 insertions(+), 53 deletions(-) create mode 100644 tests/data/requires/invalid_ancestor_base.plain create mode 100644 tests/data/requires/invalid_ancestor_top.plain diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index 0ee8808..6139f06 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -701,11 +701,16 @@ def parse_plain_file( def process_required_modules( required_modules: list[str], - code_variables: dict, template_dirs: list[str], all_required_modules: list[str], modules_trace: list[str], + chain: list[tuple[str, dict]], ) -> list[mistletoe.block_token.token]: + """Parse and fully validate every required module, deepest first. + + Appends ``(module name, marshalled plain source tree)`` to ``chain`` for each module not already in it + and returns the exported definitions of the directly required modules. + """ exported_definitions = list[mistletoe.block_token.token]() for module_name in required_modules: if module_name in modules_trace: @@ -714,10 +719,12 @@ def process_required_modules( if len(all_required_modules) > 0 and module_name == all_required_modules[-1]: continue + code_variables: dict = {} plain_file_parse_result = parse_plain_file( module_name, code_variables, template_dirs, imported_modules=[], modules_trace=[] ) + ancestor_exported_definitions: list[mistletoe.block_token.token] = [] if len(plain_file_parse_result.required_modules) == 0: if len(all_required_modules) > 0: # For now we require that there is fixed order how required modules are dependent. @@ -727,12 +734,12 @@ def process_required_modules( f"Plain syntax error: There must be a fixed order how required modules are dependent ({module_name})." ) else: - process_required_modules( + ancestor_exported_definitions = process_required_modules( plain_file_parse_result.required_modules, - code_variables, template_dirs, all_required_modules, modules_trace + [module_name], + chain, ) if EXPORTED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata: @@ -748,6 +755,12 @@ def process_required_modules( ) ) + marshalled_plain_source = validate_and_marshall_module( + plain_file_parse_result, module_name, ancestor_exported_definitions, code_variables + ) + if module_name not in (chain_module_name for chain_module_name, _ in chain): + chain.append((module_name, marshalled_plain_source)) + all_required_modules.append(module_name) return exported_definitions @@ -775,35 +788,13 @@ def process_exported_definitions(plain_source: dict, exported_definitions: list[ plain_source[plain_spec.DEFINITIONS].children.append(exported_definition) -def plain_file_parser( # noqa: C901 - plain_source_file_name: str, - template_dirs: list[str], -) -> tuple[str, dict, list[str]]: - # code_variables are used to pass code variables to the plain source - # they need to be passed as an argument to the function because they populated when liquid templating is applied - # and we need to pass them to the marshalled_plain_source_tree after it's rendered - plain_source_file_path = Path(plain_source_file_name) - if plain_source_file_path.suffix != PLAIN_SOURCE_FILE_EXTENSION: - raise PlainSyntaxError( - f"Plain syntax error: Invalid plain file extension: {plain_source_file_path.suffix}. Expected: {PLAIN_SOURCE_FILE_EXTENSION}." - ) - - module_name = ( - plain_source_file_path.stem - if plain_source_file_path.is_absolute() - else plain_source_file_path.with_suffix("").as_posix() - ) - - code_variables = {} - - plain_file_parse_result = parse_plain_file( - module_name, - code_variables, - template_dirs, - imported_modules=[], - modules_trace=[], - ) - +def validate_and_marshall_module( + plain_file_parse_result: PlainFileParseResult, + module_name: str, + exported_definitions: list[mistletoe.block_token.token], + code_variables: dict, +) -> dict: + """Every check a module must pass after parsing, and its marshalled plain source tree.""" if len(plain_file_parse_result.required_concepts) > 0: missing_required_concepts_msg = "Missing required concepts: " missing_required_concepts_msg += ", ".join(plain_file_parse_result.required_concepts) @@ -831,14 +822,6 @@ def plain_file_parser( # noqa: C901 has_requires=bool(plain_file_parse_result.required_modules), ) - exported_definitions = process_required_modules( - plain_file_parse_result.required_modules, - code_variables={}, - template_dirs=template_dirs, - all_required_modules=[], - modules_trace=[], - ) - process_exported_definitions(plain_file_parse_result.plain_source, exported_definitions) process_acceptance_tests(plain_file_parse_result.plain_source) @@ -858,7 +841,56 @@ def plain_file_parser( # noqa: C901 if plain_spec.DEFINITIONS in marshalled_plain_source: concept_utils.sort_definitions(marshalled_plain_source[plain_spec.DEFINITIONS]) - return module_name, marshalled_plain_source, plain_file_parse_result.required_modules + return marshalled_plain_source + + +def _parse_module( + plain_source_file_name: str, template_dirs: list[str] +) -> tuple[str, dict, list[str], list[tuple[str, dict]]]: + plain_source_file_path = Path(plain_source_file_name) + if plain_source_file_path.suffix != PLAIN_SOURCE_FILE_EXTENSION: + raise PlainSyntaxError( + f"Plain syntax error: Invalid plain file extension: {plain_source_file_path.suffix}. Expected: {PLAIN_SOURCE_FILE_EXTENSION}." + ) + + module_name = ( + plain_source_file_path.stem + if plain_source_file_path.is_absolute() + else plain_source_file_path.with_suffix("").as_posix() + ) + + # code_variables are populated when liquid templating is applied and consumed by process_code_variables + # once the plain source is marshalled. + code_variables: dict = {} + + plain_file_parse_result = parse_plain_file( + module_name, + code_variables, + template_dirs, + imported_modules=[], + modules_trace=[], + ) + + chain: list[tuple[str, dict]] = [] + exported_definitions = process_required_modules( + plain_file_parse_result.required_modules, + template_dirs=template_dirs, + all_required_modules=[], + modules_trace=[], + chain=chain, + ) + + marshalled_plain_source = validate_and_marshall_module( + plain_file_parse_result, module_name, exported_definitions, code_variables + ) + + return module_name, marshalled_plain_source, plain_file_parse_result.required_modules, chain + + +def plain_file_parser(plain_source_file_name: str, template_dirs: list[str]) -> tuple[str, dict, list[str]]: + """Parse a module, validating it and every module in its ``requires`` chain.""" + module_name, marshalled_plain_source, required_modules, _ = _parse_module(plain_source_file_name, template_dirs) + return module_name, marshalled_plain_source, required_modules def parse_module_chain(plain_file_name: str, template_dirs: list[str]) -> list[tuple[str, dict]]: @@ -868,15 +900,5 @@ def parse_module_chain(plain_file_name: str, template_dirs: list[str]) -> list[t first, deepest ancestors first, then the module itself. A module reached through more than one ``requires`` path appears once, at its first (deepest) position. """ - module_name, plain_source_tree, required_module_names = plain_file_parser(plain_file_name, template_dirs) - - chain: list[tuple[str, dict]] = [] - seen = set[str]() - for required_module_name in required_module_names: - for entry in parse_module_chain(get_filename_from_module_name(required_module_name), template_dirs): - if entry[0] not in seen: - seen.add(entry[0]) - chain.append(entry) - - chain.append((module_name, plain_source_tree)) - return chain + module_name, marshalled_plain_source, _, chain = _parse_module(plain_file_name, template_dirs) + return chain + [(module_name, marshalled_plain_source)] diff --git a/tests/data/requires/diamond_requires_1.plain b/tests/data/requires/diamond_requires_1.plain index 57be990..c6b8e11 100644 --- a/tests/data/requires/diamond_requires_1.plain +++ b/tests/data/requires/diamond_requires_1.plain @@ -7,6 +7,10 @@ requires: - :App: is a web application. +***implementation reqs*** + +- An implementation requirement. + ***functional specs*** - Implement the entry point for :App:. diff --git a/tests/data/requires/diamond_requires_2.plain b/tests/data/requires/diamond_requires_2.plain index 8bb8c75..f6a4773 100644 --- a/tests/data/requires/diamond_requires_2.plain +++ b/tests/data/requires/diamond_requires_2.plain @@ -7,6 +7,10 @@ requires: - :User: is the end-user of the application. +***implementation reqs*** + +- An implementation requirement. + ***functional specs*** - :User: should be able to log in using their email and password. diff --git a/tests/data/requires/independent_requires_1.plain b/tests/data/requires/independent_requires_1.plain index fa9f55d..ee05e0f 100644 --- a/tests/data/requires/independent_requires_1.plain +++ b/tests/data/requires/independent_requires_1.plain @@ -2,6 +2,10 @@ - :App: is a web application. +***implementation reqs*** + +- An implementation requirement. + ***functional specs*** - Implement the entry point for :App:. diff --git a/tests/data/requires/independent_requires_2.plain b/tests/data/requires/independent_requires_2.plain index 5b2954f..a844d3a 100644 --- a/tests/data/requires/independent_requires_2.plain +++ b/tests/data/requires/independent_requires_2.plain @@ -2,6 +2,10 @@ - :User: is the end-user of the application. +***implementation reqs*** + +- An implementation requirement. + ***functional specs*** - :User: should be able to log in using their email and password. diff --git a/tests/data/requires/invalid_ancestor_base.plain b/tests/data/requires/invalid_ancestor_base.plain new file mode 100644 index 0000000..ec5862a --- /dev/null +++ b/tests/data/requires/invalid_ancestor_base.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- Base implementation requirement. + +***functional specs*** + +- A base functionality that uses :UndefinedThing:. diff --git a/tests/data/requires/invalid_ancestor_top.plain b/tests/data/requires/invalid_ancestor_top.plain new file mode 100644 index 0000000..844d8d9 --- /dev/null +++ b/tests/data/requires/invalid_ancestor_top.plain @@ -0,0 +1,12 @@ +--- +requires: + - invalid_ancestor_base +--- + +***implementation reqs*** + +- Top implementation requirement. + +***functional specs*** + +- A top functionality. diff --git a/tests/data/requires/normal_requires_1.plain b/tests/data/requires/normal_requires_1.plain index ad4c1f3..816ec41 100644 --- a/tests/data/requires/normal_requires_1.plain +++ b/tests/data/requires/normal_requires_1.plain @@ -7,6 +7,10 @@ requires: - :App: is a web application. +***implementation reqs*** + +- An implementation requirement. + ***functional specs*** - Implement the entry point for :App:. diff --git a/tests/data/requires/normal_requires_2.plain b/tests/data/requires/normal_requires_2.plain index 45bd136..462cb82 100644 --- a/tests/data/requires/normal_requires_2.plain +++ b/tests/data/requires/normal_requires_2.plain @@ -7,6 +7,10 @@ requires: - :User: is the end-user of the application. +***implementation reqs*** + +- An implementation requirement. + ***functional specs*** - :User: should be able to log in using their email and password. diff --git a/tests/test_requires.py b/tests/test_requires.py index 6e671ba..1a726b8 100644 --- a/tests/test_requires.py +++ b/tests/test_requires.py @@ -52,3 +52,9 @@ def test_parse_module_chain_deduplicates_shared_ancestor(get_test_data_path): def test_parse_module_chain_propagates_required_module_errors(get_test_data_path): with pytest.raises(ModuleDoesNotExistError, match="Module does not exist"): plain_file.parse_module_chain("non_existent_require.plain", [get_test_data_path("data/requires")]) + + +def test_plain_file_parser_validates_required_modules(get_test_data_path): + # The ancestor is fully validated during the parse of the top module, not only when the chain is walked. + with pytest.raises(Exception, match="Concept :UndefinedThing: is not defined"): + plain_file.plain_file_parser("invalid_ancestor_top.plain", [get_test_data_path("data/requires")])