tangle-cli 0.1.2__tar.gz → 0.1.3__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 (92) hide show
  1. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/PKG-INFO +5 -3
  2. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/README.md +2 -0
  3. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/__init__.py +1 -1
  4. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/component_from_func.py +133 -78
  5. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +51 -2
  6. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +1 -18
  7. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipelines.py +9 -2
  8. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/__init__.py +43 -0
  9. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/cfg.py +169 -0
  10. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/compiler_context.py +277 -0
  11. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/emit.py +289 -0
  12. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +29 -0
  13. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/graph.py +108 -0
  14. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/ids.py +27 -0
  15. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/pipeline.py +183 -0
  16. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/placeholders.py +162 -0
  17. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/raw.py +122 -0
  18. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/ref.py +324 -0
  19. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/registered.py +200 -0
  20. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/subpipeline.py +211 -0
  21. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/task.py +218 -0
  22. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +88 -0
  23. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +363 -0
  24. tangle_cli-0.1.3/packages/tangle-cli/src/tangle_cli/python_pipeline/types.py +69 -0
  25. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/pyproject.toml +2 -1
  26. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/api_cli.py +0 -0
  27. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  28. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  29. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/args_container.py +0 -0
  30. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  31. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  32. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  33. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +0 -0
  34. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  35. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/client.py +0 -0
  36. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/component_generator.py +0 -0
  37. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  38. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/component_publisher.py +0 -0
  39. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  40. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  41. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  42. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/handler.py +0 -0
  43. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  44. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  45. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/models.py +0 -0
  46. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  47. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  48. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +0 -0
  49. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +0 -0
  50. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  51. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +0 -0
  52. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  53. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  54. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +0 -0
  55. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +0 -0
  56. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +0 -0
  57. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +0 -0
  58. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  59. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/quickstart.py +0 -0
  60. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  61. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  62. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  63. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
  64. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/OSS-CONVENTIONS.md +0 -0
  65. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/PORT-README.md +0 -0
  66. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/SKILL.md +0 -0
  67. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/agents/auth-wizard.md +0 -0
  68. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/agents/builder.md +0 -0
  69. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/agents/debugger.md +0 -0
  70. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/agents/reporter.md +0 -0
  71. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/agents/researcher.md +0 -0
  72. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/agents/reviewer.md +0 -0
  73. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/agents/scenario-builder.md +0 -0
  74. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/data-sources.md +0 -0
  75. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/event-log.md +0 -0
  76. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/example-scenarios/01-mslr-ranking.md +0 -0
  77. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/example-scenarios/02-text-classification.md +0 -0
  78. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/example-scenarios/INDEX.md +0 -0
  79. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/iterating-on-runs.md +0 -0
  80. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/knowledge-corpus.md +0 -0
  81. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/secrets.md +0 -0
  82. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/setup.md +0 -0
  83. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-0-initialize.md +0 -0
  84. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-1-analyze.md +0 -0
  85. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-2-hypothesize.md +0 -0
  86. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-3-submit.md +0 -0
  87. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-4-monitor.md +0 -0
  88. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-5-evaluate.md +0 -0
  89. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-6-synthesize.md +0 -0
  90. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/step-7-decide.md +0 -0
  91. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/tangle-tools.md +0 -0
  92. {tangle_cli-0.1.2 → tangle_cli-0.1.3}/skills/tangent/references/uploading-artifacts.md +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tangle-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: CLI for Tangle, the open-source ML pipeline orchestration platform
5
- Author: Alexey Volkov, Tangle authors
6
- Author-email: Alexey Volkov <alexey.volkov@ark-kun.com>
5
+ Author: Alexey Volkov, Tangle authors, Volv Grebennikov
6
+ Author-email: Alexey Volkov <alexey.volkov@ark-kun.com>, Volv Grebennikov <volv.grebennikov@shopify.com>
7
7
  Requires-Dist: cloud-pipelines>=0.26.3.12
8
8
  Requires-Dist: cyclopts>=4.16.1
9
9
  Requires-Dist: docstring-parser>=0.16
@@ -318,6 +318,8 @@ uv run tangle sdk pipeline-runs export RUN_ID --output pipeline.yaml
318
318
 
319
319
  `submit` hydrates refs by default and builds an API submit payload with `root_task.componentRef.spec`. Use `--no-hydrate` to submit the local YAML structure as-is. Use `--dry-run` to print the payload without creating a run.
320
320
 
321
+ Before creating a run—or printing a `--dry-run` payload—`submit` runs the same authoring validation as `tangle sdk pipelines validate` on the hydrated/resolved pipeline spec (or on the as-is spec when `--no-hydrate` is used). Invalid specs fail locally with `Pipeline validation failed` errors before the run-submission API call. For example, the pipeline root must be a graph (`implementation.graph`), so a bare `implementation.container` root is rejected before the run is submitted.
322
+
321
323
  ## Programmatic client
