diff --git a/git_utils.py b/git_utils.py index 1140cca..ae064d4 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 @@ -60,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, + initial_files: Optional[dict[str, str]] = None, ) -> Repo: """ Initializes a new git repository in the given path. @@ -74,6 +78,11 @@ def init_git_repo( repo = Repo.init(path_to_repo) _ensure_git_config(repo) + + if initial_files: + file_utils.store_response_files(path_to_repo, initial_files, []) + repo.git.add(".") + repo.git.commit( "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) ) @@ -82,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, + initial_files: Optional[dict[str, str]] = None, ) -> Repo: repo = Repo.clone_from(source_repo_path, new_repo_path) + + if initial_files: + file_utils.store_response_files(new_repo_path, initial_files, []) + repo.git.add(".") + repo.git.commit( "--allow-empty", "-m", _get_full_commit_message(INITIAL_COMMIT_MESSAGE, module_name, None, render_id) ) @@ -415,3 +433,53 @@ def get_repo_info(repo_path: Union[str, os.PathLike]) -> dict: info["remotes"] = remotes return info + + +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 + + repo = Repo(repo_path) + 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: + # 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) + 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) + 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/module_renderer.py b/module_renderer.py index 15c1e36..45e78f8 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -2,15 +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 @@ -23,9 +19,9 @@ class ModuleRenderer: def __init__( self, codeplainAPI, - filename: str, + plain_module: PlainModule, + partial_render_choice: PartialRenderChoice | None, render_range: list[str] | None, - template_dirs: list[str], args: argparse.Namespace, run_state: RunState, event_bus: EventBus, @@ -33,140 +29,26 @@ def __init__( enter_pause_event: threading.Event | None = None, ): self.codeplainAPI = codeplainAPI - self.filename = filename + self.plain_module = plain_module + self.partial_render_choice = partial_render_choice self.render_range = render_range - self.template_dirs = template_dirs self.args = args self.run_state = run_state self.event_bus = event_bus 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, + 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, + 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, @@ -187,67 +69,68 @@ def _build_render_context_for_module( ) def _render_module( - self, filename: str, render_range: list[str] | None, force_render: bool - ) -> tuple[bool, list[PlainModule], bool]: + self, plain_module: PlainModule, render_range: list[str] | None, force_render: bool + ) -> tuple[bool, 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) + 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 + ) - resources_list = [] - plain_spec.collect_linked_resources(plain_source, resources_list, None, True) + if is_partial_render_choice_module: + render_range = self.partial_render_choice.render_range - # 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, self.args.render_conformance_tests) - 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 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_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, 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)) + return False, True - 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)) + ( + (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(plain_source, resources_list) - and not plain_module.has_required_modules_code_changed(required_modules) 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, 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, + memory_manager, + 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,24 +139,35 @@ 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: + 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.filename, 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..f3698b4 --- /dev/null +++ b/partial_rendering.py @@ -0,0 +1,207 @@ +from dataclasses import dataclass +from typing import Literal + +import plain_spec +from plain2code_exceptions import ModuleDoesNotExistError +from plain_modules import PlainModule + + +@dataclass +class PartialRender: + last_render_module: PlainModule + last_render_frid: str | None + change: PlainModule | None = None + change_type: Literal["spec_change", "code_change"] | None = None + + +@dataclass +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: + 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"] != _module.get_module_source_hash() + ): + return _module + + return None + + +def code_change(plain_module: PlainModule) -> PlainModule | None: + all_modules = plain_module.all_required_modules + [plain_module] + for _module in all_modules: + if len(_module.required_modules) == 0: + continue + + module_metadata = _module.load_module_metadata() + previous_module = _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 + + return None + + +def module_comes_before_or_equal( + 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 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: + 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_render_status() + if last_rendered_module_name is None and last_rendered_frid is None: + return None + + 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 + ): + module = plain_module + else: + return None + 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, + 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: + 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_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, + force_render: bool = False, +) -> dict[str, PartialRenderChoice]: + choices = dict[str, PartialRenderChoice]() + choice_idx = 1 + + 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 + + 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") + + 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]() + 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)}[/])", + wipe_later_modules=True, + ) + 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") + + return choices diff --git a/plain2code.py b/plain2code.py index a1f0e56..4bd7eda 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 detect_partial_rendering, get_choices from plain2code_arguments import parse_arguments from plain2code_console import console from plain2code_events import RenderFailed @@ -49,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") @@ -70,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, @@ -206,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 @@ -219,11 +170,46 @@ 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_choice = None + 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, args.force_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]] + + 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, - args.filename, + plain_module, + partial_render_choice, render_range, - template_dirs, args, run_state, event_bus, @@ -295,7 +281,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_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..aa702ed 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -2,12 +2,16 @@ import json import os +import shutil +from functools import cached_property from git.exc import NoSuchPathError import git_utils +import plain_file import plain_spec -from plain2code_exceptions import ModuleDoesNotExistError +from plain2code_console import console +from plain2code_exceptions import MissingPreviousFunctionalitiesError, ModuleDoesNotExistError from render_machine.implementation_code_helpers import ImplementationCodeHelpers CODEPLAIN_MEMORY_SUBFOLDER = ".memory" @@ -18,19 +22,72 @@ 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 + ] + + @cached_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_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) + + 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_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 + + return None, None 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 +105,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,67 +122,223 @@ 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: return True 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 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.") - - 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.name] = module_metadata[MODULE_FUNCTIONALITIES] + functionalities[self.module_name] = self._get_module_functional_requirements() return functionalities - def save_module_metadata( - self, - plain_source: dict, - resources_list: list[dict], - required_modules: list[PlainModule] | None = None, - ): + 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): 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), - } - - if required_modules is not None and len(required_modules) > 0: - previous_module = required_modules[-1] - module_metadata["required_modules_code_hash"] = previous_module.get_module_code_hash() + module_metadata = self.get_hashes() + metadata_path = self.module_metadata_path() + module_metadata[MODULE_FUNCTIONALITIES] = self._get_module_functional_requirements() 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: 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) + + 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. + + 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, render_conformance_tests) + + # 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_required_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: + 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 None + + raise ModuleDoesNotExistError(f"Module {module_name} does not exist") + + def get_next_frid(self, frid: str, module_name: str) -> tuple[str, PlainModule]: + 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 + + def is_module_fully_rendered(self) -> bool: + frids = list(plain_spec.get_frids(self.plain_source)) + 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 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/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/render_machine/actions/prepare_repositories.py b/render_machine/actions/prepare_repositories.py index 7663954..7f60501 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,24 +34,33 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | ) else: + module_hashes = render_context.plain_module.get_hashes() + initial_files = { + render_context.plain_module.module_metadata_path(for_git_repo=True): json.dumps(module_hashes) + } + 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, + initial_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, + initial_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, + initial_files, ) return self.SUCCESSFUL_OUTCOME, None 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..b4a1824 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 @@ -218,7 +216,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 +261,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 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 226a442..dac3675 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -12,10 +12,12 @@ REFACTORED_CODE_COMMIT_MESSAGE, add_all_files_and_commit, diff, + get_last_rendered_functionality, init_git_repo, revert_changes, revert_to_commit_with_frid, ) +from plain2code_exceptions import InvalidGitRepositoryError @pytest.fixture @@ -434,3 +436,75 @@ 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_rendered_functionality("/path/that/does/not/exist") == (None, None) + + +def test_get_last_finished_frid_empty_repo(empty_repo): + """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): + """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_rendered_functionality(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_rendered_functionality(empty_repo) == ("module_a", "1") + + +def test_get_last_finished_frid_without_module_name(empty_repo): + """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( + empty_repo, + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("7"), + module_name=None, + frid="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 new file mode 100644 index 0000000..b757f5c --- /dev/null +++ b/tests/test_partial_rendering.py @@ -0,0 +1,316 @@ +"""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_module_render_status``. +""" + +import pytest + +from partial_rendering import ( + PartialRender, + code_change, + detect_partial_rendering, + module_comes_before_or_equal, + 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_module_render_status(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_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(): + a = FakeModule("a") + b = FakeModule("b") + missing1 = FakeModule("missing1") + missing2 = FakeModule("missing2") + + with pytest.raises(Exception, match="not found"): + module_comes_before_or_equal([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 leaf + + +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 middle + + +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 ``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 leaf + + +# ------------------------- +# 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.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(): + 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(): + """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_render_status``.""" + 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.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_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.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(): + 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.last_render_module 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 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 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", + } + + pr = detect_partial_rendering(root) + assert pr.last_render_module is middle + assert pr.change is leaf + assert pr.change_type == "code_change" + assert pr.last_render_frid == "1" diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py new file mode 100644 index 0000000..ff43657 --- /dev/null +++ b/tests/test_plain_modules.py @@ -0,0 +1,294 @@ +"""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 + +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_required_module_by_name +# -------------------------------------------------------------------------- + + +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_required_module_by_name_raises_for_unknown(root_module): + with pytest.raises(ModuleDoesNotExistError, match="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") + + +# -------------------------------------------------------------------------- +# 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_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"): + root_module.get_next_module("unknown") + + +# -------------------------------------------------------------------------- +# 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_module_render_status +# -------------------------------------------------------------------------- + + +def test_get_module_render_status_no_rendering(root_module): + assert root_module.get_module_render_status() == (None, None) + + +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_render_status() + assert module_name == "pr_leaf" + assert frid == "1" + + +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_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_render_status() + assert module_name == "pr_middle" + assert frid == "1" + + +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_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_render_status() + 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 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 new file mode 100644 index 0000000..3b0d580 --- /dev/null +++ b/tui/partial_render_tui.py @@ -0,0 +1,161 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.widgets import 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): + BINDINGS = [ + Binding("ctrl+o", "toggle_expand", "Expand/Collapse", show=False), + Binding("ctrl+c", "copy_selection", "Copy", show=False), + Binding("ctrl+d", "quit", "Quit", show=False), + ] + + def __init__( + self, + plain_module: PlainModule, + partial_render: PartialRender, + choices: dict[str, PartialRenderChoice], + state_machine_version: str, + render_id: str, + **kwargs, + ): + super().__init__(**kwargs) + self.plain_module = plain_module + self.partial_render = partial_render + 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 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: + pr = self.partial_render + + info_panel = self.query_one("#info-panel", Vertical) + info_panel.mount(Label("module status", classes="rendering-info-title")) + + 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_module.is_module_fully_rendered(): + info_box.mount(Label("Module fully rendered", classes="rendering-info-row")) + 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] + label = Label("", classes="rendering-info-row") + info_box.mount(label) + self._register_expandable(label, f"Functionality {frid}:", functionality) + + 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( + 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-title" + ) + ) + + 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: + 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 + + 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 += "[/] ---" + + 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.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: + 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 c395d5d..4cac1c5 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; } @@ -423,3 +422,42 @@ CustomFooter.footer-state-paused { text-align: right; } +/* Partial Render TUI Styles */ +#info-panel { + margin-top: 1; + height: auto; +} + +ListView { + height: auto; + background: transparent; +} +ListView:focus { + background: transparent; +} +ListItem { + background: transparent; + color: #fff; +} +ListItem:hover { + background: transparent; +} +ListItem.-highlight { + background: #333; + color: #fff; +} + +.partial-render-question { + margin: 0 1; + height: auto; +} + +.change-box { + margin: 1 1; + height: auto; +} + +#choice-list { + margin: 0 1; + height: auto; +}