Skip to content

feat(ext.generate): add prompt_mode: select for features (#779)#780

Closed
derks wants to merge 4 commits into
mainfrom
feat/generate-feature-prompt-multiple-values-pr
Closed

feat(ext.generate): add prompt_mode: select for features (#779)#780
derks wants to merge 4 commits into
mainfrom
feat/generate-feature-prompt-multiple-values-pr

Conversation

@derks
Copy link
Copy Markdown
Member

@derks derks commented May 24, 2026

Summary

Extends cement/ext/ext_generate.py (the .generate.yml template
engine) so a single feature can support more than two answers. Today
a feature prompt is boolean-like — Enable Feature: X [Y/n] — and
dispatches into either an enabled: or disabled: config block. This
PR adds an opt-in prompt_mode: select that presents a numbered
picker and dispatches the chosen value into one of N named branches
under options:. Resolves #779.

Implementation delegates directly to cement.utils.shell.Prompt
rather than building parallel prompt/validation infrastructure —
shell.Prompt.Meta.options + numbered=True + case_insensitive=True

  • Meta.default + the built-in max_attempts retry loop cover the
    whitelist, display, matching, and fallback behavior end-to-end.

The legacy boolean path is byte-identical when prompt_mode is
absent or set to boolean (the explicit default name). Every
pre-existing test fixture (tests/data/templates/generate/test1
test15) and the existing demo template walk through the new code
unchanged.

What landed

Schema — opt-in multi-valued prompts (feat: commit)

  • New optional feature keys: prompt_mode, default (required for
    select-mode), options.
  • options: is a list of dicts each with a required value: key
    and an optional prompt: label (falls back to str(value)).
    Labels are displayed in shell.Prompt's numbered list; value:
    is what flows downstream as the resolved feature state
    ({{ web_framework }} substitutes this string).
  • Each options branch may declare ignore / exclude /
    variables — same vocabulary as the existing
    enabled: / disabled: blocks.
  • Silent (no-prompt) variables via a prompt: false sentinel on a
    variable definition — the value lands from default without
    rendering an interactive prompt. Works inside options branches
    and at the top level. case and validate still apply to the
    silent default; str() coerce so YAML-decoded non-string defaults
    (bool, int) don't crash the case/validate operations.
  • Fail-fast validation up front (matches the existing requires
    validator discipline): ValueError on schema collision
    (prompt_mode: select + enabled/disabled blocks), unknown
    prompt_mode, missing value: key, empty options:, missing
    default:, default not in options values, duplicate option
    labels.
  • The or [] null-safety idiom (per commit 6895aa5) is
    generalized to the per-block keys on both the legacy and
    select-mode merge paths for consistency.

Demo — living example of both modes (docs(demo): commit)

  • demo/generate-features/ extended additively with a new
    web_framework select-mode feature (None / Flask / FastAPI) that
    uses silent framework_version variables under the flask /
    fastapi branches and ignore patterns on the none branch to
    suppress two new template files (requirements.txt, wsgi.py).
  • Existing docker / docker_compose boolean features stay
    byte-identical — they just gain an explicit prompt_mode: boolean
    for clarity. Omission and the explicit form are exactly
    equivalent (backward-compat absolute).
  • README adds a "Multi-Valued Feature (prompt_mode: select)"
    section walking through all the new keys, the numbered picker UX,
    silent variables, the --defaults headless path, and the
    requires: cascade interaction.

Changelog (docs(changelog): commit)

One line under the active ## 3.0.15 - DEVELOPMENT / Features
bucket per the project's CHANGELOG discipline ([ext.generate]
area tag).

Picker UX

A sequence of feature prompts renders as:

Enable Feature: docker [Y/n]: Y
Enable Feature: docker_compose [Y/n]: Y

Web Framework:

1: None — no web framework
2: Flask Web Framework
3: FastAPI Web Framework

Enter the number for your selection: 2

The select-mode prompt's leading blank line and trailing : keep
it consistent with the boolean feature prompt and variable prompts
(both of which hardcode : at the end of their format strings).

User picks 2, shell.Prompt returns "Flask Web Framework", the
merge step maps that label back to value: "flask" via positional
zip, walks the options[1] branch, and applies its variables /
ignore.

Acceptance status

# Truth Status
T1 prompt_mode: select explicit opt-in; legacy path byte-identical when absent or prompt_mode: boolean
T2 options: is list of dicts with required value: and optional prompt: label
T3 Silent vars via prompt: false; case+validate still apply to default; str-coerce for non-string YAML defaults
T4 Fail-fast: ValueError on schema collision / unknown prompt_mode / missing keys / default not in values / duplicate labels
T5 default: is REQUIRED for select-mode features
T6 prompt_mode defaults to 'boolean' explicitly; whitelist validator rejects everything else
T7 Multi-valued picker UX delegated to cement.utils.shell.Prompt (no parallel infrastructure)

Files touched

Area Files Notes
Implementation cement/ext/ext_generate.py +131 / -27; _process_features extended with select-mode validator + _resolve mode dispatch + select-mode merge
Tests tests/ext/test_ext_generate.py +190 lines, 15 new tests covering happy paths, every validator branch, silent vars, requires cascade, null-coalesce regressions
Test fixtures tests/data/templates/generate/test16test30 15 new .generate.yml fixture directories
Demo demo/generate-features/{README.md, templates/generate/webapp/.generate.yml, requirements.txt, wsgi.py} New web_framework feature + README walkthrough; explicit prompt_mode: boolean on existing features
Docs CHANGELOG.md One Features-bucket entry under active 3.0.15 - DEVELOPMENT
State .planning/STATE.md Quick-task completion record (preserved per project convention)

Test plan

  • make comply — ruff + mypy green
  • pdm run pytest --cov=cement.ext.ext_generate --cov-fail-under=100 tests/ext/test_ext_generate.py — 100% coverage on the extension (202/202 stmts), 35 tests pass
  • make audit-public-api — empty diff vs Phase 03 baseline (no public API leaked)
  • pdm run pytest tests/ext/test_ext_generate.py — all 35 tests pass (10 pre-existing memcached/redis failures elsewhere are infra, not this PR)
  • Manual: cd demo/generate-features && pdm run python myapp.py generate webapp /tmp/myproject --defaults --force — renders with web_framework=none defaults (no requirements.txt, no wsgi.py in output)
  • Manual: cd demo/generate-features && pdm run python myapp.py generate webapp /tmp/myproject and pick 3 at the Web Framework prompt — renders with wsgi.py and a requirements.txt containing fastapi==0.115
  • Spot-check a pre-existing template (tests/data/templates/generate/test12) walks the new code path byte-identically (legacy boolean regression anchor)

Closes #779.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added prompt_mode: select for feature prompts, enabling users to choose from multiple options with per-option configuration (variables, ignore patterns).
    • Added silent variable support (prompt: false) that uses defaults without prompting.
  • Documentation

    • Updated CHANGELOG with details on multi-valued feature selection behavior.
    • Enhanced demo showing select-mode features with option-specific file rendering and configuration.

Review Change Stack

derks and others added 4 commits May 24, 2026 16:02
Adds an opt-in multi-valued feature prompt to .generate.yml templates.
Today a feature is boolean-like and dispatches to enabled:/disabled:;
this adds a `prompt_mode: select` that presents a numbered picker
and dispatches the chosen value into one of N `options` branches.

Implementation delegates to cement.utils.shell.Prompt rather than
building parallel infrastructure: Meta.options + Meta.numbered=True
+ Meta.case_insensitive=True + Meta.default cover the whitelist,
display, matching, and fallback behavior end-to-end. The numbered
display returns the matched option label string, which the merge
step maps back to the underlying value via positional zip.

New optional schema:
- `prompt_mode: select` opts in. The default is `'boolean'` (legacy
  semantics, byte-identical when key absent — `prompt_mode: boolean`
  and omission are exactly equivalent).
- `default:` is required for select-mode and must be in the option
  values (validated at config-load).
- `options:` is a list of dicts; each has a required `value:` and an
  optional `prompt:` label (falls back to str(value)). Each option
  may also carry `ignore` / `exclude` / `variables` (same vocabulary
  as `enabled:` / `disabled:`).
- Silent (no-prompt) variables under any branch via `prompt: false`
  sentinel; `case` and `validate` still apply; str-coerced so YAML-
  decoded non-string defaults (bool, int) don't crash downstream
  case/validate operations.
- Fail-fast validation: ValueError on prompt_mode whitelist miss,
  `select` + enabled/disabled collision, missing/empty `options`,
  missing `value:` in option, missing `default:`, default not in
  option values, duplicate option labels.

Backward compat absolute: every pre-#779 .generate.yml walks the
legacy `else`-branch byte-identically. 15 pre-existing fixtures
(test1..test15) pass unchanged.

Generalizes commit 6895aa5's `or []` null-safety idiom from the
top-level YAML keys to the per-block keys on both the legacy and
select-mode merge paths.

15 new tests + 15 new fixture directories (test16..test30) cover
happy paths, every validator branch, silent variables, the requires
cascade, and the null-coalesce regressions. cement.ext.ext_generate
coverage stays 100% (202/202 stmts). ruff + mypy green.
audit-public-api: empty diff vs Phase 03 baseline.

Resolves #779.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the generate-features demo to exercise both prompt modes
side-by-side. Existing `docker` / `docker_compose` boolean features
stay byte-identical aside from gaining an explicit `prompt_mode:
boolean` so the canonical living example demonstrates both styles
(omission and explicit form are exactly equivalent).

New `web_framework` select-mode feature with three options:
- value: "none" (default) — ignores requirements.txt and wsgi.py
- value: "flask" — silent framework_version=3.0, ignores fastapi
- value: "fastapi" — silent framework_version=0.115, ignores flask

Two new template files (requirements.txt, wsgi.py) land additively
so the options branches' ignore patterns have real files to suppress.
The README "Multi-Valued Feature (prompt_mode: select)" section
walks through the schema keys, the case-insensitive numbered picker
UX, silent variables, the --defaults headless path, and the
requires: cascade interaction (a disabled select feature contributes
nothing to the merge).

End-to-end verified: `pdm run python myapp.py generate webapp /tmp
--defaults` renders 5 expected files (no requirements.txt / wsgi.py
per web_framework=none default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One entry under active 3.0.15 - DEVELOPMENT / Features bucket for
GH issue #779. Notes that `prompt_mode` defaults to `boolean`
(legacy behavior, byte-identical when key absent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quick task 260524-g47 — prompt_mode: select for ext.generate
features (resolves #779). Captures the planning artifacts from a
--full quick-task run that pivoted mid-flight from a hand-rolled
prompt/validation implementation to delegating to shell.Prompt.

Artifacts:
- CONTEXT.md (re-locked decisions post-pivot; cites shell.Prompt
  as the dispatch primitive)
- PLAN.md (first-attempt plan; preserved as historical record)
- RESEARCH.md (first-attempt grafting notes; preserved)
- REVIEW.md (first-attempt code-review findings; preserved)
- SUMMARY.md (rebuild outcome — actual commit shape, gate
  results, coverage map, adjudication dispositions)
- VERIFICATION.md (first-attempt verification; preserved)

STATE.md: added the Status column to the Quick Tasks Completed
table (first --full quick task with verifier on this project),
appended the new row pointing at feat commit 0a8ffcb, refreshed
Last Activity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 24, 2026

📝 Walkthrough

Walkthrough

This PR extends the cement generate extension with multi-valued optional feature support. It adds prompt_mode: select configuration, enabling users to interactively choose from multiple options with option-specific variable, ignore, and exclude rules. The implementation includes silent variable support (prompt: false), comprehensive validation, and 15 new test fixtures and cases covering edge cases and regressions.

Changes

Multi-valued Optional Feature Support (prompt_mode: select)

Layer / File(s) Summary
Feature prompt_mode validation and select-mode resolution
cement/ext/ext_generate.py, .planning/STATE.md, Makefile, CHANGELOG.md
prompt_mode now validates boolean/select modes; select-mode requires options and default, disallows enabled/disabled blocks, coerces option values to strings, enforces unique labels, and returns selected string values. Feature resolution introduces shell.Prompt-based numbered picker (or --defaults dispatch), per-option branch merging, and prompt: false silent variables with default assertion and type coercion.
Demo template and documentation
demo/generate-features/README.md, demo/generate-features/templates/generate/webapp/.generate.yml, demo/generate-features/templates/generate/webapp/requirements.txt, demo/generate-features/templates/generate/webapp/wsgi.py
Example webapp template adds web_framework select feature with none/flask/fastapi options and option-specific variables/ignore rules. README documents both feature modes, schema, rendered examples showing silent variables and --defaults behavior, and the interaction with requires dependencies.
Test fixtures
tests/data/templates/generate/test16/.generate.yml through test30/.generate.yml and supporting template files
Fifteen test template configurations covering select-mode variations, silent variables (with/without defaults), option-specific ignore patterns, validation failures (invalid prompt_mode, duplicate labels, missing values, empty options), silent-variable edge cases, and boolean regression. Each includes expected render artifacts.
Test suite
tests/ext/test_ext_generate.py
Fifteen new test functions validating --defaults dispatch, select-mode config validation, silent variable assertion and type coercion, null-list safe coalescing in both boolean and select merge paths, and requires cascade preventing downstream ignores when a boolean prerequisite is unmet.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • datafolklabs/cement#779: This PR implements the exact feature requested—multi-valued optional input support for feature prompts with option-specific configuration branching.

Possibly related PRs

  • datafolklabs/cement#768: Both PRs extend cement/ext/ext_generate.py feature-processing logic; PR #768 proposed the original optional-template feature design that this PR refines and tests.

Poem

🐰 A rabbit hops through templates grand,
with select modes across the land,
each option branch gets its own way,
silent variables whisper and play,
and --defaults lets us skip the fray! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ext.generate): add prompt_mode: select for features (#779)' clearly and specifically describes the main feature addition—a new prompt_mode option for multi-valued feature selection.
Linked Issues check ✅ Passed The PR implementation fully addresses Issue #779 objectives: adds multi-valued feature prompt support via prompt_mode: select, enables branch selection via options, supports silent variables, includes validation, maintains backward compatibility with boolean prompts, and implements defaults behavior.
Out of Scope Changes check ✅ Passed All changes are directly scoped to Issue #779 implementation: ext_generate.py feature logic, demo webapp template enhancements, test fixtures and test coverage, documentation (README, CHANGELOG, planning artifacts), and Makefile helper—no extraneous modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/generate-feature-prompt-multiple-values-pr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with 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.

Inline comments:
In
@.planning/todos/completed/2026-05-08-analyze-and-review-pr-768-generate-optional-features.md:
- Line 22: Replace the malformed ATX heading token "`#743`." with plain text issue
notation so markdownlint MD018 stops triggering; locate the line containing the
literal "`#743`." and change it to something like "Issue 743." or "Fixes `#743`."
(i.e., ensure there is no leading '#' immediately followed by digits).

In `@cement/ext/ext_generate.py`:
- Around line 290-293: Replace the assert with an explicit runtime check that
raises a clear exception (e.g., ValueError) when a variable with "prompt": False
has no default: check if var['default'] is None and raise ValueError(f"Variable
'{var['name']}' has prompt: false but no default") instead of using assert, and
only call val = str(var['default']) after this validation so you never call
str(None) even when Python is run with -O.
- Around line 149-173: The SelectPrompt is setting Meta.default to the label
string (default_label) but with numbered=True the prompt expects a 1-based
numeric default; change Meta.default to the 1-based index of the chosen option
(e.g., compute default_index = labels.index(default) + 1 and set Meta.default to
that numeric value or its string) and ensure you only compute this if default is
present in labels (fall back to None or omit default if not found); update the
code around prompt_text/SelectPrompt/chosen_label to use that numeric default
value.

In `@CHANGELOG.md`:
- Around line 172-179: The changelog entry for `[ext.generate]` is spread over
multiple lines; collapse it into a single-line bullet in the active development
section by replacing the multi-line block with one line that starts with
`[ext.generate]` and includes the essential text (mention `prompt_mode: select`,
`cement.utils.shell.Prompt`, and that `options` branches may declare
`ignore`/`exclude`/`variables`), keeping it concise and preserving the Issue
reference `Issue `#779``; ensure the line follows the one-line-per-entry rule and
retains the same information but compressed into a single sentence.

In `@demo/generate-features/README.md`:
- Around line 112-120: The fenced code block showing the "Web Framework"
selection is missing a language tag; update that triple-backtick fence to
include a language identifier (use "text") so it becomes ```text at the start of
the block that contains "Web Framework" through "Enter the number for your
selection:" (the fenced block in demo/generate-features/README.md).

In `@tests/ext/test_ext_generate.py`:
- Around line 372-556: All listed test functions (e.g.,
test_generate_features_select_defaults, test_generate_features_select_collision,
..., test_generate_features_select_duplicate_labels) need type annotations; add
a parameter annotation for tmp (tmp: Any) and a return annotation -> None on
each function, and add "from typing import Any" to the file imports so the
symbol Any is available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 336f9e11-33d2-4797-a456-c332acca6d7d

📥 Commits

Reviewing files that changed from the base of the PR and between 6895aa5 and c6a70dc.

📒 Files selected for processing (45)
  • .planning/STATE.md
  • .planning/todos/completed/2026-05-08-analyze-and-review-pr-768-generate-optional-features.md
  • .planning/todos/pending/2026-05-09-document-optional-features-in-gitbook-post-3-0-16.md
  • CHANGELOG.md
  • Makefile
  • cement/ext/ext_generate.py
  • demo/generate-features/README.md
  • demo/generate-features/templates/generate/webapp/.generate.yml
  • demo/generate-features/templates/generate/webapp/requirements.txt
  • demo/generate-features/templates/generate/webapp/wsgi.py
  • tests/data/templates/generate/test16/.generate.yml
  • tests/data/templates/generate/test16/branch-1-only
  • tests/data/templates/generate/test16/branch-2-only
  • tests/data/templates/generate/test16/branch-N-file
  • tests/data/templates/generate/test16/take-me
  • tests/data/templates/generate/test17/.generate.yml
  • tests/data/templates/generate/test17/take-me
  • tests/data/templates/generate/test18/.generate.yml
  • tests/data/templates/generate/test18/take-me
  • tests/data/templates/generate/test19/.generate.yml
  • tests/data/templates/generate/test19/take-me
  • tests/data/templates/generate/test20/.generate.yml
  • tests/data/templates/generate/test20/take-me
  • tests/data/templates/generate/test21/.generate.yml
  • tests/data/templates/generate/test21/take-me
  • tests/data/templates/generate/test22/.generate.yml
  • tests/data/templates/generate/test22/take-me-silent
  • tests/data/templates/generate/test23/.generate.yml
  • tests/data/templates/generate/test23/take-me
  • tests/data/templates/generate/test24/.generate.yml
  • tests/data/templates/generate/test24/take-me
  • tests/data/templates/generate/test25/.generate.yml
  • tests/data/templates/generate/test25/should-not-appear-1
  • tests/data/templates/generate/test25/take-me
  • tests/data/templates/generate/test26/.generate.yml
  • tests/data/templates/generate/test26/take-me
  • tests/data/templates/generate/test27/.generate.yml
  • tests/data/templates/generate/test27/take-me
  • tests/data/templates/generate/test28/.generate.yml
  • tests/data/templates/generate/test28/take-me
  • tests/data/templates/generate/test29/.generate.yml
  • tests/data/templates/generate/test29/take-me
  • tests/data/templates/generate/test30/.generate.yml
  • tests/data/templates/generate/test30/take-me
  • tests/ext/test_ext_generate.py

and `ignore` patterns, and dependency resolution via `requires` with
transitive cascading. Also includes a fix to `setup_template_items` to catch
`ImportError` and a default-value correction (`{}` → `[]`). Resolves issue
#743.
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 | 🟡 Minor | ⚡ Quick win

Fix malformed markdown token that triggers MD018.

Line 22 begins with #743. and is parsed as an ATX heading without a required space.
Use plain text issue notation instead to avoid markdownlint failure.

Proposed fix
-#743.
+issue `#743`.
📝 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
#743.
issue `#743`.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 22-22: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 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
@.planning/todos/completed/2026-05-08-analyze-and-review-pr-768-generate-optional-features.md
at line 22, Replace the malformed ATX heading token "`#743`." with plain text
issue notation so markdownlint MD018 stops triggering; locate the line
containing the literal "`#743`." and change it to something like "Issue 743." or
"Fixes `#743`." (i.e., ensure there is no leading '#' immediately followed by
digits).

Comment on lines +149 to +173
default_label = ( # pragma: nocover
labels[values.index(default)]
)
# Format: leading \n visually separates the
# numbered list from any prior prompt's output;
# trailing ':' matches the boolean feature prompt
# (line 175) and variable prompt (line 296) which
# both hardcode `:` at the end of their format
# strings. Template authors should NOT include
# trailing punctuation in their `prompt:` field
# (same convention as variables).
prompt_text = ( # pragma: nocover
feature.get('prompt') or f"Select Feature: {name}"
)
prompt_text = f"\n{prompt_text}:" # pragma: nocover

class SelectPrompt(shell.Prompt): # pragma: nocover
class Meta:
text = prompt_text
options = labels
numbered = True
case_insensitive = True
default = default_label

chosen_label = SelectPrompt().prompt() # pragma: nocover
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:

#!/bin/bash
# Verify Prompt numbered/default behavior and this callsite wiring.
rg -n -C6 'numbered|default|self\._meta\.options\[int\(self\.input\) - 1\]' cement/utils/shell.py
rg -n -C8 'class SelectPrompt|numbered = True|default =' cement/ext/ext_generate.py

Repository: datafolklabs/cement

Length of output: 11357


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="cement/ext/ext_generate.py"

echo "== default/numbered select prompt section =="
nl -ba "$FILE" | sed -n '110,210p'

echo "== variable prompt:false / assert section =="
nl -ba "$FILE" | sed -n '250,340p'

echo "== any assert usage in this file =="
rg -n "assert " "$FILE"

Repository: datafolklabs/cement

Length of output: 169


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="cement/ext/ext_generate.py"

echo "== default/numbered select prompt section =="
cat -n "$FILE" | sed -n '110,210p'

echo "== variable prompt:false / assert section =="
cat -n "$FILE" | sed -n '250,340p'

echo "== any assert usage in this file =="
rg -n "assert " "$FILE" || true

Repository: datafolklabs/cement

Length of output: 10494


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="cement/utils/shell.py"
# Show the exact logic around default application and numbered option parsing
sed -n '460,540p' "$FILE" | cat -n

Repository: datafolklabs/cement

Length of output: 3641


Use a numeric default for numbered select prompts.

shell.Prompt with numbered=True treats the entered/default value as a 1-based index (options[int(self.input) - 1]). cement/ext/ext_generate.py currently sets SelectPrompt.Meta.default to the option label (default_label), so pressing Enter feeds a non-numeric string into that conversion, causing retries and potentially None/mapping failures.

Proposed fix
-                    default_label = (  # pragma: nocover
-                        labels[values.index(default)]
-                    )
+                    default_idx = values.index(default) + 1  # pragma: nocover
@@
                     class SelectPrompt(shell.Prompt):  # pragma: nocover
                         class Meta:
                             text = prompt_text
                             options = labels
                             numbered = True
                             case_insensitive = True
-                            default = default_label
+                            default = str(default_idx)
🤖 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 149 - 173, The SelectPrompt is
setting Meta.default to the label string (default_label) but with numbered=True
the prompt expects a 1-based numeric default; change Meta.default to the 1-based
index of the chosen option (e.g., compute default_index = labels.index(default)
+ 1 and set Meta.default to that numeric value or its string) and ensure you
only compute this if default is present in labels (fall back to None or omit
default if not found); update the code around
prompt_text/SelectPrompt/chosen_label to use that numeric default value.

Comment on lines +290 to +293
assert var['default'] is not None, \
f"Variable '{var['name']}' has prompt: false " \
f"but no default"
val = str(var['default'])
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

Replace assert-based config validation with an explicit exception.

Line 290 uses assert for required config. Under python -O, this check is removed and invalid config can slip through (str(None)).

Proposed fix
-                assert var['default'] is not None, \
-                    f"Variable '{var['name']}' has prompt: false " \
-                    f"but no default"
+                if var['default'] is None:
+                    raise ValueError(
+                        f"Variable '{var['name']}' has prompt: false but no default"
+                    )
🤖 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 290 - 293, Replace the assert with
an explicit runtime check that raises a clear exception (e.g., ValueError) when
a variable with "prompt": False has no default: check if var['default'] is None
and raise ValueError(f"Variable '{var['name']}' has prompt: false but no
default") instead of using assert, and only call val = str(var['default']) after
this validation so you never call str(None) even when Python is run with -O.

Comment thread CHANGELOG.md
Comment on lines +172 to +179
- `[ext.generate]` Add `prompt_mode: select` for multi-valued feature
prompts. A select-mode feature presents a numbered picker (delegated
to `cement.utils.shell.Prompt`) and dispatches the chosen value into
one of N `options` branches — each branch may declare its own
`ignore` / `exclude` / `variables` (incl. silent `prompt: false`
variables). `prompt_mode` defaults to `boolean` (legacy behavior,
byte-identical when key absent).
- [Issue #779](https://github.com/datafolklabs/cement/issues/779)
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 | 🟡 Minor | ⚡ Quick win

Keep this new changelog item to a single line.

This entry is wrapped across multiple lines; collapse it into one [ext.generate] bullet in the active development section.

As per coding guidelines, "Each CHANGELOG.md entry must be one line, prefixed with [area] (e.g., [ext.smtp], [cli], [dev], [core.handler])."

🤖 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 `@CHANGELOG.md` around lines 172 - 179, The changelog entry for
`[ext.generate]` is spread over multiple lines; collapse it into a single-line
bullet in the active development section by replacing the multi-line block with
one line that starts with `[ext.generate]` and includes the essential text
(mention `prompt_mode: select`, `cement.utils.shell.Prompt`, and that `options`
branches may declare `ignore`/`exclude`/`variables`), keeping it concise and
preserving the Issue reference `Issue `#779``; ensure the line follows the
one-line-per-entry rule and retains the same information but compressed into a
single sentence.

Comment on lines +112 to +120
```
Web Framework

1: None — no web framework
2: Flask Web Framework
3: FastAPI Web Framework

Enter the number for your selection:
```
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 | 🟡 Minor | ⚡ Quick win

Add a language tag to this fenced code block.

The block starting at Line 112 is missing a language identifier (MD040).

Proposed fix
-```
+```text
 Web Framework
@@
 Enter the number for your selection:
-```
+```
📝 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
```
Web Framework
1: None — no web framework
2: Flask Web Framework
3: FastAPI Web Framework
Enter the number for your selection:
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 112-112: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@demo/generate-features/README.md` around lines 112 - 120, The fenced code
block showing the "Web Framework" selection is missing a language tag; update
that triple-backtick fence to include a language identifier (use "text") so it
becomes ```text at the start of the block that contains "Web Framework" through
"Enter the number for your selection:" (the fenced block in
demo/generate-features/README.md).

Comment on lines +372 to +556
def test_generate_features_select_defaults(tmp):
# test16: prompt_mode: select with three options; --defaults
# dispatches to the feature default ("N") and applies that
# branch's ignore patterns (suppresses branch-1-only,
# branch-2-only); branch-N-file and take-me remain.
argv = ['generate', 'test16', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
app.run()

assert exists_join(tmp.dir, 'take-me')
assert exists_join(tmp.dir, 'branch-N-file')
assert not exists_join(tmp.dir, 'branch-1-only')
assert not exists_join(tmp.dir, 'branch-2-only')


def test_generate_features_select_collision(tmp):
# test17: prompt_mode: select + enabled:/disabled: blocks on the
# same feature → ValueError at config validation.
argv = ['generate', 'test17', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(ValueError,
match="'enabled'/'disabled' blocks are not allowed"):
app.run()


def test_generate_features_select_invalid_prompt_mode(tmp):
# test18: prompt_mode: bogus → ValueError (whitelist enforced).
argv = ['generate', 'test18', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(ValueError, match="invalid prompt_mode 'bogus'"):
app.run()


def test_generate_features_select_silent_variable(tmp):
# test19: prompt_mode: select; --defaults dispatches into the "1"
# branch which carries a silent variable `chosen_version: v1-silent`
# (prompt: false). The silent variable's default lands verbatim in
# the rendered output.
argv = ['generate', 'test19', 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:
assert 'v1-silent' in f.read()


def test_generate_features_select_default_not_in_values(tmp):
# test20: feature default "X" but options only contains "Y" →
# ValueError at config validation.
argv = ['generate', 'test20', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(ValueError, match="is not in options values"):
app.run()


def test_generate_features_select_missing_value(tmp):
# test21: an options branch missing value: → ValueError.
argv = ['generate', 'test21', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(ValueError, match="missing required key: value"):
app.run()


def test_generate_features_top_level_silent_variable(tmp):
# test22: top-level variable with `prompt: false` — silent
# sentinel works outside select-mode features too. The default
# lands verbatim in the rendered output.
argv = ['generate', 'test22', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
app.run()

assert exists_join(tmp.dir, 'take-me-silent')
with open(os.path.join(tmp.dir, 'take-me-silent')) as f:
res = f.read()
assert 'myapp' in res
assert 'top_level_silent_value' in res


def test_generate_features_legacy_null_variables(tmp):
# test23: regression — legacy boolean feature with
# `enabled: { variables: null, exclude: null, ignore: null }`
# must coalesce safely via the `block.get(...) or []` form on
# the legacy merge path.
argv = ['generate', 'test23', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
app.run()

assert exists_join(tmp.dir, 'take-me')


def test_generate_features_select_null_variables(tmp):
# test24: regression — select-mode options branch with
# `variables: null, exclude: null, ignore: null` must coalesce
# safely via the same `block.get(...) or []` form on the select
# merge path.
argv = ['generate', 'test24', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
app.run()

assert exists_join(tmp.dir, 'take-me')


def test_generate_features_select_requires_cascade(tmp):
# test25: select-mode feature has `requires: [bool_prereq]` where
# bool_prereq defaults to false. The cascade returns False for
# the select feature, the merge falls into the legacy
# `state is bool` branch with no `disabled:` block (not allowed
# in select mode), and the options-branch ignore patterns DO NOT
# fire — `should-not-appear-1` remains in the rendered tree.
argv = ['generate', 'test25', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
app.run()

assert exists_join(tmp.dir, 'take-me')
assert exists_join(tmp.dir, 'should-not-appear-1')


def test_generate_features_select_empty_options(tmp):
# test26: prompt_mode: select with `options: []` → ValueError.
argv = ['generate', 'test26', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(ValueError, match="no 'options' branches"):
app.run()


def test_generate_features_silent_variable_no_default(tmp):
# test27: top-level `prompt: false` variable with no `default:` →
# AssertionError from the silent-variable short-circuit.
argv = ['generate', 'test27', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(AssertionError,
match="prompt: false but no default"):
app.run()


def test_generate_features_select_missing_default(tmp):
# test28: prompt_mode: select feature with no `default:` →
# ValueError. Without `default`, --defaults dispatch would
# silently no-op (str(None) matches no option); fail-fast at
# validation per CONTEXT.md Decision F.
argv = ['generate', 'test28', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(ValueError, match="has no 'default' value"):
app.run()


def test_generate_features_silent_variable_non_str_default(tmp):
# test29: silent variable with a YAML-decoded non-string default
# (`default: false` → Python bool) + `case: upper`. The silent
# short-circuit must coerce to str BEFORE the case operation runs;
# otherwise `False.upper()` raises AttributeError.
argv = ['generate', 'test29', 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:
# `case: upper` applied to str(False) → "FALSE".
assert 'silent_flag=FALSE' in f.read()


def test_generate_features_select_duplicate_labels(tmp):
# test30: two options with the same effective display label
# ("Same Label") → ValueError. The numbered list would be
# ambiguous so we reject at config-validation time.
argv = ['generate', 'test30', tmp.dir, '--defaults']

with GenerateApp(argv=argv) as app:
with raises(ValueError, match="duplicate option labels"):
app.run()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add type annotations to all new test functions.

All 15 new test functions are missing type annotations for the tmp parameter and return type. As per coding guidelines, Python code must use type annotations for all function parameters and return types.

📝 Recommended fix: Add type annotations

Example for the first function (apply the same pattern to all 15 new test functions):

-def test_generate_features_select_defaults(tmp):
+def test_generate_features_select_defaults(tmp: Any) -> None:
     # test16: prompt_mode: select with three options; --defaults

Apply to all new test functions:

  • test_generate_features_select_defaults
  • test_generate_features_select_collision
  • test_generate_features_select_invalid_prompt_mode
  • test_generate_features_select_silent_variable
  • test_generate_features_select_default_not_in_values
  • test_generate_features_select_missing_value
  • test_generate_features_top_level_silent_variable
  • test_generate_features_legacy_null_variables
  • test_generate_features_select_null_variables
  • test_generate_features_select_requires_cascade
  • test_generate_features_select_empty_options
  • test_generate_features_silent_variable_no_default
  • test_generate_features_select_missing_default
  • test_generate_features_silent_variable_non_str_default
  • test_generate_features_select_duplicate_labels

Add Any to the imports at the top of the file:

 import os
 import re
 from unittest.mock import patch
+from typing import Any
 
 from cement import Controller, TestApp

As per coding guidelines: "Use type annotations for all function parameters and return types in Python code"

🤖 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 `@tests/ext/test_ext_generate.py` around lines 372 - 556, All listed test
functions (e.g., test_generate_features_select_defaults,
test_generate_features_select_collision, ...,
test_generate_features_select_duplicate_labels) need type annotations; add a
parameter annotation for tmp (tmp: Any) and a return annotation -> None on each
function, and add "from typing import Any" to the file imports so the symbol Any
is available.

@derks derks closed this May 24, 2026
@derks derks reopened this May 24, 2026
@derks
Copy link
Copy Markdown
Member Author

derks commented May 24, 2026

merged to main.

@derks derks closed this May 24, 2026
@derks derks deleted the feat/generate-feature-prompt-multiple-values-pr branch May 25, 2026 00:25
@TomFreudenberg
Copy link
Copy Markdown
Contributor

TomFreudenberg commented May 26, 2026

Hi @derks its working :-)

But let me point out some additional:

  1. Features should be asked AFTER vars, like:
INFO: Generating tokeo project in /tmp/t2
App Label [inspire]: 
App Name [Inspire]: 
App Class Name [Inspire]: 
App Description [Inspire is easily crafted with Tokeo for scaleable services.]: 
App copyright hint [©2025 ACME Inc.]: 
Creator Name [John Doe]: 
Creator Email [john.doe@example.com]: 
Project URL [https://github.com/johndoe/inspire/]: 
Project license (select from https://spdx.org/licenses/) [Proprietary]: 
Use python strict for Errors than Warnings [(Y)es/(N)o] [Y]: 
Enable diskcache [(Y)es/(N)o] [Y]: 
Enable dramatiq [(Y)es/(N)o] [Y]: 
Enable grpc [(Y)es/(N)o] [Y]: 
Enable apscheduler [(Y)es/(N)o] [Y]: 
Enable pocketbase [(Y)es/(N)o] [Y]: 
Enable automate [(Y)es/(N)o] [Y]: 
Enable Feature: feature_nicegui [y/N]:
  1. I would like to use my own Prompts like:
Enable automate [(Y)es/(N)o] [Y]: 

but not:

Enable Feature: feature_nicegui [y/N]:
  1. I would like to use the same input entering as in vars:
Enable nicegui [(Y)es/(N)o] [Y]:

and not:

Enable Feature: feature_nicegui [y/N]:
  1. jinja macros (seems) not to work only with the boolean

does not work:

{% if feature_nicegui %}
    "nicegui>=3.12.1",
    "fastapi",
    "starlette",
    "email-validator",
    "pscript",
    "vbuild",
{% endif -%}

instead of e.g.:

{% if feature_automate == "Y" %}
    "invoke>=2.1.4",
    "paramiko>=2.7,<4",
{% endif -%}

for:

variables:
  - name: feature_automate
    prompt: "Enable automate [(Y)es/(N)o]"
    validate: "^[YN]$"
    case: "upper"
    default: "Y"

Looking forward to your feedback
Tom

@derks
Copy link
Copy Markdown
Member Author

derks commented May 29, 2026

Thanks for the detailed feedback, @TomFreudenberg! 🙇 Since this PR is now closed, I've captured your comment in a new issue to track these follow-ups: #782 (milestone 3.0.16 Stable). Let's continue the discussion there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate feature should support multiple valid Inputs to "enable" prompt

2 participants