322
324
 
323
325
  The stable public wrapper for downstream Python tools is:
@@ -293,6 +293,8 @@ uv run tangle sdk pipeline-runs export RUN_ID --output pipeline.yaml
293
293
 
294
294
  `submit` hydrates refs by default and builds an API submit payload with `root_task.componentRef.spec`. Use `--no-hydrate` to submit the local YAML structure as-is. Use `--dry-run` to print the payload without creating a run.
295
295
 
296
+ Before creating a run—or printing a `--dry-run` payload—`submit` runs the same authoring validation as `tangle sdk pipelines validate` on the hydrated/resolved pipeline spec (or on the as-is spec when `--no-hydrate` is used). Invalid specs fail locally with `Pipeline validation failed` errors before the run-submission API call. For example, the pipeline root must be a graph (`implementation.graph`), so a bare `implementation.container` root is rejected before the run is submitted.
297
+
296
298
  ## Programmatic client
297
299
 
298
300
  The stable public wrapper for downstream Python tools is:
@@ -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.1"
17
+ __version__ = "0.1.3"
18
18
 
19
19
  __all__ = ["TangleDynamicDiscoveryClient", "__version__"]
@@ -181,11 +181,18 @@ class FunctionSpec:
181
181
 
182
182
 
183
183
  def _ensure_cloud_pipelines_shim() -> None:
184
- """Register import-time shims used while introspecting authoring files.
185
-
186
- This allows loading Python files that use `from cloud_pipelines import components`
187
- and/or TD authoring decorators without requiring those authoring packages.
188
- The TD authoring constructs are stripped from generated runtime code later.
184
+ """Register the import-time ``cloud_pipelines`` shim used while introspecting
185
+ authoring files.
186
+
187
+ This lets us load Python files that use ``from cloud_pipelines import
188
+ components`` without requiring that authoring package to be installed; the
189
+ authoring constructs are stripped from the generated runtime code later.
190
+
191
+ OSS deliberately does NOT fabricate a shim for any *downstream* authoring
192
+ surface (e.g. a module a downstream package exposes to re-export the
193
+ authoring objects under its own import path). A downstream package that
194
+ wants its own authoring path recognised both makes that module importable
195
+ itself and registers it via :func:`register_authoring_import_module`.
189
196
  """
190
197
  if "cloud_pipelines" not in sys.modules:
191
198
  components_mod = types.ModuleType("cloud_pipelines.components")
@@ -198,41 +205,6 @@ def _ensure_cloud_pipelines_shim() -> None:
198
205
  sys.modules["cloud_pipelines"] = cloud_pipelines_mod
199
206
  sys.modules["cloud_pipelines.components"] = components_mod
200
207
 
201
- _ensure_tangle_deploy_authoring_shim()
202
-
203
-
204
- def _identity_decorator(*args, **kwargs):
205
- def decorate(func):
206
- return func
207
-
208
- return decorate
209
-
210
-
211
- class _AuthoringGeneric:
212
- def __class_getitem__(cls, item):
213
- return cls
214
-
215
- def __init__(self, *args, **kwargs):
216
- pass
217
-
218
-
219
- def _ensure_tangle_deploy_authoring_shim() -> None:
220
- """Register a tiny shim for TD pipeline authoring imports if absent."""
221
- if "tangle_deploy.python_pipeline" in sys.modules:
222
- return
223
-
224
- tangle_deploy_mod = sys.modules.get("tangle_deploy") or types.ModuleType("tangle_deploy")
225
- python_pipeline_mod = types.ModuleType("tangle_deploy.python_pipeline")
226
- for name in ("task", "pipeline", "subpipeline", "registered"):
227
- setattr(python_pipeline_mod, name, _identity_decorator)
228
- for name in ("In", "Out", "Outputs", "TaskEnv"):
229
- setattr(python_pipeline_mod, name, _AuthoringGeneric)
230
- setattr(python_pipeline_mod, "ref", lambda *args, **kwargs: None)
231
-
232
- setattr(tangle_deploy_mod, "python_pipeline", python_pipeline_mod)
233
- sys.modules.setdefault("tangle_deploy", tangle_deploy_mod)
234
- sys.modules["tangle_deploy.python_pipeline"] = python_pipeline_mod
235
-
236
208
 
237
209
  def load_python_module(file_path: Path, extra_sys_path: list[Path] | None = None) -> Any:
238
210
  """Dynamically import a Python module from a file path.
@@ -757,31 +729,66 @@ def _is_name_main_test(node: ast.expr) -> bool:
757
729
  # too, exactly like @task.
758
730
  _AUTHORING_DECORATOR_NAMES = frozenset({"task", "pipeline", "subpipeline", "registered"})
759
731
 
