Skip to content

Commit a6fab99

Browse files
committed
Added initial CLI interface.
1 parent a7665e6 commit a6fab99

File tree

7 files changed

+211
-57
lines changed

7 files changed

+211
-57
lines changed

project_forge/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Top-level interface for project forge."""
2+
3+
from project_forge.cli import cli
4+
5+
cli()

project_forge/cli.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""The command-line interface."""
2+
3+
from pathlib import Path
4+
from typing import Any, Optional
5+
6+
import rich_click as click
7+
from click.core import Context
8+
9+
from project_forge import __version__
10+
from project_forge.core.io import parse_file
11+
12+
13+
@click.group(
14+
context_settings={
15+
"help_option_names": ["-h", "--help"],
16+
},
17+
add_help_option=True,
18+
)
19+
@click.version_option(version=__version__)
20+
@click.pass_context
21+
def cli(ctx: Context) -> None:
22+
"""Version bump your Python project."""
23+
pass
24+
25+
26+
@cli.command()
27+
@click.argument(
28+
"composition",
29+
type=click.Path(exists=True, dir_okay=False, file_okay=True, resolve_path=True, path_type=Path),
30+
)
31+
@click.option(
32+
"--use-defaults",
33+
is_flag=True,
34+
help="Do not prompt for input and use the defaults specified in the composition.",
35+
)
36+
@click.option(
37+
"--output-dir",
38+
"-o",
39+
required=False,
40+
default=lambda: Path.cwd(), # NOQA: PLW0108
41+
type=click.Path(exists=True, dir_okay=True, file_okay=False, resolve_path=True, path_type=Path),
42+
help="The directory to render the composition to. Defaults to the current working directory.",
43+
)
44+
@click.option(
45+
"--data-file",
46+
"-f",
47+
required=False,
48+
type=click.Path(exists=True, dir_okay=False, file_okay=True, resolve_path=True, path_type=Path),
49+
help=(
50+
"The path to a JSON, YAML, or TOML file whose contents are added to the initial context. "
51+
"Great for answering some or all the answers for a composition."
52+
),
53+
)
54+
@click.option(
55+
"--data",
56+
"-d",
57+
nargs=2,
58+
type=str,
59+
metavar="KEY VALUE",
60+
required=False,
61+
multiple=True,
62+
help="The key-value pairs added to the initial context. Great for providing answers to composition questions.",
63+
)
64+
def build(
65+
composition: Path,
66+
use_defaults: bool,
67+
output_dir: Path,
68+
data_file: Optional[Path] = None,
69+
data: Optional[tuple[tuple[str, str], ...]] = None,
70+
):
71+
"""Build a project from a composition and render it to a directory."""
72+
from project_forge.commands.build import build_project
73+
74+
initial_context: dict[str, Any] = {}
75+
if data_file:
76+
initial_context |= parse_file(data_file)
77+
78+
if data:
79+
initial_context |= dict(data)
80+
print(type(output_dir))
81+
build_project(composition, output_dir=output_dir, use_defaults=use_defaults, initial_context=initial_context)

project_forge/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Command implementation."""

project_forge/commands/build.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Starting point to render a project."""
2+
3+
import logging
4+
from pathlib import Path
5+
from typing import Optional
6+
7+
from project_forge.configurations.composition import read_composition_file
8+
from project_forge.context_builder.context import build_context
9+
from project_forge.rendering.environment import load_environment
10+
from project_forge.rendering.render import render_env
11+
from project_forge.rendering.templates import catalog_inheritance
12+
from project_forge.tui import ask_question
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
def build_project(
18+
composition_file: Path, output_dir: Path, use_defaults: bool = False, initial_context: Optional[dict] = None
19+
) -> None:
20+
"""Render a project to a directory."""
21+
initial_context = initial_context or {}
22+
composition = read_composition_file(composition_file)
23+
24+
if use_defaults:
25+
for overlay in composition.overlays:
26+
overlay.ask_questions = False
27+
context = build_context(composition, ask_question, initial_context)
28+
29+
template_paths = [overlay.pattern.template_location.resolve() for overlay in composition.overlays] # type: ignore[union-attr]
30+
inheritance = catalog_inheritance(template_paths)
31+
env = load_environment(inheritance)
32+
render_env(env, inheritance, context, output_dir)

