tangle-cli 0.1.9__tar.gz → 0.1.10__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.9 → tangle_cli-0.1.10}/PKG-INFO +51 -1
  2. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/README.md +50 -0
  3. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/__init__.py +1 -1
  4. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +37 -15
  5. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/emit.py +179 -3
  6. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/graph.py +12 -0
  7. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/ref.py +20 -2
  8. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/subpipeline.py +18 -2
  9. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json +4 -5
  10. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/pyproject.toml +1 -1
  11. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/pyproject.toml.orig +1 -1
  12. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/api_cli.py +0 -0
  13. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  14. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  15. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/args_container.py +0 -0
  16. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  17. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  18. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  19. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +0 -0
  20. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  21. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/client.py +0 -0
  22. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/component_from_func.py +0 -0
  23. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/component_generator.py +0 -0
  24. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  25. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/component_publisher.py +0 -0
  26. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  27. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  28. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  29. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/handler.py +0 -0
  30. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  31. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  32. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/models.py +0 -0
  33. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  34. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  35. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +0 -0
  36. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py +0 -0
  37. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  38. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +0 -0
  39. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  40. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  41. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +0 -0
  42. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +0 -0
  43. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +0 -0
  44. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +0 -0
  45. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_spec_utils.py +0 -0
  46. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipeline_validation.py +0 -0
  47. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipelines.py +0 -0
  48. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +0 -0
  49. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +0 -0
  50. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  51. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/__init__.py +0 -0
  52. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/cfg.py +0 -0
  53. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/compiler_context.py +0 -0
  54. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/dynamic_data.py +0 -0
  55. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +0 -0
  56. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/ids.py +0 -0
  57. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/pipeline.py +0 -0
  58. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/placeholders.py +0 -0
  59. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/raw.py +0 -0
  60. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/registered.py +0 -0
  61. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/task.py +0 -0
  62. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +0 -0
  63. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +0 -0
  64. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/python_pipeline/types.py +0 -0
  65. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/quickstart.py +0 -0
  66. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/schema_validation.py +0 -0
  67. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/schemas/__init__.py +0 -0
  68. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/schemas/pipeline_schema.json +0 -0
  69. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  70. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  71. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  72. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
  73. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/OSS-CONVENTIONS.md +0 -0
  74. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/PORT-README.md +0 -0
  75. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/SKILL.md +0 -0
  76. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/agents/auth-wizard.md +0 -0
  77. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/agents/builder.md +0 -0
  78. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/agents/debugger.md +0 -0
  79. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/agents/reporter.md +0 -0
  80. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/agents/researcher.md +0 -0
  81. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/agents/reviewer.md +0 -0
  82. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/agents/scenario-builder.md +0 -0
  83. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/data-sources.md +0 -0
  84. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/event-log.md +0 -0
  85. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/example-scenarios/01-mslr-ranking.md +0 -0
  86. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/example-scenarios/02-text-classification.md +0 -0
  87. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/example-scenarios/INDEX.md +0 -0
  88. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/iterating-on-runs.md +0 -0
  89. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/knowledge-corpus.md +0 -0
  90. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/secrets.md +0 -0
  91. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/setup.md +0 -0
  92. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-0-initialize.md +0 -0
  93. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-1-analyze.md +0 -0
  94. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-2-hypothesize.md +0 -0
  95. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-3-submit.md +0 -0
  96. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-4-monitor.md +0 -0
  97. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-5-evaluate.md +0 -0
  98. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-6-synthesize.md +0 -0
  99. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/step-7-decide.md +0 -0
  100. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/skills/tangent/references/tangle-tools.md +0 -0
  101. {tangle_cli-0.1.9 → tangle_cli-0.1.10}/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.9
3
+ Version: 0.1.10
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>
@@ -439,6 +439,56 @@ def collision(runtime_condition: In[str]) -> Out[str]:
439
439
 
440
440
  The bound value remains under `arguments.is_enabled`; the call-site value emits as `isEnabled`. Tangle does not evaluate conditions on graph-component tasks, so `subpipeline(...)(is_enabled=...)` is rejected with guidance to condition tasks inside the child pipeline. A child graph input with that name remains available through `subpipeline(...).bind(is_enabled=...)(...)`. There is no `condition` alias.
441
441
 