760
- # The python-pipeline authoring module. ONLY imports of this module (and its
761
- # submodules) are authoring-only and stripped from the baked source. We
762
- # deliberately do NOT strip other ``tangle_deploy.*`` packages (e.g.
763
- # ``tangle_deploy.utils``): those may be legitimate runtime helpers used inside a
764
- # ``@task`` body, and dropping them would raise ``NameError`` in the operation
765
- # container.
766
- _AUTHORING_IMPORT_MODULE = "tangle_deploy.python_pipeline"
732
+ # The python-pipeline authoring modules. ONLY imports of these modules (and
733
+ # their submodules) are authoring-only and stripped from the baked source. We
734
+ # deliberately do NOT strip other packages that merely share a top-level name
735
+ # (e.g. a downstream ``*.utils``): those may be legitimate runtime helpers used
736
+ # inside a ``@task`` body, and dropping them would raise ``NameError`` in the
737
+ # operation container.
738
+ #
739
+ # OSS recognises exactly one authoring surface out of the box: the canonical
740
+ # ``tangle_cli.python_pipeline`` path. A downstream package that re-exports the
741
+ # authoring objects under its own module path — so authors may write ``from
742
+ # <downstream>.python_pipeline import task`` — registers that path via
743
+ # :func:`register_authoring_import_module`; codegen then strips either import
744
+ # the same way. OSS never hardcodes a downstream module name (the dependency
745
+ # points inward), mirroring the resolver/reader registries in the hydrator.
746
+ _AUTHORING_IMPORT_MODULES: list[str] = ["tangle_cli.python_pipeline"]
747
+
748
+
749
+ def register_authoring_import_module(module: str) -> None:
750
+ """Register *module* as an additional python-pipeline authoring surface.
751
+
752
+ A downstream package that re-exports the ``tangle_cli.python_pipeline``
753
+ authoring objects under its own module path calls this (typically at import
754
+ time) so codegen strips ``from <module> import ...`` / ``import <module>``
755
+ lines — and their submodules — from baked runtime source exactly like the
756
+ canonical OSS surface. Idempotent: registering an already-known module is a
757
+ no-op, so repeated import-time registration is safe.
758
+ """
759
+ if module not in _AUTHORING_IMPORT_MODULES:
760
+ _AUTHORING_IMPORT_MODULES.append(module)
761
+
762
+
763
+ def authoring_import_modules() -> tuple[str, ...]:
764
+ """Return the python-pipeline authoring modules recognised by codegen."""
765
+ return tuple(_AUTHORING_IMPORT_MODULES)
767
766
 
768
767
  # The authoring-only ``TaskEnv`` class name. A module-level ``X = TaskEnv(...)``
769
768
  # (or ``X = <alias>.TaskEnv(...)``) declaration is authoring-only by contract and
770
769
  # is stripped from the baked source by ``_strip_authoring_constructs``.
771
770
  # Matched by trailing NAME only (like the authoring decorators), because in
772
- # python-pipeline authoring files ``TaskEnv`` always
773
- # resolves to ``tangle_deploy.python_pipeline.TaskEnv``.
771
+ # python-pipeline authoring files ``TaskEnv`` always resolves to the
772
+ # python-pipeline authoring surface's ``TaskEnv``.
774
773
  _AUTHORING_ENV_CLASS_NAME = "TaskEnv"
775
774
 
776
775
 
777
776
  class AuthoringStripError(ValueError):
778
- """Raised when env-only authoring code cannot be safely stripped.
777
+ """Raised when authoring code cannot be safely stripped from baked source.
778
+
779
+ ``_strip_authoring_constructs`` raises this rather than bake a broken
780
+ program when an authoring construct is entangled with runtime code and
781
+ cannot be removed by line range without corrupting the kept program:
779
782
 
780
- The TaskEnv runtime-strip hardening (``_strip_authoring_constructs``)
781
- raises this when a ``@task(env=...)`` env binding is entangled with
782
- runtime code e.g. a mixed ``from _envs import UPI, helper`` import whose
783
- ``helper`` is used at runtime, or a collected env name referenced by the
784
- kept task body. Failing fast here is intentional: silently baking a broken
783
+ - a comma-separated ``import tangle_cli.python_pipeline as tp, os`` that
784
+ mixes the authoring surface with a runtime module on one statement (the
785
+ authoring alias must be stripped, but line-deletion would take ``os`` with
786
+ it); or
787
+ - a ``@task(env=...)`` env binding entangled with runtime code e.g. a
788
+ mixed ``from _envs import UPI, helper`` import whose ``helper`` is used at
789
+ runtime, or a collected env name referenced by the kept task body.
790
+
791
+ Failing fast here is intentional: silently baking a broken ``import os`` /
785
792
  ``from _envs import UPI`` / ``UPI = TaskEnv(...)`` would only surface as a
