tangle-cli 0.1.14__tar.gz → 0.1.17__tar.gz

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.
Files changed (104) hide show
  1. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/PKG-INFO +86 -2
  2. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/README.md +85 -1
  3. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/__init__.py +1 -1
  4. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/args_container.py +22 -7
  5. tangle_cli-0.1.17/packages/tangle-cli/src/tangle_cli/authenticated_identity.py +77 -0
  6. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/client.py +10 -0
  7. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_from_func.py +44 -8
  8. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_generator.py +33 -0
  9. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_publisher.py +335 -81
  10. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py +87 -5
  11. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +14 -0
  12. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +12 -0
  13. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/__init__.py +2 -0
  14. tangle_cli-0.1.17/packages/tangle-cli/src/tangle_cli/python_pipeline/publish.py +142 -0
  15. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/ref.py +10 -0
  16. tangle_cli-0.1.17/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +203 -0
  17. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/pyproject.toml +1 -1
  18. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/pyproject.toml.orig +1 -1
  19. tangle_cli-0.1.14/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +0 -88
  20. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/api_cli.py +0 -0
  21. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  22. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  23. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  24. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  25. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  26. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +0 -0
  27. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  28. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  29. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  30. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  31. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  32. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/handler.py +0 -0
  33. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  34. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  35. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/models.py +0 -0
  36. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  37. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  38. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +0 -0
  39. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +0 -0
  40. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  41. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  42. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  43. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +0 -0
  44. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +0 -0
  45. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +0 -0
  46. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +0 -0
  47. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_spec_utils.py +0 -0
  48. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_validation.py +0 -0
  49. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipelines.py +0 -0
  50. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +0 -0
  51. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  52. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/cfg.py +0 -0
  53. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/compiler_context.py +0 -0
  54. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/dynamic_data.py +0 -0
  55. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/emit.py +0 -0
  56. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +0 -0
  57. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/graph.py +0 -0
  58. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/ids.py +0 -0
  59. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/pipeline.py +0 -0
  60. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/placeholders.py +0 -0
  61. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/raw.py +0 -0
  62. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/registered.py +0 -0
  63. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/subpipeline.py +0 -0
  64. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/task.py +0 -0
  65. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +0 -0
  66. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/types.py +0 -0
  67. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/quickstart.py +0 -0
  68. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schema_validation.py +0 -0
  69. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schemas/__init__.py +0 -0
  70. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json +0 -0
  71. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schemas/pipeline_schema.json +0 -0
  72. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  73. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  74. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  75. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
  76. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/OSS-CONVENTIONS.md +0 -0
  77. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/PORT-README.md +0 -0
  78. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/SKILL.md +0 -0
  79. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/agents/auth-wizard.md +0 -0
  80. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/agents/builder.md +0 -0
  81. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/agents/debugger.md +0 -0
  82. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/agents/reporter.md +0 -0
  83. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/agents/researcher.md +0 -0
  84. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/agents/reviewer.md +0 -0
  85. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/agents/scenario-builder.md +0 -0
  86. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/data-sources.md +0 -0
  87. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/event-log.md +0 -0
  88. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/example-scenarios/01-mslr-ranking.md +0 -0
  89. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/example-scenarios/02-text-classification.md +0 -0
  90. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/example-scenarios/INDEX.md +0 -0
  91. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/iterating-on-runs.md +0 -0
  92. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/knowledge-corpus.md +0 -0
  93. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/secrets.md +0 -0
  94. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/setup.md +0 -0
  95. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-0-initialize.md +0 -0
  96. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-1-analyze.md +0 -0
  97. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-2-hypothesize.md +0 -0
  98. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-3-submit.md +0 -0
  99. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-4-monitor.md +0 -0
  100. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-5-evaluate.md +0 -0
  101. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-6-synthesize.md +0 -0
  102. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/step-7-decide.md +0 -0
  103. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/tangle-tools.md +0 -0
  104. {tangle_cli-0.1.14 → tangle_cli-0.1.17}/skills/tangent/references/uploading-artifacts.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tangle-cli
3
- Version: 0.1.14
3
+ Version: 0.1.17
4
4
  Summary: CLI for Tangle, the open-source ML pipeline orchestration platform
5
5
  Author: Alexey Volkov, Tangle authors, Volv Grebennikov
6
6
  Author-email: Alexey Volkov <alexey.volkov@ark-kun.com>, Volv Grebennikov <volv.grebennikov@shopify.com>
@@ -379,7 +379,36 @@ uv run tangle sdk published-components publish components/my-component.yaml --dr
379
379
  uv run tangle sdk published-components deprecate sha256:old --superseded-by sha256:new
380
380
  ```
381
381
 
382
- `publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined.
382
+ `publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--allow-downgrade`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined.
383
+
384
+ #### Monotonic publishing and result digests
385
+
386
+ Publishing is monotonic against the highest **non-deprecated, owner-scoped** published version of the component (ordering comes from `compare_versions`, which zero-pads shorter versions so `1.0.1 > 1.0`):
387
+
388
+ | Local vs latest published | Outcome | Notes |
389
+ | --- | --- | --- |
390
+ | nothing published (no non-deprecated owner-scoped version) | `proceed` | first publish |
391
+ | local strictly newer | `proceed` | publishes, then deprecates owner-scoped versions proven older |
392
+ | local equal | `skip` | no create/deprecate calls |
393
+ | local strictly older | `skip` | no-op; never publishes an older version and never deprecates a newer one |
394
+ | published version unreadable, or ambiguous tie at the latest version | `error` | fails closed; no create/deprecate calls |
395
+
396
+ Every result carries the digest of the version it compared against:
397
+
398
+ - `digest` — digest of a **newly created** publication (SUCCESS only, unchanged meaning).
399
+ - `latest_digest` — exact digest of the selected latest published version (set on PROCEED/SKIP, and carried through the SUCCESS/ERROR results that follow a version check). JSON output includes it as `latest_digest`.
400
+ - `ProcessingResult.resolved_digest` — the digest a caller should pin: `digest or latest_digest`, but deliberately `None` for any outcome other than SUCCESS/SKIP, so a failed publish never hands back a stale-but-plausible digest.
401
+
402
+ The check **fails closed** (an `error`, with nothing published and nothing deprecated) whenever the published state cannot be read completely:
403
+
404
+ - any non-deprecated owner-scoped candidate whose digest is missing, or whose spec/version cannot be fetched or parsed — an unreadable row could be newer than the local version, and must never be deprecated sight-unseen;
405
+ - two or more non-deprecated candidates tied at the selected latest version, where no exact digest can be chosen. The reason names the tied digests instead of guessing from API ordering.
406
+
407
+ Deprecated components are never selected as "latest", and all digest lists in results/logs are sorted, so diagnostics do not depend on API response order.
408
+
409
+ The published state is re-read immediately before create, and the same policy is re-applied to that fresh observation: a version that appeared concurrently since the first check can still turn the publish into a skip or an error. After a successful create, only digests **proven strictly older** in that final observation are deprecated — a row first seen after the publish decision is never deprecated on the strength of the earlier one. A race after the final read is not preventable client-side and needs a server-side conditional/CAS operation.
410
+
411
+ **Contract change:** republishing an older version used to proceed (publishing the older spec and deprecating the newer one). It is now a skip. `--allow-downgrade` publishes the older spec but still never deprecates a strictly newer row; deprecate those explicitly with `published-components deprecate` if that is really intended. Deliberate downgrades must opt in with `--allow-downgrade` on the CLI, or `ComponentPublisher(allow_downgrade=True)` / `allow_downgrade=True` on the `publish_component_to_tangle` / `perform_version_check` wrappers. Republishing the same version is still a skip, as before.
383
412
 
