diff --git a/CHANGELOG.md b/CHANGELOG.md index 76120be9..0b43ee16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,15 @@ Bugs: - `[dev]` `make docs` zero-warnings gate now uses `&&` (was `;`), so the recipe correctly fails on Sphinx warnings instead of silently succeeding via the trailing `cd ..` exit code +- `[ext.generate]` Fix `variables` default from `{}` to `[]` — the + generate flow iterates `variables` as a list of dicts, so the dict + default would have produced an empty key-iteration on templates + that omit the key +- `[ext.generate]` Narrow the dynamic-template-module `except` from + bare `AttributeError` to `(AttributeError, ModuleNotFoundError)` + with a `name`-match guard, so transitive `ModuleNotFoundError`s + raised inside the user's template module propagate normally + instead of being silently swallowed as "module not found" Features: @@ -154,6 +163,12 @@ Features: String form is split + whitespace-trimmed, parallel to the existing `extensions` config handling. - [Issue #746](https://github.com/datafolklabs/cement/issues/746) +- `[ext.generate]` Add optional features support to generate templates + with conditional variables, exclude/ignore patterns, and dependency + resolution via `requires` (with transitive cascade when a required + feature is disabled). Resolution is order-independent — features may + declare `requires` against features defined later in the YAML. + - [Issue #743](https://github.com/datafolklabs/cement/issues/743) Refactoring: diff --git a/CLAUDE.md b/CLAUDE.md index d61da3b2..6c2cb982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `pdm run pytest --cov=cement.core tests/core` - Test only core components **Development Environment:** -- `pdm venv create && pdm install` - Set up local development environment +- `make init` - Set up local development environment - `pdm run cement --help` - Run the cement CLI **Documentation:** diff --git a/cement/ext/ext_generate.py b/cement/ext/ext_generate.py index c7be0d5e..28ac5e46 100644 --- a/cement/ext/ext_generate.py +++ b/cement/ext/ext_generate.py @@ -26,6 +26,81 @@ class Meta(Controller.Meta): _meta: Meta # type: ignore + def _process_features(self, + features: list[dict[str, Any]], + vars: list[dict[str, Any]], + exclude_list: list[str], + ignore_list: list[str], + data: dict[str, Any]) -> None: + # validate up front so misconfigurations fail fast and consistently + # under `python -O` (asserts get stripped, ValueError does not). + feature_by_name: dict[str, dict[str, Any]] = {} + for feature in features: + if feature.get('name') is None: + raise ValueError( + "Required feature config key missing: name") + feature_by_name[feature['name']] = feature + for feature in features: + for req in feature.get('requires', []): + if req not in feature_by_name: + raise ValueError( + f"Feature '{feature['name']}' requires unknown " + f"feature '{req}'") + + # Resolve features lazily and recursively so that: + # 1. resolution is order-independent — a feature may declare + # `requires` against another feature defined later in the YAML; + # 2. interactive runs do not nag the user for a feature whose + # `requires` already resolved to False (the prompt is reached + # only after every prerequisite returned True). + feature_states: dict[str, bool] = {} + + def _resolve(name: str) -> bool: + if name in feature_states: + return feature_states[name] + feature = feature_by_name[name] + for req in feature.get('requires', []): + if not _resolve(req): + self.app.log.warning( + f"Feature '{name}' disabled (requires: {req})" + ) + feature_states[name] = False + return False + default = bool(feature.get('default', False)) + if self.app.pargs.defaults: + feature_states[name] = default + else: + default_hint = 'Y/n' if default else 'y/N' # pragma: nocover + default_val = 'y' if default else 'n' # pragma: nocover + + class FeaturePrompt(shell.Prompt): # pragma: nocover + class Meta: + text = f"Enable Feature: {name} [{default_hint}]:" + default = default_val + + p = FeaturePrompt(auto=False) # pragma: nocover + val: str = p.prompt() or default_val # pragma: nocover + feature_states[name] = val.lower() == 'y' # pragma: nocover + return feature_states[name] + + for feature in features: + _resolve(feature['name']) + + # merge enabled/disabled configs + if 'features' not in data: + data['features'] = {} + for feature in features: + name = feature['name'] + enabled = feature_states[name] + data['features'][name] = enabled + + block_key = 'enabled' if enabled else 'disabled' + block = feature.get(block_key) or {} + + vars.extend(block.get('variables', [])) + exclude_list.extend(block.get('exclude', [])) + ignore_list.extend(block.get('ignore', [])) + def _generate(self, source: str, dest: str) -> None: msg = f'Generating {self.app._meta.label} {self._meta.label} in {dest}' self.app.log.info(msg) @@ -44,14 +119,23 @@ def _generate(self, source: str, dest: str) -> None: g_config = yaml_load(f) f.close() - vars = g_config.get('variables', {}) - exclude_list = g_config.get('exclude', []) - ignore_list = g_config.get('ignore', []) + # Use `or []` (not the .get default) so explicit `key: null` in the + # YAML coalesces to an empty list — otherwise the subsequent + # `for ... in vars` / `ignore_list.append(...)` would crash on None. + vars = g_config.get('variables') or [] + exclude_list = g_config.get('exclude') or [] + ignore_list = g_config.get('ignore') or [] # default ignore the .generate.yml config g_config_yml = rf'^(.*)[\/\\\\]{self._meta.label}[\/\\\\]\.generate\.yml$' ignore_list.append(g_config_yml) + # process features (merge enabled/disabled config into vars/exclude/ignore) + features = g_config.get('features', []) + if features: + self._process_features(features, vars, exclude_list, + ignore_list, data) + var_defaults: dict = { 'name': None, 'prompt': None, @@ -156,11 +240,26 @@ def setup_template_items(app: "App") -> None: if os.path.exists(subpath) and subpath not in template_dirs: template_dirs.append(subpath) - # FIXME: not exactly sure how to test for this so not covering + # FIXME: AttributeError can fire if the imported module lacks + # __file__ (e.g., built-in / namespace packages); not testable + # from cement, so keep pragma: nocover on this branch. except AttributeError: # pragma: nocover # untestable: dynamic import - msg = 'unable to load template module' + \ - f"{mod} from {'.'.join(mod_parts)}" # pragma: nocover # untestable: dynamic import # noqa: E501 + msg = ('unable to load template module ' + f"'{mod_name}' from '{'.'.join(mod_parts)}'") app.log.debug(msg) # pragma: nocover # untestable: dynamic import + except ModuleNotFoundError as e: + # Only swallow when the missing module is the template module + # path we tried to import. A ModuleNotFoundError raised inside + # the user's template module (transitive dep missing) must + # propagate — otherwise we mask the real failure as a generic + # "template module not found" debug log. + expected = '.'.join(mod_parts + [mod_name]) + if e.name and e.name != expected and \ + not expected.startswith(f"{e.name}."): + raise + msg = ('unable to load template module ' + f"'{mod_name}' from '{'.'.join(mod_parts)}'") + app.log.debug(msg) for path in template_dirs: for item in os.listdir(path): diff --git a/demo/generate-features/README.md b/demo/generate-features/README.md new file mode 100644 index 00000000..14dee91b --- /dev/null +++ b/demo/generate-features/README.md @@ -0,0 +1,99 @@ +# Demo: Generate Extension - Features + +Demonstrates the `features` support in the `generate` extension, which allows +`.generate.yml` templates to conditionally include/exclude files and prompt for +additional variables based on optional feature toggles. + +## Template + +The `templates/generate/webapp/` template defines: + +- **Base variable:** `project_name` +- **Feature: `docker`** (default: enabled) — includes `Dockerfile` and + `.dockerignore`, prompts for `python_version` +- **Feature: `docker_compose`** (default: enabled, **requires: docker**) — + includes `docker-compose.yml` + +## Usage + +Run from this directory: + +```bash +# Generate with all defaults (docker=on, docker_compose=on) +pdm run python myapp.py generate webapp /tmp/myproject --defaults + +# Generate interactively (prompts for each variable and feature) +pdm run python myapp.py generate webapp /tmp/myproject + +# Generate with --force to overwrite existing output +pdm run python myapp.py generate webapp /tmp/myproject --defaults --force +``` + +## Configuration + +*templates/generate/webapp* + +```yaml +--- + +variables: + - name: project_name + prompt: "Project Name" + default: "myproject" + case: lower + +features: + - name: docker + default: true + enabled: + variables: + - name: python_version + prompt: "Python Version (for Docker)" + default: "3.13" + disabled: + ignore: + - '.*Dockerfile.*' + - '.*\.dockerignore.*' + + - name: docker_compose + default: true + requires: + - docker + disabled: + ignore: + - '.*docker-compose.*' +``` + +## What to Expect + +**With defaults** (both features enabled): + +```text +/tmp/myproject/ +├── .dockerignore +├── Dockerfile +├── README.md +├── app.py +└── docker-compose.yml +``` + +**With docker disabled** (docker_compose is auto-disabled via `requires`): + +```text +/tmp/myproject/ +├── README.md +└── app.py +``` + +No Dockerfile, no docker-compose.yml — disabling docker automatically +disables docker_compose because it requires docker. + +**With docker enabled, docker_compose disabled**: + +```text +/tmp/myproject/ +├── .dockerignore +├── Dockerfile +├── README.md +└── app.py +``` diff --git a/demo/generate-features/templates/generate/webapp/.dockerignore b/demo/generate-features/templates/generate/webapp/.dockerignore new file mode 100644 index 00000000..0010eec1 --- /dev/null +++ b/demo/generate-features/templates/generate/webapp/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +.env +.env.* +node_modules +*.log +.DS_Store diff --git a/demo/generate-features/templates/generate/webapp/.generate.yml b/demo/generate-features/templates/generate/webapp/.generate.yml new file mode 100644 index 00000000..a0344d96 --- /dev/null +++ b/demo/generate-features/templates/generate/webapp/.generate.yml @@ -0,0 +1,28 @@ +--- + +variables: + - name: project_name + prompt: "Project Name" + default: "myproject" + case: lower + +features: + - name: docker + default: true + enabled: + variables: + - name: python_version + prompt: "Python Version (for Docker)" + default: "3.13" + disabled: + ignore: + - '.*Dockerfile.*' + - '.*\.dockerignore.*' + + - name: docker_compose + default: true + requires: + - docker + disabled: + ignore: + - '.*docker-compose.*' diff --git a/demo/generate-features/templates/generate/webapp/Dockerfile b/demo/generate-features/templates/generate/webapp/Dockerfile new file mode 100644 index 00000000..09a1ebbb --- /dev/null +++ b/demo/generate-features/templates/generate/webapp/Dockerfile @@ -0,0 +1,12 @@ +FROM python:{{ python_version }}-slim + +WORKDIR /app + +COPY . . + +# Drop root: create an unprivileged user, hand it /app, switch to it. +RUN useradd --create-home --shell /bin/bash appuser \ + && chown -R appuser:appuser /app +USER appuser + +CMD ["python", "app.py"] diff --git a/demo/generate-features/templates/generate/webapp/README.md b/demo/generate-features/templates/generate/webapp/README.md new file mode 100644 index 00000000..ba9b0baa --- /dev/null +++ b/demo/generate-features/templates/generate/webapp/README.md @@ -0,0 +1,3 @@ +# {{ project_name }} + +Generated by myapp. diff --git a/demo/generate-features/templates/generate/webapp/app.py b/demo/generate-features/templates/generate/webapp/app.py new file mode 100644 index 00000000..94a2e525 --- /dev/null +++ b/demo/generate-features/templates/generate/webapp/app.py @@ -0,0 +1,9 @@ +"""{{ project_name }} - main application.""" + + +def main(): + print("Hello from {{ project_name }}!") + + +if __name__ == "__main__": + main() diff --git a/demo/generate-features/templates/generate/webapp/docker-compose.yml b/demo/generate-features/templates/generate/webapp/docker-compose.yml new file mode 100644 index 00000000..42df2c03 --- /dev/null +++ b/demo/generate-features/templates/generate/webapp/docker-compose.yml @@ -0,0 +1,4 @@ +services: + app: + build: . + container_name: {{ project_name }} diff --git a/tests/data/bad_template_module/__init__.py b/tests/data/bad_template_module/__init__.py new file mode 100644 index 00000000..c25734f7 --- /dev/null +++ b/tests/data/bad_template_module/__init__.py @@ -0,0 +1,6 @@ +# Fixture: a real Python package whose import raises ModuleNotFoundError +# for a *different* module name than the package itself. Used by +# tests/ext/test_ext_generate.py to verify that setup_template_items +# propagates transitive ModuleNotFoundError raised inside the user's +# template module instead of silently swallowing it as "module not found". +import nonexistent_transitive_dep # noqa: F401 diff --git a/tests/data/templates/generate/test10/.generate.yml b/tests/data/templates/generate/test10/.generate.yml new file mode 100644 index 00000000..0a1dbef8 --- /dev/null +++ b/tests/data/templates/generate/test10/.generate.yml @@ -0,0 +1,14 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: true + requires: + - nonexistent diff --git a/tests/data/templates/generate/test10/take-me b/tests/data/templates/generate/test10/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test10/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test11/.generate.yml b/tests/data/templates/generate/test11/.generate.yml new file mode 100644 index 00000000..e9b4e4a4 --- /dev/null +++ b/tests/data/templates/generate/test11/.generate.yml @@ -0,0 +1,23 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: false + disabled: + ignore: + - '.*feature1-only.*' + +- name: feature2 + default: true + requires: + - feature1 + disabled: + ignore: + - '.*feature2-only.*' diff --git a/tests/data/templates/generate/test11/feature1-only b/tests/data/templates/generate/test11/feature1-only new file mode 100644 index 00000000..459e07f7 --- /dev/null +++ b/tests/data/templates/generate/test11/feature1-only @@ -0,0 +1 @@ +feature1 content diff --git a/tests/data/templates/generate/test11/feature2-only b/tests/data/templates/generate/test11/feature2-only new file mode 100644 index 00000000..8262057b --- /dev/null +++ b/tests/data/templates/generate/test11/feature2-only @@ -0,0 +1 @@ +feature2 content diff --git a/tests/data/templates/generate/test11/take-me b/tests/data/templates/generate/test11/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test11/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test12/.generate.yml b/tests/data/templates/generate/test12/.generate.yml new file mode 100644 index 00000000..9002624d --- /dev/null +++ b/tests/data/templates/generate/test12/.generate.yml @@ -0,0 +1,13 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: true + enabled: diff --git a/tests/data/templates/generate/test12/take-me b/tests/data/templates/generate/test12/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test12/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test13/.generate.yml b/tests/data/templates/generate/test13/.generate.yml new file mode 100644 index 00000000..0906ce51 --- /dev/null +++ b/tests/data/templates/generate/test13/.generate.yml @@ -0,0 +1,12 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: true diff --git a/tests/data/templates/generate/test13/take-me b/tests/data/templates/generate/test13/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test13/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test14/.generate.yml b/tests/data/templates/generate/test14/.generate.yml new file mode 100644 index 00000000..258c33d3 --- /dev/null +++ b/tests/data/templates/generate/test14/.generate.yml @@ -0,0 +1,31 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: false + disabled: + ignore: + - '.*feature1-only.*' + +- name: feature2 + default: true + requires: + - feature1 + disabled: + ignore: + - '.*feature2-only.*' + +- name: feature3 + default: true + requires: + - feature2 + disabled: + ignore: + - '.*feature3-only.*' diff --git a/tests/data/templates/generate/test14/feature1-only b/tests/data/templates/generate/test14/feature1-only new file mode 100644 index 00000000..459e07f7 --- /dev/null +++ b/tests/data/templates/generate/test14/feature1-only @@ -0,0 +1 @@ +feature1 content diff --git a/tests/data/templates/generate/test14/feature2-only b/tests/data/templates/generate/test14/feature2-only new file mode 100644 index 00000000..8262057b --- /dev/null +++ b/tests/data/templates/generate/test14/feature2-only @@ -0,0 +1 @@ +feature2 content diff --git a/tests/data/templates/generate/test14/feature3-only b/tests/data/templates/generate/test14/feature3-only new file mode 100644 index 00000000..746cd75f --- /dev/null +++ b/tests/data/templates/generate/test14/feature3-only @@ -0,0 +1 @@ +feature3 content diff --git a/tests/data/templates/generate/test14/take-me b/tests/data/templates/generate/test14/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test14/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test15/.generate.yml b/tests/data/templates/generate/test15/.generate.yml new file mode 100644 index 00000000..34db2d0b --- /dev/null +++ b/tests/data/templates/generate/test15/.generate.yml @@ -0,0 +1,30 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +# Out-of-order requires: feature_b requires feature_a, but feature_b is +# declared FIRST in the YAML. Both default to true. The buggy single-pass +# resolver would auto-disable feature_b because feature_a's state has not +# been computed when feature_b is processed. The two-pass / fixpoint +# resolver computes all states first, then cascades, so feature_b stays +# enabled here. + +features: + +- name: feature_b + default: true + requires: + - feature_a + disabled: + ignore: + - '.*feature_b-only.*' + +- name: feature_a + default: true + disabled: + ignore: + - '.*feature_a-only.*' diff --git a/tests/data/templates/generate/test15/feature_a-only b/tests/data/templates/generate/test15/feature_a-only new file mode 100644 index 00000000..40bd7a0d --- /dev/null +++ b/tests/data/templates/generate/test15/feature_a-only @@ -0,0 +1 @@ +feature_a content diff --git a/tests/data/templates/generate/test15/feature_b-only b/tests/data/templates/generate/test15/feature_b-only new file mode 100644 index 00000000..1cf39f50 --- /dev/null +++ b/tests/data/templates/generate/test15/feature_b-only @@ -0,0 +1 @@ +feature_b content diff --git a/tests/data/templates/generate/test15/take-me b/tests/data/templates/generate/test15/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test15/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test6/.generate.yml b/tests/data/templates/generate/test6/.generate.yml new file mode 100644 index 00000000..14fd383d --- /dev/null +++ b/tests/data/templates/generate/test6/.generate.yml @@ -0,0 +1,36 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: true + enabled: + variables: + - name: feature1_var + prompt: Feature1 Variable + default: feature1_val + ignore: + - '.*no-feature1.*' + disabled: + exclude: + - '.*feature1-file.*' + ignore: + - '.*feature1-only.*' + +- name: feature2 + default: false + enabled: + variables: + - name: feature2_var + prompt: Feature2 Variable + default: feature2_val + disabled: + ignore: + - '.*feature2-only.*' + - '.*feature2-file.*' diff --git a/tests/data/templates/generate/test6/feature1-file b/tests/data/templates/generate/test6/feature1-file new file mode 100644 index 00000000..f5165711 --- /dev/null +++ b/tests/data/templates/generate/test6/feature1-file @@ -0,0 +1 @@ +feature1_var => {{ feature1_var }} diff --git a/tests/data/templates/generate/test6/feature1-only b/tests/data/templates/generate/test6/feature1-only new file mode 100644 index 00000000..2516000b --- /dev/null +++ b/tests/data/templates/generate/test6/feature1-only @@ -0,0 +1 @@ +feature1 only content diff --git a/tests/data/templates/generate/test6/feature2-file b/tests/data/templates/generate/test6/feature2-file new file mode 100644 index 00000000..39ae57ea --- /dev/null +++ b/tests/data/templates/generate/test6/feature2-file @@ -0,0 +1 @@ +feature2_var => {{ feature2_var }} diff --git a/tests/data/templates/generate/test6/feature2-only b/tests/data/templates/generate/test6/feature2-only new file mode 100644 index 00000000..6d32c72f --- /dev/null +++ b/tests/data/templates/generate/test6/feature2-only @@ -0,0 +1 @@ +feature2 only content diff --git a/tests/data/templates/generate/test6/no-feature1 b/tests/data/templates/generate/test6/no-feature1 new file mode 100644 index 00000000..b870f357 --- /dev/null +++ b/tests/data/templates/generate/test6/no-feature1 @@ -0,0 +1 @@ +no feature1 content diff --git a/tests/data/templates/generate/test6/take-me b/tests/data/templates/generate/test6/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test6/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test7/.generate.yml b/tests/data/templates/generate/test7/.generate.yml new file mode 100644 index 00000000..13dbab75 --- /dev/null +++ b/tests/data/templates/generate/test7/.generate.yml @@ -0,0 +1,35 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: false + enabled: + variables: + - name: feature1_var + prompt: Feature1 Variable + default: feature1_val + ignore: + - '.*no-feature1.*' + disabled: + exclude: + - '.*feature1-file.*' + ignore: + - '.*feature1-only.*' + +- name: feature2 + default: true + enabled: + variables: + - name: feature2_var + prompt: Feature2 Variable + default: feature2_val + disabled: + ignore: + - '.*feature2-only.*' diff --git a/tests/data/templates/generate/test7/feature1-file b/tests/data/templates/generate/test7/feature1-file new file mode 100644 index 00000000..f5165711 --- /dev/null +++ b/tests/data/templates/generate/test7/feature1-file @@ -0,0 +1 @@ +feature1_var => {{ feature1_var }} diff --git a/tests/data/templates/generate/test7/feature1-only b/tests/data/templates/generate/test7/feature1-only new file mode 100644 index 00000000..2516000b --- /dev/null +++ b/tests/data/templates/generate/test7/feature1-only @@ -0,0 +1 @@ +feature1 only content diff --git a/tests/data/templates/generate/test7/feature2-file b/tests/data/templates/generate/test7/feature2-file new file mode 100644 index 00000000..39ae57ea --- /dev/null +++ b/tests/data/templates/generate/test7/feature2-file @@ -0,0 +1 @@ +feature2_var => {{ feature2_var }} diff --git a/tests/data/templates/generate/test7/feature2-only b/tests/data/templates/generate/test7/feature2-only new file mode 100644 index 00000000..6d32c72f --- /dev/null +++ b/tests/data/templates/generate/test7/feature2-only @@ -0,0 +1 @@ +feature2 only content diff --git a/tests/data/templates/generate/test7/no-feature1 b/tests/data/templates/generate/test7/no-feature1 new file mode 100644 index 00000000..b870f357 --- /dev/null +++ b/tests/data/templates/generate/test7/no-feature1 @@ -0,0 +1 @@ +no feature1 content diff --git a/tests/data/templates/generate/test7/take-me b/tests/data/templates/generate/test7/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test7/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test8/.generate.yml b/tests/data/templates/generate/test8/.generate.yml new file mode 100644 index 00000000..8ea97121 --- /dev/null +++ b/tests/data/templates/generate/test8/.generate.yml @@ -0,0 +1,11 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- default: true diff --git a/tests/data/templates/generate/test8/take-me b/tests/data/templates/generate/test8/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test8/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test9/.generate.yml b/tests/data/templates/generate/test9/.generate.yml new file mode 100644 index 00000000..770b382e --- /dev/null +++ b/tests/data/templates/generate/test9/.generate.yml @@ -0,0 +1,23 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +features: + +- name: feature1 + default: true + disabled: + ignore: + - '.*feature1-only.*' + +- name: feature2 + default: true + requires: + - feature1 + disabled: + ignore: + - '.*feature2-only.*' diff --git a/tests/data/templates/generate/test9/feature1-only b/tests/data/templates/generate/test9/feature1-only new file mode 100644 index 00000000..459e07f7 --- /dev/null +++ b/tests/data/templates/generate/test9/feature1-only @@ -0,0 +1 @@ +feature1 content diff --git a/tests/data/templates/generate/test9/feature2-only b/tests/data/templates/generate/test9/feature2-only new file mode 100644 index 00000000..8262057b --- /dev/null +++ b/tests/data/templates/generate/test9/feature2-only @@ -0,0 +1 @@ +feature2 content diff --git a/tests/data/templates/generate/test9/take-me b/tests/data/templates/generate/test9/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test9/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/ext/test_ext_generate.py b/tests/ext/test_ext_generate.py index b19e9e37..723eaa30 100644 --- a/tests/ext/test_ext_generate.py +++ b/tests/ext/test_ext_generate.py @@ -156,3 +156,211 @@ def test_filtered_sub_dirs(tmp): assert exists_join(tmp.dir, 'exclude-me', 'take-me') assert not exists_join(tmp.dir, 'ignore-me') assert not exists_join(tmp.dir, 'ignore-me', 'take-me') + + +def test_generate_features_enabled(tmp): + # feature1 defaults to true, feature2 defaults to false + argv = ['generate', 'test6', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + # base variable rendered + assert exists_join(tmp.dir, 'take-me') + with open(os.path.join(tmp.dir, 'take-me')) as f: + res = f.read() + assert 'myapp' in res + + # feature1 enabled: its variable is available and rendered + assert exists_join(tmp.dir, 'feature1-file') + with open(os.path.join(tmp.dir, 'feature1-file')) as f: + res = f.read() + assert 'feature1_val' in res + + # feature1 enabled: feature1-only is kept (not ignored) + assert exists_join(tmp.dir, 'feature1-only') + + # feature1 enabled: no-feature1 is ignored + assert not exists_join(tmp.dir, 'no-feature1') + + # feature2 disabled: feature2-only is ignored + assert not exists_join(tmp.dir, 'feature2-only') + + +def test_generate_features_disabled(tmp): + # test6 with feature1 disabled via a separate config (test7) + # create test7 that flips defaults: feature1=false, feature2=true + argv = ['generate', 'test7', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + # base variable rendered + assert exists_join(tmp.dir, 'take-me') + with open(os.path.join(tmp.dir, 'take-me')) as f: + res = f.read() + assert 'myapp' in res + + # feature1 disabled: feature1-file is excluded (copied, not rendered) + assert exists_join(tmp.dir, 'feature1-file') + with open(os.path.join(tmp.dir, 'feature1-file')) as f: + res = f.read() + assert re.match(r'.*\{\{ feature1_var \}\}.*', res) + + # feature1 disabled: feature1-only is ignored + assert not exists_join(tmp.dir, 'feature1-only') + + # feature1 disabled: no-feature1 is kept + assert exists_join(tmp.dir, 'no-feature1') + + # feature2 enabled: feature2-only is kept + assert exists_join(tmp.dir, 'feature2-only') + + # feature2 enabled: its variable is available + assert exists_join(tmp.dir, 'feature2-file') + with open(os.path.join(tmp.dir, 'feature2-file')) as f: + res = f.read() + assert 'feature2_val' in res + + +def test_generate_features_missing_name(tmp): + argv = ['generate', 'test8', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match='Required feature config key missing: name'): + app.run() + + +def test_generate_features_requires_satisfied(tmp): + # test9: feature1=true (default), feature2=true (default), feature2 requires feature1 + argv = ['generate', 'test9', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + # both features enabled, both files present + assert exists_join(tmp.dir, 'take-me') + assert exists_join(tmp.dir, 'feature1-only') + assert exists_join(tmp.dir, 'feature2-only') + + +def test_generate_features_requires_not_satisfied(tmp): + # test11: feature1=false (default), feature2=true (default) but requires feature1 + # feature2 should be auto-disabled because feature1 is off + argv = ['generate', 'test11', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + + # feature1 disabled: feature1-only ignored + assert not exists_join(tmp.dir, 'feature1-only') + + # feature2 auto-disabled via requires: feature2-only ignored + assert not exists_join(tmp.dir, 'feature2-only') + + +def test_generate_features_requires_unknown(tmp): + # test10: feature requires a nonexistent feature + argv = ['generate', 'test10', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match="requires unknown feature"): + app.run() + + +def test_generate_features_minimal(tmp): + # test13: feature with only name and default, no enabled/disabled blocks + argv = ['generate', 'test13', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + with open(os.path.join(tmp.dir, 'take-me')) as f: + res = f.read() + assert 'myapp' in res + + +def test_generate_features_transitive_requires(tmp): + # test14: feature1=false, feature2=true requires feature1, + # feature3=true requires feature2 + # disabling feature1 should cascade: feature2 disabled, then feature3 disabled + argv = ['generate', 'test14', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + + # all three features should be disabled via transitive requires + assert not exists_join(tmp.dir, 'feature1-only') + assert not exists_join(tmp.dir, 'feature2-only') + assert not exists_join(tmp.dir, 'feature3-only') + + +def test_generate_features_requires_out_of_order(tmp): + # test15: feature_b (requires feature_a) is declared BEFORE feature_a. + # Both default to true. With the legacy single-pass resolver feature_b + # would be auto-disabled because feature_a's state has not been + # computed yet. The two-pass / fixpoint resolver evaluates all states + # first, then cascades, so both end up enabled regardless of the + # YAML declaration order. + argv = ['generate', 'test15', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + + # both features stay enabled despite the out-of-order declaration + assert exists_join(tmp.dir, 'feature_a-only') + assert exists_join(tmp.dir, 'feature_b-only') + + +def test_generate_features_null_block(tmp): + # test12: feature with enabled: null (no block defined) + argv = ['generate', 'test12', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + with open(os.path.join(tmp.dir, 'take-me')) as f: + res = f.read() + assert 'myapp' in res + + +def test_generate_bad_template_module(tmp): + class BadModuleApp(TestApp): + class Meta: + extensions = ['jinja2', 'yaml', 'generate', 'alarm'] + template_handler = 'jinja2' + template_module = 'nonexistent.templates' + handlers = [GenerateBase] + + argv = ['generate', 'test1', tmp.dir, '--defaults'] + with BadModuleApp(argv=argv, template_dir='tests/data/templates') as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + + +def test_generate_template_module_transitive_import_error(tmp): + # When the template module itself exists but raises ModuleNotFoundError + # for some other (transitive) module during import, setup_template_items + # must propagate the error rather than silently swallowing it as a + # generic "template module not found" debug log. + class TransitiveBadApp(TestApp): + class Meta: + extensions = ['jinja2', 'yaml', 'generate', 'alarm'] + template_handler = 'jinja2' + template_module = 'tests.data.bad_template_module' + handlers = [GenerateBase] + + argv = ['generate', 'test1', tmp.dir, '--defaults'] + with raises(ModuleNotFoundError, match='nonexistent_transitive_dep'): + with TransitiveBadApp(argv=argv, + template_dir='tests/data/templates') as app: + app.run()