786
793
  ``NameError`` / ``ImportError`` at container start. The message tells the
787
794
  author how to split the import or keep TaskEnv values authoring-only.
@@ -793,7 +800,7 @@ def _decorator_called_name(node: ast.expr) -> str | None:
793
800
 
794
801
  Handles ``@name`` / ``@name(...)`` and ``@mod.name`` / ``@mod.name(...)``
795
802
  forms, returning the trailing attribute/name (e.g. ``task`` for both
796
- ``@task(...)`` and ``@tangle_deploy.python_pipeline.task(...)``). Returns
803
+ ``@task(...)`` and ``@tangle_cli.python_pipeline.task(...)``). Returns
797
804
  ``None`` for shapes we do not recognise so callers leave them untouched.
798
805
 
799
806
  Limitation (v1, intentional): matching is by trailing NAME only, not by
@@ -812,36 +819,58 @@ def _decorator_called_name(node: ast.expr) -> str | None:
812
819
  return None
813
820
 
814
821
 
822
+ def _is_authoring_module(name: str) -> bool:
823
+ """Return True if *name* is an authoring module or a submodule of one."""
824
+ return any(name == mod or name.startswith(mod + ".") for mod in _AUTHORING_IMPORT_MODULES)
825
+
826
+
815
827
  def _is_authoring_import(node: ast.stmt) -> bool:
816
828
  """Return True if *node* imports the python-pipeline authoring surface.
817
829
 
818
- Matches ONLY the ``tangle_deploy.python_pipeline`` module (and its
819
- submodules):
830
+ Matches ONLY the registered authoring modules (and their submodules) — the
831
+ canonical ``tangle_cli.python_pipeline`` plus any registered via
832
+ :func:`register_authoring_import_module`:
820
833
 
821
- - ``from tangle_deploy.python_pipeline import ...`` (including the aliased
822
- ``from tangle_deploy.python_pipeline import ref as operation_by_ref`` form
823
- and submodules like ``from tangle_deploy.python_pipeline.x import y``);
824
- - ``import tangle_deploy.python_pipeline`` / ``import
825
- tangle_deploy.python_pipeline as tp``.
834
+ - ``from tangle_cli.python_pipeline import ...`` (including the aliased
835
+ ``from tangle_cli.python_pipeline import ref as operation_by_ref`` form
836
+ and submodules like ``from tangle_cli.python_pipeline.x import y``);
837
+ - ``import tangle_cli.python_pipeline`` / ``import
838
+ tangle_cli.python_pipeline as tp``;
839
+ - the equivalents for any registered downstream authoring path.
826
840
 
827
- It does NOT match other ``tangle_deploy.*`` packages (e.g.
828
- ``from tangle_deploy.utils import X``) — those can be genuine runtime helpers
841
+ It does NOT match other packages that merely share a top-level name (e.g. a
842
+ downstream ``*.utils`` module) — those can be genuine runtime helpers
829
843
  referenced inside a ``@task`` body and must survive into the baked program.
830
844
  Relative imports (``from . import x``) are never authoring imports.
831
845
  """
832
846
  if isinstance(node, ast.ImportFrom):
833
847
  if node.level: # relative import — not the authoring package
834
848
  return False
835
- module = node.module or ""
836
- return module == _AUTHORING_IMPORT_MODULE or module.startswith(_AUTHORING_IMPORT_MODULE + ".")
849
+ return _is_authoring_module(node.module or "")
837
850
  if isinstance(node, ast.Import):
838
- return any(
839
- alias.name == _AUTHORING_IMPORT_MODULE or alias.name.startswith(_AUTHORING_IMPORT_MODULE + ".")
840
- for alias in node.names
841
- )
851
+ return any(_is_authoring_module(alias.name) for alias in node.names)
842
852
  return False
843
853
 
844
854
 
855
+ def _mixed_authoring_import_aliases(node: ast.stmt) -> tuple[list[str], list[str]] | None:
856
+ """Classify a comma-separated ``import a, b`` that mixes import surfaces.
857
+
858
+ Only ``ast.Import`` (``import x, y``) can bind several modules in one
859
+ statement; a ``from X import ...`` binds a single module, so it is
860
+ all-or-nothing and never "mixed". Returns ``(authoring, runtime)`` alias
861
+ names when the statement mixes at least one authoring module with at least
862
+ one non-authoring one, else ``None`` (a pure-authoring or pure-runtime
863
+ ``import``, which the caller drops whole or leaves untouched respectively).
864
+ """
865
+ if not isinstance(node, ast.Import):
866
+ return None
867
+ authoring = [alias.name for alias in node.names if _is_authoring_module(alias.name)]
868
+ runtime = [alias.name for alias in node.names if not _is_authoring_module(alias.name)]
869
+ if authoring and runtime:
870
+ return authoring, runtime
871
+ return None
872
+
873
+
845
874
  def _attr_root_name(node: ast.expr) -> str | None:
846
875
  """Return the root ``Name`` id of an attribute chain (``a.b.c`` -> ``a``).
847
876
 
@@ -971,7 +1000,7 @@ def _strip_authoring_constructs(source_code: str) -> str:
971
1000
  - re-running an ``@task`` / ``@pipeline`` / ``@subpipeline`` decorator
972
1001
  replaces the function with a ``CallableRef`` recorder, which raises at
973
1002
  call time because there is no active ``@pipeline`` trace context;
974
- - on a thin image the ``from tangle_deploy.python_pipeline import ...``
1003
+ - on a thin image the ``from tangle_cli.python_pipeline import ...``
975
1004
  import itself can fail with ``ImportError``.
976
1005
 
977
1006
  This removes them via surgical AST line-range deletion (mirroring
@@ -986,9 +1015,12 @@ def _strip_authoring_constructs(source_code: str) -> str:
986
1015
 
987
1016
  Scope of the strip (intentional v1 boundaries):
988
1017
 
989
- - imports: only ``tangle_deploy.python_pipeline`` (and submodules) are
990
- dropped — see ``_is_authoring_import``. Other ``tangle_deploy.*`` runtime
991
- helpers are preserved.
1018
+ - imports: only the registered authoring modules (and submodules) are
1019
+ dropped — see ``_is_authoring_import``. Other runtime helpers that merely
1020
+ share a top-level name are preserved. A comma-separated ``import a, b``
1021
+ that mixes the authoring surface with a runtime module raises
1022
+ :class:`AuthoringStripError` (it cannot be partially line-deleted) — see
1023
+ ``_mixed_authoring_import_aliases``.
992
1024
  - decorators: matched by trailing NAME (``task`` / ``pipeline`` /
993
1025
  ``subpipeline``), not by import resolution — see ``_decorator_called_name``
994
1026
  for the limitation. Unrelated decorators (``@functools.cache``,
@@ -1037,6 +1069,27 @@ def _strip_authoring_constructs(source_code: str) -> str:
1037
1069
  for node in ast.walk(tree):
1038
1070
  # Authoring imports — delete the whole (possibly multi-line) statement.
1039
1071
  if isinstance(node, (ast.Import, ast.ImportFrom)) and _is_authoring_import(node):
1072
+ # A comma-separated ``import authoring_surface, os`` mixes the
1073
+ # authoring surface with a runtime module on one statement. We drop
1074
+ # authoring imports by line range, which cannot delete just part of
1075
+ # a statement — dropping the whole line would take the runtime
1076
+ # ``import os`` with it (silent NameError in the baked program),
1077
+ # while keeping it would leave the authoring import un-stripped. Fail
1078
+ # fast with split guidance rather than bake a broken program.
1079
+ mixed = _mixed_authoring_import_aliases(node)
1080
+ if mixed is not None:
1081
+ authoring_names, runtime_names = mixed
1082
+ raise AuthoringStripError(
1083
+ "import statement mixes the python-pipeline authoring "
1084
+ f"surface ({', '.join(authoring_names)}) with runtime "
1085
+ f"import(s) ({', '.join(runtime_names)}) on one line. The "
1086
+ "authoring import is stripped from the baked runtime "
1087
+ "program, but a comma-separated `import a, b` cannot be "
1088
+ "partially line-deleted without corrupting the kept import. "
1089
+ "Split the authoring import onto its own line (e.g. `import "
1090
+ "tangle_cli.python_pipeline as tp` separate from `import "
1091
+ "os`)."
1092
+ )
1040
1093
  start = node.lineno
1041
1094
  end = node.end_lineno or node.lineno
1042
1095
  removed.update(range(start, end + 1))
@@ -1686,7 +1739,8 @@ def generate_component_yaml(
1686
1739
  path_annotation_mode: ``"oss"`` always records source/YAML paths relative
1687
1740
  to their common ancestor. ``"td_legacy"`` only uses that relative
1688
1741
  common-root behavior inside a git checkout; outside git it records
1689
- ``file_path.name`` / ``output_path.name`` like legacy tangle-deploy.
1742
+ ``file_path.name`` / ``output_path.name`` to preserve the legacy
1743
+ downstream driver's historical basename-only snapshots.
1690
1744
 
1691
1745
  Returns:
1692
1746
  True on success, False on failure.
@@ -1764,8 +1818,9 @@ def generate_component_yaml(
1764
1818
  # Use the common ancestor of source and output so both paths are clean
1765
1819
  # forward references (no ".."). This lets later local maintenance
1766
1820
  # commands find the source even when YAML is generated into a separate
1767
- # output directory. TD legacy compatibility keeps basename-only paths
1768
- # outside a git checkout to preserve historical snapshots.
1821
+ # output directory. Legacy (``td_legacy``) compatibility keeps
1822
+ # basename-only paths outside a git checkout to preserve historical
1823
+ # snapshots.
1769
1824
  resolved_source = file_path.resolve()
1770
1825
  resolved_output = output_path.resolve()
1771
1826
  common_dir = Path(os.path.commonpath([resolved_source, resolved_output]))
@@ -28,6 +28,7 @@ from .logger import Logger, get_default_logger
28
28
  from .pipeline_dehydrator import DehydrateChoice, PipelineDehydrator
29
29
  from .pipeline_hydrator import HydrationError, PipelineHydrator
30
30
  from .pipeline_run_details import PipelineRunDetails
31
+ from .pipelines import collect_pipeline_spec_errors
31
32
  from .pipeline_run_search import PipelineRunSearch
32
33
  from .utils import dump_yaml
33
34
 
@@ -378,6 +379,26 @@ class PipelineRunHooks:
378
379
  """Hook for TD JOB_CONFIG time input / scheduled runtime behavior."""
379
380
  return run_args
380
381
 
382
+ def validate_pipeline_for_run(
383
+ self,
384
+ pipeline_spec: dict[str, Any],
385
+ *,
386
+ pipeline_path: str | Path | None,
387
+ effective_path: str | Path | None,
388
+ skip_validation: bool,
389
+ ) -> list[str]:
390
+ """Return submit-time validation errors for a prepared pipeline spec.
391
+
392
+ The OSS default enforces the same local authoring validator used by
393
+ ``tangle pipeline validate``. Downstreams can override or extend this
394
+ hook with stricter schema/input validators.
395
+ """
396
+
397
+ del pipeline_path, effective_path
398
+ if skip_validation:
399
+ return []
400
+ return collect_pipeline_spec_errors(pipeline_spec)
401
+
381
402
  def transform_run_name(
382
403
  self,
383
404
  run_name: str,
@@ -834,6 +855,11 @@ class PipelineRunManager(TangleCliHandler):
834
855
  for parameter in parameters.values()
835
856
  )
836
857
 
858
+ @staticmethod
859
+ def _raise_pipeline_validation_error(validation_errors: list[str]) -> None:
860
+ if validation_errors:
861
+ raise PipelineRunError("Pipeline validation failed:\n - " + "\n - ".join(validation_errors))
862
+
837
863
  def load_pipeline_for_submit(
838
864
  self,
839
865
  pipeline_path: str | Path,
@@ -909,13 +935,15 @@ class PipelineRunManager(TangleCliHandler):
909
935
  pipeline_path: str | Path | None = None,
910
936
  run_as: str | None = None,
911
937
  hydrate: bool = True,
938
+ skip_validation: bool = False,
912
939
  ) -> PipelineSubmitPayload:
913
940
  """Prepare the generic submit payload from a pipeline spec.
