tangle-cli 0.1.11__tar.gz → 0.1.13__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 (101) hide show
  1. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/PKG-INFO +55 -1
  2. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/README.md +54 -0
  3. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/__init__.py +1 -1
  4. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +33 -0
  5. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +411 -1
  6. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_validation.py +166 -11
  7. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +9 -27
  8. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/pyproject.toml +1 -1
  9. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/pyproject.toml.orig +1 -1
  10. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/OSS-CONVENTIONS.md +1 -0
  11. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/tangle-tools.md +23 -0
  12. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/api_cli.py +0 -0
  13. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  14. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  15. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/args_container.py +0 -0
  16. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  17. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  18. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  19. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  20. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/client.py +0 -0
  21. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/component_from_func.py +0 -0
  22. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/component_generator.py +0 -0
  23. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  24. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/component_publisher.py +0 -0
  25. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  26. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  27. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  28. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/handler.py +0 -0
  29. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  30. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  31. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/models.py +0 -0
  32. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  33. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  34. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +0 -0
  35. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +0 -0
  36. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py +0 -0
  37. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  38. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +0 -0
  39. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  40. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  41. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +0 -0
  42. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +0 -0
  43. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +0 -0
  44. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipeline_spec_utils.py +0 -0
  45. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/pipelines.py +0 -0
  46. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +0 -0
  47. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  48. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/__init__.py +0 -0
  49. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/cfg.py +0 -0
  50. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/compiler_context.py +0 -0
  51. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/dynamic_data.py +0 -0
  52. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/emit.py +0 -0
  53. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +0 -0
  54. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/graph.py +0 -0
  55. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/ids.py +0 -0
  56. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/pipeline.py +0 -0
  57. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/placeholders.py +0 -0
  58. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/raw.py +0 -0
  59. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/ref.py +0 -0
  60. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/registered.py +0 -0
  61. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/subpipeline.py +0 -0
  62. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/task.py +0 -0
  63. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +0 -0
  64. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +0 -0
  65. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/python_pipeline/types.py +0 -0
  66. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/quickstart.py +0 -0
  67. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/schema_validation.py +0 -0
  68. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/schemas/__init__.py +0 -0
  69. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json +0 -0
  70. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/schemas/pipeline_schema.json +0 -0
  71. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  72. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  73. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  74. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
  75. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/PORT-README.md +0 -0
  76. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/SKILL.md +0 -0
  77. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/agents/auth-wizard.md +0 -0
  78. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/agents/builder.md +0 -0
  79. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/agents/debugger.md +0 -0
  80. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/agents/reporter.md +0 -0
  81. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/agents/researcher.md +0 -0
  82. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/agents/reviewer.md +0 -0
  83. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/agents/scenario-builder.md +0 -0
  84. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/data-sources.md +0 -0
  85. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/event-log.md +0 -0
  86. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/example-scenarios/01-mslr-ranking.md +0 -0
  87. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/example-scenarios/02-text-classification.md +0 -0
  88. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/example-scenarios/INDEX.md +0 -0
  89. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/iterating-on-runs.md +0 -0
  90. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/knowledge-corpus.md +0 -0
  91. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/secrets.md +0 -0
  92. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/setup.md +0 -0
  93. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-0-initialize.md +0 -0
  94. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-1-analyze.md +0 -0
  95. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-2-hypothesize.md +0 -0
  96. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-3-submit.md +0 -0
  97. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-4-monitor.md +0 -0
  98. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-5-evaluate.md +0 -0
  99. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-6-synthesize.md +0 -0
  100. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/skills/tangent/references/step-7-decide.md +0 -0
  101. {tangle_cli-0.1.11 → tangle_cli-0.1.13}/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.11
3
+ Version: 0.1.13
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>
@@ -412,6 +412,7 @@ Pipeline run API/submit commands live under `sdk pipeline-runs`:
412
412
  ```bash
413
413
  uv run tangle sdk pipeline-runs submit pipeline.yaml --dry-run
414
414
  uv run tangle sdk pipeline-runs submit pipeline.yaml --arg key=value --annotation owner=team
415
+ uv run tangle sdk pipeline-runs submit-from-python pipeline.py --arg key=value
415
416
  uv run tangle sdk pipeline-runs wait RUN_ID --max-wait 600 --poll-interval 10
416
417
  uv run tangle sdk pipeline-runs logs EXECUTION_ID
417
418
  uv run tangle sdk pipeline-runs annotations set RUN_ID key value
@@ -427,6 +428,59 @@ uv run tangle sdk pipelines compile pipeline.py -o pipeline.yaml
427
428
  uv run tangle sdk pipelines compile pipeline.py -o pipeline.yaml --pipeline pipeline_fn_name
428
429
  ```