project_forge/context_builder/context.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Builds and manages the rendering context."""
22

33
import datetime
4-
from typing import Callable, Mapping
4+
from typing import Callable, Mapping, Optional
55

66
from project_forge.configurations.composition import Composition
77
from project_forge.context_builder.data_merge import MERGE_FUNCTION, MergeMethods
@@ -14,7 +14,7 @@ def get_starting_context() -> dict:
1414
return {"now": datetime.datetime.now(tz=datetime.timezone.utc)}
1515

1616

17-
def build_context(composition: Composition, ui: Callable) -> dict:
17+
def build_context(composition: Composition, ui: Callable, initial_context: Optional[dict] = None) -> dict:
1818
"""
1919
Build the context for the composition.
2020
@@ -28,12 +28,14 @@ def build_context(composition: Composition, ui: Callable) -> dict:
2828
Args:
2929
composition: The composition configuration.
3030
ui: A callable that takes question information and returns the result from the user interface.
31+
initial_context: The initial context to add to the context.
3132
3233
Returns:
3334
A dictionary
3435
"""
3536
running_context = get_starting_context()
36-
for key, value in composition.extra_context.items():
37+
initial_context = initial_context or {}
38+
for key, value in {**composition.extra_context, **initial_context}.items():
3739
running_context[key] = render_expression(value, running_context)
3840

3941
for overlay in composition.overlays:

project_forge/context_builder/data_merge.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def update(left_val: T, right_val: T) -> T:
7878
"""Do a `dict.update` on all the dicts."""
7979
match left_val, right_val:
8080
case (dict(), dict()):
81-
return left_val | right_val # type: ignore[operator]
81+
return left_val | right_val # type: ignore[return-value]
8282
case _:
8383
return right_val
8484

tests/test_context_builder/test_context.py

Lines changed: 86 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -15,59 +15,92 @@ def test_get_starting_context_contains_correct_keys():
1515
assert context["now"].tzinfo == datetime.timezone.utc
1616

1717

18-
def test_build_context_with_extra_context_and_overlays_composes_correct():
19-
"""Build context should render extra contexts and merge overlay contexts."""
20-
ui = Mock()
21-
22-
with (
23-
patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context,
24-
patch("project_forge.context_builder.context.render_expression") as mock_render_expression,
25-
patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay,
26-
):
27-
composition = Mock()
28-
composition.merge_keys = {}
29-
composition.extra_context = {"key": "{{ value }}", "overlay_key": "I should get overwritten"}
30-
composition.overlays = ["overlay1", "overlay2"]
31-
32-
mock_get_starting_context.return_value = {}
33-
mock_render_expression.return_value = "rendered_value"
34-
mock_process_overlay.return_value = {"overlay_key": "overlay_value"}
35-
36-
context = build_context(composition, ui)
37-
38-
assert context == {
39-
"key": "rendered_value",
40-
"overlay_key": "overlay_value",
41-
}
42-
43-
assert mock_render_expression.called
44-
assert mock_process_overlay.called
45-
assert mock_get_starting_context.called
46-
47-
48-
def test_build_context_with_empty_composition_is_starting_context():
49-
"""Building a context with an empty composition returns the starting context."""
50-
ui = Mock()
51-
starting_context = {"key": "value"}
52-
with (
53-
patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context,
54-
patch("project_forge.context_builder.context.render_expression") as mock_render_expression,
55-
patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay,
56-
):
57-
composition = Mock()
58-
composition.extra_context = {}
59-
composition.overlays = []
60-
61-
mock_get_starting_context.return_value = starting_context
62-
mock_render_expression.return_value = ""
63-
mock_process_overlay.return_value = {}
64-
65-
context = build_context(composition, ui)
66-
67-
assert context == starting_context
68-
mock_render_expression.assert_not_called()
69-
mock_process_overlay.assert_not_called()
70-
assert mock_get_starting_context.called
18+
class TestBuildContext:
19+
"""Tests for the build_context function."""
20+
21+
def test_extra_context_and_overlays_composes_correctly(self):
22+
"""Build context should render extra contexts and merge overlay contexts."""
23+
ui = Mock()
24+
25+
with (
26+
patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context,
27+
patch("project_forge.context_builder.context.render_expression") as mock_render_expression,
28+
patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay,
29+
):
30+
composition = Mock()
31+
composition.merge_keys = {}
32+
composition.extra_context = {"key": "{{ value }}", "overlay_key": "I should get overwritten"}
33+
composition.overlays = ["overlay1", "overlay2"]
34+
35+
mock_get_starting_context.return_value = {}
36+
mock_render_expression.return_value = "rendered_value"
37+
mock_process_overlay.return_value = {"overlay_key": "overlay_value"}
38+
39+
context = build_context(composition, ui)
40+
41+
assert context == {
42+
"key": "rendered_value",
43+
"overlay_key": "overlay_value",
44+
}
45+
46+
assert mock_render_expression.called
47+
assert mock_process_overlay.called
48+
assert mock_get_starting_context.called
49+
50+
def test_empty_composition_is_starting_context(self):
51+
"""Building a context with an empty composition returns the starting context."""
52+
ui = Mock()
53+
starting_context = {"key": "value"}
54+
with (
55+
patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context,
56+
patch("project_forge.context_builder.context.render_expression") as mock_render_expression,
57+
patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay,
58+
):
59+
composition = Mock()
60+
composition.extra_context = {}
61+
composition.overlays = []
62+
63+
mock_get_starting_context.return_value = starting_context
64+
mock_render_expression.return_value = ""
65+
mock_process_overlay.return_value = {}
66+
67+
context = build_context(composition, ui)
68+
69+
assert context == starting_context
70+
mock_render_expression.assert_not_called()
71+
mock_process_overlay.assert_not_called()
72+
assert mock_get_starting_context.called
73+
74+
def initial_context_merges_with_extra_context(self):
75+
"""When an initial context is passed, it merges with the extra context."""
76+
ui = Mock()
77+
78+
with (
79+
patch("project_forge.context_builder.context.get_starting_context") as mock_get_starting_context,
80+
patch("project_forge.context_builder.context.render_expression") as mock_render_expression,
81+
patch("project_forge.context_builder.context.process_overlay") as mock_process_overlay,
82+
):
83+
composition = Mock()
84+
composition.merge_keys = {}
85+
composition.extra_context = {"key": "{{ value }}", "overlay_key": "I should get overwritten"}
86+
composition.overlays = ["overlay1", "overlay2"]
87+
initial_context = {"initial_key": "initial_value"}
88+
89+
mock_get_starting_context.return_value = {}
90+
mock_render_expression.return_value = "rendered_value"
91+
mock_process_overlay.return_value = {"overlay_key": "overlay_value"}
92+
93+
context = build_context(composition, ui, initial_context)
94+
95+
assert context == {
96+
"key": "rendered_value",
97+
"overlay_key": "overlay_value",
98+
"initial_key": "initial_value",
99+
}
100+
101+
assert mock_render_expression.called
102+
assert mock_process_overlay.called
103+
assert mock_get_starting_context.called
71104

72105

73106
class TestUpdateContext:

0 commit comments

Comments
 (0)