914
941
 
915
942
  The order here is the submit-body contract shared by OSS and TD:
916
943
  prepare the spec, prepare runtime arguments, expand run-name templates,
917
- convert/sanitize the payload, then merge downstream/default annotations
918
- before caller-supplied annotations override them.
944
+ validate the prepared authoring spec, convert/sanitize the payload, then
945
+ merge downstream/default annotations before caller-supplied annotations
946
+ override them.
919
947
  """
920
948
 
921
949
  prepared_spec = self.prepare_pipeline_spec_for_submit(
@@ -926,6 +954,13 @@ class PipelineRunManager(TangleCliHandler):
926
954
  )
927
955
  prepared_run_args = self.hooks.prepare_run_arguments(prepared_spec, run_args)
928
956
  prepared_spec = self.apply_run_name_template(prepared_spec, prepared_run_args)
957
+ validation_errors = self.hooks.validate_pipeline_for_run(
958
+ prepared_spec,
959
+ pipeline_path=pipeline_path,
960
+ effective_path=None,
961
+ skip_validation=skip_validation,
962
+ )
963
+ self._raise_pipeline_validation_error(validation_errors)
929
964
  payload = self.convert_yaml_to_payload(copy.deepcopy(prepared_spec), prepared_run_args)
930
965
  payload = self.sanitize_submit_payload(payload)
931
966
  root_task = payload["root_task"]
@@ -961,6 +996,7 @@ class PipelineRunManager(TangleCliHandler):
961
996
  pipeline_path: str | Path | None = None,
962
997
  run_as: str | None = None,
963
998
  hydrate: bool = True,
999
+ skip_validation: bool = False,
964
1000
  ) -> dict[str, Any]:
965
1001
  """Build a submit body from an already-prepared pipeline spec."""
966
1002
 
@@ -971,6 +1007,7 @@ class PipelineRunManager(TangleCliHandler):
971
1007
  pipeline_path=pipeline_path,
972
1008
  run_as=run_as,
973
1009
  hydrate=hydrate,
1010
+ skip_validation=skip_validation,
974
1011
  ).to_body()
975
1012
 
976
1013
  def prepare_submit_payload(
@@ -982,6 +1019,7 @@ class PipelineRunManager(TangleCliHandler):
982
1019
  hydrate: bool = True,
983
1020
  run_as: str | None = None,
984
1021
  resolution_overrides: dict[str, Any] | None = None,
1022
+ skip_validation: bool = False,
985
1023
  ) -> PipelineSubmitPayload:
986
1024
  pipeline_spec = self.load_pipeline_for_submit(
987
1025
  pipeline_path,
@@ -995,6 +1033,7 @@ class PipelineRunManager(TangleCliHandler):
995
1033
  pipeline_path=pipeline_path,
996
1034
  run_as=run_as,
997
1035
  hydrate=hydrate,
1036
+ skip_validation=skip_validation,
998
1037
  )
999
1038
 
1000
1039
  def build_submit_body(
@@ -1006,6 +1045,7 @@ class PipelineRunManager(TangleCliHandler):
1006
1045
  hydrate: bool = True,
1007
1046
  run_as: str | None = None,
1008
1047
  resolution_overrides: dict[str, Any] | None = None,
1048
+ skip_validation: bool = False,
1009
1049
  ) -> dict[str, Any]:
1010
1050
  return self.prepare_submit_payload(
1011
1051
  pipeline_path,
@@ -1014,6 +1054,7 @@ class PipelineRunManager(TangleCliHandler):
1014
1054
  hydrate=hydrate,
1015
1055
  run_as=run_as,
1016
1056
  resolution_overrides=resolution_overrides,
1057
+ skip_validation=skip_validation,
1017
1058
  ).to_body()
1018
1059
 
1019
1060
  @staticmethod
@@ -1117,6 +1158,7 @@ class PipelineRunManager(TangleCliHandler):
1117
1158
  run_as: str | None = None,
1118
1159
  hydrate: bool = True,
1119
1160
  attempt: int = 1,
1161
+ skip_validation: bool = False,
1120
1162
  ) -> dict[str, Any]:
1121
1163
  payload = self.prepare_submit_payload_from_spec(
1122
1164
  pipeline_spec,
@@ -1125,6 +1167,7 @@ class PipelineRunManager(TangleCliHandler):
1125
1167
  pipeline_path=pipeline_path,
1126
1168
  run_as=run_as,
1127
1169
  hydrate=hydrate,
1170
+ skip_validation=skip_validation,
1128
1171
  )
1129
1172
  return self.submit_prepared_payload(payload, pipeline_path=pipeline_path, attempt=attempt)
1130
1173
 
@@ -1138,6 +1181,7 @@ class PipelineRunManager(TangleCliHandler):
1138
1181
  run_as: str | None = None,
1139
1182
  resolution_overrides: dict[str, Any] | None = None,
1140
1183
  attempt: int = 1,
1184
+ skip_validation: bool = False,
1141
1185
  ) -> dict[str, Any]:
1142
1186
  payload = self.prepare_submit_payload(
1143
1187
  pipeline_path,
@@ -1146,6 +1190,7 @@ class PipelineRunManager(TangleCliHandler):
1146
1190
  hydrate=hydrate,
1147
1191
  run_as=run_as,
1148
1192
  resolution_overrides=resolution_overrides,
1193
+ skip_validation=skip_validation,
1149
1194
  )
1150
1195
  return self.submit_prepared_payload(payload, pipeline_path=pipeline_path, attempt=attempt)
1151
1196
 
@@ -1909,6 +1954,7 @@ class PipelineRunManager(TangleCliHandler):
1909
1954
  exit_on_first_failure: bool = False,
1910
1955
  metadata: dict[str, Any] | None = None,
1911
1956
  submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
1957
+ skip_validation: bool = False,
1912
1958
  ) -> dict[str, Any]:
1913
1959
  """Submit/wait/retry an already hydrated/validated in-memory spec."""
1914
1960
 
@@ -1924,6 +1970,7 @@ class PipelineRunManager(TangleCliHandler):
1924
1970
  pipeline_path=pipeline_path,
1925
1971
  run_as=run_as,
1926
1972
  hydrate=hydrate,
1973
+ skip_validation=skip_validation,
1927
1974
  ).to_body()
1928
1975
 
1929
1976
  return self._run_body_factory(
@@ -1960,6 +2007,7 @@ class PipelineRunManager(TangleCliHandler):
1960
2007
  exit_on_first_failure: bool = False,
1961
2008
  metadata: dict[str, Any] | None = None,
1962
2009
  submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
2010
+ skip_validation: bool = False,
1963
2011
  ) -> dict[str, Any]:
1964
2012
  """Submit (and optionally wait for) a pipeline with lifecycle hooks.