442
+ ##### Task execution options and caching
443
+
444
+ Tangle caches task results, so a task that reads state which changes between runs (a run's `createdBy`, wall-clock time, an external table that the graph does not depend on) must opt out of caching explicitly. Use the reserved task-call metadata keyword `max_cache_staleness=`:
445
+
446
+ ```python
447
+ @pipeline("Scheduled-run gate")
448
+ def scheduled_gate() -> Out[str]:
449
+ is_scheduled = read_runtime_state(
450
+ name="CLOUD_PIPELINES_PIPELINE_RUN_CREATED_BY",
451
+ max_cache_staleness="P0D",
452
+ )
453
+ return is_scheduled.Output
454
+ ```
455
+
456
+ This emits the canonical task field rather than a component argument:
457
+
458
+ ```yaml
459
+ executionOptions:
460
+ cachingStrategy:
461
+ maxCacheStaleness: P0D
462
+ ```
463
+
464
+ `P0D` means "never reuse a cached result"; any other ISO-8601 duration (`P7D`) caps how stale a reusable result may be. For the rest of `ExecutionOptionsSpec`, use the general `execution_options=` passthrough:
465
+
466
+ ```python
467
+ uploaded = flaky_upload(
468
+ payload=data.Output,
469
+ execution_options={"retryStrategy": {"maxRetries": 3}},
470
+ )
471
+ ```
472
+
473
+ Both keywords may be combined; `max_cache_staleness=` wins over a `cachingStrategy.maxCacheStaleness` supplied through `execution_options=`, and every other passthrough field is preserved. A mapping passed as `execution_options=` is never mutated, so one shared constant can be reused across tasks.
474
+
475
+ Tangle models exactly two execution-option groups today — `cachingStrategy.maxCacheStaleness` and `retryStrategy.maxRetries` (required whenever `retryStrategy` is present). Any other key is rejected at compile time: the backend ignores unmodeled keys silently, so accepting one would advertise a setting that never takes effect.
476
+
477
+ Execution options are STATIC compile-time settings, so `max_cache_staleness` takes an RFC3339 duration string and `retryStrategy.maxRetries` a non-negative integer. Graph inputs, task outputs, `dynamic_secret(...)`, and `raw(...)` values are rejected because the backend does not resolve them for `executionOptions`. Passing an empty `execution_options={}` is an error — omit the keyword instead.
478
+
479
+ If a component itself declares an input named `max_cache_staleness` or `execution_options`, bind that component argument separately while using the call-site keyword for task metadata:
480
+
481
+ ```python
482
+ result = work.bind(max_cache_staleness="component-input-value")(
483
+ message="hello",
484
+ max_cache_staleness="P0D",
485
+ )
486
+ ```
487
+
488
+ The bound value remains under `arguments.max_cache_staleness`; the call-site value emits as `executionOptions`. Tangle applies caching and retries to container-component tasks, so `subpipeline(...)(max_cache_staleness=...)` and `subpipeline(...)(execution_options=...)` are rejected with guidance to set them on tasks inside the child pipeline. A child graph input with either name remains available through `subpipeline(...).bind(...)`.
489
+
490
+ See `examples/python_pipeline/execution_options_pipeline.py` for a runnable example.
491
+
442
492
  ##### Task images, dependencies, and image IDs
443
493
 
444
494
  Use `@task(image="...")` to write the component image directly. Use `dependencies_from="pyproject.toml"` when generated components need to install Python dependencies. Several tasks can share one authoring-only `TaskEnv`:
@@ -413,6 +413,56 @@ def collision(runtime_condition: In[str]) -> Out[str]:
413
413
 
414
414
  The bound value remains under `arguments.is_enabled`; the call-site value emits as `isEnabled`. Tangle does not evaluate conditions on graph-component tasks, so `subpipeline(...)(is_enabled=...)` is rejected with guidance to condition tasks inside the child pipeline. A child graph input with that name remains available through `subpipeline(...).bind(is_enabled=...)(...)`. There is no `condition` alias.
415
415
 
416
+ ##### Task execution options and caching
417
+
418
+ Tangle caches task results, so a task that reads state which changes between runs (a run's `createdBy`, wall-clock time, an external table that the graph does not depend on) must opt out of caching explicitly. Use the reserved task-call metadata keyword `max_cache_staleness=`:
419
+
420
+ ```python
421
+ @pipeline("Scheduled-run gate")
422
+ def scheduled_gate() -> Out[str]:
423
+ is_scheduled = read_runtime_state(
424
+ name="CLOUD_PIPELINES_PIPELINE_RUN_CREATED_BY",
425
+ max_cache_staleness="P0D",
426
+ )
427
+ return is_scheduled.Output
428
+ ```
429
+
430
+ This emits the canonical task field rather than a component argument:
431
+
432
+ ```yaml
433
+ executionOptions:
434
+ cachingStrategy:
435
+ maxCacheStaleness: P0D
436
+ ```
437
+
438
+ `P0D` means "never reuse a cached result"; any other ISO-8601 duration (`P7D`) caps how stale a reusable result may be. For the rest of `ExecutionOptionsSpec`, use the general `execution_options=` passthrough:
439
+
440
+ ```python
441
+ uploaded = flaky_upload(
442
+ payload=data.Output,
443
+ execution_options={"retryStrategy": {"maxRetries": 3}},
444
+ )
445
+ ```
446
+
447
+ Both keywords may be combined; `max_cache_staleness=` wins over a `cachingStrategy.maxCacheStaleness` supplied through `execution_options=`, and every other passthrough field is preserved. A mapping passed as `execution_options=` is never mutated, so one shared constant can be reused across tasks.
448
+
449
+ Tangle models exactly two execution-option groups today — `cachingStrategy.maxCacheStaleness` and `retryStrategy.maxRetries` (required whenever `retryStrategy` is present). Any other key is rejected at compile time: the backend ignores unmodeled keys silently, so accepting one would advertise a setting that never takes effect.
450
+
451
+ Execution options are STATIC compile-time settings, so `max_cache_staleness` takes an RFC3339 duration string and `retryStrategy.maxRetries` a non-negative integer. Graph inputs, task outputs, `dynamic_secret(...)`, and `raw(...)` values are rejected because the backend does not resolve them for `executionOptions`. Passing an empty `execution_options={}` is an error — omit the keyword instead.
452
+
453
+ If a component itself declares an input named `max_cache_staleness` or `execution_options`, bind that component argument separately while using the call-site keyword for task metadata:
454
+
455
+ ```python
456
+ result = work.bind(max_cache_staleness="component-input-value")(
457
+ message="hello",
458
+ max_cache_staleness="P0D",
459
+ )
460
+ ```
461
+
462
+ The bound value remains under `arguments.max_cache_staleness`; the call-site value emits as `executionOptions`. Tangle applies caching and retries to container-component tasks, so `subpipeline(...)(max_cache_staleness=...)` and `subpipeline(...)(execution_options=...)` are rejected with guidance to set them on tasks inside the child pipeline. A child graph input with either name remains available through `subpipeline(...).bind(...)`.
463
+
464
+ See `examples/python_pipeline/execution_options_pipeline.py` for a runnable example.
465
+
416
466
  ##### Task images, dependencies, and image IDs
417
467
 
418
468
  Use `@task(image="...")` to write the component image directly. Use `dependencies_from="pyproject.toml"` when generated components need to install Python dependencies. Several tasks can share one authoring-only `TaskEnv`:
@@ -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.9"
17
+ __version__ = "0.1.10"
18
18
 
19
19
  __all__ = ["TangleDynamicDiscoveryClient", "__version__"]
@@ -24,7 +24,7 @@ import urllib.parse
24
24
  import urllib.request
25
25
  from collections.abc import Sequence
26
26
  from pathlib import Path
27
- from typing import Any
27
+ from typing import Any, NamedTuple
28
28
 
29
29
  from .parser import (
30
30
  DEFAULT_OPENAPI_PATH,
@@ -384,7 +384,7 @@ from pydantic import BaseModel
384
384
  try:
385
385
  from pydantic import ConfigDict
386
386
  except ImportError: # pragma: no cover - pydantic v1 fallback
387
- ConfigDict = None # type: ignore[assignment]
387
+ ConfigDict = None # type: ignore[misc, assignment]
388
388
 
389
389
 
390
390
  class TangleGeneratedModel(BaseModel):
@@ -464,6 +464,21 @@ def generate_models(
464
464
  lines.append("")
465
465
  return "\n".join(lines)
466
466
 
467
+
468
+ class _ParamName(NamedTuple):
469
+ """Pair a generated Python parameter name with its OpenAPI wire name.
470
+
471
+ ``local_name`` is the collision-avoided Python identifier used in the
472
+ generated method signature; ``wire_name`` is the schema/OpenAPI name that
473
+ must be used as the JSON/query/path key sent to the backend. They differ
474
+ whenever the parser had to rename a field (for example a request-body field
475
+ literally named ``body`` becomes the local ``body_2``).
476
+ """
477
+
478
+ local_name: str
479
+ wire_name: str
480
+
481
+
467
482
  def _method_name(group_name: str, command_name: str) -> str:
468
483
  return f"{_safe_identifier(group_name)}_{_safe_identifier(command_name)}"
469
484
 
@@ -481,7 +496,7 @@ def _param_signature(
481
496
  has_request_body: bool,
482
497
  *,
483
498
  raw_body_override: bool = False,
484
- ) -> tuple[str, list[str], list[str], list[str], set[str], bool]:
499
+ ) -> tuple[str, list[_ParamName], list[_ParamName], list[_ParamName], set[str], bool]:
485
500
  required: list[Any] = []
486
501
  optional: list[Any] = []
487
502
  for parameter in parameters:
@@ -489,25 +504,32 @@ def _param_signature(
489
504
  ordered = required + optional
490
505
  seen: set[str] = set()
491
506
  signature_parts: list[str] = []
492
- path_names: list[str] = []
493
- query_names: list[str] = []
494
- body_names: list[str] = []
507
+ path_names: list[_ParamName] = []
508
+ query_names: list[_ParamName] = []
509
+ body_names: list[_ParamName] = []
495
510
  required_body_names: set[str] = set()
496
511
  for parameter in ordered:
497
512
  name = _safe_identifier(parameter.local_name)
498
513
  if name in seen:
499
514
  continue
500
515
  seen.add(name)
516
+ # Collision-avoiding Python locals (e.g. ``body_2``) must never leak into
517
+ # the emitted wire payload; the schema field name is the wire key. Only
518
+ # a missing name falls back to the local identifier, so schema-valid
519
+ # empty property names keep their exact wire spelling.
520
+ original_name = getattr(parameter, "original_name", None)
521
+ wire_name = name if original_name is None else str(original_name)
522
+ entry = _ParamName(local_name=name, wire_name=wire_name)
501
523
  if parameter.required:
502
524
  signature_parts.append(f"{name}: Any")
503
525
  else:
504
526
  signature_parts.append(f"{name}: Any = None")
505
527
  if parameter.location == "path":
506
- path_names.append(name)
528
+ path_names.append(entry)
507
529
  elif parameter.location == "query":
508
- query_names.append(name)
530
+ query_names.append(entry)
509
531
  elif parameter.location == "body":
510
- body_names.append(name)
532
+ body_names.append(entry)
511
533
  if parameter.required:
512
534
  required_body_names.add(name)
513
535
  include_body = has_request_body
@@ -517,27 +539,27 @@ def _param_signature(
517
539
  return ", ".join(signature_parts), path_names, query_names, body_names, required_body_names, include_body
518
540
 
519
541
 
520
- def _dict_literal(names: list[str]) -> str:
542
+ def _dict_literal(names: list[_ParamName]) -> str:
521
543
  if not names:
522
544
  return "None"
523
- return "{" + ", ".join(f"{name!r}: {name}" for name in names) + "}"
545
+ return "{" + ", ".join(f"{name.wire_name!r}: {name.local_name}" for name in names) + "}"
524
546
 
525
547
 
526
- def _body_dict_literal(names: list[str], required_names: set[str]) -> str:
548
+ def _body_dict_literal(names: list[_ParamName], required_names: set[str]) -> str:
527
549
  if not names:
528
550
  return "None"
529
- optional_names = [name for name in names if name not in required_names]
551
+ optional_names = [name for name in names if name.local_name not in required_names]
530
552
  if not optional_names:
531
553
  return _dict_literal(names)
532
554
  optional_literal = _dict_literal(optional_names)
533
555
  optional_expr = f"key: value for key, value in {optional_literal}.items() if value is not None"
534
556
  if not required_names:
535
557
  return "{" + optional_expr + "}"
536
- required_literal = _dict_literal([name for name in names if name in required_names])
558
+ required_literal = _dict_literal([name for name in names if name.local_name in required_names])
537
559
  return "{" + f"**{required_literal}, **{{{optional_expr}}}" + "}"
538
560
 
539
561
 
540
- def _merged_body_dict_literal(names: list[str], required_names: set[str]) -> str:
562
+ def _merged_body_dict_literal(names: list[_ParamName], required_names: set[str]) -> str:
541
563
  """Return request JSON with generic body fields overridden by named fields."""
542
564
 
543
565
  return f"{{**(body or {{}}), **{_body_dict_literal(names, required_names)}}}"
@@ -4,7 +4,7 @@ Canonical top-level key order:
4
4
  name, description, metadata, inputs, outputs, implementation
5
5
 
6
6
  Per-task key order:
7
- annotations?, componentRef, arguments?, isEnabled?
7
+ annotations?, componentRef, arguments?, isEnabled?, executionOptions?
8
8
 
9
9
  Argument values are emitted in the runnable ``ArgumentValue`` shape,
10
10
  dispatched purely on the VALUE's runtime type — never on the argument
@@ -34,11 +34,18 @@ The literal key the user wrote (``wait_for``, ``depends_on``,
34
34
  """
35
35
  from __future__ import annotations
36
36
 
37
+ from collections.abc import Mapping
37
38
  from typing import Any
38
39
 
39
40
  from .dynamic_data import DynamicData
40
41
  from .errors import CompileError, InvalidArgumentTypeError
41
- from .graph import IS_ENABLED_UNSET, EdgeRef, GraphBuilder, TaskNode
42
+ from .graph import (
43
+ EXECUTION_OPTIONS_UNSET,
44
+ IS_ENABLED_UNSET,
45
+ EdgeRef,
46
+ GraphBuilder,
47
+ TaskNode,
48
+ )
42
49
  from .placeholders import GraphInputPlaceholder, TaskOutputProxy
43
50
  from .raw import Raw
44
51
 
@@ -141,7 +148,7 @@ def _emit_task(
141
148
  node: TaskNode, task_path: str, exempt_paths: set[str]
142
149
  ) -> dict[str, Any]:
143
150
  """Build the per-task body dict in canonical key order:
144
- ``annotations?, componentRef, arguments?, isEnabled?``.
151
+ ``annotations?, componentRef, arguments?, isEnabled?, executionOptions?``.
145
152
 
146
153
  ``task_path`` is this task's dot-delimited JSON path
147
154
  (``implementation.graph.tasks.<task_id>``); each argument's path is
@@ -167,6 +174,10 @@ def _emit_task(
167
174
  if node.is_enabled is not IS_ENABLED_UNSET:
168
175
  body["isEnabled"] = _emit_is_enabled(node.is_enabled)
169
176
 
177
+ execution_options = _emit_execution_options(node)
178
+ if execution_options is not None:
179
+ body["executionOptions"] = execution_options
180
+
170
181
  return body
171
182
 
172
183
 
@@ -316,6 +327,171 @@ def _emit_is_enabled(value: Any) -> Any:
316
327
  )
317
328
 
318
329
 
330
+ # The fields the BACKEND actually models on ``ExecutionOptionsSpec`` —
331
+ # mirrored from ``cloud_pipelines_backend.component_structures`` and vendored
332
+ # into ``schemas/pipeline_schema.json`` (kept honest by
333
+ # ``test_execution_option_fields_match_generated_schema``).
334
+ #
335
+ # This allowlist exists because the backend's pydantic models leave ``extra``
336
+ # unset, i.e. ``extra="ignore"``: an unmodeled key such as ``timeout`` or
337
+ # ``retryStrategy.backoff`` is SILENTLY DROPPED server-side rather than
338
+ # rejected. Accepting one here would let an author believe a timeout/backoff
339
+ # is in effect when nothing applies it, so the passthrough fails closed.
340
+ _EXECUTION_OPTION_FIELDS: dict[str, frozenset[str]] = {
341
+ "cachingStrategy": frozenset({"maxCacheStaleness"}),
342
+ "retryStrategy": frozenset({"maxRetries"}),
343
+ }
344
+
345
+ # ``RetryStrategySpec.max_retries`` is a required (non-Optional) backend field,
346
+ # so a retryStrategy without it is a submit-time validation failure.
347
+ _REQUIRED_EXECUTION_OPTION_FIELDS: dict[str, frozenset[str]] = {
348
+ "retryStrategy": frozenset({"maxRetries"}),
349
+ }
350
+
351
+
352
+ def _emit_execution_options(node: TaskNode) -> dict[str, Any] | None:
353
+ """Merge the reserved execution-option keywords into ``executionOptions``.
354
+
355
+ ``execution_options=`` is the general passthrough for the modeled
356
+ ``ExecutionOptionsSpec`` (``cachingStrategy`` and ``retryStrategy``).
357
+ ``max_cache_staleness=`` is the narrow ergonomic knob for the common
358
+ "do not cache this task" case and WINS over any
359
+ ``cachingStrategy.maxCacheStaleness`` supplied through the passthrough.
360
+
361
+ Returns ``None`` when neither keyword was authored, so the key is omitted
362
+ entirely. Unlike arguments and ``isEnabled``, execution options are STATIC
363
+ compile-time settings: graph inputs, task outputs, dynamic data, and raw
364
+ values are rejected because the backend does not resolve them here. Keys
365
+ the backend does not model are rejected too — see
366
+ :data:`_EXECUTION_OPTION_FIELDS`.
367
+ """
368
+ options_value = node.execution_options
369
+ staleness = node.max_cache_staleness
370
+
371
+ if (
372
+ options_value is EXECUTION_OPTIONS_UNSET
373
+ and staleness is EXECUTION_OPTIONS_UNSET
374
+ ):
375
+ return None
376
+
377
+ options: dict[str, Any] = {}
378
+ if options_value is not EXECUTION_OPTIONS_UNSET:
379
+ if not isinstance(options_value, Mapping):
380
+ raise InvalidArgumentTypeError(
381
+ "unsupported execution_options value type "
382
+ f"{type(options_value).__name__!r}. Task execution options must "
383
+ "be a mapping such as "
384
+ '{"cachingStrategy": {"maxCacheStaleness": "P0D"}}.'
385
+ )
386
+ options = _normalize_execution_option_value(
387
+ options_value, "execution_options"
388
+ )
389
+ _validate_execution_option_fields(options)
390
+
391
+ if staleness is not EXECUTION_OPTIONS_UNSET:
392
+ if not isinstance(staleness, str):
393
+ raise InvalidArgumentTypeError(
394
+ "unsupported max_cache_staleness value type "
395
+ f"{type(staleness).__name__!r}. Task cache staleness must be a "
396
+ "duration string such as 'P0D' (never reuse cached results) or "
397
+ "'P7D'."
398
+ )
399
+ caching = options.get("cachingStrategy")
400
+ if not isinstance(caching, dict):
401
+ caching = {}
402
+ # The narrow keyword is the authoritative source for this one field.
403
+ caching["maxCacheStaleness"] = staleness
404
+ options["cachingStrategy"] = caching
405
+
406
+ if not options:
407
+ raise CompileError(
408
+ "execution_options={} is empty; omit the keyword instead of "
409
+ "passing an empty mapping so the task emits no executionOptions."
410
+ )
411
+ return options
412
+
413
+
414
+ def _validate_execution_option_fields(options: dict[str, Any]) -> None:
415
+ """Reject execution-option keys the Tangle backend does not model.
416
+
417
+ The backend ignores (silently discards) unknown keys, so a typo or an
418
+ aspirational field would compile, submit, and quietly do nothing. Failing
419
+ at compile time keeps the passthrough as fail-closed as every other value
420
+ the Python authoring surface emits.
421
+ """
422
+ for group, value in options.items():
423
+ known_fields = _EXECUTION_OPTION_FIELDS.get(group)
424
+ if known_fields is None:
425
+ supported = ", ".join(sorted(_EXECUTION_OPTION_FIELDS))
426
+ raise InvalidArgumentTypeError(
427
+ f"unknown execution_options key {group!r}. Tangle models only "
428
+ f"{supported}; unmodeled keys are silently ignored by the "
429
+ "backend, so they are rejected here instead of looking like "
430
+ "a setting that never takes effect."
431
+ )
432
+ if not isinstance(value, dict):
433
+ raise InvalidArgumentTypeError(
434
+ f"execution_options.{group} must be a mapping; got "
435
+ f"{type(value).__name__!r}."
436
+ )
437
+ unknown = sorted(set(value) - known_fields)
438
+ if unknown:
439
+ supported = ", ".join(sorted(known_fields))
440
+ raise InvalidArgumentTypeError(
441
+ f"unknown execution_options.{group} field {unknown[0]!r}. "
442
+ f"Tangle models only {supported} here; unmodeled keys are "
443
+ "silently ignored by the backend, so they are rejected at "
444
+ "compile time."
445
+ )
446
+ required = _REQUIRED_EXECUTION_OPTION_FIELDS.get(group, frozenset())
447
+ missing = sorted(required - set(value))
448
+ if missing:
449
+ raise InvalidArgumentTypeError(
450
+ f"execution_options.{group} requires {missing[0]!r}. The "
451
+ "backend rejects a partial "
452
+ f"{group} spec."
453
+ )
454
+
455
+
456
+ def _normalize_execution_option_value(value: Any, path: str) -> Any:
457
+ """Recursively validate/normalize one static execution-option value.
458
+
459
+ Mappings and sequences are copied (so a caller's dict is never mutated by
460
+ the ``max_cache_staleness`` merge) and tuples become lists so the result is
461
+ plain YAML-serializable data. Anything else — a task output, graph input,
462
+ ``dynamic_secret(...)``, ``raw(...)`` value, or an arbitrary object — is
463
+ rejected: ``executionOptions`` is applied at submit time, not resolved from
464
+ the running graph.
465
+ """
466
+ if isinstance(value, Mapping):
467
+ normalized: dict[str, Any] = {}
468
+ for key, item in value.items():
469
+ if not isinstance(key, str):
470
+ raise InvalidArgumentTypeError(
471
+ f"unsupported execution_options key type "
472
+ f"{type(key).__name__!r} at {path}. Execution option keys "
473
+ "must be strings."
474
+ )
475
+ normalized[key] = _normalize_execution_option_value(
476
+ item, f"{path}.{key}"
477
+ )
478
+ return normalized
479
+ if isinstance(value, (list, tuple)):
480
+ return [
481
+ _normalize_execution_option_value(item, f"{path}[{index}]")
482
+ for index, item in enumerate(value)
483
+ ]
484
+ if value is None or isinstance(value, (str, bool, int, float)):
485
+ return value
486
+ raise InvalidArgumentTypeError(
487
+ f"unsupported execution_options value type {type(value).__name__!r} at "
488
+ f"{path}. Task execution options are static compile-time settings, so "
489
+ "only strings, numbers, booleans, null, lists, and nested mappings are "
490
+ "supported; graph inputs, task outputs, dynamic data, and raw values "
491
+ "are not evaluated for executionOptions."
492
+ )
493
+
494
+
319
495
  def _emit_edge_value(edge: EdgeRef) -> dict[str, Any]:
320
496
  """Render an :class:`EdgeRef` as a dehydrated ``ArgumentValue``
321
497
  sub-dict (``{taskOutput|graphInput: {...}}``) used in
@@ -15,6 +15,11 @@ from typing import Any, Literal
15
15
  # instead of being silently omitted.
16
16
  IS_ENABLED_UNSET = object()
17
17
 
18
+ # Distinguishes omitted task execution options from an explicitly supplied
19
+ # value, so ``execution_options=None`` / ``max_cache_staleness=None`` fail
20
+ # closed in the emitter instead of being silently dropped.
21
+ EXECUTION_OPTIONS_UNSET = object()
22
+
18
23
 
19
24
  @dataclass
20
25
  class EdgeRef:
@@ -37,6 +42,11 @@ class TaskNode:
37
42
  ``arguments`` values may be plain strings, TaskOutputProxy objects, or
38
43
  GraphInputPlaceholder objects. ``is_enabled`` is separate task metadata;
39
44
  the emitter normalizes and serializes it as ``isEnabled`` when supplied.
45
+
46
+ ``execution_options`` (general passthrough) and ``max_cache_staleness``
47
+ (narrow ergonomic knob) are likewise task metadata, not component inputs.
48
+ The emitter merges them — the narrow keyword wins — and serializes the
49
+ result as the canonical ``executionOptions`` task field.
40
50
  """
41
51
 
42
52
  task_id: str
@@ -46,6 +56,8 @@ class TaskNode:
46
56
  arguments: dict[str, Any] = field(default_factory=dict)
47
57
  annotations: dict[str, str] | None = None
48
58
  is_enabled: Any = IS_ENABLED_UNSET
59
+ execution_options: Any = EXECUTION_OPTIONS_UNSET
60
+ max_cache_staleness: Any = EXECUTION_OPTIONS_UNSET
49
61
 
50
62
 
51
63
  @dataclass
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  from typing import Any
17
17
 
18
18
  from .errors import CompileError
19
- from .graph import IS_ENABLED_UNSET
19
+ from .graph import EXECUTION_OPTIONS_UNSET, IS_ENABLED_UNSET
20
20
 
21
21
  _UNWRAPPED_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
22
22
 
@@ -296,6 +296,8 @@ class CallableRef:
296
296
  self,
297
297
  *,
298
298
  is_enabled: Any = IS_ENABLED_UNSET,
299
+ execution_options: Any = EXECUTION_OPTIONS_UNSET,
300
+ max_cache_staleness: Any = EXECUTION_OPTIONS_UNSET,
299
301
  **kwargs: Any,
300
302
  ) -> Any:
301
303
  """Trace-mode invocation.
@@ -313,7 +315,21 @@ class CallableRef:
313
315
  input named ``is_enabled``, bind that input with
314
316
  ``ref(...).bind(is_enabled=...)``; bound kwargs remain component
315
317
  arguments while the reserved call-site keyword remains task metadata,
316
- so both may be used on the same task. Edge kwargs (``wait_for`` /
318
+ so both may be used on the same task.
319
+
320
+ ``execution_options`` and ``max_cache_staleness`` follow the same
321
+ reserved-keyword convention and are emitted as the canonical
322
+ ``executionOptions`` task field. ``execution_options`` takes the whole
323
+ ``ExecutionOptionsSpec`` mapping (``cachingStrategy`` and
324
+ ``retryStrategy`` — the only groups Tangle models);
325
+ ``max_cache_staleness`` is the narrow knob for
326
+ ``cachingStrategy.maxCacheStaleness`` (e.g. ``"P0D"`` to never reuse
327
+ cached results) and wins when both supply that field.
328
+ A component input named ``execution_options`` or
329
+ ``max_cache_staleness`` is bound the same way, with
330
+ ``ref(...).bind(max_cache_staleness=...)``.
331
+
332
+ Edge kwargs (``wait_for`` /
317
333
  ``depends_on``) and regular kwargs share one ``arguments`` dict in the
318
334
  IR; the value-vs-key dispatch happens at emit time. ``.bind(...)``
319
335
  kwargs are merged in last so call-site kwargs win on conflict (same
@@ -377,6 +393,8 @@ class CallableRef:
377
393
  arguments=merged,
378
394
  annotations=dict(self.annotations) if self.annotations else None,
379
395
  is_enabled=is_enabled,
396
+ execution_options=execution_options,
397
+ max_cache_staleness=max_cache_staleness,
380
398
  )
381
399
  builder.add_task(node)
382
400
 
@@ -16,8 +16,9 @@ ergonomics (``.bind`` / ``.named`` / ``.with_annotations`` and call-site
16
16
  kwargs). Calling the handle inside an active ``@pipeline`` trace records
17
17
  ONE parent task (never the child's internals) and returns a
18
18
  :class:`tangle_cli.python_pipeline.placeholders.TaskOutputProxy`. Tangle only
19
- supports conditional execution for container-component tasks, so the reserved
20
- call-site ``is_enabled=`` metadata keyword is rejected on subpipeline boundary
19
+ supports conditional execution and execution options for container-component
20
+ tasks, so the reserved call-site ``is_enabled=``, ``execution_options=`` and
21
+ ``max_cache_staleness=`` metadata keywords are rejected on subpipeline boundary
21
22
  (graph-component) tasks.
22
23
 
23
24
  The child body is NOT executed into the parent's :class:`GraphBuilder`.
@@ -128,6 +129,10 @@ class SubpipelineRef:
128
129
  evaluate conditions on graph-component tasks. A child graph input with
129
130
  that name remains available through ``.bind(is_enabled=...)``, matching
130
131
  the reserved-metadata collision convention used by ``CallableRef``.
132
+ ``execution_options`` / ``max_cache_staleness`` are rejected for the
133
+ same reason: caching and retries apply to the container executions
134
+ inside the child graph, so silently emitting them on the boundary task
135
+ would look like a setting that is not actually applied.
131
136
  """
132
137
  import sys
133
138
 
@@ -154,6 +159,17 @@ class SubpipelineRef:
154
159
  "input with .bind(is_enabled=...)."
155
160
  )
156
161
 
162
+ for reserved in ("execution_options", "max_cache_staleness"):
163
+ if reserved in kwargs:
164
+ raise CompileError(
165
+ f"subpipeline tasks do not support call-site {reserved}= "
166
+ "because Tangle execution options (caching, retries) "
167
+ "apply to container-component tasks. Set "
168
+ f"{reserved}= on the tasks inside the child pipeline. If "
169
+ f"the child declares a graph input named {reserved!r}, pass "
170
+ f"that input with .bind({reserved}=...)."
171
+ )
172
+
157
173
  # Resolve the parent task ID. ``.named(...)`` always wins over the
158
174
  # AST-derived auto ID.
159
175
  if self.task_id_hint is not None:
@@ -263,8 +263,7 @@
263
263
  "additionalProperties": true,
264
264
  "properties": {
265
265
  "cachingStrategy": { "$ref": "#/$defs/CachingStrategySpec" },
266
- "retryStrategy": { "$ref": "#/$defs/RetryStrategySpec" },
267
- "timeout": { "type": "string", "description": "Go-style duration, e.g. `30m`, `2h`." }
266
+ "retryStrategy": { "$ref": "#/$defs/RetryStrategySpec" }
268
267
  }
269
268
  },
270
269
 
@@ -274,7 +273,7 @@
274
273
  "properties": {
275
274
  "maxCacheStaleness": {
276
275
  "type": ["string", "null"],
277
- "description": "Maximum allowed cache age, e.g. `P7D` (ISO-8601) or `7d`."
276
+ "description": "Maximum allowed cache age as an RFC3339 duration, e.g. `P7D`; `P0D` never reuses a cached result."
278
277
  }
279
278
  }
280
279
  },
@@ -282,9 +281,9 @@
282
281
  "RetryStrategySpec": {
283
282
  "type": "object",
284
283
  "additionalProperties": true,
284
+ "required": ["maxRetries"],
285
285
  "properties": {
286
- "maxRetries": { "type": "integer", "minimum": 0 },
287
- "backoff": { "type": "string", "description": "Go-style duration, e.g. `30s`." }
286
+ "maxRetries": { "type": "integer", "minimum": 0 }
288
287
  }
289
288
  }
290
289
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tangle-cli"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tangle-cli"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
5
5
  readme = "README.md"
6
6
  authors = [