From c0119ec4338c0208973c4dac19f26be768eb88e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Wed, 1 Apr 2026 17:44:18 -0300 Subject: [PATCH 1/6] Implement cookieplone-config.json repository configuration format (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new repository-level configuration format with JSON Schema validation, grouped templates, global version pinning, and backward-compatible fallback to cookiecutter.json. - Refactor schemas.py into a schemas/ package with standalone JSON schema files (reusable for future pytest-jsonschema package) - Add repository_config.schema.json with structural validation - Add cross-referential validation (groups ↔ templates consistency) - Update get_repository_config() to prefer new format with validation - Add documentation for the new format - Add comprehensive test coverage (repository schema + loading logic) Closes #141 --- cookieplone/config/schemas.py | 109 -------- cookieplone/config/schemas/__init__.py | 34 +++ cookieplone/config/schemas/_types.py | 50 ++++ .../schemas/cookieplone_config.schema.json | 74 +++++ cookieplone/config/schemas/repository.py | 72 +++++ .../schemas/repository_config.schema.json | 55 ++++ cookieplone/config/schemas/template.py | 24 ++ cookieplone/repository.py | 45 ++- docs/src/reference/index.md | 1 + docs/src/reference/repository-config.md | 255 +++++++++++++++++ news/141.feature | 1 + .../_resources/config/cookieplone-config.json | 216 +++++++++++++++ .../cookieplone-config.json | 74 +++++ .../addons/backend_addon/cookiecutter.json | 26 ++ .../README.md | 6 + .../addons/frontend_addon/cookiecutter.json | 26 ++ .../README.md | 6 + .../distributions/monorepo/cookiecutter.json | 26 ++ .../README.md | 6 + .../projects/classic/cookiecutter.json | 26 ++ .../README.md | 6 + .../projects/monorepo/cookiecutter.json | 26 ++ .../README.md | 6 + .../templates/sub/cache/cookiecutter.json | 26 ++ .../README.md | 6 + tests/config/test_schemas_repository.py | 223 +++++++++++++++ ...st_schemas.py => test_schemas_template.py} | 2 +- tests/utils/test_repository.py | 262 +++++++++++++----- 28 files changed, 1506 insertions(+), 183 deletions(-) delete mode 100644 cookieplone/config/schemas.py create mode 100644 cookieplone/config/schemas/__init__.py create mode 100644 cookieplone/config/schemas/_types.py create mode 100644 cookieplone/config/schemas/cookieplone_config.schema.json create mode 100644 cookieplone/config/schemas/repository.py create mode 100644 cookieplone/config/schemas/repository_config.schema.json create mode 100644 cookieplone/config/schemas/template.py create mode 100644 docs/src/reference/repository-config.md create mode 100644 news/141.feature create mode 100644 tests/_resources/config/cookieplone-config.json create mode 100644 tests/_resources/templates_repo_config/cookieplone-config.json create mode 100644 tests/_resources/templates_repo_config/templates/addons/backend_addon/cookiecutter.json create mode 100644 tests/_resources/templates_repo_config/templates/addons/backend_addon/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/_resources/templates_repo_config/templates/addons/frontend_addon/cookiecutter.json create mode 100644 tests/_resources/templates_repo_config/templates/addons/frontend_addon/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/_resources/templates_repo_config/templates/distributions/monorepo/cookiecutter.json create mode 100644 tests/_resources/templates_repo_config/templates/distributions/monorepo/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/_resources/templates_repo_config/templates/projects/classic/cookiecutter.json create mode 100644 tests/_resources/templates_repo_config/templates/projects/classic/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/_resources/templates_repo_config/templates/projects/monorepo/cookiecutter.json create mode 100644 tests/_resources/templates_repo_config/templates/projects/monorepo/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/_resources/templates_repo_config/templates/sub/cache/cookiecutter.json create mode 100644 tests/_resources/templates_repo_config/templates/sub/cache/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/config/test_schemas_repository.py rename tests/config/{test_schemas.py => test_schemas_template.py} (98%) 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/repository.py b/cookieplone/repository.py index d81bf01..42d81c4 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( 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/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/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/utils/test_repository.py b/tests/utils/test_repository.py index 0bc47c4..bd0dbd4 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,200 @@ 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 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 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 From ad820a9bcc01c70704aa9e369d6801c97be22e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Wed, 1 Apr 2026 18:45:59 -0300 Subject: [PATCH 2/6] Support global version pinning from repository config (#128) Read `config.versions` from the repository-level `cookieplone-config.json` and merge it as a base layer into each template's versions context. Per-template versions override global values for the same key, so templates can still customise individual pins while inheriting shared defaults via `{{ versions. }}`. Closes #128 --- cookieplone/_types.py | 1 + cookieplone/config/state.py | 36 ++++++++++++-- cookieplone/generator/__init__.py | 1 + cookieplone/repository.py | 10 ++++ news/128.feature | 1 + tests/config/test_state.py | 24 --------- tests/config/test_state_versions.py | 76 +++++++++++++++++++++++++++++ tests/utils/test_repository.py | 19 ++++++++ 8 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 news/128.feature create mode 100644 tests/config/test_state_versions.py diff --git a/cookieplone/_types.py b/cookieplone/_types.py index 7c2db17..15af7e7 100644 --- a/cookieplone/_types.py +++ b/cookieplone/_types.py @@ -32,6 +32,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/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 42d81c4..c13e0b4 100644 --- a/cookieplone/repository.py +++ b/cookieplone/repository.py @@ -366,6 +366,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 @@ -384,5 +393,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/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/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_repository.py b/tests/utils/test_repository.py index bd0dbd4..d119859 100644 --- a/tests/utils/test_repository.py +++ b/tests/utils/test_repository.py @@ -111,6 +111,25 @@ def test_invalid_config_raises(self, tmp_path): 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.""" From e688613f431d2903e42d45194223e1d9e25206ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Wed, 1 Apr 2026 22:54:35 -0300 Subject: [PATCH 3/6] Support grouped template selection in CLI When cookieplone-config.json defines groups, the CLI presents a two-step selection: first a category, then a template within that category. Legacy repos without groups fall back to the flat template list. The display uses styled numbered lists inside labeled panels instead of tables. Also updates the default repository tag from main to next and documents the change in the relevant docs pages. Closes #118 --- cookieplone/_types.py | 11 +++ cookieplone/cli/__init__.py | 33 +++++++-- cookieplone/repository.py | 60 ++++++++++++++++ cookieplone/settings.py | 1 + cookieplone/utils/console.py | 78 +++++++++++++++------ docs/src/concepts/how-cookieplone-works.md | 8 ++- docs/src/reference/environment-variables.md | 2 +- news/118.feature | 1 + tests/utils/test_repository.py | 62 ++++++++++++++++ 9 files changed, 227 insertions(+), 29 deletions(-) create mode 100644 news/118.feature diff --git a/cookieplone/_types.py b/cookieplone/_types.py index 15af7e7..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. 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/repository.py b/cookieplone/repository.py index c13e0b4..52e2478 100644 --- a/cookieplone/repository.py +++ b/cookieplone/repository.py @@ -152,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. 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/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/tests/utils/test_repository.py b/tests/utils/test_repository.py index d119859..94f63bc 100644 --- a/tests/utils/test_repository.py +++ b/tests/utils/test_repository.py @@ -206,6 +206,68 @@ def test_filter_hidden(self, project_source, all_: bool, total_templates: int): 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.""" From 2513b9c48fb92d7731580fdc085c1a75acd63eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Wed, 1 Apr 2026 23:05:07 -0300 Subject: [PATCH 4/6] Increase test coverage for utils modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - console.py: 52% → 99% - cookiecutter.py: 67% → 100% - parsers.py: 50% → 100% - plone.py: 70% → 97% --- tests/utils/test_console.py | 274 +++++++++++++++++++++++++++---- tests/utils/test_cookiecutter.py | 118 ++++++++++++- tests/utils/test_parsers.py | 25 +++ tests/utils/test_plone.py | 101 ++++++++++++ 4 files changed, 485 insertions(+), 33 deletions(-) create mode 100644 tests/utils/test_parsers.py 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) From 78358f041733d27de9a013f7ededbd69c44b19d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 2 Apr 2026 08:57:38 -0300 Subject: [PATCH 5/6] Add regression tests for go-back and review-retry bugs Add xfail tests that reproduce issues #159 (go-back loses user-entered values) and #160 (computed fields stale after review retry). Both bugs are in tui-forms' BaseRenderer; the xfail markers will be removed once the upstream fixes land. --- tests/wizard/test_go_back_and_review.py | 225 ++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 tests/wizard/test_go_back_and_review.py 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..cc135eb --- /dev/null +++ b/tests/wizard/test_go_back_and_review.py @@ -0,0 +1,225 @@ +"""Tests for go-back navigation and review-retry behaviour. + +These tests exercise tui-forms' BaseRenderer directly 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). +""" + +import contextlib +from unittest.mock import patch + +import pytest +from tui_forms import create_form, get_renderer +from tui_forms.renderer.base import _GoBackRequest + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +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.""" + + @pytest.mark.xfail( + reason="tui-forms bug: unrecord() loses user answer (issue #159)" + ) + def test_go_back_shows_previous_answer_as_default(self): + """After answering q2='custom-version' and going back from q3, + q2 should default to 'custom-version', not the schema default.""" + frm = create_form(SCHEMA_WITH_STATIC, root_key="cookiecutter") + renderer = get_renderer("cookiecutter")(frm) + + # Flow: answer q1, answer q2='custom-version', on q3 go back, + # q2 is re-asked — capture its default, then finish. + call_count = 0 + observed_defaults = [] + + original_dispatch = renderer._dispatch.__func__ + + def patched_dispatch(self, question): + nonlocal call_count + call_count += 1 + # q1 (title): accept default + if question.key == "title" and call_count == 1: + return "My Project" + # q2 (version) first time: answer 'custom-version' + if question.key == "version" and call_count == 2: + return "custom-version" + # q3 (description): go back to re-ask q2 + if question.key == "description" and call_count == 3: + raise _GoBackRequest() + # q2 (version) second time: capture default + if question.key == "version" and call_count == 4: + default = question.default_value( + self._env, self._form.answers, self._form.root_key + ) + observed_defaults.append(default) + return default + # q3 (description) second time: accept default + if question.key == "description" and call_count == 5: + return "A project" + return original_dispatch(self, question) + + with patch.object(type(renderer), "_dispatch", patched_dispatch): + renderer.render(confirm=False) + + # The key assertion: when q2 is re-asked after go-back, + # the default should be the user's previous answer, not the schema default + assert len(observed_defaults) == 1 + assert observed_defaults[0] == "custom-version", ( + f"Expected 'custom-version' but got '{observed_defaults[0]}'. " + "Go-back lost the user's previous answer (issue #159)." + ) + + def test_go_back_preserves_computed_dependent_value(self): + """When going back to a field whose default is a Jinja2 expression + depending on an earlier answer, the user's custom value should still + be shown, not the re-rendered default.""" + frm = create_form(SCHEMA_WITH_COMPUTED, root_key="cookiecutter") + renderer = get_renderer("cookiecutter")(frm) + + call_count = 0 + observed_defaults = [] + + def patched_dispatch(self, question): + nonlocal call_count + call_count += 1 + # q1 (title): answer 'Test One' + if question.key == "title" and call_count == 1: + return "Test One" + # q2 (project_slug) first time: user enters 'custom-slug' + if question.key == "project_slug" and call_count == 2: + return "custom-slug" + # Simulate: we are now past all questions, but let's go back to q2 + # q2 on re-ask: capture default + if question.key == "project_slug" and call_count == 4: + default = question.default_value( + self._env, self._form.answers, self._form.root_key + ) + observed_defaults.append(default) + return default + # Trigger go-back at call_count == 3 + if call_count == 3: + raise _GoBackRequest() + raise _GoBackRequest() + + with ( + patch.object(type(renderer), "_dispatch", patched_dispatch), + contextlib.suppress(_GoBackRequest), + ): + renderer.render(confirm=False) + + # The user entered 'custom-slug', not the Jinja-rendered 'test-one' + if observed_defaults: + assert observed_defaults[0] == "custom-slug", ( + f"Expected 'custom-slug' but got '{observed_defaults[0]}'. " + "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.""" + + @pytest.mark.xfail( + reason="tui-forms bug: stale computed fields on retry (issue #160)" + ) + def test_computed_field_updated_after_slug_change(self): + """If user changes project_slug on review retry, __folder_name must + reflect the new slug, not the old one.""" + frm = create_form(SCHEMA_WITH_COMPUTED, root_key="cookiecutter") + renderer = get_renderer("cookiecutter")(frm) + + render_pass = 0 + + def fake_ask_string(self, question, default, prefix): + if question.key == "title": + return "My Project" + if question.key == "project_slug": + # Pass 1 (render_pass==0): user enters 'slug-1' + # Pass 2 (render_pass==1): user changes to 'slug-2' + if render_pass == 0: + return "slug-1" + return "slug-2" + return str(default) if default else "" + + def fake_summary(self, user_answers): + nonlocal render_pass + render_pass += 1 + # First review: reject to trigger retry; second: accept + return render_pass != 1 + + with ( + patch.object(type(renderer), "_ask_string", fake_ask_string), + patch.object(type(renderer), "render_summary", fake_summary), + ): + answers = renderer.render(confirm=True) + + slug = answers.get("cookiecutter", {}).get("project_slug") + folder_name = answers.get("cookiecutter", {}).get("__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)." + ) From 7f62b82fd47631f6ad8f60097cdf279f554708af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 2 Apr 2026 12:11:37 -0300 Subject: [PATCH 6/6] Upgrade tui-forms to version 1.0.0a4. Closes #159 Closes #160 Closes #161 --- pyproject.toml | 5 +- tests/wizard/conftest.py | 8 ++ tests/wizard/test_go_back_and_review.py | 174 ++++++------------------ uv.lock | 15 +- 4 files changed, 63 insertions(+), 139 deletions(-) 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/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 index cc135eb..fdfe037 100644 --- a/tests/wizard/test_go_back_and_review.py +++ b/tests/wizard/test_go_back_and_review.py @@ -1,20 +1,14 @@ """Tests for go-back navigation and review-retry behaviour. -These tests exercise tui-forms' BaseRenderer directly to verify that: +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). """ -import contextlib -from unittest.mock import patch - -import pytest -from tui_forms import create_form, get_renderer -from tui_forms.renderer.base import _GoBackRequest # --------------------------------------------------------------------------- -# Helpers +# Schemas # --------------------------------------------------------------------------- SCHEMA_WITH_COMPUTED = { @@ -76,101 +70,35 @@ 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.""" - @pytest.mark.xfail( - reason="tui-forms bug: unrecord() loses user answer (issue #159)" - ) - def test_go_back_shows_previous_answer_as_default(self): - """After answering q2='custom-version' and going back from q3, - q2 should default to 'custom-version', not the schema default.""" - frm = create_form(SCHEMA_WITH_STATIC, root_key="cookiecutter") - renderer = get_renderer("cookiecutter")(frm) - - # Flow: answer q1, answer q2='custom-version', on q3 go back, - # q2 is re-asked — capture its default, then finish. - call_count = 0 - observed_defaults = [] - - original_dispatch = renderer._dispatch.__func__ - - def patched_dispatch(self, question): - nonlocal call_count - call_count += 1 - # q1 (title): accept default - if question.key == "title" and call_count == 1: - return "My Project" - # q2 (version) first time: answer 'custom-version' - if question.key == "version" and call_count == 2: - return "custom-version" - # q3 (description): go back to re-ask q2 - if question.key == "description" and call_count == 3: - raise _GoBackRequest() - # q2 (version) second time: capture default - if question.key == "version" and call_count == 4: - default = question.default_value( - self._env, self._form.answers, self._form.root_key - ) - observed_defaults.append(default) - return default - # q3 (description) second time: accept default - if question.key == "description" and call_count == 5: - return "A project" - return original_dispatch(self, question) - - with patch.object(type(renderer), "_dispatch", patched_dispatch): - renderer.render(confirm=False) - - # The key assertion: when q2 is re-asked after go-back, - # the default should be the user's previous answer, not the schema default - assert len(observed_defaults) == 1 - assert observed_defaults[0] == "custom-version", ( - f"Expected 'custom-version' but got '{observed_defaults[0]}'. " + 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): - """When going back to a field whose default is a Jinja2 expression - depending on an earlier answer, the user's custom value should still - be shown, not the re-rendered default.""" - frm = create_form(SCHEMA_WITH_COMPUTED, root_key="cookiecutter") - renderer = get_renderer("cookiecutter")(frm) - - call_count = 0 - observed_defaults = [] - - def patched_dispatch(self, question): - nonlocal call_count - call_count += 1 - # q1 (title): answer 'Test One' - if question.key == "title" and call_count == 1: - return "Test One" - # q2 (project_slug) first time: user enters 'custom-slug' - if question.key == "project_slug" and call_count == 2: - return "custom-slug" - # Simulate: we are now past all questions, but let's go back to q2 - # q2 on re-ask: capture default - if question.key == "project_slug" and call_count == 4: - default = question.default_value( - self._env, self._form.answers, self._form.root_key - ) - observed_defaults.append(default) - return default - # Trigger go-back at call_count == 3 - if call_count == 3: - raise _GoBackRequest() - raise _GoBackRequest() - - with ( - patch.object(type(renderer), "_dispatch", patched_dispatch), - contextlib.suppress(_GoBackRequest), - ): - renderer.render(confirm=False) - - # The user entered 'custom-slug', not the Jinja-rendered 'test-one' - if observed_defaults: - assert observed_defaults[0] == "custom-slug", ( - f"Expected 'custom-slug' but got '{observed_defaults[0]}'. " - "Go-back lost the user's custom value for a computed-default field." - ) + 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." + ) # --------------------------------------------------------------------------- @@ -182,42 +110,20 @@ class TestReviewRetryRecomputesFields: """When the user declines confirmation and changes an answer that a computed field depends on, the computed field must be recalculated.""" - @pytest.mark.xfail( - reason="tui-forms bug: stale computed fields on retry (issue #160)" - ) - def test_computed_field_updated_after_slug_change(self): + 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 = create_form(SCHEMA_WITH_COMPUTED, root_key="cookiecutter") - renderer = get_renderer("cookiecutter")(frm) - - render_pass = 0 - - def fake_ask_string(self, question, default, prefix): - if question.key == "title": - return "My Project" - if question.key == "project_slug": - # Pass 1 (render_pass==0): user enters 'slug-1' - # Pass 2 (render_pass==1): user changes to 'slug-2' - if render_pass == 0: - return "slug-1" - return "slug-2" - return str(default) if default else "" - - def fake_summary(self, user_answers): - nonlocal render_pass - render_pass += 1 - # First review: reject to trigger retry; second: accept - return render_pass != 1 - - with ( - patch.object(type(renderer), "_ask_string", fake_ask_string), - patch.object(type(renderer), "render_summary", fake_summary), - ): - answers = renderer.render(confirm=True) - - slug = answers.get("cookiecutter", {}).get("project_slug") - folder_name = answers.get("cookiecutter", {}).get("__folder_name") + 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}'. " 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]]