From c88e273fa0ce2c976f86ffb0e94e0f5670eede5c Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 21 Apr 2026 11:09:51 +0200 Subject: [PATCH 01/10] Add path_resolution helper for CLI/config/default path sources Introduces resolve_path(value, source, cwd, config_dir, spec_dir) and unit tests. The helper encodes the rule that a relative path's resolution base is determined by where the value was written (shell, config file, or built-in default), not by which option key it sets. Not wired into argument parsing yet. Co-Authored-By: Claude Opus 4.7 --- path_resolution.py | 64 +++++++++++++++++++++++++++++++++ tests/test_path_resolution.py | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 path_resolution.py create mode 100644 tests/test_path_resolution.py diff --git a/path_resolution.py b/path_resolution.py new file mode 100644 index 0000000..bdc5644 --- /dev/null +++ b/path_resolution.py @@ -0,0 +1,64 @@ +"""Path resolution for CLI / config / default path arguments. + +The rule, in one sentence: the resolution base for a relative path is determined +by *where the value was written*, not by which option key it sets. + +- Values supplied on the command line resolve against the current working + directory (so they match what shell tab completion just produced). +- Values read from ``config.yaml`` resolve against the directory containing + that config file. +- Values left at their default (not supplied anywhere) resolve against the + directory containing the spec file. + +Paths are expanded for ``~`` and returned as absolute paths. Canonicalization +(symlink resolution) is left to the caller, to be done just before I/O that +needs it (writing output, executing scripts) so we don't accidentally +dereference links the user wanted preserved. +""" + +import os +from typing import Literal, Optional + +PathSource = Literal["cli", "config", "default"] + + +def resolve_path( + value: str, + source: PathSource, + *, + cwd: str, + config_dir: Optional[str] = None, + spec_dir: str, +) -> str: + """Resolve *value* to an absolute path using the base anchor for *source*. + + Args: + value: The path string as written by the user. May be absolute, relative, + or contain a leading ``~``. + source: Where the value came from -- ``"cli"``, ``"config"``, or + ``"default"``. + cwd: Base directory for ``"cli"`` values. + config_dir: Base directory for ``"config"`` values. Must be provided + whenever ``source == "config"``. + spec_dir: Base directory for ``"default"`` values. + + Returns: + Absolute path with ``~`` expanded. Symlinks are not resolved. + """ + expanded = os.path.expanduser(value) + + if os.path.isabs(expanded): + return os.path.normpath(expanded) + + if source == "cli": + base = cwd + elif source == "config": + if config_dir is None: + raise ValueError("config_dir must be provided when source == 'config'") + base = config_dir + elif source == "default": + base = spec_dir + else: + raise ValueError(f"Unknown path source: {source!r}") + + return os.path.normpath(os.path.join(base, expanded)) diff --git a/tests/test_path_resolution.py b/tests/test_path_resolution.py new file mode 100644 index 0000000..6849e81 --- /dev/null +++ b/tests/test_path_resolution.py @@ -0,0 +1,66 @@ +import os + +import pytest + +from path_resolution import resolve_path + + +CWD = "/work/cwd" +CONFIG_DIR = "/work/project/config" +SPEC_DIR = "/work/project/spec" + + +def test_cli_relative_resolves_against_cwd(): + result = resolve_path("out", "cli", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) + assert result == os.path.normpath("/work/cwd/out") + + +def test_config_relative_resolves_against_config_dir(): + result = resolve_path("out", "config", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) + assert result == os.path.normpath("/work/project/config/out") + + +def test_default_relative_resolves_against_spec_dir(): + result = resolve_path("out", "default", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) + assert result == os.path.normpath("/work/project/spec/out") + + +def test_absolute_path_is_returned_unchanged_for_all_sources(): + for source in ("cli", "config", "default"): + result = resolve_path("/abs/path", source, cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) + assert result == os.path.normpath("/abs/path") + + +def test_dotdot_segments_are_normalized(): + result = resolve_path("../sibling", "cli", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) + assert result == os.path.normpath("/work/sibling") + + +def test_leading_tilde_is_expanded(monkeypatch): + monkeypatch.setenv("HOME", "/home/alice") + result = resolve_path("~/scripts/run.sh", "cli", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) + assert result == os.path.normpath("/home/alice/scripts/run.sh") + + +def test_tilde_expansion_applies_regardless_of_source(monkeypatch): + monkeypatch.setenv("HOME", "/home/alice") + for source in ("cli", "config", "default"): + result = resolve_path("~/x", source, cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) + assert result == os.path.normpath("/home/alice/x") + + +def test_config_source_requires_config_dir(): + with pytest.raises(ValueError, match="config_dir"): + resolve_path("out", "config", cwd=CWD, config_dir=None, spec_dir=SPEC_DIR) + + +def test_config_dir_may_be_none_for_cli_and_default(): + cli_result = resolve_path("out", "cli", cwd=CWD, config_dir=None, spec_dir=SPEC_DIR) + default_result = resolve_path("out", "default", cwd=CWD, config_dir=None, spec_dir=SPEC_DIR) + assert cli_result == os.path.normpath("/work/cwd/out") + assert default_result == os.path.normpath("/work/project/spec/out") + + +def test_unknown_source_raises(): + with pytest.raises(ValueError, match="Unknown path source"): + resolve_path("out", "bogus", cwd=CWD, config_dir=CONFIG_DIR, spec_dir=SPEC_DIR) # type: ignore[arg-type] From 5cf13fa9103da0ea4ae934e08de6448208eeb6ce Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 21 Apr 2026 11:23:18 +0200 Subject: [PATCH 02/10] Track CLI/config/default provenance on parsed arguments Records where each argument value came from in args.argument_sources, so downstream path resolution can anchor relative paths against the right base (CWD for CLI values, config file's directory for config values, spec file's directory for defaults). Replaces the previous default-equality heuristic with a precise set built from a SUPPRESS defaults pass of the parser. No path-resolution behavior change yet. Co-Authored-By: Claude Opus 4.7 --- plain2code_arguments.py | 71 ++++++++++++++++++----------- tests/test_arg_provenance.py | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 25 deletions(-) create mode 100644 tests/test_arg_provenance.py diff --git a/plain2code_arguments.py b/plain2code_arguments.py index ec8a308..1c6d20e 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -1,11 +1,16 @@ import argparse import os -from typing import Any +from typing import Any, Optional, Sequence from plain2code_console import console from plain2code_exceptions import AmbiguousConfigFileError from plain2code_read_config import get_args_from_config +# Attribute on the parsed Namespace mapping each argument dest to its source: +# "cli" (explicit on the command line), "config" (from config.yaml), or +# "default" (neither -- the argparse default was used). +ARGUMENT_SOURCES = "argument_sources" + CODEPLAIN_API_KEY = os.getenv("CODEPLAIN_API_KEY") @@ -119,45 +124,60 @@ def resolve_config_file(config_name: str, plain_file_path: str): return None -def update_args_with_config(args, parser): +def _detect_cli_provided_keys(command_line: Optional[Sequence[str]] = None) -> set[str]: + """Return the set of argument dests that were explicitly provided on the command line. + + Uses a second parser with every default replaced by ``argparse.SUPPRESS``, so + any dest that ends up on the resulting namespace must have come from the + command line. + """ + tracker = create_parser() + for action in tracker._actions: + action.default = argparse.SUPPRESS + tracked_ns, _ = tracker.parse_known_args(command_line) + return set(vars(tracked_ns).keys()) + + +def update_args_with_config(args, parser, cli_provided: set[str]): + """Merge config.yaml values into ``args`` and record the source of each value. + + CLI-supplied values always win. Anything the CLI did not supply is taken + from config.yaml if present, else left at its argparse default. The mapping + from dest to source ("cli" / "config" / "default") is attached to ``args`` + as ``arg_sources`` so downstream code can resolve paths against the right + base directory. + """ + action_dests = {action.dest for action in parser._actions} + sources: dict[str, str] = {dest: ("cli" if dest in cli_provided else "default") for dest in action_dests} + try: resolved_config = resolve_config_file(args.config_name, args.filename) if resolved_config is None: console.info(f"No config file '{args.config_name}' found. Proceeding without one.") + setattr(args, ARGUMENT_SOURCES, sources) return args args.config_name = resolved_config config_args = get_args_from_config(resolved_config, parser) - # Get all action types from the parser - action_types = {action.dest: action for action in parser._actions} - - # Update args with config values, but command line args take precedence for key, value in vars(config_args).items(): - # Skip if the argument was provided on command line - if key in vars(args): - arg_action = action_types.get(key) - if arg_action and isinstance(arg_action, argparse._StoreAction): - # For regular arguments, only skip if explicitly provided - if getattr(args, key) is not None and (arg_action.default is None or value == arg_action.default): - continue - elif arg_action and isinstance(arg_action, argparse._StoreTrueAction): - # For boolean flags, skip if True (explicitly set) - if getattr(args, key): - continue - - # Set the value from config - if key in action_types: - setattr(args, key, value) - else: + if key not in action_dests: parser.error(f"Invalid argument: {key}") + # CLI takes precedence over config. + if key in cli_provided: + continue + + setattr(args, key, value) + sources[key] = "config" + except AmbiguousConfigFileError as e: parser.error(str(e)) except Exception as e: parser.error(f"Error reading config file: {str(e)}") + setattr(args, ARGUMENT_SOURCES, sources) return args @@ -349,11 +369,12 @@ def create_parser(): return parser -def parse_arguments(): +def parse_arguments(command_line: Optional[Sequence[str]] = None): parser = create_parser() - args = parser.parse_args() - args = update_args_with_config(args, parser) + args = parser.parse_args(command_line) + cli_provided = _detect_cli_provided_keys(command_line) + args = update_args_with_config(args, parser, cli_provided) if args.build_folder == args.build_dest: parser.error("--build-folder and --build-dest cannot be the same") diff --git a/tests/test_arg_provenance.py b/tests/test_arg_provenance.py new file mode 100644 index 0000000..08cdd86 --- /dev/null +++ b/tests/test_arg_provenance.py @@ -0,0 +1,86 @@ +"""Tests for argument-source provenance tracking in plain2code_arguments.""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from plain2code_arguments import ARGUMENT_SOURCES, parse_arguments + + +@pytest.fixture +def project(): + """Temporary directory with a plain file. CWD is patched to the same directory.""" + with tempfile.TemporaryDirectory() as d: + (Path(d) / "module.plain").write_text("") + with patch("os.getcwd", return_value=d): + yield d + + +def _sources(args): + return getattr(args, ARGUMENT_SOURCES) + + +def test_default_when_neither_cli_nor_config(project): + args = parse_arguments([os.path.join(project, "module.plain")]) + assert _sources(args)["build_folder"] == "default" + assert _sources(args)["conformance_tests_folder"] == "default" + + +def test_cli_value_is_marked_as_cli(project): + args = parse_arguments([os.path.join(project, "module.plain"), "--build-folder", "out"]) + assert _sources(args)["build_folder"] == "cli" + assert args.build_folder == "out" + + +def test_cli_value_equal_to_default_is_still_cli(project): + """Passing the literal default on the CLI is still an explicit CLI choice.""" + args = parse_arguments([os.path.join(project, "module.plain"), "--build-folder", "plain_modules"]) + assert _sources(args)["build_folder"] == "cli" + + +def test_config_value_is_marked_as_config(project): + (Path(project) / "config.yaml").write_text("build-folder: from_config\n") + args = parse_arguments([os.path.join(project, "module.plain")]) + assert _sources(args)["build_folder"] == "config" + assert args.build_folder == "from_config" + + +def test_cli_wins_over_config(project): + (Path(project) / "config.yaml").write_text("build-folder: from_config\n") + args = parse_arguments([os.path.join(project, "module.plain"), "--build-folder", "from_cli"]) + assert _sources(args)["build_folder"] == "cli" + assert args.build_folder == "from_cli" + + +def test_boolean_flag_provenance(project): + args = parse_arguments([os.path.join(project, "module.plain"), "--verbose"]) + assert _sources(args)["verbose"] == "cli" + assert args.verbose is True + + +def test_boolean_flag_default(project): + args = parse_arguments([os.path.join(project, "module.plain")]) + assert _sources(args)["verbose"] == "default" + assert args.verbose is False + + +def test_boolean_flag_from_config(project): + (Path(project) / "config.yaml").write_text("verbose: true\n") + args = parse_arguments([os.path.join(project, "module.plain")]) + assert _sources(args)["verbose"] == "config" + assert args.verbose is True + + +def test_mixed_sources(project): + """CLI, config, and default values coexist on the same invocation.""" + (Path(project) / "config.yaml").write_text("build-folder: from_config\n") + args = parse_arguments( + [os.path.join(project, "module.plain"), "--conformance-tests-folder", "ct_from_cli"] + ) + srcs = _sources(args) + assert srcs["conformance_tests_folder"] == "cli" + assert srcs["build_folder"] == "config" + assert srcs["build_dest"] == "default" From 1273a500eaf0ee8003719d4c2dae47dd8a31589c Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 21 Apr 2026 12:01:09 +0200 Subject: [PATCH 03/10] Resolve script paths by source, drop renderer-dir fallback --unittests-script, --conformance-tests-script, and --prepare-environment-script now resolve CLI-supplied values against CWD and config.yaml values against the config file's directory. Absolute paths are preserved. The previous renderer-directory fallback -- a third anchor invisible to users -- is removed. Co-Authored-By: Claude Opus 4.7 --- plain2code_arguments.py | 62 ++++++++------- tests/test_script_path_resolution.py | 111 +++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 tests/test_script_path_resolution.py diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 1c6d20e..22e7e33 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -2,6 +2,7 @@ import os from typing import Any, Optional, Sequence +from path_resolution import resolve_path from plain2code_console import console from plain2code_exceptions import AmbiguousConfigFileError from plain2code_read_config import get_args_from_config @@ -25,36 +26,33 @@ PREPARE_ENVIRONMENT_SCRIPT_NAME = "prepare_environment_script" -def process_test_script_path(script_arg_name, config): - """Resolve script paths in config.""" - config_file = config.config_name - script_input_path = getattr(config, script_arg_name, None) - if script_input_path is None: - return config - - # Check if the script path is absolute and keep the same path - if isinstance(script_input_path, str) and script_input_path.startswith("/"): - if not os.path.exists(script_input_path): - raise FileNotFoundError( - f"Path for {script_arg_name} not found: {script_input_path}. Set it to the absolute path or relative to the config file." - ) - return config - - # Otherwise the script path is relative - # First look for it in the config file directory, then the renderer directory - config_dir = os.path.dirname(os.path.abspath(config_file)) - config_relative_path = os.path.join(config_dir, script_input_path) - renderer_dir = os.path.dirname(os.path.abspath(__file__)) - renderer_relative_path = os.path.join(renderer_dir, script_input_path) - if os.path.exists(config_relative_path): - setattr(config, script_arg_name, config_relative_path) - elif os.path.exists(renderer_relative_path): - setattr(config, script_arg_name, renderer_relative_path) - else: +def _resolve_script_path( + script_arg_name: str, + args, + cwd: str, + config_dir: Optional[str], + spec_dir: str, +) -> None: + """Resolve a script-path argument on ``args`` in-place using its recorded source. + + Does nothing if the argument is unset. Raises ``FileNotFoundError`` if the + resolved path does not exist. + """ + original_value = getattr(args, script_arg_name, None) + if original_value is None: + return + + source = getattr(args, ARGUMENT_SOURCES, {}).get(script_arg_name, "default") + resolved = resolve_path( + original_value, source, cwd=cwd, config_dir=config_dir, spec_dir=spec_dir + ) + + if not os.path.exists(resolved): raise FileNotFoundError( - f"Path for {script_arg_name} not found: {script_input_path}. Set it to the absolute path or relative to the config file." + f"Path for {script_arg_name} not found: {original_value} (resolved to {resolved})." ) - return config + + setattr(args, script_arg_name, resolved) def non_empty_string(s): @@ -392,8 +390,14 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): if args.full_plain and args.dry_run: parser.error("--full-plain and --dry-run are mutually exclusive") + cwd = os.getcwd() + spec_dir = os.path.dirname(os.path.abspath(args.filename)) + # args.config_name is the resolved absolute path when a config file was found, + # otherwise it is still just the lookup name (e.g. "config.yaml"). + config_dir = os.path.dirname(args.config_name) if os.path.isabs(args.config_name) else None + script_arg_names = [UNIT_TESTS_SCRIPT_NAME, CONFORMANCE_TESTS_SCRIPT_NAME, PREPARE_ENVIRONMENT_SCRIPT_NAME] for script_name in script_arg_names: - args = process_test_script_path(script_name, args) + _resolve_script_path(script_name, args, cwd, config_dir, spec_dir) return args diff --git a/tests/test_script_path_resolution.py b/tests/test_script_path_resolution.py new file mode 100644 index 0000000..257012d --- /dev/null +++ b/tests/test_script_path_resolution.py @@ -0,0 +1,111 @@ +"""Tests for script-path argument resolution. + +Covers the rule that the resolution base for a relative script path is +determined by where the value was written -- CWD for CLI, config-file +directory for config.yaml values. +""" + +import os +import stat +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from plain2code_arguments import parse_arguments + + +def _make_script(path: Path) -> None: + path.write_text("#!/bin/sh\necho hello\n") + path.chmod(path.stat().st_mode | stat.S_IEXEC) + + +@pytest.fixture +def layout(): + """Project layout with separate dirs for spec, config, and CWD, each with a script.""" + with tempfile.TemporaryDirectory() as root: + spec_dir = Path(root) / "spec_dir" + config_dir = Path(root) / "config_dir" + cwd = Path(root) / "cwd" + spec_dir.mkdir() + config_dir.mkdir() + cwd.mkdir() + + (spec_dir / "module.plain").write_text("") + _make_script(spec_dir / "script_in_spec.sh") + _make_script(config_dir / "script_in_config.sh") + _make_script(cwd / "script_in_cwd.sh") + + yield { + "spec": spec_dir, + "config": config_dir, + "cwd": cwd, + "plain_file": str(spec_dir / "module.plain"), + } + + +def test_cli_script_path_resolves_against_cwd(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments( + [layout["plain_file"], "--unittests-script", "script_in_cwd.sh"] + ) + assert args.unittests_script == str(layout["cwd"] / "script_in_cwd.sh") + + +def test_config_script_path_resolves_against_config_dir(layout): + (layout["config"] / "config.yaml").write_text("unittests-script: script_in_config.sh\n") + + with patch("os.getcwd", return_value=str(layout["config"])): + args = parse_arguments([layout["plain_file"]]) + + assert args.unittests_script == str(layout["config"] / "script_in_config.sh") + + +def test_cli_script_path_does_not_fall_back_to_config_dir(layout): + """A CLI value that happens to match a file in the config dir must NOT resolve there.""" + (layout["config"] / "config.yaml").write_text("verbose: true\n") + + with patch("os.getcwd", return_value=str(layout["cwd"])): + with pytest.raises(FileNotFoundError, match="unittests_script"): + # script_in_config.sh does not exist relative to CWD, so resolution must fail + parse_arguments([layout["plain_file"], "--unittests-script", "script_in_config.sh"]) + + +def test_cli_script_with_dotdot_resolves_against_cwd(layout): + """Tab-completion-style relative path like ../config_dir/script.sh works from CWD.""" + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments( + [layout["plain_file"], "--unittests-script", "../config_dir/script_in_config.sh"] + ) + assert args.unittests_script == str(layout["config"] / "script_in_config.sh") + + +def test_absolute_script_path_preserved(layout): + abs_path = str(layout["spec"] / "script_in_spec.sh") + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--unittests-script", abs_path]) + assert args.unittests_script == abs_path + + +def test_missing_script_raises(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + with pytest.raises(FileNotFoundError): + parse_arguments([layout["plain_file"], "--unittests-script", "nope.sh"]) + + +def test_all_three_script_args_resolved(layout): + """unittests-script, conformance-tests-script, prepare-environment-script all go through the same path.""" + (layout["config"] / "config.yaml").write_text( + "unittests-script: script_in_config.sh\n" + "conformance-tests-script: script_in_config.sh\n" + "prepare-environment-script: script_in_config.sh\n" + ) + + with patch("os.getcwd", return_value=str(layout["config"])): + args = parse_arguments([layout["plain_file"]]) + + expected = str(layout["config"] / "script_in_config.sh") + assert args.unittests_script == expected + assert args.conformance_tests_script == expected + assert args.prepare_environment_script == expected From 84236eddd8aa580471fbc8e7c3eeea072d334536 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 21 Apr 2026 12:11:37 +0200 Subject: [PATCH 04/10] Resolve folder path arguments by source --base-folder, --build-folder, --conformance-tests-folder, --build-dest, --conformance-tests-dest, and --template-dir now resolve CLI values against CWD, config.yaml values against the config file's directory, and defaults against the spec file's directory. The "output lives next to the spec" rule means an invocation from a different CWD (typical in CI) no longer scatters output into the CWD. Resolution runs before the build-folder/build-dest equality check so two relative paths that resolve to the same absolute path are caught. Co-Authored-By: Claude Opus 4.7 --- plain2code_arguments.py | 62 +++++++--- tests/test_arg_provenance.py | 6 +- tests/test_folder_path_resolution.py | 173 +++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 tests/test_folder_path_resolution.py diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 22e7e33..30cc444 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -26,34 +26,51 @@ PREPARE_ENVIRONMENT_SCRIPT_NAME = "prepare_environment_script" -def _resolve_script_path( - script_arg_name: str, +def _resolve_path_arg( + arg_name: str, args, cwd: str, config_dir: Optional[str], spec_dir: str, -) -> None: - """Resolve a script-path argument on ``args`` in-place using its recorded source. +) -> Optional[str]: + """Resolve a path-valued argument on ``args`` in-place using its recorded source. - Does nothing if the argument is unset. Raises ``FileNotFoundError`` if the - resolved path does not exist. + Returns the resolved absolute path, or ``None`` if the argument is unset. """ - original_value = getattr(args, script_arg_name, None) + original_value = getattr(args, arg_name, None) if original_value is None: - return + return None - source = getattr(args, ARGUMENT_SOURCES, {}).get(script_arg_name, "default") + source = getattr(args, ARGUMENT_SOURCES, {}).get(arg_name, "default") resolved = resolve_path( original_value, source, cwd=cwd, config_dir=config_dir, spec_dir=spec_dir ) + setattr(args, arg_name, resolved) + return resolved + + +def _resolve_script_path( + script_arg_name: str, + args, + cwd: str, + config_dir: Optional[str], + spec_dir: str, +) -> None: + """Resolve a script-path argument and verify the script exists on disk. + + Scripts are expected to exist at parse time because we are about to execute + them; missing directories, by contrast, are created on demand. + """ + original_value = getattr(args, script_arg_name, None) + resolved = _resolve_path_arg(script_arg_name, args, cwd, config_dir, spec_dir) + if resolved is None: + return if not os.path.exists(resolved): raise FileNotFoundError( f"Path for {script_arg_name} not found: {original_value} (resolved to {resolved})." ) - setattr(args, script_arg_name, resolved) - def non_empty_string(s): if not s: @@ -374,6 +391,23 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): cli_provided = _detect_cli_provided_keys(command_line) args = update_args_with_config(args, parser, cli_provided) + cwd = os.getcwd() + spec_dir = os.path.dirname(os.path.abspath(args.filename)) + # args.config_name is the resolved absolute path when a config file was found, + # otherwise it is still just the lookup name (e.g. "config.yaml"). + config_dir = os.path.dirname(args.config_name) if os.path.isabs(args.config_name) else None + + folder_arg_names = [ + "base_folder", + "build_folder", + "conformance_tests_folder", + "build_dest", + "conformance_tests_dest", + "template_dir", + ] + for folder_name in folder_arg_names: + _resolve_path_arg(folder_name, args, cwd, config_dir, spec_dir) + if args.build_folder == args.build_dest: parser.error("--build-folder and --build-dest cannot be the same") if args.conformance_tests_folder == args.conformance_tests_dest: @@ -390,12 +424,6 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): if args.full_plain and args.dry_run: parser.error("--full-plain and --dry-run are mutually exclusive") - cwd = os.getcwd() - spec_dir = os.path.dirname(os.path.abspath(args.filename)) - # args.config_name is the resolved absolute path when a config file was found, - # otherwise it is still just the lookup name (e.g. "config.yaml"). - config_dir = os.path.dirname(args.config_name) if os.path.isabs(args.config_name) else None - script_arg_names = [UNIT_TESTS_SCRIPT_NAME, CONFORMANCE_TESTS_SCRIPT_NAME, PREPARE_ENVIRONMENT_SCRIPT_NAME] for script_name in script_arg_names: _resolve_script_path(script_name, args, cwd, config_dir, spec_dir) diff --git a/tests/test_arg_provenance.py b/tests/test_arg_provenance.py index 08cdd86..40e887b 100644 --- a/tests/test_arg_provenance.py +++ b/tests/test_arg_provenance.py @@ -32,7 +32,7 @@ def test_default_when_neither_cli_nor_config(project): def test_cli_value_is_marked_as_cli(project): args = parse_arguments([os.path.join(project, "module.plain"), "--build-folder", "out"]) assert _sources(args)["build_folder"] == "cli" - assert args.build_folder == "out" + assert args.build_folder == os.path.join(project, "out") def test_cli_value_equal_to_default_is_still_cli(project): @@ -45,14 +45,14 @@ def test_config_value_is_marked_as_config(project): (Path(project) / "config.yaml").write_text("build-folder: from_config\n") args = parse_arguments([os.path.join(project, "module.plain")]) assert _sources(args)["build_folder"] == "config" - assert args.build_folder == "from_config" + assert args.build_folder == os.path.join(project, "from_config") def test_cli_wins_over_config(project): (Path(project) / "config.yaml").write_text("build-folder: from_config\n") args = parse_arguments([os.path.join(project, "module.plain"), "--build-folder", "from_cli"]) assert _sources(args)["build_folder"] == "cli" - assert args.build_folder == "from_cli" + assert args.build_folder == os.path.join(project, "from_cli") def test_boolean_flag_provenance(project): diff --git a/tests/test_folder_path_resolution.py b/tests/test_folder_path_resolution.py new file mode 100644 index 0000000..7ecc02c --- /dev/null +++ b/tests/test_folder_path_resolution.py @@ -0,0 +1,173 @@ +"""Tests for folder-path argument resolution. + +Covers --base-folder, --build-folder, --conformance-tests-folder, +--build-dest, --conformance-tests-dest, --template-dir. + +The rule: CLI values resolve against CWD, config values resolve against +the config file's directory, and values left at their default (for the +output folders) resolve against the spec file's directory. +""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from plain2code_arguments import ( + DEFAULT_BUILD_DEST, + DEFAULT_BUILD_FOLDER, + DEFAULT_CONFORMANCE_TESTS_DEST, + DEFAULT_CONFORMANCE_TESTS_FOLDER, + parse_arguments, +) + + +@pytest.fixture +def layout(): + """Three separate directories: one for the spec, one for the config, one as CWD.""" + with tempfile.TemporaryDirectory() as root: + spec_dir = Path(root) / "spec_dir" + config_dir = Path(root) / "config_dir" + cwd = Path(root) / "cwd" + spec_dir.mkdir() + config_dir.mkdir() + cwd.mkdir() + (spec_dir / "module.plain").write_text("") + yield { + "spec": spec_dir, + "config": config_dir, + "cwd": cwd, + "plain_file": str(spec_dir / "module.plain"), + } + + +# ---- Default-source resolution (spec-dir-anchored) ------------------------ + + +def test_missing_build_folder_defaults_next_to_spec(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"]]) + assert args.build_folder == str(layout["spec"] / DEFAULT_BUILD_FOLDER) + + +def test_missing_conformance_tests_folder_defaults_next_to_spec(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"]]) + assert args.conformance_tests_folder == str(layout["spec"] / DEFAULT_CONFORMANCE_TESTS_FOLDER) + + +def test_missing_build_dest_defaults_next_to_spec(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"]]) + assert args.build_dest == str(layout["spec"] / DEFAULT_BUILD_DEST) + + +def test_missing_conformance_tests_dest_defaults_next_to_spec(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"]]) + assert args.conformance_tests_dest == str(layout["spec"] / DEFAULT_CONFORMANCE_TESTS_DEST) + + +def test_optional_folders_remain_none_when_unset(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"]]) + assert args.base_folder is None + assert args.template_dir is None + + +# ---- CLI-source resolution (CWD-anchored) --------------------------------- + + +def test_cli_build_folder_resolves_against_cwd(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--build-folder", "out"]) + assert args.build_folder == str(layout["cwd"] / "out") + + +def test_cli_base_folder_resolves_against_cwd(layout): + base = layout["cwd"] / "base" + base.mkdir() + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--base-folder", "base"]) + assert args.base_folder == str(base) + + +def test_cli_template_dir_resolves_against_cwd(layout): + tmpl = layout["cwd"] / "templates" + tmpl.mkdir() + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--template-dir", "templates"]) + assert args.template_dir == str(tmpl) + + +def test_cli_dotdot_folder_resolves_against_cwd(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments( + [layout["plain_file"], "--build-folder", "../spec_dir/custom_out"] + ) + assert args.build_folder == str(layout["spec"] / "custom_out") + + +def test_cli_absolute_path_preserved(layout): + abs_path = str(layout["spec"] / "explicit_out") + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--build-folder", abs_path]) + assert args.build_folder == abs_path + + +# ---- Config-source resolution (config-dir-anchored) ----------------------- + + +def test_config_build_folder_resolves_against_config_dir(layout): + (layout["config"] / "config.yaml").write_text("build-folder: out_from_config\n") + with patch("os.getcwd", return_value=str(layout["config"])): + args = parse_arguments([layout["plain_file"]]) + assert args.build_folder == str(layout["config"] / "out_from_config") + + +def test_config_all_output_folders_resolve_against_config_dir(layout): + (layout["config"] / "config.yaml").write_text( + "build-folder: b\n" + "conformance-tests-folder: ct\n" + "build-dest: d\n" + "conformance-tests-dest: cd\n" + ) + with patch("os.getcwd", return_value=str(layout["config"])): + args = parse_arguments([layout["plain_file"]]) + assert args.build_folder == str(layout["config"] / "b") + assert args.conformance_tests_folder == str(layout["config"] / "ct") + assert args.build_dest == str(layout["config"] / "d") + assert args.conformance_tests_dest == str(layout["config"] / "cd") + + +def test_config_template_dir_resolves_against_config_dir(layout): + (layout["config"] / "config.yaml").write_text("template-dir: my_templates\n") + with patch("os.getcwd", return_value=str(layout["config"])): + args = parse_arguments([layout["plain_file"]]) + assert args.template_dir == str(layout["config"] / "my_templates") + + +def test_cli_overrides_config_and_uses_cwd_anchor(layout): + """A CLI flag that overrides a config value must use CLI semantics (CWD).""" + (layout["config"] / "config.yaml").write_text("build-folder: from_config\n") + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--build-folder", "from_cli"]) + assert args.build_folder == str(layout["cwd"] / "from_cli") + + +# ---- Equality checks happen on resolved paths ---------------------------- + + +def test_build_folder_and_build_dest_equality_detected_after_resolution(layout, capsys): + """Two different relative paths that resolve to the same absolute path must trip the check.""" + (layout["config"] / "config.yaml").write_text( + "build-folder: same\n" + "build-dest: same\n" + ) + with patch("os.getcwd", return_value=str(layout["config"])): + with pytest.raises(SystemExit): + parse_arguments([layout["plain_file"]]) + err = capsys.readouterr().err + assert "--build-folder and --build-dest cannot be the same" in err From e0b3e9ec5d7bb4084918d9954c2198ba6876c276 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Wed, 22 Apr 2026 08:57:28 +0200 Subject: [PATCH 05/10] Resolve --logging-config-path by source CLI values anchor on CWD, config.yaml values on the config file's directory, and the default ('logging_config.yaml') now resolves next to the spec file rather than against CWD. Co-Authored-By: Claude Opus 4.7 --- plain2code_arguments.py | 9 ++- tests/test_logging_config_path_resolution.py | 69 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 tests/test_logging_config_path_resolution.py diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 30cc444..4508f48 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -397,16 +397,19 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): # otherwise it is still just the lookup name (e.g. "config.yaml"). config_dir = os.path.dirname(args.config_name) if os.path.isabs(args.config_name) else None - folder_arg_names = [ + # Path-valued arguments that do not need to exist at parse time: directories + # are created on demand and the logging config file is optional. + path_arg_names = [ "base_folder", "build_folder", "conformance_tests_folder", "build_dest", "conformance_tests_dest", "template_dir", + "logging_config_path", ] - for folder_name in folder_arg_names: - _resolve_path_arg(folder_name, args, cwd, config_dir, spec_dir) + for arg_name in path_arg_names: + _resolve_path_arg(arg_name, args, cwd, config_dir, spec_dir) if args.build_folder == args.build_dest: parser.error("--build-folder and --build-dest cannot be the same") diff --git a/tests/test_logging_config_path_resolution.py b/tests/test_logging_config_path_resolution.py new file mode 100644 index 0000000..e42209f --- /dev/null +++ b/tests/test_logging_config_path_resolution.py @@ -0,0 +1,69 @@ +"""Tests for --logging-config-path resolution. + +Same frame-of-reference rule as other path arguments: CLI values resolve +against CWD, config.yaml values resolve against the config file's +directory, and the default resolves against the spec file's directory. +""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from plain2code_arguments import parse_arguments + + +@pytest.fixture +def layout(): + """Three separate directories: spec dir, config dir, and CWD.""" + with tempfile.TemporaryDirectory() as root: + spec_dir = Path(root) / "spec_dir" + config_dir = Path(root) / "config_dir" + cwd = Path(root) / "cwd" + spec_dir.mkdir() + config_dir.mkdir() + cwd.mkdir() + (spec_dir / "module.plain").write_text("") + yield { + "spec": spec_dir, + "config": config_dir, + "cwd": cwd, + "plain_file": str(spec_dir / "module.plain"), + } + + +def test_default_logging_config_path_resolves_next_to_spec(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"]]) + assert args.logging_config_path == str(layout["spec"] / "logging_config.yaml") + + +def test_cli_logging_config_path_resolves_against_cwd(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments( + [layout["plain_file"], "--logging-config-path", "custom_logging.yaml"] + ) + assert args.logging_config_path == str(layout["cwd"] / "custom_logging.yaml") + + +def test_config_logging_config_path_resolves_against_config_dir(layout): + (layout["config"] / "config.yaml").write_text("logging-config-path: logging_from_config.yaml\n") + with patch("os.getcwd", return_value=str(layout["config"])): + args = parse_arguments([layout["plain_file"]]) + assert args.logging_config_path == str(layout["config"] / "logging_from_config.yaml") + + +def test_cli_absolute_logging_config_path_preserved(layout): + abs_path = str(layout["spec"] / "abs_logging.yaml") + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--logging-config-path", abs_path]) + assert args.logging_config_path == abs_path + + +def test_cli_overrides_config_with_cwd_anchor(layout): + (layout["config"] / "config.yaml").write_text("logging-config-path: from_config.yaml\n") + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--logging-config-path", "from_cli.yaml"]) + assert args.logging_config_path == str(layout["cwd"] / "from_cli.yaml") From 3a0fa5a239fa0ed17b63a97b7bf87cf92a87b23f Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Wed, 22 Apr 2026 09:07:23 +0200 Subject: [PATCH 06/10] Resolve --log-file-name by source Removes the always-spec-dir-relative special case for --log-file-name. CLI values now resolve against CWD, config.yaml values against the config file's directory, and the default ('codeplain.log') continues to land next to the spec file. The --no-log-to-file compatibility check switches from a string comparison against the default to a provenance check, so a config-supplied log-file-name is also flagged as inconsistent. get_log_file_path() is removed -- args.log_file_name is now an absolute path by the time callers see it, so setup_logging and dump_crash_logs use it directly. Co-Authored-By: Claude Opus 4.7 --- plain2code.py | 12 +--- plain2code_arguments.py | 5 +- plain2code_logger.py | 14 +--- tests/test_log_file_name_resolution.py | 91 ++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 tests/test_log_file_name_resolution.py diff --git a/plain2code.py b/plain2code.py index a1f0e56..adeb8fb 100644 --- a/plain2code.py +++ b/plain2code.py @@ -6,8 +6,6 @@ import sys import threading from pathlib import Path -from typing import Optional - import yaml from liquid2.exceptions import TemplateNotFoundError from requests.exceptions import RequestException @@ -44,7 +42,6 @@ IndentedFormatter, TuiLoggingHandler, dump_crash_logs, - get_log_file_path, ) from plain2code_state import RunState from plain2code_utils import format_duration_hms, print_dry_run_output @@ -126,8 +123,7 @@ def setup_logging( args, event_bus: EventBus, log_to_file: bool, - log_file_name: str, - plain_file_path: Optional[str], + log_file_path: str, headless: bool = False, ): # Set default level to INFO for everything not explicitly configured @@ -137,8 +133,6 @@ def setup_logging( logging.getLogger("transitions").setLevel(logging.ERROR) logging.getLogger("transitions.extensions.diagrams").setLevel(logging.ERROR) - log_file_path = get_log_file_path(plain_file_path, log_file_name) - # Try to load logging configuration from YAML file if args.logging_config_path and os.path.exists(args.logging_config_path): try: @@ -162,7 +156,7 @@ def setup_logging( handler.setFormatter(formatter) root_logger.addHandler(handler) - if log_to_file and log_file_path: + if log_to_file: try: file_handler = logging.FileHandler(log_file_path, mode="w") file_handler.setFormatter(formatter) @@ -313,7 +307,7 @@ def main(): # noqa: C901 # Suppress Rich console output. console.quiet = True - setup_logging(args, event_bus, args.log_to_file, args.log_file_name, args.filename, args.headless) + setup_logging(args, event_bus, args.log_to_file, args.log_file_name, args.headless) exc_info = None try: diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 4508f48..6a6d1bf 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -398,7 +398,7 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): config_dir = os.path.dirname(args.config_name) if os.path.isabs(args.config_name) else None # Path-valued arguments that do not need to exist at parse time: directories - # are created on demand and the logging config file is optional. + # are created on demand and the logging-related files are optional. path_arg_names = [ "base_folder", "build_folder", @@ -407,6 +407,7 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): "conformance_tests_dest", "template_dir", "logging_config_path", + "log_file_name", ] for arg_name in path_arg_names: _resolve_path_arg(arg_name, args, cwd, config_dir, spec_dir) @@ -421,7 +422,7 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): if not args.render_conformance_tests and args.copy_conformance_tests: parser.error("--copy-conformance-tests requires --conformance-tests-script to be set") - if not args.log_to_file and args.log_file_name != DEFAULT_LOG_FILE_NAME: + if not args.log_to_file and args.argument_sources.get("log_file_name") != "default": parser.error("--log-file-name cannot be used when --log-to-file is False.") if args.full_plain and args.dry_run: diff --git a/plain2code_logger.py b/plain2code_logger.py index ea86dda..2a73488 100644 --- a/plain2code_logger.py +++ b/plain2code_logger.py @@ -1,7 +1,5 @@ import logging -import os import time -from typing import Optional from event_bus import EventBus from plain2code_events import LogMessageEmitted @@ -73,14 +71,6 @@ def dump_to_file(self, filepath, formatter=None): return False -def get_log_file_path(plain_file_path: Optional[str], log_file_name: str) -> Optional[str]: - """Get the full path to the log file, relative to the plain file directory.""" - if not plain_file_path: - return None - plain_dir = os.path.dirname(os.path.abspath(plain_file_path)) - return os.path.join(plain_dir, log_file_name) - - def dump_crash_logs(args, formatter=None): """Dump buffered logs to file if CrashLogHandler is present.""" if args.log_to_file: @@ -93,6 +83,4 @@ def dump_crash_logs(args, formatter=None): crash_handler = next((h for h in root_logger.handlers if isinstance(h, CrashLogHandler)), None) if crash_handler and args.filename: - log_file_path = get_log_file_path(args.filename, args.log_file_name) - - crash_handler.dump_to_file(log_file_path, formatter) + crash_handler.dump_to_file(args.log_file_name, formatter) diff --git a/tests/test_log_file_name_resolution.py b/tests/test_log_file_name_resolution.py new file mode 100644 index 0000000..de7638a --- /dev/null +++ b/tests/test_log_file_name_resolution.py @@ -0,0 +1,91 @@ +"""Tests for --log-file-name resolution. + +Same frame-of-reference rule as other path arguments: CLI values resolve +against CWD, config.yaml values resolve against the config file's +directory, and the default resolves against the spec file's directory. + +Also verifies the "--log-file-name cannot be used when --log-to-file is +False" validation now uses the recorded argument source rather than a +string-equality heuristic against the default. +""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from plain2code_arguments import DEFAULT_LOG_FILE_NAME, parse_arguments + + +@pytest.fixture +def layout(): + """Three separate directories: spec dir, config dir, and CWD.""" + with tempfile.TemporaryDirectory() as root: + spec_dir = Path(root) / "spec_dir" + config_dir = Path(root) / "config_dir" + cwd = Path(root) / "cwd" + spec_dir.mkdir() + config_dir.mkdir() + cwd.mkdir() + (spec_dir / "module.plain").write_text("") + yield { + "spec": spec_dir, + "config": config_dir, + "cwd": cwd, + "plain_file": str(spec_dir / "module.plain"), + } + + +def test_default_log_file_name_resolves_next_to_spec(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"]]) + assert args.log_file_name == str(layout["spec"] / DEFAULT_LOG_FILE_NAME) + + +def test_cli_log_file_name_resolves_against_cwd(layout): + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--log-file-name", "my.log"]) + assert args.log_file_name == str(layout["cwd"] / "my.log") + + +def test_config_log_file_name_resolves_against_config_dir(layout): + (layout["config"] / "config.yaml").write_text("log-file-name: from_config.log\n") + with patch("os.getcwd", return_value=str(layout["config"])): + args = parse_arguments([layout["plain_file"]]) + assert args.log_file_name == str(layout["config"] / "from_config.log") + + +def test_cli_absolute_log_file_name_preserved(layout): + abs_path = str(layout["spec"] / "abs.log") + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--log-file-name", abs_path]) + assert args.log_file_name == abs_path + + +def test_log_file_name_with_no_log_to_file_errors_when_cli_supplied(layout, capsys): + with patch("os.getcwd", return_value=str(layout["cwd"])): + with pytest.raises(SystemExit): + parse_arguments( + [layout["plain_file"], "--log-file-name", "my.log", "--no-log-to-file"] + ) + err = capsys.readouterr().err + assert "--log-file-name cannot be used when --log-to-file is False" in err + + +def test_log_file_name_with_no_log_to_file_errors_when_config_supplied(layout, capsys): + """Config-supplied --log-file-name also counts as explicit.""" + (layout["config"] / "config.yaml").write_text("log-file-name: from_config.log\n") + with patch("os.getcwd", return_value=str(layout["config"])): + with pytest.raises(SystemExit): + parse_arguments([layout["plain_file"], "--no-log-to-file"]) + err = capsys.readouterr().err + assert "--log-file-name cannot be used when --log-to-file is False" in err + + +def test_no_log_to_file_with_default_log_file_name_is_allowed(layout): + """When no explicit --log-file-name is given, --no-log-to-file is accepted.""" + with patch("os.getcwd", return_value=str(layout["cwd"])): + args = parse_arguments([layout["plain_file"], "--no-log-to-file"]) + assert args.log_to_file is False From c52855f150d66abfd35f5c21b5b5232593455fc2 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Wed, 22 Apr 2026 09:18:01 +0200 Subject: [PATCH 07/10] Document path frame-of-reference rule in CLI help Describe the rule in the parser description and regenerate the CLI reference. Also drop the now-incorrect "always resolved relative to the plain file directory" note from --log-file-name. Co-Authored-By: Claude Opus 4.7 --- docs/plain2code_cli.md | 138 +++++++++++++++++++++++++++------------- plain2code_arguments.py | 26 ++++---- 2 files changed, 107 insertions(+), 57 deletions(-) diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index e02136c..9f81d79 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -1,22 +1,41 @@ # Plain2Code CLI Reference ```text -usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--build-folder BUILD_FOLDER] [--log-to-file | --no-log-to-file] - [--log-file-name LOG_FILE_NAME] [--config-name CONFIG_NAME] [--render-range RENDER_RANGE | --render-from RENDER_FROM] - [--force-render] [--unittests-script UNITTESTS_SCRIPT] [--conformance-tests-folder CONFORMANCE_TESTS_FOLDER] - [--conformance-tests-script CONFORMANCE_TESTS_SCRIPT] [--prepare-environment-script PREPARE_ENVIRONMENT_SCRIPT] - [--test-script-timeout TEST_SCRIPT_TIMEOUT] [--api [API]] [--api-key API_KEY] [--full-plain] [--dry-run] - [--replay-with REPLAY_WITH] [--template-dir TEMPLATE_DIR] [--copy-build] [--build-dest BUILD_DEST] - [--copy-conformance-tests] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] - [--logging-config-path LOGGING_CONFIG_PATH] [--headless] +usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] + [--build-folder BUILD_FOLDER] + [--log-to-file | --no-log-to-file] + [--log-file-name LOG_FILE_NAME] + [--config-name CONFIG_NAME] + [--render-range RENDER_RANGE | + --render-from RENDER_FROM] [--force-render] + [--unittests-script UNITTESTS_SCRIPT] + [--conformance-tests-folder CONFORMANCE_TESTS_FOLDER] + [--conformance-tests-script CONFORMANCE_TESTS_SCRIPT] + [--prepare-environment-script PREPARE_ENVIRONMENT_SCRIPT] + [--test-script-timeout TEST_SCRIPT_TIMEOUT] + [--api [API]] [--api-key API_KEY] [--full-plain] + [--dry-run] [--replay-with REPLAY_WITH] + [--template-dir TEMPLATE_DIR] [--copy-build] + [--build-dest BUILD_DEST] [--copy-conformance-tests] + [--conformance-tests-dest CONFORMANCE_TESTS_DEST] + [--render-machine-graph] + [--logging-config-path LOGGING_CONFIG_PATH] + [--headless] filename -Render plain code to target code. +Render plain code to target code. Path arguments resolve based on where they +were written: values given on the command line are resolved against the +current working directory, values read from config.yaml are resolved against +the config file's directory, and defaults are resolved against the directory +containing the plain file. Absolute paths (and paths starting with '~') are +used as-is. positional arguments: - filename Path to the plain file to render. The directory containing this file has highest precedence for template loading, so - you can place custom templates here to override the defaults. See --template-dir for more details about template - loading. + filename Path to the plain file to render. The directory + containing this file has highest precedence for + template loading, so you can place custom templates + here to override the defaults. See --template-dir for + more details about template loading. options: -h, --help show this help message and exit @@ -26,60 +45,89 @@ options: --build-folder BUILD_FOLDER Folder for build files --log-to-file, --no-log-to-file - Enable logging to a file. Defaults to True. Set to False to disable. + Enable logging to a file. Defaults to True. Set to + False to disable. --log-file-name LOG_FILE_NAME - Name of the log file. Defaults to 'codeplain.log'.Always resolved relative to the plain file directory.If file on - this path already exists, the already existing log file will be overwritten by the current logs. + Name of the log file. Defaults to 'codeplain.log'. If + a file already exists at the resolved path, it will be + overwritten by the current logs. --render-range RENDER_RANGE - Specify a range of functionalities to render (e.g. `1` , `2`, `3`). Use comma to separate start and end IDs. If only - one functionality ID is provided, only that functionality is rendered. Range is inclusive of both start and end IDs. + Specify a range of functionalities to render (e.g. `1` + , `2`, `3`). Use comma to separate start and end IDs. + If only one functionality ID is provided, only that + functionality is rendered. Range is inclusive of both + start and end IDs. --render-from RENDER_FROM - Continue generation starting from this specific functionality (e.g. `2`). The functionality with this ID will be - included in the output. The functionality ID must match one of the functionalities in your plain file. + Continue generation starting from this specific + functionality (e.g. `2`). The functionality with this + ID will be included in the output. The functionality + ID must match one of the functionalities in your plain + file. --force-render Force re-render of all the required modules. --unittests-script UNITTESTS_SCRIPT - Shell script to run unit tests on generated code. Receives the build folder path as its first argument (default: - 'plain_modules'). + Shell script to run unit tests on generated code. + Receives the build folder path as its first argument + (default: 'plain_modules'). --conformance-tests-folder CONFORMANCE_TESTS_FOLDER Folder for conformance test files --conformance-tests-script CONFORMANCE_TESTS_SCRIPT - Path to conformance tests shell script. Every conformance test script should accept two arguments: 1) Path to a - folder (e.g. `plain_modules/module_name`) containing generated source code, 2) Path to a subfolder of the conformance - tests folder (e.g. `conformance_tests/subfoldername`) containing test files. + Path to conformance tests shell script. Every + conformance test script should accept two arguments: + 1) Path to a folder (e.g. `plain_modules/module_name`) + containing generated source code, 2) Path to a + subfolder of the conformance tests folder (e.g. + `conformance_tests/subfoldername`) containing test + files. --prepare-environment-script PREPARE_ENVIRONMENT_SCRIPT - Path to a shell script that prepares the testing environment. The script should accept the source code folder path as - its first argument. + Path to a shell script that prepares the testing + environment. The script should accept the source code + folder path as its first argument. --test-script-timeout TEST_SCRIPT_TIMEOUT - Timeout for test scripts in seconds. If not provided, the default timeout of 120 seconds is used. - --api [API] Alternative base URL for the API. Default: `https://api.codeplain.ai` - --api-key API_KEY API key used to access the API. If not provided, the `CODEPLAIN_API_KEY` environment variable is used. - --full-plain Full preview ***plain specification before code generation.Use when you want to preview context of all ***plain - primitives that are going to be included in order to render the given module. - --dry-run Dry run preview of the code generation (without actually making any changes). + Timeout for test scripts in seconds. If not provided, + the default timeout of 120 seconds is used. + --api [API] Alternative base URL for the API. Default: + `https://api.codeplain.ai` + --api-key API_KEY API key used to access the API. If not provided, the + `CODEPLAIN_API_KEY` environment variable is used. + --full-plain Full preview ***plain specification before code + generation.Use when you want to preview context of all + ***plain primitives that are going to be included in + order to render the given module. + --dry-run Dry run preview of the code generation (without + actually making any changes). --replay-with REPLAY_WITH --template-dir TEMPLATE_DIR - Path to a custom template directory. Templates are searched in the following order: 1) Directory containing the plain - file, 2) Custom template directory (if provided through this argument), 3) Built-in standard_template_library - directory - --copy-build If set, copy the rendered contents of code in `--base-folder` folder to `--build-dest` folder after successful - rendering. + Path to a custom template directory. Templates are + searched in the following order: 1) Directory + containing the plain file, 2) Custom template + directory (if provided through this argument), 3) + Built-in standard_template_library directory + --copy-build If set, copy the rendered contents of code in `--base- + folder` folder to `--build-dest` folder after + successful rendering. --build-dest BUILD_DEST - Target folder to copy rendered contents of code to (used only if --copy-build is set). + Target folder to copy rendered contents of code to + (used only if --copy-build is set). --copy-conformance-tests - If set, copy the conformance tests of code in `--conformance-tests-folder` folder to `--conformance-tests-dest` - folder successful rendering. Requires --conformance-tests-script. + If set, copy the conformance tests of code in + `--conformance-tests-folder` folder to `--conformance- + tests-dest` folder successful rendering. Requires + --conformance-tests-script. --conformance-tests-dest CONFORMANCE_TESTS_DEST - Target folder to copy conformance tests of code to (used only if --copy-conformance-tests is set). + Target folder to copy conformance tests of code to + (used only if --copy-conformance-tests is set). --render-machine-graph If set, render the state machine graph. --logging-config-path LOGGING_CONFIG_PATH Path to the logging configuration file. - --headless Run in headless mode: no TUI, no terminal output except a single render-started message. All logs are written to the - log file. + --headless Run in headless mode: no TUI, no terminal output + except a single render-started message. All logs are + written to the log file. configuration: --config-name CONFIG_NAME - Name of the config file to look for. Looked up in the plain file directory and the current working directory. - Defaults to config.yaml. + Name of the config file to look for. Looked up in the + plain file directory and the current working + directory. Defaults to config.yaml. ``` \ No newline at end of file diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 6a6d1bf..16bcf41 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -1,6 +1,6 @@ import argparse import os -from typing import Any, Optional, Sequence +from typing import Optional, Sequence from path_resolution import resolve_path from plain2code_console import console @@ -42,9 +42,7 @@ def _resolve_path_arg( return None source = getattr(args, ARGUMENT_SOURCES, {}).get(arg_name, "default") - resolved = resolve_path( - original_value, source, cwd=cwd, config_dir=config_dir, spec_dir=spec_dir - ) + resolved = resolve_path(original_value, source, cwd=cwd, config_dir=config_dir, spec_dir=spec_dir) setattr(args, arg_name, resolved) return resolved @@ -67,9 +65,7 @@ def _resolve_script_path( return if not os.path.exists(resolved): - raise FileNotFoundError( - f"Path for {script_arg_name} not found: {original_value} (resolved to {resolved})." - ) + raise FileNotFoundError(f"Path for {script_arg_name} not found: {original_value} (resolved to {resolved}).") def non_empty_string(s): @@ -198,8 +194,15 @@ def update_args_with_config(args, parser, cli_provided: set[str]): def create_parser(): """Create the argument parser without parsing arguments.""" - parser_kwargs: dict[str, Any] = { - "description": "Render plain code to target code.", + parser_kwargs = { + "description": ( + "Render plain code to target code. " + "Path arguments resolve based on where they were written: " + "values given on the command line are resolved against the current working " + "directory, values read from config.yaml are resolved against the config " + "file's directory, and defaults are resolved against the directory containing " + "the plain file. Absolute paths (and paths starting with '~') are used as-is." + ), } parser = argparse.ArgumentParser(**parser_kwargs) @@ -226,9 +229,8 @@ def create_parser(): "--log-file-name", type=str, default=DEFAULT_LOG_FILE_NAME, - help=f"Name of the log file. Defaults to '{DEFAULT_LOG_FILE_NAME}'." - "Always resolved relative to the plain file directory." - "If file on this path already exists, the already existing log file will be overwritten by the current logs.", + help=f"Name of the log file. Defaults to '{DEFAULT_LOG_FILE_NAME}'. " + "If a file already exists at the resolved path, it will be overwritten by the current logs.", ) # Add config file arguments From a27fdae487d5d15878635887b4354672abc51f1e Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Wed, 22 Apr 2026 09:31:18 +0200 Subject: [PATCH 08/10] code style change --- tests/test_arg_provenance.py | 4 +--- tests/test_folder_path_resolution.py | 14 +++----------- tests/test_log_file_name_resolution.py | 4 +--- tests/test_logging_config_path_resolution.py | 4 +--- tests/test_path_resolution.py | 1 - tests/test_script_path_resolution.py | 8 ++------ 6 files changed, 8 insertions(+), 27 deletions(-) diff --git a/tests/test_arg_provenance.py b/tests/test_arg_provenance.py index 40e887b..f42dc8a 100644 --- a/tests/test_arg_provenance.py +++ b/tests/test_arg_provenance.py @@ -77,9 +77,7 @@ def test_boolean_flag_from_config(project): def test_mixed_sources(project): """CLI, config, and default values coexist on the same invocation.""" (Path(project) / "config.yaml").write_text("build-folder: from_config\n") - args = parse_arguments( - [os.path.join(project, "module.plain"), "--conformance-tests-folder", "ct_from_cli"] - ) + args = parse_arguments([os.path.join(project, "module.plain"), "--conformance-tests-folder", "ct_from_cli"]) srcs = _sources(args) assert srcs["conformance_tests_folder"] == "cli" assert srcs["build_folder"] == "config" diff --git a/tests/test_folder_path_resolution.py b/tests/test_folder_path_resolution.py index 7ecc02c..8c1dd0e 100644 --- a/tests/test_folder_path_resolution.py +++ b/tests/test_folder_path_resolution.py @@ -104,9 +104,7 @@ def test_cli_template_dir_resolves_against_cwd(layout): def test_cli_dotdot_folder_resolves_against_cwd(layout): with patch("os.getcwd", return_value=str(layout["cwd"])): - args = parse_arguments( - [layout["plain_file"], "--build-folder", "../spec_dir/custom_out"] - ) + args = parse_arguments([layout["plain_file"], "--build-folder", "../spec_dir/custom_out"]) assert args.build_folder == str(layout["spec"] / "custom_out") @@ -129,10 +127,7 @@ def test_config_build_folder_resolves_against_config_dir(layout): def test_config_all_output_folders_resolve_against_config_dir(layout): (layout["config"] / "config.yaml").write_text( - "build-folder: b\n" - "conformance-tests-folder: ct\n" - "build-dest: d\n" - "conformance-tests-dest: cd\n" + "build-folder: b\n" "conformance-tests-folder: ct\n" "build-dest: d\n" "conformance-tests-dest: cd\n" ) with patch("os.getcwd", return_value=str(layout["config"])): args = parse_arguments([layout["plain_file"]]) @@ -162,10 +157,7 @@ def test_cli_overrides_config_and_uses_cwd_anchor(layout): def test_build_folder_and_build_dest_equality_detected_after_resolution(layout, capsys): """Two different relative paths that resolve to the same absolute path must trip the check.""" - (layout["config"] / "config.yaml").write_text( - "build-folder: same\n" - "build-dest: same\n" - ) + (layout["config"] / "config.yaml").write_text("build-folder: same\n" "build-dest: same\n") with patch("os.getcwd", return_value=str(layout["config"])): with pytest.raises(SystemExit): parse_arguments([layout["plain_file"]]) diff --git a/tests/test_log_file_name_resolution.py b/tests/test_log_file_name_resolution.py index de7638a..eed7485 100644 --- a/tests/test_log_file_name_resolution.py +++ b/tests/test_log_file_name_resolution.py @@ -67,9 +67,7 @@ def test_cli_absolute_log_file_name_preserved(layout): def test_log_file_name_with_no_log_to_file_errors_when_cli_supplied(layout, capsys): with patch("os.getcwd", return_value=str(layout["cwd"])): with pytest.raises(SystemExit): - parse_arguments( - [layout["plain_file"], "--log-file-name", "my.log", "--no-log-to-file"] - ) + parse_arguments([layout["plain_file"], "--log-file-name", "my.log", "--no-log-to-file"]) err = capsys.readouterr().err assert "--log-file-name cannot be used when --log-to-file is False" in err diff --git a/tests/test_logging_config_path_resolution.py b/tests/test_logging_config_path_resolution.py index e42209f..e565a8d 100644 --- a/tests/test_logging_config_path_resolution.py +++ b/tests/test_logging_config_path_resolution.py @@ -42,9 +42,7 @@ def test_default_logging_config_path_resolves_next_to_spec(layout): def test_cli_logging_config_path_resolves_against_cwd(layout): with patch("os.getcwd", return_value=str(layout["cwd"])): - args = parse_arguments( - [layout["plain_file"], "--logging-config-path", "custom_logging.yaml"] - ) + args = parse_arguments([layout["plain_file"], "--logging-config-path", "custom_logging.yaml"]) assert args.logging_config_path == str(layout["cwd"] / "custom_logging.yaml") diff --git a/tests/test_path_resolution.py b/tests/test_path_resolution.py index 6849e81..9ee9790 100644 --- a/tests/test_path_resolution.py +++ b/tests/test_path_resolution.py @@ -4,7 +4,6 @@ from path_resolution import resolve_path - CWD = "/work/cwd" CONFIG_DIR = "/work/project/config" SPEC_DIR = "/work/project/spec" diff --git a/tests/test_script_path_resolution.py b/tests/test_script_path_resolution.py index 257012d..4cc5bdd 100644 --- a/tests/test_script_path_resolution.py +++ b/tests/test_script_path_resolution.py @@ -47,9 +47,7 @@ def layout(): def test_cli_script_path_resolves_against_cwd(layout): with patch("os.getcwd", return_value=str(layout["cwd"])): - args = parse_arguments( - [layout["plain_file"], "--unittests-script", "script_in_cwd.sh"] - ) + args = parse_arguments([layout["plain_file"], "--unittests-script", "script_in_cwd.sh"]) assert args.unittests_script == str(layout["cwd"] / "script_in_cwd.sh") @@ -75,9 +73,7 @@ def test_cli_script_path_does_not_fall_back_to_config_dir(layout): def test_cli_script_with_dotdot_resolves_against_cwd(layout): """Tab-completion-style relative path like ../config_dir/script.sh works from CWD.""" with patch("os.getcwd", return_value=str(layout["cwd"])): - args = parse_arguments( - [layout["plain_file"], "--unittests-script", "../config_dir/script_in_config.sh"] - ) + args = parse_arguments([layout["plain_file"], "--unittests-script", "../config_dir/script_in_config.sh"]) assert args.unittests_script == str(layout["config"] / "script_in_config.sh") From 3977d414afe1646c16df29f7d343cda379ed6a40 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Wed, 22 Apr 2026 09:32:15 +0200 Subject: [PATCH 09/10] sort imports --- plain2code.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/plain2code.py b/plain2code.py index adeb8fb..2950f90 100644 --- a/plain2code.py +++ b/plain2code.py @@ -6,6 +6,7 @@ import sys import threading from pathlib import Path + import yaml from liquid2.exceptions import TemplateNotFoundError from requests.exceptions import RequestException @@ -36,13 +37,7 @@ RenderCancelledError, RenderingCreditBalanceTooLow, ) -from plain2code_logger import ( - LOGGER_NAME, - CrashLogHandler, - IndentedFormatter, - TuiLoggingHandler, - dump_crash_logs, -) +from plain2code_logger import LOGGER_NAME, CrashLogHandler, IndentedFormatter, TuiLoggingHandler, dump_crash_logs from plain2code_state import RunState from plain2code_utils import format_duration_hms, print_dry_run_output from system_config import system_config From 0e509e697c14015ad4c1e144a964a5c7e4861dd0 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Wed, 22 Apr 2026 09:33:53 +0200 Subject: [PATCH 10/10] Pass description directly to ArgumentParser to satisfy mypy Splatting a plain dict made mypy infer dict[str, str] and reject it against ArgumentParser's varied kwarg types. Co-Authored-By: Claude Opus 4.7 --- plain2code_arguments.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 16bcf41..a403a6c 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -194,8 +194,8 @@ def update_args_with_config(args, parser, cli_provided: set[str]): def create_parser(): """Create the argument parser without parsing arguments.""" - parser_kwargs = { - "description": ( + parser = argparse.ArgumentParser( + description=( "Render plain code to target code. " "Path arguments resolve based on where they were written: " "values given on the command line are resolved against the current working " @@ -203,9 +203,7 @@ def create_parser(): "file's directory, and defaults are resolved against the directory containing " "the plain file. Absolute paths (and paths starting with '~') are used as-is." ), - } - - parser = argparse.ArgumentParser(**parser_kwargs) + ) parser.add_argument( "filename",