tan-cli 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`tan clean` -- remove this project's build root, the orchestrator's state
|
|
3
|
+
cache, and any out-of-tree slice build dir the system manifest names.
|
|
4
|
+
|
|
5
|
+
Port of `crates/tan-cli/src/commands/clean.rs` plus the pure planning half it
|
|
6
|
+
delegates to (`tan_core::clean` + `tan_core::path_guard::is_unsafe_removal_target`).
|
|
7
|
+
Ported into ONE file deliberately: the pure part is ~50 lines with exactly one
|
|
8
|
+
consumer, and a separate `tan/core/clean.py` holding a four-line guard would be
|
|
9
|
+
an abstraction with a single call site. Every function names the Rust item it
|
|
10
|
+
mirrors.
|
|
11
|
+
|
|
12
|
+
**This command DELETES, so the safety rules are stricter than anywhere else in
|
|
13
|
+
the port:**
|
|
14
|
+
|
|
15
|
+
* Every removal candidate is screened by [`is_unsafe_removal_target`] -- the
|
|
16
|
+
build root included -- BEFORE any filesystem call. A candidate that IS the
|
|
17
|
+
project root, an ancestor of it, or a bare filesystem/drive/UNC root is
|
|
18
|
+
REFUSED and reported, never silently dropped and never removed. That covers
|
|
19
|
+
the `rm -rf $UNSET_VAR` shape: `--build-root ""` is refused outright, and a
|
|
20
|
+
manifest `build_dir` that resolves onto the project root (or the
|
|
21
|
+
`--build-root` tree), above it, or onto a directory holding a `board.yaml` is
|
|
22
|
+
refused, and so is one that lies inside ANOTHER project's tree (a copied or
|
|
23
|
+
renamed project's manifest still names the original's absolute slice dirs,
|
|
24
|
+
tan-cli#1516; an enclosing parent project counts as another project).
|
|
25
|
+
**Deliberate oracle divergence (tan-cli#1516):** v0.4.1 removes such a dir.
|
|
26
|
+
Do not "fix" this back in a parity sweep.
|
|
27
|
+
`--build-root X` names a project TREE and clean removes `<X>/build`
|
|
28
|
+
(tan-cli#1482), so `.` and `..` are valid trees, not refusals.
|
|
29
|
+
* **Deliberate oracle divergence (tan-cli#1482):** the v0.4.1 binary removes X
|
|
30
|
+
itself, joined onto the project root. tan cleans `<X>/build`, with X anchored
|
|
31
|
+
at the current directory, so the same X passed to `build` and `clean` never
|
|
32
|
+
touches X's sources. Do not "fix" this back in a parity sweep.
|
|
33
|
+
* The screen is NOT "must stay under the build root", and must not become that.
|
|
34
|
+
`confine_to_build_root` -- the hardened containment guard this module DOES
|
|
35
|
+
reuse, see [`_subsumed_by_build_root`] -- answers a different question, and
|
|
36
|
+
two of the three target classes the oracle removes are legitimately OUTSIDE
|
|
37
|
+
the build root: the app-root `.alp-build-state.json`, and an out-of-tree slice
|
|
38
|
+
`build_dir` such as a Yocto tmp dir. (The v0.4.1 binary removed
|
|
39
|
+
`../outside` for `--build-root ../outside`; tan now removes
|
|
40
|
+
`../outside/build`, see the divergence note above.)
|
|
41
|
+
Applying containment to every target would refuse two supported cases and
|
|
42
|
+
diverge from the oracle on a destructive command. The rule is "not
|
|
43
|
+
catastrophic", not "not outside" (`path_guard.rs:100-103`).
|
|
44
|
+
* A symlink or junction is never followed OUT of the tree. `shutil.rmtree`
|
|
45
|
+
refuses a link outright, and [`_remove_dir`] removes the LINK itself instead
|
|
46
|
+
-- so a `build/` junctioned at another directory unlinks the junction and
|
|
47
|
+
leaves its target intact (verified against the Rust binary, whose
|
|
48
|
+
`remove_dir_all` does the same on Windows).
|
|
49
|
+
* `--dry-run` reaches no removal call at all: [`_classify`] returns a
|
|
50
|
+
`would-remove` disposition and the removal arms are never entered.
|
|
51
|
+
* The build root is never guessed. An unresolvable SDK is exit 1
|
|
52
|
+
(`clean.sdk-root-not-found`) and an unsafe build root is exit 1
|
|
53
|
+
(`clean.unsafe-build-root`), rather than a best-effort removal of something
|
|
54
|
+
nearby.
|
|
55
|
+
|
|
56
|
+
**Nothing here learns a hardware fact and nothing shells the SDK.** The
|
|
57
|
+
checkout is probed for its loader marker (`scripts/alp_project.py`, I-31) and
|
|
58
|
+
otherwise untouched: removing a build directory needs no SDK, and invoking one
|
|
59
|
+
would give `clean` a dependency it deliberately does not have (I-32, port-spec
|
|
60
|
+
anti-pattern #22). The only project input beyond the arguments is
|
|
61
|
+
`<build_root>/system-manifest.yaml` (build_root being `<X>/build` or
|
|
62
|
+
`<project_root>/build`), which this project's own build wrote.
|
|
63
|
+
|
|
64
|
+
Every failure path emits a coded envelope. An escaping traceback puts nothing
|
|
65
|
+
parseable on stdout and the extension then renders an empty panel with no
|
|
66
|
+
error, so [`clean`]'s outer guard converts any unexpected exception into
|
|
67
|
+
`clean.internal-failure` at exit 5. Its recovery path builds the envelope from
|
|
68
|
+
constants only -- never a helper that can itself throw -- because a helper
|
|
69
|
+
called from the recovery path is how a single fault became a DOUBLE fault
|
|
70
|
+
elsewhere in this port.
|
|
71
|
+
|
|
72
|
+
**The manifest sweep's YAML parser is a DECLARED dependency.** `pyyaml>=6` is a
|
|
73
|
+
base entry in `python/pyproject.toml` `[project].dependencies` -- its own
|
|
74
|
+
comment there reads "`pyyaml` is load-bearing, not optional" -- and
|
|
75
|
+
`scripts/build_binary.sh` builds the frozen binary with
|
|
76
|
+
`pip install -e ".[monitor]" "pyinstaller>=6.10"`, so PyYAML ships.
|
|
77
|
+
|
|
78
|
+
This block previously recorded a KNOWN GAP claiming the opposite: that tan
|
|
79
|
+
declared no YAML parser, that `build_binary.sh` installed
|
|
80
|
+
`pip install typer rich pyinstaller`, and that the SHIPPED `tan clean`
|
|
81
|
+
therefore took the no-PyYAML arm and warned on every built project. All three
|
|
82
|
+
were false (tan-cli#574) -- the quoted recipe was real historically, `pyyaml`
|
|
83
|
+
entered `pyproject.toml` afterwards, and this docstring was never updated. It
|
|
84
|
+
sent a maintainer to spend an artefact-size budget closing a gap already closed.
|
|
85
|
+
|
|
86
|
+
[`parse_manifest_slices`] keeps its absent-parser arm anyway, because a
|
|
87
|
+
`--no-deps` install can still lack PyYAML: that arm REPORTS rather than
|
|
88
|
+
swallows, and stays a warning since `clean` never fails over a manifest. There
|
|
89
|
+
is deliberately NO hand-rolled fallback scanner here -- a mis-parse would name
|
|
90
|
+
a PATH handed to a recursive removal.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
from __future__ import annotations
|
|
94
|
+
|
|
95
|
+
import os
|
|
96
|
+
import sys
|
|
97
|
+
from dataclasses import dataclass, field
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
from typing import Any
|
|
100
|
+
|
|
101
|
+
import typer
|
|
102
|
+
|
|
103
|
+
from tan.commands.build.materialise import MaterialiseError, confine_to_build_root
|
|
104
|
+
from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk
|
|
105
|
+
from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
|
|
106
|
+
from tan.core.dir_removal import (
|
|
107
|
+
is_link,
|
|
108
|
+
os_error_text,
|
|
109
|
+
remove_dir as _remove_dir,
|
|
110
|
+
)
|
|
111
|
+
from tan.core.dir_removal import (
|
|
112
|
+
_retry_after_clearing_readonly, # noqa: F401 -- re-exported for tests, see the removal block below
|
|
113
|
+
)
|
|
114
|
+
from tan.core.sdk_discovery import (
|
|
115
|
+
global_default_foreign_project_issue,
|
|
116
|
+
project_pin_issue,
|
|
117
|
+
resolve_sdk_root_ladder,
|
|
118
|
+
sdk_search_summary,
|
|
119
|
+
)
|
|
120
|
+
from tan.core.shapes import SDK_MARKER
|
|
121
|
+
from tan.core.system_manifest import find_manifest
|
|
122
|
+
from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
|
|
123
|
+
from tan.exit_codes import ExitCode
|
|
124
|
+
from tan.output_format import FORMAT_HELP, OutputFormat
|
|
125
|
+
|
|
126
|
+
#: The orchestrator state cache removed alongside the build root -- verbatim
|
|
127
|
+
#: `alp_clean.py`'s `targets[1]`. The orchestrator actually writes its cache at
|
|
128
|
+
#: `<build_root>/.alp-build-state.json`, already subsumed by the recursive
|
|
129
|
+
#: build-root removal; this app-root path is the faithful, usually-absent target
|
|
130
|
+
#: the Python cleaner kept. Preserved, not "fixed" (`tan_core::clean` docs).
|
|
131
|
+
STATE_FILE = ".alp-build-state.json"
|
|
132
|
+
|
|
133
|
+
#: The manifest this command sweeps for out-of-tree slice build dirs, relative
|
|
134
|
+
#: to the resolved build root.
|
|
135
|
+
MANIFEST_NAME = "system-manifest.yaml"
|
|
136
|
+
|
|
137
|
+
#: The system-manifest schema major consumed here. A different value is a
|
|
138
|
+
#: warning and the manifest is IGNORED -- never read as if it were v1
|
|
139
|
+
#: (`SYSTEM_MANIFEST_SCHEMA_VERSION`).
|
|
140
|
+
MANIFEST_SCHEMA_VERSION = 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Pure path shape -- `tan_core::path_guard`
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _rust_join(base: str, rel: str) -> str:
|
|
149
|
+
"""`PathBuf::push` semantics, which `os.path.join` does not have on Windows.
|
|
150
|
+
|
|
151
|
+
Rust replaces the base OUTRIGHT when the right-hand side carries its own
|
|
152
|
+
prefix -- a drive (`C:foo`), a UNC share (`\\\\server\\share\\x`) or the
|
|
153
|
+
device namespace (`\\\\?\\C:\\x`). `ntpath.join` agrees for a DIFFERENT
|
|
154
|
+
drive but treats a drive-relative path on the SAME drive as relative to the
|
|
155
|
+
accumulated path, so `join("C:/proj", "C:foo")` yields `C:/proj\\foo` where
|
|
156
|
+
Rust yields `C:foo`. `C:foo` is one of the shapes a `--build-root` guard has
|
|
157
|
+
to get right, so the divergence is closed here rather than tolerated.
|
|
158
|
+
|
|
159
|
+
Everything else defers to `os.path.join`, which already matches Rust for the
|
|
160
|
+
rooted-but-prefixless case (`\\rooted` keeps the base's drive) and for the
|
|
161
|
+
empty and `..` cases (verified against the Rust binary). On POSIX
|
|
162
|
+
`splitdrive` always reports no drive, so this is `os.path.join` verbatim.
|
|
163
|
+
"""
|
|
164
|
+
if os.path.splitdrive(rel)[0]:
|
|
165
|
+
return rel
|
|
166
|
+
return os.path.join(base, rel)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _cwd_or_dot() -> str:
|
|
170
|
+
try:
|
|
171
|
+
return os.getcwd()
|
|
172
|
+
except OSError:
|
|
173
|
+
return "."
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _normalize(path: str) -> str:
|
|
177
|
+
"""Lexically collapse `.`/`..` without touching the filesystem --
|
|
178
|
+
`path_guard::normalize`.
|
|
179
|
+
|
|
180
|
+
`os.path.normpath`, so no symlink is resolved and a path that does not exist
|
|
181
|
+
still normalizes. Used for COMPARISON only; a reported path keeps whatever
|
|
182
|
+
spelling the oracle would report.
|
|
183
|
+
|
|
184
|
+
One known divergence, unreachable here: Rust's `normalize` pops past the
|
|
185
|
+
start, so a relative `..` collapses to the empty path where `normpath`
|
|
186
|
+
keeps `..`. Every input below is already absolute (the project root is
|
|
187
|
+
cwd-anchored, the build root is `<tree>/build` with the tree cwd-anchored, and
|
|
188
|
+
slice dirs are joined onto the clean root), so the
|
|
189
|
+
difference cannot be reached.
|
|
190
|
+
"""
|
|
191
|
+
return os.path.normpath(path)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _has_normal_component(normalized: str) -> bool:
|
|
195
|
+
"""Whether the path names anything at all beyond a root/prefix -- Rust's
|
|
196
|
+
`components().any(Component::Normal)`.
|
|
197
|
+
|
|
198
|
+
False for `/`, `C:\\`, `C:` and a bare UNC share `\\\\server\\share`: each is
|
|
199
|
+
a root whose recursive removal takes out far more than a build tree.
|
|
200
|
+
"""
|
|
201
|
+
return os.path.splitdrive(normalized)[1].strip("\\/") != ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _is_under(base: str, path: str) -> bool:
|
|
205
|
+
"""Component-wise containment on normalized paths -- Rust's
|
|
206
|
+
`Path::starts_with`, NOT a string prefix test.
|
|
207
|
+
|
|
208
|
+
`/p/build` contains `/p/build/x` and itself, but NOT the sibling
|
|
209
|
+
`/p/build2` -- which a plain `str.startswith` would wrongly accept, and
|
|
210
|
+
which decides whether a slice dir is treated as a separate removal target.
|
|
211
|
+
Case-sensitive, matching Rust (which folds case on the Windows drive prefix
|
|
212
|
+
only, and both sides here come from the same join).
|
|
213
|
+
"""
|
|
214
|
+
base_n, path_n = _normalize(base), _normalize(path)
|
|
215
|
+
if base_n == path_n:
|
|
216
|
+
return True
|
|
217
|
+
return path_n.startswith(base_n.rstrip("\\/") + os.sep)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _resolves_onto_project_root(target: str, project_root: str) -> bool:
|
|
221
|
+
"""The lexical ancestor test of [`is_unsafe_removal_target`], re-run on
|
|
222
|
+
RESOLVED copies -- an intentional divergence from the oracle.
|
|
223
|
+
|
|
224
|
+
`path_guard::is_unsafe_removal_target` (and this port before it) compares
|
|
225
|
+
`normpath` spellings only, so both of its arms are defeated by naming the
|
|
226
|
+
project root through a symlinked PARENT component: with
|
|
227
|
+
`<base>/wlink -> <base>/work`, `--build-root <base>/wlink/proj` is not
|
|
228
|
+
lexically `<base>/work/proj`, `_remove_dir` lstats only the final component
|
|
229
|
+
(a real directory, so the link arm never fires), and `shutil.rmtree` deletes
|
|
230
|
+
the user's sources at exit 0 with `issues: []`. Measured: the frozen oracle
|
|
231
|
+
destroys the identical tree, so this is an inherited design gap rather than
|
|
232
|
+
a port regression -- and a destructive one is worth diverging on. The test
|
|
233
|
+
is ADDITIVE, so it can only ever refuse more, never remove more.
|
|
234
|
+
|
|
235
|
+
A link spelled AS the target is deliberately NOT resolved: [`_remove_dir`]
|
|
236
|
+
unlinks such a target ITSELF and never recurses through it, so removing it
|
|
237
|
+
costs exactly the link (verified against the Rust binary). Resolving here
|
|
238
|
+
would refuse that harmless case.
|
|
239
|
+
|
|
240
|
+
Resolution is for the COMPARISON only. `data.buildRoot` and every reported
|
|
241
|
+
`targets[].path` keep the spelling the caller typed, which
|
|
242
|
+
`test_normalize_is_lexical_and_never_resolves_a_symlink` and the
|
|
243
|
+
`clean` oracle-parity fixture both pin.
|
|
244
|
+
"""
|
|
245
|
+
if is_link(target):
|
|
246
|
+
return False
|
|
247
|
+
resolved = os.path.realpath(target)
|
|
248
|
+
if not _has_normal_component(_normalize(resolved)):
|
|
249
|
+
return True
|
|
250
|
+
return _is_under(resolved, os.path.realpath(project_root))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def is_unsafe_removal_target(project_root: str, target: str) -> bool:
|
|
254
|
+
"""True when recursively removing `target` would take out far more than a
|
|
255
|
+
build tree, and the caller must refuse -- `path_guard::is_unsafe_removal_target`.
|
|
256
|
+
|
|
257
|
+
Rejects a filesystem/drive/UNC root (no `Normal` component at all), and the
|
|
258
|
+
project root itself or any ancestor of it -- named lexically OR through a
|
|
259
|
+
symlinked parent ([`_resolves_onto_project_root`]). Deliberately does NOT
|
|
260
|
+
require containment under the project root: an out-of-tree slice build dir
|
|
261
|
+
is a supported clean target (see the module docstring).
|
|
262
|
+
"""
|
|
263
|
+
target_n = _normalize(target)
|
|
264
|
+
if not _has_normal_component(target_n):
|
|
265
|
+
return True
|
|
266
|
+
# True when the target IS the project root or an ancestor of it -- both
|
|
267
|
+
# would delete the user's sources.
|
|
268
|
+
if _is_under(target_n, project_root):
|
|
269
|
+
return True
|
|
270
|
+
return _resolves_onto_project_root(target, project_root)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Pure removal planning -- `tan_core::clean`
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
#: Filesystem-kind + disposition pairs, keyed by what a probe found.
|
|
278
|
+
_DIR_ACTIONS = {False: ("dir", "removed"), True: ("dir", "would-remove")}
|
|
279
|
+
_FILE_ACTIONS = {False: ("file", "removed"), True: ("file", "would-remove")}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class _Rejected:
|
|
284
|
+
"""A candidate refused as too dangerous to remove, with where it came from
|
|
285
|
+
so the message can name the culprit -- `clean::RejectedTarget`."""
|
|
286
|
+
|
|
287
|
+
path: str
|
|
288
|
+
#: `build-root` | `slice` -- the state file is never screened (see
|
|
289
|
+
#: [`plan_clean_targets`]), so it has no rejection message.
|
|
290
|
+
origin: str
|
|
291
|
+
core_id: str = ""
|
|
292
|
+
raw: str = ""
|
|
293
|
+
#: The other project's root when a slice dir sits inside it (tan-cli#1516).
|
|
294
|
+
foreign_project: str = ""
|
|
295
|
+
|
|
296
|
+
def reason(self) -> str:
|
|
297
|
+
"""One-line explanation naming the source, verbatim from
|
|
298
|
+
`RejectedTarget::reason` -- except the foreign-project branch, which is
|
|
299
|
+
tan's own (deliberate oracle divergence, tan-cli#1516: v0.4.1 removes
|
|
300
|
+
that dir). The em dash is the oracle's own character."""
|
|
301
|
+
if self.foreign_project:
|
|
302
|
+
return (
|
|
303
|
+
f"refusing to remove slice '{self.core_id}' build_dir "
|
|
304
|
+
f'"{self.raw}" (resolves to {self.path}) — it lies inside '
|
|
305
|
+
f"another project ({self.foreign_project} holds a board.yaml): "
|
|
306
|
+
"this project was copied or renamed after a build, or the dir "
|
|
307
|
+
"belongs to an enclosing project; rebuild this project or fix "
|
|
308
|
+
"build/system-manifest.yaml"
|
|
309
|
+
)
|
|
310
|
+
if self.origin == "slice":
|
|
311
|
+
return (
|
|
312
|
+
f"refusing to remove slice '{self.core_id}' build_dir "
|
|
313
|
+
f'"{self.raw}" (resolves to {self.path}) \u2014 it is the '
|
|
314
|
+
"project root, an ancestor of it, or a filesystem root; fix "
|
|
315
|
+
"build/system-manifest.yaml"
|
|
316
|
+
)
|
|
317
|
+
return (
|
|
318
|
+
f"refusing to remove build root {self.path} \u2014 it is the "
|
|
319
|
+
"project root, an ancestor of it, or a filesystem root"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass
|
|
324
|
+
class _Plan:
|
|
325
|
+
"""`clean::CleanPlan`: paths cleared for removal (build root first), plus
|
|
326
|
+
everything refused. A non-empty `rejected` means the command must report and
|
|
327
|
+
fail -- never quietly clean less than asked."""
|
|
328
|
+
|
|
329
|
+
targets: list[str] = field(default_factory=list)
|
|
330
|
+
rejected: list[_Rejected] = field(default_factory=list)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _subsumed_by_build_root(build_root: str, resolved: str) -> bool:
|
|
334
|
+
"""Whether a slice `build_dir` is already covered by the recursive build-root
|
|
335
|
+
removal, so it contributes no extra target.
|
|
336
|
+
|
|
337
|
+
Two tests, and EITHER answering "inside" is enough:
|
|
338
|
+
|
|
339
|
+
* the oracle's lexical `clean::is_under`, which is what parity is measured
|
|
340
|
+
against; and
|
|
341
|
+
* [`confine_to_build_root`], the port's hardened containment guard, reused
|
|
342
|
+
here rather than re-derived -- this is the one question in `clean` whose
|
|
343
|
+
semantics really are "is this path confined under the build root". It
|
|
344
|
+
resolves both sides, so it also catches a junction or symlink inside
|
|
345
|
+
`build/` that points out of the tree, and the Windows shapes
|
|
346
|
+
(`C:foo`, `\\x`, UNC, `\\\\?\\`) a lexical test misses.
|
|
347
|
+
|
|
348
|
+
OR, not AND, deliberately: a disagreement can then only make the port treat
|
|
349
|
+
a path as ALREADY COVERED, i.e. remove strictly less than the oracle -- and
|
|
350
|
+
the disagreement only arises when the path genuinely does live inside
|
|
351
|
+
`build_root`, which the recursive removal handles anyway, so the resulting
|
|
352
|
+
disk state is identical. Requiring both to agree would let a resolved-inside
|
|
353
|
+
path become a SEPARATE `shutil.rmtree` call, which is the one direction a
|
|
354
|
+
destructive command must not drift in.
|
|
355
|
+
"""
|
|
356
|
+
if _is_under(build_root, resolved):
|
|
357
|
+
return True
|
|
358
|
+
if not os.path.isabs(resolved):
|
|
359
|
+
# A DRIVE-RELATIVE leftover (`C:rel`, the one shape `_rust_join` cannot
|
|
360
|
+
# make absolute because Rust does not either). It cannot be containment-
|
|
361
|
+
# tested against an unrelated base without inventing a meaning for it:
|
|
362
|
+
# `Path("C:/proj/build") / "C:rel"` re-reads it as relative to the base
|
|
363
|
+
# and answers "inside", while Rust reports it as its own target resolved
|
|
364
|
+
# against drive C:'s current directory. Measured -- a manifest
|
|
365
|
+
# `build_dir: "C:rel"` made the oracle list an `absent` target the port
|
|
366
|
+
# silently dropped. The lexical answer above IS the oracle's answer here,
|
|
367
|
+
# and the candidate is still screened by `is_unsafe_removal_target`.
|
|
368
|
+
return False
|
|
369
|
+
try:
|
|
370
|
+
confine_to_build_root(Path(build_root), resolved)
|
|
371
|
+
except (MaterialiseError, OSError, ValueError):
|
|
372
|
+
# `MaterialiseError` is the escape verdict; `OSError`/`ValueError` come
|
|
373
|
+
# from `Path.resolve()` on a shape the host rejects outright (a device-
|
|
374
|
+
# namespace path, an over-long name). Either way: not proven inside.
|
|
375
|
+
return False
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _foreign_project_root(path: str, own_roots: tuple[str, ...]) -> str | None:
|
|
380
|
+
"""The nearest ancestor of an out-of-tree slice dir that holds a
|
|
381
|
+
`board.yaml`, when that ancestor is NOT this project -- tan-cli#1516.
|
|
382
|
+
|
|
383
|
+
`tan build` records each slice `build_dir` as an absolute path, so a project
|
|
384
|
+
copied or renamed after a build carries a manifest naming the ORIGINAL
|
|
385
|
+
project's build tree. Out-of-tree slice dirs stay supported (a Yocto tmp dir
|
|
386
|
+
sits under no project at all); one inside someone else's project tree never
|
|
387
|
+
is. The walk stops at this project's own roots (the project root, the
|
|
388
|
+
`--build-root` tree, the `--board-yaml` directory), so a slice dir elsewhere
|
|
389
|
+
in this project is never misread as foreign.
|
|
390
|
+
|
|
391
|
+
Two walks, and EITHER finding a foreign root refuses: the lexical spelling
|
|
392
|
+
(what the message names) and the RESOLVED one, because a symlinked component
|
|
393
|
+
or a `..` through a linked project root reaches a different directory than
|
|
394
|
+
its spelling suggests -- the same reason [`_resolves_onto_project_root`]
|
|
395
|
+
exists. Own roots are matched by identity (`samefile`), not spelling, so a
|
|
396
|
+
case-variant `--project` on a case-insensitive filesystem stays "own".
|
|
397
|
+
"""
|
|
398
|
+
# `abspath` only for the walk: it anchors a drive-relative `C:rel` the way
|
|
399
|
+
# Windows will when the removal runs. The reported path is unchanged.
|
|
400
|
+
starts = (os.path.dirname(_normalize(os.path.abspath(path))),
|
|
401
|
+
os.path.dirname(os.path.realpath(os.path.abspath(path))))
|
|
402
|
+
own_names = {_normalize(r) for r in own_roots} | {os.path.realpath(r) for r in own_roots}
|
|
403
|
+
existing = [r for r in own_roots if os.path.exists(r)]
|
|
404
|
+
|
|
405
|
+
def _is_own(directory: str) -> bool:
|
|
406
|
+
if directory in own_names or os.path.realpath(directory) in own_names:
|
|
407
|
+
return True
|
|
408
|
+
try:
|
|
409
|
+
return any(os.path.samefile(directory, r) for r in existing)
|
|
410
|
+
except OSError:
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
for current in starts:
|
|
414
|
+
while not _is_own(current):
|
|
415
|
+
if os.path.isfile(os.path.join(current, "board.yaml")):
|
|
416
|
+
return current
|
|
417
|
+
parent = os.path.dirname(current)
|
|
418
|
+
if parent == current:
|
|
419
|
+
break
|
|
420
|
+
current = parent
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def plan_clean_targets(
|
|
425
|
+
clean_root: str,
|
|
426
|
+
build_root: str,
|
|
427
|
+
slices: list[dict[str, Any]],
|
|
428
|
+
project_root: str | None = None,
|
|
429
|
+
board_yaml_dir: str | None = None,
|
|
430
|
+
) -> _Plan:
|
|
431
|
+
"""Ordered, de-duplicated removal targets -- `clean::clean_targets`.
|
|
432
|
+
|
|
433
|
+
1. `build_root`, recursively.
|
|
434
|
+
2. `<project_root>/.alp-build-state.json`.
|
|
435
|
+
3. each slice `build_dir` that lies OUTSIDE `build_root` (see
|
|
436
|
+
[`_subsumed_by_build_root`]); a relative value resolves against
|
|
437
|
+
`project_root`, an absolute one is taken as-is.
|
|
438
|
+
|
|
439
|
+
Every candidate except the state file is then screened by
|
|
440
|
+
[`is_unsafe_removal_target`]. The manifest is unvalidated file content and
|
|
441
|
+
`build_dir: ""`, `.`, `/` or `../..` each resolve to the project root or
|
|
442
|
+
above; a rejected candidate goes to `rejected` so the caller can surface it
|
|
443
|
+
and fail, never silently dropped. The state file is exempt because it is a
|
|
444
|
+
single unlink of one fixed name under the project root, never a recursive
|
|
445
|
+
removal -- matching the oracle's own exemption.
|
|
446
|
+
"""
|
|
447
|
+
# `clean_root` is the tree a relative slice `build_dir` resolves against (the
|
|
448
|
+
# `--build-root` tree X, or the project root without the flag);
|
|
449
|
+
# `project_root` is the real project root and is screened separately, so a
|
|
450
|
+
# manifest slice dir that resolves onto it is refused even when it sits
|
|
451
|
+
# inside X.
|
|
452
|
+
if project_root is None:
|
|
453
|
+
project_root = clean_root
|
|
454
|
+
# Directories that are THIS project for the foreign-project screen
|
|
455
|
+
# (tan-cli#1516): a `--board-yaml cfg/board.yaml` project's own `cfg/`
|
|
456
|
+
# must not read as another project.
|
|
457
|
+
own_roots = (project_root, clean_root) + ((board_yaml_dir,) if board_yaml_dir else ())
|
|
458
|
+
|
|
459
|
+
def _unsafe(path: str) -> bool:
|
|
460
|
+
return is_unsafe_removal_target(project_root, path) or is_unsafe_removal_target(
|
|
461
|
+
clean_root, path
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
candidates: list[tuple[str, _Rejected | None]] = [
|
|
465
|
+
(build_root, _Rejected(build_root, "build-root")),
|
|
466
|
+
(_rust_join(clean_root, STATE_FILE), None),
|
|
467
|
+
]
|
|
468
|
+
for entry in slices:
|
|
469
|
+
raw = entry.get("build_dir")
|
|
470
|
+
if not isinstance(raw, str):
|
|
471
|
+
continue
|
|
472
|
+
resolved = _rust_join(clean_root, raw)
|
|
473
|
+
if not _subsumed_by_build_root(build_root, resolved):
|
|
474
|
+
core_id = entry.get("core_id", "")
|
|
475
|
+
foreign = _foreign_project_root(resolved, own_roots) or ""
|
|
476
|
+
candidates.append(
|
|
477
|
+
# `str()`: a plain-scalar `core_id: 7` is a valid String to
|
|
478
|
+
# serde_yaml, so it can reach the rejection message as an int.
|
|
479
|
+
(resolved, _Rejected(resolved, "slice", str(core_id), raw, foreign))
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
plan = _Plan()
|
|
483
|
+
seen: list[str] = []
|
|
484
|
+
for path, rejection in candidates:
|
|
485
|
+
key = _normalize(path)
|
|
486
|
+
if key in seen:
|
|
487
|
+
continue
|
|
488
|
+
seen.append(key)
|
|
489
|
+
if rejection is None or not (
|
|
490
|
+
_unsafe(path)
|
|
491
|
+
or rejection.foreign_project
|
|
492
|
+
or (
|
|
493
|
+
rejection.origin == "slice"
|
|
494
|
+
and not is_link(path)
|
|
495
|
+
and os.path.isfile(os.path.join(path, "board.yaml"))
|
|
496
|
+
)
|
|
497
|
+
):
|
|
498
|
+
plan.targets.append(path)
|
|
499
|
+
else:
|
|
500
|
+
plan.rejected.append(rejection)
|
|
501
|
+
return plan
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _classify(is_dir: bool, is_file: bool, dry_run: bool) -> tuple[str, str]:
|
|
505
|
+
"""`(kind, action)` from a probed filesystem type + the dry-run flag --
|
|
506
|
+
`clean::classify`. A path that is neither a dir nor a file is `absent`:
|
|
507
|
+
skipped entirely, not counted, no removal attempted."""
|
|
508
|
+
if is_dir:
|
|
509
|
+
return _DIR_ACTIONS[dry_run]
|
|
510
|
+
if is_file:
|
|
511
|
+
return _FILE_ACTIONS[dry_run]
|
|
512
|
+
return ("absent", "absent")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
# The system-manifest sweep
|
|
517
|
+
# ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
#: `serde` renders an unexpected value as `<kind>` for containers and
|
|
521
|
+
#: <code><kind> `value`</code> for scalars. Harvested from the Rust binary
|
|
522
|
+
#: across 25 malformed-manifest shapes, so the port's warning text matches
|
|
523
|
+
#: rather than approximates.
|
|
524
|
+
_SERDE_KIND = {
|
|
525
|
+
type(None): "unit value",
|
|
526
|
+
bool: "boolean",
|
|
527
|
+
int: "integer",
|
|
528
|
+
float: "floating point",
|
|
529
|
+
str: "string",
|
|
530
|
+
list: "sequence",
|
|
531
|
+
dict: "map",
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _serde_value(value: Any) -> str:
|
|
536
|
+
"""How serde names an unexpected value in `invalid type: ...`.
|
|
537
|
+
|
|
538
|
+
`sequence`/`map`/`unit value` carry no payload; a bool renders lowercase
|
|
539
|
+
(`true`), a string in double quotes, a number in backticks.
|
|
540
|
+
"""
|
|
541
|
+
kind = _SERDE_KIND.get(type(value), type(value).__name__)
|
|
542
|
+
if value is None or isinstance(value, (list, dict)):
|
|
543
|
+
return kind
|
|
544
|
+
if isinstance(value, bool):
|
|
545
|
+
return f"{kind} `{'true' if value else 'false'}`"
|
|
546
|
+
if isinstance(value, str):
|
|
547
|
+
return f'{kind} "{value}"'
|
|
548
|
+
return f"{kind} `{value}`"
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _is_yaml_scalar(value: Any) -> bool:
|
|
552
|
+
"""Whether serde_yaml would accept this value for a `String` field.
|
|
553
|
+
|
|
554
|
+
YAML plain scalars are untyped, and serde_yaml 0.9 hands one to whichever
|
|
555
|
+
visitor the target field asks for -- so `core_id: 7` deserializes into
|
|
556
|
+
`String` as `"7"` with no error. Verified against the Rust binary
|
|
557
|
+
(`core_id: 7` parses clean and the run exits 0 with no issue). A port that
|
|
558
|
+
demanded `isinstance(str)` here would emit a `clean.manifest-unreadable`
|
|
559
|
+
warning the oracle does not.
|
|
560
|
+
"""
|
|
561
|
+
return isinstance(value, (str, int, float, bool))
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def parse_manifest_slices(text: str) -> tuple[list[dict[str, Any]], str | None]:
|
|
565
|
+
"""`(slices, error)` for a `system-manifest.yaml` document --
|
|
566
|
+
`parse_system_manifest`, narrowed to the one field this command consumes.
|
|
567
|
+
|
|
568
|
+
FAIL-CLOSED, matching serde: a document that is not a well-formed v1
|
|
569
|
+
manifest yields NO slices and an error string, so a half-read manifest can
|
|
570
|
+
never hand a garbage `build_dir` to a recursive removal. Tolerant of
|
|
571
|
+
additive v1 fields (`deny_unknown_fields` is deliberately off upstream).
|
|
572
|
+
|
|
573
|
+
Message text was matched against the Rust binary shape by shape; two
|
|
574
|
+
divergences remain and are deliberate:
|
|
575
|
+
|
|
576
|
+
* the trailing ` at line N column M` serde_yaml appends is absent --
|
|
577
|
+
`yaml.safe_load` discards node marks, and recovering them would mean a
|
|
578
|
+
custom composer for a warning string;
|
|
579
|
+
* a raw YAML SYNTAX error (a stray tab, an unclosed flow node) carries
|
|
580
|
+
PyYAML's wording after the shared `system-manifest is not valid YAML: `
|
|
581
|
+
prefix.
|
|
582
|
+
|
|
583
|
+
`build_dir` REQUIRES a real string, where serde_yaml would coerce a plain
|
|
584
|
+
scalar (`build_dir: 7` becomes `"7"` and, verified against the Rust binary,
|
|
585
|
+
a THIRD removal target at `<project>/7`). Diverging here is deliberate: this
|
|
586
|
+
is the only manifest field that becomes a path handed to a recursive
|
|
587
|
+
removal, and deriving a delete target from a number in a malformed manifest
|
|
588
|
+
is not a behaviour worth reproducing. The port removes strictly less, the
|
|
589
|
+
SDK emits strings, and [`test_numeric_build_dir_is_not_turned_into_a_delete_target`]
|
|
590
|
+
pins it so the choice cannot drift silently.
|
|
591
|
+
|
|
592
|
+
PyYAML IS a declared base dependency (`pyproject.toml`
|
|
593
|
+
`[project].dependencies`), so this arm is the `--no-deps`/broken-venv case
|
|
594
|
+
rather than the normal one -- an earlier version of this docstring claimed
|
|
595
|
+
tan declared no YAML dependency at all (tan-cli#574). The arm stays because
|
|
596
|
+
that case is still reachable, and its absence is REPORTED rather than
|
|
597
|
+
swallowed: a project whose slices build out of tree would otherwise have
|
|
598
|
+
them left behind with no indication why. Still only a warning; `clean`
|
|
599
|
+
never fails over a manifest.
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
import yaml # noqa: PLC0415 (optional at runtime, by design)
|
|
603
|
+
except ImportError: # pragma: no cover -- present in every real workspace
|
|
604
|
+
return [], (
|
|
605
|
+
"no YAML parser available (PyYAML is not installed), so out-of-tree "
|
|
606
|
+
"slice build dirs were not swept"
|
|
607
|
+
)
|
|
608
|
+
try:
|
|
609
|
+
doc = yaml.safe_load(text)
|
|
610
|
+
except Exception as err: # noqa: BLE001 -- any parser failure, incl. the C ext
|
|
611
|
+
return [], f"system-manifest is not valid YAML: {err}"
|
|
612
|
+
|
|
613
|
+
prefix = "system-manifest is not valid YAML: "
|
|
614
|
+
if doc is None:
|
|
615
|
+
# `yaml.safe_load` collapses two cases serde keeps apart: a file with no
|
|
616
|
+
# document at all (empty, whitespace-only, comments-only, or a bare
|
|
617
|
+
# `---`) has no fields, so serde reports the first REQUIRED one; an
|
|
618
|
+
# explicit `null`/`~` node is a real value of the wrong TYPE. Both were
|
|
619
|
+
# measured against the Rust binary. Told apart by whether the source
|
|
620
|
+
# carries any node text of its own.
|
|
621
|
+
body = "\n".join(
|
|
622
|
+
line
|
|
623
|
+
for line in text.splitlines()
|
|
624
|
+
if line.strip() not in ("", "---", "...") and not line.lstrip().startswith("#")
|
|
625
|
+
)
|
|
626
|
+
if not body.strip():
|
|
627
|
+
return [], f"{prefix}missing field `schema_version`"
|
|
628
|
+
return [], f"{prefix}invalid type: unit value, expected struct SystemManifest"
|
|
629
|
+
if not isinstance(doc, dict):
|
|
630
|
+
return [], f"{prefix}invalid type: {_serde_value(doc)}, expected struct SystemManifest"
|
|
631
|
+
|
|
632
|
+
if "schema_version" not in doc:
|
|
633
|
+
return [], f"{prefix}missing field `schema_version`"
|
|
634
|
+
version = doc["schema_version"]
|
|
635
|
+
# `bool` is an `int` in Python but never a serde `u32`, so it is a type
|
|
636
|
+
# error, not a version. Checked before the int test for that reason.
|
|
637
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
638
|
+
return [], (
|
|
639
|
+
f"{prefix}schema_version: invalid type: {_serde_value(version)}, expected u32"
|
|
640
|
+
)
|
|
641
|
+
if version != MANIFEST_SCHEMA_VERSION:
|
|
642
|
+
return [], (
|
|
643
|
+
f"unsupported system-manifest schema_version {version} (this CLI "
|
|
644
|
+
f"consumes v{MANIFEST_SCHEMA_VERSION}); upgrade the CLI or the SDK "
|
|
645
|
+
"so the versions match"
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
raw = doc.get("slices", [])
|
|
649
|
+
if not isinstance(raw, list):
|
|
650
|
+
return [], f"{prefix}slices: invalid type: {_serde_value(raw)}, expected a sequence"
|
|
651
|
+
for index, entry in enumerate(raw):
|
|
652
|
+
# `core_id`/`os` are non-Option in the Rust `Slice`, so serde fails the
|
|
653
|
+
# WHOLE document when either is missing; `build_dir` is
|
|
654
|
+
# `Option<String>`, so `null`/absent is fine and a sequence is not. A
|
|
655
|
+
# partial read here would act on a manifest the oracle rejects outright.
|
|
656
|
+
if not isinstance(entry, dict):
|
|
657
|
+
return [], (
|
|
658
|
+
f"{prefix}slices[{index}]: invalid type: {_serde_value(entry)}, "
|
|
659
|
+
"expected struct Slice"
|
|
660
|
+
)
|
|
661
|
+
for required in ("core_id", "os"):
|
|
662
|
+
if not _is_yaml_scalar(entry.get(required)):
|
|
663
|
+
missing = required not in entry or entry.get(required) is None
|
|
664
|
+
return [], (
|
|
665
|
+
f"{prefix}slices[{index}]: missing field `{required}`"
|
|
666
|
+
if missing
|
|
667
|
+
else f"{prefix}slices[{index}].{required}: invalid type: "
|
|
668
|
+
f"{_serde_value(entry[required])}, expected a string"
|
|
669
|
+
)
|
|
670
|
+
build_dir = entry.get("build_dir")
|
|
671
|
+
if build_dir is not None and not _is_yaml_scalar(build_dir):
|
|
672
|
+
return [], (
|
|
673
|
+
f"{prefix}slices[{index}].build_dir: invalid type: "
|
|
674
|
+
f"{_serde_value(build_dir)}, expected a string"
|
|
675
|
+
)
|
|
676
|
+
return list(raw), None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _read_manifest(build_root: str) -> tuple[list[dict[str, Any]], str | None]:
|
|
680
|
+
"""The manifest's slices, or `([], error)`.
|
|
681
|
+
|
|
682
|
+
A READ failure -- absent, a directory in its place, a denied ACL, non-UTF-8
|
|
683
|
+
bytes -- is SILENT (`([], None)`), matching the oracle's `Err(_) => None`
|
|
684
|
+
arm: no issue, no text, exit unchanged. Verified against the Rust binary for
|
|
685
|
+
both the directory and the non-UTF-8 cases. Only a document that WAS read
|
|
686
|
+
and could not be understood is a warning.
|
|
687
|
+
"""
|
|
688
|
+
# tan-cli#1482: the nested `<X>/build/system-manifest.yaml` spelling is
|
|
689
|
+
# found the way size/image/flash find it.
|
|
690
|
+
try:
|
|
691
|
+
text = Path(find_manifest(build_root)).read_text(encoding="utf-8")
|
|
692
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
693
|
+
return [], None
|
|
694
|
+
return parse_manifest_slices(text)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# ---------------------------------------------------------------------------
|
|
698
|
+
# Removal
|
|
699
|
+
# ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
# `is_link`/`os_error_text`/`_reraise_removal_failure`/
|
|
702
|
+
# `_retry_after_clearing_readonly`/`_RMTREE_HOOK`/`remove_dir` MOVED to
|
|
703
|
+
# `tan.core.dir_removal` (tan-cli#790, imported at the top of this file):
|
|
704
|
+
# `tan sdk remove` needs the identical read-only-retry/junction-safe removal
|
|
705
|
+
# `tan clean` already carries, and `tan.core` may import no `tan.commands.*`
|
|
706
|
+
# module, so the shared primitives had to live below both rather than inside
|
|
707
|
+
# either. Re-imported under their ORIGINAL names (`_remove_dir` is
|
|
708
|
+
# `remove_dir` aliased), so every internal call site below is unchanged and
|
|
709
|
+
# so the existing test suite's `monkeypatch.setattr(clean_cmd, "_remove_dir",
|
|
710
|
+
# ...)` and `from tan.commands.clean_cmd import is_link, os_error_text,
|
|
711
|
+
# _retry_after_clearing_readonly` keep resolving exactly as before -- a
|
|
712
|
+
# monkeypatch only cares which module ATTRIBUTE it overwrites, never which
|
|
713
|
+
# module originally defined the value that attribute pointed at, and an
|
|
714
|
+
# `import` binds a module-level attribute the same way a `def` would.
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
# ---------------------------------------------------------------------------
|
|
718
|
+
# SDK resolution
|
|
719
|
+
# ---------------------------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _cli_workspace_root(project_arg: str | None) -> Path:
|
|
723
|
+
"""`util::cli_workspace_root`: `--project` joined to the cwd, UNNORMALIZED
|
|
724
|
+
(the oracle normalizes only for the reported `project.root`), or the cwd
|
|
725
|
+
itself when the flag is absent. Feeds the SDK guard's discovery walk, whose
|
|
726
|
+
sibling/ancestor probes are lexical -- so the unnormalized spelling is what
|
|
727
|
+
keeps the two implementations probing the same directories."""
|
|
728
|
+
try:
|
|
729
|
+
cwd = os.getcwd()
|
|
730
|
+
except OSError:
|
|
731
|
+
cwd = "." # `current_dir().unwrap_or_else(|_| PathBuf::from("."))`
|
|
732
|
+
return Path(cwd if project_arg is None else _rust_join(cwd, project_arg))
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _sdk_root_refusal(sdk_root_arg: str | None, workspace_root: Path) -> str:
|
|
736
|
+
"""`clean.sdk-root-not-found`'s message (tan-cli#1444): where tan looked
|
|
737
|
+
and the flag that fixes it, the same shape as `flash.sdk-root-not-found`
|
|
738
|
+
(#1423). `--sdk-root` is terminal (I-31): no other tier was tried, so that
|
|
739
|
+
branch does not claim the ladder was searched."""
|
|
740
|
+
if sdk_root_arg is not None:
|
|
741
|
+
return (
|
|
742
|
+
f"Cannot locate alp-sdk root. `--sdk-root {sdk_root_arg}` is not an "
|
|
743
|
+
"alp-sdk checkout (no `scripts/alp_project.py` under it)."
|
|
744
|
+
)
|
|
745
|
+
return (
|
|
746
|
+
f"Cannot locate alp-sdk root. {sdk_search_summary(workspace_root)} "
|
|
747
|
+
f"To fix it, {NO_SDK_NEXT_STEPS}."
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def sdk_root_resolves(sdk_root: str | None, workspace_root: Path) -> bool:
|
|
752
|
+
"""Whether `tan.core.sdk_discovery.resolve_sdk_root_ladder` would resolve a checkout --
|
|
753
|
+
the guard behind `clean.sdk-root-not-found`.
|
|
754
|
+
|
|
755
|
+
`--sdk-root` is TERMINAL (I-31): an explicit path without the loader marker
|
|
756
|
+
fails here rather than falling through to a lower tier and cleaning against
|
|
757
|
+
a checkout the caller never named -- checked explicitly below because the
|
|
758
|
+
ladder itself returns a `--sdk-root` value unvalidated (matching the
|
|
759
|
+
oracle's `resolve_sdk_tiered`, terminal for REPORTING); this gate matches
|
|
760
|
+
`util::resolve_sdk_root`, terminal AND validated. The project-pin and
|
|
761
|
+
global-default tiers are best-effort, so a stale pointer falls through
|
|
762
|
+
instead of locking the user out.
|
|
763
|
+
|
|
764
|
+
The ladder's LAST tier is the wide positional walk (root, child `alp-sdk`,
|
|
765
|
+
sibling `alp-sdk`, sibling `alp-sdk-upstream`, then ancestors; first match
|
|
766
|
+
wins), but it is reached only when the narrower `resolve_sdk_tiered`
|
|
767
|
+
discovery tier AHEAD of it answers `None` -- a narrow hit short-circuits.
|
|
768
|
+
So in a workspace holding BOTH a child `alp-sdk` and a lateral one, what
|
|
769
|
+
gates this command is the lateral checkout, not the child, and the wide
|
|
770
|
+
walk never runs (measured against the oracle: `tan clean` there resolves
|
|
771
|
+
`../alp-sdk` too, tan-cli#263).
|
|
772
|
+
|
|
773
|
+
That ordering does not move this boolean: every candidate the narrow tier
|
|
774
|
+
probes is also one the wide walk probes, so a narrow hit implies a wide
|
|
775
|
+
hit. What the wide tail still buys is the case the narrow tier cannot
|
|
776
|
+
answer -- a `tan bootstrap` workspace whose checkout is a CHILD of the cwd,
|
|
777
|
+
where narrow returns `None` and, without the tail, `tan clean` would refuse
|
|
778
|
+
to run (tan-cli#218; measured: the oracle resolves `<ws>/alp-sdk` there).
|
|
779
|
+
|
|
780
|
+
Note this gate and the REPORTED `sdk` key are still two different
|
|
781
|
+
resolutions: [`resolve_sdk`][tan.commands.presets_cmd.resolve_sdk] (below)
|
|
782
|
+
reports through `resolve_sdk_tiered` alone, so a bootstrap-child workspace
|
|
783
|
+
gates open here while reporting no `sdk` at all.
|
|
784
|
+
"""
|
|
785
|
+
resolution = resolve_sdk_root_ladder(sdk_root, workspace_root)
|
|
786
|
+
if resolution.path is None:
|
|
787
|
+
return False
|
|
788
|
+
if resolution.tier == "sdkRootFlag":
|
|
789
|
+
return resolution.path.joinpath(*SDK_MARKER).exists()
|
|
790
|
+
return True
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# ---------------------------------------------------------------------------
|
|
794
|
+
# Envelope assembly
|
|
795
|
+
# ---------------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@dataclass
|
|
799
|
+
class _Outcome:
|
|
800
|
+
"""What one run produced. Built and returned, never emitted in place, so the
|
|
801
|
+
exception guard in [`clean`] can wrap the whole computation without also
|
|
802
|
+
catching `typer.Exit` (a `RuntimeError` subclass, not a `SystemExit`, which a
|
|
803
|
+
bare `except Exception` would otherwise swallow)."""
|
|
804
|
+
|
|
805
|
+
exit_code: ExitCode
|
|
806
|
+
data: dict[str, Any]
|
|
807
|
+
project: Project
|
|
808
|
+
sdk: SdkInfo | None
|
|
809
|
+
issues: list[Issue]
|
|
810
|
+
text: list[str]
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _report(build_root: str, dry_run: bool, targets: list[dict[str, str]], removed: int):
|
|
814
|
+
return {
|
|
815
|
+
"buildRoot": build_root,
|
|
816
|
+
"dryRun": dry_run,
|
|
817
|
+
"targets": targets,
|
|
818
|
+
"removed": removed,
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _run(
|
|
823
|
+
*,
|
|
824
|
+
app_path: str,
|
|
825
|
+
build_root_arg: str | None,
|
|
826
|
+
dry_run: bool,
|
|
827
|
+
project_arg: str | None,
|
|
828
|
+
board_yaml_arg: str | None,
|
|
829
|
+
sdk_root_arg: str | None,
|
|
830
|
+
quiet: bool,
|
|
831
|
+
) -> _Outcome:
|
|
832
|
+
"""The whole command as a computation returning one outcome. Nothing here
|
|
833
|
+
emits or exits; [`clean`] does both exactly once."""
|
|
834
|
+
workspace_root, board_yaml = resolve_project_paths(project_arg, board_yaml_arg)
|
|
835
|
+
# tan-cli#236: `boardYaml` reported only when the file really exists.
|
|
836
|
+
project = Project.resolved(workspace_root, board_yaml)
|
|
837
|
+
# tan-cli#468: `resolve_sdk` now always returns an `ActiveSdk` -- never a
|
|
838
|
+
# bare `None` -- so `broken_project_pin`/`foreign_global_default_for` are
|
|
839
|
+
# read off it unconditionally, whether or not `.path` resolved. `sdk` (the
|
|
840
|
+
# envelope's `SdkInfo`) is the one field that still depends on `.path`: no
|
|
841
|
+
# checkout means no `sdk` block, exactly as before.
|
|
842
|
+
resolved_sdk = resolve_sdk(sdk_root_arg, workspace_root)
|
|
843
|
+
sdk = (
|
|
844
|
+
SdkInfo.from_resolution(resolved_sdk.path, resolved_sdk)
|
|
845
|
+
if resolved_sdk.path is not None
|
|
846
|
+
else None
|
|
847
|
+
)
|
|
848
|
+
pin_issue = project_pin_issue(resolved_sdk.broken_project_pin, resolved_sdk.tier)
|
|
849
|
+
foreign_issue = global_default_foreign_project_issue(
|
|
850
|
+
resolved_sdk.foreign_global_default_for
|
|
851
|
+
)
|
|
852
|
+
resolution_issues = [i for i in (pin_issue, foreign_issue) if i is not None]
|
|
853
|
+
|
|
854
|
+
# App base: a non-`.` positional roots the removal at that app dir,
|
|
855
|
+
# overriding `--project`; `.` falls back to the resolved workspace.
|
|
856
|
+
if app_path == ".":
|
|
857
|
+
project_root = workspace_root
|
|
858
|
+
else:
|
|
859
|
+
try:
|
|
860
|
+
cwd = os.getcwd()
|
|
861
|
+
except OSError:
|
|
862
|
+
cwd = "."
|
|
863
|
+
project_root = _rust_join(cwd, app_path)
|
|
864
|
+
|
|
865
|
+
# SDK-root guard -- faithful to `alp_clean.py`'s `log.die('Cannot locate
|
|
866
|
+
# alp-sdk root.')`. Arguably YAGNI (removing a build dir needs no SDK), but
|
|
867
|
+
# the oracle keeps it, so the port keeps it. tan-cli#1444: the message
|
|
868
|
+
# names where tan looked and the flag that fixes it, as flash's does
|
|
869
|
+
# (#1423); `test_clean_parity.json` is history only, nothing replays it.
|
|
870
|
+
guard_root = _cli_workspace_root(project_arg)
|
|
871
|
+
if not sdk_root_resolves(sdk_root_arg, guard_root):
|
|
872
|
+
message = _sdk_root_refusal(sdk_root_arg, guard_root)
|
|
873
|
+
return _Outcome(
|
|
874
|
+
exit_code=ExitCode.RUNTIME_FAILURE,
|
|
875
|
+
data=_report("", dry_run, [], 0),
|
|
876
|
+
project=project,
|
|
877
|
+
sdk=sdk,
|
|
878
|
+
# tan-cli#468: a broken `.alp/sdk-path` pin (or a foreign
|
|
879
|
+
# `globalDefault`) with NO other tier resolving anything used to
|
|
880
|
+
# report this refusal alone -- `resolved_sdk` was a bare `None`
|
|
881
|
+
# here, dropping both facts on the floor.
|
|
882
|
+
issues=[*resolution_issues, Issue("clean.sdk-root-not-found", "error", message)],
|
|
883
|
+
text=[f"clean: {message}"],
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# DELIBERATE oracle divergence (v0.4.1 removes X itself): do not revert in a
|
|
887
|
+
# parity sweep. `--build-root X` means what it means to `tan build` / `size` / `image` /
|
|
888
|
+
# `flash` (tan-cli#1405/#1411, #1482): X is the PROJECT TREE the build ran
|
|
889
|
+
# under (absolute as-is, relative against the CURRENT DIRECTORY), and the
|
|
890
|
+
# build output lives in `<X>/build`, which is what clean removes. Passing the
|
|
891
|
+
# same X to `build` and `clean` therefore never touches X's sources. Without
|
|
892
|
+
# the flag it is `<project_root>/build`. The default is deliberately NOT
|
|
893
|
+
# normalized -- `data.buildRoot` is a compared field.
|
|
894
|
+
clean_root = project_root
|
|
895
|
+
refusal: str | None = None
|
|
896
|
+
build_dir_why: str | None = None
|
|
897
|
+
if build_root_arg is not None:
|
|
898
|
+
if build_root_arg == "":
|
|
899
|
+
# `rm -rf $UNSET_VAR` shape: an empty value is never a tree name.
|
|
900
|
+
tree = _normalize(project_root)
|
|
901
|
+
build_root = tree
|
|
902
|
+
refusal = "an empty --build-root"
|
|
903
|
+
else:
|
|
904
|
+
tree = _normalize(
|
|
905
|
+
build_root_arg if os.path.isabs(build_root_arg)
|
|
906
|
+
else _rust_join(_cwd_or_dot(), build_root_arg)
|
|
907
|
+
)
|
|
908
|
+
clean_root = tree
|
|
909
|
+
build_root = _rust_join(tree, "build")
|
|
910
|
+
if not _has_normal_component(tree):
|
|
911
|
+
refusal = f"`{tree}` is a filesystem root"
|
|
912
|
+
elif os.path.isfile(_rust_join(tree, "system-manifest.yaml")) and not os.path.isfile(
|
|
913
|
+
_rust_join(build_root, "system-manifest.yaml")
|
|
914
|
+
):
|
|
915
|
+
# tan-cli#1521: `size`/`image`/`flash` also accept X as the build
|
|
916
|
+
# directory itself, but clean appends `/build` to X, so that
|
|
917
|
+
# spelling would target `X/build`, find nothing and exit 0 with
|
|
918
|
+
# the real build tree still in place. Refuse loudly instead.
|
|
919
|
+
# Own text, not the shared `refusing to remove <tree>/build`
|
|
920
|
+
# prefix: that path does not exist, X is the build dir.
|
|
921
|
+
build_dir_why = (
|
|
922
|
+
f"`{tree}` is itself a build directory (it holds system-manifest.yaml); "
|
|
923
|
+
"pass the project tree it was built under, not its `build` directory"
|
|
924
|
+
)
|
|
925
|
+
refusal = build_dir_why
|
|
926
|
+
else:
|
|
927
|
+
build_root = _rust_join(project_root, "build")
|
|
928
|
+
|
|
929
|
+
# Fail fast, BEFORE the manifest is read: the target may not be the project
|
|
930
|
+
# root, an ancestor of it, a filesystem root, or a directory that is itself a
|
|
931
|
+
# project tree (holds a board.yaml) -- the `rm -rf` shapes that reach a
|
|
932
|
+
# recursive removal at exit 0.
|
|
933
|
+
if refusal is None and (
|
|
934
|
+
is_unsafe_removal_target(project_root, build_root)
|
|
935
|
+
or is_unsafe_removal_target(clean_root, build_root)
|
|
936
|
+
):
|
|
937
|
+
refusal = "a build root may not be the project root, an ancestor of it, or a filesystem root"
|
|
938
|
+
if (
|
|
939
|
+
refusal is None
|
|
940
|
+
and not is_link(build_root)
|
|
941
|
+
and os.path.isfile(os.path.join(build_root, "board.yaml"))
|
|
942
|
+
):
|
|
943
|
+
refusal = "it holds a board.yaml, so it is a project tree, not a build directory"
|
|
944
|
+
if refusal is not None:
|
|
945
|
+
why = (
|
|
946
|
+
build_dir_why
|
|
947
|
+
if refusal is build_dir_why
|
|
948
|
+
else f"refusing to remove `{build_root}`: {refusal}"
|
|
949
|
+
)
|
|
950
|
+
return _Outcome(
|
|
951
|
+
exit_code=ExitCode.RUNTIME_FAILURE,
|
|
952
|
+
data=_report(build_root, dry_run, [], 0),
|
|
953
|
+
project=project,
|
|
954
|
+
sdk=sdk,
|
|
955
|
+
# tan-cli#468: same reasoning as the guard above -- this refusal
|
|
956
|
+
# can fire even when `resolved_sdk.path` is `None`.
|
|
957
|
+
issues=[*resolution_issues, Issue("clean.unsafe-build-root", "error", why)],
|
|
958
|
+
text=[f"clean: {why}"],
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
text: list[str] = []
|
|
962
|
+
# tan-cli#263 review / tan-cli#464: `pin_issue`/`foreign_issue` computed
|
|
963
|
+
# once, above, from the one `resolved_sdk` -- this is the SAME pair the
|
|
964
|
+
# two refusals above prepend, read back rather than recomputed so all
|
|
965
|
+
# three paths can never disagree about whether either warning applies.
|
|
966
|
+
issues: list[Issue] = list(resolution_issues)
|
|
967
|
+
|
|
968
|
+
# Best-effort, manifest-aware sweep. Absence (or an unreadable file) is
|
|
969
|
+
# silent; a parse/version error is a warning, NEVER fatal -- clean must not
|
|
970
|
+
# fail over a manifest it only consults for an optimisation.
|
|
971
|
+
slices, manifest_error = _read_manifest(build_root)
|
|
972
|
+
if manifest_error is not None:
|
|
973
|
+
detail = f"ignoring unreadable system-manifest.yaml: {manifest_error}"
|
|
974
|
+
if not quiet:
|
|
975
|
+
text.append(f"clean: {detail}")
|
|
976
|
+
issues.append(Issue("clean.manifest-unreadable", "warning", detail))
|
|
977
|
+
|
|
978
|
+
plan = plan_clean_targets(
|
|
979
|
+
clean_root, build_root, slices, project_root, os.path.dirname(board_yaml)
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
records: list[dict[str, str]] = []
|
|
983
|
+
removed = 0
|
|
984
|
+
exit_code = ExitCode.SUCCESS
|
|
985
|
+
|
|
986
|
+
# A manifest naming a catastrophic build_dir is a broken manifest, not a
|
|
987
|
+
# reason to quietly clean less than asked. Report every refusal and fail.
|
|
988
|
+
for refused in plan.rejected:
|
|
989
|
+
why = refused.reason()
|
|
990
|
+
text.append(f"clean: {why}")
|
|
991
|
+
issues.append(Issue("clean.unsafe-target", "error", why))
|
|
992
|
+
records.append(
|
|
993
|
+
{"path": refused.path, "kind": "dir", "action": "refused-unsafe"}
|
|
994
|
+
)
|
|
995
|
+
exit_code = ExitCode.RUNTIME_FAILURE
|
|
996
|
+
|
|
997
|
+
for target in plan.targets:
|
|
998
|
+
probe = Path(target)
|
|
999
|
+
try:
|
|
1000
|
+
is_dir, is_file = probe.is_dir(), probe.is_file()
|
|
1001
|
+
except OSError:
|
|
1002
|
+
# `Path.is_dir()` swallows its own OSError, but a shape the host
|
|
1003
|
+
# rejects outright (an over-long name, an illegal character) can
|
|
1004
|
+
# still raise ValueError/OSError on some hosts. Treat as absent --
|
|
1005
|
+
# a path that cannot be probed is certainly not removed.
|
|
1006
|
+
is_dir = is_file = False
|
|
1007
|
+
except ValueError:
|
|
1008
|
+
is_dir = is_file = False
|
|
1009
|
+
kind, action = _classify(is_dir, is_file, dry_run)
|
|
1010
|
+
|
|
1011
|
+
if action == "would-remove":
|
|
1012
|
+
verb = "rmtree" if kind == "dir" else "unlink"
|
|
1013
|
+
text.append(f"[DRY] would {verb} {target}")
|
|
1014
|
+
records.append({"path": target, "kind": kind, "action": action})
|
|
1015
|
+
continue
|
|
1016
|
+
if action == "absent":
|
|
1017
|
+
records.append({"path": target, "kind": kind, "action": action})
|
|
1018
|
+
continue
|
|
1019
|
+
|
|
1020
|
+
text.append(f"clean: removing {target}")
|
|
1021
|
+
if kind == "dir":
|
|
1022
|
+
# Best-effort, matching `rmtree(ignore_errors=True)`: a failure does
|
|
1023
|
+
# NOT fail the command, but it IS reported -- as a warning issue and
|
|
1024
|
+
# the `remove-failed` action -- and is not counted `removed`. The
|
|
1025
|
+
# envelope must never claim a directory was removed when it was not.
|
|
1026
|
+
try:
|
|
1027
|
+
_remove_dir(target)
|
|
1028
|
+
except (OSError, ValueError) as err:
|
|
1029
|
+
detail = f"could not fully remove {target}: {os_error_text(err)}"
|
|
1030
|
+
text.append(f"clean: warning: {detail}")
|
|
1031
|
+
issues.append(Issue("clean.remove-failed", "warning", detail))
|
|
1032
|
+
records.append({"path": target, "kind": "dir", "action": "remove-failed"})
|
|
1033
|
+
else:
|
|
1034
|
+
removed += 1
|
|
1035
|
+
records.append({"path": target, "kind": "dir", "action": "removed"})
|
|
1036
|
+
else:
|
|
1037
|
+
# The state-file unlink is NOT ignore_errors in the Python source --
|
|
1038
|
+
# a failure propagates to exit 1.
|
|
1039
|
+
try:
|
|
1040
|
+
os.remove(target)
|
|
1041
|
+
except (OSError, ValueError) as err:
|
|
1042
|
+
detail = f"failed to remove {target}: {os_error_text(err)}"
|
|
1043
|
+
issues.append(Issue("clean.remove-failed", "error", detail))
|
|
1044
|
+
text.append(f"clean: error: {detail}")
|
|
1045
|
+
exit_code = ExitCode.RUNTIME_FAILURE
|
|
1046
|
+
records.append({"path": target, "kind": "file", "action": "remove-failed"})
|
|
1047
|
+
else:
|
|
1048
|
+
removed += 1
|
|
1049
|
+
records.append({"path": target, "kind": "file", "action": "removed"})
|
|
1050
|
+
|
|
1051
|
+
# Faithful trailing line -- suppressed under `--dry-run` (the Python source
|
|
1052
|
+
# guards it with `and not args.dry_run`) and when a hard removal error
|
|
1053
|
+
# already fired.
|
|
1054
|
+
if removed == 0 and not dry_run and exit_code == ExitCode.SUCCESS:
|
|
1055
|
+
text.append("clean: nothing to remove")
|
|
1056
|
+
|
|
1057
|
+
return _Outcome(
|
|
1058
|
+
exit_code=exit_code,
|
|
1059
|
+
data=_report(build_root, dry_run, records, removed),
|
|
1060
|
+
project=project,
|
|
1061
|
+
sdk=sdk,
|
|
1062
|
+
issues=issues,
|
|
1063
|
+
text=text,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def clean(
|
|
1068
|
+
app_path: str = typer.Argument(
|
|
1069
|
+
".",
|
|
1070
|
+
metavar="APP_PATH",
|
|
1071
|
+
help=(
|
|
1072
|
+
"Application source directory (default: '.'). The build root "
|
|
1073
|
+
"defaults to <APP_PATH>/build; a non-'.' value overrides --project."
|
|
1074
|
+
),
|
|
1075
|
+
),
|
|
1076
|
+
build_root: str = typer.Option(
|
|
1077
|
+
None,
|
|
1078
|
+
"--build-root",
|
|
1079
|
+
metavar="PATH",
|
|
1080
|
+
help=(
|
|
1081
|
+
"Project tree to clean, the same meaning as `tan build --build-root`: "
|
|
1082
|
+
"removes <PATH>/build (default tree: <APP_PATH>). Relative to the "
|
|
1083
|
+
"current directory. Refused if the target holds a board.yaml."
|
|
1084
|
+
),
|
|
1085
|
+
),
|
|
1086
|
+
dry_run: bool = typer.Option(
|
|
1087
|
+
False, "--dry-run", help="List the paths that would be removed; delete nothing."
|
|
1088
|
+
),
|
|
1089
|
+
project: str = typer.Option(
|
|
1090
|
+
None, "--project", metavar="PATH", help="Project root (defaults to '.')."
|
|
1091
|
+
),
|
|
1092
|
+
board_yaml: str = typer.Option(
|
|
1093
|
+
None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
|
|
1094
|
+
),
|
|
1095
|
+
sdk_root: str = typer.Option(
|
|
1096
|
+
None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
|
|
1097
|
+
),
|
|
1098
|
+
output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
|
|
1099
|
+
quiet: bool = typer.Option(
|
|
1100
|
+
False, "--quiet", help="Suppress the non-essential manifest notice."
|
|
1101
|
+
),
|
|
1102
|
+
verbose: bool = typer.Option(False, "--verbose", hidden=True),
|
|
1103
|
+
no_color: bool = typer.Option(False, "--no-color", hidden=True),
|
|
1104
|
+
non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True),
|
|
1105
|
+
ci: bool = typer.Option(False, "--ci", hidden=True),
|
|
1106
|
+
target: str = typer.Option(None, "--target", hidden=True),
|
|
1107
|
+
all_cores: bool = typer.Option(False, "--all", hidden=True),
|
|
1108
|
+
) -> None:
|
|
1109
|
+
"""Remove this project's build directory and build-state cache."""
|
|
1110
|
+
# The six options above are `clap`'s `GlobalArgs` members that `clean.rs`
|
|
1111
|
+
# accepts and never reads. Declared here purely so the argv SURFACE matches:
|
|
1112
|
+
# `tan clean --no-color` exits 0 on the oracle and, without these, exited 2 as
|
|
1113
|
+
# a Click usage error -- so a customer's `tan clean --ci` in a CI script
|
|
1114
|
+
# cleaned nothing. Hidden from `--help` because they do nothing.
|
|
1115
|
+
#
|
|
1116
|
+
# This gap is PORT-WIDE, not `clean`'s alone -- measured against the Rust
|
|
1117
|
+
# binary, `presets --no-color`, `doctor --no-color` and `sdk current --ci`
|
|
1118
|
+
# each still exit 2 where the oracle exits 0/4/0. Closed here rather than
|
|
1119
|
+
# left because `clean` is the command whose refusal means work that should
|
|
1120
|
+
# have been removed was not; the shared fix (one decorator for every command)
|
|
1121
|
+
# belongs with whoever owns the global-flag surface.
|
|
1122
|
+
del verbose, no_color, non_interactive, ci, target, all_cores
|
|
1123
|
+
json_mode = output_format == "json"
|
|
1124
|
+
|
|
1125
|
+
try:
|
|
1126
|
+
outcome = _run(
|
|
1127
|
+
app_path=app_path,
|
|
1128
|
+
build_root_arg=build_root,
|
|
1129
|
+
dry_run=dry_run,
|
|
1130
|
+
project_arg=project,
|
|
1131
|
+
board_yaml_arg=board_yaml,
|
|
1132
|
+
sdk_root_arg=sdk_root,
|
|
1133
|
+
quiet=quiet,
|
|
1134
|
+
)
|
|
1135
|
+
except Exception as err: # noqa: BLE001
|
|
1136
|
+
# The recurring break this guard exists for: an escaping traceback puts
|
|
1137
|
+
# nothing parseable on stdout and the extension renders an empty panel
|
|
1138
|
+
# with no error at all. CONSTANTS ONLY below -- no call to a helper that
|
|
1139
|
+
# could itself throw, which is how a single fault became a double fault
|
|
1140
|
+
# elsewhere in this port. In particular `resolve_project_paths` is NOT
|
|
1141
|
+
# re-run here: it reads the cwd, and a deleted cwd is one of the ways
|
|
1142
|
+
# `_run` can fail in the first place.
|
|
1143
|
+
outcome = _Outcome(
|
|
1144
|
+
exit_code=ExitCode.INTERNAL_FAILURE,
|
|
1145
|
+
data=_report("", dry_run, [], 0),
|
|
1146
|
+
project=Project(root=None, board_yaml=None),
|
|
1147
|
+
sdk=None,
|
|
1148
|
+
issues=[
|
|
1149
|
+
Issue(
|
|
1150
|
+
"clean.internal-failure",
|
|
1151
|
+
"error",
|
|
1152
|
+
f"clean failed unexpectedly: {err}",
|
|
1153
|
+
)
|
|
1154
|
+
],
|
|
1155
|
+
text=["clean: internal failure"],
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
# Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407
|
|
1159
|
+
# `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_
|
|
1160
|
+
# divergence`), and `outcome.text` was assembled strictly before any
|
|
1161
|
+
# `Envelope` existed -- so a seam-appended issue reached `--format json`
|
|
1162
|
+
# and was silent on the default text channel (tan-cli#799). Diffed
|
|
1163
|
+
# against `outcome.issues` (by value: `Issue` is a frozen dataclass) so
|
|
1164
|
+
# only what the seam ADDED is rendered.
|
|
1165
|
+
envelope = Envelope(
|
|
1166
|
+
"clean",
|
|
1167
|
+
outcome.project,
|
|
1168
|
+
outcome.data,
|
|
1169
|
+
outcome.issues,
|
|
1170
|
+
outcome.exit_code,
|
|
1171
|
+
sdk=outcome.sdk,
|
|
1172
|
+
)
|
|
1173
|
+
if json_mode:
|
|
1174
|
+
emit(envelope)
|
|
1175
|
+
else:
|
|
1176
|
+
seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues]
|
|
1177
|
+
# stdout is the envelope channel and carries nothing else, in either
|
|
1178
|
+
# mode; stderr carries no contract of its own.
|
|
1179
|
+
for issue in seam_extra:
|
|
1180
|
+
print(f"{issue.severity}: {issue.message}", file=sys.stderr)
|
|
1181
|
+
for line in outcome.text:
|
|
1182
|
+
print(line, file=sys.stderr)
|
|
1183
|
+
raise typer.Exit(int(outcome.exit_code))
|