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
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`tan image` -- assemble a flashable-image bundle from
|
|
3
|
+
`build/system-manifest.yaml`.
|
|
4
|
+
|
|
5
|
+
Port of `crates/tan-cli/src/commands/image.rs`: the IO half. tar+gzip each built
|
|
6
|
+
slice's `build_dir` into `image-bundle/slices/<core>-<os>.tar.gz`, copy each
|
|
7
|
+
helper-MCU firmware into `image-bundle/helper-mcus/`, and write
|
|
8
|
+
`image-bundle/bundle-manifest.json`. Every non-IO decision -- bundle shape, key
|
|
9
|
+
order, forward-slash artefact paths, the ok-slice gate, the path guard -- is pure
|
|
10
|
+
in `tan.core.image_bundle`.
|
|
11
|
+
|
|
12
|
+
**Nothing here shells the SDK, and nothing here needs one.** The main input is a
|
|
13
|
+
file this project's own build wrote (I-32 / anti-pattern #22); the one exception
|
|
14
|
+
is a helper's `firmware_path` (below), which reads a real file straight out of
|
|
15
|
+
an already-resolved SDK checkout when the build tree does not have it. No `sdk`
|
|
16
|
+
key is emitted unless the shared project resolver happened to resolve a
|
|
17
|
+
checkout, which is exactly what the oracle reports.
|
|
18
|
+
|
|
19
|
+
Deliberate divergences from the retired `west alp-image`, carried over from the
|
|
20
|
+
Rust and re-verified against the binary:
|
|
21
|
+
- the helper entry carries `chip`, not the perpetually-`null` `role`;
|
|
22
|
+
- artefact paths are forward-slash, always (the Python `\\` on Windows was a
|
|
23
|
+
latent cross-platform bug in the MACHINE contract);
|
|
24
|
+
- an IO failure is WriteFailure(3), not a traceback at rc 1;
|
|
25
|
+
- a helper's DECLARED, concrete `firmware_path` that does not resolve to a file
|
|
26
|
+
is a HARD error, not a silent skip: a consumer keying on `ok`/exit code must
|
|
27
|
+
not see a "complete" bundle missing a promised artefact. The `TBD` pending
|
|
28
|
+
sentinel and an absent `firmware_path` stay non-fatal skips.
|
|
29
|
+
|
|
30
|
+
A relative `firmware_path` resolves against **build_root, then sdk_root**
|
|
31
|
+
(`tan.core.image_bundle.helper_firmware_candidates`) -- alp-sdk#330: the SDK's
|
|
32
|
+
`som-preset-v2.schema.json` defines it repository-relative, i.e. relative to the
|
|
33
|
+
SDK checkout, not to this project's `build/`, so build-root-only resolution
|
|
34
|
+
(the pre-#330 behaviour) rejected every helper an SDK actually ships. build_root
|
|
35
|
+
still goes first, matching the precedence `flash_plan.resolve_artefact_path`
|
|
36
|
+
already established for the same two-roots ambiguity (#301, #322): a helper
|
|
37
|
+
artefact THIS build produced under `build/` wins over a same-named file the SDK
|
|
38
|
+
happens to ship. When neither root has the file, the `image.helper-missing`
|
|
39
|
+
error names every root tried and the absolute path each one produced, not just
|
|
40
|
+
the raw manifest string -- the old message left a reporter to go find the file
|
|
41
|
+
by hand to prove it existed.
|
|
42
|
+
|
|
43
|
+
The one divergence from the ORACLE is the archive bytes: Python's `tarfile` +
|
|
44
|
+
`gzip` cannot produce the Rust `tar`+`flate2` stream byte-for-byte (member order,
|
|
45
|
+
header fields, gzip metadata), so `slices[].sha256`/`size` differ. The contract
|
|
46
|
+
those fields have to satisfy is self-consistency -- each hash matches the bytes
|
|
47
|
+
this run actually wrote at that artefact path -- and
|
|
48
|
+
`tests/commands/test_image_command.py` asserts exactly that. Helper firmwares are
|
|
49
|
+
plain copies and DO hash identically to the oracle.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import hashlib
|
|
55
|
+
import json
|
|
56
|
+
import os
|
|
57
|
+
import shutil
|
|
58
|
+
import tarfile
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
from typing import Any
|
|
61
|
+
|
|
62
|
+
import typer
|
|
63
|
+
|
|
64
|
+
from tan.core.dir_removal import is_link
|
|
65
|
+
from tan.core.sdk_discovery import with_sdk_search
|
|
66
|
+
from tan.core.shapes import is_dir as _is_dir, is_file as _is_file
|
|
67
|
+
from tan.core.system_manifest import effective_build_root
|
|
68
|
+
from tan.commands.build_output import (
|
|
69
|
+
ManifestInvalid,
|
|
70
|
+
ManifestUnavailable,
|
|
71
|
+
ProjectContext,
|
|
72
|
+
load_manifest,
|
|
73
|
+
resolve_app_base,
|
|
74
|
+
resolve_build_root,
|
|
75
|
+
resolve_project_context,
|
|
76
|
+
)
|
|
77
|
+
from tan.core.global_flags import accept_global_flags
|
|
78
|
+
from tan.core.image_bundle import (
|
|
79
|
+
BUNDLE_DIR,
|
|
80
|
+
BUNDLE_MANIFEST,
|
|
81
|
+
HELPERS_DIR,
|
|
82
|
+
PENDING_SENTINEL,
|
|
83
|
+
SLICES_DIR,
|
|
84
|
+
assemble_bundle_manifest,
|
|
85
|
+
helper_artefact_rel,
|
|
86
|
+
helper_entry,
|
|
87
|
+
helper_firmware_candidates,
|
|
88
|
+
slice_archive_name,
|
|
89
|
+
slice_artefact_rel,
|
|
90
|
+
slice_entry,
|
|
91
|
+
slice_should_bundle,
|
|
92
|
+
)
|
|
93
|
+
from tan.core.sdk_discovery import sdk_resolution_issues
|
|
94
|
+
from tan.core.system_manifest import (
|
|
95
|
+
SystemManifest,
|
|
96
|
+
raw_passthrough,
|
|
97
|
+
slice_build_dir,
|
|
98
|
+
)
|
|
99
|
+
from tan.envelope import Envelope, Issue, Project, SdkDisclosure, SdkInfo, emit, json_safe_floats
|
|
100
|
+
from tan.exit_codes import ExitCode
|
|
101
|
+
from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
|
|
102
|
+
|
|
103
|
+
#: Streaming read size for the SHA-256, matching the oracle's 65536-byte buffer.
|
|
104
|
+
_HASH_CHUNK = 65536
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BundleWriteError(Exception):
|
|
108
|
+
"""A bundle-assembly IO failure -- `image.bundle-write-failed` at exit 3."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, message: str) -> None:
|
|
111
|
+
super().__init__(message)
|
|
112
|
+
self.message = message
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class _Notice:
|
|
117
|
+
"""A slice or helper item left OUT of the bundle. Carries its own issue code
|
|
118
|
+
so `--format json` reports it as an `Issue`, not only as a text line: before
|
|
119
|
+
that, an incomplete bundle was indistinguishable from a complete one in the
|
|
120
|
+
mode the extension always uses.
|
|
121
|
+
|
|
122
|
+
`severity` is `warning` for a legitimate, already-accounted-for gap (no
|
|
123
|
+
build_dir yet, an unsafe core_id/os, a `TBD` helper). The one `error` case is
|
|
124
|
+
a helper's declared, concrete `firmware_path` that is not a file -- and that
|
|
125
|
+
flips the exit/`ok`, even though assembly itself completed.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
code: str
|
|
129
|
+
severity: str
|
|
130
|
+
message: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class _Outcome:
|
|
135
|
+
exit_code: ExitCode
|
|
136
|
+
data: dict[str, Any]
|
|
137
|
+
project: Project
|
|
138
|
+
issues: list[Issue]
|
|
139
|
+
text: list[str]
|
|
140
|
+
sdk: SdkInfo | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _sha256_and_size(path: str) -> tuple[str, int]:
|
|
144
|
+
"""Streaming lowercase-hex SHA-256 + byte length. Raises
|
|
145
|
+
[`BundleWriteError`] rather than an OSError, so no read failure can escape
|
|
146
|
+
the error contract."""
|
|
147
|
+
digest = hashlib.sha256()
|
|
148
|
+
try:
|
|
149
|
+
with open(path, "rb") as handle:
|
|
150
|
+
while True:
|
|
151
|
+
chunk = handle.read(_HASH_CHUNK)
|
|
152
|
+
if not chunk:
|
|
153
|
+
break
|
|
154
|
+
digest.update(chunk)
|
|
155
|
+
size = os.stat(path).st_size
|
|
156
|
+
except OSError as err:
|
|
157
|
+
raise BundleWriteError(f"hash {path}: {err}") from err
|
|
158
|
+
return digest.hexdigest(), size
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _basename(path: str) -> str:
|
|
162
|
+
"""`Path::file_name()`: the last component, with any trailing separator
|
|
163
|
+
ignored (`os.path.basename` returns `""` there, Rust's `file_name` does not)
|
|
164
|
+
and `/` honoured as a separator on Windows too."""
|
|
165
|
+
return os.path.basename(path.replace("\\", "/").rstrip("/"))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _tar_gzip_dir(src_dir: str, dest: str) -> None:
|
|
169
|
+
"""Recursively tar+gzip `src_dir` into `dest`, tar root arcname =
|
|
170
|
+
basename(src_dir). Any existing archive is unlinked first, matching the
|
|
171
|
+
oracle (and so a half-written archive from a previous run can never be
|
|
172
|
+
appended to)."""
|
|
173
|
+
try:
|
|
174
|
+
if os.path.lexists(dest):
|
|
175
|
+
os.remove(dest)
|
|
176
|
+
except OSError as err:
|
|
177
|
+
raise BundleWriteError(f"unlink {dest}: {err}") from err
|
|
178
|
+
arcname = _basename(src_dir) or "build"
|
|
179
|
+
try:
|
|
180
|
+
with tarfile.open(dest, "w:gz") as archive:
|
|
181
|
+
archive.add(src_dir, arcname=arcname)
|
|
182
|
+
except (OSError, tarfile.TarError) as err:
|
|
183
|
+
raise BundleWriteError(f"tar {src_dir}: {err}") from err
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _bundle_slice(
|
|
187
|
+
slice_: dict, build_root: str, slices_dir: str
|
|
188
|
+
) -> dict[str, Any] | _Notice:
|
|
189
|
+
"""Tar+gzip one ok slice, or a notice saying why it was left out."""
|
|
190
|
+
core_id = str(slice_.get("core_id"))
|
|
191
|
+
os_name = str(slice_.get("os"))
|
|
192
|
+
build_dir = slice_build_dir(slice_, build_root)
|
|
193
|
+
if build_dir is None or not _is_dir(build_dir):
|
|
194
|
+
return _Notice(
|
|
195
|
+
"image.slice-skipped",
|
|
196
|
+
"warning",
|
|
197
|
+
f"image: skipping {core_id} (build_dir missing)",
|
|
198
|
+
)
|
|
199
|
+
# The shared seam that rejects a `core_id`/`os` shaped like `../../x` or
|
|
200
|
+
# `C:/x`: without it, joining onto `slices_dir` escaped the bundle dir and the
|
|
201
|
+
# unlink above could remove an arbitrary `*.tar.gz` outside the build tree.
|
|
202
|
+
archive_name = slice_archive_name(core_id, os_name)
|
|
203
|
+
if archive_name is None:
|
|
204
|
+
return _Notice(
|
|
205
|
+
"image.slice-unsafe-name",
|
|
206
|
+
"warning",
|
|
207
|
+
f"image: skipping {core_id} (core_id/os is not a safe archive name)",
|
|
208
|
+
)
|
|
209
|
+
archive = os.path.join(slices_dir, archive_name)
|
|
210
|
+
_tar_gzip_dir(build_dir, archive)
|
|
211
|
+
sha256, size = _sha256_and_size(archive)
|
|
212
|
+
artefact = slice_artefact_rel(core_id, os_name)
|
|
213
|
+
assert artefact is not None # already validated by slice_archive_name above
|
|
214
|
+
return slice_entry(core_id, os_name, artefact, sha256, size)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _unresolved_sdk_clause(sdk_root_arg: str | None, workspace_root: str | None = None) -> str:
|
|
218
|
+
"""The trailing "why was there no sdk_root" clause of an
|
|
219
|
+
`image.helper-missing` message.
|
|
220
|
+
|
|
221
|
+
tan-cli#497 defect 6: the message hardcoded "no --sdk-root and no
|
|
222
|
+
discoverable checkout" whenever nothing resolved -- including when
|
|
223
|
+
`--sdk-root` WAS supplied and failed the loader-marker check, because
|
|
224
|
+
`resolve_project_context` returns `None` for a supplied-but-invalid flag
|
|
225
|
+
exactly as it does for an absent one. So `tan image --sdk-root
|
|
226
|
+
/definitely/not/a/checkout` told the reporter to pass the flag they had
|
|
227
|
+
just typed, on a hard-error path (exit 1) that refuses to produce the
|
|
228
|
+
bundle -- sending them to look for a missing flag instead of at the typo
|
|
229
|
+
in the path they gave.
|
|
230
|
+
|
|
231
|
+
Entirely inside the `(tried ...)` clause that `tests/parity/
|
|
232
|
+
test_image_size_oracle.py` stripped from BOTH modes as a declared
|
|
233
|
+
alp-sdk#330 divergence (the frozen oracle has no `sdk_root` fallback and
|
|
234
|
+
so no such clause at all). That module went with the oracle axis in
|
|
235
|
+
tan-cli#269, so no parity CASE diffs it any more -- but BOTH branches of
|
|
236
|
+
the string below are asserted verbatim by
|
|
237
|
+
`tests/commands/test_image_command.py`'s
|
|
238
|
+
`test_a_rejected_sdk_root_flag_is_named_not_reported_as_absent` and
|
|
239
|
+
`test_an_absent_sdk_root_flag_still_says_so`, so a reword lands there too.
|
|
240
|
+
"""
|
|
241
|
+
if sdk_root_arg is None:
|
|
242
|
+
clause = "sdk root not resolved (no --sdk-root and no discoverable checkout)"
|
|
243
|
+
# tan-cli#1463: name where the ladder looked, like every other
|
|
244
|
+
# SDK-root refusal.
|
|
245
|
+
return with_sdk_search(clause, workspace_root) if workspace_root else clause
|
|
246
|
+
return (
|
|
247
|
+
f'sdk root not resolved (--sdk-root "{sdk_root_arg}" is not an alp-sdk '
|
|
248
|
+
f"checkout)"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _bundle_helper(
|
|
253
|
+
helper: dict,
|
|
254
|
+
build_root: str,
|
|
255
|
+
sdk_root: str | None,
|
|
256
|
+
helpers_dir: str,
|
|
257
|
+
used_names: set[str],
|
|
258
|
+
sdk_root_arg: str | None = None,
|
|
259
|
+
workspace_root: str | None = None,
|
|
260
|
+
) -> dict[str, Any] | _Notice | None:
|
|
261
|
+
"""Copy one helper's firmware, or a notice, or `None` for an absent one.
|
|
262
|
+
|
|
263
|
+
`sdk_root_arg` is the RAW `--sdk-root` the caller typed, carried only so
|
|
264
|
+
the unresolved-sdk clause can tell "you passed nothing" apart from "what
|
|
265
|
+
you passed is not a checkout" (tan-cli#497 defect 6); `sdk_root` remains
|
|
266
|
+
the RESOLVED root, and is the only one any path operation uses.
|
|
267
|
+
|
|
268
|
+
`used_names` tracks every destination basename already claimed this run:
|
|
269
|
+
Zephyr's default layout puts EVERY helper's firmware at the same basename
|
|
270
|
+
(`zephyr.bin`), flattened from different `firmware_path`s. Without it, the
|
|
271
|
+
second helper's copy silently overwrote the first's file and the first's
|
|
272
|
+
already-recorded `sha256` pointed at bytes that were no longer there.
|
|
273
|
+
"""
|
|
274
|
+
raw = helper.get("firmware_path")
|
|
275
|
+
if not isinstance(raw, str) or raw == "":
|
|
276
|
+
return None
|
|
277
|
+
if raw.strip() == PENDING_SENTINEL:
|
|
278
|
+
# The documented not-yet-built placeholder: a legitimate state, so a
|
|
279
|
+
# warning, never the error a genuinely missing concrete path gets.
|
|
280
|
+
return _Notice(
|
|
281
|
+
"image.helper-skipped",
|
|
282
|
+
"warning",
|
|
283
|
+
f"image: helper-mcu firmware not found at {raw}; skipping",
|
|
284
|
+
)
|
|
285
|
+
# Two roots, tried in order -- see `helper_firmware_candidates` for why
|
|
286
|
+
# build_root goes first and sdk_root is the fallback that resolves a
|
|
287
|
+
# genuinely SDK-shipped, repository-relative firmware_path (alp-sdk#330).
|
|
288
|
+
candidates = helper_firmware_candidates(raw, build_root, sdk_root)
|
|
289
|
+
firmware = next((path for _, path in candidates if _is_file(path)), None)
|
|
290
|
+
if firmware is None:
|
|
291
|
+
tried = "; ".join(f"{label} {path}" for label, path in candidates)
|
|
292
|
+
if sdk_root is None and not os.path.isabs(raw):
|
|
293
|
+
tried += f"; {_unresolved_sdk_clause(sdk_root_arg, workspace_root)}"
|
|
294
|
+
return _Notice(
|
|
295
|
+
"image.helper-missing",
|
|
296
|
+
"error",
|
|
297
|
+
f"image: helper-mcu firmware not found at {raw} (tried {tried}); "
|
|
298
|
+
"refusing to produce an incomplete bundle",
|
|
299
|
+
)
|
|
300
|
+
basename = _basename(firmware) or "firmware.bin"
|
|
301
|
+
dest_name = basename
|
|
302
|
+
counter = 1
|
|
303
|
+
while dest_name in used_names:
|
|
304
|
+
counter += 1
|
|
305
|
+
dest_name = f"{counter}-{basename}"
|
|
306
|
+
used_names.add(dest_name)
|
|
307
|
+
|
|
308
|
+
destination = os.path.join(helpers_dir, dest_name)
|
|
309
|
+
try:
|
|
310
|
+
_copy_file(firmware, destination)
|
|
311
|
+
except OSError as err:
|
|
312
|
+
raise BundleWriteError(f"copy {firmware} -> {destination}: {err}") from err
|
|
313
|
+
sha256, size = _sha256_and_size(destination)
|
|
314
|
+
name = helper.get("name")
|
|
315
|
+
chip = helper.get("chip")
|
|
316
|
+
return helper_entry(
|
|
317
|
+
name if isinstance(name, str) else None,
|
|
318
|
+
chip if isinstance(chip, str) else None,
|
|
319
|
+
helper_artefact_rel(dest_name),
|
|
320
|
+
sha256,
|
|
321
|
+
size,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _copy_file(src: str, dst: str) -> None:
|
|
326
|
+
"""Byte copy, streamed. `shutil.copy` is avoided deliberately: it also copies
|
|
327
|
+
permission bits, and on a read-only source firmware that leaves the bundle
|
|
328
|
+
copy unwritable, so the NEXT `tan image` run fails to overwrite it."""
|
|
329
|
+
with open(src, "rb") as reader, open(dst, "wb") as writer:
|
|
330
|
+
while True:
|
|
331
|
+
chunk = reader.read(_HASH_CHUNK)
|
|
332
|
+
if not chunk:
|
|
333
|
+
break
|
|
334
|
+
writer.write(chunk)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _prune_dir_contents(directory: str) -> None:
|
|
338
|
+
"""Remove every entry directly inside `directory` (links unlinked, never
|
|
339
|
+
followed), leaving the directory itself."""
|
|
340
|
+
try:
|
|
341
|
+
entries = os.listdir(directory)
|
|
342
|
+
except OSError as err:
|
|
343
|
+
raise BundleWriteError(f"list {directory}: {err}") from err
|
|
344
|
+
for name in entries:
|
|
345
|
+
entry = os.path.join(directory, name)
|
|
346
|
+
try:
|
|
347
|
+
if os.path.isdir(entry) and not os.path.islink(entry):
|
|
348
|
+
shutil.rmtree(entry)
|
|
349
|
+
else:
|
|
350
|
+
os.remove(entry)
|
|
351
|
+
except OSError as err:
|
|
352
|
+
raise BundleWriteError(f"remove stale {entry}: {err}") from err
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _assemble_bundle(
|
|
356
|
+
build_root: str,
|
|
357
|
+
sdk_root: str | None,
|
|
358
|
+
manifest: SystemManifest,
|
|
359
|
+
yaml_text: str,
|
|
360
|
+
sdk_root_arg: str | None = None,
|
|
361
|
+
workspace_root: str | None = None,
|
|
362
|
+
) -> tuple[list[_Notice], dict[str, Any], str]:
|
|
363
|
+
"""Do the filesystem work: mkdir the bundle tree, tar each ok slice, copy each
|
|
364
|
+
present helper firmware, write `bundle-manifest.json`."""
|
|
365
|
+
bundle_dir = os.path.join(build_root, BUNDLE_DIR)
|
|
366
|
+
slices_dir = os.path.join(bundle_dir, SLICES_DIR)
|
|
367
|
+
helpers_dir = os.path.join(bundle_dir, HELPERS_DIR)
|
|
368
|
+
for directory in (bundle_dir, slices_dir, helpers_dir):
|
|
369
|
+
# `makedirs(exist_ok=True)` accepts a symlink to a directory, and the
|
|
370
|
+
# prune below would then empty its out-of-tree target. Unlink the link
|
|
371
|
+
# and recreate a real directory.
|
|
372
|
+
if directory != bundle_dir and is_link(directory):
|
|
373
|
+
try:
|
|
374
|
+
os.unlink(directory)
|
|
375
|
+
except OSError:
|
|
376
|
+
try:
|
|
377
|
+
os.rmdir(directory)
|
|
378
|
+
except OSError as err:
|
|
379
|
+
raise BundleWriteError(f"unlink {directory}: {err}") from err
|
|
380
|
+
try:
|
|
381
|
+
os.makedirs(directory, exist_ok=True)
|
|
382
|
+
except OSError as err:
|
|
383
|
+
raise BundleWriteError(f"mkdir {directory}: {err}") from err
|
|
384
|
+
# tan-cli#1482: this run's bundle is what the directory holds -- archives and
|
|
385
|
+
# helper firmware left by an EARLIER run (a slice that has since failed or
|
|
386
|
+
# been skipped, a helper dropped from the manifest) are pruned so a consumer
|
|
387
|
+
# that ships the directory never gets firmware this build did not produce.
|
|
388
|
+
# tan-cli#1521: the previous bundle-manifest.json goes FIRST. It names the
|
|
389
|
+
# archives pruned below, so a run that dies mid-assembly would otherwise
|
|
390
|
+
# leave a manifest pointing at deleted or re-hashed archives.
|
|
391
|
+
stale_manifest = os.path.join(bundle_dir, BUNDLE_MANIFEST)
|
|
392
|
+
try:
|
|
393
|
+
os.remove(stale_manifest)
|
|
394
|
+
except FileNotFoundError:
|
|
395
|
+
pass
|
|
396
|
+
except OSError as err:
|
|
397
|
+
raise BundleWriteError(f"remove stale {stale_manifest}: {err}") from err
|
|
398
|
+
for directory in (slices_dir, helpers_dir):
|
|
399
|
+
_prune_dir_contents(directory)
|
|
400
|
+
|
|
401
|
+
notices: list[_Notice] = []
|
|
402
|
+
slice_entries: list[dict[str, Any]] = []
|
|
403
|
+
for slice_ in manifest.slices:
|
|
404
|
+
status = slice_.get("status")
|
|
405
|
+
if not slice_should_bundle(status):
|
|
406
|
+
# tan-cli#499 defect 1: a bare `continue` here -- the ONE exclusion
|
|
407
|
+
# in this function that reported nothing, while `build_dir`-missing,
|
|
408
|
+
# unsafe-name and the `TBD` helper all emit a notice. Reachable
|
|
409
|
+
# from a GREEN `tan build`: a slice skipped under executionPolicy
|
|
410
|
+
# (no `west`/`bitbake`) is written as `status: skipped` and
|
|
411
|
+
# `build_cmd` keeps a PARTIAL build at ok:true/exit 0, so `tan
|
|
412
|
+
# image` answered `ok:true`, `exitCode:0`, `issues:[]` and wrote a
|
|
413
|
+
# `bundle-manifest.json` whose `boot_order` still named a core
|
|
414
|
+
# `slices[]` carried no artefact for -- a release/OTA consumer
|
|
415
|
+
# keying on `ok`/`issues[]` ships a bundle missing a core's
|
|
416
|
+
# firmware.
|
|
417
|
+
#
|
|
418
|
+
# A DELIBERATE oracle divergence, measured before diverging:
|
|
419
|
+
#
|
|
420
|
+
# $ target/debug/tan image --format json --build-root build # tan 0.4.1
|
|
421
|
+
# ...,"slices":[{"core_id":"m55_hp",...}],
|
|
422
|
+
# "boot_order":[{"core":"m55_hp"},{"core":"a55_0"}]},"issues":[]}
|
|
423
|
+
#
|
|
424
|
+
# i.e. the frozen oracle drops it silently too (`image.rs`'s bare
|
|
425
|
+
# `continue`). Warning severity, not an exit-code flip: this is
|
|
426
|
+
# the same "already-accounted-for gap" class `_Notice` documents,
|
|
427
|
+
# and a `failed` status has already exited non-zero at `tan build`.
|
|
428
|
+
#
|
|
429
|
+
# Only a DECLARED status reports -- the same narrowing `size`'s
|
|
430
|
+
# twin guard applies, for the same reason: a manifest that omits
|
|
431
|
+
# `status` says nothing about whether the slice built, and this
|
|
432
|
+
# module's reader is "deliberately tolerant, not a validator". It
|
|
433
|
+
# also confines the divergence to the shape the defect actually
|
|
434
|
+
# has (`tan build` always writes a `status`) instead of every
|
|
435
|
+
# hand-written manifest in the parity corpus.
|
|
436
|
+
if isinstance(status, str):
|
|
437
|
+
notices.append(
|
|
438
|
+
_Notice(
|
|
439
|
+
"image.slice-skipped",
|
|
440
|
+
"warning",
|
|
441
|
+
f"image: skipping {slice_.get('core_id')} (status: {status})",
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
continue
|
|
445
|
+
result = _bundle_slice(slice_, build_root, slices_dir)
|
|
446
|
+
if isinstance(result, _Notice):
|
|
447
|
+
notices.append(result)
|
|
448
|
+
else:
|
|
449
|
+
slice_entries.append(result)
|
|
450
|
+
|
|
451
|
+
helper_entries: list[dict[str, Any]] = []
|
|
452
|
+
used_names: set[str] = set()
|
|
453
|
+
for helper in manifest.helper_mcus:
|
|
454
|
+
result = _bundle_helper(
|
|
455
|
+
helper, build_root, sdk_root, helpers_dir, used_names, sdk_root_arg, workspace_root
|
|
456
|
+
)
|
|
457
|
+
if result is None:
|
|
458
|
+
continue
|
|
459
|
+
if isinstance(result, _Notice):
|
|
460
|
+
notices.append(result)
|
|
461
|
+
else:
|
|
462
|
+
helper_entries.append(result)
|
|
463
|
+
|
|
464
|
+
hw_info, boot_order = raw_passthrough(yaml_text)
|
|
465
|
+
bundle = assemble_bundle_manifest(hw_info, boot_order, slice_entries, helper_entries)
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
# `indent=2` + serde_json's `to_string_pretty` separators, and exactly one
|
|
469
|
+
# trailing newline written with `newline=""` so a Windows host cannot
|
|
470
|
+
# silently rewrite the file to CRLF (I-27).
|
|
471
|
+
#
|
|
472
|
+
# `json_safe_floats` for the same reason the envelope applies it, and it
|
|
473
|
+
# matters MORE here (tan-cli#387): this is a persisted artefact that
|
|
474
|
+
# downstream flashing/OTA tooling parses long after the run that wrote
|
|
475
|
+
# it. A `.inf` in `hw_info` used to leave `Infinity` on disk inside a
|
|
476
|
+
# bundle tan had just reported as successfully assembled, so every
|
|
477
|
+
# strict consumer of that file failed with nothing in the envelope
|
|
478
|
+
# saying why. It cannot be shared with the envelope's own call -- this
|
|
479
|
+
# document is written before `data` is built, and the two go through
|
|
480
|
+
# different `json.dumps` calls.
|
|
481
|
+
document = json.dumps(json_safe_floats(bundle), indent=2) + "\n"
|
|
482
|
+
except (TypeError, ValueError) as err:
|
|
483
|
+
# `hw_info` is verbatim YAML: a mapping key JSON cannot express reaches
|
|
484
|
+
# here. The oracle hits the same wall in `Envelope::to_json` and reports
|
|
485
|
+
# it as an issue rather than aborting; this reports it as a write failure,
|
|
486
|
+
# which is where the failure actually is.
|
|
487
|
+
raise BundleWriteError(f"serialize {BUNDLE_MANIFEST}: {err}") from err
|
|
488
|
+
manifest_out = os.path.join(bundle_dir, BUNDLE_MANIFEST)
|
|
489
|
+
try:
|
|
490
|
+
with open(manifest_out, "w", encoding="utf-8", newline="") as handle:
|
|
491
|
+
handle.write(document)
|
|
492
|
+
except OSError as err:
|
|
493
|
+
raise BundleWriteError(f"write {manifest_out}: {err}") from err
|
|
494
|
+
|
|
495
|
+
return notices, bundle, bundle_dir
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _empty_bundle() -> dict[str, Any]:
|
|
499
|
+
"""An empty bundle manifest, so the envelope's `data` shape stays stable on
|
|
500
|
+
every failure path."""
|
|
501
|
+
return assemble_bundle_manifest({}, [], [], [])
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _sdk_warning_lines(issues: list[Issue]) -> list[str]:
|
|
505
|
+
"""The text-mode rendering of the `sdk_resolution_issues` pair
|
|
506
|
+
(tan-cli#497 defect 5). `{severity}: {message}` -- the shape `tan build`
|
|
507
|
+
and `tan run` already print a resolution warning with, so the same
|
|
508
|
+
workspace reads the same way whichever command a developer runs.
|
|
509
|
+
|
|
510
|
+
Nothing pins the previous silence: `test_text_mode_writes_nothing_to_
|
|
511
|
+
stdout_and_always_names_the_bundle` asserts only containment, and the
|
|
512
|
+
oracle text-parity cases redirect HOME and set no `.alp/sdk-path`, so
|
|
513
|
+
neither warning can fire there."""
|
|
514
|
+
return [f"{issue.severity}: {issue.message}" for issue in issues]
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _error_outcome(
|
|
518
|
+
project: Project,
|
|
519
|
+
context: ProjectContext,
|
|
520
|
+
exit_code: ExitCode,
|
|
521
|
+
code: str,
|
|
522
|
+
message: str,
|
|
523
|
+
) -> _Outcome:
|
|
524
|
+
"""tan-cli#464 review: took a bare `sdk: SdkInfo | None` and reported only
|
|
525
|
+
the `code`/`message` issue -- so the manifest gate (the dominant refusal
|
|
526
|
+
path, reached before `_run`'s own `sdk_resolution_issues` call further
|
|
527
|
+
down) reported neither `sdk.project-pin-unresolved` nor
|
|
528
|
+
`sdk.global-default-foreign-project`, even with `sdk.sourceTier` naming a
|
|
529
|
+
tier that should have triggered one. Takes the whole `context` so every
|
|
530
|
+
caller -- including a bundle-write failure, past the manifest gate -- gets
|
|
531
|
+
the same pair from the one shared `sdk_resolution_issues`.
|
|
532
|
+
|
|
533
|
+
tan-cli#497 defect 5: that fix reached `issues` and stopped there -- `text`
|
|
534
|
+
was built without the pair, so the DEFAULT mode dropped both warnings
|
|
535
|
+
while `--format json` reported them. Composed from the SAME list now, so
|
|
536
|
+
the two channels cannot disagree."""
|
|
537
|
+
issues = sdk_resolution_issues(
|
|
538
|
+
context.broken_project_pin, context.sdk_source_tier, context.foreign_global_default_for
|
|
539
|
+
)
|
|
540
|
+
text = _sdk_warning_lines(issues)
|
|
541
|
+
issues.append(Issue(code, "error", message))
|
|
542
|
+
return _Outcome(
|
|
543
|
+
exit_code,
|
|
544
|
+
_empty_bundle(),
|
|
545
|
+
project,
|
|
546
|
+
issues,
|
|
547
|
+
[*text, f"image: {message}"],
|
|
548
|
+
context.sdk,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _run(
|
|
553
|
+
*,
|
|
554
|
+
app_path: str | None,
|
|
555
|
+
build_root_arg: str | None,
|
|
556
|
+
project_arg: str | None,
|
|
557
|
+
board_yaml_arg: str | None,
|
|
558
|
+
sdk_root_arg: str | None,
|
|
559
|
+
disclosure: SdkDisclosure,
|
|
560
|
+
) -> _Outcome:
|
|
561
|
+
"""`disclosure` is the caller's, by reference -- the resolution facts are
|
|
562
|
+
computed HERE and `image`'s `image.internal-failure` catch-all needs a name
|
|
563
|
+
to read them from once this function has already raised. See
|
|
564
|
+
`SdkDisclosure`."""
|
|
565
|
+
context: ProjectContext = resolve_project_context(
|
|
566
|
+
project_arg, board_yaml_arg, sdk_root_arg
|
|
567
|
+
)
|
|
568
|
+
project = context.project()
|
|
569
|
+
# Recorded the instant the ladder answers, ahead of every other step this
|
|
570
|
+
# function performs -- all of which can raise something unenumerated.
|
|
571
|
+
disclosure.record(
|
|
572
|
+
context.sdk,
|
|
573
|
+
sdk_resolution_issues(
|
|
574
|
+
context.broken_project_pin,
|
|
575
|
+
context.sdk_source_tier,
|
|
576
|
+
context.foreign_global_default_for,
|
|
577
|
+
),
|
|
578
|
+
)
|
|
579
|
+
app_base = resolve_app_base(app_path, context.workspace_root)
|
|
580
|
+
build_root = resolve_build_root(build_root_arg, app_base)
|
|
581
|
+
|
|
582
|
+
build_root = effective_build_root(build_root)
|
|
583
|
+
try:
|
|
584
|
+
yaml_text, manifest = load_manifest(build_root)
|
|
585
|
+
except ManifestUnavailable as err:
|
|
586
|
+
# Note the asymmetry with `size`, which is faithful: `image` does NOT put
|
|
587
|
+
# the OS error in its message, and a non-UTF-8 manifest is "not found"
|
|
588
|
+
# here too (the read fails before the parser ever sees it).
|
|
589
|
+
return _error_outcome(
|
|
590
|
+
project,
|
|
591
|
+
context,
|
|
592
|
+
ExitCode.RUNTIME_FAILURE,
|
|
593
|
+
"image.manifest-unavailable",
|
|
594
|
+
f"system-manifest.yaml not found at {err.path}; run `tan build` first.",
|
|
595
|
+
)
|
|
596
|
+
except ManifestInvalid as err:
|
|
597
|
+
return _error_outcome(
|
|
598
|
+
project,
|
|
599
|
+
context,
|
|
600
|
+
ExitCode.RUNTIME_FAILURE,
|
|
601
|
+
"image.manifest-invalid",
|
|
602
|
+
f"{err.path}: {err.detail}",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
sdk_root = context.sdk.root if context.sdk is not None else None
|
|
606
|
+
try:
|
|
607
|
+
notices, bundle, bundle_dir = _assemble_bundle(
|
|
608
|
+
build_root, sdk_root, manifest, yaml_text, sdk_root_arg, str(context.workspace_root)
|
|
609
|
+
)
|
|
610
|
+
except BundleWriteError as err:
|
|
611
|
+
return _error_outcome(
|
|
612
|
+
project,
|
|
613
|
+
context,
|
|
614
|
+
ExitCode.WRITE_FAILURE,
|
|
615
|
+
"image.bundle-write-failed",
|
|
616
|
+
err.message,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Not always a green exit: an `error`-severity notice (a helper's concrete,
|
|
620
|
+
# declared firmware_path that is not a file) flips exit/`ok` even though
|
|
621
|
+
# assembly completed -- mkdir/tar/write all succeeded, so the manifest and
|
|
622
|
+
# every OTHER artefact are still on disk and inspectable.
|
|
623
|
+
exit_code = (
|
|
624
|
+
ExitCode.RUNTIME_FAILURE
|
|
625
|
+
if any(n.severity == "error" for n in notices)
|
|
626
|
+
else ExitCode.SUCCESS
|
|
627
|
+
)
|
|
628
|
+
issues = [Issue(n.code, n.severity, n.message) for n in notices]
|
|
629
|
+
# The same pair `_error_outcome` above computes for a manifest/bundle-write
|
|
630
|
+
# refusal, from the same shared `sdk_resolution_issues` -- read back off
|
|
631
|
+
# the disclosure rather than computed a second time, so the happy path, the
|
|
632
|
+
# refusals and the catch-all cannot disagree.
|
|
633
|
+
sdk_issues = list(disclosure.issues)
|
|
634
|
+
issues.extend(sdk_issues)
|
|
635
|
+
return _Outcome(
|
|
636
|
+
exit_code,
|
|
637
|
+
bundle,
|
|
638
|
+
project,
|
|
639
|
+
issues,
|
|
640
|
+
# tan-cli#497 defect 5, happy-path half: a bundle whose helper
|
|
641
|
+
# firmwares came out of a FALLBACK checkout used to be reported as a
|
|
642
|
+
# plain `image: bundle ready at ...` with no mention that the project
|
|
643
|
+
# pin had been ignored.
|
|
644
|
+
_sdk_warning_lines(sdk_issues)
|
|
645
|
+
+ [n.message for n in notices]
|
|
646
|
+
+ [f"image: bundle ready at {bundle_dir}"],
|
|
647
|
+
context.sdk,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def image(
|
|
652
|
+
ctx: typer.Context,
|
|
653
|
+
app_path: str = typer.Argument(
|
|
654
|
+
None,
|
|
655
|
+
metavar="APP_PATH",
|
|
656
|
+
help="Application source directory (default: the resolved --project "
|
|
657
|
+
"workspace). build_root defaults to <APP_PATH>/build.",
|
|
658
|
+
),
|
|
659
|
+
build_root: str = typer.Option(
|
|
660
|
+
None,
|
|
661
|
+
"--build-root",
|
|
662
|
+
metavar="PATH",
|
|
663
|
+
help="Override the build root holding system-manifest.yaml "
|
|
664
|
+
"(default: <APP_PATH>/build).",
|
|
665
|
+
),
|
|
666
|
+
project: str = typer.Option(
|
|
667
|
+
None, "--project", metavar="PATH", help="Project root (defaults to '.')."
|
|
668
|
+
),
|
|
669
|
+
board_yaml: str = typer.Option(
|
|
670
|
+
None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
|
|
671
|
+
),
|
|
672
|
+
sdk_root: str = typer.Option(
|
|
673
|
+
None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
|
|
674
|
+
),
|
|
675
|
+
output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
676
|
+
) -> None:
|
|
677
|
+
"""Assemble a flashable-image bundle from build/system-manifest.yaml."""
|
|
678
|
+
resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
|
|
679
|
+
json_mode = resolved_format == "json"
|
|
680
|
+
|
|
681
|
+
# tan-cli#497 defect 5, the site the first pass missed. `_error_outcome` and
|
|
682
|
+
# the happy path both report the SDK-resolution pair; this handler -- which
|
|
683
|
+
# runs strictly after `resolve_project_context` has already answered --
|
|
684
|
+
# reported only the crash, so a `.alp/sdk-path` the run ignored stayed
|
|
685
|
+
# silent on exactly the path where a reader most needs to know which
|
|
686
|
+
# checkout was in play. Recorded rather than recomputed here: the resolver
|
|
687
|
+
# is itself one of the things that can raise, and this handler must not.
|
|
688
|
+
disclosure = SdkDisclosure()
|
|
689
|
+
try:
|
|
690
|
+
outcome = _run(
|
|
691
|
+
app_path=app_path,
|
|
692
|
+
build_root_arg=build_root,
|
|
693
|
+
project_arg=project,
|
|
694
|
+
board_yaml_arg=board_yaml,
|
|
695
|
+
sdk_root_arg=sdk_root,
|
|
696
|
+
disclosure=disclosure,
|
|
697
|
+
)
|
|
698
|
+
except Exception as err: # noqa: BLE001
|
|
699
|
+
# The port's most-repeated defect class: a traceback puts nothing
|
|
700
|
+
# parseable on stdout and the extension renders an empty panel with no
|
|
701
|
+
# error. Anything reaching here is a tan bug, reported as one. Nothing in
|
|
702
|
+
# this handler can itself throw -- `_empty_bundle()` and
|
|
703
|
+
# `Project(None, None)` are both total, `_sdk_warning_lines` only
|
|
704
|
+
# formats, and no path helper is called.
|
|
705
|
+
outcome = _Outcome(
|
|
706
|
+
ExitCode.INTERNAL_FAILURE,
|
|
707
|
+
_empty_bundle(),
|
|
708
|
+
Project(root=None, board_yaml=None),
|
|
709
|
+
[
|
|
710
|
+
*disclosure.issues,
|
|
711
|
+
Issue(
|
|
712
|
+
"image.internal-failure",
|
|
713
|
+
"error",
|
|
714
|
+
f"image failed unexpectedly: {type(err).__name__}: {err}",
|
|
715
|
+
),
|
|
716
|
+
],
|
|
717
|
+
[*_sdk_warning_lines(disclosure.issues), "image: internal failure"],
|
|
718
|
+
disclosure.sdk,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
# Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407
|
|
722
|
+
# `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_
|
|
723
|
+
# divergence`), and `outcome.text` was assembled strictly before any
|
|
724
|
+
# `Envelope` existed -- so a seam-appended issue reached `--format json`
|
|
725
|
+
# and was silent on the default text channel (tan-cli#799). Diffed
|
|
726
|
+
# against `outcome.issues` (by value: `Issue` is a frozen dataclass) so
|
|
727
|
+
# only what the seam ADDED is rendered, never a duplicate of a warning
|
|
728
|
+
# `outcome.text` already carries via `_sdk_warning_lines` above.
|
|
729
|
+
envelope = Envelope(
|
|
730
|
+
"image",
|
|
731
|
+
outcome.project,
|
|
732
|
+
outcome.data,
|
|
733
|
+
outcome.issues,
|
|
734
|
+
outcome.exit_code,
|
|
735
|
+
sdk=outcome.sdk,
|
|
736
|
+
)
|
|
737
|
+
if json_mode:
|
|
738
|
+
emit(envelope)
|
|
739
|
+
else:
|
|
740
|
+
seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues]
|
|
741
|
+
stream = typer.get_text_stream("stderr")
|
|
742
|
+
for issue in seam_extra:
|
|
743
|
+
stream.write(f"{issue.severity}: {issue.message}\n")
|
|
744
|
+
for line in outcome.text:
|
|
745
|
+
stream.write(f"{line}\n")
|
|
746
|
+
raise typer.Exit(int(outcome.exit_code))
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was
|
|
750
|
+
# still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/
|
|
751
|
+
# `--target`/`--verbose`) on top of `--board-yaml`, already declared and read
|
|
752
|
+
# above; see `tan.core.global_flags`.
|
|
753
|
+
image = accept_global_flags(image)
|