diff --git a/README.md b/README.md index 5294686..d898eb0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,20 @@ pip install plain-parser ## Usage +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] +``` + +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. 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: + ```python from plain_parser import plain_file_parser diff --git a/pyproject.toml b/pyproject.toml index 0844f87..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] @@ -32,6 +33,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/__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/cli.py b/src/plain_parser/cli.py new file mode 100644 index 0000000..df4e071 --- /dev/null +++ b/src/plain_parser/cli.py @@ -0,0 +1,117 @@ +"""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.") + 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. " + "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))] + if template_dir: + 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: + parser = build_parser() + args = parser.parse_args(argv) + + try: + # 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: + print(f"Error: {e}", file=sys.stderr) + return 1 + + 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/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index d8abb5e..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,4 +841,64 @@ 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]]: + """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, marshalled_plain_source, _, chain = _parse_module(plain_file_name, template_dirs) + return chain + [(module_name, marshalled_plain_source)] 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/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 0000000..585c05a Binary files /dev/null and b/tests/data/cli/binary.bin differ diff --git a/tests/data/cli/binary_resource.plain b/tests/data/cli/binary_resource.plain new file mode 100644 index 0000000..0bca357 --- /dev/null +++ b/tests/data/cli/binary_resource.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- Follow [the binary](binary.bin). + +***functional specs*** + +- A functionality. diff --git a/tests/data/cli/code_variable_conflict.plain b/tests/data/cli/code_variable_conflict.plain new file mode 100644 index 0000000..eb087c5 --- /dev/null +++ b/tests/data/cli/code_variable_conflict.plain @@ -0,0 +1,9 @@ +***implementation reqs*** + +- An implementation requirement. + +***functional specs*** + +- Implement {% include "code_variable_template.plain", variable_name: "first" %} + +- Implement {% include "code_variable_template.plain", variable_name: "second" %} diff --git a/tests/data/cli/code_variable_template.plain b/tests/data/cli/code_variable_template.plain new file mode 100644 index 0000000..03a12a2 --- /dev/null +++ b/tests/data/cli/code_variable_template.plain @@ -0,0 +1 @@ +something named {{ variable_name | code_variable }} diff --git a/tests/data/cli/missing_resource.plain b/tests/data/cli/missing_resource.plain new file mode 100644 index 0000000..e0e687c --- /dev/null +++ b/tests/data/cli/missing_resource.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- Follow [the missing notes](does_not_exist.md). + +***functional specs*** + +- A functionality. diff --git a/tests/data/cli/not_plain.md b/tests/data/cli/not_plain.md new file mode 100644 index 0000000..3478746 --- /dev/null +++ b/tests/data/cli/not_plain.md @@ -0,0 +1 @@ +Not a plain file. diff --git a/tests/data/cli/notes.md b/tests/data/cli/notes.md new file mode 100644 index 0000000..704a13d --- /dev/null +++ b/tests/data/cli/notes.md @@ -0,0 +1,3 @@ +# Notes + +Plain text notes. diff --git a/tests/data/cli/requires_binary_resource_base.plain b/tests/data/cli/requires_binary_resource_base.plain new file mode 100644 index 0000000..253e5a1 --- /dev/null +++ b/tests/data/cli/requires_binary_resource_base.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- Follow [the binary](binary.bin). + +***functional specs*** + +- A base functionality. diff --git a/tests/data/cli/requires_binary_resource_top.plain b/tests/data/cli/requires_binary_resource_top.plain new file mode 100644 index 0000000..33ea1ac --- /dev/null +++ b/tests/data/cli/requires_binary_resource_top.plain @@ -0,0 +1,12 @@ +--- +requires: + - requires_binary_resource_base +--- + +***implementation reqs*** + +- Top implementation requirement. + +***functional specs*** + +- A top functionality. diff --git a/tests/data/cli/subdir/nested.plain b/tests/data/cli/subdir/nested.plain new file mode 100644 index 0000000..72f8371 --- /dev/null +++ b/tests/data/cli/subdir/nested.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- Nested implementation requirement. + +***functional specs*** + +- A nested functionality. diff --git a/tests/data/cli/undefined_concept.plain b/tests/data/cli/undefined_concept.plain new file mode 100644 index 0000000..adaa93a --- /dev/null +++ b/tests/data/cli/undefined_concept.plain @@ -0,0 +1,13 @@ +***definitions*** + +- :App: is a console application. + +***implementation reqs*** + +- :App: is written in Python. + +***functional specs*** + +- :App: greets the :User:. + +- :App: says goodbye to the :Visitor:. 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/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/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_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7914cdb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,325 @@ +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(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.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: + 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 + ) + + assert result.returncode == 0 + assert result.stdout.endswith(": OK\n") + assert result.stderr == "" + + +def _run_check(capsys, *argv): + exit_code = cli.main(["check", *argv]) + captured = capsys.readouterr() + 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_ok((exit_code, out, err)) + + +def test_check_valid_module_with_linked_resource_in_acceptance_test(cli_data_dir, capsys, monkeypatch): + monkeypatch.chdir(cli_data_dir) + + exit_code, out, err = _run_check(capsys, "acceptance_test_resource.plain") + + _assert_ok((exit_code, out, err)) + + +def test_check_nonexistent_template_dir_is_ignored(cli_data_dir, capsys): + exit_code, out, err = _run_check( + capsys, os.path.join(cli_data_dir, "valid.plain"), "--template-dir", os.path.join(cli_data_dir, "nope") + ) + + _assert_ok((exit_code, out, err)) + + +def test_check_undefined_concept(cli_data_dir, capsys): + exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "undefined_concept.plain")) + + assert exit_code == 1 + assert out == "" + assert err.startswith("Error: Plain syntax error: Found 2 errors in the plain file:\n") + assert ":User:" in err + assert ":Visitor:" in err + assert "Traceback" not in err + + +def test_check_missing_linked_resource(cli_data_dir, capsys, monkeypatch): + monkeypatch.chdir(cli_data_dir) + + exit_code, out, err = _run_check(capsys, "missing_resource.plain") + + assert exit_code == 1 + assert out == "" + assert err == "Error: Plain syntax error: Link does_not_exist.md does not exist.\n" + + +def test_check_binary_linked_resource(cli_data_dir, capsys, monkeypatch): + monkeypatch.chdir(cli_data_dir) + + exit_code, out, err = _run_check(capsys, "binary_resource.plain") + + assert exit_code == 1 + assert out == "" + assert err.startswith("Error: Referenced resource 'binary.bin' in module 'binary_resource' is a binary file.") + + +def test_check_base64_blob_linked_resource(cli_data_dir, capsys, monkeypatch): + monkeypatch.chdir(cli_data_dir) + + exit_code, out, err = _run_check(capsys, "base64_resource.plain") + + assert exit_code == 1 + assert out == "" + assert err.startswith( + "Error: Referenced resource '../sample_base64_image.txt' in module 'base64_resource' " + "contains a large base64-encoded blob" + ) + + +def test_check_invalid_linked_resource_in_required_module(cli_data_dir, capsys, monkeypatch): + monkeypatch.chdir(cli_data_dir) + + exit_code, out, err = _run_check(capsys, "requires_binary_resource_top.plain") + + assert exit_code == 1 + assert out == "" + assert err.startswith( + "Error: Referenced resource 'binary.bin' in module 'requires_binary_resource_base' is a binary file." + ) + + +def test_check_code_variable_with_two_values(cli_data_dir, capsys): + exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "code_variable_conflict.plain")) + + assert exit_code == 1 + assert out == "" + assert err == "Error: Code variable variable_name has multiple values: first and second\n" + + +def test_check_missing_plain_file(cli_data_dir, capsys): + exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "does_not_exist.plain")) + + assert exit_code == 1 + assert out == "" + assert err == "Error: Module does not exist (does_not_exist).\n" + + +def test_check_wrong_extension(cli_data_dir, capsys): + exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "not_plain.md")) + + 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." + ) + + +@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, interrupted_function, 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 + + +_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_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_ok(_run_check(capsys, str(spec_dir / "top.plain"))) + + +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_ok(_run_check(capsys, str(spec_dir / "top.plain"))) + + +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_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_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): + spec_dir = _project(tmp_path) + (tmp_path / "config.yaml").write_text("template-dir: project/template\n") + monkeypatch.chdir(tmp_path) + + _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): + 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_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") + + 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 + + +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", "") diff --git a/tests/test_requires.py b/tests/test_requires.py index 86bb61b..1a726b8 100644 --- a/tests/test_requires.py +++ b/tests/test_requires.py @@ -26,3 +26,35 @@ 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")]) + + +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")])