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/sdk_removal.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`tan sdk remove` (tan-cli#790) -- the pure/near-pure half: resolving
|
|
3
|
+
`<version|path>` against `--destination`, sizing a tree before it is gone,
|
|
4
|
+
and performing the removal itself with the same read-only-retry hardening
|
|
5
|
+
`tan clean` already carries (`tan.core.dir_removal`, tan-cli#790 same
|
|
6
|
+
change), plus this verb's OWN two failure classes clean's caller never had
|
|
7
|
+
to tell apart -- see [`classify_removal_error`].
|
|
8
|
+
|
|
9
|
+
**Why this exists at all, not just a hand-deleted tree.** alp-sdk-vscode's
|
|
10
|
+
own removal was one line, `fs.rmSync(target, { recursive: true, force: true
|
|
11
|
+
})` -- `force` swallows only `ENOENT`, so a locked file (a terminal cwd'd
|
|
12
|
+
into the tree, a build mid-flight, an indexer) or a permission it cannot
|
|
13
|
+
clear surfaces as a raw, unclassified `EPERM`/`EBUSY` with no verdict a
|
|
14
|
+
consumer can act on. Every OTHER consumer of a `tan`-managed SDK cache (a CI
|
|
15
|
+
script rotating versions, a clean-host reset) was left to reimplement the
|
|
16
|
+
identical judgement call by hand. This module is the ONE place that makes
|
|
17
|
+
it.
|
|
18
|
+
|
|
19
|
+
**`tan.core`, not `tan.commands.sdk_cmd`.** Keeps the command file a thin
|
|
20
|
+
orchestrator (arg parsing, the resolution-ladder call, the envelope) over
|
|
21
|
+
logic that is unit-testable without spawning a subprocess, the same split
|
|
22
|
+
`tan.core.sdk_discovery`'s own module docstring argues for. Filesystem IO
|
|
23
|
+
lives here anyway -- like `tan.core.dir_removal`'s own removal primitives,
|
|
24
|
+
and like `sdk_discovery`'s pointer/registry reads, "no direct command
|
|
25
|
+
import" is the invariant this module keeps, not "no IO at all".
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import errno
|
|
30
|
+
import os
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from tan.core.dir_removal import os_error_text, remove_dir
|
|
35
|
+
from tan.core.sdk_default_registry import is_absolute_either_platform
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def looks_like_path(raw: str) -> bool:
|
|
39
|
+
"""Whether `raw` -- the caller's `<version|path>` argument, unstripped of
|
|
40
|
+
its own meaning by this point -- names an explicit filesystem location
|
|
41
|
+
rather than a bare cache-relative version name.
|
|
42
|
+
|
|
43
|
+
A path SEPARATOR (either flavour, so a POSIX-spelled path is still
|
|
44
|
+
recognised when typed on Windows and vice versa -- the same
|
|
45
|
+
either-platform tolerance `sdk_default_registry.is_absolute_either_platform`
|
|
46
|
+
already applies to a value this same registry stores), an absolute path on
|
|
47
|
+
THIS platform, or the two relative-navigation tokens `.`/`..` (which
|
|
48
|
+
contain no separator yet plainly do not name a released version) all
|
|
49
|
+
count. Everything else -- `v0.15.0`, `0.15.0-rc1`, a bare filename with no
|
|
50
|
+
separator -- is a version name, looked up under `--destination`.
|
|
51
|
+
"""
|
|
52
|
+
return (
|
|
53
|
+
"/" in raw
|
|
54
|
+
or "\\" in raw
|
|
55
|
+
or raw in (".", "..")
|
|
56
|
+
or Path(raw).is_absolute()
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class RemovalTarget:
|
|
62
|
+
"""What `<version|path>` resolved to, plus the two facts the caller needs
|
|
63
|
+
to police it: whether it was a bare version name (always inside
|
|
64
|
+
`destination` by construction, so the outside-root refusal can never
|
|
65
|
+
apply to it) and the cache root it was resolved against."""
|
|
66
|
+
|
|
67
|
+
target: Path
|
|
68
|
+
destination: Path
|
|
69
|
+
is_named_version: bool
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_removal_target(raw_arg: str, destination: Path) -> RemovalTarget:
|
|
73
|
+
"""`raw_arg` (already stripped and confirmed non-empty by the caller)
|
|
74
|
+
resolved against `destination` -- the `--destination` value, or
|
|
75
|
+
`sdk_cmd._default_cache_root()`'s default, either way the caller's to
|
|
76
|
+
compute and pass in, since this module has no opinion on `~/.alp`
|
|
77
|
+
resolution (the same division `sdk_default_registry` already draws
|
|
78
|
+
against `sdk_discovery._home_alp_dir`).
|
|
79
|
+
|
|
80
|
+
A bare version name is joined onto `destination` UNCONDITIONALLY -- never
|
|
81
|
+
checked against what is actually installed there, because "does this
|
|
82
|
+
version exist" is exactly the idempotent-absence question the caller
|
|
83
|
+
answers next, not this one.
|
|
84
|
+
"""
|
|
85
|
+
if looks_like_path(raw_arg):
|
|
86
|
+
return RemovalTarget(Path(raw_arg), destination, False)
|
|
87
|
+
return RemovalTarget(destination / raw_arg, destination, True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_outside_cache_root(target: Path, destination: Path) -> bool:
|
|
91
|
+
"""Whether `target` sits outside `destination` -- the tan-cli#790 footgun
|
|
92
|
+
guard: "refuse anything outside the cache root unless an explicit path is
|
|
93
|
+
given and confirmed by --force". A bare version name can never trip this
|
|
94
|
+
(`resolve_removal_target` joins it onto `destination` itself), so callers
|
|
95
|
+
only need to ask this for the explicit-path arm.
|
|
96
|
+
|
|
97
|
+
Lexical containment (`os.path.abspath`, not `Path.resolve()`) for the same
|
|
98
|
+
reason `sdk_discovery._abs_posix` gives: cwd-anchored and stable for a
|
|
99
|
+
path that does not exist yet, which a removal target -- by definition,
|
|
100
|
+
once it is gone -- must still compare correctly against on a re-run.
|
|
101
|
+
`Path.is_relative_to` never touches the filesystem either (a PurePath-only
|
|
102
|
+
comparison of the already-lexically-normalised `abspath` strings), so a
|
|
103
|
+
Windows cross-drive pair (`C:\\x` vs `D:\\y`) answers plainly `False` --
|
|
104
|
+
"not relative to" -- rather than raising, the way `os.path.commonpath`
|
|
105
|
+
would on the same pair.
|
|
106
|
+
"""
|
|
107
|
+
root = Path(os.path.abspath(str(destination)))
|
|
108
|
+
candidate = Path(os.path.abspath(str(target)))
|
|
109
|
+
return not (candidate == root or candidate.is_relative_to(root))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_cache_root_itself(target: Path, destination: Path) -> bool:
|
|
113
|
+
"""Whether `target` resolves to `destination` EXACTLY -- the case
|
|
114
|
+
`is_outside_cache_root` treats as fine (`candidate == root` is
|
|
115
|
+
deliberately not "outside") but that is in fact the single most
|
|
116
|
+
destructive target this command can be pointed at: every install under
|
|
117
|
+
the cache root at once, not one of them. Found live, by measurement, not
|
|
118
|
+
by reading: `tan sdk remove .` from inside an otherwise-empty cwd, or
|
|
119
|
+
`tan sdk remove <dest> --destination <dest>`, deleted a two-version cache
|
|
120
|
+
root outright, `ok: true`, no `--force` required, because `--destination`
|
|
121
|
+
itself is never checked against `_load_bearing_reasons` (that ladder only
|
|
122
|
+
ever names a specific VERSION subdirectory, never the root that holds
|
|
123
|
+
them) and `is_outside_cache_root` explicitly exempts the equal case so a
|
|
124
|
+
caller CAN still name the root on purpose elsewhere.
|
|
125
|
+
|
|
126
|
+
Same lexical-abspath comparison as `is_outside_cache_root`, for the same
|
|
127
|
+
reason (stable for a target that will not exist once removed).
|
|
128
|
+
"""
|
|
129
|
+
root = Path(os.path.abspath(str(destination)))
|
|
130
|
+
candidate = Path(os.path.abspath(str(target)))
|
|
131
|
+
return candidate == root
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── would removing THIS take out THAT? (tan-cli#1053) ───────────────────────
|
|
135
|
+
#
|
|
136
|
+
# Every load-bearing `sdk remove` refusal asks one question -- "would deleting
|
|
137
|
+
# the target destroy this other thing?" -- and through `dev` asked it four
|
|
138
|
+
# separate times with a plain string `==` between two spellings. Every one of
|
|
139
|
+
# those failed in the UNSAFE direction: a miss does not merely skip a tidy-up,
|
|
140
|
+
# it means the refusal never fires, `remove` proceeds without `--force`, and
|
|
141
|
+
# the install another project still points at is silently orphaned -- exactly
|
|
142
|
+
# the outcome tan-cli#790's first design bar exists to prevent.
|
|
143
|
+
#
|
|
144
|
+
# The question is ASYMMETRIC, which is why the helper below is not called
|
|
145
|
+
# "same directory" and does not take two interchangeable operands. `remove_dir`
|
|
146
|
+
# unlinks a symlink it is handed and never follows it, so removing a LINK
|
|
147
|
+
# destroys nothing behind the link, while removing the real directory a link
|
|
148
|
+
# points AT does orphan whoever resolves through that link. One direction is
|
|
149
|
+
# load-bearing and the other is not, and a symmetric predicate gets one of them
|
|
150
|
+
# wrong -- measured, in review, on the first version of this code (see the
|
|
151
|
+
# `islink` arm below).
|
|
152
|
+
#
|
|
153
|
+
# THREE ARMS, in this order.
|
|
154
|
+
#
|
|
155
|
+
# 1. `os.path.normcase` -- the stdlib's own platform-aware spelling fold. On
|
|
156
|
+
# Windows (`ntpath`) it lowercases AND flips separators, so
|
|
157
|
+
# `C:/Users/Me/sdk` and `c:/users/me/sdk` compare equal there; on POSIX
|
|
158
|
+
# (`posixpath`) it is the IDENTITY, so `/home/me/sdk` and `/home/Me/sdk`
|
|
159
|
+
# stay the two genuinely different directories they really are. That is
|
|
160
|
+
# why the fold belongs here and NOT inside
|
|
161
|
+
# `sdk_default_registry.normalized_sdk_path`, whose own docstring makes
|
|
162
|
+
# the same argument: that helper folds SEPARATORS, and a case fold there
|
|
163
|
+
# would simply be wrong on POSIX. Checked FIRST and unconditionally, so
|
|
164
|
+
# naming the identical path always answers True -- including when that
|
|
165
|
+
# path is itself a link, which the next arm would otherwise veto.
|
|
166
|
+
# 2. `os.path.islink(target)` -- switch to `os.path.samestat` over two
|
|
167
|
+
# `os.lstat`s, which follows NEITHER final component. Removing a symlink
|
|
168
|
+
# unlinks the link and leaves everything behind it exactly where it was
|
|
169
|
+
# (`dir_removal.remove_dir`, and `compute_tree_bytes` charging the link's
|
|
170
|
+
# own `lstat` size, are already built on that same fact), so the only
|
|
171
|
+
# thing such a removal CAN take out is that same link under another
|
|
172
|
+
# spelling. Without this arm the `samefile` arm below fires in the wrong
|
|
173
|
+
# direction: measured on the first version of this change, a cache
|
|
174
|
+
# holding `v0.19.0` plus `current -> v0.19.0` with the workspace pinned
|
|
175
|
+
# at the REAL directory refused `tan sdk remove <cache>/current` as "the
|
|
176
|
+
# active alp-sdk for this workspace", and reported `data.wasActive:
|
|
177
|
+
# true`, in the very same envelope whose `resolvesToAfter` said the
|
|
178
|
+
# workspace still resolved at `projectPin` to a live SDK. `--force` then
|
|
179
|
+
# proved the refusal empty: the link was unlinked and both the install
|
|
180
|
+
# and the pin survived untouched.
|
|
181
|
+
#
|
|
182
|
+
# A blanket `return False` here was the SECOND version, and it was too
|
|
183
|
+
# wide in the other direction (review, round 2): with `cache/current ->
|
|
184
|
+
# v0.19.0` and `alias -> cache`, a workspace pinned at
|
|
185
|
+
# `<T>/alias/current` -- the SAME link, spelled through the alias -- lost
|
|
186
|
+
# it to `tan sdk remove <cache>/current` with no `--force` at all, going
|
|
187
|
+
# `projectPin` -> `none`. `samestat` over `lstat` keeps the harmless
|
|
188
|
+
# case allowed and refuses that one: two spellings of one LINK share an
|
|
189
|
+
# inode, a link and its own target do not.
|
|
190
|
+
# 3. `os.path.samefile` -- consulted only when the lexical arm missed and
|
|
191
|
+
# the target is not a link. `normcase` alone is NOT the fix, because it
|
|
192
|
+
# is the identity on darwin too while macOS's DEFAULT APFS volume is
|
|
193
|
+
# case-INSENSITIVE: the maintainer laptop this was measured on would
|
|
194
|
+
# still have orphaned the project. A blanket "fold on darwin" would be
|
|
195
|
+
# wrong in the other direction (macOS can be formatted case-sensitively),
|
|
196
|
+
# and probing the volume by writing a differently-cased test file mutates
|
|
197
|
+
# a filesystem this command is about to delete from, needs write
|
|
198
|
+
# permission it may not have, and cannot answer for a path that is
|
|
199
|
+
# already gone. `samefile` IS that probe, narrowed to exactly the pair
|
|
200
|
+
# being compared and mutating nothing: identical `st_dev`/`st_ino` is the
|
|
201
|
+
# filesystem's own answer, correct on a case-sensitive volume (two real
|
|
202
|
+
# directories have two inodes) and on a case-insensitive one alike.
|
|
203
|
+
#
|
|
204
|
+
# WHAT IT STILL GETS WRONG, stated rather than discovered later:
|
|
205
|
+
#
|
|
206
|
+
# * `samefile` RAISES for a path that does not exist -- and a removal target
|
|
207
|
+
# legitimately stops existing partway through this command -- so that arm
|
|
208
|
+
# degrades to the lexical answer rather than propagating. On a
|
|
209
|
+
# case-insensitive volume a comparison against an ALREADY-ABSENT path
|
|
210
|
+
# still misses. Narrow by construction: `_run_remove` only reaches its
|
|
211
|
+
# load-bearing ladder after `os.path.lexists(target)`, and a counterpart
|
|
212
|
+
# that no longer exists is not load-bearing -- removing the target cannot
|
|
213
|
+
# orphan it any further than it already is.
|
|
214
|
+
# * The `samefile` arm is skipped entirely unless BOTH sides are absolute
|
|
215
|
+
# (under either platform's flavour, since the registry it compares values
|
|
216
|
+
# from is one file shared by every `tan` on the host). A relative stored
|
|
217
|
+
# value would otherwise be anchored to the REMOVING process's cwd and
|
|
218
|
+
# invent a match its writer never wrote -- the precise hazard
|
|
219
|
+
# `normalized_sdk_path` refuses `_abs_posix` for.
|
|
220
|
+
# * The `islink` veto looks only at the target's FINAL component, exactly as
|
|
221
|
+
# `remove_dir` does. An INTERMEDIATE link in the target's path
|
|
222
|
+
# (`<cache-link>/v0.19.0`, where `cache-link -> cache`) is therefore not
|
|
223
|
+
# vetoed, and correctly so: removing that path really does delete the real
|
|
224
|
+
# `v0.19.0` directory and really would orphan whoever points at it.
|
|
225
|
+
# * `samefile` still FOLLOWS symlinks on the CANDIDATE side. An install
|
|
226
|
+
# whose pin/registry entry is spelled through a link now matches the real
|
|
227
|
+
# directory being removed -- a widening in the SAFE direction (removing it
|
|
228
|
+
# really would orphan that workspace), but a behaviour change: `--force`
|
|
229
|
+
# is required there where a plain `==` let it through.
|
|
230
|
+
# * `ntpath.normcase` folds case unconditionally, including inside a
|
|
231
|
+
# directory carrying Windows' per-directory case-sensitivity flag
|
|
232
|
+
# (`fsutil file setCaseSensitiveInfo`). Two genuinely distinct
|
|
233
|
+
# directories there compare equal and the removal refuses -- an
|
|
234
|
+
# over-refusal, recoverable with `--force`, taken over the silent-orphan
|
|
235
|
+
# alternative.
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def removal_would_take_out(candidate: str, target: str) -> bool:
|
|
239
|
+
"""Whether removing `target` would destroy `candidate` -- the single
|
|
240
|
+
comparison every load-bearing `sdk remove` refusal makes, replacing five
|
|
241
|
+
independent `==`. ASYMMETRIC on purpose: `removal_would_take_out(link,
|
|
242
|
+
real_dir)` is True while `removal_would_take_out(real_dir, link)` is
|
|
243
|
+
False, because unlinking a link destroys nothing behind it -- while two
|
|
244
|
+
spellings of the SAME link are still one thing, and still True. See the
|
|
245
|
+
section banner above for the three arms and for what this still gets
|
|
246
|
+
wrong.
|
|
247
|
+
"""
|
|
248
|
+
if os.path.normcase(candidate) == os.path.normcase(target):
|
|
249
|
+
return True
|
|
250
|
+
if not (is_absolute_either_platform(candidate) and is_absolute_either_platform(target)):
|
|
251
|
+
return False
|
|
252
|
+
if _nested_inside(candidate, target):
|
|
253
|
+
# Removing a directory removes everything under it: a candidate that
|
|
254
|
+
# LIVES INSIDE the target (`sdk remove sdk-cache` while
|
|
255
|
+
# `sdk-cache/v0.19.0` is pinned) is destroyed with it.
|
|
256
|
+
return True
|
|
257
|
+
try:
|
|
258
|
+
if os.path.islink(target):
|
|
259
|
+
# Removing a link unlinks the LINK, so the only thing this
|
|
260
|
+
# removal can take out is that same link under another spelling
|
|
261
|
+
# -- `lstat` on BOTH sides, which follows neither final
|
|
262
|
+
# component, and never `samefile`, which follows both.
|
|
263
|
+
return os.path.samestat(os.lstat(candidate), os.lstat(target))
|
|
264
|
+
return os.path.samefile(candidate, target)
|
|
265
|
+
except (OSError, ValueError):
|
|
266
|
+
# Missing, unreadable, or containing a NUL -- the same best-effort
|
|
267
|
+
# degrade every other filesystem read in this module applies. `False`
|
|
268
|
+
# here is the lexical arm's answer, already computed above.
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _lex(path: str) -> str:
|
|
273
|
+
return os.path.normcase(path).replace("\\", "/").rstrip("/")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _lexically_inside(child: str, parent: str) -> bool:
|
|
277
|
+
"""`child` is strictly below `parent`, compared on whole components."""
|
|
278
|
+
c, p = _lex(child), _lex(parent)
|
|
279
|
+
return bool(p) and c != p and c.startswith(p + "/")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _nested_inside(child: str, parent: str) -> bool:
|
|
283
|
+
"""`child` is strictly below the real directory `parent` -- lexically, or
|
|
284
|
+
after resolving symlinks in either spelling. A `parent` that is itself a
|
|
285
|
+
link is never an ancestor: unlinking it destroys nothing behind it."""
|
|
286
|
+
if _lexically_inside(child, parent):
|
|
287
|
+
return True
|
|
288
|
+
try:
|
|
289
|
+
if os.path.islink(parent):
|
|
290
|
+
return False
|
|
291
|
+
return _lexically_inside(os.path.realpath(child), os.path.realpath(parent))
|
|
292
|
+
except (OSError, ValueError):
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def removal_would_damage(candidate: str, target: str) -> bool:
|
|
297
|
+
"""Whether removing `target` destroys `candidate` OR cuts a piece out of
|
|
298
|
+
it: `target` is a strict subdirectory of the load-bearing `candidate`
|
|
299
|
+
(`sdk remove ~/.alp/sdk-cache/v0.19.0/metadata` against a pinned
|
|
300
|
+
`v0.19.0`). Leaves the install half-present, so it refuses like any other
|
|
301
|
+
orphaning removal."""
|
|
302
|
+
if removal_would_take_out(candidate, target):
|
|
303
|
+
return True
|
|
304
|
+
if not (is_absolute_either_platform(candidate) and is_absolute_either_platform(target)):
|
|
305
|
+
return False
|
|
306
|
+
if _lexically_inside(target, candidate):
|
|
307
|
+
return True
|
|
308
|
+
try:
|
|
309
|
+
if os.path.islink(target):
|
|
310
|
+
return False
|
|
311
|
+
return _lexically_inside(os.path.realpath(target), os.path.realpath(candidate))
|
|
312
|
+
except (OSError, ValueError):
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def compute_tree_bytes(path: Path) -> int:
|
|
317
|
+
"""Best-effort total size of `path` -- a single file/symlink's own size, or
|
|
318
|
+
the sum of every regular file under it. `0` for anything that does not
|
|
319
|
+
exist or that this process cannot even list; a size this process could not
|
|
320
|
+
fully account for is reported as the smaller, honest number rather than
|
|
321
|
+
guessed upward.
|
|
322
|
+
|
|
323
|
+
`os.lstat`, not `os.stat`: a symlink is charged for ITS OWN size, never
|
|
324
|
+
the size of whatever it points at, which may sit outside this tree
|
|
325
|
+
entirely (or, if it is a self-referencing loop, infinitely) and must not
|
|
326
|
+
be charged against a removal that will not touch it either --
|
|
327
|
+
`dir_removal.remove_dir` never follows a link out of the tree, and this
|
|
328
|
+
accounting stays consistent with that.
|
|
329
|
+
|
|
330
|
+
Per-entry errors (a permission-denied file, a vanished entry raced by a
|
|
331
|
+
concurrent process) are swallowed via `onerror`/skip rather than aborting
|
|
332
|
+
the whole walk -- this is an advisory `data.freedBytes` figure, not a
|
|
333
|
+
number anything downstream keys a decision on, so a partial count beats no
|
|
334
|
+
count.
|
|
335
|
+
"""
|
|
336
|
+
try:
|
|
337
|
+
if path.is_symlink() or path.is_file():
|
|
338
|
+
return os.lstat(path).st_size
|
|
339
|
+
except OSError:
|
|
340
|
+
return 0
|
|
341
|
+
total = 0
|
|
342
|
+
for root, _dirs, files in os.walk(path, onerror=lambda _err: None):
|
|
343
|
+
for name in files:
|
|
344
|
+
try:
|
|
345
|
+
total += os.lstat(os.path.join(root, name)).st_size
|
|
346
|
+
except OSError:
|
|
347
|
+
continue
|
|
348
|
+
return total
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
#: `OSError.errno` values that mean "a process holds this open right now",
|
|
352
|
+
#: not "permission denied" -- POSIX's own distinction between the two verbs
|
|
353
|
+
#: the tan-cli#790 issue asks to keep apart. `ETXTBSY` is the specific case of
|
|
354
|
+
#: a running interpreter/executable inside the tree (a workspace venv's own
|
|
355
|
+
#: `python`), which is exactly the shape a `tan`-managed SDK cache can hit.
|
|
356
|
+
_IN_USE_ERRNOS = frozenset({errno.EBUSY, errno.ETXTBSY})
|
|
357
|
+
|
|
358
|
+
#: The Windows sibling: `ERROR_SHARING_VIOLATION` (another process opened the
|
|
359
|
+
#: file without share-delete) and `ERROR_LOCK_VIOLATION` (a byte-range lock
|
|
360
|
+
#: held on it). Both are "something has this open", never "the ACL forbids
|
|
361
|
+
#: it" -- `ERROR_ACCESS_DENIED` (5) is the permission arm and is NOT in this
|
|
362
|
+
#: set, so it falls through to the default classification below.
|
|
363
|
+
_IN_USE_WINERRORS = frozenset({32, 33})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def classify_removal_error(err: OSError) -> str:
|
|
367
|
+
"""`"in-use"` (something holds a handle) or `"permission"` (an ACL/mode
|
|
368
|
+
bit `dir_removal.remove_dir`'s own read-only retry could not clear) --
|
|
369
|
+
the tan-cli#790 issue's own two verdicts, and the whole reason this
|
|
370
|
+
function exists rather than one generic `sdk.remove.failed`: "distinguish
|
|
371
|
+
the two failure modes ... a wrong verdict sends them hunting a holder
|
|
372
|
+
that does not exist" (the issue's own words, restated in its own
|
|
373
|
+
follow-up comment as the part that survived the Windows-readonly
|
|
374
|
+
correction unchanged).
|
|
375
|
+
|
|
376
|
+
Checked in this order deliberately: `winerror`, when present, is the more
|
|
377
|
+
specific signal a Windows `OSError` carries alongside a translated
|
|
378
|
+
`errno` that may not agree with it (the same precedence
|
|
379
|
+
`bootstrap_cmd._origin_exists` already gives `winerror` over `errno` for
|
|
380
|
+
an unrelated classification, and `dir_removal.os_error_text` gives it for
|
|
381
|
+
display) -- so it is consulted first, and only a `winerror` this function
|
|
382
|
+
does not recognise falls through to the errno table, never the reverse.
|
|
383
|
+
"""
|
|
384
|
+
winerror = getattr(err, "winerror", None)
|
|
385
|
+
if winerror is not None:
|
|
386
|
+
if winerror in _IN_USE_WINERRORS:
|
|
387
|
+
return "in-use"
|
|
388
|
+
return "permission"
|
|
389
|
+
if err.errno in _IN_USE_ERRNOS:
|
|
390
|
+
return "in-use"
|
|
391
|
+
return "permission"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _win_long_path(path: Path) -> str:
|
|
395
|
+
"""`path`, prefixed for Windows' `\\\\?\\` extended-length namespace,
|
|
396
|
+
which lifts the legacy 260-character `MAX_PATH` ceiling outright instead
|
|
397
|
+
of merely detecting it after the fact -- the tan-cli#790 issue's own
|
|
398
|
+
closing note calls exactly this shape of tree ("about 3 GB and deep --
|
|
399
|
+
`modules/`, `.venv`, `zephyr`") the one "exactly the shape that trips it
|
|
400
|
+
on a host without long-path support", and a removal is the one operation
|
|
401
|
+
where working around the limit is strictly better than reporting it: a
|
|
402
|
+
`tan sdk install` that got the tree onto disk in the first place already
|
|
403
|
+
proved the HOST can hold these paths, so what usually cannot re-open them
|
|
404
|
+
afterwards is the WIN32 (not NT) API surface a bare `os.remove`/`os.rmdir`
|
|
405
|
+
goes through -- the very thing this prefix routes around, before the fact,
|
|
406
|
+
for every path this removal touches.
|
|
407
|
+
|
|
408
|
+
A no-op on every other platform, and a no-op for a value that is already
|
|
409
|
+
prefixed (a caller-supplied path already spelled in the extended-length
|
|
410
|
+
or UNC-extended form) or is a UNC share (`\\\\server\\share\\...`, which
|
|
411
|
+
gets its OWN prefix form, `\\\\?\\UNC\\server\\share\\...`, not a bare
|
|
412
|
+
`\\\\?\\` glued onto two leading backslashes).
|
|
413
|
+
"""
|
|
414
|
+
if os.name != "nt":
|
|
415
|
+
return str(path)
|
|
416
|
+
absolute = os.path.abspath(str(path))
|
|
417
|
+
if absolute.startswith("\\\\?\\"):
|
|
418
|
+
return absolute
|
|
419
|
+
if absolute.startswith("\\\\"):
|
|
420
|
+
return "\\\\?\\UNC\\" + absolute[2:]
|
|
421
|
+
return "\\\\?\\" + absolute
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass(frozen=True)
|
|
425
|
+
class RemovalOutcome:
|
|
426
|
+
"""What actually happened when [`remove_sdk_tree`] tried. `ok=False`
|
|
427
|
+
always carries a `kind` (`"in-use"` | `"permission"`) and the OS-rendered
|
|
428
|
+
`detail`; the failing `path`, when the underlying `OSError` named one
|
|
429
|
+
(`err.filename`), else `None` -- a hook-driven `shutil.rmtree` failure
|
|
430
|
+
does not always carry one."""
|
|
431
|
+
|
|
432
|
+
ok: bool
|
|
433
|
+
freed_bytes: int
|
|
434
|
+
kind: str | None = None
|
|
435
|
+
detail: str | None = None
|
|
436
|
+
failing_path: str | None = None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def remove_sdk_tree(path: Path) -> RemovalOutcome:
|
|
440
|
+
"""Remove `path` (already confirmed to exist by the caller) with
|
|
441
|
+
`dir_removal.remove_dir`'s read-only-retry/junction-safe hardening, sized
|
|
442
|
+
before and (on a failure) after, so a partial removal still reports how
|
|
443
|
+
much it actually freed rather than zero.
|
|
444
|
+
|
|
445
|
+
Long-path-prefixed on Windows ([`_win_long_path`]) so the size this
|
|
446
|
+
process just proved it could walk is also one this process can delete,
|
|
447
|
+
without the caller ever seeing the raw `MAX_PATH` errno this would
|
|
448
|
+
otherwise surface as an unclassified `"permission"` failure.
|
|
449
|
+
"""
|
|
450
|
+
before = compute_tree_bytes(path)
|
|
451
|
+
try:
|
|
452
|
+
remove_dir(_win_long_path(path))
|
|
453
|
+
except (OSError, ValueError) as err:
|
|
454
|
+
after = compute_tree_bytes(path) if path.exists() else 0
|
|
455
|
+
failing_path = getattr(err, "filename", None) if isinstance(err, OSError) else None
|
|
456
|
+
kind = classify_removal_error(err) if isinstance(err, OSError) else "permission"
|
|
457
|
+
detail = os_error_text(err) if isinstance(err, OSError) else str(err)
|
|
458
|
+
return RemovalOutcome(
|
|
459
|
+
ok=False,
|
|
460
|
+
freed_bytes=max(before - after, 0),
|
|
461
|
+
kind=kind,
|
|
462
|
+
detail=detail,
|
|
463
|
+
failing_path=failing_path,
|
|
464
|
+
)
|
|
465
|
+
return RemovalOutcome(ok=True, freed_bytes=before)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Input and baud-change actions for `tan monitor --capture` (tan-cli#1451).
|
|
3
|
+
|
|
4
|
+
Pure logic over an already-open port-like object: the `--send` queue (sent
|
|
5
|
+
once, as a burst, at the start / after a delay / on the first line matching a
|
|
6
|
+
regex) and the `--reopen-at BAUD --on REGEX` baud switch (close the port and
|
|
7
|
+
reopen it at the new rate on the first match). The port is opened by the
|
|
8
|
+
caller; reopening goes through the injected `reopen(baud)` callable so this
|
|
9
|
+
module never imports pyserial and works the same over a local tty and a
|
|
10
|
+
pyserial URL such as `rfc2217://`. Clock and sleep are injected for tests.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
#: Pause between two `--send` items so a firmware that polls one key per tick
|
|
22
|
+
#: sees them separately.
|
|
23
|
+
DEFAULT_SEND_GAP_S = 0.2
|
|
24
|
+
#: Fallback reopen (only when the in-place baud change raises): tries x pause.
|
|
25
|
+
REOPEN_TRIES = 5
|
|
26
|
+
REOPEN_RETRY_S = 0.2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ActionError(Exception):
|
|
30
|
+
"""A send or reopen failed; `kind` is `send` or `reopen`."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, kind: str, message: str) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.kind = kind
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ActionSpec:
|
|
39
|
+
sends: tuple[bytes, ...] = ()
|
|
40
|
+
send_after_s: float | None = None
|
|
41
|
+
send_on: re.Pattern[str] | None = None
|
|
42
|
+
send_gap_s: float = DEFAULT_SEND_GAP_S
|
|
43
|
+
reopen_baud: int | None = None
|
|
44
|
+
reopen_on: re.Pattern[str] | None = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def watches_lines(self) -> bool:
|
|
48
|
+
return self.send_on is not None or self.reopen_on is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_write(port: Any, data: bytes) -> None:
|
|
52
|
+
port.write(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class CaptureActions:
|
|
57
|
+
"""The mutable run state. `port` is the CURRENT port: the capture loop reads
|
|
58
|
+
it each iteration, because a reopen replaces it."""
|
|
59
|
+
|
|
60
|
+
spec: ActionSpec
|
|
61
|
+
port: Any
|
|
62
|
+
reopen: Callable[[int], Any]
|
|
63
|
+
write: Callable[[Any, bytes], None] = _default_write
|
|
64
|
+
clock: Callable[[], float] = time.monotonic
|
|
65
|
+
sleep: Callable[[float], None] = time.sleep
|
|
66
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
67
|
+
_start: float = field(init=False)
|
|
68
|
+
_sent: bool = False
|
|
69
|
+
_reopened: bool = False
|
|
70
|
+
_flushed: bool = False
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
self._start = self.clock()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def sends_pending(self) -> int:
|
|
77
|
+
return 0 if self._sent else len(self.spec.sends)
|
|
78
|
+
|
|
79
|
+
def _at(self) -> float:
|
|
80
|
+
return round(self.clock() - self._start, 3)
|
|
81
|
+
|
|
82
|
+
def tick(self) -> None:
|
|
83
|
+
"""Fire the unconditional / timed send when it is due."""
|
|
84
|
+
spec = self.spec
|
|
85
|
+
if self._sent or not spec.sends or spec.send_on is not None:
|
|
86
|
+
return
|
|
87
|
+
if spec.send_after_s is None or self.clock() - self._start >= spec.send_after_s:
|
|
88
|
+
self._send()
|
|
89
|
+
|
|
90
|
+
def take_flush(self) -> bool:
|
|
91
|
+
"""True once after a baud change: the caller must drop its partial-line
|
|
92
|
+
buffer and the rest of the current read, which are old-baud garbage."""
|
|
93
|
+
flushed, self._flushed = self._flushed, False
|
|
94
|
+
return flushed
|
|
95
|
+
|
|
96
|
+
def line(self, text: str, partial: bool = False) -> None:
|
|
97
|
+
"""Offer a complete or partial line; each regex fires at most once.
|
|
98
|
+
`--send-on` also matches an unterminated partial line (a prompt);
|
|
99
|
+
`--on` (the baud switch) matches COMPLETE lines only, so a half-received
|
|
100
|
+
line is never taken for the trigger."""
|
|
101
|
+
spec = self.spec
|
|
102
|
+
if spec.sends and not self._sent and spec.send_on is not None and spec.send_on.search(text):
|
|
103
|
+
self._send()
|
|
104
|
+
if (
|
|
105
|
+
spec.reopen_baud is not None
|
|
106
|
+
and not self._reopened
|
|
107
|
+
and not partial
|
|
108
|
+
and spec.reopen_on is not None
|
|
109
|
+
and spec.reopen_on.search(text)
|
|
110
|
+
):
|
|
111
|
+
self._reopen(spec.reopen_baud, text)
|
|
112
|
+
|
|
113
|
+
def _send(self) -> None:
|
|
114
|
+
self._sent = True
|
|
115
|
+
total = 0
|
|
116
|
+
try:
|
|
117
|
+
for i, item in enumerate(self.spec.sends):
|
|
118
|
+
if i:
|
|
119
|
+
self.sleep(self.spec.send_gap_s)
|
|
120
|
+
self.write(self.port, item)
|
|
121
|
+
total += len(item)
|
|
122
|
+
flush = getattr(self.port, "flush", None)
|
|
123
|
+
if callable(flush):
|
|
124
|
+
flush()
|
|
125
|
+
except OSError as err:
|
|
126
|
+
self.events.append(
|
|
127
|
+
{"action": "send", "atSeconds": self._at(), "failed": True, "bytes": total,
|
|
128
|
+
"error": str(err)}
|
|
129
|
+
)
|
|
130
|
+
raise ActionError("send", f"writing --send input failed: {err}") from err
|
|
131
|
+
self.events.append(
|
|
132
|
+
{"action": "send", "atSeconds": self._at(), "items": len(self.spec.sends), "bytes": total}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _reopen(self, baud: int, matched: str) -> None:
|
|
136
|
+
"""Change the baud on the first match. Preferred: in place
|
|
137
|
+
(`port.baudrate = N`, supported by local serial and pyserial's
|
|
138
|
+
rfc2217://), so the link never closes and no byte is lost to a reopen
|
|
139
|
+
gap. Only if that raises: close, then reopen with a bounded retry
|
|
140
|
+
(`REOPEN_TRIES` x `REOPEN_RETRY_S`). Residual gap on the fallback path:
|
|
141
|
+
bytes sent while the port is closed are lost, and a ser2net session
|
|
142
|
+
may take a moment to accept the new connection."""
|
|
143
|
+
self._reopened = True
|
|
144
|
+
method = "in-place"
|
|
145
|
+
try:
|
|
146
|
+
self.port.baudrate = baud
|
|
147
|
+
drop = getattr(self.port, "reset_input_buffer", None)
|
|
148
|
+
if callable(drop):
|
|
149
|
+
drop() # old-baud garbage still queued in the driver
|
|
150
|
+
except Exception: # noqa: BLE001 -- any refusal falls back to a reopen
|
|
151
|
+
method = "reopen"
|
|
152
|
+
self._close_and_open(baud)
|
|
153
|
+
self._flushed = True
|
|
154
|
+
self.events.append(
|
|
155
|
+
{"action": "reopen", "atSeconds": self._at(), "baud": baud, "method": method,
|
|
156
|
+
"matchedLine": matched[-256:]}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _close_and_open(self, baud: int) -> None:
|
|
160
|
+
try:
|
|
161
|
+
self.port.close()
|
|
162
|
+
except Exception: # noqa: BLE001 -- closing a dead link is not the failure being reported
|
|
163
|
+
pass
|
|
164
|
+
last: Exception | None = None
|
|
165
|
+
for attempt in range(REOPEN_TRIES):
|
|
166
|
+
if attempt:
|
|
167
|
+
self.sleep(REOPEN_RETRY_S)
|
|
168
|
+
try:
|
|
169
|
+
self.port = self.reopen(baud)
|
|
170
|
+
return
|
|
171
|
+
except Exception as err: # noqa: BLE001 -- any open failure is retried
|
|
172
|
+
last = err
|
|
173
|
+
raise ActionError(
|
|
174
|
+
"reopen", f"reopening the port at {baud} baud failed after {REOPEN_TRIES} tries: {last}"
|
|
175
|
+
) from last
|