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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Name> --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
Expand Down
90 changes: 90 additions & 0 deletions plan/cli-help-and-options.md
Original file line number Diff line number Diff line change
@@ -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 <value>`, 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
210 changes: 210 additions & 0 deletions tests/test_cli_help_flags.py
Original file line number Diff line number Diff line change
@@ -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 <command>" 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 <command>" 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 <Name> --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"
Loading
Loading