1965
2013
 
@@ -1980,6 +2028,7 @@ class PipelineRunManager(TangleCliHandler):
1980
2028
  hydrate=hydrate,
1981
2029
  run_as=run_as,
1982
2030
  resolution_overrides=resolution_overrides,
2031
+ skip_validation=skip_validation,
1983
2032
  ).to_body()
1984
2033
 
1985
2034
  return self._run_body_factory(
@@ -109,24 +109,6 @@ class PipelineRunnerHooks(PipelineRunHooks):
109
109
 
110
110
  return pipeline_spec
111
111
 
112
- def validate_pipeline_for_run(
113
- self,
114
- pipeline_spec: dict[str, Any],
115
- *,
116
- pipeline_path: str | Path,
117
- effective_path: str | Path | None,
118
- skip_validation: bool,
119
- ) -> list[str]:
120
- """Return validation errors for a prepared pipeline spec.
121
-
122
- The OSS default intentionally does not enforce the local authoring
123
- validator here: submit-time API validation remains the source of truth,
124
- while downstreams can plug in stricter schema/input validators.
125
- """
126
-
127
- del pipeline_spec, pipeline_path, effective_path, skip_validation
128
- return []
129
-
130
112
  def has_layout(self, pipeline_spec: Mapping[str, Any]) -> bool:
131
113
  """Return True when a pipeline graph already has non-zero coordinates."""
132
114
 
@@ -547,6 +529,7 @@ class PipelineRunner(PipelineRunnerHooks, PipelineRunManager):
547
529
  pipeline_path=pipeline_path,
548
530
  run_as=run_as,
549
531
  hydrate=False,
532
+ skip_validation=skip_validation,
550
533
  )
