diff --git a/CLAUDE.md b/CLAUDE.md index 506d2c04..9d1501fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,8 +212,12 @@ Two logging modes: ### Code Generation Process - Templates in `standard_template_library/` use Liquid2 syntax - Template search order: (1) `.plain` file dir, (2) `--template-dir`, (3) built-in standard lib -- Generated code written to `build/` (configurable via `--build-folder`) -- Each FRID render produces a git commit in build folder for rollback capability +- Each module renders into its own tree under the build folder (configurable via `--build-folder`, default `plain_modules/`): + - `//code/` — implementation code (its own git repo) + - `//tests/` — conformance tests (its own git repo; only created when a conformance tests script is configured) + - `//.codeplain/` — module metadata (`module_metadata.json`), not tracked in git + - `//.memory/` — conformance test memory, not tracked in git +- Each FRID render produces a git commit in the `code/` and `tests/` repos for rollback capability ### Configuration - `config.yaml` can be placed in `.plain` file dir or CWD diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index 9f81d79d..d737597c 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -9,7 +9,6 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--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] @@ -20,8 +19,8 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] [--logging-config-path LOGGING_CONFIG_PATH] - [--headless] - filename + [--headless] [--status] [--version] + [filename] 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 @@ -39,7 +38,7 @@ positional arguments: options: -h, --help show this help message and exit - --verbose, -v Enable verbose output + --verbose, -v Set default log level to DEBUG for TUI and file logs --base-folder BASE_FOLDER Base folder for the build files --build-folder BUILD_FOLDER @@ -66,18 +65,17 @@ options: --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'). - --conformance-tests-folder CONFORMANCE_TESTS_FOLDER - Folder for conformance test files + Receives the module's code folder path as its first + argument (e.g. `plain_modules/module_name/code`). --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. + 1) Path to a folder (e.g. + `plain_modules/module_name/code`) containing generated + source code, 2) Path to a subfolder of the module's + tests folder (e.g. + `plain_modules/module_name/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 @@ -90,9 +88,9 @@ options: --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. + 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 @@ -109,10 +107,10 @@ options: 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 module's conformance tests (from + `//tests`) to `--conformance- + tests-dest` folder after 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). @@ -123,6 +121,10 @@ options: --headless Run in headless mode: no TUI, no terminal output except a single render-started message. All logs are written to the log file. + --status Display account status including user information, API + key label, and rendering credits. Does not render any + code. + --version Display the client version and exit. configuration: --config-name CONFIG_NAME diff --git a/docs/starting_a_plain_project_from_scratch.md b/docs/starting_a_plain_project_from_scratch.md index 9cc87d31..c5e6225c 100644 --- a/docs/starting_a_plain_project_from_scratch.md +++ b/docs/starting_a_plain_project_from_scratch.md @@ -22,10 +22,16 @@ my-new-project/ ├── run_unittests_[language].sh # Unit test script ├── run_conformance_tests_[language].sh # Conformance test script ├── build/ # Generated final code -├── plain_modules/ # Generated modules code -└── conformance_tests/ # Generated conformanece tests code +└── plain_modules/ # Generated modules + └── my_app/ # One folder per module + ├── .codeplain/ # Module metadata + ├── .memory/ # Conformance test memory + ├── code/ # Generated code + └── tests/ # Generated conformance tests ``` +The `tests/` folder is only created when a conformance test script is configured. + In this guide we will cover how to create each of these step by step. ## 1. Define Your .plain File diff --git a/examples/example_hello_world_golang/run.sh b/examples/example_hello_world_golang/run.sh index 5a9e4b7a..138abf8a 100644 --- a/examples/example_hello_world_golang/run.sh +++ b/examples/example_hello_world_golang/run.sh @@ -20,11 +20,11 @@ if [ $? -ne 0 ]; then exit 1 fi -cd plain_modules/hello_world_golang +cd plain_modules/hello_world_golang/code # We need to compile the tests so that we can execute them in the current folder # (https://stackoverflow.com/questions/23847003/golang-tests-and-working-directory/29541248#29541248) -go test -c ../../harness_tests/hello_world_test.go +go test -c ../../../harness_tests/hello_world_test.go # Check if test compilation has failed for the hello world example if [ $? -ne 0 ]; then diff --git a/examples/example_hello_world_python/run.sh b/examples/example_hello_world_python/run.sh index f2e92d52..45555c5e 100644 --- a/examples/example_hello_world_python/run.sh +++ b/examples/example_hello_world_python/run.sh @@ -7,9 +7,9 @@ if [ $? -ne 0 ]; then exit 1 fi -cd plain_modules/hello_world_python +cd plain_modules/hello_world_python/code -python ../../harness_tests/hello_world_display/test_hello_world.py +python ../../../harness_tests/hello_world_display/test_hello_world.py # Check if the test harness has failed for the hello world example if [ $? -ne 0 ]; then diff --git a/examples/example_hello_world_react/run.sh b/examples/example_hello_world_react/run.sh index af7ce90a..280dfb9e 100644 --- a/examples/example_hello_world_react/run.sh +++ b/examples/example_hello_world_react/run.sh @@ -20,7 +20,7 @@ if [ $? -ne 0 ]; then exit 1 fi -../../test_scripts/run_conformance_tests_cypress.sh plain_modules/hello_world_react harness_tests/hello_world_display ${VERBOSE:+-v} +../../test_scripts/run_conformance_tests_cypress.sh plain_modules/hello_world_react/code harness_tests/hello_world_display ${VERBOSE:+-v} # Check if the test harness has failed for the hello world example if [ $? -ne 0 ]; then diff --git a/git_utils.py b/git_utils.py index ae064d48..53b5b331 100644 --- a/git_utils.py +++ b/git_utils.py @@ -64,7 +64,6 @@ def init_git_repo( path_to_repo: Union[str, os.PathLike], module_name: Optional[str] = None, render_id: Optional[str] = None, - initial_files: Optional[dict[str, str]] = None, ) -> Repo: """ Initializes a new git repository in the given path. @@ -79,10 +78,6 @@ def init_git_repo( repo = Repo.init(path_to_repo) _ensure_git_config(repo) - if initial_files: - file_utils.store_response_files(path_to_repo, initial_files, []) - repo.git.add(".") - repo.git.commit( "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) ) @@ -95,14 +90,9 @@ def clone_repo( new_repo_path: str, module_name: Optional[str] = None, render_id: Optional[str] = None, - initial_files: Optional[dict[str, str]] = None, ) -> Repo: repo = Repo.clone_from(source_repo_path, new_repo_path) - if initial_files: - file_utils.store_response_files(new_repo_path, initial_files, []) - repo.git.add(".") - repo.git.commit( "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) ) diff --git a/memory_management.py b/memory_management.py index a2ade112..2a358827 100644 --- a/memory_management.py +++ b/memory_management.py @@ -3,7 +3,6 @@ import file_utils from plain2code_console import console -from plain_modules import CODEPLAIN_MEMORY_SUBFOLDER from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.render_context import RenderContext @@ -24,9 +23,9 @@ def fetch_memory_files(memory_folder: str) -> tuple[list[str], dict[str, str]]: console.debug(f"Loaded {len(memory_files_content)} memory files.") return memory_files, memory_files_content - def __init__(self, codeplain_api, module_name: str, conformance_tests_folder: str): + def __init__(self, codeplain_api, memory_folder: str): self.codeplain_api = codeplain_api - self.memory_folder = os.path.join(conformance_tests_folder, module_name, CODEPLAIN_MEMORY_SUBFOLDER) + self.memory_folder = memory_folder def create_conformance_tests_memory( self, render_context: RenderContext, exit_code: int, conformance_tests_issue: str diff --git a/metadata_utils.py b/metadata_utils.py new file mode 100644 index 00000000..68f86058 --- /dev/null +++ b/metadata_utils.py @@ -0,0 +1,46 @@ +"""Read/write helpers for the per-module metadata file (``module_metadata.json``). + +These helpers operate purely on a metadata path or a plain dict. Module-specific +logic — computing the hashes, assembling the payload from the spec and required +modules — stays on :class:`plain_modules.PlainModule`. +""" + +from __future__ import annotations + +import json +import os + +MODULE_METADATA_FILENAME = "module_metadata.json" +MODULE_FUNCTIONALITIES = "functionalities" +REQUIRED_MODULES_FUNCTIONALITIES = "required_modules_functionalities" + + +def load_metadata(metadata_path: str) -> dict | None: + """Return the parsed metadata dict, or None if the file does not exist.""" + if not os.path.exists(metadata_path): + return None + + with open(metadata_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def write_metadata(metadata_path: str, metadata: dict) -> None: + """Write metadata as indented JSON, creating the parent folder if needed.""" + os.makedirs(os.path.dirname(metadata_path), exist_ok=True) + + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=4) + + +def truncate_functionalities(metadata: dict, keep_count: int) -> bool: + """Trim the functionalities list in ``metadata`` in place to ``keep_count`` entries. + + Returns True if the list was shortened, False if it was already short enough + (in which case ``metadata`` is left untouched). + """ + functionalities = metadata.get(MODULE_FUNCTIONALITIES, []) + if len(functionalities) <= keep_count: + return False + + metadata[MODULE_FUNCTIONALITIES] = functionalities[:keep_count] + return True diff --git a/module_renderer.py b/module_renderer.py index 3647e8ab..6640d619 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -1,5 +1,4 @@ import argparse -import os import threading from event_bus import EventBus @@ -48,9 +47,8 @@ def _build_render_context_for_module( self.codeplainAPI, memory_manager, plain_module, - build_folder=os.path.join(self.args.build_folder, plain_module.module_name), + build_folder=plain_module.module_build_folder, build_dest=self.args.build_dest, - conformance_tests_folder=self.args.conformance_tests_folder, conformance_tests_dest=self.args.conformance_tests_dest, unittests_script=self.args.unittests_script, conformance_tests_script=self.args.conformance_tests_script, @@ -115,8 +113,7 @@ def _render_module( memory_manager = MemoryManager( self.codeplainAPI, - plain_module.module_name, - self.args.conformance_tests_folder, + plain_module.module_memory_folder, ) render_context = self._build_render_context_for_module( plain_module, @@ -165,7 +162,7 @@ def render_module(self) -> None: if self.args.copy_build: rendered_code_path = f"{self.args.build_dest}/" else: - rendered_code_path = self.args.build_folder + rendered_code_path = self.plain_module.module_build_folder self.run_state.set_render_generated_code_path(rendered_code_path) self.event_bus.publish(RenderCompleted(rendered_code_path=rendered_code_path)) diff --git a/plain2code.py b/plain2code.py index 4a24085a..ef60ddf4 100644 --- a/plain2code.py +++ b/plain2code.py @@ -212,6 +212,12 @@ def render( # noqa: C901 warn_if_acceptance_tests_without_conformance_script(plain_module, args) + # The module_metadata file lives outside the code git repo. That means that a crash mid-render can leave it + # claiming a functionality was implemented even thought it wasn't yet committed (because of the crash). + # Out of precaution, this reconciles every module_metadata against the code repo. + for module in plain_module.all_required_modules + [plain_module]: + module.reconcile_metadata_with_git() + render_choice = None if render_range is None: plain_module_render_state = get_plain_module_render_state(plain_module) @@ -356,7 +362,6 @@ def main(): # noqa: C901 plain_module = plain_modules.PlainModule( os.path.basename(args.filename), args.build_folder, - args.conformance_tests_folder, template_dirs, ) except Exception as e: diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 7d77ccb8..c4b19ee6 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -16,7 +16,6 @@ DEFAULT_BUILD_FOLDER = "plain_modules" -DEFAULT_CONFORMANCE_TESTS_FOLDER = "conformance_tests" DEFAULT_BUILD_DEST = "dist" DEFAULT_CONFORMANCE_TESTS_DEST = "dist_conformance_tests" @@ -299,23 +298,15 @@ def create_parser(): parser, "--unittests-script", type=str, - help="Shell script to run unit tests on generated code. Receives the build folder path as its first argument (default: 'plain_modules').", - ) - _add_arg( - parser, - "--conformance-tests-folder", - type=non_empty_string, - default=DEFAULT_CONFORMANCE_TESTS_FOLDER, - help="Folder for conformance test files", - path=True, + help="Shell script to run unit tests on generated code. Receives the module's code folder path as its first argument (e.g. `plain_modules/module_name/code`).", ) _add_arg( parser, "--conformance-tests-script", type=str, help="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.", + "1) Path to a folder (e.g. `plain_modules/module_name/code`) containing generated source code, " + "2) Path to a subfolder of the module's tests folder (e.g. `plain_modules/module_name/tests/subfoldername`) containing test files.", ) _add_arg( @@ -400,7 +391,7 @@ def create_parser(): "--copy-conformance-tests", action="store_true", default=False, - help="If set, copy the conformance tests of code in `--conformance-tests-folder` folder to `--conformance-tests-dest` folder successful rendering. Requires --conformance-tests-script.", + help="If set, copy the module's conformance tests (from `//tests`) to `--conformance-tests-dest` folder after successful rendering. Requires --conformance-tests-script.", ) _add_arg( parser, @@ -490,8 +481,8 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): 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: - parser.error("--conformance-tests-folder and --conformance-tests-dest cannot be the same") + if args.conformance_tests_dest == args.build_folder: + parser.error("--conformance-tests-dest and --build-folder cannot be the same") args.render_conformance_tests = args.conformance_tests_script is not None diff --git a/plain2code_cli.md b/plain2code_cli.md index bab11a12..0a400434 100644 --- a/plain2code_cli.md +++ b/plain2code_cli.md @@ -3,19 +3,19 @@ ```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] [--status] [--version] + [--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] + [--status] [--version] [filename] -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. +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 @@ -32,24 +32,22 @@ with '~') are used as-is. 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'). - --conformance-tests-folder CONFORMANCE_TESTS_FOLDER - Folder for conformance test files + Shell script to run unit tests on generated code. Receives the module's code folder path as its first argument (e.g. `plain_modules/module_name/code`). --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/code`) + containing generated source code, 2) Path to a subfolder of the module's tests folder (e.g. `plain_modules/module_name/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. --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. + --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 @@ -59,7 +57,7 @@ with '~') are used as-is. --build-dest BUILD_DEST 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 + If set, copy the module's conformance tests (from `//tests`) to `--conformance-tests-dest` folder after 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). diff --git a/plain_modules.py b/plain_modules.py index 0812135e..cda701ca 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import os import shutil from functools import cached_property @@ -13,16 +12,29 @@ raise GitNotInstalledError("git is not installed. Please install git and try again.") import git_utils +import metadata_utils import plain_file import plain_spec +from metadata_utils import ( + MODULE_FUNCTIONALITIES, + MODULE_METADATA_FILENAME, + REQUIRED_MODULES_FUNCTIONALITIES, +) from plain2code_console import console from render_machine.implementation_code_helpers import ImplementationCodeHelpers CODEPLAIN_MEMORY_SUBFOLDER = ".memory" CODEPLAIN_METADATA_FOLDER = ".codeplain" -MODULE_METADATA_FILENAME = "module_metadata.json" -MODULE_FUNCTIONALITIES = "functionalities" -REQUIRED_MODULES_FUNCTIONALITIES = "required_modules_functionalities" +MODULE_CODE_SUBFOLDER = "code" +MODULE_TESTS_SUBFOLDER = "tests" + + +def get_module_code_folder(modules_base_folder: str, module_name: str) -> str: + return os.path.join(modules_base_folder, module_name, MODULE_CODE_SUBFOLDER) + + +def get_module_tests_folder(modules_base_folder: str, module_name: str) -> str: + return os.path.join(modules_base_folder, module_name, MODULE_TESTS_SUBFOLDER) def _strip_functional_requirements(plain_source_tree: dict) -> dict: @@ -33,10 +45,9 @@ def _strip_functional_requirements(plain_source_tree: dict) -> dict: class PlainModule: - def __init__(self, filename: str, build_folder: str, conformance_tests_folder: str, template_dirs: list[str]): + def __init__(self, filename: str, build_folder: str, template_dirs: list[str]): self.filename = filename self.build_folder = build_folder - self.conformance_tests_folder = conformance_tests_folder self.template_dirs = template_dirs module_name, plain_source, required_modules_names = plain_file.plain_file_parser( self.filename, self.template_dirs @@ -53,7 +64,6 @@ def __init__(self, filename: str, build_folder: str, conformance_tests_folder: s PlainModule( plain_file.get_filename_from_module_name(module_name), self.build_folder, - self.conformance_tests_folder, self.template_dirs, ) for module_name in required_modules_names @@ -70,16 +80,24 @@ def all_required_modules(self) -> list[PlainModule]: return all_required_modules + @property + def module_folder(self): + return os.path.join(self.build_folder, self.module_name) + @property def module_conformance_tests_folder(self): - return os.path.join(self.conformance_tests_folder, self.module_name) + return get_module_tests_folder(self.build_folder, self.module_name) @property def module_build_folder(self): - return os.path.join(self.build_folder, self.module_name) + return get_module_code_folder(self.build_folder, self.module_name) + + @property + def module_memory_folder(self): + return os.path.join(self.module_folder, CODEPLAIN_MEMORY_SUBFOLDER) def get_codeplain_folder(self): - return os.path.join(self.module_build_folder, CODEPLAIN_METADATA_FOLDER) + return os.path.join(self.module_folder, CODEPLAIN_METADATA_FOLDER) def get_module_render_status(self) -> tuple[str | None, str | None]: module_name, frid = git_utils.get_last_rendered_functionality(self.module_build_folder) @@ -104,16 +122,7 @@ def get_repo(self): return repo def load_module_metadata(self) -> dict | None: - codeplain_folder = self.get_codeplain_folder() - if not os.path.exists(codeplain_folder): - return None - - metadata_path = os.path.join(codeplain_folder, MODULE_METADATA_FILENAME) - if not os.path.exists(metadata_path): - return None - - with open(metadata_path, "r", encoding="utf-8") as f: - return json.load(f) + return metadata_utils.load_metadata(self.module_metadata_path()) def update_frid_in_module_metadata(self, frid: str) -> None: # Store the raw FR markdown (with any {{ code_variable }} placeholders intact), exactly @@ -130,10 +139,7 @@ def update_frid_in_module_metadata(self, frid: str) -> None: functionalities.append(frid_text) metadata[MODULE_FUNCTIONALITIES] = functionalities - codeplain_folder = self.get_codeplain_folder() - os.makedirs(codeplain_folder, exist_ok=True) - with open(self.module_metadata_path(), "w", encoding="utf-8") as f: - json.dump(metadata, f, indent=4) + metadata_utils.write_metadata(self.module_metadata_path(), metadata) def get_module_source_hash(self) -> str: return plain_spec.get_hash_value([self.plain_source] + self.resources_list) @@ -186,10 +192,7 @@ def get_functionalities(self) -> dict[str, list[str]]: return functionalities - def module_metadata_path(self, for_git_repo: bool = False) -> str: - if for_git_repo: - return os.path.join(CODEPLAIN_METADATA_FOLDER, MODULE_METADATA_FILENAME) - + def module_metadata_path(self) -> str: return os.path.join(self.get_codeplain_folder(), MODULE_METADATA_FILENAME) def get_hashes(self) -> dict[str, str]: @@ -201,12 +204,46 @@ def get_hashes(self) -> dict[str, str]: hashes["required_modules_code_hash"] = self.required_modules[-1].get_module_code_hash() return hashes - def save_module_metadata(self): - codeplain_folder = self.get_codeplain_folder() - os.makedirs(codeplain_folder, exist_ok=True) + def seed_module_metadata(self) -> None: + """Write a fresh metadata file containing only the module hashes. + Called at the start of a full render, before any functionality is + rendered, so change detection has a clean baseline. + """ + metadata_utils.write_metadata(self.module_metadata_path(), self.get_hashes()) + + def truncate_metadata_functionalities(self, frid: str | None) -> None: + """Trim the stored functionalities list to the first int(frid) entries. + + The metadata file is not tracked in the module's git repo, so when the + code repo is reverted to an earlier functionality the stored list must + be trimmed to match the reverted code. A frid of None means no + functionality is implemented (empty list). + """ + metadata = self.load_module_metadata() + if metadata is None: + return + + keep_count = int(frid) if frid is not None else 0 + if metadata_utils.truncate_functionalities(metadata, keep_count): + metadata_utils.write_metadata(self.module_metadata_path(), metadata) + + def revert_code_to_frid(self, frid: str | None) -> None: + """Revert the code repo to the commit for frid and keep metadata in sync.""" + git_utils.revert_to_commit_with_frid(self.module_build_folder, frid) + self.truncate_metadata_functionalities(frid) + + def reconcile_metadata_with_git(self) -> None: + """ + Trim the metadata functionalities list to what the code repo actually committed. + """ + module_name, frid = git_utils.get_last_rendered_functionality(self.module_build_folder) + own_frid = frid if module_name == self.module_name else None + + self.truncate_metadata_functionalities(own_frid) + + def save_module_metadata(self): module_metadata = self.get_hashes() - metadata_path = self.module_metadata_path() module_metadata[MODULE_FUNCTIONALITIES] = self._get_module_functional_requirements() required_modules_functionalities = {} @@ -216,8 +253,7 @@ def save_module_metadata(self): if required_modules_functionalities: module_metadata[REQUIRED_MODULES_FUNCTIONALITIES] = required_modules_functionalities - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(module_metadata, f, indent=4) + metadata_utils.write_metadata(self.module_metadata_path(), module_metadata) def _ensure_module_folders_exist(self, first_render_frid: str, render_conformance_tests: bool): """ @@ -372,10 +408,6 @@ def has_no_rendered_functionality(self) -> bool: return False def wipe_module(self) -> None: - if os.path.exists(self.module_build_folder): - console.warning(f"Wiping module {self.module_build_folder}...") - shutil.rmtree(self.module_build_folder) - - if os.path.exists(self.module_conformance_tests_folder): - console.warning(f"Wiping conformance tests for module {self.module_conformance_tests_folder}...") - shutil.rmtree(self.module_conformance_tests_folder) + if os.path.exists(self.module_folder): + console.warning(f"Wiping module {self.module_folder}...") + shutil.rmtree(self.module_folder) diff --git a/render_machine/actions/prepare_repositories.py b/render_machine/actions/prepare_repositories.py index 4058728f..a98e2cc1 100644 --- a/render_machine/actions/prepare_repositories.py +++ b/render_machine/actions/prepare_repositories.py @@ -1,4 +1,3 @@ -import json from typing import Any import file_utils @@ -24,7 +23,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | console.debug(f"Reverting code to version implemented for {previous_frid}.") - git_utils.revert_to_commit_with_frid(render_context.build_folder, previous_frid) + render_context.plain_module.revert_code_to_frid(previous_frid) # conformance tests are still not fully implemented if render_context.render_conformance_tests: git_utils.revert_to_commit_with_frid( @@ -33,22 +32,18 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | ) else: - module_hashes = render_context.plain_module.get_hashes() - initial_files = { - render_context.plain_module.module_metadata_path(for_git_repo=True): json.dumps(module_hashes) - } + file_utils.delete_folder(render_context.plain_module.module_folder) + render_context.plain_module.seed_module_metadata() if render_context.required_modules: previous_module = render_context.required_modules[-1] console.debug(f"Cloning git repo from module {previous_module.module_name}.") - file_utils.delete_folder(render_context.build_folder) git_utils.clone_repo( previous_module.module_build_folder, render_context.build_folder, render_context.module_name, render_context.run_state.render_id, - initial_files, ) else: console.debug("Initializing git repositories for the render folders.") @@ -57,7 +52,6 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.build_folder, render_context.module_name, render_context.run_state.render_id, - initial_files, ) if render_context.base_folder: @@ -75,7 +69,6 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.conformance_tests.get_module_conformance_tests_folder(render_context.module_name), render_context.module_name, render_context.run_state.render_id, - initial_files, ) return self.SUCCESSFUL_OUTCOME, None diff --git a/render_machine/conformance_tests.py b/render_machine/conformance_tests.py index 520980a1..d43f3bf4 100644 --- a/render_machine/conformance_tests.py +++ b/render_machine/conformance_tests.py @@ -4,7 +4,7 @@ import file_utils from plain2code_console import console from plain2code_exceptions import InternalClientError -from plain_modules import PlainModule +from plain_modules import PlainModule, get_module_tests_folder CONFORMANCE_TESTS_DEFINITION_FILE_NAME = "conformance_tests.json" @@ -14,14 +14,14 @@ class ConformanceTests: def __init__( self, - conformance_tests_folder: str, + modules_base_folder: str, conformance_tests_definition_file_name: str, ): - self.conformance_tests_folder = conformance_tests_folder + self.modules_base_folder = modules_base_folder self.conformance_tests_definition_file_name = conformance_tests_definition_file_name def get_module_conformance_tests_folder(self, module_name: str) -> str: - return os.path.join(self.conformance_tests_folder, module_name) + return get_module_tests_folder(self.modules_base_folder, module_name) def _get_full_conformance_tests_definition_file_name(self, module_name: str) -> str: return os.path.join( @@ -81,7 +81,9 @@ def get_source_conformance_test_folder_name( break source_conformance_test_folder_name = ( - self.get_module_conformance_tests_folder(copy_from_module + "/." + current_testing_module_name) + os.path.join( + self.get_module_conformance_tests_folder(copy_from_module), "." + current_testing_module_name + ) + conformance_test_subfolder_name ) @@ -89,7 +91,7 @@ def get_source_conformance_test_folder_name( break new_conformance_test_folder_name = ( - self.get_module_conformance_tests_folder(module_name + "/." + current_testing_module_name) + os.path.join(self.get_module_conformance_tests_folder(module_name), "." + current_testing_module_name) + conformance_test_subfolder_name ) diff --git a/render_machine/render_context.py b/render_machine/render_context.py index fff78ebf..2ea7e806 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -34,7 +34,6 @@ def __init__( plain_module: PlainModule, build_folder: str, build_dest: str, - conformance_tests_folder: str, conformance_tests_dest: str, unittests_script: str, conformance_tests_script: str, @@ -59,7 +58,6 @@ def __init__( self.required_modules = plain_module.required_modules self.build_folder = build_folder self.build_dest = build_dest - self.conformance_tests_folder = conformance_tests_folder self.conformance_tests_dest = conformance_tests_dest self.unittests_script = unittests_script self.conformance_tests_script = conformance_tests_script @@ -91,7 +89,7 @@ def __init__( self.functional_requirements_render_attempts_failed_unit_during_conformance_tests = 0 # Initialize conformance tests utilities self.conformance_tests = ConformanceTests( - conformance_tests_folder=self.conformance_tests_folder, + modules_base_folder=plain_module.build_folder, conformance_tests_definition_file_name=CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ) diff --git a/render_machine/render_utils.py b/render_machine/render_utils.py index 7e22e4c5..790ec61e 100644 --- a/render_machine/render_utils.py +++ b/render_machine/render_utils.py @@ -12,7 +12,6 @@ import fcntl import file_utils -import git_utils import plain_spec from plain2code_console import MUTED_COLOR, RETRY_COLOR, SUCCESS_COLOR, console from plain2code_exceptions import RenderCancelledError @@ -29,7 +28,7 @@ def revert_changes_for_frid(render_context): if render_context.frid_context.frid is not None: previous_frid = plain_spec.get_previous_frid(render_context.plain_source_tree, render_context.frid_context.frid) - git_utils.revert_to_commit_with_frid(render_context.build_folder, previous_frid) + render_context.plain_module.revert_code_to_frid(previous_frid) def print_inputs(render_context, existing_files_content, message): diff --git a/tests/e2e/test_hello_world_python.py b/tests/e2e/test_hello_world_python.py index a6f792a6..b6026872 100644 --- a/tests/e2e/test_hello_world_python.py +++ b/tests/e2e/test_hello_world_python.py @@ -28,7 +28,7 @@ def test_render_and_run_hello_world_python(e2e_container, exec_in_container, cop ) assert rc == 0, f"codeplain render failed (rc={rc}):\nstdout:\n{out}\nstderr:\n{err}" - generated = "build/hello_world_python/hello_world.py" + generated = "build/hello_world_python/code/hello_world.py" rc, _, _ = exec_in_container(e2e_container, f"test -f {generated}") assert rc == 0, f"expected generated file at {generated} but it was not produced" diff --git a/tests/e2e/test_hello_world_python_windows.py b/tests/e2e/test_hello_world_python_windows.py index 23a45e89..49688aa2 100644 --- a/tests/e2e/test_hello_world_python_windows.py +++ b/tests/e2e/test_hello_world_python_windows.py @@ -49,7 +49,7 @@ def test_render_and_run_hello_world_python_windows(codeplain_exe: Path, api_key: f"codeplain render failed (rc={result.returncode}):\n" f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) - generated = tmp_path / "build" / "hello_world_python" / "hello_world.py" + generated = tmp_path / "build" / "hello_world_python" / "code" / "hello_world.py" assert generated.exists(), f"expected generated file at {generated} but it was not produced" result = subprocess.run( diff --git a/tests/test_arg_provenance.py b/tests/test_arg_provenance.py index 8fc57cd8..0ef6807f 100644 --- a/tests/test_arg_provenance.py +++ b/tests/test_arg_provenance.py @@ -26,7 +26,7 @@ def _sources(args): 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" + assert _sources(args)["build_dest"] == "default" def test_cli_value_is_marked_as_cli(project): @@ -82,8 +82,8 @@ 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-dest", "ct_from_cli"]) srcs = _sources(args) - assert srcs["conformance_tests_folder"] == "cli" + assert srcs["conformance_tests_dest"] == "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 index 8c1dd0e2..88058aa5 100644 --- a/tests/test_folder_path_resolution.py +++ b/tests/test_folder_path_resolution.py @@ -1,7 +1,7 @@ """Tests for folder-path argument resolution. -Covers --base-folder, --build-folder, --conformance-tests-folder, ---build-dest, --conformance-tests-dest, --template-dir. +Covers --base-folder, --build-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 @@ -19,7 +19,6 @@ DEFAULT_BUILD_DEST, DEFAULT_BUILD_FOLDER, DEFAULT_CONFORMANCE_TESTS_DEST, - DEFAULT_CONFORMANCE_TESTS_FOLDER, parse_arguments, ) @@ -52,12 +51,6 @@ def test_missing_build_folder_defaults_next_to_spec(layout): 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"]]) @@ -126,17 +119,23 @@ 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" - ) + (layout["config"] / "config.yaml").write_text("build-folder: b\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_removed_conformance_tests_folder_config_key_errors(layout, capsys): + (layout["config"] / "config.yaml").write_text("conformance-tests-folder: ct\n") + with patch("os.getcwd", return_value=str(layout["config"])): + with pytest.raises(SystemExit): + parse_arguments([layout["plain_file"]]) + err = capsys.readouterr().err + assert "Invalid configuration key: conformance-tests-folder" in err + + 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"])): diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py index 1a51db43..84c93975 100644 --- a/tests/test_plain2code.py +++ b/tests/test_plain2code.py @@ -77,11 +77,10 @@ def test_warning_covers_required_modules_for_real_plain_module(get_test_data_pat module with acceptance tests should still trigger the warning, naming the required module. This mirrors the dry-run path which builds a real PlainModule.""" fixtures_dir = get_test_data_path("data/acceptance_tests_warning") - with tempfile.TemporaryDirectory() as build, tempfile.TemporaryDirectory() as conformance: + with tempfile.TemporaryDirectory() as build: plain_module = PlainModule( "main_requiring_acceptance_tests.plain", build, - conformance, [fixtures_dir], ) diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index c0148eba..094a857e 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -15,7 +15,7 @@ from change_detection import determine_partial_render_start from git_utils import FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE, add_all_files_and_commit, init_git_repo from plain2code_exceptions import ModuleDoesNotExistError -from plain_modules import CODEPLAIN_METADATA_FOLDER, MODULE_METADATA_FILENAME, PlainModule +from plain_modules import MODULE_METADATA_FILENAME, PlainModule # -------------------------------------------------------------------------- # Fixtures @@ -28,36 +28,33 @@ def fixtures_dir(get_test_data_path): @pytest.fixture -def tmp_build_folders(): - """Yield (build_folder, conformance_tests_folder) as temp dirs.""" - with tempfile.TemporaryDirectory() as build, tempfile.TemporaryDirectory() as conformance: - yield build, conformance +def tmp_build_folder(): + """Yield a temp build folder holding the per-module trees.""" + with tempfile.TemporaryDirectory() as build: + yield build @pytest.fixture -def solo_module(fixtures_dir, tmp_build_folders): - build, conformance = tmp_build_folders - return PlainModule("pr_solo.plain", build, conformance, [fixtures_dir]) +def solo_module(fixtures_dir, tmp_build_folder): + return PlainModule("pr_solo.plain", tmp_build_folder, [fixtures_dir]) @pytest.fixture -def root_module(fixtures_dir, tmp_build_folders): +def root_module(fixtures_dir, tmp_build_folder): """Builds pr_root -> pr_middle -> pr_leaf, each with 2 FRIDs.""" - build, conformance = tmp_build_folders - return PlainModule("pr_root.plain", build, conformance, [fixtures_dir]) + return PlainModule("pr_root.plain", tmp_build_folder, [fixtures_dir]) @pytest.fixture -def code_var_module(fixtures_dir, tmp_build_folders): +def code_var_module(fixtures_dir, tmp_build_folder): """A solo module whose FRID 2 pulls in a template with a code variable, so its raw markdown keeps a ``{{ variable_name }}`` placeholder that differs from the rendered (variable-substituted) text.""" - build, conformance = tmp_build_folders - return PlainModule("pr_code_var.plain", build, conformance, [fixtures_dir]) + return PlainModule("pr_code_var.plain", tmp_build_folder, [fixtures_dir]) def _write_metadata(module: PlainModule, metadata: dict) -> None: - folder = os.path.join(module.module_build_folder, CODEPLAIN_METADATA_FOLDER) + folder = module.get_codeplain_folder() os.makedirs(folder, exist_ok=True) with open(os.path.join(folder, MODULE_METADATA_FILENAME), "w", encoding="utf-8") as f: json.dump(metadata, f) @@ -341,3 +338,191 @@ def test_code_variable_frid_not_flagged_as_change(code_var_module): code_var_module.update_frid_in_module_metadata("2") assert determine_partial_render_start(code_var_module) is None + + +# -------------------------------------------------------------------------- +# module folder layout +# -------------------------------------------------------------------------- + + +def test_module_folder_layout(solo_module, tmp_build_folder): + module_folder = os.path.join(tmp_build_folder, "pr_solo") + assert solo_module.module_folder == module_folder + assert solo_module.module_build_folder == os.path.join(module_folder, "code") + assert solo_module.module_conformance_tests_folder == os.path.join(module_folder, "tests") + assert solo_module.get_codeplain_folder() == os.path.join(module_folder, ".codeplain") + assert solo_module.module_memory_folder == os.path.join(module_folder, ".memory") + + +def test_wipe_module_removes_whole_module_folder(solo_module): + os.makedirs(solo_module.module_build_folder) + os.makedirs(solo_module.module_conformance_tests_folder) + solo_module.wipe_module() + assert not os.path.exists(solo_module.module_folder) + + +# -------------------------------------------------------------------------- +# seed_module_metadata +# -------------------------------------------------------------------------- + + +def test_seed_module_metadata_writes_hashes(solo_module): + solo_module.seed_module_metadata() + assert solo_module.load_module_metadata() == solo_module.get_hashes() + + +def test_seed_module_metadata_overwrites_stale_functionalities(solo_module): + _write_metadata(solo_module, {"source_hash": "stale", "functionalities": ["old fr"]}) + solo_module.seed_module_metadata() + metadata = solo_module.load_module_metadata() + assert metadata == solo_module.get_hashes() + assert "functionalities" not in metadata + + +# -------------------------------------------------------------------------- +# truncate_metadata_functionalities +# -------------------------------------------------------------------------- + + +def test_truncate_metadata_functionalities_no_metadata_is_noop(solo_module): + solo_module.truncate_metadata_functionalities("1") + assert solo_module.load_module_metadata() is None + + +def test_truncate_metadata_functionalities_shortens_list_and_keeps_hashes(solo_module): + _write_metadata(solo_module, {"source_hash": "abc", "functionalities": ["fr1", "fr2", "fr3"]}) + solo_module.truncate_metadata_functionalities("1") + metadata = solo_module.load_module_metadata() + assert metadata["functionalities"] == ["fr1"] + assert metadata["source_hash"] == "abc" + + +def test_truncate_metadata_functionalities_noop_when_list_short_enough(solo_module): + _write_metadata(solo_module, {"functionalities": ["fr1"]}) + solo_module.truncate_metadata_functionalities("2") + assert solo_module.load_module_metadata()["functionalities"] == ["fr1"] + + +def test_truncate_metadata_functionalities_none_frid_empties_list(solo_module): + _write_metadata(solo_module, {"functionalities": ["fr1", "fr2"]}) + solo_module.truncate_metadata_functionalities(None) + assert solo_module.load_module_metadata()["functionalities"] == [] + + +# -------------------------------------------------------------------------- +# revert_code_to_frid +# -------------------------------------------------------------------------- + + +def _commit_finished_frid(module: PlainModule, frid: str) -> None: + marker = Path(module.module_build_folder) / f"frid_{frid}.txt" + marker.write_text(f"frid {frid}\n") + add_all_files_and_commit( + module.module_build_folder, + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(frid), + module_name=module.module_name, + frid=frid, + ) + + +def test_revert_code_to_frid_reverts_repo_and_trims_metadata(solo_module): + os.makedirs(solo_module.module_build_folder) + init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) + _commit_finished_frid(solo_module, "1") + _commit_finished_frid(solo_module, "2") + _write_metadata(solo_module, {"source_hash": "abc", "functionalities": ["fr1", "fr2"]}) + + solo_module.revert_code_to_frid("1") + + assert os.path.exists(os.path.join(solo_module.module_build_folder, "frid_1.txt")) + assert not os.path.exists(os.path.join(solo_module.module_build_folder, "frid_2.txt")) + metadata = solo_module.load_module_metadata() + assert metadata["functionalities"] == ["fr1"] + assert metadata["source_hash"] == "abc" + # The metadata folder lives outside the code repo and must survive the revert. + assert os.path.exists(solo_module.get_codeplain_folder()) + + +def test_revert_code_to_frid_none_reverts_to_initial_state(solo_module): + os.makedirs(solo_module.module_build_folder) + init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) + _commit_finished_frid(solo_module, "1") + _write_metadata(solo_module, {"functionalities": ["fr1"]}) + + solo_module.revert_code_to_frid(None) + + assert not os.path.exists(os.path.join(solo_module.module_build_folder, "frid_1.txt")) + assert solo_module.load_module_metadata()["functionalities"] == [] + + +# -------------------------------------------------------------------------- +# reconcile_metadata_with_git +# -------------------------------------------------------------------------- + + +def test_reconcile_metadata_with_git_trims_metadata_ahead_of_git(solo_module): + # Simulates the crash window: metadata records FR 2 but git only committed FR 1. + os.makedirs(solo_module.module_build_folder) + init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) + _commit_finished_frid(solo_module, "1") + _write_metadata(solo_module, {"source_hash": "abc", "functionalities": ["fr1", "fr2"]}) + + solo_module.reconcile_metadata_with_git() + + metadata = solo_module.load_module_metadata() + assert metadata["functionalities"] == ["fr1"] + # Only the functionalities baseline is trimmed; the hashes are left untouched. + assert metadata["source_hash"] == "abc" + + +def test_reconcile_metadata_with_git_in_sync_is_noop(solo_module): + os.makedirs(solo_module.module_build_folder) + init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) + _commit_finished_frid(solo_module, "1") + _commit_finished_frid(solo_module, "2") + _write_metadata(solo_module, {"functionalities": ["fr1", "fr2"]}) + + solo_module.reconcile_metadata_with_git() + + assert solo_module.load_module_metadata()["functionalities"] == ["fr1", "fr2"] + + +def test_reconcile_metadata_with_git_no_finished_frid_empties_list(solo_module): + # Only the initial commit exists (no finished FRID), so the baseline must be emptied. + os.makedirs(solo_module.module_build_folder) + init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) + _write_metadata(solo_module, {"functionalities": ["fr1"]}) + + solo_module.reconcile_metadata_with_git() + + assert solo_module.load_module_metadata()["functionalities"] == [] + + +def test_reconcile_metadata_with_git_ignores_foreign_module_frid(solo_module): + # A repo cloned from a required module carries that module's finished FRID; this + # module has rendered none of its own, so its baseline must be emptied. + os.makedirs(solo_module.module_build_folder) + init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) + marker = Path(solo_module.module_build_folder) / "foreign.txt" + marker.write_text("foreign\n") + add_all_files_and_commit( + solo_module.module_build_folder, + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), + module_name="other-module", + frid="1", + ) + _write_metadata(solo_module, {"functionalities": ["fr1"]}) + + solo_module.reconcile_metadata_with_git() + + assert solo_module.load_module_metadata()["functionalities"] == [] + + +def test_reconcile_metadata_with_git_no_metadata_is_noop(solo_module): + os.makedirs(solo_module.module_build_folder) + init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) + _commit_finished_frid(solo_module, "1") + + solo_module.reconcile_metadata_with_git() + + assert solo_module.load_module_metadata() is None diff --git a/tests/test_prepare_repositories_layout.py b/tests/test_prepare_repositories_layout.py new file mode 100644 index 00000000..0ea658d3 --- /dev/null +++ b/tests/test_prepare_repositories_layout.py @@ -0,0 +1,119 @@ +"""Tests for the per-module output layout produced by ``PrepareRepositories`` +and the tests-folder paths derived by ``ConformanceTests``. + +Each module renders into a single tree under the build folder: + + //.codeplain/ metadata, outside the git repos + //code/ git repo with the implementation code + //tests/ git repo with the conformance tests +""" + +import os +import tempfile +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from plain_modules import PlainModule +from render_machine.actions.prepare_repositories import PrepareRepositories +from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture +def tmp_build_folder(): + with tempfile.TemporaryDirectory() as build: + yield build + + +@pytest.fixture +def solo_module(get_test_data_path, tmp_build_folder): + return PlainModule("pr_solo.plain", tmp_build_folder, [get_test_data_path("data/partial_rendering")]) + + +def _make_render_context(module: PlainModule, render_conformance_tests: bool) -> SimpleNamespace: + return SimpleNamespace( + render_range=None, + plain_module=module, + required_modules=module.required_modules, + build_folder=module.module_build_folder, + module_name=module.module_name, + run_state=SimpleNamespace(render_id="test-render-id"), + render_conformance_tests=render_conformance_tests, + conformance_tests=ConformanceTests(module.build_folder, CONFORMANCE_TESTS_DEFINITION_FILE_NAME), + base_folder=None, + ) + + +# -------------------------------------------------------------------------- +# PrepareRepositories — fresh render +# -------------------------------------------------------------------------- + + +def test_fresh_render_creates_code_and_tests_repos_and_seeds_metadata(solo_module): + render_context = _make_render_context(solo_module, render_conformance_tests=True) + + PrepareRepositories().execute(render_context, None) + + assert os.path.isdir(os.path.join(solo_module.module_build_folder, ".git")) + assert os.path.isdir(os.path.join(solo_module.module_conformance_tests_folder, ".git")) + assert solo_module.load_module_metadata() == solo_module.get_hashes() + + +def test_fresh_render_wipes_the_module_folder_first(solo_module): + stale_file = Path(solo_module.module_folder) / "stale.txt" + stale_memory = Path(solo_module.module_memory_folder) / "stale_memory.md" + stale_memory.parent.mkdir(parents=True) + stale_memory.write_text("stale") + stale_file.write_text("stale") + + render_context = _make_render_context(solo_module, render_conformance_tests=True) + PrepareRepositories().execute(render_context, None) + + assert not stale_file.exists() + assert not stale_memory.exists() + + +def test_fresh_render_without_conformance_tests_does_not_create_tests_folder(solo_module): + render_context = _make_render_context(solo_module, render_conformance_tests=False) + + PrepareRepositories().execute(render_context, None) + + assert os.path.isdir(os.path.join(solo_module.module_build_folder, ".git")) + assert not os.path.exists(solo_module.module_conformance_tests_folder) + + +# -------------------------------------------------------------------------- +# ConformanceTests — tests-folder paths +# -------------------------------------------------------------------------- + + +def test_module_conformance_tests_folder_is_tests_subfolder(tmp_build_folder): + conformance_tests = ConformanceTests(tmp_build_folder, CONFORMANCE_TESTS_DEFINITION_FILE_NAME) + assert conformance_tests.get_module_conformance_tests_folder("some_module") == os.path.join( + tmp_build_folder, "some_module", "tests" + ) + + +def test_cross_module_copy_lands_in_hidden_folder_under_tests(tmp_build_folder): + """When a module regression-tests a required module, the copied conformance + tests land in //tests/./.""" + conformance_tests = ConformanceTests(tmp_build_folder, CONFORMANCE_TESTS_DEFINITION_FILE_NAME) + original_folder = os.path.join( + conformance_tests.get_module_conformance_tests_folder("required_module"), "1_frid_feature" + ) + + source_folder, new_folder = conformance_tests.get_source_conformance_test_folder_name( + "top_module", + [], + "required_module", + original_folder, + ) + + expected = os.path.join(tmp_build_folder, "top_module", "tests", ".required_module", "1_frid_feature") + assert new_folder == expected + assert source_folder == expected