From 6b14796187c099da0106b043f604b09ffd078e3c Mon Sep 17 00:00:00 2001 From: zanjonke Date: Wed, 15 Apr 2026 15:45:46 +0200 Subject: [PATCH 01/14] Adding get_last_finished_frid --- git_utils.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/git_utils.py b/git_utils.py index 1140cca..5cb8696 100644 --- a/git_utils.py +++ b/git_utils.py @@ -1,4 +1,5 @@ import os +import re from configparser import NoOptionError, NoSectionError from typing import Optional, Union @@ -415,3 +416,15 @@ def get_repo_info(repo_path: Union[str, os.PathLike]) -> dict: info["remotes"] = remotes return info + + +def get_last_finished_frid(repo_path: Union[str, os.PathLike]) -> Optional[str]: + repo = Repo(repo_path) + commit_sha = repo.git.rev_list( + repo.active_branch.name, "--grep", FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*"), "-n", "1" + ) + if not commit_sha: + return None + commit_message = repo.commit(commit_sha).message + match = re.search(r"FRID\):(\S+) fully implemented", commit_message) + return match.group(1) if match else None From aecbb60edc34c80e2ba0d37fcbebf4dcd5df4485 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Thu, 16 Apr 2026 10:09:15 +0200 Subject: [PATCH 02/14] Refactoring plain_modules.py --- module_renderer.py | 194 ++++-------------- plain_file.py | 8 + plain_modules.py | 184 ++++++++++++++--- .../actions/prepare_repositories.py | 4 +- render_machine/conformance_tests.py | 2 +- render_machine/render_context.py | 4 +- 6 files changed, 211 insertions(+), 185 deletions(-) diff --git a/module_renderer.py b/module_renderer.py index 15c1e36..e4c0515 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -42,114 +42,6 @@ def __init__( self.stop_event = stop_event self.enter_pause_event = enter_pause_event - def _ensure_module_folders_exist(self, module_name: str, first_render_frid: str) -> tuple[str, str]: - """ - Ensure that build and conformance test folders exist for the module. - - Args: - module_name: Name of the module being rendered - first_render_frid: The first FRID in the render range - - Returns: - tuple[str, str]: (build_folder_path, conformance_tests_path) - - Raises: - MissingPreviousFridCommitsError: If any required folders are missing - """ - build_folder_path = os.path.join(self.args.build_folder, module_name) - conformance_tests_path = os.path.join(self.args.conformance_tests_folder, module_name) - - if not os.path.exists(build_folder_path): - raise MissingPreviousFunctionalitiesError( - f"Cannot start rendering from functionality {first_render_frid} for module '{module_name}' because the source code folder does not exist.\n\n" - f"To fix this, please render the module from the beginning by running:\n" - f" codeplain {module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION}" - ) - - if not os.path.exists(conformance_tests_path) and self.args.render_conformance_tests: - raise MissingPreviousFunctionalitiesError( - f"Cannot start rendering from functionality {first_render_frid} for module '{module_name}' because the conformance tests folder does not exist.\n\n" - f"To fix this, please render the module from the beginning by running:\n" - f" codeplain {module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION}" - ) - - return build_folder_path, conformance_tests_path - - def _ensure_frid_commit_exists( - self, - frid: str, - module_name: str, - build_folder_path: str, - conformance_tests_path: str, - first_render_frid: str, - ) -> None: - """ - Ensure commit exists for a single FRID in both repositories. - - Args: - frid: The FRID to check - module_name: Name of the module - build_folder_path: Path to the build folder - conformance_tests_path: Path to the conformance tests folder - first_render_frid: The first FRID in the render range (for error messages) - - Raises: - MissingPreviousFridCommitsError: If the commit is missing - """ - # Check in build folder - if not git_utils.has_commit_for_frid(build_folder_path, frid, module_name): - raise MissingPreviousFunctionalitiesError( - f"Cannot start rendering from functionality {first_render_frid} for module '{module_name}' because the implementation of the previous functionality ({frid}) hasn't been completed yet.\n\n" - f"To fix this, please render the missing functionality ({frid}) first by running:\n" - f" codeplain {module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" - ) - - # Check in conformance tests folder (only if conformance tests are enabled) - if self.args.render_conformance_tests: - if not git_utils.has_commit_for_frid(conformance_tests_path, frid, module_name): - raise MissingPreviousFunctionalitiesError( - f"Cannot start rendering from functionality {first_render_frid} for module '{module_name}' because the conformance tests for the previous functionality ({frid}) haven't been completed yet.\n\n" - f"To fix this, please render the missing functionality ({frid}) first by running:\n" - f" codeplain {module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" - ) - - def _ensure_previous_frid_commits_exist( - self, module_name: str, plain_source: dict, render_range: list[str] - ) -> None: - """ - Ensure that all FRID commits before the render_range exist. - - This is a precondition check that must pass before rendering can proceed. - Raises an exception if any previous FRID commits are missing. - - Args: - module_name: Name of the module being rendered - plain_source: The plain source tree - render_range: List of FRIDs to render - - Raises: - MissingPreviousFridCommitsError: If any previous FRID commits are missing - """ - first_render_frid = render_range[0] - - # Get all FRIDs that should have been rendered before this one - previous_frids = plain_spec.get_frids_before(plain_source, first_render_frid) - if not previous_frids: - return - - # Ensure the module folders exist - build_folder_path, conformance_tests_path = self._ensure_module_folders_exist(module_name, first_render_frid) - - # Verify commits exist for all previous FRIDs - for prev_frid in previous_frids: - self._ensure_frid_commit_exists( - prev_frid, - module_name, - build_folder_path, - conformance_tests_path, - first_render_frid, - ) - def _build_render_context_for_module( self, module_name: str, @@ -187,67 +79,59 @@ def _build_render_context_for_module( ) def _render_module( - self, filename: str, render_range: list[str] | None, force_render: bool + self, plain_module: PlainModule, render_range: list[str] | None, force_render: bool ) -> tuple[bool, list[PlainModule], bool]: """Render a module. Returns: - tuple[bool, list[PlainModule], bool]: (Whether the module was rendered, the required modules, and whether the rendering failed) + tuple[bool, bool]: (Whether the module was rendered and whether the rendering failed) """ - module_name, plain_source, required_modules_list = plain_file.plain_file_parser(filename, self.template_dirs) - - resources_list = [] - plain_spec.collect_linked_resources(plain_source, resources_list, None, True) - - # Ensure that all previous FRID commits exist before proceeding with render_range if render_range is not None: - self._ensure_previous_frid_commits_exist(module_name, plain_source, render_range) + plain_module.ensure_previous_frid_commits_exist(render_range) - required_modules = [] has_any_required_module_changed = False - if not self.args.render_machine_graph and required_modules_list: - console.debug(f"Analyzing required modules of module {module_name}...") - for required_module_name in required_modules_list: - required_module_filename = required_module_name + plain_file.PLAIN_SOURCE_FILE_EXTENSION - has_module_changed, sub_required_modules, rendering_failed = self._render_module( - required_module_filename, + if not self.args.render_machine_graph and plain_module.required_modules: + console.debug(f"Analyzing required modules of module {plain_module.module_name}...") + for required_module in plain_module.required_modules: + has_any_required_module_changed, rendering_failed = self._render_module( + required_module, None, self.args.force_render, ) if rendering_failed: - return False, required_modules, True - - if has_module_changed: - has_any_required_module_changed = True - - for sub_required_module in sub_required_modules: - if sub_required_module.name not in [m.name for m in required_modules]: - required_modules.append( - plain_modules.PlainModule(sub_required_module.name, self.args.build_folder) - ) - - required_modules.append(plain_modules.PlainModule(required_module_name, self.args.build_folder)) - - plain_module = plain_modules.PlainModule(module_name, self.args.build_folder) - if ( - ((not force_render) or any(module.name == plain_module.name for module in self.loaded_modules)) - and plain_module.get_repo() is not None - and not plain_module.has_plain_spec_changed(plain_source, resources_list) - and not plain_module.has_required_modules_code_changed(required_modules) - and not has_any_required_module_changed + return False, True + + if not ( + force_render + or any(module.filename == plain_module.filename for module in self.loaded_modules) + or plain_module.get_repo() is None + or plain_module.has_plain_spec_changed() + or plain_module.has_required_modules_code_changed() + or has_any_required_module_changed ): - return False, required_modules, False + return False, False - memory_manager = MemoryManager(self.codeplainAPI, os.path.join(self.args.build_folder, module_name)) + memory_manager = MemoryManager( + self.codeplainAPI, + os.path.join( + self.args.build_folder, + plain_module.module_name, + ), + ) render_context = self._build_render_context_for_module( - module_name, memory_manager, plain_source, required_modules, self.template_dirs, render_range + plain_module.module_name, + memory_manager, + plain_module.plain_source, + plain_module.all_required_modules, + self.template_dirs, + render_range, ) code_renderer = CodeRenderer(render_context) if self.args.render_machine_graph: code_renderer.generate_render_machine_graph() - return True, required_modules, False + return True, False code_renderer.run() if code_renderer.render_context.state == States.RENDER_FAILED.value: @@ -256,17 +140,23 @@ def _render_module( fallback_message=code_renderer.render_context.last_error_message, ) code_renderer.render_context.event_bus.publish(RenderFailed(error_message=error_message)) - return False, required_modules, True + return False, True - plain_module.save_module_metadata(plain_source, resources_list, required_modules) + plain_module.save_module_metadata() self.loaded_modules.append(plain_module) - return True, required_modules, False + return True, False def render_module(self) -> None: self.loaded_modules = list[PlainModule]() - _, _, rendering_failed = self._render_module(self.filename, self.render_range, True) + plain_module = plain_modules.PlainModule( + self.filename, + self.args.build_folder, + self.args.conformance_tests_folder, + self.template_dirs, + ) + _, rendering_failed = self._render_module(plain_module, self.render_range, True) if not rendering_failed: # Get the last module that completed rendering if self.args.copy_build: diff --git a/plain_file.py b/plain_file.py index 5ebd414..f7bc73b 100644 --- a/plain_file.py +++ b/plain_file.py @@ -53,6 +53,14 @@ def render_link(self, token: Link) -> Iterable[Fragment]: ) +def get_filename_from_module_name(module_name: str) -> str: + return f"{module_name}{PLAIN_SOURCE_FILE_EXTENSION}" + + +def get_module_name_from_filename(filename: str) -> str: + return filename.replace(PLAIN_SOURCE_FILE_EXTENSION, "") + + def remove_quotes(token): # If the token has no children, there's nothing to remove. if not hasattr(token, "children") or token.children is None: diff --git a/plain_modules.py b/plain_modules.py index e90ef02..6335b05 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -6,8 +6,9 @@ from git.exc import NoSuchPathError import git_utils +import plain_file import plain_spec -from plain2code_exceptions import ModuleDoesNotExistError +from plain2code_exceptions import MissingPreviousFunctionalitiesError, ModuleDoesNotExistError from render_machine.implementation_code_helpers import ImplementationCodeHelpers CODEPLAIN_MEMORY_SUBFOLDER = ".memory" @@ -18,19 +19,60 @@ class PlainModule: - def __init__(self, name: str, build_folder: str): - self.name = name + def __init__(self, filename: str, build_folder: str, conformance_tests_folder: str, template_dirs: list[str]): + self.filename = filename self.build_folder = build_folder - - def get_module_build_folder(self): - return os.path.join(self.build_folder, self.name) + 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 + ) + self.module_name = module_name + resources_list = [] + self.plain_source = plain_source + self.required_modules_names = required_modules_names + plain_spec.collect_linked_resources(plain_source, resources_list, None, True) + self.resources_list = resources_list + self.required_modules = [] + if len(required_modules_names) > 0: + self.required_modules = [ + 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 + ] + + @property + def all_required_modules(self) -> list[PlainModule]: + all_required_modules = [] + for required_module in self.required_modules: + if len(required_module.required_modules) > 0: + all_required_modules.extend(required_module.all_required_modules) + + all_required_modules.append(required_module) + + return all_required_modules + + @property + def module_conformance_tests_folder(self): + return os.path.join(self.conformance_tests_folder, self.module_name) + + @property + def module_build_folder(self): + return os.path.join(self.build_folder, self.module_name) def get_codeplain_folder(self): - return os.path.join(self.get_module_build_folder(), CODEPLAIN_METADATA_FOLDER) + return os.path.join(self.module_build_folder, CODEPLAIN_METADATA_FOLDER) + + def get_last_rendered_frid(self) -> str | None: + return git_utils.get_last_finished_frid(self.module_build_folder) def get_repo(self): try: - repo = git_utils.get_repo_info(self.get_module_build_folder()) + repo = git_utils.get_repo_info(self.module_build_folder) except NoSuchPathError: repo = None @@ -48,17 +90,16 @@ def load_module_metadata(self) -> dict | None: with open(metadata_path, "r", encoding="utf-8") as f: return json.load(f) - def get_module_source_hash(self, plain_source: dict, resources_list: list[dict]) -> str: - return plain_spec.get_hash_value([plain_source] + resources_list) + def get_module_source_hash(self) -> str: + return plain_spec.get_hash_value([self.plain_source] + self.resources_list) def get_module_code_hash(self) -> str: - return ImplementationCodeHelpers.calculate_build_folder_hash(self.get_module_build_folder()) + return ImplementationCodeHelpers.calculate_build_folder_hash(self.module_build_folder) def has_required_modules_code_changed( self, - required_modules: list[PlainModule] | None, ) -> bool: - if required_modules is None or len(required_modules) == 0: + if self.required_modules is None or len(self.required_modules) == 0: return False module_metadata = self.load_module_metadata() @@ -66,10 +107,10 @@ def has_required_modules_code_changed( if not module_metadata or "required_modules_code_hash" not in module_metadata: return True - previous_module = required_modules[-1] + previous_module = self.required_modules[-1] return module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() - def has_plain_spec_changed(self, plain_source: dict, resources_list: list[dict]) -> bool: + def has_plain_spec_changed(self) -> bool: module_metadata = self.load_module_metadata() if not module_metadata: @@ -78,12 +119,12 @@ def has_plain_spec_changed(self, plain_source: dict, resources_list: list[dict]) if "source_hash" not in module_metadata: return True - return module_metadata["source_hash"] != self.get_module_source_hash(plain_source, resources_list) + return module_metadata["source_hash"] != self.get_module_source_hash() - def _get_module_functional_requirements(self, plain_source: dict) -> list[str]: + def _get_module_functional_requirements(self) -> list[str]: module_functional_requirements = [] - for functional_requirement in plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS]: + for functional_requirement in self.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS]: module_functional_requirements.append(functional_requirement["markdown"]) return module_functional_requirements @@ -91,37 +132,34 @@ def _get_module_functional_requirements(self, plain_source: dict) -> list[str]: def get_functionalities(self) -> dict[str, list[str]]: module_metadata = self.load_module_metadata() if module_metadata is None: - raise ModuleDoesNotExistError(f"Module {self.name} does not exist or has no metadata.") + raise ModuleDoesNotExistError(f"Module {self.module_name} does not exist or has no metadata.") if REQUIRED_MODULES_FUNCTIONALITIES in module_metadata: functionalities = module_metadata[REQUIRED_MODULES_FUNCTIONALITIES] else: functionalities = {} - functionalities[self.name] = module_metadata[MODULE_FUNCTIONALITIES] + functionalities[self.module_name] = module_metadata[MODULE_FUNCTIONALITIES] return functionalities def save_module_metadata( self, - plain_source: dict, - resources_list: list[dict], - required_modules: list[PlainModule] | None = None, ): codeplain_folder = self.get_codeplain_folder() os.makedirs(codeplain_folder, exist_ok=True) module_metadata = { - "source_hash": self.get_module_source_hash(plain_source, resources_list), - MODULE_FUNCTIONALITIES: self._get_module_functional_requirements(plain_source), + "source_hash": self.get_module_source_hash(), + MODULE_FUNCTIONALITIES: self._get_module_functional_requirements(), } - if required_modules is not None and len(required_modules) > 0: - previous_module = required_modules[-1] + if self.required_modules is not None and len(self.required_modules) > 0: + previous_module = self.required_modules[-1] module_metadata["required_modules_code_hash"] = previous_module.get_module_code_hash() required_modules_functionalities = {} - for required_module in required_modules: + for required_module in self.required_modules: required_modules_functionalities.update(required_module.get_functionalities()) if required_modules_functionalities: @@ -130,3 +168,93 @@ def save_module_metadata( metadata_path = os.path.join(codeplain_folder, MODULE_METADATA_FILENAME) with open(metadata_path, "w", encoding="utf-8") as f: json.dump(module_metadata, f, indent=4) + + def _ensure_module_folders_exist(self, first_render_frid: str, render_conformance_tests: bool) -> tuple[str, str]: + """ + Ensure that build and conformance test folders exist for the module. + + Args: + first_render_frid: The first FRID in the render range + + Returns: + tuple[str, str]: (build_folder_path, conformance_tests_path) + + Raises: + MissingPreviousFridCommitsError: If any required folders are missing + """ + + if not os.path.exists(self.module_build_folder): + raise MissingPreviousFunctionalitiesError( + f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the source code folder does not exist.\n\n" + f"To fix this, please render the module from the beginning by running:\n" + f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION}" + ) + + if not os.path.exists(self.module_conformance_tests_folder) and render_conformance_tests: + raise MissingPreviousFunctionalitiesError( + f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the conformance tests folder does not exist.\n\n" + f"To fix this, please render the module from the beginning by running:\n" + f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION}" + ) + + def _ensure_frid_commit_exists( + self, + frid: str, + first_render_frid: str, + render_conformance_tests: bool, + ) -> None: + """ + Ensure commit exists for a single FRID in both repositories. + + Args: + frid: The FRID to check + first_render_frid: The first FRID in the render range (for error messages) + render_conformance_tests: Whether to check for conformance tests + + Raises: + MissingPreviousFridCommitsError: If the commit is missing + """ + # Check in build folder + if not git_utils.has_commit_for_frid(self.module_build_folder, frid, self.module_name): + raise MissingPreviousFunctionalitiesError( + f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the implementation of the previous functionality ({frid}) hasn't been completed yet.\n\n" + f"To fix this, please render the missing functionality ({frid}) first by running:\n" + f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" + ) + + # Check in conformance tests folder (only if conformance tests are enabled) + if render_conformance_tests: + if not git_utils.has_commit_for_frid(self.module_conformance_tests_folder, frid, self.module_name): + raise MissingPreviousFunctionalitiesError( + f"Cannot start rendering from functionality {first_render_frid} for module '{self.module_name}' because the conformance tests for the previous functionality ({frid}) haven't been completed yet.\n\n" + f"To fix this, please render the missing functionality ({frid}) first by running:\n" + f" codeplain {self.module_name}{plain_file.PLAIN_SOURCE_FILE_EXTENSION} --render-from {frid}" + ) + + def ensure_previous_frid_commits_exist(self, render_range: list[str], render_conformance_tests: bool) -> None: + """ + Ensure that all FRID commits before the render_range exist. + + This is a precondition check that must pass before rendering can proceed. + Raises an exception if any previous FRID commits are missing. + + Args: + render_range: List of FRIDs to render + render_conformance_tests: Whether to check for conformance tests + + Raises: + MissingPreviousFridCommitsError: If any previous FRID commits are missing + """ + first_render_frid = render_range[0] + + # Get all FRIDs that should have been rendered before this one + previous_frids = plain_spec.get_frids_before(self.plain_source, first_render_frid) + if not previous_frids: + return + + # Ensure the module folders exist + self._ensure_module_folders_exist(first_render_frid) + + # Verify commits exist for all previous FRIDs + for prev_frid in previous_frids: + self._ensure_frid_commit_exists(prev_frid, first_render_frid, render_conformance_tests) diff --git a/render_machine/actions/prepare_repositories.py b/render_machine/actions/prepare_repositories.py index 7663954..cc53673 100644 --- a/render_machine/actions/prepare_repositories.py +++ b/render_machine/actions/prepare_repositories.py @@ -36,11 +36,11 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | if render_context.required_modules: previous_module = render_context.required_modules[-1] if render_context.verbose: - console.debug(f"Cloning git repo from module {previous_module.name}.") + 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.get_module_build_folder(), + previous_module.module_build_folder, render_context.build_folder, render_context.module_name, render_context.run_state.render_id, diff --git a/render_machine/conformance_tests.py b/render_machine/conformance_tests.py index 2b21e66..6fd3004 100644 --- a/render_machine/conformance_tests.py +++ b/render_machine/conformance_tests.py @@ -76,7 +76,7 @@ def get_source_conformance_test_folder_name( conformance_test_subfolder_name = original_conformance_test_folder_name[len(original_prefix) :] - modules_list = [module_name] + [m.name for m in reversed(required_modules)] + modules_list = [module_name] + [m.module_name for m in reversed(required_modules)] for copy_from_module in modules_list: if copy_from_module == current_testing_module_name: diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 64f0f3f..dae210c 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -218,7 +218,7 @@ def _get_first_frid_conformance_test_running_context(self, module: PlainModule | {}, ) else: - conformance_tests_running_context.current_testing_module_name = module.name + conformance_tests_running_context.current_testing_module_name = module.module_name conformance_tests_running_context.set_conformance_tests_json( conformance_tests_running_context.current_testing_module_name, self.conformance_tests.get_conformance_tests_json( @@ -263,7 +263,7 @@ def get_next_conformance_tests_running_context(self): else: next_module_index = -1 for i, required_module in enumerate(self.required_modules): - if required_module.name == conformance_tests_running_context.current_testing_module_name: + if required_module.module_name == conformance_tests_running_context.current_testing_module_name: next_module_index = i + 1 break From b6b388ea1c11804d6e043782370969530c9c8c1c Mon Sep 17 00:00:00 2001 From: zanjonke Date: Fri, 17 Apr 2026 16:09:33 +0200 Subject: [PATCH 03/14] WIP: partial rendering --- git_utils.py | 19 +++++++--- module_renderer.py | 37 ++++++------------- partial_rendering.py | 88 ++++++++++++++++++++++++++++++++++++++++++++ plain2code.py | 63 ++++++++++++++++++++++++++++++- plain_modules.py | 16 +++++++- 5 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 partial_rendering.py diff --git a/git_utils.py b/git_utils.py index 5cb8696..6b83f10 100644 --- a/git_utils.py +++ b/git_utils.py @@ -418,13 +418,20 @@ def get_repo_info(repo_path: Union[str, os.PathLike]) -> dict: return info -def get_last_finished_frid(repo_path: Union[str, os.PathLike]) -> Optional[str]: +def get_last_finished_frid(repo_path: Union[str, os.PathLike]) -> tuple[Optional[str], Optional[str]]: + if not os.path.exists(repo_path): + return None, None + repo = Repo(repo_path) - commit_sha = repo.git.rev_list( - repo.active_branch.name, "--grep", FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*"), "-n", "1" - ) + grep_pattern = FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*") + grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") + commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") if not commit_sha: - return None + return None, None commit_message = repo.commit(commit_sha).message match = re.search(r"FRID\):(\S+) fully implemented", commit_message) - return match.group(1) if match else None + frid = match.group(1) if match else None + + match = re.search(r"Module name:\s*(\S+)\n", commit_message) + module_name = match.group(1) if match else None + return module_name, frid diff --git a/module_renderer.py b/module_renderer.py index e4c0515..eab1f32 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -23,9 +23,8 @@ class ModuleRenderer: def __init__( self, codeplainAPI, - filename: str, + plain_module: PlainModule, render_range: list[str] | None, - template_dirs: list[str], args: argparse.Namespace, run_state: RunState, event_bus: EventBus, @@ -33,9 +32,8 @@ def __init__( enter_pause_event: threading.Event | None = None, ): self.codeplainAPI = codeplainAPI - self.filename = filename + self.plain_module = plain_module self.render_range = render_range - self.template_dirs = template_dirs self.args = args self.run_state = run_state self.event_bus = event_bus @@ -44,21 +42,18 @@ def __init__( def _build_render_context_for_module( self, - module_name: str, + plain_module: PlainModule, memory_manager: MemoryManager, - plain_source: dict, - required_modules: list[PlainModule], - template_dirs: list[str], render_range: list[str] | None, ) -> RenderContext: return RenderContext( self.codeplainAPI, memory_manager, - module_name, - plain_source, - required_modules, - template_dirs, - build_folder=os.path.join(self.args.build_folder, module_name), + plain_module.module_name, + plain_module.plain_source, + plain_module.all_required_modules, + plain_module.template_dirs, + build_folder=os.path.join(self.args.build_folder, plain_module.module_name), build_dest=self.args.build_dest, conformance_tests_folder=self.args.conformance_tests_folder, conformance_tests_dest=self.args.conformance_tests_dest, @@ -120,11 +115,8 @@ def _render_module( ), ) render_context = self._build_render_context_for_module( - plain_module.module_name, + plain_module, memory_manager, - plain_module.plain_source, - plain_module.all_required_modules, - self.template_dirs, render_range, ) @@ -150,20 +142,13 @@ def _render_module( def render_module(self) -> None: self.loaded_modules = list[PlainModule]() - plain_module = plain_modules.PlainModule( - self.filename, - self.args.build_folder, - self.args.conformance_tests_folder, - self.template_dirs, - ) - _, rendering_failed = self._render_module(plain_module, self.render_range, True) + _, rendering_failed = self._render_module(self.plain_module, self.render_range, True) if not rendering_failed: # Get the last module that completed rendering if self.args.copy_build: rendered_code_path = f"{self.args.build_dest}/" else: - last_module_name = self.filename.replace(plain_file.PLAIN_SOURCE_FILE_EXTENSION, "") - rendered_code_path = f"{os.path.join(self.args.build_folder, last_module_name)}/" + rendered_code_path = f"{os.path.join(self.args.build_folder, self.plain_module.module_name)}/" 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/partial_rendering.py b/partial_rendering.py new file mode 100644 index 0000000..fa0fab7 --- /dev/null +++ b/partial_rendering.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass + +from plain_modules import PlainModule + + +@dataclass +class PartialRender: + module: PlainModule | None = None + frid: str | None = None + spec_change: bool = False + code_change: bool = False + + +def spec_change(plain_module: PlainModule) -> bool: + if len(plain_module.required_modules) == 0: + return plain_module if plain_module.has_plain_spec_changed() else None + + for required_module in plain_module.required_modules: + sc = spec_change(required_module) + if sc is not None: + return sc + + return plain_module if plain_module.has_plain_spec_changed() else None + + +def code_change(plain_module: PlainModule) -> bool: + if len(plain_module.required_modules) == 0: + return plain_module if plain_module.has_required_modules_code_changed() else None + + for required_module in plain_module.required_modules: + cc = code_change(required_module) + if cc is not None: + return cc + + return plain_module if plain_module.has_required_modules_code_changed() else None + + +def module_comes_before( + all_required_modules: list[PlainModule], + module1: PlainModule, + module2: PlainModule, +) -> bool: + for module in all_required_modules: + if module.module_name == module1.module_name: + return True + if module.module_name == module2.module_name: + return False + + raise Exception(f"Module {module1.module_name} and {module2.module_name} not found in {all_required_modules}") + + +def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: + sc = spec_change(plain_module) + cc = code_change(plain_module) + all_required_modules = plain_module.all_required_modules + last_rendered_module, last_rendered_frid = plain_module.get_last_rendered_frid() + if last_rendered_module is None and last_rendered_frid is None: + return None + + module = None + for required_module in all_required_modules: + if required_module.module_name == last_rendered_module: + module = required_module + + pr = PartialRender( + module=module, + frid=last_rendered_frid, + spec_change=False, + code_change=False, + ) + + if sc is None and cc is None: + return pr + + if sc is not None: + if module_comes_before(all_required_modules, sc, pr.module): + pr.module = sc + pr.spec_change = True + pr.frid = None + + if cc is not None: + if module_comes_before(all_required_modules, cc, pr.module): + pr.module = cc + pr.code_change = True + pr.spec_change = False + pr.frid = None + + return pr diff --git a/plain2code.py b/plain2code.py index a1f0e56..016894f 100644 --- a/plain2code.py +++ b/plain2code.py @@ -15,9 +15,11 @@ import codeplain_REST_api as codeplain_api import file_utils import plain_file +import plain_modules import plain_spec from event_bus import EventBus from module_renderer import ModuleRenderer +from partial_rendering import PartialRender, detect_partial_rendering from plain2code_arguments import parse_arguments from plain2code_console import console from plain2code_events import RenderFailed @@ -198,6 +200,49 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI): ) +def get_partial_render_choice(plain_module: plain_modules.PlainModule, partial_render: PartialRender | None) -> str: + print("--- Partial render detected ---") + print(f"Target module: {plain_module.module_name}") + print(f"Partially rendered module: {partial_render.module.module_name}") + if partial_render.frid is not None: + print(f"Last fully rendered FRID: {partial_render.frid}") + if partial_render.spec_change: + print("Spec change: Yes") + else: + print("Spec change: No") + + if partial_render.code_change: + print("Code change: Yes") + else: + print("Code change: No") + + choice_idx = 1 + options = {f"{choice_idx}": "Re-render all"} + choice_idx += 1 + if partial_render.frid is not None: + options[f"{choice_idx}"] = ( + f"Continue from FRID {partial_render.frid} of module {partial_render.module.module_name}" + ) + choice_idx += 1 + + if partial_render.spec_change: + options[f"{choice_idx}"] = f"Re-render {partial_render.module.module_name} from start" + choice_idx += 1 + + if partial_render.code_change: + options[f"{choice_idx}"] = f"Re-render {partial_render.module.module_name} from start" + choice_idx += 1 + + for key, value in options.items(): + print(f"{key}. {value}") + + while True: + selection = input("\nSelect an option: ").strip() + if selection in options: + return options[selection] + print("Invalid choice. Please try again.") + + def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) @@ -219,11 +264,25 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 enter_pause_event = threading.Event() signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set()) + plain_module = plain_modules.PlainModule( + args.filename, + args.build_folder, + args.conformance_tests_folder, + template_dirs, + ) + + partial_render = detect_partial_rendering(plain_module) + print("Partial render: ", partial_render) + if partial_render is not None: + choice = get_partial_render_choice(plain_module, partial_render) + print(f"You selected: {choice}") + + exit() + module_renderer = ModuleRenderer( codeplainAPI, - args.filename, + plain_module, render_range, - template_dirs, args, run_state, event_bus, diff --git a/plain_modules.py b/plain_modules.py index 6335b05..7b08cf5 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -67,8 +67,20 @@ def module_build_folder(self): def get_codeplain_folder(self): return os.path.join(self.module_build_folder, CODEPLAIN_METADATA_FOLDER) - def get_last_rendered_frid(self) -> str | None: - return git_utils.get_last_finished_frid(self.module_build_folder) + def get_last_rendered_frid(self) -> tuple[str, str | None]: + if len(self.required_modules) == 0: + return git_utils.get_last_finished_frid(self.module_build_folder) + + module_name, frid = git_utils.get_last_finished_frid(self.module_build_folder) + if module_name is not None and frid is not None: + return module_name, frid + + for module in reversed(self.required_modules): + last_rendered_module, last_rendered_frid = module.get_last_rendered_frid() + if last_rendered_module is not None and last_rendered_frid is not None: + return last_rendered_module, last_rendered_frid + + return None, None def get_repo(self): try: From c18e0fac45a9b295c3c25fa408115b750e241ea4 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Mon, 20 Apr 2026 10:26:42 +0200 Subject: [PATCH 04/14] Improved the choices --- partial_rendering.py | 7 ++++++ plain2code.py | 54 +++++++++++++++++++++++++++++--------------- plain_modules.py | 32 ++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/partial_rendering.py b/partial_rendering.py index fa0fab7..9d4e77b 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -11,6 +11,13 @@ class PartialRender: code_change: bool = False +@dataclass +class PartialRenderChoice: + module: PlainModule | None = None + frid: str | None = None + msg: str | None = None + + def spec_change(plain_module: PlainModule) -> bool: if len(plain_module.required_modules) == 0: return plain_module if plain_module.has_plain_spec_changed() else None diff --git a/plain2code.py b/plain2code.py index 016894f..33c03d0 100644 --- a/plain2code.py +++ b/plain2code.py @@ -19,7 +19,7 @@ import plain_spec from event_bus import EventBus from module_renderer import ModuleRenderer -from partial_rendering import PartialRender, detect_partial_rendering +from partial_rendering import PartialRender, PartialRenderChoice, detect_partial_rendering from plain2code_arguments import parse_arguments from plain2code_console import console from plain2code_events import RenderFailed @@ -200,12 +200,18 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI): ) -def get_partial_render_choice(plain_module: plain_modules.PlainModule, partial_render: PartialRender | None) -> str: +def get_partial_render_choice( + plain_module: plain_modules.PlainModule, partial_render: PartialRender | None +) -> PartialRenderChoice: + choices = dict[str, PartialRenderChoice | None]() print("--- Partial render detected ---") print(f"Target module: {plain_module.module_name}") - print(f"Partially rendered module: {partial_render.module.module_name}") - if partial_render.frid is not None: + if partial_render.module.is_module_fully_rendered(): + print(f"Last fully rendered module: {partial_render.module.module_name}") + else: + print(f"Partially rendered module: {partial_render.module.module_name}") print(f"Last fully rendered FRID: {partial_render.frid}") + if partial_render.spec_change: print("Spec change: Yes") else: @@ -216,30 +222,44 @@ def get_partial_render_choice(plain_module: plain_modules.PlainModule, partial_r else: print("Code change: No") - choice_idx = 1 - options = {f"{choice_idx}": "Re-render all"} + choice_idx = 0 + first_module = plain_module.all_required_modules[0] + choices[f"{choice_idx}"] = PartialRenderChoice( + module=first_module, + frid=None, + msg=f"Re-render all (start from first module: {first_module.module_name})", + ) choice_idx += 1 if partial_render.frid is not None: - options[f"{choice_idx}"] = ( - f"Continue from FRID {partial_render.frid} of module {partial_render.module.module_name}" + next_frid, next_module = plain_module.get_next_frid(partial_render.frid, partial_render.module.module_name) + choices[f"{choice_idx}"] = PartialRenderChoice( + module=next_module, + frid=next_frid, + msg=f"Continue from FRID {next_frid} of module {next_module.module_name}", ) choice_idx += 1 if partial_render.spec_change: - options[f"{choice_idx}"] = f"Re-render {partial_render.module.module_name} from start" + choices[f"{choice_idx}"] = PartialRenderChoice( + module=partial_render.module, frid=None, msg=f"Re-render {partial_render.module.module_name} from start" + ) choice_idx += 1 if partial_render.code_change: - options[f"{choice_idx}"] = f"Re-render {partial_render.module.module_name} from start" + choices[f"{choice_idx}"] = PartialRenderChoice( + module=partial_render.module, frid=None, msg=f"Re-render {partial_render.module.module_name} from start" + ) choice_idx += 1 - for key, value in options.items(): - print(f"{key}. {value}") + choices[f"{choice_idx}"] = PartialRenderChoice(module=None, frid=None, msg="Quit") + for key, pr_choice in choices.items(): + print(f"{key}. {pr_choice.msg}") while True: selection = input("\nSelect an option: ").strip() - if selection in options: - return options[selection] + if selection in choices: + return choices[selection] + print("Invalid choice. Please try again.") @@ -272,12 +292,10 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 ) partial_render = detect_partial_rendering(plain_module) - print("Partial render: ", partial_render) if partial_render is not None: choice = get_partial_render_choice(plain_module, partial_render) - print(f"You selected: {choice}") - - exit() + if choice.module is None and choice.frid is None and choice.msg == "Quit": + exit() module_renderer = ModuleRenderer( codeplainAPI, diff --git a/plain_modules.py b/plain_modules.py index 7b08cf5..1cb9c95 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -270,3 +270,35 @@ def ensure_previous_frid_commits_exist(self, render_range: list[str], render_con # Verify commits exist for all previous FRIDs for prev_frid in previous_frids: self._ensure_frid_commit_exists(prev_frid, first_render_frid, render_conformance_tests) + + def get_module_by_name(self, module_name: str) -> PlainModule: + for module in self.all_required_modules: + if module.module_name == module_name: + return module + + raise ModuleDoesNotExistError(f"Module {module_name} does not exist") + + def get_next_module(self, module_name: str) -> PlainModule | None: + for idx, module in enumerate(self.all_required_modules): + if module.module_name == module_name and idx < len(self.all_required_modules) - 1: + return self.all_required_modules[idx + 1] + + return None + + def get_next_frid(self, frid: str, module_name: str) -> tuple[str, PlainModule]: + module = self.get_module_by_name(module_name) + next_frid = plain_spec.get_next_frid(module.plain_source, frid) + + if next_frid is None: + next_module = self.get_next_module(module_name) + return plain_spec.get_first_frid(next_module.plain_source), next_module + + return next_frid, module + + def is_module_fully_rendered(self) -> bool: + frids = list(plain_spec.get_frids(self.plain_source)) + last_rendered_module, last_rendered_frid = git_utils.get_last_finished_frid(self.module_build_folder) + if last_rendered_module is None or last_rendered_frid is None: + return False + + return last_rendered_frid == frids[-1] From 1c9ecc4a8a0882a910df8dd9498b9bf967c3af6d Mon Sep 17 00:00:00 2001 From: zanjonke Date: Mon, 20 Apr 2026 13:42:30 +0200 Subject: [PATCH 05/14] Adding PartialRenderTUI --- module_renderer.py | 12 +++- partial_rendering.py | 6 +- plain2code.py | 147 +++++++------------------------------- plain_modules.py | 11 ++- plain_spec.py | 54 +++++++++++++- tui/partial_render_tui.py | 111 ++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 136 deletions(-) create mode 100644 tui/partial_render_tui.py diff --git a/module_renderer.py b/module_renderer.py index eab1f32..3991db7 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -8,6 +8,7 @@ import plain_spec from event_bus import EventBus from memory_management import MemoryManager +from partial_rendering import PartialRenderChoice from plain2code_console import console from plain2code_events import RenderCompleted, RenderFailed from plain2code_exceptions import MissingPreviousFunctionalitiesError @@ -24,6 +25,7 @@ def __init__( self, codeplainAPI, plain_module: PlainModule, + partial_render_choice: PartialRenderChoice | None, render_range: list[str] | None, args: argparse.Namespace, run_state: RunState, @@ -33,6 +35,7 @@ def __init__( ): self.codeplainAPI = codeplainAPI self.plain_module = plain_module + self.partial_render_choice = partial_render_choice self.render_range = render_range self.args = args self.run_state = run_state @@ -82,7 +85,7 @@ def _render_module( tuple[bool, bool]: (Whether the module was rendered and whether the rendering failed) """ if render_range is not None: - plain_module.ensure_previous_frid_commits_exist(render_range) + plain_module.ensure_previous_frid_commits_exist(render_range, self.args.render_conformance_tests) has_any_required_module_changed = False if not self.args.render_machine_graph and plain_module.required_modules: @@ -101,8 +104,8 @@ def _render_module( force_render or any(module.filename == plain_module.filename for module in self.loaded_modules) or plain_module.get_repo() is None - or plain_module.has_plain_spec_changed() - or plain_module.has_required_modules_code_changed() + or (plain_module.has_plain_spec_changed() or False) + or (plain_module.has_required_modules_code_changed() or False) or has_any_required_module_changed ): return False, False @@ -142,6 +145,9 @@ def _render_module( def render_module(self) -> None: self.loaded_modules = list[PlainModule]() + if self.partial_render_choice is not None: + self._render_module(self.partial_render_choice.module, self.partial_render_choice.render_range, True) + _, rendering_failed = self._render_module(self.plain_module, self.render_range, True) if not rendering_failed: # Get the last module that completed rendering diff --git a/partial_rendering.py b/partial_rendering.py index 9d4e77b..52e9444 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -14,11 +14,11 @@ class PartialRender: @dataclass class PartialRenderChoice: module: PlainModule | None = None - frid: str | None = None + render_range: list[str] | None = None msg: str | None = None -def spec_change(plain_module: PlainModule) -> bool: +def spec_change(plain_module: PlainModule) -> PlainModule | None: if len(plain_module.required_modules) == 0: return plain_module if plain_module.has_plain_spec_changed() else None @@ -30,7 +30,7 @@ def spec_change(plain_module: PlainModule) -> bool: return plain_module if plain_module.has_plain_spec_changed() else None -def code_change(plain_module: PlainModule) -> bool: +def code_change(plain_module: PlainModule) -> PlainModule | None: if len(plain_module.required_modules) == 0: return plain_module if plain_module.has_required_modules_code_changed() else None diff --git a/plain2code.py b/plain2code.py index 33c03d0..7ed2057 100644 --- a/plain2code.py +++ b/plain2code.py @@ -19,7 +19,7 @@ import plain_spec from event_bus import EventBus from module_renderer import ModuleRenderer -from partial_rendering import PartialRender, PartialRenderChoice, detect_partial_rendering +from partial_rendering import detect_partial_rendering from plain2code_arguments import parse_arguments from plain2code_console import console from plain2code_events import RenderFailed @@ -51,6 +51,7 @@ from plain2code_state import RunState from plain2code_utils import format_duration_hms, print_dry_run_output from system_config import system_config +from tui.partial_render_tui import PartialRenderTUI from tui.plain2code_tui import Plain2CodeTUI DEFAULT_TEMPLATE_DIRS = importlib.resources.files("standard_template_library") @@ -72,58 +73,6 @@ def print_exit_summary( console.quiet = True -def get_render_range(render_range, plain_source): - render_range = render_range.split(",") - range_end = render_range[1] if len(render_range) == 2 else render_range[0] - - return _get_frids_range(plain_source, render_range[0], range_end) - - -def get_render_range_from(start, plain_source): - return _get_frids_range(plain_source, start) - - -def compute_render_range(args, plain_source_tree): - """Compute render range from --render-range or --render-from arguments. - - Args: - args: Parsed command line arguments - plain_source_tree: Parsed plain source tree - - Returns: - List of FRIDs to render, or None to render all - """ - if args.render_range: - return get_render_range(args.render_range, plain_source_tree) - elif args.render_from: - return get_render_range_from(args.render_from, plain_source_tree) - return None - - -def _get_frids_range(plain_source, start, end=None): - frids = list(plain_spec.get_frids(plain_source)) - - start = str(start) - - if start not in frids: - raise InvalidFridArgument(f"Invalid start functionality ID: {start}. Valid IDs are: {frids}.") - - if end is not None: - end = str(end) - if end not in frids: - raise InvalidFridArgument(f"Invalid end functionality ID: {end}. Valid IDs are: {frids}.") - - end_idx = frids.index(end) + 1 - else: - end_idx = len(frids) - - start_idx = frids.index(start) - if start_idx >= end_idx: - raise InvalidFridArgument(f"Start functionality ID: {start} must be before end functionality ID: {end}.") - - return frids[start_idx:end_idx] - - def setup_logging( args, event_bus: EventBus, @@ -200,69 +149,6 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI): ) -def get_partial_render_choice( - plain_module: plain_modules.PlainModule, partial_render: PartialRender | None -) -> PartialRenderChoice: - choices = dict[str, PartialRenderChoice | None]() - print("--- Partial render detected ---") - print(f"Target module: {plain_module.module_name}") - if partial_render.module.is_module_fully_rendered(): - print(f"Last fully rendered module: {partial_render.module.module_name}") - else: - print(f"Partially rendered module: {partial_render.module.module_name}") - print(f"Last fully rendered FRID: {partial_render.frid}") - - if partial_render.spec_change: - print("Spec change: Yes") - else: - print("Spec change: No") - - if partial_render.code_change: - print("Code change: Yes") - else: - print("Code change: No") - - choice_idx = 0 - first_module = plain_module.all_required_modules[0] - choices[f"{choice_idx}"] = PartialRenderChoice( - module=first_module, - frid=None, - msg=f"Re-render all (start from first module: {first_module.module_name})", - ) - choice_idx += 1 - if partial_render.frid is not None: - next_frid, next_module = plain_module.get_next_frid(partial_render.frid, partial_render.module.module_name) - choices[f"{choice_idx}"] = PartialRenderChoice( - module=next_module, - frid=next_frid, - msg=f"Continue from FRID {next_frid} of module {next_module.module_name}", - ) - choice_idx += 1 - - if partial_render.spec_change: - choices[f"{choice_idx}"] = PartialRenderChoice( - module=partial_render.module, frid=None, msg=f"Re-render {partial_render.module.module_name} from start" - ) - choice_idx += 1 - - if partial_render.code_change: - choices[f"{choice_idx}"] = PartialRenderChoice( - module=partial_render.module, frid=None, msg=f"Re-render {partial_render.module.module_name} from start" - ) - choice_idx += 1 - - choices[f"{choice_idx}"] = PartialRenderChoice(module=None, frid=None, msg="Quit") - for key, pr_choice in choices.items(): - print(f"{key}. {pr_choice.msg}") - - while True: - selection = input("\nSelect an option: ").strip() - if selection in choices: - return choices[selection] - - print("Invalid choice. Please try again.") - - def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) @@ -271,7 +157,7 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 if args.render_range or args.render_from: # Parse the plain file to get the plain_source for FRID extraction _, plain_source, _ = plain_file.plain_file_parser(args.filename, template_dirs) - render_range = compute_render_range(args, plain_source) + render_range = plain_spec.compute_render_range(args, plain_source) codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console) codeplainAPI.verbose = args.verbose @@ -291,15 +177,30 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 template_dirs, ) - partial_render = detect_partial_rendering(plain_module) - if partial_render is not None: - choice = get_partial_render_choice(plain_module, partial_render) - if choice.module is None and choice.frid is None and choice.msg == "Quit": - exit() + partial_render_choice = None + if render_range is None: + partial_render = detect_partial_rendering(plain_module) + if partial_render is not None: + app = PartialRenderTUI( + plain_module, + partial_render, + css_path="styles.css", + ) + partial_render_choice = app.run() + if partial_render_choice is None or ( + partial_render_choice.module is None + and partial_render_choice.render_range is None + and partial_render_choice.msg == "Quit" + ): + exit() + + # We can't have both partial render choice and render range + assert not (partial_render_choice is not None and render_range is not None) module_renderer = ModuleRenderer( codeplainAPI, plain_module, + partial_render_choice, render_range, args, run_state, @@ -372,7 +273,7 @@ def main(): # noqa: C901 if args.dry_run: console.info("Printing dry run output...\n") _, plain_source_tree, _ = plain_file.plain_file_parser(args.filename, template_dirs) - render_range = compute_render_range(args, plain_source_tree) + render_range = plain_spec.compute_render_range(args, plain_source_tree) print_dry_run_output(plain_source_tree, render_range) return except Exception as e: diff --git a/plain_modules.py b/plain_modules.py index 1cb9c95..54d9020 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -112,24 +112,23 @@ def has_required_modules_code_changed( self, ) -> bool: if self.required_modules is None or len(self.required_modules) == 0: - return False + return None module_metadata = self.load_module_metadata() if not module_metadata or "required_modules_code_hash" not in module_metadata: - return True + return None previous_module = self.required_modules[-1] return module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() def has_plain_spec_changed(self) -> bool: module_metadata = self.load_module_metadata() - if not module_metadata: - return True + return None if "source_hash" not in module_metadata: - return True + return None return module_metadata["source_hash"] != self.get_module_source_hash() @@ -265,7 +264,7 @@ def ensure_previous_frid_commits_exist(self, render_range: list[str], render_con return # Ensure the module folders exist - self._ensure_module_folders_exist(first_render_frid) + self._ensure_module_folders_exist(first_render_frid, render_conformance_tests) # Verify commits exist for all previous FRIDs for prev_frid in previous_frids: diff --git a/plain_spec.py b/plain_spec.py index c5e938b..dab505d 100644 --- a/plain_spec.py +++ b/plain_spec.py @@ -5,7 +5,7 @@ from liquid2.filter import with_context -from plain2code_exceptions import InvalidLiquidVariableName +from plain2code_exceptions import InvalidFridArgument, InvalidLiquidVariableName DEFINITIONS = "definitions" NON_FUNCTIONAL_REQUIREMENTS = "implementation reqs" @@ -378,3 +378,55 @@ def hash_text(text): def get_hash_value(specifications): return hash_text(json.dumps(specifications, indent=4)) + + +def get_render_range(render_range, plain_source): + render_range = render_range.split(",") + range_end = render_range[1] if len(render_range) == 2 else render_range[0] + + return _get_frids_range(plain_source, render_range[0], range_end) + + +def get_render_range_from(start, plain_source): + return _get_frids_range(plain_source, start) + + +def compute_render_range(args, plain_source_tree): + """Compute render range from --render-range or --render-from arguments. + + Args: + args: Parsed command line arguments + plain_source_tree: Parsed plain source tree + + Returns: + List of FRIDs to render, or None to render all + """ + if args.render_range: + return get_render_range(args.render_range, plain_source_tree) + elif args.render_from: + return get_render_range_from(args.render_from, plain_source_tree) + return None + + +def _get_frids_range(plain_source, start, end=None): + frids = list(get_frids(plain_source)) + + start = str(start) + + if start not in frids: + raise InvalidFridArgument(f"Invalid start functionality ID: {start}. Valid IDs are: {frids}.") + + if end is not None: + end = str(end) + if end not in frids: + raise InvalidFridArgument(f"Invalid end functionality ID: {end}. Valid IDs are: {frids}.") + + end_idx = frids.index(end) + 1 + else: + end_idx = len(frids) + + start_idx = frids.index(start) + if start_idx >= end_idx: + raise InvalidFridArgument(f"Start functionality ID: {start} must be before end functionality ID: {end}.") + + return frids[start_idx:end_idx] diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py new file mode 100644 index 0000000..7c5c442 --- /dev/null +++ b/tui/partial_render_tui.py @@ -0,0 +1,111 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Footer, Header, Label, ListItem, ListView + +import plain_spec +from partial_rendering import PartialRender, PartialRenderChoice +from plain_modules import PlainModule + + +class PartialRenderTUI(App): + def __init__(self, plain_module: PlainModule, partial_render: PartialRender, **kwargs): + super().__init__(**kwargs) + self.plain_module = plain_module + self.partial_render = partial_render + self.choices = {} # populated in on_mount + + def compose(self) -> ComposeResult: + yield Vertical( + Label("[bold]--- Partial Render Detected ---[/bold]", classes="highlight"), + Horizontal( + Vertical(id="info-panel-left"), + Vertical(id="info-panel-right"), + id="info-panel-columns", + ), + id="info-panel", + ) + yield ListView(id="choice-list") + + def on_mount(self) -> None: + pr = self.partial_render + pm = self.plain_module + + # Info labels — left side + left = self.query_one("#info-panel-left", Vertical) + left.mount(Label(f"[#e0ff6e]Current state:[/]")) + left.mount(Label(f"Target module: [{'#79fc96'}]{pm.module_name}[/]")) + if pr.module.is_module_fully_rendered(): + left.mount(Label(f"Last fully rendered module: [{'#79fc96'}]{pr.module.module_name}[/]")) + else: + left.mount(Label(f"Partially rendered module: [{'#79fc96'}]{pr.module.module_name}[/]")) + left.mount(Label(f"Last fully rendered functionality: [{'#79fc96'}]{pr.frid}[/]")) + + left.mount(Label(f"Spec change: [{'#79fc96'}]{'Yes' if pr.spec_change else 'No'}[/]")) + left.mount(Label(f"Code change: [{'#79fc96'}]{'Yes' if pr.code_change else 'No'}[/]")) + + # Build choices (same logic as original) + choice_idx = 1 + if pr.frid is not None: + next_frid, next_module = pm.get_next_frid(pr.frid, pr.module.module_name) + functionality = next_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][int(next_frid) - 1] + print(functionality) + # Placeholder — right side + + right = self.query_one("#info-panel-right", Vertical) + right.mount(Label(f"[#e0ff6e]Next functionality:[/]")) + right.mount( + Label(f"Module [{'#79fc96'}]{next_module.module_name}[/], functionality [{'#79fc96'}]{next_frid}[/]") + ) + right.mount(Label(functionality["markdown"])) + + msg = f"Continue from next functionality (module {next_module.module_name}" + if next_frid != plain_spec.get_first_frid(next_module.plain_source): + msg += f" functionality {next_frid})" + else: + msg += ")" + self.choices[str(choice_idx)] = PartialRenderChoice( + module=next_module, + render_range=plain_spec.get_render_range_from(next_frid, next_module.plain_source), + msg=msg, + ) + choice_idx += 1 + + if pr.spec_change: + self.choices[str(choice_idx)] = PartialRenderChoice( + module=pr.module, + render_range=None, + msg=f"Re-render {pr.module.module_name} from start (spec change)", + ) + choice_idx += 1 + + if pr.code_change: + self.choices[str(choice_idx)] = PartialRenderChoice( + module=pr.module, + render_range=None, + msg=f"Re-render {pr.module.module_name} from start (code change)", + ) + choice_idx += 1 + + first_module = pm.all_required_modules[0] + self.choices[str(choice_idx)] = PartialRenderChoice( + module=first_module, + render_range=None, + msg=f"Re-render all (start from: {first_module.module_name})", + ) + choice_idx += 1 + + self.choices[str(choice_idx)] = PartialRenderChoice(module=None, render_range=None, msg="Quit") + + # Populate the ListView + lv = self.query_one("#choice-list", ListView) + self.mount(Label("How would you like to start rendering?", classes="partial-render-question"), before=lv) + for key, choice in self.choices.items(): + lv.append(ListItem(Label(f"[bold]{key}.[/bold] {choice.msg}"), id=f"choice-{key}")) + + @on(ListView.Selected) + def on_choice_selected(self, event: ListView.Selected) -> None: + # Extract the key from the widget id ("choice-1" → "1") + key = event.item.id.split("-", 1)[1] + self.selected_choice = self.choices[key] + self.exit(self.selected_choice) From 3842e215121537631a1c80baca69c9caa5f8b9b4 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Tue, 21 Apr 2026 10:28:00 +0200 Subject: [PATCH 06/14] Cleanup and bugfixing --- file_utils.py | 6 ++- git_utils.py | 2 + module_renderer.py | 27 ++++++------ partial_rendering.py | 91 ++++++++++++++++++++++++++------------- plain_modules.py | 10 ++--- tests/test_git_utils.py | 66 ++++++++++++++++++---------- tui/partial_render_tui.py | 23 ++++++---- tui/styles.css | 40 +++++++++++++++++ 8 files changed, 181 insertions(+), 84 deletions(-) diff --git a/file_utils.py b/file_utils.py index bb178cc..3fdfd47 100644 --- a/file_utils.py +++ b/file_utils.py @@ -239,7 +239,8 @@ def load_linked_resources(template_dirs: list[str], resources_list): content = open_from(template_dirs, file_name) if content is None: - raise FileNotFoundError(f""" + raise FileNotFoundError( + f""" Resource file {file_name} not found. Resource files are searched in the following order (highest to lowest precedence): 1. The directory containing your .plain file @@ -247,7 +248,8 @@ def load_linked_resources(template_dirs: list[str], resources_list): 3. The built-in 'standard_template_library' directory Please ensure that the resource exists in one of these locations, or specify the correct --template-dir if using custom templates. - """) + """ + ) linked_resources[file_name] = content diff --git a/git_utils.py b/git_utils.py index 6b83f10..a94ddc2 100644 --- a/git_utils.py +++ b/git_utils.py @@ -429,6 +429,8 @@ def get_last_finished_frid(repo_path: Union[str, os.PathLike]) -> tuple[Optional if not commit_sha: return None, None commit_message = repo.commit(commit_sha).message + if isinstance(commit_message, bytes): + commit_message = commit_message.decode("utf-8") match = re.search(r"FRID\):(\S+) fully implemented", commit_message) frid = match.group(1) if match else None diff --git a/module_renderer.py b/module_renderer.py index 3991db7..00ba3f5 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -2,16 +2,11 @@ import os import threading -import git_utils -import plain_file -import plain_modules -import plain_spec from event_bus import EventBus from memory_management import MemoryManager from partial_rendering import PartialRenderChoice from plain2code_console import console from plain2code_events import RenderCompleted, RenderFailed -from plain2code_exceptions import MissingPreviousFunctionalitiesError from plain2code_state import RunState from plain_modules import PlainModule from render_machine.code_renderer import CodeRenderer @@ -78,7 +73,7 @@ def _build_render_context_for_module( def _render_module( self, plain_module: PlainModule, render_range: list[str] | None, force_render: bool - ) -> tuple[bool, list[PlainModule], bool]: + ) -> tuple[bool, bool]: """Render a module. Returns: @@ -99,14 +94,16 @@ def _render_module( if rendering_failed: return False, True - - if not ( - force_render - or any(module.filename == plain_module.filename for module in self.loaded_modules) - or plain_module.get_repo() is None - or (plain_module.has_plain_spec_changed() or False) - or (plain_module.has_required_modules_code_changed() or False) - or has_any_required_module_changed + console.debug(f"{plain_module.module_name=}, {force_render=}, {has_any_required_module_changed=}") + console.debug( + f"{plain_module.get_repo()=}, {plain_module.has_plain_spec_changed()=}, {plain_module.has_required_modules_code_changed()=}" + ) + if ( + ((not force_render) or any(module.name == plain_module.name for module in self.loaded_modules)) + and plain_module.get_repo() is not None + and not plain_module.has_plain_spec_changed() + and not plain_module.has_required_modules_code_changed() + and not has_any_required_module_changed ): return False, False @@ -145,7 +142,7 @@ def _render_module( def render_module(self) -> None: self.loaded_modules = list[PlainModule]() - if self.partial_render_choice is not None: + if self.partial_render_choice is not None and self.partial_render_choice.module is not None: self._render_module(self.partial_render_choice.module, self.partial_render_choice.render_range, True) _, rendering_failed = self._render_module(self.plain_module, self.render_range, True) diff --git a/partial_rendering.py b/partial_rendering.py index 52e9444..722a7ab 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -1,11 +1,12 @@ from dataclasses import dataclass +from plain2code_exceptions import ModuleDoesNotExistError from plain_modules import PlainModule @dataclass class PartialRender: - module: PlainModule | None = None + module: PlainModule frid: str | None = None spec_change: bool = False code_change: bool = False @@ -19,27 +20,50 @@ class PartialRenderChoice: def spec_change(plain_module: PlainModule) -> PlainModule | None: - if len(plain_module.required_modules) == 0: - return plain_module if plain_module.has_plain_spec_changed() else None - - for required_module in plain_module.required_modules: - sc = spec_change(required_module) - if sc is not None: - return sc - - return plain_module if plain_module.has_plain_spec_changed() else None + for required_module in plain_module.all_required_modules: + module_metadata = required_module.load_module_metadata() + if ( + module_metadata + and "source_hash" in module_metadata + and module_metadata["source_hash"] != required_module.get_module_source_hash() + ): + return required_module + + module_metadata = plain_module.load_module_metadata() + if ( + module_metadata + and "source_hash" in module_metadata + and module_metadata["source_hash"] != plain_module.get_module_source_hash() + ): + return plain_module + + return None def code_change(plain_module: PlainModule) -> PlainModule | None: - if len(plain_module.required_modules) == 0: - return plain_module if plain_module.has_required_modules_code_changed() else None - - for required_module in plain_module.required_modules: - cc = code_change(required_module) - if cc is not None: - return cc - - return plain_module if plain_module.has_required_modules_code_changed() else None + for required_module in plain_module.all_required_modules: + if len(required_module.required_modules) == 0: + continue + + module_metadata = required_module.load_module_metadata() + previous_module = required_module.required_modules[-1] + if ( + module_metadata + and "required_modules_code_hash" in module_metadata + and module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() + ): + return required_module + + module_metadata = plain_module.load_module_metadata() + previous_module = plain_module.required_modules[-1] + if ( + module_metadata + and "required_modules_code_hash" in module_metadata + and module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() + ): + return plain_module + + return None def module_comes_before( @@ -47,6 +71,7 @@ def module_comes_before( module1: PlainModule, module2: PlainModule, ) -> bool: + for module in all_required_modules: if module.module_name == module1.module_name: return True @@ -69,6 +94,11 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: if required_module.module_name == last_rendered_module: module = required_module + if module is None: + raise ModuleDoesNotExistError( + f"Last rendered module {last_rendered_module} not found in {all_required_modules}" + ) + pr = PartialRender( module=module, frid=last_rendered_frid, @@ -79,17 +109,16 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: if sc is None and cc is None: return pr - if sc is not None: - if module_comes_before(all_required_modules, sc, pr.module): - pr.module = sc - pr.spec_change = True - pr.frid = None - - if cc is not None: - if module_comes_before(all_required_modules, cc, pr.module): - pr.module = cc - pr.code_change = True - pr.spec_change = False - pr.frid = None + if sc is not None and module_comes_before(all_required_modules, sc, pr.module): + pr.module = sc + pr.spec_change = True + pr.code_change = False + pr.frid = None + + if cc is not None and module_comes_before(all_required_modules, cc, pr.module): + pr.module = cc + pr.code_change = True + pr.spec_change = False + pr.frid = None return pr diff --git a/plain_modules.py b/plain_modules.py index 54d9020..44052b0 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -112,12 +112,12 @@ def has_required_modules_code_changed( self, ) -> bool: if self.required_modules is None or len(self.required_modules) == 0: - return None + return False module_metadata = self.load_module_metadata() if not module_metadata or "required_modules_code_hash" not in module_metadata: - return None + return True previous_module = self.required_modules[-1] return module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() @@ -125,10 +125,10 @@ def has_required_modules_code_changed( def has_plain_spec_changed(self) -> bool: module_metadata = self.load_module_metadata() if not module_metadata: - return None + return True if "source_hash" not in module_metadata: - return None + return True return module_metadata["source_hash"] != self.get_module_source_hash() @@ -282,7 +282,7 @@ def get_next_module(self, module_name: str) -> PlainModule | None: if module.module_name == module_name and idx < len(self.all_required_modules) - 1: return self.all_required_modules[idx + 1] - return None + return self def get_next_frid(self, frid: str, module_name: str) -> tuple[str, PlainModule]: module = self.get_module_by_name(module_name) diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 226a442..06cb2f7 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -63,7 +63,8 @@ def test_single_file_change(temp_repo): result = diff(temp_repo, "1.1") assert "test.txt" in result - expected_diff = dedent(""" + expected_diff = dedent( + """ --- a/test.txt +++ b/test.txt @@ -1,3 +1,3 @@ @@ -71,7 +72,8 @@ def test_single_file_change(temp_repo): +modified content line2 line3 - """).strip() + """ + ).strip() assert result["test.txt"] == expected_diff @@ -96,7 +98,8 @@ def test_multiple_file_changes(temp_repo): # Check first file assert "test.txt" in result - expected_diff1 = dedent(""" + expected_diff1 = dedent( + """ --- a/test.txt +++ b/test.txt @@ -1,3 +1,2 @@ @@ -104,19 +107,22 @@ def test_multiple_file_changes(temp_repo): +file1 modified line2 -line3 - """).strip() + """ + ).strip() assert result["test.txt"] == expected_diff1 # Check second file assert "file2.txt" in result - expected_diff2 = dedent(""" + expected_diff2 = dedent( + """ --- /dev/null +++ b/file2.txt @@ -0,0 +1,2 @@ +file2 modified +line2 \\ No newline at end of file - """).strip() + """ + ).strip() assert result["file2.txt"] == expected_diff2 @@ -151,7 +157,8 @@ def test_multiple_commits_diff(temp_repo): # Check first file assert "test.txt" in result - expected_diff1 = dedent(""" + expected_diff1 = dedent( + """ --- a/test.txt +++ b/test.txt @@ -1,3 +1,2 @@ @@ -159,30 +166,35 @@ def test_multiple_commits_diff(temp_repo): +file1 frid1.2 refactored version line2 -line3 - """).strip() + """ + ).strip() assert result["test.txt"] == expected_diff1 # Check second file assert "file2.txt" in result - expected_diff2 = dedent(""" + expected_diff2 = dedent( + """ --- a/file2.txt +++ b/file2.txt @@ -1,2 +1,2 @@ -file2 frid1.1 refactored version +file2 frid1.2 version line2 - """).strip() + """ + ).strip() assert result["file2.txt"] == expected_diff2 # Check third file assert "file3.txt" in result - expected_diff3 = dedent(""" + expected_diff3 = dedent( + """ --- /dev/null +++ b/file3.txt @@ -0,0 +1,2 @@ +file3 frid1.2 new file +line2 - """).strip() + """ + ).strip() assert result["file3.txt"] == expected_diff3 @@ -201,23 +213,27 @@ def test_diff_without_previous_frid_and_no_base_folder(empty_repo): result = diff(empty_repo) assert "new.txt" in result - expected_diff = dedent(""" + expected_diff = dedent( + """ --- /dev/null +++ b/new.txt @@ -0,0 +1,2 @@ +new file content +line2 - """).strip() + """ + ).strip() assert result["new.txt"] == expected_diff assert "new2.txt" in result - expected_diff2 = dedent(""" + expected_diff2 = dedent( + """ --- /dev/null +++ b/new2.txt @@ -0,0 +1,2 @@ +new file content +line2 - """).strip() + """ + ).strip() assert result["new2.txt"] == expected_diff2 @@ -235,14 +251,16 @@ def test_diff_without_previous_frid_and_base_folder(temp_repo): result = diff(temp_repo) assert "new.txt" in result - expected_diff = dedent(""" + expected_diff = dedent( + """ --- a/new.txt +++ b/new.txt @@ -1,2 +1,2 @@ -base folder content +updated base folder content line2 - """).strip() + """ + ).strip() assert result["new.txt"] == expected_diff @@ -258,13 +276,15 @@ def test_new_file(temp_repo): result = diff(temp_repo, "1.1") assert "new.txt" in result - expected_diff = dedent(""" + expected_diff = dedent( + """ --- /dev/null +++ b/new.txt @@ -0,0 +1,2 @@ +new file content +line2 - """).strip() + """ + ).strip() assert result["new.txt"] == expected_diff @@ -280,14 +300,16 @@ def test_deleted_file(temp_repo): result = diff(temp_repo, "1.1") assert "test.txt" in result - expected_diff = dedent(""" + expected_diff = dedent( + """ --- a/test.txt +++ /dev/null @@ -1,3 +0,0 @@ -initial content -line2 -line3 - """).strip() + """ + ).strip() assert result["test.txt"] == expected_diff diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index 7c5c442..1eb9145 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -1,7 +1,7 @@ from textual import on from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Footer, Header, Label, ListItem, ListView +from textual.widgets import Label, ListItem, ListView import plain_spec from partial_rendering import PartialRender, PartialRenderChoice @@ -33,27 +33,32 @@ def on_mount(self) -> None: # Info labels — left side left = self.query_one("#info-panel-left", Vertical) - left.mount(Label(f"[#e0ff6e]Current state:[/]")) + left.mount(Label("[#e0ff6e]Current state:[/]")) left.mount(Label(f"Target module: [{'#79fc96'}]{pm.module_name}[/]")) - if pr.module.is_module_fully_rendered(): + + if pr.spec_change: + left.mount(Label(f"Detected spec change of module [{'#79fc96'}]{pr.module.module_name}[/]")) + elif pr.code_change: + left.mount(Label(f"Detected code change of module [{'#79fc96'}]{pr.module.module_name}[/]")) + elif pr.frid is None or pr.module.is_module_fully_rendered(): left.mount(Label(f"Last fully rendered module: [{'#79fc96'}]{pr.module.module_name}[/]")) else: left.mount(Label(f"Partially rendered module: [{'#79fc96'}]{pr.module.module_name}[/]")) left.mount(Label(f"Last fully rendered functionality: [{'#79fc96'}]{pr.frid}[/]")) - left.mount(Label(f"Spec change: [{'#79fc96'}]{'Yes' if pr.spec_change else 'No'}[/]")) - left.mount(Label(f"Code change: [{'#79fc96'}]{'Yes' if pr.code_change else 'No'}[/]")) + # left.mount(Label(f"Spec change: [{'#79fc96'}]{'Yes' if pr.spec_change else 'No'}[/]")) + # left.mount(Label(f"Code change: [{'#79fc96'}]{'Yes' if pr.code_change else 'No'}[/]")) # Build choices (same logic as original) choice_idx = 1 if pr.frid is not None: next_frid, next_module = pm.get_next_frid(pr.frid, pr.module.module_name) + functionality = next_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][int(next_frid) - 1] - print(functionality) # Placeholder — right side right = self.query_one("#info-panel-right", Vertical) - right.mount(Label(f"[#e0ff6e]Next functionality:[/]")) + right.mount(Label("[#e0ff6e]Next functionality:[/]")) right.mount( Label(f"Module [{'#79fc96'}]{next_module.module_name}[/], functionality [{'#79fc96'}]{next_frid}[/]") ) @@ -75,7 +80,7 @@ def on_mount(self) -> None: self.choices[str(choice_idx)] = PartialRenderChoice( module=pr.module, render_range=None, - msg=f"Re-render {pr.module.module_name} from start (spec change)", + msg=f"Re-render {pr.module.module_name} from start", ) choice_idx += 1 @@ -83,7 +88,7 @@ def on_mount(self) -> None: self.choices[str(choice_idx)] = PartialRenderChoice( module=pr.module, render_range=None, - msg=f"Re-render {pr.module.module_name} from start (code change)", + msg=f"Re-render {pr.module.module_name} from start", ) choice_idx += 1 diff --git a/tui/styles.css b/tui/styles.css index c395d5d..5c88b9d 100644 --- a/tui/styles.css +++ b/tui/styles.css @@ -423,3 +423,43 @@ CustomFooter.footer-state-paused { text-align: right; } +/* Partial Render TUI Styles */ +#info-panel { + height: auto; + border: solid #e0ff6e; + padding: 1 2; + margin: 1 2; +} +#info-panel .highlight { + color: #79fc96; + margin-bottom: 1; +} +#info-panel-columns { + height: auto; +} +#info-panel-left { + width: auto; + height: auto; + margin-right: 8; +} +#info-panel-left Label { + color: $text-muted; +} +#info-panel-right { + width: auto; + height: auto; +} +#info-panel-right Label { + color: $text-muted; +} +ListView { + margin: 0 2; + height: auto; +} +ListItem { + padding: 0 1; +} +.partial-render-question { + margin: 1 2; + height: auto; +} \ No newline at end of file From 9e0d78ff7a26ad6b78c88c462a937bdc6d95da5c Mon Sep 17 00:00:00 2001 From: zanjonke Date: Tue, 21 Apr 2026 11:08:06 +0200 Subject: [PATCH 07/14] Adding tests --- file_utils.py | 6 +- tests/data/partial_rendering/pr_leaf.plain | 9 + tests/data/partial_rendering/pr_middle.plain | 14 + tests/data/partial_rendering/pr_root.plain | 14 + tests/data/partial_rendering/pr_solo.plain | 11 + tests/test_git_utils.py | 137 +++++--- tests/test_partial_rendering.py | 313 +++++++++++++++++++ tests/test_plain_modules.py | 288 +++++++++++++++++ 8 files changed, 744 insertions(+), 48 deletions(-) create mode 100644 tests/data/partial_rendering/pr_leaf.plain create mode 100644 tests/data/partial_rendering/pr_middle.plain create mode 100644 tests/data/partial_rendering/pr_root.plain create mode 100644 tests/data/partial_rendering/pr_solo.plain create mode 100644 tests/test_partial_rendering.py create mode 100644 tests/test_plain_modules.py diff --git a/file_utils.py b/file_utils.py index 3fdfd47..bb178cc 100644 --- a/file_utils.py +++ b/file_utils.py @@ -239,8 +239,7 @@ def load_linked_resources(template_dirs: list[str], resources_list): content = open_from(template_dirs, file_name) if content is None: - raise FileNotFoundError( - f""" + raise FileNotFoundError(f""" Resource file {file_name} not found. Resource files are searched in the following order (highest to lowest precedence): 1. The directory containing your .plain file @@ -248,8 +247,7 @@ def load_linked_resources(template_dirs: list[str], resources_list): 3. The built-in 'standard_template_library' directory Please ensure that the resource exists in one of these locations, or specify the correct --template-dir if using custom templates. - """ - ) + """) linked_resources[file_name] = content diff --git a/tests/data/partial_rendering/pr_leaf.plain b/tests/data/partial_rendering/pr_leaf.plain new file mode 100644 index 0000000..1d4193e --- /dev/null +++ b/tests/data/partial_rendering/pr_leaf.plain @@ -0,0 +1,9 @@ +***implementation reqs*** + +- Leaf implementation requirement. + +***functional specs*** + +- Leaf functionality one. + +- Leaf functionality two. diff --git a/tests/data/partial_rendering/pr_middle.plain b/tests/data/partial_rendering/pr_middle.plain new file mode 100644 index 0000000..8276d96 --- /dev/null +++ b/tests/data/partial_rendering/pr_middle.plain @@ -0,0 +1,14 @@ +--- +requires: + - pr_leaf +--- + +***implementation reqs*** + +- Middle implementation requirement. + +***functional specs*** + +- Middle functionality one. + +- Middle functionality two. diff --git a/tests/data/partial_rendering/pr_root.plain b/tests/data/partial_rendering/pr_root.plain new file mode 100644 index 0000000..e5d38a4 --- /dev/null +++ b/tests/data/partial_rendering/pr_root.plain @@ -0,0 +1,14 @@ +--- +requires: + - pr_middle +--- + +***implementation reqs*** + +- Root implementation requirement. + +***functional specs*** + +- Root functionality one. + +- Root functionality two. diff --git a/tests/data/partial_rendering/pr_solo.plain b/tests/data/partial_rendering/pr_solo.plain new file mode 100644 index 0000000..d46cc9a --- /dev/null +++ b/tests/data/partial_rendering/pr_solo.plain @@ -0,0 +1,11 @@ +***implementation reqs*** + +- Solo implementation requirement. + +***functional specs*** + +- Solo functionality one. + +- Solo functionality two. + +- Solo functionality three. diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 06cb2f7..90e08b7 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -12,6 +12,7 @@ REFACTORED_CODE_COMMIT_MESSAGE, add_all_files_and_commit, diff, + get_last_finished_frid, init_git_repo, revert_changes, revert_to_commit_with_frid, @@ -63,8 +64,7 @@ def test_single_file_change(temp_repo): result = diff(temp_repo, "1.1") assert "test.txt" in result - expected_diff = dedent( - """ + expected_diff = dedent(""" --- a/test.txt +++ b/test.txt @@ -1,3 +1,3 @@ @@ -72,8 +72,7 @@ def test_single_file_change(temp_repo): +modified content line2 line3 - """ - ).strip() + """).strip() assert result["test.txt"] == expected_diff @@ -98,8 +97,7 @@ def test_multiple_file_changes(temp_repo): # Check first file assert "test.txt" in result - expected_diff1 = dedent( - """ + expected_diff1 = dedent(""" --- a/test.txt +++ b/test.txt @@ -1,3 +1,2 @@ @@ -107,22 +105,19 @@ def test_multiple_file_changes(temp_repo): +file1 modified line2 -line3 - """ - ).strip() + """).strip() assert result["test.txt"] == expected_diff1 # Check second file assert "file2.txt" in result - expected_diff2 = dedent( - """ + expected_diff2 = dedent(""" --- /dev/null +++ b/file2.txt @@ -0,0 +1,2 @@ +file2 modified +line2 \\ No newline at end of file - """ - ).strip() + """).strip() assert result["file2.txt"] == expected_diff2 @@ -157,8 +152,7 @@ def test_multiple_commits_diff(temp_repo): # Check first file assert "test.txt" in result - expected_diff1 = dedent( - """ + expected_diff1 = dedent(""" --- a/test.txt +++ b/test.txt @@ -1,3 +1,2 @@ @@ -166,35 +160,30 @@ def test_multiple_commits_diff(temp_repo): +file1 frid1.2 refactored version line2 -line3 - """ - ).strip() + """).strip() assert result["test.txt"] == expected_diff1 # Check second file assert "file2.txt" in result - expected_diff2 = dedent( - """ + expected_diff2 = dedent(""" --- a/file2.txt +++ b/file2.txt @@ -1,2 +1,2 @@ -file2 frid1.1 refactored version +file2 frid1.2 version line2 - """ - ).strip() + """).strip() assert result["file2.txt"] == expected_diff2 # Check third file assert "file3.txt" in result - expected_diff3 = dedent( - """ + expected_diff3 = dedent(""" --- /dev/null +++ b/file3.txt @@ -0,0 +1,2 @@ +file3 frid1.2 new file +line2 - """ - ).strip() + """).strip() assert result["file3.txt"] == expected_diff3 @@ -213,27 +202,23 @@ def test_diff_without_previous_frid_and_no_base_folder(empty_repo): result = diff(empty_repo) assert "new.txt" in result - expected_diff = dedent( - """ + expected_diff = dedent(""" --- /dev/null +++ b/new.txt @@ -0,0 +1,2 @@ +new file content +line2 - """ - ).strip() + """).strip() assert result["new.txt"] == expected_diff assert "new2.txt" in result - expected_diff2 = dedent( - """ + expected_diff2 = dedent(""" --- /dev/null +++ b/new2.txt @@ -0,0 +1,2 @@ +new file content +line2 - """ - ).strip() + """).strip() assert result["new2.txt"] == expected_diff2 @@ -251,16 +236,14 @@ def test_diff_without_previous_frid_and_base_folder(temp_repo): result = diff(temp_repo) assert "new.txt" in result - expected_diff = dedent( - """ + expected_diff = dedent(""" --- a/new.txt +++ b/new.txt @@ -1,2 +1,2 @@ -base folder content +updated base folder content line2 - """ - ).strip() + """).strip() assert result["new.txt"] == expected_diff @@ -276,15 +259,13 @@ def test_new_file(temp_repo): result = diff(temp_repo, "1.1") assert "new.txt" in result - expected_diff = dedent( - """ + expected_diff = dedent(""" --- /dev/null +++ b/new.txt @@ -0,0 +1,2 @@ +new file content +line2 - """ - ).strip() + """).strip() assert result["new.txt"] == expected_diff @@ -300,16 +281,14 @@ def test_deleted_file(temp_repo): result = diff(temp_repo, "1.1") assert "test.txt" in result - expected_diff = dedent( - """ + expected_diff = dedent(""" --- a/test.txt +++ /dev/null @@ -1,3 +0,0 @@ -initial content -line2 -line3 - """ - ).strip() + """).strip() assert result["test.txt"] == expected_diff @@ -456,3 +435,73 @@ def test_revert_to_base_folder_no_commit(temp_repo): assert repo.active_branch.name == "main" assert len(list(repo.iter_commits())) == 1 # initial commit assert not file_path.exists() + + +def test_get_last_finished_frid_non_existent_path(): + """Return (None, None) when the repo path doesn't exist.""" + assert get_last_finished_frid("/path/that/does/not/exist") == (None, None) + + +def test_get_last_finished_frid_empty_repo(empty_repo): + """Return (None, None) when no FRID-finished commit exists.""" + assert get_last_finished_frid(empty_repo) == (None, None) + + +def test_get_last_finished_frid_returns_latest(empty_repo): + """Return the module name and frid from the most recent finished-frid commit.""" + file_path = Path(empty_repo) / "a.txt" + file_path.write_text("v1") + add_all_files_and_commit( + empty_repo, + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), + module_name="module_a", + frid="1", + ) + + file_path.write_text("v2") + add_all_files_and_commit( + empty_repo, + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("2"), + module_name="module_a", + frid="2", + ) + + assert get_last_finished_frid(empty_repo) == ("module_a", "2") + + +def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): + """Commits that aren't finished-frid checkpoints must be skipped.""" + file_path = Path(empty_repo) / "a.txt" + file_path.write_text("v1") + add_all_files_and_commit( + empty_repo, + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), + module_name="module_a", + frid="1", + ) + + # A refactor commit (not a finished-frid checkpoint) comes after. + file_path.write_text("v2") + add_all_files_and_commit( + empty_repo, + REFACTORED_CODE_COMMIT_MESSAGE.format("2"), + module_name="module_a", + frid="2", + ) + + # The last *finished* FRID is still 1 + assert get_last_finished_frid(empty_repo) == ("module_a", "1") + + +def test_get_last_finished_frid_without_module_name(empty_repo): + """If the commit omits the module name line, return None for the module.""" + file_path = Path(empty_repo) / "a.txt" + file_path.write_text("v1") + add_all_files_and_commit( + empty_repo, + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("7"), + module_name=None, + frid="7", + ) + + assert get_last_finished_frid(empty_repo) == (None, "7") diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py new file mode 100644 index 0000000..019df8a --- /dev/null +++ b/tests/test_partial_rendering.py @@ -0,0 +1,313 @@ +"""Tests for the partial rendering logic. + +These tests use lightweight fake ``PlainModule``-like objects to exercise the +pure logic in ``partial_rendering`` without depending on the filesystem or a +real ``PlainModule``. The ``FakeModule`` exposes the small surface the module +under test relies on: ``module_name``, ``required_modules``, +``all_required_modules``, ``load_module_metadata``, ``get_module_source_hash``, +``get_module_code_hash`` and (for ``detect_partial_rendering``) +``get_last_rendered_frid``. +""" + +import pytest + +# Import ``plain_file`` first — it pulls in ``file_utils`` which transitively +# imports ``plain_modules``; starting the import graph at ``plain_file`` +# sidesteps a circular-import failure that occurs when tests import +# ``partial_rendering`` (and therefore ``plain_modules``) first. +import plain_file # noqa: F401 +from partial_rendering import PartialRender, code_change, detect_partial_rendering, module_comes_before, spec_change +from plain2code_exceptions import ModuleDoesNotExistError + + +class FakeModule: + def __init__( + self, + module_name: str, + required_modules=None, + metadata=None, + source_hash: str | None = None, + code_hash: str | None = None, + last_rendered=(None, None), + ): + self.module_name = module_name + self.required_modules = list(required_modules or []) + self._metadata = metadata + self._source_hash = source_hash if source_hash is not None else f"src-{module_name}" + self._code_hash = code_hash if code_hash is not None else f"code-{module_name}" + self._last_rendered = last_rendered + + @property + def all_required_modules(self): + result = [] + for rm in self.required_modules: + if rm.required_modules: + result.extend(rm.all_required_modules) + result.append(rm) + return result + + def load_module_metadata(self): + return self._metadata + + def get_module_source_hash(self): + return self._source_hash + + def get_module_code_hash(self): + return self._code_hash + + def get_last_rendered_frid(self): + return self._last_rendered + + +def _unchanged_metadata(module: FakeModule) -> dict: + """Return a metadata dict that reflects the module's current hashes + (i.e. the module has not changed since it was last rendered).""" + metadata = {"source_hash": module.get_module_source_hash()} + if module.required_modules: + metadata["required_modules_code_hash"] = module.required_modules[-1].get_module_code_hash() + return metadata + + +# ------------------------- +# module_comes_before +# ------------------------- + + +def test_module_comes_before_first_module_wins(): + a = FakeModule("a") + b = FakeModule("b") + c = FakeModule("c") + + assert module_comes_before([a, b, c], a, b) is True + assert module_comes_before([a, b, c], b, a) is False + assert module_comes_before([a, b, c], b, c) is True + + +def test_module_comes_before_raises_when_neither_found(): + a = FakeModule("a") + b = FakeModule("b") + missing1 = FakeModule("missing1") + missing2 = FakeModule("missing2") + + with pytest.raises(Exception, match="not found"): + module_comes_before([a, b], missing1, missing2) + + +# ------------------------- +# spec_change +# ------------------------- + + +def test_spec_change_no_metadata_anywhere_returns_none(): + """If no module has metadata yet, there is no detectable change.""" + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle]) + + assert spec_change(root) is None + + +def test_spec_change_returns_none_when_hashes_match(): + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle]) + + leaf._metadata = _unchanged_metadata(leaf) + middle._metadata = _unchanged_metadata(middle) + root._metadata = _unchanged_metadata(root) + + assert spec_change(root) is None + + +def test_spec_change_returns_top_module_when_only_root_changed(): + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle]) + + leaf._metadata = _unchanged_metadata(leaf) + middle._metadata = _unchanged_metadata(middle) + root._metadata = {"source_hash": "stale-source-hash"} + + result = spec_change(root) + assert result is root + + +def test_spec_change_returns_earliest_required_module_with_change(): + """Iteration order in ``all_required_modules`` is leaf-first; a change on + the leaf must be reported in preference to one on ``middle``.""" + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle]) + + # Both required modules have stale metadata; leaf comes first. + leaf._metadata = {"source_hash": "stale-leaf"} + middle._metadata = {"source_hash": "stale-middle"} + root._metadata = _unchanged_metadata(root) + + result = spec_change(root) + assert result is leaf + + +def test_spec_change_ignores_metadata_without_source_hash(): + leaf = FakeModule("leaf") + root = FakeModule("root", required_modules=[leaf]) + + leaf._metadata = {"unrelated": "field"} + root._metadata = _unchanged_metadata(root) + + assert spec_change(root) is None + + +# ------------------------- +# code_change +# ------------------------- + + +def test_code_change_detects_change_in_top_module(): + leaf = FakeModule("leaf") + root = FakeModule("root", required_modules=[leaf]) + + root._metadata = {"required_modules_code_hash": "stale-code-hash"} + + result = code_change(root) + assert result is root + + +def test_code_change_returns_none_when_hashes_match(): + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle]) + + middle._metadata = _unchanged_metadata(middle) + root._metadata = _unchanged_metadata(root) + + assert code_change(root) is None + + +def test_code_change_skips_leaf_modules_in_iteration(): + """A module with no required modules can't exhibit a required-modules-code + change. The iteration skips such leaves without crashing on empty lists.""" + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle]) + + # Only the top module's metadata records a stale code hash. + middle._metadata = _unchanged_metadata(middle) + root._metadata = {"required_modules_code_hash": "stale"} + + result = code_change(root) + assert result is root + + +def test_code_change_prefers_earliest_required_module(): + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle]) + + # ``middle`` has a stale code hash; so does ``root``. ``middle`` comes + # first in ``all_required_modules`` so it wins. + middle._metadata = {"required_modules_code_hash": "stale-middle-code"} + root._metadata = {"required_modules_code_hash": "stale-root-code"} + + result = code_change(root) + assert result is middle + + +# ------------------------- +# detect_partial_rendering +# ------------------------- + + +def _build_fresh_tree(last_rendered=(None, None)): + """Build a root/middle/leaf tree with all metadata in-sync (no changes).""" + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle], last_rendered=last_rendered) + leaf._metadata = _unchanged_metadata(leaf) + middle._metadata = _unchanged_metadata(middle) + root._metadata = _unchanged_metadata(root) + return root, middle, leaf + + +def test_detect_partial_rendering_returns_none_when_nothing_rendered(): + root, _, _ = _build_fresh_tree(last_rendered=(None, None)) + assert detect_partial_rendering(root) is None + + +def test_detect_partial_rendering_no_changes_returns_last_rendered(): + root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "1")) + pr = detect_partial_rendering(root) + assert isinstance(pr, PartialRender) + assert pr.module is leaf + assert pr.frid == "1" + assert pr.spec_change is False + assert pr.code_change is False + + +def test_detect_partial_rendering_raises_when_last_rendered_module_unknown(): + root, _, _ = _build_fresh_tree(last_rendered=("phantom", "1")) + with pytest.raises(ModuleDoesNotExistError, match="phantom"): + detect_partial_rendering(root) + + +def test_detect_partial_rendering_spec_change_on_earlier_module_wins(): + """When a spec change is detected on a module that precedes the last + rendered module, the PartialRender shifts back to that earlier module.""" + root, _middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) + leaf._metadata = {"source_hash": "stale-leaf"} # leaf's spec changed + + pr = detect_partial_rendering(root) + assert pr.module is leaf + assert pr.spec_change is True + assert pr.code_change is False + assert pr.frid is None + + +def test_detect_partial_rendering_ignores_spec_change_on_later_module(): + """A spec change on the *root* module (which comes after any required + module in the rendering order) should not rewind the PartialRender. + ``module_comes_before`` stops at the first match, so the leaf — the last + rendered module — wins and is returned unchanged.""" + root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "1")) + root._metadata = {"source_hash": "stale-root"} # spec change on root + + pr = detect_partial_rendering(root) + assert pr.module is leaf + assert pr.spec_change is False + assert pr.frid == "1" + + +def test_detect_partial_rendering_code_change_wins_over_last_rendered(): + root, middle, _leaf = _build_fresh_tree(last_rendered=("middle", "1")) + middle._metadata = { + "source_hash": middle.get_module_source_hash(), + "required_modules_code_hash": "stale-code", + } + + pr = detect_partial_rendering(root) + assert pr.module is middle + assert pr.code_change is True + assert pr.spec_change is False + assert pr.frid is None + + +def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive(): + """When both kinds of change exist and point at the same earlier module, + only the last one applied (code) keeps its flag.""" + root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) + # Leaf has a spec change. + leaf._metadata = {"source_hash": "stale-leaf"} + # Middle has a code change. + middle._metadata = { + "source_hash": middle.get_module_source_hash(), + "required_modules_code_hash": "stale-middle-code", + } + + pr = detect_partial_rendering(root) + # Leaf wins on spec_change (earliest); code-change check then compares + # middle to leaf and leaf wins ordering, so the code_change branch is + # skipped. We end up reporting the spec change on leaf. + assert pr.module is leaf + assert pr.spec_change is True + assert pr.code_change is False + assert pr.frid is None diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py new file mode 100644 index 0000000..fe8261e --- /dev/null +++ b/tests/test_plain_modules.py @@ -0,0 +1,288 @@ +"""Tests for ``PlainModule`` — the change-detection and navigation helpers +that power the partial-rendering feature. + +These tests build real ``PlainModule`` instances from fixtures in +``tests/data/partial_rendering`` and use a per-test temporary build folder. +""" + +import json +import os +import tempfile +from pathlib import Path + +import pytest + +# Import ``plain_file`` first to avoid a circular-import failure if this +# file is the entry point into the module graph. (See the note in +# test_partial_rendering.py.) +import plain_file # noqa: F401 +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 + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture +def fixtures_dir(get_test_data_path): + return get_test_data_path("data/partial_rendering") + + +@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 + + +@pytest.fixture +def solo_module(fixtures_dir, tmp_build_folders): + build, conformance = tmp_build_folders + return PlainModule("pr_solo.plain", build, conformance, [fixtures_dir]) + + +@pytest.fixture +def root_module(fixtures_dir, tmp_build_folders): + """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]) + + +def _write_metadata(module: PlainModule, metadata: dict) -> None: + folder = os.path.join(module.module_build_folder, CODEPLAIN_METADATA_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) + + +def _init_build_repo_with_finished_frid(module: PlainModule, frid: str) -> None: + """Initialise a git repo in ``module.module_build_folder`` and commit a + ``FUNCTIONAL_REQUIREMENT_FINISHED`` checkpoint for the given FRID.""" + os.makedirs(module.module_build_folder, exist_ok=True) + init_git_repo(module.module_build_folder, module_name=module.module_name) + + # Add a file so the commit is non-empty. + 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, + ) + + +# -------------------------------------------------------------------------- +# all_required_modules +# -------------------------------------------------------------------------- + + +def test_all_required_modules_empty_for_leaf(solo_module): + assert solo_module.all_required_modules == [] + + +def test_all_required_modules_flattens_tree(root_module): + names = [m.module_name for m in root_module.all_required_modules] + # Tree is: root -> middle -> leaf; flattened depth-first, leaf comes before middle. + assert names == ["pr_leaf", "pr_middle"] + + +# -------------------------------------------------------------------------- +# has_plain_spec_changed +# -------------------------------------------------------------------------- + + +def test_has_plain_spec_changed_true_when_no_metadata(solo_module): + """With no metadata on disk, there's no recorded hash to compare — the + module is treated as changed so the renderer re-runs it.""" + assert solo_module.has_plain_spec_changed() is True + + +def test_has_plain_spec_changed_true_when_source_hash_field_missing(solo_module): + _write_metadata(solo_module, {"functionalities": []}) + assert solo_module.has_plain_spec_changed() is True + + +def test_has_plain_spec_changed_false_when_hash_matches(solo_module): + _write_metadata(solo_module, {"source_hash": solo_module.get_module_source_hash()}) + assert solo_module.has_plain_spec_changed() is False + + +def test_has_plain_spec_changed_true_when_hash_differs(solo_module): + _write_metadata(solo_module, {"source_hash": "definitely-not-the-current-hash"}) + assert solo_module.has_plain_spec_changed() is True + + +# -------------------------------------------------------------------------- +# has_required_modules_code_changed +# -------------------------------------------------------------------------- + + +def test_has_required_modules_code_changed_false_when_no_required_modules(solo_module): + # No required modules → nothing to track, so never "changed". + assert solo_module.has_required_modules_code_changed() is False + + +def test_has_required_modules_code_changed_true_when_no_metadata(root_module): + assert root_module.has_required_modules_code_changed() is True + + +def test_has_required_modules_code_changed_true_when_hash_field_missing(root_module): + _write_metadata(root_module, {"source_hash": root_module.get_module_source_hash()}) + assert root_module.has_required_modules_code_changed() is True + + +def test_has_required_modules_code_changed_false_when_hash_matches(root_module): + previous_module = root_module.required_modules[-1] + _write_metadata( + root_module, + { + "source_hash": root_module.get_module_source_hash(), + "required_modules_code_hash": previous_module.get_module_code_hash(), + }, + ) + assert root_module.has_required_modules_code_changed() is False + + +def test_has_required_modules_code_changed_true_when_hash_differs(root_module): + _write_metadata( + root_module, + { + "source_hash": root_module.get_module_source_hash(), + "required_modules_code_hash": "stale-code-hash", + }, + ) + assert root_module.has_required_modules_code_changed() is True + + +# -------------------------------------------------------------------------- +# get_module_by_name +# -------------------------------------------------------------------------- + + +def test_get_module_by_name_finds_required_module(root_module): + leaf = root_module.get_module_by_name("pr_leaf") + assert leaf.module_name == "pr_leaf" + + +def test_get_module_by_name_raises_for_unknown(root_module): + with pytest.raises(ModuleDoesNotExistError, match="phantom"): + root_module.get_module_by_name("phantom") + + +# -------------------------------------------------------------------------- +# get_next_module +# -------------------------------------------------------------------------- + + +def test_get_next_module_returns_next_in_sequence(root_module): + # all_required_modules = [pr_leaf, pr_middle] + nxt = root_module.get_next_module("pr_leaf") + assert nxt.module_name == "pr_middle" + + +def test_get_next_module_returns_self_when_at_last(root_module): + """When the given module is the last required module, ``get_next_module`` + falls back to the top-level module itself — callers then progress to the + root's first FRID.""" + nxt = root_module.get_next_module("pr_middle") + assert nxt is root_module + + +def test_get_next_module_returns_self_when_module_not_found(root_module): + """Unknown module names no longer raise — they return the top-level + module. This is a pre-existing behaviour that callers rely on when the + tree has been fully traversed.""" + nxt = root_module.get_next_module("unknown") + assert nxt is root_module + + +# -------------------------------------------------------------------------- +# get_next_frid +# -------------------------------------------------------------------------- + + +def test_get_next_frid_within_same_module(root_module): + # Each fixture module has FRIDs ["1", "2"]. + next_frid, next_module = root_module.get_next_frid("1", "pr_leaf") + assert next_frid == "2" + assert next_module.module_name == "pr_leaf" + + +def test_get_next_frid_crosses_module_boundary(root_module): + # After pr_leaf's last FRID, progress to pr_middle's first. + next_frid, next_module = root_module.get_next_frid("2", "pr_leaf") + assert next_module.module_name == "pr_middle" + assert next_frid == "1" + + +def test_get_next_frid_from_last_required_module_progresses_to_root(root_module): + # After pr_middle's last FRID, progress to the root's first FRID. + next_frid, next_module = root_module.get_next_frid("2", "pr_middle") + assert next_module is root_module + assert next_frid == "1" + + +# -------------------------------------------------------------------------- +# get_last_rendered_frid +# -------------------------------------------------------------------------- + + +def test_get_last_rendered_frid_no_rendering(root_module): + assert root_module.get_last_rendered_frid() == (None, None) + + +def test_get_last_rendered_frid_returns_from_leaf_when_only_leaf_rendered(root_module): + leaf = root_module.get_module_by_name("pr_leaf") + _init_build_repo_with_finished_frid(leaf, "1") + + module_name, frid = root_module.get_last_rendered_frid() + assert module_name == "pr_leaf" + assert frid == "1" + + +def test_get_last_rendered_frid_prefers_most_progressed_module(root_module): + """The scan walks required_modules in reverse order — the right-most + rendered module wins.""" + leaf = root_module.get_module_by_name("pr_leaf") + middle = root_module.get_module_by_name("pr_middle") + _init_build_repo_with_finished_frid(leaf, "2") + _init_build_repo_with_finished_frid(middle, "1") + + module_name, frid = root_module.get_last_rendered_frid() + assert module_name == "pr_middle" + assert frid == "1" + + +def test_get_last_rendered_frid_returns_root_when_root_has_checkpoint(root_module): + """A checkpoint in the root's own build folder takes precedence over + required-module checkpoints.""" + leaf = root_module.get_module_by_name("pr_leaf") + _init_build_repo_with_finished_frid(leaf, "1") + _init_build_repo_with_finished_frid(root_module, "2") + + module_name, frid = root_module.get_last_rendered_frid() + assert module_name == "pr_root" + assert frid == "2" + + +# -------------------------------------------------------------------------- +# is_module_fully_rendered +# -------------------------------------------------------------------------- + + +def test_is_module_fully_rendered_false_when_nothing_rendered(solo_module): + assert solo_module.is_module_fully_rendered() is False + + +def test_is_module_fully_rendered_false_when_only_first_frid_rendered(solo_module): + _init_build_repo_with_finished_frid(solo_module, "1") + assert solo_module.is_module_fully_rendered() is False + + +def test_is_module_fully_rendered_true_when_last_frid_rendered(solo_module): + # solo module has FRIDs ["1", "2", "3"]; "3" is the last. + _init_build_repo_with_finished_frid(solo_module, "3") + assert solo_module.is_module_fully_rendered() is True From 0b3b3f577acc260db3bff8ebe8ba5eb094f2e8df Mon Sep 17 00:00:00 2001 From: zanjonke Date: Tue, 21 Apr 2026 16:13:41 +0200 Subject: [PATCH 08/14] Bugfixing, refactoring and adding changed module to render from scratch --- git_utils.py | 21 +++++- module_renderer.py | 19 +++--- partial_rendering.py | 63 ++++++++++-------- plain_modules.py | 46 +++++++------ .../actions/prepare_repositories.py | 13 +++- render_machine/render_context.py | 19 +++--- tests/test_partial_rendering.py | 64 +++++++++---------- tui/partial_render_tui.py | 51 +++++++-------- 8 files changed, 166 insertions(+), 130 deletions(-) diff --git a/git_utils.py b/git_utils.py index a94ddc2..05c0298 100644 --- a/git_utils.py +++ b/git_utils.py @@ -61,7 +61,10 @@ def _ensure_git_config(repo: Repo) -> None: def init_git_repo( - path_to_repo: Union[str, os.PathLike], module_name: Optional[str] = None, render_id: Optional[str] = None + path_to_repo: Union[str, os.PathLike], + module_name: Optional[str] = None, + render_id: Optional[str] = None, + additional_files: Optional[dict[str, str]] = None, ) -> Repo: """ Initializes a new git repository in the given path. @@ -75,6 +78,11 @@ def init_git_repo( repo = Repo.init(path_to_repo) _ensure_git_config(repo) + + if additional_files: + file_utils.store_response_files(path_to_repo, additional_files, []) + repo.git.add(".") + repo.git.commit( "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) ) @@ -83,9 +91,18 @@ def init_git_repo( def clone_repo( - source_repo_path: str, new_repo_path: str, module_name: Optional[str] = None, render_id: Optional[str] = None + source_repo_path: str, + new_repo_path: str, + module_name: Optional[str] = None, + render_id: Optional[str] = None, + additional_files: Optional[dict[str, str]] = None, ) -> Repo: repo = Repo.clone_from(source_repo_path, new_repo_path) + + if additional_files: + file_utils.store_response_files(new_repo_path, additional_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/module_renderer.py b/module_renderer.py index 00ba3f5..b393b7e 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -47,10 +47,7 @@ def _build_render_context_for_module( return RenderContext( self.codeplainAPI, memory_manager, - plain_module.module_name, - plain_module.plain_source, - plain_module.all_required_modules, - plain_module.template_dirs, + plain_module, build_folder=os.path.join(self.args.build_folder, plain_module.module_name), build_dest=self.args.build_dest, conformance_tests_folder=self.args.conformance_tests_folder, @@ -94,19 +91,19 @@ def _render_module( if rendering_failed: return False, True - console.debug(f"{plain_module.module_name=}, {force_render=}, {has_any_required_module_changed=}") - console.debug( - f"{plain_module.get_repo()=}, {plain_module.has_plain_spec_changed()=}, {plain_module.has_required_modules_code_changed()=}" - ) + if ( - ((not force_render) or any(module.name == plain_module.name for module in self.loaded_modules)) + ( + (not force_render) + or any(module.module_name == plain_module.module_name for module in self.loaded_modules) + ) and plain_module.get_repo() is not None - and not plain_module.has_plain_spec_changed() - and not plain_module.has_required_modules_code_changed() and not has_any_required_module_changed ): return False, False + # plain_module.save_module_metadata(only_hashes=True) + memory_manager = MemoryManager( self.codeplainAPI, os.path.join( diff --git a/partial_rendering.py b/partial_rendering.py index 722a7ab..2cab11b 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -1,15 +1,16 @@ from dataclasses import dataclass +import plain_spec from plain2code_exceptions import ModuleDoesNotExistError from plain_modules import PlainModule @dataclass class PartialRender: - module: PlainModule - frid: str | None = None - spec_change: bool = False - code_change: bool = False + last_render_module: PlainModule + last_render_frid: str | None + change: PlainModule | None = None + change_type: str | None = None @dataclass @@ -55,13 +56,14 @@ def code_change(plain_module: PlainModule) -> PlainModule | None: return required_module module_metadata = plain_module.load_module_metadata() - previous_module = plain_module.required_modules[-1] - if ( - module_metadata - and "required_modules_code_hash" in module_metadata - and module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() - ): - return plain_module + if len(plain_module.required_modules) > 0: + previous_module = plain_module.required_modules[-1] + if ( + module_metadata + and "required_modules_code_hash" in module_metadata + and module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() + ): + return plain_module return None @@ -94,31 +96,38 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: if required_module.module_name == last_rendered_module: module = required_module + if last_rendered_module == plain_module.module_name: + if ( + last_rendered_frid is None + or plain_spec.get_next_frid(plain_module.plain_source, last_rendered_frid) is not None + ): + module = plain_module + else: + return None + if module is None: raise ModuleDoesNotExistError( - f"Last rendered module {last_rendered_module} not found in {all_required_modules}" + f"Last rendered module {last_rendered_module} not found in {[rmodule.module_name for rmodule in all_required_modules]}" ) pr = PartialRender( - module=module, - frid=last_rendered_frid, - spec_change=False, - code_change=False, + last_render_module=module, + last_render_frid=last_rendered_frid, + change=None, + change_type=None, ) if sc is None and cc is None: return pr - if sc is not None and module_comes_before(all_required_modules, sc, pr.module): - pr.module = sc - pr.spec_change = True - pr.code_change = False - pr.frid = None - - if cc is not None and module_comes_before(all_required_modules, cc, pr.module): - pr.module = cc - pr.code_change = True - pr.spec_change = False - pr.frid = None + if sc is not None: + pr.change = sc + pr.change_type = "spec_change" + + if cc is not None and ( + pr.change is None or (pr.change is not None and module_comes_before(all_required_modules, cc, pr.change)) + ): + pr.change = cc + pr.change_type = "code_change" return pr diff --git a/plain_modules.py b/plain_modules.py index 44052b0..f3d421b 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -141,33 +141,40 @@ def _get_module_functional_requirements(self) -> list[str]: return module_functional_requirements def get_functionalities(self) -> dict[str, list[str]]: - module_metadata = self.load_module_metadata() - if module_metadata is None: - raise ModuleDoesNotExistError(f"Module {self.module_name} does not exist or has no metadata.") - - if REQUIRED_MODULES_FUNCTIONALITIES in module_metadata: - functionalities = module_metadata[REQUIRED_MODULES_FUNCTIONALITIES] - else: - functionalities = {} + functionalities = {} + for required_module in self.required_modules: + functionalities.update(required_module.get_functionalities()) - functionalities[self.module_name] = module_metadata[MODULE_FUNCTIONALITIES] + functionalities[self.module_name] = self._get_module_functional_requirements() return functionalities - def save_module_metadata( - self, - ): + 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) + + return os.path.join(self.get_codeplain_folder(), MODULE_METADATA_FILENAME) + + def get_hashes(self) -> dict[str, str]: + hashes = {"source_hash": self.get_module_source_hash()} + if len(self.required_modules) > 0: + hashes["required_modules_code_hash"] = self.required_modules[-1].get_module_code_hash() + return hashes + + def save_module_metadata(self, only_hashes: bool = False): codeplain_folder = self.get_codeplain_folder() os.makedirs(codeplain_folder, exist_ok=True) - module_metadata = { - "source_hash": self.get_module_source_hash(), - MODULE_FUNCTIONALITIES: self._get_module_functional_requirements(), - } + module_metadata = self.get_hashes() + + metadata_path = self.module_metadata_path() - if self.required_modules is not None and len(self.required_modules) > 0: - previous_module = self.required_modules[-1] - module_metadata["required_modules_code_hash"] = previous_module.get_module_code_hash() + if only_hashes: + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(module_metadata, f, indent=4) + return + + module_metadata[MODULE_FUNCTIONALITIES] = (self._get_module_functional_requirements(),) required_modules_functionalities = {} for required_module in self.required_modules: @@ -176,7 +183,6 @@ def save_module_metadata( if required_modules_functionalities: module_metadata[REQUIRED_MODULES_FUNCTIONALITIES] = required_modules_functionalities - metadata_path = os.path.join(codeplain_folder, MODULE_METADATA_FILENAME) with open(metadata_path, "w", encoding="utf-8") as f: json.dump(module_metadata, f, indent=4) diff --git a/render_machine/actions/prepare_repositories.py b/render_machine/actions/prepare_repositories.py index cc53673..196f66b 100644 --- a/render_machine/actions/prepare_repositories.py +++ b/render_machine/actions/prepare_repositories.py @@ -1,3 +1,4 @@ +import json from typing import Any import file_utils @@ -33,6 +34,11 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | ) else: + module_hashes = render_context.plain_module.get_hashes() + additional_files = { + render_context.plain_module.module_metadata_path(for_git_repo=True): json.dumps(module_hashes) + } + if render_context.required_modules: previous_module = render_context.required_modules[-1] if render_context.verbose: @@ -44,13 +50,17 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.build_folder, render_context.module_name, render_context.run_state.render_id, + additional_files, ) else: if render_context.verbose: console.debug("Initializing git repositories for the render folders.") git_utils.init_git_repo( - render_context.build_folder, render_context.module_name, render_context.run_state.render_id + render_context.build_folder, + render_context.module_name, + render_context.run_state.render_id, + additional_files, ) if render_context.base_folder: @@ -68,6 +78,7 @@ 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, + additional_files, ) return self.SUCCESSFUL_OUTCOME, None diff --git a/render_machine/render_context.py b/render_machine/render_context.py index dae210c..5652038 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -33,10 +33,7 @@ def __init__( self, codeplain_api, memory_manager, - module_name: str, - plain_source_tree: dict, - required_modules: list[PlainModule], - template_dirs: list[str], + plain_module: PlainModule, build_folder: str, build_dest: str, conformance_tests_folder: str, @@ -58,10 +55,11 @@ def __init__( ): self.codeplain_api: CodeplainAPI = codeplain_api self.memory_manager = memory_manager - self.plain_source_tree = plain_source_tree - self.module_name = module_name - self.template_dirs = template_dirs - self.required_modules = required_modules + self.plain_module = plain_module + self.plain_source_tree = plain_module.plain_source + self.module_name = plain_module.module_name + self.template_dirs = plain_module.template_dirs + self.required_modules = plain_module.required_modules self.build_folder = build_folder self.build_dest = build_dest self.conformance_tests_folder = conformance_tests_folder @@ -84,8 +82,8 @@ def __init__( self.test_script_timeout = test_script_timeout resources_list = [] - plain_spec.collect_linked_resources(plain_source_tree, resources_list, None, True) - self.all_linked_resources = file_utils.load_linked_resources(template_dirs, resources_list) + plain_spec.collect_linked_resources(self.plain_source_tree, resources_list, None, True) + self.all_linked_resources = file_utils.load_linked_resources(self.template_dirs, resources_list) # Initialize context objects self.frid_context: Optional[FridContext] = None @@ -130,6 +128,7 @@ def create_snapshot(self) -> RenderContextSnapshot: ) def get_required_modules_functionalities(self): + print(f"Getting required modules functionalities for {self.module_name}...") required_modules_functionalities = {} if self.required_modules is not None and len(self.required_modules) > 0: for required_module in self.required_modules: diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index 019df8a..adb7f7c 100644 --- a/tests/test_partial_rendering.py +++ b/tests/test_partial_rendering.py @@ -238,10 +238,10 @@ def test_detect_partial_rendering_no_changes_returns_last_rendered(): root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "1")) pr = detect_partial_rendering(root) assert isinstance(pr, PartialRender) - assert pr.module is leaf - assert pr.frid == "1" - assert pr.spec_change is False - assert pr.code_change is False + assert pr.last_render_module is leaf + assert pr.last_render_frid == "1" + assert pr.change is None + assert pr.change_type is None def test_detect_partial_rendering_raises_when_last_rendered_module_unknown(): @@ -251,30 +251,31 @@ def test_detect_partial_rendering_raises_when_last_rendered_module_unknown(): def test_detect_partial_rendering_spec_change_on_earlier_module_wins(): - """When a spec change is detected on a module that precedes the last - rendered module, the PartialRender shifts back to that earlier module.""" - root, _middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) + """A spec change on an earlier (required) module is reported via + ``change``/``change_type``; ``last_render_module`` and ``last_render_frid`` + remain the ones reported by ``get_last_rendered_frid``.""" + root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) leaf._metadata = {"source_hash": "stale-leaf"} # leaf's spec changed pr = detect_partial_rendering(root) - assert pr.module is leaf - assert pr.spec_change is True - assert pr.code_change is False - assert pr.frid is None + assert pr.last_render_module is middle + assert pr.change is leaf + assert pr.change_type == "spec_change" + assert pr.last_render_frid == "1" -def test_detect_partial_rendering_ignores_spec_change_on_later_module(): - """A spec change on the *root* module (which comes after any required - module in the rendering order) should not rewind the PartialRender. - ``module_comes_before`` stops at the first match, so the leaf — the last - rendered module — wins and is returned unchanged.""" +def test_detect_partial_rendering_spec_change_on_top_module_is_reported(): + """A spec change on the top (root) module is reported on the + ``PartialRender``; ``last_render_module``/``last_render_frid`` reflect the + module that was last rendered.""" root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "1")) root._metadata = {"source_hash": "stale-root"} # spec change on root pr = detect_partial_rendering(root) - assert pr.module is leaf - assert pr.spec_change is False - assert pr.frid == "1" + assert pr.last_render_module is leaf + assert pr.change is root + assert pr.change_type == "spec_change" + assert pr.last_render_frid == "1" def test_detect_partial_rendering_code_change_wins_over_last_rendered(): @@ -285,15 +286,17 @@ def test_detect_partial_rendering_code_change_wins_over_last_rendered(): } pr = detect_partial_rendering(root) - assert pr.module is middle - assert pr.code_change is True - assert pr.spec_change is False - assert pr.frid is None + assert pr.last_render_module is middle + assert pr.change is middle + assert pr.change_type == "code_change" + assert pr.last_render_frid == "1" def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive(): - """When both kinds of change exist and point at the same earlier module, - only the last one applied (code) keeps its flag.""" + """When both a spec change and a code change are detected, the + ``code_change`` branch only overrides if ``cc`` comes before the current + ``pr.change``. Here leaf (spec) precedes middle (code) in + ``all_required_modules``, so the spec change on leaf wins.""" root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) # Leaf has a spec change. leaf._metadata = {"source_hash": "stale-leaf"} @@ -304,10 +307,7 @@ def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive() } pr = detect_partial_rendering(root) - # Leaf wins on spec_change (earliest); code-change check then compares - # middle to leaf and leaf wins ordering, so the code_change branch is - # skipped. We end up reporting the spec change on leaf. - assert pr.module is leaf - assert pr.spec_change is True - assert pr.code_change is False - assert pr.frid is None + assert pr.last_render_module is middle + assert pr.change is leaf + assert pr.change_type == "spec_change" + assert pr.last_render_frid == "1" diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index 1eb9145..c91b19e 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -36,23 +36,24 @@ def on_mount(self) -> None: left.mount(Label("[#e0ff6e]Current state:[/]")) left.mount(Label(f"Target module: [{'#79fc96'}]{pm.module_name}[/]")) - if pr.spec_change: - left.mount(Label(f"Detected spec change of module [{'#79fc96'}]{pr.module.module_name}[/]")) - elif pr.code_change: - left.mount(Label(f"Detected code change of module [{'#79fc96'}]{pr.module.module_name}[/]")) - elif pr.frid is None or pr.module.is_module_fully_rendered(): - left.mount(Label(f"Last fully rendered module: [{'#79fc96'}]{pr.module.module_name}[/]")) - else: - left.mount(Label(f"Partially rendered module: [{'#79fc96'}]{pr.module.module_name}[/]")) - left.mount(Label(f"Last fully rendered functionality: [{'#79fc96'}]{pr.frid}[/]")) + if pr.change_type == "spec_change": + left.mount(Label(f"Detected spec change of module [{'#79fc96'}]{pr.last_render_module.module_name}[/]")) + elif pr.change_type == "code_change": + left.mount(Label(f"Detected code change of module [{'#79fc96'}]{pr.last_render_module.module_name}[/]")) - # left.mount(Label(f"Spec change: [{'#79fc96'}]{'Yes' if pr.spec_change else 'No'}[/]")) - # left.mount(Label(f"Code change: [{'#79fc96'}]{'Yes' if pr.code_change else 'No'}[/]")) + if pr.last_render_frid is None or pr.last_render_module.is_module_fully_rendered(): + left.mount(Label(f"Module [{'#79fc96'}]{pr.last_render_module.module_name}[/] was fully rendered.")) + else: + left.mount( + Label( + f"Module [{'#79fc96'}]{pr.last_render_module.module_name}[/] is partially rendered. Last fully rendered functionality was [{'#79fc96'}]{pr.last_render_frid}[/]" + ) + ) # Build choices (same logic as original) choice_idx = 1 - if pr.frid is not None: - next_frid, next_module = pm.get_next_frid(pr.frid, pr.module.module_name) + if pr.last_render_frid is not None: + next_frid, next_module = pm.get_next_frid(pr.last_render_frid, pr.last_render_module.module_name) functionality = next_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][int(next_frid) - 1] # Placeholder — right side @@ -76,30 +77,26 @@ def on_mount(self) -> None: ) choice_idx += 1 - if pr.spec_change: + if pr.change: + reason = "spec change" if pr.change_type == "spec_change" else "code change" self.choices[str(choice_idx)] = PartialRenderChoice( - module=pr.module, + module=pr.change, render_range=None, - msg=f"Re-render {pr.module.module_name} from start", + msg=f"Re-render {pr.change.module_name} from start due to {reason}", ) choice_idx += 1 - if pr.code_change: + first_module = pm.all_required_modules[0] + if first_module.module_name != pr.last_render_module.module_name and ( + pr.change is not None and first_module.module_name != pr.change.module_name + ): self.choices[str(choice_idx)] = PartialRenderChoice( - module=pr.module, + module=first_module, render_range=None, - msg=f"Re-render {pr.module.module_name} from start", + msg=f"Re-render all (start from: {first_module.module_name})", ) choice_idx += 1 - first_module = pm.all_required_modules[0] - self.choices[str(choice_idx)] = PartialRenderChoice( - module=first_module, - render_range=None, - msg=f"Re-render all (start from: {first_module.module_name})", - ) - choice_idx += 1 - self.choices[str(choice_idx)] = PartialRenderChoice(module=None, render_range=None, msg="Quit") # Populate the ListView From e6ac6f4748fbd149f89ec694bb3d9f22bb0766e6 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Wed, 22 Apr 2026 10:30:31 +0200 Subject: [PATCH 09/14] Styling the PartialRenderTUI --- plain2code.py | 2 + tui/partial_render_tui.py | 128 ++++++++++++++++++++++++++------------ tui/styles.css | 48 +++++++------- 3 files changed, 114 insertions(+), 64 deletions(-) diff --git a/plain2code.py b/plain2code.py index 7ed2057..5b0a3cd 100644 --- a/plain2code.py +++ b/plain2code.py @@ -184,6 +184,8 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 app = PartialRenderTUI( plain_module, partial_render, + system_config.client_version, + run_state.render_id, css_path="styles.css", ) partial_render_choice = app.run() diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index c91b19e..d9edbf1 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -1,70 +1,78 @@ from textual import on from textual.app import App, ComposeResult -from textual.containers import Horizontal, Vertical -from textual.widgets import Label, ListItem, ListView +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.widgets import ContentSwitcher, Label, ListItem, ListView, Static import plain_spec from partial_rendering import PartialRender, PartialRenderChoice from plain_modules import PlainModule +from tui.components import CustomFooter, TUIComponents class PartialRenderTUI(App): - def __init__(self, plain_module: PlainModule, partial_render: PartialRender, **kwargs): + BINDINGS = [ + Binding("ctrl+o", "toggle_expand", "Expand/Collapse", show=False), + Binding("ctrl+c", "quit", "Quit", show=False), + Binding("ctrl+d", "quit", "Quit", show=False), + ] + + def __init__( + self, + plain_module: PlainModule, + partial_render: PartialRender, + state_machine_version: str, + render_id: str, + **kwargs, + ): super().__init__(**kwargs) self.plain_module = plain_module self.partial_render = partial_render self.choices = {} # populated in on_mount + self.state_machine_version = state_machine_version + self.render_id = render_id + self._expandable_labels: list[dict] = [] def compose(self) -> ComposeResult: - yield Vertical( - Label("[bold]--- Partial Render Detected ---[/bold]", classes="highlight"), - Horizontal( - Vertical(id="info-panel-left"), - Vertical(id="info-panel-right"), - id="info-panel-columns", - ), - id="info-panel", - ) - yield ListView(id="choice-list") + with ContentSwitcher(id="content-switcher", initial=TUIComponents.DASHBOARD_VIEW.value): + with Vertical(id=TUIComponents.DASHBOARD_VIEW.value): + with VerticalScroll(): + yield Static( + f"[#FFFFFF]*codeplain[/#FFFFFF] [#888888](v{self.state_machine_version})[/#888888]", + id="codeplain-header", + classes="codeplain-header", + ) + yield Vertical(id="info-panel") + yield ListView(id="choice-list") + yield CustomFooter(render_id=self.render_id) def on_mount(self) -> None: pr = self.partial_render pm = self.plain_module - # Info labels — left side - left = self.query_one("#info-panel-left", Vertical) - left.mount(Label("[#e0ff6e]Current state:[/]")) - left.mount(Label(f"Target module: [{'#79fc96'}]{pm.module_name}[/]")) + info_panel = self.query_one("#info-panel", Vertical) + info_panel.mount(Label("module status", classes="rendering-info-title")) - if pr.change_type == "spec_change": - left.mount(Label(f"Detected spec change of module [{'#79fc96'}]{pr.last_render_module.module_name}[/]")) - elif pr.change_type == "code_change": - left.mount(Label(f"Detected code change of module [{'#79fc96'}]{pr.last_render_module.module_name}[/]")) + info_box = Vertical(classes="rendering-info-box") + info_panel.mount(info_box) + info_box.mount(Label(f"module: {pr.last_render_module.module_name}", classes="rendering-info-row")) if pr.last_render_frid is None or pr.last_render_module.is_module_fully_rendered(): - left.mount(Label(f"Module [{'#79fc96'}]{pr.last_render_module.module_name}[/] was fully rendered.")) + info_box.mount(Label("module fully rendered", classes="rendering-info-row")) else: - left.mount( - Label( - f"Module [{'#79fc96'}]{pr.last_render_module.module_name}[/] is partially rendered. Last fully rendered functionality was [{'#79fc96'}]{pr.last_render_frid}[/]" - ) - ) + frid = pr.last_render_frid + functionality = pr.last_render_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][int(frid) - 1][ + "markdown" + ] + label = Label("", classes="rendering-info-row") + info_box.mount(label) + self._register_expandable(label, f"functionality {frid}:", functionality) # Build choices (same logic as original) choice_idx = 1 if pr.last_render_frid is not None: next_frid, next_module = pm.get_next_frid(pr.last_render_frid, pr.last_render_module.module_name) - functionality = next_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][int(next_frid) - 1] - # Placeholder — right side - - right = self.query_one("#info-panel-right", Vertical) - right.mount(Label("[#e0ff6e]Next functionality:[/]")) - right.mount( - Label(f"Module [{'#79fc96'}]{next_module.module_name}[/], functionality [{'#79fc96'}]{next_frid}[/]") - ) - right.mount(Label(functionality["markdown"])) - msg = f"Continue from next functionality (module {next_module.module_name}" if next_frid != plain_spec.get_first_frid(next_module.plain_source): msg += f" functionality {next_frid})" @@ -77,14 +85,44 @@ def on_mount(self) -> None: ) choice_idx += 1 + change_box = Vertical(classes="change-box") + info_panel.mount(change_box) if pr.change: reason = "spec change" if pr.change_type == "spec_change" else "code change" + + title_start = "Spec changes" if pr.change_type == "spec_change" else "Code changes" + change_box.mount( + Label( + f"--- {title_start} detected in required module [#5593FF]{pr.change.module_name}[/] ---", + classes="rendering-info-row", + ) + ) + change_box.mount( + Label(f"{title_start} in a required module may affect the current module", classes="rendering-info-row") + ) + self.choices[str(choice_idx)] = PartialRenderChoice( module=pr.change, render_range=None, msg=f"Re-render {pr.change.module_name} from start due to {reason}", ) choice_idx += 1 + elif pr.last_render_frid is None or pr.last_render_module.is_module_fully_rendered(): + change_box.mount(Label("--- Rendering interrupted ---", classes="rendering-info-row")) + change_box.mount(Label("The current module was fully rendered.", classes="rendering-info-row")) + else: + change_box.mount( + Label( + f"--- Rendering interrupted during [#5593FF]functionality {pr.last_render_frid}[/] ---", + classes="rendering-info-row", + ) + ) + change_box.mount( + Label( + "Resume from the last successfully rendered functionality or start over.", + classes="rendering-info-row", + ) + ) first_module = pm.all_required_modules[0] if first_module.module_name != pr.last_render_module.module_name and ( @@ -101,13 +139,25 @@ def on_mount(self) -> None: # Populate the ListView lv = self.query_one("#choice-list", ListView) - self.mount(Label("How would you like to start rendering?", classes="partial-render-question"), before=lv) + self.mount(Label("How would you like to continue?", classes="partial-render-question"), before=lv) for key, choice in self.choices.items(): lv.append(ListItem(Label(f"[bold]{key}.[/bold] {choice.msg}"), id=f"choice-{key}")) + lv.focus() + + def _register_expandable(self, label: Label, prefix: str, full_text: str) -> None: + first_line = full_text[:20] + short = f"{prefix} {first_line} [#888](ctrl+o to expand)[/]" + full = f"{prefix} {full_text} [#888](ctrl+o to collapse)[/]" + label.update(short) + self._expandable_labels.append({"label": label, "short": short, "full": full, "expanded": False}) + + def action_toggle_expand(self) -> None: + for entry in self._expandable_labels: + entry["expanded"] = not entry["expanded"] + entry["label"].update(entry["full"] if entry["expanded"] else entry["short"]) @on(ListView.Selected) def on_choice_selected(self, event: ListView.Selected) -> None: - # Extract the key from the widget id ("choice-1" → "1") key = event.item.id.split("-", 1)[1] self.selected_choice = self.choices[key] self.exit(self.selected_choice) diff --git a/tui/styles.css b/tui/styles.css index 5c88b9d..a0d9306 100644 --- a/tui/styles.css +++ b/tui/styles.css @@ -426,40 +426,38 @@ CustomFooter.footer-state-paused { /* Partial Render TUI Styles */ #info-panel { height: auto; - border: solid #e0ff6e; - padding: 1 2; - margin: 1 2; } -#info-panel .highlight { - color: #79fc96; - margin-bottom: 1; -} -#info-panel-columns { + +ListView { height: auto; + background: transparent; } -#info-panel-left { - width: auto; - height: auto; - margin-right: 8; +ListView:focus { + background: transparent; } -#info-panel-left Label { - color: $text-muted; +ListItem { + background: transparent; + color: #fff; } -#info-panel-right { - width: auto; - height: auto; +ListItem:hover { + background: transparent; } -#info-panel-right Label { - color: $text-muted; +ListItem.-highlight { + background: #333; + color: #fff; } -ListView { - margin: 0 2; + +.partial-render-question { + margin: 0 1; height: auto; } -ListItem { - padding: 0 1; + +.change-box { + margin: 1 1; + height: auto; } -.partial-render-question { - margin: 1 2; + +#choice-list { + margin: 0 1; height: auto; } \ No newline at end of file From b9dd3339ff71ae97095360ff8accaf7a1838693c Mon Sep 17 00:00:00 2001 From: zanjonke Date: Wed, 22 Apr 2026 12:20:20 +0200 Subject: [PATCH 10/14] Bugfix for partial render choice and polishing styling --- module_renderer.py | 16 +++++++++------ partial_rendering.py | 4 ++-- plain_modules.py | 5 ++++- tests/test_partial_rendering.py | 24 +++++++++++----------- tui/partial_render_tui.py | 35 +++++++++++++++++++++------------ tui/styles.css | 4 ++-- 6 files changed, 53 insertions(+), 35 deletions(-) diff --git a/module_renderer.py b/module_renderer.py index b393b7e..6cb0ad7 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -76,11 +76,17 @@ def _render_module( Returns: tuple[bool, bool]: (Whether the module was rendered and whether the rendering failed) """ + is_partial_render_choice_module = ( + self.partial_render_choice is not None + and self.partial_render_choice.module is not None + and self.partial_render_choice.module.module_name == plain_module.module_name + ) + render_range = self.partial_render_choice.render_range if is_partial_render_choice_module else render_range if render_range is not None: plain_module.ensure_previous_frid_commits_exist(render_range, self.args.render_conformance_tests) has_any_required_module_changed = False - if not self.args.render_machine_graph and plain_module.required_modules: + if not self.args.render_machine_graph and plain_module.required_modules and not is_partial_render_choice_module: console.debug(f"Analyzing required modules of module {plain_module.module_name}...") for required_module in plain_module.required_modules: has_any_required_module_changed, rendering_failed = self._render_module( @@ -99,11 +105,12 @@ def _render_module( ) and plain_module.get_repo() is not None and not has_any_required_module_changed + and not plain_module.has_plain_spec_changed() + and not plain_module.has_required_modules_code_changed() + and not is_partial_render_choice_module ): return False, False - # plain_module.save_module_metadata(only_hashes=True) - memory_manager = MemoryManager( self.codeplainAPI, os.path.join( @@ -139,9 +146,6 @@ def _render_module( def render_module(self) -> None: self.loaded_modules = list[PlainModule]() - if self.partial_render_choice is not None and self.partial_render_choice.module is not None: - self._render_module(self.partial_render_choice.module, self.partial_render_choice.render_range, True) - _, rendering_failed = self._render_module(self.plain_module, self.render_range, True) if not rendering_failed: # Get the last module that completed rendering diff --git a/partial_rendering.py b/partial_rendering.py index 2cab11b..ac4efb7 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -53,7 +53,7 @@ def code_change(plain_module: PlainModule) -> PlainModule | None: and "required_modules_code_hash" in module_metadata and module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() ): - return required_module + return previous_module module_metadata = plain_module.load_module_metadata() if len(plain_module.required_modules) > 0: @@ -63,7 +63,7 @@ def code_change(plain_module: PlainModule) -> PlainModule | None: and "required_modules_code_hash" in module_metadata and module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() ): - return plain_module + return previous_module return None diff --git a/plain_modules.py b/plain_modules.py index f3d421b..a423b18 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -72,7 +72,7 @@ def get_last_rendered_frid(self) -> tuple[str, str | None]: return git_utils.get_last_finished_frid(self.module_build_folder) module_name, frid = git_utils.get_last_finished_frid(self.module_build_folder) - if module_name is not None and frid is not None: + if module_name is not None and frid is not None and module_name == self.module_name: return module_name, frid for module in reversed(self.required_modules): @@ -277,6 +277,9 @@ def ensure_previous_frid_commits_exist(self, render_range: list[str], render_con self._ensure_frid_commit_exists(prev_frid, first_render_frid, render_conformance_tests) def get_module_by_name(self, module_name: str) -> PlainModule: + if self.module_name == module_name: + return self + for module in self.all_required_modules: if module.module_name == module_name: return module diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index adb7f7c..d7686be 100644 --- a/tests/test_partial_rendering.py +++ b/tests/test_partial_rendering.py @@ -170,7 +170,7 @@ def test_code_change_detects_change_in_top_module(): root._metadata = {"required_modules_code_hash": "stale-code-hash"} result = code_change(root) - assert result is root + assert result is leaf def test_code_change_returns_none_when_hashes_match(): @@ -196,7 +196,7 @@ def test_code_change_skips_leaf_modules_in_iteration(): root._metadata = {"required_modules_code_hash": "stale"} result = code_change(root) - assert result is root + assert result is middle def test_code_change_prefers_earliest_required_module(): @@ -205,12 +205,13 @@ def test_code_change_prefers_earliest_required_module(): root = FakeModule("root", required_modules=[middle]) # ``middle`` has a stale code hash; so does ``root``. ``middle`` comes - # first in ``all_required_modules`` so it wins. + # first in ``all_required_modules``, so ``middle``'s previous module + # (``leaf``) is reported as the changed code. middle._metadata = {"required_modules_code_hash": "stale-middle-code"} root._metadata = {"required_modules_code_hash": "stale-root-code"} result = code_change(root) - assert result is middle + assert result is leaf # ------------------------- @@ -279,7 +280,7 @@ def test_detect_partial_rendering_spec_change_on_top_module_is_reported(): def test_detect_partial_rendering_code_change_wins_over_last_rendered(): - root, middle, _leaf = _build_fresh_tree(last_rendered=("middle", "1")) + root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) middle._metadata = { "source_hash": middle.get_module_source_hash(), "required_modules_code_hash": "stale-code", @@ -287,20 +288,21 @@ def test_detect_partial_rendering_code_change_wins_over_last_rendered(): pr = detect_partial_rendering(root) assert pr.last_render_module is middle - assert pr.change is middle + assert pr.change is leaf assert pr.change_type == "code_change" assert pr.last_render_frid == "1" def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive(): """When both a spec change and a code change are detected, the - ``code_change`` branch only overrides if ``cc`` comes before the current - ``pr.change``. Here leaf (spec) precedes middle (code) in - ``all_required_modules``, so the spec change on leaf wins.""" + ``code_change`` branch overrides if ``cc`` comes before (or equals) the + current ``pr.change``. Here both point at ``leaf`` — spec_change returns + the module whose spec is stale; code_change returns the previous module + whose code has changed — so the code_change branch wins the tie.""" root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) # Leaf has a spec change. leaf._metadata = {"source_hash": "stale-leaf"} - # Middle has a code change. + # Middle records a stale code hash for its required module (leaf). middle._metadata = { "source_hash": middle.get_module_source_hash(), "required_modules_code_hash": "stale-middle-code", @@ -309,5 +311,5 @@ def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive() pr = detect_partial_rendering(root) assert pr.last_render_module is middle assert pr.change is leaf - assert pr.change_type == "spec_change" + assert pr.change_type == "code_change" assert pr.last_render_frid == "1" diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index d9edbf1..8daa4de 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -56,9 +56,9 @@ def on_mount(self) -> None: info_box = Vertical(classes="rendering-info-box") info_panel.mount(info_box) - info_box.mount(Label(f"module: {pr.last_render_module.module_name}", classes="rendering-info-row")) + info_box.mount(Label(f"Module: {pr.last_render_module.module_name}", classes="rendering-info-row")) if pr.last_render_frid is None or pr.last_render_module.is_module_fully_rendered(): - info_box.mount(Label("module fully rendered", classes="rendering-info-row")) + info_box.mount(Label("Module fully rendered", classes="rendering-info-row")) else: frid = pr.last_render_frid functionality = pr.last_render_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][int(frid) - 1][ @@ -66,18 +66,18 @@ def on_mount(self) -> None: ] label = Label("", classes="rendering-info-row") info_box.mount(label) - self._register_expandable(label, f"functionality {frid}:", functionality) + self._register_expandable(label, f"Functionality {frid}:", functionality) # Build choices (same logic as original) choice_idx = 1 if pr.last_render_frid is not None: next_frid, next_module = pm.get_next_frid(pr.last_render_frid, pr.last_render_module.module_name) - msg = f"Continue from next functionality (module {next_module.module_name}" + msg = "Continue from" if next_frid != plain_spec.get_first_frid(next_module.plain_source): - msg += f" functionality {next_frid})" + msg += f" functionality [#5593FF]{next_frid}[/]" else: - msg += ")" + msg += f" module [#5593FF]{next_module.module_name}[/]" self.choices[str(choice_idx)] = PartialRenderChoice( module=next_module, render_range=plain_spec.get_render_range_from(next_frid, next_module.plain_source), @@ -88,8 +88,6 @@ def on_mount(self) -> None: change_box = Vertical(classes="change-box") info_panel.mount(change_box) if pr.change: - reason = "spec change" if pr.change_type == "spec_change" else "code change" - title_start = "Spec changes" if pr.change_type == "spec_change" else "Code changes" change_box.mount( Label( @@ -98,18 +96,29 @@ def on_mount(self) -> None: ) ) change_box.mount( - Label(f"{title_start} in a required module may affect the current module", classes="rendering-info-row") + Label( + f"{title_start} in a required module may affect the current module", classes="rendering-info-title" + ) ) + all_affected_modules = list[str]() + affected_module = False + for module in pm.all_required_modules: + if module.module_name == pr.change.module_name: + affected_module = True + if affected_module: + all_affected_modules.append(module.module_name) + self.choices[str(choice_idx)] = PartialRenderChoice( module=pr.change, render_range=None, - msg=f"Re-render {pr.change.module_name} from start due to {reason}", + msg=f"Re-render all affected modules ([#5593FF]{', '.join(all_affected_modules)}[/])", ) choice_idx += 1 + elif pr.last_render_frid is None or pr.last_render_module.is_module_fully_rendered(): change_box.mount(Label("--- Rendering interrupted ---", classes="rendering-info-row")) - change_box.mount(Label("The current module was fully rendered.", classes="rendering-info-row")) + change_box.mount(Label("The current module was fully rendered.", classes="rendering-info-title")) else: change_box.mount( Label( @@ -120,7 +129,7 @@ def on_mount(self) -> None: change_box.mount( Label( "Resume from the last successfully rendered functionality or start over.", - classes="rendering-info-row", + classes="rendering-info-title", ) ) @@ -131,7 +140,7 @@ def on_mount(self) -> None: self.choices[str(choice_idx)] = PartialRenderChoice( module=first_module, render_range=None, - msg=f"Re-render all (start from: {first_module.module_name})", + msg=f"Re-render from first module ([#5593FF]{first_module.module_name}[/])", ) choice_idx += 1 diff --git a/tui/styles.css b/tui/styles.css index a0d9306..6aad491 100644 --- a/tui/styles.css +++ b/tui/styles.css @@ -14,8 +14,7 @@ Screen { /* Codeplain Header Box - *codeplain (version)*/ #codeplain-header { - dock: top; - margin: 1 0; + margin: 1 0 0 0; width: auto; height: auto; } @@ -425,6 +424,7 @@ CustomFooter.footer-state-paused { /* Partial Render TUI Styles */ #info-panel { + margin-top: 1; height: auto; } From 79d8810a2f44891d9751b1542258cb6e5f8713b6 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Wed, 22 Apr 2026 14:29:27 +0200 Subject: [PATCH 11/14] Updating footer of PartialRenderTUI --- tui/components.py | 80 +++++++++++++++++++++++++++++++++------ tui/partial_render_tui.py | 2 +- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/tui/components.py b/tui/components.py index c394b91..b076765 100644 --- a/tui/components.py +++ b/tui/components.py @@ -13,18 +13,59 @@ class CustomFooter(Horizontal): """A custom footer with keyboard shortcuts and render ID.""" - FOOTER_BASE_TEXT = "ctrl+c: copy * ctrl+d: quit * ctrl+l: toggle logs" - FOOTER_RENDERING_TEXT = FOOTER_BASE_TEXT + " * ctrl+p: pause" - RENDER_PAUSING_TEXT = FOOTER_BASE_TEXT + " * pausing ..." - RENDER_PAUSED_TEXT = FOOTER_BASE_TEXT + " * ctrl+p: resume" - RENDER_FINISHED_TEXT = "enter: exit * ctrl+c: copy * ctrl+l: toggle logs" + KEYBOARD_SHORTCUTS = ["ctrl+c", "ctrl+d", "ctrl+l", "ctrl+p", "enter"] + + KEYBOARD_SHORTCUTS_EFFECTS = { + "default": { + "ctrl+c": "copy", + "ctrl+d": "quit", + "ctrl+l": "toggle logs", + "ctrl+p": "pause", + }, + "pausing": { + "ctrl+c": "copy", + "ctrl+d": "quit", + "ctrl+l": "toggle logs", + }, + "paused": { + "ctrl+c": "copy", + "ctrl+d": "quit", + "ctrl+l": "toggle logs", + "ctrl+p": "resume", + }, + "finished": { + "enter": "exit", + "ctrl+c": "copy", + "ctrl+l": "toggle logs", + }, + } + + APPENDING_TEXT = { + "pausing": "pausing ...", + } - def __init__(self, render_id: str = "", **kwargs): + DELIMITER = " * " + + def __init__(self, render_id: str = "", use_logs_shortcut: bool = True, use_pause_shortcut: bool = True, **kwargs): super().__init__(**kwargs) self.render_id = render_id + self.use_logs_shortcut = use_logs_shortcut + self.use_pause_shortcut = use_pause_shortcut + self.available_keyboard_shortcuts = self.KEYBOARD_SHORTCUTS[:] + if not self.use_logs_shortcut: + self.available_keyboard_shortcuts.remove("ctrl+l") + if not self.use_pause_shortcut: + self.available_keyboard_shortcuts.remove("ctrl+p") def compose(self): - self._footer_text_widget = Static(self.FOOTER_RENDERING_TEXT, classes="custom-footer-text") + footer_rendering_text = self.DELIMITER.join( + [ + f"{shortcut}: {effect}" + for shortcut, effect in self.KEYBOARD_SHORTCUTS_EFFECTS["default"].items() + if shortcut in self.available_keyboard_shortcuts + ] + ) + self._footer_text_widget = Static(footer_rendering_text, classes="custom-footer-text") yield self._footer_text_widget if self.render_id: yield Static(f"render id: {self.render_id} ", classes="custom-footer-render-id") @@ -33,18 +74,35 @@ def update_footer_state(self, state: Literal["rendering", "pausing", "paused", " self.remove_class("footer-state-default") self.remove_class("footer-state-paused") + if state in self.KEYBOARD_SHORTCUTS_EFFECTS: + state_dict = self.KEYBOARD_SHORTCUTS_EFFECTS[state] + else: + state_dict = self.KEYBOARD_SHORTCUTS_EFFECTS["default"] + + footer_rendering_text = self.DELIMITER.join( + [ + f"{shortcut}: {effect}" + for shortcut, effect in state_dict.items() + if shortcut in self.available_keyboard_shortcuts + ] + ) + + appending_text = self.APPENDING_TEXT.get(state, "") + if not appending_text.strip() == "": + footer_rendering_text = f"{footer_rendering_text} * {appending_text}" + if self._footer_text_widget is not None: if state == "rendering": - self._footer_text_widget.update(self.FOOTER_RENDERING_TEXT) + self._footer_text_widget.update(footer_rendering_text) self.add_class("footer-state-default") elif state == "pausing": - self._footer_text_widget.update(self.RENDER_PAUSING_TEXT) + self._footer_text_widget.update(footer_rendering_text) self.add_class("footer-state-paused") elif state == "paused": - self._footer_text_widget.update(self.RENDER_PAUSED_TEXT) + self._footer_text_widget.update(footer_rendering_text) self.add_class("footer-state-paused") elif state == "finished": - self._footer_text_widget.update(self.RENDER_FINISHED_TEXT) + self._footer_text_widget.update(footer_rendering_text) self.add_class("footer-state-default") diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index 8daa4de..8f5144e 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -44,7 +44,7 @@ def compose(self) -> ComposeResult: ) yield Vertical(id="info-panel") yield ListView(id="choice-list") - yield CustomFooter(render_id=self.render_id) + yield CustomFooter(render_id=self.render_id, use_logs_shortcut=False, use_pause_shortcut=False) def on_mount(self) -> None: pr = self.partial_render From 532e947ebd3958cb6c871c877d9f4c8be7759834 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Wed, 22 Apr 2026 15:05:25 +0200 Subject: [PATCH 12/14] Cleanup --- git_utils.py | 12 +++++----- partial_rendering.py | 2 +- plain_modules.py | 23 +++++++++++------- .../actions/prepare_repositories.py | 8 +++---- render_machine/render_context.py | 1 - tests/test_plain_modules.py | 15 +++++------- tui/partial_render_tui.py | 24 +++++++++++++++---- tui/styles.css | 2 +- 8 files changed, 51 insertions(+), 36 deletions(-) diff --git a/git_utils.py b/git_utils.py index 05c0298..dd10cae 100644 --- a/git_utils.py +++ b/git_utils.py @@ -64,7 +64,7 @@ def init_git_repo( path_to_repo: Union[str, os.PathLike], module_name: Optional[str] = None, render_id: Optional[str] = None, - additional_files: Optional[dict[str, str]] = None, + initial_files: Optional[dict[str, str]] = None, ) -> Repo: """ Initializes a new git repository in the given path. @@ -79,8 +79,8 @@ def init_git_repo( repo = Repo.init(path_to_repo) _ensure_git_config(repo) - if additional_files: - file_utils.store_response_files(path_to_repo, additional_files, []) + if initial_files: + file_utils.store_response_files(path_to_repo, initial_files, []) repo.git.add(".") repo.git.commit( @@ -95,12 +95,12 @@ def clone_repo( new_repo_path: str, module_name: Optional[str] = None, render_id: Optional[str] = None, - additional_files: Optional[dict[str, str]] = None, + initial_files: Optional[dict[str, str]] = None, ) -> Repo: repo = Repo.clone_from(source_repo_path, new_repo_path) - if additional_files: - file_utils.store_response_files(new_repo_path, additional_files, []) + if initial_files: + file_utils.store_response_files(new_repo_path, initial_files, []) repo.git.add(".") repo.git.commit( diff --git a/partial_rendering.py b/partial_rendering.py index ac4efb7..ef2fb70 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -80,7 +80,7 @@ def module_comes_before( if module.module_name == module2.module_name: return False - raise Exception(f"Module {module1.module_name} and {module2.module_name} not found in {all_required_modules}") + raise ValueError(f"Module {module1.module_name} and {module2.module_name} not found in {all_required_modules}") def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: diff --git a/plain_modules.py b/plain_modules.py index a423b18..3ec0130 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -2,6 +2,7 @@ import json import os +from functools import cached_property from git.exc import NoSuchPathError @@ -45,7 +46,7 @@ def __init__(self, filename: str, build_folder: str, conformance_tests_folder: s for module_name in required_modules_names ] - @property + @cached_property def all_required_modules(self) -> list[PlainModule]: all_required_modules = [] for required_module in self.required_modules: @@ -67,7 +68,7 @@ def module_build_folder(self): def get_codeplain_folder(self): return os.path.join(self.module_build_folder, CODEPLAIN_METADATA_FOLDER) - def get_last_rendered_frid(self) -> tuple[str, str | None]: + def get_last_rendered_frid(self) -> tuple[str | None, str | None]: if len(self.required_modules) == 0: return git_utils.get_last_finished_frid(self.module_build_folder) @@ -174,7 +175,7 @@ def save_module_metadata(self, only_hashes: bool = False): json.dump(module_metadata, f, indent=4) return - module_metadata[MODULE_FUNCTIONALITIES] = (self._get_module_functional_requirements(),) + module_metadata[MODULE_FUNCTIONALITIES] = self._get_module_functional_requirements() required_modules_functionalities = {} for required_module in self.required_modules: @@ -186,7 +187,7 @@ def save_module_metadata(self, only_hashes: bool = False): with open(metadata_path, "w", encoding="utf-8") as f: json.dump(module_metadata, f, indent=4) - def _ensure_module_folders_exist(self, first_render_frid: str, render_conformance_tests: bool) -> tuple[str, str]: + def _ensure_module_folders_exist(self, first_render_frid: str, render_conformance_tests: bool): """ Ensure that build and conformance test folders exist for the module. @@ -286,12 +287,16 @@ def get_module_by_name(self, module_name: str) -> PlainModule: raise ModuleDoesNotExistError(f"Module {module_name} does not exist") - def get_next_module(self, module_name: str) -> PlainModule | None: - for idx, module in enumerate(self.all_required_modules): - if module.module_name == module_name and idx < len(self.all_required_modules) - 1: - return self.all_required_modules[idx + 1] + def get_next_module(self, module_name: str) -> PlainModule: + all_modules = self.all_required_modules + [self] + for idx, module in enumerate(all_modules): + if module.module_name == module_name and idx < len(all_modules) - 1: + return all_modules[idx + 1] + + if module_name == self.module_name: + return self - return self + raise ModuleDoesNotExistError(f"Module {module_name} does not exist") def get_next_frid(self, frid: str, module_name: str) -> tuple[str, PlainModule]: module = self.get_module_by_name(module_name) diff --git a/render_machine/actions/prepare_repositories.py b/render_machine/actions/prepare_repositories.py index 196f66b..7f60501 100644 --- a/render_machine/actions/prepare_repositories.py +++ b/render_machine/actions/prepare_repositories.py @@ -35,7 +35,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | else: module_hashes = render_context.plain_module.get_hashes() - additional_files = { + initial_files = { render_context.plain_module.module_metadata_path(for_git_repo=True): json.dumps(module_hashes) } @@ -50,7 +50,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.build_folder, render_context.module_name, render_context.run_state.render_id, - additional_files, + initial_files, ) else: if render_context.verbose: @@ -60,7 +60,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.build_folder, render_context.module_name, render_context.run_state.render_id, - additional_files, + initial_files, ) if render_context.base_folder: @@ -78,7 +78,7 @@ 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, - additional_files, + initial_files, ) return self.SUCCESSFUL_OUTCOME, None diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 5652038..b4a1824 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -128,7 +128,6 @@ def create_snapshot(self) -> RenderContextSnapshot: ) def get_required_modules_functionalities(self): - print(f"Getting required modules functionalities for {self.module_name}...") required_modules_functionalities = {} if self.required_modules is not None and len(self.required_modules) > 0: for required_module in self.required_modules: diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index fe8261e..b409afa 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -183,20 +183,17 @@ def test_get_next_module_returns_next_in_sequence(root_module): assert nxt.module_name == "pr_middle" -def test_get_next_module_returns_self_when_at_last(root_module): +def test_get_next_module_returns_self_when_at_last_required_module(root_module): """When the given module is the last required module, ``get_next_module`` - falls back to the top-level module itself — callers then progress to the - root's first FRID.""" + returns the top-level module — callers then progress to the root's first FRID.""" nxt = root_module.get_next_module("pr_middle") assert nxt is root_module -def test_get_next_module_returns_self_when_module_not_found(root_module): - """Unknown module names no longer raise — they return the top-level - module. This is a pre-existing behaviour that callers rely on when the - tree has been fully traversed.""" - nxt = root_module.get_next_module("unknown") - assert nxt is root_module +def test_get_next_module_raises_when_module_not_found(root_module): + """Unknown module names raise ``ModuleDoesNotExistError``.""" + with pytest.raises(ModuleDoesNotExistError, match="unknown"): + root_module.get_next_module("unknown") # -------------------------------------------------------------------------- diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index 8f5144e..9c9b3a9 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -13,7 +13,7 @@ class PartialRenderTUI(App): BINDINGS = [ Binding("ctrl+o", "toggle_expand", "Expand/Collapse", show=False), - Binding("ctrl+c", "quit", "Quit", show=False), + Binding("ctrl+c", "copy_selection", "Copy", show=False), Binding("ctrl+d", "quit", "Quit", show=False), ] @@ -61,9 +61,8 @@ def on_mount(self) -> None: info_box.mount(Label("Module fully rendered", classes="rendering-info-row")) else: frid = pr.last_render_frid - functionality = pr.last_render_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][int(frid) - 1][ - "markdown" - ] + specifications, _ = plain_spec.get_specifications_for_frid(pr.last_render_module.plain_source, frid) + functionality = specifications[plain_spec.FUNCTIONAL_REQUIREMENTS][-1] label = Label("", classes="rendering-info-row") info_box.mount(label) self._register_expandable(label, f"Functionality {frid}:", functionality) @@ -167,6 +166,21 @@ def action_toggle_expand(self) -> None: @on(ListView.Selected) def on_choice_selected(self, event: ListView.Selected) -> None: - key = event.item.id.split("-", 1)[1] + item_id = event.item.id + if not item_id or not item_id.startswith("choice-"): + raise ValueError(f"Invalid item ID: {item_id}") + key = item_id.split("-", 1)[1] self.selected_choice = self.choices[key] self.exit(self.selected_choice) + + async def action_copy_selection(self) -> None: + """Handle ctrl+c: copy selected text if any. + + - If text is selected -> copy it to clipboard + - If no text is selected -> do nothing + """ + selected_text = self.screen.get_selected_text() + if selected_text: + self.copy_to_clipboard(selected_text) + self.screen.clear_selection() + self.notify("Copied to clipboard", timeout=2) diff --git a/tui/styles.css b/tui/styles.css index 6aad491..4cac1c5 100644 --- a/tui/styles.css +++ b/tui/styles.css @@ -460,4 +460,4 @@ ListItem.-highlight { #choice-list { margin: 0 1; height: auto; -} \ No newline at end of file +} From a583d82ff1589b015b26ace8a35f0c6961ffa12c Mon Sep 17 00:00:00 2001 From: zanjonke Date: Thu, 23 Apr 2026 08:12:41 +0200 Subject: [PATCH 13/14] Refactoring partial_render_tui.py --- git_utils.py | 37 +++++++++- partial_rendering.py | 125 ++++++++++++++++++++++---------- plain2code.py | 36 +++++---- plain_modules.py | 42 ++++++++--- tests/test_git_utils.py | 19 +++-- tests/test_partial_rendering.py | 22 ++++-- tests/test_plain_modules.py | 18 ++--- tui/partial_render_tui.py | 96 +++++++++--------------- 8 files changed, 242 insertions(+), 153 deletions(-) diff --git a/git_utils.py b/git_utils.py index dd10cae..ae064d4 100644 --- a/git_utils.py +++ b/git_utils.py @@ -435,7 +435,7 @@ def get_repo_info(repo_path: Union[str, os.PathLike]) -> dict: return info -def get_last_finished_frid(repo_path: Union[str, os.PathLike]) -> tuple[Optional[str], Optional[str]]: +def get_last_rendered_functionality(repo_path: Union[str, os.PathLike]) -> tuple[Optional[str], Optional[str]]: if not os.path.exists(repo_path): return None, None @@ -443,14 +443,43 @@ def get_last_finished_frid(repo_path: Union[str, os.PathLike]) -> tuple[Optional grep_pattern = FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(".*") grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") + if not commit_sha: - return None, None + # Repo was interrupted during the first functionality, fallback to initial commit and provide only module name + grep_pattern = INITIAL_COMMIT_MESSAGE + grep_pattern = grep_pattern.replace("[", "\\[").replace("]", "\\]") + commit_sha = repo.git.rev_list(repo.active_branch.name, "--grep", grep_pattern, "-n", "1") + if not commit_sha: + raise InvalidGitRepositoryError("Git repository is in an invalid state. Initial commit could not be found.") + + commit_message = repo.commit(commit_sha).message + if isinstance(commit_message, bytes): + commit_message = commit_message.decode("utf-8") + + match = re.search(r"Module name:\s*(\S+)\n", commit_message) + if not match: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Could not find module name in initial commit." + ) + + module_name = match.group(1) + return module_name, None + commit_message = repo.commit(commit_sha).message if isinstance(commit_message, bytes): commit_message = commit_message.decode("utf-8") + match = re.search(r"FRID\):(\S+) fully implemented", commit_message) - frid = match.group(1) if match else None + if not match: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Could not find frid in finished commit." + ) + frid = match.group(1) match = re.search(r"Module name:\s*(\S+)\n", commit_message) - module_name = match.group(1) if match else None + if not match: + raise InvalidGitRepositoryError( + "Git repository is in an invalid state. Could not find module name in finished commit." + ) + module_name = match.group(1) return module_name, frid diff --git a/partial_rendering.py b/partial_rendering.py index ef2fb70..f0b1d3d 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Literal import plain_spec from plain2code_exceptions import ModuleDoesNotExistError @@ -10,7 +11,7 @@ class PartialRender: last_render_module: PlainModule last_render_frid: str | None change: PlainModule | None = None - change_type: str | None = None + change_type: Literal["spec_change", "code_change"] | None = None @dataclass @@ -21,43 +22,27 @@ class PartialRenderChoice: def spec_change(plain_module: PlainModule) -> PlainModule | None: - for required_module in plain_module.all_required_modules: - module_metadata = required_module.load_module_metadata() + all_modules = plain_module.all_required_modules + [plain_module] + for _module in all_modules: + module_metadata = _module.load_module_metadata() if ( module_metadata and "source_hash" in module_metadata - and module_metadata["source_hash"] != required_module.get_module_source_hash() + and module_metadata["source_hash"] != _module.get_module_source_hash() ): - return required_module - - module_metadata = plain_module.load_module_metadata() - if ( - module_metadata - and "source_hash" in module_metadata - and module_metadata["source_hash"] != plain_module.get_module_source_hash() - ): - return plain_module + return _module return None def code_change(plain_module: PlainModule) -> PlainModule | None: - for required_module in plain_module.all_required_modules: - if len(required_module.required_modules) == 0: + all_modules = plain_module.all_required_modules + [plain_module] + for _module in all_modules: + if len(_module.required_modules) == 0: continue - module_metadata = required_module.load_module_metadata() - previous_module = required_module.required_modules[-1] - if ( - module_metadata - and "required_modules_code_hash" in module_metadata - and module_metadata["required_modules_code_hash"] != previous_module.get_module_code_hash() - ): - return previous_module - - module_metadata = plain_module.load_module_metadata() - if len(plain_module.required_modules) > 0: - previous_module = plain_module.required_modules[-1] + module_metadata = _module.load_module_metadata() + previous_module = _module.required_modules[-1] if ( module_metadata and "required_modules_code_hash" in module_metadata @@ -68,7 +53,7 @@ def code_change(plain_module: PlainModule) -> PlainModule | None: return None -def module_comes_before( +def module_comes_before_or_equal( all_required_modules: list[PlainModule], module1: PlainModule, module2: PlainModule, @@ -87,16 +72,11 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: sc = spec_change(plain_module) cc = code_change(plain_module) all_required_modules = plain_module.all_required_modules - last_rendered_module, last_rendered_frid = plain_module.get_last_rendered_frid() - if last_rendered_module is None and last_rendered_frid is None: + last_rendered_module_name, last_rendered_frid = plain_module.get_module_last_rendered_functionality() + if last_rendered_module_name is None and last_rendered_frid is None: return None - module = None - for required_module in all_required_modules: - if required_module.module_name == last_rendered_module: - module = required_module - - if last_rendered_module == plain_module.module_name: + if last_rendered_module_name == plain_module.module_name: if ( last_rendered_frid is None or plain_spec.get_next_frid(plain_module.plain_source, last_rendered_frid) is not None @@ -105,9 +85,14 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: else: return None + module = None + for required_module in all_required_modules: + if required_module.module_name == last_rendered_module_name: + module = required_module + if module is None: raise ModuleDoesNotExistError( - f"Last rendered module {last_rendered_module} not found in {[rmodule.module_name for rmodule in all_required_modules]}" + f"Last rendered module {last_rendered_module_name} not found in {[rmodule.module_name for rmodule in all_required_modules]}" ) pr = PartialRender( @@ -125,9 +110,73 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: pr.change_type = "spec_change" if cc is not None and ( - pr.change is None or (pr.change is not None and module_comes_before(all_required_modules, cc, pr.change)) + pr.change is None + or (pr.change is not None and module_comes_before_or_equal(all_required_modules, cc, pr.change)) ): pr.change = cc pr.change_type = "code_change" return pr + + +def get_choices(plain_module: PlainModule, partial_render: PartialRender) -> dict[str, PartialRenderChoice]: + choices = dict[str, PartialRenderChoice]() + choice_idx = 1 + if not partial_render.last_render_module.is_module_fully_rendered(): + choices[str(choice_idx)] = PartialRenderChoice( + module=partial_render.last_render_module, + render_range=None, + msg=f"Start from module [#5593FF]{partial_render.last_render_module.module_name}[/]", + ) + choice_idx += 1 + if not partial_render.last_render_module.is_initial_module(): + if not partial_render.last_render_frid: + raise ValueError("Last render FRID is not set for a non-initial module") + + next_frid, next_module = plain_module.get_next_frid( + partial_render.last_render_frid, partial_render.last_render_module.module_name + ) + render_range = plain_spec.get_render_range_from(next_frid, next_module.plain_source) + msg = "Continue from" + if next_frid != plain_spec.get_first_frid(next_module.plain_source): + msg += f" functionality [#5593FF]{next_frid}[/]" + else: + msg += f" module [#5593FF]{next_module.module_name}[/]" + + choices[str(choice_idx)] = PartialRenderChoice( + module=next_module, + render_range=render_range, + msg=msg, + ) + choice_idx += 1 + + if partial_render.change: + all_affected_modules = list[str]() + affected_module = False + for module in plain_module.all_required_modules: + if module.module_name == partial_render.change.module_name: + affected_module = True + if affected_module: + all_affected_modules.append(module.module_name) + + choices[str(choice_idx)] = PartialRenderChoice( + module=partial_render.change, + render_range=None, + msg=f"Re-render all affected modules ([#5593FF]{', '.join(all_affected_modules)}[/])", + ) + choice_idx += 1 + + first_module = plain_module.all_required_modules[0] + if first_module.module_name != partial_render.last_render_module.module_name and ( + partial_render.change is not None and first_module.module_name != partial_render.change.module_name + ): + choices[str(choice_idx)] = PartialRenderChoice( + module=first_module, + render_range=None, + msg=f"Re-render from first module ([#5593FF]{first_module.module_name}[/])", + ) + choice_idx += 1 + + choices[str(choice_idx)] = PartialRenderChoice(module=None, render_range=None, msg="Quit") + + return choices diff --git a/plain2code.py b/plain2code.py index 5b0a3cd..0194a01 100644 --- a/plain2code.py +++ b/plain2code.py @@ -19,7 +19,7 @@ import plain_spec from event_bus import EventBus from module_renderer import ModuleRenderer -from partial_rendering import detect_partial_rendering +from partial_rendering import detect_partial_rendering, get_choices from plain2code_arguments import parse_arguments from plain2code_console import console from plain2code_events import RenderFailed @@ -181,20 +181,26 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 if render_range is None: partial_render = detect_partial_rendering(plain_module) if partial_render is not None: - app = PartialRenderTUI( - plain_module, - partial_render, - system_config.client_version, - run_state.render_id, - css_path="styles.css", - ) - partial_render_choice = app.run() - if partial_render_choice is None or ( - partial_render_choice.module is None - and partial_render_choice.render_range is None - and partial_render_choice.msg == "Quit" - ): - exit() + choices = get_choices(plain_module, partial_render) + if len(choices) > 2: + app = PartialRenderTUI( + plain_module, + partial_render, + choices, + system_config.client_version, + run_state.render_id, + css_path="styles.css", + ) + partial_render_choice = app.run() + if partial_render_choice is None or ( + partial_render_choice.module is None + and partial_render_choice.render_range is None + and partial_render_choice.msg == "Quit" + ): + sys.exit(0) + else: + # Last choice is Quit, first choice is the only other actionable choice + partial_render_choice = choices[list(choices.keys())[0]] # We can't have both partial render choice and render range assert not (partial_render_choice is not None and render_range is not None) diff --git a/plain_modules.py b/plain_modules.py index 3ec0130..d9e4d60 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -68,18 +68,18 @@ def module_build_folder(self): def get_codeplain_folder(self): return os.path.join(self.module_build_folder, CODEPLAIN_METADATA_FOLDER) - def get_last_rendered_frid(self) -> tuple[str | None, str | None]: + def get_module_last_rendered_functionality(self) -> tuple[str | None, str | None]: if len(self.required_modules) == 0: - return git_utils.get_last_finished_frid(self.module_build_folder) + return git_utils.get_last_rendered_functionality(self.module_build_folder) - module_name, frid = git_utils.get_last_finished_frid(self.module_build_folder) - if module_name is not None and frid is not None and module_name == self.module_name: + module_name, frid = git_utils.get_last_rendered_functionality(self.module_build_folder) + if module_name is not None and module_name == self.module_name: return module_name, frid for module in reversed(self.required_modules): - last_rendered_module, last_rendered_frid = module.get_last_rendered_frid() - if last_rendered_module is not None and last_rendered_frid is not None: - return last_rendered_module, last_rendered_frid + last_rendered_module_name, last_rendered_frid = module.get_module_last_rendered_functionality() + if last_rendered_module_name is not None: + return last_rendered_module_name, last_rendered_frid return None, None @@ -310,8 +310,28 @@ def get_next_frid(self, frid: str, module_name: str) -> tuple[str, PlainModule]: def is_module_fully_rendered(self) -> bool: frids = list(plain_spec.get_frids(self.plain_source)) - last_rendered_module, last_rendered_frid = git_utils.get_last_finished_frid(self.module_build_folder) - if last_rendered_module is None or last_rendered_frid is None: - return False + last_rendered_module_name, last_rendered_frid = git_utils.get_last_rendered_functionality( + self.module_build_folder + ) + if ( + last_rendered_module_name is not None + and last_rendered_module_name == self.module_name + and last_rendered_frid is not None + and last_rendered_frid == frids[-1] + ): + return True + + return False + + def is_initial_module(self) -> bool: + last_rendered_module_name, last_rendered_frid = git_utils.get_last_rendered_functionality( + self.module_build_folder + ) + if ( + last_rendered_module_name is not None + and last_rendered_module_name == self.module_name + and last_rendered_frid is None + ): + return True - return last_rendered_frid == frids[-1] + return False diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 90e08b7..dac3675 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -12,11 +12,12 @@ REFACTORED_CODE_COMMIT_MESSAGE, add_all_files_and_commit, diff, - get_last_finished_frid, + get_last_rendered_functionality, init_git_repo, revert_changes, revert_to_commit_with_frid, ) +from plain2code_exceptions import InvalidGitRepositoryError @pytest.fixture @@ -439,12 +440,13 @@ def test_revert_to_base_folder_no_commit(temp_repo): def test_get_last_finished_frid_non_existent_path(): """Return (None, None) when the repo path doesn't exist.""" - assert get_last_finished_frid("/path/that/does/not/exist") == (None, None) + assert get_last_rendered_functionality("/path/that/does/not/exist") == (None, None) def test_get_last_finished_frid_empty_repo(empty_repo): - """Return (None, None) when no FRID-finished commit exists.""" - assert get_last_finished_frid(empty_repo) == (None, None) + """Raise InvalidGitRepositoryError when no FRID-finished commit exists and the initial commit has no module name.""" + with pytest.raises(InvalidGitRepositoryError, match="Could not find module name in initial commit"): + get_last_rendered_functionality(empty_repo) def test_get_last_finished_frid_returns_latest(empty_repo): @@ -466,7 +468,7 @@ def test_get_last_finished_frid_returns_latest(empty_repo): frid="2", ) - assert get_last_finished_frid(empty_repo) == ("module_a", "2") + assert get_last_rendered_functionality(empty_repo) == ("module_a", "2") def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): @@ -490,11 +492,11 @@ def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): ) # The last *finished* FRID is still 1 - assert get_last_finished_frid(empty_repo) == ("module_a", "1") + assert get_last_rendered_functionality(empty_repo) == ("module_a", "1") def test_get_last_finished_frid_without_module_name(empty_repo): - """If the commit omits the module name line, return None for the module.""" + """Raise InvalidGitRepositoryError when the finished commit omits the module name line.""" file_path = Path(empty_repo) / "a.txt" file_path.write_text("v1") add_all_files_and_commit( @@ -504,4 +506,5 @@ def test_get_last_finished_frid_without_module_name(empty_repo): frid="7", ) - assert get_last_finished_frid(empty_repo) == (None, "7") + with pytest.raises(InvalidGitRepositoryError, match="Could not find module name in finished commit"): + get_last_rendered_functionality(empty_repo) diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index d7686be..a3d5a90 100644 --- a/tests/test_partial_rendering.py +++ b/tests/test_partial_rendering.py @@ -6,7 +6,7 @@ under test relies on: ``module_name``, ``required_modules``, ``all_required_modules``, ``load_module_metadata``, ``get_module_source_hash``, ``get_module_code_hash`` and (for ``detect_partial_rendering``) -``get_last_rendered_frid``. +``get_module_last_rendered_functionality``. """ import pytest @@ -16,7 +16,13 @@ # sidesteps a circular-import failure that occurs when tests import # ``partial_rendering`` (and therefore ``plain_modules``) first. import plain_file # noqa: F401 -from partial_rendering import PartialRender, code_change, detect_partial_rendering, module_comes_before, spec_change +from partial_rendering import ( + PartialRender, + code_change, + detect_partial_rendering, + module_comes_before_or_equal, + spec_change, +) from plain2code_exceptions import ModuleDoesNotExistError @@ -55,7 +61,7 @@ def get_module_source_hash(self): def get_module_code_hash(self): return self._code_hash - def get_last_rendered_frid(self): + def get_module_last_rendered_functionality(self): return self._last_rendered @@ -78,9 +84,9 @@ def test_module_comes_before_first_module_wins(): b = FakeModule("b") c = FakeModule("c") - assert module_comes_before([a, b, c], a, b) is True - assert module_comes_before([a, b, c], b, a) is False - assert module_comes_before([a, b, c], b, c) is True + assert module_comes_before_or_equal([a, b, c], a, b) is True + assert module_comes_before_or_equal([a, b, c], b, a) is False + assert module_comes_before_or_equal([a, b, c], b, c) is True def test_module_comes_before_raises_when_neither_found(): @@ -90,7 +96,7 @@ def test_module_comes_before_raises_when_neither_found(): missing2 = FakeModule("missing2") with pytest.raises(Exception, match="not found"): - module_comes_before([a, b], missing1, missing2) + module_comes_before_or_equal([a, b], missing1, missing2) # ------------------------- @@ -254,7 +260,7 @@ def test_detect_partial_rendering_raises_when_last_rendered_module_unknown(): def test_detect_partial_rendering_spec_change_on_earlier_module_wins(): """A spec change on an earlier (required) module is reported via ``change``/``change_type``; ``last_render_module`` and ``last_render_frid`` - remain the ones reported by ``get_last_rendered_frid``.""" + remain the ones reported by ``get_module_last_rendered_functionality``.""" root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) leaf._metadata = {"source_hash": "stale-leaf"} # leaf's spec changed diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index b409afa..2307593 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -223,24 +223,24 @@ def test_get_next_frid_from_last_required_module_progresses_to_root(root_module) # -------------------------------------------------------------------------- -# get_last_rendered_frid +# get_module_last_rendered_functionality # -------------------------------------------------------------------------- -def test_get_last_rendered_frid_no_rendering(root_module): - assert root_module.get_last_rendered_frid() == (None, None) +def test_get_module_last_rendered_functionality_no_rendering(root_module): + assert root_module.get_module_last_rendered_functionality() == (None, None) -def test_get_last_rendered_frid_returns_from_leaf_when_only_leaf_rendered(root_module): +def test_get_module_last_rendered_functionality_returns_from_leaf_when_only_leaf_rendered(root_module): leaf = root_module.get_module_by_name("pr_leaf") _init_build_repo_with_finished_frid(leaf, "1") - module_name, frid = root_module.get_last_rendered_frid() + module_name, frid = root_module.get_module_last_rendered_functionality() assert module_name == "pr_leaf" assert frid == "1" -def test_get_last_rendered_frid_prefers_most_progressed_module(root_module): +def test_get_module_last_rendered_functionality_prefers_most_progressed_module(root_module): """The scan walks required_modules in reverse order — the right-most rendered module wins.""" leaf = root_module.get_module_by_name("pr_leaf") @@ -248,19 +248,19 @@ def test_get_last_rendered_frid_prefers_most_progressed_module(root_module): _init_build_repo_with_finished_frid(leaf, "2") _init_build_repo_with_finished_frid(middle, "1") - module_name, frid = root_module.get_last_rendered_frid() + module_name, frid = root_module.get_module_last_rendered_functionality() assert module_name == "pr_middle" assert frid == "1" -def test_get_last_rendered_frid_returns_root_when_root_has_checkpoint(root_module): +def test_get_module_last_rendered_functionality_returns_root_when_root_has_checkpoint(root_module): """A checkpoint in the root's own build folder takes precedence over required-module checkpoints.""" leaf = root_module.get_module_by_name("pr_leaf") _init_build_repo_with_finished_frid(leaf, "1") _init_build_repo_with_finished_frid(root_module, "2") - module_name, frid = root_module.get_last_rendered_frid() + module_name, frid = root_module.get_module_last_rendered_functionality() assert module_name == "pr_root" assert frid == "2" diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index 9c9b3a9..57cc573 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -21,6 +21,7 @@ def __init__( self, plain_module: PlainModule, partial_render: PartialRender, + choices: dict[str, PartialRenderChoice], state_machine_version: str, render_id: str, **kwargs, @@ -28,10 +29,10 @@ def __init__( super().__init__(**kwargs) self.plain_module = plain_module self.partial_render = partial_render - self.choices = {} # populated in on_mount self.state_machine_version = state_machine_version self.render_id = render_id self._expandable_labels: list[dict] = [] + self.choices = choices def compose(self) -> ComposeResult: with ContentSwitcher(id="content-switcher", initial=TUIComponents.DASHBOARD_VIEW.value): @@ -48,7 +49,6 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: pr = self.partial_render - pm = self.plain_module info_panel = self.query_one("#info-panel", Vertical) info_panel.mount(Label("module status", classes="rendering-info-title")) @@ -57,9 +57,9 @@ def on_mount(self) -> None: info_panel.mount(info_box) info_box.mount(Label(f"Module: {pr.last_render_module.module_name}", classes="rendering-info-row")) - if pr.last_render_frid is None or pr.last_render_module.is_module_fully_rendered(): + if pr.last_render_module.is_module_fully_rendered(): info_box.mount(Label("Module fully rendered", classes="rendering-info-row")) - else: + elif pr.last_render_frid is not None: frid = pr.last_render_frid specifications, _ = plain_spec.get_specifications_for_frid(pr.last_render_module.plain_source, frid) functionality = specifications[plain_spec.FUNCTIONAL_REQUIREMENTS][-1] @@ -67,25 +67,9 @@ def on_mount(self) -> None: info_box.mount(label) self._register_expandable(label, f"Functionality {frid}:", functionality) - # Build choices (same logic as original) - choice_idx = 1 - if pr.last_render_frid is not None: - next_frid, next_module = pm.get_next_frid(pr.last_render_frid, pr.last_render_module.module_name) - - msg = "Continue from" - if next_frid != plain_spec.get_first_frid(next_module.plain_source): - msg += f" functionality [#5593FF]{next_frid}[/]" - else: - msg += f" module [#5593FF]{next_module.module_name}[/]" - self.choices[str(choice_idx)] = PartialRenderChoice( - module=next_module, - render_range=plain_spec.get_render_range_from(next_frid, next_module.plain_source), - msg=msg, - ) - choice_idx += 1 - change_box = Vertical(classes="change-box") info_panel.mount(change_box) + if pr.change: title_start = "Spec changes" if pr.change_type == "spec_change" else "Code changes" change_box.mount( @@ -100,56 +84,48 @@ def on_mount(self) -> None: ) ) - all_affected_modules = list[str]() - affected_module = False - for module in pm.all_required_modules: - if module.module_name == pr.change.module_name: - affected_module = True - if affected_module: - all_affected_modules.append(module.module_name) - - self.choices[str(choice_idx)] = PartialRenderChoice( - module=pr.change, - render_range=None, - msg=f"Re-render all affected modules ([#5593FF]{', '.join(all_affected_modules)}[/])", - ) - choice_idx += 1 - - elif pr.last_render_frid is None or pr.last_render_module.is_module_fully_rendered(): + elif pr.last_render_module.is_module_fully_rendered(): change_box.mount(Label("--- Rendering interrupted ---", classes="rendering-info-row")) change_box.mount(Label("The current module was fully rendered.", classes="rendering-info-title")) else: - change_box.mount( - Label( - f"--- Rendering interrupted during [#5593FF]functionality {pr.last_render_frid}[/] ---", - classes="rendering-info-row", - ) - ) - change_box.mount( - Label( - "Resume from the last successfully rendered functionality or start over.", - classes="rendering-info-title", + interrupted_frid = "1" + interrupted_module = pr.last_render_module + if pr.last_render_frid: + next_frid, next_module = pr.last_render_module.get_next_frid( + pr.last_render_frid, pr.last_render_module.module_name ) - ) + if next_frid is not None: + interrupted_frid = next_frid + interrupted_module = next_module - first_module = pm.all_required_modules[0] - if first_module.module_name != pr.last_render_module.module_name and ( - pr.change is not None and first_module.module_name != pr.change.module_name - ): - self.choices[str(choice_idx)] = PartialRenderChoice( - module=first_module, - render_range=None, - msg=f"Re-render from first module ([#5593FF]{first_module.module_name}[/])", - ) - choice_idx += 1 + msg = f"--- Rendering interrupted during [#5593FF]functionality {interrupted_frid}" + if interrupted_module != pr.last_render_module: + msg += f" of module {interrupted_module.module_name}[/] ---" + else: + msg += "[/] ---" - self.choices[str(choice_idx)] = PartialRenderChoice(module=None, render_range=None, msg="Quit") + change_box.mount(Label(msg, classes="rendering-info-row")) + + if pr.last_render_module.is_initial_module(): + change_box.mount( + Label( + "Resume from interrupted functionality.", + classes="rendering-info-title", + ) + ) + else: + change_box.mount( + Label( + "Resume from the last successfully rendered functionality or start over.", + classes="rendering-info-title", + ) + ) # Populate the ListView lv = self.query_one("#choice-list", ListView) self.mount(Label("How would you like to continue?", classes="partial-render-question"), before=lv) for key, choice in self.choices.items(): - lv.append(ListItem(Label(f"[bold]{key}.[/bold] {choice.msg}"), id=f"choice-{key}")) + lv.append(ListItem(Label(f"[bold]{key}.[/bold] {choice.msg}"), id=f"choice-{key}")) lv.focus() def _register_expandable(self, label: Label, prefix: str, full_text: str) -> None: From 9b096e628ca5ac532e3b3a4f1d9fd8f4dcacb575 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Thu, 23 Apr 2026 11:12:08 +0200 Subject: [PATCH 14/14] Bugfixing module_renderer.py --- module_renderer.py | 21 ++++++- partial_rendering.py | 107 ++++++++++++++++++++------------ plain2code.py | 6 +- plain_modules.py | 39 +++++++----- tests/test_partial_rendering.py | 11 +--- tests/test_plain_modules.py | 55 +++++++++------- tui/partial_render_tui.py | 21 +++---- 7 files changed, 155 insertions(+), 105 deletions(-) diff --git a/module_renderer.py b/module_renderer.py index 6cb0ad7..45e78f8 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -81,7 +81,10 @@ def _render_module( and self.partial_render_choice.module is not None and self.partial_render_choice.module.module_name == plain_module.module_name ) - render_range = self.partial_render_choice.render_range if is_partial_render_choice_module else render_range + + if is_partial_render_choice_module: + render_range = self.partial_render_choice.render_range + if render_range is not None: plain_module.ensure_previous_frid_commits_exist(render_range, self.args.render_conformance_tests) @@ -89,12 +92,12 @@ def _render_module( if not self.args.render_machine_graph and plain_module.required_modules and not is_partial_render_choice_module: console.debug(f"Analyzing required modules of module {plain_module.module_name}...") for required_module in plain_module.required_modules: - has_any_required_module_changed, rendering_failed = self._render_module( + has_module_changed, rendering_failed = self._render_module( required_module, None, self.args.force_render, ) - + has_any_required_module_changed |= has_module_changed if rendering_failed: return False, True @@ -145,6 +148,18 @@ def _render_module( return True, False def render_module(self) -> None: + if self.partial_render_choice is not None and self.partial_render_choice.wipe_later_modules: + later_module = False + all_modules = self.plain_module.all_required_modules + [self.plain_module] + for module in all_modules: + if module.module_name == self.partial_render_choice.module.module_name: + later_module = True + continue + + if later_module: + console.info(f"Wiping module {module.module_name}...") + module.wipe_module() + self.loaded_modules = list[PlainModule]() _, rendering_failed = self._render_module(self.plain_module, self.render_range, True) if not rendering_failed: diff --git a/partial_rendering.py b/partial_rendering.py index f0b1d3d..f3698b4 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -19,6 +19,7 @@ class PartialRenderChoice: module: PlainModule | None = None render_range: list[str] | None = None msg: str | None = None + wipe_later_modules: bool = False def spec_change(plain_module: PlainModule) -> PlainModule | None: @@ -72,7 +73,7 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: sc = spec_change(plain_module) cc = code_change(plain_module) all_required_modules = plain_module.all_required_modules - last_rendered_module_name, last_rendered_frid = plain_module.get_module_last_rendered_functionality() + last_rendered_module_name, last_rendered_frid = plain_module.get_module_render_status() if last_rendered_module_name is None and last_rendered_frid is None: return None @@ -84,16 +85,17 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: module = plain_module else: return None - - module = None - for required_module in all_required_modules: - if required_module.module_name == last_rendered_module_name: - module = required_module - - if module is None: - raise ModuleDoesNotExistError( - f"Last rendered module {last_rendered_module_name} not found in {[rmodule.module_name for rmodule in all_required_modules]}" - ) + else: + found_module: PlainModule | None = None + for required_module in all_required_modules: + if required_module.module_name == last_rendered_module_name: + found_module = required_module + + if found_module is None: + raise ModuleDoesNotExistError( + f"Last rendered module {last_rendered_module_name} not found in {[rmodule.module_name for rmodule in all_required_modules]}" + ) + module = found_module pr = PartialRender( last_render_module=module, @@ -119,36 +121,56 @@ def detect_partial_rendering(plain_module: PlainModule) -> PartialRender | None: return pr -def get_choices(plain_module: PlainModule, partial_render: PartialRender) -> dict[str, PartialRenderChoice]: +def get_choices( + plain_module: PlainModule, + partial_render: PartialRender, + force_render: bool = False, +) -> dict[str, PartialRenderChoice]: choices = dict[str, PartialRenderChoice]() choice_idx = 1 - if not partial_render.last_render_module.is_module_fully_rendered(): + + if partial_render.last_render_module.is_initial_module(): choices[str(choice_idx)] = PartialRenderChoice( module=partial_render.last_render_module, render_range=None, msg=f"Start from module [#5593FF]{partial_render.last_render_module.module_name}[/]", + wipe_later_modules=True, ) choice_idx += 1 - if not partial_render.last_render_module.is_initial_module(): - if not partial_render.last_render_frid: - raise ValueError("Last render FRID is not set for a non-initial module") - next_frid, next_module = plain_module.get_next_frid( - partial_render.last_render_frid, partial_render.last_render_module.module_name - ) - render_range = plain_spec.get_render_range_from(next_frid, next_module.plain_source) - msg = "Continue from" - if next_frid != plain_spec.get_first_frid(next_module.plain_source): - msg += f" functionality [#5593FF]{next_frid}[/]" - else: - msg += f" module [#5593FF]{next_module.module_name}[/]" + elif not partial_render.last_render_module.is_module_fully_rendered(): + if not partial_render.last_render_frid: + raise ValueError("Last render FRID is not set for a non-initial module") - choices[str(choice_idx)] = PartialRenderChoice( - module=next_module, - render_range=render_range, - msg=msg, - ) - choice_idx += 1 + next_frid, next_module = plain_module.get_next_frid( + partial_render.last_render_frid, partial_render.last_render_module.module_name + ) + render_range = plain_spec.get_render_range_from(next_frid, next_module.plain_source) + msg = "Continue from" + if next_frid != plain_spec.get_first_frid(next_module.plain_source): + msg += f" functionality [#5593FF]{next_frid}[/]" + else: + msg += f" module [#5593FF]{next_module.module_name}[/]" + + choices[str(choice_idx)] = PartialRenderChoice( + module=next_module, + render_range=render_range if not force_render else None, + wipe_later_modules=force_render, + msg=msg, + ) + choice_idx += 1 + + else: + next_module = plain_module.get_next_module(partial_render.last_render_module.module_name) + if next_module is None: + next_module = partial_render.last_render_module + + choices[str(choice_idx)] = PartialRenderChoice( + module=next_module, + render_range=None, + msg=f"Start from module [#5593FF]{next_module.module_name}[/]", + ) + choice_idx += 1 if partial_render.change: all_affected_modules = list[str]() @@ -163,19 +185,22 @@ def get_choices(plain_module: PlainModule, partial_render: PartialRender) -> dic module=partial_render.change, render_range=None, msg=f"Re-render all affected modules ([#5593FF]{', '.join(all_affected_modules)}[/])", + wipe_later_modules=True, ) choice_idx += 1 - first_module = plain_module.all_required_modules[0] - if first_module.module_name != partial_render.last_render_module.module_name and ( - partial_render.change is not None and first_module.module_name != partial_render.change.module_name - ): - choices[str(choice_idx)] = PartialRenderChoice( - module=first_module, - render_range=None, - msg=f"Re-render from first module ([#5593FF]{first_module.module_name}[/])", - ) - choice_idx += 1 + if len(plain_module.all_required_modules) > 0: + first_module = plain_module.all_required_modules[0] + if first_module.module_name != partial_render.last_render_module.module_name and ( + partial_render.change is not None and first_module.module_name != partial_render.change.module_name + ): + choices[str(choice_idx)] = PartialRenderChoice( + module=first_module, + render_range=None, + msg=f"Re-render from first module ([#5593FF]{first_module.module_name}[/])", + wipe_later_modules=True, + ) + choice_idx += 1 choices[str(choice_idx)] = PartialRenderChoice(module=None, render_range=None, msg="Quit") diff --git a/plain2code.py b/plain2code.py index 0194a01..4bd7eda 100644 --- a/plain2code.py +++ b/plain2code.py @@ -181,7 +181,7 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 if render_range is None: partial_render = detect_partial_rendering(plain_module) if partial_render is not None: - choices = get_choices(plain_module, partial_render) + choices = get_choices(plain_module, partial_render, args.force_render) if len(choices) > 2: app = PartialRenderTUI( plain_module, @@ -202,8 +202,8 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 # Last choice is Quit, first choice is the only other actionable choice partial_render_choice = choices[list(choices.keys())[0]] - # We can't have both partial render choice and render range - assert not (partial_render_choice is not None and render_range is not None) + if partial_render_choice is not None and render_range is not None: + raise Exception("Partial rendering and render range cannot be used together") module_renderer = ModuleRenderer( codeplainAPI, diff --git a/plain_modules.py b/plain_modules.py index d9e4d60..aa702ed 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -2,6 +2,7 @@ import json import os +import shutil from functools import cached_property from git.exc import NoSuchPathError @@ -9,6 +10,7 @@ import git_utils import plain_file import plain_spec +from plain2code_console import console from plain2code_exceptions import MissingPreviousFunctionalitiesError, ModuleDoesNotExistError from render_machine.implementation_code_helpers import ImplementationCodeHelpers @@ -68,7 +70,7 @@ def module_build_folder(self): def get_codeplain_folder(self): return os.path.join(self.module_build_folder, CODEPLAIN_METADATA_FOLDER) - def get_module_last_rendered_functionality(self) -> tuple[str | None, str | None]: + def get_module_render_status(self) -> tuple[str | None, str | None]: if len(self.required_modules) == 0: return git_utils.get_last_rendered_functionality(self.module_build_folder) @@ -77,7 +79,7 @@ def get_module_last_rendered_functionality(self) -> tuple[str | None, str | None return module_name, frid for module in reversed(self.required_modules): - last_rendered_module_name, last_rendered_frid = module.get_module_last_rendered_functionality() + last_rendered_module_name, last_rendered_frid = module.get_module_render_status() if last_rendered_module_name is not None: return last_rendered_module_name, last_rendered_frid @@ -162,19 +164,12 @@ 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, only_hashes: bool = False): + def save_module_metadata(self): codeplain_folder = self.get_codeplain_folder() os.makedirs(codeplain_folder, exist_ok=True) module_metadata = self.get_hashes() - metadata_path = self.module_metadata_path() - - if only_hashes: - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(module_metadata, f, indent=4) - return - module_metadata[MODULE_FUNCTIONALITIES] = self._get_module_functional_requirements() required_modules_functionalities = {} @@ -277,10 +272,7 @@ def ensure_previous_frid_commits_exist(self, render_range: list[str], render_con for prev_frid in previous_frids: self._ensure_frid_commit_exists(prev_frid, first_render_frid, render_conformance_tests) - def get_module_by_name(self, module_name: str) -> PlainModule: - if self.module_name == module_name: - return self - + def get_required_module_by_name(self, module_name: str) -> PlainModule: for module in self.all_required_modules: if module.module_name == module_name: return module @@ -294,16 +286,22 @@ def get_next_module(self, module_name: str) -> PlainModule: return all_modules[idx + 1] if module_name == self.module_name: - return self + return None raise ModuleDoesNotExistError(f"Module {module_name} does not exist") def get_next_frid(self, frid: str, module_name: str) -> tuple[str, PlainModule]: - module = self.get_module_by_name(module_name) + if module_name != self.module_name: + module = self.get_required_module_by_name(module_name) + else: + module = self + next_frid = plain_spec.get_next_frid(module.plain_source, frid) if next_frid is None: next_module = self.get_next_module(module_name) + if next_module is None: + next_module = self return plain_spec.get_first_frid(next_module.plain_source), next_module return next_frid, module @@ -335,3 +333,12 @@ def is_initial_module(self) -> bool: return True 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) diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index a3d5a90..b757f5c 100644 --- a/tests/test_partial_rendering.py +++ b/tests/test_partial_rendering.py @@ -6,16 +6,11 @@ under test relies on: ``module_name``, ``required_modules``, ``all_required_modules``, ``load_module_metadata``, ``get_module_source_hash``, ``get_module_code_hash`` and (for ``detect_partial_rendering``) -``get_module_last_rendered_functionality``. +``get_module_render_status``. """ import pytest -# Import ``plain_file`` first — it pulls in ``file_utils`` which transitively -# imports ``plain_modules``; starting the import graph at ``plain_file`` -# sidesteps a circular-import failure that occurs when tests import -# ``partial_rendering`` (and therefore ``plain_modules``) first. -import plain_file # noqa: F401 from partial_rendering import ( PartialRender, code_change, @@ -61,7 +56,7 @@ def get_module_source_hash(self): def get_module_code_hash(self): return self._code_hash - def get_module_last_rendered_functionality(self): + def get_module_render_status(self): return self._last_rendered @@ -260,7 +255,7 @@ def test_detect_partial_rendering_raises_when_last_rendered_module_unknown(): def test_detect_partial_rendering_spec_change_on_earlier_module_wins(): """A spec change on an earlier (required) module is reported via ``change``/``change_type``; ``last_render_module`` and ``last_render_frid`` - remain the ones reported by ``get_module_last_rendered_functionality``.""" + remain the ones reported by ``get_module_render_status``.""" root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) leaf._metadata = {"source_hash": "stale-leaf"} # leaf's spec changed diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index 2307593..ff43657 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -12,10 +12,6 @@ import pytest -# Import ``plain_file`` first to avoid a circular-import failure if this -# file is the entry point into the module graph. (See the note in -# test_partial_rendering.py.) -import plain_file # noqa: F401 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 @@ -158,18 +154,25 @@ def test_has_required_modules_code_changed_true_when_hash_differs(root_module): # -------------------------------------------------------------------------- -# get_module_by_name +# get_required_module_by_name # -------------------------------------------------------------------------- -def test_get_module_by_name_finds_required_module(root_module): - leaf = root_module.get_module_by_name("pr_leaf") +def test_get_required_module_by_name_finds_required_module(root_module): + leaf = root_module.get_required_module_by_name("pr_leaf") assert leaf.module_name == "pr_leaf" -def test_get_module_by_name_raises_for_unknown(root_module): +def test_get_required_module_by_name_raises_for_unknown(root_module): with pytest.raises(ModuleDoesNotExistError, match="phantom"): - root_module.get_module_by_name("phantom") + root_module.get_required_module_by_name("phantom") + + +def test_get_required_module_by_name_raises_for_self(root_module): + """Unlike the previous ``get_module_by_name``, the required-only lookup + does not match the top module itself.""" + with pytest.raises(ModuleDoesNotExistError, match="pr_root"): + root_module.get_required_module_by_name("pr_root") # -------------------------------------------------------------------------- @@ -183,13 +186,19 @@ def test_get_next_module_returns_next_in_sequence(root_module): assert nxt.module_name == "pr_middle" -def test_get_next_module_returns_self_when_at_last_required_module(root_module): +def test_get_next_module_returns_top_module_when_at_last_required_module(root_module): """When the given module is the last required module, ``get_next_module`` returns the top-level module — callers then progress to the root's first FRID.""" nxt = root_module.get_next_module("pr_middle") assert nxt is root_module +def test_get_next_module_returns_none_when_asked_for_next_after_top_module(root_module): + """There is no module after the top-level module — ``get_next_module`` + signals that by returning ``None`` (callers fall back explicitly).""" + assert root_module.get_next_module("pr_root") is None + + def test_get_next_module_raises_when_module_not_found(root_module): """Unknown module names raise ``ModuleDoesNotExistError``.""" with pytest.raises(ModuleDoesNotExistError, match="unknown"): @@ -223,44 +232,44 @@ def test_get_next_frid_from_last_required_module_progresses_to_root(root_module) # -------------------------------------------------------------------------- -# get_module_last_rendered_functionality +# get_module_render_status # -------------------------------------------------------------------------- -def test_get_module_last_rendered_functionality_no_rendering(root_module): - assert root_module.get_module_last_rendered_functionality() == (None, None) +def test_get_module_render_status_no_rendering(root_module): + assert root_module.get_module_render_status() == (None, None) -def test_get_module_last_rendered_functionality_returns_from_leaf_when_only_leaf_rendered(root_module): - leaf = root_module.get_module_by_name("pr_leaf") +def test_get_module_render_status_returns_from_leaf_when_only_leaf_rendered(root_module): + leaf = root_module.get_required_module_by_name("pr_leaf") _init_build_repo_with_finished_frid(leaf, "1") - module_name, frid = root_module.get_module_last_rendered_functionality() + module_name, frid = root_module.get_module_render_status() assert module_name == "pr_leaf" assert frid == "1" -def test_get_module_last_rendered_functionality_prefers_most_progressed_module(root_module): +def test_get_module_render_status_prefers_most_progressed_module(root_module): """The scan walks required_modules in reverse order — the right-most rendered module wins.""" - leaf = root_module.get_module_by_name("pr_leaf") - middle = root_module.get_module_by_name("pr_middle") + leaf = root_module.get_required_module_by_name("pr_leaf") + middle = root_module.get_required_module_by_name("pr_middle") _init_build_repo_with_finished_frid(leaf, "2") _init_build_repo_with_finished_frid(middle, "1") - module_name, frid = root_module.get_module_last_rendered_functionality() + module_name, frid = root_module.get_module_render_status() assert module_name == "pr_middle" assert frid == "1" -def test_get_module_last_rendered_functionality_returns_root_when_root_has_checkpoint(root_module): +def test_get_module_render_status_returns_root_when_root_has_checkpoint(root_module): """A checkpoint in the root's own build folder takes precedence over required-module checkpoints.""" - leaf = root_module.get_module_by_name("pr_leaf") + leaf = root_module.get_required_module_by_name("pr_leaf") _init_build_repo_with_finished_frid(leaf, "1") _init_build_repo_with_finished_frid(root_module, "2") - module_name, frid = root_module.get_module_last_rendered_functionality() + module_name, frid = root_module.get_module_render_status() assert module_name == "pr_root" assert frid == "2" diff --git a/tui/partial_render_tui.py b/tui/partial_render_tui.py index 57cc573..3b0d580 100644 --- a/tui/partial_render_tui.py +++ b/tui/partial_render_tui.py @@ -2,7 +2,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Vertical, VerticalScroll -from textual.widgets import ContentSwitcher, Label, ListItem, ListView, Static +from textual.widgets import Label, ListItem, ListView, Static import plain_spec from partial_rendering import PartialRender, PartialRenderChoice @@ -35,16 +35,15 @@ def __init__( self.choices = choices def compose(self) -> ComposeResult: - with ContentSwitcher(id="content-switcher", initial=TUIComponents.DASHBOARD_VIEW.value): - with Vertical(id=TUIComponents.DASHBOARD_VIEW.value): - with VerticalScroll(): - yield Static( - f"[#FFFFFF]*codeplain[/#FFFFFF] [#888888](v{self.state_machine_version})[/#888888]", - id="codeplain-header", - classes="codeplain-header", - ) - yield Vertical(id="info-panel") - yield ListView(id="choice-list") + with Vertical(id=TUIComponents.DASHBOARD_VIEW.value): + with VerticalScroll(): + yield Static( + f"[#FFFFFF]*codeplain[/#FFFFFF] [#888888](v{self.state_machine_version})[/#888888]", + id="codeplain-header", + classes="codeplain-header", + ) + yield Vertical(id="info-panel") + yield ListView(id="choice-list") yield CustomFooter(render_id=self.render_id, use_logs_shortcut=False, use_pause_shortcut=False) def on_mount(self) -> None: