tan-cli 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/core/bootstrap.py
ADDED
|
@@ -0,0 +1,2572 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Pure decision logic for `tan bootstrap` -- no IO, no subprocesses.
|
|
3
|
+
|
|
4
|
+
Mirrors `crates/tan-core/src/bootstrap/` (its `manifest`/`prerequisites`/
|
|
5
|
+
`runtime`/`blocks`/`workspace_guard` split, collapsed into one module because
|
|
6
|
+
Python needs no visibility ceremony to keep them apart). The spawning half lives
|
|
7
|
+
in `tan.commands.bootstrap_cmd`.
|
|
8
|
+
|
|
9
|
+
The FACTS every step acts on -- tool lists, argv, pip specs, pins, env map,
|
|
10
|
+
hints -- come from `<sdkRoot>/metadata/bootstrap.json`, never from literals
|
|
11
|
+
here. That file is a live consumer contract (invariant **I-64**: *"tan (Rust,
|
|
12
|
+
cross-platform) has read the same facts since tan-cli PR #55 ... not merely an
|
|
13
|
+
INTENDED future consumer"*), and its own drift gate
|
|
14
|
+
(`scripts/check_bootstrap_manifest.py`) inspects only `bootstrap.sh` and
|
|
15
|
+
`bootstrap.ps1` -- so a hand-ported constant here desyncs silently. The
|
|
16
|
+
`fallback_facts` constants below are therefore stale-by-default and exist only
|
|
17
|
+
for an SDK predating the manifest.
|
|
18
|
+
|
|
19
|
+
**tan does not shell the SDK's bootstrap scripts.** Invariant **I-32** and
|
|
20
|
+
anti-pattern **22** of `docs/superpowers/specs/2026-07-29-tan-port-invariants.md`
|
|
21
|
+
record that giving a command an alp-sdk-script dependency it deliberately does
|
|
22
|
+
not have is a regression the parity gates cannot see; the Rust oracle's own
|
|
23
|
+
module doc says the same ("No `bash` anywhere -- native Windows is a first-class
|
|
24
|
+
host (#49), so the two scripts are the parity oracle for CONTROL FLOW and
|
|
25
|
+
message strings, not a runtime dependency"). The scripts are read as an oracle
|
|
26
|
+
for wording and step ORDER, and re-implemented.
|
|
27
|
+
|
|
28
|
+
Message strings and step order come from those two oracles. Their whitespace is
|
|
29
|
+
load-bearing twice over: a human reads the lines, and the envelope's issue
|
|
30
|
+
message is `" ".join(lines)`.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from tan.core.os_class import infer_runtime_for_core_id
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import shlex
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from dataclasses import dataclass, replace
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from tan.core import artifact_provenance
|
|
46
|
+
from tan.core.artifact_provenance import ArtifactProvenance
|
|
47
|
+
from tan.core.timestamp import generated_at_iso
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Hosts
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
#: The four hosts the flow distinguishes. Plain strings, not an enum: these ARE
|
|
54
|
+
#: the manifest's own `prerequisites.install` keys for three of the four, so a
|
|
55
|
+
#: separate enum would only need translating back.
|
|
56
|
+
LINUX = "linux"
|
|
57
|
+
MACOS = "macos"
|
|
58
|
+
#: `install.linux`'s package-manager keys (alp-sdk#1464 / tan-cli#760) -- see
|
|
59
|
+
#: `detect_linux_pm` for the detection order and why `pacman` has no constant
|
|
60
|
+
#: here at all.
|
|
61
|
+
LINUX_PM_APT = "apt"
|
|
62
|
+
LINUX_PM_DNF = "dnf"
|
|
63
|
+
WINDOWS = "windows"
|
|
64
|
+
OTHER = "other"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_host_os(platform: str) -> str:
|
|
68
|
+
"""Classify a `sys.platform` value. A PARAMETER, not read from `sys` here,
|
|
69
|
+
so both branches stay testable from either host (`HostOs::detect`)."""
|
|
70
|
+
if platform.startswith("linux"):
|
|
71
|
+
return LINUX
|
|
72
|
+
if platform == "darwin":
|
|
73
|
+
return MACOS
|
|
74
|
+
if platform in ("win32", "cygwin"):
|
|
75
|
+
return WINDOWS
|
|
76
|
+
return OTHER
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def os_label(host: str) -> str:
|
|
80
|
+
"""The POSIX script's `OS_LABEL`. `windows-bash` (git-bash/MSYS) has no
|
|
81
|
+
counterpart: on Windows `tan bootstrap` runs the native flow, which prints
|
|
82
|
+
the Python version instead of an OS label."""
|
|
83
|
+
return "unknown" if host == OTHER else host
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Constants (the documented fallbacks -- stale by default; see the module doc)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
#: FALLBACK Zephyr pin, used only when the SDK has no `metadata/bootstrap.json`.
|
|
91
|
+
ZEPHYR_VERSION = "v4.4.1"
|
|
92
|
+
|
|
93
|
+
#: FALLBACK west requirement -- a FLOOR, not a pin. Mirrors `west.pipSpec`.
|
|
94
|
+
WEST_REQUIREMENT = "west>=0.14.0"
|
|
95
|
+
|
|
96
|
+
#: Manifest path relative to the SDK checkout root.
|
|
97
|
+
BOOTSTRAP_MANIFEST_REL_PATH = "metadata/bootstrap.json"
|
|
98
|
+
|
|
99
|
+
#: The only `schemaVersion` this consumer understands
|
|
100
|
+
#: (`metadata/schemas/bootstrap-v1.schema.json` pins it `const: 1`).
|
|
101
|
+
BOOTSTRAP_MANIFEST_SCHEMA_VERSION = 1
|
|
102
|
+
|
|
103
|
+
#: `${SDK_ROOT}` / `${WORKSPACE_DIR}` substitution tokens.
|
|
104
|
+
TOKEN_SDK_ROOT = "${SDK_ROOT}"
|
|
105
|
+
TOKEN_WORKSPACE_DIR = "${WORKSPACE_DIR}"
|
|
106
|
+
|
|
107
|
+
#: The dedicated subdirectory the workspace-parent guard offers to relocate the
|
|
108
|
+
#: checkout into. NOT a detection heuristic -- the guard never keys off a
|
|
109
|
+
#: directory NAME (see `parent_needs_workspace_guard`); this is only the name tan
|
|
110
|
+
#: chooses for the new home it builds.
|
|
111
|
+
DEFAULT_WORKSPACE_DIR_NAME = "alp-workspace"
|
|
112
|
+
|
|
113
|
+
#: `tan doctor`'s wording, reused verbatim so the two agree
|
|
114
|
+
#: (`tan_core::build_readiness::YOCTO_HOST_DETAIL`).
|
|
115
|
+
YOCTO_HOST_DETAIL = "Yocto builds are Linux-only; use WSL2 or a Linux host/container."
|
|
116
|
+
|
|
117
|
+
#: The per-core `os:` value that takes a core OUT of play entirely.
|
|
118
|
+
OS_OFF = "off"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Venv layout
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class VenvLayout:
|
|
128
|
+
"""Where a venv keeps its executables and what they are called. The
|
|
129
|
+
DIRECTORY names are manifest facts (`venv.posixBinDir`/`windowsBinDir`); the
|
|
130
|
+
executable names are not in the manifest and live here."""
|
|
131
|
+
|
|
132
|
+
bin_dir: str
|
|
133
|
+
python: str
|
|
134
|
+
west: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def venv_layout(is_windows: bool) -> VenvLayout:
|
|
138
|
+
if is_windows:
|
|
139
|
+
return VenvLayout("Scripts", "python.exe", "west.exe")
|
|
140
|
+
return VenvLayout("bin", "python", "west")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def venv_exe_names(bin_dir: str, facts: BootstrapFacts) -> VenvLayout:
|
|
144
|
+
"""The venv executable names for whichever bin dir actually WON. Both
|
|
145
|
+
scripts pick the bin dir by which one exists, so a `Scripts/` venv created
|
|
146
|
+
under git-bash keeps working on a POSIX host -- the names follow that
|
|
147
|
+
choice, not the host."""
|
|
148
|
+
return venv_layout(bin_dir == facts.venv_windows_bin_dir)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def python_candidates(is_windows: bool) -> list[list[str]]:
|
|
152
|
+
"""Host-interpreter candidates to probe, best first.
|
|
153
|
+
|
|
154
|
+
Windows leads with the `py` launcher because a machine can have a perfectly
|
|
155
|
+
good 3.12 with NO bare `python` on PATH, and the bare `python.exe` there is
|
|
156
|
+
very often the Microsoft Store alias -- on PATH, prints nothing.
|
|
157
|
+
"""
|
|
158
|
+
if is_windows:
|
|
159
|
+
return [["py", "-3"], ["python"], ["python3"]]
|
|
160
|
+
return [["python3"], ["python"]]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Version parsing (tan_core::preflight)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_version_tag(revision: str) -> str | None:
|
|
169
|
+
"""`"v4.4.1"` / `"4.4"` / `"v4.4.0-rc1"` -> `"4.4.1"` / `"4.4.0"` /
|
|
170
|
+
`"4.4.0"`. `None` for a branch/SHA with no leading `MAJOR.MINOR`.
|
|
171
|
+
|
|
172
|
+
Normalises the two shapes that would defeat the comparison: a missing PATCH
|
|
173
|
+
reads as `0`, and a pre-release suffix is dropped from the patch component
|
|
174
|
+
rather than failing the whole parse.
|
|
175
|
+
"""
|
|
176
|
+
stripped = revision.strip()
|
|
177
|
+
if stripped.startswith("v"):
|
|
178
|
+
stripped = stripped[1:]
|
|
179
|
+
parts = stripped.split(".")
|
|
180
|
+
if len(parts) < 2:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
major = int(parts[0])
|
|
184
|
+
minor = int(parts[1])
|
|
185
|
+
except ValueError:
|
|
186
|
+
return None
|
|
187
|
+
patch = 0
|
|
188
|
+
if len(parts) > 2:
|
|
189
|
+
digits = re.match(r"\d+", parts[2])
|
|
190
|
+
if digits is not None:
|
|
191
|
+
patch = int(digits.group(0))
|
|
192
|
+
return f"{major}.{minor}.{patch}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_zephyr_version_file(body: str) -> str | None:
|
|
196
|
+
"""`<ZEPHYR_BASE>/VERSION` -> `MAJOR.MINOR.PATCH`. `None` when MAJOR or
|
|
197
|
+
MINOR is missing; PATCHLEVEL defaults to `0`."""
|
|
198
|
+
major: int | None = None
|
|
199
|
+
minor: int | None = None
|
|
200
|
+
patch = 0
|
|
201
|
+
for line in body.splitlines():
|
|
202
|
+
key, sep, value = line.partition("=")
|
|
203
|
+
if not sep:
|
|
204
|
+
continue
|
|
205
|
+
key = key.strip()
|
|
206
|
+
raw = value.strip()
|
|
207
|
+
if key == "VERSION_MAJOR":
|
|
208
|
+
major = _int_or_none(raw)
|
|
209
|
+
elif key == "VERSION_MINOR":
|
|
210
|
+
minor = _int_or_none(raw)
|
|
211
|
+
elif key == "PATCHLEVEL":
|
|
212
|
+
patch = _int_or_none(raw) or 0
|
|
213
|
+
if major is None or minor is None:
|
|
214
|
+
return None
|
|
215
|
+
return f"{major}.{minor}.{patch}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _int_or_none(raw: str) -> int | None:
|
|
219
|
+
try:
|
|
220
|
+
return int(raw)
|
|
221
|
+
except ValueError:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def parse_west_zephyr_pin(body: str) -> str | None:
|
|
226
|
+
"""The Zephyr pin as `MAJOR.MINOR.PATCH` from a `west.yml` body: the
|
|
227
|
+
`manifest.projects[]` entry named `zephyr`, whose `revision` is a tag.
|
|
228
|
+
|
|
229
|
+
PyYAML when importable, else a two-key scan. tan ships no YAML dependency
|
|
230
|
+
and the frozen binary is built without one, so the fallback is THE path on
|
|
231
|
+
the shipped artifact -- the same bargain `presets_cmd._load_som_yaml` and
|
|
232
|
+
`generate_cmd._board_sku` strike.
|
|
233
|
+
"""
|
|
234
|
+
revision = _west_zephyr_revision(body)
|
|
235
|
+
return parse_version_tag(revision) if revision else None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _west_zephyr_revision(body: str) -> str | None:
|
|
239
|
+
try:
|
|
240
|
+
import yaml # noqa: PLC0415 (optional at runtime, by design)
|
|
241
|
+
except ImportError:
|
|
242
|
+
return _scan_west_zephyr_revision(body)
|
|
243
|
+
try:
|
|
244
|
+
doc = yaml.safe_load(body)
|
|
245
|
+
except Exception: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises
|
|
246
|
+
return None
|
|
247
|
+
if not isinstance(doc, dict):
|
|
248
|
+
return None
|
|
249
|
+
manifest = doc.get("manifest")
|
|
250
|
+
if not isinstance(manifest, dict):
|
|
251
|
+
return None
|
|
252
|
+
projects = manifest.get("projects")
|
|
253
|
+
if not isinstance(projects, list):
|
|
254
|
+
return None
|
|
255
|
+
for project in projects:
|
|
256
|
+
if isinstance(project, dict) and project.get("name") == "zephyr":
|
|
257
|
+
revision = project.get("revision")
|
|
258
|
+
return revision if isinstance(revision, str) else None
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _scan_west_zephyr_revision(body: str) -> str | None:
|
|
263
|
+
"""The no-PyYAML reader: `revision:` inside the `- name: zephyr` list item.
|
|
264
|
+
|
|
265
|
+
Answers one question, in either key order (`revision:` may precede
|
|
266
|
+
`name:`), and stops at the next `- ` item so a later project's revision is
|
|
267
|
+
never attributed to zephyr.
|
|
268
|
+
"""
|
|
269
|
+
in_item = False
|
|
270
|
+
is_zephyr = False
|
|
271
|
+
revision: str | None = None
|
|
272
|
+
for raw in body.splitlines():
|
|
273
|
+
stripped = raw.strip()
|
|
274
|
+
if not stripped or stripped.startswith("#"):
|
|
275
|
+
continue
|
|
276
|
+
if stripped.startswith("- "):
|
|
277
|
+
if is_zephyr and revision is not None:
|
|
278
|
+
return revision
|
|
279
|
+
in_item = True
|
|
280
|
+
is_zephyr = False
|
|
281
|
+
revision = None
|
|
282
|
+
stripped = stripped[2:].strip()
|
|
283
|
+
if not in_item:
|
|
284
|
+
continue
|
|
285
|
+
key, sep, value = stripped.partition(":")
|
|
286
|
+
if not sep:
|
|
287
|
+
continue
|
|
288
|
+
cleaned = value.strip().strip("'\"")
|
|
289
|
+
if key.strip() == "name" and cleaned == "zephyr":
|
|
290
|
+
is_zephyr = True
|
|
291
|
+
elif key.strip() == "revision":
|
|
292
|
+
revision = cleaned
|
|
293
|
+
return revision if is_zephyr else None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def resolve_zephyr_pin(west_yml: str | None, facts_version: str) -> str:
|
|
297
|
+
"""The ONE Zephyr pin the workspace-reuse test compares against.
|
|
298
|
+
|
|
299
|
+
`west.yml` leads because `build`'s preflight `zephyrVersion` check reads
|
|
300
|
+
exactly that file, and the operator re-runs `tan bootstrap` ON its warning
|
|
301
|
+
(`tan build` does not do it for them -- tan-cli#427 settled that this port
|
|
302
|
+
has no implicit bootstrap and is not getting one). With
|
|
303
|
+
two pin sources an SDK bump made bootstrap ADOPT a workspace preflight
|
|
304
|
+
simultaneously called stale -- a loop that never converges. Full
|
|
305
|
+
`MAJOR.MINOR.PATCH`, never a `MAJOR.MINOR` truncation: that truncation is
|
|
306
|
+
what let a `v4.4.0` tree satisfy a `v4.4.1` pin, silently.
|
|
307
|
+
"""
|
|
308
|
+
if west_yml is not None:
|
|
309
|
+
pinned = parse_west_zephyr_pin(west_yml)
|
|
310
|
+
if pinned is not None:
|
|
311
|
+
return pinned
|
|
312
|
+
return parse_version_tag(facts_version) or ""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# `metadata/bootstrap.json`
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class BootstrapManifestError(Exception):
|
|
321
|
+
"""A manifest that is present and unusable. NEVER a silent fallback: the
|
|
322
|
+
absent-file case is the legacy path and falls back, but degrading here would
|
|
323
|
+
re-introduce hand-ported behaviour against an SDK that explicitly declared
|
|
324
|
+
something else."""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True)
|
|
328
|
+
class NativeLibHint:
|
|
329
|
+
"""A per-OS optional-native-libs hint. `note` is an ARRAY of lines, not one
|
|
330
|
+
paragraph (the schema's `minItems: 1`): both scripts print one line per
|
|
331
|
+
element, so an aligned `package -> API` mapping survives instead of
|
|
332
|
+
collapsing into a ~380-char unwrapped line."""
|
|
333
|
+
|
|
334
|
+
note: tuple[str, ...]
|
|
335
|
+
command: str | None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclass(frozen=True)
|
|
339
|
+
class Tokens:
|
|
340
|
+
"""`${SDK_ROOT}` / `${WORKSPACE_DIR}` substitution values.
|
|
341
|
+
|
|
342
|
+
Applied at RENDER time, not baked in at load time, because workspace
|
|
343
|
+
selection can repoint `workspace_dir` afterwards (adopting a compatible
|
|
344
|
+
`$ZEPHYR_BASE` tree). `bootstrap.sh` re-substitutes on every
|
|
345
|
+
`print_env_lines` call for exactly this reason; `bootstrap.ps1` binds once
|
|
346
|
+
BEFORE selection and prints the pre-reuse path -- we follow bash.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
sdk_root: str
|
|
350
|
+
workspace_dir: str
|
|
351
|
+
|
|
352
|
+
def apply(self, value: str) -> str:
|
|
353
|
+
"""One blind substitution pass (`tok()` / `Resolve-BootstrapToken`)."""
|
|
354
|
+
return value.replace(TOKEN_SDK_ROOT, self.sdk_root).replace(
|
|
355
|
+
TOKEN_WORKSPACE_DIR, self.workspace_dir
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass(frozen=True)
|
|
360
|
+
class BootstrapFacts:
|
|
361
|
+
"""The workspace-assembly facts, however obtained: parsed from the manifest,
|
|
362
|
+
or reconstructed from the fallback constants for an SDK that predates it.
|
|
363
|
+
|
|
364
|
+
ONE shape for both sources so no step branches on provenance -- only
|
|
365
|
+
`from_manifest` records which it was, for the envelope's
|
|
366
|
+
`factsFromManifest`.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
zephyr_version: str
|
|
370
|
+
zephyr_requirements_path: str
|
|
371
|
+
venv_dir_name: str
|
|
372
|
+
venv_posix_bin_dir: str
|
|
373
|
+
venv_windows_bin_dir: str
|
|
374
|
+
prerequisites_posix: tuple[str, ...]
|
|
375
|
+
#: `prerequisites.macos`, or EMPTY when the manifest declares none -- which
|
|
376
|
+
#: means "read `posix`", the behaviour of every SDK before v0.14.0. See
|
|
377
|
+
#: `prerequisites` for why that fallback is load-bearing rather than tidy.
|
|
378
|
+
prerequisites_macos: tuple[str, ...]
|
|
379
|
+
prerequisites_windows: tuple[str, ...]
|
|
380
|
+
python_min_version: tuple[int, int]
|
|
381
|
+
#: `zephyr.pythonMinVersion` -- tan-cli#606: alp-sdk#1078 (Option A, the
|
|
382
|
+
#: chosen path) added this Zephyr-SCOPED floor alongside the pre-existing
|
|
383
|
+
#: host-universal `prerequisites.pythonMinVersion` above; the two are
|
|
384
|
+
#: deliberately different numbers ("3.12" vs "3.10" as of this writing),
|
|
385
|
+
#: not a duplicate. `None` when the manifest predates the key (every SDK
|
|
386
|
+
#: before alp-sdk's zephyr-scoped-floor change) -- callers fall back to
|
|
387
|
+
#: `doctor_cmd.ZEPHYR_PYTHON_FLOOR` in that case, same as before this field
|
|
388
|
+
#: existed. Not `_require`d: unlike `prerequisites.pythonMinVersion`, an
|
|
389
|
+
#: absent key here is a normal, expected shape, not a malformed manifest.
|
|
390
|
+
zephyr_python_min_version: tuple[int, int] | None
|
|
391
|
+
#: `prerequisites.install`, keyed `linux`/`macos`/`windows`. `macos`/
|
|
392
|
+
#: `windows` map tool -> command directly. `linux` is one level deeper
|
|
393
|
+
#: (alp-sdk#1464 / tan-cli#760): PACKAGE MANAGER -> tool -> command --
|
|
394
|
+
#: `{"apt": {...}, "dnf": {...}}`, `dnf` a SUBSET of `apt`'s tools (see
|
|
395
|
+
#: `normalize_linux_install`/`_fallback_install_commands`). Use
|
|
396
|
+
#: `install_for_host`, never this field directly.
|
|
397
|
+
install: dict[str, dict[str, str] | dict[str, dict[str, str]]]
|
|
398
|
+
#: `artifactProvenance`, tool -> tier/licence/upstream page/size, already
|
|
399
|
+
#: normalised (tan-cli#1066, alp-sdk#1574). TOP-LEVEL in the manifest, not
|
|
400
|
+
#: under `prerequisites`, even though its keys are the prerequisite tool
|
|
401
|
+
#: vocabulary. EMPTY for an SDK predating alp-sdk v0.16.0, and empty for a
|
|
402
|
+
#: malformed block -- never an error: this is advisory metadata for a
|
|
403
|
+
#: consumer's consent screen, and refusing to bootstrap over it would trade
|
|
404
|
+
#: a working host for a display fact. See `tan.core.artifact_provenance`.
|
|
405
|
+
artifact_provenance: dict[str, ArtifactProvenance]
|
|
406
|
+
west_pip_spec: str
|
|
407
|
+
west_init_args: tuple[str, ...]
|
|
408
|
+
west_update_args: tuple[str, ...]
|
|
409
|
+
west_export_args: tuple[str, ...]
|
|
410
|
+
west_extension_guard: str
|
|
411
|
+
pip_bootstrap_upgrade: tuple[str, ...]
|
|
412
|
+
pip_sdk_extras: tuple[str, ...]
|
|
413
|
+
#: `pip install -e` target: alp-sdk's own `alp_cli`/`alp_mcp` tooling (the
|
|
414
|
+
#: `alp-mcp` console script). Not a planner install -- tan plans in-process
|
|
415
|
+
#: (tan-cli#270).
|
|
416
|
+
pip_editable_install: str
|
|
417
|
+
#: `env`, ordered, still tokened. A list of pairs because ORDER is what
|
|
418
|
+
#: makes the rendered `export`/`$env:` lines come out in the manifest's
|
|
419
|
+
#: declared order (serde's `preserve_order`; `json.loads` gives it free).
|
|
420
|
+
env: tuple[tuple[str, str], ...]
|
|
421
|
+
hint_linux: NativeLibHint
|
|
422
|
+
hint_macos: NativeLibHint
|
|
423
|
+
hint_windows: NativeLibHint
|
|
424
|
+
manual_install_windows: tuple[str, ...]
|
|
425
|
+
#: `manualInstallHints.posix.note` -- EMPTY when the manifest declares no
|
|
426
|
+
#: `posix` key at all (tan-cli#495 defect 6). Optional on the wire, unlike
|
|
427
|
+
#: `windows`: every SDK before alp-sdk v0.14.0 declares `windows` alone,
|
|
428
|
+
#: and requiring it here would turn each of those into a hard
|
|
429
|
+
#: `BootstrapManifestError` for everyone who runs `tan bootstrap` against
|
|
430
|
+
#: an older SDK -- the same trap `prerequisites.install` and
|
|
431
|
+
#: `prerequisites.windows` above already document. Empty renders nothing,
|
|
432
|
+
#: which is exactly what those SDKs did before the field existed.
|
|
433
|
+
manual_install_posix: tuple[str, ...]
|
|
434
|
+
from_manifest: bool
|
|
435
|
+
|
|
436
|
+
def venv_bin_dir(self, is_windows: bool) -> str:
|
|
437
|
+
return self.venv_windows_bin_dir if is_windows else self.venv_posix_bin_dir
|
|
438
|
+
|
|
439
|
+
def prerequisites(self, host: str) -> tuple[str, ...]:
|
|
440
|
+
"""The tool list for this host. The lists genuinely differ (`python` vs
|
|
441
|
+
`python3`) and the manifest records that faithfully rather than
|
|
442
|
+
unifying them -- so does this.
|
|
443
|
+
|
|
444
|
+
Takes the HOST, not `is_windows`, since alp-sdk v0.14.0: that release
|
|
445
|
+
added `xz` and `wget` to `prerequisites.posix` AND a separate
|
|
446
|
+
`prerequisites.macos` that omits them. Keying off a bool hands macOS the
|
|
447
|
+
POSIX list and refuses a stock macOS host -- which ships neither `wget`
|
|
448
|
+
nor a standalone `xz` -- over tools the SDK does not ask macOS for.
|
|
449
|
+
|
|
450
|
+
An EMPTY `prerequisites_macos` means the manifest declared none (every
|
|
451
|
+
SDK before v0.14.0), and macOS then reads `posix` exactly as it always
|
|
452
|
+
did. The fallback is the old behaviour, not a guess.
|
|
453
|
+
"""
|
|
454
|
+
if host == WINDOWS:
|
|
455
|
+
return self.prerequisites_windows
|
|
456
|
+
if host == MACOS and self.prerequisites_macos:
|
|
457
|
+
return self.prerequisites_macos
|
|
458
|
+
return self.prerequisites_posix
|
|
459
|
+
|
|
460
|
+
def install_for_host(self, host: str, linux_pm: str | None = None) -> dict[str, str]:
|
|
461
|
+
"""THE one place the manifest's `linux`/`macos`/`windows` install keying
|
|
462
|
+
is reconciled with `prerequisites`' `posix`/`windows` tool-list keying.
|
|
463
|
+
Callers resolve once, by host, and hand the resolved map down -- so no
|
|
464
|
+
caller can look a tool up in the wrong OS's table (a POSIX refusal on
|
|
465
|
+
macOS getting Linux's `apt-get` lines).
|
|
466
|
+
|
|
467
|
+
`OTHER` (a POSIX host that is neither Linux nor macOS) has no manifest
|
|
468
|
+
entry and is not going to grow one: every tool there reports
|
|
469
|
+
`command: null`. The alternatives are both worse than the `null` -- a
|
|
470
|
+
throw, or handing a BSD user a `brew install` line.
|
|
471
|
+
|
|
472
|
+
`linux_pm` (alp-sdk#1464 / tan-cli#760) is the extra hop `host ==
|
|
473
|
+
LINUX` needs: `self.install[LINUX]` is package-manager -> tool ->
|
|
474
|
+
command, so a caller must say WHICH sub-map it wants. `None` (the
|
|
475
|
+
default) is an EMPTY map, never a guess -- a caller with no confirmed
|
|
476
|
+
package manager (`detect_linux_pm`) gets `command: null` for every
|
|
477
|
+
tool, not one PM's data served for another's.
|
|
478
|
+
"""
|
|
479
|
+
if host == LINUX:
|
|
480
|
+
if linux_pm is None:
|
|
481
|
+
return {}
|
|
482
|
+
sub = self.install.get(LINUX, {})
|
|
483
|
+
node = sub.get(linux_pm) if isinstance(sub, dict) else None
|
|
484
|
+
return dict(node) if isinstance(node, dict) else {}
|
|
485
|
+
return self.install.get(host, {})
|
|
486
|
+
|
|
487
|
+
def native_lib_hint(self, host: str) -> NativeLibHint | None:
|
|
488
|
+
"""`None` for `OTHER` -- `bootstrap.sh`'s `*)` arm prints no hint, just
|
|
489
|
+
the not-detected line."""
|
|
490
|
+
return {
|
|
491
|
+
LINUX: self.hint_linux,
|
|
492
|
+
MACOS: self.hint_macos,
|
|
493
|
+
WINDOWS: self.hint_windows,
|
|
494
|
+
}.get(host)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _str_list(value: Any, what: str) -> tuple[str, ...]:
|
|
498
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
499
|
+
raise BootstrapManifestError(
|
|
500
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: `{what}` is not a list of strings"
|
|
501
|
+
)
|
|
502
|
+
return tuple(value)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _require(doc: Any, key: str, kind: type, what: str) -> Any:
|
|
506
|
+
if not isinstance(doc, dict):
|
|
507
|
+
raise BootstrapManifestError(
|
|
508
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: `{what}` is not an object"
|
|
509
|
+
)
|
|
510
|
+
value = doc.get(key)
|
|
511
|
+
if not isinstance(value, kind):
|
|
512
|
+
raise BootstrapManifestError(
|
|
513
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped "
|
|
514
|
+
f"`{what}.{key}`"
|
|
515
|
+
)
|
|
516
|
+
return value
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _hint(doc: Any, key: str) -> NativeLibHint:
|
|
520
|
+
node = doc.get(key) if isinstance(doc, dict) else None
|
|
521
|
+
if not isinstance(node, dict):
|
|
522
|
+
raise BootstrapManifestError(
|
|
523
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped "
|
|
524
|
+
f"`nativeLibHints.{key}`"
|
|
525
|
+
)
|
|
526
|
+
command = node.get("command")
|
|
527
|
+
return NativeLibHint(
|
|
528
|
+
note=_str_list(node.get("note"), f"nativeLibHints.{key}.note"),
|
|
529
|
+
command=command if isinstance(command, str) else None,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def parse_min_version(raw: str) -> tuple[int, int] | None:
|
|
534
|
+
"""`"3.10"` -> `(3, 10)`."""
|
|
535
|
+
major, sep, minor = raw.strip().partition(".")
|
|
536
|
+
if not sep:
|
|
537
|
+
return None
|
|
538
|
+
try:
|
|
539
|
+
return int(major.strip()), int(minor.strip())
|
|
540
|
+
except ValueError:
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def is_plain_relative(raw: str) -> bool:
|
|
545
|
+
"""A relative path with no `..`, no root and no drive letter -- the shape a
|
|
546
|
+
manifest-supplied directory name must have before it is joined onto the
|
|
547
|
+
workspace (`tan_core::path_guard::is_plain_relative`)."""
|
|
548
|
+
if not raw or raw != raw.strip():
|
|
549
|
+
return False
|
|
550
|
+
if os.path.isabs(raw) or ntpath_isabs(raw):
|
|
551
|
+
return False
|
|
552
|
+
parts = re.split(r"[\\/]", raw)
|
|
553
|
+
return all(part not in ("", ".", "..") for part in parts)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def ntpath_isabs(raw: str) -> bool:
|
|
557
|
+
"""Windows-shaped absoluteness (`C:\\x`, `\\\\server\\share`, `\\x`),
|
|
558
|
+
checked on EVERY host: the manifest is authored once and consumed on all
|
|
559
|
+
three, so a POSIX `os.path.isabs` alone would wave `C:\\Windows` through.
|
|
560
|
+
|
|
561
|
+
NOT version-stable, and deliberately left that way (tan-cli#1139): its
|
|
562
|
+
answer for a rooted-but-driveless path flips at CPython 3.13, and both
|
|
563
|
+
sides are supported. Its only two callers absorb that -- `is_plain_relative`
|
|
564
|
+
above is indifferent (its component scan rejects every divergent input
|
|
565
|
+
anyway), and `resolve_workspace_target` decides the shape up front in
|
|
566
|
+
`_is_rooted_no_drive`, which owns the measurement and the reasoning. A
|
|
567
|
+
THIRD caller would have to do the same: do not read a version-stable
|
|
568
|
+
answer out of this."""
|
|
569
|
+
import ntpath # noqa: PLC0415 -- one call site
|
|
570
|
+
|
|
571
|
+
return ntpath.isabs(raw) or bool(re.match(r"^[A-Za-z]:", raw))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def parse_bootstrap_manifest(text: str) -> BootstrapFacts:
|
|
575
|
+
"""Parse `metadata/bootstrap.json`. Pure -- the caller reads the file and
|
|
576
|
+
decides what an absent file means (see `fallback_facts`).
|
|
577
|
+
|
|
578
|
+
`schemaVersion` is read on its own FIRST: a future manifest may legitimately
|
|
579
|
+
reshape fields this consumer would otherwise fail on, and the user deserves
|
|
580
|
+
"unsupported version N", not "missing field `foo`".
|
|
581
|
+
"""
|
|
582
|
+
try:
|
|
583
|
+
doc = json.loads(text)
|
|
584
|
+
except ValueError as err:
|
|
585
|
+
raise BootstrapManifestError(
|
|
586
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: {err}"
|
|
587
|
+
) from err
|
|
588
|
+
if not isinstance(doc, dict):
|
|
589
|
+
raise BootstrapManifestError(
|
|
590
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: not a JSON object"
|
|
591
|
+
)
|
|
592
|
+
version = doc.get("schemaVersion")
|
|
593
|
+
# `bool` excluded explicitly: `True == 1` in Python, so `schemaVersion: true`
|
|
594
|
+
# would pass an `== 1` test that serde's `as_u64()` rejects.
|
|
595
|
+
if not isinstance(version, int) or isinstance(version, bool):
|
|
596
|
+
raise BootstrapManifestError(
|
|
597
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing `schemaVersion`"
|
|
598
|
+
)
|
|
599
|
+
if version != BOOTSTRAP_MANIFEST_SCHEMA_VERSION:
|
|
600
|
+
raise BootstrapManifestError(
|
|
601
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} declares schemaVersion {version}, but this "
|
|
602
|
+
f"`tan` supports only {BOOTSTRAP_MANIFEST_SCHEMA_VERSION}. Update `tan`, or "
|
|
603
|
+
f"pin an SDK whose bootstrap manifest this version understands."
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
zephyr = doc.get("zephyr")
|
|
607
|
+
venv = doc.get("venv")
|
|
608
|
+
prerequisites = doc.get("prerequisites")
|
|
609
|
+
west = doc.get("west")
|
|
610
|
+
pip = doc.get("pip")
|
|
611
|
+
env = doc.get("env")
|
|
612
|
+
hints = doc.get("nativeLibHints")
|
|
613
|
+
manual = doc.get("manualInstallHints")
|
|
614
|
+
|
|
615
|
+
min_raw = _require(prerequisites, "pythonMinVersion", str, "prerequisites")
|
|
616
|
+
python_min_version = parse_min_version(min_raw)
|
|
617
|
+
if python_min_version is None:
|
|
618
|
+
raise BootstrapManifestError(
|
|
619
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: "
|
|
620
|
+
f"prerequisites.pythonMinVersion `{min_raw}` is not MAJOR.MINOR"
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
# OPTIONAL, unlike `prerequisites.pythonMinVersion` above: absent is the
|
|
624
|
+
# normal shape for any manifest predating alp-sdk#1078's zephyr-scoped
|
|
625
|
+
# floor, not a malformed one -- so a present-but-unparseable value still
|
|
626
|
+
# hard-fails (it was deliberately authored), but a missing key does not.
|
|
627
|
+
zephyr_min_raw = zephyr.get("pythonMinVersion") if isinstance(zephyr, dict) else None
|
|
628
|
+
zephyr_python_min_version: tuple[int, int] | None = None
|
|
629
|
+
if zephyr_min_raw is not None:
|
|
630
|
+
if not isinstance(zephyr_min_raw, str):
|
|
631
|
+
raise BootstrapManifestError(
|
|
632
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: "
|
|
633
|
+
f"zephyr.pythonMinVersion is not a string"
|
|
634
|
+
)
|
|
635
|
+
zephyr_python_min_version = parse_min_version(zephyr_min_raw)
|
|
636
|
+
if zephyr_python_min_version is None:
|
|
637
|
+
raise BootstrapManifestError(
|
|
638
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: "
|
|
639
|
+
f"zephyr.pythonMinVersion `{zephyr_min_raw}` is not MAJOR.MINOR"
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
dir_name = _require(venv, "dirName", str, "venv")
|
|
643
|
+
# `venv.dirName` joins straight onto `workspace_dir` and the join's result is
|
|
644
|
+
# later handed to `rmtree` when a stale venv is recreated -- an unvalidated
|
|
645
|
+
# `..`-bearing or absolute value would let the manifest name an arbitrary
|
|
646
|
+
# removal target outside the workspace. Rejected at this one seam, which
|
|
647
|
+
# every consumer of the name reads through.
|
|
648
|
+
if not is_plain_relative(dir_name):
|
|
649
|
+
raise BootstrapManifestError(
|
|
650
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: venv.dirName "
|
|
651
|
+
f"`{dir_name}` is not a plain relative path"
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if not isinstance(env, dict):
|
|
655
|
+
raise BootstrapManifestError(
|
|
656
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped `env`"
|
|
657
|
+
)
|
|
658
|
+
manual_node = manual.get("windows") if isinstance(manual, dict) else None
|
|
659
|
+
posix_node = manual.get("posix") if isinstance(manual, dict) else None
|
|
660
|
+
if not isinstance(manual_node, dict):
|
|
661
|
+
raise BootstrapManifestError(
|
|
662
|
+
f"{BOOTSTRAP_MANIFEST_REL_PATH} could not be read: missing or mistyped "
|
|
663
|
+
f"`manualInstallHints.windows`"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
return BootstrapFacts(
|
|
667
|
+
zephyr_version=_require(zephyr, "version", str, "zephyr"),
|
|
668
|
+
zephyr_requirements_path=_require(zephyr, "requirementsPath", str, "zephyr"),
|
|
669
|
+
venv_dir_name=dir_name,
|
|
670
|
+
venv_posix_bin_dir=_require(venv, "posixBinDir", str, "venv"),
|
|
671
|
+
venv_windows_bin_dir=_require(venv, "windowsBinDir", str, "venv"),
|
|
672
|
+
prerequisites_posix=_str_list(prerequisites.get("posix"), "prerequisites.posix"),
|
|
673
|
+
# OPTIONAL on the wire: absent means "use `posix`", which is every SDK
|
|
674
|
+
# before v0.14.0. Required here, it would turn each of those into a hard
|
|
675
|
+
# ValidationFailure for every `tan bootstrap` run against such an SDK.
|
|
676
|
+
prerequisites_macos=_str_list(prerequisites.get("macos", []), "prerequisites.macos"),
|
|
677
|
+
prerequisites_windows=_str_list(
|
|
678
|
+
prerequisites.get("windows"), "prerequisites.windows"
|
|
679
|
+
),
|
|
680
|
+
python_min_version=python_min_version,
|
|
681
|
+
zephyr_python_min_version=zephyr_python_min_version,
|
|
682
|
+
install=_resolve_install_commands(prerequisites.get("install")),
|
|
683
|
+
# Read off `doc`, not `prerequisites`: alp-sdk#1574 put the block at
|
|
684
|
+
# the manifest's TOP level. Never `_require`d -- see the field comment.
|
|
685
|
+
artifact_provenance=artifact_provenance.parse_table(
|
|
686
|
+
doc.get(artifact_provenance.BLOCK_KEY)
|
|
687
|
+
),
|
|
688
|
+
west_pip_spec=_require(west, "pipSpec", str, "west"),
|
|
689
|
+
west_init_args=_str_list(_require(west, "initArgs", list, "west"), "west.initArgs"),
|
|
690
|
+
west_update_args=_str_list(
|
|
691
|
+
_require(west, "updateArgs", list, "west"), "west.updateArgs"
|
|
692
|
+
),
|
|
693
|
+
west_export_args=_str_list(
|
|
694
|
+
_require(west, "exportArgs", list, "west"), "west.exportArgs"
|
|
695
|
+
),
|
|
696
|
+
west_extension_guard=_require(west, "extensionGuardCommand", str, "west"),
|
|
697
|
+
pip_bootstrap_upgrade=_str_list(
|
|
698
|
+
_require(pip, "bootstrapUpgrade", list, "pip"), "pip.bootstrapUpgrade"
|
|
699
|
+
),
|
|
700
|
+
pip_sdk_extras=_str_list(_require(pip, "sdkExtras", list, "pip"), "pip.sdkExtras"),
|
|
701
|
+
pip_editable_install=_require(pip, "editableInstall", str, "pip"),
|
|
702
|
+
# A non-string value degrades to `""` rather than failing the manifest,
|
|
703
|
+
# matching serde's `v.as_str().unwrap_or_default()`.
|
|
704
|
+
env=tuple((k, v if isinstance(v, str) else "") for k, v in env.items()),
|
|
705
|
+
hint_linux=_hint(hints, LINUX),
|
|
706
|
+
hint_macos=_hint(hints, MACOS),
|
|
707
|
+
hint_windows=_hint(hints, WINDOWS),
|
|
708
|
+
manual_install_windows=_str_list(
|
|
709
|
+
manual_node.get("note"), "manualInstallHints.windows.note"
|
|
710
|
+
),
|
|
711
|
+
# OPTIONAL on the wire, unlike `windows` just above: a missing or
|
|
712
|
+
# non-dict `posix` is an empty list, never an error (see the field's
|
|
713
|
+
# own comment on `BootstrapFacts`).
|
|
714
|
+
manual_install_posix=(
|
|
715
|
+
_str_list(posix_node.get("note"), "manualInstallHints.posix.note")
|
|
716
|
+
if isinstance(posix_node, dict)
|
|
717
|
+
else ()
|
|
718
|
+
),
|
|
719
|
+
from_manifest=True,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _fallback_install_commands() -> dict[str, dict[str, str] | dict[str, dict[str, str]]]:
|
|
724
|
+
"""The install one-liners as `metadata/bootstrap.json` carries them.
|
|
725
|
+
|
|
726
|
+
Two callers: the whole-manifest fallback, and `_resolve_install_commands`'s
|
|
727
|
+
gap-fill for a manifest predating alp-sdk#959 (which carried no `install`
|
|
728
|
+
key at all). Note `ninja`'s PACKAGE name differs from the binary name --
|
|
729
|
+
which is the whole argument for carrying these as data rather than guessing.
|
|
730
|
+
|
|
731
|
+
`LINUX` is re-pinned to alp-sdk `dev` @ `7a419865` (tan-cli#760's second
|
|
732
|
+
half / alp-sdk#1464+#1471): package-manager-keyed now, matching
|
|
733
|
+
`BootstrapFacts.install`'s own nested shape, not the flat tool -> command
|
|
734
|
+
map this table carried before. `dnf` has no `ninja` entry and there is no
|
|
735
|
+
`pacman` key at all -- both deliberate DATA (see `detect_linux_pm`), not
|
|
736
|
+
gaps to fill in later.
|
|
737
|
+
"""
|
|
738
|
+
return {
|
|
739
|
+
LINUX: {
|
|
740
|
+
LINUX_PM_APT: {
|
|
741
|
+
"git": "sudo apt-get install -y git",
|
|
742
|
+
"cmake": "sudo apt-get install -y cmake",
|
|
743
|
+
"python3": "sudo apt-get install -y python3",
|
|
744
|
+
"ninja": "sudo apt-get install -y ninja-build",
|
|
745
|
+
# `xz`/`wget` joined `prerequisites.posix` at alp-sdk v0.14.0.
|
|
746
|
+
# Same package-name-differs-from-binary-name point as `ninja`:
|
|
747
|
+
# the binary is `xz`, the package is `xz-utils`.
|
|
748
|
+
"xz": "sudo apt-get install -y xz-utils",
|
|
749
|
+
"wget": "sudo apt-get install -y wget",
|
|
750
|
+
},
|
|
751
|
+
LINUX_PM_DNF: {
|
|
752
|
+
"git": "sudo dnf install -y git",
|
|
753
|
+
"cmake": "sudo dnf install -y cmake",
|
|
754
|
+
"python3": "sudo dnf install -y python3",
|
|
755
|
+
# No `ninja` -- see this function's own docstring.
|
|
756
|
+
"xz": "sudo dnf install -y xz",
|
|
757
|
+
"wget": "sudo dnf install -y wget",
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
MACOS: {
|
|
761
|
+
"git": "brew install git",
|
|
762
|
+
"cmake": "brew install cmake",
|
|
763
|
+
"python3": "brew install python3",
|
|
764
|
+
"ninja": "brew install ninja",
|
|
765
|
+
# Present even though `prerequisites.macos` does NOT list `xz`/`wget`
|
|
766
|
+
# -- the manifest declares these commands for macOS regardless, and
|
|
767
|
+
# this table is byte-pinned to it. A user who needs them (an SDK
|
|
768
|
+
# predating `prerequisites.macos`, so macOS reads the POSIX list)
|
|
769
|
+
# gets the `brew` line rather than Linux's `apt-get`.
|
|
770
|
+
"xz": "brew install xz",
|
|
771
|
+
"wget": "brew install wget",
|
|
772
|
+
},
|
|
773
|
+
WINDOWS: {
|
|
774
|
+
"git": "winget install -e --id Git.Git",
|
|
775
|
+
"cmake": "winget install -e --id Kitware.CMake",
|
|
776
|
+
"python": "winget install -e --id Python.Python.3.12",
|
|
777
|
+
"ninja": "winget install -e --id Ninja-build.Ninja",
|
|
778
|
+
# NOT in `prerequisites.windows` -- the manifest declares an install
|
|
779
|
+
# command for a tool it does not require. `7z` is what unpacks the
|
|
780
|
+
# Zephyr SDK's native-Windows hosttools archive, so a Windows user
|
|
781
|
+
# who takes that optional step needs the line; bootstrap.ps1 itself
|
|
782
|
+
# does not. Carried because this table is byte-pinned to the
|
|
783
|
+
# manifest's `prerequisites.install.windows`, not to its tool LIST.
|
|
784
|
+
"7zip": "winget install -e --id 7zip.7zip",
|
|
785
|
+
},
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def normalize_linux_install(raw: Any) -> dict[str, dict[str, str]]:
|
|
790
|
+
"""`prerequisites.install.linux`, canonicalised to PACKAGE MANAGER -> tool
|
|
791
|
+
-> command, whichever of the two shapes alp-sdk declared it in.
|
|
792
|
+
|
|
793
|
+
**New shape** (alp-sdk#1464, `dev` @ `7a419865` on): `{"apt": {tool:
|
|
794
|
+
command}, "dnf": {tool: command}}` -- detected structurally, by every
|
|
795
|
+
top-level value being a dict, not by key name, so a package manager this
|
|
796
|
+
parser has never heard of is still readable rather than silently dropped.
|
|
797
|
+
|
|
798
|
+
**Legacy shape** (every manifest before alp-sdk#1471): a FLAT tool ->
|
|
799
|
+
command map, unconditionally Debian's -- that key had no other content
|
|
800
|
+
until #1464 gave it a package-manager dimension at all. Read here AS
|
|
801
|
+
`apt`'s sub-map (design decision (4)): a NEW tan against an OLD
|
|
802
|
+
`--sdk-root` still works on a real apt host exactly as it always did, and
|
|
803
|
+
`select_linux_install` never hands the same data out to a dnf/pacman/other
|
|
804
|
+
caller -- read what was always there, under the key it was implicitly
|
|
805
|
+
for, and let `detect_linux_pm` decide who may see it.
|
|
806
|
+
|
|
807
|
+
Empty/absent/malformed input returns `{}` -- callers fall back to
|
|
808
|
+
`_fallback_install_commands()[LINUX]`, matching every other per-OS gap in
|
|
809
|
+
`_resolve_install_commands`.
|
|
810
|
+
"""
|
|
811
|
+
if not isinstance(raw, dict) or not raw:
|
|
812
|
+
return {}
|
|
813
|
+
if all(isinstance(v, dict) for v in raw.values()):
|
|
814
|
+
out: dict[str, dict[str, str]] = {}
|
|
815
|
+
for pm, tools in raw.items():
|
|
816
|
+
if not isinstance(pm, str) or not isinstance(tools, dict):
|
|
817
|
+
continue
|
|
818
|
+
clean = {k: v for k, v in tools.items() if isinstance(k, str) and isinstance(v, str)}
|
|
819
|
+
if clean:
|
|
820
|
+
out[pm] = clean
|
|
821
|
+
return out
|
|
822
|
+
# Legacy flat shape -- read as `apt`'s sub-map (see the docstring above).
|
|
823
|
+
clean = {k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)}
|
|
824
|
+
return {LINUX_PM_APT: clean} if clean else {}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def select_linux_install(normalized: dict[str, dict[str, str]], pm: str | None) -> dict[str, str]:
|
|
828
|
+
"""The tool -> command map for ONE package manager, out of
|
|
829
|
+
`normalize_linux_install`'s output. `pm=None` -- no package manager
|
|
830
|
+
confirmed on this host (`detect_linux_pm` found neither `apt-get` nor
|
|
831
|
+
`dnf`, or found a third one this manifest ships no sub-map for, e.g.
|
|
832
|
+
Arch's `pacman`) -- is `{}`, never a different PM's data: the whole point
|
|
833
|
+
of keying `install.linux` by package manager is that one PM's shape must
|
|
834
|
+
never stand in for another's."""
|
|
835
|
+
if pm is None:
|
|
836
|
+
return {}
|
|
837
|
+
sub = normalized.get(pm)
|
|
838
|
+
return dict(sub) if isinstance(sub, dict) else {}
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def detect_linux_pm(available: Callable[[str], bool]) -> str | None:
|
|
842
|
+
"""Which package-manager sub-map to read under `install.linux`
|
|
843
|
+
(alp-sdk#1464 / tan-cli#760) -- `apt` checked before `dnf`, `pacman`
|
|
844
|
+
never probed.
|
|
845
|
+
|
|
846
|
+
**Order and fallthrough MUST agree with alp-sdk's own two detectors** --
|
|
847
|
+
`scripts/bootstrap.sh`'s `LINUX_PM` block and `scripts/alp_cli/doctor.py`'s
|
|
848
|
+
`_prereq_linux_pm()` -- both probe `apt-get` before `dnf`, neither probes
|
|
849
|
+
`pacman`, both fall through to a host-neutral degrade when neither
|
|
850
|
+
resolves. A third detector that disagrees with those two is a future
|
|
851
|
+
drift bug: keep this order in lockstep with those two by hand; there is
|
|
852
|
+
no shared gate across the two repos that would.
|
|
853
|
+
|
|
854
|
+
`pacman` is deliberately never probed: `install.linux` ships no `pacman`
|
|
855
|
+
sub-map at all (an unattended `pacman -Sy` risks a partial upgrade) --
|
|
856
|
+
detecting it would only ever resolve to the same `None` below.
|
|
857
|
+
|
|
858
|
+
Pure: `available` is injected (`on_path(binary) is not None`), matching
|
|
859
|
+
every other PATH check in this module -- see `_confirmed`.
|
|
860
|
+
"""
|
|
861
|
+
if available("apt-get"):
|
|
862
|
+
return LINUX_PM_APT
|
|
863
|
+
if available("dnf"):
|
|
864
|
+
return LINUX_PM_DNF
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _resolve_install_commands(
|
|
869
|
+
declared: Any,
|
|
870
|
+
) -> dict[str, dict[str, str] | dict[str, dict[str, str]]]:
|
|
871
|
+
"""`prerequisites.install` as parsed, with each EMPTY per-OS map replaced by
|
|
872
|
+
the fallback's.
|
|
873
|
+
|
|
874
|
+
PER OS, not whole-subtree: `install: {}` -- or one carrying `windows` alone
|
|
875
|
+
-- is indistinguishable from an absent key after parsing, and filling only
|
|
876
|
+
the whole subtree would hand the absent OSes empty maps. On Windows that is
|
|
877
|
+
the real pre-#959 loss: all four `winget` lines vanish. Emptiness is the
|
|
878
|
+
signal because a SERVED OS map is never legitimately empty (the producer's
|
|
879
|
+
schema requires its keys to equal `prerequisites.<os>`).
|
|
880
|
+
|
|
881
|
+
Degrade, do not refuse: every shape handled here is out of contract today,
|
|
882
|
+
and a `ValidationFailure` on a manifest field fails the whole
|
|
883
|
+
`tan bootstrap` run -- which is the step a customer has to get through
|
|
884
|
+
before `tan build` or `tan run` can work at all, since neither of those
|
|
885
|
+
bootstraps implicitly (tan-cli#427).
|
|
886
|
+
|
|
887
|
+
`LINUX` is handled separately (tan-cli#760's second half):
|
|
888
|
+
`normalize_linux_install` reconciles both shapes `install.linux` has ever
|
|
889
|
+
had into one PM-keyed map, so `BootstrapFacts.install[LINUX]` is ALWAYS
|
|
890
|
+
`{pm: {tool: command}}`.
|
|
891
|
+
"""
|
|
892
|
+
fallback = _fallback_install_commands()
|
|
893
|
+
if not isinstance(declared, dict):
|
|
894
|
+
return fallback
|
|
895
|
+
out: dict[str, dict[str, str] | dict[str, dict[str, str]]] = {}
|
|
896
|
+
for host in (MACOS, WINDOWS):
|
|
897
|
+
node = declared.get(host)
|
|
898
|
+
clean = (
|
|
899
|
+
{k: v for k, v in node.items() if isinstance(k, str) and isinstance(v, str)}
|
|
900
|
+
if isinstance(node, dict)
|
|
901
|
+
else {}
|
|
902
|
+
)
|
|
903
|
+
out[host] = clean or fallback[host]
|
|
904
|
+
out[LINUX] = normalize_linux_install(declared.get(LINUX)) or fallback[LINUX]
|
|
905
|
+
return out
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def fallback_facts(min_python: tuple[int, int]) -> BootstrapFacts:
|
|
909
|
+
"""The hand-ported facts, for an SDK with no `metadata/bootstrap.json`.
|
|
910
|
+
|
|
911
|
+
LAST-KNOWN values transcribed from the pre-#917 scripts. The manifest wins
|
|
912
|
+
outright when present, so an SDK-side pin bump reaches tan without a tan
|
|
913
|
+
release; `check_bootstrap_manifest.py` does not scan this file, so treat
|
|
914
|
+
every literal below as stale-by-default.
|
|
915
|
+
"""
|
|
916
|
+
return BootstrapFacts(
|
|
917
|
+
zephyr_version=ZEPHYR_VERSION,
|
|
918
|
+
zephyr_requirements_path="zephyr/scripts/requirements.txt",
|
|
919
|
+
venv_dir_name=".venv",
|
|
920
|
+
venv_posix_bin_dir="bin",
|
|
921
|
+
venv_windows_bin_dir="Scripts",
|
|
922
|
+
# `ninja` is POSIX too, not Windows-only: Zephyr picks Ninja as its
|
|
923
|
+
# default CMake generator on every host, so a POSIX box without it fails
|
|
924
|
+
# `west build` with a CMake error naming nothing useful. `xz`/`wget`
|
|
925
|
+
# joined the list at alp-sdk v0.14.0, which also split `macos` out
|
|
926
|
+
# WITHOUT them -- a stock macOS host has neither.
|
|
927
|
+
prerequisites_posix=("git", "cmake", "python3", "ninja", "xz", "wget"),
|
|
928
|
+
prerequisites_macos=("git", "cmake", "python3", "ninja"),
|
|
929
|
+
prerequisites_windows=("git", "cmake", "python", "ninja"),
|
|
930
|
+
python_min_version=min_python,
|
|
931
|
+
# Transcribed from `contract/fixtures/bootstrap/manifest.json`'s
|
|
932
|
+
# `zephyr.pythonMinVersion`, same as `zephyr_version` above -- held to
|
|
933
|
+
# the vendored fixture by
|
|
934
|
+
# `test_the_fallback_constants_match_the_real_manifest_field_for_field`,
|
|
935
|
+
# NOT derived from `min_python` (the caller's `prerequisites.
|
|
936
|
+
# pythonMinVersion`): the two keys are independently-declared numbers
|
|
937
|
+
# in the real manifest ("3.12" vs "3.10" as of this writing) and must
|
|
938
|
+
# not be conflated here either.
|
|
939
|
+
zephyr_python_min_version=(3, 12),
|
|
940
|
+
install=_fallback_install_commands(),
|
|
941
|
+
# No fallback provenance, deliberately: an SDK with no manifest at all
|
|
942
|
+
# publishes no licensing claim, and transcribing one here would be tan
|
|
943
|
+
# ASSERTING a licence nobody handed it -- the one thing the `null`
|
|
944
|
+
# spelling exists to avoid (tan-cli#1066).
|
|
945
|
+
artifact_provenance={},
|
|
946
|
+
west_pip_spec=WEST_REQUIREMENT,
|
|
947
|
+
west_init_args=("init", "-l"),
|
|
948
|
+
west_update_args=("update", "--narrow", "-o=--depth=1"),
|
|
949
|
+
west_export_args=("zephyr-export",),
|
|
950
|
+
west_extension_guard="alp-migrate",
|
|
951
|
+
pip_bootstrap_upgrade=("pip", "wheel"),
|
|
952
|
+
pip_sdk_extras=("jsonschema", "imgtool"),
|
|
953
|
+
pip_editable_install=TOKEN_SDK_ROOT,
|
|
954
|
+
env=(
|
|
955
|
+
("ZEPHYR_BASE", f"{TOKEN_WORKSPACE_DIR}/zephyr"),
|
|
956
|
+
("ZEPHYR_TOOLCHAIN_VARIANT", "zephyr"),
|
|
957
|
+
),
|
|
958
|
+
# The note arrays are transcribed VERBATIM, intra-line padding included:
|
|
959
|
+
# the manifest carries the `->` column alignment, and re-wrapping here
|
|
960
|
+
# would make the fallback print differently from the manifest path.
|
|
961
|
+
hint_linux=NativeLibHint(
|
|
962
|
+
note=(
|
|
963
|
+
"libmosquitto-dev -> alp_mqtt_* (cleartext + TLS)",
|
|
964
|
+
"libasound2-dev -> alp_audio_*",
|
|
965
|
+
"libssl-dev -> alp_hash_* / alp_aead_* / alp_random_bytes",
|
|
966
|
+
),
|
|
967
|
+
command=(
|
|
968
|
+
"sudo apt-get install -y libmosquitto-dev libasound2-dev libssl-dev "
|
|
969
|
+
"pkg-config"
|
|
970
|
+
),
|
|
971
|
+
),
|
|
972
|
+
hint_macos=NativeLibHint(
|
|
973
|
+
note=(
|
|
974
|
+
"Equivalents via Homebrew:",
|
|
975
|
+
"mosquitto -> alp_mqtt_* (cleartext + TLS)",
|
|
976
|
+
"macOS uses CoreAudio rather than ALSA, so the Yocto audio backend "
|
|
977
|
+
"doesn't apply on macOS hosts.",
|
|
978
|
+
"OpenSSL ships with macOS.",
|
|
979
|
+
),
|
|
980
|
+
command="brew install mosquitto pkg-config",
|
|
981
|
+
),
|
|
982
|
+
hint_windows=NativeLibHint(
|
|
983
|
+
note=(
|
|
984
|
+
"Under Git Bash / MSYS2 the Yocto-side backends aren't intended to run "
|
|
985
|
+
"-- the canonical use is WSL2 + Ubuntu with the linux command above; "
|
|
986
|
+
"skip this step on native Windows.",
|
|
987
|
+
),
|
|
988
|
+
command=None,
|
|
989
|
+
),
|
|
990
|
+
manual_install_windows=(
|
|
991
|
+
"The Zephyr SDK (`west sdk install`) is a separate, manual, one-time "
|
|
992
|
+
"install on native Windows -- not auto-installed by bootstrap.ps1. It is "
|
|
993
|
+
"the one every Zephyr-on-M customer needs: it provides the "
|
|
994
|
+
"`arm-zephyr-eabi` cross toolchain the real-silicon build (`west build` / "
|
|
995
|
+
"`west flash`) actually uses. Run it from your west workspace's top-level "
|
|
996
|
+
"directory -- the alp-sdk checkout's parent directory -- after this script "
|
|
997
|
+
"completes.",
|
|
998
|
+
"7-Zip must already be on PATH before running `west sdk install` on native "
|
|
999
|
+
"Windows: west delegates .7z extraction to patoolib, which shells out to "
|
|
1000
|
+
"an external 7z/7za/7zr/7zz/7zzs/unar binary and has no pure-Python "
|
|
1001
|
+
"fallback.",
|
|
1002
|
+
"The Zephyr SDK's native-Windows hosttools bundle ships neither `dtc` nor "
|
|
1003
|
+
"`gperf` (verified: `hosttools_windows-x86_64.7z`, sdk-ng v1.0.1, "
|
|
1004
|
+
"sha256-checked against upstream's own sha256.sum -- 1486 entries via "
|
|
1005
|
+
"`7z l`, zero dtc/gperf/device-tree matches -- while the equivalent Linux "
|
|
1006
|
+
"hosttools archive does ship `dtc`). Both are separate, manual installs on "
|
|
1007
|
+
"native Windows if you need them (see docs/cross-platform-setup.md); "
|
|
1008
|
+
"WARN-only in `alp doctor` (`_check_dtc` / `_check_gperf`) -- not required "
|
|
1009
|
+
"by bootstrap.ps1.",
|
|
1010
|
+
"The Arm GNU Toolchain (`arm-none-eabi-gcc`) is a SEPARATE manual install, "
|
|
1011
|
+
"needed by three opt-in paths -- rebuilding the GD32 bridge firmware "
|
|
1012
|
+
"(custom-carrier bring-up or bridge recovery), building the CC3501E bridge "
|
|
1013
|
+
"firmware's silicon-free stub target (its production image builds with TI "
|
|
1014
|
+
"ticlang, not this toolchain), or hand-writing bare-metal firmware for a "
|
|
1015
|
+
"real M-class core -- most customers never touch any of them, since the "
|
|
1016
|
+
"GD32G553 ships pre-flashed by Alp Lab (rebuilding it is optional and "
|
|
1017
|
+
"fully open, see docs/gd32-bridge.md). Installer EXE: "
|
|
1018
|
+
"https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads (tick "
|
|
1019
|
+
"'Add path to environment variable' during install).",
|
|
1020
|
+
"native_sim / Yocto need WSL2 (docs/cross-platform-setup.md section 5).",
|
|
1021
|
+
),
|
|
1022
|
+
# Transcribed from `contract/fixtures/bootstrap/manifest.json`, the
|
|
1023
|
+
# vendored producer output every fallback field is held against by
|
|
1024
|
+
# `test_the_fallback_constants_match_the_real_manifest_field_for_field`
|
|
1025
|
+
# -- this one included, since tan-cli#585 re-vendored the fixture. It
|
|
1026
|
+
# used to carry ONE deliberate departure: the stale fixture ended this
|
|
1027
|
+
# first note on `tan sdk switch`, which is in
|
|
1028
|
+
# `sdk_cmd.NOT_PORTED_SDK_SUBCOMMANDS` and REFUSES in this build, so
|
|
1029
|
+
# transcribing it verbatim failed `test_sdk_onboarding_dead_end.py`.
|
|
1030
|
+
# The refresh removed the conflict at its source; nothing here is
|
|
1031
|
+
# exempt from the field-for-field check any more.
|
|
1032
|
+
manual_install_posix=(
|
|
1033
|
+
"The Zephyr SDK (`west sdk install`) is a separate, manual, one-time install on "
|
|
1034
|
+
"Linux/macOS -- not auto-installed by bootstrap.sh. It is the one every Zephyr-on-M "
|
|
1035
|
+
"customer on Linux or Apple Silicon macOS needs (Intel Mac excepted -- see below): it "
|
|
1036
|
+
"provides the `arm-zephyr-eabi` cross toolchain the real-silicon build (`west build` / "
|
|
1037
|
+
"`west flash`) actually uses. Run it from your west workspace's "
|
|
1038
|
+
"top-level directory -- the alp-sdk checkout's parent directory -- after this script "
|
|
1039
|
+
"completes, e.g. `west sdk install --gnu-toolchains arm-zephyr-eabi --no-hosttools "
|
|
1040
|
+
"--install-dir \"$PWD/zephyr-sdk\"`; see docs/getting-started.md for the full one-liner "
|
|
1041
|
+
"and select the checkout with `tan build --sdk-root <path>` or `.alp/sdk-path`. On an "
|
|
1042
|
+
"Intel Mac this command has no target to install: the pinned SDK ships `macos-aarch64` "
|
|
1043
|
+
"only (no `macos-x86_64`), so real-silicon Zephyr builds need a Linux host instead -- "
|
|
1044
|
+
"`native_sim` and this bootstrap step are unaffected.",
|
|
1045
|
+
"The Arm GNU Toolchain (`arm-none-eabi-gcc`) is a SEPARATE manual install, needed by "
|
|
1046
|
+
"three opt-in paths -- rebuilding the GD32 bridge firmware (custom-carrier bring-up or"
|
|
1047
|
+
" bridge recovery), building the CC3501E bridge firmware's silicon-free stub target "
|
|
1048
|
+
"(its production image builds with TI ticlang, not this toolchain), or hand-writing "
|
|
1049
|
+
"bare-metal firmware for a real M-class core -- most customers never touch any of "
|
|
1050
|
+
"them, since the GD32G553 ships pre-flashed by Alp Lab (rebuilding it is optional and "
|
|
1051
|
+
"fully open, see docs/gd32-bridge.md). See docs/cross-platform-setup.md section 2.3 "
|
|
1052
|
+
"(Linux) / 3.4 (macOS) for the apt/brew/curl install.",
|
|
1053
|
+
"`west sdk install` may print \"could not find a 'file' executable, falling back to "
|
|
1054
|
+
"guess mime type by file extension\" -- patool's extension-based fallback works fine "
|
|
1055
|
+
"without it; this is WARN-only, not a bootstrap.sh prerequisite. Install `file` "
|
|
1056
|
+
"(`apt-get install -y file` / it ships by default on macOS) only to silence the "
|
|
1057
|
+
"message.",
|
|
1058
|
+
),
|
|
1059
|
+
from_manifest=False,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# ---------------------------------------------------------------------------
|
|
1064
|
+
# The prerequisite gate's PURE half: what a refusal says
|
|
1065
|
+
# ---------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
@dataclass(frozen=True)
|
|
1069
|
+
class MissingPrerequisite:
|
|
1070
|
+
"""One missing host prerequisite, in the form a consumer can act on.
|
|
1071
|
+
|
|
1072
|
+
`command` is `None` -- never prose -- for a tool the manifest lists no
|
|
1073
|
+
command for: a consumer renders this field as something it can RUN, and
|
|
1074
|
+
prose in a runnable-command field is a button that fails. The generic advice
|
|
1075
|
+
belongs in the printed line (`hint_line`) only.
|
|
1076
|
+
|
|
1077
|
+
`provenance` (tan-cli#1066) is the same manifest's `artifactProvenance`
|
|
1078
|
+
entry for this tool: the tier, licence, upstream page and size alp-sdk
|
|
1079
|
+
v0.16.0 publishes (alplabai/alp-sdk#1574). It rides on the ENTRY rather
|
|
1080
|
+
than as a sibling map on `data` because the join is by `tool`, an identity
|
|
1081
|
+
the entry already carries -- a consumer must not have to re-join two
|
|
1082
|
+
arrays, and a consumer that resolved its own SDK checkout must not join
|
|
1083
|
+
tan's commands against a DIFFERENT checkout's provenance (a real risk:
|
|
1084
|
+
`tan bootstrap` relocates the alp-sdk checkout to
|
|
1085
|
+
`<parent>/alp-workspace/<name>`). Defaults to `UNKNOWN` -- all four fields
|
|
1086
|
+
`null` -- for every caller with no table to hand, which is exactly what a
|
|
1087
|
+
tool with no entry reports; see `tan.core.artifact_provenance`.
|
|
1088
|
+
"""
|
|
1089
|
+
|
|
1090
|
+
tool: str
|
|
1091
|
+
command: str | None
|
|
1092
|
+
provenance: ArtifactProvenance = artifact_provenance.UNKNOWN
|
|
1093
|
+
|
|
1094
|
+
def as_dict(self) -> dict[str, str | int | None]:
|
|
1095
|
+
"""`{tool, command}` plus provenance's four keys, ALWAYS all six: the
|
|
1096
|
+
provenance keys are never omitted and never defaulted, so
|
|
1097
|
+
`contract/doctor-data-keys.json` can declare one item shape for every
|
|
1098
|
+
SDK (an older one simply reports `null`s)."""
|
|
1099
|
+
return {
|
|
1100
|
+
"tool": self.tool,
|
|
1101
|
+
"command": self.command,
|
|
1102
|
+
**self.provenance.as_dict(),
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
@dataclass(frozen=True)
|
|
1107
|
+
class PrereqFailure:
|
|
1108
|
+
"""A refused prerequisite gate: the `bootstrap.<code>` suffix, the message
|
|
1109
|
+
lines verbatim, and the structured per-tool form of them.
|
|
1110
|
+
|
|
1111
|
+
The structured half exists because the envelope's issue message is
|
|
1112
|
+
`" ".join(lines)` and an install command contains the same spaces the join
|
|
1113
|
+
used -- the split is not recoverable, so a consumer that wants "which tool,
|
|
1114
|
+
which command" must be HANDED it (alp-sdk-vscode#347 proved that parse dead
|
|
1115
|
+
and deleted it).
|
|
1116
|
+
|
|
1117
|
+
The code is per-refusal rather than one blanket `prerequisites-missing`
|
|
1118
|
+
because the Python-floor refusals have no missing TOOL at all -- a
|
|
1119
|
+
`{tool, command}` pair cannot represent "the Python you have is 3.10".
|
|
1120
|
+
"""
|
|
1121
|
+
|
|
1122
|
+
code: str
|
|
1123
|
+
lines: tuple[str, ...]
|
|
1124
|
+
missing: tuple[MissingPrerequisite, ...] = ()
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
#: Program names this guard refuses to accept as "the confirmed installer",
|
|
1128
|
+
#: even when `available` says yes -- tan-cli#760 review, MINOR 1. Every one
|
|
1129
|
+
#: of these is a universally-present WRAPPER that can front an absent real
|
|
1130
|
+
#: installer without this guard ever seeing it: `env FOO=bar apt-get ...` and
|
|
1131
|
+
#: `sh -c '...'` both resolve their OWN leading token to one of these, so
|
|
1132
|
+
#: `available("env")` (true on nearly every host) would confirm a command
|
|
1133
|
+
#: whose real dependency was never checked at all. Harmless against today's
|
|
1134
|
+
#: manifest (none of the six shipped commands use a wrapper); load-bearing
|
|
1135
|
+
#: the moment landing (b) adds a real per-distro command table.
|
|
1136
|
+
_OPAQUE_WRAPPER_BINARIES = frozenset(
|
|
1137
|
+
{"env", "sh", "bash", "dash", "zsh", "ksh", "eval", "exec"}
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
#: Shell composition this guard cannot see past -- `apt-get update && apt-get
|
|
1141
|
+
#: install -y cmake` would confirm only the FIRST command, then hand out the
|
|
1142
|
+
#: whole two-command line as if the second had been checked too. Refuse
|
|
1143
|
+
#: outright on any of these rather than half-confirm (tan-cli#760 review,
|
|
1144
|
+
#: MINOR 1). `"&"` alone (a single backgrounding operator, e.g. `apt-get
|
|
1145
|
+
#: install -y cmake & rm -rf /`) already contains `"&&"` as a substring, so
|
|
1146
|
+
#: listing both would be redundant; `"|"` likewise already covers `"||"`.
|
|
1147
|
+
#: `"\n"` closes the same class of gap a real shell would hit at a bare
|
|
1148
|
+
#: newline (`"apt-get update\napt-get install -y cmake"`) -- tan-cli#760
|
|
1149
|
+
#: review round 3, NIT: both were measured to slip past the pre-round-3 list.
|
|
1150
|
+
#: None of today's six manifest commands use any of these.
|
|
1151
|
+
_SHELL_METACHARACTERS = ("&", ";", "|", "`", "$(", "\n")
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def leading_binary(command: str) -> str:
|
|
1155
|
+
"""The program name a shell would actually invoke for one of the
|
|
1156
|
+
manifest's install one-liners -- stripping a literal leading `sudo `
|
|
1157
|
+
exactly as `--fix` (`doctor_cmd.run_fix`) does before it ever spawns
|
|
1158
|
+
anything, and parsing the rest with `shlex.split`, the SAME parser
|
|
1159
|
+
`run_fix` itself calls on the (sudo-stripped) command before resolving
|
|
1160
|
+
`argv[0]` -- so whatever this returns is provably the same token
|
|
1161
|
+
`run_fix` would try to resolve, quoting included, not merely a similar
|
|
1162
|
+
one (tan-cli#760 review, MINOR 2: `str.split` disagreed with `run_fix`'s
|
|
1163
|
+
`shlex.split` on a quoted path). `""` for a blank command, or one
|
|
1164
|
+
`shlex` cannot parse at all (an unterminated quote) -- nothing to
|
|
1165
|
+
confirm either way, and callers treat an empty name as "not available"
|
|
1166
|
+
rather than guessing at a half-token."""
|
|
1167
|
+
stripped = command.strip()
|
|
1168
|
+
if stripped.startswith("sudo "):
|
|
1169
|
+
stripped = stripped[len("sudo ") :].lstrip()
|
|
1170
|
+
if not stripped:
|
|
1171
|
+
return ""
|
|
1172
|
+
try:
|
|
1173
|
+
parts = shlex.split(stripped)
|
|
1174
|
+
except ValueError:
|
|
1175
|
+
return ""
|
|
1176
|
+
return parts[0] if parts else ""
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _confirmed(command: str, available: Callable[[str], bool]) -> bool:
|
|
1180
|
+
"""Whether `command` -- one manifest install one-liner -- is safe to hand
|
|
1181
|
+
a consumer as a real, runnable command on THIS host. The tan-cli#760
|
|
1182
|
+
guard's one decision point, shared by `confirmed_install_commands`
|
|
1183
|
+
(dict shape) and `confirm_missing` (`MissingPrerequisite` tuple shape)
|
|
1184
|
+
below -- there is exactly one place that decides this, even though there
|
|
1185
|
+
are now two entry points for the two shapes a caller needs it in.
|
|
1186
|
+
|
|
1187
|
+
Refuses outright, never half-confirms, on a shell metacharacter
|
|
1188
|
+
(`_SHELL_METACHARACTERS`) or an opaque wrapper leading binary
|
|
1189
|
+
(`_OPAQUE_WRAPPER_BINARIES`) -- tan-cli#760 review MINOR 1.
|
|
1190
|
+
|
|
1191
|
+
When the command is `sudo`-prefixed, `sudo` itself is confirmed too, not
|
|
1192
|
+
only the program after it: a stock `debian:12` image has `apt-get` but
|
|
1193
|
+
does NOT ship `sudo` by default, so confirming `apt-get` alone would
|
|
1194
|
+
still hand out a command that fails with `sudo: not found`
|
|
1195
|
+
(tan-cli#760 review, MINOR 3).
|
|
1196
|
+
|
|
1197
|
+
This still is not an exhaustive runnability proof -- a PATH-shadowing
|
|
1198
|
+
binary, a permissions error, an environment quirk are all beyond what a
|
|
1199
|
+
PATH lookup can promise. What it DOES guarantee: every program name this
|
|
1200
|
+
command would need to spawn is confirmed present, or the command is
|
|
1201
|
+
refused rather than handed out as if it were checked."""
|
|
1202
|
+
if any(meta in command for meta in _SHELL_METACHARACTERS):
|
|
1203
|
+
return False
|
|
1204
|
+
stripped = command.strip()
|
|
1205
|
+
needs_sudo = stripped.startswith("sudo ")
|
|
1206
|
+
binary = leading_binary(command)
|
|
1207
|
+
if not binary or binary in _OPAQUE_WRAPPER_BINARIES:
|
|
1208
|
+
return False
|
|
1209
|
+
if needs_sudo and not available("sudo"):
|
|
1210
|
+
return False
|
|
1211
|
+
return available(binary)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def confirmed_install_commands(
|
|
1215
|
+
install: dict[str, str], available: Callable[[str], bool]
|
|
1216
|
+
) -> dict[str, str]:
|
|
1217
|
+
"""`install`, keeping only the entries `_confirmed` (above) accepts on
|
|
1218
|
+
THIS host -- the tan-cli#760 guard's dict-shaped entry point.
|
|
1219
|
+
|
|
1220
|
+
Every `MissingPrerequisite.command` this port emits is built by looking a
|
|
1221
|
+
tool up in a dict shaped exactly like `install` (`_structured_missing`,
|
|
1222
|
+
`doctor_cmd.prerequisites_check`) or, for a `PrereqFailure` already built
|
|
1223
|
+
(`posix_venv_unusable()`), via `confirm_missing`'s tuple-shaped twin
|
|
1224
|
+
below -- `_confirmed` is the one decision both apply. Measured on
|
|
1225
|
+
`fedora:42`/`archlinux:latest`/`rockylinux:9`: alp-sdk's Linux table is
|
|
1226
|
+
six `sudo apt-get install -y ...` lines and none of those hosts has
|
|
1227
|
+
`apt-get` on PATH, so every entry is dropped here and the tool downgrades
|
|
1228
|
+
to `command: null` everywhere that dict then feeds the CUSTOMER-FACING
|
|
1229
|
+
surfaces -- `data.missingPrerequisites[].command`, which a human reads
|
|
1230
|
+
and `alp-sdk-vscode`'s Fix button sends verbatim to a terminal. (`tan
|
|
1231
|
+
doctor --fix` itself does its OWN PATH resolution and must NOT read a
|
|
1232
|
+
guarded dict -- see `doctor_cmd.prerequisites_check`'s `fix_missing`.)
|
|
1233
|
+
|
|
1234
|
+
Pure: `available` is injected rather than a real PATH probe, keeping this
|
|
1235
|
+
module's "no IO" contract (see the module docstring). Callers pass the
|
|
1236
|
+
real check -- `on_path(binary) is not None` -- from `bootstrap_cmd`/
|
|
1237
|
+
`doctor_cmd`, both of which already own a hardened PATH walk."""
|
|
1238
|
+
return {
|
|
1239
|
+
tool: command for tool, command in install.items() if _confirmed(command, available)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def confirm_missing(
|
|
1244
|
+
missing: tuple[MissingPrerequisite, ...], available: Callable[[str], bool]
|
|
1245
|
+
) -> tuple[MissingPrerequisite, ...]:
|
|
1246
|
+
"""`missing` -- an already-built `PrereqFailure.missing`/`{tool, command}`
|
|
1247
|
+
sequence, such as `posix_venv_unusable()` returns -- with any entry
|
|
1248
|
+
`_confirmed` (above) rejects degraded to `command: None`. The tuple-
|
|
1249
|
+
shaped twin of `confirmed_install_commands`, for a caller that already
|
|
1250
|
+
has `MissingPrerequisite` objects rather than the `install` dict they
|
|
1251
|
+
were built from (tan-cli#760 review MAJOR 2 / tan-cli#765:
|
|
1252
|
+
`posix_venv_unusable()`'s hardcoded `sudo apt-get install -y
|
|
1253
|
+
python3-venv` reached the envelope completely unguarded before this
|
|
1254
|
+
existed -- the confirmation guard was never actually "the ONE place"
|
|
1255
|
+
that decided what got handed out; this closes that gap without changing
|
|
1256
|
+
`posix_venv_unusable`'s own signature).
|
|
1257
|
+
|
|
1258
|
+
An entry whose `command` is already `None` passes through unchanged --
|
|
1259
|
+
nothing to confirm, and re-checking it would be a wasted PATH probe."""
|
|
1260
|
+
out: list[MissingPrerequisite] = []
|
|
1261
|
+
for m in missing:
|
|
1262
|
+
if m.command is not None and not _confirmed(m.command, available):
|
|
1263
|
+
# `replace`, not `MissingPrerequisite(m.tool, None)`: this guard
|
|
1264
|
+
# nulls the COMMAND and nothing else. Rebuilding the entry from two
|
|
1265
|
+
# of its fields silently dropped the third the moment `provenance`
|
|
1266
|
+
# existed (tan-cli#1066) -- so an unconfirmable installer would
|
|
1267
|
+
# have cost the customer the licence/tier the consent screen needs
|
|
1268
|
+
# to render, on exactly the hosts (a Fedora box reading alp-sdk's
|
|
1269
|
+
# apt table) where this guard fires.
|
|
1270
|
+
out.append(replace(m, command=None))
|
|
1271
|
+
else:
|
|
1272
|
+
out.append(m)
|
|
1273
|
+
return tuple(out)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _structured_missing(
|
|
1277
|
+
missing: list[str],
|
|
1278
|
+
install: dict[str, str],
|
|
1279
|
+
provenance: dict[str, ArtifactProvenance] | None = None,
|
|
1280
|
+
) -> tuple[MissingPrerequisite, ...]:
|
|
1281
|
+
"""The `{tool, command, <provenance>}` entries for a refusal.
|
|
1282
|
+
|
|
1283
|
+
`provenance` defaults to `None` -- "no table", indistinguishable
|
|
1284
|
+
downstream from "no entry for this tool" (`artifact_provenance.for_tool`)
|
|
1285
|
+
-- so a caller that has not resolved a manifest (and every pre-tan-cli#1066
|
|
1286
|
+
unit test of the refusal builders) keeps reporting the same `null`s an SDK
|
|
1287
|
+
predating `artifactProvenance` yields."""
|
|
1288
|
+
return tuple(
|
|
1289
|
+
MissingPrerequisite(
|
|
1290
|
+
tool, install.get(tool), artifact_provenance.for_tool(provenance, tool)
|
|
1291
|
+
)
|
|
1292
|
+
for tool in missing
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def hint_line(tool: str, install: dict[str, str]) -> str:
|
|
1297
|
+
"""The printed report line for one missing Windows prerequisite. A tool the
|
|
1298
|
+
manifest lists no command for gets generic ADVICE rather than being dropped
|
|
1299
|
+
-- which is why this is separate from `_structured_missing` and not an
|
|
1300
|
+
`or` over the same lookup. The rendering (two-space indent, ` -> ` with
|
|
1301
|
+
two spaces each side) is `bootstrap.ps1`'s and must stay byte-identical."""
|
|
1302
|
+
command = install.get(tool)
|
|
1303
|
+
if command is not None:
|
|
1304
|
+
return f" {tool} -> {command}"
|
|
1305
|
+
return f" {tool} -> install `{tool}` and put it on PATH"
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
#: tan-cli#355, added as a SECOND line on the refusals below -- the oracle's own
|
|
1309
|
+
#: first line is left byte-identical. See `posix_refusal` for why.
|
|
1310
|
+
#:
|
|
1311
|
+
#: tan-cli#650: both hints below now carry the SAME parenthetical --
|
|
1312
|
+
#: `--fix` mutates the host, so it goes through the identical `can_prompt`
|
|
1313
|
+
#: consent gate (`tan.core.consent`) every other host mutation in this CLI
|
|
1314
|
+
#: does, and that gate refuses outright with no TTY on either `stdin` or
|
|
1315
|
+
#: `stderr` -- piped, redirected, or CI, exactly the shape a Dockerfile `RUN`
|
|
1316
|
+
#: or an onboarding script has. Measured in a clean `ubuntu:24.04` container
|
|
1317
|
+
#: (tan-cli#650): `tan doctor --build --fix` with no TTY attached exits 4 and
|
|
1318
|
+
#: changes nothing. This hint is often the FIRST thing a customer reads about
|
|
1319
|
+
#: `--fix`, before they have tried it -- recommending it with no caveat there
|
|
1320
|
+
#: reads as a working remedy, which for a scripted/CI caller it is not.
|
|
1321
|
+
_DOCTOR_FIX_HINT = (
|
|
1322
|
+
"Or run `tan doctor --build --fix` (needs a real, interactive terminal) "
|
|
1323
|
+
"to install them from the SDK's manifest."
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
#: tan-cli#370. The line above is TRUE only where the manifest's own install
|
|
1327
|
+
#: commands need no elevation OR the caller is already root. alp-sdk's
|
|
1328
|
+
#: `prerequisites.install.linux` is six `sudo apt-get install -y ...` entries;
|
|
1329
|
+
#: `doctor --build --fix` never spawns the `sudo` PROGRAM itself
|
|
1330
|
+
#: (`doctor_cmd.fix_needs_sudo_check`, `doctor.fix-needs-sudo`) -- under
|
|
1331
|
+
#: `--format json` this process's stdio is captured end to end and a password
|
|
1332
|
+
#: prompt would hang forever rather than fail loudly -- but tan-cli#650 made
|
|
1333
|
+
#: it root-aware: for a caller whose effective UID is already 0 (a root
|
|
1334
|
+
#: container, most CI base images, a fresh cloud VM) there is no elevation
|
|
1335
|
+
#: left to acquire, so `--fix` strips the manifest's literal `sudo ` word and
|
|
1336
|
+
#: runs the rest of the line directly. A non-root Linux caller still gets
|
|
1337
|
+
#: NOTHING installed there, only the exact command printed per tool.
|
|
1338
|
+
#:
|
|
1339
|
+
#: Keyed on the COMMANDS, not on the platform: macOS is POSIX and its `brew
|
|
1340
|
+
#: install ...` needs no elevation, so it earns the plain wording, and Windows
|
|
1341
|
+
#: `winget` (user-scope) does too. Reading the commands is also what keeps this
|
|
1342
|
+
#: correct against a manifest nobody here has seen.
|
|
1343
|
+
_DOCTOR_FIX_HINT_NEEDS_ELEVATION = (
|
|
1344
|
+
"Or run `tan doctor --build --fix` (needs a real, interactive terminal): "
|
|
1345
|
+
"it prints the exact command for each tool from the SDK's manifest, runs "
|
|
1346
|
+
"the ones needing no elevation, and -- when already running as root -- "
|
|
1347
|
+
"runs the `sudo`-prefixed ones too, without ever spawning `sudo` itself."
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _doctor_fix_hint(missing: list[str], install: dict[str, str]) -> str:
|
|
1352
|
+
"""Which of the three hints is true for THESE tools on THIS host.
|
|
1353
|
+
|
|
1354
|
+
Only the commands reachable for the MISSING tools are considered: a `sudo`
|
|
1355
|
+
entry belonging to some tool that is already present says nothing about
|
|
1356
|
+
what `--fix` will do in this run. A tool the manifest has no command for
|
|
1357
|
+
cannot need elevation either -- it contributes generic advice, not a spawn.
|
|
1358
|
+
|
|
1359
|
+
tan-cli#760 adds the third shape. `install` here has already been through
|
|
1360
|
+
`confirmed_install_commands`, so by the time this runs "no entry for tool
|
|
1361
|
+
X" means "no command this host can actually run" -- which, when it is
|
|
1362
|
+
true of EVERY missing tool, means `--fix` cannot INSTALL anything for
|
|
1363
|
+
them here (it may still report a diagnostic, e.g. `tan doctor --fix`'s
|
|
1364
|
+
own `doctor.fix-installer-not-found` -- this hint is about the remedy,
|
|
1365
|
+
not that separate command's full behaviour). The two hints above both
|
|
1366
|
+
promise an install ("it prints the exact command for each tool from the
|
|
1367
|
+
SDK's manifest"), which is exactly the unrunnable-remedy defect this
|
|
1368
|
+
guard exists to close, just moved from the structured field into this
|
|
1369
|
+
prose line instead. Name the tools that are missing; a guessed package
|
|
1370
|
+
NAME here would be the identical defect against a different OS/distro
|
|
1371
|
+
(see `zephyr_requirements_hint` for the precedent)."""
|
|
1372
|
+
confirmed = [tool for tool in missing if install.get(tool) is not None]
|
|
1373
|
+
if not confirmed:
|
|
1374
|
+
return (
|
|
1375
|
+
"`tan doctor --build --fix` has no confirmed install command for "
|
|
1376
|
+
f"this host: {', '.join(missing)}. Install them with your OS's "
|
|
1377
|
+
"package manager and put them on PATH, then re-run."
|
|
1378
|
+
)
|
|
1379
|
+
return (
|
|
1380
|
+
_DOCTOR_FIX_HINT_NEEDS_ELEVATION
|
|
1381
|
+
if any(install.get(tool, "").split(maxsplit=1)[:1] == ["sudo"] for tool in missing)
|
|
1382
|
+
else _DOCTOR_FIX_HINT
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def windows_refusal(
|
|
1387
|
+
missing: list[str],
|
|
1388
|
+
install: dict[str, str],
|
|
1389
|
+
provenance: dict[str, ArtifactProvenance] | None = None,
|
|
1390
|
+
) -> PrereqFailure:
|
|
1391
|
+
"""`bootstrap.ps1`'s `$Prereqs` loop: header, one `hint_line` each, the
|
|
1392
|
+
reopen-PowerShell tail.
|
|
1393
|
+
|
|
1394
|
+
`provenance` reaches only the STRUCTURED half (`missingPrerequisites[]`),
|
|
1395
|
+
never the printed lines -- tan-cli#1066 carries a licensing fact for a
|
|
1396
|
+
consumer's consent screen; it does not re-word a refusal the oracle's
|
|
1397
|
+
wording is pinned against."""
|
|
1398
|
+
lines = ["Missing required tools:"]
|
|
1399
|
+
lines.extend(hint_line(tool, install) for tool in missing)
|
|
1400
|
+
lines.append("Install the tools above (then reopen PowerShell) and re-run.")
|
|
1401
|
+
# tan-cli#355: same gap as the POSIX refusal -- name the installer tan ships.
|
|
1402
|
+
# The Windows wording is tan's own (it already carries per-tool hints the
|
|
1403
|
+
# POSIX one may not), so this is an addition, not a divergence.
|
|
1404
|
+
# tan-cli#370: which of the two hints is true depends on the commands, not
|
|
1405
|
+
# on the platform -- Windows `winget` is user-scope today, but the manifest
|
|
1406
|
+
# is alp-sdk's to change and this reads what it actually says.
|
|
1407
|
+
lines.append(_doctor_fix_hint(missing, install))
|
|
1408
|
+
return PrereqFailure(
|
|
1409
|
+
"prerequisites-missing",
|
|
1410
|
+
tuple(lines),
|
|
1411
|
+
_structured_missing(missing, install, provenance),
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def posix_refusal(
|
|
1416
|
+
missing: list[str],
|
|
1417
|
+
install: dict[str, str],
|
|
1418
|
+
provenance: dict[str, ArtifactProvenance] | None = None,
|
|
1419
|
+
) -> PrereqFailure:
|
|
1420
|
+
"""`bootstrap.sh`'s one line: the tool names and nothing else -- TWO spaces
|
|
1421
|
+
before "Install". The oracle prints no per-tool commands and neither may
|
|
1422
|
+
this; alp-sdk#959 changed what the STRUCTURED half carries, not what a POSIX
|
|
1423
|
+
user reads.
|
|
1424
|
+
|
|
1425
|
+
**tan-cli#355 adds a SECOND line, and only a second line.** The oracle's
|
|
1426
|
+
first line is still emitted byte for byte, two spaces and all, and a parity
|
|
1427
|
+
test pins it so the match stays provable. What is added is the sentence
|
|
1428
|
+
naming `tan doctor --build --fix`.
|
|
1429
|
+
|
|
1430
|
+
A DELIBERATE divergence from the oracle, recorded here so nobody restores
|
|
1431
|
+
the silence. "The oracle prints no per-tool commands and neither may this"
|
|
1432
|
+
was right when tan had no installer of its own; tan-cli#91 changed that
|
|
1433
|
+
fact, and `doctor --build --fix` now runs exactly the manifest-owned
|
|
1434
|
+
install commands these missing tools need. Measured in a pristine
|
|
1435
|
+
`ubuntu:24.04`, a first-time customer got
|
|
1436
|
+
|
|
1437
|
+
Missing required tools: cmake ninja xz wget. Install them and re-run.
|
|
1438
|
+
|
|
1439
|
+
and nothing else, while the command that would install them sat one
|
|
1440
|
+
subcommand away, unmentioned. Withholding a remedy tan HAS, to match an
|
|
1441
|
+
oracle that never had one, is parity serving nobody.
|
|
1442
|
+
|
|
1443
|
+
The per-tool commands themselves still stay OUT of the prose -- that half of
|
|
1444
|
+
the original constraint holds, and they remain where alp-sdk#959 put them,
|
|
1445
|
+
in the structured payload's `{tool, command}` pairs -- which is also where
|
|
1446
|
+
tan-cli#1066's `provenance` goes, and only there (see `windows_refusal`)."""
|
|
1447
|
+
return PrereqFailure(
|
|
1448
|
+
"prerequisites-missing",
|
|
1449
|
+
(
|
|
1450
|
+
f"Missing required tools: {' '.join(missing)}. Install them and re-run.",
|
|
1451
|
+
_doctor_fix_hint(missing, install),
|
|
1452
|
+
),
|
|
1453
|
+
_structured_missing(missing, install, provenance),
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def windows_python_not_runnable(install: dict[str, str]) -> PrereqFailure:
|
|
1458
|
+
"""Windows: `python` is on PATH but did not run -- the Microsoft Store alias
|
|
1459
|
+
prints nothing (`bootstrap.ps1`'s `$PyVer` check).
|
|
1460
|
+
|
|
1461
|
+
Its own code, not `prerequisites-missing`: there is no missing tool here and
|
|
1462
|
+
no `{tool, command}` pair that could carry the fix, so the install command
|
|
1463
|
+
reaches the user through the PROSE -- which is exactly why the package ID in
|
|
1464
|
+
it comes from `prerequisites.install.windows` like every other one. A
|
|
1465
|
+
hardcoded `Python.Python.3.12` here would be a second copy of a manifest
|
|
1466
|
+
fact sitting beside a correct read of it.
|
|
1467
|
+
"""
|
|
1468
|
+
command = install.get("python")
|
|
1469
|
+
if command is not None:
|
|
1470
|
+
line = (
|
|
1471
|
+
f"python did not run (Windows Store alias?). Install real Python: "
|
|
1472
|
+
f"{command}, reopen PowerShell, re-run."
|
|
1473
|
+
)
|
|
1474
|
+
else:
|
|
1475
|
+
# Only reachable for an out-of-contract manifest: the schema requires
|
|
1476
|
+
# `install.windows`' keys to equal `prerequisites.windows`, which lists
|
|
1477
|
+
# `python`. Degrade the sentence rather than inventing a package ID.
|
|
1478
|
+
line = (
|
|
1479
|
+
"python did not run (Windows Store alias?). Install a real Python 3, "
|
|
1480
|
+
"reopen PowerShell, re-run."
|
|
1481
|
+
)
|
|
1482
|
+
return PrereqFailure("python-not-runnable", (line,))
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def posix_python_not_runnable() -> PrereqFailure:
|
|
1486
|
+
"""POSIX: `python3` is on PATH but did not run -- the only failure this port
|
|
1487
|
+
adds over `bootstrap.sh`, which would have hit it one step later at
|
|
1488
|
+
`python3 -m venv`."""
|
|
1489
|
+
return PrereqFailure(
|
|
1490
|
+
"python-not-runnable",
|
|
1491
|
+
("python3 is on PATH but did not run. Install a working Python 3 and re-run.",),
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def python_too_old(
|
|
1496
|
+
found: tuple[int, int],
|
|
1497
|
+
floor: tuple[int, int],
|
|
1498
|
+
install: dict[str, str],
|
|
1499
|
+
*,
|
|
1500
|
+
floor_source: str,
|
|
1501
|
+
manifest_floor: tuple[int, int] | None = None,
|
|
1502
|
+
) -> PrereqFailure:
|
|
1503
|
+
"""A working interpreter below the EFFECTIVE floor.
|
|
1504
|
+
|
|
1505
|
+
**This is the customer-facing fix, not a port.** The oracle refuses here on
|
|
1506
|
+
Windows only and against the MANIFEST's floor
|
|
1507
|
+
(`crates/tan-cli/src/commands/bootstrap/steps.rs`, whose POSIX branch states
|
|
1508
|
+
outright *"this branch cannot fail on version"*). Three facts compose into a
|
|
1509
|
+
silent failure: `metadata/bootstrap.json:16` declares
|
|
1510
|
+
`"pythonMinVersion": "3.10"`; Zephyr's `cmake/modules/python.cmake:14` sets
|
|
1511
|
+
`set(PYTHON_MINIMUM_REQUIRED 3.12)`; Ubuntu 22.04 ships `python3` = 3.10. So
|
|
1512
|
+
today `tan bootstrap` succeeds, and the customer's FIRST build dies inside
|
|
1513
|
+
Zephyr's CMake configure with an error naming Zephyr rather than us. The
|
|
1514
|
+
floor enforced here is therefore the EFFECTIVE one -- the higher of the two
|
|
1515
|
+
-- on BOTH platforms, the same floor `tan doctor` already reports
|
|
1516
|
+
(`tan.commands.doctor_cmd.python_check`, via the same
|
|
1517
|
+
`zephyr_python_floor`).
|
|
1518
|
+
|
|
1519
|
+
Tool-less, so the install command travels in the prose. `floor_source` names
|
|
1520
|
+
WHERE the number came from, and `manifest_floor` (when it is lower) names
|
|
1521
|
+
the skew -- otherwise a customer refused at 3.11 greps the manifest, reads
|
|
1522
|
+
`3.10`, and concludes tan is broken.
|
|
1523
|
+
|
|
1524
|
+
The manifest's install command is SUPPRESSED in the skew case, deliberately.
|
|
1525
|
+
That command is scoped to the manifest's OWN floor, so it cannot be trusted
|
|
1526
|
+
to deliver a higher one: on the host this whole fix exists for -- Ubuntu
|
|
1527
|
+
22.04 -- `sudo apt-get install -y python3` installs 3.10, which is exactly
|
|
1528
|
+
the version being refused. Printing it would send the customer round a loop.
|
|
1529
|
+
"""
|
|
1530
|
+
skewed = manifest_floor is not None and manifest_floor < floor
|
|
1531
|
+
verdict = (
|
|
1532
|
+
f"Python {found[0]}.{found[1]} found; the SDK tooling needs "
|
|
1533
|
+
f">= {floor[0]}.{floor[1]}"
|
|
1534
|
+
)
|
|
1535
|
+
command = None if skewed else (install.get("python") or install.get("python3"))
|
|
1536
|
+
line = f"{verdict} ({command})." if command is not None else f"{verdict}."
|
|
1537
|
+
line = f"{line} That floor comes from {floor_source}."
|
|
1538
|
+
if skewed and manifest_floor is not None:
|
|
1539
|
+
line = (
|
|
1540
|
+
f"{line} alp-sdk's {BOOTSTRAP_MANIFEST_REL_PATH} declares only "
|
|
1541
|
+
f"{manifest_floor[0]}.{manifest_floor[1]}, so its own install command is "
|
|
1542
|
+
f"not enough here -- install a Python "
|
|
1543
|
+
f"{floor[0]}.{floor[1]}+ and put it ahead of "
|
|
1544
|
+
f"{found[0]}.{found[1]} on PATH, then re-run so the workspace venv is "
|
|
1545
|
+
f"built with it."
|
|
1546
|
+
)
|
|
1547
|
+
return PrereqFailure("python-too-old", (line,))
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def python_floor_skew_warning(
|
|
1551
|
+
manifest_floor: tuple[int, int],
|
|
1552
|
+
effective_floor: tuple[int, int],
|
|
1553
|
+
source: str,
|
|
1554
|
+
from_manifest: bool = True,
|
|
1555
|
+
) -> tuple[str, str] | None:
|
|
1556
|
+
"""`(code suffix, message)` when the two declared floors disagree, else
|
|
1557
|
+
`None`.
|
|
1558
|
+
|
|
1559
|
+
Reported rather than silently reconciled, and worded to match
|
|
1560
|
+
`tan.commands.doctor_cmd.python_floor_skew_check` -- doctor raises the same
|
|
1561
|
+
verdict as `doctor.pythonFloor`, and two commands describing one manifest
|
|
1562
|
+
defect differently is the drift this port keeps hitting. Fires on a
|
|
1563
|
+
SUCCESSFUL run too: the host is fine and the two declared floors disagree.
|
|
1564
|
+
|
|
1565
|
+
It does NOT follow that the fix belongs in `metadata/bootstrap.json` -- this
|
|
1566
|
+
docstring used to say so, and the remedy below used to act on it. Raising
|
|
1567
|
+
`prerequisites.pythonMinVersion` was tried and REVERTED (alp-sdk#1078): the
|
|
1568
|
+
key is host-universal while this floor is Zephyr's, so raising it refuses a
|
|
1569
|
+
3.10/3.11 host for a Yocto-only or metadata-only project that builds today.
|
|
1570
|
+
The skew is deliberate; the message says so and points the customer at the
|
|
1571
|
+
only thing that actually helps them (tan-cli#300).
|
|
1572
|
+
|
|
1573
|
+
`from_manifest=False` (pass `facts.from_manifest`) means `manifest_floor`
|
|
1574
|
+
never actually came from a read `metadata/bootstrap.json` -- this SDK
|
|
1575
|
+
predates it (`load_facts`'s `_manifest_absent_floor` branch) -- and is
|
|
1576
|
+
instead tan's own frozen fallback constant standing in. Claiming alp-sdk's
|
|
1577
|
+
manifest "declares" that number, and telling the customer to edit it, would
|
|
1578
|
+
send them to a file bootstrap never read.
|
|
1579
|
+
"""
|
|
1580
|
+
if manifest_floor >= effective_floor:
|
|
1581
|
+
return None
|
|
1582
|
+
if from_manifest:
|
|
1583
|
+
claim = (
|
|
1584
|
+
f"alp-sdk's {BOOTSTRAP_MANIFEST_REL_PATH} declares pythonMinVersion "
|
|
1585
|
+
f"{manifest_floor[0]}.{manifest_floor[1]}"
|
|
1586
|
+
)
|
|
1587
|
+
# NOT "raise pythonMinVersion in the manifest". That was tried and
|
|
1588
|
+
# REVERTED (alp-sdk#1078): the key is host-universal, and
|
|
1589
|
+
# `build_readiness.rs:401` checks Python BEFORE any `os_set` branch, so
|
|
1590
|
+
# raising it refuses a 3.10/3.11 host for a Yocto-only or metadata-only
|
|
1591
|
+
# project that builds today -- and 3.12 is unreachable via the remedy
|
|
1592
|
+
# the manifest itself offers (`sudo apt-get install -y python3`) on the
|
|
1593
|
+
# Ubuntu 22.04 hosts the docs recommend. This warning fires while
|
|
1594
|
+
# bootstrap is REFUSING, so it is the last line a blocked user reads and
|
|
1595
|
+
# the likeliest thing they act on; it has to name something that helps
|
|
1596
|
+
# them, not an SDK edit that would make things worse (tan-cli#300).
|
|
1597
|
+
fix = (
|
|
1598
|
+
f" The skew is known and deliberately unresolved (alp-sdk#1078): the "
|
|
1599
|
+
f"manifest key is host-universal while this floor is Zephyr's. Nothing "
|
|
1600
|
+
f"to change in alp-sdk -- put a Python "
|
|
1601
|
+
f"{effective_floor[0]}.{effective_floor[1]} or newer on the build path."
|
|
1602
|
+
)
|
|
1603
|
+
else:
|
|
1604
|
+
claim = (
|
|
1605
|
+
f"this SDK checkout has no {BOOTSTRAP_MANIFEST_REL_PATH} to declare a floor, "
|
|
1606
|
+
f"so tan's own built-in floor {manifest_floor[0]}.{manifest_floor[1]} is "
|
|
1607
|
+
f"standing in"
|
|
1608
|
+
)
|
|
1609
|
+
fix = " Update this SDK checkout to a version that ships that manifest."
|
|
1610
|
+
return (
|
|
1611
|
+
"python-floor-skew",
|
|
1612
|
+
f"{claim}, but the build's effective floor is "
|
|
1613
|
+
f"{effective_floor[0]}.{effective_floor[1]} (from {source}). bootstrap enforces "
|
|
1614
|
+
f"the higher, effective floor, so a host this manifest would have accepted is "
|
|
1615
|
+
f"refused here rather than failing later inside Zephyr's CMake configure."
|
|
1616
|
+
f"{fix}",
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
# ---------------------------------------------------------------------------
|
|
1621
|
+
# The Python CEILING (tan-cli#285): a floor alone caught "too old"; it cannot
|
|
1622
|
+
# catch "too new for the ecosystem".
|
|
1623
|
+
# ---------------------------------------------------------------------------
|
|
1624
|
+
|
|
1625
|
+
#: The highest CPython minor tan has actually seen a full venv build clean
|
|
1626
|
+
#: against. STALE BY DEFAULT, exactly like `ZEPHYR_VERSION` above -- there is
|
|
1627
|
+
#: no `pythonMaxVersion` in `metadata/bootstrap.json` yet (it carries only the
|
|
1628
|
+
#: FLOOR, `pythonMinVersion`), so this is tan's own placeholder until that
|
|
1629
|
+
#: manifest can carry a real ceiling. Bump it only against a real run that
|
|
1630
|
+
#: built a complete venv on the newer minor -- not by inference.
|
|
1631
|
+
#:
|
|
1632
|
+
#: A design choice, not a mechanical value, and worth stating explicitly: this
|
|
1633
|
+
#: used to read `(3, 13)`, on the reasoning "3.14 broke, so one minor below it
|
|
1634
|
+
#: is probably fine" -- a COMPUTED guess asserted as "a MEASUREMENT, not a
|
|
1635
|
+
#: computed bound", which it never was (nothing in CI, `getting-started.yml`
|
|
1636
|
+
#: or a first-blink run has ever bootstrapped on 3.13). `(3, 12)` is what is
|
|
1637
|
+
#: actually measured good (every CI Python job pins it) against `(3, 14)`
|
|
1638
|
+
#: measured bad (the `hidapi` failure this whole mechanism exists to warn
|
|
1639
|
+
#: about). Tightening the number to what is true is NOT the same change as
|
|
1640
|
+
#: tightening the gate: this stays a WARN at 3.12 exactly as it was at 3.13 --
|
|
1641
|
+
#: see `python_ceiling_warning`'s own docstring for why a hard refusal here
|
|
1642
|
+
#: would be its own defect, symmetric to the floor bug this port already
|
|
1643
|
+
#: fixed. A working 3.13 host still bootstraps clean either way; it now also
|
|
1644
|
+
#: gets told, correctly, that this port has not verified that combination.
|
|
1645
|
+
PYTHON_CEILING_KNOWN_GOOD = (3, 12)
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def python_ceiling_warning(found: tuple[int, int], venv_dir: str) -> tuple[str, str] | None:
|
|
1649
|
+
"""`(code suffix, message)` when `found` is newer than any Python tan has
|
|
1650
|
+
verified a complete venv against, else `None`. `venv_dir` is the
|
|
1651
|
+
already-rendered (`_native`) workspace venv path, named in the remedy.
|
|
1652
|
+
|
|
1653
|
+
**Deliberately a WARN, never a refusal.** The floor check above refuses,
|
|
1654
|
+
because a too-OLD interpreter is a GUARANTEED failure -- Zephyr's own CMake
|
|
1655
|
+
configure enforces its floor unconditionally. A too-NEW interpreter is not
|
|
1656
|
+
guaranteed to fail at all: most projects never touch the specific optional
|
|
1657
|
+
dependency (`hidapi`, in the one case measured so far) that lacks a
|
|
1658
|
+
prebuilt wheel for it, and most hosts will bootstrap a perfectly complete
|
|
1659
|
+
venv anyway. Refusing a host that would have built cleanly is the same
|
|
1660
|
+
defect the floor fix above exists to close, mirrored onto the other edge --
|
|
1661
|
+
a hard ceiling that blocks a WORKING host is its own bug, not a safety
|
|
1662
|
+
rail. This warning exists only to give the customer the "why" up front,
|
|
1663
|
+
before they spend time chasing a build failure back to their interpreter
|
|
1664
|
+
choice; the venv-completeness check (tan-cli#285's other half) is what
|
|
1665
|
+
actually catches it when it happens.
|
|
1666
|
+
"""
|
|
1667
|
+
if found <= PYTHON_CEILING_KNOWN_GOOD:
|
|
1668
|
+
return None
|
|
1669
|
+
return (
|
|
1670
|
+
"python-newer-than-verified",
|
|
1671
|
+
f"Python {found[0]}.{found[1]} is newer than the highest tan has verified a "
|
|
1672
|
+
f"complete venv against ({PYTHON_CEILING_KNOWN_GOOD[0]}."
|
|
1673
|
+
f"{PYTHON_CEILING_KNOWN_GOOD[1]}). Not refused -- most hosts and most projects "
|
|
1674
|
+
f"bootstrap cleanly on a newer Python anyway -- but a dependency with no "
|
|
1675
|
+
f"prebuilt wheel yet for this interpreter (hidapi is the one seen so far) can "
|
|
1676
|
+
f"still fall back to a source build and fail. If a later warning reports the "
|
|
1677
|
+
f"venv incomplete: delete {venv_dir} (there is no --recreate-venv) and re-run "
|
|
1678
|
+
f"`tan bootstrap` -- a REUSED venv keeps the interpreter that created it, so "
|
|
1679
|
+
f"installing another Python 3 alongside this one does nothing by itself. On "
|
|
1680
|
+
f"Windows, put that older interpreter first on PATH before re-running (or create "
|
|
1681
|
+
f"the venv yourself, e.g. `py -3.12 -m venv {venv_dir}`), since tan's own default "
|
|
1682
|
+
f"candidate is `py -3`, which resolves to the newest install.",
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
# ---------------------------------------------------------------------------
|
|
1687
|
+
# The pip phase's remediation hints (tan-cli#285): gated on the REAL host, not
|
|
1688
|
+
# assumed Linux.
|
|
1689
|
+
# ---------------------------------------------------------------------------
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def zephyr_requirements_hint(host: str) -> str:
|
|
1693
|
+
"""The OS-gated remedy appended to the `zephyr-requirements` warning.
|
|
1694
|
+
|
|
1695
|
+
Only LINUX and WINDOWS get a named package/command below: those are the
|
|
1696
|
+
two hosts a real failure has actually been measured and diagnosed on (a
|
|
1697
|
+
stock ubuntu-24.04 CI runner; Python 3.14 on Windows, `LINK : fatal error
|
|
1698
|
+
LNK1104`). Printing the Linux line unconditionally used to send a Windows
|
|
1699
|
+
customer to run `sudo apt-get` on a host with no `apt-get` at all, and to
|
|
1700
|
+
misdiagnose an MSVC linker failure as a missing header. macOS/other get a
|
|
1701
|
+
host-neutral line rather than a GUESSED command -- printing an unverified
|
|
1702
|
+
package name would repeat the exact defect this fixes, just against a
|
|
1703
|
+
different OS.
|
|
1704
|
+
|
|
1705
|
+
None of the three text blames "the output above"/"the output" as if a
|
|
1706
|
+
reader can already see it: `--format json` has no terminal output at all
|
|
1707
|
+
-- the caller (`pip_phase`) appends the actual captured pip tail to the
|
|
1708
|
+
SAME message when one was captured, so "the captured pip output" here
|
|
1709
|
+
always names something that is either right there in the message or
|
|
1710
|
+
genuinely was not captured (text mode, where the child's own log already
|
|
1711
|
+
streamed live).
|
|
1712
|
+
"""
|
|
1713
|
+
if host == WINDOWS:
|
|
1714
|
+
return (
|
|
1715
|
+
"On Windows this is usually `hidapi` with no prebuilt wheel yet for this "
|
|
1716
|
+
"Python, falling back to a source build that needs the MSVC linker (look "
|
|
1717
|
+
"for `LINK : fatal error LNK1104` in the captured pip output -- this is NOT "
|
|
1718
|
+
"a missing native header): install the \"Desktop development with C++\" "
|
|
1719
|
+
"workload from the Visual Studio Build Tools "
|
|
1720
|
+
"(https://visualstudio.microsoft.com/visual-cpp-build-tools/), which "
|
|
1721
|
+
"supplies both the linker and the Windows SDK libraries hidapi links "
|
|
1722
|
+
"against, then re-run `tan bootstrap`."
|
|
1723
|
+
)
|
|
1724
|
+
if host == LINUX:
|
|
1725
|
+
return (
|
|
1726
|
+
"On Linux this is usually `hidapi` needing native headers: `sudo apt-get "
|
|
1727
|
+
"install -y pkg-config libusb-1.0-0-dev libudev-dev`, then re-run `tan "
|
|
1728
|
+
"bootstrap`."
|
|
1729
|
+
)
|
|
1730
|
+
return (
|
|
1731
|
+
"Check the captured pip output for the real cause (often a native "
|
|
1732
|
+
"dependency with no prebuilt wheel for this host), then re-run `tan bootstrap`."
|
|
1733
|
+
)
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def posix_venv_unusable() -> PrereqFailure:
|
|
1737
|
+
"""Linux: `python3` runs and clears every check above, but its `venv` module
|
|
1738
|
+
cannot create a usable environment because `ensurepip` is missing --
|
|
1739
|
+
Debian/Ubuntu split `python3-venv` out of the base `python3` package.
|
|
1740
|
+
|
|
1741
|
+
A SECOND check, deliberately not folded into the manifest's
|
|
1742
|
+
`prerequisites.posix` list: that list is an alp-sdk fact and `python3-venv`
|
|
1743
|
+
is not in it upstream. Its own code, like the Python-floor refusals -- and
|
|
1744
|
+
unlike them it HAS a real `{tool, command}` pair, which a Fix button needs.
|
|
1745
|
+
|
|
1746
|
+
`python3-venv`, not the version-specific `python3.NN-venv` Python's own
|
|
1747
|
+
error names: apt resolves the unversioned meta-package to the matching
|
|
1748
|
+
versioned one, and this message cannot know which minor is running.
|
|
1749
|
+
"""
|
|
1750
|
+
return PrereqFailure(
|
|
1751
|
+
"venv-unusable",
|
|
1752
|
+
(
|
|
1753
|
+
"python3 found, but its venv module cannot create a usable virtual "
|
|
1754
|
+
"environment (ensurepip is missing). On Debian/Ubuntu: sudo apt-get "
|
|
1755
|
+
"install -y python3-venv, then re-run.",
|
|
1756
|
+
),
|
|
1757
|
+
(MissingPrerequisite("python3-venv", "sudo apt-get install -y python3-venv"),),
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def reported_missing(
|
|
1762
|
+
missing: tuple[MissingPrerequisite, ...],
|
|
1763
|
+
) -> list[dict[str, str | int | None]] | None:
|
|
1764
|
+
"""The envelope form: `None` when the refusal names no tool.
|
|
1765
|
+
|
|
1766
|
+
`[]` is NEVER a value here. The Python-floor refusals reach this empty, and
|
|
1767
|
+
`[]` on the wire would spell "checked, nothing missing" -- which is what a
|
|
1768
|
+
run that found the list clean reports, as `None`. One fact, one spelling.
|
|
1769
|
+
"""
|
|
1770
|
+
return [m.as_dict() for m in missing] if missing else None
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
# ---------------------------------------------------------------------------
|
|
1774
|
+
# The Yocto host gate
|
|
1775
|
+
# ---------------------------------------------------------------------------
|
|
1776
|
+
|
|
1777
|
+
#: Verdicts of `yocto_gate`.
|
|
1778
|
+
GATE_CLEAR = "clear"
|
|
1779
|
+
GATE_WARN = "warn"
|
|
1780
|
+
GATE_REFUSE = "refuse"
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def in_play_runtimes(
|
|
1784
|
+
board_cores: dict[str, str | None] | None,
|
|
1785
|
+
board_os: str | None,
|
|
1786
|
+
topology: dict[str, str],
|
|
1787
|
+
) -> list[str]:
|
|
1788
|
+
"""The distinct runtimes a project puts in play, sorted.
|
|
1789
|
+
|
|
1790
|
+
A `cores:` block IS the project's core selection: each entry resolves
|
|
1791
|
+
through its explicit `os:` override (`"off"` removes the core), else the
|
|
1792
|
+
matching topology entry, else the core-id heuristic. With no `cores:` block
|
|
1793
|
+
a v1 top-level `os:` wins, and failing that the whole SoM topology is in
|
|
1794
|
+
play.
|
|
1795
|
+
|
|
1796
|
+
`topology` empty means the SoM metadata could not be read; an empty RESULT
|
|
1797
|
+
means "unresolvable", which every caller must treat as "proceed".
|
|
1798
|
+
"""
|
|
1799
|
+
def from_topology(core_id: str) -> str:
|
|
1800
|
+
return topology.get(core_id) or infer_runtime_for_core_id(core_id)
|
|
1801
|
+
|
|
1802
|
+
def declared(value: str | None) -> str | None:
|
|
1803
|
+
cleaned = (value or "").strip()
|
|
1804
|
+
return cleaned or None
|
|
1805
|
+
|
|
1806
|
+
out: set[str] = set()
|
|
1807
|
+
if board_cores:
|
|
1808
|
+
for core_id, raw in board_cores.items():
|
|
1809
|
+
os_value = declared(raw)
|
|
1810
|
+
if os_value == OS_OFF:
|
|
1811
|
+
continue
|
|
1812
|
+
out.add(os_value or from_topology(core_id))
|
|
1813
|
+
else:
|
|
1814
|
+
top_level = declared(board_os)
|
|
1815
|
+
if top_level is not None and top_level != OS_OFF:
|
|
1816
|
+
out.add(top_level)
|
|
1817
|
+
else:
|
|
1818
|
+
out.update(topology.values())
|
|
1819
|
+
return sorted(out)
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def yocto_gate(runtimes: list[str], host: str) -> str:
|
|
1823
|
+
"""Refusal is deliberately narrow -- only a project that is *entirely* Yocto
|
|
1824
|
+
on a non-Linux host. Erring toward running is harmless (bootstrap is
|
|
1825
|
+
idempotent); erring toward refusing bricks the command.
|
|
1826
|
+
|
|
1827
|
+
The test is "every runtime in play is `yocto`" rather than "none is
|
|
1828
|
+
`zephyr`/`baremetal`": an unrecognised `os:` string is an unresolvable core,
|
|
1829
|
+
and unresolvable means proceed.
|
|
1830
|
+
"""
|
|
1831
|
+
if host == LINUX or not runtimes:
|
|
1832
|
+
return GATE_CLEAR
|
|
1833
|
+
if all(r == "yocto" for r in runtimes):
|
|
1834
|
+
return GATE_REFUSE
|
|
1835
|
+
if any(r == "yocto" for r in runtimes):
|
|
1836
|
+
return GATE_WARN
|
|
1837
|
+
return GATE_CLEAR
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def yocto_only_refusal() -> str:
|
|
1841
|
+
return (
|
|
1842
|
+
f"every core in this project targets Yocto. {YOCTO_HOST_DETAIL} Re-run "
|
|
1843
|
+
f"`tan bootstrap` inside WSL2 or on a Linux host."
|
|
1844
|
+
)
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def yocto_mixed_warning() -> str:
|
|
1848
|
+
return (
|
|
1849
|
+
f"a Yocto core is in play. {YOCTO_HOST_DETAIL} The Zephyr/baremetal cores "
|
|
1850
|
+
f"bootstrap normally here."
|
|
1851
|
+
)
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
# ---------------------------------------------------------------------------
|
|
1855
|
+
# `$ZEPHYR_BASE` workspace selection
|
|
1856
|
+
# ---------------------------------------------------------------------------
|
|
1857
|
+
|
|
1858
|
+
#: Outcomes of `decide_workspace_reuse`.
|
|
1859
|
+
REUSE = "reuse"
|
|
1860
|
+
STALE = "stale"
|
|
1861
|
+
MANIFEST_MISMATCH = "manifest-mismatch"
|
|
1862
|
+
INCOMPATIBLE = "incompatible"
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def decide_workspace_reuse(
|
|
1866
|
+
version_file: str,
|
|
1867
|
+
top_is_west_workspace: bool,
|
|
1868
|
+
manifest_is_sdk: bool,
|
|
1869
|
+
pin: str,
|
|
1870
|
+
) -> tuple[str, str]:
|
|
1871
|
+
"""`(choice, that tree's Zephyr version)` from already-gathered facts.
|
|
1872
|
+
|
|
1873
|
+
Untouched reuse needs ALL THREE of a `.west/` topdir, a manifest resolving
|
|
1874
|
+
to the SDK root, and an EXACT `MAJOR.MINOR.PATCH` match. A tree clearing the
|
|
1875
|
+
first two but not the third is `STALE` -- it is this SDK's own workspace, so
|
|
1876
|
+
`west update` against this SDK's own `west.yml` is precisely what brings it
|
|
1877
|
+
back to the pins, and adopting it is cheaper and less surprising than
|
|
1878
|
+
cloning a second Zephyr elsewhere.
|
|
1879
|
+
|
|
1880
|
+
STILL NOT COVERED: only `zephyr`'s pin is compared. A bump touching only a
|
|
1881
|
+
non-`zephyr` `west.yml` project (`hal_alif`, `cmsis`, `mcuboot`) leaves the
|
|
1882
|
+
version identical, so this still returns `REUSE`.
|
|
1883
|
+
"""
|
|
1884
|
+
version = parse_zephyr_version_file(version_file)
|
|
1885
|
+
if version is None or not top_is_west_workspace:
|
|
1886
|
+
# No readable VERSION -- nothing to judge, so it cannot be adopted.
|
|
1887
|
+
return INCOMPATIBLE, version or ""
|
|
1888
|
+
if not manifest_is_sdk:
|
|
1889
|
+
# #769 stays version-gated: a foreign tree on some unrelated Zephyr is
|
|
1890
|
+
# simply not this workspace, and gets the plain "ignoring it" message.
|
|
1891
|
+
return (MANIFEST_MISMATCH if version == pin else INCOMPATIBLE), version
|
|
1892
|
+
return (REUSE if version == pin else STALE), version
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def parent_needs_workspace_guard(
|
|
1896
|
+
entries: list[str],
|
|
1897
|
+
checkout_name: str,
|
|
1898
|
+
venv_dir_name: str,
|
|
1899
|
+
dot_west_is_workspace: bool,
|
|
1900
|
+
) -> bool:
|
|
1901
|
+
"""Whether the checkout's parent needs the workspace-parent guard.
|
|
1902
|
+
|
|
1903
|
+
`west init -l <alp-sdk>` forces the west topdir to be the checkout's own
|
|
1904
|
+
PARENT, so a customer who clones into `~/Downloads` gets
|
|
1905
|
+
zephyr/modules/.west/venv sprayed there, unannounced, outside the checkout
|
|
1906
|
+
where no `.gitignore` can reach it. Proceed silently when the parent holds
|
|
1907
|
+
NOTHING BUT the checkout, bootstrap's OWN venv, and/or an existing west
|
|
1908
|
+
workspace; otherwise guard.
|
|
1909
|
+
|
|
1910
|
+
`dot_west_is_workspace` is a TYPED fact the caller computes with a
|
|
1911
|
+
filesystem check, never inferred from `entries` containing the literal
|
|
1912
|
+
`".west"`: a plain FILE named `.west` is not a workspace, and letting the
|
|
1913
|
+
NAME answer that was a false PROCEED. When it is true, every other entry is
|
|
1914
|
+
that workspace's own content.
|
|
1915
|
+
|
|
1916
|
+
Otherwise the parent is judged purely on COUNT, dotfiles included.
|
|
1917
|
+
Deliberately NOT a directory-NAME check (no `Downloads`/`Desktop` list): a
|
|
1918
|
+
name list is locale-dependent and incomplete by construction.
|
|
1919
|
+
"""
|
|
1920
|
+
if dot_west_is_workspace:
|
|
1921
|
+
return False
|
|
1922
|
+
venv_top = re.split(r"[\\/]", venv_dir_name)[0] if venv_dir_name else None
|
|
1923
|
+
return any(entry != checkout_name and entry != venv_top for entry in entries)
|
|
1924
|
+
|
|
1925
|
+
|
|
1926
|
+
def resolve_workspace_target(raw: str, cwd: str) -> str:
|
|
1927
|
+
"""Validate + absolutise `--workspace <path>`. Raises `ValueError`.
|
|
1928
|
+
|
|
1929
|
+
This relocates a customer's checkout, so an empty value (`--workspace ""`,
|
|
1930
|
+
the classic unset-`$WS` shell accident) or an ambiguous drive-relative one
|
|
1931
|
+
(an MSYS-style `/e/foo/ws` on Windows) must never resolve to a guess. Pure
|
|
1932
|
+
validation -- no IO.
|
|
1933
|
+
"""
|
|
1934
|
+
trimmed = raw.strip()
|
|
1935
|
+
if not trimmed:
|
|
1936
|
+
raise ValueError("--workspace requires a non-empty path")
|
|
1937
|
+
if _is_drive_relative(trimmed):
|
|
1938
|
+
raise ValueError(_drive_relative(trimmed))
|
|
1939
|
+
# Rooted-but-driveless is decided BEFORE absoluteness, not inside it: the
|
|
1940
|
+
# two `isabs` oracles below disagree about this exact shape either side of
|
|
1941
|
+
# CPython 3.13, and the old ordering rode on that (tan-cli#1139 --
|
|
1942
|
+
# `_is_rooted_no_drive`).
|
|
1943
|
+
if _is_rooted_no_drive(trimmed):
|
|
1944
|
+
raise ValueError(_rooted_no_drive(trimmed))
|
|
1945
|
+
if os.path.isabs(trimmed) or ntpath_isabs(trimmed):
|
|
1946
|
+
return os.path.normpath(trimmed)
|
|
1947
|
+
if trimmed.startswith(("/", "\\")):
|
|
1948
|
+
# Not reachable through any input measured on 3.12.3/3.13.15/3.14.7: a
|
|
1949
|
+
# single leading separator is refused above on Windows and for any
|
|
1950
|
+
# `\`-led input, and is POSIX-absolute on the LINE ABOVE otherwise
|
|
1951
|
+
# (`/proj/x` is never refused on POSIX); a doubled one is
|
|
1952
|
+
# `ntpath_isabs`-absolute on all three. Kept so "a rooted path is NEVER
|
|
1953
|
+
# silently joined onto `cwd`" is total rather than contingent on what a
|
|
1954
|
+
# future `ntpath.isabs` says (tan-cli#1139).
|
|
1955
|
+
raise ValueError(_rooted_no_drive(trimmed))
|
|
1956
|
+
return os.path.normpath(os.path.join(cwd, trimmed))
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def _is_drive_relative(raw: str) -> bool:
|
|
1960
|
+
"""`C:ws` -- a drive letter with NO separator after it (tan-cli#495 defect 8).
|
|
1961
|
+
|
|
1962
|
+
Windows keeps a separate current directory PER DRIVE, so `C:ws` means "`ws`
|
|
1963
|
+
under whatever the process's current directory on C: happens to be" -- the
|
|
1964
|
+
same class of ambiguity `--workspace /e/foo` is already refused for, and
|
|
1965
|
+
the caller is about to RELOCATE a customer's checkout into it.
|
|
1966
|
+
|
|
1967
|
+
Fixed here at the call site rather than in `ntpath_isabs`, which has two
|
|
1968
|
+
callers and wants the opposite answers from them. `ntpath.isabs("C:ws")`
|
|
1969
|
+
is False while `ntpath_isabs`'s own `^[A-Za-z]:` regex is True, and that
|
|
1970
|
+
disagreement is CORRECT for the other caller: `is_plain_relative` must
|
|
1971
|
+
reject `C:ws` as a manifest-supplied directory name, which is exactly what
|
|
1972
|
+
the regex buys it. Narrowing the predicate would have let `C:ws` through
|
|
1973
|
+
there as a "plain relative" name to be joined onto the workspace.
|
|
1974
|
+
|
|
1975
|
+
CONFIRMED post-3.13 (tan-cli#1139), which is not a given: `ntpath.isabs`
|
|
1976
|
+
changed in that release, but not for `"C:ws"` -- `False` on 3.12.3,
|
|
1977
|
+
3.13.15 and 3.14.7 alike -- so the paragraph above still describes the
|
|
1978
|
+
live code. It does NOT cover the second, distinct divergence, which is
|
|
1979
|
+
also fixed at a call site but for a different reason; see
|
|
1980
|
+
`_is_rooted_no_drive`.
|
|
1981
|
+
"""
|
|
1982
|
+
return bool(re.match(r"^[A-Za-z]:(?![\\/])", raw))
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def _is_rooted_no_drive(raw: str) -> bool:
|
|
1986
|
+
"""`\\proj\\ws` -- a SINGLE leading separator, so a root but no drive
|
|
1987
|
+
(tan-cli#1139; the fourth measured `pathlib`/`ntpath` difference across
|
|
1988
|
+
this repo's supported range, after tan-cli#1126's three). THE canonical
|
|
1989
|
+
site for that measurement -- `ntpath_isabs`, `_is_drive_relative` and
|
|
1990
|
+
`resolve_workspace_target`'s tail comment point here, not restate it.
|
|
1991
|
+
|
|
1992
|
+
Asked as a REGEX over the string rather than as an `isabs` question, and
|
|
1993
|
+
ahead of `resolve_workspace_target`'s absoluteness branch, because
|
|
1994
|
+
`ntpath.isabs` answers this shape differently either side of CPython 3.13
|
|
1995
|
+
while `PureWindowsPath.is_absolute()` has said `False` throughout --
|
|
1996
|
+
measured, not assumed:
|
|
1997
|
+
|
|
1998
|
+
interpreter ntpath.isabs(r"\\proj\\x") PureWindowsPath(...).is_absolute()
|
|
1999
|
+
3.12.3 True False
|
|
2000
|
+
3.13.15 False False
|
|
2001
|
+
3.14.7 False False
|
|
2002
|
+
|
|
2003
|
+
The regex is not merely SUFFICIENT for the shapes someone thought to
|
|
2004
|
+
probe, it is EQUIVALENT to the divergence: over a 40-input corpus, on all
|
|
2005
|
+
three interpreters, `ntpath.isabs` differs across the boundary iff the
|
|
2006
|
+
string matches `^[\\/](?![\\/])` -- zero mismatches in either direction.
|
|
2007
|
+
So this covers the whole class, tripled and mixed separators included.
|
|
2008
|
+
|
|
2009
|
+
`requires-python = ">=3.12"`, so both answers are live. Nested inside the
|
|
2010
|
+
absoluteness branch the guard inherited the split and was DEAD off Windows
|
|
2011
|
+
on 3.12: `--workspace \\proj\\ws` fell through to `normpath` and relocated a
|
|
2012
|
+
checkout into a literal `\\proj\\ws` directory instead of raising.
|
|
2013
|
+
|
|
2014
|
+
Host-aware, because the two separators are not the same fact: a leading
|
|
2015
|
+
`/` IS a genuine absolute path on POSIX and must keep resolving there,
|
|
2016
|
+
while a leading `\\` names no drive on any host and `/` names none on
|
|
2017
|
+
Windows. TWO leading separators are excluded -- a UNC root or a device
|
|
2018
|
+
path (`\\\\srv\\share`, `\\\\?\\C:\\ws`), absolute on all three.
|
|
2019
|
+
"""
|
|
2020
|
+
if not re.match(r"^[\\/](?![\\/])", raw):
|
|
2021
|
+
return False
|
|
2022
|
+
return os.name == "nt" or raw[0] == "\\"
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
def _drive_relative(trimmed: str) -> str:
|
|
2026
|
+
return (
|
|
2027
|
+
f"--workspace '{trimmed}' is drive-relative, which is ambiguous (it would "
|
|
2028
|
+
f"resolve against the current directory on that drive, not the drive root); "
|
|
2029
|
+
f"pass a full absolute path such as '{trimmed[:2]}\\{trimmed[2:]}' instead"
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def _rooted_no_drive(trimmed: str) -> str:
|
|
2034
|
+
return (
|
|
2035
|
+
f"--workspace '{trimmed}' has a root but no drive, which is ambiguous on this "
|
|
2036
|
+
f"host (it would resolve against whichever drive the process happens to be "
|
|
2037
|
+
f"running from); pass a full absolute path instead"
|
|
2038
|
+
)
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
# ---------------------------------------------------------------------------
|
|
2042
|
+
# `.west/config` (an ini file, read/written by hand -- west is not installed yet)
|
|
2043
|
+
# ---------------------------------------------------------------------------
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
def _section_header(line: str) -> str | None:
|
|
2047
|
+
trimmed = line.strip()
|
|
2048
|
+
if trimmed.startswith("[") and trimmed.endswith("]"):
|
|
2049
|
+
return trimmed[1:-1].strip()
|
|
2050
|
+
return None
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def _key_value(line: str) -> tuple[str, str] | None:
|
|
2054
|
+
trimmed = line.lstrip()
|
|
2055
|
+
if not trimmed or trimmed[0] in "#;":
|
|
2056
|
+
return None
|
|
2057
|
+
key, sep, value = line.partition("=")
|
|
2058
|
+
if not sep or not key.strip():
|
|
2059
|
+
return None
|
|
2060
|
+
return key.strip(), value.strip()
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def get_manifest_path(config: str) -> str | None:
|
|
2064
|
+
"""The `[manifest]` section's `path = ` value. Section-scoped: a `path =`
|
|
2065
|
+
line under a different section is never returned."""
|
|
2066
|
+
section = ""
|
|
2067
|
+
for line in config.splitlines():
|
|
2068
|
+
header = _section_header(line)
|
|
2069
|
+
if header is not None:
|
|
2070
|
+
section = header
|
|
2071
|
+
continue
|
|
2072
|
+
if section != "manifest":
|
|
2073
|
+
continue
|
|
2074
|
+
pair = _key_value(line)
|
|
2075
|
+
if pair is not None and pair[0].lower() == "path":
|
|
2076
|
+
return pair[1]
|
|
2077
|
+
return None
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def same_directory(a: Path, b: Path) -> bool:
|
|
2081
|
+
"""True when `a` and `b` name the same directory. `realpath` when both exist
|
|
2082
|
+
(the reliable answer); a lexical `normpath`+`normcase` comparison when either
|
|
2083
|
+
does not -- e.g. a stale config's target SDK version since pruned.
|
|
2084
|
+
|
|
2085
|
+
Moved down from `bootstrap_cmd._same_directory` with `manifest_points_at`
|
|
2086
|
+
(tan-cli#495): it is pure path logic with no IO policy of its own, and
|
|
2087
|
+
leaving it in the command module was half of what forced the deferred
|
|
2088
|
+
imports described on `manifest_points_at`.
|
|
2089
|
+
"""
|
|
2090
|
+
try:
|
|
2091
|
+
if a.exists() and b.exists():
|
|
2092
|
+
return os.path.realpath(a) == os.path.realpath(b)
|
|
2093
|
+
except OSError:
|
|
2094
|
+
pass
|
|
2095
|
+
return os.path.normcase(os.path.normpath(str(a))) == os.path.normcase(
|
|
2096
|
+
os.path.normpath(str(b))
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
def manifest_points_at(topdir: Path, repo_root: Path) -> bool:
|
|
2101
|
+
"""Whether `<topdir>/.west/config`'s `[manifest] path` resolves to
|
|
2102
|
+
`repo_root`. west and the venv are not necessarily set up when this is
|
|
2103
|
+
asked, so the config is read directly rather than shelling
|
|
2104
|
+
`west config manifest.path`.
|
|
2105
|
+
|
|
2106
|
+
**Lives in `tan.core` because `tan.core.venv` is its heaviest caller**
|
|
2107
|
+
(tan-cli#495). It was defined in `tan.commands.bootstrap_cmd`, so
|
|
2108
|
+
`venv.py` -- a `tan.core` module -- reached UP into the command layer four
|
|
2109
|
+
separate times, each behind a deferred `# noqa: PLC0415` import whose only
|
|
2110
|
+
job was to dodge the resulting import cycle. That inverts this repo's
|
|
2111
|
+
layering (pure logic in `tan/core` or `tan/planner`, never the command/IO
|
|
2112
|
+
file), and a cycle-dodging import is a symptom to fix rather than
|
|
2113
|
+
annotate. `bootstrap_cmd` now imports it from here like every other
|
|
2114
|
+
consumer.
|
|
2115
|
+
|
|
2116
|
+
Reads leniently (`errors="replace"`), matching the reader this used to be
|
|
2117
|
+
handed: a `.west/config` with a mojibake byte is a config whose `path` key
|
|
2118
|
+
should still be found, not an exception out of a predicate.
|
|
2119
|
+
"""
|
|
2120
|
+
try:
|
|
2121
|
+
config = (topdir / ".west" / "config").read_text(encoding="utf-8", errors="replace")
|
|
2122
|
+
except (OSError, ValueError):
|
|
2123
|
+
return False
|
|
2124
|
+
rel = get_manifest_path(config)
|
|
2125
|
+
if rel is None:
|
|
2126
|
+
return False
|
|
2127
|
+
return same_directory(topdir / rel.strip(), repo_root)
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def set_manifest_path(config: str, new_rel: str) -> str | None:
|
|
2131
|
+
"""`config` with the `[manifest]` section's `path` rewritten, every other
|
|
2132
|
+
line byte-identical -- each line's own terminator (`\\r\\n`, `\\n`, or none
|
|
2133
|
+
for a final newline-less line) survives, so a CRLF `.west/config` stays
|
|
2134
|
+
CRLF. `None` when there is no line to replace."""
|
|
2135
|
+
section = ""
|
|
2136
|
+
out: list[str] = []
|
|
2137
|
+
rewrote = False
|
|
2138
|
+
for segment in config.splitlines(keepends=True):
|
|
2139
|
+
content = segment.rstrip("\r\n")
|
|
2140
|
+
terminator = segment[len(content) :]
|
|
2141
|
+
header = _section_header(content)
|
|
2142
|
+
if header is not None:
|
|
2143
|
+
section = header
|
|
2144
|
+
elif not rewrote and section == "manifest":
|
|
2145
|
+
pair = _key_value(content)
|
|
2146
|
+
if pair is not None and pair[0].lower() == "path":
|
|
2147
|
+
out.append(f"path = {new_rel}{terminator}")
|
|
2148
|
+
rewrote = True
|
|
2149
|
+
continue
|
|
2150
|
+
out.append(segment)
|
|
2151
|
+
return "".join(out) if rewrote else None
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
# ---------------------------------------------------------------------------
|
|
2155
|
+
# The `<topdir>/.west/tan-workspace-sdk` record (tan-cli#292). Written by
|
|
2156
|
+
# `tan.commands.bootstrap_cmd.record_workspace_sdk` after a `west update` that
|
|
2157
|
+
# actually ran; read back by `tan.commands.doctor_cmd`'s `venvProvenance`
|
|
2158
|
+
# check. A record-less workspace (bootstrapped by alp-sdk's own
|
|
2159
|
+
# `bootstrap.sh`, `crates/tan-cli/src/venv.rs:25-27`) is NOT an error here --
|
|
2160
|
+
# `parse_workspace_sdk_record` only ever returns "usable" or `None`.
|
|
2161
|
+
# ---------------------------------------------------------------------------
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
|
+
@dataclass(frozen=True)
|
|
2165
|
+
class WorkspaceSdkRecord:
|
|
2166
|
+
"""A parsed `<topdir>/.west/tan-workspace-sdk`. `sdk_path` is the only
|
|
2167
|
+
field every record (even one written before tan-cli#292) carries; the
|
|
2168
|
+
venv provenance fields are `None` on an older record, or one written by a
|
|
2169
|
+
caller that could not compute them -- ABSENCE, never a claim, so a
|
|
2170
|
+
consumer never reads a `None` as "confirmed empty"."""
|
|
2171
|
+
|
|
2172
|
+
sdk_path: str
|
|
2173
|
+
#: The venv directory, relative to `topdir` (e.g. `.venv`) -- so a moved
|
|
2174
|
+
#: workspace, or one whose `metadata/bootstrap.json` names a non-default
|
|
2175
|
+
#: `venv.dirName`, still resolves without re-deriving it.
|
|
2176
|
+
venv_dir_name: str | None = None
|
|
2177
|
+
#: The bin-dir layout actually created (`bin` / `Scripts`, tan-cli#291) --
|
|
2178
|
+
#: which directory `venv_dir_name` holds the executables under.
|
|
2179
|
+
venv_layout: str | None = None
|
|
2180
|
+
#: Lowercase-hex SHA-256 of the `zephyr.requirementsPath` file that
|
|
2181
|
+
#: populated the venv's Python packages (`bootstrap_cmd.pip_phase`) --
|
|
2182
|
+
#: the provenance stamp: the venv can be re-verified against a LATER
|
|
2183
|
+
#: read of the same file without re-running pip.
|
|
2184
|
+
requirements_digest: str | None = None
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
def workspace_sdk_record_json(
|
|
2188
|
+
sdk_path: str,
|
|
2189
|
+
venv_dir_name: str | None = None,
|
|
2190
|
+
venv_layout: str | None = None,
|
|
2191
|
+
requirements_digest: str | None = None,
|
|
2192
|
+
) -> str:
|
|
2193
|
+
"""The `<topdir>/.west/tan-workspace-sdk` record's contents: which SDK a
|
|
2194
|
+
`west update` last synced this topdir's trees to, plus (tan-cli#292) which
|
|
2195
|
+
venv it populated and a content-hash provenance stamp for the Zephyr
|
|
2196
|
+
requirements that filled it. `updatedAt` is `generated_at_iso()`, matching
|
|
2197
|
+
`sdk_pointer_json`'s own self-contained timestamp -- `SOURCE_DATE_EPOCH`
|
|
2198
|
+
wins over the clock, so a captured record is reproducible, and that helper
|
|
2199
|
+
NEVER raises (an out-of-range epoch used to kill `tan init` here).
|
|
2200
|
+
|
|
2201
|
+
Deliberately its OWN function, not a `tan.core.scaffold.sdk_pointer_json`
|
|
2202
|
+
extension: that function is the `.alp/sdk-path` PROJECT pin and
|
|
2203
|
+
`~/.alp/sdk-default` GLOBAL pin -- a different record with different
|
|
2204
|
+
readers (`tan init`'s scaffold, `sdk_cmd`'s resolution ladder) -- growing
|
|
2205
|
+
ITS shape for this record's needs would silently add fields those readers
|
|
2206
|
+
never asked for and never validate.
|
|
2207
|
+
|
|
2208
|
+
`venv_dir_name`/`venv_layout`/`requirements_digest` are omitted from the
|
|
2209
|
+
JSON (not written as `null`) when the caller has nothing to report --
|
|
2210
|
+
mirroring `Check.as_dict`'s optional fields -- so a record predating
|
|
2211
|
+
tan-cli#292 and one written by a caller that could not compute a hash are
|
|
2212
|
+
indistinguishable on the wire, and `parse_workspace_sdk_record` reads both
|
|
2213
|
+
as "nothing to compare against" rather than a false claim.
|
|
2214
|
+
"""
|
|
2215
|
+
payload: dict[str, str] = {"sdkPath": sdk_path, "updatedAt": generated_at_iso()}
|
|
2216
|
+
if venv_dir_name is not None:
|
|
2217
|
+
payload["venvDir"] = venv_dir_name
|
|
2218
|
+
if venv_layout is not None:
|
|
2219
|
+
payload["venvLayout"] = venv_layout
|
|
2220
|
+
if requirements_digest is not None:
|
|
2221
|
+
payload["requirementsDigest"] = requirements_digest
|
|
2222
|
+
return json.dumps(payload, indent=2) + "\n"
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
def parse_workspace_sdk_record(text: str) -> WorkspaceSdkRecord | None:
|
|
2226
|
+
"""Parse a `<topdir>/.west/tan-workspace-sdk` record's text. `None` on
|
|
2227
|
+
anything that is not a usable record -- not JSON, not an object, or no
|
|
2228
|
+
usable `sdkPath` -- so a record `doctor` cannot read is "nothing to
|
|
2229
|
+
compare against", the SAME as no record at all, never a mismatch WARNING
|
|
2230
|
+
against a checkout `tan` cannot even name.
|
|
2231
|
+
"""
|
|
2232
|
+
try:
|
|
2233
|
+
doc = json.loads(text)
|
|
2234
|
+
except ValueError:
|
|
2235
|
+
return None
|
|
2236
|
+
if not isinstance(doc, dict):
|
|
2237
|
+
return None
|
|
2238
|
+
sdk_path = doc.get("sdkPath")
|
|
2239
|
+
if not isinstance(sdk_path, str) or not sdk_path:
|
|
2240
|
+
return None
|
|
2241
|
+
|
|
2242
|
+
def _opt(key: str) -> str | None:
|
|
2243
|
+
value = doc.get(key)
|
|
2244
|
+
return value if isinstance(value, str) and value else None
|
|
2245
|
+
|
|
2246
|
+
return WorkspaceSdkRecord(
|
|
2247
|
+
sdk_path=sdk_path,
|
|
2248
|
+
venv_dir_name=_opt("venvDir"),
|
|
2249
|
+
venv_layout=_opt("venvLayout"),
|
|
2250
|
+
requirements_digest=_opt("requirementsDigest"),
|
|
2251
|
+
)
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
# ---------------------------------------------------------------------------
|
|
2255
|
+
# The printed blocks. Copy-pasteable shell snippets, so they carry NO
|
|
2256
|
+
# `bootstrap: ` prefix (unlike the progress lines) and their whitespace is
|
|
2257
|
+
# load-bearing.
|
|
2258
|
+
# ---------------------------------------------------------------------------
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
def render_env_lines(
|
|
2262
|
+
env: tuple[tuple[str, str], ...], tokens: Tokens, prefix: str, is_windows: bool
|
|
2263
|
+
) -> list[str]:
|
|
2264
|
+
"""The manifest's `env` map as shell-ready lines.
|
|
2265
|
+
|
|
2266
|
+
POSIX (`print_env_lines`) quotes the value only when it looks like a path --
|
|
2267
|
+
contains `/` -- which keeps `export ZEPHYR_TOOLCHAIN_VARIANT=zephyr`
|
|
2268
|
+
unquoted while `ZEPHYR_BASE` is quoted. Windows (`Write-EnvLines`) always
|
|
2269
|
+
quotes.
|
|
2270
|
+
|
|
2271
|
+
One deliberate divergence from `bootstrap.ps1`: a token-substituted value is
|
|
2272
|
+
separator-normalised, so Windows emits `C:\\dev\\ws\\zephyr` rather than the
|
|
2273
|
+
script's mixed `C:\\dev\\ws/zephyr`. Both work; only one is copy-pasteable
|
|
2274
|
+
without a double-take. A value with no token in it is passed through
|
|
2275
|
+
untouched.
|
|
2276
|
+
"""
|
|
2277
|
+
lines = []
|
|
2278
|
+
for key, raw in env:
|
|
2279
|
+
value = tokens.apply(raw)
|
|
2280
|
+
substituted = value != raw
|
|
2281
|
+
if is_windows:
|
|
2282
|
+
if substituted:
|
|
2283
|
+
value = value.replace("/", "\\")
|
|
2284
|
+
lines.append(f'{prefix}$env:{key} = "{value}"')
|
|
2285
|
+
elif "/" in value:
|
|
2286
|
+
lines.append(f'{prefix}export {key}="{value}"')
|
|
2287
|
+
else:
|
|
2288
|
+
lines.append(f"{prefix}export {key}={value}")
|
|
2289
|
+
return lines
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
def print_env_block(
|
|
2293
|
+
facts: BootstrapFacts, tokens: Tokens, venv_bin_dir: str, is_windows: bool
|
|
2294
|
+
) -> list[str]:
|
|
2295
|
+
"""`--print-env`: the venv-activation comment header plus the rendered `env`
|
|
2296
|
+
map. Both scripts print exactly this and exit 0."""
|
|
2297
|
+
venv = facts.venv_dir_name
|
|
2298
|
+
if is_windows:
|
|
2299
|
+
# The workspace token is forward-slash on every OS (the resolved project
|
|
2300
|
+
# path), so it is normalised here or this line comes out mixed
|
|
2301
|
+
# (`C:/Users/dev\.venv\Scripts\Activate.ps1`).
|
|
2302
|
+
workspace = tokens.workspace_dir.replace("/", "\\")
|
|
2303
|
+
lines = [
|
|
2304
|
+
"# Add to your PowerShell profile (or run before invoking the SDK):",
|
|
2305
|
+
"# Activate the workspace venv (west + Zephyr/SDK Python deps live here):",
|
|
2306
|
+
f'# & "{workspace}\\{venv}\\{venv_bin_dir}\\Activate.ps1"',
|
|
2307
|
+
]
|
|
2308
|
+
else:
|
|
2309
|
+
lines = [
|
|
2310
|
+
"# Add to your shell profile (or run before invoking the SDK):",
|
|
2311
|
+
"# Activate the workspace venv (west + Zephyr/SDK Python deps live here):",
|
|
2312
|
+
f'# source "{tokens.workspace_dir}/{venv}/{venv_bin_dir}/activate"',
|
|
2313
|
+
]
|
|
2314
|
+
lines.extend(render_env_lines(facts.env, tokens, "", is_windows))
|
|
2315
|
+
return lines
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
def optional_libs_block(facts: BootstrapFacts, host: str) -> list[str]:
|
|
2319
|
+
"""The trailing manual-install hint.
|
|
2320
|
+
|
|
2321
|
+
POSIX prints the manifest's per-OS optional-native-libs note (plus its
|
|
2322
|
+
install command, when the OS has one); native Windows prints
|
|
2323
|
+
`manualInstallHints.windows.note`, one two-space-indented line per element
|
|
2324
|
+
under its own heading -- the SDK-sourced fact, not a hand-typed copy that
|
|
2325
|
+
would desync silently.
|
|
2326
|
+
|
|
2327
|
+
The Windows arm must NOT also read `nativeLibHints.windows.note`: appending
|
|
2328
|
+
both printed the Arm/Zephyr-SDK sentence twice. That field is parsed for
|
|
2329
|
+
round-trip fidelity but rendered by NOTHING here -- host detection reads the
|
|
2330
|
+
real platform, so a Windows host always takes this branch, git-bash or not,
|
|
2331
|
+
and the `bootstrap.sh` arm below is unreachable there.
|
|
2332
|
+
|
|
2333
|
+
NO blank line between the Windows heading and the first note element: the
|
|
2334
|
+
oracle has nothing in between. The POSIX arm below still emits its blank
|
|
2335
|
+
because `bootstrap.sh` genuinely echoes one.
|
|
2336
|
+
"""
|
|
2337
|
+
if host == WINDOWS:
|
|
2338
|
+
lines = ["", "bootstrap: NOT auto-installed (manual, one-time):"]
|
|
2339
|
+
lines.extend(f" {line}" for line in facts.manual_install_windows)
|
|
2340
|
+
return lines
|
|
2341
|
+
|
|
2342
|
+
lines = ["", "bootstrap: Optional native libraries unlock the Yocto-side backends:"]
|
|
2343
|
+
hint = facts.native_lib_hint(host)
|
|
2344
|
+
if hint is None:
|
|
2345
|
+
# Structured as the oracle's `match` is (`blocks.rs:191-202`): this arm
|
|
2346
|
+
# pushes its line and FALLS THROUGH to the manual-install block below
|
|
2347
|
+
# rather than returning. Equivalent today -- `native_lib_hint` is None
|
|
2348
|
+
# only for `OTHER`, which that block already excludes -- but the shape
|
|
2349
|
+
# is kept identical so that adding an OS, or making a per-OS hint
|
|
2350
|
+
# optional at parse, cannot silently re-drop the POSIX note the way
|
|
2351
|
+
# tan-cli#495 defect 6 did.
|
|
2352
|
+
lines.append(" (OS not auto-detected; see docs/testing.md)")
|
|
2353
|
+
else:
|
|
2354
|
+
lines.append("")
|
|
2355
|
+
lines.extend(f" {line}" for line in hint.note)
|
|
2356
|
+
if hint.command:
|
|
2357
|
+
lines.append("")
|
|
2358
|
+
lines.append(f" {hint.command}")
|
|
2359
|
+
# tan-cli#495 defect 6: `manualInstallHints.posix.note`, which the port
|
|
2360
|
+
# dropped at parse, at render AND in the fallback -- so Linux/macOS
|
|
2361
|
+
# customers were never told that `west sdk install` is a separate manual
|
|
2362
|
+
# step, the one every Zephyr-on-M customer needs. Lands AFTER the
|
|
2363
|
+
# optional-native-libs section because the oracle prints it after
|
|
2364
|
+
# (`blocks.rs:229-245`, `bootstrap.sh:594` vs `:638`), under the same
|
|
2365
|
+
# heading the Windows arm uses.
|
|
2366
|
+
#
|
|
2367
|
+
# `LINUX`/`MACOS` ONLY, matching the oracle's `matches!(host, Linux |
|
|
2368
|
+
# MacOs)` and the shell `case` behind it -- never `OTHER`, which must not
|
|
2369
|
+
# start rendering a POSIX-specific fact by accident. The Windows arm
|
|
2370
|
+
# returned early above, so this is unreachable there regardless.
|
|
2371
|
+
if host in (LINUX, MACOS) and facts.manual_install_posix:
|
|
2372
|
+
lines.append("")
|
|
2373
|
+
lines.append("bootstrap: NOT auto-installed (manual, one-time):")
|
|
2374
|
+
lines.extend(f" {line}" for line in facts.manual_install_posix)
|
|
2375
|
+
return lines
|
|
2376
|
+
|
|
2377
|
+
|
|
2378
|
+
def next_steps_block(
|
|
2379
|
+
facts: BootstrapFacts,
|
|
2380
|
+
tokens: Tokens,
|
|
2381
|
+
venv_dir: str,
|
|
2382
|
+
venv_bin_dir: str,
|
|
2383
|
+
is_windows: bool,
|
|
2384
|
+
) -> list[str]:
|
|
2385
|
+
"""The closing "Next steps:" block: activate the venv, export the `env`
|
|
2386
|
+
map, run `tan doctor`, and one ready-to-paste build command."""
|
|
2387
|
+
lines = ["", "Next steps:"]
|
|
2388
|
+
if is_windows:
|
|
2389
|
+
lines.append(
|
|
2390
|
+
" # Activate the workspace venv (west + Zephyr/SDK deps + tan's Python "
|
|
2391
|
+
"backend):"
|
|
2392
|
+
)
|
|
2393
|
+
lines.append(f' & "{venv_dir}\\{venv_bin_dir}\\Activate.ps1"')
|
|
2394
|
+
else:
|
|
2395
|
+
lines.append(" # Activate the workspace venv (west + Zephyr/SDK deps live here):")
|
|
2396
|
+
lines.append(f' source "{venv_dir}/{venv_bin_dir}/activate"')
|
|
2397
|
+
lines.append("")
|
|
2398
|
+
lines.append(" # Make Zephyr reachable for builds:")
|
|
2399
|
+
lines.extend(render_env_lines(facts.env, tokens, " ", is_windows))
|
|
2400
|
+
# The pinned install.sh/install.ps1 one-liner, NOT `cargo install --git`
|
|
2401
|
+
# (that built unpinned HEAD). `tan doctor`, not `--build`: plain doctor
|
|
2402
|
+
# already folds in the build-readiness preflight.
|
|
2403
|
+
if is_windows:
|
|
2404
|
+
install_line = (
|
|
2405
|
+
" # for: irm https://raw.githubusercontent.com/alplabai/tan-cli/main/"
|
|
2406
|
+
"install.ps1 | iex):"
|
|
2407
|
+
)
|
|
2408
|
+
else:
|
|
2409
|
+
install_line = (
|
|
2410
|
+
" # for: curl -fsSL https://raw.githubusercontent.com/alplabai/tan-cli/"
|
|
2411
|
+
"main/install.sh | sh):"
|
|
2412
|
+
)
|
|
2413
|
+
lines.extend(
|
|
2414
|
+
[
|
|
2415
|
+
"",
|
|
2416
|
+
" # Sanity-check the host environment (needs tan on PATH -- see README.md",
|
|
2417
|
+
install_line,
|
|
2418
|
+
" tan doctor",
|
|
2419
|
+
"",
|
|
2420
|
+
]
|
|
2421
|
+
)
|
|
2422
|
+
if is_windows:
|
|
2423
|
+
# `bootstrap.ps1` interpolates a native backslash path here and spells
|
|
2424
|
+
# the example as `examples\...`, so a raw forward-slash `${SDK_ROOT}`
|
|
2425
|
+
# would print mixed.
|
|
2426
|
+
repo_root = tokens.sdk_root.replace("/", "\\")
|
|
2427
|
+
lines.extend(
|
|
2428
|
+
[
|
|
2429
|
+
" # Or jump straight into building an example for real silicon",
|
|
2430
|
+
" # (needs the Zephyr SDK toolchain -- bootstrap just tried to acquire it",
|
|
2431
|
+
" # automatically (ADR 0021 Lane 1 P1); `tan doctor` above confirms "
|
|
2432
|
+
"whether it",
|
|
2433
|
+
" # landed, and names the exact install command if it did not):",
|
|
2434
|
+
" west build -b alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he `",
|
|
2435
|
+
f" examples\\peripheral-io\\uart-echo -- "
|
|
2436
|
+
f"-DEXTRA_ZEPHYR_MODULES={repo_root}",
|
|
2437
|
+
"",
|
|
2438
|
+
"References:",
|
|
2439
|
+
" - docs\\cross-platform-setup.md -- the full per-OS setup guide",
|
|
2440
|
+
" - docs\\cli.md -- the tan CLI verb reference",
|
|
2441
|
+
]
|
|
2442
|
+
)
|
|
2443
|
+
else:
|
|
2444
|
+
# Routed through `tan build`, not a raw `west build`: the printed
|
|
2445
|
+
# success message otherwise routes the customer around tan's own claim
|
|
2446
|
+
# to be "the single executor and the user command surface".
|
|
2447
|
+
# `--sdk-root`/`--project` are ABSOLUTE because the workspace-parent
|
|
2448
|
+
# guard can have just moved the checkout to a sibling
|
|
2449
|
+
# `alp-workspace/alp-sdk`, so `$PWD` silently builds from the wrong tree.
|
|
2450
|
+
lines.extend(
|
|
2451
|
+
[
|
|
2452
|
+
" # Run the local test suite:",
|
|
2453
|
+
" bash scripts/test-all.sh",
|
|
2454
|
+
"",
|
|
2455
|
+
" # Or jump straight into building an example for real silicon",
|
|
2456
|
+
" # (needs the Zephyr SDK toolchain -- bootstrap just tried to acquire it",
|
|
2457
|
+
" # automatically (ADR 0021 Lane 1 P1); `tan doctor` above confirms "
|
|
2458
|
+
"whether it",
|
|
2459
|
+
" # landed, and names the exact install command if it did not):",
|
|
2460
|
+
f' tan build --sdk-root "{tokens.sdk_root}" \\',
|
|
2461
|
+
f' --project "{tokens.sdk_root}/examples/peripheral-io/uart-echo"',
|
|
2462
|
+
"",
|
|
2463
|
+
"References:",
|
|
2464
|
+
" - docs/testing.md -- full test-coverage map + how to run "
|
|
2465
|
+
"from scratch",
|
|
2466
|
+
" - docs/test-plan.md -- per-feature verification ledger "
|
|
2467
|
+
"(\u23f3 / \U0001f7e1 / \u2705)",
|
|
2468
|
+
]
|
|
2469
|
+
)
|
|
2470
|
+
return lines
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
#: Blocking codes that are not an install, with the clause that fits them.
|
|
2474
|
+
#: Every other member reads "<code> did not install".
|
|
2475
|
+
_NOT_AN_INSTALL = {"west-patches-failed": "zephyr/patches.yml was not applied"}
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
def _blocking_clause(blocking: list[str]) -> str:
|
|
2479
|
+
installs = [code for code in blocking if code not in _NOT_AN_INSTALL]
|
|
2480
|
+
clauses = [f"{', '.join(installs)} did not install"] if installs else []
|
|
2481
|
+
clauses += [_NOT_AN_INSTALL[code] for code in blocking if code in _NOT_AN_INSTALL]
|
|
2482
|
+
return "; ".join(clauses)
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
def completion_verdict(blocking: list[str], allow_partial: bool) -> tuple[list[str], bool]:
|
|
2486
|
+
"""The closing text line(s), and whether the run counts as a SUCCESS,
|
|
2487
|
+
given which install phases left the workspace unable to do what it was
|
|
2488
|
+
bootstrapped for (tan-cli#220 / tan-cli#285).
|
|
2489
|
+
|
|
2490
|
+
Ported from the Rust oracle's `verdict()`
|
|
2491
|
+
(`crates/tan-cli/src/commands/bootstrap/mod.rs`), not re-derived: the
|
|
2492
|
+
wording, the named failures and the `--allow-partial` escape hatch are the
|
|
2493
|
+
ALREADY-SHIPPED, ALREADY TAGGED (`CHANGELOG.md` `[0.5.0-rc1]`) contract
|
|
2494
|
+
tan-cli#220 defined. A second, independently-worded rule for the same
|
|
2495
|
+
decision is exactly how this port's closing line, its escape hatch and its
|
|
2496
|
+
severity drift from the one alp-sdk-vscode and every other consumer
|
|
2497
|
+
already integrated against.
|
|
2498
|
+
|
|
2499
|
+
`blocking` is `Log.blocking()`'s output, in the order the warnings were
|
|
2500
|
+
raised: the subset of recorded warning codes after which the workspace
|
|
2501
|
+
cannot do what it was bootstrapped for (`WORKSPACE_BLOCKING`). Empty (the
|
|
2502
|
+
normal case) reports success, unchanged from before tan-cli#220.
|
|
2503
|
+
|
|
2504
|
+
Printing `bootstrap: complete.` and exiting 0 after a step already warned
|
|
2505
|
+
the venv is incomplete is the original defect: both read as an unqualified
|
|
2506
|
+
green light, and nothing about the exit code or the closing line told a
|
|
2507
|
+
consumer -- human or the extension -- to go look back at a warning that
|
|
2508
|
+
may have scrolled off screen minutes earlier (`hidapi`'s wheel build is
|
|
2509
|
+
minutes into a cold `west update`). `--allow-partial` is the informed
|
|
2510
|
+
escape: it still reports success, but the line still NAMES what did not
|
|
2511
|
+
install, so accepting the gap is a choice rather than a silent default.
|
|
2512
|
+
"""
|
|
2513
|
+
if not blocking:
|
|
2514
|
+
return ["bootstrap: complete."], True
|
|
2515
|
+
named = _blocking_clause(blocking)
|
|
2516
|
+
if allow_partial:
|
|
2517
|
+
return (
|
|
2518
|
+
[
|
|
2519
|
+
"bootstrap: complete.",
|
|
2520
|
+
f" (--allow-partial: {named}; commands that need them will fail.)",
|
|
2521
|
+
],
|
|
2522
|
+
True,
|
|
2523
|
+
)
|
|
2524
|
+
return (
|
|
2525
|
+
[
|
|
2526
|
+
f"bootstrap: INCOMPLETE -- {named}, so this workspace "
|
|
2527
|
+
f"cannot build yet.",
|
|
2528
|
+
" The messages above name the remedy for each. Fix them and re-run `tan "
|
|
2529
|
+
"bootstrap`, or pass --allow-partial to accept this workspace as-is (the "
|
|
2530
|
+
"west workspace and venv are already on disk, and a build that needs none "
|
|
2531
|
+
"of the missing packages will still work).",
|
|
2532
|
+
],
|
|
2533
|
+
False,
|
|
2534
|
+
)
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
def capture_tail(stdout: bytes | str, stderr: bytes | str, lines: int = 4) -> str:
|
|
2538
|
+
"""The last few non-empty lines of a failed step's captured output. Prefers
|
|
2539
|
+
stderr, falling back to stdout when stderr is empty; `""` when there is
|
|
2540
|
+
nothing usable.
|
|
2541
|
+
|
|
2542
|
+
Without this the JSON envelope carried no failure reason at all -- a pip
|
|
2543
|
+
traceback, a "no such file" -- because only the exit status was read.
|
|
2544
|
+
|
|
2545
|
+
`lines` defaults to 4 -- unchanged for every existing caller. `west sdk
|
|
2546
|
+
install`'s failure path (`bootstrap_cmd._acquire_toolchain`) passes a
|
|
2547
|
+
wider window (tan-cli#990 review): a real CI run's `tar --xz` extraction
|
|
2548
|
+
failure produced a message naming NO cause at all, because the actual
|
|
2549
|
+
subprocess error line sat above the closing frames of `west`'s own
|
|
2550
|
+
Python traceback and the 4-line default discarded it along with
|
|
2551
|
+
everything else -- a diagnostic gap that cannot be recovered after the
|
|
2552
|
+
fact, since the untruncated child output is never written anywhere else.
|
|
2553
|
+
"""
|
|
2554
|
+
text = _as_text(stderr)
|
|
2555
|
+
if not text.strip():
|
|
2556
|
+
text = _as_text(stdout)
|
|
2557
|
+
tail = [line for line in text.splitlines() if line.strip()][-lines:]
|
|
2558
|
+
return " | ".join(tail)
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def _as_text(value: bytes | str) -> str:
|
|
2562
|
+
if isinstance(value, bytes):
|
|
2563
|
+
return value.decode("utf-8", errors="replace")
|
|
2564
|
+
return value or ""
|
|
2565
|
+
|
|
2566
|
+
|
|
2567
|
+
def die(base: str, detail: str) -> str:
|
|
2568
|
+
"""A fatal message: the script's own `die` text plus whatever detail the
|
|
2569
|
+
runner recovered. Text mode usually has none (the child's log already
|
|
2570
|
+
streamed), so the bare message is what the user sees there -- no dangling
|
|
2571
|
+
colon."""
|
|
2572
|
+
return f"{base}: {detail}" if detail.strip() else base
|