From a6fab99419439d7cb64102d9755c714e27df2bda Mon Sep 17 00:00:00 2001 From: Corey Oordt Date: Mon, 18 Nov 2024 07:33:12 -0600 Subject: [PATCH] Added initial CLI interface. --- project_forge/__main__.py | 5 + project_forge/cli.py | 81 ++++++++++++ project_forge/commands/__init__.py | 1 + project_forge/commands/build.py | 32 +++++ project_forge/context_builder/context.py | 8 +- project_forge/context_builder/data_merge.py | 2 +- tests/test_context_builder/test_context.py | 139 ++++++++++++-------- 7 files changed, 211 insertions(+), 57 deletions(-) create mode 100644 project_forge/__main__.py create mode 100644 project_forge/cli.py create mode 100644 project_forge/commands/__init__.py create mode 100644 project_forge/commands/build.py diff --git a/project_forge/__main__.py b/project_forge/__main__.py new file mode 100644 index 0000000..74ea8b3 --- /dev/null +++ b/project_forge/__main__.py @@ -0,0 +1,5 @@ +"""Top-level interface for project forge.""" + +from project_forge.cli import cli + +cli() diff --git a/project_forge/cli.py b/project_forge/cli.py new file mode 100644 index 0000000..fb45938 --- /dev/null +++ b/project_forge/cli.py @@ -0,0 +1,81 @@ +"""The command-line interface.""" + +from pathlib import Path +from typing import Any, Optional + +import rich_click as click +from click.core import Context + +from project_forge import __version__ +from project_forge.core.io import parse_file + + +@click.group( + context_settings={ + "help_option_names": ["-h", "--help"], + }, + add_help_option=True, +) +@click.version_option(version=__version__) +@click.pass_context +def cli(ctx: Context) -> None: + """Version bump your Python project.""" + pass + + +@cli.command() +@click.argument( + "composition", + type=click.Path(exists=True, dir_okay=False, file_okay=True, resolve_path=True, path_type=Path), +) +@click.option( + "--use-defaults", + is_flag=True, + help="Do not prompt for input and use the defaults specified in the composition.", +) +@click.option( + "--output-dir", + "-o", + required=False, + default=lambda: Path.cwd(), # NOQA: PLW0108 + type=click.Path(exists=True, dir_okay=True, file_okay=False, resolve_path=True, path_type=Path), + help="The directory to render the composition to. Defaults to the current working directory.", +) +@click.option( + "--data-file", + "-f", + required=False, + type=click.Path(exists=True, dir_okay=False, file_okay=True, resolve_path=True, path_type=Path), + help=( + "The path to a JSON, YAML, or TOML file whose contents are added to the initial context. " + "Great for answering some or all the answers for a composition." + ), +) +@click.option( + "--data", + "-d", + nargs=2, + type=str, + metavar="KEY VALUE", + required=False, + multiple=True, + help="The key-value pairs added to the initial context. Great for providing answers to composition questions.", +) +def build( + composition: Path, + use_defaults: bool, + output_dir: Path, + data_file: Optional[Path] = None, + data: Optional[tuple[tuple[str, str], ...]] = None, +): + """Build a project from a composition and render it to a directory.""" + from project_forge.commands.build import build_project + + initial_context: dict[str, Any] = {} + if data_file: + initial_context |= parse_file(data_file) + + if data: + initial_context |= dict(data) + print(type(output_dir)) + build_project(composition, output_dir=output_dir, use_defaults=use_defaults, initial_context=initial_context) diff --git a/project_forge/commands/__init__.py b/project_forge/commands/__init__.py new file mode 100644 index 0000000..63f210e --- /dev/null +++ b/project_forge/commands/__init__.py @@ -0,0 +1 @@ +"""Command implementation.""" diff --git a/project_forge/commands/build.py b/project_forge/commands/build.py new file mode 100644 index 0000000..c6b5e79 --- /dev/null +++ b/project_forge/commands/build.py @@ -0,0 +1,32 @@ +"""Starting point to render a project.""" + +import logging +from pathlib import Path +from typing import Optional + +from project_forge.configurations.composition import read_composition_file +from project_forge.context_builder.context import build_context +from project_forge.rendering.environment import load_environment +from project_forge.rendering.render import render_env +from project_forge.rendering.templates import catalog_inheritance +from project_forge.tui import ask_question + +logger = logging.getLogger(__name__) + + +def build_project( + composition_file: Path, output_dir: Path, use_defaults: bool = False, initial_context: Optional[dict] = None +) -> None: + """Render a project to a directory.""" + initial_context = initial_context or {} + composition = read_composition_file(composition_file) + + if use_defaults: + for overlay in composition.overlays: + overlay.ask_questions = False + context = build_context(composition, ask_question, initial_context) + + template_paths = [overlay.pattern.template_location.resolve() for overlay in composition.overlays] # type: ignore[union-attr] + inheritance = catalog_inheritance(template_paths) + env = load_environment(inheritance) + render_env(env, inheritance, context, output_dir) diff --git a/project_forge/context_builder/context.py b/project_forge/context_builder/context.py index a99f8c0..0a3979d 100644 --- a/project_forge/context_builder/context.py +++ b/project_forge/context_builder/context.py @@ -1,7 +1,7 @@ """Builds and manages the rendering context.""" import datetime -from typing import Callable, Mapping +from typing import Callable, Mapping, Optional from project_forge.configurations.composition import Composition from project_forge.context_builder.data_merge import MERGE_FUNCTION, MergeMethods @@ -14,7 +14,7 @@ def get_starting_context() -> dict: return {"now": datetime.datetime.now(tz=datetime.timezone.utc)} -def build_context(composition: Composition, ui: Callable) -> dict: +def build_context(composition: Composition, ui: Callable, initial_context: Optional[dict] = None) -> dict: """ Build the context for the composition. @@ -28,12 +28,14 @@ def build_context(composition: Composition, ui: Callable) -> dict: Args: composition: The composition configuration. ui: A callable that takes question information and returns the result from the user interface. + initial_context: The initial context to add to the context. Returns: A dictionary """ running_context = get_starting_context() - for key, value in composition.extra_context.items(): + initial_context = initial_context or {} + for key, value in {**composition.extra_context, **initial_context}.items(): running_context[key] = render_expression(value, running_context) for overlay in composition.overlays: diff --git a/project_forge/context_builder/data_merge.py b/project_forge/context_builder/data_merge.py index 0eb4aa4..dcb2348 100644 --- a/project_forge/context_builder/data_merge.py +++ b/project_forge/context_builder/data_merge.py @@ -78,7 +78,7 @@ def update(left_val: T, right_val: T) -> T: """Do a `dict.update` on all the dicts.""" match left_val, right_val: case (dict(), dict()): - return left_val | right_val # type: ignore[operator] + return left_val | right_val # type: ignore[return-value] case _: return right_val diff --git a/tests/test_context_builder/test_context.py b/tests/test_context_builder/test_context.py index e8e0911..90a0bd2 100644 --- a/tests/test_context_builder/test_context.py +++ b/tests/test_context_builder/test_context.py @@ -15,59 +15,92 @@ def test_get_starting_context_contains_correct_keys(): assert context["now"].tzinfo == datetime.timezone.utc -def test_build_context_with_extra_context_and_overlays_composes_correct(): - """Build context should render extra contexts and merge overlay contexts.""" - ui = Mock() - - with ( - patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context, - patch("project_forge.context_builder.context.render_expression") as mock_render_expression, - patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay, - ): - composition = Mock() - composition.merge_keys = {} - composition.extra_context = {"key": "{{ value }}", "overlay_key": "I should get overwritten"} - composition.overlays = ["overlay1", "overlay2"] - - mock_get_starting_context.return_value = {} - mock_render_expression.return_value = "rendered_value" - mock_process_overlay.return_value = {"overlay_key": "overlay_value"} - - context = build_context(composition, ui) - - assert context == { - "key": "rendered_value", - "overlay_key": "overlay_value", - } - - assert mock_render_expression.called - assert mock_process_overlay.called - assert mock_get_starting_context.called - - -def test_build_context_with_empty_composition_is_starting_context(): - """Building a context with an empty composition returns the starting context.""" - ui = Mock() - starting_context = {"key": "value"} - with ( - patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context, - patch("project_forge.context_builder.context.render_expression") as mock_render_expression, - patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay, - ): - composition = Mock() - composition.extra_context = {} - composition.overlays = [] - - mock_get_starting_context.return_value = starting_context - mock_render_expression.return_value = "" - mock_process_overlay.return_value = {} - - context = build_context(composition, ui) - - assert context == starting_context - mock_render_expression.assert_not_called() - mock_process_overlay.assert_not_called() - assert mock_get_starting_context.called +class TestBuildContext: + """Tests for the build_context function.""" + + def test_extra_context_and_overlays_composes_correctly(self): + """Build context should render extra contexts and merge overlay contexts.""" + ui = Mock() + + with ( + patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context, + patch("project_forge.context_builder.context.render_expression") as mock_render_expression, + patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay, + ): + composition = Mock() + composition.merge_keys = {} + composition.extra_context = {"key": "{{ value }}", "overlay_key": "I should get overwritten"} + composition.overlays = ["overlay1", "overlay2"] + + mock_get_starting_context.return_value = {} + mock_render_expression.return_value = "rendered_value" + mock_process_overlay.return_value = {"overlay_key": "overlay_value"} + + context = build_context(composition, ui) + + assert context == { + "key": "rendered_value", + "overlay_key": "overlay_value", + } + + assert mock_render_expression.called + assert mock_process_overlay.called + assert mock_get_starting_context.called + + def test_empty_composition_is_starting_context(self): + """Building a context with an empty composition returns the starting context.""" + ui = Mock() + starting_context = {"key": "value"} + with ( + patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context, + patch("project_forge.context_builder.context.render_expression") as mock_render_expression, + patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay, + ): + composition = Mock() + composition.extra_context = {} + composition.overlays = [] + + mock_get_starting_context.return_value = starting_context + mock_render_expression.return_value = "" + mock_process_overlay.return_value = {} + + context = build_context(composition, ui) + + assert context == starting_context + mock_render_expression.assert_not_called() + mock_process_overlay.assert_not_called() + assert mock_get_starting_context.called + + def initial_context_merges_with_extra_context(self): + """When an initial context is passed, it merges with the extra context.""" + ui = Mock() + + with ( + patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context, + patch("project_forge.context_builder.context.render_expression") as mock_render_expression, + patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay, + ): + composition = Mock() + composition.merge_keys = {} + composition.extra_context = {"key": "{{ value }}", "overlay_key": "I should get overwritten"} + composition.overlays = ["overlay1", "overlay2"] + initial_context = {"initial_key": "initial_value"} + + mock_get_starting_context.return_value = {} + mock_render_expression.return_value = "rendered_value" + mock_process_overlay.return_value = {"overlay_key": "overlay_value"} + + context = build_context(composition, ui, initial_context) + + assert context == { + "key": "rendered_value", + "overlay_key": "overlay_value", + "initial_key": "initial_value", + } + + assert mock_render_expression.called + assert mock_process_overlay.called + assert mock_get_starting_context.called class TestUpdateContext: