tan-cli 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/cli.py
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The `tan` command-line surface: the Typer app, the root callback, and the
|
|
3
|
+
`--format json` error path that wraps Click's own dispatch.
|
|
4
|
+
|
|
5
|
+
Defines ``main``, but no longer owns the PROCESS boundary: ``pyproject.toml``'s
|
|
6
|
+
``[project.scripts]`` names ``tan.__main__:main``, and ``__main__.py`` wraps
|
|
7
|
+
this ``main`` to swallow a closed stdout (``tan generate --help | head``) as a
|
|
8
|
+
quiet success instead of a traceback. Anything that must happen for EVERY
|
|
9
|
+
invocation regardless of subcommand belongs there, not here.
|
|
10
|
+
|
|
11
|
+
Commands register in ``tan.command_table.COMMAND_TABLE`` with a STATIC,
|
|
12
|
+
function-local import -- see ``tan.commands.__init__`` for why an
|
|
13
|
+
importlib/pkgutil registry is a trap: it works from source and fails inside a
|
|
14
|
+
PyInstaller ``--onefile`` binary, which is how tan actually ships. ``app`` is a
|
|
15
|
+
``LazyTyper`` that refuses ``app.command(...)``: ``COMMAND_TABLE`` is the one
|
|
16
|
+
place to add a command, and the single table everything else here derives from
|
|
17
|
+
(``_SUBCOMMAND_NAMES``, and the pre-/post-subcommand ``--format`` cases in
|
|
18
|
+
``tests/commands/test_cli_global_flags.py``).
|
|
19
|
+
"""
|
|
20
|
+
import io
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from typer.core import TyperCommand
|
|
25
|
+
|
|
26
|
+
from tan.core.global_flags import RELOCATABLE_FLAG_ARITY
|
|
27
|
+
from tan.envelope import (
|
|
28
|
+
Envelope,
|
|
29
|
+
Issue,
|
|
30
|
+
Project,
|
|
31
|
+
emit,
|
|
32
|
+
envelope_emitted,
|
|
33
|
+
envelope_emitted_exit_code,
|
|
34
|
+
)
|
|
35
|
+
from tan.exit_codes import ExitCode
|
|
36
|
+
from tan.lazy_typer import COMMAND_NAMES, LazyTyper
|
|
37
|
+
from tan.output_format import declared_formats, invalid_format_message
|
|
38
|
+
from tan.version import TAN_VERSION
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#: The `--format` a DISPATCHED subcommand actually resolved to, as a bool, or
|
|
42
|
+
#: `None` when NO command body ran this process (`main()` resets it per run).
|
|
43
|
+
#:
|
|
44
|
+
#: This is the missing half of Rust's own structure (tan-cli#546/#491 defect 3).
|
|
45
|
+
#: `crates/tan-cli/src/main.rs` decides the channel like this:
|
|
46
|
+
#:
|
|
47
|
+
#: match Cli::try_parse() {
|
|
48
|
+
#: Ok(cli) => run(cli), // channel = cli.format
|
|
49
|
+
#: Err(e) => if wants_json(argv) { ... } // channel = the textual scan
|
|
50
|
+
#: }
|
|
51
|
+
#:
|
|
52
|
+
#: -- so the naive `wants_json` scan is consulted ONLY once clap has already
|
|
53
|
+
#: FAILED, and can never affect a run that parsed. The port promoted the same
|
|
54
|
+
#: scan to a process-wide switch, which is what let a `--format json` that is
|
|
55
|
+
#: not tan's own flag (forwarded past `--` to `west alp-quality`, or sitting in
|
|
56
|
+
#: another option's value slot) replace a text-mode command's real, coded answer
|
|
57
|
+
#: with an unrequested `command:"cli"` / `cli.parse-error` document on stdout.
|
|
58
|
+
#:
|
|
59
|
+
#: Two earlier attempts tried to answer the same question by making the argv
|
|
60
|
+
#: SCAN smarter (an arity walk, then a clap-style hyphen guard). Both failed,
|
|
61
|
+
#: and a third textual shape cannot work either -- MEASURED against the oracle,
|
|
62
|
+
#: which answers the two structurally identical argvs differently:
|
|
63
|
+
#:
|
|
64
|
+
#: tan quality -- --format json oracle: TEXT (0 bytes on stdout)
|
|
65
|
+
#: tan build -- --format json oracle: JSON (`cli.parse-error`)
|
|
66
|
+
#:
|
|
67
|
+
#: The only thing separating them is whether the parser ACCEPTED the argv
|
|
68
|
+
#: (`quality` forwards trailing args, `build` refuses them), which no scan of
|
|
69
|
+
#: argv alone can know. So the scan stays exactly as it is -- a faithful port of
|
|
70
|
+
#: Rust's `wants_json`, used only where Rust uses it -- and the parse OUTCOME is
|
|
71
|
+
#: recorded here instead.
|
|
72
|
+
_dispatched_json_mode: bool | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _DispatchedCommand(TyperCommand):
|
|
76
|
+
"""A `TyperCommand` that records the `--format` its own parse resolved.
|
|
77
|
+
|
|
78
|
+
`Command.invoke` is called by Click only after `cmd.make_context(...)` has
|
|
79
|
+
already succeeded, so reaching it means the FULL parse (root's flags, the
|
|
80
|
+
subcommand name, and the subcommand's own options) was accepted and this
|
|
81
|
+
command's body is about to run -- exactly Rust's `Ok(cli)` arm, and exactly
|
|
82
|
+
the boundary `main()` cannot otherwise see from the outside.
|
|
83
|
+
|
|
84
|
+
The `--format` parameter is found by its OPTION STRING rather than by the
|
|
85
|
+
`output_format` parameter name every command happens to use today: the name
|
|
86
|
+
is a convention 32 modules share, the option string is the contract.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def invoke(self, ctx):
|
|
90
|
+
global _dispatched_json_mode
|
|
91
|
+
param = next((p for p in ctx.command.params if "--format" in p.opts), None)
|
|
92
|
+
value = ctx.params.get(param.name) if param is not None else None
|
|
93
|
+
# `OutputFormat.JSON` (an enum), the plain string a `ValidateOutputFormat`
|
|
94
|
+
# or a future non-enum choice would give, or `None` for the commands
|
|
95
|
+
# whose `--format` defaults to `None` rather than `TEXT` -- all three
|
|
96
|
+
# collapse to "is this the envelope channel".
|
|
97
|
+
_dispatched_json_mode = getattr(value, "value", value) == "json"
|
|
98
|
+
return super().invoke(ctx)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
app = LazyTyper(add_completion=False, command_cls=_DispatchedCommand)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Registered through `tan.command_table.COMMAND_TABLE`, whose loaders are plain
|
|
105
|
+
# STATIC `from tan.commands.x_cmd import x` statements inside functions --
|
|
106
|
+
# PyInstaller follows the static import graph only, so a pkgutil/importlib
|
|
107
|
+
# auto-registry works from source and produces a frozen `tan` that cannot find
|
|
108
|
+
# its own commands (see `tan.commands.__init__`), while a function-local import
|
|
109
|
+
# is still seen. Nothing here imports a command module: `LazyTyper` runs a
|
|
110
|
+
# loader only when Click looks that command up, so `tan --version` and
|
|
111
|
+
# `tan <cmd> --help` skip the other ~30 modules (tan-cli#1505). Registering in
|
|
112
|
+
# the table rather than with a decorator in the command module keeps
|
|
113
|
+
# `tan.commands.*` free of any `tan.cli` import, which would be a cycle. Each
|
|
114
|
+
# row carries a `rich_help_panel` that groups commands into six titled panels
|
|
115
|
+
# on `--help`; a new command gains grouping with the same row that registers it.
|
|
116
|
+
# Every command dispatches through `_DispatchedCommand` (set as the Typer's
|
|
117
|
+
# `command_cls`) rather than threading `cls=` through each row.
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: Every registered subcommand name, DERIVED from `tan.command_table.COMMAND_TABLE`
|
|
121
|
+
#: rather than retyped beside it (tan-cli#378). Used only to find the argv
|
|
122
|
+
#: BOUNDARY `_reorder_global_flags` moves a leading global flag across; it is
|
|
123
|
+
#: never itself treated as a flag. Hand-kept, this was a second place to forget
|
|
124
|
+
#: a command -- and forgetting one is silent: a name missing from the set is not
|
|
125
|
+
#: recognised as the boundary, so the whole rewrite aborts and every leading
|
|
126
|
+
#: global flag on that command goes back to being a Click "No such option".
|
|
127
|
+
#: Reading names off the table costs no command import (tan-cli#1505).
|
|
128
|
+
_SUBCOMMAND_NAMES = frozenset(COMMAND_NAMES)
|
|
129
|
+
|
|
130
|
+
#: clap's `GlobalArgs` (`crates/tan-cli/src/cli.rs` lines 24-73) PLUS
|
|
131
|
+
#: `--format`: every field there is `#[arg(long, global = true, ...)]`, so clap
|
|
132
|
+
#: accepts each on EITHER side of the subcommand name, and `--format` is one of
|
|
133
|
+
#: them. `--version` is not -- it lives on `Cli` directly in clap, not
|
|
134
|
+
#: `GlobalArgs`, and is root-only on both sides already.
|
|
135
|
+
#: Value: the flag's arity (1 = takes a value, 0 = boolean).
|
|
136
|
+
#:
|
|
137
|
+
#: Imported from `tan.core.global_flags` rather than hand-copied a second
|
|
138
|
+
#: time (tan-cli#261): that module is also what
|
|
139
|
+
#: `tan.core.global_flags.accept_global_flags` reads to decide which flags a
|
|
140
|
+
#: command is missing, so this reorder table and the per-command injection
|
|
141
|
+
#: list cannot drift apart the way two independent hand-written copies of
|
|
142
|
+
#: clap's `GlobalArgs` field list eventually would. `--format` is in the
|
|
143
|
+
#: relocation table but NOT the injection list -- see `RELOCATABLE_FLAG_ARITY`
|
|
144
|
+
#: there for why the two must stay separate.
|
|
145
|
+
_RELOCATABLE_FLAG_ARITY: dict[str, int] = RELOCATABLE_FLAG_ARITY
|
|
146
|
+
|
|
147
|
+
#: The oracle's global `--format` only ever had two values (measured:
|
|
148
|
+
#: `target/debug/tan.exe --format sarif validate` and `... --format bogus
|
|
149
|
+
#: new-som --sku FOO` both answer `error: invalid value '<x>' for '--format
|
|
150
|
+
#: <FORMAT>' [possible values: text, json]` at exit 2, in BOTH argv
|
|
151
|
+
#: positions) -- but this port's global `--format` is wider than that
|
|
152
|
+
#: (tan-cli#403): `_format_callback` accepts `_every_declared_format()`, the
|
|
153
|
+
#: union over every registered command's own `--format`, not a hard-coded
|
|
154
|
+
#: pair. `_reorder_global_flags` has to relocate that SAME domain, via the
|
|
155
|
+
#: SAME function, or a value outside the oracle's two but inside a real
|
|
156
|
+
#: command's own domain (`diagnostic-v1`/`sarif`, `validate`'s) collapses the
|
|
157
|
+
#: rewrite and drops the subcommand entirely instead of relocating past it
|
|
158
|
+
#: (tan-cli#433) -- a second, hand-kept copy of this set is exactly the drift
|
|
159
|
+
#: that produced #433 in the first place.
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _reorder_global_flags(argv: list[str]) -> list[str]:
|
|
163
|
+
"""Move a leading GLOBAL flag (`_RELOCATABLE_FLAG_ARITY`) from before the
|
|
164
|
+
subcommand name to immediately after it, where a command that implements
|
|
165
|
+
it already has its own local option declared (see e.g. `clean_cmd.clean`'s
|
|
166
|
+
trailing `--quiet`/`--ci`/`--target`/... parameters) -- Click only reads
|
|
167
|
+
options declared on the GROUP callback (`root`, below) for anything
|
|
168
|
+
appearing before the subcommand name, and `root` does not declare these,
|
|
169
|
+
so today they are a hard parse error there.
|
|
170
|
+
Concretely: `alp-sdk-vscode/src/west.ts`'s `alpBuild` invokes `tan
|
|
171
|
+
--project <app> build`, and `alpCli/vscodeAdapter.ts`'s `withSdkRoot`
|
|
172
|
+
prepends `--sdk-root <path>` ahead of the subcommand for nearly every
|
|
173
|
+
command the extension runs (`runAlpCommand`/`runAlpInTerminal`).
|
|
174
|
+
|
|
175
|
+
A pure `list[str] -> list[str]` argv rewrite, run before Typer/Click ever
|
|
176
|
+
sees it, so no per-command code has to change to gain the pre-subcommand
|
|
177
|
+
position -- only the POSITION moves. A command that does not implement a
|
|
178
|
+
given flag at all keeps failing exactly as it does in the (already
|
|
179
|
+
correct, already tested) post-subcommand position -- e.g. `tan build
|
|
180
|
+
--sdk-root x --bogus` and `tan --sdk-root x build --bogus` both still
|
|
181
|
+
fail on `--bogus`; this never invents support a command never had, and
|
|
182
|
+
never swallows an unrecognised flag silently.
|
|
183
|
+
|
|
184
|
+
`--format` is relocated by exactly this mechanism too (tan-cli#378), and
|
|
185
|
+
that is the WHOLE of how it is global now. It used to be excepted here --
|
|
186
|
+
left sitting ahead of the subcommand for `root`'s own `--format` to catch
|
|
187
|
+
and stash on `ctx.obj`, with a hand-written set of commands allowed to
|
|
188
|
+
read it back. That set reached 12 of the 32 registered commands and
|
|
189
|
+
stopped; the other 20 (`tan --format json doctor`, `... model list`,
|
|
190
|
+
`... sdk current`, `... validate`, ...) were refused at rc=2 with a
|
|
191
|
+
`cli.parse-error` envelope naming `command: "cli"` -- the wrong command
|
|
192
|
+
for a machine caller to key off, on argv the oracle accepts. Relocation
|
|
193
|
+
needs no such set because it does not depend on a command opting in:
|
|
194
|
+
every registered command declares AND reads its own `--format` (the
|
|
195
|
+
position every golden already uses, and where each command's own
|
|
196
|
+
"'x' (choose from 'text', 'json')" validation lives), so moving the token
|
|
197
|
+
there hands it to a parameter that acts on it. A command added later is
|
|
198
|
+
covered by the `COMMAND_TABLE` row that registers it, with nothing
|
|
199
|
+
else to remember -- pinned by
|
|
200
|
+
`tests/commands/test_cli_global_flags.py`, which derives its cases from
|
|
201
|
+
that same registration table.
|
|
202
|
+
|
|
203
|
+
`--format`'s relocatable domain is `_every_declared_format()` -- the union
|
|
204
|
+
over every registered command's own `--format`, not just the oracle's
|
|
205
|
+
`text`/`json` pair (tan-cli#403) -- because `root`'s own eager
|
|
206
|
+
`_format_callback` accepts that identical union; the two have to read the
|
|
207
|
+
SAME set or a value `_format_callback` would happily accept gets treated
|
|
208
|
+
here as not-the-flag-at-all and collapses the rewrite instead of
|
|
209
|
+
relocating past the subcommand name (tan-cli#433). A value outside even
|
|
210
|
+
that union -- `bogus` -- still is not this flag, and the rewrite still
|
|
211
|
+
collapses to just that `--format <value>` pair so `root` refuses it at
|
|
212
|
+
parse time, before any command resolves -- see the `--format` branch below
|
|
213
|
+
for why leaving the rest of argv in place does not reach the refusal.
|
|
214
|
+
|
|
215
|
+
Deliberately conservative otherwise: any OTHER token before the first
|
|
216
|
+
subcommand name that is not a recognised global flag (or that flag's
|
|
217
|
+
value) — `--help`, `--version`, or a bare positional — aborts the rewrite
|
|
218
|
+
and returns `argv` untouched, so every existing argv shape (a normal `tan
|
|
219
|
+
build ...` with zero leading tokens is a no-op by construction; `--version`,
|
|
220
|
+
a bad command, a bare invocation, all of which have no subcommand token to
|
|
221
|
+
move anything after) sees the exact argv it always has.
|
|
222
|
+
"""
|
|
223
|
+
moved: list[str] = []
|
|
224
|
+
i = 0
|
|
225
|
+
n = len(argv)
|
|
226
|
+
while i < n:
|
|
227
|
+
token = argv[i]
|
|
228
|
+
if token in _SUBCOMMAND_NAMES:
|
|
229
|
+
return [token, *moved, *argv[i + 1 :]]
|
|
230
|
+
name = token.split("=", 1)[0]
|
|
231
|
+
arity = _RELOCATABLE_FLAG_ARITY.get(name)
|
|
232
|
+
if arity is None:
|
|
233
|
+
return argv # not a recognised global flag and not the subcommand
|
|
234
|
+
if name == "--format":
|
|
235
|
+
# The relocatable domain is `_every_declared_format()`, not a
|
|
236
|
+
# hard-coded `text`/`json` pair (tan-cli#433, a regression of
|
|
237
|
+
# tan-cli#403): `_format_callback` already accepts the union over
|
|
238
|
+
# every registered command's `--format`, including `validate`'s
|
|
239
|
+
# `diagnostic-v1`/`sarif`, so this rewrite has to relocate that
|
|
240
|
+
# SAME set or it refuses a value `root` itself would have
|
|
241
|
+
# accepted, collapsing the argv and dropping the subcommand along
|
|
242
|
+
# with it -- exactly #433's `tan --format diagnostic-v1 validate
|
|
243
|
+
# --offline`, which lost `validate` entirely and answered "a
|
|
244
|
+
# command is required" instead of the diagnostic-v1 document `tan
|
|
245
|
+
# validate --offline --format diagnostic-v1` produces.
|
|
246
|
+
#
|
|
247
|
+
# A value outside even that union is not this flag at all, and the
|
|
248
|
+
# oracle refuses such a VALUE at PARSE time, before the command
|
|
249
|
+
# body runs -- measured: `tan --format bogus new-som --sku FOO`
|
|
250
|
+
# rejects the value and does no work, while a rewrite that let the
|
|
251
|
+
# value through made it something only `new-som`'s own body
|
|
252
|
+
# validates, and `new-som` WRITES metadata files. The rewrite
|
|
253
|
+
# therefore collapses to exactly the two tokens that reproduce
|
|
254
|
+
# that refusal through `root`'s own `_format_callback`, dropping
|
|
255
|
+
# the rest of argv -- see the `return` below for why leaving argv
|
|
256
|
+
# alone does not work.
|
|
257
|
+
if "=" in token:
|
|
258
|
+
value = token.split("=", 1)[1]
|
|
259
|
+
else:
|
|
260
|
+
value = argv[i + 1] if i + 1 < n else None
|
|
261
|
+
if value not in _every_declared_format():
|
|
262
|
+
# Just returning `argv` here (the first cut of tan-cli#378)
|
|
263
|
+
# does NOT reach that refusal, for two measured reasons.
|
|
264
|
+
#
|
|
265
|
+
# (1) `root`'s `--format` is a plain, non-`multiple` Click
|
|
266
|
+
# option, so Click's parser overwrites `state.opts` on each
|
|
267
|
+
# occurrence and only ever type-casts the LAST one
|
|
268
|
+
# (`click.parser._match_long_opt`) -- `_format_callback` is
|
|
269
|
+
# never handed the bad value at all when a second `--format`
|
|
270
|
+
# follows. Measured: `tan --format bogus --format json new-som
|
|
271
|
+
# --sku FOO` reached `new-som`'s BODY (its SDK-root preflight
|
|
272
|
+
# message printed), where the oracle answers `invalid value
|
|
273
|
+
# 'bogus'` having run nothing, and where a resolvable
|
|
274
|
+
# `--sdk-root` means real metadata writes.
|
|
275
|
+
#
|
|
276
|
+
# (2) Returning the untouched argv also strands every global
|
|
277
|
+
# flag already scanned ahead of this one back in the
|
|
278
|
+
# pre-subcommand position `root` does not declare: `tan
|
|
279
|
+
# --verbose --format bogus validate` answered `No such option:
|
|
280
|
+
# --verbose` -- naming a flag this port supports, for an argv
|
|
281
|
+
# whose actual fault is the `--format` value.
|
|
282
|
+
#
|
|
283
|
+
# Collapsing to `--format <bad-value>` alone fixes both: it is
|
|
284
|
+
# the only argv that makes `root` refuse THIS value, at parse,
|
|
285
|
+
# before any command resolves. Everything dropped is either a
|
|
286
|
+
# global flag (accepted, never read on a refused invocation) or
|
|
287
|
+
# the subcommand the oracle likewise never runs. The `=`
|
|
288
|
+
# spelling collapses to its single token; a trailing `--format`
|
|
289
|
+
# with no value at all collapses to just the flag, which Click
|
|
290
|
+
# reports as "Option '--format' requires an argument." (exit 2,
|
|
291
|
+
# matching clap's own "a value is required" refusal).
|
|
292
|
+
return argv[i : i + (1 if "=" in token else 2)]
|
|
293
|
+
if "=" in token or arity == 0:
|
|
294
|
+
moved.append(token)
|
|
295
|
+
i += 1
|
|
296
|
+
continue
|
|
297
|
+
if i + 1 >= n:
|
|
298
|
+
return argv # "--sdk-root" with no value: let Click report it natively
|
|
299
|
+
moved.extend((token, argv[i + 1]))
|
|
300
|
+
i += 2
|
|
301
|
+
return argv # no subcommand token ever found
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _value_taking_options(command: str | None) -> frozenset[str]:
|
|
305
|
+
"""Every option STRING that swallows the next argv token as its value --
|
|
306
|
+
`root`'s own, plus `command`'s when argv names one.
|
|
307
|
+
|
|
308
|
+
Read off Click's real parameter objects rather than a table in this
|
|
309
|
+
module: the options that matter here are per-command (`scaffold --name`,
|
|
310
|
+
`explain --template`, `init --destination`), so no table here could know
|
|
311
|
+
them, and a table that tried would be stale the first time a command grew
|
|
312
|
+
an option. `--help`/`--version` and every other boolean are excluded by
|
|
313
|
+
`is_flag`; `click.Argument`s are excluded by the leading-dash test (their
|
|
314
|
+
`opts` carry the parameter NAME, not a flag).
|
|
315
|
+
"""
|
|
316
|
+
group = app.lazy_group()
|
|
317
|
+
params = list(group.params)
|
|
318
|
+
subcommand = group.commands.get(command) if command is not None else None
|
|
319
|
+
if subcommand is not None:
|
|
320
|
+
params += list(subcommand.params)
|
|
321
|
+
return frozenset(
|
|
322
|
+
opt
|
|
323
|
+
for param in params
|
|
324
|
+
for opt in (*param.opts, *param.secondary_opts)
|
|
325
|
+
if opt.startswith("-") and not getattr(param, "is_flag", False) and param.nargs == 1
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _wants_help(argv: list[str]) -> bool:
|
|
330
|
+
"""Whether `--help` appears in an OPTION position -- not as some other
|
|
331
|
+
option's VALUE (tan-cli#394).
|
|
332
|
+
|
|
333
|
+
A plain `"--help" in argv` was wrong in a way `--help`'s eagerness hides:
|
|
334
|
+
Click's parser takes the next token as a value-carrying option's argument
|
|
335
|
+
WITHOUT checking whether it looks like an option
|
|
336
|
+
(`click.parser._get_value_from_state` pops `rargs[0]` unconditionally), so
|
|
337
|
+
a `--help` consumed that way NEVER reaches the eager help callback. The
|
|
338
|
+
pre-scan believed it had; `main()` then routed the whole argv through
|
|
339
|
+
`_emit_help_envelope`, which runs the command under `CliRunner` and
|
|
340
|
+
reports its captured output as one `command: "cli"` envelope. Measured:
|
|
341
|
+
`tan scaffold --format json --name --help --destination out` wrote three
|
|
342
|
+
real files and answered `{"command":"cli","ok":true,...,"data":{"message":
|
|
343
|
+
"<the escaped scaffold envelope>"}}` -- so a consumer reading
|
|
344
|
+
`data.written` saw `[]` for a run that wrote, and `tan explain --format
|
|
345
|
+
json --template --help` re-labelled a real `explain.template-unknown`
|
|
346
|
+
refusal as `cli.parse-error`. Text mode reported the truth in both cases;
|
|
347
|
+
only the JSON channel lied.
|
|
348
|
+
|
|
349
|
+
Walking argv with the real arity table instead means the two channels
|
|
350
|
+
agree: a `--help` in value position is left to the command, which then
|
|
351
|
+
emits its OWN envelope (the same one text mode describes), and a `--help`
|
|
352
|
+
in option position still short-circuits here.
|
|
353
|
+
"""
|
|
354
|
+
takes_value = _value_taking_options(
|
|
355
|
+
next((token for token in argv if token in _SUBCOMMAND_NAMES), None)
|
|
356
|
+
)
|
|
357
|
+
i = 0
|
|
358
|
+
while i < len(argv):
|
|
359
|
+
token = argv[i]
|
|
360
|
+
# `--` ends option parsing for Click exactly as it does for clap, so a
|
|
361
|
+
# `--help` after it is a positional argument and the eager help callback
|
|
362
|
+
# never fires. Measured against the oracle: `tan explain -- --help`
|
|
363
|
+
# exits 2 with a refusal, not help.
|
|
364
|
+
if token == "--":
|
|
365
|
+
return False
|
|
366
|
+
if token == "--help":
|
|
367
|
+
return True
|
|
368
|
+
# `--opt=value` carries its value inline, so it consumes one token; a
|
|
369
|
+
# bare value-taking `--opt` eats the token after it, whatever it says.
|
|
370
|
+
i += 2 if token in takes_value else 1
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _emit_help_envelope(argv: list[str]) -> int:
|
|
375
|
+
"""Under `--format json`, `--help` must still land as ONE JSON envelope on
|
|
376
|
+
stdout -- mirroring Rust's `emit_parse_error` path for clap's DisplayHelp
|
|
377
|
+
error kind (`main.rs`, `json_mode_help_yields_zero_exit_and_no_issue`:
|
|
378
|
+
exit 0, `issues: []`, the rendered help as `data.message`). Click's own
|
|
379
|
+
`--help` handling prints straight to stdout and calls `ctx.exit(0)` before
|
|
380
|
+
a command (or even `root`) ever runs, bypassing `emit()` entirely, so it
|
|
381
|
+
has to be intercepted here instead -- `CliRunner` drives the exact same
|
|
382
|
+
Click command and captures what it would have printed as a string,
|
|
383
|
+
without ever touching the real stdout.
|
|
384
|
+
|
|
385
|
+
Not help-specific in what it reads back: `result.exit_code`/`result.output`
|
|
386
|
+
cover the rare case where argv makes Click reject the invocation before
|
|
387
|
+
ever reaching the eager `--help` callback, the same way Rust's generic
|
|
388
|
+
`err.exit_code()`/`err.render()` do for ANY clap parse outcome, help
|
|
389
|
+
included.
|
|
390
|
+
|
|
391
|
+
Returns the exit code the ENVELOPE just printed reports, so the caller can
|
|
392
|
+
`sys.exit` it: `tan --format json badcmd --help` renders help for an
|
|
393
|
+
unknown command, which Click (and the oracle) both exit 2 for, and a
|
|
394
|
+
process exit of 0 there would contradict the very envelope on stdout.
|
|
395
|
+
|
|
396
|
+
tan-cli#810: `CliRunner` is imported HERE, not at module scope. It is the
|
|
397
|
+
only use of `click.testing` in the package -- a TEST helper reached on one
|
|
398
|
+
production path -- and `cli.py` is imported by every `tan` invocation,
|
|
399
|
+
`tan --version` included, for a branch almost none of them take.
|
|
400
|
+
"""
|
|
401
|
+
from click.testing import CliRunner # noqa: PLC0415
|
|
402
|
+
|
|
403
|
+
result = CliRunner().invoke(app.lazy_group(), argv, prog_name="tan")
|
|
404
|
+
message = result.output.strip()
|
|
405
|
+
code = result.exit_code
|
|
406
|
+
issues = [] if code == 0 else [Issue("cli.parse-error", "error", message)]
|
|
407
|
+
emit(
|
|
408
|
+
Envelope(
|
|
409
|
+
"cli",
|
|
410
|
+
Project(root=None, board_yaml=None),
|
|
411
|
+
{"message": message},
|
|
412
|
+
issues,
|
|
413
|
+
code,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
return code
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _format_callback(ctx: typer.Context, value: str | None) -> str | None:
|
|
420
|
+
"""Validates `--format`'s value the moment Click parses it. Marked
|
|
421
|
+
`is_eager=True` on the option below, alongside `--version`
|
|
422
|
+
(`_version_callback`), so the two race on ARGV POSITION rather than on
|
|
423
|
+
`root`'s declaration order -- Click sorts eager params by the order they
|
|
424
|
+
actually appeared on the command line, not by where they're declared
|
|
425
|
+
(`click.core.iter_params_for_processing`). Verified against the oracle
|
|
426
|
+
(`target/debug/tan.exe`): `tan --format bogus --version` exits 2 on the
|
|
427
|
+
bad value without ever reaching `--version` (`--format` comes first in
|
|
428
|
+
argv); `tan --version --format bogus` instead prints the version and
|
|
429
|
+
exits 0, never validating the value that comes after it (`--version` wins
|
|
430
|
+
the race and exits before `--format` is ever processed). Without
|
|
431
|
+
`is_eager=True` here, `--version`'s own eager callback would ALWAYS run
|
|
432
|
+
first regardless of position -- eager beats non-eager unconditionally --
|
|
433
|
+
which would have broken the already-tested `--format json --version` /
|
|
434
|
+
`--format=json --version` cases (`test_version_under_format_json_is_an_
|
|
435
|
+
envelope_not_a_bare_line`): `--version`'s callback would fire before
|
|
436
|
+
`--format`'s value had even been parsed.
|
|
437
|
+
|
|
438
|
+
clap validates `--format`'s VALUE eagerly too -- measured: `tan --format
|
|
439
|
+
bogus`, `tan --format bogus --version`, and `tan --format "" build` (an
|
|
440
|
+
empty value counts as invalid: clap says "a value is required for
|
|
441
|
+
'--format <FORMAT>' but none was supplied") all exit 2 on the value
|
|
442
|
+
itself. `ctx.fail()` gives the same Click UsageError shape (exit 2) every
|
|
443
|
+
other CLI mistake here already gets.
|
|
444
|
+
|
|
445
|
+
Reaches the argv shapes where `--format` is still parsed by `root` since
|
|
446
|
+
tan-cli#378 relocated the pre-subcommand position: no subcommand at all
|
|
447
|
+
(bare `tan --format bogus`, `tan --format bogus --version`), a rewrite
|
|
448
|
+
`_reorder_global_flags` aborted on some earlier unrecognised token (which
|
|
449
|
+
is itself a usage error) -- and, deliberately, EVERY pre-subcommand
|
|
450
|
+
`--format` whose value is outside `_ROOT_FORMAT_VALUES`, which that
|
|
451
|
+
function leaves in place precisely so this refusal is what a caller gets.
|
|
452
|
+
That is the oracle's own ordering: it rejects the value before the
|
|
453
|
+
subcommand runs, so `tan --format bogus new-som --sku FOO` writes nothing.
|
|
454
|
+
With `text`/`json` and a subcommand named, the value lands on that
|
|
455
|
+
command's own `--format` instead, whose identically-shaped "'x' (choose
|
|
456
|
+
from 'text', 'json')" `BadParameter` -- the first statement in every
|
|
457
|
+
command body, before any work -- refuses the post-subcommand position at
|
|
458
|
+
the same exit 2.
|
|
459
|
+
"""
|
|
460
|
+
if value is None:
|
|
461
|
+
return value
|
|
462
|
+
# The domain here is the UNION over every registered command, not the
|
|
463
|
+
# two-value list this used to hard-code (tan-cli#403). It has to be: the
|
|
464
|
+
# callback is EAGER and fires before `ctx.invoked_subcommand` is known, so
|
|
465
|
+
# a `text`/`json` domain refused `tan --format diagnostic-v1 validate
|
|
466
|
+
# --offline` at exit 2 while the post-subcommand spelling of the same run
|
|
467
|
+
# emitted the document. `validate` really does declare four formats
|
|
468
|
+
# (`ValidateOutputFormat`), and the two spellings are meant to be one flag.
|
|
469
|
+
#
|
|
470
|
+
# Eagerness itself stays, and is not negotiable: the docstring above pins
|
|
471
|
+
# measured oracle behaviour where `tan --format bogus --version` must exit
|
|
472
|
+
# 2 BEFORE `--version` runs. Moving the whole check into `root`'s body
|
|
473
|
+
# would hand that race to `--version`.
|
|
474
|
+
#
|
|
475
|
+
# This is only the OUTER bound. `root` narrows to the invoked subcommand's
|
|
476
|
+
# own list once it knows which one that is.
|
|
477
|
+
allowed = _every_declared_format()
|
|
478
|
+
if value not in allowed:
|
|
479
|
+
ctx.fail(invalid_format_message(value, allowed))
|
|
480
|
+
return value
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _every_declared_format() -> tuple[str, ...]:
|
|
484
|
+
"""Every `--format` value ANY registered command accepts, first-seen order.
|
|
485
|
+
|
|
486
|
+
Derived from the live command tree rather than listed. A hand-kept union
|
|
487
|
+
would be a second copy of the same fact, and a command that gained a
|
|
488
|
+
fourth format would then be refused here by a stale copy -- which is the
|
|
489
|
+
exact shape of the bug this replaced.
|
|
490
|
+
"""
|
|
491
|
+
seen: dict[str, None] = {}
|
|
492
|
+
group = app.lazy_group()
|
|
493
|
+
for name in sorted(getattr(group, "commands", {})):
|
|
494
|
+
for value in declared_formats(group.commands[name]):
|
|
495
|
+
seen.setdefault(value, None)
|
|
496
|
+
# `or` guards the degenerate case only: a tree with no `--format` anywhere
|
|
497
|
+
# would otherwise refuse every value, including the two that always worked.
|
|
498
|
+
return tuple(seen) or ("text", "json")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _version_callback(ctx: typer.Context, value: bool) -> bool:
|
|
502
|
+
"""Genuinely eager `--version`, via Typer's own `is_eager=True` +
|
|
503
|
+
`callback=` mechanism (the option below; the same idiom
|
|
504
|
+
`click.version_option()` uses) -- not a hand-rolled `sys.exit` scattered
|
|
505
|
+
through `root`'s body.
|
|
506
|
+
|
|
507
|
+
tan-cli#326: a bare `return` from inside `root`'s function BODY does not
|
|
508
|
+
stop Click's own group dispatch. `click.core.MultiCommand.invoke`
|
|
509
|
+
resolves the subcommand and calls the group callback's body BEFORE it
|
|
510
|
+
invokes the subcommand, so a body that just `return`s (the pre-fix shape)
|
|
511
|
+
falls straight through to the subcommand running anyway -- `tan --version
|
|
512
|
+
init --template zephyr-app --destination <dir>` printed the version AND
|
|
513
|
+
created the project. Raising `typer.Exit` from an EAGER option's own
|
|
514
|
+
callback instead stops the run during argument PARSING itself
|
|
515
|
+
(`Command.parse_args`, called from `make_context`), which happens before
|
|
516
|
+
`MultiCommand.invoke` -- and therefore before subcommand resolution -- is
|
|
517
|
+
ever reached; `Command.main` wraps `make_context` and `invoke` in the SAME
|
|
518
|
+
try/except Exit, so this is caught and converted to a real process exit
|
|
519
|
+
exactly the same way a `typer.Exit` raised from the body would be
|
|
520
|
+
(verified empirically: a `CliRunner` probe with a dummy subcommand behind
|
|
521
|
+
two eager options confirms the subcommand never runs when the earlier one
|
|
522
|
+
raises).
|
|
523
|
+
|
|
524
|
+
`ctx.resilient_parsing` guards the same case Click's own `version_option`
|
|
525
|
+
guards: shell-completion parsing, which must not have side effects (not
|
|
526
|
+
reachable here today -- `add_completion=False` -- but the guard is the
|
|
527
|
+
documented idiom, kept for when that changes).
|
|
528
|
+
|
|
529
|
+
The envelope-vs-plain-text choice below is a raw scan of the real argv
|
|
530
|
+
(`_wants_json`, the SAME textual scan `main()` uses to route `--help`),
|
|
531
|
+
not `ctx.params.get("output_format")` -- deliberately, and NOT what a
|
|
532
|
+
first pass at this fix reached for. `--format`'s own callback is eager
|
|
533
|
+
too, but Click only races two eager options against each other while
|
|
534
|
+
BOTH are options of the SAME command (`root`); `tan --version sdk current
|
|
535
|
+
--format json` puts `--format json` on the OTHER side of the subcommand
|
|
536
|
+
boundary entirely -- Click hands `root` only `["--version"]` and leaves
|
|
537
|
+
`["sdk", "current", "--format", "json"]` as protected args for `sdk`'s own
|
|
538
|
+
parser, so `root`'s `output_format` parameter is `None` there regardless
|
|
539
|
+
of processing order; `ctx.params` genuinely never has the answer. Yet the
|
|
540
|
+
oracle DOES fold that trailing `--format json` into one JSON version
|
|
541
|
+
envelope (tan-cli#326's own repro; verified against `target/debug/tan.exe
|
|
542
|
+
--version sdk current --format json`) -- clap's real version handling
|
|
543
|
+
reads the format value from a scan of the whole process argv at the
|
|
544
|
+
moment it fires, not from however far its own structured parse had
|
|
545
|
+
gotten. A raw scan is what reaches that value from here too. It also
|
|
546
|
+
keeps the three narrower, Click-parseable cases correct as a side effect
|
|
547
|
+
(verified against the oracle for all four): `--format json --version` and
|
|
548
|
+
`--version --format json` both choose JSON (the literal text is present
|
|
549
|
+
either way); `--version --format bogus` chooses plain text (the literal
|
|
550
|
+
"json" is absent, so this never even asks whether "bogus" is a valid
|
|
551
|
+
value -- matching the oracle exactly, which also never validates it once
|
|
552
|
+
`--version` has already won).
|
|
553
|
+
"""
|
|
554
|
+
if not value or ctx.resilient_parsing:
|
|
555
|
+
return value
|
|
556
|
+
if _wants_json(sys.argv[1:]):
|
|
557
|
+
# Under `--format json`, stdout is the envelope channel even for
|
|
558
|
+
# `--version`: Rust routes clap's version output through
|
|
559
|
+
# `emit_parse_error` (main.rs), giving exit 0, no `issues`, and the
|
|
560
|
+
# rendered line as `data.message`.
|
|
561
|
+
emit(
|
|
562
|
+
Envelope(
|
|
563
|
+
"cli",
|
|
564
|
+
Project(root=None, board_yaml=None),
|
|
565
|
+
{"message": f"tan {TAN_VERSION}"},
|
|
566
|
+
[],
|
|
567
|
+
ExitCode.SUCCESS,
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
else:
|
|
571
|
+
# MUST match /^tan \d+\.\d+\.\d+/ -- the extension rejects the binary
|
|
572
|
+
# otherwise (alp-sdk-vscode/src/alpCli/service.ts:107-121).
|
|
573
|
+
typer.echo(f"tan {TAN_VERSION}")
|
|
574
|
+
raise typer.Exit()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@app.callback(invoke_without_command=True)
|
|
578
|
+
def root(
|
|
579
|
+
ctx: typer.Context,
|
|
580
|
+
version: bool = typer.Option(
|
|
581
|
+
False, "--version", callback=_version_callback, is_eager=True
|
|
582
|
+
),
|
|
583
|
+
output_format: str = typer.Option(
|
|
584
|
+
None,
|
|
585
|
+
"--format",
|
|
586
|
+
metavar="FORMAT",
|
|
587
|
+
help="Output format: text or json.",
|
|
588
|
+
callback=_format_callback,
|
|
589
|
+
is_eager=True,
|
|
590
|
+
),
|
|
591
|
+
) -> None:
|
|
592
|
+
"""tan CLI -- board configuration, generation, and project tooling."""
|
|
593
|
+
# The `ctx.obj["format"]` seam thirteen command modules read as a fallback
|
|
594
|
+
# (`output_format or (ctx.obj or {}).get("format") or "text"`). Since
|
|
595
|
+
# tan-cli#378 relocated the pre-subcommand `--format` past the subcommand
|
|
596
|
+
# name, those reads land on the command's OWN parameter and this is `None`
|
|
597
|
+
# on every argv that names a command -- kept because the seam is what
|
|
598
|
+
# makes the two positions interchangeable if a future argv shape reaches a
|
|
599
|
+
# command without going through `_reorder_global_flags`, and because
|
|
600
|
+
# dropping it would leave `ctx.obj` unset for readers that would then have
|
|
601
|
+
# to be edited in lockstep for no behavioural gain.
|
|
602
|
+
ctx.obj = {"format": output_format}
|
|
603
|
+
if ctx.invoked_subcommand is None:
|
|
604
|
+
# Bare invocation. Rust's clap requires a subcommand and exits 2 with
|
|
605
|
+
# help on stderr (crates/tan-cli/src/cli.rs); invoke_without_command
|
|
606
|
+
# exists only so --version can run without one, so an actually-bare
|
|
607
|
+
# call has to be rejected by hand here, or `tan` with no args
|
|
608
|
+
# silently "succeeds" -- the defect this module exists to fix. Click's
|
|
609
|
+
# own idiom for "this callback found the invocation invalid":
|
|
610
|
+
# ctx.fail() raises its usual UsageError (usage line + message to
|
|
611
|
+
# stderr, exit code 2), the same shape every other CLI mistake here
|
|
612
|
+
# already gets, so bare invocation does not need its own bespoke
|
|
613
|
+
# rendering.
|
|
614
|
+
ctx.fail(
|
|
615
|
+
"a command is required.\n"
|
|
616
|
+
"New here? `tan doctor` checks this host, `tan init` creates a project, "
|
|
617
|
+
"`tan build` builds it.\n"
|
|
618
|
+
"`tan --help` lists all commands by category."
|
|
619
|
+
)
|
|
620
|
+
# No third branch here any more (tan-cli#378). This callback used to refuse
|
|
621
|
+
# a pre-subcommand `--format` for any command outside a hand-written
|
|
622
|
+
# allowlist of commands that honoured it -- the refusal was correct given the
|
|
623
|
+
# premise (a command that merely ACCEPTED the flag and ran in text mode
|
|
624
|
+
# would exit 0 with nothing on stdout), but the premise stopped holding
|
|
625
|
+
# once `_reorder_global_flags` relocates `--format` to where the command's
|
|
626
|
+
# own, always-read parameter picks it up. The allowlist reached 12 of 32
|
|
627
|
+
# commands and stalled, so 20 valid oracle invocations exited 2 with
|
|
628
|
+
# `command: "cli"`; deleting the branch is what makes the flag global for
|
|
629
|
+
# all 32 with nothing left to keep in sync.
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _wants_json(argv: list[str]) -> bool:
|
|
633
|
+
"""Textual scan for ``--format json`` / ``--format=json``, mirroring
|
|
634
|
+
Rust's ``wants_json`` (crates/tan-cli/src/main.rs). Needed because a
|
|
635
|
+
usage error (bare invocation, an unknown command, a bad flag) means Click
|
|
636
|
+
exits via its own machinery before any option ever gets parsed into
|
|
637
|
+
something this code could otherwise trust.
|
|
638
|
+
|
|
639
|
+
Deliberately still `--`-blind and arity-blind, exactly like Rust's: this is
|
|
640
|
+
the PARSE-FAILURE arm's scan, not the run's channel. Since tan-cli#546 the
|
|
641
|
+
channel a DISPATCHED command answers on comes from `_DispatchedCommand` --
|
|
642
|
+
the command's own resolved `--format` -- and this scan only decides what a
|
|
643
|
+
run that never reached a command body does. Making it cleverer is what the
|
|
644
|
+
two reverted attempts on #546 did; see `_dispatched_json_mode`'s own comment
|
|
645
|
+
for the oracle measurement that rules out any third textual shape too.
|
|
646
|
+
"""
|
|
647
|
+
for i, arg in enumerate(argv):
|
|
648
|
+
if arg == "--format=json":
|
|
649
|
+
return True
|
|
650
|
+
if arg == "--format" and i + 1 < len(argv) and argv[i + 1] == "json":
|
|
651
|
+
return True
|
|
652
|
+
return False
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _usage_error_envelope(exit_code: int, captured_stderr: str = "") -> str:
|
|
656
|
+
"""The JSON envelope for a Click-level usage error under `--format json`.
|
|
657
|
+
|
|
658
|
+
`captured_stderr` is Click's own rendered message (usage line + the
|
|
659
|
+
specific complaint, e.g. "Error: No such option: --bogus") -- tee'd off
|
|
660
|
+
the real stderr stream by the caller as it printed, not recovered from the
|
|
661
|
+
exception object. Without it this function had exactly one message for
|
|
662
|
+
EVERY usage error ("invalid command line invocation"), so the actual
|
|
663
|
+
reason a caller's argv was rejected existed nowhere: not on stdout (this
|
|
664
|
+
generic string), and not on stderr either (the pre-fix caller discarded
|
|
665
|
+
the capture whenever no command-level envelope had been emitted, which is
|
|
666
|
+
precisely the case here). Rust's ``emit_parse_error`` (main.rs) affords
|
|
667
|
+
the specific clap message because it intercepts the error object itself,
|
|
668
|
+
before clap prints anything; recovering the equivalent object here would
|
|
669
|
+
mean depending on Typer's private, vendored click-alike exception
|
|
670
|
+
hierarchy (`typer._click.exceptions`, NOT the public `click` package's
|
|
671
|
+
classes -- confirmed empirically against typer==0.27.0/click==8.4.1:
|
|
672
|
+
TyperGroup and everything it raises descends from
|
|
673
|
+
`typer._click.core`/`exceptions`, not `click`'s own); tee-ing the text
|
|
674
|
+
Click already rendered is the version-stable seam instead.
|
|
675
|
+
"""
|
|
676
|
+
message = captured_stderr.strip() or "invalid command line invocation"
|
|
677
|
+
env = Envelope(
|
|
678
|
+
"cli",
|
|
679
|
+
Project(root=None, board_yaml=None),
|
|
680
|
+
{"message": message},
|
|
681
|
+
[Issue("cli.parse-error", "error", message)],
|
|
682
|
+
exit_code,
|
|
683
|
+
)
|
|
684
|
+
return env.to_json()
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
#: The POSIX 128+SIGINT exit code Ctrl-C lands on. It does NOT reach `main()`
|
|
688
|
+
#: as a raw `KeyboardInterrupt`: Typer's own command wrapper catches it wherever
|
|
689
|
+
#: in the command's call stack it was raised and re-raises `Exit(130)`, which
|
|
690
|
+
#: Click's `Command.main` turns into a plain `sys.exit(130)`. So by the time the
|
|
691
|
+
#: handler in `main()` sees it, an interrupted run is indistinguishable from any
|
|
692
|
+
#: other non-zero exit that emitted no envelope -- and used to fall into the
|
|
693
|
+
#: same `_usage_error_envelope` branch every genuine Click usage error does.
|
|
694
|
+
#: Measured on `dev` (SIGINT 4s into a real `tan build --plan-from ... --execute
|
|
695
|
+
#: --format json` whose slice spawns `sleep 30`): exit 130 and
|
|
696
|
+
#: `{"command":"cli","exitCode":130,...,"code":"cli.parse-error","message":
|
|
697
|
+
#: "invalid command line invocation"}` -- an envelope asserting the COMMAND LINE
|
|
698
|
+
#: was invalid for a run that was already programming a board, at an exit code
|
|
699
|
+
#: outside the contract's fixed 0-5 set (tan-cli#491 defect 4).
|
|
700
|
+
_SIGINT_EXIT_CODE = 130
|
|
701
|
+
|
|
702
|
+
#: What the interrupt envelope says on both channels. Not command-specific:
|
|
703
|
+
#: `KeyboardInterrupt` reaches the handler identically from all 32 subcommands,
|
|
704
|
+
#: and the command that was running has already lost its own `data` by then.
|
|
705
|
+
_INTERRUPTED_MESSAGE = "interrupted (Ctrl-C)"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _interrupted_envelope() -> str:
|
|
709
|
+
"""The one JSON envelope an interrupted `--format json` run reports.
|
|
710
|
+
|
|
711
|
+
`cli.interrupted` names what actually happened. `ExitCode.RUNTIME_FAILURE`
|
|
712
|
+
(1), not 130: the envelope contract fixes `exitCode` to `tan.exit_codes.
|
|
713
|
+
ExitCode`'s 0-5, and the wire invariant `process exit code ==
|
|
714
|
+
envelope.exitCode` (tan-cli#327) means the two cannot disagree -- so the
|
|
715
|
+
caller of this function exits 1 as well. A DELIBERATE divergence from the
|
|
716
|
+
oracle, which has no SIGINT handler at all and simply dies from the signal
|
|
717
|
+
with zero bytes on stdout; the port cannot copy that and still keep "stdout
|
|
718
|
+
is the envelope channel" for a `--format json` run. TEXT mode is untouched
|
|
719
|
+
and still exits 130 through Click's own machinery, so an operator's shell
|
|
720
|
+
still sees the signal.
|
|
721
|
+
"""
|
|
722
|
+
env = Envelope(
|
|
723
|
+
"cli",
|
|
724
|
+
Project(root=None, board_yaml=None),
|
|
725
|
+
{"message": _INTERRUPTED_MESSAGE},
|
|
726
|
+
[Issue("cli.interrupted", "error", _INTERRUPTED_MESSAGE)],
|
|
727
|
+
int(ExitCode.RUNTIME_FAILURE),
|
|
728
|
+
)
|
|
729
|
+
return env.to_json()
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
class _TeeStderr:
|
|
733
|
+
"""Writes through to the REAL stderr immediately, while also keeping a
|
|
734
|
+
copy -- needed only to fold a Click-level usage error's message into the
|
|
735
|
+
JSON envelope (`_usage_error_envelope`, above).
|
|
736
|
+
|
|
737
|
+
Pre-fix, `--format json` wrapped the whole run in
|
|
738
|
+
`contextlib.redirect_stderr(io.StringIO())`: nothing reached the real
|
|
739
|
+
stderr until the process was about to exit, so a long-running `tan build
|
|
740
|
+
--format json` against a real Zephyr tree printed NOTHING for the whole
|
|
741
|
+
build, then dumped it all at once -- a customer watching the console sees
|
|
742
|
+
a hang, not a build. Every write goes to `_real` first, synchronously, so
|
|
743
|
+
a slice's output (`build_cmd._stream`) streams exactly as it does in text
|
|
744
|
+
mode; the buffered copy exists purely so the `SystemExit` handler below can
|
|
745
|
+
read back what Click already printed.
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
def __init__(self, real: object) -> None:
|
|
749
|
+
self._real = real
|
|
750
|
+
self._buffer = io.StringIO()
|
|
751
|
+
|
|
752
|
+
def write(self, s: str) -> int:
|
|
753
|
+
self._buffer.write(s)
|
|
754
|
+
return self._real.write(s)
|
|
755
|
+
|
|
756
|
+
def flush(self) -> None:
|
|
757
|
+
self._real.flush()
|
|
758
|
+
|
|
759
|
+
def getvalue(self) -> str:
|
|
760
|
+
return self._buffer.getvalue()
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _reconfigure_stdio() -> None:
|
|
764
|
+
"""Force UTF-8, LF-only stdout/stderr, once, at the process boundary.
|
|
765
|
+
|
|
766
|
+
Every command downstream just `print()`s -- correctness here is what
|
|
767
|
+
makes that safe. A normal Windows `TextIOWrapper` translates a written
|
|
768
|
+
`\\n` to `\\r\\n` and encodes with the process's ANSI code page, neither of
|
|
769
|
+
which the oracle's `serde_json`/`println!` output does. Both are visible
|
|
770
|
+
on stdout, not just in theory: measured, `tan completion --shell bash`
|
|
771
|
+
was 3975 bytes with 108 `\\r` where the oracle's was 3867 bytes with zero
|
|
772
|
+
-- and the emitted script is a hard syntax error when sourced in a strict
|
|
773
|
+
bash (`syntax error near unexpected token $'{\\r''`); `clean --format
|
|
774
|
+
json` and a bare `--format json` both ended `\\r\\n` too, so this is a
|
|
775
|
+
process-wide stdout-newline defect, not a completion-specific one. A
|
|
776
|
+
frozen/piped stream may not implement `.reconfigure()` (e.g. a test
|
|
777
|
+
harness's in-memory buffer) -- `hasattr` skips those rather than raising,
|
|
778
|
+
since the fix only matters for the real console/pipe case it targets.
|
|
779
|
+
"""
|
|
780
|
+
for stream in (sys.stdout, sys.stderr):
|
|
781
|
+
if hasattr(stream, "reconfigure"):
|
|
782
|
+
stream.reconfigure(encoding="utf-8", newline="\n")
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def main() -> None:
|
|
786
|
+
"""Process entrypoint.
|
|
787
|
+
|
|
788
|
+
Text mode (the default) lets Click run standalone: it already prints its
|
|
789
|
+
own errors/help to stderr and exits with the right code, which is exactly
|
|
790
|
+
the contract there -- Click's exact RENDERING of a usage error carries no
|
|
791
|
+
promises of its own. That rule outlived its statement of record:
|
|
792
|
+
``tests/parity/oracle.py`` declared it, scoped to the Rust-vs-Python diff,
|
|
793
|
+
and went with the oracle axis in tan-cli#269, so THIS docstring is where it
|
|
794
|
+
lives now. Read it narrowly. What is unpinned is Click/rich's wording and
|
|
795
|
+
box-drawing, because nothing diffs it against the retired binary's clap
|
|
796
|
+
renderer. tan's stderr as a CHANNEL is pinned hard -- ~500 assertions under
|
|
797
|
+
``python/tests/`` take it as their subject, ``contract/envelopes``' goldens
|
|
798
|
+
require it EMPTY under ``--format json``, and
|
|
799
|
+
``tests/test_stdout_bytes.py::test_stderr_also_has_no_cr`` forbids ``\r``
|
|
800
|
+
in it.
|
|
801
|
+
|
|
802
|
+
``--format json`` cannot be handled that way: Click's default dispatch
|
|
803
|
+
prints straight to stdout/stderr and calls `sys.exit` itself for a usage
|
|
804
|
+
error, none of which goes through the envelope, so a bare invocation or a
|
|
805
|
+
bad flag under `--format json` would otherwise leave stdout either empty
|
|
806
|
+
or carrying human text instead of the one JSON envelope the contract
|
|
807
|
+
promises (the hard constraint: "stdout is the envelope channel"). Rust's
|
|
808
|
+
``main.rs`` hits the identical problem and solves it by intercepting the
|
|
809
|
+
parse error before clap prints it; the equivalent interception point here
|
|
810
|
+
is process exit itself -- `app()` still runs standalone (so its stderr
|
|
811
|
+
text and exit code are unchanged), and this wraps it only to add the
|
|
812
|
+
missing stdout envelope when the exit signals failure under `--format
|
|
813
|
+
json`.
|
|
814
|
+
"""
|
|
815
|
+
global _dispatched_json_mode
|
|
816
|
+
|
|
817
|
+
_reconfigure_stdio()
|
|
818
|
+
# Reset per run, not merely initialised at import: `main()` is called more
|
|
819
|
+
# than once per process by the in-process tests, and a stale answer from the
|
|
820
|
+
# previous run would decide this one's output channel.
|
|
821
|
+
_dispatched_json_mode = None
|
|
822
|
+
original_argv = sys.argv[1:]
|
|
823
|
+
argv = _reorder_global_flags(original_argv)
|
|
824
|
+
sys.argv = [sys.argv[0], *argv]
|
|
825
|
+
# Scanned off the ORIGINAL argv, not the rewritten one: `_reorder_global_flags`
|
|
826
|
+
# COLLAPSES to just the offending `--format <bad-value>` pair the moment a
|
|
827
|
+
# pre-subcommand `--format` carries a value the oracle's global `--format`
|
|
828
|
+
# does not have (see its own docstring), which drops every later token --
|
|
829
|
+
# including a SECOND, accepted `--format json` the oracle still notices when
|
|
830
|
+
# it decides which channel to answer on. Measured against the oracle: `tan
|
|
831
|
+
# --format sarif --format json validate --offline` answers a 423-byte JSON
|
|
832
|
+
# envelope on stdout (`wants_json` in Rust's own `main.rs` is a textual scan
|
|
833
|
+
# of the whole process argv too, not a re-parse of whatever clap's actual
|
|
834
|
+
# value resolution landed on). Reading the rewritten `argv` here instead
|
|
835
|
+
# left that same invocation with `--format json` already dropped by the
|
|
836
|
+
# collapse, so `json_mode` came back `False` and the usage-error fallback
|
|
837
|
+
# below never ran: `main()` fell into the text-mode branch, which prints
|
|
838
|
+
# Click's error to stderr only -- zero bytes on stdout, a hard violation of
|
|
839
|
+
# "stdout is the envelope channel" for an argv the caller marked `--format
|
|
840
|
+
# json`.
|
|
841
|
+
json_mode = _wants_json(original_argv)
|
|
842
|
+
|
|
843
|
+
if json_mode and _wants_help(argv):
|
|
844
|
+
# `--help` short-circuits Click before `root`/any command ever runs
|
|
845
|
+
# (see `_emit_help_envelope`), so it needs its own path entirely --
|
|
846
|
+
# by the time a `SystemExit` from it would reach the block below,
|
|
847
|
+
# Click has already printed the human help text straight to stdout.
|
|
848
|
+
# `sys.exit`, not a bare `return`: the process exit code must agree
|
|
849
|
+
# with the `exitCode` of the envelope just printed (Rust's own
|
|
850
|
+
# `json_exit_code` doc comment states the same invariant) --
|
|
851
|
+
# `tan --format json badcmd --help` renders help for an unknown
|
|
852
|
+
# command at exit 2, and a bare `return` here left the process exiting
|
|
853
|
+
# 0 regardless.
|
|
854
|
+
sys.exit(_emit_help_envelope(argv))
|
|
855
|
+
|
|
856
|
+
if not json_mode:
|
|
857
|
+
# `prog_name="tan"` -- Click otherwise derives the name it prints in
|
|
858
|
+
# `Usage: ...` from `os.path.basename(sys.argv[0])`, which is the
|
|
859
|
+
# frozen binary's OWN filename (`tan.exe` locally, or whatever
|
|
860
|
+
# `release.yml` renamed the uploaded asset to, e.g.
|
|
861
|
+
# `tan-x86_64-pc-windows-msvc.exe`, if a user runs the download in
|
|
862
|
+
# place). Pinned here rather than left to derive, same as
|
|
863
|
+
# `_emit_help_envelope`'s `prog_name="tan"` above.
|
|
864
|
+
app(prog_name="tan")
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
# `--format json`, past `--help`: TEE stderr for the duration of the run
|
|
868
|
+
# (`_TeeStderr`) rather than capturing it -- every write still reaches the
|
|
869
|
+
# real stderr AS IT HAPPENS, so a slice's live output (build's `_stream`)
|
|
870
|
+
# streams exactly as it does in text mode; a long `tan build --format
|
|
871
|
+
# json` against a real Zephyr tree no longer goes silent for the whole
|
|
872
|
+
# build and dumps at the end. The kept copy exists only to fold Click's
|
|
873
|
+
# own pre-dispatch usage-error text (bare invocation, an unknown command,
|
|
874
|
+
# a bad flag -- printed straight to stderr before any command runs,
|
|
875
|
+
# mirroring clap's `err.exit()`) into the envelope below via
|
|
876
|
+
# `_usage_error_envelope`, so the specific reason a caller's argv was
|
|
877
|
+
# rejected is not silently different between the two channels.
|
|
878
|
+
# `not envelope_emitted()` -- the same flag `emit()` sets -- still gates
|
|
879
|
+
# the envelope fallback itself: a command that already wrote its own and
|
|
880
|
+
# then exited non-zero (every failed `tan build`) must not get a second
|
|
881
|
+
# one appended, two JSON documents on stdout is the same break as none.
|
|
882
|
+
real_stderr = sys.stderr
|
|
883
|
+
captured_stderr = _TeeStderr(real_stderr)
|
|
884
|
+
sys.stderr = captured_stderr
|
|
885
|
+
try:
|
|
886
|
+
try:
|
|
887
|
+
# `prog_name="tan"` -- see the text-mode call above; it matters MORE
|
|
888
|
+
# here, since a Click usage error's rendered message (captured via
|
|
889
|
+
# `_TeeStderr`) is what `_usage_error_envelope` folds verbatim into
|
|
890
|
+
# `data.message`, a machine-readable envelope field a consumer
|
|
891
|
+
# should not see vary with how the binary happened to be named.
|
|
892
|
+
app(prog_name="tan")
|
|
893
|
+
except SystemExit as exc:
|
|
894
|
+
code = exc.code
|
|
895
|
+
if code is None:
|
|
896
|
+
code = int(ExitCode.SUCCESS)
|
|
897
|
+
elif not isinstance(code, int):
|
|
898
|
+
code = int(ExitCode.RUNTIME_FAILURE)
|
|
899
|
+
if not envelope_emitted():
|
|
900
|
+
# `_dispatched_json_mode is False` -- a command's body really
|
|
901
|
+
# RAN, and its OWN `--format` resolved to text. That run owes
|
|
902
|
+
# stdout nothing (text mode carries no envelope promise), so
|
|
903
|
+
# neither of the two fallbacks below may fire for it: printing
|
|
904
|
+
# one is how `tan quality -- --format json` used to answer a
|
|
905
|
+
# forwarded west failure with `command:"cli"` /
|
|
906
|
+
# `cli.parse-error` on stdout for a caller that asked for text
|
|
907
|
+
# (tan-cli#546). `None` -- nothing dispatched -- means the parse
|
|
908
|
+
# itself failed, which is precisely Rust's `Err` arm, and the
|
|
909
|
+
# textual `_wants_json` scan that got us into this branch is
|
|
910
|
+
# exactly what Rust consults there.
|
|
911
|
+
ran_in_text_mode = _dispatched_json_mode is False
|
|
912
|
+
if code == _SIGINT_EXIT_CODE and not ran_in_text_mode:
|
|
913
|
+
# Ahead of the generic usage-error fallback: an interrupted
|
|
914
|
+
# run is not a usage error, and `sys.exit` here (rather than
|
|
915
|
+
# re-raising `exc`) is what keeps the process code equal to
|
|
916
|
+
# the `exitCode` the envelope just printed. See
|
|
917
|
+
# `_interrupted_envelope`.
|
|
918
|
+
print(_interrupted_envelope())
|
|
919
|
+
sys.exit(int(ExitCode.RUNTIME_FAILURE))
|
|
920
|
+
if code != 0 and not ran_in_text_mode:
|
|
921
|
+
print(_usage_error_envelope(code, captured_stderr.getvalue()))
|
|
922
|
+
raise
|
|
923
|
+
# tan-cli#327: `Envelope.to_json()`'s serialize-failure fallback
|
|
924
|
+
# can report a different `exitCode` (5, `envelope.serialize-
|
|
925
|
+
# failed`) than the command's own `typer.Exit(code)` -- the
|
|
926
|
+
# command chose `code` BEFORE `emit()` ever tried to encode the
|
|
927
|
+
# envelope, so a fallback there leaves `code` stale. The wire
|
|
928
|
+
# invariant is `process exit code == envelope.exitCode`
|
|
929
|
+
# (mirrors the Rust `json_exit_code` boundary and its
|
|
930
|
+
# `json_exit_code_follows_serialize_failure_fallback_not_stale_
|
|
931
|
+
# run_exit` test); `emit()` is the one place that already knows
|
|
932
|
+
# the REAL code, so read it back rather than re-deriving
|
|
933
|
+
# anything from the JSON this process just printed.
|
|
934
|
+
emitted_code = envelope_emitted_exit_code()
|
|
935
|
+
if emitted_code is not None and emitted_code != code:
|
|
936
|
+
sys.exit(emitted_code)
|
|
937
|
+
raise
|
|
938
|
+
finally:
|
|
939
|
+
sys.stderr = real_stderr
|