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/scaffold.py
ADDED
|
@@ -0,0 +1,2203 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""What files a `tan init` template lays down, and the disk diff/write around it.
|
|
3
|
+
|
|
4
|
+
Pure domain plus reads of tan's OWN template data. Mirrors
|
|
5
|
+
`crates/tan-core/src/wizard/` (`service/c_project.rs`, `service/vendored.rs`,
|
|
6
|
+
`service/example_catalog.rs`, `filesystem.rs`); the command shell that turns
|
|
7
|
+
these results into an envelope is `tan.commands.init_cmd`.
|
|
8
|
+
|
|
9
|
+
**No SDK checkout is consulted anywhere in here (I-32).** Five of the six
|
|
10
|
+
templates render a byte-for-byte capture of the SDK's own `--emit scaffold`
|
|
11
|
+
output, checked in under `tan/templates/vendored/` (see that package's
|
|
12
|
+
docstring); the sixth, `minimal-app`, is tan's OWN hand-generated stub and has
|
|
13
|
+
no SDK catalog entry at all. So a fresh customer with no alp-sdk anywhere gets
|
|
14
|
+
a real project, and -- the reason the contract fixture can exist -- `tan init
|
|
15
|
+
--template minimal-app --preview` is deterministic in an empty temp directory.
|
|
16
|
+
`--from-example` is the one init path that genuinely needs a checkout, because
|
|
17
|
+
it copies a directory out of one; it reports `init.sdk-root-unresolved`.
|
|
18
|
+
|
|
19
|
+
Deliberately NOT reimplemented here: the SDK's own
|
|
20
|
+
`scripts/alp_template.py::_scaffold_cmakelists()` regex rewrite of a scaffolded
|
|
21
|
+
`CMakeLists.txt`. That rewrite matches the *current* boilerplate text and
|
|
22
|
+
returns its input unchanged when it does not recognise the shape, so a second
|
|
23
|
+
implementation of it in tan is a silent `ALP_SDK_ROOT`-guess bug waiting for
|
|
24
|
+
the next SDK CMake change. It ran ONCE, at vendor time, and its output is what
|
|
25
|
+
`vendored/` holds.
|
|
26
|
+
|
|
27
|
+
**Hardware facts (the one documented exception).** `app_core_for_sku` maps a SKU
|
|
28
|
+
prefix to a core id, and `_family_bucket` maps one to a vendored tree. Both
|
|
29
|
+
descend from the Rust, both are the *only* hardware knowledge in the port, and
|
|
30
|
+
both exist because a scaffold has to name a core before any SDK is reachable --
|
|
31
|
+
`tan validate` re-checks the guess once one is. They now read ONE table
|
|
32
|
+
(`_SOM_FAMILIES`), which is what makes "the two derivations can never disagree"
|
|
33
|
+
true rather than aspirational: tan-cli#579 is exactly the bug where they did,
|
|
34
|
+
`app_core_for_sku` having grown an arm that `_family_bucket` never got, so a
|
|
35
|
+
`--som` of that family silently rendered the ALIF tree's content. A family the
|
|
36
|
+
table knows has no vendored tree is now REFUSED (`UnsupportedSomError` ->
|
|
37
|
+
`init.som-unsupported`), never rendered against another vendor's. Do not grow
|
|
38
|
+
this: no SKU list, no addresses, no pin names. Note what is NOT here as a result: the
|
|
39
|
+
OS is never selected (I-01/I-02) -- every generated core entry is `os: zephyr`
|
|
40
|
+
because the app source this scaffold writes is Zephyr source, and a scaffolded
|
|
41
|
+
`board.yaml` carries no top-level `os:` key at all.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from tan.core.os_class import infer_runtime_for_core_id
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
import re
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from pathlib import Path, PureWindowsPath
|
|
52
|
+
|
|
53
|
+
from tan.core.fs_confine import PathEscapeError, resolve_confined
|
|
54
|
+
from tan.core.scaffold_selftest_identity import retarget_example_build_target_comment
|
|
55
|
+
from tan.core.scaffold_selftest_identity import retarget_selftest_soc_identity
|
|
56
|
+
from tan.core.scaffold_selftest_identity import retarget_selftest_som_identity
|
|
57
|
+
from tan.core.timestamp import generated_at_iso
|
|
58
|
+
from tan.templates import VENDORED_ROOT
|
|
59
|
+
|
|
60
|
+
#: Template ids, in registry order (`WizardTemplateId::as_str`). Wire contract:
|
|
61
|
+
#: `data.templateId` echoes one of these verbatim.
|
|
62
|
+
TEMPLATE_IDS = (
|
|
63
|
+
"minimal-app",
|
|
64
|
+
"zephyr-app",
|
|
65
|
+
"sensor-starter",
|
|
66
|
+
"iot-starter",
|
|
67
|
+
"edge-ai-starter",
|
|
68
|
+
"board-diagnostics",
|
|
69
|
+
"multicore-mailbox",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
#: The template a non-interactive `tan init` with no `--template` gets.
|
|
73
|
+
#: `zephyr-app`, NOT `minimal-app` (tan-cli #97). Until tan-cli#309, TWO bugs
|
|
74
|
+
#: compounded: `board.yaml`'s `app: ./src` sent the planner's `_zephyr_app_dir`
|
|
75
|
+
#: straight at `src/CMakeLists.txt` (it has a `CMakeLists.txt` of its own, so
|
|
76
|
+
#: the parent-fallback that would have reached the real one never fired), and
|
|
77
|
+
#: THAT file called plain `add_executable(alp_app ...)` with no `find_package(
|
|
78
|
+
#: Zephyr ...)` at all -- so `west build -b <board> <project>/src` configured
|
|
79
|
+
#: and linked a genuine x86-64 host binary, silently, for a core declared
|
|
80
|
+
#: `os: zephyr`; the root `CMakeLists.txt` (dead code the whole time) was never
|
|
81
|
+
#: even the file at fault. tan-cli#309 fixed both: the generator itself
|
|
82
|
+
#: (`_minimal_app_root_cmake`/`_minimal_app_src_cmake` below) and `board.yaml`'s
|
|
83
|
+
#: `app:` (`_minimal_app_board_yaml`). zephyr-app stays the default regardless
|
|
84
|
+
#: -- that is a separate, still-live product choice (vendored from a real SDK
|
|
85
|
+
#: catalog entry vs. tan's own hand-generated stub), not something this fix
|
|
86
|
+
#: revisits. Do not "simplify" this back to the first registry entry.
|
|
87
|
+
DEFAULT_TEMPLATE_ID = "zephyr-app"
|
|
88
|
+
|
|
89
|
+
#: tan template id -> its vendored SDK scaffold-catalog directory.
|
|
90
|
+
#: `minimal-app` is absent deliberately: it is the one template with no SDK
|
|
91
|
+
#: catalog entry (`vendored/MANIFEST.md`, "minimal-app stays hand-generated").
|
|
92
|
+
_VENDORED_TEMPLATE_DIR = {
|
|
93
|
+
"zephyr-app": "minimal",
|
|
94
|
+
"sensor-starter": "sensor",
|
|
95
|
+
"iot-starter": "iot",
|
|
96
|
+
"edge-ai-starter": "edge-ai",
|
|
97
|
+
"board-diagnostics": "diagnostics",
|
|
98
|
+
# The only id that matches its SDK catalog id verbatim (tan-cli#864).
|
|
99
|
+
"multicore-mailbox": "multicore-mailbox",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_family_gated(template_id: str) -> bool:
|
|
104
|
+
"""Whether `template_id` renders a family-specific vendored tree at all --
|
|
105
|
+
`False` for `minimal-app` ALONE, tan's one hand-generated, vendor-neutral
|
|
106
|
+
template (`plan_template_files`'s `template_id == "minimal-app"` early
|
|
107
|
+
return; it never reaches `_vendored_family`/`UnsupportedSomError`, so no
|
|
108
|
+
SoM family can ever refuse it). A public read of `_VENDORED_TEMPLATE_DIR`
|
|
109
|
+
rather than a second copy of its key set -- `explain_cmd` uses this to
|
|
110
|
+
decide whether `UNSUPPORTED_SOM_FAMILY_PREFIXES` applies to a given
|
|
111
|
+
template's `data.som` (tan-cli#866)."""
|
|
112
|
+
return template_id in _VENDORED_TEMPLATE_DIR
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
#: The SKU assumed when `--som` is absent (`tan_core::DEFAULT_SOM_SKU`).
|
|
116
|
+
DEFAULT_SOM_SKU = "E1M-AEN801"
|
|
117
|
+
|
|
118
|
+
#: Templates whose SDK catalog entry gates `supported.som_skus` to fewer SKUs
|
|
119
|
+
#: than tan vendors family trees for. Consulted BEFORE anything is planned, so
|
|
120
|
+
#: an unsupported `--som` is refused rather than quietly rendered against the
|
|
121
|
+
#: wrong tree.
|
|
122
|
+
#:
|
|
123
|
+
#: Was a single `IOT_STARTER_SUPPORTED_SKU` constant plus one hard-coded `if`
|
|
124
|
+
#: in `init_cmd`. tan-cli#864 added a second such template and measured both
|
|
125
|
+
#: ways the one-off failed to generalise: `--som E1M-AEN301` rendered the
|
|
126
|
+
#: AEN801 tree at `exitCode 0` (`_family_bucket` maps every unrecognised AEN
|
|
127
|
+
#: prefix onto the default family), and `--som E1M-V2N101` surfaced as
|
|
128
|
+
#: `init.template-unreadable` exit 5 -- "your tan installation is broken" for
|
|
129
|
+
#: a user whose `--som` was simply wrong.
|
|
130
|
+
#:
|
|
131
|
+
#: `iot-starter`: the CC3501E Wi-Fi transport is silicon-validated on this SKU
|
|
132
|
+
#: alone. `multicore-mailbox`: the SDK refuses to emit it for anything else --
|
|
133
|
+
#: `alp_project: multicore-mailbox: sku 'E1M-AEN301' is not supported
|
|
134
|
+
#: (supported: ['E1M-AEN801'])`, rc=1.
|
|
135
|
+
TEMPLATE_SUPPORTED_SKUS: dict[str, tuple[str, ...]] = {
|
|
136
|
+
"iot-starter": ("E1M-AEN801",),
|
|
137
|
+
"multicore-mailbox": ("E1M-AEN801",),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#: Vendored directory name per SoM family, `(alif_ensemble, renesas_v2n)` --
|
|
141
|
+
#: the two representative SKUs the SDK catalog declares.
|
|
142
|
+
_FAMILY_TREES = ("E1M-AEN801", "E1M-V2N101")
|
|
143
|
+
|
|
144
|
+
#: SoM family table: `(SKU prefix, app-core id, vendored tree)`, consulted in
|
|
145
|
+
#: order. A `None` tree means tan vendors NO scaffold for that family (no row
|
|
146
|
+
#: is `None` today).
|
|
147
|
+
#:
|
|
148
|
+
#: ONE table, read by BOTH `app_core_for_sku` and `_family_bucket`, which is
|
|
149
|
+
#: what makes this module's docstring claim -- "the two derivations can never
|
|
150
|
+
#: disagree" -- true by construction instead of by two hand-synced prefix
|
|
151
|
+
#: tests. tan-cli#579: they DID disagree. `app_core_for_sku` grew an arm
|
|
152
|
+
#: `_family_bucket` did not, so every SKU of that family took the latter's
|
|
153
|
+
#: `else` arm onto the Alif tree.
|
|
154
|
+
_SOM_FAMILIES: tuple[tuple[str, str, str | None], ...] = (
|
|
155
|
+
("E1M-V2N", "m33_sm", _FAMILY_TREES[1]), # Renesas RZ/V2N
|
|
156
|
+
("E1M-V2M", "m33_sm", _FAMILY_TREES[1]), # Renesas RZ/V2M -- shares the V2N tree
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
#: SKU prefixes `_SOM_FAMILIES` declares NO vendored tree for -- the exact
|
|
160
|
+
#: fact `_family_bucket`/`UnsupportedSomError`/`init.som-unsupported` gate a
|
|
161
|
+
#: `--som` on, DERIVED from that table rather than retyped, so a family added
|
|
162
|
+
#: to (or removed from) `_SOM_FAMILIES` updates this automatically instead of
|
|
163
|
+
#: needing a second hand-edit. tan-cli#866: `explain_cmd` reads this to
|
|
164
|
+
#: publish the SAME family-level refusal `tan init` already enforces as
|
|
165
|
+
#: structured `data.som` on `tan explain --template`, instead of the
|
|
166
|
+
#: hand-written "(E1M-AEN801 only)" prose that gave #866 its name -- the
|
|
167
|
+
#: exact drift `_SOM_FAMILIES` vs. `app_core_for_sku` risked before tan-cli#579
|
|
168
|
+
#: unified them into one table, now risked again between this table and any
|
|
169
|
+
#: description string that repeats a fact it already states.
|
|
170
|
+
UNSUPPORTED_SOM_FAMILY_PREFIXES: tuple[str, ...] = tuple(
|
|
171
|
+
prefix for prefix, _core, tree in _SOM_FAMILIES if tree is None
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
#: `(app core, tree)` for E1M-AEN* AND for any prefix `_SOM_FAMILIES` does not
|
|
175
|
+
#: recognise. Deliberately still a guess rather than a refusal: `tan init` is
|
|
176
|
+
#: SDK-free and cannot tell a future SKU from a typo, and guessing Alif is
|
|
177
|
+
#: what every version of this module has done. What matters is that BOTH
|
|
178
|
+
#: derivations take this arm together, so an unrecognised SKU still gets a
|
|
179
|
+
#: self-consistent scaffold; `tan validate` re-checks the guess once an SDK
|
|
180
|
+
#: resolves.
|
|
181
|
+
_DEFAULT_FAMILY: tuple[str, str] = ("m55_hp", _FAMILY_TREES[0])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _som_family(sku: str) -> tuple[str, str | None]:
|
|
185
|
+
"""`(app-core id, vendored tree)` for `sku` -- the single lookup both
|
|
186
|
+
hardware-adjacent derivations below are built on."""
|
|
187
|
+
for prefix, core, tree in _SOM_FAMILIES:
|
|
188
|
+
if sku.startswith(prefix):
|
|
189
|
+
return core, tree
|
|
190
|
+
return _DEFAULT_FAMILY
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read_verbatim(path: Path) -> str:
|
|
194
|
+
"""Read `path` as UTF-8 text with newlines UNTRANSLATED.
|
|
195
|
+
|
|
196
|
+
Every scaffold byte round-trips through here. Universal-newline mode would
|
|
197
|
+
fold a CRLF file's `\\r\\n` to `\\n` on read and expand it back on write, so on
|
|
198
|
+
Windows an LF template would be written CRLF (breaking byte-parity with the
|
|
199
|
+
Rust binary's output and with the vendored LF capture) and an existing CRLF
|
|
200
|
+
file would compare EQUAL to LF planned content -- a real on-disk difference
|
|
201
|
+
reported as `unchanged`.
|
|
202
|
+
|
|
203
|
+
`open(newline="")`, not `Path.read_text(newline=...)`: that keyword landed in
|
|
204
|
+
3.13 and this package's floor is 3.12.
|
|
205
|
+
|
|
206
|
+
Raises `OSError` for a non-UTF-8 file too (tan-cli#415): `UnicodeDecodeError`
|
|
207
|
+
is a `ValueError`, not an `OSError`, so a bare `except OSError` around a
|
|
208
|
+
call to this function -- both call sites below use exactly that -- would
|
|
209
|
+
otherwise let a corrupt vendored tree escape as an unhandled traceback
|
|
210
|
+
instead of the `TemplateDataError` they already raise for every other way
|
|
211
|
+
the read can fail. Folded in here, once, rather than duplicated at each
|
|
212
|
+
call site: mirrors Rust's own `read_to_string`, which returns an
|
|
213
|
+
`io::Error` (kind `InvalidData`) for invalid UTF-8 rather than a distinct
|
|
214
|
+
error type (the same equivalence `kconfig_cmd.py`'s `_resolve_core`
|
|
215
|
+
documents for the identical reason).
|
|
216
|
+
|
|
217
|
+
The synthesised `OSError` NAMES THE FILE (tan-cli#494 defect 4). Python's
|
|
218
|
+
own `open()` interpolates the filename into every `OSError` it raises, so
|
|
219
|
+
the permission-denied branch always reported a path; the codec branch --
|
|
220
|
+
the one a stray binary actually trips -- raised a bare
|
|
221
|
+
`UnicodeDecodeError` whose `str()` is only `'utf-8' codec can't decode
|
|
222
|
+
byte 0xff in position 88: invalid start byte`. The caller wraps that
|
|
223
|
+
verbatim into `init.example-unreadable`, leaving the customer with a
|
|
224
|
+
600-file tree and nothing to act on. The oracle's own message
|
|
225
|
+
(`read_to_string` -> `io::Error`) names the path, so this restores parity
|
|
226
|
+
as well as sense.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
230
|
+
return handle.read()
|
|
231
|
+
except UnicodeDecodeError as err:
|
|
232
|
+
raise OSError(f"{path}: {err}") from err
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _write_verbatim(path: Path, content: str) -> None:
|
|
236
|
+
"""The write half of [`_read_verbatim`] -- same reasoning, same keyword."""
|
|
237
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
238
|
+
handle.write(content)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TemplateDataError(Exception):
|
|
242
|
+
"""tan's own vendored template tree is missing or unreadable.
|
|
243
|
+
|
|
244
|
+
Not a user error and not reachable from a correct build: the tree ships
|
|
245
|
+
inside the package. It IS reachable from a frozen binary built without the
|
|
246
|
+
`--add-data` that carries it, which is why this is an exception with its own
|
|
247
|
+
issue code instead of an `IOError` escaping as a traceback.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class UnsupportedSomError(Exception):
|
|
252
|
+
"""`--som` names a SoM family tan vendors no scaffold tree for, so the
|
|
253
|
+
requested template cannot be rendered for it (tan-cli#579).
|
|
254
|
+
|
|
255
|
+
A USER error (exit 2), not a broken installation: the tree is not missing,
|
|
256
|
+
it was never captured, because alp-sdk's own scaffold catalog declares no
|
|
257
|
+
entry for that family. `init_cmd` reports it as `init.som-unsupported`.
|
|
258
|
+
|
|
259
|
+
Refusing is the whole point, and it is a DELIBERATE divergence from the
|
|
260
|
+
frozen v0.4.1 oracle, which exits 0 with `issues: []` and writes the Alif
|
|
261
|
+
tree for a family it has no tree for. The two alternatives were weighed and
|
|
262
|
+
rejected:
|
|
263
|
+
|
|
264
|
+
* **Vendor a tree for it.** Not tan's to write. `templates/vendored/` is a
|
|
265
|
+
byte-for-byte capture of alp-sdk's `--emit scaffold` output (see its
|
|
266
|
+
`MANIFEST.md`), and `tests/parity/scaffold_byte_parity.py` re-runs the
|
|
267
|
+
live emit against a reachable checkout and fails on drift. A tree
|
|
268
|
+
hand-authored here would be tan inventing hardware facts (I-26) AND
|
|
269
|
+
would fail that gate the moment an SDK is reachable. When alp-sdk adds
|
|
270
|
+
the catalog entry, this refusal retires by filling in one table row in
|
|
271
|
+
`_SOM_FAMILIES`.
|
|
272
|
+
* **Fall back with a loud advisory.** The wrong-vendor project still
|
|
273
|
+
lands on disk, gets committed, and stays wrong: its `CMakeLists.txt`
|
|
274
|
+
asks the SDK loader for the tree family's core while the `board.yaml`
|
|
275
|
+
beside it declares the target's, its README's `west build -b ...` line
|
|
276
|
+
names another vendor's board target, and its `chips:`/`preset:` describe
|
|
277
|
+
another module's BOM. A one-line warning on one terminal, once, does not
|
|
278
|
+
undo that.
|
|
279
|
+
|
|
280
|
+
Scoped to the VENDORED templates: `minimal-app` is tan's own
|
|
281
|
+
hand-generated, vendor-neutral stub and stays available for every SKU --
|
|
282
|
+
which is what makes refusing cheap rather than a dead end.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self, template_id: str, sku: str) -> None:
|
|
286
|
+
super().__init__(
|
|
287
|
+
f"Template '{template_id}' has no vendored scaffold for SoM '{sku}'. "
|
|
288
|
+
f"tan ships scaffold trees for the E1M-AEN* and E1M-V2N*/E1M-V2M* "
|
|
289
|
+
f"families only; rendering one of those for this SoM would write "
|
|
290
|
+
f"another vendor's content under your SKU -- its board.yaml "
|
|
291
|
+
f"`preset:`/`chips:`, its README's `west build -b ...` target, and a "
|
|
292
|
+
f"CMakeLists.txt pinned to that family's core. Use `--template "
|
|
293
|
+
f"minimal-app`, which is vendor-neutral and scaffolds any SoM, or "
|
|
294
|
+
f"`--from-example <example>` with an alp-sdk checkout."
|
|
295
|
+
)
|
|
296
|
+
self.template_id = template_id
|
|
297
|
+
self.sku = sku
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class SomBlockUnsupportedError(Exception):
|
|
301
|
+
"""Base for every reason `vendored_som`/`retarget_board_yaml_som` REFUSE
|
|
302
|
+
a `som:` block instead of silently discarding `--som` -- catch THIS at
|
|
303
|
+
every call site (`init_cmd.py`), not either leaf below, so a THIRD leaf
|
|
304
|
+
added for the next spelling needs no call site touched outside this
|
|
305
|
+
module.
|
|
306
|
+
|
|
307
|
+
tan-cli#1041 review: this class did not exist until the amendment
|
|
308
|
+
widened tan-cli#1041 from three spellings to six and two of them turned
|
|
309
|
+
out to need a message that was not about flow style at all
|
|
310
|
+
(`UnreadableSomBlockError`, below) -- `FlowStyleSomError` alone was
|
|
311
|
+
already the exact shape `init_cmd.py`'s three `except` clauses wanted,
|
|
312
|
+
so splitting the raise from the catch (a common base, not a rename) was
|
|
313
|
+
the smaller, safer change.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class FlowStyleSomError(SomBlockUnsupportedError):
|
|
318
|
+
"""The board.yaml's top-level `som:` line opens a YAML FLOW mapping
|
|
319
|
+
(`som: {sku: ..., hw_rev: ...}`, valid YAML but on one physical line)
|
|
320
|
+
rather than the BLOCK style (`som:` alone, `sku:`/`hw_rev:` indented
|
|
321
|
+
beneath it) `vendored_som`/`retarget_board_yaml_som` understand.
|
|
322
|
+
|
|
323
|
+
tan-cli#1029, `Refs #1008`. This module is deliberately NOT a YAML
|
|
324
|
+
parser (see its own module docstring): it scans line-by-line and edits
|
|
325
|
+
in place, which is what lets a hand-authored comment, a wrapped comment
|
|
326
|
+
block, or column alignment survive a `--som` retarget byte-for-byte.
|
|
327
|
+
Extending that scan to also parse a flow mapping correctly -- telling a
|
|
328
|
+
comma inside a quoted value apart from one separating two keys, an
|
|
329
|
+
escaped brace from a real one, a `{`/`}` that itself appears inside a
|
|
330
|
+
quoted string -- needs a real YAML parser, not a seventh line-oriented
|
|
331
|
+
special case bolted onto tan-cli#1008's own six-round history of exactly
|
|
332
|
+
that kind of case. Refusing loudly is the deliberate choice over
|
|
333
|
+
supporting it: the alternative was `vendored_som` silently reporting
|
|
334
|
+
`(None, None)` and `retarget_board_yaml_som` silently returning its
|
|
335
|
+
input byte-for-byte unchanged, so `--som` was discarded with `issues:
|
|
336
|
+
[]` and exit 0 -- indistinguishable from success.
|
|
337
|
+
|
|
338
|
+
Raised from `vendored_som` (the reader) the moment it sees this shape;
|
|
339
|
+
`retarget_board_yaml_som` (the writer) inherits the same raise for free,
|
|
340
|
+
since it calls `vendored_som` first -- one rule, in one place, so the
|
|
341
|
+
two cannot diverge on which `som:` lines this module refuses, the same
|
|
342
|
+
shape `top_level_key_name`/`_is_som_key_line`/`_split_child_key` already
|
|
343
|
+
hold for tan-cli#1008.
|
|
344
|
+
|
|
345
|
+
Only covers the SAME-LINE flow shape (`som: {...}`, `som: &s {...}`,
|
|
346
|
+
`som: !!map {...}`); a flow mapping whose `{` opens on the NEXT physical
|
|
347
|
+
line is `UnreadableSomBlockError`'s job (tan-cli#1041) -- this class's
|
|
348
|
+
own detector (`_som_flow_style_body`) only ever inspects the `som:`
|
|
349
|
+
line itself, by design (see that function's docstring).
|
|
350
|
+
|
|
351
|
+
Reachability (tan-cli#1029): 0 of the SDK's 100 tracked `board.yaml`
|
|
352
|
+
files and 0 vendored templates use a flow-style `som:` mapping --
|
|
353
|
+
hand-authored only, same as the spaced/quoted shapes tan-cli#1008 rounds
|
|
354
|
+
5-6 fixed.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def __init__(self, flow_body: str) -> None:
|
|
358
|
+
super().__init__(
|
|
359
|
+
f"This board.yaml's `som:` block is written in YAML flow style "
|
|
360
|
+
f"(`som: {flow_body}`), which tan's board.yaml scaffolder does "
|
|
361
|
+
f"not parse. Rewrite the `som:` block in block style (`sku:`/"
|
|
362
|
+
f"`hw_rev:` each on their own indented line beneath `som:`) and "
|
|
363
|
+
f"try again -- keep any `&anchor`/`!tag` the `som:` key itself "
|
|
364
|
+
f"carries where it is: dropping it would silently break a `*alias` "
|
|
365
|
+
f"referring to it elsewhere in the file."
|
|
366
|
+
)
|
|
367
|
+
self.flow_body = flow_body
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class UnreadableSomBlockError(SomBlockUnsupportedError):
|
|
371
|
+
"""`vendored_som` recognized a top-level `som:` entry -- either the
|
|
372
|
+
line-oriented scan matched a literal `som:`/`"som":` line
|
|
373
|
+
(`_is_som_key_line`), or a real YAML parse resolved one that no literal
|
|
374
|
+
`som:` text exists for at all -- but found no LITERAL `sku:` line
|
|
375
|
+
nested beneath it to read or rewrite.
|
|
376
|
+
|
|
377
|
+
tan-cli#1041 (the amendment). Three of the amendment's six named
|
|
378
|
+
spellings land here, all sharing the identical root cause -- a `som:`
|
|
379
|
+
(or an effective one, produced by a document-root merge) whose `sku:`
|
|
380
|
+
is not a plain indented line this scanner can find -- rather than three
|
|
381
|
+
separate detectors for three separate reasons:
|
|
382
|
+
|
|
383
|
+
- a YAML ALIAS (`som: *base`): the whole value is a reference to a
|
|
384
|
+
mapping defined elsewhere; no child lines follow the `som:` line at
|
|
385
|
+
all.
|
|
386
|
+
- a MERGE key (`<<: *base`) inside the `som:` block with no explicit
|
|
387
|
+
`sku:` override alongside it: the real `sku:` value lives in
|
|
388
|
+
whatever `base` points at, not in this block. (A merge key WITH an
|
|
389
|
+
explicit override retargets correctly, same as any other block-style
|
|
390
|
+
`som:` -- the override line is a literal `sku:` this scan already
|
|
391
|
+
finds, so it never reaches this class at all.)
|
|
392
|
+
- a MERGE key at the DOCUMENT ROOT (`<<: *base`, sibling to `som:`
|
|
393
|
+
rather than nested under it) whose target itself defines `som:`: no
|
|
394
|
+
`som:` text exists anywhere in the file for the line scan to find, so
|
|
395
|
+
`vendored_som`'s own scan reports nothing at all -- this is the one
|
|
396
|
+
shape a real `yaml.safe_load` backstop is needed for (see
|
|
397
|
+
`vendored_som`'s docstring), not the line scan.
|
|
398
|
+
- a FLOW mapping split across MORE than one physical line (`som:\\n
|
|
399
|
+
{sku: ..., hw_rev: ...}`, with or without a leading comment or
|
|
400
|
+
`&anchor` on the `som:` line itself): `FlowStyleSomError`'s own
|
|
401
|
+
detector only ever looks at the `som:` line, by design, so this shape
|
|
402
|
+
falls straight through it and is caught here instead, by the same
|
|
403
|
+
"a `sku:` line was never found" signal every other shape in this list
|
|
404
|
+
trips.
|
|
405
|
+
|
|
406
|
+
Deliberately ONE generic message rather than a shape-specific one per
|
|
407
|
+
bullet above: naming "alias" vs "merge key" vs "multi-line flow"
|
|
408
|
+
correctly would need to re-derive which one this actually is -- the
|
|
409
|
+
exact per-spelling special-casing tan-cli#1008 (six rounds) and
|
|
410
|
+
tan-cli#1035 (three rounds) already show costs more than it is worth.
|
|
411
|
+
What every shape above has in common -- a `som:` this module can see
|
|
412
|
+
exists, paired with no `sku:` line it can find inside it -- is exactly
|
|
413
|
+
what the message says, and is enough to tell a customer what to fix.
|
|
414
|
+
|
|
415
|
+
Raised from `vendored_som`; `retarget_board_yaml_som` inherits it the
|
|
416
|
+
same way it already inherits `FlowStyleSomError`, by calling
|
|
417
|
+
`vendored_som` first.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
def __init__(self) -> None:
|
|
421
|
+
super().__init__(
|
|
422
|
+
"This board.yaml's `som:` block could not be read: tan found a "
|
|
423
|
+
"`som:` key but no literal `sku:` line indented directly "
|
|
424
|
+
"beneath it. A YAML `*alias`, a `<<:` merge key with no "
|
|
425
|
+
"explicit `sku:` override (whether inside the `som:` block or "
|
|
426
|
+
"at the top of the file), and a flow mapping split across more "
|
|
427
|
+
"than one line all look like this to tan's board.yaml "
|
|
428
|
+
"scaffolder, which edits `som:` blocks line-by-line rather "
|
|
429
|
+
"than as a parsed document. Rewrite the `som:` block in block "
|
|
430
|
+
"style, with `sku:` (and `hw_rev:`, if set) each written as "
|
|
431
|
+
"their own literal line indented beneath `som:`, and try "
|
|
432
|
+
"again."
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class ExampleReadError(Exception):
|
|
437
|
+
"""`--from-example`'s source directory is missing, or a file in it is not
|
|
438
|
+
readable UTF-8 text. `not_found` separates the two: a missing example is
|
|
439
|
+
the user's typo (exit 2), an unreadable one is a runtime failure (exit 1)."""
|
|
440
|
+
|
|
441
|
+
def __init__(self, message: str, *, not_found: bool) -> None:
|
|
442
|
+
super().__init__(message)
|
|
443
|
+
self.not_found = not_found
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@dataclass(frozen=True)
|
|
447
|
+
class PlannedFile:
|
|
448
|
+
"""One file the template will lay down: its project-relative, forward-slash
|
|
449
|
+
path and its exact content."""
|
|
450
|
+
|
|
451
|
+
relative_path: str
|
|
452
|
+
content: str
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass(frozen=True)
|
|
456
|
+
class FileChange:
|
|
457
|
+
#: `new` | `update` | `unchanged` -- `WizardFileChangeKind::as_str`, wire
|
|
458
|
+
#: contract via `data.fileChanges[].kind`.
|
|
459
|
+
relative_path: str
|
|
460
|
+
kind: str
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass(frozen=True)
|
|
464
|
+
class WriteResult:
|
|
465
|
+
written: list[str]
|
|
466
|
+
unchanged: list[str]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class ScaffoldWriteError(Exception):
|
|
470
|
+
"""A write failed part-way. Carries the `partial` result accumulated before
|
|
471
|
+
the failure: reporting `written: []` for a project that really is half on
|
|
472
|
+
disk leaves a consumer with no idea what to clean up or reopen."""
|
|
473
|
+
|
|
474
|
+
def __init__(self, message: str, partial: WriteResult) -> None:
|
|
475
|
+
super().__init__(message)
|
|
476
|
+
self.partial = partial
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
# Path guard
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def is_plain_relative(raw: str) -> bool:
|
|
485
|
+
"""True when `raw` is a plain relative path: non-empty, no `.`/`..`, not
|
|
486
|
+
absolute, not drive-rooted or root-relative.
|
|
487
|
+
|
|
488
|
+
`tan_core::is_plain_relative`. Guards the two inputs that decide WHERE
|
|
489
|
+
files come from and go to: `--name` (joined onto the destination, so an
|
|
490
|
+
unchecked `..` or absolute value put the project root -- and with `--force`
|
|
491
|
+
an overwrite target -- anywhere the process can write) and `--from-example`
|
|
492
|
+
(joined onto the SDK `examples/` root).
|
|
493
|
+
|
|
494
|
+
Evaluated with WINDOWS path semantics on every platform: `C:foo` and `\\x`
|
|
495
|
+
are not `is_absolute()` on Windows yet still escape a join, and being
|
|
496
|
+
stricter than POSIX needs to be costs nothing -- no template or example
|
|
497
|
+
path legitimately contains a backslash or a drive letter.
|
|
498
|
+
|
|
499
|
+
A bare `.` and a leading `./` are rejected, not normalised away: `.` made
|
|
500
|
+
`--from-example .` clear this guard, join back to `examples/` itself, and
|
|
501
|
+
copy the SDK's entire examples tree in as one "example".
|
|
502
|
+
"""
|
|
503
|
+
if not raw or raw[0] in "/\\":
|
|
504
|
+
return False
|
|
505
|
+
if PureWindowsPath(raw).drive:
|
|
506
|
+
return False
|
|
507
|
+
segments = [s for s in re.split(r"[\\/]", raw) if s]
|
|
508
|
+
if not segments:
|
|
509
|
+
return False
|
|
510
|
+
return all(s not in (".", "..") for s in segments)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
# Hardware-adjacent derivations (see the module docstring)
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def app_core_for_sku(sku: str) -> str:
|
|
519
|
+
"""Canonical Zephyr app-core id for a SoM family. `tan init` is SDK-free,
|
|
520
|
+
so this maps by SKU prefix (`_SOM_FAMILIES`); `tan validate` re-checks it
|
|
521
|
+
against the real SoM catalogue once an SDK resolves. Read by the
|
|
522
|
+
hand-generated `minimal-app` template and by `retarget_board_yaml_cores`,
|
|
523
|
+
and derived from the SAME table `_family_bucket` reads, so the two
|
|
524
|
+
derivations cannot disagree (tan-cli#579)."""
|
|
525
|
+
return _som_family(sku)[0]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _family_bucket(sku: str) -> str | None:
|
|
529
|
+
"""The vendored tree directory for `sku`, or `None` when tan vendors no
|
|
530
|
+
scaffold for that SoM family -- `_vendored_files` turns that into an
|
|
531
|
+
`UnsupportedSomError`.
|
|
532
|
+
|
|
533
|
+
tan-cli#579: this used to be
|
|
534
|
+
`_FAMILY_TREES[1] if sku.startswith(("E1M-V2N","E1M-V2M")) else
|
|
535
|
+
_FAMILY_TREES[0]`, so a family `app_core_for_sku` right above
|
|
536
|
+
already knew (with no tree) fell down the `else` arm and got the ALIF tree's content,
|
|
537
|
+
at `ok: true` / exit 0 / `issues: []`. An unrecognised prefix still takes
|
|
538
|
+
the Alif default, in both derivations at once (see `_DEFAULT_FAMILY`);
|
|
539
|
+
only a family tan positively knows it has no tree for is refused."""
|
|
540
|
+
return _som_family(sku)[1]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# board.yaml SoM retargeting
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _split_cr(line: str) -> tuple[str, str]:
|
|
549
|
+
"""Split a `content.split("\\n")` element into `(body, cr)`, where `cr` is
|
|
550
|
+
the `"\\r"` a CRLF source leaves on the end of every line and `""` for LF.
|
|
551
|
+
|
|
552
|
+
tan-cli#404: the `\\r` is a LINE TERMINATOR, never content, and every
|
|
553
|
+
character-level decision below (is the tail a comment? where does the value
|
|
554
|
+
token end? is there anything after `sku:` at all?) got it wrong when it was
|
|
555
|
+
left attached. Stripping it once, up front, is what makes those decisions
|
|
556
|
+
terminator-insensitive; the caller re-appends it verbatim, so a CRLF file
|
|
557
|
+
goes back out CRLF and byte-parity with `_read_verbatim` holds.
|
|
558
|
+
"""
|
|
559
|
+
return (line[:-1], "\r") if line.endswith("\r") else (line, "")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _retargeted_sku_line(indent: str, after_key: str, sku: str) -> tuple[str, bool]:
|
|
563
|
+
"""Rewrite one `sku:` line body onto `sku`, given `after_key` -- everything
|
|
564
|
+
after the child key's colon, as returned by [`_split_child_key`] (so a
|
|
565
|
+
space before the colon in the SOURCE line, `sku : x`, is already handled
|
|
566
|
+
by the caller; this function only ever emits a normalized `sku:`, no
|
|
567
|
+
space, regardless of how the source was spaced). Returns the new body (no
|
|
568
|
+
line terminator -- see [`_split_cr`]) and whether a trailing COMMENT was
|
|
569
|
+
dropped, which is what tells the caller a wrapped comment block has been
|
|
570
|
+
opened and its continuation lines must go too.
|
|
571
|
+
"""
|
|
572
|
+
stripped = after_key.lstrip(" \t")
|
|
573
|
+
leading_ws = after_key[: len(after_key) - len(stripped)]
|
|
574
|
+
if not stripped or stripped.startswith("#"):
|
|
575
|
+
# `sku:` with nothing after it, or `sku: # comment` with a comment but
|
|
576
|
+
# no value. Splicing at the first whitespace run would either glue the
|
|
577
|
+
# value onto `sku:` (read back as a scalar, not a mapping entry) or eat
|
|
578
|
+
# the `#` into it. A `sku:` with no value names no SoM, so its comment
|
|
579
|
+
# is not stale and stays.
|
|
580
|
+
return f"{indent}sku: {sku}{after_key}", False
|
|
581
|
+
# `[ \t]`, not `\s`: the value token ends at a space or a tab and at nothing
|
|
582
|
+
# else. `\s` also matched the `\r` of a CRLF line, so the terminator was
|
|
583
|
+
# captured into `tail` and thrown away with it -- one mixed-EOL line in the
|
|
584
|
+
# very file the customer is about to edit (tan-cli#404).
|
|
585
|
+
match = re.search(r"[ \t]", stripped)
|
|
586
|
+
value = stripped[: match.start()] if match else stripped
|
|
587
|
+
tail = stripped[match.start() :] if match else ""
|
|
588
|
+
if value == sku:
|
|
589
|
+
return f"{indent}sku:{leading_ws}{sku}{tail}", False
|
|
590
|
+
return f"{indent}sku:{leading_ws}{sku}", tail.lstrip(" \t").startswith("#")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _is_wrapped_comment_line(body: str, sku_indent: str) -> bool:
|
|
594
|
+
"""True when `body` is a comment-only line indented PAST `sku_indent` --
|
|
595
|
+
i.e. a continuation of the comment the `sku:` line opened, not a comment
|
|
596
|
+
documenting whatever comes next (tan-cli#404). A blank line ends the block:
|
|
597
|
+
it is not a comment, and a comment resuming after one is a new thought."""
|
|
598
|
+
stripped = body.lstrip(" \t")
|
|
599
|
+
if not stripped.startswith("#"):
|
|
600
|
+
return False
|
|
601
|
+
return len(body) - len(stripped) > len(sku_indent)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def top_level_key_name(text: str) -> str:
|
|
605
|
+
"""Everything in `text` before its first `:`, whitespace-trimmed -- the
|
|
606
|
+
key name of a `<key>:` mapping line, tolerating a space (or more) before
|
|
607
|
+
the colon (`som :` is valid YAML: `yaml.safe_load("som :\n sku: x\n")`
|
|
608
|
+
-> `{"som": {"sku": "x"}}`). Returns the whole (trimmed) `text` when
|
|
609
|
+
there is no `:` at all, matching `str.split(":", 1)[0]`'s own behaviour
|
|
610
|
+
on a colon-less string.
|
|
611
|
+
|
|
612
|
+
tan-cli#1008 review round 5: this repo now has THREE independent readers
|
|
613
|
+
of "what is this line's top-level key" --
|
|
614
|
+
`generate_cmd._scan_som_sku` (`stripped.split(":", 1)[0].strip() ==
|
|
615
|
+
"som"`), `bootstrap_cmd._scan_board_slice` (`key =
|
|
616
|
+
stripped.partition(":")[0].strip()`), and this file's own
|
|
617
|
+
`_is_som_key_line` (round 4's fix, which used a STRICTER
|
|
618
|
+
`startswith(f"{key}:")` that rejected `som :`). That divergence is
|
|
619
|
+
exactly how round 4's own bug happened one level up (`_is_som_key_line`
|
|
620
|
+
vs `vendored_som`'s pre-round-4 exact-match). One rule, in one place;
|
|
621
|
+
`generate_cmd`/`bootstrap_cmd` import and call THIS instead of keeping
|
|
622
|
+
their own copies.
|
|
623
|
+
|
|
624
|
+
tan-cli#1041: also strips a single layer of matching `'`/`"` quotes
|
|
625
|
+
around the key, so a QUOTED top-level key (`"som":`, `'som':`) resolves
|
|
626
|
+
to the same name as its bare spelling -- `yaml.safe_load` already treats
|
|
627
|
+
them identically (`{"som": ...}` either way), and this was the one
|
|
628
|
+
spelling of the six tan-cli#1041 named where the fix belongs HERE
|
|
629
|
+
(widening the one shared rule) rather than in a refusal: nothing about a
|
|
630
|
+
quoted key stops the line-oriented writer beneath it from working, so
|
|
631
|
+
unlike its five siblings this shape should retarget correctly, not just
|
|
632
|
+
refuse loudly. Only fires when the closing quote is immediately followed
|
|
633
|
+
by (optional whitespace then) a `:` -- i.e. this really is `"key":`, not
|
|
634
|
+
some other quoted scalar that merely starts the text -- so a colon-less
|
|
635
|
+
string's contract above (return the whole trimmed text) still holds for
|
|
636
|
+
everything else, quoted or not. No escape handling: `som`/`sku`/`hw_rev`,
|
|
637
|
+
the only keys any caller of this function ever tests against, contain no
|
|
638
|
+
character that would ever need one.
|
|
639
|
+
"""
|
|
640
|
+
stripped = text.strip()
|
|
641
|
+
quote = stripped[:1]
|
|
642
|
+
if quote in ("'", '"'):
|
|
643
|
+
end = stripped.find(quote, 1)
|
|
644
|
+
if end != -1 and stripped[end + 1 :].lstrip(" \t")[:1] == ":":
|
|
645
|
+
return stripped[1:end]
|
|
646
|
+
return text.split(":", 1)[0].strip()
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _is_som_key_line(body: str) -> bool:
|
|
650
|
+
"""Whether `body` is the top-level `som:` line -- unindented, with
|
|
651
|
+
`som` as [`top_level_key_name`]'s answer for it (so `som :`, a trailing
|
|
652
|
+
comment, and trailing whitespace all still match -- only indentation and
|
|
653
|
+
the key name itself are checked).
|
|
654
|
+
|
|
655
|
+
tan-cli#1008 review round 4: `retarget_board_yaml_som`'s scan and
|
|
656
|
+
`vendored_som`'s reader used to apply DIFFERENT rules for this -- the
|
|
657
|
+
scan matched `trimmed.startswith("som:")` (tolerant of a trailing
|
|
658
|
+
comment/whitespace), the reader matched an exact `body == "som:"`
|
|
659
|
+
(strict) -- so a `som:` line carrying a trailing comment or trailing
|
|
660
|
+
whitespace was recognized by the scan (which retargeted `sku:` inside
|
|
661
|
+
it) but not by the reader (which then reported `existing_sku` as
|
|
662
|
+
`None`, so the sibling `hw_rev:`-drop this file exists for silently
|
|
663
|
+
stood down). One predicate, used by both callers, so a third caller
|
|
664
|
+
cannot diverge from either again.
|
|
665
|
+
"""
|
|
666
|
+
return bool(body) and body[0] not in " \t" and top_level_key_name(body) == "som"
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _split_child_key(trimmed: str) -> tuple[str, str] | None:
|
|
670
|
+
"""Split an already-indent-stripped CHILD mapping line (one nested under
|
|
671
|
+
a top-level block, e.g. a `som:` block's `sku:`/`hw_rev:`) into
|
|
672
|
+
`(key, after_colon)`. `key` is literally [`top_level_key_name`]'s answer
|
|
673
|
+
for `trimmed` -- not a second implementation of its rule -- so a space
|
|
674
|
+
before the colon (`sku :`, `hw_rev :`) AND a quoted key (`"sku":`,
|
|
675
|
+
`'hw_rev':`) are both tolerated here exactly as they are at the
|
|
676
|
+
top-level `som:` line, because both call sites now run the same code.
|
|
677
|
+
`None` when `trimmed` has no `:` at all (not a mapping line).
|
|
678
|
+
|
|
679
|
+
tan-cli#1008 review round 6: `vendored_som` (the reader) and
|
|
680
|
+
`retarget_board_yaml_som` (the writer) applied DIFFERENT rules for "is
|
|
681
|
+
this line a `sku:`/`hw_rev:` child" -- the reader used a tolerant
|
|
682
|
+
`trimmed.partition(":")`, the writer an exact
|
|
683
|
+
`trimmed.startswith("hw_rev:")`/`startswith("sku:")`. Round 5 already
|
|
684
|
+
fixed this exact class of divergence one level up for the top-level
|
|
685
|
+
`som:` line (`_is_som_key_line`); it did not carry the fix down to the
|
|
686
|
+
child keys underneath it. Concretely: on `hw_rev : r2` the reader saw
|
|
687
|
+
the key (arming the cross-family `drop_hw_rev` logic) while the writer's
|
|
688
|
+
`startswith` did not match the line at all, so a hand-authored spaced
|
|
689
|
+
`hw_rev :`/`sku :` line was either left un-dropped (major 1) or -- worse
|
|
690
|
+
-- had its `hw_rev:` deleted by the drop logic while its own `sku :`
|
|
691
|
+
line went unretargeted, since the writer's `sku:` `startswith` check
|
|
692
|
+
also failed to match (major 2, a silent `--som` no-op). One rule, in one
|
|
693
|
+
place, used by both the reader and the writer, so they cannot diverge
|
|
694
|
+
again.
|
|
695
|
+
|
|
696
|
+
tan-cli#1060 review: this docstring used to CLAIM the top-level rule
|
|
697
|
+
applied here while the body stayed a bare `trimmed.partition(":")` that
|
|
698
|
+
never unquoted anything -- true the day round 6 landed, false the moment
|
|
699
|
+
tan-cli#1041 taught `top_level_key_name` to strip a quoted key's `'`/`"`
|
|
700
|
+
pair and this function did not follow. Consequence: `"som":` (a
|
|
701
|
+
top-level quoted key) retargeted correctly, but the exact same quoting
|
|
702
|
+
one level down (`som:` with a `"sku":`/`'hw_rev':` child) did not --
|
|
703
|
+
`found_sku_key` never set, `entered_som and not found_sku_key` firing
|
|
704
|
+
`UnreadableSomBlockError` for a shape that has a perfectly literal
|
|
705
|
+
`sku:` line to rewrite, precisely the top-level-vs-child divergence round
|
|
706
|
+
6 exists to prevent, reopened one call site later. Calling
|
|
707
|
+
`top_level_key_name` here rather than re-inlining its rule is what makes
|
|
708
|
+
that divergence structurally impossible rather than merely undocumented:
|
|
709
|
+
a future change to the quoting rule only has one function to change.
|
|
710
|
+
"""
|
|
711
|
+
if ":" not in trimmed:
|
|
712
|
+
return None
|
|
713
|
+
_key, _colon, rest = trimmed.partition(":")
|
|
714
|
+
return top_level_key_name(trimmed), rest
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
#: A single leading YAML node-property token: an anchor (`&name`) or a tag
|
|
718
|
+
#: (`!tag`, `!!type`, or a verbatim `!<...>` URI) -- whatever character
|
|
719
|
+
#: sequence starts at `&`/`!` and runs to the next whitespace. Doesn't
|
|
720
|
+
#: validate the anchor name or tag URI; that is not this scanner's job, only
|
|
721
|
+
#: recognizing that a property token, rather than the value, sits here.
|
|
722
|
+
_NODE_PROPERTY_RE = re.compile(r"^[&!]\S+")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _strip_yaml_node_properties(text: str) -> str:
|
|
726
|
+
"""Strip zero or more leading YAML node-property tokens (an anchor
|
|
727
|
+
`&name`, a tag `!tag`/`!!type`, or both together, in EITHER order,
|
|
728
|
+
separated by whitespace) from `text`, returning whatever remains after
|
|
729
|
+
the last one (with any following whitespace also stripped).
|
|
730
|
+
|
|
731
|
+
tan-cli#1035 review round 2 major: separates "what decorations precede
|
|
732
|
+
the value" from "is the value a flow mapping" -- the two concerns
|
|
733
|
+
`_som_flow_style_body` conflated when it tested `stripped.startswith("{")`
|
|
734
|
+
against text that could still carry an anchor/tag prefix. YAML allows an
|
|
735
|
+
anchor and a tag together, in either order (`&s !!map {...}` and
|
|
736
|
+
`!!map &s {...}` are both valid), and either alone; this strips as many
|
|
737
|
+
property tokens as are present, so `_som_flow_style_body`'s own `{` test
|
|
738
|
+
runs against the actual value, never against a property token that
|
|
739
|
+
happens to not start with `{`.
|
|
740
|
+
"""
|
|
741
|
+
remainder = text
|
|
742
|
+
while True:
|
|
743
|
+
match = _NODE_PROPERTY_RE.match(remainder)
|
|
744
|
+
if not match:
|
|
745
|
+
return remainder
|
|
746
|
+
remainder = remainder[match.end() :].lstrip(" \t")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _som_flow_style_body(body: str) -> str | None:
|
|
750
|
+
"""When `body` is the top-level `som:` line ([`_is_som_key_line`]) AND
|
|
751
|
+
what follows its colon -- past any anchor/tag prefix
|
|
752
|
+
([`_strip_yaml_node_properties`]) -- opens a YAML FLOW mapping (`som:
|
|
753
|
+
{sku: ..., hw_rev: ...}`, `som: &s {sku: ...}`, `som: !!map {sku: ...}`)
|
|
754
|
+
-- i.e. the first non-blank character there is `{` -- return the ORIGINAL
|
|
755
|
+
trailing text (properties included) verbatim, for the error message.
|
|
756
|
+
`None` for an ordinary block-style `som:` line: nothing, only a trailing
|
|
757
|
+
comment, or a bare anchor/tag (`som: &s`, `som: !!map`, `som: &s !!map`)
|
|
758
|
+
with nothing flow-shaped after it, all of which are valid YAML that still
|
|
759
|
+
opens a BLOCK mapping on the lines beneath it, not a non-`som:` line.
|
|
760
|
+
|
|
761
|
+
tan-cli#1029: the one signal both `vendored_som` and
|
|
762
|
+
`retarget_board_yaml_som` need to refuse a shape neither actually reads
|
|
763
|
+
(see [`FlowStyleSomError`]) -- called from `vendored_som` alone; the
|
|
764
|
+
writer inherits the same refusal by calling the reader first, so the
|
|
765
|
+
two share this one rule rather than each guessing independently, the
|
|
766
|
+
same shape `top_level_key_name`/`_is_som_key_line`/`_split_child_key`
|
|
767
|
+
already hold for tan-cli#1008.
|
|
768
|
+
|
|
769
|
+
tan-cli#1035 review round 2 major 1: an earlier version of this function
|
|
770
|
+
treated ANY non-comment content after the colon as flow style, which
|
|
771
|
+
also caught `som: &s` (an anchor) and `som: !!map` (a tag) -- both valid
|
|
772
|
+
BLOCK-style `som:` lines -- so it was narrowed to `stripped.startswith
|
|
773
|
+
("{")`. That narrowing over-corrected: it tested the RAW text after the
|
|
774
|
+
colon, so `som: &s {sku: ...}` and `som: !!map {sku: ...}` -- genuine
|
|
775
|
+
flow mappings carrying an anchor/tag prefix -- no longer started with
|
|
776
|
+
`{` and escaped the detector entirely, reopening tan-cli#1029's own
|
|
777
|
+
silent-`--som`-discard symptom on exactly the shape this function exists
|
|
778
|
+
to refuse. Stripping the anchor/tag prefix FIRST, then testing the
|
|
779
|
+
remainder, is what lets both prior fixes stay true at once: a bare
|
|
780
|
+
anchor/tag still falls through to the block path (nothing left to test
|
|
781
|
+
after stripping), and an anchor/tag ahead of a real `{` is still caught
|
|
782
|
+
(something starting with `{` left after stripping).
|
|
783
|
+
"""
|
|
784
|
+
if not _is_som_key_line(body):
|
|
785
|
+
return None
|
|
786
|
+
_key, _colon, rest = body.partition(":")
|
|
787
|
+
stripped = rest.lstrip(" \t")
|
|
788
|
+
if not stripped or stripped.startswith("#"):
|
|
789
|
+
return None
|
|
790
|
+
remainder = _strip_yaml_node_properties(stripped)
|
|
791
|
+
if not remainder or remainder.startswith("#"):
|
|
792
|
+
return None
|
|
793
|
+
if not remainder.startswith("{"):
|
|
794
|
+
return None
|
|
795
|
+
return stripped
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
#: Mirrors `scripts/alp_project_loader._SKU_FAMILY` -- deliberately
|
|
799
|
+
#: duplicated rather than imported: this file plans
|
|
800
|
+
#: board.yaml content with no SDK checkout to consult (it is SDK-free by
|
|
801
|
+
#: design -- see `test_init_command.py`'s "`tan init` is SDK-free and
|
|
802
|
+
#: cannot tell a ..." precedent), so this needs only the family CODE
|
|
803
|
+
#: encoded in the SKU string itself, never a metadata lookup.
|
|
804
|
+
_SKU_FAMILY_PREFIX = re.compile(r"^E1M-(AEN|V2N|V2M)")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _same_som_family(a: str, b: str) -> bool:
|
|
808
|
+
"""Whether two SKUs share the SoM family a `hw_rev:` value is scoped to.
|
|
809
|
+
|
|
810
|
+
tan-cli#1008 review round 4 minor: an INTRA-family retarget (e.g.
|
|
811
|
+
`E1M-AEN801` -> `E1M-AEN301`, both `aen`) shares ONE family
|
|
812
|
+
`hw-revisions.yaml` table, so an explicit `hw_rev:` valid for the source
|
|
813
|
+
SKU is still a real, declared revision for the target one -- dropping it
|
|
814
|
+
there (as an unconditional cross-SKU drop would) silently substitutes
|
|
815
|
+
the new SKU's own `default_hw_rev:`, which can be a DIFFERENT declared
|
|
816
|
+
revision with different `pad_route_overrides` -- a silent change of
|
|
817
|
+
which hardware variant gets built, with `tan validate` clean and `tan
|
|
818
|
+
init` reporting no issue, since both revisions are legitimately known
|
|
819
|
+
and buildable. That is strictly worse than tan-cli#743's original bug: a
|
|
820
|
+
loud refusal became a silent substitution. A CROSS-family retarget (the
|
|
821
|
+
tan-cli#743/#1008 round-3 case: `E1M-AEN801` -> `E1M-V2N101`) still
|
|
822
|
+
drops it -- the value is from a table that has nothing to do with the
|
|
823
|
+
new SKU at all, not merely a different declared revision of the same
|
|
824
|
+
hardware family. Returns `False` (the conservative, already-shipped
|
|
825
|
+
round-3 behaviour: drop) whenever either SKU does not match the known
|
|
826
|
+
family pattern -- a shape this function cannot judge safely.
|
|
827
|
+
"""
|
|
828
|
+
match_a = _SKU_FAMILY_PREFIX.match(a)
|
|
829
|
+
match_b = _SKU_FAMILY_PREFIX.match(b)
|
|
830
|
+
return match_a is not None and match_b is not None and match_a.group(1) == match_b.group(1)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def retarget_board_yaml_som(content: str, sku: str) -> str:
|
|
834
|
+
"""Rewrite the FIRST `som:` -> `sku:` value to `sku`, leaving the rest of
|
|
835
|
+
that line byte-for-byte alone -- UNLESS the value is actually changing, in
|
|
836
|
+
which case a trailing comment on that line is dropped (all of it, however
|
|
837
|
+
many physical lines it spans) and a sibling `hw_rev:` line inside the same
|
|
838
|
+
`som:` block, if one is present, is dropped outright.
|
|
839
|
+
|
|
840
|
+
`wizard::retarget_board_yaml_som`. Only the value token moves: the gap
|
|
841
|
+
before a trailing comment is preserved, so a column-aligned inline comment
|
|
842
|
+
(the vendored `iot` scaffold's `sku:` line has one) survives, and passing a
|
|
843
|
+
tree its OWN vendored SKU is a byte-exact no-op (`--template
|
|
844
|
+
iot-starter` always does: its `--som` is validated equal to
|
|
845
|
+
`TEMPLATE_SUPPORTED_SKUS` before this ever runs). Reconstructing the tail
|
|
846
|
+
as a fixed two-space gap silently collapsed that alignment even in the
|
|
847
|
+
no-op case.
|
|
848
|
+
|
|
849
|
+
A DIFFERENT `sku` is the one case that tail must NOT survive: an
|
|
850
|
+
`--from-example` board.yaml's inline comment routinely names the ORIGINAL
|
|
851
|
+
SoM's vendor/silicon (e.g. `sku: E1M-AEN801 # Alif Ensemble E8 SoM`), and
|
|
852
|
+
there is no SKU->vendor-name table here to rewrite it correctly for the new
|
|
853
|
+
one -- retargeting onto `E1M-V2N101` left that Alif comment on a Renesas
|
|
854
|
+
SKU. Dropping it is honest; inventing a new one is not this function's job.
|
|
855
|
+
|
|
856
|
+
tan-cli#404 -- that drop used to be strictly line-local, and 12 of the 100
|
|
857
|
+
`board.yaml` files in the SDK example catalogue carry a `sku:` comment that
|
|
858
|
+
WRAPS. `aen/edgeai-vision-aen` retargeted onto `E1M-V2N101` kept four of its
|
|
859
|
+
five comment lines: a Renesas RZ/V2N project whose `board.yaml` documented a
|
|
860
|
+
pair of Alif Ethos-U55s, an Ethos-U85 and a VeriSilicon ISP Pico
|
|
861
|
+
(`vsi,isp-pico`) attached to nothing -- in a file the project ships as
|
|
862
|
+
teaching material, with nothing in `tan init`'s output saying a comment had
|
|
863
|
+
been rewritten at all. So the drop now consumes the block: the inline
|
|
864
|
+
comment plus every following comment-only line indented past the `sku:` key.
|
|
865
|
+
Deliberately anchored to THAT comment, not to any comment near a changed
|
|
866
|
+
`sku:` -- a `sku:` line with no comment of its own opens no block, so a
|
|
867
|
+
comment documenting the next key is never swallowed.
|
|
868
|
+
|
|
869
|
+
tan-cli#1008 review round 3: the identical "dropping it is honest,
|
|
870
|
+
inventing a new one is not this function's job" reasoning applies to an
|
|
871
|
+
explicit `hw_rev:` sibling. This function previously only ever rewrote
|
|
872
|
+
`sku:`, so a retarget onto a DIFFERENT SKU used to leave the ORIGINAL
|
|
873
|
+
example's `hw_rev:` in place verbatim -- a value from a different
|
|
874
|
+
family's table (or, worse, one that happens to collide with an unrelated
|
|
875
|
+
revision key in the new family's table), producing a `sku:`/`hw_rev:`
|
|
876
|
+
pair no family table actually declares. `tan validate` refuses that with
|
|
877
|
+
"not a known hardware revision", while `tan init` -- before this fix --
|
|
878
|
+
said nothing at all, or (an earlier round of this same fix) named the
|
|
879
|
+
WRONG revision: both are the tan-cli#743 contradiction this whole check
|
|
880
|
+
exists to close, just reached via a stale cross-retarget value instead
|
|
881
|
+
of an absent one. Dropping the sibling `hw_rev:` on a CROSS-family
|
|
882
|
+
retarget lets the scaffold fall back to the NEW SoM's own
|
|
883
|
+
`default_hw_rev:` -- the same resolution rule a board.yaml with no
|
|
884
|
+
explicit `hw_rev:` at all already follows, and the one `tan validate`
|
|
885
|
+
resolves against.
|
|
886
|
+
|
|
887
|
+
tan-cli#1008 review round 4 minor: an INTRA-family retarget keeps the
|
|
888
|
+
sibling `hw_rev:` instead -- see `_same_som_family`'s own docstring for
|
|
889
|
+
the full reasoning (short version: within one family the value is still
|
|
890
|
+
a real, deliberately-chosen revision, and dropping it there would
|
|
891
|
+
silently substitute a DIFFERENT declared revision -- possibly with
|
|
892
|
+
different `pad_route_overrides` -- with no warning at all, which is
|
|
893
|
+
worse than the bug this fix closes). Also round 4: `retarget_board_yaml_
|
|
894
|
+
som`'s own scan and `vendored_som`'s reader (used below to learn
|
|
895
|
+
`existing_sku`) now share ONE `som:`-block-entry predicate
|
|
896
|
+
(`_is_som_key_line`) -- they used to disagree on a `som:` line carrying
|
|
897
|
+
a trailing comment or trailing whitespace, which silently reintroduced
|
|
898
|
+
this same stale-`hw_rev:` defect on exactly that shape of file.
|
|
899
|
+
|
|
900
|
+
tan-cli#1029: raises [`FlowStyleSomError`] on a flow-style `som:` block
|
|
901
|
+
(`som: {sku: ..., hw_rev: ...}`) instead of silently returning `content`
|
|
902
|
+
byte-for-byte unchanged -- inherited for free from the `vendored_som`
|
|
903
|
+
call immediately below, which raises first.
|
|
904
|
+
|
|
905
|
+
tan-cli#1041: the same inheritance now also covers
|
|
906
|
+
[`UnreadableSomBlockError`] -- a `som:` block this line-oriented scan
|
|
907
|
+
(the one below, not `vendored_som`'s) would otherwise silently leave
|
|
908
|
+
untouched because it never finds a literal `sku:` line to rewrite (a
|
|
909
|
+
`*alias`, an un-overridden `<<:` merge key inside OR outside the `som:`
|
|
910
|
+
block, or a flow mapping split across more than one line). A QUOTED
|
|
911
|
+
`som:`/`"som":` key is NOT one of these: it retargets correctly, same
|
|
912
|
+
as the bare spelling, because [`top_level_key_name`] (tan-cli#1041)
|
|
913
|
+
unquotes it before the shared `_is_som_key_line`/`_split_child_key`
|
|
914
|
+
checks below ever see it -- nothing about a quoted KEY stops the scan
|
|
915
|
+
from finding a perfectly ordinary literal `sku:` line beneath it.
|
|
916
|
+
"""
|
|
917
|
+
existing_sku, _existing_hw_rev = vendored_som(content)
|
|
918
|
+
changing_sku = existing_sku is not None and existing_sku != sku
|
|
919
|
+
drop_hw_rev = changing_sku and not _same_som_family(existing_sku, sku)
|
|
920
|
+
|
|
921
|
+
out: list[str] = []
|
|
922
|
+
in_som = False
|
|
923
|
+
sku_rewritten = False
|
|
924
|
+
hw_rev_dropped = not drop_hw_rev # nothing to drop for a no-op or intra-family retarget
|
|
925
|
+
# The indent of whichever dropped construct's wrapped-comment
|
|
926
|
+
# continuation lines are still being consumed; `None` at every other
|
|
927
|
+
# point. Shared by the `sku:` comment drop and the `hw_rev:` line drop
|
|
928
|
+
# below -- they never overlap, since each anchors a distinct physical
|
|
929
|
+
# line.
|
|
930
|
+
consuming: str | None = None
|
|
931
|
+
for line in content.split("\n"):
|
|
932
|
+
body, cr = _split_cr(line)
|
|
933
|
+
if consuming is not None:
|
|
934
|
+
if _is_wrapped_comment_line(body, consuming):
|
|
935
|
+
continue
|
|
936
|
+
consuming = None
|
|
937
|
+
trimmed = body.lstrip(" \t")
|
|
938
|
+
if body and body[0] not in " \t":
|
|
939
|
+
# A new top-level key: entering `som:`, or leaving it.
|
|
940
|
+
in_som = _is_som_key_line(body)
|
|
941
|
+
out.append(line)
|
|
942
|
+
continue
|
|
943
|
+
child = _split_child_key(trimmed) if in_som else None
|
|
944
|
+
child_key, child_rest = child if child is not None else (None, "")
|
|
945
|
+
if in_som and not hw_rev_dropped and child_key == "hw_rev":
|
|
946
|
+
indent = body[: len(body) - len(trimmed)]
|
|
947
|
+
hw_rev_dropped = True
|
|
948
|
+
consuming = indent # also drop any wrapped comment it opened
|
|
949
|
+
continue
|
|
950
|
+
elif in_som and not sku_rewritten and child_key == "sku":
|
|
951
|
+
indent = body[: len(body) - len(trimmed)]
|
|
952
|
+
new_body, comment_dropped = _retargeted_sku_line(indent, child_rest, sku)
|
|
953
|
+
out.append(new_body + cr)
|
|
954
|
+
sku_rewritten = True
|
|
955
|
+
consuming = indent if comment_dropped else None
|
|
956
|
+
continue
|
|
957
|
+
out.append(line)
|
|
958
|
+
return "\n".join(out)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _indent_of(body: str) -> int:
|
|
962
|
+
return len(body) - len(body.lstrip(" \t"))
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _cores_block_span(lines: list[str]) -> tuple[int, int] | None:
|
|
966
|
+
"""`(start, end)` half-open line indices of the top-level `cores:` block's
|
|
967
|
+
CHILDREN, or `None` when there is no such block. `start` is the line after
|
|
968
|
+
`cores:`; `end` is the next column-0 key (or EOF)."""
|
|
969
|
+
start: int | None = None
|
|
970
|
+
for index, line in enumerate(lines):
|
|
971
|
+
body, _ = _split_cr(line)
|
|
972
|
+
if not body or body[0] in " \t":
|
|
973
|
+
continue
|
|
974
|
+
if start is not None:
|
|
975
|
+
return (start, index)
|
|
976
|
+
if body.lstrip(" \t").startswith("cores:"):
|
|
977
|
+
start = index + 1
|
|
978
|
+
return None if start is None else (start, len(lines))
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def retarget_board_yaml_cores(content: str, sku: str, source_sku: str) -> str:
|
|
982
|
+
"""Re-derive a vendored `cores:` block for `sku` (tan-cli#494 defect 2).
|
|
983
|
+
|
|
984
|
+
`tan init` picks a vendored tree by FAMILY (`_family_bucket`) and then
|
|
985
|
+
retargeted only the `som: sku:` line, so every SKU outside the two
|
|
986
|
+
representative ones (`E1M-AEN801` / `E1M-V2N101`) inherited that tree's
|
|
987
|
+
core ids verbatim. `tan init --template edge-ai-starter --som E1M-AEN301`
|
|
988
|
+
wrote `cores: a32_cluster:` for an Ensemble E3, which has no Cortex-A32 --
|
|
989
|
+
reported `ok:true` / `exitCode 0` / `issues:[]`, and `tan validate` then
|
|
990
|
+
hard-errored (exit 2) on the very next command. An unvendored family landed
|
|
991
|
+
on the Alif tree and got `m55_hp`, contradicting this same module's `app_core_for_sku`.
|
|
992
|
+
|
|
993
|
+
Two edits, both of which can only REMOVE wrong facts, never invent new
|
|
994
|
+
ones -- `tan init` is SDK-free and has no SoM topology to consult:
|
|
995
|
+
|
|
996
|
+
* the APP core (the one entry declaring `app:`) is renamed to
|
|
997
|
+
`app_core_for_sku(sku)`, tan's own family mapping, the same one
|
|
998
|
+
`minimal-app` already renders and `tan validate` re-checks against the
|
|
999
|
+
real catalogue;
|
|
1000
|
+
* every OTHER entry is DROPPED. Those are the `os: "off"` secondary
|
|
1001
|
+
cluster declarations (`a32_cluster` on the E8, `a55_cluster` on the
|
|
1002
|
+
V2N) which exist only on the tree's own representative SKU. A
|
|
1003
|
+
core absent from `cores:` is simply not built, so dropping is always
|
|
1004
|
+
sound; keeping a made-up id, or guessing the target's cluster id from a
|
|
1005
|
+
table tan cannot verify, is not.
|
|
1006
|
+
|
|
1007
|
+
NO-OP by construction when `sku == source_sku` (the tree's own SKU, which
|
|
1008
|
+
is what `E1M-AEN801`/`E1M-V2N101` always hit) -- byte-exact passthrough,
|
|
1009
|
+
so the parity and vendored-capture fixtures are untouched. Also a no-op
|
|
1010
|
+
when the block has no unambiguous single `app:` entry: an unrecognised
|
|
1011
|
+
shape is left verbatim rather than half-rewritten.
|
|
1012
|
+
"""
|
|
1013
|
+
if sku == source_sku:
|
|
1014
|
+
return content
|
|
1015
|
+
lines = content.split("\n")
|
|
1016
|
+
span = _cores_block_span(lines)
|
|
1017
|
+
if span is None:
|
|
1018
|
+
return content
|
|
1019
|
+
start, end = span
|
|
1020
|
+
entry_indent: int | None = None
|
|
1021
|
+
entries: list[tuple[int, int]] = [] # (key line index, entry end index)
|
|
1022
|
+
for index in range(start, end):
|
|
1023
|
+
body, _ = _split_cr(lines[index])
|
|
1024
|
+
if not body.strip():
|
|
1025
|
+
continue
|
|
1026
|
+
indent = _indent_of(body)
|
|
1027
|
+
if entry_indent is None:
|
|
1028
|
+
entry_indent = indent
|
|
1029
|
+
if indent == entry_indent:
|
|
1030
|
+
if entries:
|
|
1031
|
+
entries[-1] = (entries[-1][0], index)
|
|
1032
|
+
entries.append((index, end))
|
|
1033
|
+
if not entries:
|
|
1034
|
+
return content
|
|
1035
|
+
entries[-1] = (entries[-1][0], end)
|
|
1036
|
+
|
|
1037
|
+
def declares_app(entry: tuple[int, int]) -> bool:
|
|
1038
|
+
key, stop = entry
|
|
1039
|
+
return any(
|
|
1040
|
+
_split_cr(lines[i])[0].lstrip(" \t").startswith("app:")
|
|
1041
|
+
for i in range(key + 1, stop)
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
app_entries = [e for e in entries if declares_app(e)]
|
|
1045
|
+
if len(app_entries) != 1:
|
|
1046
|
+
return content
|
|
1047
|
+
app_key, app_stop = app_entries[0]
|
|
1048
|
+
key_body, key_cr = _split_cr(lines[app_key])
|
|
1049
|
+
trimmed = key_body.lstrip(" \t")
|
|
1050
|
+
if ":" not in trimmed:
|
|
1051
|
+
return content
|
|
1052
|
+
indent = key_body[: len(key_body) - len(trimmed)]
|
|
1053
|
+
tail = trimmed[trimmed.index(":") :]
|
|
1054
|
+
kept = [f"{indent}{app_core_for_sku(sku)}{tail}{key_cr}"]
|
|
1055
|
+
kept.extend(lines[app_key + 1 : app_stop])
|
|
1056
|
+
# The block's own trailing blank line separates `cores:` from the next
|
|
1057
|
+
# top-level key. It rides along with whichever entry came last, so it is
|
|
1058
|
+
# re-appended by hand when that entry was one of the dropped ones.
|
|
1059
|
+
if end > start and not lines[end - 1].strip() and kept[-1].strip():
|
|
1060
|
+
kept.append(lines[end - 1])
|
|
1061
|
+
return "\n".join([*lines[:start], *kept, *lines[end:]])
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
# ---------------------------------------------------------------------------
|
|
1065
|
+
# `--cores`: heterogeneous scaffolding
|
|
1066
|
+
# ---------------------------------------------------------------------------
|
|
1067
|
+
|
|
1068
|
+
#: Accepted per-core OS values for a `--cores` entry (`id:os`). Mirrors
|
|
1069
|
+
#: `tan-cli::commands::init::resolve::CORE_OS_CHOICES`.
|
|
1070
|
+
_CORE_OS_CHOICES = ("zephyr", "yocto", "baremetal", "off")
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
class CoresError(Exception):
|
|
1074
|
+
"""`--cores` failed to parse or validate; carries the user-facing message
|
|
1075
|
+
`init_cmd` wraps as `init.invalid-cores` (exit 2 -- validation)."""
|
|
1076
|
+
|
|
1077
|
+
def __init__(self, message: str) -> None:
|
|
1078
|
+
super().__init__(message)
|
|
1079
|
+
self.message = message
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _is_valid_core_id(core_id: str) -> bool:
|
|
1083
|
+
"""`^[a-z][a-z0-9_]+$`, hand-checked (ASCII only) to mirror the Rust
|
|
1084
|
+
byte-level test exactly rather than trust `str.isalpha`/`isdigit`, which
|
|
1085
|
+
are Unicode-aware and would accept an id `board.yaml`'s own schema (and
|
|
1086
|
+
the Rust CLI) both reject."""
|
|
1087
|
+
if len(core_id) < 2 or not ("a" <= core_id[0] <= "z"):
|
|
1088
|
+
return False
|
|
1089
|
+
return all(("a" <= c <= "z") or ("0" <= c <= "9") or c == "_" for c in core_id)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def parse_cores(raw: str | None) -> list[tuple[str, str]]:
|
|
1093
|
+
"""Parse + validate `--cores` (`id[:os],...`) into `(id, os)` pairs. OS is
|
|
1094
|
+
inferred from the core-id silicon class when omitted. Raises `CoresError`
|
|
1095
|
+
on an id outside `^[a-z][a-z0-9_]+$`, an unknown OS, or a duplicate id --
|
|
1096
|
+
invalid values would otherwise flow verbatim into board.yaml. `None`/empty
|
|
1097
|
+
-> no cores (single-core default). Mirrors
|
|
1098
|
+
`tan-cli::commands::init::resolve::parse_cores`."""
|
|
1099
|
+
if not raw:
|
|
1100
|
+
return []
|
|
1101
|
+
cores: list[tuple[str, str]] = []
|
|
1102
|
+
for entry in raw.split(","):
|
|
1103
|
+
entry = entry.strip()
|
|
1104
|
+
if not entry:
|
|
1105
|
+
continue
|
|
1106
|
+
id_part, _sep, os_part = entry.partition(":")
|
|
1107
|
+
core_id = id_part.strip()
|
|
1108
|
+
if not core_id:
|
|
1109
|
+
continue
|
|
1110
|
+
if not _is_valid_core_id(core_id):
|
|
1111
|
+
raise CoresError(
|
|
1112
|
+
f"Invalid core id '{core_id}' in --cores (expected lowercase id "
|
|
1113
|
+
f"matching ^[a-z][a-z0-9_]+$, e.g. m33_sm)."
|
|
1114
|
+
)
|
|
1115
|
+
os_value = os_part.strip()
|
|
1116
|
+
if os_value:
|
|
1117
|
+
if os_value not in _CORE_OS_CHOICES:
|
|
1118
|
+
raise CoresError(
|
|
1119
|
+
f"Invalid OS '{os_value}' for core '{core_id}' in --cores "
|
|
1120
|
+
f"(expected one of: zephyr, yocto, baremetal, off)."
|
|
1121
|
+
)
|
|
1122
|
+
else:
|
|
1123
|
+
os_value = infer_runtime_for_core_id(core_id)
|
|
1124
|
+
if any(existing == core_id for existing, _ in cores):
|
|
1125
|
+
raise CoresError(f"Duplicate core id '{core_id}' in --cores.")
|
|
1126
|
+
cores.append((core_id, os_value))
|
|
1127
|
+
return cores
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _rust_lines(text: str) -> list[str]:
|
|
1131
|
+
"""`text.split("\\n")`, minus a trailing empty element from a trailing
|
|
1132
|
+
`\\n` -- what Rust's `str::lines()` yields for the same LF-only text, and
|
|
1133
|
+
what `vendored_app_core_key`/`vendored_core_ids`/`splice_companion_cores`
|
|
1134
|
+
below are ported line-for-line against."""
|
|
1135
|
+
lines = text.split("\n")
|
|
1136
|
+
if lines and lines[-1] == "":
|
|
1137
|
+
lines.pop()
|
|
1138
|
+
return lines
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def vendored_app_core_key(board_yaml: str) -> str | None:
|
|
1142
|
+
"""The `cores:` block's APP-core key (e.g. `"m33_sm"`) -- the core whose
|
|
1143
|
+
block owns an `app:` line, NOT simply the first core listed. A
|
|
1144
|
+
heterogeneous scaffold (`edge-ai`) lists its companion core FIRST
|
|
1145
|
+
(`os: "off"`, no `app:`); trusting position would return the companion.
|
|
1146
|
+
Works uniformly on every template this port plans (including the
|
|
1147
|
+
hand-generated `minimal-app`, whose sole core also carries `app:`), so
|
|
1148
|
+
unlike the Rust port there is no need for a per-template
|
|
1149
|
+
`vendored_*_app_core_for_sku` family -- this reads the ACTUAL planned
|
|
1150
|
+
content instead of a second, independently-derived lookup. Mirrors
|
|
1151
|
+
`wizard::service::vendored::vendored_app_core_key`."""
|
|
1152
|
+
in_cores = False
|
|
1153
|
+
current_core: str | None = None
|
|
1154
|
+
for line in _rust_lines(board_yaml):
|
|
1155
|
+
if not in_cores:
|
|
1156
|
+
in_cores = line == "cores:"
|
|
1157
|
+
continue
|
|
1158
|
+
if line and not line[0].isspace():
|
|
1159
|
+
break # The next top-level key ends the cores: block.
|
|
1160
|
+
if line.startswith(" ") and not line[2:3].isspace() and line.endswith(":"):
|
|
1161
|
+
current_core = line[2:-1] # A ` <core>:` entry key line.
|
|
1162
|
+
elif line.startswith(" app:"):
|
|
1163
|
+
if current_core is not None:
|
|
1164
|
+
return current_core
|
|
1165
|
+
return None
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def vendored_core_ids(board_yaml: str) -> list[tuple[str, str]]:
|
|
1169
|
+
"""Every `(core id, os)` pair declared in a board.yaml's `cores:` block,
|
|
1170
|
+
in file order -- the app core AND any pre-declared companions (e.g.
|
|
1171
|
+
edge-ai's `a55_cluster`/`a32_cluster`). `os` is that core's own `os:`
|
|
1172
|
+
child line verbatim (quotes and all, e.g. `"off"`), or `"zephyr"` when
|
|
1173
|
+
absent (every vendored app core omits `os:` -- implicitly Zephyr).
|
|
1174
|
+
Mirrors `wizard::service::vendored::vendored_core_ids`."""
|
|
1175
|
+
in_cores = False
|
|
1176
|
+
ids: list[list[str]] = []
|
|
1177
|
+
for line in _rust_lines(board_yaml):
|
|
1178
|
+
if not in_cores:
|
|
1179
|
+
in_cores = line == "cores:"
|
|
1180
|
+
continue
|
|
1181
|
+
if line and not line[0].isspace():
|
|
1182
|
+
break
|
|
1183
|
+
if line.startswith(" ") and not line[2:3].isspace() and line.endswith(":"):
|
|
1184
|
+
ids.append([line[2:-1], "zephyr"])
|
|
1185
|
+
elif line.startswith(" os: "):
|
|
1186
|
+
if ids:
|
|
1187
|
+
ids[-1][1] = line[len(" os: ") :]
|
|
1188
|
+
return [(core_id, os_value) for core_id, os_value in ids]
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _yaml_scalar_value(after_colon: str) -> str | None:
|
|
1192
|
+
"""The bare scalar value from the text AFTER a mapping key's `:` --
|
|
1193
|
+
stripped of leading whitespace, a trailing comment, and any surrounding
|
|
1194
|
+
quote characters (`'`/`"`) -- mirroring
|
|
1195
|
+
`generate_cmd._scan_som_sku`/`bootstrap_cmd._scan_board_slice`'s own
|
|
1196
|
+
`.strip().strip("'\"")` rule for this identical scalar.
|
|
1197
|
+
|
|
1198
|
+
tan-cli#1008 review round 5: this function used to keep the quotes,
|
|
1199
|
+
which silently evaded the checks reading its result. A quoted
|
|
1200
|
+
`sku: "E1M-AEN801"` made `_SKU_FAMILY.match('"E1M-AEN801"')` fail, so the
|
|
1201
|
+
family check saw nothing to judge, reachable through an unstripped
|
|
1202
|
+
quote. A quoted `hw_rev: "r1"` was worse: `changing_sku` (a bare string compare)
|
|
1203
|
+
read `'"E1M-AEN801"' != "E1M-AEN801"` as TRUE even for a byte-for-byte
|
|
1204
|
+
intra-SKU no-op, so both round three's no-op guard and round four's
|
|
1205
|
+
intra-family guard were defeated at once -- a real `hw_rev:` silently
|
|
1206
|
+
dropped and replaced by the SoM's own `default_hw_rev:`, possibly a
|
|
1207
|
+
DIFFERENT declared revision with different `pad_route_overrides`, with
|
|
1208
|
+
`tan validate` clean and `tan init` reporting no issue.
|
|
1209
|
+
|
|
1210
|
+
`None` for nothing after the colon, or only a comment.
|
|
1211
|
+
"""
|
|
1212
|
+
stripped = after_colon.lstrip(" \t")
|
|
1213
|
+
if not stripped or stripped.startswith("#"):
|
|
1214
|
+
return None
|
|
1215
|
+
match = re.search(r"[ \t]", stripped)
|
|
1216
|
+
token = stripped[: match.start()] if match else stripped
|
|
1217
|
+
return token.strip("'\"") or None
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def vendored_som(board_yaml: str) -> tuple[str | None, str | None]:
|
|
1221
|
+
"""The `som:` block's `sku:`/`hw_rev:` scalar values, read the same
|
|
1222
|
+
line-oriented way `vendored_app_core_key`/`vendored_core_ids` read the
|
|
1223
|
+
`cores:` block -- `None` for either key that is absent or carries no
|
|
1224
|
+
value. Tolerates a space before the child key's colon (`sku : x`,
|
|
1225
|
+
round 5, via the shared [`_split_child_key`] -- round 6: this reader and
|
|
1226
|
+
`retarget_board_yaml_som`'s writer now both call it, rather than each
|
|
1227
|
+
keeping its own copy of the rule) and strips surrounding quote
|
|
1228
|
+
characters from the value (round 5, `_yaml_scalar_value`).
|
|
1229
|
+
|
|
1230
|
+
tan-cli#743 majors 1+2: this is what reads the SoM/hw_rev pair a scaffolded board.yaml ACTUALLY
|
|
1231
|
+
carries, rather than `--som` (silently absent on a bare
|
|
1232
|
+
`--from-example`/`--topology`, even though the copied board.yaml already
|
|
1233
|
+
names a SKU on disk) or an assumed-absent `hw_rev:` (`retarget_board_yaml_som`
|
|
1234
|
+
drops a sibling `hw_rev:` only on a CROSS-family retarget -- tan-cli#1008
|
|
1235
|
+
review rounds 3+4 -- and otherwise leaves it exactly as the source
|
|
1236
|
+
example wrote it, whether that is the ORIGINAL SKU's own value or one
|
|
1237
|
+
surviving a same-family retarget, so it must be read, not presumed
|
|
1238
|
+
absent).
|
|
1239
|
+
|
|
1240
|
+
Raises [`FlowStyleSomError`] (tan-cli#1029) the moment the top-level
|
|
1241
|
+
`som:` line turns out to be flow-style rather than block-style --
|
|
1242
|
+
`retarget_board_yaml_som` calls this function first and so inherits the
|
|
1243
|
+
identical refusal, rather than each guessing independently whether a
|
|
1244
|
+
line it cannot read is safe to treat as "no som: block at all".
|
|
1245
|
+
|
|
1246
|
+
Also raises [`UnreadableSomBlockError`] (tan-cli#1041) when a `som:`
|
|
1247
|
+
entry is present -- literally, or (see below) only once the document is
|
|
1248
|
+
actually parsed -- but no LITERAL `sku:` line was found nested beneath
|
|
1249
|
+
it: see that class's own docstring for the four spellings this covers.
|
|
1250
|
+
Two different signals feed it, because only one of them can see a
|
|
1251
|
+
`som:` this scan never finds text for at all:
|
|
1252
|
+
|
|
1253
|
+
- `entered_som`: this scan's OWN `_is_som_key_line` matched a literal
|
|
1254
|
+
`som:`/`"som":` line, so a real block was entered, but the loop below
|
|
1255
|
+
never set `sku`. Covers an alias and an in-block merge key with no
|
|
1256
|
+
override, and -- since `FlowStyleSomError`'s own detector only ever
|
|
1257
|
+
looks at the `som:` line itself -- a flow mapping split across more
|
|
1258
|
+
than one physical line, which this loop's per-line `_split_child_key`
|
|
1259
|
+
also never recognises as a `sku:` child (its first `:` splits on the
|
|
1260
|
+
wrong token, e.g. `{sku` from ` {sku: ..., hw_rev: ...}`).
|
|
1261
|
+
- the `yaml.safe_load` backstop below, run ONLY when `entered_som` is
|
|
1262
|
+
still `False` at the end of the scan (i.e. this scan is about to
|
|
1263
|
+
report "no som: block at all"): a document-root merge key (`<<:
|
|
1264
|
+
*base`, a SIBLING of `som:`, not nested under it) can produce an
|
|
1265
|
+
effective `som:` mapping with no `som:` TEXT anywhere in the file for
|
|
1266
|
+
any line scan to ever find. Deferred, function-local `import yaml` --
|
|
1267
|
+
`tests/gates/test_cli_import_is_lean.py` (tan-cli#810) pins that a
|
|
1268
|
+
bare `tan --version` loads no YAML machinery at all, and this module
|
|
1269
|
+
is reached from `init_cmd`, which `tan/cli.py` static-imports on
|
|
1270
|
+
every invocation -- so the import must stay inside the one branch
|
|
1271
|
+
that is actually reached only from a real `tan init`/`generate` call,
|
|
1272
|
+
never at import time. Swallows a genuine YAML syntax error the same
|
|
1273
|
+
way the rest of this function already tolerates one (as "nothing
|
|
1274
|
+
found"): a document this broken was never going to scan cleanly
|
|
1275
|
+
either, and this backstop's whole job is narrower than "validate the
|
|
1276
|
+
file" -- it only asks "did a merge manufacture a `som:` this scan is
|
|
1277
|
+
blind to".
|
|
1278
|
+
|
|
1279
|
+
tan-cli#1041 asked, verbatim, that reading the `som:` block with a real
|
|
1280
|
+
YAML parse be considered, and that a rejection say why in the code. Here
|
|
1281
|
+
is why: a real parse would tell `retarget_board_yaml_som` (the writer
|
|
1282
|
+
that calls this reader first) THAT a `sku:` exists and what its parsed
|
|
1283
|
+
value is, but not WHICH BYTES on WHICH LINE to rewrite -- a `yaml.Node`
|
|
1284
|
+
carries no reliable back-reference to source position/formatting, and
|
|
1285
|
+
`retarget_board_yaml_som` must reproduce everything this scan does not
|
|
1286
|
+
touch byte-for-byte: a hand-authored comment, a wrapped comment block
|
|
1287
|
+
(`_is_wrapped_comment_line`), inline alignment, the file's own CRLF-vs-LF
|
|
1288
|
+
terminator (`_split_cr`, tan-cli#404). A parse-then-reserialize writer
|
|
1289
|
+
would lose all of that -- exactly the class of regression the module
|
|
1290
|
+
docstring's own "Deliberately NOT reimplemented here" paragraph already
|
|
1291
|
+
refuses for the CMakeLists rewrite, for the identical reason. So the
|
|
1292
|
+
line-oriented scan stays the reader for every shape it CAN see text for,
|
|
1293
|
+
and `yaml.safe_load` is used only as a narrow, read-only DETECTOR for
|
|
1294
|
+
the one shape it cannot -- a document-root merge key -- never as a
|
|
1295
|
+
second source of the `sku`/`hw_rev` values this function returns; those
|
|
1296
|
+
two are always `_yaml_scalar_value` of a literal line, so a value the
|
|
1297
|
+
scan found and one a full parse would find never have a chance to
|
|
1298
|
+
disagree.
|
|
1299
|
+
"""
|
|
1300
|
+
in_som = False
|
|
1301
|
+
entered_som = False
|
|
1302
|
+
found_sku_key = False
|
|
1303
|
+
sku: str | None = None
|
|
1304
|
+
hw_rev: str | None = None
|
|
1305
|
+
for line in _rust_lines(board_yaml):
|
|
1306
|
+
body, _cr = _split_cr(line)
|
|
1307
|
+
if not in_som:
|
|
1308
|
+
flow_body = _som_flow_style_body(body)
|
|
1309
|
+
if flow_body is not None:
|
|
1310
|
+
raise FlowStyleSomError(flow_body)
|
|
1311
|
+
in_som = _is_som_key_line(body)
|
|
1312
|
+
entered_som = entered_som or in_som
|
|
1313
|
+
continue
|
|
1314
|
+
if body and not body[0].isspace():
|
|
1315
|
+
break # The next top-level key ends the som: block.
|
|
1316
|
+
trimmed = body.lstrip(" \t")
|
|
1317
|
+
child = _split_child_key(trimmed)
|
|
1318
|
+
if child is None:
|
|
1319
|
+
continue
|
|
1320
|
+
child_key, child_rest = child
|
|
1321
|
+
if child_key == "sku":
|
|
1322
|
+
found_sku_key = True
|
|
1323
|
+
sku = _yaml_scalar_value(child_rest)
|
|
1324
|
+
elif child_key == "hw_rev":
|
|
1325
|
+
hw_rev = _yaml_scalar_value(child_rest)
|
|
1326
|
+
# `found_sku_key`, not `sku is None` -- a bare `sku:`/`sku: # tbd` (no
|
|
1327
|
+
# value token at all) is a LITERAL `sku:` line this scan found and
|
|
1328
|
+
# `retarget_board_yaml_som` already knows how to fill in
|
|
1329
|
+
# (`_retargeted_sku_line`'s own "nothing after the colon" branch); it
|
|
1330
|
+
# must not be confused with the six tan-cli#1041 shapes where no `sku:`
|
|
1331
|
+
# line exists for the scan to find in the first place.
|
|
1332
|
+
if entered_som and not found_sku_key:
|
|
1333
|
+
raise UnreadableSomBlockError()
|
|
1334
|
+
if not entered_som:
|
|
1335
|
+
import yaml # noqa: PLC0415 (declared base dep; deferred -- see docstring)
|
|
1336
|
+
|
|
1337
|
+
try:
|
|
1338
|
+
doc = yaml.safe_load(board_yaml)
|
|
1339
|
+
except yaml.YAMLError:
|
|
1340
|
+
doc = None
|
|
1341
|
+
if isinstance(doc, dict) and isinstance(doc.get("som"), dict):
|
|
1342
|
+
raise UnreadableSomBlockError()
|
|
1343
|
+
return sku, hw_rev
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def splice_companion_cores(board_yaml: str, cores: list[tuple[str, str]]) -> str:
|
|
1347
|
+
"""Splice `--cores` companions (and a default RPMsg channel to the first
|
|
1348
|
+
ACTIVE one, `os != "off"`) into `board_yaml`, after the sole app-core
|
|
1349
|
+
entry already inside its `cores:` block. A no-op when `cores` is empty or
|
|
1350
|
+
the content has no `cores:` block. Skips any id already declared in the
|
|
1351
|
+
`cores:` block (not just the app core) so this can never emit a
|
|
1352
|
+
duplicate mapping key -- belt-and-suspenders behind `init_cmd`'s own
|
|
1353
|
+
upfront guard. Mirrors
|
|
1354
|
+
`wizard::service::vendored::splice_companion_cores`."""
|
|
1355
|
+
if not cores:
|
|
1356
|
+
return board_yaml
|
|
1357
|
+
app_core = vendored_app_core_key(board_yaml)
|
|
1358
|
+
if app_core is None:
|
|
1359
|
+
return board_yaml
|
|
1360
|
+
existing_ids = {core_id for core_id, _os in vendored_core_ids(board_yaml)}
|
|
1361
|
+
|
|
1362
|
+
companion_lines: list[str] = []
|
|
1363
|
+
for core_id, os_value in cores:
|
|
1364
|
+
if core_id == app_core or core_id in existing_ids:
|
|
1365
|
+
continue
|
|
1366
|
+
companion_lines.append(f" {core_id}:")
|
|
1367
|
+
# `off` is a YAML 1.1 boolean keyword (`yaml.safe_load("os: off")` ->
|
|
1368
|
+
# `{"os": False}`), so an unquoted companion entry writes a bool where
|
|
1369
|
+
# the schema requires the string `"off"` -- confirmed against
|
|
1370
|
+
# `metadata/schemas/board-v2.schema.json`'s `os` enum, which rejects
|
|
1371
|
+
# `False` with "False is not of type 'string'". Every vendored
|
|
1372
|
+
# scaffold already quotes it this way (e.g.
|
|
1373
|
+
# `tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml`); the other
|
|
1374
|
+
# three enum values (`zephyr`/`yocto`/`baremetal`) are not YAML
|
|
1375
|
+
# keywords and stay bare to match that same convention.
|
|
1376
|
+
os_literal = '"off"' if os_value == "off" else os_value
|
|
1377
|
+
companion_lines.append(f" os: {os_literal}")
|
|
1378
|
+
if os_value == "yocto":
|
|
1379
|
+
companion_lines.append(" image: alp-image-edge")
|
|
1380
|
+
if not companion_lines:
|
|
1381
|
+
return board_yaml
|
|
1382
|
+
|
|
1383
|
+
out: list[str] = []
|
|
1384
|
+
in_cores = False
|
|
1385
|
+
inserted = False
|
|
1386
|
+
for line in _rust_lines(board_yaml):
|
|
1387
|
+
is_top_level = bool(line) and line[0] not in (" ", "\t")
|
|
1388
|
+
if is_top_level:
|
|
1389
|
+
if in_cores and not inserted:
|
|
1390
|
+
out.extend(companion_lines)
|
|
1391
|
+
inserted = True
|
|
1392
|
+
in_cores = line == "cores:"
|
|
1393
|
+
out.append(line)
|
|
1394
|
+
if in_cores and not inserted:
|
|
1395
|
+
out.extend(companion_lines)
|
|
1396
|
+
inserted = True
|
|
1397
|
+
|
|
1398
|
+
result = "\n".join(out) + "\n"
|
|
1399
|
+
if inserted:
|
|
1400
|
+
companion = next(
|
|
1401
|
+
(
|
|
1402
|
+
core_id
|
|
1403
|
+
for core_id, os_value in cores
|
|
1404
|
+
if core_id != app_core and os_value != "off" and core_id not in existing_ids
|
|
1405
|
+
),
|
|
1406
|
+
None,
|
|
1407
|
+
)
|
|
1408
|
+
# tan-cli#925: never append over a board that already declares
|
|
1409
|
+
# `ipc:`. PyYAML accepts a duplicate top-level key and keeps the
|
|
1410
|
+
# LAST, so an unconditional append does not fail -- it silently
|
|
1411
|
+
# discards the project's own channel. alp-sdk's multicore-mailbox
|
|
1412
|
+
# scaffold declares `alp_shmem0`, and both its `src/main.c` and
|
|
1413
|
+
# `peer/main.c` `#define SHMEM_REGION_NAME "alp_shmem0"`.
|
|
1414
|
+
declares_ipc = any(line.startswith("ipc:") for line in _rust_lines(board_yaml))
|
|
1415
|
+
if companion is not None and not declares_ipc:
|
|
1416
|
+
result += (
|
|
1417
|
+
"\nipc:\n"
|
|
1418
|
+
" - kind: rpmsg\n"
|
|
1419
|
+
" name: alp_default_rpmsg\n"
|
|
1420
|
+
f" endpoints: [{app_core}, {companion}]\n"
|
|
1421
|
+
" carve_out_kb: 256\n"
|
|
1422
|
+
)
|
|
1423
|
+
return result
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
# ---------------------------------------------------------------------------
|
|
1427
|
+
# Template planning
|
|
1428
|
+
# ---------------------------------------------------------------------------
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def plan_template_files(template_id: str, sku: str) -> list[PlannedFile]:
|
|
1432
|
+
"""The files `template_id` lays down for `sku`.
|
|
1433
|
+
|
|
1434
|
+
Raises `TemplateDataError` when a vendored tree cannot be read,
|
|
1435
|
+
`UnsupportedSomError` when tan vendors no tree for `sku`'s SoM family
|
|
1436
|
+
(tan-cli#579), and is `KeyError`-free otherwise: the caller has already
|
|
1437
|
+
validated `template_id` against `TEMPLATE_IDS`.
|
|
1438
|
+
|
|
1439
|
+
`minimal-app` can never raise the second: it is hand-generated here and
|
|
1440
|
+
vendor-neutral, so it scaffolds every SKU -- see `UnsupportedSomError`.
|
|
1441
|
+
"""
|
|
1442
|
+
if template_id == "minimal-app":
|
|
1443
|
+
return _minimal_app_files(sku)
|
|
1444
|
+
return _vendored_files(_VENDORED_TEMPLATE_DIR[template_id], template_id, sku)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _vendored_family(template_id: str, sku: str) -> str:
|
|
1448
|
+
"""Which family subdirectory `template_id` reads for `sku` -- and the one
|
|
1449
|
+
place that refuses a SoM family tan vendors no tree for (tan-cli#579).
|
|
1450
|
+
|
|
1451
|
+
Split out of `_vendored_files` (which does the IO) so the family choice --
|
|
1452
|
+
the decision tan-cli#579 got wrong -- is one small pure lookup with its
|
|
1453
|
+
refusal beside it. Raising HERE means the caller's run fails before a
|
|
1454
|
+
single byte of another vendor's tree is read, so no half-planned project
|
|
1455
|
+
can escape.
|
|
1456
|
+
"""
|
|
1457
|
+
# `iot` has exactly one vendored tree, no family split (its caller rejects
|
|
1458
|
+
# any other SKU first); every other template has two.
|
|
1459
|
+
restricted = TEMPLATE_SUPPORTED_SKUS.get(template_id)
|
|
1460
|
+
family = restricted[0] if restricted else _family_bucket(sku)
|
|
1461
|
+
if family is None:
|
|
1462
|
+
raise UnsupportedSomError(template_id, sku)
|
|
1463
|
+
return family
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _vendored_files(tree: str, template_id: str, sku: str) -> list[PlannedFile]:
|
|
1467
|
+
"""Read a vendored scaffold tree and retarget its `board.yaml` onto `sku`.
|
|
1468
|
+
|
|
1469
|
+
Files come back sorted by their relative POSIX path (`CMakeLists.txt`,
|
|
1470
|
+
`README.md`, `board.yaml`, ... -- uppercase first), the order the Rust
|
|
1471
|
+
`vendored_tree!` macro lists them in, so `data.fileChanges[]` matches the
|
|
1472
|
+
shipped binary's without a hand-kept list here to drift out of step with
|
|
1473
|
+
it. `iot` is the one tree where the two LISTS differ, not just their order:
|
|
1474
|
+
the frozen Rust tree never got `src/cc3501e_bridge.{c,h}` (tan-cli#1275,
|
|
1475
|
+
alp-sdk#2112 -- vendored as `NON_ENVELOPE_EXTRAS` in
|
|
1476
|
+
`tests/parity/scaffold_byte_parity.py` because alp-sdk's own scaffold
|
|
1477
|
+
catalog omits them from the envelope, alplabai/alp-sdk#2241;
|
|
1478
|
+
`test_scaffold_content_oracle_parity.py`'s `FILE_SET_DIVERGENCE` records
|
|
1479
|
+
the count divergence until tan-cli#269 deleted the oracle axis;
|
|
1480
|
+
`tests/core/test_template_integrity.py` pins the FILE SET now, not the
|
|
1481
|
+
diff), so `tan init --template iot-starter --format json` returns two
|
|
1482
|
+
`fileChanges[]` entries more than the oracle did. (`native_sim.conf`,
|
|
1483
|
+
this tree's earlier and now-retired divergence source, was removed
|
|
1484
|
+
entirely at tan-cli#1275 -- alp-sdk#2173 fixed the mbedtls PSA-crypto
|
|
1485
|
+
break it existed to work around, so `mqtt-telemetry` no longer ships
|
|
1486
|
+
one.) Sorting is what keeps every file the two trees DO share in the
|
|
1487
|
+
same relative order. Sorted on
|
|
1488
|
+
that STRING, never on the `Path`: `PurePath.__lt__` compares a
|
|
1489
|
+
case-FOLDED key on Windows, so sorting paths ordered `board.yaml` before
|
|
1490
|
+
`CMakeLists.txt` there and after it on Linux -- the same command emitting a
|
|
1491
|
+
different `fileChanges[]` order per platform.
|
|
1492
|
+
"""
|
|
1493
|
+
family = _vendored_family(template_id, sku)
|
|
1494
|
+
root = VENDORED_ROOT / tree / family
|
|
1495
|
+
try:
|
|
1496
|
+
paths = _example_source_files(root) if root.is_dir() else []
|
|
1497
|
+
if not paths:
|
|
1498
|
+
raise TemplateDataError(
|
|
1499
|
+
f"tan's vendored template tree for '{template_id}' is empty at "
|
|
1500
|
+
f"'{root}'. This is a broken tan installation, not a project problem "
|
|
1501
|
+
f"-- reinstall tan, or rebuild the binary with the template data "
|
|
1502
|
+
f"(scripts/build_binary.sh)."
|
|
1503
|
+
)
|
|
1504
|
+
files = []
|
|
1505
|
+
for path in paths:
|
|
1506
|
+
relative = path.relative_to(root).as_posix()
|
|
1507
|
+
content = _read_verbatim(path)
|
|
1508
|
+
if relative == "board.yaml":
|
|
1509
|
+
content = retarget_board_yaml_som(content, sku) # tan-cli#494 defect 2
|
|
1510
|
+
content = retarget_board_yaml_cores(content, sku, family)
|
|
1511
|
+
content = retarget_selftest_som_identity(content, sku, family)
|
|
1512
|
+
content = retarget_selftest_soc_identity(content, sku, family)
|
|
1513
|
+
content = retarget_example_build_target_comment(content, sku, family)
|
|
1514
|
+
files.append(PlannedFile(relative, content))
|
|
1515
|
+
_require_complete_tree(template_id, root, files)
|
|
1516
|
+
except OSError as err:
|
|
1517
|
+
raise TemplateDataError(
|
|
1518
|
+
f"tan's vendored template tree for '{template_id}' could not be read at "
|
|
1519
|
+
f"'{root}': {err}"
|
|
1520
|
+
) from err
|
|
1521
|
+
return files
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
#: Files every vendored scaffold tree must carry, whatever the template: the
|
|
1525
|
+
#: build script, the SoM declaration, and the Zephyr config. A tree missing any
|
|
1526
|
+
#: of them is not a scaffold.
|
|
1527
|
+
_VENDORED_BASELINE_FILES = ("CMakeLists.txt", "board.yaml", "prj.conf")
|
|
1528
|
+
|
|
1529
|
+
#: `target_sources(app PRIVATE <path> ...)`, across line breaks -- `edge-ai`
|
|
1530
|
+
#: spells it over three lines, every other template on one.
|
|
1531
|
+
_TARGET_SOURCES_RE = re.compile(r"target_sources\s*\(\s*app\s+PRIVATE\s+(.*?)\)", re.DOTALL)
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _require_complete_tree(template_id: str, root: Path, files: list[PlannedFile]) -> None:
|
|
1535
|
+
"""Refuse a PARTIALLY delivered vendored tree (tan-cli#494 defect 3).
|
|
1536
|
+
|
|
1537
|
+
The `if not paths:` guard above is all-or-nothing: it fires only when the
|
|
1538
|
+
tree yields ZERO files, so any non-empty SUBSET read as complete. With
|
|
1539
|
+
`src/main.c` absent -- a mis-scoped PyInstaller `--add-data`, a partial
|
|
1540
|
+
copy, an extracted onedir whose `src/` lost `+x` -- `tan init` wrote five
|
|
1541
|
+
files, reported `ok:true` / `issues:[]`, and the `CMakeLists.txt` it DID
|
|
1542
|
+
write still said `target_sources(app PRIVATE src/main.c)`. The customer's
|
|
1543
|
+
first `tan build` then died inside CMake with "Cannot find source file",
|
|
1544
|
+
reported to them as their project's problem. `TemplateDataError` /
|
|
1545
|
+
`init.template-unreadable` is exactly the coded outcome that broken-
|
|
1546
|
+
installation population is owed.
|
|
1547
|
+
|
|
1548
|
+
The expected set is DERIVED, never a hand-kept list that would drift out
|
|
1549
|
+
of step with the trees: a baseline every scaffold has by definition
|
|
1550
|
+
(`_VENDORED_BASELINE_FILES`) plus every relative path the tree's own
|
|
1551
|
+
`CMakeLists.txt` hands to `target_sources(app PRIVATE ...)`. That second
|
|
1552
|
+
half is what makes the check track a template that gains a `.c` file with
|
|
1553
|
+
no edit here -- and it is the precise set whose absence breaks the build.
|
|
1554
|
+
"""
|
|
1555
|
+
present = {f.relative_path for f in files}
|
|
1556
|
+
expected = set(_VENDORED_BASELINE_FILES)
|
|
1557
|
+
cmake = next((f.content for f in files if f.relative_path == "CMakeLists.txt"), None)
|
|
1558
|
+
if cmake is not None:
|
|
1559
|
+
for block in _TARGET_SOURCES_RE.findall(cmake):
|
|
1560
|
+
expected.update(tok for tok in block.split() if not tok.startswith("$"))
|
|
1561
|
+
missing = sorted(expected - present)
|
|
1562
|
+
if missing:
|
|
1563
|
+
raise TemplateDataError(
|
|
1564
|
+
f"tan's vendored template tree for '{template_id}' at '{root}' is "
|
|
1565
|
+
f"incomplete -- missing {', '.join(missing)}. This is a broken tan "
|
|
1566
|
+
f"installation, not a project problem -- reinstall tan, or rebuild the "
|
|
1567
|
+
f"binary with the template data (scripts/build_binary.sh)."
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def vendored_library_names_for(template_id: str) -> list[str] | None:
|
|
1572
|
+
"""The library names `tan explain --template <id>` reports for `template_id`.
|
|
1573
|
+
|
|
1574
|
+
`None` for `minimal-app` -- the one template with no vendored tree, whose
|
|
1575
|
+
caller keeps reading the registry's own (empty) `libs` field. A list
|
|
1576
|
+
otherwise, read straight from that template's Alif-Ensemble-family
|
|
1577
|
+
`board.yaml`: `Some(vec![])` in Rust terms, so an EMPTY list means "this
|
|
1578
|
+
scaffold genuinely ships no libraries", never "unknown".
|
|
1579
|
+
|
|
1580
|
+
Mirrors `crates/tan-core/src/wizard/service/vendored.rs`'s
|
|
1581
|
+
`vendored_library_names_for`, including its choice of family tree: the
|
|
1582
|
+
`libraries:` block is identical across the AEN/V2N pair of every
|
|
1583
|
+
family-split template (Rust asserts that in
|
|
1584
|
+
`vendored_library_names_matches_across_families`) and `tan explain` has no
|
|
1585
|
+
`--som` to pick one with, so the AEN tree stands in for both.
|
|
1586
|
+
|
|
1587
|
+
Fixes tan-cli#124 by construction: the summary derives from the scaffold
|
|
1588
|
+
bytes `tan init` actually writes, not from a second hand-synced registry
|
|
1589
|
+
field that went stale (`edge-ai-starter` reported no libraries while its
|
|
1590
|
+
vendored board.yaml declares `tflite-micro`).
|
|
1591
|
+
|
|
1592
|
+
Raises `TemplateDataError` when the tree will not read, so `tan explain`
|
|
1593
|
+
can report a coded issue instead of letting an `OSError` escape as a
|
|
1594
|
+
traceback -- the Rust `expect()`s here and panics.
|
|
1595
|
+
"""
|
|
1596
|
+
tree = _VENDORED_TEMPLATE_DIR.get(template_id)
|
|
1597
|
+
if tree is None:
|
|
1598
|
+
return None
|
|
1599
|
+
path = VENDORED_ROOT / tree / _FAMILY_TREES[0] / "board.yaml"
|
|
1600
|
+
try:
|
|
1601
|
+
text = _read_verbatim(path)
|
|
1602
|
+
except OSError as err:
|
|
1603
|
+
raise TemplateDataError(
|
|
1604
|
+
f"tan's vendored board.yaml for '{template_id}' could not be read at "
|
|
1605
|
+
f"'{path}': {err}. This is a broken tan installation, not a project "
|
|
1606
|
+
f"problem -- reinstall tan, or rebuild the binary with the template "
|
|
1607
|
+
f"data (scripts/build_binary.sh)."
|
|
1608
|
+
) from err
|
|
1609
|
+
return _library_names(text)
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _library_names(board_yaml: str) -> list[str]:
|
|
1613
|
+
"""Every name in a board.yaml's top-level `libraries:` block, in file order.
|
|
1614
|
+
|
|
1615
|
+
A line scan, where Rust parses the whole document through `BoardModel`. The
|
|
1616
|
+
input is not a customer's file -- it is tan's own vendored capture, LF, with
|
|
1617
|
+
a shape `tests/core/test_scaffold.py` byte-diffs against the Rust trees --
|
|
1618
|
+
so a YAML parser would only buy generality this never needs, and PyYAML is
|
|
1619
|
+
an OPTIONAL runtime import in this package (see `validate_cmd`): depending
|
|
1620
|
+
on it here would make `tan explain --template edge-ai-starter` answer
|
|
1621
|
+
differently depending on whether a wheel happened to be installed.
|
|
1622
|
+
|
|
1623
|
+
Both `LibraryEntry` spellings Rust's model accepts are covered, not just the
|
|
1624
|
+
scoped one every vendored tree uses today: the bare shorthand
|
|
1625
|
+
(`- tflite-micro`) and the scoped object (`- name: tflite-micro`, whose
|
|
1626
|
+
sibling keys like `cores: [m55_hp]` are skipped as they are not list items).
|
|
1627
|
+
Comments and blank lines inside the block are skipped; the first column-0
|
|
1628
|
+
line ends it, exactly as Rust's own `vendored_core_ids` line scan does.
|
|
1629
|
+
"""
|
|
1630
|
+
names: list[str] = []
|
|
1631
|
+
in_block = False
|
|
1632
|
+
for line in board_yaml.splitlines():
|
|
1633
|
+
if not in_block:
|
|
1634
|
+
in_block = line.rstrip() == "libraries:"
|
|
1635
|
+
continue
|
|
1636
|
+
if line and not line[0].isspace():
|
|
1637
|
+
break # The next top-level key ends the libraries: block.
|
|
1638
|
+
entry = line.strip()
|
|
1639
|
+
if not entry.startswith("- "):
|
|
1640
|
+
continue # Blank line, comment, or a scoped entry's sibling key.
|
|
1641
|
+
entry = entry[2:].strip()
|
|
1642
|
+
key, sep, value = entry.partition(":")
|
|
1643
|
+
raw = value.strip() if sep and key.strip() == "name" else entry
|
|
1644
|
+
names.append(raw.strip("\"'"))
|
|
1645
|
+
return names
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
# ---------------------------------------------------------------------------
|
|
1649
|
+
# `minimal-app`: the one hand-generated template
|
|
1650
|
+
# ---------------------------------------------------------------------------
|
|
1651
|
+
#
|
|
1652
|
+
# tan's OWN content, not a copy of anything the SDK ships -- the SDK catalog has
|
|
1653
|
+
# no `minimal-app` entry (its `minimal` entry is what `zephyr-app` vendors). Its
|
|
1654
|
+
# `board.yaml` (below) declares `os: zephyr` and its README says so too -- it
|
|
1655
|
+
# was always meant to build as a real Zephyr app; "hand-generated" describes
|
|
1656
|
+
# where the content comes from (tan's own generator, not a vendored SDK
|
|
1657
|
+
# capture), not a licence to skip Zephyr's own boilerplate.
|
|
1658
|
+
#
|
|
1659
|
+
# tan-cli#309 -- two bugs, not one, and fixing only the first makes the second
|
|
1660
|
+
# worse (a CMake configure error instead of a silent host binary):
|
|
1661
|
+
#
|
|
1662
|
+
# 1. `board.yaml`'s `app:` decides which CMakeLists.txt `west build` actually
|
|
1663
|
+
# configures, via the planner's `_zephyr_app_dir`
|
|
1664
|
+
# (`tan/planner/orchestrator.py`): it resolves `app:` to a directory, and
|
|
1665
|
+
# picks that directory ITSELF whenever it holds a `CMakeLists.txt` of its
|
|
1666
|
+
# own, falling back to the PARENT only when it does not. This template's
|
|
1667
|
+
# `src/` deliberately keeps its own `CMakeLists.txt` (the two-file split
|
|
1668
|
+
# below), so `app: ./src` (through v0.5.0-rc3) sent `west build` straight at
|
|
1669
|
+
# `src/CMakeLists.txt` -- the root `CMakeLists.txt` (`project()` +
|
|
1670
|
+
# `add_subdirectory(src)`, never `add_executable`) was dead code the whole
|
|
1671
|
+
# time, not the file at fault.
|
|
1672
|
+
# 2. `src/CMakeLists.txt` -- the file actually configured -- called plain
|
|
1673
|
+
# `add_executable(alp_app ${ALP_APP_SOURCES})`, no `find_package(Zephyr
|
|
1674
|
+
# ...)` anywhere in either file. CMake configures and links that shape fine
|
|
1675
|
+
# (measured: a real `alp_app.exe`, PE32+ x86-64, built from `CMakeFiles/
|
|
1676
|
+
# alp_app.dir/{main,features/app_bootstrap}.obj` -- app_bootstrap.c WAS
|
|
1677
|
+
# compiled and linked, just into a host binary Zephyr's build never ran at
|
|
1678
|
+
# all), so `tan build` reported success for a project that was never Zephyr.
|
|
1679
|
+
#
|
|
1680
|
+
# `_minimal_app_root_cmake`/`_minimal_app_src_cmake` below fix (2): the root
|
|
1681
|
+
# file now carries `find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})`
|
|
1682
|
+
# before `project()`, and `src/CMakeLists.txt` contributes to Zephyr's own
|
|
1683
|
+
# `app` target via `target_sources(app ...)` instead of a second
|
|
1684
|
+
# `add_executable` -- the same KIND of CMake every vendored template already
|
|
1685
|
+
# writes (e.g. `templates/vendored/minimal/*/CMakeLists.txt`), while keeping
|
|
1686
|
+
# its own hand-generated CONTENT. `_minimal_app_board_yaml` fixes (1): `app: .`
|
|
1687
|
+
# (the project root) so `_zephyr_app_dir` resolves to the root file directly,
|
|
1688
|
+
# without ever consulting `src/`. Measured after both fixes: a real CMake +
|
|
1689
|
+
# Ninja + Zephyr-SDK configure+build compiles `src/features/app_bootstrap.c`
|
|
1690
|
+
# into `app/libapp.a` alongside `src/main.c`, and Zephyr's own link step pulls
|
|
1691
|
+
# `libapp.a` in whole (`-Wl,--whole-archive app/libapp.a`) on the way to a real
|
|
1692
|
+
# `zephyr.elf`.
|
|
1693
|
+
#
|
|
1694
|
+
# Ported from `wizard/service/c_project.rs`/`gen_board_yaml`'s `app: ./src` up
|
|
1695
|
+
# through both defects (`crates/` is FROZEN -- see `docs/ROADMAP.md`'s standing
|
|
1696
|
+
# rule -- so tan-cli#309 is fixed here only, not there); `minimal-app` still
|
|
1697
|
+
# is not the non-interactive default (see `DEFAULT_TEMPLATE_ID`), a separate,
|
|
1698
|
+
# independent choice. `contract/envelopes/init-preview-minimal-app` pins its
|
|
1699
|
+
# exact eight-file list/order (path + change-kind only, never file content or
|
|
1700
|
+
# `board.yaml`'s `app:` value), which neither fix touches.
|
|
1701
|
+
|
|
1702
|
+
#: minimal-app's one feature file: `(path, unit name, TODO line)`.
|
|
1703
|
+
_MINIMAL_APP_FEATURE_FILE = (
|
|
1704
|
+
"src/features/app_bootstrap.c",
|
|
1705
|
+
"app_bootstrap",
|
|
1706
|
+
"TODO: register app services and initialize runtime modules.",
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
_MINIMAL_APP_EXPLANATION = (
|
|
1710
|
+
"Minimal template keeps generated code intentionally small and neutral.",
|
|
1711
|
+
"Use this baseline when you want full control over feature bring-up order.",
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
_MINIMAL_APP_BODY_LINES = (
|
|
1715
|
+
"Alp minimal starter boot",
|
|
1716
|
+
"TODO: add your application logic",
|
|
1717
|
+
)
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _minimal_app_files(sku: str) -> list[PlannedFile]:
|
|
1721
|
+
feature_path, unit_name, todo_line = _MINIMAL_APP_FEATURE_FILE
|
|
1722
|
+
return [
|
|
1723
|
+
PlannedFile("board.yaml", _minimal_app_board_yaml(sku)),
|
|
1724
|
+
PlannedFile("README.md", _minimal_app_readme(sku)),
|
|
1725
|
+
# No `prj_conf_extras`: minimal-app declares none.
|
|
1726
|
+
PlannedFile("prj.conf", "CONFIG_ASSERT=y\nCONFIG_NEWLIB_LIBC=y\n"),
|
|
1727
|
+
PlannedFile("CMakeLists.txt", _minimal_app_root_cmake()),
|
|
1728
|
+
PlannedFile("src/CMakeLists.txt", _minimal_app_src_cmake()),
|
|
1729
|
+
PlannedFile(
|
|
1730
|
+
"include/app/app.h",
|
|
1731
|
+
"// SPDX-License-Identifier: Apache-2.0\n"
|
|
1732
|
+
"\n"
|
|
1733
|
+
"#ifndef ALP_APP_APP_H\n"
|
|
1734
|
+
"#define ALP_APP_APP_H\n"
|
|
1735
|
+
"\n"
|
|
1736
|
+
"int alp_app_init(void);\n"
|
|
1737
|
+
"int alp_app_run(void);\n"
|
|
1738
|
+
"\n"
|
|
1739
|
+
"#endif /* ALP_APP_APP_H */\n",
|
|
1740
|
+
),
|
|
1741
|
+
PlannedFile("src/main.c", _minimal_app_main_c()),
|
|
1742
|
+
PlannedFile(feature_path, _feature_file(unit_name, todo_line)),
|
|
1743
|
+
]
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _minimal_app_board_yaml(sku: str) -> str:
|
|
1747
|
+
"""A board.yaml conforming to the SDK board schema: `som` + `cores` are the
|
|
1748
|
+
only required top-level keys, and the OS is per-core. There is deliberately
|
|
1749
|
+
NO top-level `os:` key (I-02) and no way to ask for one -- a core's runtime
|
|
1750
|
+
follows its Cortex class, and this scaffold's app source is Zephyr.
|
|
1751
|
+
|
|
1752
|
+
`app: .` (the PROJECT ROOT), not `./src` (tan-cli#309 round 2):
|
|
1753
|
+
`_zephyr_app_dir` (`tan/planner/orchestrator.py`) resolves `app:` to the
|
|
1754
|
+
directory holding the CMakeLists.txt `west build` actually configures, and
|
|
1755
|
+
picks the `app:` path ITSELF whenever that path has its own
|
|
1756
|
+
`CMakeLists.txt` -- falling back to its parent only when it does not. This
|
|
1757
|
+
template's `src/` deliberately keeps a `CMakeLists.txt` of its own (the
|
|
1758
|
+
two-file split `_minimal_app_root_cmake`/`_minimal_app_src_cmake` write),
|
|
1759
|
+
so `app: ./src` sent `west build` straight at `src/CMakeLists.txt` --
|
|
1760
|
+
a bare `target_sources(app ...)` with no `find_package(Zephyr ...)`
|
|
1761
|
+
of its own -- and skipped the root file (with the REAL `find_package`/
|
|
1762
|
+
`project()`) entirely. `app: .` resolves to the project root directly:
|
|
1763
|
+
`_zephyr_app_dir` finds `CMakeLists.txt` right there and returns it
|
|
1764
|
+
without ever consulting `src/`."""
|
|
1765
|
+
return (
|
|
1766
|
+
"# Generated by `tan init`.\n"
|
|
1767
|
+
"# board.yaml describes hardware: the SoM SKU + per-core app map.\n"
|
|
1768
|
+
"# Validate it with `tan validate` once an SDK is resolved.\n"
|
|
1769
|
+
"\n"
|
|
1770
|
+
"som:\n"
|
|
1771
|
+
f" sku: {sku}\n"
|
|
1772
|
+
"cores:\n"
|
|
1773
|
+
f" {app_core_for_sku(sku)}:\n"
|
|
1774
|
+
" os: zephyr\n"
|
|
1775
|
+
" app: .\n"
|
|
1776
|
+
)
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _minimal_app_readme(sku: str) -> str:
|
|
1780
|
+
notes = "".join(f"- {line}\n" for line in _MINIMAL_APP_EXPLANATION)
|
|
1781
|
+
return (
|
|
1782
|
+
"# Alp Starter Project\n"
|
|
1783
|
+
"\n"
|
|
1784
|
+
"Template: minimal-app\n"
|
|
1785
|
+
f"SoM: {sku}\n"
|
|
1786
|
+
f"App core: {app_core_for_sku(sku)} (Zephyr)\n"
|
|
1787
|
+
"\n"
|
|
1788
|
+
"## Generated Starter Notes\n"
|
|
1789
|
+
"\n"
|
|
1790
|
+
f"{notes}"
|
|
1791
|
+
"\n"
|
|
1792
|
+
"## Next Steps\n"
|
|
1793
|
+
"\n"
|
|
1794
|
+
"- Run Alp: Validate board.yaml.\n"
|
|
1795
|
+
"- Run Alp: Generate all to produce derived outputs under build/generated/.\n"
|
|
1796
|
+
"- Extend source files under src/features/ for your target behavior.\n"
|
|
1797
|
+
"\n"
|
|
1798
|
+
"This workspace was generated by Alp: New Project Wizard.\n"
|
|
1799
|
+
"Use Alp commands to validate, generate, and build outputs.\n"
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _minimal_app_root_cmake() -> str:
|
|
1804
|
+
"""tan-cli#309: the file `board.yaml`'s `app: .` now points `west build`
|
|
1805
|
+
at directly, so THIS is the file that has to carry Zephyr's boilerplate --
|
|
1806
|
+
before the fix it was `add_subdirectory(src)` with nothing before it, and
|
|
1807
|
+
`board.yaml`'s `app: ./src` skipped straight past it to `src/CMakeLists.txt`
|
|
1808
|
+
(`_minimal_app_board_yaml`'s docstring has the full mechanism).
|
|
1809
|
+
`find_package(Zephyr ...)` has to run before `project()` -- Zephyr's own
|
|
1810
|
+
convention (every vendored template does the same, e.g.
|
|
1811
|
+
`templates/vendored/minimal/*/CMakeLists.txt`) -- because `find_package`
|
|
1812
|
+
is what resolves the toolchain/board machinery `project()` consumes when it
|
|
1813
|
+
enables the C language; reversing the order leaves `project()` running
|
|
1814
|
+
before Zephyr's own CMake modules are even on `CMAKE_MODULE_PATH`."""
|
|
1815
|
+
return (
|
|
1816
|
+
"cmake_minimum_required(VERSION 3.20.0)\n"
|
|
1817
|
+
"find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})\n"
|
|
1818
|
+
"project(alp_starter C)\n"
|
|
1819
|
+
"\n"
|
|
1820
|
+
"add_subdirectory(src)\n"
|
|
1821
|
+
)
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
def _minimal_app_src_cmake() -> str:
|
|
1825
|
+
"""Contributes to Zephyr's own `app` target via `target_sources`/
|
|
1826
|
+
`target_include_directories` -- never a second `add_executable`, which
|
|
1827
|
+
Zephyr's build never links in (tan-cli#309)."""
|
|
1828
|
+
feature_path = _MINIMAL_APP_FEATURE_FILE[0]
|
|
1829
|
+
rel = feature_path[len("src/") :] if feature_path.startswith("src/") else feature_path
|
|
1830
|
+
return (
|
|
1831
|
+
"target_sources(app PRIVATE\n"
|
|
1832
|
+
" main.c\n"
|
|
1833
|
+
f" {rel}\n"
|
|
1834
|
+
")\n"
|
|
1835
|
+
"target_include_directories(app PRIVATE ../include)\n"
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _minimal_app_main_c() -> str:
|
|
1840
|
+
line1, line2 = _MINIMAL_APP_BODY_LINES
|
|
1841
|
+
return (
|
|
1842
|
+
"// SPDX-License-Identifier: Apache-2.0\n"
|
|
1843
|
+
"\n"
|
|
1844
|
+
'#include "app/app.h"\n'
|
|
1845
|
+
"#include <stdio.h>\n"
|
|
1846
|
+
"\n"
|
|
1847
|
+
"int alp_app_init(void) {\n"
|
|
1848
|
+
" // TODO: initialize app-level services.\n"
|
|
1849
|
+
" return 0;\n"
|
|
1850
|
+
"}\n"
|
|
1851
|
+
"\n"
|
|
1852
|
+
"int alp_app_run(void) {\n"
|
|
1853
|
+
" // TODO: execute one app cycle.\n"
|
|
1854
|
+
" return 0;\n"
|
|
1855
|
+
"}\n"
|
|
1856
|
+
"\n"
|
|
1857
|
+
"int main(void) {\n"
|
|
1858
|
+
" if (alp_app_init() != 0) {\n"
|
|
1859
|
+
' puts("alp_app_init failed");\n'
|
|
1860
|
+
" return 1;\n"
|
|
1861
|
+
" }\n"
|
|
1862
|
+
"\n"
|
|
1863
|
+
" if (alp_app_run() != 0) {\n"
|
|
1864
|
+
' puts("alp_app_run failed");\n'
|
|
1865
|
+
" return 1;\n"
|
|
1866
|
+
" }\n"
|
|
1867
|
+
"\n"
|
|
1868
|
+
f' puts("{line1}");\n'
|
|
1869
|
+
f' puts("{line2}");\n'
|
|
1870
|
+
" return 0;\n"
|
|
1871
|
+
"}\n"
|
|
1872
|
+
)
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def _feature_file(unit_name: str, todo_line: str) -> str:
|
|
1876
|
+
return (
|
|
1877
|
+
"// SPDX-License-Identifier: Apache-2.0\n"
|
|
1878
|
+
"\n"
|
|
1879
|
+
"#include <stdio.h>\n"
|
|
1880
|
+
"\n"
|
|
1881
|
+
f"int {unit_name}_step(void) {{\n"
|
|
1882
|
+
f" // {todo_line}\n"
|
|
1883
|
+
" return 0;\n"
|
|
1884
|
+
"}\n"
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
# ---------------------------------------------------------------------------
|
|
1889
|
+
# `--from-example`: copy an SDK example verbatim
|
|
1890
|
+
# ---------------------------------------------------------------------------
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
#: Directory names under an SDK example that hold BUILD OUTPUT, never example
|
|
1894
|
+
#: source -- pruned by [`read_example_tree`] (tan-cli#494 defect 1).
|
|
1895
|
+
#:
|
|
1896
|
+
#: Transcribed from alp-sdk's OWN `.gitignore`, which is the authority on what
|
|
1897
|
+
#: is untracked artifact in that repo: `tan init --from-example` copies an
|
|
1898
|
+
#: example into a NEW project, and a `west build` run in place turns a 6-file
|
|
1899
|
+
#: example into 613 files of `CMakeCache.txt`/`.ninja_deps`/`libapp.a`. Those
|
|
1900
|
+
#: are not the customer's project, and the first binary among them aborts the
|
|
1901
|
+
#: whole command with `init.example-unreadable`.
|
|
1902
|
+
#:
|
|
1903
|
+
#: The transcription is of TWO blocks, and the round of this fix that shipped
|
|
1904
|
+
#: with tan-cli#583 took only part of the first -- it claimed in this very
|
|
1905
|
+
#: comment to be "EXACTLY the five `.gitignore` patterns" while covering five
|
|
1906
|
+
#: of the SEVEN directory patterns alp-sdk declares:
|
|
1907
|
+
#:
|
|
1908
|
+
#: * `.gitignore:1-6`, the `# Build directories` block -- `build/`, `build_*/`,
|
|
1909
|
+
#: `out/`, `cmake-build-*/`, `bwdt/`. `out/` and `bwdt/` were BOTH missed.
|
|
1910
|
+
#: * `.gitignore:36-37`, the west/twister block -- `twister-out/`,
|
|
1911
|
+
#: `twister-out.*/`. Both were taken.
|
|
1912
|
+
#:
|
|
1913
|
+
#: `out/` is not hypothetical and is not only a `west build -d out` spelling:
|
|
1914
|
+
#: `examples/camera-vision/ai-object-detection-realtime/README.md:83` tells the
|
|
1915
|
+
#: customer, in the example's own instructions, to run `dxcom -m yolov8n.onnx
|
|
1916
|
+
#: -c yolov8n_config.json -o out/` INSIDE the example. Measured with that `out/`
|
|
1917
|
+
#: present: a `.dxnn` blob there fails the whole command with
|
|
1918
|
+
#: `init.example-unreadable`, and an all-text `out/` (Intel HEX is ASCII, so
|
|
1919
|
+
#: `out/zephyr/zephyr.hex` qualifies) is copied silently at `ok:true` / exit 0 /
|
|
1920
|
+
#: `issues: []` -- a stale artefact from someone else's build landing in a brand
|
|
1921
|
+
#: new project. `bwdt/` carries no explanation anywhere in alp-sdk, but it sits
|
|
1922
|
+
#: inside that `# Build directories` block, so the same authority covers it.
|
|
1923
|
+
#:
|
|
1924
|
+
#: Pruning by a `.gitignore`d NAME can only ever drop artefact, never source: a
|
|
1925
|
+
#: name alp-sdk declares untracked cannot BE tracked example content there, and
|
|
1926
|
+
#: `--from-example` reads an alp-sdk checkout by construction
|
|
1927
|
+
#: (`init_cmd._plan_from_example` resolves under `<sdk>/examples`). Measured
|
|
1928
|
+
#: against alp-sdk `origin/dev` 7d58ef32: ZERO tracked files anywhere in that
|
|
1929
|
+
#: repo lie under an `out/` or `bwdt/` path segment. That invariant is what
|
|
1930
|
+
#: makes ADDING a declared pattern safe and INVENTING one unsafe -- an earlier
|
|
1931
|
+
#: cut also pruned `build-`, in no pattern there, which would have silently
|
|
1932
|
+
#: dropped a hand-written `build-utils/` from a customer's new project.
|
|
1933
|
+
#:
|
|
1934
|
+
#: FILE patterns are deliberately out of scope: `.gitignore:55` also declares
|
|
1935
|
+
#: `*.out`, but this mechanism prunes DIRECTORY names off `os.walk`'s
|
|
1936
|
+
#: `dirnames`, and a file-level filter is a different change with a different
|
|
1937
|
+
#: blast radius. No shipped example carries one.
|
|
1938
|
+
#:
|
|
1939
|
+
#: `tests/core/test_scaffold.py::test_the_prune_list_still_covers_alp_sdk_s_own_
|
|
1940
|
+
#: build_directory_gitignore_blocks` re-reads BOTH source blocks out of a bound
|
|
1941
|
+
#: `ALP_SDK_ROOT` checkout and fails when alp-sdk declares a directory that is
|
|
1942
|
+
#: neither pruned here nor recorded there as deliberately kept -- so the next
|
|
1943
|
+
#: one is caught rather than transcribed short a second time.
|
|
1944
|
+
#:
|
|
1945
|
+
#: DELIBERATE DIVERGENCE from the v0.4.1 Rust oracle, which HAD the same missing
|
|
1946
|
+
#: exclusion (`crates/tan-core/src/wizard/filesystem.rs::collect_example_files`,
|
|
1947
|
+
#: deleted along with the rest of `crates/` by tan-cli#269) and failed
|
|
1948
|
+
#: identically -- the oracle was a fixed point for BEHAVIOUR THAT IS RIGHT, and
|
|
1949
|
+
#: copying a build tree into a new project is not. No parity capture pins
|
|
1950
|
+
#: `--from-example` content (`tests/fixtures/oracle_captures/
|
|
1951
|
+
#: PARITY-COVERAGE.txt` covers `scaffold` refusals only), so nothing frozen
|
|
1952
|
+
#: moves for this.
|
|
1953
|
+
#:
|
|
1954
|
+
#: Three more references to the now-deleted `crates/` tree survive in this file
|
|
1955
|
+
#: (lines 5, 933 and 1048) as history of where this module was ported from;
|
|
1956
|
+
#: they are pre-existing and repo-wide, and are left for a sweep of their own.
|
|
1957
|
+
_EXAMPLE_BUILD_OUTPUT_DIRS = ("build", "out", "bwdt", "twister-out")
|
|
1958
|
+
_EXAMPLE_BUILD_OUTPUT_PREFIXES = ("build_", "cmake-build-", "twister-out.")
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
def _is_build_output_dir(name: str) -> bool:
|
|
1962
|
+
"""Whether a directory NAME under an example is untracked build output.
|
|
1963
|
+
|
|
1964
|
+
Exact-match names and `startswith` prefixes are kept apart on purpose: the
|
|
1965
|
+
`.gitignore` patterns behind the first group carry no `*`, so `out/` must
|
|
1966
|
+
NOT match `outputs/` or `outbox/` and `build/` must not match
|
|
1967
|
+
`build-utils/`. Only the three patterns that really are globs
|
|
1968
|
+
(`build_*/`, `cmake-build-*/`, `twister-out.*/`) get prefix treatment.
|
|
1969
|
+
"""
|
|
1970
|
+
return name in _EXAMPLE_BUILD_OUTPUT_DIRS or name.startswith(
|
|
1971
|
+
_EXAMPLE_BUILD_OUTPUT_PREFIXES
|
|
1972
|
+
)
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def _example_source_files(source_dir: Path) -> list[Path]:
|
|
1976
|
+
"""Every regular file under `source_dir` that is example SOURCE: build-output
|
|
1977
|
+
directories pruned, symlinks skipped on BOTH the file and the directory side.
|
|
1978
|
+
|
|
1979
|
+
`os.walk`, not `Path.rglob("*")`, for two reasons the walk needs and the
|
|
1980
|
+
glob cannot give: `dirnames` is mutable, which is what makes pruning a
|
|
1981
|
+
build tree cost nothing instead of stat-ing all 607 files inside it; and
|
|
1982
|
+
`onerror` surfaces a directory that could not be listed instead of
|
|
1983
|
+
`rglob`'s silent skip (`_vendored_files` needs the same guarantee -- see
|
|
1984
|
+
tan-cli#494 defect 3).
|
|
1985
|
+
|
|
1986
|
+
Symlinks are skipped, matching the oracle's `DirEntry::file_type()`, whose
|
|
1987
|
+
own doc comment says "Symlinks and other non-regular entries are skipped":
|
|
1988
|
+
`Path.is_file()` FOLLOWS a symlinked file, so a link pointing outside the
|
|
1989
|
+
example would inline that outside file's content into the customer's new
|
|
1990
|
+
project. `followlinks` stays at its `False` default for the directory side.
|
|
1991
|
+
"""
|
|
1992
|
+
|
|
1993
|
+
def _raise(err: OSError) -> None:
|
|
1994
|
+
raise err
|
|
1995
|
+
|
|
1996
|
+
found: list[Path] = []
|
|
1997
|
+
for dirpath, dirnames, filenames in os.walk(source_dir, onerror=_raise):
|
|
1998
|
+
dirnames[:] = [d for d in dirnames if not _is_build_output_dir(d)]
|
|
1999
|
+
here = Path(dirpath)
|
|
2000
|
+
for name in filenames:
|
|
2001
|
+
candidate = here / name
|
|
2002
|
+
# `is_symlink()` FIRST: a dangling link is not `is_file()` either,
|
|
2003
|
+
# and the two must not be distinguishable here.
|
|
2004
|
+
if candidate.is_symlink() or not candidate.is_file():
|
|
2005
|
+
continue
|
|
2006
|
+
found.append(candidate)
|
|
2007
|
+
return sorted(found, key=lambda p: p.relative_to(source_dir).as_posix())
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def read_example_tree(source_dir: Path) -> list[PlannedFile]:
|
|
2011
|
+
"""Read every regular file under `source_dir` verbatim as UTF-8 text, paths
|
|
2012
|
+
forward-slash normalised and sorted by that relative path (NOT by `Path` --
|
|
2013
|
+
see `_vendored_files` for why that ordering is platform-dependent).
|
|
2014
|
+
|
|
2015
|
+
Build output is NOT example source and never reaches the new project --
|
|
2016
|
+
see `_example_source_files`, which also does the sorting and the
|
|
2017
|
+
symlink/unlistable-directory handling.
|
|
2018
|
+
|
|
2019
|
+
UTF-8 only, like the Rust: a binary-carrying example surfaces as an
|
|
2020
|
+
`ExampleReadError` instead of being copied corrupt. All shipped examples are
|
|
2021
|
+
text today -- and with build output pruned, that is true of a built-in-place
|
|
2022
|
+
checkout too.
|
|
2023
|
+
|
|
2024
|
+
tan-cli#1116 round 2: NOT an `is_dir()` pre-flight -- a first pass here
|
|
2025
|
+
guarded `source_dir.is_dir()` with `except OSError`, reasoning that
|
|
2026
|
+
`Path.is_dir()` swallows only `ENOENT`/`ENOTDIR`/`EBADF`/`ELOOP`
|
|
2027
|
+
(`pathlib`'s own `_IGNORED_ERRNOS`), not `EACCES`. That RAISING
|
|
2028
|
+
BEHAVIOUR held on 3.12.3 AND 3.13.15 (`is_dir()` on a permission-denied
|
|
2029
|
+
ancestor still raises `PermissionError`, `errno=13`, on both) and
|
|
2030
|
+
stopped holding only in 3.14.7, where `is_dir()` swallows EVERY
|
|
2031
|
+
`OSError` -- `EACCES` included -- and returns `False`; the
|
|
2032
|
+
`_IGNORED_ERRNOS` CONSTANT the reasoning named is a release earlier
|
|
2033
|
+
than that (gone from `pathlib` since 3.13, `AttributeError` probing for
|
|
2034
|
+
it there), so a guard keyed on either "the constant still exists" or
|
|
2035
|
+
"I measured the raise on the interpreters I have" is wrong on some
|
|
2036
|
+
supported interpreter -- the boundary is behavioural, on 3.14 alone,
|
|
2037
|
+
and does not track the constant's own removal a release earlier. So the
|
|
2038
|
+
guarded pre-flight was version-dependent and silently dead only on
|
|
2039
|
+
3.14.7 (measured: seam1's CI leg runs CPython 3.14.7 exactly). On that
|
|
2040
|
+
interpreter a permission-denied parent fell through to
|
|
2041
|
+
`is_source_dir is False`, which this function itself raises as
|
|
2042
|
+
`not_found=True` -- the "user's typo" arm -- the exact wrong answer
|
|
2043
|
+
this docstring's own words rule out two paragraphs up. The ELOOP shape
|
|
2044
|
+
is a release EARLIER still: `is_dir()` on a symlink loop already
|
|
2045
|
+
returns `False` (never raises) on 3.12.3 and 3.13.15 alike, so
|
|
2046
|
+
`not_found` was wrong for THAT shape on every interpreter this function
|
|
2047
|
+
ships on, not merely on 3.14 -- the version-independent rewrite below
|
|
2048
|
+
fixes it everywhere at once rather than chasing a second boundary.
|
|
2049
|
+
|
|
2050
|
+
The fix: no stat call at all. `_example_source_files`'s `os.walk(...,
|
|
2051
|
+
onerror=_raise)` already re-raises whatever `os.scandir` cannot get past
|
|
2052
|
+
-- `FileNotFoundError` for a genuinely absent path, `NotADirectoryError`
|
|
2053
|
+
for `source_dir` itself (or an ancestor) being a plain file,
|
|
2054
|
+
`PermissionError`/other `OSError` (ELOOP included) for everything else
|
|
2055
|
+
-- so calling it directly and classifying by the REAL exception is
|
|
2056
|
+
version-independent by construction, the same shape `validate_document`
|
|
2057
|
+
(`metadata_schema.py`) and `_resolve_hw_rev` (`perf_apply.py`) now use.
|
|
2058
|
+
`FileNotFoundError`/`NotADirectoryError` keep the ORIGINAL "not a
|
|
2059
|
+
directory" wording and `not_found=True` -- both are "this is not a real
|
|
2060
|
+
example directory," a typo-shaped answer, exactly what the pre-existing
|
|
2061
|
+
`is_dir() is False` branch covered for those two causes. Every other
|
|
2062
|
+
failure (a permission-denied ancestor, an ELOOP symlink loop, a
|
|
2063
|
+
non-UTF-8 file once inside a directory that DID open) is `not_found=
|
|
2064
|
+
False`: a directory this cannot even read is a runtime failure, never
|
|
2065
|
+
the customer's typo.
|
|
2066
|
+
"""
|
|
2067
|
+
files: list[PlannedFile] = []
|
|
2068
|
+
try:
|
|
2069
|
+
for path in _example_source_files(source_dir):
|
|
2070
|
+
files.append(
|
|
2071
|
+
PlannedFile(path.relative_to(source_dir).as_posix(), _read_verbatim(path))
|
|
2072
|
+
)
|
|
2073
|
+
except (FileNotFoundError, NotADirectoryError) as err:
|
|
2074
|
+
# tan-cli#1116 review round 3: str(err), not a hardcoded
|
|
2075
|
+
# f"'{source_dir}' is not a directory." -- this handler catches
|
|
2076
|
+
# whatever `os.walk`'s `onerror=_raise` re-raises from ANYWHERE in
|
|
2077
|
+
# the tree it is walking, not only from `source_dir` itself. A
|
|
2078
|
+
# NESTED directory that changes shape between being listed (as a
|
|
2079
|
+
# directory, into `dirnames`) and being descended into (a genuine
|
|
2080
|
+
# TOCTOU window `os.walk` does not close) raises this exact pair of
|
|
2081
|
+
# exceptions from THAT path, not `source_dir` -- a hardcoded
|
|
2082
|
+
# message blaming `source_dir` would misattribute the failure to
|
|
2083
|
+
# the wrong directory. `str(err)` uses Python's own message, which
|
|
2084
|
+
# names the REAL path that failed (`FileNotFoundError`'s and
|
|
2085
|
+
# `NotADirectoryError`'s own `__str__` already include it), so this
|
|
2086
|
+
# is accurate whichever path in the tree actually broke.
|
|
2087
|
+
raise ExampleReadError(str(err), not_found=True) from err
|
|
2088
|
+
except (OSError, UnicodeDecodeError) as err:
|
|
2089
|
+
# UnicodeDecodeError is a ValueError, not an OSError -- catching only
|
|
2090
|
+
# OSError would let a binary file in an example escape as a traceback.
|
|
2091
|
+
raise ExampleReadError(str(err), not_found=False) from err
|
|
2092
|
+
return files
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
# ---------------------------------------------------------------------------
|
|
2096
|
+
# Disk: diff, then write
|
|
2097
|
+
# ---------------------------------------------------------------------------
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
def _existing_content(path: Path) -> str | None:
|
|
2101
|
+
"""The file's content, or None when it is absent/unreadable -- an unreadable
|
|
2102
|
+
file compares unequal to anything, so it is planned as an `update`."""
|
|
2103
|
+
try:
|
|
2104
|
+
return _read_verbatim(path)
|
|
2105
|
+
except (OSError, UnicodeDecodeError):
|
|
2106
|
+
return None
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def collect_file_changes(project_root: Path, files: list[PlannedFile]) -> list[FileChange]:
|
|
2110
|
+
"""Classify each planned file as `new` / `update` / `unchanged` relative to
|
|
2111
|
+
`project_root`. Read-only: `--preview` stops after this."""
|
|
2112
|
+
changes = []
|
|
2113
|
+
for planned in files:
|
|
2114
|
+
path = project_root / planned.relative_path
|
|
2115
|
+
if not path.exists():
|
|
2116
|
+
kind = "new"
|
|
2117
|
+
else:
|
|
2118
|
+
existing = _existing_content(path)
|
|
2119
|
+
kind = "unchanged" if existing == planned.content else "update"
|
|
2120
|
+
changes.append(FileChange(planned.relative_path, kind))
|
|
2121
|
+
return changes
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def write_files(project_root: Path, files: list[PlannedFile]) -> WriteResult:
|
|
2125
|
+
"""Write every planned file under `project_root`, creating parents as
|
|
2126
|
+
needed. A file whose content already matches is skipped and counted
|
|
2127
|
+
`unchanged`.
|
|
2128
|
+
|
|
2129
|
+
Every planned target is confined to `project_root` -- after symlink
|
|
2130
|
+
resolution -- BEFORE anything is written: a pre-existing symlinked (or
|
|
2131
|
+
junctioned) parent directory, or a symlinked existing leaf, that would
|
|
2132
|
+
carry a write outside the project refuses the WHOLE run rather than
|
|
2133
|
+
writing through it and reporting the in-project logical path as written
|
|
2134
|
+
(tan-cli#325). A symlinked project ROOT itself is unaffected -- see
|
|
2135
|
+
`resolve_confined`.
|
|
2136
|
+
|
|
2137
|
+
Raises `ScaffoldWriteError` carrying the partial result on the first
|
|
2138
|
+
failure -- an unwritable destination, a path component that is a file
|
|
2139
|
+
rather than a directory, a read-only tree, a full disk, or the escape
|
|
2140
|
+
guard above. Every one of those is the user's environment, not a tan bug,
|
|
2141
|
+
so none may escape as a traceback.
|
|
2142
|
+
"""
|
|
2143
|
+
targets: list[tuple[PlannedFile, Path]] = []
|
|
2144
|
+
for planned in files:
|
|
2145
|
+
try:
|
|
2146
|
+
target = resolve_confined(project_root, project_root / planned.relative_path)
|
|
2147
|
+
except (PathEscapeError, OSError, ValueError) as err:
|
|
2148
|
+
raise ScaffoldWriteError(
|
|
2149
|
+
f"refusing to write '{planned.relative_path}': {err}",
|
|
2150
|
+
WriteResult([], []),
|
|
2151
|
+
) from err
|
|
2152
|
+
targets.append((planned, target))
|
|
2153
|
+
|
|
2154
|
+
written: list[str] = []
|
|
2155
|
+
unchanged: list[str] = []
|
|
2156
|
+
for planned, target in targets:
|
|
2157
|
+
if target.exists() and _existing_content(target) == planned.content:
|
|
2158
|
+
unchanged.append(planned.relative_path)
|
|
2159
|
+
continue
|
|
2160
|
+
try:
|
|
2161
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2162
|
+
_write_verbatim(target, planned.content)
|
|
2163
|
+
except OSError as err:
|
|
2164
|
+
raise ScaffoldWriteError(str(err), WriteResult(written, unchanged)) from err
|
|
2165
|
+
written.append(planned.relative_path)
|
|
2166
|
+
return WriteResult(written, unchanged)
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def scaffold_tree_preview(files: list[PlannedFile]) -> str:
|
|
2170
|
+
"""The planned files as an ASCII `tree`-style listing, paths sorted, last
|
|
2171
|
+
entry marked ``\\`-- ``. Text mode only -- JSON callers read
|
|
2172
|
+
`data.fileChanges`."""
|
|
2173
|
+
paths = sorted(f.relative_path for f in files)
|
|
2174
|
+
lines = ["."]
|
|
2175
|
+
for i, path in enumerate(paths):
|
|
2176
|
+
lines.append(f"{'`--' if i + 1 == len(paths) else '|--'} {path}")
|
|
2177
|
+
return "\n".join(lines) + "\n"
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
def sdk_pointer_json(sdk_path: str) -> str:
|
|
2181
|
+
"""The `.alp/sdk-path` pointer file's contents, so a new project is
|
|
2182
|
+
reproducible without a separate `tan sdk switch`. Shape and two-space indent
|
|
2183
|
+
from `commands::sdk::write_sdk_pointer`; the path is recorded exactly as the
|
|
2184
|
+
caller resolved it.
|
|
2185
|
+
|
|
2186
|
+
`SOURCE_DATE_EPOCH` wins over the clock, so a captured pointer is
|
|
2187
|
+
reproducible -- through `tan.core.timestamp`, which NEVER raises. This runs
|
|
2188
|
+
AFTER the customer's project files already landed, and an out-of-range epoch
|
|
2189
|
+
(the MILLISECONDS case) used to kill `tan init` with a traceback here.
|
|
2190
|
+
"""
|
|
2191
|
+
import json # noqa: PLC0415 -- one call site, not worth a module-level import
|
|
2192
|
+
|
|
2193
|
+
return (
|
|
2194
|
+
json.dumps({"sdkPath": sdk_path, "updatedAt": generated_at_iso()}, indent=2)
|
|
2195
|
+
+ "\n"
|
|
2196
|
+
)
|
|
2197
|
+
|
|
2198
|
+
|
|
2199
|
+
def posix(path: Path) -> str:
|
|
2200
|
+
"""`path` as a string with forward slashes -- what the envelope's path-shaped
|
|
2201
|
+
fields carry (the conformance harness normalises `\\` -> `/` on those keys,
|
|
2202
|
+
and a golden is authored in the normalised form)."""
|
|
2203
|
+
return str(path).replace(os.sep, "/")
|