From a788ccfd954fb65abad003e97365f31255916039 Mon Sep 17 00:00:00 2001 From: Ana Date: Thu, 8 Oct 2026 13:57:03 +0200 Subject: [PATCH 1/4] refactor: make parse steps callable without the file reads The chain parse, link resolution and FRID walk move from check into parse_spec, which reads the linked files only when load_resources is set. check calls it with the flag on and behaves as before. --- src/plain_parser/cli.py | 30 +++++++++-- .../cli/resource_before_frid_walk/base.plain | 8 +++ .../resource_before_frid_walk/snippet.plain | 1 + .../cli/resource_before_frid_walk/top.plain | 14 +++++ tests/test_cli.py | 54 ++++++++++++++++++- 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 tests/data/cli/resource_before_frid_walk/base.plain create mode 100644 tests/data/cli/resource_before_frid_walk/snippet.plain create mode 100644 tests/data/cli/resource_before_frid_walk/top.plain diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index df4e071..2b7e43d 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -3,6 +3,7 @@ import argparse import os import sys +from dataclasses import dataclass import yaml @@ -71,23 +72,46 @@ def template_dir_from_config(config_file: str) -> str | None: 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.""" +@dataclass +class ParseResult: + chain: list[str] # Module names, deepest ancestor first, entry last. + tree: dict # The entry module's plain source tree. + resources: dict[str, str] # Each link target in the chain, as written, to its absolute path. + + +def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: bool) -> ParseResult: + """Parse the module and its requires chain, resolve its links, and walk its FRIDs; raise on the first error. + + With ``load_resources``, each module's linked files are also read and checked (text only, no large base64 blob) right + after that module's links are collected, so a resource error is reported before a FRID-walk error. + """ 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) + resources: dict[str, str] = {} 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) + if load_resources: + loaders.load_linked_resources(template_dirs, resources_list, module_name) + for resource in resources_list: + target = resource["target"] + resources[target] = os.path.abspath(loaders.resolve_linked_resource(template_dirs, target)) _, 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) + return ParseResult([module_name for module_name, _ in chain], top_plain_source_tree, resources) + + +def check(plain_file_path: str, template_dir: str | None) -> None: + """Raise if the module, its requires chain, or its linked resources are invalid.""" + parse_spec(plain_file_path, template_dir, load_resources=True) + def main(argv: list[str] | None = None) -> int: parser = build_parser() diff --git a/tests/data/cli/resource_before_frid_walk/base.plain b/tests/data/cli/resource_before_frid_walk/base.plain new file mode 100644 index 0000000..97044b1 --- /dev/null +++ b/tests/data/cli/resource_before_frid_walk/base.plain @@ -0,0 +1,8 @@ +***implementation reqs*** + +- :Implementation: is written in Python. +- :Implementation: follows [the data](../binary.bin). + +***functional specs*** + +- :Implementation: has an entry point. diff --git a/tests/data/cli/resource_before_frid_walk/snippet.plain b/tests/data/cli/resource_before_frid_walk/snippet.plain new file mode 100644 index 0000000..2603442 --- /dev/null +++ b/tests/data/cli/resource_before_frid_walk/snippet.plain @@ -0,0 +1 @@ +the variable {{ name | code_variable }} diff --git a/tests/data/cli/resource_before_frid_walk/top.plain b/tests/data/cli/resource_before_frid_walk/top.plain new file mode 100644 index 0000000..4ab7c68 --- /dev/null +++ b/tests/data/cli/resource_before_frid_walk/top.plain @@ -0,0 +1,14 @@ +--- +requires: + - base +--- + +***implementation reqs*** + +- :Implementation: is written in Python. + +***functional specs*** + +- :Implementation: names {% include "snippet.plain", name: "first" %}. + +- :Implementation: names {% include "snippet.plain", name: "second" %}. diff --git a/tests/test_cli.py b/tests/test_cli.py index 67417a8..5b64a17 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,8 @@ import pytest -from plain_parser import cli +from plain_parser import cli, plain_file +from plain_parser.exceptions import PlainSyntaxError, UnsupportedResourceType @pytest.fixture @@ -327,3 +328,54 @@ def test_check_valid_module_prints_ok_line_with_file_as_given(cli_specs_dir, cap exit_code, out, err = _run_check(capsys, "valid.plain") assert (exit_code, out, err) == (0, "valid.plain: OK\n", "") + + +def test_check_reports_ancestor_resource_before_frid_walk_error(cli_specs_dir, capsys): + # base.plain links a binary file; top.plain also has a code-variable conflict only the FRID walk finds. + spec = os.path.join(cli_specs_dir, "resource_before_frid_walk", "top.plain") + + exit_code, out, err = _run_check(capsys, spec) + + assert (exit_code, out) == (1, "") + assert err.startswith("Error: Referenced resource '../binary.bin' in module 'base' is a binary file.") + + +@pytest.fixture +def requires_specs_dir(get_test_data_path): + return get_test_data_path("data/requires") + + +@pytest.mark.parametrize( + "entry, expected_chain", + [ + ("chain_top.plain", ["chain_base", "chain_middle", "chain_top"]), + # chain_fork_top requires chain_base and chain_middle, which itself requires chain_base. + ("chain_fork_top.plain", ["chain_base", "chain_middle", "chain_fork_top"]), + ], +) +def test_parse_spec_chain_is_deepest_ancestor_first(requires_specs_dir, entry, expected_chain): + result = cli.parse_spec(os.path.join(requires_specs_dir, entry), None, load_resources=False) + + assert result.chain == expected_chain + + +def test_parse_spec_tree_is_the_entry_module_tree(requires_specs_dir): + result = cli.parse_spec(os.path.join(requires_specs_dir, "chain_top.plain"), None, load_resources=False) + + _, entry_tree = plain_file.parse_module_chain("chain_top.plain", [requires_specs_dir])[-1] + assert result.tree == entry_tree + + +def test_parse_spec_raises_on_diverging_requires(requires_specs_dir): + with pytest.raises(PlainSyntaxError, match="There must be a fixed order how required modules are dependent"): + cli.parse_spec(os.path.join(requires_specs_dir, "diamond_requires_main.plain"), None, load_resources=False) + + +def test_parse_spec_loads_resources_only_when_asked(cli_specs_dir): + spec = os.path.join(cli_specs_dir, "binary_resource.plain") + + with pytest.raises(UnsupportedResourceType): + cli.parse_spec(spec, None, load_resources=True) + + result = cli.parse_spec(spec, None, load_resources=False) + assert result.resources == {"binary.bin": os.path.join(os.path.abspath(cli_specs_dir), "binary.bin")} From f36569fef75642df8aadac656004cd7e5c6bf13f Mon Sep 17 00:00:00 2001 From: Ana Date: Thu, 8 Oct 2026 13:59:00 +0200 Subject: [PATCH 2/4] feat: add a parse command that prints the parse result as JSON New CLI command, plain-parser parse , with the same options as check. It prints every module of the requires chain with its tree and each link target with its resolved path. Unlike check, it does not read the linked files. A relative --template-dir is now searched, and shown in errors, as an absolute path, as codeplain does. --- src/plain_parser/cli.py | 126 ++++++-- tests/data/cli/parent_link/a/spec/top.plain | 7 + tests/data/cli/parent_link/b/shared/notes.md | 1 + tests/data/cli/parent_link/b/tpl/.gitkeep | 0 tests/data/cli/shared_link_helper.plain | 7 + tests/data/cli/shared_link_top.plain | 12 + tests/data/cli/template_link/spec/top.plain | 7 + tests/data/cli/template_link/tpl/notes.md | 1 + tests/test_cli.py | 322 ++++++++++++++++++- 9 files changed, 447 insertions(+), 36 deletions(-) create mode 100644 tests/data/cli/parent_link/a/spec/top.plain create mode 100644 tests/data/cli/parent_link/b/shared/notes.md create mode 100644 tests/data/cli/parent_link/b/tpl/.gitkeep create mode 100644 tests/data/cli/shared_link_helper.plain create mode 100644 tests/data/cli/shared_link_top.plain create mode 100644 tests/data/cli/template_link/spec/top.plain create mode 100644 tests/data/cli/template_link/tpl/notes.md diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 2b7e43d..fef5cfe 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -1,6 +1,7 @@ -"""Command-line entry point: ``plain-parser check [--template-dir DIR] [--config-name NAME]``.""" +"""Command-line entry point: ``plain-parser {check,parse} [--template-dir DIR] [--config-name NAME]``.""" import argparse +import json import os import sys from dataclasses import dataclass @@ -11,6 +12,7 @@ DEFAULT_CONFIG_NAME = "config.yaml" TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir") +PARSE_FORMAT_VERSION = 1 class AmbiguousConfigFileError(Exception): @@ -21,19 +23,29 @@ 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 = subparsers.add_parser( + "check", + help="Validate a .plain module and its requires chain, and read every linked file: " + "binary files and large base64 blobs are rejected, as codeplain requires.", ) - 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.", + parse_parser = subparsers.add_parser( + "parse", + help="Print the parse result of a .plain module and its requires chain as JSON. Linked files are " + "located but never read.", ) + for subparser in (check_parser, parse_parser): + subparser.add_argument("plain_file", help="Path to the .plain module.") + subparser.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.", + ) + subparser.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 @@ -74,23 +86,26 @@ def template_dir_from_config(config_file: str) -> str | None: @dataclass class ParseResult: - chain: list[str] # Module names, deepest ancestor first, entry last. - tree: dict # The entry module's plain source tree. - resources: dict[str, str] # Each link target in the chain, as written, to its absolute path. + modules: list[ + tuple[str, dict] + ] # (module name, plain source tree) for the chain, deepest ancestor first, entry last. + resources: dict[str, str] # Each link target in the chain, as written, to the absolute path it resolves to. def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: bool) -> ParseResult: - """Parse the module and its requires chain, resolve its links, and walk its FRIDs; raise on the first error. + """Parse the module and its requires chain, resolve its links, and walk the entry's FRIDs; raise on the first error. With ``load_resources``, each module's linked files are also read and checked (text only, no large base64 blob) right after that module's links are collected, so a resource error is reported before a FRID-walk error. """ + # Absolute, so the chain parse and the resource paths below search the same directories. template_dirs = [os.path.dirname(os.path.abspath(plain_file_path))] if template_dir: - template_dirs.append(template_dir) + template_dirs.append(os.path.abspath(template_dir)) chain = plain_file.parse_module_chain(os.path.basename(plain_file_path), template_dirs) + # The target joined as written onto its template dir (`..` kept, symlinks not resolved). resources: dict[str, str] = {} for module_name, plain_source_tree in chain: resources_list: list[dict] = [] @@ -99,13 +114,13 @@ def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: b loaders.load_linked_resources(template_dirs, resources_list, module_name) for resource in resources_list: target = resource["target"] - resources[target] = os.path.abspath(loaders.resolve_linked_resource(template_dirs, target)) + resources[target] = loaders.resolve_linked_resource(template_dirs, target) _, 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) - return ParseResult([module_name for module_name, _ in chain], top_plain_source_tree, resources) + return ParseResult(chain, resources) def check(plain_file_path: str, template_dir: str | None) -> None: @@ -113,6 +128,77 @@ def check(plain_file_path: str, template_dir: str | None) -> None: parse_spec(plain_file_path, template_dir, load_resources=True) +def _error_output(type_name: str, message: str) -> dict: + return {"format_version": PARSE_FORMAT_VERSION, "type": type_name, "message": message} + + +def _escape(text: str) -> str: + """The text with every non-ASCII character as a backslash escape, so it always encodes.""" + return text.encode("ascii", "backslashreplace").decode("ascii") + + +def _encode_output(output: dict) -> bytes: + """The output as UTF-8 JSON; raise if it is not JSON-serialisable or holds text that is not valid UTF-8.""" + return (json.dumps(output, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + +def parse(plain_file_path: str, template_dir: str | None) -> int: + """Print the parse result, or the parser error, as one UTF-8 JSON document on stdout; return the exit code.""" + output: dict + try: + result = parse_spec(plain_file_path, template_dir, load_resources=False) + output = { + "format_version": PARSE_FORMAT_VERSION, + "modules": [{"module": module_name, "tree": tree} for module_name, tree in result.modules], + "resources": result.resources, + } + exit_code = 0 + except Exception as e: + output = _error_output(type(e).__name__, str(e)) + exit_code = 1 + + try: + data = _encode_output(output) + except UnicodeEncodeError as e: + # A file or directory name that is not valid UTF-8, which is allowed on Linux. + if exit_code == 1: + # A parser error whose message names such a file. Keep its type and escape the name. + output = _error_output(output["type"], _escape(output["message"])) + else: + # A valid result that contains such a name. Report the output line that contains it. + line_start = e.object.rfind("\n", 0, e.start) + 1 + line_end = e.object.find("\n", e.end) + line = e.object[line_start : line_end if line_end != -1 else None].strip().rstrip(",") + output = _error_output( + type(e).__name__, f"The parse result holds text that is not valid UTF-8: {_escape(line)}" + ) + data = _encode_output(output) + exit_code = 1 + except Exception as e: + # A value in the result that JSON cannot represent. Report it as a JSON error. + data = _encode_output(_error_output(type(e).__name__, _escape(str(e)))) + exit_code = 1 + + # Write the UTF-8 bytes to stdout's byte stream, because sys.stdout encodes text with the system encoding, which + # may not be UTF-8. + buffer = getattr(sys.stdout, "buffer", None) + if buffer is None: + # A Python caller replaced stdout with a stream that takes only text. + sys.stdout.write(data.decode("utf-8")) + return exit_code + + try: + sys.stdout.flush() # Send out anything already written to sys.stdout first. + buffer.write(data) + buffer.flush() + except BrokenPipeError: + # The reader closed the pipe early. Send stdout to /dev/null, so Python's flush at exit does not fail again, + # and keep the exit code. + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + return exit_code + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) @@ -120,6 +206,8 @@ def main(argv: list[str] | None = None) -> int: 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) + if args.command == "parse": + return parse(args.plain_file, args.template_dir or config_template_dir) check(args.plain_file, args.template_dir or config_template_dir) except KeyboardInterrupt: return 130 diff --git a/tests/data/cli/parent_link/a/spec/top.plain b/tests/data/cli/parent_link/a/spec/top.plain new file mode 100644 index 0000000..7065c83 --- /dev/null +++ b/tests/data/cli/parent_link/a/spec/top.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- A req. + +***functional specs*** + +- A functionality. See [the notes](../shared/notes.md). diff --git a/tests/data/cli/parent_link/b/shared/notes.md b/tests/data/cli/parent_link/b/shared/notes.md new file mode 100644 index 0000000..bfa6551 --- /dev/null +++ b/tests/data/cli/parent_link/b/shared/notes.md @@ -0,0 +1 @@ +notes diff --git a/tests/data/cli/parent_link/b/tpl/.gitkeep b/tests/data/cli/parent_link/b/tpl/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/cli/shared_link_helper.plain b/tests/data/cli/shared_link_helper.plain new file mode 100644 index 0000000..61f18e7 --- /dev/null +++ b/tests/data/cli/shared_link_helper.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- A req. + +***functional specs*** + +- A functionality. See [the notes](notes.md). diff --git a/tests/data/cli/shared_link_top.plain b/tests/data/cli/shared_link_top.plain new file mode 100644 index 0000000..19b565d --- /dev/null +++ b/tests/data/cli/shared_link_top.plain @@ -0,0 +1,12 @@ +--- +requires: + - shared_link_helper +--- + +***implementation reqs*** + +- A req. + +***functional specs*** + +- A functionality. Also [the notes](notes.md). diff --git a/tests/data/cli/template_link/spec/top.plain b/tests/data/cli/template_link/spec/top.plain new file mode 100644 index 0000000..61f18e7 --- /dev/null +++ b/tests/data/cli/template_link/spec/top.plain @@ -0,0 +1,7 @@ +***implementation reqs*** + +- A req. + +***functional specs*** + +- A functionality. See [the notes](notes.md). diff --git a/tests/data/cli/template_link/tpl/notes.md b/tests/data/cli/template_link/tpl/notes.md new file mode 100644 index 0000000..bfa6551 --- /dev/null +++ b/tests/data/cli/template_link/tpl/notes.md @@ -0,0 +1 @@ +notes diff --git a/tests/test_cli.py b/tests/test_cli.py index 5b64a17..f575a89 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,6 @@ +import contextlib +import io +import json import os import shutil import subprocess @@ -6,7 +9,6 @@ import pytest from plain_parser import cli, plain_file -from plain_parser.exceptions import PlainSyntaxError, UnsupportedResourceType @pytest.fixture @@ -116,6 +118,20 @@ def test_check_missing_linked_resource(cli_specs_dir, capsys, monkeypatch, tmp_p ) +def test_check_missing_linked_resource_lists_relative_template_dir_as_absolute( + cli_specs_dir, capsys, monkeypatch, tmp_path +): + monkeypatch.chdir(tmp_path) + (tmp_path / "tpl").mkdir() + + exit_code, _, err = _run_check( + capsys, os.path.join(cli_specs_dir, "missing_resource.plain"), "--template-dir", "tpl" + ) + + assert exit_code == 1 + assert err.endswith(f" 1. {cli_specs_dir}\n 2. {os.path.join(os.getcwd(), 'tpl')}\n") + + def test_check_binary_linked_resource(cli_specs_dir, capsys, monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) @@ -345,6 +361,56 @@ def requires_specs_dir(get_test_data_path): return get_test_data_path("data/requires") +def _run_parse(capsys, *argv): + exit_code = cli.main(["parse", *argv]) + captured = capsys.readouterr() + return exit_code, captured.out, captured.err + + +def _parse_output(out): + # The whole of stdout must be one JSON document followed by one newline. + assert out.endswith("\n") and not out.endswith("\n\n"), repr(out[-20:]) + return json.loads(out) + + +def _module_names(result: dict) -> list[str]: + return [module["module"] for module in result["modules"]] + + +def test_parse_valid_module_prints_success_json(cli_specs_dir, capsys): + exit_code, out, err = _run_parse(capsys, os.path.join(cli_specs_dir, "valid.plain")) + + assert (exit_code, err) == (0, "") + result = _parse_output(out) + assert set(result) == {"format_version", "modules", "resources"} + assert result["format_version"] == 1 + assert result["resources"] == {} + _, tree = plain_file.parse_module_chain("valid.plain", [cli_specs_dir])[-1] + assert result["modules"] == [{"module": "valid", "tree": json.loads(json.dumps(tree))}] + + +def test_parse_invalid_module_prints_error_json(cli_specs_dir, capsys): + spec = os.path.join(cli_specs_dir, "undefined_concept.plain") + + exit_code, out, err = _run_parse(capsys, spec) + + assert (exit_code, err) == (1, "") + result = _parse_output(out) + assert set(result) == {"format_version", "type", "message"} + assert result["format_version"] == 1 + assert result["type"] == "PlainSyntaxError" + _, _, check_err = _run_check(capsys, spec) + assert check_err == f"Error: {result['message']}\n" + + +def test_parse_reports_an_error_only_the_frid_walk_finds(cli_specs_dir, capsys): + # The two values of variable_name are only compared while walking the functional specs. + exit_code, out, _ = _run_parse(capsys, os.path.join(cli_specs_dir, "code_variable_conflict.plain")) + + assert exit_code == 1 + assert _parse_output(out)["message"] == "Code variable variable_name has multiple values: first and second" + + @pytest.mark.parametrize( "entry, expected_chain", [ @@ -353,29 +419,251 @@ def requires_specs_dir(get_test_data_path): ("chain_fork_top.plain", ["chain_base", "chain_middle", "chain_fork_top"]), ], ) -def test_parse_spec_chain_is_deepest_ancestor_first(requires_specs_dir, entry, expected_chain): - result = cli.parse_spec(os.path.join(requires_specs_dir, entry), None, load_resources=False) +def test_parse_prints_every_module_of_the_chain_deepest_ancestor_first( + requires_specs_dir, capsys, entry, expected_chain +): + exit_code, out, _ = _run_parse(capsys, os.path.join(requires_specs_dir, entry)) + + assert exit_code == 0 + chain = plain_file.parse_module_chain(entry, [requires_specs_dir]) + expected_modules = [{"module": name, "tree": json.loads(json.dumps(tree))} for name, tree in chain] + assert [module["module"] for module in expected_modules] == expected_chain + assert _parse_output(out)["modules"] == expected_modules + + +def test_parse_diverging_requires_is_parser_error(requires_specs_dir, capsys): + exit_code, out, _ = _run_parse(capsys, os.path.join(requires_specs_dir, "diamond_requires_main.plain")) + + assert exit_code == 1 + assert _parse_output(out)["type"] == "PlainSyntaxError" + + +def _write_module(path, body="", requires=None): + front = "---\nrequires:\n" + "".join(f" - {name}\n" for name in requires) + "---\n\n" if requires else "" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + front + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality." + body + ) + + +def test_parse_resolves_link_from_template_dir(cli_specs_dir, capsys): + # template_link/spec/top.plain links notes.md, which exists only in template_link/tpl. + template_dir = os.path.join(cli_specs_dir, "template_link", "tpl") + + exit_code, out, _ = _run_parse( + capsys, os.path.join(cli_specs_dir, "template_link", "spec", "top.plain"), "--template-dir", template_dir + ) + + assert exit_code == 0 + assert _parse_output(out)["resources"] == {"notes.md": os.path.join(os.path.abspath(template_dir), "notes.md")} + + +@pytest.mark.parametrize("entry", ["binary_resource.plain", "requires_binary_resource_top.plain"]) +def test_parse_accepts_binary_resource_that_check_rejects(cli_specs_dir, capsys, entry): + spec = os.path.join(cli_specs_dir, entry) + + exit_code, out, _ = _run_parse(capsys, spec) + assert exit_code == 0 + assert _parse_output(out)["resources"] == {"binary.bin": os.path.join(os.path.abspath(cli_specs_dir), "binary.bin")} + + check_exit_code, _, check_err = _run_check(capsys, spec) + assert check_exit_code == 1 + assert "'binary.bin'" in check_err and "is a binary file" in check_err + + +def test_parse_lists_a_target_linked_from_two_modules_once(cli_specs_dir, capsys): + # shared_link_top.plain and the shared_link_helper.plain it requires both link notes.md. + exit_code, out, _ = _run_parse(capsys, os.path.join(cli_specs_dir, "shared_link_top.plain")) + + assert exit_code == 0 + result = _parse_output(out) + assert _module_names(result) == ["shared_link_helper", "shared_link_top"] + assert result["resources"] == {"notes.md": os.path.join(os.path.abspath(cli_specs_dir), "notes.md")} + + +def test_parse_without_file_argument_is_usage_error(capsys): + with pytest.raises(SystemExit) as exc_info: + cli.main(["parse"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert captured.out == "" + assert "usage:" in captured.err + - assert result.chain == expected_chain +def test_parse_config_in_both_locations_is_usage_error(tmp_path, capsys, monkeypatch): + spec_dir = _project(tmp_path, "config.yaml", "template-dir: template\n") + monkeypatch.chdir(tmp_path) + (tmp_path / "config.yaml").write_text("template-dir: template\n") + + with pytest.raises(SystemExit) as exc_info: + cli.main(["parse", str(spec_dir / "top.plain")]) + + assert exc_info.value.code == 2 + assert capsys.readouterr().out == "" + + +def test_parse_writes_utf8_json_whatever_the_stdout_encoding(tmp_path): + _write_module(tmp_path / "unicode.plain", " It greets “Zoë” with ✓.\n") + + result = subprocess.run( + [sys.executable, "-c", "import sys; from plain_parser import cli; sys.exit(cli.main(sys.argv[1:]))"] + + ["parse", str(tmp_path / "unicode.plain")], + capture_output=True, + env={**os.environ, "PYTHONIOENCODING": "ascii"}, + ) + + assert (result.returncode, result.stderr) == (0, b"") + assert "“Zoë” with ✓" in json.dumps(json.loads(result.stdout.decode("utf-8")), ensure_ascii=False) + + +def test_parse_to_a_closed_pipe_keeps_the_exit_code_and_prints_no_error(cli_specs_dir): + read_end, write_end = os.pipe() + os.close(read_end) # Closed before the child starts, so its first write fails with a broken pipe. + try: + result = subprocess.run( + [sys.executable, "-c", "import sys; from plain_parser import cli; sys.exit(cli.main(sys.argv[1:]))"] + + ["parse", os.path.join(cli_specs_dir, "valid.plain")], + stdout=write_end, + stderr=subprocess.PIPE, + ) + finally: + os.close(write_end) + + assert (result.returncode, result.stderr) == (0, b"") -def test_parse_spec_tree_is_the_entry_module_tree(requires_specs_dir): - result = cli.parse_spec(os.path.join(requires_specs_dir, "chain_top.plain"), None, load_resources=False) +def test_parse_resource_path_is_the_absolute_template_dir_joined_with_the_target(cli_specs_dir, capsys, monkeypatch): + # The template dir made absolute against the working directory, the target joined as written. + # parent_link/a/spec/top.plain links ../shared/notes.md, which exists only next to the empty parent_link/b/tpl. + parent_link = os.path.join(cli_specs_dir, "parent_link") + monkeypatch.chdir(parent_link) - _, entry_tree = plain_file.parse_module_chain("chain_top.plain", [requires_specs_dir])[-1] - assert result.tree == entry_tree + exit_code, out, _ = _run_parse(capsys, os.path.join("a", "spec", "top.plain"), "--template-dir", "b/tpl") + assert exit_code == 0 + template_dir = os.path.join(os.path.realpath(parent_link), "b", "tpl") # getcwd() is the real path + assert _parse_output(out)["resources"] == {"../shared/notes.md": os.path.join(template_dir, "../shared/notes.md")} + + +def test_parse_resource_path_keeps_a_symlink_unresolved(tmp_path, capsys): + # The OS follows `lnk` before applying `..`, so removing `..` as text would name x.md in the spec dir. + _write_module(tmp_path / "top.plain", " See [x](lnk/../x.md).\n") + (tmp_path / "sub" / "real").mkdir(parents=True) + (tmp_path / "sub" / "x.md").write_text("read through the symlink\n") + (tmp_path / "x.md").write_text("next to the spec\n") + try: + os.symlink(os.path.join("sub", "real"), tmp_path / "lnk") + except OSError: + pytest.skip("symlinks are not available") + + exit_code, out, _ = _run_parse(capsys, str(tmp_path / "top.plain")) + + assert exit_code == 0 + path = _parse_output(out)["resources"]["lnk/../x.md"] + assert path == os.path.join(str(tmp_path), "lnk/../x.md") + with open(path) as f: + assert f.read() == "read through the symlink\n" -def test_parse_spec_raises_on_diverging_requires(requires_specs_dir): - with pytest.raises(PlainSyntaxError, match="There must be a fixed order how required modules are dependent"): - cli.parse_spec(os.path.join(requires_specs_dir, "diamond_requires_main.plain"), None, load_resources=False) +def test_parse_with_template_dir_from_config_prints_only_json(tmp_path, capsys): + spec_dir = _project(tmp_path, "config.yaml", "template-dir: template\n") -def test_parse_spec_loads_resources_only_when_asked(cli_specs_dir): - spec = os.path.join(cli_specs_dir, "binary_resource.plain") + exit_code, out, err = _run_parse(capsys, str(spec_dir / "top.plain")) - with pytest.raises(UnsupportedResourceType): - cli.parse_spec(spec, None, load_resources=True) + assert (exit_code, err) == (0, "") + assert _module_names(_parse_output(out)) == ["helper", "top"] - result = cli.parse_spec(spec, None, load_resources=False) - assert result.resources == {"binary.bin": os.path.join(os.path.abspath(cli_specs_dir), "binary.bin")} + +@pytest.mark.parametrize("interrupted_function", ["parse_spec", "resolve_config_file"]) +def test_parse_interrupted_exits_130_with_empty_stdout(cli_specs_dir, capsys, monkeypatch, interrupted_function): + def interrupt(*_, **__): + raise KeyboardInterrupt + + monkeypatch.setattr(cli, interrupted_function, interrupt) + + exit_code = cli.main(["parse", os.path.join(cli_specs_dir, "valid.plain")]) + + captured = capsys.readouterr() + assert exit_code == 130 + assert captured.out == "" + assert "Traceback" not in captured.err + + +@pytest.mark.parametrize("entry", ["does_not_exist.plain", "not_plain.md"]) +def test_parse_missing_or_non_plain_entry_is_parser_error(cli_specs_dir, capsys, entry): + exit_code, out, err = _run_parse(capsys, os.path.join(cli_specs_dir, entry)) + + assert (exit_code, err) == (1, "") + assert set(_parse_output(out)) == {"format_version", "type", "message"} + + +def test_parse_path_that_is_not_utf8_is_reported_as_json_error(tmp_path, capsys): + spec_dir = os.path.join(os.fsencode(tmp_path), b"bad\xffname") + try: + os.mkdir(spec_dir) + except OSError: + pytest.skip("the file system rejects names that are not UTF-8") + spec_dir_str = os.fsdecode(spec_dir) + _write_module(tmp_path / spec_dir_str / "top.plain", " See [the notes](notes.md).\n") + (tmp_path / spec_dir_str / "notes.md").write_text("notes\n") + + exit_code, out, err = _run_parse(capsys, os.path.join(spec_dir_str, "top.plain")) + + assert (exit_code, err) == (1, "") + result = _parse_output(out) + assert result["type"] == "UnicodeEncodeError" + assert result["message"].startswith("The parse result holds text that is not valid UTF-8: ") + assert "bad\\udcffname" in result["message"] + + +def test_parse_module_name_that_is_not_utf8_is_quoted_without_trailing_comma(tmp_path, capsys, monkeypatch): + spec_name = os.fsdecode(b"bad\xffname.plain") + try: + _write_module(tmp_path / spec_name) + except OSError: + pytest.skip("the file system rejects names that are not UTF-8") + monkeypatch.chdir(tmp_path) + + exit_code, out, err = _run_parse(capsys, spec_name) + + assert (exit_code, err) == (1, "") + assert _parse_output(out)["message"] == ( + 'The parse result holds text that is not valid UTF-8: "module": "bad\\udcffname"' + ) + + +def test_parse_error_naming_a_path_that_is_not_utf8_keeps_its_type(tmp_path, capsys): + spec_dir = os.path.join(os.fsencode(tmp_path), b"bad\xffname") + try: + os.mkdir(spec_dir) + except OSError: + pytest.skip("the file system rejects names that are not UTF-8") + spec_dir_str = os.fsdecode(spec_dir) + _write_module(tmp_path / spec_dir_str / "top.plain", " See [the notes](missing.md).\n") + + exit_code, out, err = _run_parse(capsys, os.path.join(spec_dir_str, "top.plain")) + + assert (exit_code, err) == (1, "") + result = _parse_output(out) + assert result["type"] == "PlainSyntaxError" + assert "bad\\udcffname" in result["message"] + + +def test_parse_result_that_is_not_json_is_reported_as_json_error(cli_specs_dir, capsys, monkeypatch): + monkeypatch.setattr(cli, "parse_spec", lambda *_, **__: cli.ParseResult([("valid", {"x": object()})], {})) + + exit_code, out, err = _run_parse(capsys, os.path.join(cli_specs_dir, "valid.plain")) + + assert (exit_code, err) == (1, "") + assert _parse_output(out)["type"] == "TypeError" + + +def test_parse_writes_to_a_stdout_without_byte_buffer(cli_specs_dir): + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + exit_code = cli.main(["parse", os.path.join(cli_specs_dir, "valid.plain")]) + + assert exit_code == 0 + assert _module_names(_parse_output(stdout.getvalue())) == ["valid"] From 5b621e3cb89127eadca8885b70579bbae68076a4 Mon Sep 17 00:00:00 2001 From: Ana Date: Thu, 8 Oct 2026 13:59:20 +0200 Subject: [PATCH 3/4] docs: document the check and parse commands in the README --- README.md | 61 +++++++++++++++++++++++++++++++++++------ src/plain_parser/cli.py | 2 +- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d898eb0..e98b8b6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Python parser for [`***plain`](https://www.plainlang.org/docs/) specification files. -Reads a `.plain` module and its `import` / `requires` chain, resolves Liquid templates, +Reads a `.plain` module and its `import` / `requires` chain, resolves Liquid templates, validates concepts and linked resources, and returns the marshalled specification tree plus the ordered list of functionalities (FRIDs). @@ -12,21 +12,66 @@ plus the ordered list of functionalities (FRIDs). pip install plain-parser ``` -## Usage +## Command line -Validate a module and its `requires` chain from the command line (`: OK` and exit `0` when valid; `Error: …` on stderr and exit `1` otherwise): +``` +plain-parser check [--template-dir DIR] [--config-name NAME] +plain-parser parse [--template-dir DIR] [--config-name NAME] +``` -```bash -plain-parser check my_spec.plain [--template-dir DIR] [--config-name NAME] +Both commands parse the module and its `requires` chain, check that every linked resource is an existing +file (not a directory), and walk the module's functionalities. They stop at the first error. + +### `check` + +Validates the module. In addition to the steps above it reads every linked file and rejects binary files and +files holding a large base64 blob. + +``` +$ plain-parser check my_spec.plain +my_spec.plain: OK ``` +Exit `0` when the module is valid. On an error, `Error: …` is written to stderr and the exit code is `1`. + +### `parse` + +Prints the parse result as one UTF-8 JSON document on stdout. Linked files are located but not read. + +``` +$ plain-parser parse my_spec.plain +{ + "format_version": 1, + "modules": [{"module": "...", "tree": {...}}, ...], + "resources": {...} +} +``` + +- `format_version`: `1`. +- `modules`: every module in the `requires` chain, in build order: deepest ancestor first, the module itself last. + Each entry has the module name and its specification tree, in the shape described under *Python* below. +- `resources`: every linked-resource target in the chain, as written in the spec, mapped to the absolute path it + resolves to. Two spellings of one file (`notes.md`, `./notes.md`) are two keys. + +Exit `0` on success. On a parser error the JSON is `{"format_version": 1, "type": "", +"message": "…"}` and the exit code is `1`. + +### Options and exit codes + 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. +`--config-name`), found next to the `.plain` file or in the working directory. Finding it in both is a usage +error. 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: +| Exit code | Meaning | +|---|---| +| `0` | Success | +| `1` | The module, its chain, or a linked resource is invalid | +| `2` | Usage error, including an ambiguous or malformed config file | +| `130` | Interrupted | + +## Python ```python from plain_parser import plain_file_parser diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index fef5cfe..2f76545 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -26,7 +26,7 @@ def build_parser() -> argparse.ArgumentParser: check_parser = subparsers.add_parser( "check", help="Validate a .plain module and its requires chain, and read every linked file: " - "binary files and large base64 blobs are rejected, as codeplain requires.", + "binary files and large base64 blobs are rejected.", ) parse_parser = subparsers.add_parser( "parse", From 7b6c1ab10dee1c64f24926ddffe2ab9830cb4f0f Mon Sep 17 00:00:00 2001 From: Ana Date: Tue, 13 Oct 2026 14:33:58 +0200 Subject: [PATCH 4/4] feat: print resource paths relative to the entry module's directory The `resources` map of `parse` held the absolute path of each linked file, so the same project printed a different result on every machine and, on Windows, a path mixing `\` and `/`. The only information in that path is which lookup directory matched, so the value is now that directory relative to the entry module's directory, with `/` as separator, and the target appended as written. Joining the value onto the entry module's directory names the file the parser matched. Resolution itself is unchanged: `find_resource_dir` is the one search, `resolve_linked_resource` still returns the joined path for the link existence check, and `parse_spec` holds the output contract that the `parse` command serialises. When no relative path exists (another drive on Windows) the value is the absolute path, still with `/`. --- README.md | 8 +++-- src/plain_parser/cli.py | 10 +++++-- src/plain_parser/loaders.py | 31 ++++++++++++++++---- tests/test_cli.py | 58 +++++++++++++++++++++++++++---------- tests/test_loaders.py | 33 ++++++++++++++++++++- 5 files changed, 113 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e98b8b6..38645e4 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,12 @@ $ plain-parser parse my_spec.plain - `format_version`: `1`. - `modules`: every module in the `requires` chain, in build order: deepest ancestor first, the module itself last. Each entry has the module name and its specification tree, in the shape described under *Python* below. -- `resources`: every linked-resource target in the chain, as written in the spec, mapped to the absolute path it - resolves to. Two spellings of one file (`notes.md`, `./notes.md`) are two keys. +- `resources`: every linked-resource target in the chain, as written in the spec, mapped to the path it resolves + to, relative to the entry module's directory, with `/` as separator: `notes.md` when found next to the module, + `../tpl/notes.md` when found in a template directory. The directory part is lexical (symlinks not resolved) and + the target is appended as written (`..` kept), so joining the value onto the entry module's directory names the + file the parser matched. When no relative path exists (another drive on Windows) the value is the absolute path, + still with `/`. Two spellings of one file (`notes.md`, `./notes.md`) are two keys. Exit `0` on success. On a parser error the JSON is `{"format_version": 1, "type": "", "message": "…"}` and the exit code is `1`. diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 2f76545..24a9a48 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -89,7 +89,9 @@ class ParseResult: modules: list[ tuple[str, dict] ] # (module name, plain source tree) for the chain, deepest ancestor first, entry last. - resources: dict[str, str] # Each link target in the chain, as written, to the absolute path it resolves to. + # Each link target in the chain, as written, to the path it resolves to, relative to the entry module's dir + # with `/` as separator (loaders.relative_resource_path). + resources: dict[str, str] def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: bool) -> ParseResult: @@ -105,7 +107,8 @@ def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: b chain = plain_file.parse_module_chain(os.path.basename(plain_file_path), template_dirs) - # The target joined as written onto its template dir (`..` kept, symlinks not resolved). + # The target joined as written onto its template dir (`..` kept, symlinks not resolved), made relative to the + # entry module's dir so the result does not depend on where the project sits on the machine. resources: dict[str, str] = {} for module_name, plain_source_tree in chain: resources_list: list[dict] = [] @@ -114,7 +117,8 @@ def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: b loaders.load_linked_resources(template_dirs, resources_list, module_name) for resource in resources_list: target = resource["target"] - resources[target] = loaders.resolve_linked_resource(template_dirs, target) + resource_dir = loaders.find_resource_dir(template_dirs, target) + resources[target] = loaders.relative_resource_path(template_dirs[0], resource_dir, target) _, top_plain_source_tree = chain[-1] for frid in plain_spec.get_frids(top_plain_source_tree): diff --git a/src/plain_parser/loaders.py b/src/plain_parser/loaders.py index 138e4c2..47c7d43 100644 --- a/src/plain_parser/loaders.py +++ b/src/plain_parser/loaders.py @@ -36,20 +36,41 @@ def open_from(dirs, file_name): return None -def resolve_linked_resource(dirs, file_name): - """Return the first existing path for file_name across dirs, or None if not found. +def find_resource_dir(dirs, file_name): + """Return the first dir in which file_name exists, or None if not found. Mirrors the search order used by open_from so that validating a linked resource and loading it can never disagree. """ for dir in dirs: - full_file_name = os.path.join(dir, file_name) - if os.path.exists(full_file_name): - return full_file_name + if os.path.exists(os.path.join(dir, file_name)): + return dir return None +def resolve_linked_resource(dirs, file_name): + """Return the first existing path for file_name across dirs, or None if not found.""" + resource_dir = find_resource_dir(dirs, file_name) + return None if resource_dir is None else os.path.join(resource_dir, file_name) + + +def relative_resource_path(base_dir: str, resource_dir: str, target: str) -> str: + """The path of target in resource_dir, relative to base_dir, with `/` as separator. + + The dir part is computed lexically (symlinks not resolved) and the target is appended as written (`..` kept), + so joining the result onto base_dir names the same file as os.path.join(resource_dir, target). When no relative + path exists (another drive on Windows), the absolute path is returned instead, still with `/`. + """ + try: + rel_dir = os.path.relpath(resource_dir, base_dir) + except ValueError: + return os.path.join(resource_dir, target).replace(os.sep, "/") + if rel_dir == os.curdir: + return target + return rel_dir.replace(os.sep, "/") + "/" + target + + def load_linked_resources(template_dirs: list[str], resources_list, module_name: str): linked_resources = {} diff --git a/tests/test_cli.py b/tests/test_cli.py index f575a89..fd76d29 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,7 +8,7 @@ import pytest -from plain_parser import cli, plain_file +from plain_parser import cli, loaders, plain_file @pytest.fixture @@ -455,7 +455,7 @@ def test_parse_resolves_link_from_template_dir(cli_specs_dir, capsys): ) assert exit_code == 0 - assert _parse_output(out)["resources"] == {"notes.md": os.path.join(os.path.abspath(template_dir), "notes.md")} + assert _parse_output(out)["resources"] == {"notes.md": "../tpl/notes.md"} @pytest.mark.parametrize("entry", ["binary_resource.plain", "requires_binary_resource_top.plain"]) @@ -464,7 +464,7 @@ def test_parse_accepts_binary_resource_that_check_rejects(cli_specs_dir, capsys, exit_code, out, _ = _run_parse(capsys, spec) assert exit_code == 0 - assert _parse_output(out)["resources"] == {"binary.bin": os.path.join(os.path.abspath(cli_specs_dir), "binary.bin")} + assert _parse_output(out)["resources"] == {"binary.bin": "binary.bin"} check_exit_code, _, check_err = _run_check(capsys, spec) assert check_exit_code == 1 @@ -478,7 +478,7 @@ def test_parse_lists_a_target_linked_from_two_modules_once(cli_specs_dir, capsys assert exit_code == 0 result = _parse_output(out) assert _module_names(result) == ["shared_link_helper", "shared_link_top"] - assert result["resources"] == {"notes.md": os.path.join(os.path.abspath(cli_specs_dir), "notes.md")} + assert result["resources"] == {"notes.md": "notes.md"} def test_parse_without_file_argument_is_usage_error(capsys): @@ -533,8 +533,10 @@ def test_parse_to_a_closed_pipe_keeps_the_exit_code_and_prints_no_error(cli_spec assert (result.returncode, result.stderr) == (0, b"") -def test_parse_resource_path_is_the_absolute_template_dir_joined_with_the_target(cli_specs_dir, capsys, monkeypatch): - # The template dir made absolute against the working directory, the target joined as written. +def test_parse_resource_path_is_the_template_dir_relative_to_the_entry_dir_joined_with_the_target( + cli_specs_dir, capsys, monkeypatch +): + # The template dir made relative to the entry module's dir, the target joined as written. # parent_link/a/spec/top.plain links ../shared/notes.md, which exists only next to the empty parent_link/b/tpl. parent_link = os.path.join(cli_specs_dir, "parent_link") monkeypatch.chdir(parent_link) @@ -542,8 +544,7 @@ def test_parse_resource_path_is_the_absolute_template_dir_joined_with_the_target exit_code, out, _ = _run_parse(capsys, os.path.join("a", "spec", "top.plain"), "--template-dir", "b/tpl") assert exit_code == 0 - template_dir = os.path.join(os.path.realpath(parent_link), "b", "tpl") # getcwd() is the real path - assert _parse_output(out)["resources"] == {"../shared/notes.md": os.path.join(template_dir, "../shared/notes.md")} + assert _parse_output(out)["resources"] == {"../shared/notes.md": "../../b/tpl/../shared/notes.md"} def test_parse_resource_path_keeps_a_symlink_unresolved(tmp_path, capsys): @@ -561,11 +562,38 @@ def test_parse_resource_path_keeps_a_symlink_unresolved(tmp_path, capsys): assert exit_code == 0 path = _parse_output(out)["resources"]["lnk/../x.md"] - assert path == os.path.join(str(tmp_path), "lnk/../x.md") - with open(path) as f: + assert path == "lnk/../x.md" + with open(os.path.join(str(tmp_path), path)) as f: assert f.read() == "read through the symlink\n" +def test_parse_resource_path_falls_back_to_absolute_when_no_relative_path_exists(cli_specs_dir, capsys, monkeypatch): + # On Windows a template dir on another drive has no relative path; os.path.relpath raises ValueError. + template_dir = os.path.join(cli_specs_dir, "template_link", "tpl") + monkeypatch.setattr(os.path, "relpath", lambda *args: (_ for _ in ()).throw(ValueError("different drives"))) + + exit_code, out, _ = _run_parse( + capsys, os.path.join(cli_specs_dir, "template_link", "spec", "top.plain"), "--template-dir", template_dir + ) + + assert exit_code == 0 + expected = os.path.join(os.path.abspath(template_dir), "notes.md").replace(os.sep, "/") + assert _parse_output(out)["resources"] == {"notes.md": expected} + + +def test_parse_spec_resource_path_joined_onto_the_entry_dir_names_the_resolved_file(cli_specs_dir): + # The library result and the command print the same value: parse_spec is the contract, parse serialises it. + entry = os.path.join(cli_specs_dir, "template_link", "spec", "top.plain") + template_dir = os.path.join(cli_specs_dir, "template_link", "tpl") + + result = cli.parse_spec(entry, template_dir, load_resources=False) + + value = result.resources["notes.md"] + assert value == "../tpl/notes.md" + joined = os.path.join(os.path.dirname(os.path.abspath(entry)), value) + assert os.path.samefile(joined, loaders.resolve_linked_resource([os.path.abspath(template_dir)], "notes.md")) + + def test_parse_with_template_dir_from_config_prints_only_json(tmp_path, capsys): spec_dir = _project(tmp_path, "config.yaml", "template-dir: template\n") @@ -598,7 +626,8 @@ def test_parse_missing_or_non_plain_entry_is_parser_error(cli_specs_dir, capsys, assert set(_parse_output(out)) == {"format_version", "type", "message"} -def test_parse_path_that_is_not_utf8_is_reported_as_json_error(tmp_path, capsys): +def test_parse_dir_name_that_is_not_utf8_stays_out_of_a_valid_result(tmp_path, capsys): + # Resource paths are relative to the entry module's dir, so the dir's own name never reaches the output. spec_dir = os.path.join(os.fsencode(tmp_path), b"bad\xffname") try: os.mkdir(spec_dir) @@ -610,11 +639,8 @@ def test_parse_path_that_is_not_utf8_is_reported_as_json_error(tmp_path, capsys) exit_code, out, err = _run_parse(capsys, os.path.join(spec_dir_str, "top.plain")) - assert (exit_code, err) == (1, "") - result = _parse_output(out) - assert result["type"] == "UnicodeEncodeError" - assert result["message"].startswith("The parse result holds text that is not valid UTF-8: ") - assert "bad\\udcffname" in result["message"] + assert (exit_code, err) == (0, "") + assert _parse_output(out)["resources"] == {"notes.md": "notes.md"} def test_parse_module_name_that_is_not_utf8_is_quoted_without_trailing_comma(tmp_path, capsys, monkeypatch): diff --git a/tests/test_loaders.py b/tests/test_loaders.py index f66d5bf..9ebe6eb 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -5,7 +5,13 @@ import pytest from plain_parser.exceptions import UnsupportedBase64Content, UnsupportedResourceType -from plain_parser.loaders import MAX_BASE64_BLOB_LENGTH, load_linked_resources, resolve_linked_resource +from plain_parser.loaders import ( + MAX_BASE64_BLOB_LENGTH, + find_resource_dir, + load_linked_resources, + relative_resource_path, + resolve_linked_resource, +) @pytest.fixture @@ -182,3 +188,28 @@ def test_liquid_error_message_without_position(): error = LiquidSyntaxError("missing expression", token=None) assert liquid_error_message(error) == "Plain syntax error: Invalid Liquid: missing expression" + + +def test_find_resource_dir_returns_the_dir_not_the_joined_path(): + with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second: + with open(os.path.join(second, "notes.md"), "w") as f: + f.write("x") + + assert find_resource_dir([first, second], "notes.md") == second + assert find_resource_dir([first], "notes.md") is None + + +def test_relative_resource_path_in_the_base_dir_is_the_target(): + assert relative_resource_path("/p/spec", "/p/spec", "notes.md") == "notes.md" + assert relative_resource_path("/p/spec", "/p/spec", "../shared.md") == "../shared.md" + + +def test_relative_resource_path_prefixes_the_dir_lexically_and_keeps_the_target(): + assert relative_resource_path("/p/app/python", "/p/tpl", "notes.md") == "../../tpl/notes.md" + assert relative_resource_path("/p/app/python", "/p/tpl", "../sub.md") == "../../tpl/../sub.md" + assert relative_resource_path("/p/spec", "/p/spec/tpl/sub", "deep.md") == "tpl/sub/deep.md" + + +def test_relative_resource_path_falls_back_to_absolute_with_forward_slashes(monkeypatch): + monkeypatch.setattr(os.path, "relpath", lambda *args: (_ for _ in ()).throw(ValueError("different drives"))) + assert relative_resource_path("/p/spec", "/q/tpl", "notes.md") == "/q/tpl/notes.md"