diff --git a/cookieplone/_types.py b/cookieplone/_types.py index 7c2db17..b537b09 100644 --- a/cookieplone/_types.py +++ b/cookieplone/_types.py @@ -14,6 +14,17 @@ class CookieploneTemplate: hidden: bool = False +@dataclass +class CookieploneTemplateGroup: + """A named group of related templates in a cookieplone repository.""" + + name: str + title: str + description: str + templates: dict[str, "CookieploneTemplate"] + hidden: bool = False + + @dataclass class RepositoryInfo: """Resolved repository state for a cookieplone run. @@ -32,6 +43,7 @@ class RepositoryInfo: checkout: str accept_hooks: bool config_dict: dict[str, Any] + global_versions: dict[str, str] = field(default_factory=dict) cleanup_paths: list[Path] = field(default_factory=list) diff --git a/cookieplone/cli/__init__.py b/cookieplone/cli/__init__.py index 7d38116..c74fda3 100644 --- a/cookieplone/cli/__init__.py +++ b/cookieplone/cli/__init__.py @@ -19,6 +19,7 @@ from cookieplone.logger import configure_logger, logger from cookieplone.repository import ( get_base_repository, + get_template_groups, get_template_options, ) from cookieplone.utils import console, files, internal @@ -100,12 +101,34 @@ def annotate_context(context: dict, repo_path: Path, template: str) -> dict: return context +def prompt_for_group( + groups: dict[str, t.CookieploneTemplateGroup], +) -> t.CookieploneTemplateGroup: + """Display template groups and prompt user to choose one.""" + choices = {f"{idx}": name for idx, name in enumerate(groups, 1)} + console.welcome_screen(groups=groups) + answer = Prompt.ask("Select a category", choices=list(choices.keys()), default="1") + return groups[choices[answer]] + + def prompt_for_template(base_path: Path, all_: bool = False) -> t.CookieploneTemplate: - """Parse cookiecutter.json in base_path and prompt user to choose.""" - templates = get_template_options(base_path, all_) + """Parse config in base_path and prompt user to choose a template. + + When the repository defines groups, a two-step selection is presented: + first the user picks a category, then a template within that category. + Otherwise the flat template list is shown directly. + """ + groups = get_template_groups(base_path, all_) + if groups: + group = prompt_for_group(groups) + console.clear_screen() + templates = group.templates + else: + templates = get_template_options(base_path, all_) choices = {f"{idx}": name for idx, name in enumerate(templates, 1)} - console.welcome_screen(templates) + console.welcome_screen(templates=templates) answer = Prompt.ask("Select a template", choices=list(choices.keys()), default="1") + console.clear_screen() return templates[choices[answer]] @@ -139,7 +162,9 @@ def cli( data.OptionalPath, typer.Option("--output-dir", "-o", help="Where to generate the code."), ] = None, - tag: Annotated[str, typer.Option("--tag", "--branch", help="Tag.")] = "main", + tag: Annotated[ + str, typer.Option("--tag", "--branch", help="Tag.") + ] = settings.REPO_DEFAULT_TAG, info: Annotated[ bool, typer.Option( diff --git a/cookieplone/config/schemas.py b/cookieplone/config/schemas.py deleted file mode 100644 index 8c54561..0000000 --- a/cookieplone/config/schemas.py +++ /dev/null @@ -1,109 +0,0 @@ -"""JSONSchema definitions for the cookieplone.json v2 configuration format.""" - -from typing import Any, TypedDict - -import jsonschema -from jsonschema.exceptions import ValidationError - - -class SubTemplate(TypedDict): - """A sub-template entry from the ``config.subtemplates`` list. - - :param id: Path identifier for the sub-template (e.g. ``"sub/backend"``). - :param title: Human-readable label shown in logs and hooks. - :param enabled: Either a static value (``"0"``/``"1"``) or a Jinja2 - expression (e.g. ``"{{ cookiecutter.has_frontend }}"``). - """ - - id: str - title: str - enabled: str - - -COOKIEPLONE_CONFIG_SCHEMA: dict[str, Any] = { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["schema"], - "properties": { - "id": {"type": "string"}, - "schema": { - "type": "object", - "required": ["version", "properties"], - "properties": { - "title": {"type": "string"}, - "description": {"type": "string"}, - "version": {"type": "string", "const": "2.0"}, - "properties": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": ["type", "default"], - "properties": { - "type": {"type": "string"}, - "title": {"type": "string"}, - "description": {"type": "string"}, - "default": {}, - "format": {"type": "string"}, - "validator": {"type": "string"}, - "oneOf": { - "type": "array", - "items": { - "type": "object", - "required": ["const", "title"], - "properties": { - "const": {"type": "string"}, - "title": {"type": "string"}, - }, - }, - }, - }, - }, - }, - }, - }, - "config": { - "type": "object", - "properties": { - "extensions": { - "type": "array", - "items": {"type": "string"}, - }, - "no_render": { - "type": "array", - "items": {"type": "string"}, - }, - "versions": { - "type": "object", - "additionalProperties": {"type": "string"}, - }, - "subtemplates": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "title", "enabled"], - "properties": { - "id": {"type": "string"}, - "title": {"type": "string"}, - "enabled": {"type": "string"}, - }, - }, - }, - }, - "additionalProperties": False, - }, - }, - "additionalProperties": False, -} - - -def validate_cookieplone_config(data: dict[str, Any]) -> bool: - """Validate a config dict against the cookieplone.json v2 schema. - - :param data: The configuration dict to validate. - :returns: ``True`` if validation passes, ``False`` otherwise. - """ - try: - jsonschema.validate(data, COOKIEPLONE_CONFIG_SCHEMA) - return True - except ValidationError: - return False diff --git a/cookieplone/config/schemas/__init__.py b/cookieplone/config/schemas/__init__.py new file mode 100644 index 0000000..2ffab48 --- /dev/null +++ b/cookieplone/config/schemas/__init__.py @@ -0,0 +1,34 @@ +"""JSONSchema definitions for cookieplone configuration formats. + +Defines JSON Schemas and validation helpers for: + +- **Repository config** (``cookieplone-config.json``): the root file that + lists available templates, groups, global versions, and optional + repository extension. +- **Template config** (``cookieplone.json`` v2): the per-template file that + describes form fields and generator settings. +""" + +from cookieplone.config.schemas._types import ( + SubTemplate, + TemplateEntry, + TemplateGroup, +) +from cookieplone.config.schemas.repository import ( + REPOSITORY_CONFIG_SCHEMA, + validate_repository_config, +) +from cookieplone.config.schemas.template import ( + COOKIEPLONE_CONFIG_SCHEMA, + validate_cookieplone_config, +) + +__all__ = [ + "COOKIEPLONE_CONFIG_SCHEMA", + "REPOSITORY_CONFIG_SCHEMA", + "SubTemplate", + "TemplateEntry", + "TemplateGroup", + "validate_cookieplone_config", + "validate_repository_config", +] diff --git a/cookieplone/config/schemas/_types.py b/cookieplone/config/schemas/_types.py new file mode 100644 index 0000000..0a2aff1 --- /dev/null +++ b/cookieplone/config/schemas/_types.py @@ -0,0 +1,50 @@ +"""TypedDict definitions for cookieplone configuration structures.""" + +from typing import TypedDict + + +class TemplateEntry(TypedDict): + """A template entry from the repository-level ``templates`` mapping. + + :param path: Relative filesystem path to the template directory + (e.g. ``"./templates/projects/monorepo"``). + :param title: Human-readable label shown in the template selection menu. + :param description: Short description of what the template generates. + :param hidden: When ``True`` the template is excluded from the default + menu and only shown with ``--all`` or when invoked by name. + """ + + path: str + title: str + description: str + hidden: bool + + +class TemplateGroup(TypedDict): + """A template group from the repository-level ``groups`` mapping. + + :param title: Human-readable label for the group category. + :param description: Short description of the group's purpose. + :param templates: Ordered list of template IDs that belong to this group. + Each ID must match a key in the top-level ``templates`` mapping. + :param hidden: When ``True`` the group is excluded from the default menu. + """ + + title: str + description: str + templates: list[str] + hidden: bool + + +class SubTemplate(TypedDict): + """A sub-template entry from the ``config.subtemplates`` list. + + :param id: Path identifier for the sub-template (e.g. ``"sub/backend"``). + :param title: Human-readable label shown in logs and hooks. + :param enabled: Either a static value (``"0"``/``"1"``) or a Jinja2 + expression (e.g. ``"{{ cookiecutter.has_frontend }}"``). + """ + + id: str + title: str + enabled: str diff --git a/cookieplone/config/schemas/cookieplone_config.schema.json b/cookieplone/config/schemas/cookieplone_config.schema.json new file mode 100644 index 0000000..b0c4968 --- /dev/null +++ b/cookieplone/config/schemas/cookieplone_config.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["schema"], + "properties": { + "id": {"type": "string"}, + "schema": { + "type": "object", + "required": ["version", "properties"], + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"}, + "version": {"type": "string", "const": "2.0"}, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["type", "default"], + "properties": { + "type": {"type": "string"}, + "title": {"type": "string"}, + "description": {"type": "string"}, + "default": {}, + "format": {"type": "string"}, + "validator": {"type": "string"}, + "oneOf": { + "type": "array", + "items": { + "type": "object", + "required": ["const", "title"], + "properties": { + "const": {"type": "string"}, + "title": {"type": "string"} + } + } + } + } + } + } + } + }, + "config": { + "type": "object", + "properties": { + "extensions": { + "type": "array", + "items": {"type": "string"} + }, + "no_render": { + "type": "array", + "items": {"type": "string"} + }, + "versions": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "subtemplates": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "enabled"], + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "enabled": {"type": "string"} + } + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/cookieplone/config/schemas/repository.py b/cookieplone/config/schemas/repository.py new file mode 100644 index 0000000..b051101 --- /dev/null +++ b/cookieplone/config/schemas/repository.py @@ -0,0 +1,72 @@ +"""Validation for the repository-level ``cookieplone-config.json`` format.""" + +import json +from pathlib import Path +from typing import Any + +import jsonschema +from jsonschema.exceptions import ValidationError + +_SCHEMA_PATH = Path(__file__).parent / "repository_config.schema.json" +REPOSITORY_CONFIG_SCHEMA: dict[str, Any] = json.loads(_SCHEMA_PATH.read_text()) + + +def _collect_group_errors(data: dict[str, Any]) -> list[str]: + """Check cross-referential constraints between groups and templates. + + :param data: A repository config dict that has already passed schema + validation. + :returns: List of human-readable error messages. Empty if valid. + """ + errors: list[str] = [] + template_ids = set(data.get("templates", {})) + seen: dict[str, str] = {} # template_id -> group_id + + for group_id, group in data.get("groups", {}).items(): + for tmpl_id in group.get("templates", []): + if tmpl_id not in template_ids: + errors.append( + f"Group '{group_id}' references template " + f"'{tmpl_id}' which is not defined in 'templates'." + ) + elif tmpl_id in seen: + errors.append( + f"Template '{tmpl_id}' appears in both group " + f"'{seen[tmpl_id]}' and group '{group_id}'." + ) + else: + seen[tmpl_id] = group_id + + # Every template must belong to a group + ungrouped = template_ids - set(seen) + for tmpl_id in sorted(ungrouped): + errors.append(f"Template '{tmpl_id}' is not assigned to any group.") + + return errors + + +def validate_repository_config(data: dict[str, Any]) -> tuple[bool, list[str]]: + """Validate a repository config dict against the cookieplone-config.json + schema. + + Performs two levels of validation: + + 1. **Structural**: validates the dict against + :data:`REPOSITORY_CONFIG_SCHEMA` using JSON Schema. + 2. **Cross-referential**: checks that every template referenced by a group + exists in ``templates``, that no template appears in multiple groups, + and that every template belongs to at least one group. + + :param data: The repository configuration dict to validate. + :returns: A ``(valid, errors)`` tuple. *valid* is ``True`` when no errors + were found; *errors* is a list of human-readable error messages. + """ + errors: list[str] = [] + try: + jsonschema.validate(data, REPOSITORY_CONFIG_SCHEMA) + except ValidationError as exc: + errors.append(exc.message) + return False, errors + + errors.extend(_collect_group_errors(data)) + return len(errors) == 0, errors diff --git a/cookieplone/config/schemas/repository_config.schema.json b/cookieplone/config/schemas/repository_config.schema.json new file mode 100644 index 0000000..47f210c --- /dev/null +++ b/cookieplone/config/schemas/repository_config.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["version", "title", "templates"], + "properties": { + "version": {"type": "string", "const": "1.0"}, + "title": {"type": "string"}, + "description": {"type": "string"}, + "extends": {"type": "string"}, + "groups": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["title", "description", "templates"], + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"}, + "templates": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + }, + "hidden": {"type": "boolean", "default": false} + }, + "additionalProperties": false + } + }, + "templates": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["path", "title", "description"], + "properties": { + "path": {"type": "string"}, + "title": {"type": "string"}, + "description": {"type": "string"}, + "hidden": {"type": "boolean", "default": false} + }, + "additionalProperties": false + }, + "minProperties": 1 + }, + "config": { + "type": "object", + "properties": { + "versions": { + "type": "object", + "additionalProperties": {"type": "string"} + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/cookieplone/config/schemas/template.py b/cookieplone/config/schemas/template.py new file mode 100644 index 0000000..d694f7e --- /dev/null +++ b/cookieplone/config/schemas/template.py @@ -0,0 +1,24 @@ +"""Validation for the per-template ``cookieplone.json`` v2 format.""" + +import json +from pathlib import Path +from typing import Any + +import jsonschema +from jsonschema.exceptions import ValidationError + +_SCHEMA_PATH = Path(__file__).parent / "cookieplone_config.schema.json" +COOKIEPLONE_CONFIG_SCHEMA: dict[str, Any] = json.loads(_SCHEMA_PATH.read_text()) + + +def validate_cookieplone_config(data: dict[str, Any]) -> bool: + """Validate a config dict against the cookieplone.json v2 schema. + + :param data: The configuration dict to validate. + :returns: ``True`` if validation passes, ``False`` otherwise. + """ + try: + jsonschema.validate(data, COOKIEPLONE_CONFIG_SCHEMA) + return True + except ValidationError: + return False diff --git a/cookieplone/config/state.py b/cookieplone/config/state.py index a53f12f..9e0773d 100644 --- a/cookieplone/config/state.py +++ b/cookieplone/config/state.py @@ -215,11 +215,29 @@ def _apply_overwrites_to_schema( property_["default"] = overwrite +def _merge_versions( + global_versions: dict[str, str] | None, + template_versions: dict[str, str], +) -> dict[str, str]: + """Merge repository-level and per-template version pinning dicts. + + *global_versions* provides the base layer (from ``cookieplone-config.json``). + *template_versions* provides per-template overrides that take precedence + for any key present in both dicts. + + :param global_versions: Repository-level version pins, or ``None``. + :param template_versions: Per-template version pins. + :returns: Merged version dict. + """ + return {**(global_versions or {}), **template_versions} + + def _generate_state( parsed: ParsedConfig, default_context: dict[str, Any] | None = None, extra_context: dict[str, Any] | None = None, replay_context: dict[str, Any] | None = None, + global_versions: dict[str, str] | None = None, ) -> CookieploneState: """Build a :class:`CookieploneState` from a parsed config and optional context overrides. @@ -234,6 +252,9 @@ def _generate_state( :param extra_context: Explicit overrides supplied by the caller. :param replay_context: Full replay file dict (the top-level structure with a ``"cookiecutter"`` key). The inner dict is extracted automatically. + :param global_versions: Repository-level version pinning from + ``cookieplone-config.json``. Merged as a base layer under the + per-template versions so that templates can override individual keys. :returns: A fully initialised :class:`CookieploneState`. """ schema = parsed.schema @@ -267,9 +288,12 @@ def _generate_state( if variable in schema.get("properties", {}): schema["properties"][variable]["default"] = value + # Merge versions: global (repository-level) as base, per-template overrides + versions = _merge_versions(global_versions, parsed.versions) + state_data = { DEFAULT_DATA_KEY: data, - "versions": parsed.versions, + "versions": versions, } state: CookieploneState = CookieploneState( @@ -280,7 +304,7 @@ def _generate_state( no_render=parsed.no_render, subtemplates=parsed.subtemplates, template_id=parsed.template_id, - versions=parsed.versions, + versions=versions, answers=answers, ) @@ -292,6 +316,7 @@ def generate_state( default_context: dict[str, Any] | None = None, extra_context: dict[str, Any] | None = None, replay_context: dict[str, Any] | None = None, + global_versions: dict[str, str] | None = None, ) -> CookieploneState: """Generate the state for a Cookieplone run. @@ -303,6 +328,9 @@ def generate_state( :param extra_context: Explicit key/value overrides supplied by the caller. :param replay_context: Full replay file dict. When provided, schema defaults are replaced by previously recorded answers. + :param global_versions: Repository-level version pinning from + ``cookieplone-config.json``. Passed through to :func:`_generate_state` + where it is merged as a base layer under per-template versions. :returns: A fully initialised :class:`CookieploneState`. :raises exc.ConfigDoesNotExistException: If no schema file is found under *template_path*. @@ -313,7 +341,9 @@ def generate_state( f"No configuration file found in {template_path}. " "Please ensure a 'cookieplone.json' or 'cookiecutter.json' file exists." ) - return _generate_state(parsed, default_context, extra_context, replay_context) + return _generate_state( + parsed, default_context, extra_context, replay_context, global_versions + ) def load_schema_from_path(template_path: Path) -> ParsedConfig | None: diff --git a/cookieplone/generator/__init__.py b/cookieplone/generator/__init__.py index 674a8f9..11d30d2 100644 --- a/cookieplone/generator/__init__.py +++ b/cookieplone/generator/__init__.py @@ -98,6 +98,7 @@ def generate(config: GenerateConfig) -> Path: default_context=repository_info.config_dict["default_context"], extra_context=config.extra_context, replay_context=context_from_replayfile if config.replay else None, + global_versions=repository_info.global_versions, ) run_config = config.to_run_config() diff --git a/cookieplone/repository.py b/cookieplone/repository.py index d81bf01..52e2478 100644 --- a/cookieplone/repository.py +++ b/cookieplone/repository.py @@ -17,9 +17,12 @@ RepositoryNotFound, ) -CONFIG_FILENAME = "cookiecutter.json" +REPO_CONFIG_FILENAME = "cookieplone-config.json" + +LEGACY_CONFIG_FILENAME = "cookiecutter.json" CONFIG_FILENAMES = [ + REPO_CONFIG_FILENAME, "cookiecutter.json", "cookieplone.json", ] @@ -68,11 +71,41 @@ def get_base_repository( def get_repository_config(base_path: Path) -> dict[str, Any]: - """Open and parse the repository configuration file.""" - path = base_path / CONFIG_FILENAME - if not path.exists(): - raise RuntimeError(f"{CONFIG_FILENAME} not found in {base_path}") - return json.loads(path.read_text()) + """Open and parse the repository configuration file. + + Looks for ``cookieplone-config.json`` first. When found, the file is + validated against :data:`~cookieplone.config.schemas.REPOSITORY_CONFIG_SCHEMA` + and the ``templates`` mapping is returned directly. + + Falls back to the legacy ``cookiecutter.json`` when the new format is + not present. + + :param base_path: Root directory of the template repository. + :returns: Parsed configuration dict containing at least a ``templates`` key. + :raises RuntimeError: When no configuration file is found or when + ``cookieplone-config.json`` fails validation. + """ + repo_config_path = base_path / REPO_CONFIG_FILENAME + if repo_config_path.exists(): + data = json.loads(repo_config_path.read_text()) + from cookieplone.config.schemas import validate_repository_config + + valid, errors = validate_repository_config(data) + if not valid: + msg = f"Invalid {REPO_CONFIG_FILENAME} in {base_path}:\n" + "\n".join( + f" - {e}" for e in errors + ) + raise RuntimeError(msg) + return data + + legacy_path = base_path / LEGACY_CONFIG_FILENAME + if legacy_path.exists(): + return json.loads(legacy_path.read_text()) + + raise RuntimeError( + f"No configuration file found in {base_path}. " + f"Expected {REPO_CONFIG_FILENAME} or {LEGACY_CONFIG_FILENAME}." + ) def _parse_template_options( @@ -119,6 +152,66 @@ def get_template_options( return _parse_template_options(base_path, config, all_) +def _parse_template_groups( + base_path: Path, config: dict[str, Any], all_: bool +) -> dict[str, t.CookieploneTemplateGroup] | None: + """Parse the ``"groups"`` section of a repository config. + + Returns an ordered dict mapping group IDs to + :class:`~cookieplone._types.CookieploneTemplateGroup` instances, or + ``None`` when no groups are defined in the config. + + Hidden groups (and hidden templates within visible groups) are excluded + unless *all_* is ``True``. + + :param base_path: Resolved root directory of the template repository. + :param config: Parsed repository config dict. + :param all_: When ``True`` include hidden groups and templates. + :returns: Ordered dict of groups, or ``None``. + """ + groups_data = config.get("groups") + if not groups_data: + return None + + all_templates = _parse_template_options(base_path, config, all_=True) + + groups: dict[str, t.CookieploneTemplateGroup] = {} + for group_id, group_data in groups_data.items(): + hidden = group_data.get("hidden", False) + if hidden and not all_: + continue + group_templates: dict[str, t.CookieploneTemplate] = {} + for tmpl_id in group_data.get("templates", []): + if tmpl_id in all_templates: + tmpl = all_templates[tmpl_id] + if tmpl.hidden and not all_: + continue + group_templates[tmpl_id] = tmpl + if group_templates: + groups[group_id] = t.CookieploneTemplateGroup( + name=group_id, + title=group_data["title"], + description=group_data["description"], + templates=group_templates, + hidden=hidden, + ) + return groups if groups else None + + +def get_template_groups( + base_path: Path, all_: bool = False +) -> dict[str, t.CookieploneTemplateGroup] | None: + """Return template groups from the repository config, or ``None``. + + :param base_path: Root directory of the template repository. + :param all_: When ``True`` include hidden groups and templates. + :returns: Ordered dict of groups, or ``None`` when no groups are defined. + """ + base_path = base_path.resolve() + config = get_repository_config(base_path) + return _parse_template_groups(base_path, config, all_) + + def _repository_has_config(repo_directory: Path): """Determine if `repo_directory` contains a `cookiecutter.json` file. @@ -333,6 +426,15 @@ def get_repository( base_repo_dir = Path(base_repo_dir) + # Extract global versions from the repository-level config (if present). + global_versions: dict[str, str] = {} + if _repository_has_config(root_repo_dir): + try: + repo_config = get_repository_config(root_repo_dir) + global_versions = repo_config.get("config", {}).get("versions", {}) + except RuntimeError: + pass + repo_dir = _run_pre_hook(base_repo_dir, repo_dir, accept_hooks) # Prepare cleanup_paths @@ -351,5 +453,6 @@ def get_repository( template_name=template_name, accept_hooks=accept_hooks, config_dict=config_dict, + global_versions=global_versions, cleanup_paths=cleanup_paths, ) diff --git a/cookieplone/settings.py b/cookieplone/settings.py index daf74e0..a053365 100644 --- a/cookieplone/settings.py +++ b/cookieplone/settings.py @@ -70,6 +70,7 @@ class PythonVersionSupport: COOKIEPLONE_REPO = "https://github.com/plone/cookieplone" TEMPLATES_REPO = "https://github.com/plone/cookieplone-templates" REPO_DEFAULT = "gh:plone/cookieplone-templates" +REPO_DEFAULT_TAG = "next" # Default branch of cookieplone-templates # Config QUIET_MODE_VAR = "COOKIEPLONE_QUIET_MODE_SWITCH" diff --git a/cookieplone/utils/console.py b/cookieplone/utils/console.py index af9f2c1..5344f39 100644 --- a/cookieplone/utils/console.py +++ b/cookieplone/utils/console.py @@ -2,22 +2,26 @@ # # SPDX-License-Identifier: MIT import os +from collections.abc import Sequence from contextlib import contextmanager from pathlib import Path from textwrap import dedent from rich import print as base_print from rich.align import Align -from rich.console import Group +from rich.console import Console, Group from rich.markup import escape from rich.panel import Panel from rich.table import Table +from rich.text import Text from cookieplone import _types as t from cookieplone.settings import QUIET_MODE_VAR from .internal import cookieplone_info, version_info +_console = Console() + BANNER = """ ******* *************** @@ -53,6 +57,11 @@ """ +def clear_screen(): + """Clear the terminal screen.""" + _console.clear() + + def choose_banner() -> str: """Based on the terminal width, decide which banner to use.""" banner = BANNER @@ -131,7 +140,9 @@ def panel(title: str, msg: str = "", subtitle: str = "", url: str = ""): def create_table( - columns: list[dict] | None = None, rows: list[list[str]] | None = None, **kwargs + columns: list[dict] | None = None, + rows: Sequence[Sequence[str]] | None = None, + **kwargs, ) -> Table: """Create table.""" table = Table(**kwargs) @@ -143,30 +154,55 @@ def create_table( return table -def table_available_templates( - title: str, rows: dict[str, t.CookieploneTemplate] -) -> Table: - """Display a table of options.""" - columns = [ - {"title": "#", "justify": "center", "style": "cyan", "no_wrap": True}, - {"title": "Title", "style": "blue"}, - {"title": "Description", "justify": "left", "style": "blue"}, - ] - rows = [ - (f"{idx}", template.title, template.description) - for idx, template in enumerate(rows.values(), start=1) - ] - return create_table(columns, rows, title=title, expand=True) - - -def welcome_screen(templates: list[t.CookieploneTemplate] | None = None): +def styled_list( + items: list[tuple[str, str]], +) -> Text: + """Build a styled numbered list from (title, description) pairs.""" + text = Text() + for idx, (title, description) in enumerate(items, start=1): + text.append("\n") + text.append(f" {idx} ", style="bold cyan") + text.append(title, style="bold blue") + text.append(f"\n {description}", style="dim") + text.append("\n") + return text + + +def list_available_templates( + templates: dict[str, t.CookieploneTemplate], +) -> Text: + """Display templates as a styled list.""" + items = [(template.title, template.description) for template in templates.values()] + return styled_list(items) + + +def list_available_groups( + groups: dict[str, t.CookieploneTemplateGroup], +) -> Text: + """Display template groups as a styled list.""" + items = [(group.title, group.description) for group in groups.values()] + return styled_list(items) + + +def welcome_screen( + templates: dict[str, t.CookieploneTemplate] | None = None, + groups: dict[str, t.CookieploneTemplateGroup] | None = None, +): banner = choose_banner() items = [ Align.center(f"[bold blue]{banner}[/bold blue]"), ] - if templates: + if groups: items.append( - Panel(table_available_templates(title="Templates", rows=templates)) + Panel(list_available_groups(groups), title="Categories", title_align="left") + ) + elif templates: + items.append( + Panel( + list_available_templates(templates), + title="Templates", + title_align="left", + ) ) panel = Panel( Group(*items), diff --git a/docs/src/concepts/how-cookieplone-works.md b/docs/src/concepts/how-cookieplone-works.md index 0a69e54..373fcab 100644 --- a/docs/src/concepts/how-cookieplone-works.md +++ b/docs/src/concepts/how-cookieplone-works.md @@ -33,15 +33,17 @@ Cookieplone resolves the template repository from one of these sources (in prior The resolved source is a git URL, local path, zip archive, or abbreviated form (`gh:`, `gl:`, `bb:`). Cookieplone clones or copies the repository to a temporary directory, -checking out the tag or branch specified by `--tag` (default: `main`) or `COOKIEPLONE_REPOSITORY_TAG`. +checking out the tag or branch specified by `--tag` (default: `next`) or `COOKIEPLONE_REPOSITORY_TAG`. --- ### 2. Template selection -Cookieplone reads the root `cookiecutter.json` from the cloned repository to discover available templates. +Cookieplone reads the root configuration (`cookieplone-config.json` or `cookiecutter.json`) from the cloned repository to discover available templates. If you provided a template name on the command line, Cookieplone selects it directly. -Otherwise, it displays an interactive menu listing the non-hidden templates and asks you to choose one. +Otherwise, it displays an interactive menu. +When the configuration defines groups, Cookieplone first asks you to pick a category, then shows the templates in that category. +When no groups are defined, the flat template list is shown directly. --- diff --git a/docs/src/reference/environment-variables.md b/docs/src/reference/environment-variables.md index 67fc162..d7573ff 100644 --- a/docs/src/reference/environment-variables.md +++ b/docs/src/reference/environment-variables.md @@ -28,7 +28,7 @@ cookieplone ## `COOKIEPLONE_REPOSITORY_TAG` - **Type**: string (git tag or branch name) -- **Default**: `main` +- **Default**: `next` Specifies the git tag or branch to check out when cloning the template repository. Overrides the `--tag`/`--branch` CLI flag. diff --git a/docs/src/reference/index.md b/docs/src/reference/index.md index 9d44737..83f1210 100644 --- a/docs/src/reference/index.md +++ b/docs/src/reference/index.md @@ -16,6 +16,7 @@ Use these pages to look up exact behaviour, accepted values, and defaults. :maxdepth: 1 cli +repository-config schema-v2 schema-v1 filters diff --git a/docs/src/reference/repository-config.md b/docs/src/reference/repository-config.md new file mode 100644 index 0000000..542c032 --- /dev/null +++ b/docs/src/reference/repository-config.md @@ -0,0 +1,255 @@ +--- +myst: + html_meta: + "description": "Full specification for the cookieplone-config.json repository configuration file." + "property=og:description": "Full specification for the cookieplone-config.json repository configuration file." + "property=og:title": "Repository configuration (cookieplone-config.json)" + "keywords": "Cookieplone, cookieplone-config.json, repository, templates, groups, versions, extends" +--- + +# Repository configuration (`cookieplone-config.json`) + +The `cookieplone-config.json` file lives at the root of a template repository. +It declares the available templates, organizes them into groups for display, and provides global configuration shared across all templates. + +## Top-level structure + +```json +{ + "version": "1.0", + "title": "Plone Community Templates", + "description": "Official Cookieplone templates for the Plone community", + "extends": "", + "groups": { }, + "templates": { }, + "config": { } +} +``` + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `version` | string | yes | Schema version. Must be `"1.0"`. | +| `title` | string | yes | Human-readable name for the template repository. | +| `description` | string | no | Short description of the repository. | +| `extends` | string | no | Repository to inherit templates from (see {ref}`repo-extends`). | +| `groups` | object | no | Template groups for the selection menu (see {ref}`repo-groups`). | +| `templates` | object | yes | Template definitions (see {ref}`repo-templates`). | +| `config` | object | no | Global configuration shared by all templates (see {ref}`repo-config`). | + +(repo-templates)= +## `templates` + +A mapping of template IDs to template entry objects. +Each key is a unique identifier used to reference the template from the CLI, groups, and sub-template hooks. + +```json +{ + "templates": { + "project": { + "path": "./templates/projects/monorepo", + "title": "Volto Project", + "description": "Create a new Plone project that uses the Volto frontend", + "hidden": false + }, + "sub/cache": { + "path": "./templates/sub/cache", + "title": "Cache settings", + "description": "Sub-template with cache settings.", + "hidden": true + } + } +} +``` + +### Template entry fields + +| Key | Type | Required | Default | Description | +|-----|------|----------|---------|-------------| +| `path` | string | yes | | Relative path from the repository root to the template directory. | +| `title` | string | yes | | Human-readable label shown in the selection menu. | +| `description` | string | yes | | Short description of what the template generates. | +| `hidden` | boolean | no | `false` | When `true`, the template is excluded from the default menu. | + +Hidden templates can still be invoked directly by name: + +```console +cookieplone sub/cache +``` + +Or made visible with `--all`: + +```console +cookieplone --all +``` + +(repo-groups)= +## `groups` + +Groups organize templates into categories for the selection menu. +Each key is a unique group identifier. + +```json +{ + "groups": { + "projects": { + "title": "Projects", + "description": "Generators that create a new Plone project", + "templates": ["project", "classic_project"], + "hidden": false + }, + "sub": { + "title": "Sub-Templates", + "description": "Templates used only for internal purposes", + "templates": ["sub/cache", "sub/frontend_project"], + "hidden": true + } + } +} +``` + +### Group entry fields + +| Key | Type | Required | Default | Description | +|-----|------|----------|---------|-------------| +| `title` | string | yes | | Human-readable label for the group category. | +| `description` | string | yes | | Short description of the group. | +| `templates` | array of strings | yes | | Ordered list of template IDs. Must not be empty. | +| `hidden` | boolean | no | `false` | When `true`, the group is excluded from the default menu. | + +### Validation constraints + +- Every template ID in a group must match a key in the top-level `templates` mapping. +- A template must not appear in more than one group. +- Every template must be assigned to at least one group. + +(repo-config)= +## `config` + +Global configuration shared across all templates in the repository. + +```json +{ + "config": { + "versions": { + "gha_version_checkout": "v6", + "gha_version_setup_node": "v4", + "frontend_pnpm": "10.20.0" + } + } +} +``` + +### `config.versions` + +A flat mapping of version identifiers to version strings. +These values are available in all templates via the `versions` Jinja2 namespace: + +```jinja +{{ versions.gha_version_checkout }} +``` + +Individual templates can override specific version values through their own `config.versions` in `cookieplone.json`. +Template-level values take precedence over repository-level values for the same key. + +See {doc}`/reference/schema-v2` for details on the per-template `config.versions`. + +(repo-extends)= +## `extends` + +```{note} +The `extends` field is reserved for a future version. +It is accepted by the schema but not yet processed by the generator. +``` + +The `extends` field declares that this repository builds on top of another template repository. +When implemented, it will allow organizations to: + +- Inherit templates from an upstream source (such as the Plone community repository). +- Override specific templates with custom versions. +- Add new templates on top of the upstream set. + +```json +{ + "extends": "gh:plone/cookieplone-templates" +} +``` + +## Backward compatibility + +Repositories that use the legacy `cookiecutter.json` format (a flat `templates` mapping without groups or versioning) continue to work. +Cookieplone checks for `cookieplone-config.json` first, then falls back to `cookiecutter.json`. + +## Full example + +```json +{ + "version": "1.0", + "title": "Plone Community Templates", + "description": "Official Cookieplone templates for the Plone community", + "extends": "", + "groups": { + "projects": { + "title": "Projects", + "description": "Generators that create a new Plone project", + "templates": ["project", "classic_project"], + "hidden": false + }, + "add-ons": { + "title": "Add-ons", + "description": "Extend Plone with add-ons", + "templates": ["backend_addon", "frontend_addon"], + "hidden": false + }, + "sub": { + "title": "Sub-Templates", + "description": "Templates used only for internal purposes", + "templates": ["sub/cache"], + "hidden": true + } + }, + "templates": { + "project": { + "path": "./templates/projects/monorepo", + "title": "Volto Project", + "description": "Create a new Plone project that uses the Volto frontend", + "hidden": false + }, + "classic_project": { + "path": "./templates/projects/classic", + "title": "Classic UI Project", + "description": "Create a new Plone project that uses Classic UI", + "hidden": false + }, + "backend_addon": { + "path": "./templates/add-ons/backend", + "title": "Backend Add-on", + "description": "Create a new Python package to be used with Plone", + "hidden": false + }, + "frontend_addon": { + "path": "./templates/add-ons/frontend", + "title": "Frontend Add-on", + "description": "Create a new Node package to be used with Volto", + "hidden": false + }, + "sub/cache": { + "path": "./templates/sub/cache", + "title": "Cache settings", + "description": "Sub-template with cache settings.", + "hidden": true + } + }, + "config": { + "versions": { + "gha_version_checkout": "v6", + "frontend_pnpm": "10.20.0" + } + } +} +``` + +## Related pages + +- {doc}`/concepts/template-repositories`: how template repositories are structured. +- {doc}`/reference/schema-v2`: the per-template schema format. +- {doc}`/concepts/subtemplates`: how sub-templates compose. diff --git a/news/118.feature b/news/118.feature new file mode 100644 index 0000000..5516a1b --- /dev/null +++ b/news/118.feature @@ -0,0 +1 @@ +Support grouped template selection in the CLI when ``cookieplone-config.json`` defines ``groups``. Users first pick a category, then a template within that category. @ericof diff --git a/news/128.feature b/news/128.feature new file mode 100644 index 0000000..82884d8 --- /dev/null +++ b/news/128.feature @@ -0,0 +1 @@ +Support global version pinning from repository-level ``cookieplone-config.json``. Templates access shared versions via ``{{ versions. }}`` with per-template overrides taking precedence. @ericof diff --git a/news/141.feature b/news/141.feature new file mode 100644 index 0000000..e999152 --- /dev/null +++ b/news/141.feature @@ -0,0 +1 @@ +Implemented `cookieplone-config.json` repository configuration format with JSON Schema validation, grouped templates, global version pinning, and backward-compatible fallback to `cookiecutter.json`. @ericof diff --git a/pyproject.toml b/pyproject.toml index c757187..7484da8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "packaging==26.0", "gitpython==3.1.46", "xmltodict==1.0.4", - "tui-forms>=1.0.0a2", + "tui-forms>=1.0.0a4", ] [project.entry-points.pytest11] @@ -67,6 +67,7 @@ dev = [ "towncrier>=23.11.0", "zest-releaser[recommended]>=9.1.3", "zestreleaser-towncrier>=1.3.0", + "tui-forms[test]>=1.0.0a4", ] docs = [ @@ -93,6 +94,8 @@ docs = [ [tool.uv] default-groups = ["dev", "docs"] +[tool.uv.sources] + [tool.coverage.run] source_pkgs = ["cookieplone"] branch = true diff --git a/tests/_resources/config/cookieplone-config.json b/tests/_resources/config/cookieplone-config.json new file mode 100644 index 0000000..563c01b --- /dev/null +++ b/tests/_resources/config/cookieplone-config.json @@ -0,0 +1,216 @@ +{ + "version": "1.0", + "title": "Plone Community Templates", + "description": "Official Cookieplone templates for the Plone community", + "extends": "", + "groups": { + "projects": { + "title": "Projects", + "description": "Generators that create a new Plone project", + "templates": [ + "project", + "classic_project" + ], + "hidden": false + }, + "add-ons": { + "title": "Add-ons", + "description": "Extend Plone with add-ons", + "templates": [ + "monorepo_addon", + "backend_addon", + "frontend_addon", + "seven_addon" + ], + "hidden": false + }, + "documentation": { + "title": "Documentation", + "description": "Documentation templates for Plone projects", + "templates": [ + "documentation_starter" + ], + "hidden": false + }, + "devops": { + "title": "DevOps", + "description": "DevOps templates for Plone projects", + "templates": [ + "devops_ansible" + ], + "hidden": false + }, + "ci": { + "title": "CI / CD", + "description": "Continuous Integration and Continuous Deployment templates for Plone projects", + "templates": [ + "ci_gh_backend_addon", + "ci_gh_frontend_addon", + "ci_gh_monorepo_addon", + "ci_gh_project", + "ci_gh_classic_project" + ], + "hidden": true + }, + "other": { + "title": "Other", + "description": "Other templates used by our community", + "templates": [ + "agents_instructions", + "ide_vscode" + ], + "hidden": true + }, + "sub": { + "title": "Sub-Templates", + "description": "Templates used only for internal purposes", + "templates": [ + "sub/cache", + "sub/frontend_project", + "sub/project_settings", + "sub/addon_settings", + "sub/classic_project_settings" + ], + "hidden": true + } + }, + "templates": { + "project": { + "path": "./templates/projects/monorepo", + "title": "Volto Project", + "description": "Create a new Plone project that uses the Volto frontend", + "hidden": false + }, + "classic_project": { + "path": "./templates/projects/classic", + "title": "Classic UI Project", + "description": "Create a new Plone project that uses Classic UI", + "hidden": false + }, + "backend_addon": { + "path": "./templates/add-ons/backend", + "title": "Backend Add-on for Plone", + "description": "Create a new Python package to be used with Plone", + "hidden": false + }, + "frontend_addon": { + "path": "./templates/add-ons/frontend", + "title": "Frontend Add-on for Plone", + "description": "Create a new Node package to be used with Volto", + "hidden": false + }, + "monorepo_addon": { + "path": "./templates/add-ons/monorepo", + "title": "Add-on for Plone (Backend + Volto)", + "description": "Create a new codebase for a Plone add-on that includes both backend and Volto frontend", + "hidden": false + }, + "seven_addon": { + "path": "./templates/add-ons/seven_addon", + "title": "Seven Frontend Add-on for Plone", + "description": "Create a new Node package to be used with Seven", + "hidden": false + }, + "sub/cache": { + "path": "./templates/sub/cache", + "title": "Cache settings for a monorepo Plone project", + "description": "Subtemplate with cache settings to be applied to a project.", + "hidden": true + }, + "sub/frontend_project": { + "path": "./templates/sub/frontend_project", + "title": "A frontend project (used in Container images)", + "description": "Subtemplate with configuration used in container images for frontend project.", + "hidden": true + }, + "sub/project_settings": { + "path": "./templates/sub/project_settings", + "title": "Project settings to be applied on top of a mono repo project", + "description": "Subtemplate with configuration and settings for a mono repo project.", + "hidden": true + }, + "sub/addon_settings": { + "path": "./templates/sub/addon_settings", + "title": "Add-on settings to be applied on top of a mono repo project", + "description": "Subtemplate with configuration and settings for a mono repo add-on.", + "hidden": true + }, + "sub/classic_project_settings": { + "path": "./templates/sub/classic_project_settings", + "title": "Project settings to be applied on top of a Classic UI project", + "description": "Subtemplate with configuration and settings for a Classic UI project.", + "hidden": true + }, + "documentation_starter": { + "path": "./templates/docs/starter", + "title": "Documentation scaffold for Plone projects", + "description": "Create a new documentation scaffold for Plone projects", + "hidden": false + }, + "devops_ansible": { + "path": "./templates/devops/ansible", + "title": "Ansible Playbooks for Plone", + "description": "Ansible setup to manage a Docker Swarm cluster for Plone hosting.", + "hidden": true + }, + "ci_gh_backend_addon": { + "path": "./templates/ci/gh_backend_addon", + "title": "CI: GitHub Actions for Backend Add-on", + "description": "GitHub Actions configuration for CI of a Plone Backend Add-on", + "hidden": true + }, + "ci_gh_frontend_addon": { + "path": "./templates/ci/gh_frontend_addon", + "title": "CI: GitHub Actions for Frontend Add-on", + "description": "GitHub Actions configuration for CI of a Volto Frontend Add-on", + "hidden": true + }, + "ci_gh_monorepo_addon": { + "path": "./templates/ci/gh_monorepo_addon", + "title": "CI: GitHub Actions for Monorepo Add-on", + "description": "GitHub Actions configuration for CI of a Plone Monorepo Add-on", + "hidden": true + }, + "ci_gh_project": { + "path": "./templates/ci/gh_project", + "title": "CI: GitHub Actions for Project", + "description": "GitHub Actions configuration for CI of a Plone Project", + "hidden": true + }, + "ci_gh_classic_project": { + "path": "./templates/ci/gh_classic_project", + "title": "CI: GitHub Actions for a Classic Project", + "description": "GitHub Actions configuration for CI of a Plone Classic Project", + "hidden": true + }, + "agents_instructions": { + "path": "./templates/agents/instructions", + "title": "Agents / LLM: Instructions", + "description": "Instructions for Agents / LLM used in Plone projects", + "hidden": true + }, + "ide_vscode": { + "path": "./templates/ide/vscode", + "title": "VSCode IDE Configuration", + "description": "Configuration files for Visual Studio Code IDE used in Plone projects", + "hidden": true + } + }, + "config": { + "versions": { + "devops_traefik_version": "v2.11", + "devops_zeo_version": "6.0.0", + "frontend_mrs_developer": "^2.2.0", + "frontend_pnpm": "10.20.0", + "frontend_release_it": "^19.0.5", + "tools_pre_commit": "3.7.1", + "gha_version_node": "22.x", + "gha_version_checkout": "v6", + "gha_version_setup_node": "v4", + "gha_version_cache": "v4", + "gha_version_background_action": "v1", + "gha_version_upload_artifact": "v4", + "gha_version_pages_deploy": "v4" + } + } +} diff --git a/tests/_resources/templates_repo_config/cookieplone-config.json b/tests/_resources/templates_repo_config/cookieplone-config.json new file mode 100644 index 0000000..9f78bb5 --- /dev/null +++ b/tests/_resources/templates_repo_config/cookieplone-config.json @@ -0,0 +1,74 @@ +{ + "version": "1.0", + "title": "Test Templates", + "description": "Templates used for testing cookieplone", + "groups": { + "projects": { + "title": "Projects", + "description": "Project templates", + "templates": ["project", "project_classic"], + "hidden": false + }, + "addons": { + "title": "Add-ons", + "description": "Add-on templates", + "templates": ["backend_addon", "frontend_addon"], + "hidden": false + }, + "distributions": { + "title": "Distributions", + "description": "Distribution templates", + "templates": ["distribution"], + "hidden": false + }, + "sub": { + "title": "Sub-Templates", + "description": "Internal sub-templates", + "templates": ["sub/cache"], + "hidden": true + } + }, + "templates": { + "project": { + "path": "./templates/projects/monorepo", + "title": "A Plone Project", + "description": "Create a new Plone project with backend and frontend components", + "hidden": false + }, + "project_classic": { + "path": "./templates/projects/classic", + "title": "A Plone Classic Project", + "description": "Create a new Plone Classic project", + "hidden": false + }, + "backend_addon": { + "path": "./templates/addons/backend", + "title": "Backend Add-on for Plone", + "description": "Create a new Python package to be used with Plone", + "hidden": false + }, + "frontend_addon": { + "path": "./templates/addons/frontend", + "title": "Frontend Add-on for Plone", + "description": "Create a new Node package to be used with Volto", + "hidden": false + }, + "distribution": { + "path": "./templates/distributions/monorepo", + "title": "A Plone and Volto distribution", + "description": "Create a new Distribution with Plone and Volto", + "hidden": false + }, + "sub/cache": { + "path": "./templates/sub/cache", + "title": "A subtemplate to handle cache settings", + "description": "Update cache settings for a project", + "hidden": true + } + }, + "config": { + "versions": { + "gha_version_checkout": "v6" + } + } +} diff --git a/tests/_resources/templates_repo_config/templates/addons/backend_addon/cookiecutter.json b/tests/_resources/templates_repo_config/templates/addons/backend_addon/cookiecutter.json new file mode 100644 index 0000000..61f3bfe --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/addons/backend_addon/cookiecutter.json @@ -0,0 +1,26 @@ +{ + "title": "Add-on", + "description": "A new add-on for Plone", + "check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Add-on Title", + "description": "A short description of your add-on" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_subtemplates": [ + [ + "sub/bar", + "Bar", + "1" + ] + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates_repo_config/templates/addons/backend_addon/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates_repo_config/templates/addons/backend_addon/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..faa767c --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/addons/backend_addon/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,6 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +{%- if cookiecutter.check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/_resources/templates_repo_config/templates/addons/frontend_addon/cookiecutter.json b/tests/_resources/templates_repo_config/templates/addons/frontend_addon/cookiecutter.json new file mode 100644 index 0000000..61f3bfe --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/addons/frontend_addon/cookiecutter.json @@ -0,0 +1,26 @@ +{ + "title": "Add-on", + "description": "A new add-on for Plone", + "check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Add-on Title", + "description": "A short description of your add-on" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_subtemplates": [ + [ + "sub/bar", + "Bar", + "1" + ] + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates_repo_config/templates/addons/frontend_addon/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates_repo_config/templates/addons/frontend_addon/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..faa767c --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/addons/frontend_addon/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,6 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +{%- if cookiecutter.check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/_resources/templates_repo_config/templates/distributions/monorepo/cookiecutter.json b/tests/_resources/templates_repo_config/templates/distributions/monorepo/cookiecutter.json new file mode 100644 index 0000000..61f3bfe --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/distributions/monorepo/cookiecutter.json @@ -0,0 +1,26 @@ +{ + "title": "Add-on", + "description": "A new add-on for Plone", + "check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Add-on Title", + "description": "A short description of your add-on" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_subtemplates": [ + [ + "sub/bar", + "Bar", + "1" + ] + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates_repo_config/templates/distributions/monorepo/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates_repo_config/templates/distributions/monorepo/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..faa767c --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/distributions/monorepo/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,6 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +{%- if cookiecutter.check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/_resources/templates_repo_config/templates/projects/classic/cookiecutter.json b/tests/_resources/templates_repo_config/templates/projects/classic/cookiecutter.json new file mode 100644 index 0000000..61f3bfe --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/projects/classic/cookiecutter.json @@ -0,0 +1,26 @@ +{ + "title": "Add-on", + "description": "A new add-on for Plone", + "check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Add-on Title", + "description": "A short description of your add-on" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_subtemplates": [ + [ + "sub/bar", + "Bar", + "1" + ] + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates_repo_config/templates/projects/classic/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates_repo_config/templates/projects/classic/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..faa767c --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/projects/classic/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,6 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +{%- if cookiecutter.check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/_resources/templates_repo_config/templates/projects/monorepo/cookiecutter.json b/tests/_resources/templates_repo_config/templates/projects/monorepo/cookiecutter.json new file mode 100644 index 0000000..61f3bfe --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/projects/monorepo/cookiecutter.json @@ -0,0 +1,26 @@ +{ + "title": "Add-on", + "description": "A new add-on for Plone", + "check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Add-on Title", + "description": "A short description of your add-on" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_subtemplates": [ + [ + "sub/bar", + "Bar", + "1" + ] + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates_repo_config/templates/projects/monorepo/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates_repo_config/templates/projects/monorepo/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..faa767c --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/projects/monorepo/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,6 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +{%- if cookiecutter.check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/_resources/templates_repo_config/templates/sub/cache/cookiecutter.json b/tests/_resources/templates_repo_config/templates/sub/cache/cookiecutter.json new file mode 100644 index 0000000..61f3bfe --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/sub/cache/cookiecutter.json @@ -0,0 +1,26 @@ +{ + "title": "Add-on", + "description": "A new add-on for Plone", + "check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Add-on Title", + "description": "A short description of your add-on" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_subtemplates": [ + [ + "sub/bar", + "Bar", + "1" + ] + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates_repo_config/templates/sub/cache/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates_repo_config/templates/sub/cache/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..faa767c --- /dev/null +++ b/tests/_resources/templates_repo_config/templates/sub/cache/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,6 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +{%- if cookiecutter.check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/config/test_schemas_repository.py b/tests/config/test_schemas_repository.py new file mode 100644 index 0000000..910eaaf --- /dev/null +++ b/tests/config/test_schemas_repository.py @@ -0,0 +1,223 @@ +"""Tests for cookieplone.config.schemas.repository.""" + +import json +from pathlib import Path + +from cookieplone.config.schemas import validate_repository_config + +RESOURCES = Path(__file__).parent.parent / "_resources" / "config" + + +def _minimal_repo(): + """Return a minimal valid repository config.""" + return { + "version": "1.0", + "title": "Test Templates", + "groups": { + "main": { + "title": "Main", + "description": "Main templates", + "templates": ["project"], + "hidden": False, + }, + }, + "templates": { + "project": { + "path": "./templates/project", + "title": "Project", + "description": "A project template", + "hidden": False, + }, + }, + } + + +class TestValidateRepositoryConfig: + """Tests for validate_repository_config.""" + + def test_minimal_valid(self): + valid, errors = validate_repository_config(_minimal_repo()) + assert valid is True + assert errors == [] + + def test_fixture_file(self): + """Validate the full test fixture.""" + data = json.loads((RESOURCES / "cookieplone-config.json").read_text()) + valid, errors = validate_repository_config(data) + assert valid is True, errors + + def test_with_description(self): + data = _minimal_repo() + data["description"] = "Templates for testing" + valid, _errors = validate_repository_config(data) + assert valid is True + + def test_with_extends(self): + data = _minimal_repo() + data["extends"] = "gh:plone/cookieplone-templates" + valid, _errors = validate_repository_config(data) + assert valid is True + + def test_with_empty_extends(self): + data = _minimal_repo() + data["extends"] = "" + valid, _errors = validate_repository_config(data) + assert valid is True + + def test_with_config_versions(self): + data = _minimal_repo() + data["config"] = {"versions": {"gha_checkout": "v6"}} + valid, _errors = validate_repository_config(data) + assert valid is True + + def test_multiple_groups(self): + data = _minimal_repo() + data["groups"]["addons"] = { + "title": "Add-ons", + "description": "Add-on templates", + "templates": ["addon"], + "hidden": False, + } + data["templates"]["addon"] = { + "path": "./templates/addon", + "title": "Add-on", + "description": "An add-on template", + "hidden": False, + } + valid, _errors = validate_repository_config(data) + assert valid is True + + def test_hidden_group(self): + data = _minimal_repo() + data["groups"]["internal"] = { + "title": "Internal", + "description": "Internal templates", + "templates": ["sub"], + "hidden": True, + } + data["templates"]["sub"] = { + "path": "./templates/sub", + "title": "Sub", + "description": "A sub-template", + "hidden": True, + } + valid, _errors = validate_repository_config(data) + assert valid is True + + # -- Structural validation errors -- + + def test_missing_version(self): + data = _minimal_repo() + del data["version"] + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_wrong_version(self): + data = _minimal_repo() + data["version"] = "2.0" + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_missing_title(self): + data = _minimal_repo() + del data["title"] + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_missing_templates(self): + data = _minimal_repo() + del data["templates"] + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_empty_templates(self): + data = _minimal_repo() + data["templates"] = {} + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_template_missing_path(self): + data = _minimal_repo() + del data["templates"]["project"]["path"] + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_template_missing_title(self): + data = _minimal_repo() + del data["templates"]["project"]["title"] + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_template_extra_key(self): + data = _minimal_repo() + data["templates"]["project"]["unknown"] = "value" + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_group_missing_title(self): + data = _minimal_repo() + del data["groups"]["main"]["title"] + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_group_empty_templates(self): + data = _minimal_repo() + data["groups"]["main"]["templates"] = [] + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_extra_top_level_key(self): + data = _minimal_repo() + data["unknown"] = "value" + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_extra_config_key(self): + data = _minimal_repo() + data["config"] = {"unknown": "value"} + valid, _errors = validate_repository_config(data) + assert valid is False + + def test_empty_dict(self): + valid, _errors = validate_repository_config({}) + assert valid is False + + # -- Cross-referential validation errors -- + + def test_group_references_unknown_template(self): + data = _minimal_repo() + data["groups"]["main"]["templates"].append("nonexistent") + valid, errors = validate_repository_config(data) + assert valid is False + assert any("nonexistent" in e for e in errors) + + def test_template_in_multiple_groups(self): + data = _minimal_repo() + data["groups"]["other"] = { + "title": "Other", + "description": "Other templates", + "templates": ["project"], + "hidden": False, + } + valid, errors = validate_repository_config(data) + assert valid is False + assert any("appears in both" in e for e in errors) + + def test_template_not_in_any_group(self): + data = _minimal_repo() + data["templates"]["orphan"] = { + "path": "./templates/orphan", + "title": "Orphan", + "description": "Orphan template", + "hidden": False, + } + valid, errors = validate_repository_config(data) + assert valid is False + assert any("orphan" in e for e in errors) + + def test_no_groups_all_templates_ungrouped(self): + data = _minimal_repo() + del data["groups"] + valid, errors = validate_repository_config(data) + assert valid is False + assert any("not assigned to any group" in e for e in errors) diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas_template.py similarity index 98% rename from tests/config/test_schemas.py rename to tests/config/test_schemas_template.py index 4f3d6ed..9f2f9af 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas_template.py @@ -1,4 +1,4 @@ -"""Tests for cookieplone.config.schemas.""" +"""Tests for cookieplone.config.schemas.template.""" import pytest diff --git a/tests/config/test_state.py b/tests/config/test_state.py index 36048e2..7a98170 100644 --- a/tests/config/test_state.py +++ b/tests/config/test_state.py @@ -2,7 +2,6 @@ from cookieplone.config import CookieploneState from cookieplone.config import state as config -from cookieplone.config.v2 import ParsedConfig CONFIG_FILES = [ "config/v1-agents_instructions.json", @@ -77,26 +76,3 @@ def test_generate_state_overrides( assert len(questions) == len_questions question = properties[key] assert question["default"] == default - - -class TestGenerateStateVersions: - """Tests for versions handling in _generate_state.""" - - def test_versions_in_state_data(self): - """state.data contains a 'versions' key after state creation.""" - parsed = ParsedConfig( - schema={"version": "2.0", "properties": {}}, - versions={"gha_checkout": "v6", "plone": "6.1"}, - ) - state = config._generate_state(parsed) - assert "versions" in state.data - assert state.data["versions"] == {"gha_checkout": "v6", "plone": "6.1"} - - def test_empty_versions_in_state_data(self): - """state.data contains an empty 'versions' dict when config has none.""" - parsed = ParsedConfig( - schema={"version": "2.0", "properties": {}}, - ) - state = config._generate_state(parsed) - assert "versions" in state.data - assert state.data["versions"] == {} diff --git a/tests/config/test_state_versions.py b/tests/config/test_state_versions.py new file mode 100644 index 0000000..caf6e82 --- /dev/null +++ b/tests/config/test_state_versions.py @@ -0,0 +1,76 @@ +"""Tests for version merging in cookieplone.config.state.""" + +import pytest + +from cookieplone.config import state as config +from cookieplone.config.v2 import ParsedConfig + + +@pytest.mark.parametrize( + "global_versions,template_versions,expected", + [ + (None, {}, {}), + ({"traefik": "v2.11"}, {}, {"traefik": "v2.11"}), + (None, {"plone": "6.1"}, {"plone": "6.1"}), + ({}, {"plone": "6.1"}, {"plone": "6.1"}), + ( + {"traefik": "v2.11"}, + {"plone": "6.1"}, + {"traefik": "v2.11", "plone": "6.1"}, + ), + ( + {"gha_checkout": "v6", "traefik": "v2.11"}, + {"gha_checkout": "v7"}, + {"gha_checkout": "v7", "traefik": "v2.11"}, + ), + ], + ids=[ + "both_empty", + "global_only", + "template_only", + "empty_global_dict", + "disjoint_keys", + "template_overrides_global", + ], +) +def test_merge_versions(global_versions, template_versions, expected): + assert config._merge_versions(global_versions, template_versions) == expected + + +@pytest.mark.parametrize( + "template_versions,global_versions,expected_versions", + [ + ( + {"gha_checkout": "v6", "plone": "6.1"}, + None, + {"gha_checkout": "v6", "plone": "6.1"}, + ), + ({}, None, {}), + ( + {}, + {"gha_checkout": "v6", "traefik": "v2.11"}, + {"gha_checkout": "v6", "traefik": "v2.11"}, + ), + ( + {"gha_checkout": "v7"}, + {"gha_checkout": "v6", "traefik": "v2.11"}, + {"gha_checkout": "v7", "traefik": "v2.11"}, + ), + ({"plone": "6.1"}, None, {"plone": "6.1"}), + ], + ids=[ + "template_versions_only", + "no_versions", + "global_versions_only", + "template_overrides_global", + "global_none_passthrough", + ], +) +def test_generate_state_versions(template_versions, global_versions, expected_versions): + parsed = ParsedConfig( + schema={"version": "2.0", "properties": {}}, + versions=template_versions, + ) + state = config._generate_state(parsed, global_versions=global_versions) + assert state.data["versions"] == expected_versions + assert state.versions == expected_versions diff --git a/tests/utils/test_console.py b/tests/utils/test_console.py index 8a5bd8d..e08004b 100644 --- a/tests/utils/test_console.py +++ b/tests/utils/test_console.py @@ -1,7 +1,13 @@ +from pathlib import Path from unittest.mock import patch import pytest +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from cookieplone._types import CookieploneTemplate, CookieploneTemplateGroup +from cookieplone.settings import QUIET_MODE_VAR from cookieplone.utils import console @@ -17,35 +23,239 @@ def func(width: int) -> None: return func -@patch("cookieplone.utils.console.print") -def test_print_plone_banner(mock_print): - console.print_plone_banner() - mock_print.assert_called_once_with(console.BANNER, "bold", "blue") - - -@pytest.mark.parametrize( - "width,banner", - [ - (80, console.BANNER), - (90, console.PLONE_LOGOTYPE_BANNER), - (100, console.PLONE_LOGOTYPE_BANNER), - ], -) -def test_choose_banner(set_console_width, width: int, banner: str): - set_console_width(width) - assert console.choose_banner() == banner - - -@pytest.mark.parametrize( - "func,msg,style,color", - [ - [console.info, "foo", "bold", "white"], - [console.success, "foo", "bold", "green"], - [console.error, "foo", "bold", "red"], - [console.warning, "foo", "bold", "yellow"], - ], -) -@patch("cookieplone.utils.console.print") -def test_prints(mock_print, func, msg, style, color): - func(msg) - mock_print.assert_called_once_with(msg, style, color) +@pytest.fixture +def sample_templates(): + return { + "project": CookieploneTemplate( + name="project", + title="A Plone Project", + description="Create a new Plone project", + path=Path("templates/projects/monorepo"), + ), + "addon": CookieploneTemplate( + name="addon", + title="Backend Add-on", + description="Create a backend add-on", + path=Path("templates/addons/backend"), + ), + } + + +@pytest.fixture +def sample_groups(sample_templates): + return { + "projects": CookieploneTemplateGroup( + name="projects", + title="Projects", + description="Project generators", + templates={"project": sample_templates["project"]}, + ), + "addons": CookieploneTemplateGroup( + name="addons", + title="Add-ons", + description="Add-on generators", + templates={"addon": sample_templates["addon"]}, + ), + } + + +class TestChooseBanner: + @pytest.mark.parametrize( + "width,banner", + [ + (80, console.BANNER), + (90, console.PLONE_LOGOTYPE_BANNER), + (100, console.PLONE_LOGOTYPE_BANNER), + ], + ) + def test_by_terminal_width(self, set_console_width, width, banner): + set_console_width(width) + assert console.choose_banner() == banner + + def test_fallback_on_oserror(self, monkeypatch): + monkeypatch.setattr( + "os.get_terminal_size", lambda: (_ for _ in ()).throw(OSError) + ) + assert console.choose_banner() == console.BANNER + + +class TestClearScreen: + @patch("cookieplone.utils.console._console") + def test_delegates_to_console(self, mock_console): + console.clear_screen() + mock_console.clear.assert_called_once() + + +class TestPrint: + @patch("cookieplone.utils.console.print") + def test_print_plone_banner(self, mock_print): + console.print_plone_banner() + mock_print.assert_called_once_with(console.BANNER, "bold", "blue") + + @pytest.mark.parametrize( + "func,msg,style,color", + [ + (console.info, "foo", "bold", "white"), + (console.success, "foo", "bold", "green"), + (console.error, "foo", "bold", "red"), + (console.warning, "foo", "bold", "yellow"), + ], + ) + @patch("cookieplone.utils.console.print") + def test_level_functions(self, mock_print, func, msg, style, color): + func(msg) + mock_print.assert_called_once_with(msg, style, color) + + @patch("cookieplone.utils.console._print") + def test_print_with_markup(self, mock_print): + console.print("hello", style="bold", color="red") + mock_print.assert_called_once_with("[bold red]hello[/bold red]") + + @patch("cookieplone.utils.console._print") + def test_print_without_markup(self, mock_print): + console.print("hello") + mock_print.assert_called_once_with("hello") + + @patch("cookieplone.utils.console.base_print") + def test__print_respects_quiet_mode(self, mock_base_print, monkeypatch): + monkeypatch.setenv(QUIET_MODE_VAR, "1") + console._print("should not print") + mock_base_print.assert_not_called() + + @patch("cookieplone.utils.console.base_print") + def test__print_outputs_when_not_quiet(self, mock_base_print, monkeypatch): + monkeypatch.delenv(QUIET_MODE_VAR, raising=False) + console._print("visible") + mock_base_print.assert_called_once_with("visible") + + +class TestPanel: + @patch("cookieplone.utils.console._print") + def test_panel_without_url(self, mock_print): + console.panel(title="Test", msg="body") + args = mock_print.call_args[0] + assert isinstance(args[0], Panel) + + @patch("cookieplone.utils.console._print") + def test_panel_with_url(self, mock_print): + console.panel(title="Test", msg="body", url="https://example.com") + args = mock_print.call_args[0] + assert isinstance(args[0], Panel) + + +class TestCreateTable: + def test_returns_table(self): + columns = [ + {"title": "#", "style": "cyan"}, + {"title": "Name", "style": "blue"}, + ] + rows = [("1", "Alice"), ("2", "Bob")] + result = console.create_table(columns, rows) + assert isinstance(result, Table) + assert result.row_count == 2 + + +class TestStyledList: + def test_returns_text(self): + items = [("Title A", "Desc A"), ("Title B", "Desc B")] + result = console.styled_list(items) + assert isinstance(result, Text) + plain = result.plain + assert "1" in plain + assert "Title A" in plain + assert "Desc A" in plain + assert "2" in plain + assert "Title B" in plain + + def test_empty_list(self): + result = console.styled_list([]) + assert isinstance(result, Text) + assert result.plain == "\n" + + +class TestListAvailableTemplates: + def test_returns_text(self, sample_templates): + result = console.list_available_templates(sample_templates) + assert isinstance(result, Text) + assert "A Plone Project" in result.plain + assert "Backend Add-on" in result.plain + + +class TestListAvailableGroups: + def test_returns_text(self, sample_groups): + result = console.list_available_groups(sample_groups) + assert isinstance(result, Text) + assert "Projects" in result.plain + assert "Add-ons" in result.plain + + +class TestWelcomeScreen: + @patch("cookieplone.utils.console.base_print") + def test_with_groups(self, mock_print, sample_groups): + console.welcome_screen(groups=sample_groups) + mock_print.assert_called_once() + panel = mock_print.call_args[0][0] + assert isinstance(panel, Panel) + + @patch("cookieplone.utils.console.base_print") + def test_with_templates(self, mock_print, sample_templates): + console.welcome_screen(templates=sample_templates) + mock_print.assert_called_once() + panel = mock_print.call_args[0][0] + assert isinstance(panel, Panel) + + @patch("cookieplone.utils.console.base_print") + def test_without_args(self, mock_print): + console.welcome_screen() + mock_print.assert_called_once() + panel = mock_print.call_args[0][0] + assert isinstance(panel, Panel) + + +class TestVersionScreen: + @patch("cookieplone.utils.console.base_print") + def test_calls_base_print(self, mock_print): + console.version_screen() + mock_print.assert_called_once() + + +class TestInfoScreen: + @patch("cookieplone.utils.console.base_print") + def test_renders_panel(self, mock_print): + console.info_screen(repository="/var/repo", passwd="", tag="main") + mock_print.assert_called_once() + panel = mock_print.call_args[0][0] + assert isinstance(panel, Panel) + + +class TestQuietMode: + def test_enable_quiet_mode(self, monkeypatch): + monkeypatch.delenv(QUIET_MODE_VAR, raising=False) + console.enable_quiet_mode() + import os + + assert os.environ.get(QUIET_MODE_VAR) == "1" + + def test_disable_quiet_mode(self, monkeypatch): + monkeypatch.setenv(QUIET_MODE_VAR, "1") + console.disable_quiet_mode() + import os + + assert os.environ.get(QUIET_MODE_VAR) is None + + def test_context_manager(self, monkeypatch): + import os + + monkeypatch.delenv(QUIET_MODE_VAR, raising=False) + with console.quiet_mode(): + assert os.environ.get(QUIET_MODE_VAR) == "1" + assert os.environ.get(QUIET_MODE_VAR) is None + + def test_context_manager_cleans_up_on_exception(self, monkeypatch): + import os + + monkeypatch.delenv(QUIET_MODE_VAR, raising=False) + with pytest.raises(ValueError, match="boom"), console.quiet_mode(): + assert os.environ.get(QUIET_MODE_VAR) == "1" + raise ValueError("boom") + assert os.environ.get(QUIET_MODE_VAR) is None diff --git a/tests/utils/test_cookiecutter.py b/tests/utils/test_cookiecutter.py index 92cfa88..a5f30e1 100644 --- a/tests/utils/test_cookiecutter.py +++ b/tests/utils/test_cookiecutter.py @@ -1,10 +1,21 @@ """Tests for cookieplone.utils.cookiecutter.""" +import json +import sys + import pytest from cookiecutter.exceptions import OutputDirExistsException from jinja2 import Environment +from jinja2.exceptions import UndefinedError -from cookieplone.utils.cookiecutter import create_jinja_env, parse_output_dir_exception +from cookieplone.utils.cookiecutter import ( + create_jinja_env, + dump_replay, + import_patch, + load_replay, + parse_output_dir_exception, + parse_undefined_error, +) class TestParseOutputDirException: @@ -73,3 +84,108 @@ def test_env_is_reusable(self): env = create_jinja_env({"a": "1", "b": "2"}) assert env.from_string("{{ cookiecutter.a }}").render() == "1" assert env.from_string("{{ cookiecutter.b }}").render() == "2" + + +class TestImportPatch: + """Tests for import_patch.""" + + def test_adds_repo_dir_to_sys_path(self, tmp_path): + repo_dir = tmp_path / "my-repo" + repo_dir.mkdir() + with import_patch(repo_dir): + assert str(repo_dir) in sys.path + + def test_restores_sys_path_on_exit(self, tmp_path): + repo_dir = tmp_path / "my-repo" + repo_dir.mkdir() + original_path = sys.path.copy() + with import_patch(repo_dir): + pass + assert sys.path == original_path + + def test_restores_sys_path_on_exception(self, tmp_path): + repo_dir = tmp_path / "my-repo" + repo_dir.mkdir() + original_path = sys.path.copy() + with pytest.raises(RuntimeError), import_patch(repo_dir): + raise RuntimeError("boom") + assert sys.path == original_path + + def test_accepts_string_path(self, tmp_path): + repo_dir = tmp_path / "my-repo" + repo_dir.mkdir() + with import_patch(str(repo_dir)): + assert str(repo_dir) in sys.path + + +class TestLoadReplay: + """Tests for load_replay.""" + + def test_returns_empty_dict_when_replay_is_false(self, tmp_path): + result = load_replay(tmp_path, tmp_path, False, "template") + assert result == {} + + def test_loads_from_replay_dir_when_replay_is_true(self, tmp_path): + replay_dir = tmp_path / "replay" + replay_dir.mkdir() + context = {"cookiecutter": {"name": "test"}} + replay_file = replay_dir / "mytemplate.json" + replay_file.write_text(json.dumps(context)) + result = load_replay(tmp_path, replay_dir, True, "mytemplate") + assert result == context + + def test_loads_from_explicit_path(self, tmp_path): + context = {"cookiecutter": {"name": "test"}} + replay_file = tmp_path / "custom.json" + replay_file.write_text(json.dumps(context)) + result = load_replay(tmp_path, tmp_path, replay_file, "ignored") + assert result == context + + +class TestDumpReplay: + """Tests for dump_replay.""" + + def test_dump_creates_replay_file(self, tmp_path): + from cookieplone.config import Answers + + answers = Answers(answers={"name": "test-project"}) + dump_replay(answers, tmp_path, "mytemplate") + replay_file = tmp_path / "mytemplate.json" + assert replay_file.exists() + data = json.loads(replay_file.read_text()) + assert data["cookiecutter"] == {"name": "test-project"} + + +class TestParseUndefinedError: + """Tests for parse_undefined_error.""" + + @pytest.mark.parametrize( + ("error_msg", "base_msg", "expected"), + [ + ( + "'dict object' has no attribute '__cookieplone_template'", + "Error", + "Error: Variable '__cookieplone_template' is undefined", + ), + ( + "'dict object' has no attribute 'my_var'", + "", + ": Variable 'my_var' is undefined", + ), + ( + "something weird !!!", + "Error", + "Error", + ), + ], + ids=["typical_error", "empty_base_msg", "non_identifier"], + ) + def test_parse_undefined_error(self, error_msg, base_msg, expected): + exc = UndefinedError(error_msg) + result = parse_undefined_error(exc, base_msg) + assert result == expected + + def test_empty_message(self): + exc = UndefinedError("") + result = parse_undefined_error(exc, "base") + assert result == "base" diff --git a/tests/utils/test_parsers.py b/tests/utils/test_parsers.py new file mode 100644 index 0000000..d95975e --- /dev/null +++ b/tests/utils/test_parsers.py @@ -0,0 +1,25 @@ +import pytest + +from cookieplone.utils.parsers import parse_boolean + + +class TestParseBoolean: + @pytest.mark.parametrize( + "value,expected", + [ + ("1", True), + ("yes", True), + ("y", True), + ("YES", True), + ("Y", True), + ("Yes", True), + ("0", False), + ("no", False), + ("n", False), + ("false", False), + ("", False), + ("anything", False), + ], + ) + def test_parse_boolean(self, value, expected): + assert parse_boolean(value) is expected diff --git a/tests/utils/test_plone.py b/tests/utils/test_plone.py index 2f16955..c250a39 100644 --- a/tests/utils/test_plone.py +++ b/tests/utils/test_plone.py @@ -137,6 +137,17 @@ def test_add_package_dependency_to_zcml(read_data_file, package: str): assert f"""""" in zcml_data +def test_add_dependency_to_zcml_no_include_key(): + """When there is no element, inject one.""" + raw_xml = ( + '' + '' + "" + ) + result = plone.add_dependency_to_zcml("plone.restapi", raw_xml) + assert 'package="plone.restapi"' in result + + @pytest.mark.parametrize( "profile", [ @@ -150,3 +161,93 @@ def test_add_dependency_profile_to_metadata(read_data_file, profile: str): assert profile not in src_xml xml_data = func(profile, src_xml) assert f"""profile-{profile}""" in xml_data + + +def test_add_dependency_profile_no_dependencies_key(): + """When element is missing, create it.""" + raw_xml = '1' + result = plone.add_dependency_profile_to_metadata( + "plone.app.caching:default", raw_xml + ) + assert "profile-plone.app.caching:default" in result + + +def test_add_dependency_profile_empty_dependencies(): + """When exists but has no child.""" + raw_xml = ( + '' + "1" + "" + ) + result = plone.add_dependency_profile_to_metadata("plone.volto:default", raw_xml) + assert "profile-plone.volto:default" in result + + +def test_create_namespace_packages_destination_exists(package_dir): + """When destination already exists, copytree merges and removes source.""" + base = package_dir.parent + # Pre-create destination with some content + dest = base / "collective" / "mypackage" + dest.mkdir(parents=True) + (dest / "existing.txt").write_text("keep me") + plone.create_namespace_packages(package_dir, "collective.mypackage") + assert dest.is_dir() + # Original content merged + assert (dest / "__init__.py").exists() + # Pre-existing content preserved + assert (dest / "existing.txt").exists() + # Source removed + assert not package_dir.exists() + + +class TestFormatPythonCodebase: + """Tests for format_python_codebase.""" + + def test_no_pyproject_toml(self, tmp_path, caplog): + """Logs and raises FileNotFoundError when no pyproject.toml.""" + import logging + + with ( + caplog.at_level(logging.INFO, logger="cookieplone"), + pytest.raises(FileNotFoundError), + ): + plone.format_python_codebase(tmp_path) + assert "No pyproject.toml" in caplog.text + + def test_uses_ruff_formatters(self, tmp_path, monkeypatch): + """When pyproject.toml contains [tool.ruff], NEW_PY_FORMATTERS are used.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[tool.ruff]\nline-length = 88\n") + calls = [] + monkeypatch.setattr( + plone, + "NEW_PY_FORMATTERS", + (("ruff_mock", lambda p: calls.append(("ruff_mock", p))),), + ) + monkeypatch.setattr( + plone, + "OLD_PY_FORMATTERS", + (("old_mock", lambda p: calls.append(("old_mock", p))),), + ) + plone.format_python_codebase(tmp_path) + assert len(calls) == 1 + assert calls[0] == ("ruff_mock", tmp_path) + + def test_uses_old_formatters(self, tmp_path, monkeypatch): + """Without [tool.ruff], OLD_PY_FORMATTERS are used.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[tool.black]\nline-length = 88\n") + calls = [] + monkeypatch.setattr( + plone, + "NEW_PY_FORMATTERS", + (("ruff_mock", lambda p: calls.append(("ruff_mock", p))),), + ) + monkeypatch.setattr( + plone, + "OLD_PY_FORMATTERS", + (("old_mock", lambda p: calls.append(("old_mock", p))),), + ) + plone.format_python_codebase(tmp_path) + assert len(calls) == 1 + assert calls[0] == ("old_mock", tmp_path) diff --git a/tests/utils/test_repository.py b/tests/utils/test_repository.py index 0bc47c4..94f63bc 100644 --- a/tests/utils/test_repository.py +++ b/tests/utils/test_repository.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import pytest @@ -5,73 +6,281 @@ from cookieplone import _types as t from cookieplone import repository +TEMPLATE_PARAMS = [ + ( + "project", + "A Plone Project", + "Create a new Plone project with backend and frontend components", + "templates/projects/monorepo", + ), + ( + "project_classic", + "A Plone Classic Project", + "Create a new Plone Classic project", + "templates/projects/classic", + ), + ( + "backend_addon", + "Backend Add-on for Plone", + "Create a new Python package to be used with Plone", + "templates/addons/backend", + ), + ( + "frontend_addon", + "Frontend Add-on for Plone", + "Create a new Node package to be used with Volto", + "templates/addons/frontend", + ), + ( + "distribution", + "A Plone and Volto distribution", + "Create a new Distribution with Plone and Volto", + "templates/distributions/monorepo", + ), +] -@pytest.fixture(scope="session") -def project_source(resources_folder) -> Path: - path = (resources_folder / "templates_sub_folder").resolve() - return path +class TestGetRepositoryConfigLegacy: + """Tests for get_repository_config with legacy cookiecutter.json.""" -@pytest.mark.parametrize( - "template_name,title,description,path", - ( - ( - "project", - "A Plone Project", - "Create a new Plone project with backend and frontend components", - "templates/projects/monorepo", - ), - ( - "project_classic", - "A Plone Classic Project", - "Create a new Plone Classic project", - "templates/projects/classic", - ), - ( - "backend_addon", - "Backend Add-on for Plone", - "Create a new Python package to be used with Plone", - "templates/addons/backend", - ), - ( - "frontend_addon", - "Frontend Add-on for Plone", - "Create a new Node package to be used with Volto", - "templates/addons/frontend", - ), - ( - "distribution", - "A Plone and Volto distribution", - "Create a new Distribution with Plone and Volto", - "templates/distributions/monorepo", - ), - ), -) -def test_get_template_options( - project_source, template_name: str, title: str, description: str, path: str -): - func = repository.get_template_options - results = func(project_source) - assert isinstance(results, dict) - template = results[template_name] - assert isinstance(template, t.CookieploneTemplate) - assert template.title == title - assert template.description == description - assert isinstance(template.path, Path) - assert f"{template.path}" == path - - -@pytest.mark.parametrize( - "all_,total_templates", - [ - (False, 5), - (True, 6), - ], -) -def test_get_template_options_filter_hidden( - project_source, all_: bool, total_templates: int -): - func = repository.get_template_options - results = func(project_source, all_) - assert isinstance(results, dict) - assert len(results) == total_templates + @pytest.fixture(scope="class") + def project_source(self, resources_folder) -> Path: + return (resources_folder / "templates_sub_folder").resolve() + + def test_returns_dict(self, project_source): + result = repository.get_repository_config(project_source) + assert isinstance(result, dict) + + def test_has_templates(self, project_source): + result = repository.get_repository_config(project_source) + assert "templates" in result + + def test_no_config_file_raises(self, tmp_path): + with pytest.raises(RuntimeError, match="No configuration file found"): + repository.get_repository_config(tmp_path) + + +class TestGetRepositoryConfigNew: + """Tests for get_repository_config with cookieplone-config.json.""" + + @pytest.fixture(scope="class") + def project_source(self, resources_folder) -> Path: + return (resources_folder / "templates_repo_config").resolve() + + def test_returns_dict(self, project_source): + result = repository.get_repository_config(project_source) + assert isinstance(result, dict) + + def test_has_templates(self, project_source): + result = repository.get_repository_config(project_source) + assert "templates" in result + + def test_has_groups(self, project_source): + result = repository.get_repository_config(project_source) + assert "groups" in result + + def test_has_config_versions(self, project_source): + result = repository.get_repository_config(project_source) + assert result["config"]["versions"]["gha_version_checkout"] == "v6" + + def test_prefers_new_format_over_legacy(self, tmp_path): + """When both files exist, cookieplone-config.json wins.""" + legacy = {"templates": {"t": {"path": ".", "title": "T", "description": "D"}}} + new = { + "version": "1.0", + "title": "New", + "groups": { + "g": { + "title": "G", + "description": "G", + "templates": ["t"], + } + }, + "templates": {"t": {"path": ".", "title": "T New", "description": "D"}}, + } + (tmp_path / "cookiecutter.json").write_text(json.dumps(legacy)) + (tmp_path / "cookieplone-config.json").write_text(json.dumps(new)) + result = repository.get_repository_config(tmp_path) + assert result["templates"]["t"]["title"] == "T New" + + def test_invalid_config_raises(self, tmp_path): + """An invalid cookieplone-config.json raises RuntimeError.""" + bad = {"version": "1.0"} # missing title, templates + (tmp_path / "cookieplone-config.json").write_text(json.dumps(bad)) + with pytest.raises(RuntimeError, match=r"Invalid cookieplone-config\.json"): + repository.get_repository_config(tmp_path) + + +class TestGetRepositoryConfigVersions: + """Tests for config.versions extraction from cookieplone-config.json.""" + + @pytest.fixture(scope="class") + def project_source(self, resources_folder) -> Path: + return (resources_folder / "templates_repo_config").resolve() + + def test_config_versions_present(self, project_source): + result = repository.get_repository_config(project_source) + versions = result.get("config", {}).get("versions", {}) + assert versions == {"gha_version_checkout": "v6"} + + def test_legacy_config_has_no_versions(self, resources_folder): + project_source = (resources_folder / "templates_sub_folder").resolve() + result = repository.get_repository_config(project_source) + versions = result.get("config", {}).get("versions", {}) + assert versions == {} + + +class TestGetTemplateOptionsLegacy: + """Tests for get_template_options with legacy cookiecutter.json.""" + + @pytest.fixture(scope="class") + def project_source(self, resources_folder) -> Path: + return (resources_folder / "templates_sub_folder").resolve() + + @pytest.mark.parametrize("template_name,title,description,path", TEMPLATE_PARAMS) + def test_get_template_options( + self, + project_source, + template_name: str, + title: str, + description: str, + path: str, + ): + results = repository.get_template_options(project_source) + assert isinstance(results, dict) + template = results[template_name] + assert isinstance(template, t.CookieploneTemplate) + assert template.title == title + assert template.description == description + assert isinstance(template.path, Path) + assert f"{template.path}" == path + + @pytest.mark.parametrize( + "all_,total_templates", + [ + (False, 5), + (True, 6), + ], + ) + def test_filter_hidden(self, project_source, all_: bool, total_templates: int): + results = repository.get_template_options(project_source, all_) + assert isinstance(results, dict) + assert len(results) == total_templates + + +class TestGetTemplateOptionsNew: + """Tests for get_template_options with cookieplone-config.json.""" + + @pytest.fixture(scope="class") + def project_source(self, resources_folder) -> Path: + return (resources_folder / "templates_repo_config").resolve() + + @pytest.mark.parametrize("template_name,title,description,path", TEMPLATE_PARAMS) + def test_get_template_options( + self, + project_source, + template_name: str, + title: str, + description: str, + path: str, + ): + results = repository.get_template_options(project_source) + assert isinstance(results, dict) + template = results[template_name] + assert isinstance(template, t.CookieploneTemplate) + assert template.title == title + assert template.description == description + assert isinstance(template.path, Path) + assert f"{template.path}" == path + + @pytest.mark.parametrize( + "all_,total_templates", + [ + (False, 5), + (True, 6), + ], + ) + def test_filter_hidden(self, project_source, all_: bool, total_templates: int): + results = repository.get_template_options(project_source, all_) + assert isinstance(results, dict) + assert len(results) == total_templates + + +class TestGetTemplateGroupsNew: + """Tests for get_template_groups with cookieplone-config.json.""" + + @pytest.fixture(scope="class") + def project_source(self, resources_folder) -> Path: + return (resources_folder / "templates_repo_config").resolve() + + def test_returns_dict(self, project_source): + result = repository.get_template_groups(project_source) + assert isinstance(result, dict) + + def test_visible_groups_count(self, project_source): + result = repository.get_template_groups(project_source, all_=False) + assert len(result) == 3 + + def test_all_groups_count(self, project_source): + result = repository.get_template_groups(project_source, all_=True) + assert len(result) == 4 + + @pytest.mark.parametrize( + "group_id,title,num_templates", + [ + ("projects", "Projects", 2), + ("addons", "Add-ons", 2), + ("distributions", "Distributions", 1), + ], + ) + def test_visible_group_contents( + self, project_source, group_id, title, num_templates + ): + result = repository.get_template_groups(project_source, all_=False) + group = result[group_id] + assert group.title == title + assert len(group.templates) == num_templates + + def test_hidden_group_excluded_by_default(self, project_source): + result = repository.get_template_groups(project_source, all_=False) + assert "sub" not in result + + def test_hidden_group_included_with_all(self, project_source): + result = repository.get_template_groups(project_source, all_=True) + assert "sub" in result + + def test_group_templates_are_cookieplone_templates(self, project_source): + result = repository.get_template_groups(project_source) + for group in result.values(): + for tmpl in group.templates.values(): + assert isinstance(tmpl, t.CookieploneTemplate) + + +class TestGetTemplateGroupsLegacy: + """Tests for get_template_groups with legacy cookiecutter.json (no groups).""" + + @pytest.fixture(scope="class") + def project_source(self, resources_folder) -> Path: + return (resources_folder / "templates_sub_folder").resolve() + + def test_returns_none(self, project_source): + result = repository.get_template_groups(project_source) + assert result is None + + +class TestRepositoryHasConfig: + """Tests for _repository_has_config.""" + + def test_with_legacy_config(self, resources_folder): + path = resources_folder / "templates_sub_folder" + assert repository._repository_has_config(path) is True + + def test_with_new_config(self, resources_folder): + path = resources_folder / "templates_repo_config" + assert repository._repository_has_config(path) is True + + def test_empty_dir(self, tmp_path): + assert repository._repository_has_config(tmp_path) is False + + def test_nonexistent_dir(self, tmp_path): + assert repository._repository_has_config(tmp_path / "nope") is False diff --git a/tests/wizard/conftest.py b/tests/wizard/conftest.py index 503c33a..4031243 100644 --- a/tests/wizard/conftest.py +++ b/tests/wizard/conftest.py @@ -3,10 +3,18 @@ from unittest.mock import MagicMock import pytest +from tui_forms.renderer.base import BaseRenderer +from tui_forms.renderer.cookiecutter import CookiecutterRenderer from cookieplone.config.state import CookieploneState +@pytest.fixture() +def renderer_klass() -> type[BaseRenderer]: + """Use the CookiecutterRenderer for wizard tests.""" + return CookiecutterRenderer + + @pytest.fixture() def minimal_schema(): """A minimal v2 schema.""" diff --git a/tests/wizard/test_go_back_and_review.py b/tests/wizard/test_go_back_and_review.py new file mode 100644 index 0000000..fdfe037 --- /dev/null +++ b/tests/wizard/test_go_back_and_review.py @@ -0,0 +1,131 @@ +"""Tests for go-back navigation and review-retry behaviour. + +These tests exercise tui-forms' rendering fixtures to verify that: +- Going back preserves previously entered answers as defaults (issue #159). +- Computed fields are recalculated after the user changes answers + on review retry (issue #160). +""" + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +SCHEMA_WITH_COMPUTED = { + "title": "Test Form", + "description": "", + "type": "object", + "properties": { + "title": { + "type": "string", + "title": "Project Title", + "default": "My Project", + }, + "project_slug": { + "type": "string", + "title": "Project Slug", + "default": "{{ cookiecutter.title | lower | replace(' ', '-') }}", + }, + "__folder_name": { + "type": "string", + "title": "Folder Name", + "default": "{{ cookiecutter.project_slug }}", + "format": "computed", + }, + }, + "required": [], +} + +SCHEMA_WITH_STATIC = { + "title": "Test Form", + "description": "", + "type": "object", + "properties": { + "title": { + "type": "string", + "title": "Project Title", + "default": "My Project", + }, + "version": { + "type": "string", + "title": "Version", + "default": "1.0.0", + }, + "description": { + "type": "string", + "title": "Description", + "default": "A project", + }, + }, + "required": [], +} + + +# --------------------------------------------------------------------------- +# Issue #159 — go-back loses user-entered values +# --------------------------------------------------------------------------- + + +class TestGoBackPreservesAnswers: + """When the user types '<' to go back, the previous answer should be + offered as the default on re-ask, not the original schema default.""" + + def test_go_back_shows_previous_answer_as_default(self, make_form, render_form): + """After answering version='custom-version' and going back from + description, pressing Enter on version should keep 'custom-version'.""" + frm = make_form(SCHEMA_WITH_STATIC, root_key="cookiecutter") + # Flow: title, version=custom-version, go-back from description, + # accept version default (Enter), answer description + answers = render_form( + frm, + ["My Project", "custom-version", "<", "", "A project"], + ) + assert answers["cookiecutter"]["version"] == "custom-version", ( + "Go-back lost the user's previous answer (issue #159)." + ) + + def test_go_back_preserves_computed_dependent_value(self, make_form, render_form): + """When going back to a field whose default is a Jinja2 expression, + the user's custom value should still be shown on re-ask.""" + frm = make_form(SCHEMA_WITH_COMPUTED, root_key="cookiecutter") + # Flow: title, slug=custom-slug, go-back from end-of-form, + # accept slug default (Enter) + # Note: __folder_name is computed (hidden), so after project_slug + # we've answered all visible questions. We go back to re-ask slug. + answers = render_form( + frm, + ["Test One", "custom-slug", "<", ""], + ) + assert answers["cookiecutter"]["project_slug"] == "custom-slug", ( + "Go-back lost the user's custom value for a computed-default field." + ) + + +# --------------------------------------------------------------------------- +# Issue #160 — computed fields stale after review retry +# --------------------------------------------------------------------------- + + +class TestReviewRetryRecomputesFields: + """When the user declines confirmation and changes an answer that a + computed field depends on, the computed field must be recalculated.""" + + def test_computed_field_updated_after_slug_change(self, make_form, render_form): + """If user changes project_slug on review retry, __folder_name must + reflect the new slug, not the old one.""" + frm = make_form(SCHEMA_WITH_COMPUTED, root_key="cookiecutter") + # Flow: + # Pass 1: title, slug-1, decline review ("n") + # Pass 2: title, slug-2, accept review ("y") + answers = render_form( + frm, + ["My Project", "slug-1", "n", "My Project", "slug-2", "y"], + confirm=True, + ) + slug = answers["cookiecutter"]["project_slug"] + folder_name = answers["cookiecutter"]["__folder_name"] + assert slug == "slug-2", f"Expected project_slug='slug-2' but got '{slug}'" + assert folder_name == "slug-2", ( + f"Expected __folder_name='slug-2' but got '{folder_name}'. " + "Computed field was not recalculated after review retry (issue #160)." + ) diff --git a/uv.lock b/uv.lock index bc90fad..ff5ba5a 100644 --- a/uv.lock +++ b/uv.lock @@ -462,6 +462,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "towncrier" }, + { name = "tui-forms", extra = ["test"] }, { name = "zest-releaser", extra = ["recommended"] }, { name = "zestreleaser-towncrier" }, ] @@ -496,7 +497,7 @@ requires-dist = [ { name = "gitpython", specifier = "==3.1.46" }, { name = "packaging", specifier = "==26.0" }, { name = "semver", specifier = "==3.0.4" }, - { name = "tui-forms", specifier = ">=1.0.0a2" }, + { name = "tui-forms", specifier = ">=1.0.0a4" }, { name = "typer", specifier = "==0.24.1" }, { name = "xmltodict", specifier = "==1.0.4" }, ] @@ -509,6 +510,7 @@ dev = [ { name = "pytest", specifier = "==8.1.1" }, { name = "pytest-cov", specifier = "==5.0.0" }, { name = "towncrier", specifier = ">=23.11.0" }, + { name = "tui-forms", extras = ["test"], specifier = ">=1.0.0a4" }, { name = "zest-releaser", extras = ["recommended"], specifier = ">=9.1.3" }, { name = "zestreleaser-towncrier", specifier = ">=1.3.0" }, ] @@ -2316,15 +2318,20 @@ wheels = [ [[package]] name = "tui-forms" -version = "1.0.0a2" +version = "1.0.0a4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "jsonschema" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/16/e205eeede7028fadf0d76beb774a7e37563420fcdcdaac18eef043608c58/tui_forms-1.0.0a2.tar.gz", hash = "sha256:478e4a26ba2da6db97f04e085e337256c175e6880b18f0b514fdf2e704410bb0", size = 27823, upload-time = "2026-03-30T17:42:16.136Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/43/8a95ae4b08d4109702df4cb29d51de6c623625ffb73fdbc93e0bdf693047/tui_forms-1.0.0a4.tar.gz", hash = "sha256:8c7cb0ceb5c60a3f83e693088be25de37f27cd930270f6a6a08936b7f302b0c8", size = 30320, upload-time = "2026-04-02T14:15:24.03Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/69/41e050aaf8a58475410c754d783a0640fd375c635a703226709b7a1b8b49/tui_forms-1.0.0a2-py3-none-any.whl", hash = "sha256:f0f203a9cc0eaa3f5246f2f1fda1939b246aa9369618a666011cd54e139931c5", size = 37248, upload-time = "2026-03-30T17:42:13.847Z" }, + { url = "https://files.pythonhosted.org/packages/40/b5/c7dc9108d172467d4f1f9590994a627a7b90f36f34485fb9d87e448951aa/tui_forms-1.0.0a4-py3-none-any.whl", hash = "sha256:a8274fbfd754fb18ba9d149a2f881447986aa4bc70ab558e7ef131b94ebbe7df", size = 41049, upload-time = "2026-04-02T14:15:25.369Z" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, ] [[package]]