From 76c2d881cac4fda6a46c42afa13479970121c32f Mon Sep 17 00:00:00 2001 From: Andre van Zuydam Date: Sat, 17 Oct 2026 03:48:01 +0200 Subject: [PATCH 1/2] fix(cli): --help prints usage; unknown options are refused `--help` / `-h` after any command now prints that command's usage and exits 0 without touching the project. Handlers never looked for it, so it ran the command for real: `generate model --help` wrote a model named `--help`, `lint --help` installed ruff, `migrate --help` created app.db. Each registry entry now declares its options ({name: takes_value}), and main() checks them before any handler runs: - an unknown option is refused with a did-you-mean (`--feilds` was silently dropped and the model came out without its fields); - a value option with no value is refused (`--fields` crashed with a traceback, `--every` was ignored); - `--name=value` works as well as `--name value`; - `test` is not checked: its arguments belong to pytest. Also: - `generate` refuses a name that starts with `-`; - `ai --all` installs every context file as its help says (it opened the menu); - `help --help` prints the reference (`help` is not option-checked); - `generate crud --skip-model` works. `generate model` has always suggested it, but no generator had the option. Tests: 96 no-mock subprocess cases; the project tree is hashed before and after each --help run. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andre van Zuydam --- CHANGELOG.md | 19 ++++ plan/cli-help-and-options.md | 90 +++++++++++++++ tests/test_cli_help_flags.py | 210 +++++++++++++++++++++++++++++++++++ tina4_python/cli/__init__.py | 174 +++++++++++++++++++++++------ 4 files changed, 460 insertions(+), 33 deletions(-) create mode 100644 plan/cli-help-and-options.md create mode 100644 tests/test_cli_help_flags.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e362cc..9ccca5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ https://tina4.com/python/36-releases This file records framework-specific changes. The release notes above remain the authority for shipped versions. +## Unreleased + +### Fixed +- CLI: `--help` / `-h` on any command prints that command's usage and changes + nothing. Before, handlers never looked for it and ran for real: + `generate model --help` wrote a model named `--help`, `lint --help` installed + ruff, `migrate --help` created `app.db`. +- CLI: an unknown option is refused with a did-you-mean (`--feilds` → + `--fields`) instead of being silently dropped, and a value option given no + value is refused instead of crashing (`--fields`) or being ignored (`--every`). + `--name=value` works as well as `--name value`. `test` still hands its + arguments to pytest unchecked. +- CLI: `generate` refuses a name that starts with `-`. +- CLI: `ai --all` installs every context file, as its help says; it opened the + menu before. +- CLI: `generate crud --skip-model` works. `generate model` has always + suggested it, but no generator had the option; it was silently ignored and + would now be refused. + ## 3.13.151 - 2026-10-15 ### Security diff --git a/plan/cli-help-and-options.md b/plan/cli-help-and-options.md new file mode 100644 index 00000000..7bfa3c11 --- /dev/null +++ b/plan/cli-help-and-options.md @@ -0,0 +1,90 @@ +# Task: CLI `--help` and option checking, all four frameworks + +## Outcome +`--help` / `-h` on any framework CLI command prints that command's usage and +writes nothing. Unknown options are refused with a did-you-mean, value options +need a value, and `ai --all` works. Same behaviour and same wording in Python, +PHP, Ruby and Node, each with equivalent no-mock tests. + +## Context +Found through `tina4 generate model --help`, which created a model called +`--help` (four files). The Rust `tina4` client forwards these commands verbatim, +so the fix belongs in the framework CLIs. A sweep in a scaffolded project +(Termux, tina4python 3.13.151) showed every one of the 31 forwarded commands +ignored `--help`. Several wrote files or changed the project: + +| Command with `--help` | What it did | +|---|---| +| `generate model/route/page/crud/migration/...` | files named `--help` / `-h` | +| `generate base`, `generate auth` | ran fully | +| `migrate`, `migrate:rollback`, `migrate:status`, `seed`, `console` | created `app.db` | +| `lint` | installed ruff, rewrote `pyproject.toml` and `uv.lock` | +| `start`, `test`, `queue` | exit 1, no help | + +Other broken switches found by the same audit: +- Misspelled or short options (`--feilds`, `--publc`, `-f`) were silently + dropped: the model came out without its fields, exit 0. +- `--fields` with no value crashed with a traceback; `--every` with no value + was ignored. +- `ai --all` was documented but the code checked for the bare word `all`. + +## Design (Python is the reference) +- Each registry entry declares `flags`: `{name: takes_value}`. `test` is + `None` (its arguments belong to pytest). Queue subcommands and the + resolution-aware generators (`--json`, `--dry-run`) have their own maps. +- `main()`, before any handler runs: + 1. `--help` / `-h` anywhere after a command → that command's usage, exit 0. + 2. Unknown option → `Unknown option --feilds for "generate model". Did you mean --fields?` + then `Run "tina4python generate model --help" for its options.`, exit 1. + 3. Value option with no value → `Option --fields for "generate model" needs a value: --fields `, exit 1. +- `--name=value` is accepted as well as `--name value`. +- `generate` refuses a name starting with `-`. +- Delegated commands (`doctor`, `setup`, `deploy`) are untouched: the `tina4` + client answers them. +- **Ruby exception:** `serve -h` already means `--host` there. `--help` is help + everywhere; `-h` keeps meaning host for Ruby `serve` so scripts don't break. + +## Parity +| Surface | Python | PHP | Ruby | Node | +|---------|--------|-----|------|------| +| `--help` / `-h` per command, no side effects | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | +| unknown option refused + did-you-mean | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | +| value option needs a value | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | +| `--name=value` | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | +| `ai --all` | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | +| generate name can't start with `-` | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | +| `generate crud --skip-model` (the `generate model` hint) | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | +| `help --help` prints the reference | ✅ | ❌ BUILD | ❌ BUILD | ❌ BUILD | + +PHP, Ruby and Node track their own columns in their repos' copy of this plan. + +## Tests (real, no mocks, positive + negative) +- [x] every command and generator, `--help` and `-h`: exit 0, usage printed, + project tree hashed before/after and unchanged (70 cases) +- [x] `--help` after the name and options still wins +- [x] top-level `--help` / `-h`; `generate --help` lists every generator +- [x] misspelled options refused with the right suggestion, nothing written +- [x] short and foreign options refused +- [x] value option without a value refused, no traceback +- [x] `--fields price:float` and `--fields=price:float` both reach the model +- [x] `test -q -k one` still runs pytest +- [x] `ai --all` writes CLAUDE.md without the menu +- [x] every option in a usage string is an accepted option (drift guard) +- [x] `help`, `help --help`, `help -h` print the reference (Node's port found + `help --help` said "Unknown option") +- [x] `generate crud Product --skip-model` after `generate model` keeps the + model byte-identical (PHP's port found the hint pointed at a missing option) +- [x] all 78 original help cases fail on unfixed v3 +- [x] tests/test_cli*.py: 250 passed, 1 skipped. One flaky failure in + `test_cli_serve_debug_env.py` (1 in 7 runs, real server boot); 5/5 on v3 + and 6/7 with the fix +- [x] full suite (macOS, CPython 3.14, no service containers): + branch 6184 passed / 2 failed / 938 skipped; unfixed v3 6091 / 3 / 938. + No failure is new: Firebird connect-timeout and server libcrypto fail on + both, and v3's third is the flaky serve-debug boot test. + +## Commits +- fix(cli): --help prints usage; unknown options are refused (one commit on + fix/cli-help-flags; see `git log`) + +## Status: Python ready for PR vs v3; PHP, Ruby, Node PRs open alongside diff --git a/tests/test_cli_help_flags.py b/tests/test_cli_help_flags.py new file mode 100644 index 00000000..b91db99a --- /dev/null +++ b/tests/test_cli_help_flags.py @@ -0,0 +1,210 @@ +# Copyright (c) 2026 Code Infinity +# SPDX-License-Identifier: MPL-2.0 +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +"""`--help` / `-h` print usage and change nothing, for every command. + +Handlers never looked for a help flag, so it ran the command for real: +`generate model --help` wrote a model named `--help` (four files), `lint --help` +installed ruff, `migrate --help` created app.db. NO MOCKS: each case runs the +REAL `tina4python` entrypoint in a real temp project and diffs the directory. +""" +import hashlib +import os +import subprocess +import sys + +import pytest + +from conftest import child_pythonpath +from tina4_python.cli import COMMANDS, GENERATORS + +_ENTRYPOINT = "import sys; from tina4_python.cli import main; main()" + + +def _run_cli(argv, cwd): + env = os.environ.copy() + env["PYTHONPATH"] = child_pythonpath() + return subprocess.run( + [sys.executable, "-c", _ENTRYPOINT, *argv], + cwd=str(cwd), env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL, + ) + + +def _snapshot(root): + """Every path under root with a hash of its bytes.""" + seen = {} + for dirpath, dirnames, filenames in os.walk(root): + for d in dirnames: + seen[os.path.relpath(os.path.join(dirpath, d), root)] = "dir" + for f in filenames: + path = os.path.join(dirpath, f) + with open(path, "rb") as fh: + seen[os.path.relpath(path, root)] = hashlib.sha256(fh.read()).hexdigest() + return seen + + +def _project(tmp_path): + """A minimal project: an app.py and a pyproject.toml that lint could rewrite.""" + (tmp_path / "app.py").write_text("print('app')\n") + (tmp_path / "pyproject.toml").write_text('[project]\nname = "demo"\nversion = "0.0.0"\n') + return tmp_path + + +_COMMAND_CASES = [[name] for name in COMMANDS if name != "help"] +_GENERATOR_CASES = [["generate", name] for name in GENERATORS] + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +@pytest.mark.parametrize("argv", _COMMAND_CASES + _GENERATOR_CASES, ids=" ".join) +def test_help_flag_prints_usage_and_writes_nothing(tmp_path, argv, flag): + project = _project(tmp_path) + before = _snapshot(project) + result = _run_cli([*argv, flag], project) + assert result.returncode == 0, result.stdout + result.stderr + assert f"Usage: tina4python {' '.join(argv)}" in result.stdout + assert _snapshot(project) == before, "a --help run changed the project" + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_help_flag_after_the_name_still_wins(tmp_path, flag): + project = _project(tmp_path) + before = _snapshot(project) + result = _run_cli(["generate", "model", "Product", "--fields", "name:string", flag], project) + assert result.returncode == 0, result.stdout + result.stderr + assert "Usage: tina4python generate model" in result.stdout + assert _snapshot(project) == before + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_top_level_help_flag_prints_the_reference(tmp_path, flag): + result = _run_cli([flag], tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert "Usage: tina4python " in result.stdout + + +@pytest.mark.parametrize("argv", [["help"], ["help", "--help"], ["help", "-h"]]) +def test_the_help_command_always_prints_the_reference(tmp_path, argv): + result = _run_cli(argv, tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert "Usage: tina4python " in result.stdout + assert "Unknown option" not in result.stdout + + +def test_generate_help_lists_every_generator(tmp_path): + result = _run_cli(["generate", "--help"], tmp_path) + assert result.returncode == 0 + for name in GENERATORS: + assert name in result.stdout + + +@pytest.mark.parametrize("argv", [ + ["generate", "model", "--fields", "name:string"], # name forgotten + ["generate", "route", "--public"], +]) +def test_a_name_that_looks_like_an_option_is_refused(tmp_path, argv): + project = _project(tmp_path) + before = _snapshot(project) + result = _run_cli(argv, project) + assert result.returncode == 1 + assert "can't start with '-'" in result.stdout + assert _snapshot(project) == before + + +# ---- options: unknown ones, missing values, --name=value, ai --all ------- + +@pytest.mark.parametrize("argv, wrong, suggestion", [ + (["generate", "model", "Product", "--feilds", "name:string"], "--feilds", "--fields"), + (["generate", "route", "products", "--publc"], "--publc", "--public"), + (["serve", "--prot", "9000"], "--prot", "--port"), + (["queue", "work", "--onse"], "--onse", "--once"), +]) +def test_a_misspelled_option_is_refused_with_a_suggestion(tmp_path, argv, wrong, suggestion): + project = _project(tmp_path) + before = _snapshot(project) + result = _run_cli(argv, project) + assert result.returncode == 1, result.stdout + result.stderr + assert f"Unknown option {wrong}" in result.stdout + assert f"Did you mean {suggestion}?" in result.stdout + assert _snapshot(project) == before + + +@pytest.mark.parametrize("argv", [ + ["generate", "model", "Product", "-f", "name:string"], # short options don't exist + ["lint", "--everything"], + ["migrate", "--force"], +]) +def test_an_option_the_command_does_not_have_is_refused(tmp_path, argv): + project = _project(tmp_path) + before = _snapshot(project) + result = _run_cli(argv, project) + assert result.returncode == 1, result.stdout + result.stderr + assert "Unknown option" in result.stdout + assert _snapshot(project) == before + + +@pytest.mark.parametrize("argv, option", [ + (["generate", "model", "Product", "--fields"], "--fields"), # used to crash + (["generate", "service", "Nightly", "--every"], "--every"), # used to ignore it + (["generate", "model", "Product", "--fields", "--no-migration"], "--fields"), +]) +def test_a_value_option_without_a_value_is_refused(tmp_path, argv, option): + project = _project(tmp_path) + before = _snapshot(project) + result = _run_cli(argv, project) + assert result.returncode == 1, result.stdout + result.stderr + assert f"Option {option} for" in result.stdout and "needs a value" in result.stdout + assert "Traceback" not in result.stderr + assert _snapshot(project) == before + + +@pytest.mark.parametrize("fields_arg", [["--fields", "price:float"], ["--fields=price:float"]]) +def test_a_value_option_works_in_both_spellings(tmp_path, fields_arg): + project = _project(tmp_path) + result = _run_cli(["generate", "model", "Product", *fields_arg], project) + assert result.returncode == 0, result.stdout + result.stderr + assert "price" in (project / "src" / "orm" / "Product.py").read_text() + + +def test_test_passes_its_arguments_to_pytest_unchecked(tmp_path): + project = _project(tmp_path) + (project / "tests").mkdir() + (project / "tests" / "test_x.py").write_text("def test_one():\n assert True\n") + result = _run_cli(["test", "-q", "-k", "one"], project) + assert "Unknown option" not in result.stdout + assert "1 passed" in result.stdout + result.stderr + + +def test_ai_all_installs_without_the_menu(tmp_path): + result = _run_cli(["ai", "--all"], tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert (tmp_path / "CLAUDE.md").exists() + + +def test_every_option_in_the_help_is_an_accepted_option(): + """The usage text and the accepted options can't drift apart again.""" + import re + from tina4_python.cli import _QUEUE_FLAGS, _RESOLUTION_FLAGS, _RESOLUTION_TARGETS + for name, spec in COMMANDS.items(): + for option in re.findall(r"--([a-z][a-z-]*)", spec.get("usage", "")): + assert option in (spec.get("flags") or {}), f"{name}: --{option} documented, not accepted" + for name, spec in GENERATORS.items(): + accepted = dict(spec["flags"], **(_RESOLUTION_FLAGS if name in _RESOLUTION_TARGETS else {})) + for option in re.findall(r"--([a-z][a-z-]*)", spec.get("usage", "")): + assert option in accepted, f"generate {name}: --{option} documented, not accepted" + assert set(_QUEUE_FLAGS) == set(COMMANDS["queue"]["subcommands"]) + + +def test_crud_skip_model_follows_the_model_generators_hint(tmp_path): + """`generate model` suggests `generate crud --skip-model`; it must work.""" + project = _project(tmp_path) + assert _run_cli(["generate", "model", "Product", "--fields", "price:float"], project).returncode == 0 + model = (project / "src" / "orm" / "Product.py").read_text() + result = _run_cli(["generate", "crud", "Product", "--skip-model"], project) + assert result.returncode == 0, result.stdout + result.stderr + assert "already exists" not in result.stdout + assert (project / "src" / "orm" / "Product.py").read_text() == model + assert list((project / "src" / "routes").glob("*.py")), "crud wrote no admin route" diff --git a/tina4_python/cli/__init__.py b/tina4_python/cli/__init__.py index 50d4d6cf..f63fd48f 100644 --- a/tina4_python/cli/__init__.py +++ b/tina4_python/cli/__init__.py @@ -25,6 +25,7 @@ `tina4python doctor` behaves exactly like `tina4 doctor` without cloning the client's implementation into four languages. """ +import difflib import os import re import secrets @@ -176,13 +177,18 @@ def _parse_flags(args: list[str]) -> tuple[dict, list[str]]: """Parse --key value and --flag from args. Returns (flags, positional).""" # Boolean-only flags that never take a value argument boolean_flags = {"no-browser", "no-reload", "no-kill", "production", "managed", "all", "clear", - "json", "public", "no-migration", "once", "dry-run", "fix", "no-install"} + "json", "public", "no-migration", "no-templates", "skip-model", "once", "dry-run", + "fix", "no-install"} flags = {} positional = [] i = 0 while i < len(args): - if args[i].startswith("--"): + if args[i].startswith("--") and "=" in args[i]: + key, _, value = args[i][2:].partition("=") + flags[key] = value + i += 1 + elif args[i].startswith("--"): key = args[i][2:] if key in boolean_flags: flags[key] = True @@ -381,10 +387,13 @@ def _delegate_to_client(command: str, args: list) -> int: # ── Main entry point ───────────────────────────────────────────────── +HELP_FLAGS = ("--help", "-h") + + def main(): """CLI entry point.""" args = sys.argv[1:] - if not args: + if not args or args[0].lower() in HELP_FLAGS: _help() return @@ -396,6 +405,15 @@ def main(): # `commands --json` manifest, so dispatch, help, and discovery never drift. spec = COMMANDS.get(command) if spec: + # `--help` / `-h` anywhere after a command prints that command's usage + # and touches nothing. Handlers never looked for it, so it ran the + # command for real: `generate model --help` wrote a model named + # `--help`, `lint --help` installed ruff, `migrate --help` made app.db. + # Delegated commands are left alone - the client answers --help itself. + if command != "help" and any(a in HELP_FLAGS for a in cmd_args): + _command_help(command, cmd_args) + return + _check_options(command, cmd_args) spec["handler"](cmd_args) elif command in DELEGATED: sys.exit(_delegate_to_client(command, cmd_args)) @@ -453,6 +471,80 @@ def _env_migrate(args): print(f" original kept at {backup} (delete once you've verified)") +def _command_help(command: str, args: list) -> None: + """Usage for one command (or one generator), from the same registries.""" + if command == "generate": + kind = next((a.lower() for a in args if not a.startswith("-")), None) + gen = GENERATORS.get(kind) if kind else None + if gen: + print(f"Usage: tina4python generate {kind} {gen.get('usage', '')}".rstrip()) + print(f"\n {gen['summary']}") + return + print("Usage: tina4python generate [options]\n\nGenerators:") + pad = max(len(name) for name in GENERATORS) + for name, gen in GENERATORS.items(): + print(f" {name:<{pad}} {gen['summary']}") + return + spec = COMMANDS[command] + print(f"Usage: tina4python {command} {spec.get('usage', '')}".rstrip()) + print(f"\n {spec['summary']}") + if spec.get("subcommands"): + print(f" Subcommands: {', '.join(spec['subcommands'])}") + + +def _accepted_options(command: str, args: list) -> dict | None: + """The options `command` accepts ({name: takes_value}), or None when its + arguments are not ours to check (`test` hands them to pytest, an unknown + generator or queue subcommand is reported by its own handler).""" + if command == "generate": + kind = args[0].lower() if args else "" + gen = GENERATORS.get(kind) + if gen is None: + return None + accepted = dict(gen.get("flags", {})) + if kind in _RESOLUTION_TARGETS: + accepted.update(_RESOLUTION_FLAGS) + return accepted + if command == "queue": + return _QUEUE_FLAGS.get(args[0].lower() if args else "") + return COMMANDS[command].get("flags", {}) + + +def _check_options(command: str, args: list) -> None: + """Refuse an option the command doesn't have, or a value option given no + value. Both used to slip through: `--feilds` was silently dropped (the + model came out without its fields) and a bare `--fields` crashed.""" + accepted = _accepted_options(command, args) + if accepted is None: + return + label = command if command not in ("generate", "queue") else f"{command} {args[0].lower()}" + i = 0 + while i < len(args): + arg = args[i] + if arg.startswith("-") and arg != "-": + name, has_value, _ = arg.lstrip("-").partition("=") + if not arg.startswith("--") or name not in accepted: + print(f'Unknown option {arg.partition("=")[0]} for "{label}".', end="") + close = difflib.get_close_matches(name, list(accepted), n=1, cutoff=0.6) + print(f" Did you mean --{close[0]}?" if close else "") + print(f'Run "tina4python {label} --help" for its options.') + sys.exit(1) + if accepted[name] and not has_value: + if i + 1 >= len(args) or args[i + 1].startswith("-"): + print(f'Option --{name} for "{label}" needs a value: --{name} ') + sys.exit(1) + i += 1 + i += 1 + + +def _reject_flag_like_name(name: str, what: str) -> None: + """Refuse a name that is really a mistyped option (`--fields` with no + name before it, `-x`): it would otherwise become a file called `--fields`.""" + if name.startswith("-"): + print(f"Invalid {what} name: {name!r} - names can't start with '-'.") + sys.exit(1) + + def _help(args=None): """Print the human-readable command reference. @@ -1130,7 +1222,7 @@ def _ai(args): """Install AI coding assistant context files.""" from tina4_python.ai import show_menu, install_selected, install_context - if args and args[0].lower() == "all": + if args and args[0].lower() in ("all", "--all"): install_context(".") else: selection = show_menu(".") @@ -1156,6 +1248,8 @@ def _ai(args): # validator, seeder, websocket, listener, auth, test, form, view) keep their # existing behaviour — this is the minimum surface the plan calls out. _RESOLUTION_TARGETS = {"model", "route", "migration", "middleware", "queue"} +# Options every resolution-aware generator accepts on top of its own. +_RESOLUTION_FLAGS = {"json": False, "dry-run": False} # Envelope schema version. Bump when the SHAPE changes; adding an optional # key on the resolution object is not a break. @@ -1659,6 +1753,8 @@ def _generate(args): sys.exit(1) name = args[1] if len(args) > 1 else "" + if what not in no_name_generators: + _reject_flag_like_name(name, what) flags, _ = _parse_flags(args[2:] if len(args) > 2 else []) # ADR-0062: the four wired generators expose a JSON envelope and a @@ -1984,7 +2080,9 @@ def _gen_crud(name: str, flags: dict): # 1. Model + migration (emit_test=False — crud emits its own broader gate # test at step 4, so the sub-generators stay quiet to avoid double-emit). - _gen_model(name, flags, emit_test=False) + # --skip-model: the model already exists (`generate model` suggests this). + if "skip-model" not in flags: + _gen_model(name, flags, emit_test=False) # 2. Admin page route: registers AutoCrud (secure-by-default; --public opens # writes) and renders the to_crud admin UI at /admin/. @@ -3908,6 +4006,14 @@ def _queue_clear(args): # Sub-dispatch table for the `queue` command — the single source for its # subcommands (drives _queue dispatch AND the manifest's queue.subcommands). +# Options per queue subcommand (name -> takes a value), checked by main(). +_QUEUE_FLAGS = { + "work": {"once": False, "poll": True, "services": True}, + "stats": {"json": False}, + "retry": {}, + "clear": {}, +} + _QUEUE_SUBCOMMANDS = { "work": _queue_work, "stats": _queue_stats, @@ -4024,52 +4130,54 @@ def _commands(args=None): # (_help), AND the machine-readable manifest (commands --json). Add a command # in ONE place and it appears everywhere — there is no second list to sync. # -# GENERATORS[name] = {"handler": fn(name, flags), "usage": str, "summary": str} +# GENERATORS[name] = {"handler": fn(name, flags), "flags": {...}, "usage": str, "summary": str} # COMMANDS[name] = {"handler": fn(args), "summary": str, +# "flags"?: {name: bool}, # options main() accepts; True = takes a value. +# # Absent = none. None = not checked (test -> pytest) # "usage"?: str, # arg/flag hint for _help (human only) # "args"?: [str], # positional args for the manifest ("x?" = optional) # "subcommands"?: [str]} # sub-names for the manifest (generate) GENERATORS = { - "model": {"handler": _gen_model, "usage": ' [--fields "name:string,price:float"] [--table-name ]', "summary": "ORM model + matching migration"}, - "route": {"handler": _gen_route, "usage": " [--model Name] [--public]", "summary": "CRUD route file, secure by default (--public opens writes)"}, - "base": {"handler": _gen_base, "usage": "", "summary": "Frond base template + index page + pages/ folder"}, - "page": {"handler": _gen_page, "usage": "", "summary": "Page that extends base.twig (auto-creates pages/ + base)"}, - "crud": {"handler": _gen_crud, "usage": ' [--fields "..."] [--public] [--no-templates]', "summary": "Model + migration + AutoCrud-backed admin page (to_crud) + editable crud/ templates + gate test"}, - "migration": {"handler": _gen_migration, "usage": "", "summary": "Timestamped migration file (UP/DOWN)"}, - "middleware": {"handler": _gen_middleware, "usage": "", "summary": "Middleware with before/after hooks"}, - "test": {"handler": _gen_test, "usage": " [--model Name]", "summary": "pytest test file"}, - "form": {"handler": _gen_form, "usage": ' [--fields "..."]', "summary": "Form template with inputs matching model fields"}, - "view": {"handler": _gen_view, "usage": ' [--fields "..."]', "summary": "List + detail view templates"}, - "auth": {"handler": _gen_auth, "usage": "", "summary": "Login/register routes + User model + templates"}, - "service": {"handler": _gen_service, "usage": ' [--every 5m | --cron "..."]', "summary": "Scheduled ServiceRunner task (src/services/)"}, - "queue": {"handler": _gen_queue, "usage": "", "summary": "Producer + consumer worker (src/services/)"}, - "validator": {"handler": _gen_validator, "usage": "", "summary": "Request-body Validator (src/validators/)"}, - "seeder": {"handler": _gen_seeder, "usage": "", "summary": "FakeData + seed_orm seeder (src/seeds/)"}, - "websocket": {"handler": _gen_websocket, "usage": "", "summary": "@websocket handler (src/routes/)"}, - "listener": {"handler": _gen_listener, "usage": "", "summary": "@on(event) listener (src/listeners/)"}, + "model": {"handler": _gen_model, "flags": {"fields": True, "table-name": True, "no-migration": False}, "usage": ' [--fields "name:string,price:float"] [--table-name ]', "summary": "ORM model + matching migration"}, + "route": {"handler": _gen_route, "flags": {"model": True, "public": False}, "usage": " [--model Name] [--public]", "summary": "CRUD route file, secure by default (--public opens writes)"}, + "base": {"handler": _gen_base, "flags": {}, "usage": "", "summary": "Frond base template + index page + pages/ folder"}, + "page": {"handler": _gen_page, "flags": {}, "usage": "", "summary": "Page that extends base.twig (auto-creates pages/ + base)"}, + "crud": {"handler": _gen_crud, "flags": {"fields": True, "table-name": True, "public": False, "no-templates": False, "no-migration": False, "skip-model": False}, "usage": ' [--fields "..."] [--public] [--no-templates] [--skip-model]', "summary": "Model + migration + AutoCrud-backed admin page (to_crud) + editable crud/ templates + gate test"}, + "migration": {"handler": _gen_migration, "flags": {"fields": True}, "usage": "", "summary": "Timestamped migration file (UP/DOWN)"}, + "middleware": {"handler": _gen_middleware, "flags": {}, "usage": "", "summary": "Middleware with before/after hooks"}, + "test": {"handler": _gen_test, "flags": {"model": True, "public": False}, "usage": " [--model Name]", "summary": "pytest test file"}, + "form": {"handler": _gen_form, "flags": {"fields": True, "table-name": True}, "usage": ' [--fields "..."]', "summary": "Form template with inputs matching model fields"}, + "view": {"handler": _gen_view, "flags": {"fields": True, "table-name": True}, "usage": ' [--fields "..."]', "summary": "List + detail view templates"}, + "auth": {"handler": _gen_auth, "flags": {"fields": True, "table-name": True, "no-migration": False}, "usage": "", "summary": "Login/register routes + User model + templates"}, + "service": {"handler": _gen_service, "flags": {"every": True, "cron": True}, "usage": ' [--every 5m | --cron "..."]', "summary": "Scheduled ServiceRunner task (src/services/)"}, + "queue": {"handler": _gen_queue, "flags": {}, "usage": "", "summary": "Producer + consumer worker (src/services/)"}, + "validator": {"handler": _gen_validator, "flags": {}, "usage": "", "summary": "Request-body Validator (src/validators/)"}, + "seeder": {"handler": _gen_seeder, "flags": {"table-name": True}, "usage": "", "summary": "FakeData + seed_orm seeder (src/seeds/)"}, + "websocket": {"handler": _gen_websocket, "flags": {}, "usage": "", "summary": "@websocket handler (src/routes/)"}, + "listener": {"handler": _gen_listener, "flags": {}, "usage": "", "summary": "@on(event) listener (src/listeners/)"}, } COMMANDS = { - "init": {"handler": _init, "usage": "[dir]", "args": ["dir?"], "summary": "Scaffold a new project"}, - "serve": {"handler": _serve, "usage": "[--port P] [--no-browser] [--no-reload]", "summary": "Start dev server (default: 0.0.0.0:7146)"}, - "start": {"handler": _serve, "summary": "Alias for serve"}, + "init": {"handler": _init, "flags": {"ai": False, "production": False}, "usage": "[dir]", "args": ["dir?"], "summary": "Scaffold a new project"}, + "serve": {"handler": _serve, "flags": {"port": True, "host": True, "no-browser": False, "no-reload": False, "no-kill": False, "production": False, "managed": False}, "usage": "[--port P] [--no-browser] [--no-reload]", "summary": "Start dev server (default: 0.0.0.0:7146)"}, + "start": {"handler": _serve, "flags": {"port": True, "host": True, "no-browser": False, "no-reload": False, "no-kill": False, "production": False, "managed": False}, "summary": "Alias for serve"}, "migrate": {"handler": _migrate, "summary": "Run pending database migrations"}, - "migrate:create": {"handler": _migrate_create, "usage": "", "args": ["description"], "summary": "Create a new migration file"}, + "migrate:create": {"handler": _migrate_create, "flags": {"fields": True, "table-name": True, "json": False, "dry-run": False}, "usage": "", "args": ["description"], "summary": "Create a new migration file"}, "migrate:rollback": {"handler": _migrate_rollback, "summary": "Rollback last migration batch"}, "migrate:status": {"handler": _migrate_status, "summary": "Show migration status"}, "env-migrate": {"handler": _env_migrate, "usage": "[path]", "args": ["path?"], "summary": "Rewrite .env to TINA4_-prefixed names (v3.12 migration)"}, "seed": {"handler": _seed, "summary": "Run database seeders"}, "routes": {"handler": _routes, "summary": "List all registered routes"}, - "test": {"handler": _test, "summary": "Run test suite"}, + "test": {"handler": _test, "flags": None, "summary": "Run test suite"}, "queue": {"handler": _queue, "usage": " [topic]", "subcommands": list(_QUEUE_SUBCOMMANDS), "summary": "Run queue workers and manage jobs"}, - "build": {"handler": _build, "usage": "[--tag NAME] [--file PATH]", "summary": "Build the deployable Docker image"}, - "lint": {"handler": _lint, "usage": "[--fix] [--no-install]", "summary": "Lint src/ + app.py (installs ruff dev-only on demand; zero-dep syntax baseline with --no-install)"}, - "ai": {"handler": _ai, "usage": "[--all]", "summary": "Install AI coding assistant context"}, + "build": {"handler": _build, "flags": {"tag": True, "file": True}, "usage": "[--tag NAME] [--file PATH]", "summary": "Build the deployable Docker image"}, + "lint": {"handler": _lint, "flags": {"fix": False, "no-install": False}, "usage": "[--fix] [--no-install]", "summary": "Lint src/ + app.py (installs ruff dev-only on demand; zero-dep syntax baseline with --no-install)"}, + "ai": {"handler": _ai, "flags": {"all": False}, "usage": "[--all]", "summary": "Install AI coding assistant context"}, "generate": {"handler": _generate, "usage": " [options]", "subcommands": list(GENERATORS), "summary": "Generate scaffolding (see Generators below)"}, "console": {"handler": _console, "summary": "Start interactive REPL with framework loaded"}, - "commands": {"handler": _commands, "usage": "[--json]", "summary": "List available commands (add --json for machine form)"}, - "help": {"handler": _help, "summary": "Show this help"}, + "commands": {"handler": _commands, "flags": {"json": False}, "usage": "[--json]", "summary": "List available commands (add --json for machine form)"}, + "help": {"handler": _help, "flags": None, "summary": "Show this help"}, } # Commands the `tina4` client OWNS and this CLI reaches by delegation — see the From cbdd681c80ee3a5ba8cdb9e2a8974186dcc8a624 Mon Sep 17 00:00:00 2001 From: Andre van Zuydam Date: Sat, 17 Oct 2026 04:37:53 +0200 Subject: [PATCH 2/2] refactor(cli): split option checking to keep the metrics gate green The regression gate flagged _check_options at cyclomatic complexity 13. Same behaviour and messages, in three small functions: _check_options walks the arguments, _option_problem judges one, _unknown_option_message builds the did-you-mean text. `tina4 metrics --path tina4_python --fail-on-regression` passes; tests/test_cli_help_flags.py and test_cli_generate*: 185 passed. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Andre van Zuydam --- tina4_python/cli/__init__.py | 45 +++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/tina4_python/cli/__init__.py b/tina4_python/cli/__init__.py index f63fd48f..0cd5c638 100644 --- a/tina4_python/cli/__init__.py +++ b/tina4_python/cli/__init__.py @@ -517,24 +517,37 @@ def _check_options(command: str, args: list) -> None: accepted = _accepted_options(command, args) if accepted is None: return - label = command if command not in ("generate", "queue") else f"{command} {args[0].lower()}" + label = f"{command} {args[0].lower()}" if command in ("generate", "queue") else command i = 0 while i < len(args): - arg = args[i] - if arg.startswith("-") and arg != "-": - name, has_value, _ = arg.lstrip("-").partition("=") - if not arg.startswith("--") or name not in accepted: - print(f'Unknown option {arg.partition("=")[0]} for "{label}".', end="") - close = difflib.get_close_matches(name, list(accepted), n=1, cutoff=0.6) - print(f" Did you mean --{close[0]}?" if close else "") - print(f'Run "tina4python {label} --help" for its options.') - sys.exit(1) - if accepted[name] and not has_value: - if i + 1 >= len(args) or args[i + 1].startswith("-"): - print(f'Option --{name} for "{label}" needs a value: --{name} ') - sys.exit(1) - i += 1 - i += 1 + problem, consumed = _option_problem(args, i, accepted, label) + if problem: + print(problem) + sys.exit(1) + i += 1 + consumed + + +def _option_problem(args: list, i: int, accepted: dict, label: str) -> tuple[str | None, int]: + """What is wrong with args[i] (None if nothing), and how many following + arguments it consumes as its value.""" + arg = args[i] + if not arg.startswith("-") or arg == "-": + return None, 0 + name, has_value, _ = arg.lstrip("-").partition("=") + if not arg.startswith("--") or name not in accepted: + return _unknown_option_message(arg, name, accepted, label), 0 + if not accepted[name] or has_value: + return None, 0 + if i + 1 >= len(args) or args[i + 1].startswith("-"): + return f'Option --{name} for "{label}" needs a value: --{name} ', 0 + return None, 1 + + +def _unknown_option_message(arg: str, name: str, accepted: dict, label: str) -> str: + close = difflib.get_close_matches(name, list(accepted), n=1, cutoff=0.6) + hint = f" Did you mean --{close[0]}?" if close else "" + return (f'Unknown option {arg.partition("=")[0]} for "{label}".{hint}\n' + f'Run "tina4python {label} --help" for its options.') def _reject_flag_like_name(name: str, what: str) -> None: