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/envelope.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Machine-readable result envelope. JSON mode writes exactly one to stdout."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from tan.core.sdk_discovery import sdk_ladder_divergence_issue, sdk_resolution_issues
|
|
14
|
+
from tan.exit_codes import ExitCode
|
|
15
|
+
|
|
16
|
+
#: Every code point in the UTF-16 surrogate range. A Python `str` may hold one
|
|
17
|
+
#: UNPAIRED -- `os.fsdecode`/`surrogateescape` turns each byte of an
|
|
18
|
+
#: un-decodable filesystem name into exactly one of these (`proj\xffx` ->
|
|
19
|
+
#: `proj\udcffx`) and carries it verbatim into `project.root`, an argv token, a
|
|
20
|
+
#: scanned path in `data`. It is a perfectly ordinary character to `json.dumps`,
|
|
21
|
+
#: which writes it straight through under `ensure_ascii=False` -- but it is NOT
|
|
22
|
+
#: valid UTF-8, so the failure used to land one call later, at `emit()`'s
|
|
23
|
+
#: `print(text)`, as an uncaught `UnicodeEncodeError` AFTER `_serialise()` had
|
|
24
|
+
#: already reported success (tan-cli#491). See `_scrub_lone_surrogates`.
|
|
25
|
+
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
26
|
+
|
|
27
|
+
#: What each one becomes: U+FFFD REPLACEMENT CHARACTER, which is precisely what
|
|
28
|
+
#: the frozen v0.4.1 oracle puts on the wire for the same directory. Measured,
|
|
29
|
+
#: not assumed -- `target/debug/tan inspect --format json` run from a directory
|
|
30
|
+
#: created as `os.fsdecode(b"proj\xffx")` answers
|
|
31
|
+
#: `"root":"...\/proj\xef\xbf\xbdx"` at exit 0 (`\xef\xbf\xbd` is U+FFFD's UTF-8
|
|
32
|
+
#: encoding), because every path Rust puts in the envelope goes through
|
|
33
|
+
#: `Path::to_string_lossy`, whose lossy step is this same substitution, one
|
|
34
|
+
#: replacement character per un-decodable byte. `surrogateescape` is also one
|
|
35
|
+
#: surrogate per un-decodable byte, so the two agree character for character.
|
|
36
|
+
_REPLACEMENT_CHARACTER = "\ufffd"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scrub_lone_surrogates(text: str) -> str:
|
|
40
|
+
"""`text` with every surrogate code point replaced by U+FFFD.
|
|
41
|
+
|
|
42
|
+
Applied to the SERIALISED JSON rather than walked over the payload: a
|
|
43
|
+
surrogate can arrive in a `data` value, in a `project.root`, in an issue
|
|
44
|
+
MESSAGE, or in a dict KEY, and one pass over the finished document catches
|
|
45
|
+
all four for the cost of one regex scan instead of a second full recursive
|
|
46
|
+
walk beside `json_safe_floats`. Nothing else in the document can be a
|
|
47
|
+
surrogate -- an astral character is a single non-surrogate code point in a
|
|
48
|
+
Python `str`, and `json.dumps` escapes the only characters it rewrites
|
|
49
|
+
(`"`, `\\`, controls) into ASCII -- so every match came from the payload.
|
|
50
|
+
|
|
51
|
+
Deliberately NOT `ensure_ascii=True` as a fallback re-serialisation (the
|
|
52
|
+
first shape tried for tan-cli#491, and rejected against the oracle):
|
|
53
|
+
that escapes the surrogate as `\\udcff`, which is parseable JSON but is not
|
|
54
|
+
what the oracle emits, and it also escapes every OTHER non-ASCII character
|
|
55
|
+
in the same document -- so one bad byte in a path would have turned a
|
|
56
|
+
perfectly good `Sensör Ölçüm` elsewhere in `data` into `Sens\\u00f6r`,
|
|
57
|
+
re-opening the byte-for-byte divergence `ensure_ascii=False` exists to
|
|
58
|
+
close. This substitution touches only the offending characters.
|
|
59
|
+
"""
|
|
60
|
+
return _LONE_SURROGATE.sub(_REPLACEMENT_CHARACTER, text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
#: OPEN QUESTION, deliberately left open (tan-cli#491). This substitution is
|
|
64
|
+
#: SILENT: nothing in the envelope tells a consumer that a value it is reading
|
|
65
|
+
#: was rewritten, so a path that differs from the real one only in the bytes
|
|
66
|
+
#: that were replaced reads as authoritative. Announcing it -- a `warning`
|
|
67
|
+
#: issue beside the payload -- was considered and NOT done, because the
|
|
68
|
+
#: substitution was chosen for byte parity with the frozen v0.4.1 oracle
|
|
69
|
+
#: (`Path::to_string_lossy` makes the identical replacement and emits no issue
|
|
70
|
+
#: of its own), so adding one changes the wire bytes of a case that currently
|
|
71
|
+
#: matches by construction. `--format json` is a machine contract; that is a
|
|
72
|
+
#: divergence to decide deliberately, not a side effect of a bug fix. Left for
|
|
73
|
+
#: the maintainer with the trade-off recorded rather than settled here.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: tan-cli#491's invariant is that NO payload, however malformed, may stop the
|
|
77
|
+
#: single envelope from being written -- the same claim `_serialise`'s own
|
|
78
|
+
#: `# noqa: BLE001 -- no payload may ever crash stdout` has always made. After
|
|
79
|
+
#: the surrogate scrub above, TWO sites still broke it, both measured on
|
|
80
|
+
#: `dev`@`4dcfdf5`, not theorised:
|
|
81
|
+
#:
|
|
82
|
+
#: * `_serialise`'s `except` arm re-serialises `self.project` (and
|
|
83
|
+
#: `self.sdk`) VERBATIM, so a value there that `json.dumps` cannot encode
|
|
84
|
+
#: detonates the very fallback that exists to keep stdout alive:
|
|
85
|
+
#: `Envelope("x", Project(root=<non-str>, board_yaml=None), {"ok": 1}, [],
|
|
86
|
+
#: 0).to_json()` raised `TypeError: Object of type ... is not JSON
|
|
87
|
+
#: serializable` straight out of the arm -- zero bytes on stdout through
|
|
88
|
+
#: `emit()`, a raw traceback through `tan.cli`'s two `to_json()` callers.
|
|
89
|
+
#: * `emit()`'s `print(text)` sat outside every guard. That is the exact
|
|
90
|
+
#: placement error #491 names: `_serialise()` returns a `str` and reports
|
|
91
|
+
#: success, and the ENCODE happens one call later, at the write.
|
|
92
|
+
#:
|
|
93
|
+
#: `_last_resort_document` is what both sites fall back to. It is deliberately
|
|
94
|
+
#: `ensure_ascii=True` -- the opposite of `_serialise`, which needs
|
|
95
|
+
#: `ensure_ascii=False` for byte parity on the normal path. Here the document's
|
|
96
|
+
#: job is to be writable to a stdout that has ALREADY refused one string, so
|
|
97
|
+
#: pure ASCII (encodable under utf-8, ascii, latin-1, every Windows code page)
|
|
98
|
+
#: is worth more than parity. It is also why no surrogate scrub is needed on
|
|
99
|
+
#: this path: `ensure_ascii=True` renders one as the escape `\udcff`, ASCII on
|
|
100
|
+
#: the wire.
|
|
101
|
+
#:
|
|
102
|
+
#: TWO of its fields are not literals, and neither is trusted. `command` is
|
|
103
|
+
#: typed `str` but nothing enforces that at runtime, and a non-`str` there made
|
|
104
|
+
#: the document itself raise (`TypeError: Object of type ... is not JSON
|
|
105
|
+
#: serializable`) -- taking `to_json()` AND `emit()` down with it, since
|
|
106
|
+
#: `emit`'s own arm calls this with the same argument. The reason text runs
|
|
107
|
+
#: `err.__str__`, which is payload code too. Both go through `str()` inside a
|
|
108
|
+
#: guard, so the only way out of the function is a document. `"cli"` is the
|
|
109
|
+
#: fallback command name because `tan.cli`'s own envelopes already use it for a
|
|
110
|
+
#: run with no resolved subcommand -- a value a consumer already handles.
|
|
111
|
+
#:
|
|
112
|
+
#: Both guards catch `Exception`, deliberately NOT `BaseException`: a `__str__`
|
|
113
|
+
#: that raises `KeyboardInterrupt` escapes intact, and that is correct.
|
|
114
|
+
#: Swallowing an interrupt is the defect #491's own Ctrl-C arm exists to fix.
|
|
115
|
+
#:
|
|
116
|
+
#: `envelope.serialize-failed` and `ExitCode.INTERNAL_FAILURE` (5) reuse the
|
|
117
|
+
#: existing fallback's code and exit code rather than introducing new ones: the
|
|
118
|
+
#: consumer effect is identical (tan could not represent this run's output),
|
|
119
|
+
#: and `contract/issue-codes.json` already registers it.
|
|
120
|
+
def _last_resort_document(command: str, err: Exception) -> str:
|
|
121
|
+
"""One parseable envelope built from ASCII literals and two COERCED
|
|
122
|
+
strings -- what stdout gets when neither the real envelope nor
|
|
123
|
+
`_serialise`'s own fallback could be produced or written. See the notes
|
|
124
|
+
above for the two sites, the two coercions, and the reused issue code.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
reason = f"{type(err).__name__}: {err}"
|
|
128
|
+
except Exception: # noqa: BLE001 -- an exception's own __str__ may raise
|
|
129
|
+
reason = type(err).__name__
|
|
130
|
+
try:
|
|
131
|
+
name = str(command)
|
|
132
|
+
except Exception: # noqa: BLE001 -- so may an object standing in for a command name
|
|
133
|
+
name = "cli"
|
|
134
|
+
return json.dumps(
|
|
135
|
+
{
|
|
136
|
+
"command": name,
|
|
137
|
+
"ok": False,
|
|
138
|
+
"exitCode": int(ExitCode.INTERNAL_FAILURE),
|
|
139
|
+
# Never the real `project`/`sdk`/`data`: carrying them through is
|
|
140
|
+
# precisely what broke the arm that sent us here. Hand-built rather
|
|
141
|
+
# than `Project.as_dict()`/`Issue.as_dict()` for the same reason --
|
|
142
|
+
# this document may not call code that can raise. The cost is that
|
|
143
|
+
# a field added to either type would silently give this envelope a
|
|
144
|
+
# different shape from every other one on the contract, which
|
|
145
|
+
# `test_the_last_resort_document_has_the_same_key_set_as_a_normal_envelope`
|
|
146
|
+
# is what notices.
|
|
147
|
+
"project": {"root": None, "boardYaml": None},
|
|
148
|
+
"data": None,
|
|
149
|
+
"issues": [
|
|
150
|
+
{
|
|
151
|
+
"code": "envelope.serialize-failed",
|
|
152
|
+
"severity": "error",
|
|
153
|
+
"message": f"failed to write command output: {reason}",
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
separators=(",", ":"),
|
|
158
|
+
ensure_ascii=True,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def json_safe_floats(value: Any) -> Any:
|
|
163
|
+
"""`value` with every NON-FINITE float replaced by `None`, recursively --
|
|
164
|
+
`serde_json`'s own answer for an `f64` RFC 8259 cannot express (tan-cli#387).
|
|
165
|
+
|
|
166
|
+
Python's `json.dumps` defaults to `allow_nan=True` and writes the
|
|
167
|
+
non-standard literals `Infinity`, `-Infinity` and `NaN`. Those are not JSON.
|
|
168
|
+
`JSON.parse` throws on them, and the alp-sdk-vscode extension's only channel
|
|
169
|
+
is this envelope -- so a `build/system-manifest.yaml` carrying `.inf` in
|
|
170
|
+
`hw_info` (verbatim into `data` via `raw_passthrough`) used to hand the
|
|
171
|
+
consumer a parse throw at exit code 0 with `issues:[]`: no coded signal to
|
|
172
|
+
fall back on, and nothing to distinguish it from tan crashing. The oracle on
|
|
173
|
+
the identical input emits `null` and the consumer gets a field it can
|
|
174
|
+
inspect.
|
|
175
|
+
|
|
176
|
+
NOT solved with `allow_nan=False` at the `json.dumps` below. That raises,
|
|
177
|
+
which lands in `_serialise`'s `envelope.serialize-failed` / exit-5 fallback
|
|
178
|
+
where the oracle exits 0 -- one divergence traded for another. This projects
|
|
179
|
+
instead, so the exit code and `issues` stay exactly what the command decided.
|
|
180
|
+
|
|
181
|
+
Applied at SERIALISE time, mirroring where `serde_json` makes the same
|
|
182
|
+
substitution, so it holds for every command's `data` rather than for the one
|
|
183
|
+
payload the defect was found in.
|
|
184
|
+
|
|
185
|
+
Map KEYS are deliberately left alone: `json.dumps` renders a non-finite
|
|
186
|
+
float key as the *string* `"Infinity"`, which is already valid JSON, and
|
|
187
|
+
rewriting it would change a key a consumer may be matching on.
|
|
188
|
+
|
|
189
|
+
A new object always -- the caller's payload is never mutated, so a command
|
|
190
|
+
that inspects its own `data` after `emit()` still sees its own floats.
|
|
191
|
+
"""
|
|
192
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
193
|
+
return None
|
|
194
|
+
if isinstance(value, dict):
|
|
195
|
+
return {k: json_safe_floats(v) for k, v in value.items()}
|
|
196
|
+
if isinstance(value, (list, tuple)):
|
|
197
|
+
# A tuple becomes a list: `json.dumps` writes both as a JSON array, so
|
|
198
|
+
# the wire bytes are unchanged.
|
|
199
|
+
return [json_safe_floats(v) for v in value]
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class Issue:
|
|
205
|
+
code: str
|
|
206
|
+
severity: str
|
|
207
|
+
message: str
|
|
208
|
+
|
|
209
|
+
def as_dict(self) -> dict[str, str]:
|
|
210
|
+
return {"code": self.code, "severity": self.severity, "message": self.message}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True)
|
|
214
|
+
class Project:
|
|
215
|
+
root: str | None
|
|
216
|
+
board_yaml: str | None
|
|
217
|
+
|
|
218
|
+
def as_dict(self) -> dict[str, Any]:
|
|
219
|
+
return {"root": self.root, "boardYaml": self.board_yaml}
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def resolved(root: str | None, board_yaml: str | None) -> "Project":
|
|
223
|
+
"""The `project` block for a RESOLVED project -- the one seam every
|
|
224
|
+
command that reports a real (as opposed to hardcoded-None) `board_yaml`
|
|
225
|
+
should build it through (tan-cli#236, #170; mirrors the Rust CLI's
|
|
226
|
+
`Project::from_context` in `crates/tan-cli/src/envelope.rs`).
|
|
227
|
+
|
|
228
|
+
`board_yaml` is reported only when a file is really there. The doc has
|
|
229
|
+
always read "if found"; before this fix, every call site cloned a
|
|
230
|
+
resolver's `<root>/board.yaml` straight through regardless of whether
|
|
231
|
+
anything was there, so a consumer that opened it got ENOENT. `null` is
|
|
232
|
+
not a new value here -- it is what the field already carries wherever
|
|
233
|
+
resolution finds nothing.
|
|
234
|
+
|
|
235
|
+
`root` passes through untouched -- tan-cli#236 rules a
|
|
236
|
+
directory-is-not-a-project question explicitly out of scope.
|
|
237
|
+
|
|
238
|
+
Deliberately NOT pushed into the resolvers that compute `board_yaml`
|
|
239
|
+
(`resolve_project_paths`, `resolve_project_context`, ...): several
|
|
240
|
+
commands need that unfiltered path precisely when the file is absent --
|
|
241
|
+
a "no board.yaml at `<path>`" refusal message, in particular -- so
|
|
242
|
+
nulling it at the resolver would strip the path out of the very message
|
|
243
|
+
that names it. Reporting is the seam; the resolver is not.
|
|
244
|
+
"""
|
|
245
|
+
exists = board_yaml is not None and os.path.exists(board_yaml)
|
|
246
|
+
return Project(root=root, board_yaml=board_yaml if exists else None)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True)
|
|
250
|
+
class SdkInfo:
|
|
251
|
+
root: str
|
|
252
|
+
source_tier: str
|
|
253
|
+
#: tan-cli#478. CARRIED from the resolution, never re-derived at emit time,
|
|
254
|
+
#: and deliberately NOT part of `as_dict` -- the wire shape stays
|
|
255
|
+
#: `{root, sourceTier}`, which is the extension handshake.
|
|
256
|
+
#:
|
|
257
|
+
#: Carried rather than recomputed because `~/.alp/sdk-default` is MUTABLE
|
|
258
|
+
#: mid-run: `bootstrap` rewrites it with its own `writtenFor` before its
|
|
259
|
+
#: envelope goes out, and `init` writes the project pin after resolving.
|
|
260
|
+
#: `test_bootstrap_command.py` pins the resolution-time semantic ("`init`
|
|
261
|
+
#: surfaces `sdk.global-default-foreign-project` BEFORE `_pin_sdk`
|
|
262
|
+
#: writes"), so an emit-time re-read would report the POST-mutation state
|
|
263
|
+
#: and the warning would vanish on exactly the two commands that change
|
|
264
|
+
#: it. That is the difference from `_with_sdk_divergence` below, which may
|
|
265
|
+
#: re-derive safely: #407 is a stateless property of the filesystem,
|
|
266
|
+
#: #464 is a property of the resolution THIS run acted on.
|
|
267
|
+
foreign_global_default_for: str | None = None
|
|
268
|
+
#: The unreadable/unresolvable `.alp/sdk-path` pin, same carry-through --
|
|
269
|
+
#: `sdk_resolution_issues` emits the two together, in this order.
|
|
270
|
+
broken_project_pin: str | None = None
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_resolution(cls, root: str, resolution: Any) -> SdkInfo:
|
|
274
|
+
"""The ONE blessed constructor. `resolution` is any object carrying
|
|
275
|
+
`tier`/`foreign_global_default_for`/`broken_project_pin` -- every
|
|
276
|
+
ladder already answers with one (`SdkRootResolution`, `ActiveSdk`).
|
|
277
|
+
|
|
278
|
+
A raw `SdkInfo(root, tier)` silently drops both facts, which is how 16
|
|
279
|
+
of ~32 commands ended up disclosing a foreign global default and the
|
|
280
|
+
rest staying quiet; `tests/gates/test_sdk_info_is_built_from_a_resolution.py`
|
|
281
|
+
refuses new raw constructions outside the seams that predate this.
|
|
282
|
+
"""
|
|
283
|
+
return cls(
|
|
284
|
+
root,
|
|
285
|
+
resolution.tier,
|
|
286
|
+
getattr(resolution, "foreign_global_default_for", None),
|
|
287
|
+
getattr(resolution, "broken_project_pin", None),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def as_dict(self) -> dict[str, str]:
|
|
291
|
+
# Forward slashes ALWAYS, mirroring `crates/tan-cli/src/sdk_report.rs`'s
|
|
292
|
+
# `root.replace('\\', "/")` and the guarantee its own doc comment makes:
|
|
293
|
+
# "`sdk.root` never diverges by separator style depending on which
|
|
294
|
+
# resolver happened to record it." This is part of the extension
|
|
295
|
+
# handshake, so it must be platform-identical.
|
|
296
|
+
#
|
|
297
|
+
# Normalised HERE, at the one shared seam, rather than in each command:
|
|
298
|
+
# `build`, `doctor` and `sdk` all populate this field and all three had
|
|
299
|
+
# the same defect. No conformance fixture can catch it -- `sdk` is absent
|
|
300
|
+
# from every committed golden, because none of them resolves a checkout.
|
|
301
|
+
# `data.sdkPath` stays raw/native on both sides; only this key is posix.
|
|
302
|
+
return {"root": self.root.replace("\\", "/"), "sourceTier": self.source_tier}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass
|
|
306
|
+
class SdkDisclosure:
|
|
307
|
+
"""What a command already knew about its SDK resolution at the moment it
|
|
308
|
+
blew up, so its OUTER `<command>.internal-failure` catch-all can report it
|
|
309
|
+
(tan-cli#497 defect 2, the site the first pass missed).
|
|
310
|
+
|
|
311
|
+
Every command here splits into a `_run`-style inner function that resolves
|
|
312
|
+
the SDK and an outer wrapper that guards the whole thing with a bare
|
|
313
|
+
`except Exception`. The facts -- which checkout answered, and the
|
|
314
|
+
`sdk.project-pin-unresolved` / `sdk.global-default-foreign-project` pair
|
|
315
|
+
that says the pin was ignored -- are computed INSIDE the inner function, so
|
|
316
|
+
the handler in the outer one had no name to read them from and reported the
|
|
317
|
+
crash alone. `model`/`run` avoided that by resolving in the outer function;
|
|
318
|
+
the three that could not (their resolution needs paths the inner function
|
|
319
|
+
resolves) pass one of these down and `record()` into it the instant the
|
|
320
|
+
pair is known.
|
|
321
|
+
|
|
322
|
+
MUTABLE and shared by reference on purpose: the handler must see what the
|
|
323
|
+
inner call recorded before it raised. Reading an unrecorded disclosure is
|
|
324
|
+
the honest "nothing was resolved yet" -- `None` and `[]`, exactly what a
|
|
325
|
+
crash before the ladder ran should report.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
sdk: SdkInfo | None = None
|
|
329
|
+
issues: list[Issue] = field(default_factory=list)
|
|
330
|
+
|
|
331
|
+
def record(self, sdk: SdkInfo | None, issues: list[Issue]) -> None:
|
|
332
|
+
"""Copies `issues` rather than aliasing it: the caller goes on to
|
|
333
|
+
`append` command-specific issues onto its own list, and those are not
|
|
334
|
+
resolution facts -- a crash after them must not report them twice."""
|
|
335
|
+
self.sdk = sdk
|
|
336
|
+
self.issues = list(issues)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
#: The three commands resolving the SDK through `resolve_sdk_root_wide` rather
|
|
340
|
+
#: than `resolve_sdk_root_ladder` (tan-cli#407). Only used to label WHICH side
|
|
341
|
+
#: of a divergence the reader is holding; getting it wrong would swap two
|
|
342
|
+
#: labels in a warning, never change which root a command uses.
|
|
343
|
+
WIDE_LADDER_COMMANDS = frozenset({"init", "generate", "examples"})
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class Envelope:
|
|
347
|
+
def __init__(self, command, project, data, issues, exit_code, sdk=None):
|
|
348
|
+
self.command = command
|
|
349
|
+
self.project = project
|
|
350
|
+
self.data = data
|
|
351
|
+
issues = self._with_sdk_divergence(command, project, issues, sdk)
|
|
352
|
+
self.issues = self._with_sdk_resolution_advisories(issues, sdk)
|
|
353
|
+
self.exit_code = int(exit_code)
|
|
354
|
+
self.sdk = sdk
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _with_sdk_resolution_advisories(issues, sdk):
|
|
358
|
+
"""Append tan-cli#464's pair -- `sdk.project-pin-unresolved` and
|
|
359
|
+
`sdk.global-default-foreign-project` -- from what the resolution
|
|
360
|
+
already recorded on `sdk`.
|
|
361
|
+
|
|
362
|
+
Same argument as `_with_sdk_divergence` below, applied to a second
|
|
363
|
+
fact: doing it at the one seam every envelope passes through, instead
|
|
364
|
+
of at each command, is what stops the 33rd command from forgetting.
|
|
365
|
+
Before this, 16 of ~32 commands hand-called `sdk_resolution_issues`
|
|
366
|
+
and the rest silently resolved another project's checkout -- `validate`
|
|
367
|
+
spawning ITS schema validator for this project's board.yaml, `trace`
|
|
368
|
+
printing ITS orchestrator path -- at `ok: true, issues: []`.
|
|
369
|
+
|
|
370
|
+
DEDUPED BY CODE, and that is load-bearing rather than defensive: the
|
|
371
|
+
hand-call sites still exist, and several fold these two into a
|
|
372
|
+
command-specific ORDER this must not disturb. A command that already
|
|
373
|
+
emitted the pair keeps its own copy and position; one that never did
|
|
374
|
+
gets it here.
|
|
375
|
+
|
|
376
|
+
Appends to a NEW list -- several commands keep rendering their own
|
|
377
|
+
text from the list they passed in.
|
|
378
|
+
|
|
379
|
+
`sdk_resolution_issues` is imported at module level, from
|
|
380
|
+
`tan.core.sdk_discovery` (tan-cli#408 review follow-up) -- it used to
|
|
381
|
+
live in `sdk_cmd`, which imports this module at module level, so a
|
|
382
|
+
top-level import here was circular; moving `resolve_sdk_tiered` and
|
|
383
|
+
its two `Issue` builders alongside the ladders is what makes this
|
|
384
|
+
importable at the top of this file at all.
|
|
385
|
+
"""
|
|
386
|
+
if sdk is None:
|
|
387
|
+
return issues
|
|
388
|
+
advisories = sdk_resolution_issues(
|
|
389
|
+
sdk.broken_project_pin, sdk.source_tier, sdk.foreign_global_default_for
|
|
390
|
+
)
|
|
391
|
+
if not advisories:
|
|
392
|
+
return issues
|
|
393
|
+
seen = {issue.code for issue in issues}
|
|
394
|
+
extra = [issue for issue in advisories if issue.code not in seen]
|
|
395
|
+
return [*issues, *extra] if extra else issues
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _with_sdk_divergence(command, project, issues, sdk):
|
|
399
|
+
"""Append the tan-cli#407 warning when the two SDK ladders would answer
|
|
400
|
+
DIFFERENT checkouts from this project root.
|
|
401
|
+
|
|
402
|
+
Done at the ONE seam every command's envelope passes through, not at
|
|
403
|
+
each of the 20 `SdkInfo(...)` construction sites: #407's whole
|
|
404
|
+
complaint is that the two ladders report the same `sourceTier`
|
|
405
|
+
("discovery") for two roots, and a fix present on `build` but missing
|
|
406
|
+
on the other sixteen commands would leave exactly the silence the
|
|
407
|
+
issue is about -- the vscode extension branches on `sourceTier` from
|
|
408
|
+
`generate`/`examples` (wide) AND `build`/`doctor`/`sdk current`
|
|
409
|
+
(narrow), so partial coverage still gives it two roots it cannot tell
|
|
410
|
+
apart.
|
|
411
|
+
|
|
412
|
+
Gated on `sourceTier == "discovery"` before anything touches the disk,
|
|
413
|
+
which makes this free in every normal layout: any HIGHER tier
|
|
414
|
+
(`--sdk-root`, the project pin, the global default) is shared verbatim
|
|
415
|
+
by both ladders and cannot be the pair that differs, so there is
|
|
416
|
+
nothing to compare. That gate is also why passing `sdk_root_arg=None`
|
|
417
|
+
below is exact rather than approximate -- a `--sdk-root` run reports
|
|
418
|
+
`sdkRootFlag`, never `discovery`, so it never reaches here.
|
|
419
|
+
|
|
420
|
+
Appends to a NEW list. Mutating the caller's would be a side effect on
|
|
421
|
+
an argument, and several commands keep rendering their own text from
|
|
422
|
+
the list they passed in.
|
|
423
|
+
|
|
424
|
+
`sdk_ladder_divergence_issue` is imported at module level, from
|
|
425
|
+
`tan.core.sdk_discovery` (tan-cli#408) -- it used to live in
|
|
426
|
+
`build_cmd`, which imports this module at module level, so a
|
|
427
|
+
top-level import here was circular; the extraction is what makes it
|
|
428
|
+
importable at the top of this file at all.
|
|
429
|
+
"""
|
|
430
|
+
if sdk is None or getattr(sdk, "source_tier", None) != "discovery":
|
|
431
|
+
return issues
|
|
432
|
+
# `project.root` is None for the commands that are not project-scoped
|
|
433
|
+
# -- `examples` and `sdk current` both report `{"root": null}` -- and
|
|
434
|
+
# those are exactly two of the commands #407 measured as divergent, so
|
|
435
|
+
# bailing on a null root would have left the reported collision
|
|
436
|
+
# unreported on the wide side. They resolved the SDK from the cwd, so
|
|
437
|
+
# that is the workspace root to ask about.
|
|
438
|
+
root = getattr(project, "root", None)
|
|
439
|
+
try:
|
|
440
|
+
# RESOLVED, never the raw `project.root`. Several commands report
|
|
441
|
+
# it as the relative `"."` (`validate` does), and `Path(".").parent`
|
|
442
|
+
# is `"."` -- so the ladders' lateral `../alp-sdk` candidate
|
|
443
|
+
# collapses onto the child `./alp-sdk` and a real divergence reads
|
|
444
|
+
# as agreement. Measured: from a cwd where `doctor` warned,
|
|
445
|
+
# `validate` did not, purely because of that one character.
|
|
446
|
+
start = Path(root).resolve() if root else Path.cwd()
|
|
447
|
+
|
|
448
|
+
divergence = sdk_ladder_divergence_issue(
|
|
449
|
+
None, start, wide=command in WIDE_LADDER_COMMANDS
|
|
450
|
+
)
|
|
451
|
+
except Exception: # noqa: BLE001
|
|
452
|
+
# A warning about ambiguity must never be the reason a command
|
|
453
|
+
# cannot report its actual result. Whatever the run was doing is
|
|
454
|
+
# more important than this advisory.
|
|
455
|
+
return issues
|
|
456
|
+
if divergence is None:
|
|
457
|
+
return issues
|
|
458
|
+
if any(getattr(i, "code", None) == divergence.code for i in issues):
|
|
459
|
+
return issues
|
|
460
|
+
return [*issues, divergence]
|
|
461
|
+
|
|
462
|
+
def _as_dict(self) -> dict[str, Any]:
|
|
463
|
+
out: dict[str, Any] = {
|
|
464
|
+
"command": self.command,
|
|
465
|
+
"ok": self.exit_code == 0,
|
|
466
|
+
"exitCode": self.exit_code,
|
|
467
|
+
"project": self.project.as_dict(),
|
|
468
|
+
}
|
|
469
|
+
# Absent, not null -- see test_sdk_key_is_absent_when_none_not_null.
|
|
470
|
+
if self.sdk is not None:
|
|
471
|
+
out["sdk"] = self.sdk.as_dict()
|
|
472
|
+
out["data"] = self.data
|
|
473
|
+
out["issues"] = [i.as_dict() for i in self.issues]
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
def to_json(self) -> str:
|
|
477
|
+
return self._serialise()[0]
|
|
478
|
+
|
|
479
|
+
def _serialise(self) -> tuple[str, int]:
|
|
480
|
+
"""The JSON text plus the exit code THAT TEXT actually reports.
|
|
481
|
+
|
|
482
|
+
The two differ only when serialisation itself fails: the fallback
|
|
483
|
+
below substitutes `ExitCode.INTERNAL_FAILURE` (5) for whatever
|
|
484
|
+
`self.exit_code` was, so a caller that wants the wire invariant
|
|
485
|
+
`process exit code == envelope.exitCode` to hold (tan-cli#327) must
|
|
486
|
+
read the code from HERE, not from `self.exit_code` -- `emit()` does
|
|
487
|
+
exactly that. `to_json()` stays a plain `str` return -- every existing
|
|
488
|
+
caller (`test_envelope.py`'s `json.loads(env.to_json())` assertions)
|
|
489
|
+
depends on that shape.
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
# `ensure_ascii=False`: the default (True) escapes every non-ASCII
|
|
493
|
+
# codepoint as `\uXXXX`, which is valid JSON but not what the
|
|
494
|
+
# oracle emits -- `serde_json::to_string` writes raw UTF-8 bytes
|
|
495
|
+
# verbatim (measured: `scaffold --name "Sensör Ölçüm"` on the
|
|
496
|
+
# Rust CLI puts the literal `Sensör Ölçüm` on the wire, not
|
|
497
|
+
# `Sensör...`). A consumer that byte-compares tan's envelope
|
|
498
|
+
# against the oracle's, or that greps stdout for a raw non-ASCII
|
|
499
|
+
# string, saw a divergence stdout never had a reason to carry.
|
|
500
|
+
#
|
|
501
|
+
# `json_safe_floats`: `Infinity`/`-Infinity`/`NaN` are Python's
|
|
502
|
+
# non-standard extension literals, not JSON (tan-cli#387). See that
|
|
503
|
+
# function for why this is a projection and not `allow_nan=False`.
|
|
504
|
+
#
|
|
505
|
+
# `_scrub_lone_surrogates`: `ensure_ascii=False` writes a lone
|
|
506
|
+
# surrogate straight through into the `str` this returns, and
|
|
507
|
+
# nothing downstream could recover from that -- the encode fails at
|
|
508
|
+
# `emit()`'s `print(text)`, one call AFTER this function has already
|
|
509
|
+
# reported success, so the `except` below (whose own comment says
|
|
510
|
+
# "no payload may ever crash stdout") never sees it and the process
|
|
511
|
+
# dies with ZERO bytes on stdout (tan-cli#491). Scrubbed HERE, on
|
|
512
|
+
# the finished document, so `to_json()` -- and therefore
|
|
513
|
+
# `tan.cli._usage_error_envelope`'s own `print` -- is covered by the
|
|
514
|
+
# same call.
|
|
515
|
+
return (
|
|
516
|
+
_scrub_lone_surrogates(
|
|
517
|
+
json.dumps(
|
|
518
|
+
json_safe_floats(self._as_dict()),
|
|
519
|
+
separators=(",", ":"),
|
|
520
|
+
ensure_ascii=False,
|
|
521
|
+
)
|
|
522
|
+
),
|
|
523
|
+
self.exit_code,
|
|
524
|
+
)
|
|
525
|
+
except Exception as err: # noqa: BLE001 -- no payload may ever crash stdout
|
|
526
|
+
fallback_code = int(ExitCode.INTERNAL_FAILURE)
|
|
527
|
+
# The WHOLE fallback, construction included, sits inside the guard
|
|
528
|
+
# below -- not just its `json.dumps`. Building it runs payload code
|
|
529
|
+
# in three places, and every one of them can raise: `SdkInfo.
|
|
530
|
+
# as_dict()` calls `self.root.replace(...)` (an `AttributeError` for
|
|
531
|
+
# a non-`str` root), the f-string below calls `err.__str__`, and
|
|
532
|
+
# `json.dumps` then re-encodes `project`/`sdk` verbatim. With only
|
|
533
|
+
# the `dumps` wrapped, the first two escaped `_serialise` entirely
|
|
534
|
+
# -- past `emit()`, past `to_json()`, out to a traceback and zero
|
|
535
|
+
# bytes on stdout, which is the exact shape tan-cli#491 is about.
|
|
536
|
+
try:
|
|
537
|
+
fallback = {
|
|
538
|
+
"command": self.command,
|
|
539
|
+
"ok": False,
|
|
540
|
+
"exitCode": fallback_code,
|
|
541
|
+
"project": self.project.as_dict(),
|
|
542
|
+
}
|
|
543
|
+
if self.sdk is not None:
|
|
544
|
+
fallback["sdk"] = self.sdk.as_dict()
|
|
545
|
+
fallback["data"] = None
|
|
546
|
+
fallback["issues"] = [
|
|
547
|
+
Issue(
|
|
548
|
+
"envelope.serialize-failed",
|
|
549
|
+
"error",
|
|
550
|
+
f"failed to serialize command output: {err}",
|
|
551
|
+
).as_dict()
|
|
552
|
+
]
|
|
553
|
+
# Scrubbed on this arm too: `self.project.as_dict()` is carried
|
|
554
|
+
# into the fallback verbatim, and `err` itself stringifies
|
|
555
|
+
# whatever the failed payload held -- either can carry the same
|
|
556
|
+
# lone surrogate, so the fallback that exists to keep stdout
|
|
557
|
+
# alive must not be the thing that kills it.
|
|
558
|
+
return (
|
|
559
|
+
_scrub_lone_surrogates(
|
|
560
|
+
json.dumps(fallback, separators=(",", ":"), ensure_ascii=False)
|
|
561
|
+
),
|
|
562
|
+
fallback_code,
|
|
563
|
+
)
|
|
564
|
+
except Exception as fallback_err: # noqa: BLE001 -- nor may THIS arm crash stdout
|
|
565
|
+
# Keeping this here (rather than only at `emit`) is what makes
|
|
566
|
+
# `to_json()` total as well -- and `to_json()` is what
|
|
567
|
+
# `tan.cli`'s `_usage_error_envelope`/`_interrupted_envelope`
|
|
568
|
+
# print. See `_last_resort_document`.
|
|
569
|
+
return _last_resort_document(self.command, fallback_err), fallback_code
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
#: Whether this process has already written its one envelope to stdout.
|
|
573
|
+
#: Process-global because the thing it guards is process-global: stdout.
|
|
574
|
+
_emitted = False
|
|
575
|
+
|
|
576
|
+
#: The exit code the LAST-emitted envelope's own JSON actually reports --
|
|
577
|
+
#: `None` until `emit()` runs once. See `envelope_emitted_exit_code()`.
|
|
578
|
+
_emitted_exit_code: int | None = None
|
|
579
|
+
|
|
580
|
+
#: Why `emit()`'s guard below catches `(ValueError, TypeError)` and not
|
|
581
|
+
#: `Exception` (tan-cli#491). Those are the ENCODE class: `UnicodeEncodeError`
|
|
582
|
+
#: is a `UnicodeError` is a `ValueError`, and a codec handed something it
|
|
583
|
+
#: cannot accept raises `TypeError`. They fire BEFORE any byte leaves the
|
|
584
|
+
#: stream, because `TextIOWrapper.write` encodes the whole string before
|
|
585
|
+
#: writing any of it -- measured on an ascii-encoded stream, the buffer is
|
|
586
|
+
#: empty after the raise and the ASCII document then lands as the only thing on
|
|
587
|
+
#: it. Recovering there is safe: stdout is still empty.
|
|
588
|
+
#:
|
|
589
|
+
#: An `OSError` is deliberately NOT caught. Measured on a
|
|
590
|
+
#: `TextIOWrapper`->`BufferedWriter(4096)`->raw stream that accepts one block
|
|
591
|
+
#: and then fails, given a 20 KB envelope: 4096 bytes of a TRUNCATED first
|
|
592
|
+
#: document are already on the wire. Appending the last-resort document to that
|
|
593
|
+
#: would report a clean `envelope.serialize-failed` over stdout no consumer can
|
|
594
|
+
#: parse -- worse than the raise, which at least claims nothing. So an I/O
|
|
595
|
+
#: failure propagates exactly as it did before this guard existed, and
|
|
596
|
+
#: `_emitted` stays `False`.
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def emit(envelope: Envelope) -> int:
|
|
600
|
+
"""Write THE envelope to stdout, record that it went out, and return the
|
|
601
|
+
exit code that JSON just reported.
|
|
602
|
+
|
|
603
|
+
A command signals failure by exiting non-zero, and `tan.cli.main` wraps
|
|
604
|
+
the whole dispatch to add a `cli.parse-error` envelope when a `--format
|
|
605
|
+
json` run exits non-zero with nothing on stdout -- the Click-usage-error
|
|
606
|
+
path, which never reaches a command at all. Without this flag that
|
|
607
|
+
fallback also fires after a command that ALREADY printed its own
|
|
608
|
+
envelope, putting two JSON documents on stdout: valid JSON lines, and a
|
|
609
|
+
consumer that parses stdout whole gets neither.
|
|
610
|
+
|
|
611
|
+
The returned code (also stashed in `_emitted_exit_code`, read back by
|
|
612
|
+
`envelope_emitted_exit_code()`) is `envelope.exit_code` UNLESS
|
|
613
|
+
serialisation itself fell back to `envelope.serialize-failed` -- in which
|
|
614
|
+
case it is the fallback's `ExitCode.INTERNAL_FAILURE` (5), not the
|
|
615
|
+
command's original code. tan-cli#327: a caller that already committed to
|
|
616
|
+
its own `typer.Exit(<original code>)` before calling this needs a way to
|
|
617
|
+
learn the fallback happened; `tan.cli.main`'s process boundary is that
|
|
618
|
+
caller, so the wire invariant `process exit code == envelope.exitCode`
|
|
619
|
+
holds even when serialisation fails.
|
|
620
|
+
|
|
621
|
+
Same for the WRITE failing (tan-cli#491): the last-resort document below
|
|
622
|
+
reports 5 too, and this returns 5, so a command that had already committed
|
|
623
|
+
to `typer.Exit(0)` still exits 5 to match the JSON stdout actually carries.
|
|
624
|
+
"""
|
|
625
|
+
global _emitted, _emitted_exit_code
|
|
626
|
+
try:
|
|
627
|
+
text, exit_code = envelope._serialise()
|
|
628
|
+
print(text)
|
|
629
|
+
except (ValueError, TypeError) as err: # the ENCODE class only -- see above
|
|
630
|
+
exit_code = int(ExitCode.INTERNAL_FAILURE)
|
|
631
|
+
print(_last_resort_document(envelope.command, err))
|
|
632
|
+
_emitted = True
|
|
633
|
+
_emitted_exit_code = exit_code
|
|
634
|
+
return exit_code
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def envelope_emitted() -> bool:
|
|
638
|
+
return _emitted
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def envelope_emitted_exit_code() -> int | None:
|
|
642
|
+
"""The exit code the one emitted envelope's JSON reports, or `None` if
|
|
643
|
+
nothing has been emitted yet this process. `tan.cli.main` reads this at
|
|
644
|
+
the process boundary to catch a serialize-failure fallback that changed
|
|
645
|
+
the reported code out from under a command's own `typer.Exit` (tan-cli#327)
|
|
646
|
+
-- carried directly from `emit()`, never re-derived by parsing stdout back."""
|
|
647
|
+
return _emitted_exit_code
|
tan/exit_codes.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Stable process exit codes, fixed by the CLI output contract."""
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExitCode(IntEnum):
|
|
7
|
+
SUCCESS = 0
|
|
8
|
+
RUNTIME_FAILURE = 1
|
|
9
|
+
VALIDATION_FAILURE = 2
|
|
10
|
+
WRITE_FAILURE = 3
|
|
11
|
+
DOCTOR_FAILURE = 4
|
|
12
|
+
INTERNAL_FAILURE = 5
|