384
413
  There is no separate OSS `publish-all` command. To publish multiple components, pass a YAML/JSON config list, or `_defaults` + `configs`, to the same `published-components publish` command; the command aggregates results and exits nonzero if any component errors. A top-level `_select` node can choose between such documents per environment (see [Environment-selected configs](#environment-selected-configs-_select)).
385
414
 
@@ -611,6 +640,61 @@ def score(...):
611
640
  ...
612
641
  ```
613
642
 
643
+ Declare the environment in a config file instead with `TaskEnv.from_config(path)`. A relative `path` resolves against the **calling pipeline module**, never the working directory, so the same script selects the same config wherever `tangle` runs from:
644
+
645
+ ```python
646
+ EVAL = TaskEnv.from_config("envs.yaml") # relative to THIS file
647
+ ```
648
+
649
+ ```yaml
650
+ # envs.yaml
651
+ image: python:3.12
652
+ dependencies_from: pyproject.toml # relative to THIS config file
653
+ ```
654
+
655
+ The file is read with the same loader `--config` uses, so the `_select` directive works here identically — the case is chosen exactly and case-sensitively by an environment variable, and an unset or unmatched variable is a hard error unless an explicit `default` branch is authored (no implicit default, no implicit production):
656
+
657
+ ```yaml
658
+ _select:
659
+ env: DEPLOY_ENVIRONMENT
660
+ cases:
661
+ production:
662
+ image: registry.example/scoring:prod
663
+ staging:
664
+ image: registry.example/scoring:staging
665
+ default:
666
+ image: python:3.12
667
+ ```
668
+
669
+ The document must resolve to exactly one object, and its keys must be named parameters of the generated `__init__` of the class `from_config` was called on — `InitVar` pseudo-fields included, `ClassVar`s and `field(init=False)` excluded — so these files stay environment-only: pipeline concerns such as file paths, versioning, annotations, schedules, or subscriptions have no field to land in and are rejected with the allowed field names. `from_config` is generic — any `TaskEnv` dataclass subclass inherits it unchanged, returns its own type, and is validated against its own fields:
670
+
671
+ ```python
672
+ from tangle_cli.python_pipeline import TaskEnv
673
+
674
+ @dataclass(frozen=True)
675
+ class GpuEnv(TaskEnv):
676
+ accelerator: str = ""
677
+
678
+ def __post_init__(self):
679
+ super().__post_init__()
680
+ if self.accelerator not in ("gpu", "tpu"):
681
+ raise ValueError("GpuEnv.accelerator must be one of: gpu, tpu")
682
+
683
+ GPU = GpuEnv.from_config("envs.yaml") # accepts image, dependencies_from, accelerator
684
+ ```
685
+
686
+ Failures raise `CompileError` naming the resolved config path. A config file is untrusted input, so **no diagnostic echoes a config value**. Keys are rendered through a capped, control-character-scrubbing renderer, and a constructor's own validation text is never quoted — it could embed a value directly, nested inside a structure, or transformed (lower cased, sliced, re-encoded). The rejected exception is also kept off `__cause__`/`__context__`, since `traceback.format_exception` would otherwise print it into the same CI log. The message names the class and the fields present, and points at constructing the class directly to see the validation error:
687
+
688
+ ```
689
+ GpuEnv.from_config: /repo/envs.yaml case is not a valid GpuEnv (fields present:
690
+ accelerator, image). Its validation message is withheld because it can contain
691
+ config values; construct GpuEnv(...) directly to see it.
692
+ ```
693
+
694
+ Calling `GpuEnv(...)` directly is unaffected and raises the ordinary `ValueError` with its full message.
695
+
696
+ See `examples/python_pipeline/task_env_from_config/` for a runnable example.
697
+
614
698
  Use `@task(image_id="eval-slim")` when source should carry a logical image name instead of a concrete registry reference. Downstream code can register defaults with `register_image_id(...)`, and callers can override at compile time with repeatable `--image ID=REF`:
615
699
 
616
700
  ```bash
@@ -353,7 +353,36 @@ uv run tangle sdk published-components publish components/my-component.yaml --dr
353
353
  uv run tangle sdk published-components deprecate sha256:old --superseded-by sha256:new
354
354
  ```
355
355
 
356
- `publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined.
356
+ `publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--allow-downgrade`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined.
357
+
358
+ #### Monotonic publishing and result digests
359
+
360
+ Publishing is monotonic against the highest **non-deprecated, owner-scoped** published version of the component (ordering comes from `compare_versions`, which zero-pads shorter versions so `1.0.1 > 1.0`):
361
+
362
+ | Local vs latest published | Outcome | Notes |
363
+ | --- | --- | --- |
364
+ | nothing published (no non-deprecated owner-scoped version) | `proceed` | first publish |
365
+ | local strictly newer | `proceed` | publishes, then deprecates owner-scoped versions proven older |
366
+ | local equal | `skip` | no create/deprecate calls |
367
+ | local strictly older | `skip` | no-op; never publishes an older version and never deprecates a newer one |
368
+ | published version unreadable, or ambiguous tie at the latest version | `error` | fails closed; no create/deprecate calls |
369
+
370
+ Every result carries the digest of the version it compared against:
371
+
372
+ - `digest` — digest of a **newly created** publication (SUCCESS only, unchanged meaning).
373
+ - `latest_digest` — exact digest of the selected latest published version (set on PROCEED/SKIP, and carried through the SUCCESS/ERROR results that follow a version check). JSON output includes it as `latest_digest`.
374
+ - `ProcessingResult.resolved_digest` — the digest a caller should pin: `digest or latest_digest`, but deliberately `None` for any outcome other than SUCCESS/SKIP, so a failed publish never hands back a stale-but-plausible digest.
375
+
376
+ The check **fails closed** (an `error`, with nothing published and nothing deprecated) whenever the published state cannot be read completely:
377
+
378
+ - any non-deprecated owner-scoped candidate whose digest is missing, or whose spec/version cannot be fetched or parsed — an unreadable row could be newer than the local version, and must never be deprecated sight-unseen;
379
+ - two or more non-deprecated candidates tied at the selected latest version, where no exact digest can be chosen. The reason names the tied digests instead of guessing from API ordering.
380
+
381
+ Deprecated components are never selected as "latest", and all digest lists in results/logs are sorted, so diagnostics do not depend on API response order.
382
+
383
+ The published state is re-read immediately before create, and the same policy is re-applied to that fresh observation: a version that appeared concurrently since the first check can still turn the publish into a skip or an error. After a successful create, only digests **proven strictly older** in that final observation are deprecated — a row first seen after the publish decision is never deprecated on the strength of the earlier one. A race after the final read is not preventable client-side and needs a server-side conditional/CAS operation.
384
+
385
+ **Contract change:** republishing an older version used to proceed (publishing the older spec and deprecating the newer one). It is now a skip. `--allow-downgrade` publishes the older spec but still never deprecates a strictly newer row; deprecate those explicitly with `published-components deprecate` if that is really intended. Deliberate downgrades must opt in with `--allow-downgrade` on the CLI, or `ComponentPublisher(allow_downgrade=True)` / `allow_downgrade=True` on the `publish_component_to_tangle` / `perform_version_check` wrappers. Republishing the same version is still a skip, as before.
357
386
 
358
387
  There is no separate OSS `publish-all` command. To publish multiple components, pass a YAML/JSON config list, or `_defaults` + `configs`, to the same `published-components publish` command; the command aggregates results and exits nonzero if any component errors. A top-level `_select` node can choose between such documents per environment (see [Environment-selected configs](#environment-selected-configs-_select)).
359
388
 
@@ -585,6 +614,61 @@ def score(...):
585
614
  ...
586
615
  ```
587
616
 
617
+ Declare the environment in a config file instead with `TaskEnv.from_config(path)`. A relative `path` resolves against the **calling pipeline module**, never the working directory, so the same script selects the same config wherever `tangle` runs from:
618
+
619
+ ```python
620
+ EVAL = TaskEnv.from_config("envs.yaml") # relative to THIS file
621
+ ```
622
+
623
+ ```yaml
624
+ # envs.yaml
625
+ image: python:3.12
626
+ dependencies_from: pyproject.toml # relative to THIS config file
627
+ ```
628
+
629
+ The file is read with the same loader `--config` uses, so the `_select` directive works here identically — the case is chosen exactly and case-sensitively by an environment variable, and an unset or unmatched variable is a hard error unless an explicit `default` branch is authored (no implicit default, no implicit production):
630
+
631
+ ```yaml
632
+ _select:
633
+ env: DEPLOY_ENVIRONMENT
634
+ cases:
635
+ production:
636
+ image: registry.example/scoring:prod
637
+ staging:
638
+ image: registry.example/scoring:staging
639
+ default:
640
+ image: python:3.12
641
+ ```
642
+
643
+ The document must resolve to exactly one object, and its keys must be named parameters of the generated `__init__` of the class `from_config` was called on — `InitVar` pseudo-fields included, `ClassVar`s and `field(init=False)` excluded — so these files stay environment-only: pipeline concerns such as file paths, versioning, annotations, schedules, or subscriptions have no field to land in and are rejected with the allowed field names. `from_config` is generic — any `TaskEnv` dataclass subclass inherits it unchanged, returns its own type, and is validated against its own fields:
644
+
645
+ ```python
646
+ from tangle_cli.python_pipeline import TaskEnv
647
+
648
+ @dataclass(frozen=True)
649
+ class GpuEnv(TaskEnv):
650
+ accelerator: str = ""
651
+
652
+ def __post_init__(self):
653
+ super().__post_init__()
654
+ if self.accelerator not in ("gpu", "tpu"):
655
+ raise ValueError("GpuEnv.accelerator must be one of: gpu, tpu")
656
+
657
+ GPU = GpuEnv.from_config("envs.yaml") # accepts image, dependencies_from, accelerator
658
+ ```
659
+
660
+ Failures raise `CompileError` naming the resolved config path. A config file is untrusted input, so **no diagnostic echoes a config value**. Keys are rendered through a capped, control-character-scrubbing renderer, and a constructor's own validation text is never quoted — it could embed a value directly, nested inside a structure, or transformed (lower cased, sliced, re-encoded). The rejected exception is also kept off `__cause__`/`__context__`, since `traceback.format_exception` would otherwise print it into the same CI log. The message names the class and the fields present, and points at constructing the class directly to see the validation error:
661
+
662
+ ```
663
+ GpuEnv.from_config: /repo/envs.yaml case is not a valid GpuEnv (fields present:
664
+ accelerator, image). Its validation message is withheld because it can contain
665
+ config values; construct GpuEnv(...) directly to see it.
666
+ ```
667
+
668
+ Calling `GpuEnv(...)` directly is unaffected and raises the ordinary `ValueError` with its full message.
669
+
670
+ See `examples/python_pipeline/task_env_from_config/` for a runnable example.
671
+
588
672
  Use `@task(image_id="eval-slim")` when source should carry a logical image name instead of a concrete registry reference. Downstream code can register defaults with `register_image_id(...)`, and callers can override at compile time with repeatable `--image ID=REF`:
589
673
 
590
674
  ```bash
@@ -14,6 +14,6 @@ from tangle_cli.dynamic_discovery_client import TangleDynamicDiscoveryClient
14
14
  try:
15
15
  __version__ = metadata_version("tangle-cli")
16
16
  except PackageNotFoundError:
17
- __version__ = "0.1.14"
17
+ __version__ = "0.1.17"
18
18
 
19
19
  __all__ = ["TangleDynamicDiscoveryClient", "__version__"]
@@ -41,10 +41,17 @@ class ConfigFileError(Exception):
41
41
  """Raised when there is an error loading or resolving a config file."""
42
42
 
43
43
 
44
- def _render_case_key(key: str) -> str:
45
- """Render an already-validated configured case key for diagnostics."""
44
+ def _render_config_key(key: Any) -> str:
45
+ """Render a config-document key safely for a diagnostic message.
46
46
 
47
- rendered = "".join(char if char.isprintable() else "?" for char in key)
47
+ Config keys come from user files, so a diagnostic must never echo them
48
+ verbatim: non-printable characters are replaced and the result is length
49
+ capped before being quoted. Accepts non-string keys (YAML permits them) so
50
+ callers can report a bad key without first proving it is a string.
51
+ """
52
+
53
+ text = key if isinstance(key, str) else str(key)
54
+ rendered = "".join(char if char.isprintable() else "?" for char in text)
48
55
  if len(rendered) > _MAX_RENDERED_CASE_KEY_LENGTH:
49
56
  rendered = rendered[: _MAX_RENDERED_CASE_KEY_LENGTH - 3] + "..."
50
57
  return repr(rendered)
@@ -172,8 +179,14 @@ class ArgsContainer:
172
179
  if seen is not None and depth <= seen[0]:
173
180
  return seen[2]
174
181
 
182
+ # Every key reaching a diagnostic goes through the ONE capped/scrubbed
183
+ # renderer: these keys are user input, and an unbounded repr() would
184
+ # let a hostile document paste control characters or a wall of text
185
+ # into a compile/CI log.
175
186
  siblings = sorted(
176
- repr(key) for key in node if not (isinstance(key, str) and key.startswith("_"))
187
+ _render_config_key(key)
188
+ for key in node
189
+ if not (isinstance(key, str) and key.startswith("_"))
177
190
  )
178
191
  if siblings:
179
192
  raise ConfigFileError(
@@ -189,7 +202,9 @@ class ArgsContainer:
189
202
  selector_dict = cast(dict[Any, Any], selector)
190
203
 
191
204
  unexpected = sorted(
192
- repr(key) for key in selector_dict if key not in ("env", "cases", "default")
205
+ _render_config_key(key)
206
+ for key in selector_dict
207
+ if key not in ("env", "cases", "default")
193
208
  )
194
209
  if unexpected:
195
210
  raise ConfigFileError(
@@ -231,7 +246,7 @@ class ArgsContainer:
231
246
  # Every branch document is shape-checked, not just the selected one,
232
247
  # so a malformed selector fails identically in every environment.
233
248
  ArgsContainer._validate_branch_document(
234
- case_value, f"{SELECT_KEY} case {_render_case_key(case_key)}", depth, memo
249
+ case_value, f"{SELECT_KEY} case {_render_config_key(case_key)}", depth, memo
235
250
  )
236
251
 
237
252
  default_branch: Any = None
@@ -241,7 +256,7 @@ class ArgsContainer:
241
256
  default_branch, f"{SELECT_KEY}.default", depth, memo
242
257
  )
243
258
 
244
- allowed = ", ".join(_render_case_key(key) for key in sorted(cases_dict))
259
+ allowed = ", ".join(_render_config_key(key) for key in sorted(cases_dict))
245
260
  summary: _SelectorSummary = (env_name, cases_dict, default_branch, allowed)
246
261
  if memo is not None:
247
262
  previous = memo.get(node_id)
@@ -0,0 +1,77 @@
1
+ """Resolution of the authenticated account, and the symbolic ``me`` publisher.
2
+
3
+ Compilation is offline, so a compiler cannot write the author's account id into
4
+ a component entry. It writes the symbolic publisher :data:`ME` instead, and the
5
+ account is resolved at hydration time by whoever is actually authenticated.
6
+
7
+ Two callers need the account: the publisher, which scopes its version check and
8
+ deprecation to the owner, and hydration of entries whose publisher is ``me``.
9
+ They must agree, so the parsing lives here once.
10
+
11
+ They differ only in what an *unknown* account means, so that choice is left to
12
+ the caller: the publisher degrades, while resolution must fail closed -- see
13
+ :func:`require_authenticated_user_id`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from typing import Any
20
+
21
+ #: Symbolic publisher meaning "whoever is authenticated at hydration time".
22
+ #: Matched exactly and case-sensitively, so a literal account id that happens to
23
+ #: differ in case is never mistaken for the sentinel.
24
+ ME = "me"
25
+
26
+ __all__ = [
27
+ "ME",
28
+ "IdentityUnavailableError",
29
+ "authenticated_user_id",
30
+ "is_symbolic_me",
31
+ "require_authenticated_user_id",
32
+ ]
33
+
34
+
35
+ class IdentityUnavailableError(RuntimeError):
36
+ """The authenticated account could not be determined."""
37
+
38
+
39
+ def is_symbolic_me(publisher: Any) -> bool:
40
+ """Whether ``publisher`` is the symbolic self-reference rather than an id."""
41
+ return publisher == ME
42
+
43
+
44
+ def authenticated_user_id(client: Any) -> str | None:
45
+ """Return the current user id, or ``None`` if it cannot be read.
46
+
47
+ An empty or missing id is as unusable as no answer at all.
48
+ """
49
+ try:
50
+ user_info = client.users_me()
51
+ except Exception:
52
+ return None
53
+ if user_info is None:
54
+ return None
55
+ if isinstance(user_info, Mapping):
56
+ value = user_info.get("id")
57
+ else:
58
+ value = getattr(user_info, "id", None)
59
+ return str(value) if value else None
60
+
61
+
62
+ def require_authenticated_user_id(client: Any) -> str:
63
+ """Return the current user id, raising when it cannot be determined.
64
+
65
+ For owner-scoped *resolution* an unknown account must never widen the
66
+ search: continuing unscoped is what would let a component published by
67
+ someone else under the same name be resolved as the author's own code.
68
+ """
69
+ user_id = authenticated_user_id(client)
70
+ if not user_id:
71
+ raise IdentityUnavailableError(
72
+ "Cannot determine the authenticated account, so a component "
73
+ f"published by '{ME}' cannot be resolved. Refusing to fall back to "
74
+ "an unscoped lookup, which could resolve a component owned by "
75
+ "someone else. Re-authenticate and retry."
76
+ )
77
+ return user_id
@@ -952,12 +952,22 @@ class TangleApiClient(GeneratedTangleApiOperations):
952
952
  search_digests.add(str(data["digest"]))
953
953
 
954
954
  publisher_filter = published_by_substring or published_by
955
+ # The API parameter is a SUBSTRING match. When the caller asked for an
956
+ # exact owner it is therefore only a prefilter, and accepting its
957
+ # results verbatim would let a superset owner id (``alice`` matching
958
+ # ``alice2``) satisfy an exact request. An owner-scoped lookup is an
959
+ # identity control, so exactness is enforced here on the returned rows
960
+ # and a row with no owner is never accepted. Explicit
961
+ # ``published_by_substring`` callers keep substring semantics.
962
+ exact_owner = published_by if published_by and not published_by_substring else None
955
963
  found: dict[str, ComponentInfo] = {}
956
964
 
957
965
  def add(info: ComponentInfo) -> None:
958
966
  key = info.digest or info.name
959
967
  if not key:
960
968
  return
969
+ if exact_owner is not None and info.published_by != exact_owner:
970
+ return
961
971
  found[key] = info
962
972
  if verbose:
963
973
  self.logger.info(f" Found existing component: {info.name} ({key[:16]}...)")
@@ -986,8 +986,13 @@ def _is_name_main_test(node: ast.expr) -> bool:
986
986
  # _strip_authoring_constructs). ``registered`` marks an op published separately
987
987
  # via its own gen_config.yaml; when that same op is baked (through its
988
988
  # local_from_python entry) the decorator + its authoring import must be stripped
989
- # too, exactly like @task.
990
- _AUTHORING_DECORATOR_NAMES = frozenset({"task", "pipeline", "subpipeline", "registered"})
989
+ # too, exactly like @task. ``Publish`` likewise only records a publication
990
+ # declaration for the compiler; leaving it in the baked program would raise
991
+ # ``NameError`` at container startup, since its import is stripped with the
992
+ # rest of the authoring surface.
993
+ _AUTHORING_DECORATOR_NAMES = frozenset(
994
+ {"task", "pipeline", "subpipeline", "registered", "Publish"}
995
+ )
991
996
 
992
997
  # The python-pipeline authoring modules. ONLY imports of these modules (and
993
998
  # their submodules) are authoring-only and stripped from the baked source. We
@@ -2015,6 +2020,15 @@ def build_component_dict(
2015
2020
  # ============================================================================
2016
2021
 
2017
2022
 
2023
+ def _within(path: Path, root: Path) -> bool:
2024
+ """Whether *path* lies inside *root*."""
2025
+ try:
2026
+ path.resolve().relative_to(root.resolve())
2027
+ except ValueError:
2028
+ return False
2029
+ return True
2030
+
2031
+
2018
2032
  def generate_component_yaml(
2019
2033
  file_path: Path,
2020
2034
  output_path: Path,
@@ -2030,12 +2044,21 @@ def generate_component_yaml(
2030
2044
  emit_generation_annotations: bool = True,
2031
2045
  path_annotation_mode: Literal["oss", "td_legacy"] = "oss",
2032
2046
  unwrapped_inputs: dict[str, Any] | None = None,
2047
+ logical_output_path: Path | None = None,
2033
2048
  ) -> bool:
2034
2049
  """Generate a component YAML file from a Python function.
2035
2050
 
2036
2051
  Args:
2037
2052
  file_path: Path to the Python source file
2038
2053
  output_path: Where to write the generated YAML
2054
+ logical_output_path: Where the component is to be UNDERSTOOD to live,
2055
+ for provenance only. Defaults to ``output_path``. Pass this when
2056
+ the file is written somewhere incidental -- a private staging
2057
+ directory, a scratch area -- so the recorded provenance describes
2058
+ the component rather than the accident of where bytes landed.
2059
+ Provenance is otherwise derived from the physical path, which
2060
+ would embed that location and make the emitted YAML, and therefore
2061
+ its digest, differ between runs of identical source.
2039
2062
  container_image: Docker image reference
2040
2063
  function_name: Function to extract (auto-detected if None)
2041
2064
  dependencies_from: Path to pyproject.toml with pip dependencies
@@ -2111,7 +2134,6 @@ def generate_component_yaml(
2111
2134
  deps = read_dependencies(dependencies_from)
2112
2135
 
2113
2136
  # 4. Build annotations
2114
- directory = file_path.parent.resolve()
2115
2137
  module_code = file_path.read_text()
2116
2138
 
2117
2139
  annotations: dict[str, str] = {
@@ -2143,9 +2165,18 @@ def generate_component_yaml(
2143
2165
  # basename-only paths outside a git checkout to preserve historical
2144
2166
  # snapshots.
2145
2167
  resolved_source = file_path.resolve()
2146
- resolved_output = output_path.resolve()
2168
+ # Provenance describes where the component LIVES, which is not always
2169
+ # where this call happens to write it. Kept UNRESOLVED: ``td_legacy``
2170
+ # annotates the lexical basename, so resolving here would rewrite the
2171
+ # recorded name whenever the output is a symlink.
2172
+ annotation_output = logical_output_path if logical_output_path is not None else output_path
2173
+ resolved_output = annotation_output.resolve()
2147
2174
  common_dir = Path(os.path.commonpath([resolved_source, resolved_output]))
2148
- git_root = get_git_root(directory)
2175
+ # Discover from the SOURCE's real directory: a symlinked source file
2176
+ # whose link lives outside the checkout would otherwise find no repo
2177
+ # and drop every git annotation.
2178
+ source_dir = resolved_source.parent
2179
+ git_root = get_git_root(source_dir)
2149
2180
  use_common_paths = path_annotation_mode == "oss" or git_root is not None
2150
2181
 
2151
2182
  def _path_annotation(path: Path) -> str:
@@ -2158,7 +2189,7 @@ def generate_component_yaml(
2158
2189
 
2159
2190
  if not strip_source_path:
2160
2191
  annotations["python_original_code_path"] = _path_annotation(file_path)
2161
- annotations["component_yaml_path"] = _path_annotation(output_path)
2192
+ annotations["component_yaml_path"] = _path_annotation(annotation_output)
2162
2193
  if emit_generation_annotations:
2163
2194
  if dependencies_from:
2164
2195
  annotations["tangle_cli_generation_dependencies_from"] = _path_annotation(dependencies_from)
@@ -2167,7 +2198,12 @@ def generate_component_yaml(
2167
2198
 
2168
2199
  # Git info — use the same common ancestor as git_relative_dir when common paths are active.
2169
2200
  if git_root:
2170
- git_info = get_git_info(common_dir)
2201
+ # Read the repository from a directory KNOWN to be inside it. The
2202
+ # common ancestor of source and output need not be: any output
2203
+ # outside the checkout (a configured output_folder, a staging dir)
2204
+ # drags it out, and reading git there returns nothing, silently
2205
+ # publishing a component with no repository of origin.
2206
+ git_info = get_git_info(common_dir if _within(common_dir, git_root) else source_dir)
2171
2207
  git_info.pop("_git_root", None)
2172
2208
  # Override git_relative_dir to be the common ancestor
2173
2209
  try:
@@ -2176,7 +2212,7 @@ def generate_component_yaml(
2176
2212
  pass
2177
2213
  annotations.update(git_info)
2178
2214
  else:
2179
- git_info = get_git_info(directory)
2215
+ git_info = get_git_info(source_dir)
2180
2216
  git_info.pop("_git_root", None)
2181
2217
  annotations.update(git_info)
2182
2218
 
@@ -128,6 +128,7 @@ class ComponentGenerator:
128
128
  resolve_root: Path | None = None,
129
129
  emit_generation_annotations: bool = True,
130
130
  unwrapped_inputs: dict[str, Any] | None = None,
131
+ logical_output_path: Path | None = None,
131
132
  ) -> bool:
132
133
  """Generate component YAML from a Python function source file.
133
134
 
@@ -147,6 +148,8 @@ class ComponentGenerator:
147
148
  unwrapped_inputs: Optional persisted unwrap schema. Hydrate forwards
148
149
  this ``local_from_python.unwrapped_inputs`` payload so component
149
150
  generation expands dict parameters exactly as compile did.
151
+ logical_output_path: Where the component is to be understood to
152
+ live, for provenance only. Defaults to ``output_path``.
150
153
 
151
154
  Returns:
152
155
  True when generation succeeds, otherwise False.
@@ -154,6 +157,12 @@ class ComponentGenerator:
154
157
 
155
158
  from tangle_cli.component_from_func import generate_component_yaml
156
159
 
160
+ # Forwarded only when set. These hops are overridable, and an older
161
+ # override written against the previous signature must keep working
162
+ # for every caller that does not use the seam.
163
+ seam: dict[str, Any] = (
164
+ {} if logical_output_path is None else {"logical_output_path": logical_output_path}
165
+ )
157
166
  return generate_component_yaml(
158
167
  file_path=file_path,
159
168
  output_path=output_path,
@@ -168,6 +177,7 @@ class ComponentGenerator:
168
177
  resolve_root=resolve_root,
169
178
  emit_generation_annotations=emit_generation_annotations,
170
179
  unwrapped_inputs=unwrapped_inputs,
180
+ **seam,
171
181
  )
172
182
 
173
183
  def regenerate_yaml(
@@ -184,6 +194,7 @@ class ComponentGenerator:
184
194
  resolve_root: Path | None = None,
185
195
  emit_generation_annotations: bool = True,
186
196
  unwrapped_inputs: dict[str, Any] | None = None,
197
+ logical_output_path: Path | None = None,
187
198
  ) -> bool:
188
199
  """Regenerate a YAML component from a Python function source file.
189
200
 
@@ -202,6 +213,10 @@ class ComponentGenerator:
202
213
  emit_generation_annotations: Whether to emit regeneration metadata.
203
214
  unwrapped_inputs: Optional ``local_from_python.unwrapped_inputs``
204
215
  schema to preserve compile-time unwrap expansion at hydrate time.
216
+ logical_output_path: Where the component is to be understood to
217
+ live, for provenance only. Defaults to ``output_path``. Note
218
+ that the image is still read back from the PHYSICAL output, so
219
+ a caller writing to a fresh location supplies ``image``.
205
220
 
206
221
  Returns:
207
222
  True when regeneration succeeds, otherwise False.
@@ -218,6 +233,9 @@ class ComponentGenerator:
218
233
  self._log(f" Found dependencies: {deps_file}")
219
234
 
220
235
  final_output.parent.mkdir(parents=True, exist_ok=True)
236
+ seam: dict[str, Any] = (
237
+ {} if logical_output_path is None else {"logical_output_path": logical_output_path}
238
+ )
221
239
  return self.run_generation(
222
240
  python_file=python_file,
223
241
  final_output=final_output,
@@ -231,6 +249,7 @@ class ComponentGenerator:
231
249
  resolve_root=resolve_root,
232
250
  emit_generation_annotations=emit_generation_annotations,
233
251
  unwrapped_inputs=unwrapped_inputs,
252
+ **seam,
234
253
  )
235
254
 
236
255
  def run_generation(
@@ -248,6 +267,7 @@ class ComponentGenerator:
248
267
  resolve_root: Path | None = None,
249
268
  emit_generation_annotations: bool = True,
250
269
  unwrapped_inputs: dict[str, Any] | None = None,
270
+ logical_output_path: Path | None = None,
251
271
  ) -> bool:
252
272
  """Execute component generation and clean up partial output on failure.
253
273
 
@@ -265,6 +285,8 @@ class ComponentGenerator:
265
285
  emit_generation_annotations: Whether to emit regeneration metadata.
266
286
  unwrapped_inputs: Optional persisted unwrap schema forwarded to the
267
287
  low-level generator for compile/hydrate interface parity.
288
+ logical_output_path: Where the component is to be understood to
289
+ live, for provenance only. Defaults to ``final_output``.
268
290
 
269
291
  Returns:
270
292
  True when generation succeeds, otherwise False. On failure, any
@@ -274,6 +296,9 @@ class ComponentGenerator:
274
296
  try:
275
297
  function_detail = f" function {func_name!r}" if func_name else ""
276
298
  self._log(f" Generating component from {python_file.name}{function_detail}...")
299
+ seam: dict[str, Any] = (
300
+ {} if logical_output_path is None else {"logical_output_path": logical_output_path}
301
+ )
277
302
  success = self.generate_component_yaml(
278
303
  file_path=python_file,
279
304
  output_path=final_output,
@@ -287,6 +312,7 @@ class ComponentGenerator:
287
312
  resolve_root=resolve_root,
288
313
  emit_generation_annotations=emit_generation_annotations,
289
314
  unwrapped_inputs=unwrapped_inputs,
315
+ **seam,
290
316
  )
291
317
  if not success:
292
318
  self._log(" ❌ Failed to generate component", err=True)
@@ -340,6 +366,7 @@ def regenerate_yaml(
340
366
  resolve_root: Path | None = None,
341
367
  logger: Any | None = None,
342
368
  unwrapped_inputs: dict[str, Any] | None = None,
369
+ logical_output_path: Path | None = None,
343
370
  ) -> bool:
344
371
  """Regenerate component YAML through the default generator.
345
372
 
@@ -358,11 +385,16 @@ def regenerate_yaml(
358
385
  logger: Optional logger object used by the generator.
359
386
  unwrapped_inputs: Optional persisted unwrap schema from
360
387
  ``local_from_python.unwrapped_inputs``.
388
+ logical_output_path: Where the component is to be understood to live,
389
+ for provenance only. Defaults to ``output_path``.
361
390
 
362
391
  Returns:
363
392
  True when regeneration succeeds, otherwise False.
364
393
  """
365
394
 
395
+ seam: dict[str, Any] = (
396
+ {} if logical_output_path is None else {"logical_output_path": logical_output_path}
397
+ )
366
398
  return ComponentGenerator(logger=logger, verbose=verbose).regenerate_yaml(
367
399
  python_file=python_file,
368
400
  output_path=output_path,
@@ -375,6 +407,7 @@ def regenerate_yaml(
375
407
  mode=mode,
376
408
  resolve_root=resolve_root,
377
409
  unwrapped_inputs=unwrapped_inputs,
410
+ **seam,
378
411
  )
379
412
 
380
413