429
430
 
431
+ To compile and submit in one step, without keeping the compiled YAML around, use
432
+ `pipeline-runs submit-from-python`:
433
+
434
+ ```bash
435
+ uv run tangle sdk pipeline-runs submit-from-python pipeline.py \
436
+ --override batch_size=100 \
437
+ --image eval-slim=registry.example/eval-slim@sha256:... \
438
+ --arg shop=acme --annotation owner=team
439
+ ```
440
+
441
+ It compiles the script, hydrates the bundle, submits the run, and removes the
442
+ compiled artifacts again — including on dry runs and failures. Its run-tier
443
+ flags are the same as `pipeline-runs submit`; the extra compile-tier flags are
444
+ `--pipeline`, repeatable `--override KEY=VALUE`, and repeatable `--image ID=REF`.
445
+ Use `pipelines compile` instead when the compiled YAML itself is what you want.
446
+
447
+ The two value tiers are distinct and easy to confuse:
448
+
449
+ | Flag | Tier | Meaning |
450
+ | --- | --- | --- |
451
+ | `--override KEY=VALUE` | compile | `cfg` value used while the graph is built |
452
+ | `--image ID=REF` | compile | resolves `@task(image_id=ID)` to a registry ref |
453
+ | `--arg` / `--args-json` / `--arg-secret` | run | pipeline arguments for the created run |
454
+
455
+ Notes:
456
+
457
+ - Hydration is always on: the compiled bundle references local sidecars
458
+ (`resolve://./<stem>.components.yaml#…`, `file://<stem>.subgraphs/…`) that the
459
+ server cannot read. There is no `--no-hydrate` here.
460
+ - The bundle is compiled next to the script (never in `/tmp`) under a unique
461
+ hidden `.tangle-submit-*.yaml` name, because relative refs such as
462
+ `ref(url="file://./component.yaml")` resolve against the *output* directory.
463
+ The name is allocated with an exclusive create, so concurrent compiles of the
464
+ same script never collide or overwrite a sibling YAML. A symlinked script
465
+ compiles next to its resolved target, where its `config.yaml` and relative
466
+ refs actually live.
467
+ - The bundle stays readable until every run has been submitted, so
468
+ run-lifecycle hooks still see an existing `pipeline_path`; cleanup then
469
+ removes exactly that stem's files (`.yaml`, `.components.yaml`,
470
+ `.subgraphs/`) and nothing else.
471
+ - Submission never waits; use `pipeline-runs wait RUN_ID`.
472
+ - With a multi-entry `--config` file, entries may carry different `--override` /
473
+ `--image` values, and **every entry is fully prepared before any run is
474
+ created**: each is compiled exactly once, hydrated, merged with its run
475
+ arguments and secrets, validated, and frozen into a submit body; only then are
476
+ the frozen bodies submitted in order (no recompilation). An unsupported config
477
+ key (`hydrate:`, `pipeline_path:`), a malformed `override` / `image` /
478
+ `arg-secret` value, an input given as both `--arg` and `--arg-secret`, a
479
+ missing script, or a compile/hydrate error in *any* entry therefore creates no
480
+ runs and leaves no artifacts behind. A runtime failure while submitting entry
481
+ N can still follow the successful submits of entries 1..N-1 — that is inherent
482
+ to creating N runs.
483
+
430
484
  A minimal graph uses `@pipeline` for the graph and `@task` for local Python components. `@task` functions are not executed at compile time; the compiler records call sites, emits a sibling `<output>.components.yaml` with `local_from_python` entries, and rewrites task component refs to that sidecar. Hydrate later regenerates the same component YAML from the Python source.
431
485
 
432
486
  ```python
@@ -386,6 +386,7 @@ Pipeline run API/submit commands live under `sdk pipeline-runs`:
386
386
  ```bash
387
387
  uv run tangle sdk pipeline-runs submit pipeline.yaml --dry-run
388
388
  uv run tangle sdk pipeline-runs submit pipeline.yaml --arg key=value --annotation owner=team
