fix: REPL help and choice-option crashes under Click-vendoring Typer (>=0.25)#443
Conversation
uv lock --upgrade -> Typer 0.26.7, Click 8.4.1, etc. fastapi is held at <0.137 in the [server] extra: with 0.137, serve --ui stops requiring a token on protected endpoints (/doctor, /version, /changelog, /agents reachable unauthenticated), reopening GHSA-ffpq-prmh-3gx2.
typer.main.get_command returns a TyperGroup; Typer >=0.25 vendors its own Click, so it is not a standalone click.Group subclass and isinstance(_, click.Group) collapsed the REPL command tree to empty (help and tab-completion showed only help/exit). Replace with a structural _is_group() TypeGuard at the three vendored-Click isinstance sites (repl, check_command_sync, test_permissions); stop silently swallowing the tree-build error.
The --mode/--poll-strategy (job run), --role/--default-role (project invite), --role (member-set-role) and --role-hint (dev-portal identity) options passed a standalone click.Choice into Typer. Under a Click-vendoring Typer (>=0.25) the BadParameter it raises is a different class than the one Typer's parser catches, so an invalid value escaped as an uncaught traceback instead of a clean exit-2 usage error. Replace the click.Choice options with StrEnum types so Typer validates with its own Click. Valid values and --help are unchanged.
ddd4632 to
328ae14
Compare
Two gaps the fixes left untested: - REPL `help` command output (not just the underlying tree): assert it lists command groups, guarding the empty-list regression at the command level. - invalid choice value -> clean exit 2 (no uncaught traceback) across all seven choice options (job run --mode/--poll-strategy, project invite --role/--default-role, member-set-role --role, dev-portal identity add/edit --role-hint).
padak
left a comment
There was a problem hiding this comment.
Review summary
Clean, well-tested, root-cause fix (not a workaround). All three changes trace to a single cause — Typer >=0.25 vendors its own Click (typer._click), so isinstance(x, click.Group) / standalone click.Choice objects misbehave against the vendored copy. Bundling them is the right call. LGTM after the uv.lock rebase below. (Comment only — not approving/requesting changes.)
What I verified
| Area | Result |
|---|---|
Dropping import click from job.py / project.py / dev_portal.py |
✅ Safe — the only click references in those files were the click.Choice calls this PR removes. |
StrEnum vs. the service/client validation (mode not in VALID_JOB_MODES, poll_strategy not in VALID_POLL_STRATEGIES, client.py:2622) |
✅ No regression — a StrEnum member behaves identically to a plain str under in frozenset[str], ==, json.dumps, and f-strings. The defense-in-depth checks in job_service.py / client.py keep working unchanged. |
_is_group() TypeGuard (duck-typing via list_commands) |
✅ Correct fix for the vendoring; TypeGuard gives ty proper narrowing. |
Drift guards in test_choice_enums.py |
✅ ProjectRole is correctly order-sensitive (the --role help text renders `" |
Bare except: pass → stderr log in _build_command_tree |
✅ Removes the silent-degrade-to-empty-tree path that hid the original bug. |
fastapi<0.137 cap |
✅ Legitimate — 0.137 reopens GHSA-ffpq-prmh-3gx2 (unauthenticated /doctor, /version, /changelog, /agents under serve --ui). |
| Version sync (marketplace.json + plugin.json + pyproject.toml) + changelog | ✅ All on 0.63.4; all three fixes documented. |
Nice touch keeping the default as typer.Option(JobMode(DEFAULT_JOB_MODE), ...) rather than JobMode.run — the default stays bound to the constant (single source of truth), and a drift between the constant and the enum would fail fast at import time, on top of the drift test.
Findings (all non-blocking)
🟡 Rebase needed — uv.lock conflict. PR is currently CONFLICTING. The only real conflict is uv.lock (main bumped cryptography in 0f9d184); pyproject.toml merges cleanly. A rebase + uv lock regen should clear it.
🟡 fastapi<0.137 cap has no tracking issue. The cap blocks future security updates to fastapi. The PR notes the follow-up ("serve --ui route-aware auth check needs updating"), but without an issue this risks becoming a silent long-term pin. Suggest filing one so the cap gets lifted deliberately rather than forgotten.
🟢 REPL direction (out of scope for this PR). Roughly half of this change invests in the REPL (help/tab-completion fix + two new REPL tests). There was an internal question on whether the REPL stays long-term. Flagging only so the decision is made deliberately — the choice-option fix and the fastapi cap are independently valuable and share the same root cause regardless of what happens to the REPL, so this does not affect mergeability here.
Verdict
Real user-facing bug (anyone installing via uv tool install / the curl script gets Typer 0.26.7 and hits both crashes; CI never saw it because the lockfile pinned 0.24.1). Mergeable after the uv.lock rebase.
|
The |
Resolve the uv.lock conflict (keep cryptography 49.0.0, already ahead of main's 48.0.1). Also fix typer 0.26.7 fallout that only the whole-tree `make check` catches (per-file checks missed it): - test helpers annotate typer.testing.Result (0.26 stopped reusing click.testing.Result) -> fixes `ty check`. - isinstance(_, click.Group) is False under vendored Click, so scripts/generate_skill.py uses the structural _is_group, and reads param_type_name instead of isinstance(click.Argument/Option) so the SKILL.md table keeps its arg/option hints. - scripts/benchmark.py casts stdio_total to float for the subtraction.
4eb4273 to
4ff12cc
Compare
padak
left a comment
There was a problem hiding this comment.
Review of #443 — fix: REPL help and choice-option crashes under Click-vendoring Typer (>=0.25)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
The PR fixes two independent crash paths introduced when Typer >=0.25 began vendoring its own
copy of Click (typer._click): (1) the REPL command tree collapsed to empty because
_build_command_tree used isinstance(x, click.Group) against a TyperGroup that is not a
subclass of standalone click.Group; (2) seven click.Choice-backed CLI options raised an
uncaught traceback on invalid input because click.BadParameter from the vendored namespace
is not caught by Typer's error handler. Both are fixed correctly. Additionally, the PR caps
fastapi<0.137 (security: reopening of GHSA-ffpq-prmh-3gx2) and bumps the dependency lock
to ship Typer 0.26.7 / Click 8.4.1.
make check passes (4137 tests). No new CLI commands are added, so no plugin synchronization
map entries are required. No layer violations, no token discipline issues, no security concerns
in the new code.
Verdict: APPROVE. Zero blocking findings. Two non-blocking observations and one NIT noted below.
Verdict
- Verdict: APPROVE
- Blocking findings: 0
- Non-blocking findings: 2
- Nits: 1
Blocking findings
(none)
Non-blocking findings
[NB-1] pyproject.toml:12 — typer[all] extra no longer exists in Typer 0.26+
The dependency spec "typer[all]>=0.12" references an extra that Typer removed in 0.25+:
rich and shellingham are now unconditional dependencies, not an optional group. The PR
description acknowledges this produces a harmless uv warning but defers the fix. A dangling
extra spec silently widens the "known good" envelope: if a future Typer release reintroduces
[all] with different semantics, the spec would silently opt in. Suggested fix (can be a
follow-up): change "typer[all]>=0.12" to "typer>=0.12" and verify make check still
passes (it will, since rich + shellingham are now unconditional).
[NB-2] src/keboola_agent_cli/commands/repl.py:27,36 — _is_group duck-types around vendored Click, but click.Context (standalone) is still used to walk the tree
_is_group correctly avoids isinstance(_, click.Group) by checking for the list_commands
callable. However, _build_command_tree still passes the TyperGroup into a standalone
click.Context(click_group) (line 36). This works in practice today because Typer's
vendored Context and standalone Click's Context share the same protocol; both accept a
command-like object with a list_commands method. If a future Typer version's vendored Click
diverges further (e.g., modifies the Context.__init__ signature), this call site would be
the next breakage point. The correct long-term fix is typer.Context(click_group) (use
Typer's own context builder) — but this is lower priority than the shipped fix, since the
current behavior is verified working on 0.26.7. Documenting this latent coupling with a
one-line comment would suffice if a full refactor is deferred.
Nits
[NIT-1]src/keboola_agent_cli/scripts/benchmark.py:372— thefloat()cast on
stdio_results[i]["total"]is a correcttytype-narrowing fix but is unrelated to the
Typer vendoring issue. It is a harmless inclusion; consider a separate commit note next time
so reviewers can distinguish incidental churn from the structural fix.
Verification log
gh pr view 443 --json title,body,files→ 21 files, +744/-508, no new CLI commands,fix:prefix. PR description matches diff scope. ✓git rev-parse --abbrev-ref HEAD→fix/typer-vendored-click(worktreenifty-ishizaka-2f768b) ✓make check→ 4137 passed, 8 skipped, 125 deselected;ruff,ty,skill-check,command-sync-checkall clean ✓- Layer violation grep (typer/click in services, httpx in commands, formatter in clients) → empty ✓
isinstance(g, click.Group)onTyperGroup(vendored) →False;_is_group(g)→True— root bug confirmed and fixed ✓_build_command_tree(click_app)with Typer 0.26.7 → tree size 304 (project listandjob runpresent); was previously empty due to the bug ✓isinstance(g, click.Group)check removed fromscripts/check_command_sync.py,scripts/generate_skill.py,tests/test_permissions.py— all now import_is_groupfromrepl.py✓param.param_type_nameattribute verified present on bothclick.Argument("argument") andclick.Option("option") in Click 8.4.1 ✓ProjectRolemember order (admin, guest, readOnly, share) matchesPROJECT_ROLEStuple inconstants.py; drift guardtest_project_role_enum_matches_constantasserts order ✓StrEnumis a subclass ofstr, soJobMode.runpassesmode: strat the service layer without a change tojob_service.py::run_job✓typer[all]extra: Typer 0.26.7 dist-info has noProvides-Extraentries — the extra is gone ✓make skill-gen→ no diff inplugins/kbagent/skills/kbagent/SKILL.md(no new commands to register) ✓- No new commands →
OPERATION_REGISTRY,AGENT_CONTEXT,commands-reference.md,keboola-expert.md,gotchas.mddo not require updates ✓ fastapi<0.137cap inpyproject.tomlwith inline comment referencing GHSA-ffpq-prmh-3gx2;test_serve_ui.pyalready guards the auth requirement on protected endpoints ✓- No bare
except:, noprint()insrc/, no rawerror_code="..."strings, no magic number timeouts in new code ✓
Open questions for the author
(none)
padak
left a comment
There was a problem hiding this comment.
Approved. The fix correctly addresses both crash paths under Typer >=0.25 (Click-vendoring): the empty REPL command tree and the uncaught BadParameter traceback on choice-options. make check is green (4137 tests), no 3-layer architecture violations, and no plugin/agent/docs sync drift (the PR adds no new commands).
See the detailed review comment for two non-blocking follow-ups: cleaning up the typer[all] extra in pyproject.toml (NB-1) and the latent click.Context coupling in repl.py (NB-2). Neither blocks merge.
Why
Typer >=0.25 vendors its own copy of Click (
typer._click), which breaks code thathands standalone-
clickobjects to Typer or checks against standaloneclicktypes.Users installing via
uv tool install/ the curl script get the latest Typer (0.26.7);the lockfile pinned 0.24.1, so
uv sync/ CI never hit it.helpand tab-completion showed onlyhelp/exit:_build_command_treegated its walk with
isinstance(x, click.Group), False for a vendoredTyperGroup,so the tree built empty (hidden by a bare
except). Same bug also inscripts/check_command_sync.pyandtests/test_permissions.py.click.Choicevalues (job run --mode/--poll-strategy,project invite --role/--default-role,project member-set-role --role,dev-portal identity --role-hint) raised an uncaught traceback instead of exit 2 — standaloneclick.BadParameterisn't caught by Typer's vendored-Click handler.What changed
repl.py(+check_command_sync.py,test_permissions.py): structural_is_group()TypeGuardreplacesisinstance(_, click.Group); swallowed tree-build error now logged.job.py/project.py/dev_portal.py: the sevenclick.Choiceoptions becomeStrEnumtypes; drift-guarded against the constants by
tests/test_choice_enums.py.<0.137: with 0.137,serve --uistops requiring a token on protected endpoints(
/doctor,/version,/changelog,/agentsreachable unauthenticated), reopeningGHSA-ffpq-prmh-3gx2.
Verification
Full suite (minus live-cred e2e) green on Typer 0.24.1 and 0.26.7;
ruff+tyclean.Adds a REPL
help-output test and a parametrized invalid-choice -> exit-2 test acrossall seven choice options.
Follow-ups (not in this PR)
typer[all]extra no longer exists in Typer 0.26 (harmlessuvwarning).serve --uiroute-aware auth check updated, then thecap can lift.