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,296 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Is alp-sdk's `zephyr/patches.yml` applied in the resolved west workspace?
|
|
3
|
+
(tan-cli#1376)
|
|
4
|
+
|
|
5
|
+
A customer hit `alp_camera_open FAILED: ALP_ERR_NOSUPPORT` because the
|
|
6
|
+
workspace lacked patch 0001 (Alif clock `set_rate`) while `tan build` said
|
|
7
|
+
`ok: true`. `tan bootstrap` applies the patches (`bootstrap_patches.py`); this
|
|
8
|
+
module only ASKS, for `tan doctor` and `tan build`, and never writes to the
|
|
9
|
+
workspace.
|
|
10
|
+
|
|
11
|
+
The SDK's own `scripts/verify_west_patches.py` is the source of truth and is
|
|
12
|
+
run unchanged; this module reads its exit code (0 applied / 1 not applied /
|
|
13
|
+
2 uninspectable / 3 module not checked out) and its failure report. It never
|
|
14
|
+
raises: an absent verifier (older SDK), a missing interpreter, a timeout or an
|
|
15
|
+
unreadable report all become `unchecked`, because a check that cannot run must
|
|
16
|
+
not fail an offline doctor or a build.
|
|
17
|
+
|
|
18
|
+
Caching (`tan build` only): a positive verdict (applied, or applied with some
|
|
19
|
+
modules not checked out) is stored in `<project>/build/` keyed by (every
|
|
20
|
+
workspace module's HEAD, `patches.yml` bytes, and a working-tree fingerprint:
|
|
21
|
+
`(path, mtime_ns, size)` of every file the patches write, looked up under every
|
|
22
|
+
module checkout). The fingerprint matters because `west patch clean`,
|
|
23
|
+
`git checkout -- .` and `git stash` revert patches WITHOUT moving HEAD; they
|
|
24
|
+
rewrite the patched files, so mtime/size change and the cache misses. Only
|
|
25
|
+
positive verdicts are cached: a cached "missing" would go stale the moment the
|
|
26
|
+
user ran `tan bootstrap`.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import tempfile
|
|
34
|
+
from dataclasses import dataclass, field, replace
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from tan.core.atomic_write import atomic_write_text
|
|
38
|
+
from tan.core.host_python import probe_host_python
|
|
39
|
+
from tan.core.patch_paths import patch_paths
|
|
40
|
+
from tan.core.subprocess_env import spawn_env
|
|
41
|
+
from tan.core.venv import venv_python, west_program
|
|
42
|
+
from tan.core.west_patches import (
|
|
43
|
+
UnappliedPatch,
|
|
44
|
+
cache_key,
|
|
45
|
+
classify_verify,
|
|
46
|
+
parse_unapplied,
|
|
47
|
+
parse_unapplied_patches,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
APPLIED = "applied"
|
|
51
|
+
MISSING = "missing"
|
|
52
|
+
UNCHECKED = "unchecked"
|
|
53
|
+
|
|
54
|
+
CACHE_FILE = ".tan-workspace-patches.json"
|
|
55
|
+
_VERIFIER = Path("scripts") / "verify_west_patches.py"
|
|
56
|
+
_PATCHES_YML = Path("zephyr") / "patches.yml"
|
|
57
|
+
_TIMEOUT_S = 120
|
|
58
|
+
_PYTHON_FLOOR = (3, 10)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class PatchCheck:
|
|
63
|
+
state: str # APPLIED | MISSING | UNCHECKED
|
|
64
|
+
patches: list[UnappliedPatch] = field(default_factory=list)
|
|
65
|
+
modules: list[str] = field(default_factory=list)
|
|
66
|
+
#: Why `unchecked`, or the applied count line; empty for `missing`.
|
|
67
|
+
note: str = ""
|
|
68
|
+
cached: bool = False
|
|
69
|
+
#: Why `tan build` re-verifies on every build: the cache key could not be
|
|
70
|
+
#: built in full, so nothing is cached. Empty when caching was in play.
|
|
71
|
+
cache_note: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run(argv: list[str], cwd: str, timeout: int = _TIMEOUT_S) -> tuple[int | None, str, str]:
|
|
75
|
+
try:
|
|
76
|
+
out = subprocess.run(
|
|
77
|
+
argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8",
|
|
78
|
+
errors="replace", stdin=subprocess.DEVNULL, timeout=timeout,
|
|
79
|
+
env=spawn_env(), check=False,
|
|
80
|
+
)
|
|
81
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
82
|
+
return None, "", ""
|
|
83
|
+
return out.returncode, out.stdout, out.stderr
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _head(module_dir: Path) -> str:
|
|
87
|
+
"""A cheap, spawn-free identity of a checkout's HEAD. Not a verdict: it
|
|
88
|
+
only has to CHANGE when the checkout moves."""
|
|
89
|
+
try:
|
|
90
|
+
git = module_dir / ".git"
|
|
91
|
+
gitdir = git
|
|
92
|
+
if git.is_file():
|
|
93
|
+
text = git.read_text(encoding="utf-8").strip()
|
|
94
|
+
if text.startswith("gitdir:"):
|
|
95
|
+
gitdir = (module_dir / text[len("gitdir:"):].strip()).resolve()
|
|
96
|
+
head_file = gitdir / "HEAD"
|
|
97
|
+
head = head_file.read_text(encoding="utf-8").strip()
|
|
98
|
+
if head.startswith("ref:"):
|
|
99
|
+
ref = head[4:].strip()
|
|
100
|
+
loose = gitdir / ref
|
|
101
|
+
if loose.is_file():
|
|
102
|
+
return loose.read_text(encoding="utf-8").strip()
|
|
103
|
+
packed = gitdir / "packed-refs"
|
|
104
|
+
if packed.is_file():
|
|
105
|
+
for line in packed.read_text(encoding="utf-8").splitlines():
|
|
106
|
+
if line.endswith(" " + ref):
|
|
107
|
+
return line.split(" ", 1)[0]
|
|
108
|
+
return f"{ref}@{head_file.stat().st_mtime_ns}"
|
|
109
|
+
return head
|
|
110
|
+
except (OSError, UnicodeDecodeError):
|
|
111
|
+
return "unreadable"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _workspace_heads(west: str, workspace: Path) -> dict[str, str] | None:
|
|
115
|
+
code, out, _ = _run([west, "list", "-f", "{abspath}"], str(workspace))
|
|
116
|
+
if code != 0:
|
|
117
|
+
return None
|
|
118
|
+
return {p: _head(Path(p)) for p in (ln.strip() for ln in out.splitlines()) if p}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_PATCH_ENTRY = re.compile(r"^\s*-?\s*path:\s*['\"]?([^'\"#\s]+)", re.MULTILINE)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _patched_files(sdk: Path) -> list[str] | None:
|
|
125
|
+
"""Repo-relative paths every `patches.yml` patch touches, or `None`.
|
|
126
|
+
|
|
127
|
+
Every path a patch touches counts, deletions, renames and binary patches
|
|
128
|
+
included (`tan.core.patch_paths`), so a revert that restores a deleted
|
|
129
|
+
file moves the fingerprint.
|
|
130
|
+
|
|
131
|
+
`None` when the list cannot be trusted to be complete: `patches.yml` or
|
|
132
|
+
any named patch is unreadable, or no path was found at all. A partial
|
|
133
|
+
list would silently weaken the cache key towards HEAD-only, reopening the
|
|
134
|
+
revert hole for the dropped files, so the caller skips caching instead.
|
|
135
|
+
"""
|
|
136
|
+
rels: set[str] = set()
|
|
137
|
+
try:
|
|
138
|
+
entries = _PATCH_ENTRY.findall((sdk / _PATCHES_YML).read_text(encoding="utf-8"))
|
|
139
|
+
for entry in entries:
|
|
140
|
+
text = (sdk / "zephyr" / "patches" / entry).read_text(encoding="utf-8", errors="replace")
|
|
141
|
+
rels.update(patch_paths(text))
|
|
142
|
+
except (OSError, ValueError):
|
|
143
|
+
return None
|
|
144
|
+
return sorted(rels) or None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _tree_fingerprint(module_dirs: list[str], rels: list[str]) -> dict[str, str]:
|
|
148
|
+
"""`{path: "mtime_ns:size"}` for each patched file present under any module."""
|
|
149
|
+
out: dict[str, str] = {}
|
|
150
|
+
for d in module_dirs:
|
|
151
|
+
for rel in rels:
|
|
152
|
+
f = Path(d) / rel
|
|
153
|
+
try:
|
|
154
|
+
st = f.stat()
|
|
155
|
+
except OSError:
|
|
156
|
+
continue
|
|
157
|
+
out[str(f)] = f"{st.st_mtime_ns}:{st.st_size}"
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cache_key(west: str, workspace: Path, sdk: Path, why: list[str]) -> str | None:
|
|
162
|
+
"""The cache key, or `None` after appending the reason to `why` when it
|
|
163
|
+
cannot be built in full (nothing is then cached)."""
|
|
164
|
+
heads = _workspace_heads(west, workspace)
|
|
165
|
+
if heads is None:
|
|
166
|
+
why.append("the west workspace modules could not be listed")
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
rels = _patched_files(sdk)
|
|
170
|
+
if rels is None:
|
|
171
|
+
why.append("zephyr/patches.yml or a patch it names could not be read in full")
|
|
172
|
+
return None
|
|
173
|
+
tree = _tree_fingerprint(list(heads), rels)
|
|
174
|
+
return cache_key((sdk / _PATCHES_YML).read_bytes(), {**heads, **tree})
|
|
175
|
+
except OSError:
|
|
176
|
+
why.append("zephyr/patches.yml could not be read")
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _interpreter(workspace: Path, sdk_root: str) -> str | None:
|
|
181
|
+
# The workspace venv carries west + pyyaml (Zephyr's requirements), which
|
|
182
|
+
# the verifier imports; the resolved host Python is the fallback.
|
|
183
|
+
py = venv_python(str(workspace), sdk_root)
|
|
184
|
+
if py is not None:
|
|
185
|
+
return py
|
|
186
|
+
host = probe_host_python(_PYTHON_FLOOR, need_west=True)
|
|
187
|
+
return host.interpreter if host is not None else None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _read_cache(path: Path, key: str) -> str | None:
|
|
191
|
+
"""The cached state (`APPLIED` or `UNCHECKED`) for `key`, else `None`."""
|
|
192
|
+
try:
|
|
193
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
194
|
+
except (OSError, ValueError):
|
|
195
|
+
return None
|
|
196
|
+
if isinstance(doc, dict) and doc.get("key") == key and doc.get("state") in (APPLIED, UNCHECKED):
|
|
197
|
+
return doc["state"]
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _cache_hit(cache_dir: Path, key: str | None) -> PatchCheck | None:
|
|
202
|
+
cached = _read_cache(cache_dir / CACHE_FILE, key) if key is not None else None
|
|
203
|
+
if cached == APPLIED:
|
|
204
|
+
return PatchCheck(APPLIED, note="verified applied (cached)", cached=True)
|
|
205
|
+
if cached == UNCHECKED:
|
|
206
|
+
return PatchCheck(UNCHECKED, note=_PARTIAL_NOTE, cached=True)
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _write_cache(path: Path, key: str, state: str) -> None:
|
|
211
|
+
try:
|
|
212
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
atomic_write_text(str(path), json.dumps({"key": key, "state": state}))
|
|
214
|
+
except Exception: # noqa: BLE001 -- the cache is an optimisation
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_PARTIAL_NOTE = (
|
|
219
|
+
"some zephyr/patches.yml modules are not checked out in this workspace; "
|
|
220
|
+
"their patches could not be checked"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def check_workspace_patches(
|
|
225
|
+
workspace: Path,
|
|
226
|
+
sdk_root: str | None,
|
|
227
|
+
*,
|
|
228
|
+
cache_dir: Path | None = None,
|
|
229
|
+
timeout: int = _TIMEOUT_S,
|
|
230
|
+
) -> PatchCheck:
|
|
231
|
+
"""Verify `<sdk_root>/zephyr/patches.yml` against `workspace`. Read-only,
|
|
232
|
+
and never raises: anything unexpected is `unchecked`."""
|
|
233
|
+
why: list[str] = []
|
|
234
|
+
try:
|
|
235
|
+
result = _check(workspace, sdk_root, cache_dir, timeout, why)
|
|
236
|
+
if why and result.state != MISSING:
|
|
237
|
+
result = replace(result, cache_note=why[0])
|
|
238
|
+
return result
|
|
239
|
+
except Exception: # noqa: BLE001 -- an advisory check must never break its caller
|
|
240
|
+
return PatchCheck(UNCHECKED, note="the patch check failed unexpectedly")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _check(
|
|
244
|
+
workspace: Path, sdk_root: str | None, cache_dir: Path | None, timeout: int,
|
|
245
|
+
why: list[str],
|
|
246
|
+
) -> PatchCheck:
|
|
247
|
+
if sdk_root is None:
|
|
248
|
+
return PatchCheck(UNCHECKED, note="no alp-sdk checkout resolved")
|
|
249
|
+
sdk = Path(sdk_root)
|
|
250
|
+
verifier, patches_yml = sdk / _VERIFIER, sdk / _PATCHES_YML
|
|
251
|
+
if not verifier.is_file() or not patches_yml.is_file():
|
|
252
|
+
return PatchCheck(
|
|
253
|
+
UNCHECKED,
|
|
254
|
+
note="this alp-sdk checkout carries no patch verifier "
|
|
255
|
+
"(scripts/verify_west_patches.py), so patches cannot be checked",
|
|
256
|
+
)
|
|
257
|
+
west = west_program(str(workspace), sdk_root)
|
|
258
|
+
key = None
|
|
259
|
+
if cache_dir is not None:
|
|
260
|
+
key = _cache_key(west, workspace, sdk, why)
|
|
261
|
+
hit = _cache_hit(cache_dir, key)
|
|
262
|
+
if hit is not None:
|
|
263
|
+
return hit
|
|
264
|
+
|
|
265
|
+
python = _interpreter(workspace, sdk_root)
|
|
266
|
+
if python is None:
|
|
267
|
+
return PatchCheck(UNCHECKED, note="no Python able to run the patch verifier was found")
|
|
268
|
+
argv = [
|
|
269
|
+
python, str(verifier), "--repo", str(sdk),
|
|
270
|
+
"--topdir", str(workspace), "--west", west,
|
|
271
|
+
]
|
|
272
|
+
with tempfile.TemporaryDirectory(prefix="tan-patches-") as scratch:
|
|
273
|
+
code, _out, err = _run(argv, scratch, timeout)
|
|
274
|
+
verdict = classify_verify(code)
|
|
275
|
+
if verdict == "applied":
|
|
276
|
+
if key is not None and cache_dir is not None:
|
|
277
|
+
_write_cache(cache_dir / CACHE_FILE, key, APPLIED)
|
|
278
|
+
return PatchCheck(APPLIED, note="verified applied")
|
|
279
|
+
if verdict == "unchecked":
|
|
280
|
+
# Everything inspectable is patched: as good as applied until a
|
|
281
|
+
# head or patched file moves, so it is cached too.
|
|
282
|
+
if key is not None and cache_dir is not None:
|
|
283
|
+
_write_cache(cache_dir / CACHE_FILE, key, UNCHECKED)
|
|
284
|
+
return PatchCheck(UNCHECKED, note=_PARTIAL_NOTE)
|
|
285
|
+
if verdict != "unapplied" or "Traceback" in err:
|
|
286
|
+
return PatchCheck(
|
|
287
|
+
UNCHECKED,
|
|
288
|
+
note="the patch verifier could not inspect this workspace "
|
|
289
|
+
"(run scripts/verify_west_patches.py directly to see why)",
|
|
290
|
+
)
|
|
291
|
+
# Exit 1 is the verifier's "not applied"; if it printed no parsable
|
|
292
|
+
# line, the patches are unnamed but still missing.
|
|
293
|
+
patches = parse_unapplied_patches(err)
|
|
294
|
+
lcode, listing, _ = _run([*argv, "--list-unapplied"], scratch, timeout)
|
|
295
|
+
modules = parse_unapplied(listing) if lcode == 0 else []
|
|
296
|
+
return PatchCheck(MISSING, patches=patches, modules=modules)
|
tan/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""What to tell a `tan init --from-example` user whose copied example reads a
|
|
3
|
+
pre-generated `generated/alp.conf` (alp-sdk#866).
|
|
4
|
+
|
|
5
|
+
alp-sdk#866 deleted the example `CMakeLists.txt` bridge that wrote the
|
|
6
|
+
per-core `alp.conf` at configure time. An example's twister scenarios and
|
|
7
|
+
bare `west build` lines now pass `EXTRA_CONF_FILE=generated/alp.conf`, and
|
|
8
|
+
its prose points at alp-sdk's `scripts/gen_example_alp_conf.py` to write it.
|
|
9
|
+
That script only walks alp-sdk's own `examples/`: run against a project
|
|
10
|
+
`tan init --from-example` copied out, it exits 0 having written nothing.
|
|
11
|
+
`tan build` needs none of it (the plan writes and passes its own `alp.conf`),
|
|
12
|
+
but a bare `west build`/twister run in the copied project names a missing
|
|
13
|
+
overlay, which Zephyr refuses.
|
|
14
|
+
|
|
15
|
+
`--from-example` copies the example verbatim -- the prose is the SDK's, at
|
|
16
|
+
the bound SDK's revision, and tan does not rewrite it. What tan CAN do
|
|
17
|
+
honestly is say, once, which command writes the file in this project:
|
|
18
|
+
`tan generate --target zephyr-conf --core <id> --output <dir>/generated/alp.conf`
|
|
19
|
+
writes exactly the bytes the build plan's `configArtefacts` carry for that
|
|
20
|
+
core. Pure: takes the planned files, returns the message or `None`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import posixpath
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Iterable
|
|
28
|
+
from typing import Protocol
|
|
29
|
+
|
|
30
|
+
#: The reference the message is about. `(?<![\w])` rejects Zephyr's
|
|
31
|
+
#: per-image `<image>_EXTRA_CONF_FILE=` spelling.
|
|
32
|
+
_READS_GENERATED = re.compile(r"(?<![\w])EXTRA_CONF_FILE=generated/alp\.conf(?![\w./])")
|
|
33
|
+
|
|
34
|
+
#: Core OSes that have no Zephyr `alp.conf` at all.
|
|
35
|
+
_NON_ZEPHYR_OS = frozenset({"off", "yocto", "baremetal", "linux"})
|
|
36
|
+
|
|
37
|
+
_CORE_PLACEHOLDER = "<core-id>"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _File(Protocol):
|
|
41
|
+
relative_path: str
|
|
42
|
+
content: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dir_of(path: str) -> str:
|
|
46
|
+
return posixpath.dirname(path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _app_dirs(cores: object, paths: set[str]) -> dict[str, list[str]]:
|
|
50
|
+
"""`{project-relative west app dir: [core ids]}` for every Zephyr-capable
|
|
51
|
+
core. Mirrors the planner's `_zephyr_app_dir` rule: the `app:` directory
|
|
52
|
+
itself when it carries a `CMakeLists.txt`, else its parent."""
|
|
53
|
+
out: dict[str, list[str]] = {}
|
|
54
|
+
if not isinstance(cores, dict):
|
|
55
|
+
return out
|
|
56
|
+
for core_id, cfg in cores.items():
|
|
57
|
+
if not isinstance(core_id, str) or not isinstance(cfg, dict):
|
|
58
|
+
continue
|
|
59
|
+
if str(cfg.get("os", "")).lower() in _NON_ZEPHYR_OS:
|
|
60
|
+
continue
|
|
61
|
+
app = cfg.get("app")
|
|
62
|
+
if not isinstance(app, str) or not app.strip():
|
|
63
|
+
continue
|
|
64
|
+
app_dir = posixpath.normpath(app.strip())
|
|
65
|
+
app_dir = "" if app_dir == "." else app_dir
|
|
66
|
+
if posixpath.join(app_dir, "CMakeLists.txt") not in paths:
|
|
67
|
+
app_dir = _dir_of(app_dir)
|
|
68
|
+
out.setdefault(app_dir, []).append(core_id)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pregeneration_message(
|
|
73
|
+
files: Iterable[_File], board_cores: object, sdk_display: str, subject: str
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""The one-paragraph instruction, or `None` when no copied file reads
|
|
76
|
+
`generated/alp.conf`. *board_cores* is the copied board.yaml's parsed
|
|
77
|
+
`cores:` mapping (anything else degrades to the `<core-id>` placeholder).
|
|
78
|
+
"""
|
|
79
|
+
files = list(files)
|
|
80
|
+
readers = sorted(f.relative_path for f in files if _READS_GENERATED.search(f.content))
|
|
81
|
+
if not readers:
|
|
82
|
+
return None
|
|
83
|
+
by_dir = _app_dirs(board_cores, {f.relative_path for f in files})
|
|
84
|
+
commands = []
|
|
85
|
+
for directory in sorted({_dir_of(r) for r in readers}):
|
|
86
|
+
candidates = by_dir.get(directory, [])
|
|
87
|
+
core = candidates[0] if len(candidates) == 1 else _CORE_PLACEHOLDER
|
|
88
|
+
output = posixpath.join(directory, "generated/alp.conf")
|
|
89
|
+
commands.append(
|
|
90
|
+
f"tan generate --target zephyr-conf --core {core} "
|
|
91
|
+
f"--sdk-root {sdk_display} --output {output}"
|
|
92
|
+
)
|
|
93
|
+
return (
|
|
94
|
+
f"{subject} reads generated/alp.conf ({', '.join(readers)}): `tan build` "
|
|
95
|
+
"writes its own and does not need it, but a bare `west build` or twister "
|
|
96
|
+
"run in this project does. Write it with: "
|
|
97
|
+
+ "; ".join(f"`{c}`" for c in commands)
|
|
98
|
+
+ ". The example's own pointer, alp-sdk's scripts/gen_example_alp_conf.py, "
|
|
99
|
+
"only writes inside alp-sdk's examples/ (alp-sdk#866)."
|
|
100
|
+
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`metadata/bootstrap.json`'s `artifactProvenance` block, normalised --
|
|
3
|
+
pure, no IO (same contract as `tan.core.bootstrap`, which imports this).
|
|
4
|
+
|
|
5
|
+
alp-sdk v0.16.0 (alplabai/alp-sdk#1574) added a top-level `artifactProvenance`
|
|
6
|
+
map to the manifest tan already reads for `missingPrerequisites[].command`:
|
|
7
|
+
|
|
8
|
+
"artifactProvenance": {
|
|
9
|
+
"cmake": {"tier": "A", "source": "https://cmake.org/",
|
|
10
|
+
"sizeBytes": null, "licence": "BSD-3-Clause"},
|
|
11
|
+
...
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
Its keys are the `prerequisites.{posix,macos,windows}` tool vocabulary (plus
|
|
15
|
+
`7zip`) -- the SAME names `missingPrerequisites[].tool` already carries -- so
|
|
16
|
+
carrying the block onto those entries is a join on an identity that already
|
|
17
|
+
exists, not a new one (tan-cli#1066).
|
|
18
|
+
|
|
19
|
+
**`source` on the wire becomes `sourceUrl`.** alp-sdk spells the upstream
|
|
20
|
+
project page `source`; the envelope deliberately does not. The consumer that
|
|
21
|
+
asked for this (alp-sdk-vscode#467's dependency-consent screen) already
|
|
22
|
+
defines a `source` column as *what will actually run* -- the install command,
|
|
23
|
+
verbatim -- and the artifact a user actually installs is a brew bottle or an
|
|
24
|
+
apt package, not the tarball behind that page. One name for two meanings would
|
|
25
|
+
misinform the security reader the screen exists for, so tan-cli#1066 renames
|
|
26
|
+
it at the seam it crosses. `licence` keeps alp-sdk's own (British) spelling,
|
|
27
|
+
because that one is the same fact under the same name.
|
|
28
|
+
|
|
29
|
+
**Absence is `null`, never an omitted key and never a default.** `xz` and
|
|
30
|
+
`7zip` genuinely declare no licence upstream and every entry's `sizeBytes` is
|
|
31
|
+
`null` today; an SDK predating the block (v0.16.0-rc1 and earlier) reports
|
|
32
|
+
`null` for all four. "Not reported" is a true answer and an invented one is
|
|
33
|
+
not -- and a key set that is stable regardless of the SDK in front of it is
|
|
34
|
+
what lets `contract/doctor-data-keys.json` declare one shape rather than
|
|
35
|
+
"these four keys, sometimes".
|
|
36
|
+
|
|
37
|
+
**Malformed input DEGRADES, it does not refuse.** This is the one place this
|
|
38
|
+
module deliberately does NOT reuse the curated-error register the sibling
|
|
39
|
+
readers use (`planner.template._require_mapping_doc` / `_require_field`, PR
|
|
40
|
+
tan-cli#1073/#1082, which raise a named `TemplateError`). Those guard a
|
|
41
|
+
document whose content the command cannot proceed without. This one is
|
|
42
|
+
advisory display metadata hanging off `tan doctor` -- whose whole job is to
|
|
43
|
+
report on a broken environment, and which already treats a missing or
|
|
44
|
+
malformed `bootstrap.json` as a warning with a documented fallback rather than
|
|
45
|
+
a refusal (`doctor_cmd._load_manifest`'s own docstring: "a doctor that cannot
|
|
46
|
+
start because the thing it diagnoses is broken is the failure mode it exists
|
|
47
|
+
to prevent"). Refusing here would let one upstream typo in a field nobody
|
|
48
|
+
builds with take out the diagnosis of every OTHER thing wrong with the host.
|
|
49
|
+
So every malformed shape -- a non-mapping block, a non-mapping entry, a
|
|
50
|
+
missing or wrongly-typed field -- collapses to the SAME `null` an absent SDK
|
|
51
|
+
key yields, per FIELD rather than per block: a `cmake` entry whose `sizeBytes`
|
|
52
|
+
is the string `"12"` still reports its real `tier`/`licence`/`sourceUrl`.
|
|
53
|
+
|
|
54
|
+
**Degrading is not the same as saying nothing** (tan-cli#1066 review).
|
|
55
|
+
Silently, a corrupt block and an SDK predating the block produce byte-identical
|
|
56
|
+
envelopes -- so an alp-sdk generator regression would render "not reported" on
|
|
57
|
+
every consent screen with `tan doctor` saying nothing at all, which is the
|
|
58
|
+
failure mode `doctor` exists to prevent. `problems_in` (below) is what makes it
|
|
59
|
+
sayable: `doctor_cmd` raises its EXISTING `bootstrapManifest` warn and
|
|
60
|
+
`doctor.bootstrap-manifest` issue for a block that is present and unreadable,
|
|
61
|
+
and stays silent for one that is merely absent. Nothing about the entries or
|
|
62
|
+
the exit code changes -- the four keys are still there, still `null`.
|
|
63
|
+
|
|
64
|
+
Its report is DERIVED from these parsers, never a second opinion about types: a
|
|
65
|
+
field is called unreadable exactly when the manifest declared a value (not
|
|
66
|
+
`null`, not absent) and the real reader still answered `None`. Tightening
|
|
67
|
+
`_count` -- the negative-size refusal, say -- starts being reported with no
|
|
68
|
+
change to `problems_in` at all; loosening it stops being reported. A test pins
|
|
69
|
+
that coupling rather than trusting this paragraph.
|
|
70
|
+
"""
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
from dataclasses import dataclass
|
|
74
|
+
from typing import Any
|
|
75
|
+
|
|
76
|
+
#: The manifest's own key for the block, and for the one field renamed on the
|
|
77
|
+
#: wire. Named rather than inlined so the rename has exactly one spelling.
|
|
78
|
+
BLOCK_KEY = "artifactProvenance"
|
|
79
|
+
SDK_SOURCE_KEY = "source"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ArtifactProvenance:
|
|
84
|
+
"""One tool's provenance, already normalised to the wire's own shape.
|
|
85
|
+
|
|
86
|
+
Every field defaults to `None` so `ArtifactProvenance()` IS the "not
|
|
87
|
+
reported" value -- there is no second spelling of absence to keep in sync,
|
|
88
|
+
and a caller with no table at hand (`UNKNOWN` below) produces exactly what
|
|
89
|
+
a caller holding a table with no entry for its tool does.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
tier: str | None = None
|
|
93
|
+
licence: str | None = None
|
|
94
|
+
source_url: str | None = None
|
|
95
|
+
size_bytes: int | None = None
|
|
96
|
+
|
|
97
|
+
def as_dict(self) -> dict[str, str | int | None]:
|
|
98
|
+
"""The four keys as `missingPrerequisites[]` carries them. Always all
|
|
99
|
+
four, always in this order -- see the module docstring on absence."""
|
|
100
|
+
return {
|
|
101
|
+
"tier": self.tier,
|
|
102
|
+
"licence": self.licence,
|
|
103
|
+
"sourceUrl": self.source_url,
|
|
104
|
+
"sizeBytes": self.size_bytes,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
#: The "nothing reported" provenance: an SDK predating `artifactProvenance`, a
|
|
109
|
+
#: tool with no entry in it (`west`, `zephyrSdk`, `setools`, `jlink` and
|
|
110
|
+
#: `python3-venv` all have none today), or an entry too malformed to read.
|
|
111
|
+
UNKNOWN = ArtifactProvenance()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _text(value: Any) -> str | None:
|
|
115
|
+
"""A string field, or `None` for anything else -- including a number, a
|
|
116
|
+
`null` alp-sdk wrote deliberately (`xz`/`7zip`'s `licence`), and a nested
|
|
117
|
+
object. No `str()` coercion: rendering `{'a': 1}` as a licence in a consent
|
|
118
|
+
screen would be an invented fact, which is the one thing this must not do."""
|
|
119
|
+
return value if isinstance(value, str) else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _count(value: Any) -> int | None:
|
|
123
|
+
"""A byte count, or `None`. `bool` is excluded explicitly for the reason
|
|
124
|
+
the sibling readers exclude it (`parse_bootstrap_manifest`, `_load_manifest`):
|
|
125
|
+
`True == 1` in Python, so `"sizeBytes": true` would otherwise report a size
|
|
126
|
+
of one byte. A float is rejected rather than truncated -- alp-sdk emits
|
|
127
|
+
`null` or an integer, and a truncation here would be tan inventing a
|
|
128
|
+
precision the manifest never claimed.
|
|
129
|
+
|
|
130
|
+
A NEGATIVE int is refused for the same reason as the float: `-4096` is not
|
|
131
|
+
a download size, and rendering "-4 KB" on a consent screen is the same
|
|
132
|
+
class of nonsense as rendering a truncated float. `0` is kept -- an empty
|
|
133
|
+
artefact is silly but arithmetically meaningful, and `null` already exists
|
|
134
|
+
for "not reported", so coercing `0` would erase a distinction the manifest
|
|
135
|
+
can actually draw."""
|
|
136
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
137
|
+
return None
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def entry_from_doc(doc: Any) -> ArtifactProvenance:
|
|
142
|
+
"""One `artifactProvenance[<tool>]` value, normalised. Anything that is not
|
|
143
|
+
a mapping (a bare string, a list, `null`) yields `UNKNOWN` rather than
|
|
144
|
+
raising -- see the module docstring."""
|
|
145
|
+
if not isinstance(doc, dict):
|
|
146
|
+
return UNKNOWN
|
|
147
|
+
return ArtifactProvenance(
|
|
148
|
+
tier=_text(doc.get("tier")),
|
|
149
|
+
licence=_text(doc.get("licence")),
|
|
150
|
+
source_url=_text(doc.get(SDK_SOURCE_KEY)),
|
|
151
|
+
size_bytes=_count(doc.get("sizeBytes")),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_table(raw: Any) -> dict[str, ArtifactProvenance]:
|
|
156
|
+
"""The whole `artifactProvenance` block, normalised to `tool ->
|
|
157
|
+
ArtifactProvenance`.
|
|
158
|
+
|
|
159
|
+
`{}` for an absent block, a block that is not a mapping, or one whose every
|
|
160
|
+
entry is unreadable -- all four of which are the same fact downstream
|
|
161
|
+
("nothing to report"), and none of which is an error here. A non-string key
|
|
162
|
+
is dropped: `missingPrerequisites[].tool` is a string, so a key that could
|
|
163
|
+
never match one carries no information and keeping it would only widen this
|
|
164
|
+
return type for a lookup that cannot hit.
|
|
165
|
+
"""
|
|
166
|
+
if not isinstance(raw, dict):
|
|
167
|
+
return {}
|
|
168
|
+
return {
|
|
169
|
+
tool: entry_from_doc(doc) for tool, doc in raw.items() if isinstance(tool, str)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _entry_problems(tool: str, entry: Any) -> list[str]:
|
|
174
|
+
"""One tool's row: everything the manifest DECLARED there that the real
|
|
175
|
+
readers could not read, named `<tool>.<manifest key>`.
|
|
176
|
+
|
|
177
|
+
Derived from `entry_from_doc`'s own answer rather than a second copy of the
|
|
178
|
+
type rules, so it cannot drift from what the parse actually accepts (see
|
|
179
|
+
the module docstring's "Malformed input" section)."""
|
|
180
|
+
if not isinstance(entry, dict):
|
|
181
|
+
kind = "null" if entry is None else type(entry).__name__
|
|
182
|
+
return [f"`{tool}` is {kind}, not an object"]
|
|
183
|
+
parsed = entry_from_doc(entry)
|
|
184
|
+
return [
|
|
185
|
+
f"`{tool}.{key}` is not readable ({entry[key]!r})"
|
|
186
|
+
for key, read in (
|
|
187
|
+
("tier", parsed.tier),
|
|
188
|
+
("licence", parsed.licence),
|
|
189
|
+
(SDK_SOURCE_KEY, parsed.source_url),
|
|
190
|
+
("sizeBytes", parsed.size_bytes),
|
|
191
|
+
)
|
|
192
|
+
# DECLARED and unreadable. An absent key and an explicit `null` are
|
|
193
|
+
# both "not reported", the normal shape (`xz`/`7zip` licence, every
|
|
194
|
+
# `sizeBytes` today), and neither is a problem.
|
|
195
|
+
if entry.get(key) is not None and read is None
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def problems_in(doc: dict[str, Any]) -> str | None:
|
|
200
|
+
"""What in `doc`'s `artifactProvenance` block tan could not read, as ONE
|
|
201
|
+
human-readable line, or `None` when there is nothing to say.
|
|
202
|
+
|
|
203
|
+
Takes the whole parsed manifest, not the block, because the distinction it
|
|
204
|
+
draws is about the KEY: an absent `artifactProvenance` is every SDK before
|
|
205
|
+
v0.16.0 and stays silent, while a key that IS present and unreadable --
|
|
206
|
+
`"artifactProvenance": null` included, which no alp-sdk has ever emitted --
|
|
207
|
+
is a producer defect worth naming. It reports; it never refuses and never
|
|
208
|
+
changes what the entries carry. See the module docstring for why silence
|
|
209
|
+
was not an option and which channel this feeds.
|
|
210
|
+
"""
|
|
211
|
+
if BLOCK_KEY not in doc:
|
|
212
|
+
return None
|
|
213
|
+
raw = doc[BLOCK_KEY]
|
|
214
|
+
if not isinstance(raw, dict):
|
|
215
|
+
kind = "null" if raw is None else type(raw).__name__
|
|
216
|
+
return f"`{BLOCK_KEY}` is {kind}, not an object"
|
|
217
|
+
|
|
218
|
+
problems: list[str] = []
|
|
219
|
+
for tool, entry in raw.items():
|
|
220
|
+
if not isinstance(tool, str):
|
|
221
|
+
problems.append(f"`{BLOCK_KEY}` has a non-string key ({tool!r})")
|
|
222
|
+
continue
|
|
223
|
+
problems.extend(_entry_problems(tool, entry))
|
|
224
|
+
if not problems:
|
|
225
|
+
return None
|
|
226
|
+
# Bounded: a wholly corrupt block would otherwise put dozens of clauses
|
|
227
|
+
# into one `doctor` line. The count stays exact, so nothing is hidden.
|
|
228
|
+
head = "; ".join(problems[:3])
|
|
229
|
+
if len(problems) > 3:
|
|
230
|
+
head += f"; and {len(problems) - 3} more"
|
|
231
|
+
return head
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def for_tool(table: dict[str, ArtifactProvenance] | None, tool: str) -> ArtifactProvenance:
|
|
235
|
+
"""`table`'s entry for `tool`, or `UNKNOWN`. Takes `None` for a caller that
|
|
236
|
+
has no table at all (every pre-tan-cli#1066 call site, and every unit test
|
|
237
|
+
of the refusal builders), so "no table" and "no entry" cannot diverge."""
|
|
238
|
+
if not table:
|
|
239
|
+
return UNKNOWN
|
|
240
|
+
return table.get(tool, UNKNOWN)
|