tan-cli 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The malformed-document register, in ONE place both catalog readers import.
|
|
3
|
+
|
|
4
|
+
tan-cli#1084. `metadata/templates/catalog-v1.json` is read by TWO deliberate
|
|
5
|
+
implementations -- `tan/planner/template.py` (`load_catalog` +
|
|
6
|
+
`find_template_by_cores`) and `tan/core/example_catalog.py`
|
|
7
|
+
(`unsupported_som` + `find_example_by_cores`) -- and they cannot be collapsed
|
|
8
|
+
into one: `tan.planner.paths` binds `REPO = sdk_root()` at MODULE scope, so
|
|
9
|
+
importing anything under `tan.planner` before `bind_sdk_root` has run raises
|
|
10
|
+
`PlannerRootError`, and `tan init`'s whole SDK-free path (invariant I-32) must
|
|
11
|
+
keep working with no checkout bound at all. `example_catalog.py`'s own module
|
|
12
|
+
docstring carries the long form of that reasoning.
|
|
13
|
+
|
|
14
|
+
PR #1073 extracted the register (`require_mapping_doc` / `require_field`) and
|
|
15
|
+
PR #1082 extended it (`require_key`, plus the catalog's own two readers) --
|
|
16
|
+
but only inside `tan/planner/template.py`, because that was the module those
|
|
17
|
+
issues named. The result was the more dangerous half of a duplicated read:
|
|
18
|
+
after #1082 the planner REFUSED a malformed catalog with a curated error while
|
|
19
|
+
`example_catalog.py` still crashed raw (`json.JSONDecodeError`,
|
|
20
|
+
`AttributeError`, `TypeError`, `KeyError`, `FileNotFoundError`) or silently
|
|
21
|
+
mis-read it -- and `tests/gates/
|
|
22
|
+
test_example_catalog_cores_selector_agrees_with_planner.py`, which asserts the
|
|
23
|
+
two agree, covered only WELL-FORMED input, so the divergence was invisible to
|
|
24
|
+
CI. Two implementations of one read that agree on good input and diverge on
|
|
25
|
+
bad, with a test asserting they agree.
|
|
26
|
+
|
|
27
|
+
So the register moved here, to a module with no `tan.planner` in its import
|
|
28
|
+
closure and nothing but the standard library in it. It is a MOVE, not a copy:
|
|
29
|
+
`template.py` binds the same objects (`_require_field = _GUARDS.require_field`
|
|
30
|
+
and friends) so its ~40 call sites are byte-identical to what #1073/#1082
|
|
31
|
+
landed, and neutering one method here reds tests across BOTH readers and all
|
|
32
|
+
four documents. That is the proof the two are on one register rather than two
|
|
33
|
+
that happen to agree today.
|
|
34
|
+
|
|
35
|
+
WHAT `error` IS FOR
|
|
36
|
+
-------------------
|
|
37
|
+
|
|
38
|
+
The message shape is shared; the exception TYPE is not, and must not be.
|
|
39
|
+
`tan/planner/cli._emit_scaffold` catches `TemplateError` and nothing else,
|
|
40
|
+
while `tan/commands/init_cmd._plan_from_topology` catches
|
|
41
|
+
`CoresTopologyError` and nothing else -- so a register that raised one fixed
|
|
42
|
+
class would leave the other caller's curated error escaping as a traceback,
|
|
43
|
+
which is the very defect this family exists to close. `DocumentGuards` is
|
|
44
|
+
therefore constructed with the class its caller contracts for, once, at module
|
|
45
|
+
scope on each side.
|
|
46
|
+
|
|
47
|
+
THE MESSAGE REGISTER
|
|
48
|
+
--------------------
|
|
49
|
+
|
|
50
|
+
Three shapes, matching the register `tan/model/targets.py:312-323`
|
|
51
|
+
established -- each naming the FILE, the FIELD and the actual TYPE:
|
|
52
|
+
|
|
53
|
+
malformed {what} at {path}: expected {noun}, got {type}
|
|
54
|
+
{doc} {field} must be {noun}, got {type}
|
|
55
|
+
{doc} {field} is missing required key {key!r}
|
|
56
|
+
|
|
57
|
+
`noun` on the first defaults to "a YAML mapping" -- byte-identical to what
|
|
58
|
+
tan-cli#1034/#1048/#1052 landed for the three YAML documents `template.py`
|
|
59
|
+
reads -- and the catalog's two readers pass "a JSON object", because
|
|
60
|
+
`catalog-v1.json` is JSON and calling its top level a YAML mapping would be a
|
|
61
|
+
curated message that is not true (tan-cli#1077).
|
|
62
|
+
|
|
63
|
+
`require_key` adds EXACTLY the check the other two cannot express -- the key
|
|
64
|
+
is present -- and DELEGATES both type checks to `require_field`: the container
|
|
65
|
+
must be a mapping before `key in` means anything, and the value must be @kind
|
|
66
|
+
before the caller indexes it. One definition of the type half, one new message
|
|
67
|
+
(the missing-key line). Its @kind is optional because `$defs/parameter`'s
|
|
68
|
+
`default:` is the one schema-required key with NO declared type -- any JSON
|
|
69
|
+
value is legal there -- so `kind=None` requires presence only, never a shape
|
|
70
|
+
the schema does not.
|
|
71
|
+
|
|
72
|
+
`require_readable_text` (tan-cli#1085) is the FOURTH shape: not a type check
|
|
73
|
+
at all, but the curated-raise half of "read this file's bytes", shared by
|
|
74
|
+
`read_catalog_document` below and by `tan/planner/template.py::
|
|
75
|
+
render_to_envelope`'s template-example `board.yaml` read. Both used to run
|
|
76
|
+
the identical `except (OSError, UnicodeDecodeError)` body by hand -- one
|
|
77
|
+
inside this module, one still in `template.py` after tan-cli#1116 fixed its
|
|
78
|
+
narrowed `except OSError` to match -- naming a different `what` but otherwise
|
|
79
|
+
byte-for-byte the same. `read_catalog_document` now calls it instead of
|
|
80
|
+
inlining the read, so the two curated "cannot read X at Y: Z" messages this
|
|
81
|
+
module produces come from one definition instead of two that happened to
|
|
82
|
+
still agree.
|
|
83
|
+
|
|
84
|
+
HOW AN ABSENT FIELD IS NORMALISED IS THE CALLER'S DECISION (tan-cli#1052
|
|
85
|
+
review), and `require_field` deliberately checks EXACTLY what it is handed.
|
|
86
|
+
The two callers in `template.py` differ on purpose and both are pinned by live
|
|
87
|
+
tests:
|
|
88
|
+
|
|
89
|
+
* The example `board.yaml` sites normalise **`None` only** --
|
|
90
|
+
`[] if raw is None else raw`. An absent field and an explicit `null` pass; a
|
|
91
|
+
PRESENT but illegal falsy scalar (`pins: 0`, `pins: false`, `pins: ''`,
|
|
92
|
+
`cores: 0`) is refused, because it is just as illegal against
|
|
93
|
+
`board.schema.json` as `pins: 3`, and refusing one while silently emptying
|
|
94
|
+
the other is an asymmetry nothing would pin.
|
|
95
|
+
* The SoM-preset and board-metadata sites keep their pre-existing
|
|
96
|
+
`doc.get(field) or <empty>`, which collapses EVERY falsy value before this
|
|
97
|
+
helper sees it, so `topology: 0` / `e1m_routes.gpio: 0` still degrade
|
|
98
|
+
silently. That asymmetry is tan-cli#1048's own recorded decision, pinned by
|
|
99
|
+
`test_board_route_entries_malformed_board.py::
|
|
100
|
+
test_a_falsy_scalar_section_value_still_degrades_silently`; changing it is a
|
|
101
|
+
behaviour change to two documents #1052 does not name. It is also defensible
|
|
102
|
+
on BEHAVIOUR: there a falsy section degrades to `[]` and is then caught by
|
|
103
|
+
`_resolve_pin_target`'s curated `has no unambiguous 'board_alias:'`, so
|
|
104
|
+
nothing silently wrong escapes -- where `pins: 0` reached NO downstream
|
|
105
|
+
guard at all. Remove that catch and the asymmetry stops being correct.
|
|
106
|
+
|
|
107
|
+
The catalog sites use the sentinel form `record.get(key, [])` they already
|
|
108
|
+
had: absent degrades to `[]`, present-but-`null` is refused as `got NoneType`.
|
|
109
|
+
|
|
110
|
+
NOT STRICTER THAN THE SCHEMAS. Every key `require_key` is used on is
|
|
111
|
+
`required` in `metadata/schemas/template-catalog-v1.schema.json`, and
|
|
112
|
+
`require_field`'s `pins:`-style sites are `type: array` with no `minItems`, so
|
|
113
|
+
an empty list still renders -- rejecting it would be a new refusal, not a
|
|
114
|
+
fixed crash.
|
|
115
|
+
|
|
116
|
+
THE MEMBERSHIP BAR, AMENDED (tan-cli#1133, PR #1160 review)
|
|
117
|
+
-----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
The bar stated below -- "a shape RUN BY more than one consumer module that
|
|
120
|
+
can be unified without changing any consumer's pinned contract" -- is a
|
|
121
|
+
SUFFICIENT condition, not a necessary one, and #1133 is where that
|
|
122
|
+
distinction had to be made explicit. `require_yaml_mapping_doc`,
|
|
123
|
+
`read_yaml_mapping` and `require_readable_bytes` each have exactly ONE
|
|
124
|
+
consumer module today (`tan/planner/template.py`; `example_catalog.py` reads
|
|
125
|
+
JSON only and decodes nothing). Read literally, the bar excludes all three,
|
|
126
|
+
and the first cut of #1133 duly defined the two YAML ones as module-level
|
|
127
|
+
functions inside `template.py`.
|
|
128
|
+
|
|
129
|
+
They are here instead, on a SECOND sufficient condition this module already
|
|
130
|
+
embodied without ever writing down -- FORMAT SYMMETRY: **when this register
|
|
131
|
+
already answers a question for one serialisation, the same question for
|
|
132
|
+
another serialisation belongs beside it, not in a consumer.**
|
|
133
|
+
`read_catalog_document` is read + parse + shape-check for JSON.
|
|
134
|
+
`read_yaml_mapping` is read + parse + shape-check for YAML. Splitting those
|
|
135
|
+
across two modules would have meant a reader asking "where does this repo
|
|
136
|
+
decide whether a document is the shape it claims to be?" getting two
|
|
137
|
+
different answers depending on the file extension -- which is a smaller,
|
|
138
|
+
quieter version of exactly the two-implementations-of-one-read problem
|
|
139
|
+
tan-cli#1084 created this module to end.
|
|
140
|
+
|
|
141
|
+
Two arguments that were made for keeping them in `template.py`, recorded
|
|
142
|
+
because one of them was simply WRONG and a future round should not re-derive
|
|
143
|
+
it as if it were new:
|
|
144
|
+
|
|
145
|
+
* "The register is standard-library-only, so `import yaml` would put PyYAML
|
|
146
|
+
in the import closure of `tan init`'s SDK-free path." FALSE as stated: it
|
|
147
|
+
is true only of a MODULE-SCOPE import, and this package has an established
|
|
148
|
+
idiom that defeats it. Seven `tan/core/**` modules already defer `import
|
|
149
|
+
yaml` into a function body -- `board_context.py`, `bootstrap.py`,
|
|
150
|
+
`doctor_libraries.py`, `som_buildability.py`, `scaffold.py`,
|
|
151
|
+
`system_manifest.py`, `flash_plan.py` -- and `som_buildability.py:109`
|
|
152
|
+
documents that exact reasoning. `require_yaml_mapping_doc` does the same,
|
|
153
|
+
so the property this module claims stays true and is now enforced by a
|
|
154
|
+
test rather than by the absence of the import.
|
|
155
|
+
* "One consumer, so extraction would be for its own sake." Answered by the
|
|
156
|
+
format-symmetry condition above, and by SIZE, which is not a tiebreaker
|
|
157
|
+
here but a real cost: at the time, `template.py` was 2206 lines -- the
|
|
158
|
+
SIXTH-largest module under `python/tan/` (`bootstrap_cmd.py` 4450,
|
|
159
|
+
`doctor_cmd.py` 4415, `flash_cmd.py` 3497, `bootstrap.py` 2558,
|
|
160
|
+
`flash_plan.py` 2273 all sat above it, measured at the commit that first
|
|
161
|
+
wrote this paragraph -- not the fourth-largest, nor the most oversized,
|
|
162
|
+
two claims earlier revisions of this same correction each made and each
|
|
163
|
+
got wrong the same way: naming only some of what actually sat above it)
|
|
164
|
+
-- and `_module_size_budget_core.MIRRORED_PREFIX`'s comment
|
|
165
|
+
("upstream's to split, not this repo's") was read as barring a split of it
|
|
166
|
+
here. WRONG, corrected in tan-cli#1142 alongside the split that comment was
|
|
167
|
+
misread as forbidding: `MIRRORED_PREFIX` bars a `PINNED_HASHES` module's
|
|
168
|
+
shape from diverging from a moving upstream file it is 3-way-merged
|
|
169
|
+
against; `template.py` is `HAND_PORT_SOURCES` instead, flagged against
|
|
170
|
+
upstream and never merged, so it has no such cost (see
|
|
171
|
+
`tan/planner/template_pins.py`'s own docstring). This module is 400-odd
|
|
172
|
+
lines, well under the cap, and splittable regardless. Keeping 71 lines of
|
|
173
|
+
shared-shape code in a file everyone at the time believed could never shed
|
|
174
|
+
them, to satisfy a bar written to prevent gratuitous extraction, would have
|
|
175
|
+
been the letter of the rule against its purpose.
|
|
176
|
+
|
|
177
|
+
WHAT IS DELIBERATELY NOT HERE
|
|
178
|
+
------------------------------
|
|
179
|
+
|
|
180
|
+
tan-cli#1085 closed the CROSS-MODULE half of this family that was still
|
|
181
|
+
duplicated by hand (`require_readable_text`, the read-half shape
|
|
182
|
+
`render_to_envelope`'s example `board.yaml` read shared with
|
|
183
|
+
`read_catalog_document`). It did not shrink `template.py` back toward its
|
|
184
|
+
pre-family size: **1548** lines at tan-cli#1001 (`156582e`, before #1025
|
|
185
|
+
opened this family at all) -> **1658** at the end of #1025/#1034/#1037/
|
|
186
|
+
#1048 -> `1780` (#1073, +122) -> `2156` (#1082, +376) -> `2020` (#1084,
|
|
187
|
+
-136) -> `2051 -> 2066` (smaller rounds since) -> `2063` (#1085, -3) ->
|
|
188
|
+
`2125` (rounds since) -> **`2219` (tan-cli#1133, +94)**. Net **+671** over
|
|
189
|
+
the true 1548-line baseline, re-measured rather than carried forward: the
|
|
190
|
+
`+515` this paragraph recorded at #1085 was true then and is not now, and a
|
|
191
|
+
count nobody re-measures is the same defect as a caller count nobody
|
|
192
|
+
re-measures (below). #1084 remains the only round that ever removed
|
|
193
|
+
anything. This module is the piece of that growth two CONSUMER modules
|
|
194
|
+
(`template.py` and `example_catalog.py`) both call into through the same
|
|
195
|
+
curated-raise contract, and no more:
|
|
196
|
+
|
|
197
|
+
* `_require_constraints` stays in `template.py`. It guards `$defs/parameter`'s
|
|
198
|
+
`constraints:` bounds, which only the planner's parameter resolution reads;
|
|
199
|
+
`example_catalog.py` has no parameter path at all, so moving it would be
|
|
200
|
+
extraction for its own sake with no second consumer to prove it shared.
|
|
201
|
+
tan-cli#1087 (PR #1123) kept `_check_constraints`'s `ParameterError` local
|
|
202
|
+
for the identical reason.
|
|
203
|
+
* `_require_mapping_doc`/`_require_field`/`_require_key`/`require_readable_
|
|
204
|
+
text` belong here. The bar is not "more than one caller" (this module's
|
|
205
|
+
OWN methods calling each other satisfy that trivially and prove nothing)
|
|
206
|
+
and not "a caller in a different module" either (`resolve_targets` below
|
|
207
|
+
never calls `_load_som_doc` at all -- it reimplements it -- so a rule
|
|
208
|
+
keyed on CALLING would wrongly admit that as sharing). The bar is a
|
|
209
|
+
MEMBERSHIP property: **a shape RUN BY more than one consumer module that
|
|
210
|
+
can be unified without changing any consumer's pinned contract.** All
|
|
211
|
+
four here clear it: `template.py` and `example_catalog.py` both run
|
|
212
|
+
`require_mapping_doc` (`template.py` directly; `example_catalog.py` only
|
|
213
|
+
via `read_catalog_document`/`catalog_templates`, which it calls directly
|
|
214
|
+
-- the delegation is load-bearing, confirmed by neutering `require_
|
|
215
|
+
mapping_doc` alone and watching `example_catalog`'s own suite red through
|
|
216
|
+
that path and no other). `require_readable_text` is the newest member and
|
|
217
|
+
the clearest case: the day before this change it was two INLINE copies,
|
|
218
|
+
one in this module and one in `template.py`, both already on the exact
|
|
219
|
+
same `self.error`-injected contract -- nothing to change to unify them,
|
|
220
|
+
which is why they did. tan-cli#1133 gave it a THIRD and FOURTH caller
|
|
221
|
+
(`template.py`'s two `metadata/**` reads, through `_read_yaml_mapping`)
|
|
222
|
+
and one optional argument, `absent`, which only `template.py` passes.
|
|
223
|
+
That asymmetry is deliberate and is NOT a second definition creeping
|
|
224
|
+
back in: the read itself, the `except` tuple and the `cannot read {what}
|
|
225
|
+
at {path}` message stay single-definition, and `absent` only chooses
|
|
226
|
+
which of two messages the ONE `FileNotFoundError` case gets -- those two
|
|
227
|
+
callers have a better one, naming the SKU or the board being resolved,
|
|
228
|
+
and `read_catalog_document` does not.
|
|
229
|
+
* `_load_som_doc` and `_board_route_entries` do NOT clear that bar, but not
|
|
230
|
+
because they lack a second caller -- they have two each, WITHIN
|
|
231
|
+
`template.py` (`_load_som_doc`: `_default_preset_for_sku` and
|
|
232
|
+
`_topology_for_sku`; `_board_route_entries`: `_board_alias_to_entry` and
|
|
233
|
+
`_resolve_pin_target`), which is exactly why each is its own function
|
|
234
|
+
instead of two inlined reads. The documents they read ARE read
|
|
235
|
+
independently elsewhere, but "independently" is the operative word --
|
|
236
|
+
none of the rivals below is RUN from this module or from `template.py`;
|
|
237
|
+
each is its own, separately-evolved implementation with its own
|
|
238
|
+
contract, so the membership bar excludes all of them even before the
|
|
239
|
+
"unify without changing a pinned contract" half is reached:
|
|
240
|
+
`tan/model/targets.py::resolve_targets` (`template.py`'s `_load_som_doc`
|
|
241
|
+
comment already names it) reimplements the `is_file()`-then-
|
|
242
|
+
`yaml.safe_load`-then-mapping-check shape `_load_som_doc` HAD until
|
|
243
|
+
tan-cli#1133 replaced it -- `targets.py:307-313` still carries it (pre-flight at :307, the raise at
|
|
244
|
+
:308, `yaml.safe_load` at :311, the mapping check at :312-313), and
|
|
245
|
+
the audit script's shape 3 does not report it, because its raises are
|
|
246
|
+
builtins (`FileNotFoundError`/`ValueError`) rather than a curated class;
|
|
247
|
+
a separate contract, not covered by this change -- and it raises a
|
|
248
|
+
bare `FileNotFoundError` for the missing-file case and a bare `ValueError`
|
|
249
|
+
for the malformed case. Only the `ValueError` half is pinned by a test
|
|
250
|
+
(`tests/model/test_targets_malformed_preset.py`, `match="expected a YAML
|
|
251
|
+
mapping"`/`match="malformed SoM preset"`); `grep -rn "no SoM preset for
|
|
252
|
+
SKU" python/tests/` is empty, so the `FileNotFoundError` half's exact
|
|
253
|
+
message is NOT test-pinned today -- only its TYPE is implied by
|
|
254
|
+
`resolve_targets`'s own signature and callers, so this claim covers the
|
|
255
|
+
half that is actually proven and no more. `tan/core/som_buildability.py`'s
|
|
256
|
+
`_safe_load_mapping`-backed read is a THIRD implementation, contracted to
|
|
257
|
+
return `None` on every failure instead ("Never raises"), seeded into
|
|
258
|
+
`tests/gates/test_never_raises_contract_holds.py`. `tan/planner/
|
|
259
|
+
loader.py` (the same eight-section `_ROUTE_SECTIONS` tuple, inline) and
|
|
260
|
+
`tan/planner/project_emit/bom_netlist.py` (the same `e1m_routes:`
|
|
261
|
+
flatten) reimplement `_board_route_entries`'s read too, each to a FOURTH
|
|
262
|
+
contract: neither raises OR returns `None` on a wrong shape -- both
|
|
263
|
+
silently SKIP the one malformed entry and keep the rest
|
|
264
|
+
(`bom_netlist.py:76,79,82`'s three `if not isinstance(...): continue`
|
|
265
|
+
filters; `loader.py:1078`'s `if isinstance(e1m, str) and isinstance(
|
|
266
|
+
macro, str):` value-level filter) -- because both consume a project
|
|
267
|
+
document already schema-validated upstream, a different situation from
|
|
268
|
+
`template.py`'s scaffold-time raw-metadata read. A silent per-entry skip
|
|
269
|
+
is not "no guard at all"; it is its own distinct contract, and one MORE
|
|
270
|
+
reason four independently-evolved readers cannot fold into one shared
|
|
271
|
+
primitive without changing at least one of them: `targets.py`'s
|
|
272
|
+
`FileNotFoundError`/`ValueError`, `som_buildability.py`'s whole-function
|
|
273
|
+
`None`, and `loader.py`/`bom_netlist.py`'s per-entry silent skip are
|
|
274
|
+
four genuinely different answers to "what happens on a malformed read",
|
|
275
|
+
and this register offers exactly one (`self.error`, injected once per
|
|
276
|
+
caller). Left local, with the real rivals named here instead of an
|
|
277
|
+
unqualified "no other module reads it" claim, for a follow-up to weigh
|
|
278
|
+
on purpose rather than by default. `_docs_ref` stays for a third reason
|
|
279
|
+
again: it has one caller and degrades to `"main"` rather than
|
|
280
|
+
curated-raising at all, a different shape from everything else in this
|
|
281
|
+
file.
|
|
282
|
+
|
|
283
|
+
Staying local is a scope claim, not a soundness one -- and tan-cli#1085
|
|
284
|
+
left that distinction load-bearing rather than theoretical. At #1085 both
|
|
285
|
+
functions still guarded their OWN absence with a pre-flight `is_file()`
|
|
286
|
+
and then read+parsed UNGUARDED (no `try` at all, so `audit_narrow_except_
|
|
287
|
+
contracts.py`'s too-narrow-a-`try` sweep could not see them) -- a real,
|
|
288
|
+
live gap of the SAME #1116 class `require_readable_text` closes for the
|
|
289
|
+
catalog and the example `board.yaml`, reachable through `emit_scaffold`'s
|
|
290
|
+
`except TemplateError` and nothing else. tan-cli#1133 CLOSED it: both now
|
|
291
|
+
read through `template.py::_read_yaml_mapping`, which calls
|
|
292
|
+
`require_readable_text` here (with `absent=`) and then a local YAML parse
|
|
293
|
+
guard, and neither carries a pre-flight any more. They are still local,
|
|
294
|
+
for the membership reason above and no other; what is gone is the
|
|
295
|
+
soundness debt that used to sit behind that scope claim. The audit script
|
|
296
|
+
grew a third shape, `absent-try`, in the same change, so a fourth
|
|
297
|
+
instance of this is findable rather than invisible.
|
|
298
|
+
* The `SkuNotSupportedError` / `CoresTopologyNotFoundError` selection
|
|
299
|
+
semantics stay with their own modules. This register answers "is this
|
|
300
|
+
document the shape it claims to be", never "which record did you mean".
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
from __future__ import annotations
|
|
304
|
+
|
|
305
|
+
import json
|
|
306
|
+
from collections.abc import Callable
|
|
307
|
+
from typing import Any
|
|
308
|
+
|
|
309
|
+
__all__ = ["SHAPE_NOUN", "DocumentGuards"]
|
|
310
|
+
|
|
311
|
+
#: `isinstance` kind -> the noun the curated message uses. Four shapes cover
|
|
312
|
+
#: every field guard on both sides: a mapping, a list, a scalar string, and --
|
|
313
|
+
#: since tan-cli#1077 reached `constraints.minimum`/`constraints.maximum`, the
|
|
314
|
+
#: only numeric fields anything here subscripts -- an integer. `bool`
|
|
315
|
+
#: satisfies `int` in Python, so `minimum: true` passes and behaves as
|
|
316
|
+
#: `minimum: 1`; that is a curated error rather than a crash, and JSON `true`
|
|
317
|
+
#: is refused upstream by the schema's own `"type": "integer"`.
|
|
318
|
+
SHAPE_NOUN: dict[type, str] = {
|
|
319
|
+
dict: "a mapping", list: "a list", str: "a string", int: "an integer"}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class DocumentGuards:
|
|
323
|
+
"""The register, bound to the exception class ONE caller contracts for.
|
|
324
|
+
|
|
325
|
+
See the module docstring for the message shapes, the schema-strictness
|
|
326
|
+
claim, and why the exception type is a constructor argument rather than a
|
|
327
|
+
fixed class.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(self, error: type[Exception]) -> None:
|
|
331
|
+
self.error = error
|
|
332
|
+
|
|
333
|
+
def require_mapping_doc(
|
|
334
|
+
self, doc: Any, *, path: Any, what: str, noun: str = "a YAML mapping",
|
|
335
|
+
) -> dict[str, Any]:
|
|
336
|
+
"""The OUTER-document half: @doc parsed, but is it a mapping?
|
|
337
|
+
|
|
338
|
+
A document that parses (legal YAML/JSON) but is not a mapping
|
|
339
|
+
(illegal against its schema) must not reach the caller's
|
|
340
|
+
`.get(...)`, which raises a raw `AttributeError` a CLI user sees as
|
|
341
|
+
a traceback instead of a curated error naming the file and the
|
|
342
|
+
actual type.
|
|
343
|
+
"""
|
|
344
|
+
if not isinstance(doc, dict):
|
|
345
|
+
raise self.error(
|
|
346
|
+
f"malformed {what} at {path}: expected {noun}, "
|
|
347
|
+
f"got {type(doc).__name__}")
|
|
348
|
+
return doc
|
|
349
|
+
|
|
350
|
+
def require_field(self, value: Any, kind: type, *, doc: Any, field: str) -> Any:
|
|
351
|
+
"""The NESTED-field half: @field of @doc must be @kind before the
|
|
352
|
+
caller indexes or iterates it.
|
|
353
|
+
|
|
354
|
+
An outer `require_mapping_doc` says nothing about what is INSIDE the
|
|
355
|
+
mapping, and every round of this family found its next sibling
|
|
356
|
+
exactly one level in. This checks EXACTLY what it is handed --
|
|
357
|
+
normalising an absent field is the caller's decision, and the two
|
|
358
|
+
deliberately differ (module docstring).
|
|
359
|
+
"""
|
|
360
|
+
if not isinstance(value, kind):
|
|
361
|
+
raise self.error(
|
|
362
|
+
f"{doc} {field} must be {SHAPE_NOUN[kind]}, got "
|
|
363
|
+
f"{type(value).__name__}")
|
|
364
|
+
return value
|
|
365
|
+
|
|
366
|
+
def require_key(
|
|
367
|
+
self, mapping: Any, key: str, kind: type | None = None, *,
|
|
368
|
+
doc: Any, field: str,
|
|
369
|
+
) -> Any:
|
|
370
|
+
"""The MISSING-KEY third: @key is present on @mapping, and its value
|
|
371
|
+
is @kind (or any value, when @kind is None).
|
|
372
|
+
|
|
373
|
+
The catalog is read by BARE SUBSCRIPT, so its failure mode is
|
|
374
|
+
`KeyError` -- exactly as raw a traceback as the
|
|
375
|
+
`AttributeError`/`TypeError` the YAML documents produce, but neither
|
|
376
|
+
other method can express it: one checks a whole document, the other
|
|
377
|
+
a value already in hand. Both type checks DELEGATE to
|
|
378
|
+
`require_field`, so the register keeps one definition of them.
|
|
379
|
+
"""
|
|
380
|
+
self.require_field(mapping, dict, doc=doc, field=field)
|
|
381
|
+
if key not in mapping:
|
|
382
|
+
raise self.error(
|
|
383
|
+
f"{doc} {field} is missing required key {key!r}")
|
|
384
|
+
value = mapping[key]
|
|
385
|
+
if kind is None:
|
|
386
|
+
return value
|
|
387
|
+
return self.require_field(value, kind, doc=doc, field=f"{field}.{key}")
|
|
388
|
+
|
|
389
|
+
def require_readable_text(
|
|
390
|
+
self, path: Any, *, what: str,
|
|
391
|
+
absent: str | Callable[[], str] | None = None,
|
|
392
|
+
) -> str:
|
|
393
|
+
"""@path, decoded as UTF-8 text, or the injected curated error --
|
|
394
|
+
the read-half tan-cli#1085 pulled out of `read_catalog_document`
|
|
395
|
+
below once `render_to_envelope`'s example `board.yaml` read
|
|
396
|
+
(`tan/planner/template.py`) turned out to run the identical body by
|
|
397
|
+
hand, naming a different @what.
|
|
398
|
+
|
|
399
|
+
`except (OSError, UnicodeDecodeError)`, not a pre-flight `is_file()`,
|
|
400
|
+
so a present-but-unreadable path (a directory, a permissions error)
|
|
401
|
+
is named too, not only a missing one. `UnicodeDecodeError` is a
|
|
402
|
+
`ValueError`, NOT an `OSError` -- catching only `OSError` (tan-cli#1096
|
|
403
|
+
review, and tan-cli#1116's own repeat of the same trap in
|
|
404
|
+
`render_to_envelope`) let a non-UTF-8 document escape past a curated
|
|
405
|
+
`except` clause that thought it already covered every failure.
|
|
406
|
+
|
|
407
|
+
@absent (tan-cli#1133) is the caller's OWN message for the one
|
|
408
|
+
failure a pre-flight `is_file()` used to answer for itself: the file
|
|
409
|
+
is not there. `_load_som_doc`'s `no metadata/e1m_modules/<sku>.yaml
|
|
410
|
+
for sku <sku>` and `_board_route_entries`' `no metadata/boards/
|
|
411
|
+
<board>.yaml for board <board>` are both pinned by live tests
|
|
412
|
+
(`tests/planner/test_render_to_envelope_malformed_example_board.py`),
|
|
413
|
+
and both are more useful than a generic `cannot read`, because they
|
|
414
|
+
name the SKU or board the caller was resolving rather than only the
|
|
415
|
+
path it derived. Passing @absent is therefore what lets a caller DROP
|
|
416
|
+
its pre-flight -- the tan-cli#1127 trap, where `Path.is_file()`
|
|
417
|
+
itself raises `PermissionError` on 3.12.3/3.13.15 and returns `False`
|
|
418
|
+
on 3.14.7, so the same unreadable file was a raw traceback on two
|
|
419
|
+
interpreters and a curated-but-UNTRUE "no such file" on the third
|
|
420
|
+
(both measured on this tree before the fix).
|
|
421
|
+
|
|
422
|
+
@absent may also be a zero-argument CALLABLE, resolved only if the
|
|
423
|
+
`FileNotFoundError` branch is taken -- see `_unreadable` below.
|
|
424
|
+
|
|
425
|
+
ONLY `FileNotFoundError` takes @absent, deliberately. Every other
|
|
426
|
+
`OSError` means the path is there in some form and could not be read
|
|
427
|
+
as text -- a directory (`IsADirectoryError`), a parent that is a
|
|
428
|
+
regular file (`NotADirectoryError`), a denied mode
|
|
429
|
+
(`PermissionError`), a symlink loop (`ELOOP`) -- and for all of them
|
|
430
|
+
`cannot read {what} at {path}: {strerror}` is true where "no such
|
|
431
|
+
file" would not be. An `is_file()` pre-flight could not draw that
|
|
432
|
+
line at all: it answered `False` to every one of them.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
return path.read_text(encoding="utf-8")
|
|
436
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
437
|
+
raise self._unreadable(exc, path, what, absent) from exc
|
|
438
|
+
|
|
439
|
+
def require_readable_bytes(
|
|
440
|
+
self, path: Any, *, what: str,
|
|
441
|
+
absent: str | Callable[[], str] | None = None,
|
|
442
|
+
) -> bytes:
|
|
443
|
+
"""@path's RAW BYTES, or the injected curated error -- the same
|
|
444
|
+
contract as `require_readable_text` above for a caller that must not
|
|
445
|
+
decode (tan-cli#1133).
|
|
446
|
+
|
|
447
|
+
Its one real caller is `tan/planner/template.py::_rendered_bytes`,
|
|
448
|
+
which copies a template's `files.user_owned` entries verbatim so that
|
|
449
|
+
`alp_template.py render` and `--emit scaffold` hand back the same
|
|
450
|
+
bytes -- a template asset is not required to be text at all, and
|
|
451
|
+
`render()` writes whatever it read. Measured before this fix, on
|
|
452
|
+
3.12.3, 3.13.15 and 3.14.7 alike: a `chmod 000` source escaped
|
|
453
|
+
`emit_scaffold` as a raw `PermissionError`, a deleted one as a raw
|
|
454
|
+
`FileNotFoundError`, and a directory in its place as a raw
|
|
455
|
+
`IsADirectoryError`.
|
|
456
|
+
|
|
457
|
+
NOT expressible as `require_readable_bytes` -> `.decode()` for the
|
|
458
|
+
text case, which is why these are two methods rather than one:
|
|
459
|
+
`Path.read_text` opens in TEXT mode and applies universal-newline
|
|
460
|
+
translation, so a CRLF document reaches the caller with `\\n`, while
|
|
461
|
+
`read_bytes().decode("utf-8")` preserves `\\r\\n`. Folding one into
|
|
462
|
+
the other would silently change what every existing caller of the
|
|
463
|
+
text half sees on a CRLF checkout -- exactly the kind of quiet
|
|
464
|
+
behaviour change this family exists to prevent.
|
|
465
|
+
|
|
466
|
+
`UnicodeDecodeError` cannot arise here (nothing is decoded), so the
|
|
467
|
+
`except` is `OSError` alone -- narrower than the text half's on
|
|
468
|
+
purpose, and narrow BECAUSE the failure surface is smaller, not
|
|
469
|
+
because the shape was copied without thinking.
|
|
470
|
+
"""
|
|
471
|
+
try:
|
|
472
|
+
return path.read_bytes()
|
|
473
|
+
except OSError as exc:
|
|
474
|
+
raise self._unreadable(exc, path, what, absent) from exc
|
|
475
|
+
|
|
476
|
+
def _unreadable(
|
|
477
|
+
self, exc: BaseException, path: Any, what: str,
|
|
478
|
+
absent: str | Callable[[], str] | None,
|
|
479
|
+
) -> Exception:
|
|
480
|
+
"""The curated error for a failed read -- ONE definition of the
|
|
481
|
+
message, shared by both read halves above.
|
|
482
|
+
|
|
483
|
+
@absent, when the caller passed one, is used for `FileNotFoundError`
|
|
484
|
+
and nothing else: every other failure means the path is there in some
|
|
485
|
+
form and could not be read, so `cannot read ...` is true where "no
|
|
486
|
+
such file" would not be.
|
|
487
|
+
|
|
488
|
+
@absent may be a zero-argument CALLABLE as well as a string. The
|
|
489
|
+
rationale lives HERE, in the one function that resolves it and the
|
|
490
|
+
one both read halves and all four widened signatures funnel into,
|
|
491
|
+
rather than on `require_readable_text` above: on the signature it
|
|
492
|
+
would be four copies of one paragraph, and each copy would be a
|
|
493
|
+
place for the next edit to fall out of step with the resolution
|
|
494
|
+
rule it describes.
|
|
495
|
+
|
|
496
|
+
It is resolved HERE too -- inside the `FileNotFoundError` branch -- so a
|
|
497
|
+
caller whose absent-message is expensive or itself fallible does not
|
|
498
|
+
pay for it, or trip over it, on the paths that never use it
|
|
499
|
+
(tan-cli#1171 review). `libraries.load_manifest` is the caller that
|
|
500
|
+
needs it: its message ends `Available: <every manifest stem>`, and
|
|
501
|
+
building that list means LISTING `metadata/libraries/`. Eagerly, that
|
|
502
|
+
directory walk ran ahead of the guarded read on every call -- so with
|
|
503
|
+
`metadata/` itself denied it raised a raw `PermissionError` out of
|
|
504
|
+
`Path.is_dir()` on 3.12.3 and 3.13.15 while the guarded read gave the
|
|
505
|
+
curated message on 3.14.7, reinstating the exact tan-cli#1127 split
|
|
506
|
+
this family exists to remove (all three measured). Passed lazily, the
|
|
507
|
+
listing happens only after the read has already answered "not there",
|
|
508
|
+
which is the one branch it describes.
|
|
509
|
+
"""
|
|
510
|
+
if absent is not None and isinstance(exc, FileNotFoundError):
|
|
511
|
+
return self.error(absent() if callable(absent) else absent)
|
|
512
|
+
return self.error(
|
|
513
|
+
f"cannot read {what} at {path}: "
|
|
514
|
+
f"{getattr(exc, 'strerror', None) or exc}")
|
|
515
|
+
|
|
516
|
+
def require_yaml_mapping_doc(
|
|
517
|
+
self, text: str, *, path: Any, what: str,
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
"""@text parsed as YAML and known to be a mapping, or the injected
|
|
520
|
+
curated error -- the YAML twin of `read_catalog_document`'s
|
|
521
|
+
`json.JSONDecodeError` arm (tan-cli#1133).
|
|
522
|
+
|
|
523
|
+
`yaml.YAMLError` is neither an `OSError` nor a `ValueError`, so no
|
|
524
|
+
`except` clause on any of `tan/planner/template.py`'s three
|
|
525
|
+
outer-document YAML reads had ever covered it: measured through the
|
|
526
|
+
real `emit_scaffold` on 3.12.3, 3.13.15 and 3.14.7, a malformed
|
|
527
|
+
`metadata/e1m_modules/<sku>.yaml`, `metadata/boards/<board>.yaml` or
|
|
528
|
+
template-example `board.yaml` each raised a raw
|
|
529
|
+
`yaml.parser.ParserError` past a caller catching `TemplateError` and
|
|
530
|
+
nothing else.
|
|
531
|
+
|
|
532
|
+
The message is `read_catalog_document`'s verbatim with the format
|
|
533
|
+
name swapped -- `malformed {what} at {path}: not valid YAML
|
|
534
|
+
({problem}, line L column C)` -- because the two answer the same
|
|
535
|
+
question about two serialisations of the same kind of document.
|
|
536
|
+
`problem`/`problem_mark` belong to `MarkedYAMLError` (a scanner or
|
|
537
|
+
parser error); a plain `YAMLError` with neither degrades to its own
|
|
538
|
+
one-line text rather than folding a multi-line dump into a curated
|
|
539
|
+
message.
|
|
540
|
+
|
|
541
|
+
`import yaml` is FUNCTION-LOCAL, not module-scope, and that is the
|
|
542
|
+
whole of what keeps this module's stated property true: nothing in
|
|
543
|
+
`tan init`'s SDK-free path calls this method, so PyYAML stays out of
|
|
544
|
+
that path's import closure. The idiom is this package's own, not an
|
|
545
|
+
invention here -- `som_buildability.py`, `board_context.py`,
|
|
546
|
+
`bootstrap.py`, `doctor_libraries.py`, `scaffold.py`,
|
|
547
|
+
`system_manifest.py` and `flash_plan.py` all defer it the same way,
|
|
548
|
+
and `som_buildability.py:109` documents the reasoning verbatim.
|
|
549
|
+
"""
|
|
550
|
+
import yaml
|
|
551
|
+
try:
|
|
552
|
+
doc = yaml.safe_load(text)
|
|
553
|
+
except yaml.YAMLError as exc:
|
|
554
|
+
problem = getattr(exc, "problem", None) or str(exc).replace("\n", " ")
|
|
555
|
+
mark = getattr(exc, "problem_mark", None)
|
|
556
|
+
where = ("" if mark is None else
|
|
557
|
+
f", line {mark.line + 1} column {mark.column + 1}")
|
|
558
|
+
raise self.error(
|
|
559
|
+
f"malformed {what} at {path}: not valid YAML "
|
|
560
|
+
f"({problem}{where})") from exc
|
|
561
|
+
return self.require_mapping_doc(doc or {}, path=path, what=what)
|
|
562
|
+
|
|
563
|
+
def read_yaml_mapping(
|
|
564
|
+
self, path: Any, *, what: str,
|
|
565
|
+
absent: str | Callable[[], str] | None = None,
|
|
566
|
+
) -> dict[str, Any]:
|
|
567
|
+
"""A YAML document at @path, read AND parsed AND known to be a
|
|
568
|
+
mapping -- the exact composite `read_catalog_document` below already
|
|
569
|
+
is for JSON (tan-cli#1133).
|
|
570
|
+
|
|
571
|
+
`tan/planner/template.py`'s two `metadata/**` reads used to spell
|
|
572
|
+
this as a pre-flight `if not path.is_file(): raise` followed by a
|
|
573
|
+
completely bare `yaml.safe_load(path.read_text(encoding="utf-8"))` --
|
|
574
|
+
no `try` at any point, which is why the too-narrow-a-`try` sweep
|
|
575
|
+
could not see them.
|
|
576
|
+
|
|
577
|
+
The pre-flight is gone rather than supplemented: `Path.is_file()` is
|
|
578
|
+
itself the tan-cli#1127 trap -- against a `chmod 000` PARENT
|
|
579
|
+
directory it raises `PermissionError` on 3.12.3 and 3.13.15 and
|
|
580
|
+
returns `False` on 3.14.7, so one unreadable file was a raw traceback
|
|
581
|
+
on two interpreters and, on the third, a curated but FALSE "no such
|
|
582
|
+
file" (all four cells measured). Classifying on the real exception
|
|
583
|
+
gives one answer on all three: `FileNotFoundError` -> @absent,
|
|
584
|
+
every other read failure -> `cannot read ...`, an unparseable
|
|
585
|
+
document -> `malformed ...`.
|
|
586
|
+
"""
|
|
587
|
+
return self.require_yaml_mapping_doc(
|
|
588
|
+
self.require_readable_text(path, what=what, absent=absent),
|
|
589
|
+
path=path, what=what)
|
|
590
|
+
|
|
591
|
+
def read_optional_text(self, path: Any, *, what: str) -> str | None:
|
|
592
|
+
"""@path's text, or `None` when the document is NOT THERE AT ALL --
|
|
593
|
+
every other read failure still raises the injected curated error
|
|
594
|
+
(tan-cli#1162).
|
|
595
|
+
|
|
596
|
+
The one shape `require_readable_text` cannot express: a caller for
|
|
597
|
+
whom an absent document is a legal branch rather than a failure.
|
|
598
|
+
`tan/planner/topology.py::_core_os_choices` is its consumer -- a
|
|
599
|
+
`metadata_root` that carries no `schemas/` of its own (a synthetic
|
|
600
|
+
test root) falls back to the in-tree `BOARD_SCHEMA`, and that
|
|
601
|
+
fallback is the function's own documented contract, not an error
|
|
602
|
+
path.
|
|
603
|
+
|
|
604
|
+
Absent is `FileNotFoundError` OR `NotADirectoryError`, and the
|
|
605
|
+
second is not a widening for its own sake: `ENOTDIR` means an
|
|
606
|
+
ancestor of @path is a regular file, so the directory the caller
|
|
607
|
+
was looking in does not exist as a directory -- the same "this root
|
|
608
|
+
carries no such document" fact `ENOENT` reports, arrived at one
|
|
609
|
+
level up. Every OTHER `OSError` (`PermissionError`,
|
|
610
|
+
`IsADirectoryError`, `ELOOP`) and `UnicodeDecodeError` means the
|
|
611
|
+
document IS there and could not be read, which is a failure and
|
|
612
|
+
must not degrade to a silent fallback onto a DIFFERENT document.
|
|
613
|
+
|
|
614
|
+
That distinction is the whole reason this is a `try` rather than
|
|
615
|
+
the `is_file()` pre-flight it replaces. `Path.is_file()` answers
|
|
616
|
+
`False` to all of those alike where it answers at all, and on a
|
|
617
|
+
`chmod 000` parent it does not answer at all on two of the three
|
|
618
|
+
interpreters this repo supports (tan-cli#1127) -- so the pre-flight
|
|
619
|
+
version of this branch raised a raw `PermissionError` on 3.12.3 and
|
|
620
|
+
3.13.15 and silently read the wrong schema on 3.14.7.
|
|
621
|
+
"""
|
|
622
|
+
try:
|
|
623
|
+
return path.read_text(encoding="utf-8")
|
|
624
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
625
|
+
return None
|
|
626
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
627
|
+
raise self._unreadable(exc, path, what, None) from exc
|
|
628
|
+
|
|
629
|
+
def require_json_mapping_doc(
|
|
630
|
+
self, text: str, *, path: Any, what: str, noun: str = "a JSON object",
|
|
631
|
+
) -> dict[str, Any]:
|
|
632
|
+
"""@text parsed as JSON and known to be a mapping, or the injected
|
|
633
|
+
curated error -- `read_catalog_document`'s own parse+shape arm,
|
|
634
|
+
pulled out so it is not the catalog's private property
|
|
635
|
+
(tan-cli#1162).
|
|
636
|
+
|
|
637
|
+
Extracted for the same FORMAT-SYMMETRY reason the module docstring
|
|
638
|
+
gives for `require_yaml_mapping_doc`, and in the same direction:
|
|
639
|
+
that method already answers "is this text the document it claims to
|
|
640
|
+
be" for YAML, and the JSON half was sitting inline in one
|
|
641
|
+
catalog-specific composite where a second JSON reader could not
|
|
642
|
+
reach it. `tan/planner/zephyr_board.py::_load_soc_spec` and
|
|
643
|
+
`tan/planner/topology.py::_core_os_choices` are that second and
|
|
644
|
+
third reader.
|
|
645
|
+
|
|
646
|
+
A MOVE, not a copy: `read_catalog_document` below now calls this,
|
|
647
|
+
so the `not valid JSON` message has one definition rather than two
|
|
648
|
+
that happen to agree. Its own messages are unchanged byte for byte
|
|
649
|
+
-- `what="template catalog"` and the default @noun reproduce
|
|
650
|
+
exactly what tan-cli#1084 landed.
|
|
651
|
+
|
|
652
|
+
No `doc or {}` here, deliberately, where `require_yaml_mapping_doc`
|
|
653
|
+
has one: an empty YAML document parses to `None` and legitimately
|
|
654
|
+
means "an empty mapping", while empty JSON text is a
|
|
655
|
+
`json.JSONDecodeError` and a literal `null` is a document that says
|
|
656
|
+
`null` -- refused as `got NoneType`, which is what the catalog has
|
|
657
|
+
always done.
|
|
658
|
+
"""
|
|
659
|
+
try:
|
|
660
|
+
doc = json.loads(text)
|
|
661
|
+
except json.JSONDecodeError as exc:
|
|
662
|
+
raise self.error(
|
|
663
|
+
f"malformed {what} at {path}: not valid JSON "
|
|
664
|
+
f"({exc.msg}, line {exc.lineno} column {exc.colno})") from exc
|
|
665
|
+
return self.require_mapping_doc(doc, path=path, what=what, noun=noun)
|
|
666
|
+
|
|
667
|
+
def read_json_mapping(
|
|
668
|
+
self, path: Any, *, what: str,
|
|
669
|
+
absent: str | Callable[[], str] | None = None,
|
|
670
|
+
noun: str = "a JSON object",
|
|
671
|
+
) -> dict[str, Any]:
|
|
672
|
+
"""A JSON document at @path, read AND parsed AND known to be a
|
|
673
|
+
mapping -- exactly what `read_yaml_mapping` above is for YAML, and
|
|
674
|
+
what `read_catalog_document` below now IS (tan-cli#1162).
|
|
675
|
+
|
|
676
|
+
@absent behaves as it does on `require_readable_text`: it is the
|
|
677
|
+
caller's own message for `FileNotFoundError` and nothing else, and
|
|
678
|
+
passing one is what lets a caller drop an `is_file()` pre-flight
|
|
679
|
+
without losing the message that pre-flight used to produce.
|
|
680
|
+
`_load_soc_spec`'s `no SoC spec at <path>` is preserved that way,
|
|
681
|
+
byte for byte.
|
|
682
|
+
"""
|
|
683
|
+
return self.require_json_mapping_doc(
|
|
684
|
+
self.require_readable_text(path, what=what, absent=absent),
|
|
685
|
+
path=path, what=what, noun=noun)
|
|
686
|
+
|
|
687
|
+
def read_catalog_document(self, path: Any) -> dict[str, Any]:
|
|
688
|
+
"""`metadata/templates/catalog-v1.json`, decoded and known to be a
|
|
689
|
+
JSON object -- the read BOTH catalog readers do.
|
|
690
|
+
|
|
691
|
+
The file-read half is `require_readable_text` above; `json.
|
|
692
|
+
JSONDecodeError` is a `ValueError` too, so an `except TemplateError`
|
|
693
|
+
never caught it and a half-written catalog reached the user as a
|
|
694
|
+
traceback.
|
|
695
|
+
|
|
696
|
+
A one-line call into `read_json_mapping` since tan-cli#1162, which
|
|
697
|
+
pulled the read+parse+shape composite this function WAS out to
|
|
698
|
+
where a second and third JSON reader could use it. Its messages are
|
|
699
|
+
unchanged byte for byte; what changed is that they now have one
|
|
700
|
+
definition. Kept as a named method rather than inlined at its two
|
|
701
|
+
call sites because `what="template catalog"` is the fact those
|
|
702
|
+
callers must not have to spell twice.
|
|
703
|
+
"""
|
|
704
|
+
return self.read_json_mapping(path, what="template catalog")
|
|
705
|
+
|
|
706
|
+
def catalog_templates(self, doc: Any, *, path: Any) -> list[Any]:
|
|
707
|
+
"""`templates:` as a real list, on a catalog that is really a mapping.
|
|
708
|
+
|
|
709
|
+
Every `doc.get("templates", ...)` site on both sides iterated
|
|
710
|
+
whatever was there, and the `.get` itself assumed a mapping:
|
|
711
|
+
`templates: 3` was `TypeError: 'int' object is not iterable` and a
|
|
712
|
+
bare-list catalog was `AttributeError: 'list' object has no
|
|
713
|
+
attribute 'get'`.
|
|
714
|
+
|
|
715
|
+
ABSENT still degrades to `[]` -- the pre-existing default, which
|
|
716
|
+
each caller already answers with its own not-found error. Only a
|
|
717
|
+
PRESENT non-list is refused, so nothing that read before stops
|
|
718
|
+
reading.
|
|
719
|
+
"""
|
|
720
|
+
self.require_mapping_doc(doc, path=path, what="template catalog",
|
|
721
|
+
noun="a JSON object")
|
|
722
|
+
return self.require_field(doc.get("templates", []), list,
|
|
723
|
+
doc=path, field="templates")
|