389
+ uv run tangle sdk pipeline-runs submit-from-python pipeline.py --arg key=value
389
390
  uv run tangle sdk pipeline-runs wait RUN_ID --max-wait 600 --poll-interval 10
390
391
  uv run tangle sdk pipeline-runs logs EXECUTION_ID
391
392
  uv run tangle sdk pipeline-runs annotations set RUN_ID key value
@@ -401,6 +402,59 @@ uv run tangle sdk pipelines compile pipeline.py -o pipeline.yaml
401
402
  uv run tangle sdk pipelines compile pipeline.py -o pipeline.yaml --pipeline pipeline_fn_name
402
403
  ```
403
404
 
405
+ To compile and submit in one step, without keeping the compiled YAML around, use
406
+ `pipeline-runs submit-from-python`:
407
+
408
+ ```bash
409
+ uv run tangle sdk pipeline-runs submit-from-python pipeline.py \
410
+ --override batch_size=100 \
411
+ --image eval-slim=registry.example/eval-slim@sha256:... \
412
+ --arg shop=acme --annotation owner=team
413
+ ```
414
+
415
+ It compiles the script, hydrates the bundle, submits the run, and removes the
416
+ compiled artifacts again — including on dry runs and failures. Its run-tier
417
+ flags are the same as `pipeline-runs submit`; the extra compile-tier flags are
418
+ `--pipeline`, repeatable `--override KEY=VALUE`, and repeatable `--image ID=REF`.
419
+ Use `pipelines compile` instead when the compiled YAML itself is what you want.
420
+
421
+ The two value tiers are distinct and easy to confuse:
422
+
423
+ | Flag | Tier | Meaning |
424
+ | --- | --- | --- |
425
+ | `--override KEY=VALUE` | compile | `cfg` value used while the graph is built |
426
+ | `--image ID=REF` | compile | resolves `@task(image_id=ID)` to a registry ref |
427
+ | `--arg` / `--args-json` / `--arg-secret` | run | pipeline arguments for the created run |
428
+
429
+ Notes:
430
+
431
+ - Hydration is always on: the compiled bundle references local sidecars
432
+ (`resolve://./<stem>.components.yaml#…`, `file://<stem>.subgraphs/…`) that the
433
+ server cannot read. There is no `--no-hydrate` here.
434
+ - The bundle is compiled next to the script (never in `/tmp`) under a unique
435
+ hidden `.tangle-submit-*.yaml` name, because relative refs such as
436
+ `ref(url="file://./component.yaml")` resolve against the *output* directory.
437
+ The name is allocated with an exclusive create, so concurrent compiles of the
438
+ same script never collide or overwrite a sibling YAML. A symlinked script
439
+ compiles next to its resolved target, where its `config.yaml` and relative
440
+ refs actually live.
441
+ - The bundle stays readable until every run has been submitted, so
442
+ run-lifecycle hooks still see an existing `pipeline_path`; cleanup then
443
+ removes exactly that stem's files (`.yaml`, `.components.yaml`,
444
+ `.subgraphs/`) and nothing else.
445
+ - Submission never waits; use `pipeline-runs wait RUN_ID`.
446
+ - With a multi-entry `--config` file, entries may carry different `--override` /
447
+ `--image` values, and **every entry is fully prepared before any run is
448
+ created**: each is compiled exactly once, hydrated, merged with its run
449
+ arguments and secrets, validated, and frozen into a submit body; only then are
450
+ the frozen bodies submitted in order (no recompilation). An unsupported config
451
+ key (`hydrate:`, `pipeline_path:`), a malformed `override` / `image` /
452
+ `arg-secret` value, an input given as both `--arg` and `--arg-secret`, a
453
+ missing script, or a compile/hydrate error in *any* entry therefore creates no
454
+ runs and leaves no artifacts behind. A runtime failure while submitting entry
455
+ N can still follow the successful submits of entries 1..N-1 — that is inherent
456
+ to creating N runs.
457
+
404
458
  A minimal graph uses `@pipeline` for the graph and `@task` for local Python components. `@task` functions are not executed at compile time; the compiler records call sites, emits a sibling `<output>.components.yaml` with `local_from_python` entries, and rewrites task component refs to that sidecar. Hydrate later regenerates the same component YAML from the Python source.
405
459
 
