diff --git a/CHANGELOG.md b/CHANGELOG.md index e092aa2f..307ff6b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,12 @@ Bugs: 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" +- `[ext.generate]` `type: boolean` template variables now emit a real + Python bool at the top level of the render context (`data[name]`), + so `{% if feature_x %}` works in jinja2/mustache; the boolean prompt + uses a vars-style `[(Y)es/(N)o] [default]:` format and all variables + prompt in a single declaration-order pass — resolves #782 (the + former `features:` namespace and pre-pass are removed) Features: @@ -181,9 +187,53 @@ Features: variables). `prompt_mode` defaults to `boolean` (legacy behavior, byte-identical when key absent). - [Issue #779](https://github.com/datafolklabs/cement/issues/779) +- `[ext.generate]` Add `type: choice` template variables — a numbered + `cement.utils.shell.Prompt` picker that emits the chosen option string + at the top level of the render context. `options:` accepts a scalar + list or per-option `{value, prompt}` objects; per-option effects live + in `extend:` rules keyed by `when: `. Misconfig (empty options, + option missing `value`, default not in options, duplicate labels) is + fail-fast `ValueError`. +- `[ext.generate]` `type: boolean` `prompt:` is now polymorphic — + in addition to the framework-owned string form and the silent + `prompt: false` form, an object form `{text, accept, reject}` lets the + template author own the full prompt text and supply case-insensitive + `accept:`/`reject:` token lists that map input to a real bool. Input + matching neither asserts and aborts (`Invalid Response`), mirroring + `validate:`. A bool-like `accept:`/`reject:` token that YAML 1.1 + coerced to a Python bool raises `ValueError` telling the author to + quote it. +- `[ext.generate]` `extend.when` now composes three match forms — a + scalar value (equality), an in-list membership (`when: [a, b]`), and a + string-type regex (`re.match`, string variables only) — and multiple + matching rules all fire, accumulating their `variables`/`exclude`/ + `ignore`. `extend.variables` are nested and prompted depth-first in + place only when their parent rule fires. A new top-level `requires:` + key gates a variable using the same match vocabulary (`[name]` → + truthy, `{name: value}` → equality, `{name: [v1, v2]}` → in-list), + AND-ed across keys and resolved order-independently via lazy + recursion. A requires-gated-out variable is set to its `default` (so + templates never `KeyError`) and its `extend` rules do not fire. The + unified schema also addresses the PR #780 review feedback: features + prompt after vars in declaration order, with custom per-feature + prompt text and vars-style input. + - [Issue #782](https://github.com/datafolklabs/cement/issues/782) + - [PR #780](https://github.com/datafolklabs/cement/pull/780) Refactoring: +- `[ext.generate]` Remove the unreleased `features:` schema + (`prompt_mode`, `enabled:`/`disabled:` blocks, select `options:` + effects) wholesale — everything it expressed is now a `type: + boolean`/`type: choice` variable carrying `extend:`/`requires:`. The + legacy compatibility bridge is deleted; `features:` is no longer read + by the engine (#782). + +- `[dev]` Migrate the `demo/generate-features/` webapp template to the + unified `type:`/`extend:`/`requires:` schema (off the removed + `features:` schema) and demonstrate the #782 fix — `{% if docker %}` + and `{% if web_framework == ... %}` now render against the top-level + bool/choice exposure - `[ext.smtp]` PEP 8 naming, idiomatic string methods, and cleaner type validation - `[ext.smtp]` Refactor `_make_message` into focused private methods - `[ext.smtp]` Simplify X-header normalization and preserve original casing diff --git a/cement/ext/ext_generate.py b/cement/ext/ext_generate.py index 5762179b..1abdbc89 100644 --- a/cement/ext/ext_generate.py +++ b/cement/ext/ext_generate.py @@ -26,212 +26,10 @@ 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}'") - - # `prompt_mode` whitelist + select-mode schema validation. Runs - # up front so misconfigurations surface before any prompt is - # shown (matches the existing `requires` validation discipline). - # Default is 'boolean' (legacy semantics, byte-identical to the - # pre-#779 code path); 'select' opts in to the multi-valued - # numbered-list flow built on shell.Prompt. - for feature in features: - mode = feature.get('prompt_mode', 'boolean') - if mode not in ('boolean', 'select'): - raise ValueError( - f"Feature '{feature['name']}' has invalid " - f"prompt_mode '{mode}' " - f"(must be 'boolean' or 'select')") - if mode == 'boolean': - continue - if 'enabled' in feature or 'disabled' in feature: - raise ValueError( - f"Feature '{feature['name']}' uses prompt_mode: " - f"select; 'enabled'/'disabled' blocks are not " - f"allowed in this mode") - options = feature.get('options') or [] - if not options: - raise ValueError( - f"Feature '{feature['name']}' uses prompt_mode: " - f"select but has no 'options' branches") - values: list[str] = [] - labels: list[str] = [] - for opt in options: - if opt.get('value') is None: - raise ValueError( - f"Feature '{feature['name']}' has an 'options' " - f"branch missing required key: value") - # YAML may decode unquoted `1` as int; coerce so both - # `value: 1` and `value: "1"` compare uniformly against - # the resolved feature state (always a str). - values.append(str(opt['value'])) - labels.append(opt.get('prompt') or str(opt['value'])) - if len(set(labels)) != len(labels): - raise ValueError( - f"Feature '{feature['name']}' has duplicate option " - f"labels {labels} — each option's effective display " - f"label (prompt: or str(value)) must be unique so " - f"the numbered selection is unambiguous") - # `default` is required for prompt_mode: select. It's the - # prompt's default value AND the value `--defaults` dispatches - # to without prompting; must itself appear in `options`. - if feature.get('default') is None: - raise ValueError( - f"Feature '{feature['name']}' uses prompt_mode: " - f"select but has no 'default' value") - if str(feature['default']) not in values: - raise ValueError( - f"Feature '{feature['name']}' default " - f"'{feature['default']}' is not in options " - f"values {values}") - - # 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). - # - # For prompt_mode: select features the state is the chosen - # option's `value` (str); for legacy boolean features it is a - # bool. - feature_states: dict[str, bool | str] = {} - - def _resolve(name: str) -> bool | str: - 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 - mode = feature.get('prompt_mode', 'boolean') - if mode == 'select': - # Validation above guarantees `default` is set, is in - # `options` values, and that all labels are unique. The - # --defaults dispatch is direct; the interactive path - # delegates to shell.Prompt for numbered display, - # whitelist enforcement, case-insensitive matching, - # default fallback, and retry/abort semantics. - default = str(feature['default']) - if self.app.pargs.defaults: - feature_states[name] = default - else: - options = feature['options'] # pragma: nocover - values = [ # pragma: nocover - str(opt['value']) for opt in options - ] - labels = [ # pragma: nocover - opt.get('prompt') or str(opt['value']) - for opt in options - ] - 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 - feature_states[name] = ( # pragma: nocover - values[labels.index(chosen_label)] - ) - else: - bool_default = bool(feature.get('default', False)) - if self.app.pargs.defaults: - feature_states[name] = bool_default - else: - default_hint = 'Y/n' if bool_default else 'y/N' # pragma: nocover - default_val = 'y' if bool_default else 'n' # pragma: nocover - - class BoolFeaturePrompt(shell.Prompt): # pragma: nocover - class Meta: - text = f"Enable Feature: {name} [{default_hint}]:" - default = default_val - - bp = BoolFeaturePrompt(auto=False) # pragma: nocover - bval: str = bp.prompt() or default_val # pragma: nocover - feature_states[name] = bval.lower() == 'y' # pragma: nocover - return feature_states[name] - - for feature in features: - _resolve(feature['name']) - - # merge enabled/disabled/options configs into vars/exclude/ignore - if 'features' not in data: - data['features'] = {} - for feature in features: - name = feature['name'] - state = feature_states[name] - data['features'][name] = state - - if isinstance(state, bool): - # Legacy boolean path — byte-identical to pre-#779. - # A select-mode feature disabled via `requires` also - # lands here (state == False) — its `options` branches - # contribute nothing, matching boolean-`disabled` - # semantics for a disabled feature. - block_key = 'enabled' if state else 'disabled' - block = feature.get(block_key) or {} - else: - # prompt_mode: select — look up the matched option by - # value. Validation guarantees a match exists. - block = {} - for opt in feature.get('options') or []: - if str(opt['value']) == state: - block = opt - break - - # Generalize commit 6895aa52's `or []` idiom from the - # top-level YAML keys to the per-block keys so a template - # author writing `variables: null` (etc.) inside any block - # coalesces safely. - vars.extend(block.get('variables') or []) - exclude_list.extend(block.get('exclude') or []) - ignore_list.extend(block.get('ignore') or []) - 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) - data: dict[str, dict[str, Any]] = {} + data: dict[str, Any] = {} # builtin vars maj_min = float(f'{VERSION[0]}.{VERSION[1]}') @@ -257,28 +55,46 @@ def _generate(self, source: str, dest: str) -> None: 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` is the released compat anchor (extended ADDITIVELY + # with `type`/`extend`/`requires`). A `type: string` variable with + # none of the new keys takes the identity path below and behaves + # byte-identically to the released schema (test1-5/22/27/29 oracle). var_defaults: dict = { 'name': None, 'prompt': None, 'validate': None, 'case': None, 'default': None, + 'type': 'string', + 'extend': None, + 'requires': None, } + # Index the top-level variables by name so `requires:` can resolve + # a referenced variable on demand (D-09/D-11). Only top-level + # variables are addressable by `requires`; nested + # `extend.variables` are gated by their parent rule (D-10). + var_by_name: dict[str, dict[str, Any]] = {} for defined_var in vars: - var = var_defaults.copy() - var.update(defined_var) - for key in ['name', 'prompt']: - assert var[key] is not None, \ - f"Required generate config key missing: {key}" + if defined_var.get('name') is not None: + var_by_name[defined_var['name']] = defined_var + + # In-flight guard for `requires:` recursion. A variable is added to + # `resolving` while its prerequisites resolve and removed in a + # `finally`; re-entering for a name still in flight (e.g. A requires + # B + B requires A) is a cycle -> fail-fast ValueError instead of + # recursing to RecursionError. + resolving: set[str] = set() + + def _resolve_string(var: dict[str, Any]) -> Any: + # Released `type: string` resolution — byte-identical to the + # pre-#782 variable loop. `prompt` is required (assert) unless + # the silent `prompt: false` sentinel is used. + assert var['prompt'] is not None, \ + "Required generate config key missing: prompt" val: Any = None + default_text = '' if var['prompt'] is False: # Silent variable: `prompt: false` sentinel uses `default` # directly without prompting. case+validate still apply @@ -321,8 +137,332 @@ class Meta: if var['validate'] is not None: assert re.match(var['validate'], val), \ f"Invalid Response (must match: '{var['validate']}')" + return val + + def _resolve_boolean(var: dict[str, Any]) -> bool: + # `type: boolean` — emits a real Python bool at data[name] so + # `{% if name %}` works in jinja2/mustache (bug #4 fix, D-05). + # `prompt:` is polymorphic `string | false | object` (D-12): + # - string -> framework owns the y/n text (Plan 01); + # - false -> silent, use default directly; + # - object {text, accept, reject} -> author owns the text, + # accept/reject token lists map input -> bool (Plan 02). + # Silent (`prompt: false`) and `--defaults` both dispatch to + # `bool(default)`. + default = bool(var['default']) + if var['prompt'] is False: + assert var['default'] is not None, \ + f"Variable '{var['name']}' has prompt: false " \ + f"but no default" + return default + + prompt = var['prompt'] + if isinstance(prompt, dict): + # D-12 object-form: validate + resolve via accept/reject. + return _resolve_boolean_object(var, prompt, default) + + if self.app.pargs.defaults: + return default + + # Interactive string-form prompt — Tom's #782 pt 2/3: replace + # the old "Enable Feature:" text with a vars-style boolean prompt. + # No explicit `prompt:` → default the label to "Enable {name}" + # (D-12), mirroring the choice path's `or f"Select: {name}"` + # fallback — otherwise `var['prompt']` is None and the prompt + # renders the literal "None [(Y)es/(N)o] ...". + hint = 'Y' if default else 'N' + label = prompt or f"Enable {var['name']}" + + class BoolPrompt(shell.Prompt): + class Meta: + text = f"{label} [(Y)es/(N)o] [{hint}]:" + default = hint + + answer = BoolPrompt().prompt() # pragma: nocover # defensive: unreachable + # y/yes -> True, n/no -> False, empty -> default. This mapping + # is the coverable, bug-prone logic — driven under real coverage + # by the patched-prompt tests (test31), NOT pragma'd. + token = (answer or '').strip().lower() + if token in ('y', 'yes'): + return True + if token in ('n', 'no'): + return False + return default + + def _resolve_boolean_object(var: dict[str, Any], + prompt: dict[str, Any], + default: bool) -> bool: + # D-12 object-form `prompt: {text, accept, reject}`. The author + # owns the full text (NO framework decoration). `accept:`/ + # `reject:` are case-insensitive token lists mapping input -> + # bool; input matching NEITHER asserts + aborts like a failed + # validate: (D-14). Empty input falls through to `default`. + accept = prompt.get('accept') or [] + reject = prompt.get('reject') or [] + + # YAML 1.1 bool-coercion guard (Pitfall 1): bare `yes`/`no`/ + # `on`/`off`/`true`/`false` in a list coerce to Python bool + # under yaml.full_load. A bool member is a silent landmine — + # surface it as a fast, explicit ValueError telling the author + # to quote the token. ValueError (not assert) survives + # `python -O`, matching the schema-error discipline. + for member in list(accept) + list(reject): + if isinstance(member, bool): + raise ValueError( + f"Variable '{var['name']}' has a bool-like " + f"accept:/reject: token that YAML coerced to a " + f"Python bool ({member!r}); quote it " + f"(e.g. accept: [y, \"yes\"]) so it stays a string") + + accept_tokens = [str(m).strip().lower() for m in accept] + reject_tokens = [str(m).strip().lower() for m in reject] - data[var['name']] = val + if self.app.pargs.defaults: + return default + + class BoolObjectPrompt(shell.Prompt): + class Meta: + text = prompt['text'] + default = '' + + answer = BoolObjectPrompt().prompt() # pragma: nocover # defensive: unreachable + # accept member -> True, reject member -> False, empty -> + # default, junk -> assert-abort. This accept/reject MAPPING is + # the bug-prone logic — covered under real patched-prompt tests + # (test35), NOT pragma'd. + token = (answer or '').strip().lower() + if token == '': + return default + if token in accept_tokens: + return True + if token in reject_tokens: + return False + assert token in accept_tokens or token in reject_tokens, \ + "Invalid Response (must be one of accept/reject)" + return default # pragma: nocover # defensive: unreachable + + def _resolve_choice(var: dict[str, Any]) -> str: + # `type: choice` — numbered shell.Prompt picker emitting the + # chosen option value (str) at data[name]. `options:` is either + # a scalar list (`[none, flask, fastapi]`) or per-option objects + # (`{value, prompt}`, D-16). Per-option effects live in `extend:` + # keyed by value, not inline. Schema misconfig is fail-fast + # ValueError (survives `python -O`): empty options, option object + # missing `value`, default not in option values, duplicate labels. + options = var.get('options') or [] + if not options: + raise ValueError( + f"Variable '{var['name']}' is type: choice but has " + f"no 'options'") + + values: list[str] = [] + labels: list[str] = [] + for opt in options: + if isinstance(opt, dict): + if opt.get('value') is None: + raise ValueError( + f"Variable '{var['name']}' has an 'options' " + f"branch missing required key: value") + # YAML may decode `value: 1` as int; coerce so both + # `value: 1` and `value: "1"` compare uniformly. + values.append(str(opt['value'])) + labels.append(opt.get('prompt') or str(opt['value'])) + else: + values.append(str(opt)) + labels.append(str(opt)) + + if len(set(labels)) != len(labels): + raise ValueError( + f"Variable '{var['name']}' has duplicate option " + f"labels {labels} — each option's effective display " + f"label (prompt: or str(value)) must be unique so the " + f"numbered selection is unambiguous") + + if var['default'] is None: + raise ValueError( + f"Variable '{var['name']}' is type: choice but has " + f"no 'default' value") + default = str(var['default']) + if default not in values: + raise ValueError( + f"Variable '{var['name']}' default '{var['default']}' " + f"is not in options values {values}") + + if self.app.pargs.defaults: + return default + + default_label = labels[values.index(default)] + prompt_text = var['prompt'] or f"Select: {var['name']}" + prompt_text = f"\n{prompt_text}:" + + class SelectPrompt(shell.Prompt): + class Meta: + text = prompt_text + options = labels + numbered = True + case_insensitive = True + default = default_label + + chosen_label = SelectPrompt().prompt() # pragma: nocover # defensive: unreachable + # label -> value mapping is the coverable logic — driven under + # real coverage by the patched-prompt test (test16), NOT pragma'd. + # `.prompt()` is typed `str | None`; empty input resolves to the + # default_label via Meta.default, so str() keeps mypy happy + # without masking a real None (the numbered picker always yields + # a label). + return values[labels.index(str(chosen_label))] + + def _when_matches(value: Any, when: Any, vtype: str) -> bool: + # Compose the three `extend.when` / `requires` match forms + # (D-07), dispatching on the carrying variable's `type` (Q2): + # - list -> in-list membership. Booleans compare by Python + # `==`; choice/string members str()-coerce both sides. + # - regex -> `re.match(when, value)` — STRING type only + # (mirrors `validate:`); a non-string `when` on a string + # var still falls through to scalar equality below. + # - scalar -> equality. Booleans by Python `==` (so + # `when: true` matches `True`, not the string "True"); + # choice/string by str()-coerced equality (so `when: 1` + # matches a "1" option value; per the L219 discipline). + if isinstance(when, list): + if vtype == 'boolean': + return value in when + return str(value) in [str(w) for w in when] + if vtype == 'string' and isinstance(when, str): + return re.match(when, value) is not None + if vtype == 'boolean': + return bool(value == when) + return str(when) == str(value) + + def _gated_default(var: dict[str, Any], vtype: str) -> Any: + # Value emitted for a `requires`-gated-out variable (Q1): its + # own `default`, typed like a fully-resolved value, so the + # template never KeyErrors. boolean -> Python bool; + # choice/string -> str(default) (matching the resolved-path + # str invariant). A gated-out var's extend rules do NOT fire. + # Fail-fast (ValueError survives `python -O`, matching the D-17 + # guard style): a gated-out var with no default would otherwise + # leak the literal string "None" into the template context. + if var['default'] is None: + raise ValueError( + f"Variable '{var['name']}' uses requires: but has " + f"no default") + if vtype == 'boolean': + return bool(var['default']) + return str(var['default']) + + def _requires_satisfied(var: dict[str, Any]) -> bool: + # Evaluate top-level `requires:` (D-09/D-11), AND-ed across + # keys. Each referenced variable is resolved ON DEMAND (lazy + # recursion + memoize-on-`data`), so a prerequisite declared + # LATER still resolves first — resolution is order-independent. + # Vocab forms (matching `extend.when`): + # - `requires: [name]` -> `name` must be truthy + # - `requires: {name: value}` -> equality / regex / in-list + # via `_when_matches` + requires = var['requires'] + if requires is None: + return True + + if isinstance(requires, dict): + items = list(requires.items()) + else: + # list/sugar form: each entry is a bare name -> truthy + items = [(name, None) for name in requires] + + for req_name, expected in items: + if req_name not in var_by_name: + raise ValueError( + f"Variable '{var['name']}' requires unknown " + f"variable '{req_name}'") + resolve_and_emit(var_by_name[req_name]) + req_value = data[req_name] + req_var = var_by_name[req_name] + if expected is None: + # truthy sugar + if not req_value: + return False + else: + if not _when_matches(req_value, expected, + req_var.get('type', 'string')): + return False + return True + + def resolve_and_emit(defined_var: dict[str, Any]) -> None: + var = var_defaults.copy() + var.update(defined_var) + if var['name'] is None: + raise ValueError( + "Required generate config key missing: name") + + # Memoize on `data` membership so a variable referenced more than + # once (e.g. via `requires`) resolves exactly once and resolution + # stays declaration-order-independent (D-11). + if var['name'] in data: + return + + # Cycle guard: a name re-entered while still in flight (its + # prerequisites haven't finished resolving) means `requires:` + # forms a loop (A requires B + B requires A). Fail fast. + if var['name'] in resolving: + raise ValueError( + f"Cyclic variable dependency detected for " + f"'{var['name']}'") + resolving.add(var['name']) + try: + vtype = var['type'] + if vtype not in ('string', 'boolean', 'choice'): + raise ValueError( + f"Variable '{var['name']}' has invalid type " + f"'{vtype}' (must be string, boolean, or choice)") + + # D-17: case:/validate: are string-only semantics. A boolean + # is parsed via accept/reject; a choice is constrained by + # options — declaring case:/validate: on either is a + # fail-fast schema misconfig (ValueError survives `python -O`). + if vtype in ('boolean', 'choice') and \ + (var['case'] is not None or + var['validate'] is not None): + raise ValueError( + f"case:/validate: are string-only; variable " + f"'{var['name']}' is type: {vtype}") + + # `requires:` gate (D-09/D-11). If any prerequisite fails, the + # var is GATED OUT: set to its `default` at data[name] (Q1) + # and its `extend` rules do NOT fire. Resolved BEFORE prompting + # so an interactive run never nags for a gated-out variable. + if not _requires_satisfied(var): + data[var['name']] = _gated_default(var, vtype) + return + + value: Any + if vtype == 'boolean': + value = _resolve_boolean(var) + elif vtype == 'choice': + value = _resolve_choice(var) + else: + value = _resolve_string(var) + + data[var['name']] = value + + # `extend:` rules compose — every rule whose `when` matches + # the resolved value fires, accumulating exclude/ignore and + # recursing depth-first into its nested variables in place + # (D-07/D-08). `_when_matches` composes scalar equality, + # in-list membership, and (string-type) regex; multiple + # matching rules all fire. + for rule in var['extend'] or []: + when = rule.get('when') + if _when_matches(value, when, vtype): + exclude_list.extend(rule.get('exclude') or []) + ignore_list.extend(rule.get('ignore') or []) + for nested in rule.get('variables') or []: + resolve_and_emit(nested) + finally: + resolving.discard(var['name']) + + for defined_var in vars: + resolve_and_emit(defined_var) try: self.app.template.copy(source, dest, data, @@ -381,7 +521,7 @@ def setup_template_items(app: "App") -> None: # 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. + # from cement, so the branch below is excluded from coverage. except AttributeError: # pragma: nocover # untestable: dynamic import msg = ('unable to load template module ' f"'{mod_name}' from '{'.'.join(mod_parts)}'") diff --git a/demo/generate-features/README.md b/demo/generate-features/README.md index 374625a5..72d8b76f 100644 --- a/demo/generate-features/README.md +++ b/demo/generate-features/README.md @@ -1,28 +1,39 @@ -# Demo: Generate Extension - Features +# Demo: Generate Extension - Typed Variables -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. +Demonstrates the unified typed-variable support in the `generate` extension, +which lets `.generate.yml` templates declare each variable's `type:` +(`string`, `boolean`, or `choice`), conditionally include/exclude files and +prompt for additional variables via `extend:`, and gate a variable on other +variables via `requires:`. -The demo exercises **both** feature prompt modes: +> **Schema note:** the legacy `features:` / `prompt_mode:` / +> `enabled:` / `disabled:` vocabulary has been removed. Everything is now a +> single `variables:` list where each entry carries a `type:`. A boolean or +> choice variable lands at the **top level** of the template context +> (`data[name]`), so `{% if docker %}` and `{% if web_framework == "flask" %}` +> work directly (the #782 fix) — no `features.*` namespace. -- `prompt_mode: boolean` — the original single y/N toggle (`docker`, - `docker_compose`). -- `prompt_mode: select` — a numbered multi-valued picker (`web_framework`), - added for [issue #779][779]. +The demo exercises all three variable types: + +- `type: string` — a plain text variable (`project_name`). +- `type: boolean` — a single y/N toggle (`docker`, `docker_compose`). +- `type: choice` — a numbered multi-valued picker (`web_framework`). ## Template The `templates/generate/webapp/` template defines: -- **Base variable:** `project_name` -- **Feature: `docker`** (boolean, default: enabled) — includes `Dockerfile` - and `.dockerignore`, prompts for `python_version` -- **Feature: `docker_compose`** (boolean, default: enabled, - **requires: docker**) — includes `docker-compose.yml` -- **Feature: `web_framework`** (select, default: `none`) — picks between - None / Flask / FastAPI, controls `requirements.txt` + `wsgi.py` - rendering and a silent `framework_version` variable +- **String variable:** `project_name` +- **Boolean `docker`** (default: `true`) — when on, prompts for + `python_version` and renders `Dockerfile` + `.dockerignore`; when off, + `extend.when: false` ignores those files. +- **Boolean `docker_compose`** (default: `true`, **`requires: [docker]`**) — + renders `docker-compose.yml`; when off (or gated off because `docker` is + off) its `extend.when: false` ignores the compose file. +- **Choice `web_framework`** (default: `none`) — picks between + None / Flask / FastAPI. Each branch's `extend.when: ` controls + `requirements.txt` + `wsgi.py` rendering and a silent `framework_version` + variable. ## Usage @@ -32,7 +43,7 @@ Run from this directory: # Generate with all defaults (docker=on, docker_compose=on, web_framework=none) pdm run python myapp.py generate webapp /tmp/myproject --defaults -# Generate interactively (prompts for each variable and feature) +# Generate interactively (prompts for each typed variable) pdm run python myapp.py generate webapp /tmp/myproject # Generate with --force to overwrite existing output @@ -52,49 +63,54 @@ variables: default: "myproject" case: lower -features: - name: docker - prompt_mode: boolean + type: boolean default: true - enabled: - variables: - - name: python_version - prompt: "Python Version (for Docker)" - default: "3.13" - disabled: - ignore: - - '.*Dockerfile.*' - - '.*\.dockerignore.*' + extend: + - when: true + variables: + - name: python_version + prompt: "Python Version (for Docker)" + default: "3.13" + - when: false + ignore: + - '.*Dockerfile.*' + - '.*\.dockerignore.*' - name: docker_compose - prompt_mode: boolean + type: boolean default: true requires: - docker - disabled: - ignore: - - '.*docker-compose.*' + extend: + - when: false + ignore: + - '.*docker-compose.*' - name: web_framework - prompt_mode: select + type: choice prompt: "Web Framework" default: "none" options: - value: "none" prompt: "None — no web framework" + - value: "flask" + prompt: "Flask Web Framework" + - value: "fastapi" + prompt: "FastAPI Web Framework" + extend: + - when: "none" ignore: - '.*requirements\.txt.*' - '.*wsgi\.py.*' - - value: "flask" - prompt: "Flask Web Framework" + - when: "flask" variables: - name: framework_version prompt: false default: "3.0" ignore: - '.*fastapi.*' - - value: "fastapi" - prompt: "FastAPI Web Framework" + - when: "fastapi" variables: - name: framework_version prompt: false @@ -103,11 +119,57 @@ features: - '.*flask.*' ``` -## Multi-Valued Feature (prompt_mode: select) +## Typed Variables + +### `type: string` + +The released default. A plain `{name, prompt, default}` variable with optional +`case:` / `validate:`. Omitting `type:` is equivalent to `type: string`. + +### `type: boolean` -The `web_framework` feature uses the multi-valued `prompt_mode: select` -schema. Under the hood it delegates to `cement.utils.shell.Prompt` with -`numbered=True`, so the user sees a numbered picker: +A single y/N prompt rendered as ` [(Y)es/(N)o] []:` — e.g. +`Enable docker [(Y)es/(N)o] [Y]:`. With no `prompt:` key the label defaults to +`Enable `; an explicit `prompt:` overrides it. Input `y`/`yes` → `True`, +`n`/`no` → `False`, empty → `default`. The resolved value is a real Python +`bool`, so it lands at the **top level** of the context. Use it in a +conditional: + +```jinja +{% if docker %}...Dockerfile-only content...{% endif %} +``` + +For full control of the wording and accepted tokens, give `prompt:` an object +instead of a string — the author owns the text, and `accept:` / `reject:` are +case-insensitive token lists that map the answer to a `bool` (input matching +neither aborts, like a failed `validate:`): + +```yaml + - name: docker + type: boolean + default: true + prompt: + text: "Use Docker? [(Y)ay/(N)ay]" + accept: [y, yay] + reject: [n, nay] +``` + +> Quote bool-like tokens (`"yes"`, `"no"`, `"on"`, `"off"`) inside +> `accept:` / `reject:` — under YAML 1.1 they otherwise decode to a Python +> `bool` and the loader rejects them with a clear error. +> +> **`{{ bool }}` interpolation gotcha:** a boolean rendered as *text* +> (`{{ docker }}`) interpolates the **capitalized Python repr** — `True` or +> `False` — **not** `true`/`false` and **not** `yes`/`no`. This is true for +> both the jinja2 and mustache (pystache) template handlers. Conditionals +> (`{% if docker %}`, mustache `{{#docker}}` / `{{^docker}}`) are the intended +> use and behave correctly; only direct text interpolation of a bool shows the +> `True`/`False` repr. + +### `type: choice` + +A numbered multi-valued picker. Under the hood it delegates to +`cement.utils.shell.Prompt` with `numbered=True`: ``` Web Framework @@ -119,56 +181,50 @@ Web Framework Enter the number for your selection: ``` -**Schema keys** in select mode: - -- `prompt_mode: select` — opt-in switch. The default is `'boolean'` - (single y/N prompt with `enabled:` / `disabled:` blocks, as the - `docker` and `docker_compose` features above use). The - `prompt_mode` key MAY be omitted on boolean features for - backward-compat with templates predating #779 — `prompt_mode: - boolean` and omission are exactly equivalent. -- `prompt` — the heading text shown above the numbered list. -- `default` — REQUIRED. Must equal one of the `options[].value` - entries (validated at config-load). Used both as the Prompt's - default-when-user-hits-Enter and as the `--defaults` headless - dispatch value. -- `options` — list of branches. Each branch: - - `value:` (required) — the resolved feature state in the rendered - template (`{{ web_framework }}` substitutes this string). - - `prompt:` (optional) — the human-readable label shown in the - numbered list. Falls back to `str(value)` if omitted. Must be - unique across the feature's options. - - `ignore:` / `exclude:` / `variables:` (all optional) — same - vocabulary as the boolean-feature `enabled:` / `disabled:` - blocks; merged into the template render context when this option - is selected. - -**Silent variables (`prompt: false`)**: a variable definition with -`prompt: false` is set to its `default` without rendering an -interactive prompt — useful for branch-specific metadata like a -framework's pinned version. The variable's `case` and `validate` (if -declared) still apply to the default value. Silent variables also -work outside `selected` branches (top-level `variables`). - -**Headless run with `--defaults`**: +Choice schema keys: -```bash -pdm run python myapp.py generate webapp /tmp/myproject --defaults -# → web_framework = "none" (feature-level default) -# → requirements.txt and wsgi.py both ignored (not rendered) -``` +- `options` — list of branches. Each branch is either a bare scalar + (`options: ["1", "2"]`) or an object with `value:` (required, the resolved + string `{{ web_framework }}` substitutes) and an optional `prompt:` label + shown in the numbered list (falls back to `str(value)`). +- `default` — REQUIRED; must equal one of the option values (validated at + config-load). Used as the Prompt's default-on-Enter and the `--defaults` + headless value. +- The resolved choice value lands at the top level, so + `{% if web_framework == "flask" %}` works directly. + +## `extend:` — conditional effects + +Each variable may carry an `extend:` list. A rule fires when its `when:` +matches the resolved value: + +- **scalar equality:** `when: true` / `when: "flask"` +- **in-list membership:** `when: ["flask", "fastapi"]` +- **string regex** (string vars only): `when: "^3\\."` + +A firing rule contributes its `variables:` (prompted *in place*, in +declaration order — including silent `prompt: false` metadata like +`framework_version`), `ignore:` (regex paths skipped), and `exclude:` +(paths copied verbatim, no rendering). Multiple matching rules compose. -**Interaction with `requires:`**: a select-mode feature may declare -`requires: [other_feature]`. If a prerequisite resolves to false the -select feature is disabled and treated as "off" — none of its -`options` branches fire, and no `ignore` / `exclude` / `variables` -from any branch are applied. This mirrors the legacy boolean-feature -semantics: a disabled feature contributes nothing to the template -merge. If you need "off" to actively suppress files, attach the -`ignore` patterns to the prerequisite's own `disabled:` block instead -of an `options` branch. +## `requires:` — variable gating -[779]: https://github.com/datafolklabs/cement/issues/779 +A variable may declare `requires:` on other top-level variables: + +- `requires: [docker]` — list of bare names; each must be truthy. +- `requires: {web_framework: flask}` — map form; equality. +- `requires: {web_framework: [flask, fastapi]}` — map form; in-list. + +`requires:` resolves **top-level** variables only (nested `extend.variables` +are not addressable by name). The map form reuses the same matcher as +`extend.when`, so a string-typed prerequisite also accepts a regex. + +Multiple entries are AND-ed and resolved order-independently. If a `requires:` +gate fails, the variable is set to its (typed) **default** at the top level +(so `{{ var }}` / `{% if var %}` never errors) and **none** of its `extend:` +rules fire. In this demo, disabling `docker` automatically gates off +`docker_compose` (which `requires: [docker]`), so `docker-compose.yml` is not +rendered. ## What to Expect @@ -183,10 +239,10 @@ of an `options` branch. └── docker-compose.yml ``` -`web_framework=none` (the feature's default) ignores `requirements.txt` -and `wsgi.py`, so they do not appear in the rendered tree. +`web_framework=none` ignores `requirements.txt` and `wsgi.py`, so they do not +appear in the rendered tree. -**With docker disabled** (docker_compose is auto-disabled via `requires`): +**With docker off** (`docker_compose` is auto-gated via `requires: [docker]`): ```text /tmp/myproject/ @@ -194,10 +250,10 @@ and `wsgi.py`, so they do not appear in the rendered tree. └── app.py ``` -No Dockerfile, no docker-compose.yml — disabling docker automatically -disables docker_compose because it requires docker. +No Dockerfile, no docker-compose.yml — gating off `docker` cascades to +`docker_compose`. -**With web_framework=fastapi** (and other features default): +**With web_framework=fastapi** (other variables default): ```text /tmp/myproject/ @@ -212,3 +268,5 @@ disables docker_compose because it requires docker. `requirements.txt` substitutes `fastapi==0.115` from the silent `framework_version` variable on the fastapi branch. + +[782]: https://github.com/datafolklabs/cement/issues/782 diff --git a/demo/generate-features/templates/generate/webapp/.generate.yml b/demo/generate-features/templates/generate/webapp/.generate.yml index 663d38c9..84ce33bc 100644 --- a/demo/generate-features/templates/generate/webapp/.generate.yml +++ b/demo/generate-features/templates/generate/webapp/.generate.yml @@ -6,49 +6,54 @@ variables: default: "myproject" case: lower -features: - name: docker - prompt_mode: boolean + type: boolean default: true - enabled: - variables: - - name: python_version - prompt: "Python Version (for Docker)" - default: "3.13" - disabled: - ignore: - - '.*Dockerfile.*' - - '.*\.dockerignore.*' + extend: + - when: true + variables: + - name: python_version + prompt: "Python Version (for Docker)" + default: "3.13" + - when: false + ignore: + - '.*Dockerfile.*' + - '.*\.dockerignore.*' - name: docker_compose - prompt_mode: boolean + type: boolean default: true requires: - docker - disabled: - ignore: - - '.*docker-compose.*' + extend: + - when: false + ignore: + - '.*docker-compose.*' - name: web_framework - prompt_mode: select + type: choice prompt: "Web Framework" default: "none" options: - value: "none" prompt: "None — no web framework" + - value: "flask" + prompt: "Flask Web Framework" + - value: "fastapi" + prompt: "FastAPI Web Framework" + extend: + - when: "none" ignore: - '.*requirements\.txt.*' - '.*wsgi\.py.*' - - value: "flask" - prompt: "Flask Web Framework" + - when: "flask" variables: - name: framework_version prompt: false default: "3.0" ignore: - '.*fastapi.*' - - value: "fastapi" - prompt: "FastAPI Web Framework" + - when: "fastapi" variables: - name: framework_version prompt: false diff --git a/demo/generate-features/templates/generate/webapp/README.md b/demo/generate-features/templates/generate/webapp/README.md index ba9b0baa..81be62e1 100644 --- a/demo/generate-features/templates/generate/webapp/README.md +++ b/demo/generate-features/templates/generate/webapp/README.md @@ -1,3 +1,12 @@ # {{ project_name }} Generated by myapp. +{% if docker %} +This project ships a Dockerfile (Python {{ python_version }}). +{% endif %} +{% if docker_compose %} +A docker-compose.yml is included for local orchestration. +{% endif %} +{% if web_framework != "none" %} +Web framework: {{ web_framework }} {{ framework_version }}. +{% endif %} diff --git a/demo/generate-features/templates/generate/webapp/app.py b/demo/generate-features/templates/generate/webapp/app.py index 94a2e525..cf140e51 100644 --- a/demo/generate-features/templates/generate/webapp/app.py +++ b/demo/generate-features/templates/generate/webapp/app.py @@ -3,6 +3,17 @@ def main(): print("Hello from {{ project_name }}!") +{% if docker %} + # docker is a top-level boolean, so the conditional above works directly. + print("Container build: enabled") +{% endif %} +{% if web_framework == "flask" %} + print("Web framework: Flask {{ framework_version }}") +{% elif web_framework == "fastapi" %} + print("Web framework: FastAPI {{ framework_version }}") +{% else %} + print("Web framework: none") +{% endif %} if __name__ == "__main__": diff --git a/tests/data/templates/generate/test10/.generate.yml b/tests/data/templates/generate/test10/.generate.yml index 0a1dbef8..1062da80 100644 --- a/tests/data/templates/generate/test10/.generate.yml +++ b/tests/data/templates/generate/test10/.generate.yml @@ -6,9 +6,8 @@ variables: prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: true requires: - nonexistent diff --git a/tests/data/templates/generate/test11/.generate.yml b/tests/data/templates/generate/test11/.generate.yml index e9b4e4a4..0c5f3bd0 100644 --- a/tests/data/templates/generate/test11/.generate.yml +++ b/tests/data/templates/generate/test11/.generate.yml @@ -6,18 +6,20 @@ variables: prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: false - disabled: - ignore: - - '.*feature1-only.*' + extend: + - when: false + ignore: + - '.*feature1-only.*' - name: feature2 + type: boolean default: true requires: - feature1 - disabled: - ignore: - - '.*feature2-only.*' + extend: + - when: true + ignore: + - '.*feature2-only.*' diff --git a/tests/data/templates/generate/test12/.generate.yml b/tests/data/templates/generate/test12/.generate.yml index 9002624d..ce33630b 100644 --- a/tests/data/templates/generate/test12/.generate.yml +++ b/tests/data/templates/generate/test12/.generate.yml @@ -6,8 +6,11 @@ variables: prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: true - enabled: + extend: + - when: true + variables: + exclude: + ignore: diff --git a/tests/data/templates/generate/test13/.generate.yml b/tests/data/templates/generate/test13/.generate.yml index 0906ce51..ec333f01 100644 --- a/tests/data/templates/generate/test13/.generate.yml +++ b/tests/data/templates/generate/test13/.generate.yml @@ -6,7 +6,6 @@ variables: prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: true diff --git a/tests/data/templates/generate/test14/.generate.yml b/tests/data/templates/generate/test14/.generate.yml index 258c33d3..374d7b15 100644 --- a/tests/data/templates/generate/test14/.generate.yml +++ b/tests/data/templates/generate/test14/.generate.yml @@ -1,31 +1,41 @@ --- +# Transitive requires: feature1 (false) gates feature2, which gates +# feature3. Each gated-out variable renders as its own `default` (Q1). +# feature2/feature3 default to false so the cascade propagates: a +# gated-out var's resolved value is its default, and a falsy default +# means downstream `requires` against it also fail. + variables: - name: app_name prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: false - disabled: - ignore: - - '.*feature1-only.*' + extend: + - when: false + ignore: + - '.*feature1-only.*' - name: feature2 - default: true + type: boolean + default: false requires: - feature1 - disabled: - ignore: - - '.*feature2-only.*' + extend: + - when: true + ignore: + - '.*feature2-only.*' - name: feature3 - default: true + type: boolean + default: false requires: - feature2 - disabled: - ignore: - - '.*feature3-only.*' + extend: + - when: true + ignore: + - '.*feature3-only.*' diff --git a/tests/data/templates/generate/test14/take-me b/tests/data/templates/generate/test14/take-me index 795b8a93..9ae194d7 100644 --- a/tests/data/templates/generate/test14/take-me +++ b/tests/data/templates/generate/test14/take-me @@ -1 +1,2 @@ app_name => {{ app_name }} +feature1={{ feature1 }} feature2={{ feature2 }} feature3={{ feature3 }} diff --git a/tests/data/templates/generate/test15/.generate.yml b/tests/data/templates/generate/test15/.generate.yml index 34db2d0b..7f1414f0 100644 --- a/tests/data/templates/generate/test15/.generate.yml +++ b/tests/data/templates/generate/test15/.generate.yml @@ -1,30 +1,30 @@ --- +# Out-of-order requires: feature_b requires feature_a, but feature_b is +# declared FIRST in the YAML. Both default to true. The lazy recursive +# resolver resolves feature_a on demand when feature_b's requires gate is +# checked, so resolution is order-independent and both stay enabled. + 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 + type: boolean default: true requires: - feature_a - disabled: - ignore: - - '.*feature_b-only.*' + extend: + - when: false + ignore: + - '.*feature_b-only.*' - name: feature_a + type: boolean default: true - disabled: - ignore: - - '.*feature_a-only.*' + extend: + - when: false + ignore: + - '.*feature_a-only.*' diff --git a/tests/data/templates/generate/test16/.generate.yml b/tests/data/templates/generate/test16/.generate.yml index 1485246d..db5c0548 100644 --- a/tests/data/templates/generate/test16/.generate.yml +++ b/tests/data/templates/generate/test16/.generate.yml @@ -1,28 +1,32 @@ --- variables: - - name: app_name - prompt: App Name - default: myapp -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick a flavor" - default: "N" - options: - - value: "N" - prompt: "None — vanilla" - ignore: - - '.*branch-1-only.*' - - '.*branch-2-only.*' - - value: "1" - prompt: "Branch One" - ignore: - - '.*branch-N-file.*' - - '.*branch-2-only.*' - - value: "2" - prompt: "Branch Two" - ignore: - - '.*branch-N-file.*' - - '.*branch-1-only.*' +- name: app_name + prompt: App Name + default: myapp + +- name: feature_choice + type: choice + prompt: "Pick a flavor" + default: "N" + options: + - value: "N" + prompt: "None — vanilla" + - value: "1" + prompt: "Branch One" + - value: "2" + prompt: "Branch Two" + extend: + - when: "N" + ignore: + - '.*branch-1-only.*' + - '.*branch-2-only.*' + - when: "1" + ignore: + - '.*branch-N-file.*' + - '.*branch-2-only.*' + - when: "2" + ignore: + - '.*branch-N-file.*' + - '.*branch-1-only.*' diff --git a/tests/data/templates/generate/test17/.generate.yml b/tests/data/templates/generate/test17/.generate.yml deleted file mode 100644 index f7983c15..00000000 --- a/tests/data/templates/generate/test17/.generate.yml +++ /dev/null @@ -1,16 +0,0 @@ ---- - -variables: - - name: app_name - prompt: App Name - default: myapp - -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick" - default: "A" - options: - - value: "A" - enabled: - ignore: [] diff --git a/tests/data/templates/generate/test17/take-me b/tests/data/templates/generate/test17/take-me deleted file mode 100644 index 48cdce85..00000000 --- a/tests/data/templates/generate/test17/take-me +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/tests/data/templates/generate/test18/.generate.yml b/tests/data/templates/generate/test18/.generate.yml deleted file mode 100644 index 1c169379..00000000 --- a/tests/data/templates/generate/test18/.generate.yml +++ /dev/null @@ -1,11 +0,0 @@ ---- - -variables: - - name: app_name - prompt: App Name - default: myapp - -features: - - name: feature_choice - prompt_mode: bogus - default: "N" diff --git a/tests/data/templates/generate/test18/take-me b/tests/data/templates/generate/test18/take-me deleted file mode 100644 index 48cdce85..00000000 --- a/tests/data/templates/generate/test18/take-me +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/tests/data/templates/generate/test19/.generate.yml b/tests/data/templates/generate/test19/.generate.yml index a2b4917c..8fc75179 100644 --- a/tests/data/templates/generate/test19/.generate.yml +++ b/tests/data/templates/generate/test19/.generate.yml @@ -1,25 +1,24 @@ --- variables: - - name: app_name - prompt: App Name - default: myapp -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick a version" - default: "1" - options: - - value: "1" - prompt: "Version One" - variables: - - name: chosen_version - prompt: false - default: "v1-silent" - - value: "2" - prompt: "Version Two" - variables: - - name: chosen_version - prompt: false - default: "v2-silent" +- name: app_name + prompt: App Name + default: myapp + +- name: feature_choice + type: choice + prompt: "Pick a version" + default: "1" + options: ["1", "2"] + extend: + - when: "1" + variables: + - name: chosen_version + prompt: false + default: "v1-silent" + - when: "2" + variables: + - name: chosen_version + prompt: false + default: "v2-silent" diff --git a/tests/data/templates/generate/test20/.generate.yml b/tests/data/templates/generate/test20/.generate.yml index a5f05120..1a8d9a6c 100644 --- a/tests/data/templates/generate/test20/.generate.yml +++ b/tests/data/templates/generate/test20/.generate.yml @@ -1,14 +1,14 @@ --- variables: - - name: app_name - prompt: App Name - default: myapp -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick" - default: "X" - options: - - value: "Y" +- name: app_name + prompt: App Name + default: myapp + +- name: feature_choice + type: choice + prompt: "Pick" + default: "X" + options: + - value: "Y" diff --git a/tests/data/templates/generate/test21/.generate.yml b/tests/data/templates/generate/test21/.generate.yml index 32be3c55..3dfab433 100644 --- a/tests/data/templates/generate/test21/.generate.yml +++ b/tests/data/templates/generate/test21/.generate.yml @@ -1,14 +1,14 @@ --- variables: - - name: app_name - prompt: App Name - default: myapp -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick" - default: "A" - options: - - prompt: "A label with no value key" +- name: app_name + prompt: App Name + default: myapp + +- name: feature_choice + type: choice + prompt: "Pick" + default: "A" + options: + - prompt: "A label with no value key" diff --git a/tests/data/templates/generate/test23/.generate.yml b/tests/data/templates/generate/test23/.generate.yml index 3ec114bc..7a04f0fd 100644 --- a/tests/data/templates/generate/test23/.generate.yml +++ b/tests/data/templates/generate/test23/.generate.yml @@ -5,13 +5,14 @@ variables: prompt: App Name default: myapp -features: - name: legacy_bool_feature + type: boolean default: true - enabled: - variables: null - exclude: null - ignore: null - disabled: - ignore: - - '.*nonexistent.*' + extend: + - when: true + variables: null + exclude: null + ignore: null + - when: false + ignore: + - '.*nonexistent.*' diff --git a/tests/data/templates/generate/test24/.generate.yml b/tests/data/templates/generate/test24/.generate.yml index c5596f66..9beb69d2 100644 --- a/tests/data/templates/generate/test24/.generate.yml +++ b/tests/data/templates/generate/test24/.generate.yml @@ -5,13 +5,13 @@ variables: prompt: App Name default: myapp -features: - name: feature_choice - prompt_mode: select + type: choice prompt: "Pick" default: "A" - options: - - value: "A" + options: ["A"] + extend: + - when: "A" variables: null exclude: null ignore: null diff --git a/tests/data/templates/generate/test25/.generate.yml b/tests/data/templates/generate/test25/.generate.yml index 8170cad2..724417b9 100644 --- a/tests/data/templates/generate/test25/.generate.yml +++ b/tests/data/templates/generate/test25/.generate.yml @@ -5,17 +5,18 @@ variables: prompt: App Name default: myapp -features: - name: bool_prereq + type: boolean default: false - name: select_dependent - prompt_mode: select + type: choice prompt: "Dependent" default: "X" + options: ["X"] requires: - bool_prereq - options: - - value: "X" + extend: + - when: "X" ignore: - '.*should-not-appear.*' diff --git a/tests/data/templates/generate/test26/.generate.yml b/tests/data/templates/generate/test26/.generate.yml index 58b4a256..e4312832 100644 --- a/tests/data/templates/generate/test26/.generate.yml +++ b/tests/data/templates/generate/test26/.generate.yml @@ -1,13 +1,13 @@ --- variables: - - name: app_name - prompt: App Name - default: myapp -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick" - default: "A" - options: [] +- name: app_name + prompt: App Name + default: myapp + +- name: feature_choice + type: choice + prompt: "Pick" + default: "A" + options: [] diff --git a/tests/data/templates/generate/test28/.generate.yml b/tests/data/templates/generate/test28/.generate.yml index 47cae73f..73cd00c5 100644 --- a/tests/data/templates/generate/test28/.generate.yml +++ b/tests/data/templates/generate/test28/.generate.yml @@ -1,14 +1,14 @@ --- variables: - - name: app_name - prompt: App Name - default: myapp -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick" - options: - - value: "A" - - value: "B" +- name: app_name + prompt: App Name + default: myapp + +- name: feature_choice + type: choice + prompt: "Pick" + options: + - value: "A" + - value: "B" diff --git a/tests/data/templates/generate/test30/.generate.yml b/tests/data/templates/generate/test30/.generate.yml index 2e143092..f78475b4 100644 --- a/tests/data/templates/generate/test30/.generate.yml +++ b/tests/data/templates/generate/test30/.generate.yml @@ -1,17 +1,17 @@ --- variables: - - name: app_name - prompt: App Name - default: myapp -features: - - name: feature_choice - prompt_mode: select - prompt: "Pick" - default: "A" - options: - - value: "A" - prompt: "Same Label" - - value: "B" - prompt: "Same Label" +- name: app_name + prompt: App Name + default: myapp + +- name: feature_choice + type: choice + prompt: "Pick" + default: "A" + options: + - value: "A" + prompt: "Same Label" + - value: "B" + prompt: "Same Label" diff --git a/tests/data/templates/generate/test31/.generate.yml b/tests/data/templates/generate/test31/.generate.yml new file mode 100644 index 00000000..c122c9ca --- /dev/null +++ b/tests/data/templates/generate/test31/.generate.yml @@ -0,0 +1,8 @@ +--- + +variables: + +- name: enable_thing + type: boolean + prompt: Enable Thing + default: false diff --git a/tests/data/templates/generate/test31/take-me b/tests/data/templates/generate/test31/take-me new file mode 100644 index 00000000..a06da713 --- /dev/null +++ b/tests/data/templates/generate/test31/take-me @@ -0,0 +1 @@ +{% if enable_thing %}enabled{% else %}disabled{% endif %} diff --git a/tests/data/templates/generate/test32/.generate.yml b/tests/data/templates/generate/test32/.generate.yml new file mode 100644 index 00000000..64ab60ea --- /dev/null +++ b/tests/data/templates/generate/test32/.generate.yml @@ -0,0 +1,8 @@ +--- + +variables: + +- name: bad_bool + type: boolean + default: true + case: upper diff --git a/tests/data/templates/generate/test32/take-me b/tests/data/templates/generate/test32/take-me new file mode 100644 index 00000000..4088c330 --- /dev/null +++ b/tests/data/templates/generate/test32/take-me @@ -0,0 +1 @@ +app_name => placeholder diff --git a/tests/data/templates/generate/test33/.generate.yml b/tests/data/templates/generate/test33/.generate.yml new file mode 100644 index 00000000..8035ce61 --- /dev/null +++ b/tests/data/templates/generate/test33/.generate.yml @@ -0,0 +1,8 @@ +--- + +variables: + +- name: silent_flag + type: boolean + prompt: false + default: false diff --git a/tests/data/templates/generate/test33/take-me b/tests/data/templates/generate/test33/take-me new file mode 100644 index 00000000..b5ee8dfb --- /dev/null +++ b/tests/data/templates/generate/test33/take-me @@ -0,0 +1 @@ +{% if silent_flag %}flag-on{% else %}flag-off{% endif %} diff --git a/tests/data/templates/generate/test34/.generate.yml b/tests/data/templates/generate/test34/.generate.yml new file mode 100644 index 00000000..802b2276 --- /dev/null +++ b/tests/data/templates/generate/test34/.generate.yml @@ -0,0 +1,7 @@ +--- + +variables: + +- name: bad_type_var + type: bogus + default: x diff --git a/tests/data/templates/generate/test34/take-me b/tests/data/templates/generate/test34/take-me new file mode 100644 index 00000000..4088c330 --- /dev/null +++ b/tests/data/templates/generate/test34/take-me @@ -0,0 +1 @@ +app_name => placeholder diff --git a/tests/data/templates/generate/test35/.generate.yml b/tests/data/templates/generate/test35/.generate.yml new file mode 100644 index 00000000..9c0423a2 --- /dev/null +++ b/tests/data/templates/generate/test35/.generate.yml @@ -0,0 +1,11 @@ +--- + +variables: + +- name: enable_thing + type: boolean + default: false + prompt: + text: "Do you want the thing? (y/yes/yay vs n/no/nay)" + accept: [y, "yes", yay] + reject: [n, "no", nay] diff --git a/tests/data/templates/generate/test35/take-me b/tests/data/templates/generate/test35/take-me new file mode 100644 index 00000000..a06da713 --- /dev/null +++ b/tests/data/templates/generate/test35/take-me @@ -0,0 +1 @@ +{% if enable_thing %}enabled{% else %}disabled{% endif %} diff --git a/tests/data/templates/generate/test36/.generate.yml b/tests/data/templates/generate/test36/.generate.yml new file mode 100644 index 00000000..0c87669d --- /dev/null +++ b/tests/data/templates/generate/test36/.generate.yml @@ -0,0 +1,11 @@ +--- + +variables: + +- name: enable_thing + type: boolean + default: false + prompt: + text: "Bare bool-like token in accept (YAML 1.1 coercion landmine)" + accept: [y, yes] + reject: [n, "no"] diff --git a/tests/data/templates/generate/test36/take-me b/tests/data/templates/generate/test36/take-me new file mode 100644 index 00000000..a06da713 --- /dev/null +++ b/tests/data/templates/generate/test36/take-me @@ -0,0 +1 @@ +{% if enable_thing %}enabled{% else %}disabled{% endif %} diff --git a/tests/data/templates/generate/test37/.generate.yml b/tests/data/templates/generate/test37/.generate.yml new file mode 100644 index 00000000..4aa916cf --- /dev/null +++ b/tests/data/templates/generate/test37/.generate.yml @@ -0,0 +1,10 @@ +--- + +variables: + +- name: feature_choice + type: choice + prompt: "Pick" + default: "a" + case: upper + options: ["a", "b"] diff --git a/tests/data/templates/generate/test37/take-me b/tests/data/templates/generate/test37/take-me new file mode 100644 index 00000000..ae96aa15 --- /dev/null +++ b/tests/data/templates/generate/test37/take-me @@ -0,0 +1 @@ +{{ feature_choice }} diff --git a/tests/data/templates/generate/test38/.generate.yml b/tests/data/templates/generate/test38/.generate.yml new file mode 100644 index 00000000..fd0e3a70 --- /dev/null +++ b/tests/data/templates/generate/test38/.generate.yml @@ -0,0 +1,32 @@ +--- + +# extend.when LIST-form membership: `when: [flask, fastapi]` fires +# because the resolved value "flask" is a member; `when: [fastapi]` +# does NOT fire because "flask" is not a member. + +variables: + - name: app_name + prompt: App Name + default: myapp + + - name: web_framework + type: choice + prompt: "Web Framework" + default: "flask" + options: ["none", "flask", "fastapi"] + extend: + - when: ["flask", "fastapi"] + ignore: + - '.*not-web.*' + - when: ["fastapi"] + ignore: + - '.*fastapi-only.*' + + # boolean list-form when: value True is a member of [true] -> fires + - name: enable_extra + type: boolean + default: true + extend: + - when: [true] + ignore: + - '.*bool-list-ignored.*' diff --git a/tests/data/templates/generate/test38/bool-list-ignored b/tests/data/templates/generate/test38/bool-list-ignored new file mode 100644 index 00000000..b20ef1a7 --- /dev/null +++ b/tests/data/templates/generate/test38/bool-list-ignored @@ -0,0 +1 @@ +ignored when the boolean True is a member of [true] diff --git a/tests/data/templates/generate/test38/fastapi-only b/tests/data/templates/generate/test38/fastapi-only new file mode 100644 index 00000000..cd0e8d4f --- /dev/null +++ b/tests/data/templates/generate/test38/fastapi-only @@ -0,0 +1 @@ +kept because value flask is not in [fastapi] diff --git a/tests/data/templates/generate/test38/not-web b/tests/data/templates/generate/test38/not-web new file mode 100644 index 00000000..4125efe2 --- /dev/null +++ b/tests/data/templates/generate/test38/not-web @@ -0,0 +1 @@ +should be ignored when value in [flask, fastapi] diff --git a/tests/data/templates/generate/test38/take-me b/tests/data/templates/generate/test38/take-me new file mode 100644 index 00000000..1187b504 --- /dev/null +++ b/tests/data/templates/generate/test38/take-me @@ -0,0 +1 @@ +web_framework => {{ web_framework }} diff --git a/tests/data/templates/generate/test39/.generate.yml b/tests/data/templates/generate/test39/.generate.yml new file mode 100644 index 00000000..2ac85fa6 --- /dev/null +++ b/tests/data/templates/generate/test39/.generate.yml @@ -0,0 +1,22 @@ +--- + +# extend.when STRING-type regex (re.match, string vars only): the +# resolved value "v2.1" matches `^v2` so that rule fires; `^v3` does +# not match so its rule does not fire. + +variables: + - name: app_name + prompt: App Name + default: myapp + + - name: version + type: string + prompt: false + default: "v2.1" + extend: + - when: "^v2" + ignore: + - '.*not-v2.*' + - when: "^v3" + ignore: + - '.*v3-only.*' diff --git a/tests/data/templates/generate/test39/not-v2 b/tests/data/templates/generate/test39/not-v2 new file mode 100644 index 00000000..b306a279 --- /dev/null +++ b/tests/data/templates/generate/test39/not-v2 @@ -0,0 +1 @@ +ignored because version matches ^v2 diff --git a/tests/data/templates/generate/test39/take-me b/tests/data/templates/generate/test39/take-me new file mode 100644 index 00000000..54ae94ee --- /dev/null +++ b/tests/data/templates/generate/test39/take-me @@ -0,0 +1 @@ +version => {{ version }} diff --git a/tests/data/templates/generate/test39/v3-only b/tests/data/templates/generate/test39/v3-only new file mode 100644 index 00000000..2e8fa78d --- /dev/null +++ b/tests/data/templates/generate/test39/v3-only @@ -0,0 +1 @@ +kept because version v2.1 does not match ^v3 diff --git a/tests/data/templates/generate/test40/.generate.yml b/tests/data/templates/generate/test40/.generate.yml new file mode 100644 index 00000000..4369c5b6 --- /dev/null +++ b/tests/data/templates/generate/test40/.generate.yml @@ -0,0 +1,26 @@ +--- + +# Q1: a requires-gated-out variable is set to its `default` at +# data[name] (so {{ dependent }} renders "fallback" and never +# KeyErrors), AND its extend rules do NOT fire (should-be-kept +# survives even though the `when` would match the default value). + +variables: + - name: app_name + prompt: App Name + default: myapp + + - name: prereq + type: boolean + default: false + + - name: dependent + type: string + prompt: false + default: "fallback" + requires: + - prereq + extend: + - when: "fallback" + ignore: + - '.*should-be-kept.*' diff --git a/tests/data/templates/generate/test40/should-be-kept b/tests/data/templates/generate/test40/should-be-kept new file mode 100644 index 00000000..1077ff76 --- /dev/null +++ b/tests/data/templates/generate/test40/should-be-kept @@ -0,0 +1 @@ +kept because the gated-out dependent's extend rule does not fire diff --git a/tests/data/templates/generate/test40/take-me b/tests/data/templates/generate/test40/take-me new file mode 100644 index 00000000..c45f2159 --- /dev/null +++ b/tests/data/templates/generate/test40/take-me @@ -0,0 +1 @@ +dependent => {{ dependent }} diff --git a/tests/data/templates/generate/test41/.generate.yml b/tests/data/templates/generate/test41/.generate.yml new file mode 100644 index 00000000..96cafd9d --- /dev/null +++ b/tests/data/templates/generate/test41/.generate.yml @@ -0,0 +1,35 @@ +--- + +# Dict-form requires: {name: value} equality (D-09). `dep_ok` requires +# mode == "advanced" (satisfied → resolves to its default and its extend +# fires); `dep_no` requires mode == "basic" (NOT satisfied → gated out, +# renders its default, extend does not fire). + +variables: + - name: mode + type: choice + prompt: "Mode" + default: "advanced" + options: ["basic", "advanced"] + + - name: dep_ok + type: string + prompt: false + default: "ok" + requires: + mode: advanced + extend: + - when: "ok" + ignore: + - '.*dep-ok-ignored.*' + + - name: dep_no + type: string + prompt: false + default: "no" + requires: + mode: basic + extend: + - when: "no" + ignore: + - '.*dep-no-kept.*' diff --git a/tests/data/templates/generate/test41/dep-no-kept b/tests/data/templates/generate/test41/dep-no-kept new file mode 100644 index 00000000..e247fbf0 --- /dev/null +++ b/tests/data/templates/generate/test41/dep-no-kept @@ -0,0 +1 @@ +kept because dep_no requires {mode: basic} fails -> gated out -> extend does not fire diff --git a/tests/data/templates/generate/test41/dep-ok-ignored b/tests/data/templates/generate/test41/dep-ok-ignored new file mode 100644 index 00000000..6b0c39df --- /dev/null +++ b/tests/data/templates/generate/test41/dep-ok-ignored @@ -0,0 +1 @@ +ignored because dep_ok requires {mode: advanced} is satisfied and its extend fires diff --git a/tests/data/templates/generate/test41/take-me b/tests/data/templates/generate/test41/take-me new file mode 100644 index 00000000..08fd1ffe --- /dev/null +++ b/tests/data/templates/generate/test41/take-me @@ -0,0 +1 @@ +mode={{ mode }} dep_ok={{ dep_ok }} dep_no={{ dep_no }} diff --git a/tests/data/templates/generate/test42/.generate.yml b/tests/data/templates/generate/test42/.generate.yml new file mode 100644 index 00000000..832c02f9 --- /dev/null +++ b/tests/data/templates/generate/test42/.generate.yml @@ -0,0 +1,18 @@ +--- + +# test42: type: boolean string-form with NO `prompt:` key. The +# framework must default the label to "Enable {name}" (D-12) so the +# rendered prompt reads "Enable enable_thing [(Y)es/(N)o] [N]:" — NOT +# the literal "None [(Y)es/(N)o] [N]:". A second var declares an +# explicit `prompt:` to prove the author label still overrides. + +variables: + +- name: enable_thing + type: boolean + default: false + +- name: enable_other + type: boolean + prompt: Use Other + default: false diff --git a/tests/data/templates/generate/test42/take-me b/tests/data/templates/generate/test42/take-me new file mode 100644 index 00000000..62ea1402 --- /dev/null +++ b/tests/data/templates/generate/test42/take-me @@ -0,0 +1,2 @@ +{% if enable_thing %}enabled{% else %}disabled{% endif %} +{% if enable_other %}other-on{% else %}other-off{% endif %} diff --git a/tests/data/templates/generate/test43/.generate.yml b/tests/data/templates/generate/test43/.generate.yml new file mode 100644 index 00000000..2c5b52a8 --- /dev/null +++ b/tests/data/templates/generate/test43/.generate.yml @@ -0,0 +1,15 @@ +--- + +variables: + +- name: app_name + prompt: App Name + default: myapp + +- name: feature1 + type: boolean + default: false + +- name: dependent + requires: + - feature1 diff --git a/tests/data/templates/generate/test43/take-me b/tests/data/templates/generate/test43/take-me new file mode 100644 index 00000000..795b8a93 --- /dev/null +++ b/tests/data/templates/generate/test43/take-me @@ -0,0 +1 @@ +app_name => {{ app_name }} diff --git a/tests/data/templates/generate/test44/.generate.yml b/tests/data/templates/generate/test44/.generate.yml new file mode 100644 index 00000000..1b1b4345 --- /dev/null +++ b/tests/data/templates/generate/test44/.generate.yml @@ -0,0 +1,15 @@ +--- + +variables: + +- name: alpha + type: boolean + default: true + requires: + - beta + +- name: beta + type: boolean + default: true + requires: + - alpha diff --git a/tests/data/templates/generate/test44/take-me b/tests/data/templates/generate/test44/take-me new file mode 100644 index 00000000..16c61368 --- /dev/null +++ b/tests/data/templates/generate/test44/take-me @@ -0,0 +1 @@ +alpha => {{ alpha }} diff --git a/tests/data/templates/generate/test6/.generate.yml b/tests/data/templates/generate/test6/.generate.yml index 14fd383d..3da01057 100644 --- a/tests/data/templates/generate/test6/.generate.yml +++ b/tests/data/templates/generate/test6/.generate.yml @@ -6,31 +6,33 @@ variables: prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: true - enabled: - variables: - - name: feature1_var - prompt: Feature1 Variable - default: feature1_val - ignore: - - '.*no-feature1.*' - disabled: - exclude: - - '.*feature1-file.*' - ignore: - - '.*feature1-only.*' + extend: + - when: true + variables: + - name: feature1_var + prompt: Feature1 Variable + default: feature1_val + ignore: + - '.*no-feature1.*' + - when: false + exclude: + - '.*feature1-file.*' + ignore: + - '.*feature1-only.*' - name: feature2 + type: boolean default: false - enabled: - variables: - - name: feature2_var - prompt: Feature2 Variable - default: feature2_val - disabled: - ignore: - - '.*feature2-only.*' - - '.*feature2-file.*' + extend: + - when: true + variables: + - name: feature2_var + prompt: Feature2 Variable + default: feature2_val + - when: false + ignore: + - '.*feature2-only.*' + - '.*feature2-file.*' diff --git a/tests/data/templates/generate/test7/.generate.yml b/tests/data/templates/generate/test7/.generate.yml index 13dbab75..70eec137 100644 --- a/tests/data/templates/generate/test7/.generate.yml +++ b/tests/data/templates/generate/test7/.generate.yml @@ -6,30 +6,32 @@ variables: prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: false - enabled: - variables: - - name: feature1_var - prompt: Feature1 Variable - default: feature1_val - ignore: - - '.*no-feature1.*' - disabled: - exclude: - - '.*feature1-file.*' - ignore: - - '.*feature1-only.*' + extend: + - when: true + variables: + - name: feature1_var + prompt: Feature1 Variable + default: feature1_val + ignore: + - '.*no-feature1.*' + - when: false + exclude: + - '.*feature1-file.*' + ignore: + - '.*feature1-only.*' - name: feature2 + type: boolean default: true - enabled: - variables: - - name: feature2_var - prompt: Feature2 Variable - default: feature2_val - disabled: - ignore: - - '.*feature2-only.*' + extend: + - when: true + variables: + - name: feature2_var + prompt: Feature2 Variable + default: feature2_val + - when: false + ignore: + - '.*feature2-only.*' diff --git a/tests/data/templates/generate/test8/.generate.yml b/tests/data/templates/generate/test8/.generate.yml index 8ea97121..333e9bbb 100644 --- a/tests/data/templates/generate/test8/.generate.yml +++ b/tests/data/templates/generate/test8/.generate.yml @@ -6,6 +6,5 @@ variables: prompt: App Name default: myapp -features: - - default: true + type: boolean diff --git a/tests/data/templates/generate/test9/.generate.yml b/tests/data/templates/generate/test9/.generate.yml index 770b382e..36bc63fb 100644 --- a/tests/data/templates/generate/test9/.generate.yml +++ b/tests/data/templates/generate/test9/.generate.yml @@ -6,18 +6,20 @@ variables: prompt: App Name default: myapp -features: - - name: feature1 + type: boolean default: true - disabled: - ignore: - - '.*feature1-only.*' + extend: + - when: false + ignore: + - '.*feature1-only.*' - name: feature2 + type: boolean default: true requires: - feature1 - disabled: - ignore: - - '.*feature2-only.*' + extend: + - when: false + ignore: + - '.*feature2-only.*' diff --git a/tests/ext/test_ext_generate.py b/tests/ext/test_ext_generate.py index 2bb1c8a7..c4210dcc 100644 --- a/tests/ext/test_ext_generate.py +++ b/tests/ext/test_ext_generate.py @@ -224,15 +224,19 @@ def test_generate_features_disabled(tmp): def test_generate_features_missing_name(tmp): + # test8: a variable missing the required `name` key → ValueError + # (config schema error, fail-fast, survives `python -O`). argv = ['generate', 'test8', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: - with raises(ValueError, match='Required feature config key missing: name'): + with raises(ValueError, match='Required generate config key missing: name'): app.run() def test_generate_features_requires_satisfied(tmp): - # test9: feature1=true (default), feature2=true (default), feature2 requires feature1 + # test9: feature1=true (default), feature2=true (default) requires + # feature1. Both gates pass, neither `when: false` ignore fires, so + # both feature1-only and feature2-only stay. argv = ['generate', 'test9', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -245,8 +249,11 @@ def test_generate_features_requires_satisfied(tmp): 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 + # test11: feature1=false (default) → its `when: false` ignores + # feature1-only. feature2=true requires feature1, so feature2 is + # GATED OUT: set to its default and its `when: true` extend rule does + # NOT fire (Q1). feature2-only therefore STAYS (the gated-out var's + # effects are omitted, not inverted into a disabled block). argv = ['generate', 'test11', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -254,19 +261,43 @@ def test_generate_features_requires_not_satisfied(tmp): assert exists_join(tmp.dir, 'take-me') - # feature1 disabled: feature1-only ignored + # feature1 false: its when:false rule fires, 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') + # feature2 gated out via requires: its extend rule does NOT fire, + # so feature2-only is kept (Q1 — effects omitted, not disabled) + assert exists_join(tmp.dir, 'feature2-only') def test_generate_features_requires_unknown(tmp): - # test10: feature requires a nonexistent feature + # test10: a variable requires a nonexistent variable → ValueError. argv = ['generate', 'test10', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: - with raises(ValueError, match="requires unknown feature"): + with raises(ValueError, match="requires unknown variable"): + app.run() + + +def test_generate_requires_gated_no_default(tmp): + # test43: `dependent` (a string var) is gated out by `requires: feature1` + # (feature1 defaults false) but declares no `default`. Emitting it would + # otherwise leak the literal string "None" into the context; instead + # _gated_default fail-fasts with a clear ValueError. + argv = ['generate', 'test43', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match="requires: but has no default"): + app.run() + + +def test_generate_requires_cycle(tmp): + # test44: alpha requires beta and beta requires alpha — a dependency + # cycle. resolve_and_emit's in-flight guard raises a clear ValueError + # rather than recursing until Python raises RecursionError. + argv = ['generate', 'test44', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match="Cyclic variable dependency"): app.run() @@ -283,30 +314,135 @@ def test_generate_features_minimal(tmp): assert 'myapp' in res +def test_generate_boolean_prompt_yes(tmp): + # test31: type: boolean with a string-form prompt, run interactively + # (no --defaults). Patch shell.Prompt.prompt to return "yes" → the + # y/yes→True mapping runs and data[name] is True (jinja renders the + # enabled branch). + with patch.object(shell.Prompt, 'prompt', return_value='yes'): + argv = ['generate', 'test31', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'enabled' in f.read() + + +def test_generate_boolean_prompt_no(tmp): + # test31: prompt returns "n" → n/no→False mapping; data[name] False. + with patch.object(shell.Prompt, 'prompt', return_value='n'): + argv = ['generate', 'test31', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'disabled' in f.read() + + +def test_generate_boolean_prompt_empty_uses_default(tmp): + # test31: prompt returns "" (empty) → falls through to the var's bool + # default (false) → data[name] False. + with patch.object(shell.Prompt, 'prompt', return_value=''): + argv = ['generate', 'test31', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'disabled' in f.read() + + +def test_generate_boolean_prompt_no_label_defaults_to_enable(tmp): + # test42: type: boolean string-form with NO `prompt:` key. The + # framework must default the label to "Enable {name}" (D-12) — the + # rendered prompt text reads "Enable enable_thing [(Y)es/(N)o] [N]:", + # NOT the literal "None [(Y)es/(N)o] [N]:". An explicit `prompt:` on + # the second var ("Use Other") still wins. Capture each prompt's + # bound Meta.text via a recorder patched onto shell.Prompt.prompt + # (mirrors the test31 patched-prompt pattern), then assert the labels. + seen = [] + + def record(self): + seen.append(self._meta.text) + return '' + + with patch.object(shell.Prompt, 'prompt', record): + argv = ['generate', 'test42', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + + assert 'Enable enable_thing [(Y)es/(N)o] [N]:' in seen + # explicit prompt: still overrides — never decorated with the label. + assert 'Use Other [(Y)es/(N)o] [N]:' in seen + # and the broken literal must NOT appear. + assert 'None [(Y)es/(N)o] [N]:' not in seen + + +def test_generate_boolean_case_is_string_only(tmp): + # test32: a type: boolean variable that ALSO declares case: → ValueError + # (D-17: case:/validate: are string-only semantics; declaring either on + # a boolean is a fail-fast schema misconfig). + argv = ['generate', 'test32', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match="case.*string-only"): + app.run() + + +def test_generate_boolean_silent(tmp): + # test33: type: boolean with `prompt: false` (silent) emits bool(default) + # at data[name] — a real Python bool, NOT str(default). The fixture uses + # `default: false`, so a correctly-typed False renders the else branch + # (flag-off). A regressed impl emitting the string 'False' would be + # truthy → render flag-on → fail here, guarding the typed-output contract. + argv = ['generate', 'test33', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'flag-off' in f.read() + + +def test_generate_invalid_type(tmp): + # test34: a variable declaring an unknown `type:` → ValueError (T-05.1-01 + # input-validation mitigation; fail-fast, survives `python -O`). + argv = ['generate', 'test34', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match="invalid type 'bogus'"): + app.run() + + 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 + # test14: feature1=false gates feature2 (default false), which gates + # feature3 (default false). Each gated-out variable resolves to its + # own falsy default (Q1), so the cascade propagates: a downstream + # `requires` against a gated-out var also fails. The resolved bool + # values render into take-me; all three end up False. argv = ['generate', 'test14', 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() + # transitive cascade: all three gated-out to their False default + assert 'feature1=False' in res + assert 'feature2=False' in res + assert 'feature3=False' in res - # all three features should be disabled via transitive requires + # feature1 false: its when:false rule fires, feature1-only ignored assert not exists_join(tmp.dir, 'feature1-only') - assert not exists_join(tmp.dir, 'feature2-only') - assert not exists_join(tmp.dir, 'feature3-only') + + # feature2/feature3 gated out: their extend rules do NOT fire (Q1), + # so their -only files stay + assert exists_join(tmp.dir, 'feature2-only') + assert 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. + # Both default to true. The lazy recursive resolver resolves + # feature_a on demand when feature_b's requires gate is checked, so + # resolution is order-independent and both stay enabled regardless of + # the YAML declaration order. argv = ['generate', 'test15', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -320,7 +456,9 @@ def test_generate_features_requires_out_of_order(tmp): def test_generate_features_null_block(tmp): - # test12: feature with enabled: null (no block defined) + # test12: a type: boolean variable with an extend rule whose + # variables/exclude/ignore sub-keys are all null → coalesce to empty + # lists via the `... or []` idiom (no crash). argv = ['generate', 'test12', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -369,11 +507,12 @@ class Meta: # ─── #779: prompt_mode: select multi-valued feature prompts ───────────── -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. +def test_generate_choice_defaults(tmp): + # test16: type: choice with three options; --defaults dispatches to + # the variable default ("N"). The migrated fixture carries an + # `extend.when: "N"` rule whose ignore patterns (branch-1-only, + # branch-2-only) must FIRE via scalar str()-coerced equality; + # branch-N-file and take-me remain. argv = ['generate', 'test16', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -385,31 +524,20 @@ def test_generate_features_select_defaults(tmp): 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'] +# test17 (select_collision) and test18 (invalid_prompt_mode) were +# REMOVED in Plan 03: they asserted on the `prompt_mode`/`enabled`/ +# `disabled` schema that ceases to exist once the legacy `features:` +# bridge is deleted. The `type:` whitelist they would have repurposed to +# is already covered by test_generate_invalid_type (test34, `type: +# bogus` → ValueError). - 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. +def test_generate_choice_silent_variable_via_extend(tmp): + # test19: type: choice with a SCALAR options list (["1", "2"]); + # --defaults dispatches to the default ("1"). The migrated fixture's + # `extend.when: "1"` rule fires via scalar str()-coerced equality and + # contributes a silent variable `chosen_version: v1-silent` + # (prompt: false). The silent variable's default lands verbatim. argv = ['generate', 'test19', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -420,8 +548,8 @@ def test_generate_features_select_silent_variable(tmp): 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" → +def test_generate_choice_default_not_in_values(tmp): + # test20: choice default "X" but options only contains "Y" → # ValueError at config validation. argv = ['generate', 'test20', tmp.dir, '--defaults'] @@ -430,8 +558,8 @@ def test_generate_features_select_default_not_in_values(tmp): app.run() -def test_generate_features_select_missing_value(tmp): - # test21: an options branch missing value: → ValueError. +def test_generate_choice_missing_value(tmp): + # test21: a choice options branch object missing value: → ValueError. argv = ['generate', 'test21', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -456,10 +584,9 @@ def test_generate_features_top_level_silent_variable(tmp): 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. + # test23: a type: boolean extend rule with + # `variables: null, exclude: null, ignore: null` must coalesce + # safely via the `rule.get(...) or []` idiom (no crash). argv = ['generate', 'test23', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -469,10 +596,9 @@ def test_generate_features_legacy_null_variables(tmp): def test_generate_features_select_null_variables(tmp): - # test24: regression — select-mode options branch with + # test24: a type: choice extend rule with # `variables: null, exclude: null, ignore: null` must coalesce - # safely via the same `block.get(...) or []` form on the select - # merge path. + # safely via the same `rule.get(...) or []` idiom. argv = ['generate', 'test24', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -482,12 +608,11 @@ def test_generate_features_select_null_variables(tmp): 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. + # test25: a type: choice variable has `requires: [bool_prereq]` + # where bool_prereq defaults to false. The choice is GATED OUT: set + # to its default and its extend rules do NOT fire (Q1), so the + # options-branch ignore patterns never apply — `should-not-appear-1` + # remains in the rendered tree. argv = ['generate', 'test25', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: @@ -497,12 +622,12 @@ def test_generate_features_select_requires_cascade(tmp): assert exists_join(tmp.dir, 'should-not-appear-1') -def test_generate_features_select_empty_options(tmp): - # test26: prompt_mode: select with `options: []` → ValueError. +def test_generate_choice_empty_options(tmp): + # test26: type: choice with `options: []` → ValueError. argv = ['generate', 'test26', tmp.dir, '--defaults'] with GenerateApp(argv=argv) as app: - with raises(ValueError, match="no 'options' branches"): + with raises(ValueError, match="no 'options'"): app.run() @@ -517,15 +642,15 @@ def test_generate_features_silent_variable_no_default(tmp): 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. +def test_generate_choice_missing_default(tmp): + # test28: type: choice 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"): + with raises(ValueError, match="has no 'default'"): app.run() @@ -545,12 +670,188 @@ def test_generate_features_silent_variable_non_str_default(tmp): 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. +def test_generate_choice_duplicate_labels(tmp): + # test30: two choice 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() + + +# ─── #782 Plan 02: type: choice picker + object-form boolean prompt ────── + + +def test_generate_choice_picker_maps_label_to_value(tmp): + # test16 (interactive): patch shell.Prompt.prompt to return the + # numbered-picker label for the "2" option ("Branch Two"). The + # picker maps the chosen label → option value "2", and the + # extend.when: "2" rule fires (ignores branch-N-file + branch-1-only; + # branch-2-only survives). + with patch.object(shell.Prompt, 'prompt', return_value='Branch Two'): + argv = ['generate', 'test16', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + assert exists_join(tmp.dir, 'branch-2-only') + assert not exists_join(tmp.dir, 'branch-N-file') + assert not exists_join(tmp.dir, 'branch-1-only') + + +def test_generate_boolean_object_prompt_accept(tmp): + # test35: object-form bool prompt {text, accept, reject}. A member + # of accept (case-insensitive) → data[name] True → enabled branch. + with patch.object(shell.Prompt, 'prompt', return_value='YAY'): + argv = ['generate', 'test35', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'enabled' in f.read() + + +def test_generate_boolean_object_prompt_reject(tmp): + # test35: a member of reject → data[name] False → disabled branch. + with patch.object(shell.Prompt, 'prompt', return_value='No'): + argv = ['generate', 'test35', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'disabled' in f.read() + + +def test_generate_boolean_object_prompt_empty_uses_default(tmp): + # test35: empty input falls through to the var's bool default + # (false) → disabled branch. + with patch.object(shell.Prompt, 'prompt', return_value=''): + argv = ['generate', 'test35', tmp.dir] + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'disabled' in f.read() + + +def test_generate_boolean_object_prompt_defaults(tmp): + # test35 with --defaults: the object-form bool short-circuits to + # bool(default) (false) without prompting → disabled branch. + argv = ['generate', 'test35', tmp.dir, '--defaults'] + with GenerateApp(argv=argv) as app: + app.run() + with open(os.path.join(tmp.dir, 'take-me')) as f: + assert 'disabled' in f.read() + + +def test_generate_boolean_object_prompt_junk_aborts(tmp): + # test35: junk input matching NEITHER accept nor reject → assert + + # abort with an "Invalid Response"-style message (D-14, mirrors + # validate:). No silent coercion to False. + with patch.object(shell.Prompt, 'prompt', return_value='maybe'): + argv = ['generate', 'test35', tmp.dir] + with GenerateApp(argv=argv) as app: + with raises(AssertionError, match="Invalid Response.*"): + app.run() + + +def test_generate_boolean_object_prompt_yaml_bool_coercion_guard(tmp): + # test36: an accept:/reject: member that loaded as a Python bool + # (YAML 1.1 coercion of bare `yes`) → ValueError at config load, + # instructing the author to quote the bool-like token. + argv = ['generate', 'test36', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match="quote"): + app.run() + + +def test_generate_choice_case_is_string_only(tmp): + # test37: a type: choice variable that ALSO declares case: → + # ValueError (D-17: case:/validate: are string-only; declaring + # either on a choice is a fail-fast schema misconfig). + argv = ['generate', 'test37', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + with raises(ValueError, match="case.*string-only"): + app.run() + + +# ─── #782 Plan 03: extend.when list/regex, nesting, requires: ──────────── + + +def test_generate_extend_when_list_membership(tmp): + # test38: choice value "flask" (default). The `when: [flask, fastapi]` + # rule fires via in-list membership (ignores not-web); the + # `when: [fastapi]` rule does NOT fire ("flask" not a member) so + # fastapi-only is kept. Rules compose independently. + argv = ['generate', 'test38', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + # when: [flask, fastapi] matched → not-web ignored + assert not exists_join(tmp.dir, 'not-web') + # when: [fastapi] did NOT match → fastapi-only kept + assert exists_join(tmp.dir, 'fastapi-only') + # boolean list-form: True is a member of [true] → fires + assert not exists_join(tmp.dir, 'bool-list-ignored') + + +def test_generate_requires_dict_equality(tmp): + # test41: dict-form `requires: {name: value}` equality. dep_ok + # requires mode == "advanced" (satisfied → its extend fires, + # dep-ok-ignored removed); dep_no requires mode == "basic" (NOT + # satisfied → gated out, renders its default "no", extend does NOT + # fire so dep-no-kept survives). + argv = ['generate', 'test41', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + with open(os.path.join(tmp.dir, 'take-me')) as f: + res = f.read() + assert 'mode=advanced' in res + assert 'dep_ok=ok' in res + # gated-out dep_no still renders its default (Q1) + assert 'dep_no=no' in res + + # dep_ok requires satisfied → its extend fired + assert not exists_join(tmp.dir, 'dep-ok-ignored') + # dep_no gated out → its extend did NOT fire + assert exists_join(tmp.dir, 'dep-no-kept') + + +def test_generate_extend_when_string_regex(tmp): + # test39: a type: string variable resolving to "v2.1". The + # `when: "^v2"` rule fires via re.match (string-type regex; ignores + # not-v2); the `when: "^v3"` rule does NOT match so v3-only is kept. + argv = ['generate', 'test39', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + assert exists_join(tmp.dir, 'take-me') + # ^v2 matched "v2.1" → not-v2 ignored + assert not exists_join(tmp.dir, 'not-v2') + # ^v3 did NOT match → v3-only kept + assert exists_join(tmp.dir, 'v3-only') + + +def test_generate_requires_gated_out_renders_default(tmp): + # test40 (Q1): prereq=false gates `dependent`. The gated-out var is + # set to its `default` at data[name] (renders "fallback", never + # KeyErrors) AND its extend rule does NOT fire — even though + # `when: "fallback"` would match the default value — so + # should-be-kept survives. + argv = ['generate', 'test40', tmp.dir, '--defaults'] + + with GenerateApp(argv=argv) as app: + app.run() + + with open(os.path.join(tmp.dir, 'take-me')) as f: + # gated-out dependent renders as its default (Q1) + assert 'fallback' in f.read() + + # the gated-out var's extend rule did NOT fire (Q1) + assert exists_join(tmp.dir, 'should-be-kept')