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/text_layout.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""One wrapping seam every text-mode command can share.
|
|
3
|
+
|
|
4
|
+
Measured across the 24 read-only commands: 13 emit at least one line over
|
|
5
|
+
100 columns (`doctor` 557, `monitor` 362, `size` 267, ...), and only
|
|
6
|
+
`build_cmd` (via stdlib `textwrap`) wraps anything at all. `doctor` is the
|
|
7
|
+
first command to be fixed; this module is the reusable seam the rest of that
|
|
8
|
+
sweep points at afterwards, so it carries no `doctor` concept -- a caller
|
|
9
|
+
supplies its own prefix and hanging indent, this just wraps text under them.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import textwrap
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def wrap_block(body: str, width: int, initial_indent: str, hanging_indent: str) -> list[str]:
|
|
17
|
+
"""Wrap `body` to `width` columns, `initial_indent` prefixing the first
|
|
18
|
+
line and `hanging_indent` every continuation line -- so a continuation
|
|
19
|
+
never starts at column 0 and reads as subordinate to the line above it.
|
|
20
|
+
|
|
21
|
+
`width` is a target, not a hard ceiling: `break_long_words=False` (see
|
|
22
|
+
below) means a single token that, plus whichever indent frames it, is
|
|
23
|
+
alone longer than `width` -- a SKU, a long flag, a path -- comes back
|
|
24
|
+
over-length and intact on its own line rather than being cut mid-
|
|
25
|
+
character. Every OTHER line this returns does fit `width`; a caller that
|
|
26
|
+
needs a hard cap on every line cannot assume that of this one.
|
|
27
|
+
|
|
28
|
+
Always returns at least one line (`[initial_indent]` for an empty
|
|
29
|
+
`body`), so a caller building `"{prefix}{first_wrapped_chunk}"` never has
|
|
30
|
+
to special-case an empty result.
|
|
31
|
+
"""
|
|
32
|
+
# `break_on_hyphens`/`break_long_words` default True, which is wrong for
|
|
33
|
+
# this seam: this repo's house rule is that SKUs, part numbers, flags and
|
|
34
|
+
# paths are reproduced verbatim, and stdlib's defaults mangle exactly
|
|
35
|
+
# those -- `arm-zephyr-eabi` splits into `arm-` / `zephyr-eabi`,
|
|
36
|
+
# `--sdk-root` into `--sdk-` / `root`. Both off trades a hard `width`
|
|
37
|
+
# ceiling for that guarantee -- see the docstring above for the resulting
|
|
38
|
+
# overflow contract.
|
|
39
|
+
wrapped = textwrap.wrap(
|
|
40
|
+
body,
|
|
41
|
+
width=width,
|
|
42
|
+
initial_indent=initial_indent,
|
|
43
|
+
subsequent_indent=hanging_indent,
|
|
44
|
+
break_on_hyphens=False,
|
|
45
|
+
break_long_words=False,
|
|
46
|
+
)
|
|
47
|
+
return wrapped or [initial_indent]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wrap_lines(lines: list[str], width: int | None, hanging_indent: str = " ") -> list[str]:
|
|
51
|
+
"""Wrap every line in `lines` independently through `wrap_block`, no
|
|
52
|
+
prefix added to a first line (a caller that wants a bullet or label puts
|
|
53
|
+
it in the line's own text before calling this) and `hanging_indent` on
|
|
54
|
+
any continuation line it produces.
|
|
55
|
+
|
|
56
|
+
`width is None` -- stderr is not a terminal, see `tan.env.wrap_width` --
|
|
57
|
+
returns `lines` UNCHANGED: piped/redirected output must reproduce a
|
|
58
|
+
command's report byte-for-byte, which is what every existing
|
|
59
|
+
subprocess/golden test for `explain`/`sdk current` depends on. There is
|
|
60
|
+
no per-line classification here (an earlier version of this seam kept a
|
|
61
|
+
table of "record-shaped" lines exempt from wrapping, on the theory that
|
|
62
|
+
a piped reader might grep them) -- that reader can never observe a
|
|
63
|
+
wrapped line in the first place, since a pipe makes stderr not a tty and
|
|
64
|
+
this function returns `lines` verbatim in that case. Wrapping applies
|
|
65
|
+
uniformly instead; `break_long_words=False` on `wrap_block` already
|
|
66
|
+
keeps a long path/id/SKU intact on its own line rather than mangled.
|
|
67
|
+
"""
|
|
68
|
+
if width is None:
|
|
69
|
+
return lines
|
|
70
|
+
out: list[str] = []
|
|
71
|
+
for line in lines:
|
|
72
|
+
out.extend(wrap_block(line, width, "", hanging_indent))
|
|
73
|
+
return out
|
tan/core/timestamp.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The port's `generatedAt`/`updatedAt` timestamp helpers. Both NEVER raise,
|
|
3
|
+
and both render the SAME stamp on every platform.
|
|
4
|
+
|
|
5
|
+
`generated_at_iso` lets `SOURCE_DATE_EPOCH` win over the clock, so a captured
|
|
6
|
+
envelope -- or a written `.alp/sdk-path` -- is reproducible. Counterpart of
|
|
7
|
+
`crate::util::generated_at_iso` + `tan_core::clock::format_iso8601_utc`.
|
|
8
|
+
`wall_clock_iso` (below `generated_at_iso`) deliberately does NOT: it is for
|
|
9
|
+
the one field whose job is to order two REAL writes against each other on one
|
|
10
|
+
host, where `SOURCE_DATE_EPOCH` reproducibility would defeat the field
|
|
11
|
+
instead of serving it. See its docstring before reaching for it, or before
|
|
12
|
+
"fixing" a stamp that looks non-reproducible back onto `generated_at_iso`.
|
|
13
|
+
|
|
14
|
+
Shared rather than per-command because three commands each carried their own
|
|
15
|
+
copy of these four lines, ONE copy was hardened against an out-of-range epoch,
|
|
16
|
+
and the other two shipped the defect. A timestamp helper that throws is not
|
|
17
|
+
cosmetic in this port -- each caller breaks differently and none of them
|
|
18
|
+
harmlessly:
|
|
19
|
+
|
|
20
|
+
* `tan debug-config` calls it from the recovery path of its own exception
|
|
21
|
+
guard, so a throw DOUBLE-FAULTS: the first failure is caught, the recovery
|
|
22
|
+
re-raises, and the process dies with a raw traceback and EMPTY stdout --
|
|
23
|
+
precisely the break that guard exists to prevent.
|
|
24
|
+
* `tan init` calls it through `tan.core.scaffold.sdk_pointer_json` while
|
|
25
|
+
writing `.alp/sdk-path`, i.e. AFTER the customer's project files already
|
|
26
|
+
landed.
|
|
27
|
+
* `tan doctor` calls it inside its own try/except, so it degrades to
|
|
28
|
+
`doctor.internal-failure` instead -- a fabricated "this host is broken"
|
|
29
|
+
verdict on a host that is fine.
|
|
30
|
+
|
|
31
|
+
The realistic trigger is `SOURCE_DATE_EPOCH` in MILLISECONDS (1700000000000 is
|
|
32
|
+
year 55840, not 2023), and CI and reproducible-build environments are exactly
|
|
33
|
+
what set that variable.
|
|
34
|
+
|
|
35
|
+
**Out of range is REFUSED, not rendered, and the range is `datetime`'s -- year
|
|
36
|
+
[1; 9999] -- on every platform.** The arithmetic is deliberately `datetime`'s
|
|
37
|
+
and not `time.gmtime`'s: gmtime's range is the platform's `time_t`, so the same
|
|
38
|
+
env var used to render `55840-11-08T22:13:20Z` on glibc and fall back to the
|
|
39
|
+
wall clock on Windows, whose CRT rejects the year. One input, two answers, from
|
|
40
|
+
a variable whose entire purpose is reproducibility -- and `253402300799`
|
|
41
|
+
(9999-12-31T23:59:59Z) split the same way in the other direction. `datetime`'s
|
|
42
|
+
range is fixed by the language, so the fallback now happens on the same inputs
|
|
43
|
+
everywhere. Note this is still catch-not-predict: the OverflowError is caught,
|
|
44
|
+
never anticipated -- only the range that raises it is now portable.
|
|
45
|
+
|
|
46
|
+
**This deliberately DIVERGES from the Rust oracle, which renders the 5-digit
|
|
47
|
+
year.** `tan_core::clock::format_iso8601_utc` does its own proleptic-Gregorian
|
|
48
|
+
arithmetic (Hinnant's `civil_from_days`) and formats `{year:04}`, which widens
|
|
49
|
+
rather than truncates, so the Rust emits `55840-11-08T22:13:20.000Z` and cannot
|
|
50
|
+
fail. Matching that would mean hand-rolling the same civil-date algorithm here
|
|
51
|
+
to emit a stamp no consumer can read back: `%Y` is exactly four digits (see
|
|
52
|
+
`_strptime.TimeRE`), and ECMAScript's Date Time String Format spells a year
|
|
53
|
+
past 9999 as `+055840`, not `55840`. The wall clock is a wrong-but-parseable
|
|
54
|
+
answer to an epoch that was already nonsense; a 5-digit year is an unparseable
|
|
55
|
+
one. Fixing the shared bug beats bug-for-bug parity here -- if the Rust is ever
|
|
56
|
+
unfrozen, it should refuse the same range.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import os
|
|
62
|
+
import time
|
|
63
|
+
from datetime import datetime, timedelta, timezone
|
|
64
|
+
|
|
65
|
+
#: Both output shapes fall back to the Unix epoch itself: a wrong-but-shaped
|
|
66
|
+
#: stamp keeps the envelope parseable, which is the whole point.
|
|
67
|
+
_FALLBACK = "1970-01-01T00:00:00"
|
|
68
|
+
|
|
69
|
+
_UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _render(moment: datetime, *, millis: bool) -> str:
|
|
73
|
+
return (
|
|
74
|
+
moment.replace(tzinfo=None).isoformat(timespec="milliseconds" if millis else "seconds")
|
|
75
|
+
+ "Z"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generated_at_iso(*, millis: bool = False) -> str:
|
|
80
|
+
"""`SOURCE_DATE_EPOCH` when set and usable, else the wall clock, as
|
|
81
|
+
`YYYY-MM-DDTHH:MM:SSZ` -- or `YYYY-MM-DDTHH:MM:SS.mmmZ` with `millis=True`.
|
|
82
|
+
|
|
83
|
+
Two shapes because the callers' committed goldens pin two: `tan doctor`'s
|
|
84
|
+
`data.generatedAt` and `.alp/sdk-path`'s `updatedAt` carry seconds,
|
|
85
|
+
`tan debug-config`'s `data.generatedAt` carries milliseconds. Unifying the
|
|
86
|
+
WIRE shapes is a contract change plus a golden rewrite; unifying the code
|
|
87
|
+
behind them is not, so only the code is unified here.
|
|
88
|
+
|
|
89
|
+
An unparseable OR out-of-range value is the clock -- never an error,
|
|
90
|
+
because no caller of a timestamp is in a position to report one.
|
|
91
|
+
|
|
92
|
+
**For a field that ORDERS two writes against each other on ONE host,
|
|
93
|
+
use `wall_clock_iso` instead** -- this function's entire point is that a
|
|
94
|
+
captured envelope is reproducible, which is exactly wrong for a field
|
|
95
|
+
whose job is to tell two REAL writes apart.
|
|
96
|
+
"""
|
|
97
|
+
seconds: float | int = time.time()
|
|
98
|
+
raw = os.environ.get("SOURCE_DATE_EPOCH")
|
|
99
|
+
if raw is not None:
|
|
100
|
+
try:
|
|
101
|
+
# Kept as an `int`, never floated: `float(int("1" * 400))` raises
|
|
102
|
+
# OverflowError, which this helper promises never to let out.
|
|
103
|
+
seconds = int(raw.strip())
|
|
104
|
+
except ValueError:
|
|
105
|
+
pass
|
|
106
|
+
for candidate in (seconds, time.time()):
|
|
107
|
+
try:
|
|
108
|
+
moment = _UNIX_EPOCH + timedelta(seconds=candidate)
|
|
109
|
+
except (OverflowError, OSError, ValueError):
|
|
110
|
+
continue # outside year [1; 9999] -- try the clock
|
|
111
|
+
return _render(moment, millis=millis)
|
|
112
|
+
# The supplied stamp AND the wall clock are both unusable: still no throw.
|
|
113
|
+
return f"{_FALLBACK}.000Z" if millis else f"{_FALLBACK}Z"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def wall_clock_iso(*, millis: bool = False) -> str:
|
|
117
|
+
"""The literal wall clock, `SOURCE_DATE_EPOCH` deliberately NOT included --
|
|
118
|
+
same two wire shapes as `generated_at_iso`, same promise that it never
|
|
119
|
+
raises, but reproducibility is the WRONG promise for this one caller.
|
|
120
|
+
|
|
121
|
+
**The one caller is `tan.core.sdk_default_registry.with_entry`'s
|
|
122
|
+
`updated_at`**, via `bootstrap_cmd._write_global_sdk_registry`. That field
|
|
123
|
+
orders two REAL `tan bootstrap` runs against each other on ONE host's
|
|
124
|
+
mutable `~/.alp/sdk-defaults.json` -- it is machine-local runtime state,
|
|
125
|
+
never a build artefact, and nothing about it is meant to be byte-
|
|
126
|
+
reproducible across machines or CI runs. `SOURCE_DATE_EPOCH` winning here
|
|
127
|
+
would silently defeat the very field it stamps: every `tan bootstrap` run
|
|
128
|
+
inside one `SOURCE_DATE_EPOCH`-pinned shell -- exactly what CI and
|
|
129
|
+
reproducible-build setups export, see `generated_at_iso` above -- would
|
|
130
|
+
carry the IDENTICAL `updatedAt`, so `entry_updated_at > best_updated_at`
|
|
131
|
+
in `deepest_covering_entry` would never fire and resolution would fall
|
|
132
|
+
back to whichever raw origin string `dict`/`sort_keys=True` visits first
|
|
133
|
+
-- the exact accident-of-spelling tie tan-cli#904 second round fixed,
|
|
134
|
+
silently reopened by the one environment variable whose entire purpose is
|
|
135
|
+
determinism. Measured pre-fix, with `SOURCE_DATE_EPOCH` exported: two
|
|
136
|
+
bootstraps of DIFFERENT projects stamp the identical `updatedAt` and the
|
|
137
|
+
STALE alias entry answers every time, and lowering the variable BETWEEN
|
|
138
|
+
two runs makes the chronologically LATER bootstrap lose outright.
|
|
139
|
+
|
|
140
|
+
Wall-clock ordering is itself non-monotonic (an NTP step-back, a VM
|
|
141
|
+
snapshot restore) -- inherent, since this registry has no cross-process
|
|
142
|
+
monotonic key to use instead, so a clock that moves backward between two
|
|
143
|
+
real bootstraps can still tie-break the wrong way (review, #904 final
|
|
144
|
+
round, nit).
|
|
145
|
+
|
|
146
|
+
Every OTHER `generatedAt`/`updatedAt` field in this codebase -- all
|
|
147
|
+
EIGHT `generated_at_iso` call sites, enumerated (review, #904 final
|
|
148
|
+
round, nit -- an earlier revision of this list named 4 of the 8 and
|
|
149
|
+
called it exhaustive): `tan doctor` (`doctor_cmd.py`), `tan
|
|
150
|
+
debug-config` (`debug_config_cmd.py`), `.alp/sdk-path`
|
|
151
|
+
(`scaffold.sdk_pointer_json`), `<topdir>/.west/tan-workspace-sdk`
|
|
152
|
+
(`bootstrap.workspace_sdk_record_json`), the legacy
|
|
153
|
+
`~/.alp/sdk-default` pointer (`bootstrap_cmd.py`, the one sharing this
|
|
154
|
+
module's own resolution ladder), `tan inspect` (`inspect_cmd.py`), `tan
|
|
155
|
+
trace` (`trace_cmd.py`), and `tan support-bundle`'s two fields
|
|
156
|
+
(`support_bundle_cmd.py`) -- is informational display of a SINGLE
|
|
157
|
+
write, never compared against a sibling write, so `SOURCE_DATE_EPOCH`
|
|
158
|
+
reproducibility is exactly the right behaviour there -- this function
|
|
159
|
+
exists for the one field that is a comparison key, not a display
|
|
160
|
+
value. `sdk_discovery._pointer_target` reads `sdkPath` only, never
|
|
161
|
+
`updatedAt`; `bootstrap.parse_workspace_sdk_record` never reads
|
|
162
|
+
`updatedAt` either; and no mtime comparison exists anywhere in the
|
|
163
|
+
resolution ladder -- so none of the eight is secretly a second
|
|
164
|
+
comparison key this function should also be serving. If a future field
|
|
165
|
+
needs the same thing, it belongs here, not on `generated_at_iso`.
|
|
166
|
+
|
|
167
|
+
Never raises, but not for the reason an earlier revision of this
|
|
168
|
+
docstring gave (review, #904 final round, major): `time.time()` reads
|
|
169
|
+
the SYSTEM clock, which a host can set to any value a user or a faulty
|
|
170
|
+
RTC/NTP step chooses, including one past `datetime`'s year-9999 ceiling
|
|
171
|
+
-- "cannot land outside" was true of `SOURCE_DATE_EPOCH`'s validated
|
|
172
|
+
range in `generated_at_iso`, never of the raw wall clock. This function
|
|
173
|
+
now defends the same way: an out-of-range wall clock degrades to the
|
|
174
|
+
shared `_FALLBACK` epoch rather than raising, keeping the module
|
|
175
|
+
header's "Both NEVER raise" promise for this caller too, not just for
|
|
176
|
+
`generated_at_iso`'s env-var-supplied value.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
moment = _UNIX_EPOCH + timedelta(seconds=time.time())
|
|
180
|
+
except (OverflowError, OSError, ValueError):
|
|
181
|
+
return f"{_FALLBACK}.000Z" if millis else f"{_FALLBACK}Z"
|
|
182
|
+
return _render(moment, millis=millis)
|
tan/core/tool_lookup.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""ONE "where is this tool" lookup, for every command that spawns one.
|
|
3
|
+
|
|
4
|
+
**tan-cli#567 / #532.** The hardened lookup lived in five independent
|
|
5
|
+
hand-rolled copies, and tan-cli#510 was caused by exactly that shape: an
|
|
6
|
+
availability CHECK that walked `%PATH%` by hand so the current directory could
|
|
7
|
+
not shadow a tool, and a SPAWN, in the same function, that handed the bare
|
|
8
|
+
identity back to the platform's own resolver -- whose Windows search order
|
|
9
|
+
(`CreateProcess` with `lpApplicationName=NULL`) puts *the current directory for
|
|
10
|
+
the parent process* AHEAD of `PATH`. The check protected against a hazard the
|
|
11
|
+
spawn then re-opened.
|
|
12
|
+
|
|
13
|
+
`resolve_tool` is that lookup, moved out of `tan.commands.build.execute` where
|
|
14
|
+
#510 hardened it, so the flash write path and the size path can spawn the SAME
|
|
15
|
+
resolved path instead of maintaining a third and fourth opinion about it. The
|
|
16
|
+
callers keep their own policy about what to do when a tool is missing; only the
|
|
17
|
+
*finding* is shared.
|
|
18
|
+
|
|
19
|
+
**Why not `shutil.which` on Windows.** Its stdlib implementation inserts
|
|
20
|
+
`os.curdir` ahead of every `PATH` entry ("the current directory takes
|
|
21
|
+
precedence on Windows" -- its own source comment), reproducing `CreateProcess`'s
|
|
22
|
+
native search order. `where.exe` behaves the same way. The Rust oracle
|
|
23
|
+
(`crates/tan-cli/src/util.rs::command_on_path`) walks `%PATH%` by hand
|
|
24
|
+
precisely so a project checked out with its own `west.exe`/`openocd.exe`/
|
|
25
|
+
`size.exe` at its root can never be picked up in place of the real tool; this
|
|
26
|
+
is that walk.
|
|
27
|
+
|
|
28
|
+
**The Windows candidate set -- the one deliberate behaviour change here.**
|
|
29
|
+
For a bare, extension-less identity this walk tries `tool` + each `%PATHEXT%`
|
|
30
|
+
suffix and NEVER `tool` itself ([`windows_candidate_names`]). That is a
|
|
31
|
+
Windows-arm behaviour change relative to `doctor_cmd.on_path`, one of the five
|
|
32
|
+
hand-rolled lookups #532 consolidates, whose extension list is `[""] + PATHEXT`
|
|
33
|
+
-- it accepts an extension-less `%PATH%` file, and accepts it AHEAD of every
|
|
34
|
+
suffixed sibling. Three reasons the oracle's shape is the one to consolidate on:
|
|
35
|
+
|
|
36
|
+
* **Oracle parity.** `crates/tan-cli/src/util.rs::find_on_path` is
|
|
37
|
+
`if has_ext { dir.join(command) } else { for ext in &exts { ... } }` -- no
|
|
38
|
+
bare candidate. The oracle is the fixed point this port is measured against,
|
|
39
|
+
and `build/execute.py::_resolve_tool` (the copy this module IS) already
|
|
40
|
+
agreed with it. Only `on_path` did not -- and since tan-cli#532 it no
|
|
41
|
+
longer exists as a separate walk: `doctor_cmd.on_path` delegates here, so
|
|
42
|
+
the divergence this section describes is now historical, and the behaviour
|
|
43
|
+
change it records has taken effect for the doctor and `faultdecode_cmd`
|
|
44
|
+
as well.
|
|
45
|
+
* **Windows itself never selects one for a bare identity.** `CreateProcess`
|
|
46
|
+
with `lpApplicationName=NULL` appends ONLY `.exe` to an unqualified name and
|
|
47
|
+
does not consult `%PATHEXT%` at all -- the documented reason
|
|
48
|
+
`subprocess.run(["npm"])` raises `FileNotFoundError` while `npm.cmd` sits on
|
|
49
|
+
`%PATH%` (measured in `tests/commands/test_execute.py::_copy_interpreter_as`).
|
|
50
|
+
`cmd.exe` and PowerShell resolve a typed command name through `%PATHEXT%`
|
|
51
|
+
too. This walk is meant to be a HARDENED version of that search -- more
|
|
52
|
+
permissive along one axis only (`%PATHEXT%`), never admitting files no
|
|
53
|
+
Windows resolver would produce for the name.
|
|
54
|
+
* **It would be unsafe now that the value is SPAWNED, not reduced to a bool.**
|
|
55
|
+
The extension-less files really found on a Windows `%PATH%` are
|
|
56
|
+
overwhelmingly POSIX shims -- `npm`/`yarn`/`git-*` sh scripts shipped beside
|
|
57
|
+
their `.cmd` sibling in the SAME directory -- and `""` going first means the
|
|
58
|
+
sh script wins. Since tan-cli#567 that value goes to `subprocess` as
|
|
59
|
+
`executable=`, i.e. `lpApplicationName` with no search, where a non-PE file
|
|
60
|
+
is `[WinError 193] %1 is not a valid Win32 application`. The leniency would
|
|
61
|
+
turn a working `.cmd` spawn into a hard spawn failure, and would make the
|
|
62
|
+
go/no-go gate approve a file the spawn cannot launch: the very check/spawn
|
|
63
|
+
disagreement #567 closes, re-entered from the other side.
|
|
64
|
+
|
|
65
|
+
What is given up is bare-identity DISCOVERY of an extension-less file that is
|
|
66
|
+
a valid PE. Windows will execute one of those -- `.exe` is appended only to a
|
|
67
|
+
name carrying no path -- and that capability is untouched here: an absolute
|
|
68
|
+
`tool` is answered by existence alone and spawned verbatim, so a plan may
|
|
69
|
+
still name one. A venv rewrite is the narrower case: `venv.tool_in_venv`
|
|
70
|
+
(`tan/core/venv.py:277`) appends `.exe` whenever the winning directory is
|
|
71
|
+
`Scripts`, which is what `_resolve_layout` picks for a stock Windows venv, so
|
|
72
|
+
a rewrite can only name an extension-less file in the `bin`-on-Windows layout.
|
|
73
|
+
Only finding it by bare name on `%PATH%` is refused, and the refusal names the
|
|
74
|
+
`%PATH%` it walked.
|
|
75
|
+
"""
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
import os
|
|
79
|
+
import shutil
|
|
80
|
+
from collections.abc import Mapping
|
|
81
|
+
from dataclasses import dataclass
|
|
82
|
+
from pathlib import Path, PureWindowsPath
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ToolResolution:
|
|
87
|
+
"""One answer to both "is `tool` available" and "where is it" --
|
|
88
|
+
`resolved` is the absolute path to actually spawn (`None` when nothing
|
|
89
|
+
was found), `searched` is always populated, found or not, and describes
|
|
90
|
+
where this looked.
|
|
91
|
+
|
|
92
|
+
Replaces the old `_command_on_path`/`_tool_is_available` pair
|
|
93
|
+
(tan-cli#510): that pair answered a bare bool from one hardened lookup,
|
|
94
|
+
and the spawn below then repeated a SEPARATE, unhardened one -- handing
|
|
95
|
+
the bare identity straight to the platform's own resolver, whose Windows
|
|
96
|
+
search order includes a current-directory step the availability check
|
|
97
|
+
was written specifically to exclude. One resolver now; the check and the
|
|
98
|
+
spawn can never again disagree about what ran."""
|
|
99
|
+
|
|
100
|
+
resolved: str | None
|
|
101
|
+
searched: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_tool(tool: str, env: Mapping[str, str]) -> ToolResolution:
|
|
105
|
+
"""Resolve `tool` -- an identity per ADR-0020 (never a path in a
|
|
106
|
+
well-formed plan), or an absolute path a caller already resolved
|
|
107
|
+
(`west_program`'s workspace-venv rewrite, `execute_slices`;
|
|
108
|
+
`flash_cmd._programs_resolved_in_venv`) -- to the absolute path to spawn.
|
|
109
|
+
|
|
110
|
+
An absolute `tool` is answered by existence alone: whoever produced it
|
|
111
|
+
already did the searching, and re-walking PATH for something that is
|
|
112
|
+
already a path would be wrong.
|
|
113
|
+
|
|
114
|
+
A bare/relative name is walked exactly the way the old
|
|
115
|
+
`_command_on_path` did -- this IS that lookup, now doubling as the
|
|
116
|
+
resolution the spawn itself uses instead of a second, divergent one:
|
|
117
|
+
POSIX via `shutil.which` (no CWD special-case there); Windows via a
|
|
118
|
+
hand-rolled `%PATH%` walk, deliberately NOT `shutil.which` -- see this
|
|
119
|
+
module's own docstring for both halves of that reasoning. The candidate
|
|
120
|
+
NAMES that walk tries are [`windows_candidate_names`], which is where the
|
|
121
|
+
one deliberate Windows-arm behaviour change this consolidation makes (an
|
|
122
|
+
extensionless PATH file is no longer a hit, matching the oracle rather
|
|
123
|
+
than `doctor_cmd.on_path`) is recorded and justified.
|
|
124
|
+
|
|
125
|
+
`env` -- MAJOR 2 of the tan-cli#510 review: this used to read
|
|
126
|
+
`os.environ["PATH"]` directly, 46 lines before `execute_slices`
|
|
127
|
+
assembled the slice's OWN `env` (the one the spawn below actually gets),
|
|
128
|
+
so a plan that pinned a different `PATH` in `command.env` resolved
|
|
129
|
+
against the PARENT's PATH and then spawned a DIFFERENT binary than the
|
|
130
|
+
one the check just approved -- the pre-fix `Command::new(&tool)` case
|
|
131
|
+
this whole issue exists to close, reintroduced one call up. Callers pass
|
|
132
|
+
the FULLY ASSEMBLED child env (post `assemble_slice_env`/
|
|
133
|
+
`with_venv_on_path`, or `flash_cmd`'s `prepend_path(os.environ,
|
|
134
|
+
venv_bin)`), never `os.environ` when the child gets something else -- a
|
|
135
|
+
plan that pins a PATH is asking for that PATH to be used, matching
|
|
136
|
+
pre-fix POSIX `Popen`, which always selected via `os.get_exec_path(env)`
|
|
137
|
+
(the spawn's OWN env), never the calling process's.
|
|
138
|
+
|
|
139
|
+
`searched` is populated on every return, including a hit: a missingTool
|
|
140
|
+
refusal that can only say "not found" is a support ticket; one that
|
|
141
|
+
names the literal PATH entries this walked is a fix the customer
|
|
142
|
+
applies themselves (tan-cli#510 acceptance)."""
|
|
143
|
+
if Path(tool).is_absolute():
|
|
144
|
+
if Path(tool).exists():
|
|
145
|
+
return ToolResolution(tool, f"`{tool}` (given as an absolute path)")
|
|
146
|
+
# tan-cli#510 review, minor: the old wording ("searched `<path>`
|
|
147
|
+
# (given as an absolute path)") echoed the same path back as if a
|
|
148
|
+
# search had run one -- nothing was searched, the path was just
|
|
149
|
+
# checked. Naming the miss plainly avoids the tautology.
|
|
150
|
+
return ToolResolution(None, f"`{tool}` -- that path does not exist")
|
|
151
|
+
|
|
152
|
+
if os.name != "nt":
|
|
153
|
+
# `os.get_exec_path(env)` mirrors what a POSIX `Popen(..., env=env)`
|
|
154
|
+
# itself consults to resolve a bare argv[0] (`os.defpath` when `env`
|
|
155
|
+
# carries no `PATH` at all) -- the same fallback `shutil.which`'s own
|
|
156
|
+
# `path=None` default would use from `os.environ`, reproduced here
|
|
157
|
+
# against `env` instead.
|
|
158
|
+
#
|
|
159
|
+
# **EMPTY entries are dropped first (tan-cli#567).** This is not
|
|
160
|
+
# tidiness: `PATH="$PATH:"`, `PATH=":$PATH"` and a `;;` on Windows are
|
|
161
|
+
# all routine, and an empty entry means "the current directory" to
|
|
162
|
+
# every POSIX PATH consumer. `shutil.which(tool, path=":/nope:")`
|
|
163
|
+
# joins `""` with the name and probes it RELATIVE TO CWD -- measured on
|
|
164
|
+
# this tree, `resolve_tool("cwdprobe", {"PATH": ":/nope:"})` answered
|
|
165
|
+
# `'cwdprobe'` (relative!) for a file that exists only in the process's
|
|
166
|
+
# own working directory. That is the exact hazard the hand-rolled walk
|
|
167
|
+
# exists to prevent, reachable on the BUILD path since tan-cli#510 and
|
|
168
|
+
# missed because the Windows branch below already filtered and the
|
|
169
|
+
# POSIX branch did not. `size_cmd`'s now-retired private copy had the
|
|
170
|
+
# filter; consolidating without it would have made three commands
|
|
171
|
+
# share one weaker lookup.
|
|
172
|
+
search_dirs = [d for d in os.get_exec_path(dict(env)) if d]
|
|
173
|
+
if not search_dirs:
|
|
174
|
+
return ToolResolution(None, "PATH is unset")
|
|
175
|
+
path = os.pathsep.join(search_dirs)
|
|
176
|
+
return ToolResolution(shutil.which(tool, path=path), f"PATH: {path}")
|
|
177
|
+
|
|
178
|
+
path = env.get("PATH")
|
|
179
|
+
if not path:
|
|
180
|
+
return ToolResolution(None, "PATH is unset")
|
|
181
|
+
names = windows_candidate_names(tool, env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"))
|
|
182
|
+
for directory in path.split(os.pathsep):
|
|
183
|
+
if not directory:
|
|
184
|
+
continue
|
|
185
|
+
for name in names:
|
|
186
|
+
# `os.path`, not `pathlib`, and the difference is MEASURED rather
|
|
187
|
+
# than stylistic (tan-cli#811). This is the only remaining site
|
|
188
|
+
# with the shape: a full miss probes every `%PATH%` entry x every
|
|
189
|
+
# `%PATHEXT%` suffix, which on the host that issue measured is 92
|
|
190
|
+
# dirs x 15 names = 1380 candidates, and a `Path` object is built
|
|
191
|
+
# and thrown away for each. Same loop, same candidate set, only
|
|
192
|
+
# the construction and stat swapped: 0.0389 -> 0.0177 s per miss,
|
|
193
|
+
# 2.2x. POSIX never reaches here (it returns via `shutil.which`
|
|
194
|
+
# above), so this is a Windows-only cost and a Windows-only fix.
|
|
195
|
+
#
|
|
196
|
+
# **This is not a pure cost swap, and the difference is the
|
|
197
|
+
# SHIPPED floor, not an edge case** (review of #874). On 3.14
|
|
198
|
+
# `Path.is_file()` IS `os.path.isfile` (`if follow_symlinks:
|
|
199
|
+
# return os.path.isfile(self)`), so the two are the same call.
|
|
200
|
+
# On 3.12 and 3.13 they are not: `Path.is_file()` re-raises any
|
|
201
|
+
# `OSError` outside `_IGNORED_ERRNOS`/`_IGNORED_WINERRORS`, while
|
|
202
|
+
# `genericpath.isfile` swallows every `OSError` and `ValueError`.
|
|
203
|
+
# `pyproject.toml` pins `requires-python = ">=3.12"` and the
|
|
204
|
+
# frozen binaries customers install are built on 3.12, so the
|
|
205
|
+
# divergence ships. Measured on this tree, one unreadable
|
|
206
|
+
# directory, same file:
|
|
207
|
+
#
|
|
208
|
+
# 3.12.14 Path.is_file() -> PermissionError errno=13
|
|
209
|
+
# os.path.isfile() -> False
|
|
210
|
+
# 3.13.15 Path.is_file() -> PermissionError errno=13
|
|
211
|
+
# os.path.isfile() -> False
|
|
212
|
+
# 3.14.7 both -> False
|
|
213
|
+
#
|
|
214
|
+
# Reachable on Windows: `ERROR_ACCESS_DENIED (5)` and
|
|
215
|
+
# `ERROR_SHARING_VIOLATION (32)` both map to `EACCES` in
|
|
216
|
+
# `PC/errmap.h`, so a `%PATH%` entry on a share whose credentials
|
|
217
|
+
# have expired, or an ACL-locked directory, used to abort the
|
|
218
|
+
# whole walk -- even when the tool sat in a LATER entry.
|
|
219
|
+
#
|
|
220
|
+
# Skipping it is the DELIBERATE choice, not a side effect. This
|
|
221
|
+
# loop carries no `try` of its own, so whatever the predicate
|
|
222
|
+
# raises escapes `resolve_tool` entirely; no call site guards
|
|
223
|
+
# it and `tan/cli.py` installs no top-level handler, so on
|
|
224
|
+
# 3.12/3.13 that `PermissionError` was a traceback where an
|
|
225
|
+
# envelope belongs. `tan/core/shapes.py` already states this
|
|
226
|
+
# repo's position for exactly this shape -- "a path with an
|
|
227
|
+
# embedded NUL or an unreadable parent must read as 'not an SDK
|
|
228
|
+
# root', never as a traceback where a refusal belongs" -- and a
|
|
229
|
+
# `%PATH%` entry tan cannot stat is the same question. An entry
|
|
230
|
+
# that is skipped is not silent: a tool found nowhere answers
|
|
231
|
+
# `resolved=None`, which `build_cmd` routes through
|
|
232
|
+
# `executionPolicy.missingTool` (`build_cmd._dispatch`, default
|
|
233
|
+
# skip) and names in the refusal. Pinned by
|
|
234
|
+
# `test_an_unreadable_path_entry_is_skipped_not_raised`.
|
|
235
|
+
#
|
|
236
|
+
# One asymmetry this leaves, small but real: the string PROBED is
|
|
237
|
+
# no longer the string RETURNED. `os.path.join` keeps a `.` or a
|
|
238
|
+
# duplicated separator the `%PATH%` entry carried where the
|
|
239
|
+
# `PureWindowsPath` return below collapses it, so
|
|
240
|
+
# `C:\\Windows\\.\\System32` probes 30 characters and returns 28.
|
|
241
|
+
# Against the 260-char `MAX_PATH` on a host without long paths
|
|
242
|
+
# enabled, a candidate within two characters of the limit can now
|
|
243
|
+
# miss where it previously hit.
|
|
244
|
+
if os.path.isfile(os.path.join(directory, name)):
|
|
245
|
+
# The RETURN is still built the pathlib way, once, on the
|
|
246
|
+
# winner -- deliberately NOT `os.path.join`'s own string.
|
|
247
|
+
# `os.path.join` preserves whatever separators the `%PATH%`
|
|
248
|
+
# entry carried (`os.path.join("C:/Windows/System32",
|
|
249
|
+
# "cmd.exe")` is `'C:/Windows/System32\\cmd.exe'`) where
|
|
250
|
+
# `str(PureWindowsPath(...) / ...)` normalises to
|
|
251
|
+
# `'C:\\Windows\\System32\\cmd.exe'`, and this string is
|
|
252
|
+
# customer-visible: `build_cmd` publishes it as
|
|
253
|
+
# `data.slices[].resolvedTool` (`build_cmd._slice_result`
|
|
254
|
+
# sets it unconditionally).
|
|
255
|
+
# tan-cli#811 proposed `os.path.normpath` on the return to
|
|
256
|
+
# close that gap; it does, but it ALSO collapses `..`, which
|
|
257
|
+
# pathlib does not, so a `%PATH%` entry written relative to a
|
|
258
|
+
# parent would come back rewritten. Rebuilding the winner
|
|
259
|
+
# with the original expression is byte-identical instead of
|
|
260
|
+
# merely equivalent, and costs one path object per HIT, never
|
|
261
|
+
# per candidate.
|
|
262
|
+
#
|
|
263
|
+
# `PureWindowsPath`, not `Path`, and that is a TESTABILITY
|
|
264
|
+
# fix rather than a behaviour one (review of #874). The two
|
|
265
|
+
# produce the same string on Windows by inheritance, not by
|
|
266
|
+
# coincidence: `WindowsPath.__mro__` contains
|
|
267
|
+
# `PureWindowsPath`, `__str__` and `__truediv__` are the SAME
|
|
268
|
+
# objects on both, and both carry the frozen `ntpath` parser
|
|
269
|
+
# (verified on 3.12.14 and 3.14.7). But `Path` here made the
|
|
270
|
+
# hit path unreachable from a POSIX test -- `WindowsPath /
|
|
271
|
+
# str` refuses to build -- which is how this walk's only
|
|
272
|
+
# regression test decayed into grepping its own source. This
|
|
273
|
+
# module already uses `PureWindowsPath` for the same reason
|
|
274
|
+
# in `windows_candidate_names` below.
|
|
275
|
+
return ToolResolution(str(PureWindowsPath(directory) / name), f"PATH: {path}")
|
|
276
|
+
return ToolResolution(None, f"PATH: {path}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def windows_candidate_names(tool: str, pathext: str) -> list[str]:
|
|
280
|
+
"""The file names the `%PATH%` walk above may consider for the bare
|
|
281
|
+
identity `tool`: `tool` verbatim once it already carries an extension,
|
|
282
|
+
and otherwise `tool` + each `%PATHEXT%` suffix -- **never the bare,
|
|
283
|
+
extensionless name itself.**
|
|
284
|
+
|
|
285
|
+
Split out as a pure function so the Windows candidate set can be
|
|
286
|
+
unit-tested from any host, mirroring the Rust oracle, which splits its own
|
|
287
|
+
search core out for the same reason and says so
|
|
288
|
+
(`crates/tan-cli/src/util.rs::find_on_path`, "Pure search core split out
|
|
289
|
+
of `windows_path_lookup` purely for unit testing").
|
|
290
|
+
|
|
291
|
+
What this said before tan-cli#811 -- that `resolve_tool`'s Windows branch
|
|
292
|
+
is unreachable from POSIX because `pathlib.Path` "dispatches on `os.name`
|
|
293
|
+
at construction, so patching it raises" -- was wrong in both halves, and
|
|
294
|
+
three tests here were built on the false version. Measured on 3.12.14 and
|
|
295
|
+
3.14.7: `Path("git")` under a patched `os.name` CONSTRUCTS a
|
|
296
|
+
`WindowsPath` (`Path.__new__` reaches `object.__new__(cls)`, bypassing
|
|
297
|
+
the raising subclass `__new__`), and the lexical and stat methods then do
|
|
298
|
+
not raise, they LIE -- `Path("/usr/bin/git").is_absolute()` is `False`.
|
|
299
|
+
Where a refusal does happen (`WindowsPath / str`, or `WindowsPath(...)`
|
|
300
|
+
directly) it is a plain `NotImplementedError` on the SHIPPED floor:
|
|
301
|
+
`pathlib` has no `UnsupportedOperation` attribute at all on 3.12, which
|
|
302
|
+
3.13 renamed it into as a `NotImplementedError` subclass.
|
|
303
|
+
|
|
304
|
+
So since tan-cli#811 the walk is DELIBERATELY reachable from POSIX and is
|
|
305
|
+
driven there -- the miss loop probes through `os.path`, the hit spells
|
|
306
|
+
with `PureWindowsPath` -- because while it was not, this walk's only
|
|
307
|
+
regression test decayed into grepping the module's own source.
|
|
308
|
+
|
|
309
|
+
A patched-`os.name` test proves less than it runs: `os.path` stays
|
|
310
|
+
`posixpath`, so the probe joins with `/` against a real POSIX filesystem
|
|
311
|
+
while the return spells with `\\`. Such a test pins candidate ORDER, the
|
|
312
|
+
skip-don't-raise decision and the returned SPELLING -- not Windows path
|
|
313
|
+
syntax on the probe, which is this function's own job.
|
|
314
|
+
|
|
315
|
+
Excluding the bare name is the one deliberate Windows-arm behaviour
|
|
316
|
+
change tan-cli#567 makes; this module's own docstring carries the whole
|
|
317
|
+
justification under "The Windows candidate set", and
|
|
318
|
+
`test_bare_argv0_spawn.py::test_the_windows_walk_never_considers_the_bare
|
|
319
|
+
_extensionless_name` pins it.
|
|
320
|
+
"""
|
|
321
|
+
if PureWindowsPath(tool).suffix:
|
|
322
|
+
return [tool]
|
|
323
|
+
# `;` literally, not `os.pathsep`: `%PATHEXT%` is a Windows-only variable
|
|
324
|
+
# and is always `;`-separated (the oracle splits it on `';'` too). They are
|
|
325
|
+
# the same character on the platform this runs on -- spelling it makes the
|
|
326
|
+
# function answer the Windows question from any host, which is the point of
|
|
327
|
+
# extracting it.
|
|
328
|
+
return [tool + ext for ext in pathext.split(";") if ext]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def resolve_program_positions(
|
|
332
|
+
argv: list[str], env: Mapping[str, str], separator: str | None = None
|
|
333
|
+
) -> tuple[list[str], str | None]:
|
|
334
|
+
"""Rewrite every PROGRAM position in `argv` -- `argv[0]`, plus the token
|
|
335
|
+
right after each `separator` (a `"|"` pipeline token, when one is given) --
|
|
336
|
+
to the absolute path [`resolve_tool`] found for it against `env`, the SAME
|
|
337
|
+
environment the spawn will hand the child.
|
|
338
|
+
|
|
339
|
+
Returns `(argv, unresolved)`. `unresolved` names the FIRST program position
|
|
340
|
+
that resolved to nothing, and is `None` when every one of them resolved.
|
|
341
|
+
Arguments are never touched.
|
|
342
|
+
|
|
343
|
+
A program that ALREADY carries a path separator is left exactly as it is
|
|
344
|
+
and never counts as unresolved: it is not a PATH identity, so there is no
|
|
345
|
+
PATH lookup to harden. That covers the absolute paths a caller resolved
|
|
346
|
+
itself (`flash_cmd._programs_resolved_in_venv`'s venv rewrite, which
|
|
347
|
+
already probed the file) without re-searching them, and it keeps this
|
|
348
|
+
agreeing with `is_rust_absolute` on the rooted-but-driveless Windows form
|
|
349
|
+
that `Path.is_absolute` and `os.path.isabs` disagree about across
|
|
350
|
+
interpreter versions (`tan.core.flash_plan`'s own convention).
|
|
351
|
+
|
|
352
|
+
**A bare program that does not resolve is left BARE, and the caller
|
|
353
|
+
decides.** This function never invents a path. Its callers on the write
|
|
354
|
+
path refuse the spawn outright rather than pass a bare name to
|
|
355
|
+
`CreateProcess`, because "not on PATH and not in the venv" leaves the
|
|
356
|
+
current directory as the only remaining supplier -- which is the whole
|
|
357
|
+
hazard. Pure: no IO beyond the `is_file`/`exists` probes `resolve_tool`
|
|
358
|
+
already does.
|
|
359
|
+
"""
|
|
360
|
+
out: list[str] = []
|
|
361
|
+
unresolved: str | None = None
|
|
362
|
+
is_program = True
|
|
363
|
+
for arg in argv:
|
|
364
|
+
if is_program and not _has_path_separator(arg):
|
|
365
|
+
resolution = resolve_tool(arg, env)
|
|
366
|
+
if resolution.resolved is None:
|
|
367
|
+
if unresolved is None:
|
|
368
|
+
unresolved = arg
|
|
369
|
+
out.append(arg)
|
|
370
|
+
else:
|
|
371
|
+
out.append(resolution.resolved)
|
|
372
|
+
else:
|
|
373
|
+
out.append(arg)
|
|
374
|
+
is_program = separator is not None and arg == separator
|
|
375
|
+
return out, unresolved
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _has_path_separator(arg: str) -> bool:
|
|
379
|
+
"""Whether `arg` names a location rather than a PATH identity. `os.altsep`
|
|
380
|
+
is checked too, so a forward-slashed path on Windows -- which the OS
|
|
381
|
+
accepts and half this repo's fixtures write -- is not mistaken for a bare
|
|
382
|
+
command name."""
|
|
383
|
+
return os.sep in arg or (os.altsep is not None and os.altsep in arg)
|