Skip to content

Commit

Permalink
Added initial CLI interface.
Browse files Browse the repository at this point in the history
  • Loading branch information
coordt committed Nov 18, 2024
1 parent a7665e6 commit a6fab99
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 57 deletions.
5 changes: 5 additions & 0 deletions project_forge/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Top-level interface for project forge."""

from project_forge.cli import cli

cli()
81 changes: 81 additions & 0 deletions project_forge/cli.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions project_forge/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Command implementation."""
32 changes: 32 additions & 0 deletions project_forge/commands/build.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 5 additions & 3 deletions project_forge/context_builder/context.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion project_forge/context_builder/data_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
139 changes: 86 additions & 53 deletions tests/test_context_builder/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit a6fab99

Please sign in to comment.