diff --git a/README.md b/README.md index d898eb0..38645e4 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,70 @@ 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 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`. + +### 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 df4e071..24a9a48 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -1,8 +1,10 @@ -"""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 import yaml @@ -10,6 +12,7 @@ DEFAULT_CONFIG_NAME = "config.yaml" TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir") +PARSE_FORMAT_VERSION = 1 class AmbiguousConfigFileError(Exception): @@ -20,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.", ) - 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 @@ -71,23 +84,124 @@ 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: + modules: list[ + tuple[str, dict] + ] # (module name, plain source tree) for the chain, deepest ancestor first, entry last. + # 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: + """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), 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] = [] 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"] + 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): plain_spec.get_specifications_for_frid(top_plain_source_tree, frid) + return ParseResult(chain, 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 _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() @@ -96,6 +210,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/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/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/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/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 67417a8..fd76d29 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 @@ -5,7 +8,7 @@ import pytest -from plain_parser import cli +from plain_parser import cli, loaders, plain_file @pytest.fixture @@ -115,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) @@ -327,3 +344,352 @@ 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") + + +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", + [ + ("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_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": "../tpl/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": "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": "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 + + +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_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) + + exit_code, out, _ = _run_parse(capsys, os.path.join("a", "spec", "top.plain"), "--template-dir", "b/tpl") + + assert exit_code == 0 + 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): + # 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 == "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") + + exit_code, out, err = _run_parse(capsys, str(spec_dir / "top.plain")) + + assert (exit_code, err) == (0, "") + assert _module_names(_parse_output(out)) == ["helper", "top"] + + +@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_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) + 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) == (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): + 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"] 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"