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/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/plain2code.py b/plain2code.py index a1f0e56..2950f90 100644 --- a/plain2code.py +++ b/plain2code.py @@ -6,7 +6,6 @@ import sys import threading from pathlib import Path -from typing import Optional import yaml from liquid2.exceptions import TemplateNotFoundError @@ -38,14 +37,7 @@ RenderCancelledError, RenderingCreditBalanceTooLow, ) -from plain2code_logger import ( - LOGGER_NAME, - CrashLogHandler, - IndentedFormatter, - TuiLoggingHandler, - dump_crash_logs, - get_log_file_path, -) +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 @@ -126,8 +118,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 +128,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 +151,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 +302,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 ec8a308..a403a6c 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -1,11 +1,17 @@ import argparse import os -from typing import Any +from typing import 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 +# 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") @@ -20,36 +26,46 @@ 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: - 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 +def _resolve_path_arg( + arg_name: str, + args, + cwd: str, + config_dir: Optional[str], + spec_dir: str, +) -> Optional[str]: + """Resolve a path-valued argument on ``args`` in-place using its recorded source. + + Returns the resolved absolute path, or ``None`` if the argument is unset. + """ + original_value = getattr(args, arg_name, None) + if original_value is None: + 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) + 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}).") def non_empty_string(s): @@ -119,55 +135,75 @@ 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 def create_parser(): """Create the argument parser without parsing arguments.""" - parser_kwargs: dict[str, Any] = { - "description": "Render plain code to target code.", - } - - parser = argparse.ArgumentParser(**parser_kwargs) + 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 " + "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.add_argument( "filename", @@ -191,9 +227,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 @@ -349,11 +384,33 @@ 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) + + 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 + + # Path-valued arguments that do not need to exist at parse time: directories + # are created on demand and the logging-related files are optional. + path_arg_names = [ + "base_folder", + "build_folder", + "conformance_tests_folder", + "build_dest", + "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) if args.build_folder == args.build_dest: parser.error("--build-folder and --build-dest cannot be the same") @@ -365,7 +422,7 @@ def parse_arguments(): 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: @@ -373,6 +430,6 @@ def parse_arguments(): 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/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_arg_provenance.py b/tests/test_arg_provenance.py new file mode 100644 index 0000000..f42dc8a --- /dev/null +++ b/tests/test_arg_provenance.py @@ -0,0 +1,84 @@ +"""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 == os.path.join(project, "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 == 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 == os.path.join(project, "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" diff --git a/tests/test_folder_path_resolution.py b/tests/test_folder_path_resolution.py new file mode 100644 index 0000000..8c1dd0e --- /dev/null +++ b/tests/test_folder_path_resolution.py @@ -0,0 +1,165 @@ +"""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 diff --git a/tests/test_log_file_name_resolution.py b/tests/test_log_file_name_resolution.py new file mode 100644 index 0000000..eed7485 --- /dev/null +++ b/tests/test_log_file_name_resolution.py @@ -0,0 +1,89 @@ +"""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 diff --git a/tests/test_logging_config_path_resolution.py b/tests/test_logging_config_path_resolution.py new file mode 100644 index 0000000..e565a8d --- /dev/null +++ b/tests/test_logging_config_path_resolution.py @@ -0,0 +1,67 @@ +"""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") diff --git a/tests/test_path_resolution.py b/tests/test_path_resolution.py new file mode 100644 index 0000000..9ee9790 --- /dev/null +++ b/tests/test_path_resolution.py @@ -0,0 +1,65 @@ +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] diff --git a/tests/test_script_path_resolution.py b/tests/test_script_path_resolution.py new file mode 100644 index 0000000..4cc5bdd --- /dev/null +++ b/tests/test_script_path_resolution.py @@ -0,0 +1,107 @@ +"""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