Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
111 changes: 105 additions & 6 deletions cement/ext/ext_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +29 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

fd -type f -name "ext_generate.py"

Repository: datafolklabs/cement

Length of output: 1941


🏁 Script executed:

cat -n cement/ext/ext_generate.py | head -150

Repository: datafolklabs/cement

Length of output: 1941


🏁 Script executed:

rg -n "def _process_features|def _generate" cement/ext/ext_generate.py -A 10

Repository: datafolklabs/cement

Length of output: 1186


🏁 Script executed:

cat -n cement/ext/ext_generate.py | sed -n '120,130p'

Repository: datafolklabs/cement

Length of output: 601


🏁 Script executed:

rg -n "vars\b" cement/ext/ext_generate.py | head -20

Repository: datafolklabs/cement

Length of output: 446


Rename vars parameter and variable before merge.

The parameter vars on line 31 and the variable assignment on line 124 shadow Python's built-in vars() function. Ruff flags these as A002 and A001 violations respectively. Renaming both to variables (or similar) fixes the lint failures and improves readability since the data actually represents variables. This is required per the coding guideline: "All code must pass ruff linting (v0.3.2+)".

🧰 Tools
🪛 Ruff (0.15.12)

[error] 31-31: Function argument vars is shadowing a Python builtin

(A002)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cement/ext/ext_generate.py` around lines 29 - 34, Rename the parameter named
vars in the _process_features method to variables and also rename the local
variable currently assigned to vars (the one flagged by Ruff) to variables so
you no longer shadow Python's built-in vars(); update all references inside
_process_features to use variables instead of vars (including any iteration,
lookups, or passes to other functions) to fix the A002/A001 lint violations.

# 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)
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
99 changes: 99 additions & 0 deletions demo/generate-features/README.md
Original file line number Diff line number Diff line change
@@ -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
```
17 changes: 17 additions & 0 deletions demo/generate-features/templates/generate/webapp/.dockerignore
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions demo/generate-features/templates/generate/webapp/.generate.yml
Original file line number Diff line number Diff line change
@@ -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.*'
12 changes: 12 additions & 0 deletions demo/generate-features/templates/generate/webapp/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Comment on lines +3 to +12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the Dockerfile exists and read its full content
find demo -name "Dockerfile" -type f | head -20

Repository: datafolklabs/cement

Length of output: 122


🏁 Script executed:

# Look for the specific file mentioned
cat demo/generate-features/templates/generate/webapp/Dockerfile

Repository: datafolklabs/cement

Length of output: 149


🏁 Script executed:

# Check for .dockerignore in the same directory
ls -la demo/generate-features/templates/generate/webapp/

Repository: datafolklabs/cement

Length of output: 473


Drop root before starting the app.

The template never switches away from the default root user, so every generated container runs with full privileges. Add an unprivileged user and hand off /app before CMD.

Suggested fix
 FROM python:{{ python_version }}-slim
 
 WORKDIR /app
 COPY . .
+RUN useradd --create-home --shell /usr/sbin/nologin appuser \
+    && chown -R appuser:appuser /app
+USER appuser
 
 CMD ["python", "app.py"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
WORKDIR /app
COPY . .
CMD ["python", "app.py"]
FROM python:{{ python_version }}-slim
WORKDIR /app
COPY . .
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
&& chown -R appuser:appuser /app
USER appuser
CMD ["python", "app.py"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/generate-features/templates/generate/webapp/Dockerfile` around lines 3 -
6, Create and switch to an unprivileged user in the Dockerfile: add steps to
create a non-root user (e.g., "appuser"), chown -R /app to transfer ownership
after COPY, and add a USER appuser line before the existing CMD
["python","app.py"] so the container drops root privileges when starting the
application; keep WORKDIR /app and COPY . . as-is but ensure the chown happens
after the copy.

3 changes: 3 additions & 0 deletions demo/generate-features/templates/generate/webapp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# {{ project_name }}

Generated by myapp.
9 changes: 9 additions & 0 deletions demo/generate-features/templates/generate/webapp/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""{{ project_name }} - main application."""


def main():
print("Hello from {{ project_name }}!")


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
services:
app:
build: .
container_name: {{ project_name }}
6 changes: 6 additions & 0 deletions tests/data/bad_template_module/__init__.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions tests/data/templates/generate/test10/.generate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---

variables:

- name: app_name
prompt: App Name
default: myapp

features:

- name: feature1
default: true
requires:
- nonexistent
1 change: 1 addition & 0 deletions tests/data/templates/generate/test10/take-me
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
app_name => {{ app_name }}
Loading
Loading