diff --git a/cookieplone/config/schemas.py b/cookieplone/config/schemas.py new file mode 100644 index 0000000..8c54561 --- /dev/null +++ b/cookieplone/config/schemas.py @@ -0,0 +1,109 @@ +"""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/state.py b/cookieplone/config/state.py index 34bd956..a53f12f 100644 --- a/cookieplone/config/state.py +++ b/cookieplone/config/state.py @@ -5,8 +5,9 @@ from cookiecutter import exceptions as exc +from cookieplone.config.schemas import SubTemplate from cookieplone.config.v1 import parse_v1 -from cookieplone.config.v2 import parse_v2 +from cookieplone.config.v2 import ParsedConfig, parse_v2 from cookieplone.logger import logger from cookieplone.settings import DEFAULT_DATA_KEY, DEFAULT_VALIDATORS from cookieplone.utils import files as f @@ -57,16 +58,30 @@ class CookieploneState: """All state needed to drive a single Cookieplone template generation run. :param schema: Parsed schema dict (v1 or v2) describing the template's variables. - :param data: Runtime context keyed by ``root_key`` (usually ``"cookiecutter"``). - This dict is mutated during generation as wizard answers are collected. + :param data: Runtime context passed to cookiecutter's ``generate_files``. + Contains ``root_key`` (usually ``"cookiecutter"``) with template variables + and ``"versions"`` with the version pinning dict. Mutated during + generation as wizard answers and internal keys are injected. :param root_key: The top-level key under which template variables are stored. Defaults to :data:`~cookieplone.settings.DEFAULT_DATA_KEY`. :param context: The three override sources (user config, extra, replay) captured at initialisation time for later introspection. :param answers: Wizard output — both the full rendered answers and the subset supplied by the user. Populated after the wizard completes. - :param extensions: Jinja2 extension class paths extracted from the schema's - ``_extensions`` property. + :param extensions: Jinja2 extension class paths extracted from the config's + ``extensions`` list. + :param no_render: Glob patterns for files that should be copied without Jinja + rendering, extracted from the config's ``no_render`` list. + :param subtemplates: Sub-template definitions extracted from the + config's ``subtemplates`` list. Each entry is a + :class:`~cookieplone.config.schemas.SubTemplate` with ``id``, + ``title``, and ``enabled`` keys. The ``enabled`` value can be a + static string (``"1"``/``"0"``) or a Jinja2 expression rendered + against the current context during generation. + :param template_id: Template identifier from the config's top-level ``id`` field. + :param versions: Version pinning dict from the config's ``versions`` mapping. + Injected into ``data["versions"]`` so templates can access values via + ``{{ versions. }}``. """ schema: dict[str, Any] @@ -75,24 +90,24 @@ class CookieploneState: context: Context = field(default_factory=Context) answers: Answers = field(default_factory=Answers) extensions: list[str] = field(default_factory=list) + no_render: list[str] = field(default_factory=list) + subtemplates: list[SubTemplate] = field(default_factory=list) + template_id: str = "" + versions: dict[str, str] = field(default_factory=dict) -def _parse_schema(context: dict[str, Any], version: str = "1.0") -> dict[str, Any]: - """Parse the raw schema from the context.""" - if version == "1.0": - context = parse_v1(context) - elif version == "2.0": - context = parse_v2(context) +def _parse_schema(context: dict[str, Any], version: str = "1.0") -> ParsedConfig: + """Parse the raw schema from the context and return a :class:`ParsedConfig`.""" + parsed = parse_v1(context) if version == "1.0" else parse_v2(context) + schema = parsed.schema # All questions will be under `properties` for key, val_func in DEFAULT_VALIDATORS.items(): - if not (question := context["properties"].get(key)) or question.get( - "validator" - ): + if not (question := schema["properties"].get(key)) or question.get("validator"): continue logger.debug(f"Setting {val_func} for question {key}") question["validator"] = val_func - return context + return parsed def _filter_initial_answers( @@ -200,20 +215,13 @@ def _apply_overwrites_to_schema( property_["default"] = overwrite -def _get_extensions_from_schema(schema: dict[str, Any]) -> list[str]: - """Extract Jinja extensions from the schema.""" - properties: dict[str, Any] = schema.get("properties", {}) - extensions_property: dict[str, Any] = properties.get("_extensions", {}) - return extensions_property.get("default", []) - - def _generate_state( - schema: dict[str, Any], + parsed: ParsedConfig, default_context: dict[str, Any] | None = None, extra_context: dict[str, Any] | None = None, replay_context: dict[str, Any] | None = None, ) -> CookieploneState: - """Build a :class:`CookieploneState` from a parsed schema and optional + """Build a :class:`CookieploneState` from a parsed config and optional context overrides. When *replay_context* is provided the schema defaults are ignored and the @@ -221,14 +229,14 @@ def _generate_state( *default_context* and *extra_context* are applied in order to overwrite schema defaults before the wizard runs. - :param schema: Parsed v2 schema dict (``{"version": "2.0", "properties": ...}``). + :param parsed: A :class:`ParsedConfig` containing the schema and config fields. :param default_context: Values from the user-level config file. :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. :returns: A fully initialised :class:`CookieploneState`. """ - extensions = _get_extensions_from_schema(schema) + schema = parsed.schema context = Context( default=default_context if default_context else {}, extra=extra_context if extra_context else {}, @@ -259,11 +267,20 @@ def _generate_state( if variable in schema.get("properties", {}): schema["properties"][variable]["default"] = value + state_data = { + DEFAULT_DATA_KEY: data, + "versions": parsed.versions, + } + state: CookieploneState = CookieploneState( schema=schema, - data={DEFAULT_DATA_KEY: data}, + data=state_data, context=context, - extensions=extensions, + extensions=parsed.extensions, + no_render=parsed.no_render, + subtemplates=parsed.subtemplates, + template_id=parsed.template_id, + versions=parsed.versions, answers=answers, ) @@ -291,22 +308,22 @@ def generate_state( *template_path*. :raises exc.ContextDecodingException: If the schema file contains invalid JSON. """ - if (schema := load_schema_from_path(template_path)) is None: + if (parsed := load_schema_from_path(template_path)) is None: raise exc.ConfigDoesNotExistException( f"No configuration file found in {template_path}. " "Please ensure a 'cookieplone.json' or 'cookiecutter.json' file exists." ) - return _generate_state(schema, default_context, extra_context, replay_context) + return _generate_state(parsed, default_context, extra_context, replay_context) -def load_schema_from_path(template_path: Path) -> dict | None: +def load_schema_from_path(template_path: Path) -> ParsedConfig | None: """Load and parse the schema from the filesystem. Tries ``cookieplone.json`` (v2) then ``cookiecutter.json`` (v1) under *template_path*. Returns ``None`` if neither file exists. :param template_path: Directory to search for a schema file. - :returns: Parsed schema dict, or ``None`` if no schema file was found. + :returns: A :class:`ParsedConfig`, or ``None`` if no schema file was found. :raises exc.ContextDecodingException: If the file exists but contains invalid JSON. """ diff --git a/cookieplone/config/v1.py b/cookieplone/config/v1.py index 9b86fd9..fc6fb3d 100644 --- a/cookieplone/config/v1.py +++ b/cookieplone/config/v1.py @@ -25,10 +25,20 @@ from typing import Any +from cookieplone.config.v2 import ParsedConfig from cookieplone.utils.config import convert_v1_to_v2 -def parse_v1(context: dict[str, Any]) -> dict[str, Any]: +def parse_v1(context: dict[str, Any]) -> ParsedConfig: """Parse configuration from the old format used in cookiecutter.json files.""" schema: dict[str, Any] = context.get("cookiecutter", context) - return convert_v1_to_v2(schema) + converted = convert_v1_to_v2(schema) + config = converted.get("config", {}) + return ParsedConfig( + schema=converted["schema"], + extensions=config.get("extensions", []), + no_render=config.get("no_render", []), + versions=config.get("versions", {}), + subtemplates=config.get("subtemplates", []), + template_id=converted.get("id", ""), + ) diff --git a/cookieplone/config/v2.py b/cookieplone/config/v2.py index 05d6d50..c454d6e 100644 --- a/cookieplone/config/v2.py +++ b/cookieplone/config/v2.py @@ -1,48 +1,70 @@ -"""Format used by cookieplone.json files in cookieplone templates. - -```json -{ - "title": "Form title", - "description": "Form description", - "version": "2.0", - "properties": { - "field_name": { - "type": "string", - "title": "Question to ask the user", - "description": "Description of the question", - "validator": "path.to.validator_function", - "default": "default value" - }, - "number": { - "type": "integer", - "title": "Enter a number", - "validator": "path.to.number_validator_function", - "default": 0 - }, - "other_field_name": { - "type": "string", - "oneOf": [ - {"const": "option1", "title": "Option 1 description"}, - {"const": "option2", "title": "Option 2 description"} - ], - "title": "Choose an option", - "validator": "path.to.another_validator_function", - "default": "option1" +"""Parser for the cookieplone.json v2 configuration format. + +The v2 format separates form schema from generator configuration:: + + { + "id": "project", + "schema": { + "title": "Cookieplone Project", + "description": "", + "version": "2.0", + "properties": { ... } }, - "a_computed_field": { - "type": "string", - "format": "computed", - "title": "Choose an option", - "default": "{{ cookiecutter.field_name }}_computed" + "config": { + "extensions": [...], + "no_render": [...], + "versions": { ... }, + "subtemplates": [ + {"id": "...", "title": "...", "enabled": "..."} + ] } } -} -``` """ +from dataclasses import dataclass, field from typing import Any +from cookieplone.config.schemas import SubTemplate + + +@dataclass +class ParsedConfig: + """Result of parsing a v2 configuration file. + + :param schema: The form schema dict (``title``, ``description``, + ``version``, ``properties``). + :param extensions: Jinja2 extension class paths. + :param no_render: Glob patterns for files copied without rendering. + :param versions: Version pinning mapping (e.g. ``{"gha_checkout": "v6"}``). + :param subtemplates: Sub-template definitions as + :class:`~cookieplone.config.schemas.SubTemplate` dicts with + ``id``, ``title``, and ``enabled`` keys. + :param template_id: The template identifier from the top-level ``id`` field. + """ + + schema: dict[str, Any] = field(default_factory=dict) + extensions: list[str] = field(default_factory=list) + no_render: list[str] = field(default_factory=list) + versions: dict[str, str] = field(default_factory=dict) + subtemplates: list[SubTemplate] = field(default_factory=list) + template_id: str = "" + + +def parse_v2(context: dict[str, Any]) -> ParsedConfig: + """Parse a v2 configuration dict into schema and config components. + + :param context: The raw v2 configuration dict. + :returns: A :class:`ParsedConfig` with the schema and config fields separated. + """ + config = context.get("config", {}) + schema = context.get("schema", context) + template_id = context.get("id", "") -def parse_v2(context: dict[str, Any]) -> dict[str, Any]: - """Return the configuration.""" - return context + return ParsedConfig( + schema=schema, + extensions=config.get("extensions", []), + no_render=config.get("no_render", []), + versions=config.get("versions", {}), + subtemplates=config.get("subtemplates", []), + template_id=template_id, + ) diff --git a/cookieplone/generator/main.py b/cookieplone/generator/main.py index 5c962b4..06df912 100644 --- a/cookieplone/generator/main.py +++ b/cookieplone/generator/main.py @@ -6,18 +6,25 @@ from cookiecutter import exceptions as exc from cookiecutter.generate import generate_files +from jinja2.exceptions import UndefinedError from cookieplone._types import RepositoryInfo, RunConfig from cookieplone.config import CookieploneState from cookieplone.exceptions import GeneratorException, OutputDirExistsException from cookieplone.utils import files as f from cookieplone.utils.answers import remove_internal_keys -from cookieplone.utils.cookiecutter import import_patch, parse_output_dir_exception +from cookieplone.utils.cookiecutter import ( + import_patch, + parse_output_dir_exception, + parse_undefined_error, +) +from cookieplone.utils.subtemplates import process_subtemplates from cookieplone.wizard import wizard def _annotate_data( data: dict[str, Any], + state: CookieploneState, run_config: RunConfig, repository_info: RepositoryInfo, ) -> None: @@ -25,10 +32,15 @@ def _annotate_data( These ``_`` and ``__`` prefixed keys are read by cookiecutter's hook runner and file renderer to locate the template, resolve paths, and - record provenance. They are written directly into *data* in-place. + record provenance. Config keys from the parsed schema (extensions, + no-render patterns, subtemplates, versions) are also injected so that + cookiecutter and post-generation hooks can consume them. + + All keys are written directly into *data* in-place. :param data: The mutable context dict for the current template key (e.g. ``state.data["cookiecutter"]``). + :param state: Current run state containing parsed config fields. :param run_config: Runtime options providing the output directory. :param repository_info: Resolved repository paths and metadata. """ @@ -45,6 +57,13 @@ def _annotate_data( # include checkout details in the context dict data["_checkout"] = repository_info.checkout + # Inject config keys from the parsed schema so cookiecutter, + # its Jinja environment, and post-generation hooks can read them. + data["_extensions"] = state.extensions or [] + data["_copy_without_render"] = state.no_render or [] + data["__cookieplone_template"] = state.template_id + data["__cookieplone_subtemplates"] = process_subtemplates(state, data) + def _cookieplone( state: CookieploneState, @@ -73,7 +92,8 @@ def _cookieplone( ) internal_data.update(wizard_answers.answers) - _annotate_data(internal_data, run_config, repository_info) + _annotate_data(internal_data, state, run_config, repository_info) + # Create project from local context and project template. with import_patch(repository_info.repo_dir): result = generate_files( @@ -107,6 +127,9 @@ def cookieplone( :returns: Path to the generated project directory. :raises GeneratorException: For any cookiecutter-level failure, wrapping the original exception and preserving the run state. + :raises OutputDirExistsException: When the target output directory + already exists (either raised directly or unwrapped from a + :exc:`~cookiecutter.exceptions.UndefinedVariableInTemplate`). """ try: @@ -130,7 +153,8 @@ def cookieplone( message=msg, state=state, original=exc_info ) from exc_info except exc.UndefinedVariableInTemplate as exc_info: - msg = f"Undefined variable in template: {exc_info.message}" + src_message = exc_info.message + msg = f"Undefined variable in template: {src_message}" # Sometimes the exception is wrapped in a cookiecutter exception with # the original error as an attribute, so we check for that to preserve # the original error message and type. @@ -140,6 +164,8 @@ def cookieplone( raise OutputDirExistsException( message=msg, state=state, original=exc_info ) from exc_info + elif isinstance(exc_info, UndefinedError): + msg = parse_undefined_error(exc_info, src_message) raise GeneratorException( message=msg, state=state, original=exc_info ) from exc_info diff --git a/cookieplone/templates/fixtures.py b/cookieplone/templates/fixtures.py index ae128e4..233c893 100644 --- a/cookieplone/templates/fixtures.py +++ b/cookieplone/templates/fixtures.py @@ -15,6 +15,8 @@ "_copy_without_render", "__prompts__", "__cookieplone_subtemplates", + "__cookieplone_template", + "__validators__", "json", # Probably `cookiecutter.json` ) @@ -77,14 +79,17 @@ def func(key: str, ignore: list[str] | None = None) -> bool: def _read_configuration(base_folder: Path) -> dict: - """Read cookiecutter.json.""" - file_ = base_folder / "cookiecutter.json" - return json.loads(file_.read_text()) + """Read template configuration from cookieplone.json or cookiecutter.json.""" + v2_file = base_folder / "cookieplone.json" + v1_file = base_folder / "cookiecutter.json" + if v2_file.is_file(): + return json.loads(v2_file.read_text()) + return json.loads(v1_file.read_text()) @pytest.fixture def configuration_data(template_repository_root) -> dict: - """Return configuration from cookiecutter.json.""" + """Return configuration from cookieplone.json or cookiecutter.json.""" return _read_configuration(template_repository_root) @@ -93,9 +98,18 @@ def sub_templates(configuration_data, template_repository_root) -> list[Path]: """Return a list of subtemplates used by this template.""" templates = [] parent = Path(template_repository_root).parent - sub_templates = configuration_data.get("__cookieplone_subtemplates", []) - for sub_template in sub_templates: - sub_template_id = sub_template[0] + # v2 format: config.subtemplates as list of {"id", "title", "enabled"} + config = configuration_data.get("config", {}) + raw_subtemplates = config.get("subtemplates", []) + if not raw_subtemplates: + # v1 fallback: flat __cookieplone_subtemplates as list of [id, title, enabled] + raw_subtemplates = configuration_data.get("__cookieplone_subtemplates", []) + for sub_template in raw_subtemplates: + if isinstance(sub_template, dict): + sub_template_id = sub_template["id"] + else: + # v1 tuple/list format + sub_template_id = sub_template[0] sub_template_path = (parent / sub_template_id).resolve() if not sub_template_path.exists(): sub_template_path = Path(parent.parent / sub_template_id).resolve() @@ -103,15 +117,28 @@ def sub_templates(configuration_data, template_repository_root) -> list[Path]: return templates +def _get_variable_keys(config_data: dict) -> set[str]: + """Extract variable keys from a configuration dict (v1 or v2 format).""" + # v2 format: keys are in schema.properties + schema = config_data.get("schema", {}) + properties = schema.get("properties", {}) + if properties: + return set(properties.keys()) + # v1 format: keys are at the top level + return set(config_data.keys()) + + @pytest.fixture def configuration_variables(configuration_data, sub_templates, valid_key) -> set[str]: - """Return a set of variables available in cookiecutter.json.""" + """Return a set of variables available in the template configuration.""" # Variables - variables = {key for key in configuration_data if valid_key(key)} + all_keys = _get_variable_keys(configuration_data) + variables = {key for key in all_keys if valid_key(key)} for sub_template in sub_templates: sub_config = _read_configuration(sub_template) + sub_keys = _get_variable_keys(sub_config) variables.update({ - key for key in sub_config if valid_key(key) and key.startswith("__") + key for key in sub_keys if valid_key(key) and key.startswith("__") }) return variables @@ -126,7 +153,12 @@ def _all_files_in_template( project_files = list(template_folder.glob("**/*")) all_files = hooks_files + project_files if include_configuration: - all_files.append(base_path / "cookiecutter.json") + v2_config = base_path / "cookieplone.json" + v1_config = base_path / "cookiecutter.json" + if v2_config.is_file(): + all_files.append(v2_config) + elif v1_config.is_file(): + all_files.append(v1_config) return all_files diff --git a/cookieplone/utils/config.py b/cookieplone/utils/config.py index b1312a9..59a0a4a 100644 --- a/cookieplone/utils/config.py +++ b/cookieplone/utils/config.py @@ -65,10 +65,6 @@ from pathlib import Path from typing import Any -import jsonschema -from jsonschema.exceptions import ValidationError -from tui_forms.parser import _FORM_SCHEMA - from cookieplone.utils import files @@ -103,51 +99,100 @@ def _get_validator(validators: dict[str, str], key: str) -> str: return validator_name if validator_name is not None else "" +def _convert_subtemplates( + raw_subtemplates: list[Any], +) -> list[dict[str, str]]: + """Convert v1 subtemplate tuples ``[id, title, enabled]`` to dicts.""" + subtemplates: list[dict[str, str]] = [] + for entry in raw_subtemplates: + if isinstance(entry, (list, tuple)) and len(entry) >= 3: + subtemplates.append({ + "id": entry[0], + "title": entry[1], + "enabled": entry[2], + }) + return subtemplates + + +def _convert_property( + key: str, + raw_value: Any, + prompts: dict[str, Any], + validators: dict[str, str], +) -> dict[str, Any]: + """Convert a single v1 property to a v2 property definition.""" + prompt_info = _get_prompt_info(prompts, key, raw_value) + validator = _get_validator(validators, key) + prop: dict[str, Any] = { + "type": "string", + "title": prompt_info.title, + "default": raw_value, + "validator": validator, + } + if key.startswith("__"): + prop["format"] = "computed" + elif key.startswith("_"): + prop["format"] = "constant" + elif isinstance(raw_value, list): + prop["default"] = raw_value[0] if raw_value else raw_value + prop["oneOf"] = [ + {"const": value, "title": label} for value, label in prompt_info.options + ] + elif isinstance(raw_value, bool): + prop["type"] = "boolean" + elif isinstance(raw_value, int): + prop["type"] = "integer" + elif isinstance(raw_value, str): + prop["type"] = "string" + else: + raise ValueError(f"Unsupported type for key '{key}': {type(raw_value)}") + return prop + + def convert_v1_to_v2(src: dict[str, Any]) -> dict[str, Any]: - """Convert a version 1 config dict to a version 2 config dict.""" + """Convert a version 1 config dict to a version 2 config dict. + + Extracts generator configuration keys (``_extensions``, + ``_copy_without_render``, ``__cookieplone_subtemplates``, + ``__cookieplone_template``) from the flat v1 properties into a + separate ``config`` section and wraps the remaining fields under + ``schema``. + """ data: dict[str, Any] = src.get("cookiecutter", src.copy()) data = dict(data) prompts = data.pop("__prompts__", {}) validators = data.pop("__validators__", {}) + + # Extract config keys before iterating properties + extensions = data.pop("_extensions", []) + no_render = data.pop("_copy_without_render", []) + template_id = data.pop("__cookieplone_template", "") + subtemplates = _convert_subtemplates(data.pop("__cookieplone_subtemplates", [])) + properties: OrderedDict[str, Any] = OrderedDict() for key, raw_value in data.items(): - prompt_info = _get_prompt_info(prompts, key, raw_value) - validator = _get_validator(validators, key) - prop: dict[str, Any] = { - "type": "string", - "title": prompt_info.title, - "default": raw_value, - "validator": validator, - } - if key.startswith("__"): - prop["format"] = "computed" - elif key.startswith("_"): - prop["format"] = "constant" - elif isinstance(raw_value, list): - # Old style choice, convert to JSONSchema oneOf with const/title - prop["default"] = raw_value[0] if raw_value else raw_value - prop["oneOf"] = [ - {"const": value, "title": label} for value, label in prompt_info.options - ] - elif isinstance(raw_value, bool): - prop["type"] = "boolean" - elif isinstance(raw_value, int): - prop["type"] = "integer" - elif isinstance(raw_value, str): - prop["type"] = "string" - # Sub-dict values (nested dicts) are not yet supported in the - # cookieplone schema; they would need a recursive conversion or a - # dedicated "object" type. For now they fall through to the - # ValueError below. - else: - raise ValueError(f"Unsupported type for key '{key}': {type(raw_value)}") - properties[key] = prop - return { + properties[key] = _convert_property(key, raw_value, prompts, validators) + + config: dict[str, Any] = {} + if extensions: + config["extensions"] = extensions + if no_render: + config["no_render"] = no_render + if subtemplates: + config["subtemplates"] = subtemplates + + result: dict[str, Any] = {} + if template_id: + result["id"] = template_id + result["schema"] = { "title": "Cookieplone", "description": "", "version": "2.0", "properties": properties, } + if config: + result["config"] = config + return result def cookiecutter_to_cookieplone(src: Path, dst: Path) -> Path: @@ -158,10 +203,7 @@ def cookiecutter_to_cookieplone(src: Path, dst: Path) -> Path: def validate_config(config: dict[str, Any]) -> bool: - """Validate the config is a valid JSONSchema.""" - try: - jsonschema.validate(config, _FORM_SCHEMA) - status = True - except ValidationError: - status = False - return status + """Validate the config against the cookieplone v2 JSONSchema.""" + from cookieplone.config.schemas import validate_cookieplone_config + + return validate_cookieplone_config(config) diff --git a/cookieplone/utils/cookiecutter.py b/cookieplone/utils/cookiecutter.py index d3e8d64..8f05e0c 100644 --- a/cookieplone/utils/cookiecutter.py +++ b/cookieplone/utils/cookiecutter.py @@ -1,11 +1,19 @@ +from __future__ import annotations + import os import sys from contextlib import contextmanager, suppress from copy import copy from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from jinja2 import Environment from cookiecutter.exceptions import OutputDirExistsException from cookiecutter.replay import dump, load +from cookiecutter.utils import create_env_with_context +from jinja2.exceptions import UndefinedError from cookieplone.config import Answers from cookieplone.settings import DEFAULT_DATA_KEY @@ -63,6 +71,26 @@ def dump_replay(answers: Answers, replay_dir: Path, template_name: str) -> None: dump(replay_dir, template_name, context) +def create_jinja_env(context: dict) -> Environment: + """Create a Jinja2 environment for the given context. + + If *context* does not contain a :data:`~cookieplone.settings.DEFAULT_DATA_KEY` + key, it is wrapped automatically so that + :func:`cookiecutter.utils.create_env_with_context` receives the structure + it expects. This allows callers to pass either a full cookiecutter context + or a plain data dict. + + :param context: Template context dict — either a full cookiecutter context + (``{"cookiecutter": {...}}``) or a plain data dict. + :returns: A configured :class:`~jinja2.Environment`. + """ + if DEFAULT_DATA_KEY not in context: + context = {DEFAULT_DATA_KEY: context} + env = create_env_with_context(context) + env.globals.update(context) + return env + + def parse_output_dir_exception(exc_info: OutputDirExistsException) -> str: """Parse the output directory from a cookiecutter OutputDirExistsException. @@ -85,3 +113,24 @@ def parse_output_dir_exception(exc_info: OutputDirExistsException) -> str: if path.exists(): return f"'{path}'" return "" + + +def parse_undefined_error(exc_info: UndefinedError, msg: str = "") -> str: + """Extract the undefined variable name from a Jinja2 :exc:`UndefinedError`. + + Parses the exception message (e.g. ``"'dict object' has no attribute + '__cookieplone_template'"``) and appends the variable name to *msg* when + it can be identified. + + :param exc_info: The Jinja2 :exc:`UndefinedError` instance. + :param msg: Base message to augment with the variable name. + :returns: *msg* with the variable name appended, or *msg* unchanged if + the variable name cannot be determined. + """ + message = exc_info.args[0] if exc_info.args else str(exc_info) + # "'dict object' has no attribute '__cookieplone_template'" + parts = message.split(" ") + key = parts[-1].strip("'") # Get the last part and remove quotes + if key.isidentifier(): + return f"{msg}: Variable '{key}' is undefined" + return msg diff --git a/cookieplone/utils/subtemplates.py b/cookieplone/utils/subtemplates.py new file mode 100644 index 0000000..0f3d7f0 --- /dev/null +++ b/cookieplone/utils/subtemplates.py @@ -0,0 +1,26 @@ +from typing import Any + +from cookieplone.config import CookieploneState +from cookieplone.utils.cookiecutter import create_jinja_env + + +def process_subtemplates( + state: CookieploneState, data: dict[str, Any] +) -> list[list[str]]: + """Convert v2 subtemplates into the v1 tuple format for post-gen hooks. + + Post-generation hooks expect ``__cookieplone_subtemplates`` as a list of + ``[id, title, enabled]`` lists. The ``enabled`` value may be a Jinja2 + expression (e.g. ``"{{ cookiecutter.has_frontend }}"``), so it is rendered + against the current context before being returned. + + :param state: Current run state containing the parsed subtemplates. + :param data: The cookiecutter context dict (inner dict, not the wrapper). + :returns: A list of ``[id, title, enabled]`` lists ready for injection. + """ + subtemplates = state.subtemplates or [] + env = create_jinja_env(data) + return [ + [s["id"], s["title"], env.from_string(s["enabled"]).render()] + for s in subtemplates + ] diff --git a/docs/src/concepts/subtemplates.md b/docs/src/concepts/subtemplates.md index 80222bc..d629a69 100644 --- a/docs/src/concepts/subtemplates.md +++ b/docs/src/concepts/subtemplates.md @@ -26,9 +26,13 @@ A post-generation hook in the main template then calls the sub-templates program This keeps each sub-template focused, testable, and reusable independently. -## The `templates` key +## Declaring sub-templates -Sub-templates are declared in the root `cookiecutter.json` under the `templates` key: +There are two levels where sub-templates appear in a Cookieplone repository. + +### Repository-level `templates` key + +The root `cookiecutter.json` lists all templates the repository provides under the `templates` key: ```json { @@ -59,6 +63,29 @@ In this example: - `project` is the main template, visible to users. - `backend` and `frontend` are sub-templates called programmatically; they are hidden from the menu. +### Template-level: `config.subtemplates` + +Inside each template's `cookieplone.json` (v2 format), the `config.subtemplates` array declares which sub-templates should run after generation. +Each entry has an `id`, a `title`, and an `enabled` field: + +```json +{ + "config": { + "subtemplates": [ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + {"id": "sub/frontend", "title": "Frontend", "enabled": "{{ cookiecutter.has_frontend }}"} + ] + } +} +``` + +The `enabled` field can be a static value (`"1"` or `"0"`) or a Jinja2 expression. +Expressions are rendered against the current template context after the user completes the wizard, allowing sub-templates to be conditionally enabled based on user answers. + +During generation, these entries are converted into `[id, title, enabled]` lists and injected into the template context as `__cookieplone_subtemplates`, where post-generation hooks can read them. + +See {doc}`/reference/schema-v2` for the full specification of the `config.subtemplates` format. + ## Calling a sub-template from a hook A post-generation hook in the `project` template calls the sub-templates after the main template is rendered: diff --git a/docs/src/reference/schema-v1.md b/docs/src/reference/schema-v1.md index d3e30b6..88a2181 100644 --- a/docs/src/reference/schema-v1.md +++ b/docs/src/reference/schema-v1.md @@ -112,10 +112,15 @@ See {doc}`/concepts/template-repositories` for the full structure. To upgrade a v1 schema to v2: 1. Rename `cookiecutter.json` to `cookieplone.json`. -2. Replace the flat structure with the `"version": "2.0"` and `"properties"` structure. +2. Wrap the field definitions under `schema.properties` and add `schema.version: "2.0"`. 3. Move prompts from `__prompts__` into each field's `"title"` key. 4. Move validators from `__validators__` into each field's `"validator"` key. 5. Convert computed fields from `__key` to a property with `"format": "computed"`. +6. Move `_extensions` to `config.extensions`. +7. Move `_copy_without_render` to `config.no_render`. +8. Move `__cookieplone_subtemplates` to `config.subtemplates`, converting each `[id, title, enabled]` tuple to an object `{"id": "...", "title": "...", "enabled": "..."}`. +9. Move `__cookieplone_template` to the top-level `id` key. +10. Add version pins to `config.versions` as needed (accessible in templates as `{{ version. }}`). ## Related pages diff --git a/docs/src/reference/schema-v2.md b/docs/src/reference/schema-v2.md index 9e054d3..5027e62 100644 --- a/docs/src/reference/schema-v2.md +++ b/docs/src/reference/schema-v2.md @@ -4,7 +4,7 @@ myst: "description": "Full specification for the cookieplone.json v2 template schema." "property=og:description": "Full specification for the cookieplone.json v2 template schema." "property=og:title": "Schema v2 reference (cookieplone.json)" - "keywords": "Cookieplone, cookieplone.json, schema, v2, properties, computed, choice, validator" + "keywords": "Cookieplone, cookieplone.json, schema, v2, properties, computed, choice, validator, config, extensions, versions, subtemplates" --- # Schema v2 reference (`cookieplone.json`) @@ -13,12 +13,44 @@ Template schemas in v2 format are stored in a file named `cookieplone.json` at t ## Top-level structure +A v2 file separates the **form schema** (what the user sees) from the **generator configuration** (how the template is processed). + +```json +{ + "id": "project", + "schema": { + "title": "My template", + "description": "A short description shown to the user.", + "version": "2.0", + "properties": { } + }, + "config": { + "extensions": [], + "no_render": [], + "versions": {}, + "subtemplates": [] + } +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `id` | string | no | Unique identifier for the template. | +| `schema` | object | yes | Form definition shown to the user. | +| `config` | object | no | Generator configuration (extensions, versions, etc.). | + +## Schema object + +The `schema` object defines the interactive form. + ```json { - "title": "My template", - "description": "A short description shown to the user.", - "version": "2.0", - "properties": { } + "schema": { + "title": "My template", + "description": "A short description shown to the user.", + "version": "2.0", + "properties": { } + } } ``` @@ -164,34 +196,187 @@ The function at that path must accept a single string argument and return `bool` Fields whose names match an entry in `DEFAULT_VALIDATORS` are wired automatically. See {doc}`/reference/validators` for the complete list. -## Complete example +## Configuration object + +The optional `config` object holds generator settings that are **not** shown in the wizard. +These values control how Cookieplone processes the template after the user answers all questions. ```json { - "title": "Plone add-on", - "description": "A minimal Plone add-on template.", - "version": "2.0", - "properties": { - "python_package_name": { - "type": "string", - "title": "Python package name", - "description": "Use dots for namespaces: collective.myaddon", - "default": "collective.myaddon" - }, - "plone_version": { - "type": "string", - "title": "Plone version", - "default": "6.1.2" - }, - "class_name": { - "type": "string", - "format": "computed", - "default": "{{ cookiecutter.python_package_name | pascal_case }}" + "config": { + "extensions": [ + "cookieplone.filters.latest_plone", + "cookieplone.filters.latest_volto" + ], + "no_render": ["*.png", "devops/etc"], + "versions": { + "gha_checkout": "v6", + "plone": "6.1" }, - "package_path": { - "type": "string", - "format": "computed", - "default": "{{ cookiecutter.python_package_name | package_path }}" + "subtemplates": [ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + {"id": "sub/frontend", "title": "Frontend", "enabled": "{{ cookiecutter.has_frontend }}"} + ] + } +} +``` + +| Key | Type | Description | +|---|---|---| +| `extensions` | array of strings | Jinja2 extension classes to load (dotted import paths). These make custom filters and tags available in template files. | +| `no_render` | array of strings | Glob patterns for files that should be copied as-is, without Jinja2 rendering. | +| `versions` | object | String-to-string mapping of version identifiers. Injected into the template context as `{{ version. }}`. | +| `subtemplates` | array of objects | Sub-templates to run after the main template. Each entry has `id`, `title`, and `enabled` keys. | + +All `config` keys are optional. +When a key is absent or empty, the corresponding feature is not activated. + +### `extensions` + +Jinja2 extension modules to load. +Each entry is a dotted Python import path pointing to a class that extends `jinja2.ext.Extension`. + +```json +{ + "config": { + "extensions": [ + "cookieplone.filters.latest_plone", + "cookieplone.filters.pascal_case" + ] + } +} +``` + +### `no_render` + +Glob patterns for files that should be copied verbatim, without Jinja2 rendering. +Use this for binary files or files whose content conflicts with Jinja2 syntax. + +```json +{ + "config": { + "no_render": ["*.png", "*.ico", "devops/etc"] + } +} +``` + +### `versions` + +A flat mapping of version identifiers to version strings. +These are injected into the template context as a top-level `versions` namespace, separate from the `cookiecutter` namespace. + +```json +{ + "config": { + "versions": { + "gha_checkout": "v6", + "plone": "6.1", + "volto": "18.10.0" + } + } +} +``` + +In template files, reference these values with `{{ versions.gha_checkout }}`, `{{ versions.plone }}`, etc. + +```{note} +Unlike other config keys, `versions` lives outside the `cookiecutter` namespace. +The full template context passed to Jinja2 looks like `{"cookiecutter": {...}, "versions": {...}}`. +``` + +### `subtemplates` + +Sub-templates that run after the main template completes. +Each entry is an object with three required keys. + +```json +{ + "config": { + "subtemplates": [ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + {"id": "sub/frontend", "title": "Frontend", "enabled": "{{ cookiecutter.has_frontend }}"} + ] + } +} +``` + +| Key | Type | Description | +|---|---|---| +| `id` | string | Path to the sub-template directory, relative to the template repository root. | +| `title` | string | Human-readable label shown in logs and passed to post-generation hooks. | +| `enabled` | string | Controls whether the sub-template runs. See below. | + +#### The `enabled` field + +The `enabled` field determines whether a sub-template is activated. +It can be a **static value** or a **Jinja2 expression**: + +- **Static**: `"1"` to always enable, `"0"` to always disable. +- **Jinja2 expression**: An expression like `"{{ cookiecutter.has_frontend }}"` that is rendered against the current template context after all user answers are collected. The resolved value is passed through to the post-generation hook. + +```json +{ + "config": { + "subtemplates": [ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + {"id": "sub/docs", "title": "Documentation", "enabled": "{{ cookiecutter.initialize_docs }}"}, + {"id": "sub/frontend", "title": "Frontend", "enabled": "{{ cookiecutter.has_frontend }}"} + ] + } +} +``` + +In this example, `sub/backend` always runs, while `sub/docs` and `sub/frontend` depend on the user's answers. + +#### How subtemplates are processed + +During generation, each subtemplate entry is converted into a `[id, title, enabled]` list and injected into the template context as `__cookieplone_subtemplates`. +Post-generation hooks read this list to decide which sub-templates to invoke. + +The processing order matches the declaration order in the configuration file. + +## Complete example + +```json +{ + "id": "addon", + "schema": { + "title": "Plone add-on", + "description": "A minimal Plone add-on template.", + "version": "2.0", + "properties": { + "python_package_name": { + "type": "string", + "title": "Python package name", + "description": "Use dots for namespaces: collective.myaddon", + "default": "collective.myaddon" + }, + "plone_version": { + "type": "string", + "title": "Plone version", + "default": "6.1.2" + }, + "class_name": { + "type": "string", + "format": "computed", + "default": "{{ cookiecutter.python_package_name | pascal_case }}" + }, + "package_path": { + "type": "string", + "format": "computed", + "default": "{{ cookiecutter.python_package_name | package_path }}" + } + } + }, + "config": { + "extensions": [ + "cookieplone.filters.latest_plone", + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_path" + ], + "no_render": ["*.png"], + "versions": { + "gha_checkout": "v6" } } } diff --git a/docs/styles/config/vocabularies/Base/accept.txt b/docs/styles/config/vocabularies/Base/accept.txt index 9d10a4d..3d8e527 100644 --- a/docs/styles/config/vocabularies/Base/accept.txt +++ b/docs/styles/config/vocabularies/Base/accept.txt @@ -1,4 +1,5 @@ Cookiecutter Cookieplone +[Ss]ub-?templates? traceback pre_prompt diff --git a/news/156.feature b/news/156.feature new file mode 100644 index 0000000..385636c --- /dev/null +++ b/news/156.feature @@ -0,0 +1 @@ +Refactored v2 config format to separate schema from generator configuration, with `SubTemplate` TypedDict, `config.versions` as a top-level context namespace, and Jinja2 rendering for subtemplate `enabled` fields. @ericof diff --git a/tests/_resources/config/v2-sub-project_settings.json b/tests/_resources/config/v2-sub-project_settings.json index 59d387e..2ed863e 100644 --- a/tests/_resources/config/v2-sub-project_settings.json +++ b/tests/_resources/config/v2-sub-project_settings.json @@ -1,305 +1,290 @@ { - "title": "Cookieplone", - "description": "", - "version": "2.0", - "properties": { - "title": { - "title": "title", - "default": "Project Title", - "validator": "", - "type": "string" - }, - "description": { - "title": "description", - "default": "A new project using Plone 6.", - "validator": "", - "type": "string" - }, - "project_slug": { - "title": "project_slug", - "default": "{{ cookiecutter.title | slugify }}", - "validator": "", - "type": "string" - }, - "author": { - "title": "author", - "default": "Plone Foundation", - "validator": "", - "type": "string" - }, - "email": { - "title": "email", - "default": "collective@plone.org", - "validator": "", - "type": "string" - }, - "python_package_name": { - "title": "python_package_name", - "default": "{{ cookiecutter.project_slug|replace(' ', '')|replace('-', '.') }}", - "validator": "", - "type": "string" - }, - "frontend_addon_name": { - "title": "frontend_addon_name", - "default": "volto-{{ cookiecutter.python_package_name|replace('_', '-')|replace('.', '-') }}", - "validator": "", - "type": "string" - }, - "npm_package_name": { - "title": "npm_package_name", - "default": "{{ cookiecutter.frontend_addon_name }}", - "validator": "", - "type": "string" - }, - "language_code": { - "title": "language_code", - "default": "en", - "validator": "", - "type": "string" - }, - "use_prerelease_versions": { - "title": "use_prerelease_versions", - "default": "{{ 'No' | use_prerelease_versions }}", - "validator": "", - "type": "string" - }, - "plone_version": { - "title": "plone_version", - "default": "{{ 'No' | latest_plone }}", - "validator": "", - "type": "string" - }, - "volto_version": { - "title": "volto_version", - "default": "{{ cookiecutter.use_prerelease_versions | latest_volto }}", - "validator": "", - "type": "string" - }, - "github_organization": { - "title": "github_organization", - "default": "collective", - "validator": "", - "type": "string" - }, - "container_registry": { - "title": "container_registry", - "default": "github", - "validator": "", - "type": "string", - "oneOf": [] - }, - "__feature_distribution": { - "title": "__feature_distribution", - "default": "0", - "validator": "", - "type": "string", - "format": "computed" - }, - "__backend_managed_by_uv": { - "title": "__backend_managed_by_uv", - "default": "false", - "validator": "", - "type": "string", - "format": "computed" - }, - "__project_slug": { - "title": "__project_slug", - "default": "{{ cookiecutter.project_slug }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__repository_url": { - "title": "__repository_url", - "default": "https://github.com/{{ cookiecutter.github_organization }}/{{ cookiecutter.__project_slug }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__repository_git": { - "title": "__repository_git", - "default": "git@github.com:{{ cookiecutter.github_organization }}/{{ cookiecutter.__project_slug }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__node_version": { - "title": "__node_version", - "default": "{{ cookiecutter.volto_version | node_version_for_volto }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__npm_package_name": { - "title": "__npm_package_name", - "default": "{{ cookiecutter.npm_package_name }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__container_registry_prefix": { - "title": "__container_registry_prefix", - "default": "{{ cookiecutter.container_registry | image_prefix }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__container_image_prefix": { - "title": "__container_image_prefix", - "default": "{{ cookiecutter.__container_registry_prefix }}{{ cookiecutter.github_organization }}/{{ cookiecutter.project_slug }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__folder_name": { - "title": "__folder_name", - "default": "{{ cookiecutter.project_slug }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__package_path": { - "title": "__package_path", - "default": "{{ cookiecutter.python_package_name | package_path }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__profile_language": { - "title": "__profile_language", - "default": "{{ cookiecutter.language_code|gs_language_code }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__locales_language": { - "title": "__locales_language", - "default": "{{ cookiecutter.language_code|locales_language_code }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__python_version": { - "title": "__python_version", - "default": "3.12", - "validator": "", - "type": "string", - "format": "computed" - }, - "__supported_versions_python": { - "title": "__supported_versions_python", - "default": [ - "{{ cookiecutter.__python_version }}" - ], - "validator": "", - "type": "string", - "format": "computed" - }, - "__supported_versions_plone": { - "title": "__supported_versions_plone", - "default": [ - "{{ cookiecutter.plone_version | as_major_minor }}" - ], - "validator": "", - "type": "string", - "format": "computed" - }, - "__python_version_identifier": { - "title": "__python_version_identifier", - "default": "{{ cookiecutter.__python_version | replace('.', '') }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__version_plone_volto": { - "title": "__version_plone_volto", - "default": "{{ cookiecutter.volto_version }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__version_pnpm": { - "title": "__version_pnpm", - "default": "{{ '10.20.0' if cookiecutter.volto_version >= '19' else '9.1.1' }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__test_framework": { - "title": "__test_framework", - "default": "{{ 'vitest' if cookiecutter.volto_version >= '19' else 'jest'}}", - "validator": "", - "type": "string", - "format": "computed" - }, - "_copy_without_render": { - "title": "_copy_without_render", - "default": [], - "validator": "", - "type": "string", - "format": "constant" - }, - "_extensions": { - "title": "_extensions", - "default": [ - "cookieplone.filters.use_prerelease_versions", - "cookieplone.filters.node_version_for_volto", - "cookieplone.filters.extract_host", - "cookieplone.filters.image_prefix", - "cookieplone.filters.pascal_case", - "cookieplone.filters.locales_language_code", - "cookieplone.filters.gs_language_code", - "cookieplone.filters.package_namespace_path", - "cookieplone.filters.package_path", - "cookieplone.filters.as_major_minor", - "cookieplone.filters.latest_volto", - "cookieplone.filters.latest_plone" - ], - "validator": "", - "type": "string", - "format": "constant" - }, - "__cookieplone_repository_path": { - "title": "__cookieplone_repository_path", - "default": "", - "validator": "", - "type": "string", - "format": "computed" - }, - "__cookieplone_template": { - "title": "__cookieplone_template", - "default": "", - "validator": "", - "type": "string", - "format": "computed" - }, - "__generator_sha": { - "title": "__generator_sha", - "default": "", - "validator": "", - "type": "string", - "format": "computed" - }, - "__generator_template_url": { - "title": "__generator_template_url", - "default": "https://github.com/plone/cookieplone-templates/tree/main/{{ cookiecutter.__cookieplone_template }}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__generator_date_long": { - "title": "__generator_date_long", - "default": "{% now 'utc', '%Y-%m-%d %H:%M:%S' %}", - "validator": "", - "type": "string", - "format": "computed" - }, - "__generator_signature": { - "title": "__generator_signature", - "default": "Generated from the [`cookieplone-templates` {{ cookiecutter.__cookieplone_template }} template]({{ cookiecutter.__generator_template_url }}) on {{ cookiecutter.__generator_date_long }}.", - "validator": "", - "type": "string", - "format": "computed" + "id": "", + "schema": { + "title": "Cookieplone", + "description": "", + "version": "2.0", + "properties": { + "title": { + "title": "title", + "default": "Project Title", + "validator": "", + "type": "string" + }, + "description": { + "title": "description", + "default": "A new project using Plone 6.", + "validator": "", + "type": "string" + }, + "project_slug": { + "title": "project_slug", + "default": "{{ cookiecutter.title | slugify }}", + "validator": "", + "type": "string" + }, + "author": { + "title": "author", + "default": "Plone Foundation", + "validator": "", + "type": "string" + }, + "email": { + "title": "email", + "default": "collective@plone.org", + "validator": "", + "type": "string" + }, + "python_package_name": { + "title": "python_package_name", + "default": "{{ cookiecutter.project_slug|replace(' ', '')|replace('-', '.') }}", + "validator": "", + "type": "string" + }, + "frontend_addon_name": { + "title": "frontend_addon_name", + "default": "volto-{{ cookiecutter.python_package_name|replace('_', '-')|replace('.', '-') }}", + "validator": "", + "type": "string" + }, + "npm_package_name": { + "title": "npm_package_name", + "default": "{{ cookiecutter.frontend_addon_name }}", + "validator": "", + "type": "string" + }, + "language_code": { + "title": "language_code", + "default": "en", + "validator": "", + "type": "string" + }, + "use_prerelease_versions": { + "title": "use_prerelease_versions", + "default": "{{ 'No' | use_prerelease_versions }}", + "validator": "", + "type": "string" + }, + "plone_version": { + "title": "plone_version", + "default": "{{ 'No' | latest_plone }}", + "validator": "", + "type": "string" + }, + "volto_version": { + "title": "volto_version", + "default": "{{ cookiecutter.use_prerelease_versions | latest_volto }}", + "validator": "", + "type": "string" + }, + "github_organization": { + "title": "github_organization", + "default": "collective", + "validator": "", + "type": "string" + }, + "container_registry": { + "title": "container_registry", + "default": "github", + "validator": "", + "type": "string", + "oneOf": [] + }, + "__feature_distribution": { + "title": "__feature_distribution", + "default": "0", + "validator": "", + "type": "string", + "format": "computed" + }, + "__backend_managed_by_uv": { + "title": "__backend_managed_by_uv", + "default": "false", + "validator": "", + "type": "string", + "format": "computed" + }, + "__project_slug": { + "title": "__project_slug", + "default": "{{ cookiecutter.project_slug }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__repository_url": { + "title": "__repository_url", + "default": "https://github.com/{{ cookiecutter.github_organization }}/{{ cookiecutter.__project_slug }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__repository_git": { + "title": "__repository_git", + "default": "git@github.com:{{ cookiecutter.github_organization }}/{{ cookiecutter.__project_slug }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__node_version": { + "title": "__node_version", + "default": "{{ cookiecutter.volto_version | node_version_for_volto }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__npm_package_name": { + "title": "__npm_package_name", + "default": "{{ cookiecutter.npm_package_name }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__container_registry_prefix": { + "title": "__container_registry_prefix", + "default": "{{ cookiecutter.container_registry | image_prefix }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__container_image_prefix": { + "title": "__container_image_prefix", + "default": "{{ cookiecutter.__container_registry_prefix }}{{ cookiecutter.github_organization }}/{{ cookiecutter.project_slug }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__folder_name": { + "title": "__folder_name", + "default": "{{ cookiecutter.project_slug }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__package_path": { + "title": "__package_path", + "default": "{{ cookiecutter.python_package_name | package_path }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__profile_language": { + "title": "__profile_language", + "default": "{{ cookiecutter.language_code|gs_language_code }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__locales_language": { + "title": "__locales_language", + "default": "{{ cookiecutter.language_code|locales_language_code }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__python_version": { + "title": "__python_version", + "default": "3.12", + "validator": "", + "type": "string", + "format": "computed" + }, + "__supported_versions_python": { + "title": "__supported_versions_python", + "default": [ + "{{ cookiecutter.__python_version }}" + ], + "validator": "", + "type": "string", + "format": "computed" + }, + "__supported_versions_plone": { + "title": "__supported_versions_plone", + "default": [ + "{{ cookiecutter.plone_version | as_major_minor }}" + ], + "validator": "", + "type": "string", + "format": "computed" + }, + "__python_version_identifier": { + "title": "__python_version_identifier", + "default": "{{ cookiecutter.__python_version | replace('.', '') }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__version_plone_volto": { + "title": "__version_plone_volto", + "default": "{{ cookiecutter.volto_version }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__version_pnpm": { + "title": "__version_pnpm", + "default": "{{ '10.20.0' if cookiecutter.volto_version >= '19' else '9.1.1' }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__test_framework": { + "title": "__test_framework", + "default": "{{ 'vitest' if cookiecutter.volto_version >= '19' else 'jest'}}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__cookieplone_repository_path": { + "title": "__cookieplone_repository_path", + "default": "", + "validator": "", + "type": "string", + "format": "computed" + }, + "__generator_sha": { + "title": "__generator_sha", + "default": "", + "validator": "", + "type": "string", + "format": "computed" + }, + "__generator_template_url": { + "title": "__generator_template_url", + "default": "https://github.com/plone/cookieplone-templates/tree/main/{{ cookiecutter.__cookieplone_template }}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__generator_date_long": { + "title": "__generator_date_long", + "default": "{% now 'utc', '%Y-%m-%d %H:%M:%S' %}", + "validator": "", + "type": "string", + "format": "computed" + }, + "__generator_signature": { + "title": "__generator_signature", + "default": "Generated from the [`cookieplone-templates` {{ cookiecutter.__cookieplone_template }} template]({{ cookiecutter.__generator_template_url }}) on {{ cookiecutter.__generator_date_long }}.", + "validator": "", + "type": "string", + "format": "computed" + } } + }, + "config": { + "extensions": [ + "cookieplone.filters.use_prerelease_versions", + "cookieplone.filters.node_version_for_volto", + "cookieplone.filters.extract_host", + "cookieplone.filters.image_prefix", + "cookieplone.filters.pascal_case", + "cookieplone.filters.locales_language_code", + "cookieplone.filters.gs_language_code", + "cookieplone.filters.package_namespace_path", + "cookieplone.filters.package_path", + "cookieplone.filters.as_major_minor", + "cookieplone.filters.latest_volto", + "cookieplone.filters.latest_plone" + ] } } diff --git a/tests/_resources/dummy_package/.gitignore b/tests/_resources/dummy_package/.gitignore new file mode 100644 index 0000000..132d69c --- /dev/null +++ b/tests/_resources/dummy_package/.gitignore @@ -0,0 +1 @@ +.ruff_cache/ diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py new file mode 100644 index 0000000..4f3d6ed --- /dev/null +++ b/tests/config/test_schemas.py @@ -0,0 +1,149 @@ +"""Tests for cookieplone.config.schemas.""" + +import pytest + +from cookieplone.config.schemas import validate_cookieplone_config + + +def _minimal_v2(): + """Return a minimal valid v2 config.""" + return { + "schema": { + "version": "2.0", + "properties": { + "title": { + "type": "string", + "default": "My Project", + }, + }, + }, + } + + +class TestValidateCookieploneConfig: + """Tests for validate_cookieplone_config.""" + + def test_minimal_valid(self): + assert validate_cookieplone_config(_minimal_v2()) is True + + def test_with_id(self): + data = _minimal_v2() + data["id"] = "project" + assert validate_cookieplone_config(data) is True + + def test_with_config_extensions(self): + data = _minimal_v2() + data["config"] = {"extensions": ["cookieplone.filters.latest_plone"]} + assert validate_cookieplone_config(data) is True + + def test_with_config_no_render(self): + data = _minimal_v2() + data["config"] = {"no_render": ["*.png", "devops/etc"]} + assert validate_cookieplone_config(data) is True + + def test_with_config_versions(self): + data = _minimal_v2() + data["config"] = {"versions": {"gha_checkout": "v6", "plone": "6.1"}} + assert validate_cookieplone_config(data) is True + + def test_with_config_subtemplates(self): + data = _minimal_v2() + data["config"] = { + "subtemplates": [ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + ], + } + assert validate_cookieplone_config(data) is True + + def test_full_config(self): + data = { + "id": "project", + "schema": { + "title": "Cookieplone Project", + "description": "A Plone project", + "version": "2.0", + "properties": { + "title": { + "type": "string", + "title": "Project Title", + "default": "My Project", + }, + "__folder_name": { + "type": "string", + "format": "computed", + "default": "{{ cookiecutter.title | slugify }}", + }, + }, + }, + "config": { + "extensions": ["cookieplone.filters.latest_plone"], + "no_render": ["*.png"], + "versions": {"gha_checkout": "v6"}, + "subtemplates": [ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + ], + }, + } + assert validate_cookieplone_config(data) is True + + def test_missing_schema(self): + assert validate_cookieplone_config({"id": "test"}) is False + + def test_missing_version(self): + data = {"schema": {"properties": {}}} + assert validate_cookieplone_config(data) is False + + def test_wrong_version(self): + data = {"schema": {"version": "1.0", "properties": {}}} + assert validate_cookieplone_config(data) is False + + def test_missing_properties(self): + data = {"schema": {"version": "2.0"}} + assert validate_cookieplone_config(data) is False + + def test_extra_top_level_key(self): + data = _minimal_v2() + data["unknown_key"] = "value" + assert validate_cookieplone_config(data) is False + + def test_extra_config_key(self): + data = _minimal_v2() + data["config"] = {"unknown": "value"} + assert validate_cookieplone_config(data) is False + + def test_subtemplate_missing_id(self): + data = _minimal_v2() + data["config"] = { + "subtemplates": [{"title": "Backend", "enabled": "1"}], + } + assert validate_cookieplone_config(data) is False + + def test_empty_dict(self): + assert validate_cookieplone_config({}) is False + + @pytest.mark.parametrize( + "property_def", + [ + {"type": "string", "default": "value"}, + {"type": "integer", "default": 42}, + {"type": "boolean", "default": True}, + {"type": "string", "default": "x", "format": "computed"}, + {"type": "string", "default": "x", "validator": "mod.func"}, + { + "type": "string", + "default": "MIT", + "oneOf": [ + {"const": "MIT", "title": "MIT License"}, + {"const": "GPL", "title": "GPL"}, + ], + }, + ], + ) + def test_valid_property_types(self, property_def): + data = { + "schema": { + "version": "2.0", + "properties": {"field": property_def}, + }, + } + assert validate_cookieplone_config(data) is True diff --git a/tests/config/test_state.py b/tests/config/test_state.py index aa622e8..36048e2 100644 --- a/tests/config/test_state.py +++ b/tests/config/test_state.py @@ -2,6 +2,7 @@ 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", @@ -44,9 +45,9 @@ def test_generate_state(template_path, config_file: str): @pytest.mark.parametrize( "config_file,use_extra,use_replay,len_properties,len_questions,key,default", [ - ("config/v1-project.json", False, False, 65, 19, "title", "Project Title"), - ("config/v1-project.json", True, False, 65, 19, "title", "Titulo"), - ("config/v1-project.json", True, True, 65, 19, "title", "Other Project"), + ("config/v1-project.json", False, False, 61, 19, "title", "Project Title"), + ("config/v1-project.json", True, False, 61, 19, "title", "Titulo"), + ("config/v1-project.json", True, True, 61, 19, "title", "Other Project"), ], ) def test_generate_state_overrides( @@ -76,3 +77,26 @@ 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_v1.py b/tests/config/test_v1.py index 717bb94..efbab00 100644 --- a/tests/config/test_v1.py +++ b/tests/config/test_v1.py @@ -3,10 +3,11 @@ from typing import Any from cookieplone.config.v1 import parse_v1 +from cookieplone.config.v2 import ParsedConfig -def test_returns_dict(): - """parse_v1 returns a dict.""" +def test_returns_parsed_config(): + """parse_v1 returns a ParsedConfig.""" context = { "cookiecutter": { "title": "My Project", @@ -14,7 +15,7 @@ def test_returns_dict(): } } result = parse_v1(context) - assert isinstance(result, dict) + assert isinstance(result, ParsedConfig) def test_result_has_version_2(): @@ -25,7 +26,7 @@ def test_result_has_version_2(): } } result = parse_v1(context) - assert result.get("version") == "2.0" + assert result.schema.get("version") == "2.0" def test_result_has_properties(): @@ -37,7 +38,7 @@ def test_result_has_properties(): } } result = parse_v1(context) - assert "properties" in result + assert "properties" in result.schema def test_string_field_conversion(): @@ -48,7 +49,7 @@ def test_string_field_conversion(): } } result = parse_v1(context) - prop = result["properties"]["title"] + prop = result.schema["properties"]["title"] assert prop["type"] == "string" assert prop["default"] == "My Project" @@ -61,20 +62,33 @@ def test_computed_field_conversion(): } } result = parse_v1(context) - prop = result["properties"]["__folder_name"] + prop = result.schema["properties"]["__folder_name"] assert prop["format"] == "computed" def test_constant_field_conversion(): - """Fields prefixed with _ (single underscore) are converted to constant format.""" + """Fields with _ prefix are extracted to config, not properties.""" context = { "cookiecutter": { - "_copy_without_render": [], + "_copy_without_render": ["*.png"], } } result = parse_v1(context) - prop = result["properties"]["_copy_without_render"] - assert prop["format"] == "constant" + assert "_copy_without_render" not in result.schema.get("properties", {}) + assert result.no_render == ["*.png"] + + +def test_extensions_extracted_to_config(): + """_extensions is extracted to ParsedConfig.extensions.""" + context = { + "cookiecutter": { + "_extensions": ["cookieplone.filters.latest_plone"], + "title": "My Project", + } + } + result = parse_v1(context) + assert "_extensions" not in result.schema.get("properties", {}) + assert result.extensions == ["cookieplone.filters.latest_plone"] def test_choice_field_default(): @@ -85,7 +99,7 @@ def test_choice_field_default(): } } result = parse_v1(context) - prop = result["properties"]["license"] + prop = result.schema["properties"]["license"] assert prop["default"] == "MIT" @@ -105,7 +119,7 @@ def test_choice_field_with_prompts(): } } result = parse_v1(context) - prop = result["properties"]["license"] + prop = result.schema["properties"]["license"] assert "oneOf" in prop assert len(prop["oneOf"]) == 3 assert prop["oneOf"][0]["const"] == "MIT" @@ -124,7 +138,7 @@ def test_prompts_become_titles(): } } result = parse_v1(context) - assert result["properties"]["title"]["title"] == "Enter project title" + assert result.schema["properties"]["title"]["title"] == "Enter project title" def test_prompts_not_in_properties(): @@ -138,7 +152,7 @@ def test_prompts_not_in_properties(): } } result = parse_v1(context) - assert "__prompts__" not in result["properties"] + assert "__prompts__" not in result.schema["properties"] def test_validators_applied(): @@ -153,7 +167,7 @@ def test_validators_applied(): } result = parse_v1(context) assert ( - result["properties"]["hostname"]["validator"] + result.schema["properties"]["hostname"]["validator"] == "cookieplone.validators.hostname" ) @@ -169,7 +183,7 @@ def test_validators_not_in_properties(): } } result = parse_v1(context) - assert "__validators__" not in result["properties"] + assert "__validators__" not in result.schema["properties"] def test_extracts_from_cookiecutter_key(): @@ -180,7 +194,7 @@ def test_extracts_from_cookiecutter_key(): } } result = parse_v1(context) - assert "title" in result["properties"] + assert "title" in result.schema["properties"] def test_works_without_cookiecutter_key(): @@ -189,7 +203,7 @@ def test_works_without_cookiecutter_key(): "title": "My Project", } result = parse_v1(context) - assert "title" in result["properties"] + assert "title" in result.schema["properties"] def test_integer_field_conversion(): @@ -200,7 +214,7 @@ def test_integer_field_conversion(): } } result = parse_v1(context) - prop = result["properties"]["port"] + prop = result.schema["properties"]["port"] assert prop["type"] == "integer" assert prop["default"] == 8080 @@ -213,4 +227,43 @@ def test_boolean_field_conversion(): } } result = parse_v1(context) - assert "has_volto" in result["properties"] + assert "has_volto" in result.schema["properties"] + + +def test_subtemplates_extracted(): + """__cookieplone_subtemplates are extracted as structured objects.""" + context = { + "cookiecutter": { + "title": "My Project", + "__cookieplone_subtemplates": [ + ["sub/backend", "Backend", "1"], + ["sub/frontend", "Frontend", "{{ cookiecutter.has_frontend }}"], + ], + } + } + result = parse_v1(context) + assert "__cookieplone_subtemplates" not in result.schema.get("properties", {}) + assert len(result.subtemplates) == 2 + assert result.subtemplates[0] == { + "id": "sub/backend", + "title": "Backend", + "enabled": "1", + } + assert result.subtemplates[1] == { + "id": "sub/frontend", + "title": "Frontend", + "enabled": "{{ cookiecutter.has_frontend }}", + } + + +def test_template_id_extracted(): + """__cookieplone_template is extracted as template_id.""" + context = { + "cookiecutter": { + "title": "My Project", + "__cookieplone_template": "project", + } + } + result = parse_v1(context) + assert "__cookieplone_template" not in result.schema.get("properties", {}) + assert result.template_id == "project" diff --git a/tests/config/test_v2.py b/tests/config/test_v2.py index 5f4c47f..ad49a6d 100644 --- a/tests/config/test_v2.py +++ b/tests/config/test_v2.py @@ -2,96 +2,183 @@ from typing import Any -from cookieplone.config.v2 import parse_v2 +from cookieplone.config.v2 import ParsedConfig, parse_v2 -def test_returns_dict(): - """parse_v2 returns a dict.""" - context: dict[str, Any] = {"version": "2.0", "properties": {}} - result = parse_v2(context) - assert isinstance(result, dict) - - -def test_returns_same_object(): - """parse_v2 returns the exact same dict (pass-through).""" - context: dict[str, Any] = {"version": "2.0", "properties": {}} +def test_returns_parsed_config(): + """parse_v2 returns a ParsedConfig.""" + context: dict[str, Any] = { + "schema": {"version": "2.0", "properties": {}}, + } result = parse_v2(context) - assert result is context + assert isinstance(result, ParsedConfig) def test_preserves_version(): - """parse_v2 preserves the version key.""" - context: dict[str, Any] = {"version": "2.0", "properties": {}} + """parse_v2 preserves the version key in the schema.""" + context: dict[str, Any] = { + "schema": {"version": "2.0", "properties": {}}, + } result = parse_v2(context) - assert result["version"] == "2.0" + assert result.schema["version"] == "2.0" def test_preserves_properties(): """parse_v2 preserves the properties.""" context: dict[str, Any] = { - "version": "2.0", - "properties": { - "title": { - "type": "string", - "default": "My Project", - } + "schema": { + "version": "2.0", + "properties": { + "title": { + "type": "string", + "default": "My Project", + } + }, }, } result = parse_v2(context) - assert result["properties"]["title"]["default"] == "My Project" + assert result.schema["properties"]["title"]["default"] == "My Project" def test_preserves_computed_fields(): """parse_v2 preserves computed fields.""" context: dict[str, Any] = { - "version": "2.0", - "properties": { - "__folder_name": { - "type": "string", - "format": "computed", - "default": "{{ cookiecutter.title | slugify }}", - } + "schema": { + "version": "2.0", + "properties": { + "__folder_name": { + "type": "string", + "format": "computed", + "default": "{{ cookiecutter.title | slugify }}", + } + }, }, } result = parse_v2(context) - prop = result["properties"]["__folder_name"] + prop = result.schema["properties"]["__folder_name"] assert prop["format"] == "computed" def test_preserves_oneof_choices(): """parse_v2 preserves oneOf choice fields.""" context: dict[str, Any] = { - "version": "2.0", - "properties": { - "license": { - "type": "string", - "oneOf": [ - {"const": "MIT", "title": "MIT License"}, - {"const": "GPL-2.0", "title": "GNU GPL v2"}, - ], - "default": "MIT", - } + "schema": { + "version": "2.0", + "properties": { + "license": { + "type": "string", + "oneOf": [ + {"const": "MIT", "title": "MIT License"}, + {"const": "GPL-2.0", "title": "GNU GPL v2"}, + ], + "default": "MIT", + } + }, }, } result = parse_v2(context) - assert len(result["properties"]["license"]["oneOf"]) == 2 + assert len(result.schema["properties"]["license"]["oneOf"]) == 2 -def test_preserves_extra_keys(): - """parse_v2 preserves any extra keys in the context.""" +def test_preserves_schema_title(): + """parse_v2 preserves title and description in the schema.""" context: dict[str, Any] = { - "version": "2.0", - "title": "My Form", - "description": "A form description", - "properties": {}, + "schema": { + "version": "2.0", + "title": "My Form", + "description": "A form description", + "properties": {}, + }, } result = parse_v2(context) - assert result["title"] == "My Form" - assert result["description"] == "A form description" + assert result.schema["title"] == "My Form" + assert result.schema["description"] == "A form description" def test_empty_context(): """parse_v2 handles an empty dict.""" context: dict[str, Any] = {} result = parse_v2(context) - assert result == {} + assert result.schema == {} + assert result.extensions == [] + assert result.no_render == [] + assert result.versions == {} + assert result.subtemplates == [] + assert result.template_id == "" + + +def test_extracts_extensions(): + """parse_v2 extracts extensions from config.""" + context: dict[str, Any] = { + "schema": {"version": "2.0", "properties": {}}, + "config": { + "extensions": ["cookieplone.filters.latest_plone"], + }, + } + result = parse_v2(context) + assert result.extensions == ["cookieplone.filters.latest_plone"] + + +def test_extracts_no_render(): + """parse_v2 extracts no_render from config.""" + context: dict[str, Any] = { + "schema": {"version": "2.0", "properties": {}}, + "config": { + "no_render": ["*.png", "devops/etc"], + }, + } + result = parse_v2(context) + assert result.no_render == ["*.png", "devops/etc"] + + +def test_extracts_versions(): + """parse_v2 extracts versions from config.""" + context: dict[str, Any] = { + "schema": {"version": "2.0", "properties": {}}, + "config": { + "versions": {"gha_checkout": "v6", "plone": "6.1"}, + }, + } + result = parse_v2(context) + assert result.versions == {"gha_checkout": "v6", "plone": "6.1"} + + +def test_extracts_subtemplates(): + """parse_v2 extracts subtemplates from config.""" + context: dict[str, Any] = { + "schema": {"version": "2.0", "properties": {}}, + "config": { + "subtemplates": [ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + ], + }, + } + result = parse_v2(context) + assert len(result.subtemplates) == 1 + assert result.subtemplates[0] == { + "id": "sub/backend", + "title": "Backend", + "enabled": "1", + } + + +def test_extracts_template_id(): + """parse_v2 extracts the top-level id.""" + context: dict[str, Any] = { + "id": "project", + "schema": {"version": "2.0", "properties": {}}, + } + result = parse_v2(context) + assert result.template_id == "project" + + +def test_no_config_section(): + """parse_v2 works when there is no config section.""" + context: dict[str, Any] = { + "schema": {"version": "2.0", "properties": {}}, + } + result = parse_v2(context) + assert result.extensions == [] + assert result.no_render == [] + assert result.versions == {} + assert result.subtemplates == [] diff --git a/tests/conftest.py b/tests/conftest.py index 5f47786..2d778fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,6 +128,8 @@ def dummy_package(tmpdir_factory, resources_folder) -> Generator[Path]: src = (resources_folder / "dummy_package").resolve() for item in src.rglob("*"): + if any(part.startswith(".") for part in item.relative_to(src).parts): + continue if item.is_file(): parent = item.parent.relative_to(src) name = item.name diff --git a/tests/generator/test_annotate_data.py b/tests/generator/test_annotate_data.py index 79edd0f..356ce88 100644 --- a/tests/generator/test_annotate_data.py +++ b/tests/generator/test_annotate_data.py @@ -1,46 +1,95 @@ """Tests for _annotate_data.""" +from cookieplone.config.state import CookieploneState from cookieplone.generator.main import _annotate_data -def test_sets_template(run_config, repository_info): +def test_sets_template(state, run_config, repository_info): """_annotate_data sets _template to repo_dir.""" data: dict = {"title": "Test"} - _annotate_data(data, run_config, repository_info) + _annotate_data(data, state, run_config, repository_info) assert data["_template"] == str(repository_info.repo_dir) -def test_sets_cookieplone_repository_path(run_config, repository_info): +def test_sets_cookieplone_repository_path(state, run_config, repository_info): """_annotate_data sets __cookieplone_repository_path.""" data: dict = {"title": "Test"} - _annotate_data(data, run_config, repository_info) + _annotate_data(data, state, run_config, repository_info) assert data["__cookieplone_repository_path"] == str(repository_info.root_repo_dir) -def test_sets_output_dir(run_config, repository_info): +def test_sets_output_dir(state, run_config, repository_info): """_annotate_data sets _output_dir to resolved output_dir.""" data: dict = {"title": "Test"} - _annotate_data(data, run_config, repository_info) + _annotate_data(data, state, run_config, repository_info) assert data["_output_dir"] == str(run_config.output_dir.resolve()) -def test_sets_repo_dir(run_config, repository_info): +def test_sets_repo_dir(state, run_config, repository_info): """_annotate_data sets _repo_dir.""" data: dict = {"title": "Test"} - _annotate_data(data, run_config, repository_info) + _annotate_data(data, state, run_config, repository_info) assert data["_repo_dir"] == str(repository_info.repo_dir) -def test_sets_checkout(run_config, repository_info): +def test_sets_checkout(state, run_config, repository_info): """_annotate_data sets _checkout.""" data: dict = {"title": "Test"} - _annotate_data(data, run_config, repository_info) + _annotate_data(data, state, run_config, repository_info) assert data["_checkout"] == repository_info.checkout -def test_mutates_dict_in_place(run_config, repository_info): +def test_mutates_dict_in_place(state, run_config, repository_info): """_annotate_data mutates the input dict and returns None.""" data: dict = {"title": "Test"} - result = _annotate_data(data, run_config, repository_info) + result = _annotate_data(data, state, run_config, repository_info) assert result is None assert "_template" in data + + +def test_injects_extensions(run_config, repository_info): + """_annotate_data injects _extensions when state has extensions.""" + state = CookieploneState( + schema={"version": "2.0", "properties": {}}, + data={"cookiecutter": {}}, + extensions=["cookieplone.filters.latest_plone"], + ) + data: dict = {} + _annotate_data(data, state, run_config, repository_info) + assert data["_extensions"] == ["cookieplone.filters.latest_plone"] + + +def test_injects_no_render(run_config, repository_info): + """_annotate_data injects _copy_without_render when state has no_render.""" + state = CookieploneState( + schema={"version": "2.0", "properties": {}}, + data={"cookiecutter": {}}, + no_render=["*.png", "devops/etc"], + ) + data: dict = {} + _annotate_data(data, state, run_config, repository_info) + assert data["_copy_without_render"] == ["*.png", "devops/etc"] + + +def test_injects_subtemplates(run_config, repository_info): + """_annotate_data injects __cookieplone_subtemplates as v1 tuples.""" + state = CookieploneState( + schema={"version": "2.0", "properties": {}}, + data={"cookiecutter": {}}, + subtemplates=[ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + ], + ) + data: dict = {} + _annotate_data(data, state, run_config, repository_info) + assert data["__cookieplone_subtemplates"] == [["sub/backend", "Backend", "1"]] + + +def test_injects_empty_config_defaults(state, run_config, repository_info): + """_annotate_data injects config keys with empty defaults when state has none.""" + data: dict = {} + _annotate_data(data, state, run_config, repository_info) + assert data["_extensions"] == [] + assert data["_copy_without_render"] == [] + assert data["__cookieplone_subtemplates"] == [] + assert data["__cookieplone_template"] == "" diff --git a/tests/test_filters.py b/tests/test_filters.py index 4f3bceb..c9df3d7 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -3,7 +3,8 @@ import pytest from cookiecutter.generate import generate_context -from cookiecutter.utils import create_env_with_context + +from cookieplone.utils.cookiecutter import create_jinja_env @pytest.fixture @@ -101,6 +102,5 @@ def func(filter_: str) -> Path: def test_filters(generate_context_file, filter_: str, raw: str, expected: str): path = generate_context_file(filter_) context = generate_context(path) - env = create_env_with_context(context) - result = env.from_string(raw) - assert result.render() == expected + env = create_jinja_env(context) + assert env.from_string(raw).render() == expected diff --git a/tests/utils/test_config.py b/tests/utils/test_config.py index 7185eb2..4fd9213 100644 --- a/tests/utils/test_config.py +++ b/tests/utils/test_config.py @@ -246,11 +246,18 @@ def test_convert_v1_to_v2(read_config_file, config_file: str): result = config.convert_v1_to_v2(data) assert isinstance(result, dict) - assert result.get("title") == "Cookieplone" - assert result.get("version") == "2.0" - assert "properties" in result - properties = result["properties"] + assert "schema" in result + schema = result["schema"] + assert schema.get("title") == "Cookieplone" + assert schema.get("version") == "2.0" + assert "properties" in schema + properties = schema["properties"] assert isinstance(properties, dict) + # Config keys should not appear in properties + assert "_extensions" not in properties + assert "_copy_without_render" not in properties + assert "__cookieplone_subtemplates" not in properties + assert "__cookieplone_template" not in properties @pytest.mark.parametrize("config_file", CONFIG_FILES) @@ -258,7 +265,7 @@ def test_convert_v1_to_v2_choice_uses_one_of(read_config_file, config_file: str) """Test that choice fields use oneOf instead of options.""" data = read_config_file(config_file) result = config.convert_v1_to_v2(data) - properties = result["properties"] + properties = result["schema"]["properties"] for key, prop in properties.items(): assert "options" not in prop, ( f"Property '{key}' uses 'options' instead of 'oneOf'" @@ -286,7 +293,7 @@ def test_simple_choice_field(self): }, } result = config.convert_v1_to_v2(data) - prop = result["properties"]["language_code"] + prop = result["schema"]["properties"]["language_code"] assert "options" not in prop assert "oneOf" in prop @@ -310,7 +317,7 @@ def test_boolean_like_choice_field(self): }, } result = config.convert_v1_to_v2(data) - prop = result["properties"]["enable_feature"] + prop = result["schema"]["properties"]["enable_feature"] assert "options" not in prop assert prop["oneOf"] == [ @@ -324,7 +331,7 @@ def test_choice_field_without_prompts(self): "color": ["red", "blue", "green"], } result = config.convert_v1_to_v2(data) - prop = result["properties"]["color"] + prop = result["schema"]["properties"]["color"] assert "options" not in prop assert prop["oneOf"] == [] diff --git a/tests/utils/test_cookiecutter.py b/tests/utils/test_cookiecutter.py index b2a61ee..92cfa88 100644 --- a/tests/utils/test_cookiecutter.py +++ b/tests/utils/test_cookiecutter.py @@ -2,8 +2,9 @@ import pytest from cookiecutter.exceptions import OutputDirExistsException +from jinja2 import Environment -from cookieplone.utils.cookiecutter import parse_output_dir_exception +from cookieplone.utils.cookiecutter import create_jinja_env, parse_output_dir_exception class TestParseOutputDirException: @@ -38,3 +39,37 @@ def test_parse_output_dir_exception(self, project_dir, msg_template, expect_path assert result == f"'{project_dir}'" else: assert result == "" + + +class TestCreateJinjaEnv: + """Tests for create_jinja_env.""" + + def test_returns_environment(self): + """create_jinja_env returns a Jinja2 Environment.""" + env = create_jinja_env({}) + assert isinstance(env, Environment) + + def test_wraps_plain_dict(self): + """A plain data dict is wrapped under DEFAULT_DATA_KEY.""" + env = create_jinja_env({"title": "Hello"}) + result = env.from_string("{{ cookiecutter.title }}").render() + assert result == "Hello" + + def test_accepts_full_context(self): + """A dict already keyed by DEFAULT_DATA_KEY is used as-is.""" + context = {"cookiecutter": {"title": "Hello"}} + env = create_jinja_env(context) + result = env.from_string("{{ cookiecutter.title }}").render() + assert result == "Hello" + + def test_renders_expression(self): + """Jinja2 expressions are evaluated against the context.""" + env = create_jinja_env({"name": "plone", "version": "6.1"}) + template = "{{ cookiecutter.name }}-{{ cookiecutter.version }}" + assert env.from_string(template).render() == "plone-6.1" + + def test_env_is_reusable(self): + """A single env can render multiple templates.""" + env = create_jinja_env({"a": "1", "b": "2"}) + assert env.from_string("{{ cookiecutter.a }}").render() == "1" + assert env.from_string("{{ cookiecutter.b }}").render() == "2" diff --git a/tests/utils/test_subtemplates.py b/tests/utils/test_subtemplates.py new file mode 100644 index 0000000..da9e0e1 --- /dev/null +++ b/tests/utils/test_subtemplates.py @@ -0,0 +1,104 @@ +"""Tests for cookieplone.utils.subtemplates.""" + +import pytest + +from cookieplone.config import CookieploneState +from cookieplone.utils.subtemplates import process_subtemplates + + +@pytest.fixture +def make_state(): + """Create a CookieploneState with the given subtemplates.""" + + def func(subtemplates: list[dict[str, str]]) -> CookieploneState: + return CookieploneState( + schema={"version": "2.0", "properties": {}}, + data={"cookiecutter": {}}, + subtemplates=subtemplates, + ) + + return func + + +class TestProcessSubtemplates: + """Tests for process_subtemplates.""" + + def test_empty_subtemplates(self, make_state): + """Returns empty list when state has no subtemplates.""" + state = make_state([]) + assert process_subtemplates(state, {}) == [] + + def test_none_subtemplates(self): + """Returns empty list when subtemplates is None.""" + state = CookieploneState( + schema={"version": "2.0", "properties": {}}, + data={"cookiecutter": {}}, + ) + assert process_subtemplates(state, {}) == [] + + def test_static_enabled(self, make_state): + """Static enabled values are passed through unchanged.""" + state = make_state([ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + {"id": "sub/frontend", "title": "Frontend", "enabled": "0"}, + ]) + result = process_subtemplates(state, {}) + assert result == [ + ["sub/backend", "Backend", "1"], + ["sub/frontend", "Frontend", "0"], + ] + + def test_jinja_enabled_rendered(self, make_state): + """Jinja2 expressions in enabled are rendered against the context.""" + state = make_state([ + { + "id": "docs/starter", + "title": "Documentation", + "enabled": "{{ cookiecutter.initialize_docs }}", + }, + ]) + data = {"initialize_docs": "1"} + result = process_subtemplates(state, data) + assert result == [["docs/starter", "Documentation", "1"]] + + def test_jinja_enabled_false(self, make_state): + """Jinja2 expression resolves to the actual context value.""" + state = make_state([ + { + "id": "sub/frontend", + "title": "Frontend", + "enabled": "{{ cookiecutter.has_frontend }}", + }, + ]) + data = {"has_frontend": "0"} + result = process_subtemplates(state, data) + assert result == [["sub/frontend", "Frontend", "0"]] + + def test_mixed_static_and_jinja(self, make_state): + """Mix of static and Jinja2 enabled values.""" + state = make_state([ + {"id": "sub/backend", "title": "Backend", "enabled": "1"}, + { + "id": "sub/frontend", + "title": "Frontend", + "enabled": "{{ cookiecutter.has_frontend }}", + }, + {"id": "sub/settings", "title": "Settings", "enabled": "1"}, + ]) + data = {"has_frontend": "1"} + result = process_subtemplates(state, data) + assert result == [ + ["sub/backend", "Backend", "1"], + ["sub/frontend", "Frontend", "1"], + ["sub/settings", "Settings", "1"], + ] + + def test_preserves_order(self, make_state): + """Output order matches input order.""" + state = make_state([ + {"id": "c", "title": "C", "enabled": "1"}, + {"id": "a", "title": "A", "enabled": "1"}, + {"id": "b", "title": "B", "enabled": "1"}, + ]) + result = process_subtemplates(state, {}) + assert [r[0] for r in result] == ["c", "a", "b"]