406
460
  ```python
@@ -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.11"
17
+ __version__ = "0.1.13"
18
18
 
19
19
  __all__ = ["TangleDynamicDiscoveryClient", "__version__"]
@@ -46,6 +46,39 @@ def optional_path(value: str | pathlib.Path | object | None) -> pathlib.Path | N
46
46
  return None
47
47
 
48
48
 
49
+ def parse_overrides(values: list[str] | None) -> dict[str, str]:
50
+ """Parse repeatable ``--override KEY=VALUE`` compile-time cfg overrides.
51
+
52
+ Shared verbatim by ``sdk pipelines compile`` and ``sdk pipeline-runs
53
+ submit-from-python`` so both accept exactly the same syntax and reject the
54
+ same mistakes. An empty VALUE is allowed (``--override note=``).
55
+ """
56
+
57
+ parsed: dict[str, str] = {}
58
+ for value in values or []:
59
+ if "=" not in value:
60
+ raise SystemExit("--override entries must use KEY=VALUE syntax")
61
+ key, parsed_value = value.split("=", 1)
62
+ if not key:
63
+ raise SystemExit("--override entries must use KEY=VALUE syntax")
64
+ parsed[key] = parsed_value
65
+ return parsed
66
+
67
+
68
+ def parse_image_overrides(values: list[str] | None) -> dict[str, str]:
69
+ """Parse repeatable ``--image ID=REF`` compile-time image-id overrides."""
70
+
71
+ parsed: dict[str, str] = {}
72
+ for value in values or []:
73
+ if "=" not in value:
74
+ raise SystemExit("--image entries must use ID=REF syntax")
75
+ image_id, image_ref = value.split("=", 1)
76
+ if not image_id or not image_ref:
77
+ raise SystemExit("--image entries must use ID=REF syntax")
78
+ parsed[image_id] = image_ref
79
+ return parsed
80
+
81
+
49
82
  def api_arg_specs(
50
83
  *,
51
84
  base_url: str | None = None,
@@ -2,11 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import difflib
5
6
  import json
6
7
  import os
7
8
  import pathlib
9
+ import shutil
8
10
  import sys
9
- from typing import Annotated, Any
11
+ import tempfile
12
+ from contextlib import ExitStack, contextmanager, suppress
13
+ from typing import TYPE_CHECKING, Annotated, Any, Callable, Iterator
10
14
 
11
15
  from cyclopts import App, Parameter
12
16
 
@@ -17,6 +21,8 @@ from .cli_helpers import (
17
21
  include_env_credentials_for_args,
18
22
  load_args_or_exit,
19
23
  optional_path,
24
+ parse_image_overrides,
25
+ parse_overrides,
20
26
  print_json,
21
27
  )
22
28
  from .cli_options import (
@@ -42,6 +48,10 @@ from .pipeline_run_manager import (
42
48
  parse_key_value_entries,
43
49
  )
44
50
  from .pipeline_run_search import normalize_query_input, parse_annotation
51
+ from .pipelines import PipelineValidationError, compile_pipeline_file
52
+
53
+ if TYPE_CHECKING: # pragma: no cover - typing only
54
+ from .pipeline_compiler import CompileResult
45
55
 
46
56
  app = App(name="pipeline-runs", help="Submit and inspect Tangle pipeline runs.")
47
57
  annotations_app = App(name="annotations", help="Work with pipeline-run annotations.")
@@ -120,6 +130,56 @@ def _run_manager_action(config: str | None, cli_base_url: str | None, specs: dic
120
130
  finalize_logs()
121
131
 
122
132
 
133
+ def _run_prepared_manager_actions(
134
+ config: str | None,
135
+ cli_base_url: str | None,
136
+ specs: dict[str, tuple[Any, ...]],
137
+ prepare,
138
+ submit,
139
+ *,
140
+ precheck: Callable[[ArgsContainer], None] | None = None,
141
+ ):
142
+ """Prepare EVERY config entry, then submit the prepared results in order.
143
+
144
+ ``prepare(manager, args, artifacts)`` returns one entry's frozen payload and
145
+ may register cleanup on the shared ``artifacts`` stack; ``submit`` performs
146
+ the single API write. Splitting the phases means a failure while preparing
147
+ any entry submits nothing. Artifacts outlive preparation because run
148
+ lifecycle hooks read the compiled path during submission.
149
+ """
150
+
151
+ loaded = load_args_or_exit(config, **specs)
152
+ if precheck is not None:
153
+ for args in loaded:
154
+ precheck(args)
155
+
156
+ with ExitStack() as log_finalizers:
157
+ entries: list[tuple[ArgsContainer, PipelineRunManager]] = []
158
+ for args in loaded:
159
+ try:
160
+ logger, finalize_logs = logger_for_log_type(getattr(args, "log_type", "console"))
161
+ except ValueError as exc:
162
+ raise SystemExit(str(exc)) from exc
163
+ log_finalizers.callback(finalize_logs)
164
+ entries.append((args, _manager(args, cli_base_url=cli_base_url, logger=logger)))
165
+
166
+ prepared: list[Any] = []
167
+ with ExitStack() as artifacts:
168
+ for args, manager in entries:
169
+ try:
170
+ prepared.append(prepare(manager, args, artifacts))
171
+ except PipelineRunError as exc:
172
+ raise SystemExit(str(exc)) from exc
173
+
174
+ for (args, manager), payload in zip(entries, prepared):
175
+ try:
176
+ result = submit(manager, args, payload)
177
+ except PipelineRunError as exc:
178
+ raise SystemExit(str(exc)) from exc
179
+ if result is not None:
180
+ print_json(result)
181
+
182
+
123
183
  def _run_annotation_action(config: str | None, cli_base_url: str | None, specs: dict[str, tuple[Any, ...]], fn):
124
184
  for args in load_args_or_exit(config, **specs):
125
185
  try:
@@ -247,6 +307,356 @@ def pipeline_runs_submit(
247
307
  _run_manager_action(config, base_url, specs, action)
248
308
 
249
309
 
310
+ #: Config fields this command understands. Used only to catch MISSPELLINGS of
311
+ #: supported keys (`overide:`), which would otherwise be dropped silently and
312
+ #: submit something other than what was authored. Unrelated keys in a shared
313
+ #: config file are deliberately left alone.
314
+ _SUBMIT_FROM_PYTHON_CONFIG_KEYS = frozenset(
315
+ "script_path pipeline override image arg args args_json arg_secret arg_secrets "
316
+ "annotation dry_run run_as trusted_source trusted_hydration trusted_hydration_cli "
317
+ "submit_recovery_attempts log_type base_url token auth_header header".split()
318
+ )
319
+
320
+ #: Keys that are valid for other commands but must never be honored here,
321
+ #: because silently ignoring them would change what actually gets submitted.
322
+ _SUBMIT_FROM_PYTHON_REJECTED_CONFIG_KEYS = {
323
+ "hydrate": (
324
+ "submit-from-python always hydrates the compiled bundle; "
325
+ "its relative refs are meaningless to the server otherwise"
326
+ ),
327
+ "pipeline_path": (
328
+ "submit-from-python compiles a Python script; "
329
+ "use script_path (and --pipeline to pick the root @pipeline function)"
330
+ ),
331
+ }
332
+
333
+ _LOG_TYPES = ("console", "none", "file")
334
+
335
+ #: Dot-prefixed so the throwaway bundle stays hidden next to the user's sources.
336
+ _TEMP_BUNDLE_PREFIX = ".tangle-submit-"
337
+
338
+
339
+ @contextmanager
340
+ def _compiled_bundle(
341
+ script: pathlib.Path,
342
+ *,
343
+ overrides: dict[str, str],
344
+ image_overrides: dict[str, str],
345
+ pipeline_name: str | None,
346
+ logger: Logger,
347
+ ) -> Iterator[CompileResult]:
348
+ """Compile *script* to a throwaway bundle beside it, always removing it.
349
+
350
+ The bundle cannot live in a temp directory: the compiler derives every
351
+ relative URL from the output path, so author-written refs such as
352
+ ``ref(url="file://./component.yaml")`` only resolve when the bundle sits in
353
+ the script's own (physically resolved) directory. The name is allocated
354
+ with an exclusive create, so concurrent compiles never collide.
355
+ """
356
+
357
+ script_path = script.resolve()
358
+ try:
359
+ handle = tempfile.NamedTemporaryFile(
360
+ dir=script_path.parent, prefix=_TEMP_BUNDLE_PREFIX, suffix=".yaml", delete=False
361
+ )
362
+ except OSError as exc:
363
+ raise PipelineValidationError(
364
+ f"Cannot create a compile output in {script_path.parent}: {exc}"
365
+ ) from exc
366
+ root = pathlib.Path(handle.name)
367
+ closed = False
368
+ try:
369
+ handle.close()
370
+ closed = True
371
+ result = compile_pipeline_file(
372
+ script_path,
373
+ root,
374
+ overrides=overrides,
375
+ pipeline_name=pipeline_name,
376
+ image_overrides=image_overrides,
377
+ logger=logger,
378
+ )
379
+ for warning in result.warnings:
380
+ logger.warn(warning)
381
+ yield result
382
+ finally:
383
+ # Cleanup is stem-scoped and must never mask the primary error.
384
+ if not closed:
385
+ with suppress(Exception):
386
+ handle.close()
387
+ for path in (root, root.with_name(root.stem + ".components.yaml")):
388
+ try:
389
+ path.unlink()
390
+ except FileNotFoundError:
391
+ pass
392
+ except OSError as exc:
393
+ logger.warn(f"Could not remove compile artifact {path}: {exc}")
394
+ shutil.rmtree(root.with_name(root.stem + ".subgraphs"), ignore_errors=True)
395
+
396
+
397
+ def _misspelled_config_key(key: str) -> str | None:
398
+ """Return the supported field *key* looks like a typo of, if any."""
399
+
400
+ if key.startswith("_") or key in _SUBMIT_FROM_PYTHON_CONFIG_KEYS:
401
+ return None
402
+ normalized = "".join(char for char in key.lower() if char.isalnum())
403
+ for known in _SUBMIT_FROM_PYTHON_CONFIG_KEYS:
404
+ if normalized == known.replace("_", ""):
405
+ return known
406
+ close = difflib.get_close_matches(key, sorted(_SUBMIT_FROM_PYTHON_CONFIG_KEYS), n=1, cutoff=0.85)
407
+ return close[0] if close else None
408
+
409
+
410
+ def _resolve_run_arguments(args: ArgsContainer) -> dict[str, Any]:
411
+ """Resolve one entry's run arguments: literals plus secret bindings.
412
+
413
+ Pure, so the precheck and the preparation phase share one implementation
414
+ and an ``--arg``/``--arg-secret`` conflict is caught before any submit.
415
+ """
416
+
417
+ run_args = parse_json_or_key_values(args.args_json or args.args_config, args.arg)
418
+ secret_names = normalize_arg_secret_config(args.arg_secrets_config)
419
+ secret_names.update(parse_arg_secret_entries(args.arg_secret))
420
+ return merge_secret_run_args(run_args, secret_names)
421
+
422
+
423
+ def _check_submit_from_python_inputs(args: ArgsContainer) -> None:
424
+ """Validate one entry before ANY entry is compiled or submitted.
425
+
426
+ Only deterministic, side-effect-free checks belong here: they run for every
427
+ config entry up front, so a malformed value in the last entry cannot land
428
+ after earlier entries were already submitted.
429
+ """
430
+
431
+ config = getattr(args, "_config", {})
432
+ if isinstance(config, dict):
433
+ for key, reason in _SUBMIT_FROM_PYTHON_REJECTED_CONFIG_KEYS.items():
434
+ if key in config:
435
+ raise SystemExit(
436
+ f"Config error: '{key}' is not supported by submit-from-python: {reason}"
437
+ )
438
+ for key in config:
439
+ if (suggestion := _misspelled_config_key(str(key))) is not None:
440
+ raise SystemExit(
441
+ f"Config error: unknown submit-from-python field '{key}'; "
442
+ f"did you mean '{suggestion}'?"
443
+ )
444
+
445
+ # Safety-sensitive values reach retry/trust logic, where a wrong type is
446
+ # worse than a rejected config: a non-int recovery budget crashes an
447
+ # ambiguous submit before its recovery lookup, and a truthy string silently
448
+ # enables allow-all hydration.
449
+ attempts = getattr(args, "submit_recovery_attempts", None)
450
+ if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 0:
451
+ raise SystemExit("Config error: submit_recovery_attempts must be a non-negative integer")
452
+ trusted_hydration = getattr(args, "trusted_hydration_cli", None)
453
+ if trusted_hydration is not None and not isinstance(trusted_hydration, bool):
454
+ raise SystemExit("Config error: trusted_hydration_cli must be a boolean")
455
+
456
+ if getattr(args, "log_type", "console") not in _LOG_TYPES:
457
+ raise SystemExit(f"--log-type must be one of: {', '.join(_LOG_TYPES)}")
458
+
459
+ try:
460
+ parse_overrides(args.override)
461
+ parse_image_overrides(args.image)
462
+ _resolve_run_arguments(args)
463
+ parse_key_value_entries(args.annotation)
464
+ except PipelineRunError as exc:
465
+ raise SystemExit(str(exc)) from exc
466
+
467
+ if args.script_path is not None and not pathlib.Path(args.script_path).exists():
468
+ raise SystemExit(f"Pipeline script not found: {args.script_path}")
469
+
470
+
471
+ @app.command(name="submit-from-python")
472
+ def pipeline_runs_submit_from_python(
473
+ script_path: pathlib.Path | None = None,
474
+ *,
475
+ pipeline: Annotated[
476
+ str | None,
477
+ Parameter(
478
+ name="--pipeline",
479
+ help="Select the root @pipeline function by name when the file defines several.",
480
+ ),
481
+ ] = None,
482
+ override: Annotated[
483
+ list[str] | None,
484
+ Parameter(
485
+ name="--override",
486
+ help="Compile-time config override as KEY=VALUE. Repeat for multiple.",
487
+ negative_iterable=(),
488
+ ),
489
+ ] = None,
490
+ image: Annotated[
491
+ list[str] | None,
492
+ Parameter(
493
+ name="--image",
494
+ help=(
495
+ "Compile-time image-id override as ID=REF for @task(image_id=ID). "
496
+ "Repeat for multiple IDs."
497
+ ),
498
+ negative_iterable=(),
499
+ ),
500
+ ] = None,
501
+ arg: Annotated[
502
+ list[str] | None,
503
+ Parameter(help="Pipeline argument as KEY=VALUE. Repeat for multiple.", negative_iterable=()),
504
+ ] = None,
505
+ args_json: Annotated[str | None, Parameter(help="Pipeline arguments as a JSON object.")] = None,
506
+ arg_secret: Annotated[
507
+ list[str] | None,
508
+ Parameter(
509
+ name="--arg-secret",
510
+ help=(
511
+ "Pipeline argument bound to a Tangle secret as INPUT=SECRET_NAME. "
512
+ "Repeat for multiple."
513
+ ),
514
+ negative_iterable=(),
515
+ ),
516
+ ] = None,
517
+ annotation: Annotated[
518
+ list[str] | None,
519
+ Parameter(help="Run annotation as KEY=VALUE. Repeat for multiple.", negative_iterable=()),
520
+ ] = None,
521
+ dry_run: Annotated[
522
+ bool | None,
523
+ Parameter(help="Compile, hydrate and print the submit payload without creating a run."),
524
+ ] = None,
525
+ run_as: Annotated[
526
+ str | None,
527
+ Parameter(help="Downstream extension point; unsupported by the OSS default hooks."),
528
+ ] = None,
529
+ trusted_source: Annotated[
530
+ list[str] | None,
531
+ Parameter(
532
+ name="--trusted-source",
533
+ help="Trusted local_from_python source root or glob. Repeat for multiple.",
534
+ negative_iterable=(),
535
+ ),
536
+ ] = None,
537
+ trusted_hydration: Annotated[
538
+ bool | None,
539
+ Parameter(
540
+ name="--trusted-hydration",
541
+ help="Allow all local_from_python execution during hydration for trusted inputs.",
542
+ ),
543
+ ] = None,
544
+ base_url: BaseUrlOption = None,
545
+ token: TokenOption = None,
546
+ auth_header: AuthHeaderOption = None,
547
+ header: HeaderOption = None,
548
+ config: ConfigOption = None,
549
+ submit_recovery_attempts: Annotated[
550
+ int,
551
+ Parameter(
552
+ help=(
553
+ "Number of post-failed-submit recovery lookups before resubmitting; "
554
+ "higher values wait longer for delayed run registration."
555
+ )
556
+ ),
557
+ ] = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
558
+ log_type: LogTypeOption = "console",
559
+ ) -> None:
560
+ """Compile a Python-authored pipeline and submit it as a run.
561
+
562
+ Equivalent to ``pipelines compile`` followed by ``pipeline-runs submit``,
563
+ without leaving compiled YAML behind. The bundle is compiled next to the
564
+ script (so relative ``file://`` refs keep resolving), hydrated, submitted,
565
+ and then removed — also on dry runs and failures. Use ``pipelines compile``
566
+ when the compiled YAML itself is what you want.
567
+
568
+ Compile-time values and run-time values are distinct: ``--override
569
+ KEY=VALUE`` sets ``cfg`` values consumed while the graph is built and
570
+ ``--image ID=REF`` resolves ``@task(image_id=ID)``, while ``--arg`` /
571
+ ``--args-json`` / ``--arg-secret`` set pipeline arguments for the run.
572
+
573
+ Hydration is always on: the compiled bundle's refs point at local sidecars
574
+ the server cannot read. Submission never waits; use ``pipeline-runs wait``.
575
+
576
+ A multi-entry ``--config`` prepares EVERY entry first — compile once,
577
+ hydrate, merge run arguments and secrets, validate, freeze the submit body
578
+ — and only then submits the frozen bodies in order. A failure while
579
+ preparing any entry therefore creates no runs and leaves no artifacts.
580
+ """
581
+
582
+ specs = {
583
+ "script_path": ("script_path", script_path, None, False, True, optional_path),
584
+ "pipeline": (pipeline, None),
585
+ "override": (override, None),
586
+ "image": (image, None),
587
+ "arg": (arg, None),
588
+ "args_json": (args_json, None),
589
+ "args_config": ("args", None, None, True),
590
+ "arg_secret": (arg_secret, None),
591
+ "arg_secrets_config": ("arg_secrets", None, None, True),
592
+ "annotation": (annotation, None),
593
+ "dry_run": (dry_run, None),
594
+ "run_as": (run_as, None),
595
+ "trusted_source": (trusted_source, None),
596
+ "trusted_hydration_cli": ("trusted_hydration_cli", trusted_hydration, None, False),
597
+ "submit_recovery_attempts": (submit_recovery_attempts, _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS),
598
+ "log_type": (log_type, "console"),
599
+ **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header),
600
+ }
601
+
602
+ def prepare(
603
+ manager: PipelineRunManager, args: ArgsContainer, artifacts: ExitStack
604
+ ) -> tuple[dict[str, Any], pathlib.Path]:
605
+ """Compile once, hydrate, validate, and freeze this entry's submit body.
606
+
607
+ The compiled path travels with the body so run lifecycle hooks keep the
608
+ path-backed context they had when submission happened inside the
609
+ bundle; that is why the bundle outlives preparation.
610
+ """
611
+
612
+ try:
613
+ compiled = artifacts.enter_context(
614
+ _compiled_bundle(
615
+ args.script_path,
616
+ overrides=parse_overrides(args.override),
617
+ image_overrides=parse_image_overrides(args.image),
618
+ pipeline_name=args.pipeline,
619
+ logger=manager.logger,
620
+ )
621
+ )
622
+ body = manager.build_submit_body(
623
+ compiled.pipeline_path,
624
+ run_args=_resolve_run_arguments(args),
625
+ annotations=parse_key_value_entries(args.annotation),
626
+ hydrate=True,
627
+ run_as=args.run_as,
628
+ )
629
+ return body, compiled.pipeline_path
630
+ except PipelineValidationError as exc:
631
+ raise SystemExit(str(exc)) from exc
632
+
633
+ def submit(
634
+ manager: PipelineRunManager,
635
+ args: ArgsContainer,
636
+ prepared: tuple[dict[str, Any], pathlib.Path],
637
+ ) -> dict[str, Any]:
638
+ body, pipeline_path = prepared
639
+ if args.dry_run:
640
+ return body
641
+ # The body is already hydrated and validated, so the run is created
642
+ # without recompiling; submission-id stamping and the ambiguous-submit
643
+ # recovery lookup still happen inside the run lifecycle.
644
+ return manager.run_prepared_body(
645
+ body,
646
+ pipeline_path=pipeline_path,
647
+ submit_recovery_attempts=args.submit_recovery_attempts,
648
+ )["response"]
649
+
650
+ _run_prepared_manager_actions(
651
+ config,
652
+ base_url,
653
+ specs,
654
+ prepare,
655
+ submit,
656
+ precheck=_check_submit_from_python_inputs,
657
+ )
658
+
659
+
250
660
  @app.command(name="details")
251
661
  def pipeline_runs_details(
252
662
  run_id: str | None = None,