Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
64502ff
Update dependencies, .gitignore, and changelog configuration
coordt Mar 27, 2026
614ac8d
Use `TEMPORARY_CACHE_DIR` for temporary caching instead of `Temporary…
coordt Mar 27, 2026
ce67fa9
Update pre-commit configuration: add pyupgrade hook, remove mdformat
coordt Mar 27, 2026
7ee48fa
Add `IndentedLoggerAdapter` for structured and indented logging
coordt Mar 27, 2026
aff2ec7
Rename `overwrite` merge method to `update` for clarity
coordt Mar 27, 2026
06b6867
Annotate `default` in `parse_git_path` as `PathInfo` for improved typ…
coordt Mar 27, 2026
6746c0c
Update merge method options in composition field description
coordt Mar 27, 2026
5e3aad7
Cache default environment for expression rendering to improve perform…
coordt Mar 27, 2026
6ff9f83
Replace `logging.getLogger` with `get_indented_logger` in `render.py`…
coordt Mar 27, 2026
3772982
Refactor variable naming in template rendering for improved readability
coordt Mar 27, 2026
f6009ba
Add verbosity option and integrate logging setup for CLI build command
coordt Mar 27, 2026
ceb1047
Add tests for deeply nested templates, expression caching, indented l…
coordt Mar 27, 2026
1364515
Remove unused `TemporaryDirectory` mock in caching test
coordt Mar 27, 2026
a1d4e95
Remove mdformat from pre-commit
coordt Mar 28, 2026
6c433c0
Simplify `IndentedLoggerAdapter` by removing unused `setup_logging` a…
coordt Mar 28, 2026
44bb911
Replace `logging.getLogger` with `get_indented_logger` in `caching.py…
coordt Mar 28, 2026
7ddc650
Add `nested-overwrite` merge method to `MERGE_FUNCTION` and correspon…
coordt Mar 28, 2026
444a0d8
Refactor logging setup, improve `nested_overwrite` type handling, and…
coordt Mar 28, 2026
e4f64aa
Merge branch 'master' into code-review
coordt Mar 28, 2026
aedf2e0
update dependencies
coordt Mar 28, 2026
0be5296
Fixes some linting errors
coordt Mar 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ output_pipeline:
kwargs:
filename: "{{ changelog_filename }}"
last_heading_pattern: (?im)^## \d+\.\d+(?:\.\d+)?\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)$
- action: MDFormat
kwargs:
filename: CHANGELOG.md

# Full or relative paths to look for output generation templates.
template_dirs:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,4 @@ site-packages
reports
*.env
todo.txt
temp.py
33 changes: 10 additions & 23 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.11.11'
rev: 'v0.15.8'
hooks:
- id: ruff-format
exclude: tests/fixtures/.*
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
exclude: test.*
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
rev: v2.4.2
hooks:
- id: codespell
additional_dependencies:
- tomli
args: [--ignore-words-list, astroid ]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-case-conflict
Expand All @@ -38,22 +38,25 @@ repos:
- id: end-of-file-fixer
exclude: "^tests/resources/"
- id: fix-byte-order-marker
- id: fix-encoding-pragma
args: [ "--remove" ]
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: ["--keep-runtime-typing", ]
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
additional_dependencies: [ "gibberish-detector" ]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
rev: v1.19.1
hooks:
- id: mypy
args: [ --no-strict-optional, --ignore-missing-imports ]
additional_dependencies: [ "pydantic>2.0", "toml", "types-PyYAML" ]
exclude: "^tests/"
- repo: https://github.com/jsh9/pydoclint
rev: 0.6.7
rev: 0.8.3
hooks:
- id: pydoclint
args:
Expand All @@ -64,21 +67,5 @@ repos:
- id: interrogate
exclude: test.*

- repo: https://github.com/executablebooks/mdformat
rev: 0.7.22
hooks:
- id: mdformat
args:
- "--check"
additional_dependencies:
- "mdformat-mkdocs[recommended]"

- repo: https://github.com/executablebooks/mdformat
rev: 0.7.22
hooks:
- id: mdformat
additional_dependencies:
- "mdformat-mkdocs[recommended]"