551
534
  submit_payloads[attempt] = submit_payload
552
535
  return submit_payload.to_body()
@@ -89,8 +89,8 @@ def validate_pipeline_file(path: str | Path) -> dict[str, Any]:
89
89
  return pipeline
90
90
 
91
91
 
92
- def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None:
93
- """Validate the OSS-compatible local pipeline shape.
92
+ def collect_pipeline_spec_errors(pipeline: Mapping[str, Any]) -> list[str]:
93
+ """Return OSS-compatible local pipeline shape validation errors.
94
94
 
95
95
  This is a pragmatic validator for local authoring workflows. It focuses on
96
96
  the graph structure that the CLI commands consume rather than provider-specific
@@ -99,6 +99,13 @@ def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None:
99
99
 
100
100
  errors: list[str] = []
101
101
  _validate_root_pipeline(pipeline, errors)
102
+ return errors
103
+
104
+
105
+ def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None:
106
+ """Validate the OSS-compatible local pipeline shape."""
107
+
108
+ errors = collect_pipeline_spec_errors(pipeline)
102
109
  if errors:
103
110
  details = "\n".join(f"- {error}" for error in errors)
104
111
  raise PipelineValidationError(f"Pipeline validation failed:\n{details}")
@@ -0,0 +1,43 @@
1
+ """Python-first authoring surface for Tangle pipelines.
2
+
3
+ End users write::
4
+
5
+ from tangle_cli.python_pipeline import pipeline, task, registered, ref, raw, subpipeline, TaskEnv, In, Out
6
+
7
+ ``cfg`` is NOT a top-level export — it is a parameter the framework
8
+ injects into the user's pipeline function at trace time. Importing the
9
+ :class:`tangle_cli.python_pipeline.cfg.Cfg` class is reserved for the
10
+ compile driver.
11
+
12
+ ``import tangle_cli.python_pipeline`` is kept light: it does not
13
+ eagerly import the heavy ``tangle_cli.component_generator`` codegen
14
+ module or the tracer machinery.
15
+
16
+ Module map: authoring entry points live in :mod:`.pipeline`,
17
+ :mod:`.task`, :mod:`.subpipeline`, :mod:`.registered`, :mod:`.ref` and
18
+ :mod:`.raw`; the trace-time IR is built in :mod:`.trace` / :mod:`.graph`
19
+ and lowered to the dehydrated dict shape by :mod:`.emit`.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from .pipeline import pipeline
24
+ from .raw import raw
25
+ from .ref import ref
26
+ from .registered import registered
27
+ from .subpipeline import subpipeline
28
+ from .task import task
29
+ from .task_env import TaskEnv
30
+ from .types import In, Out, Outputs
31
+
32
+ __all__ = [
33
+ "pipeline",
34
+ "task",
35
+ "registered",
36
+ "ref",
37
+ "raw",
38
+ "subpipeline",
39
+ "TaskEnv",
40
+ "In",
41
+ "Out",
42
+ "Outputs",
43
+ ]