ci:
autofix_prs: false
10 changes: 4 additions & 6 deletions project_forge/caching.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Caching operations."""

import logging
from pathlib import Path
from tempfile import TemporaryDirectory

from project_forge.core.indented_logger import get_indented_logger
from project_forge.core.io import make_sure_path_exists, remove_single_path
from project_forge.core.urls import ParsedURL
from project_forge.git_commands import clone
from project_forge.settings import get_settings
from project_forge.settings import TEMPORARY_CACHE_DIR, get_settings

logger = logging.getLogger(__name__)
logger = get_indented_logger(__name__)


def get_cache_dir() -> Path:
Expand All @@ -21,8 +20,7 @@ def get_cache_dir() -> Path:
"""
settings = get_settings()
if settings.disable_cache:
with TemporaryDirectory() as temp_dir:
return Path(temp_dir)
return TEMPORARY_CACHE_DIR
else:
make_sure_path_exists(settings.cache_dir)
return settings.cache_dir
Expand Down
32 changes: 31 additions & 1 deletion project_forge/cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""The command-line interface."""

import logging
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.indented_logger import VERBOSITY
from project_forge.core.io import parse_file
from project_forge.core.urls import parse_git_url
from project_forge.ui.defaults import return_defaults
Expand All @@ -31,6 +33,14 @@ def cli(ctx: Context) -> None:
"composition",
type=str,
)
@click.option(
"-v",
"--verbose",
count=True,
required=False,
envvar="PROJECT_FORGE_VERBOSE",
help="Print verbose logging to stderr. Can specify several times for more verbosity.",
)
@click.option(
"--use-defaults",
is_flag=True,
Expand All @@ -40,7 +50,7 @@ def cli(ctx: Context) -> None:
"--output-dir",
"-o",
required=False,
default=lambda: Path.cwd(), # NOQA: PLW0108
default=Path.cwd,
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.",
)
Expand All @@ -66,6 +76,7 @@ def cli(ctx: Context) -> None:
)
def build(
composition: str,
verbose: int,
use_defaults: bool,
output_dir: Path,
data_file: Optional[Path] = None,
Expand All @@ -74,6 +85,8 @@ def build(
"""Build a project from a composition and render it to a directory."""
from project_forge.commands.build import build_project

setup_logging(verbose)

parsed_url = parse_git_url(composition)
composition_path = Path(parsed_url.full_path)

Expand Down Expand Up @@ -123,3 +136,20 @@ def write_schemas(output_dir: Path):

pattern_path = output_dir / "pattern.schema.json"
pattern_path.write_text(result.pattern_schema)


def setup_logging(verbose: int = 0) -> None:
"""Configure the logging."""
import click
from rich.logging import RichHandler

verbosity = VERBOSITY.get(verbose, VERBOSITY[3])
logging.basicConfig(
level=verbosity,
datefmt="[%X]",
handlers=[
RichHandler(
rich_tracebacks=True, show_level=False, show_path=False, show_time=False, tracebacks_suppress=[click]
)
],
)
22 changes: 15 additions & 7 deletions project_forge/context_builder/data_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
from collections import OrderedDict
from functools import reduce
from itertools import chain
from typing import Any, Iterable, Literal, MutableMapping, TypeVar, overload

from immutabledict import immutabledict
Expand Down Expand Up @@ -69,8 +70,6 @@ def merge_iterables(iter1: Iterable, iter2: Iterable) -> set:
Returns:
The merged, de-duplicated sequence as a set
"""
from itertools import chain

return set(chain(freeze_data(iter1), freeze_data(iter2)))


Expand All @@ -83,15 +82,16 @@ def update(left_val: T, right_val: T) -> T:
return right_val


def nested_overwrite(*dicts: dict) -> dict:
def nested_overwrite(left_val: T, right_val: T) -> T:
"""
Merges dicts deeply.

Args:
*dicts: List of dicts to merge with the first one as the base
left_val: The item to merge into
right_val: The item to merge from

Returns:
dict: The merged dict
The merged item
"""

def merge_into(d1: dict, d2: dict) -> dict:
Expand All @@ -102,7 +102,11 @@ def merge_into(d1: dict, d2: dict) -> dict:
d1[key] = merge_into(d1[key], value)
return d1

return reduce(merge_into, dicts, {})
match left_val, right_val:
case (dict(), dict()):
return reduce(merge_into, (left_val, right_val), {}) # type: ignore[return-value]
case _:
return right_val


def comprehensive_merge(left_val: T, right_val: T) -> T:
Expand Down Expand Up @@ -150,7 +154,7 @@ def merge_into(d1: Any, d2: Any) -> Any:


# Strategies merging data.
MergeMethods = Literal["overwrite", "comprehensive"]
MergeMethods = Literal["update", "comprehensive", "nested-overwrite"]

UPDATE = "update"
"""Overwrite at the top level like `dict.update()`."""
Expand All @@ -163,7 +167,11 @@ def merge_into(d1: Any, d2: Any) -> Any:
- dicts are recursively merged
"""

NESTED_OVERWRITE = "nested-overwrite"
"""Deeply merge dicts, overwriting values at each nested level."""

MERGE_FUNCTION = {
COMPREHENSIVE: comprehensive_merge,
UPDATE: update,
NESTED_OVERWRITE: nested_overwrite,
}
93 changes: 93 additions & 0 deletions project_forge/core/indented_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""A logger adapter that adds an indent to the beginning of each message."""

import logging
from contextvars import ContextVar
from typing import Any, MutableMapping, Optional, Tuple

CURRENT_INDENT = ContextVar("current_indent", default=0)

VERBOSITY = {
0: logging.WARNING,
1: logging.INFO,
2: logging.DEBUG,
3: logging.DEBUG,
}


class IndentedLoggerAdapter(logging.LoggerAdapter):
"""
Logger adapter that adds an indent to the beginning of each message.

Parameters:
logger: The logger to adapt.
extra: Extra values to add to the logging context.
depth: The number of `indent_char` to generate for each indent level.
indent_char: The character or string to use for indenting.
reset: `True` if the indent level should be reset to zero.
"""

def __init__(
self,
logger: logging.Logger,
extra: Optional[dict] = None,
depth: int = 2,
indent_char: str = " ",
reset: bool = False,
):
super().__init__(logger, extra or {})
self._depth = depth
self._indent_char = indent_char
if reset:
self.reset()

@property
def current_indent(self) -> int:
"""
The current indent level.
"""
return CURRENT_INDENT.get()

def indent(self, amount: int = 1) -> None:
"""
Increase the indent level by `amount`.
"""
CURRENT_INDENT.set(CURRENT_INDENT.get() + amount)

def dedent(self, amount: int = 1) -> None:
"""
Decrease the indent level by `amount`.
"""
CURRENT_INDENT.set(max(0, CURRENT_INDENT.get() - amount))

def reset(self) -> None:
"""
Reset the indent level to zero.
"""
CURRENT_INDENT.set(0)

@property
def indent_str(self) -> str:
"""
The indent string.
"""
return (self._indent_char * self._depth) * CURRENT_INDENT.get()

def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> Tuple[str, MutableMapping[str, Any]]:
"""
Process the message and add the indent.

Args:
msg: The logging message.
kwargs: Keyword arguments passed to the logger.

Returns:
A tuple containing the message and keyword arguments.
"""
msg = self.indent_str + msg

return msg, kwargs


def get_indented_logger(name: str) -> IndentedLoggerAdapter:
"""Get a logger with indentation."""
return IndentedLoggerAdapter(logging.getLogger(name))
4 changes: 2 additions & 2 deletions project_forge/core/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def remove_single_path(path: Path) -> None:
try:
rmtree(path, ignore_errors=False, onerror=remove_readonly_bit)
except Exception as e: # pragma: no-coverage
raise IOError("Failed to remove directory.") from e
raise OSError("Failed to remove directory.") from e
try:
path.unlink()
except FileNotFoundError: # pragma: no-coverage
Expand All @@ -123,4 +123,4 @@ def remove_single_path(path: Path) -> None:
path.chmod(stat.S_IWRITE)
path.unlink()
except Exception as exc: # pragma: no-coverage
raise IOError("Failed to remove file.") from exc
raise OSError("Failed to remove file.") from exc
9 changes: 8 additions & 1 deletion project_forge/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,14 @@ def parse_internal_path(path: str) -> Dict[str, str]:
def parse_git_path(path: str) -> PathInfo:
"""Parse the path from a git URL into components."""
match = PATH_INFO_RE.match(path)
default = {"owner": "", "repo_name": "", "raw_internal_path": "", "checkout": "", "groups_path": "", "dot_git": ""}
default: PathInfo = {
"owner": "",
"repo_name": "",
"raw_internal_path": "",
"checkout": "",
"groups_path": "",
"dot_git": "",
}
if not match:
return default
if not match.group("raw_internal_path"):
Expand Down
Loading
Loading