tangle-cli 0.1.17__tar.gz → 0.1.19__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 (103) hide show
  1. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/PKG-INFO +29 -1
  2. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/README.md +28 -0
  3. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/__init__.py +1 -1
  4. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py +38 -2
  5. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipelines.py +11 -0
  6. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/compiler_context.py +8 -0
  7. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +11 -0
  8. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +62 -11
  9. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/schema_validation.py +158 -8
  10. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/pyproject.toml +1 -1
  11. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/pyproject.toml.orig +1 -1
  12. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/api_cli.py +0 -0
  13. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  14. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  15. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/args_container.py +0 -0
  16. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  17. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  18. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/authenticated_identity.py +0 -0
  19. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  20. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +0 -0
  21. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  22. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/client.py +0 -0
  23. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/component_from_func.py +0 -0
  24. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/component_generator.py +0 -0
  25. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  26. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/component_publisher.py +0 -0
  27. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  28. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  29. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  30. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/handler.py +0 -0
  31. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  32. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  33. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/models.py +0 -0
  34. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  35. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  36. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +0 -0
  37. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +0 -0
  38. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  39. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +0 -0
  40. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  41. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  42. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +0 -0
  43. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +0 -0
  44. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +0 -0
  45. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +0 -0
  46. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_spec_utils.py +0 -0
  47. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipeline_validation.py +0 -0
  48. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +0 -0
  49. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +0 -0
  50. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  51. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/__init__.py +0 -0
  52. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/cfg.py +0 -0
  53. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/dynamic_data.py +0 -0
  54. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/emit.py +0 -0
  55. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/graph.py +0 -0
  56. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/ids.py +0 -0
  57. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/pipeline.py +0 -0
  58. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/placeholders.py +0 -0
  59. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/publish.py +0 -0
  60. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/raw.py +0 -0
  61. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/ref.py +0 -0
  62. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/registered.py +0 -0
  63. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/subpipeline.py +0 -0
  64. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/task.py +0 -0
  65. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +0 -0
  66. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/python_pipeline/types.py +0 -0
  67. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/quickstart.py +0 -0
  68. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/schemas/__init__.py +0 -0
  69. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json +0 -0
  70. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/schemas/pipeline_schema.json +0 -0
  71. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  72. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  73. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  74. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
  75. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/OSS-CONVENTIONS.md +0 -0
  76. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/PORT-README.md +0 -0
  77. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/SKILL.md +0 -0
  78. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/agents/auth-wizard.md +0 -0
  79. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/agents/builder.md +0 -0
  80. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/agents/debugger.md +0 -0
  81. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/agents/reporter.md +0 -0
  82. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/agents/researcher.md +0 -0
  83. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/agents/reviewer.md +0 -0
  84. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/agents/scenario-builder.md +0 -0
  85. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/data-sources.md +0 -0
  86. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/event-log.md +0 -0
  87. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/example-scenarios/01-mslr-ranking.md +0 -0
  88. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/example-scenarios/02-text-classification.md +0 -0
  89. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/example-scenarios/INDEX.md +0 -0
  90. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/iterating-on-runs.md +0 -0
  91. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/knowledge-corpus.md +0 -0
  92. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/secrets.md +0 -0
  93. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/setup.md +0 -0
  94. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-0-initialize.md +0 -0
  95. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-1-analyze.md +0 -0
  96. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-2-hypothesize.md +0 -0
  97. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-3-submit.md +0 -0
  98. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-4-monitor.md +0 -0
  99. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-5-evaluate.md +0 -0
  100. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-6-synthesize.md +0 -0
  101. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/step-7-decide.md +0 -0
  102. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/skills/tangent/references/tangle-tools.md +0 -0
  103. {tangle_cli-0.1.17 → tangle_cli-0.1.19}/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.17
3
+ Version: 0.1.19
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>
@@ -537,6 +537,34 @@ def greeting_pipeline(who: In[str], cfg) -> Out[str]:
537
537
 
538
538
  Task IDs default from the left-hand variable name at the call site, converted to title case. If there is no simple left-hand variable, or if you want a stable explicit label, call `.named("Task Id")` before invoking the task. Use `.bind(...)` to pre-fill task arguments and `.with_annotations({...})` to add per-task annotations.
539
539
 
540
+ ##### Root pipeline annotations
541
+
542
+ `@pipeline(annotations={...})` writes the compiled pipeline's root `metadata.annotations` block. A caller that compiles programmatically can supply the block instead — typically from its own per-environment config file, so the values do not have to be hard-coded in source:
543
+
544
+ ```python
545
+ from tangle_cli.pipelines import compile_pipeline_file
546
+
547
+ compile_pipeline_file(
548
+ "pipeline.py",
549
+ "pipeline.yaml",
550
+ pipeline_annotations={"environment": "staging", "owner": "search-platform"},
551
+ )
552
+ ```
553
+
554
+ The same keyword exists on `tangle_cli.pipeline_compiler.compile_pipeline` and on `PipelineCompiler.compile_file`. There is no CLI flag: the source route already exists, and what the keyword adds is a programmatic/config route for the part of the block that varies by environment.
555
+
556
+ Semantics:
557
+
558
+ - **Per-key merge, caller wins.** `@pipeline(annotations={"author": "a", "version": "1.0"})` compiled with `pipeline_annotations={"version": "2.0", "environment": "staging"}` emits all three keys, with `version: "2.0"`. Source keys the caller does not mention are preserved, so invariants stay in source and only the varying subset is passed in.
559
+ - **Omitted or `{}` is a no-op**, byte for byte — an empty mapping is not a destructive clear of the source block.
560
+ - **Root only.** `subpipeline` children never inherit it, so child subgraph sidecar names, bytes, and component digests are unaffected. A child that wants annotations declares its own.
561
+ - **Descriptive only.** Root metadata is not read by the orchestrator, so it cannot influence placement, routing, scheduling, or run identity. Use pipeline-run annotations for anything execution-bearing.
562
+ - **`str -> str`, validated up front.** A non-mapping argument, a non-string key or value, an empty key, a `system/`-prefixed key (reserved by Tangle), or a template delimiter (`{{`, `{%`, `{#`) in a key or value raises `InvalidPipelineAnnotationsError` (a `CompileError`) before anything is imported or written. Annotations usually come from an untrusted config file, so the diagnostics name the key and the type and never echo a value. Values are baked into the compiled YAML and the stored pipeline definition: labels only, never secrets.
563
+
564
+ The rules live in one place, `tangle_cli.schema_validation`: `check_annotations(mapping, policy=..., error_cls=...)` applied under a named `AnnotationPolicy`. `CALLER_ANNOTATION_POLICY` is the strict input policy described above; `DOCUMENT_ANNOTATION_POLICY` is the lenient policy every pipeline document is validated against (scalar-or-null values, no key rules), matching the schema and hand-authored YAML. Only the caller-supplied input surface is strict: existing documents are accepted exactly as before, and the document check still runs on the merged result, so annotations reaching the output by any route are validated.
565
+
566
+ A distribution that reads these annotations from its own config file should call `check_annotations(mapping, policy=CALLER_ANNOTATION_POLICY, error_cls=...)` at config-parse time — passing its own error type and adding the config path and key to the message — so one user mistake produces one diagnostic instead of two competing ones. The compiler's own call is then the backstop for anything arriving by another route.
567
+
540
568
  ##### Conditional task execution
541
569
 
542
570
  Pipeline inputs used as conditions are ordinary `In[str]` values; there is no special conditional input annotation. Pass the value through the reserved task-call metadata keyword `is_enabled=`:
@@ -511,6 +511,34 @@ def greeting_pipeline(who: In[str], cfg) -> Out[str]:
511
511
 
512
512
  Task IDs default from the left-hand variable name at the call site, converted to title case. If there is no simple left-hand variable, or if you want a stable explicit label, call `.named("Task Id")` before invoking the task. Use `.bind(...)` to pre-fill task arguments and `.with_annotations({...})` to add per-task annotations.
513
513
 
514
+ ##### Root pipeline annotations
515
+
516
+ `@pipeline(annotations={...})` writes the compiled pipeline's root `metadata.annotations` block. A caller that compiles programmatically can supply the block instead — typically from its own per-environment config file, so the values do not have to be hard-coded in source:
517
+
518
+ ```python
519
+ from tangle_cli.pipelines import compile_pipeline_file
520
+
521
+ compile_pipeline_file(
522
+ "pipeline.py",
523
+ "pipeline.yaml",
524
+ pipeline_annotations={"environment": "staging", "owner": "search-platform"},
525
+ )
526
+ ```
527
+
528
+ The same keyword exists on `tangle_cli.pipeline_compiler.compile_pipeline` and on `PipelineCompiler.compile_file`. There is no CLI flag: the source route already exists, and what the keyword adds is a programmatic/config route for the part of the block that varies by environment.
529
+
530
+ Semantics:
531
+
532
+ - **Per-key merge, caller wins.** `@pipeline(annotations={"author": "a", "version": "1.0"})` compiled with `pipeline_annotations={"version": "2.0", "environment": "staging"}` emits all three keys, with `version: "2.0"`. Source keys the caller does not mention are preserved, so invariants stay in source and only the varying subset is passed in.
533
+ - **Omitted or `{}` is a no-op**, byte for byte — an empty mapping is not a destructive clear of the source block.
534
+ - **Root only.** `subpipeline` children never inherit it, so child subgraph sidecar names, bytes, and component digests are unaffected. A child that wants annotations declares its own.
535
+ - **Descriptive only.** Root metadata is not read by the orchestrator, so it cannot influence placement, routing, scheduling, or run identity. Use pipeline-run annotations for anything execution-bearing.
536
+ - **`str -> str`, validated up front.** A non-mapping argument, a non-string key or value, an empty key, a `system/`-prefixed key (reserved by Tangle), or a template delimiter (`{{`, `{%`, `{#`) in a key or value raises `InvalidPipelineAnnotationsError` (a `CompileError`) before anything is imported or written. Annotations usually come from an untrusted config file, so the diagnostics name the key and the type and never echo a value. Values are baked into the compiled YAML and the stored pipeline definition: labels only, never secrets.
537
+
538
+ The rules live in one place, `tangle_cli.schema_validation`: `check_annotations(mapping, policy=..., error_cls=...)` applied under a named `AnnotationPolicy`. `CALLER_ANNOTATION_POLICY` is the strict input policy described above; `DOCUMENT_ANNOTATION_POLICY` is the lenient policy every pipeline document is validated against (scalar-or-null values, no key rules), matching the schema and hand-authored YAML. Only the caller-supplied input surface is strict: existing documents are accepted exactly as before, and the document check still runs on the merged result, so annotations reaching the output by any route are validated.
539
+
540
+ A distribution that reads these annotations from its own config file should call `check_annotations(mapping, policy=CALLER_ANNOTATION_POLICY, error_cls=...)` at config-parse time — passing its own error type and adding the config path and key to the message — so one user mistake produces one diagnostic instead of two competing ones. The compiler's own call is then the backstop for anything arriving by another route.
541
+
514
542
  ##### Conditional task execution
515
543
 
516
544
  Pipeline inputs used as conditions are ordinary `In[str]` values; there is no special conditional input annotation. Pass the value through the reserved task-call metadata keyword `is_enabled=`:
@@ -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.17"
17
+ __version__ = "0.1.19"
18
18
 
19
19
  __all__ = ["TangleDynamicDiscoveryClient", "__version__"]
@@ -65,14 +65,19 @@ from .python_pipeline.compiler_context import (
65
65
  overrides_fingerprint,
66
66
  )
67
67
  from .python_pipeline.emit import _TASK_URL_PLACEHOLDER, emit_pipeline
68
- from .python_pipeline.errors import CompileError
68
+ from .python_pipeline.errors import CompileError, InvalidPipelineAnnotationsError
69
69
  from .python_pipeline.pipeline import PipelineFn
70
70
  from .python_pipeline.ref import CallableRef
71
71
  from .python_pipeline.registered import _REGISTERED_URL_PLACEHOLDER
72
72
  from .python_pipeline.subpipeline import _SUBPIPELINE_URL_PLACEHOLDER, SubpipelineRef
73
73
  from .python_pipeline.trace import trace_pipeline
74
74
  from .python_pipeline.types import In
75
- from .schema_validation import SchemaValidationError, validate_dehydrated_pipeline
75
+ from .schema_validation import (
76
+ CALLER_ANNOTATION_POLICY,
77
+ SchemaValidationError,
78
+ check_annotations,
79
+ validate_dehydrated_pipeline,
80
+ )
76
81
  from .utils import dump_yaml
77
82
 
78
83
 
@@ -141,6 +146,7 @@ def compile_pipeline(
141
146
  pipeline_name: str | None = None,
142
147
  emit_components_sidecar: bool = True,
143
148
  image_overrides: Mapping[str, str] | None = None,
149
+ pipeline_annotations: Mapping[str, str] | None = None,
144
150
  ) -> CompileResult:
145
151
  """Compile ``script`` to a single pipeline YAML at ``output``.
146
152
 
@@ -174,6 +180,18 @@ def compile_pipeline(
174
180
  image_overrides: Compile-time image-id overrides from ``--image
175
181
  ID=REF``. They apply only to ``@task(image_id=...)`` refs that do
176
182
  not also set an explicit ``image=``.
183
+ pipeline_annotations: Caller-supplied ROOT ``metadata.annotations``
184
+ (``str -> str``), typically read from a downstream config file so
185
+ the block can vary per environment. Merged PER KEY over the root
186
+ ``@pipeline(annotations=...)`` block, caller winning on collision;
187
+ ``None`` / ``{}`` is a no-op that leaves the compiled bytes
188
+ identical. ROOT ONLY — ``subpipeline`` children do not inherit it,
189
+ so their sidecar names, bytes, and digests are unaffected. Root
190
+ metadata is descriptive: it is not read by the orchestrator and
191
+ cannot influence placement, routing, scheduling, or identity. See
192
+ :data:`tangle_cli.schema_validation.CALLER_ANNOTATION_POLICY`
193
+ for the accepted shape; a malformed mapping raises
194
+ :class:`~tangle_cli.python_pipeline.errors.InvalidPipelineAnnotationsError`.
177
195
 
178
196
  Returns:
179
197
  A :class:`CompileResult`. ``components_path`` is the sidecar path
@@ -186,6 +204,15 @@ def compile_pipeline(
186
204
  """
187
205
  overrides = dict(overrides or {})
188
206
  image_overrides = dict(image_overrides or {})
207
+ # Validated up front, by the shared validation layer, so a hostile or
208
+ # malformed annotation mapping fails before any module is imported or any
209
+ # file is written. The compiler owns no annotation rules of its own; it
210
+ # only chooses the caller-facing error type.
211
+ root_annotations = check_annotations(
212
+ pipeline_annotations,
213
+ policy=CALLER_ANNOTATION_POLICY,
214
+ error_cls=InvalidPipelineAnnotationsError,
215
+ )
189
216
 
190
217
  # 1. Validate the script path.
191
218
  script_path = Path(script).resolve()
@@ -241,6 +268,7 @@ def compile_pipeline(
241
268
  emit_components_sidecar=emit_components_sidecar,
242
269
  source_dirs=purge_dirs,
243
270
  image_overrides=image_overrides,
271
+ pipeline_annotations=root_annotations,
244
272
  )
245
273
 
246
274
  # 5. Compile the root (and, recursively, all children) into in-memory
@@ -445,6 +473,12 @@ def _compile_pipeline_fn(
445
473
  # output guard must skip for THIS artifact's body.
446
474
  with _temp_sys_path(base_dir):
447
475
  builder = trace_pipeline(pipeline_fn, cfg=cfg, inputs={})
476
+ # Caller-supplied root annotations: ROOT ONLY (a child keeps exactly what
477
+ # its own ``@pipeline`` declared), merged PER KEY with the caller winning
478
+ # on collision, before emit so they pass the same emit-time guards as
479
+ # authored ones. ``update`` keeps the emitted order source-first.
480
+ if is_root and ctx.pipeline_annotations:
481
+ builder.annotations.update(ctx.pipeline_annotations)
448
482
  body_dict, exempt_paths = emit_pipeline(builder)
449
483
 
450
484
  # 3. The canonical compile key for dedup / cycle detection. A child is
@@ -2785,6 +2819,7 @@ class PipelineCompiler(TangleCliHandler):
2785
2819
  pipeline_name: str | None = None,
2786
2820
  emit_components_sidecar: bool = True,
2787
2821
  image_overrides: Mapping[str, str] | None = None,
2822
+ pipeline_annotations: Mapping[str, str] | None = None,
2788
2823
  ) -> CompileResult:
2789
2824
  """Compile ``script`` to a single dehydrated pipeline YAML at ``output``.
2790
2825
 
@@ -2804,6 +2839,7 @@ class PipelineCompiler(TangleCliHandler):
2804
2839
  pipeline_name=pipeline_name,
2805
2840
  emit_components_sidecar=emit_components_sidecar,
2806
2841
  image_overrides=image_overrides,
2842
+ pipeline_annotations=pipeline_annotations,
2807
2843
  )
2808
2844
  self.log.info(f"wrote {result.pipeline_path}")
2809
2845
  if result.components_path is not None:
@@ -259,6 +259,7 @@ def compile_pipeline_file(
259
259
  pipeline_name: str | None = None,
260
260
  emit_components_sidecar: bool = True,
261
261
  image_overrides: Mapping[str, str] | None = None,
262
+ pipeline_annotations: Mapping[str, str] | None = None,
262
263
  logger: Any | None = None,
263
264
  ) -> CompileResult:
264
265
  """Compile a Python-authored pipeline to a dehydrated YAML bundle.
@@ -271,6 +272,12 @@ def compile_pipeline_file(
271
272
  The :class:`~tangle_cli.pipeline_compiler.CompileResult` is returned as-is —
272
273
  unlike hydrate, the compiler already exposes its public result type, so there
273
274
  is nothing to repackage.
275
+
276
+ ``pipeline_annotations`` supplies ROOT ``metadata.annotations`` (``str ->
277
+ str``) merged per key over the root ``@pipeline(annotations=...)`` block,
278
+ caller winning on collision; it applies to the root only and is a no-op
279
+ when omitted or empty. See
280
+ :func:`~tangle_cli.pipeline_compiler.compile_pipeline`.
274
281
  """
275
282
 
276
283
  from .pipeline_compiler import PipelineCompiler
@@ -286,6 +293,10 @@ def compile_pipeline_file(
286
293
  pipeline_name=pipeline_name,
287
294
  emit_components_sidecar=emit_components_sidecar,
288
295
  image_overrides=dict(image_overrides) if image_overrides else None,
296
+ # Passed through unconverted: the compiler validates the shape and
297
+ # takes its own copy, so a malformed mapping fails with the
298
+ # compiler's value-free CompileError instead of a bare TypeError.
299
+ pipeline_annotations=pipeline_annotations,
289
300
  )
290
301
  except (CompileError, SchemaValidationError) as exc:
291
302
  raise PipelineValidationError(str(exc)) from exc
@@ -253,6 +253,14 @@ class CompileContext:
253
253
  root_overrides: dict[str, str] = field(default_factory=dict)
254
254
  emit_components_sidecar: bool = True
255
255
  image_overrides: dict[str, str] = field(default_factory=dict)
256
+ # Caller-supplied ROOT ``metadata.annotations`` (already validated),
257
+ # merged PER KEY over the root ``@pipeline(annotations=...)`` block just
258
+ # before emit. ROOT ONLY: children never inherit it, so a child sidecar's
259
+ # bytes — and therefore its component digest — are untouched by it. It is
260
+ # deliberately absent from :class:`PipelineCompileKey` /
261
+ # ``overrides_fingerprint`` (as ``image_overrides`` is), so sidecar
262
+ # filenames stay identity-derived rather than content-derived.
263
+ pipeline_annotations: dict[str, str] = field(default_factory=dict)
256
264
  max_depth: int = 32
257
265
  # Compiled CHILD artifacts keyed by compile key (Decision M dedup).
258
266
  # The root is NOT stored here; it is returned directly.
@@ -27,3 +27,14 @@ class AmbiguousTaskIdError(CompileError):
27
27
 
28
28
  class InvalidArgumentTypeError(CompileError):
29
29
  """Raised on an argument value with no supported emit dispatch."""
30
+
31
+
32
+ class InvalidPipelineAnnotationsError(CompileError):
33
+ """Raised on a malformed caller-supplied ``pipeline_annotations`` mapping.
34
+
35
+ A dedicated type because these annotations usually originate in a
36
+ downstream CONFIG file: a caller that reads such a file can catch this
37
+ precisely and re-raise with the config path and key attached, without
38
+ broadly catching :class:`CompileError` and swallowing unrelated compile
39
+ failures. Messages never echo an annotation value.
40
+ """
@@ -36,10 +36,65 @@ from __future__ import annotations
36
36
  import inspect
37
37
  from dataclasses import dataclass
38
38
  from pathlib import Path
39
+ from types import FrameType
39
40
  from typing import TypeVar
40
41
 
41
42
  _TaskEnvT = TypeVar("_TaskEnvT", bound="TaskEnv")
42
43
 
44
+ #: A dataclass-generated ``__init__`` is compiled by ``exec``, so its code
45
+ #: object carries this synthetic filename instead of a real module path.
46
+ _GENERATED_CODE_FILENAME = "<string>"
47
+
48
+ #: Hard bound on the frame walk below. Construction of one object never needs
49
+ #: this many frames; the bound only keeps a pathological stack from being
50
+ #: walked to its root, degrading to the documented working-directory fallback.
51
+ _MAX_CONSTRUCTION_FRAMES = 64
52
+
53
+
54
+ def _definition_site_file(instance: object) -> str | None:
55
+ """``__file__`` of the code that constructed ``instance``, or ``None``.
56
+
57
+ The definition site is the first frame *outside* the construction of this
58
+ object: the frame after the OUTERMOST construction frame, which is
59
+ normally the dataclass-generated ``__init__``. Because the boundary is
60
+ found by identity and not by a fixed ``f_back`` count, the answer is the
61
+ author's file at any dataclass subclass depth — a subclass that overrides
62
+ ``__post_init__`` and calls ``super()`` adds a frame, and adding frames no
63
+ longer changes the result. ``None`` means the site has no ``__file__``
64
+ (``exec``'d or interactive code), which callers treat as "unknown".
65
+ """
66
+
67
+ frames: list[FrameType] = []
68
+ frame = inspect.currentframe()
69
+ try:
70
+ # Skip this helper itself, then collect the stack above it.
71
+ frame = frame.f_back if frame is not None else None
72
+ while frame is not None and len(frames) < _MAX_CONSTRUCTION_FRAMES:
73
+ frames.append(frame)
74
+ frame = frame.f_back
75
+ finally:
76
+ # Break the frame reference cycle this function would otherwise leave.
77
+ del frame
78
+
79
+ try:
80
+ outermost = -1
81
+ for index, candidate in enumerate(frames):
82
+ # A frame constructing THIS object binds it to ``self``: the base
83
+ # and every subclass ``__post_init__``, plus the generated
84
+ # ``__init__``. Nothing outside the construction can hold the
85
+ # object yet, so this is exact rather than a frame count.
86
+ code = candidate.f_code
87
+ if candidate.f_locals.get("self") is instance or (
88
+ code.co_name == "__init__"
89
+ and code.co_filename == _GENERATED_CODE_FILENAME
90
+ ):
91
+ outermost = index
92
+ site = frames[outermost + 1] if outermost + 1 < len(frames) else None
93
+ filename = site.f_globals.get("__file__") if site is not None else None
94
+ return filename if isinstance(filename, str) and filename else None
95
+ finally:
96
+ del frames
97
+
43
98
 
44
99
  @dataclass(frozen=True)
45
100
  class TaskEnv:
@@ -49,7 +104,8 @@ class TaskEnv:
49
104
  image: Container image for the component. Required.
50
105
  dependencies_from: Optional path to a ``pyproject.toml`` (or any file
51
106
  the hydrator understands) declaring pip dependencies. A relative
52
- path is resolved at the ``TaskEnv`` *definition site*, so a shared
107
+ path is resolved at the ``TaskEnv`` *definition site* — the file
108
+ that constructed this env, at any subclass depth — so a shared
53
109
  ``_envs.py`` resolves intuitively; pass an absolute ``Path`` to
54
110
  avoid that. When omitted, the hydrator's existing dependency
55
111
  discovery still applies.
@@ -66,16 +122,11 @@ class TaskEnv:
66
122
 
67
123
  p = Path(self.dependencies_from)
68
124
  if not p.is_absolute():
69
- # Frames: __post_init__ -> generated __init__ -> the definition site.
70
- frame = inspect.currentframe()
71
- caller = (
72
- frame.f_back.f_back
73
- if frame and frame.f_back and frame.f_back.f_back
74
- else None
75
- )
76
- filename = caller.f_globals.get("__file__") if caller else None
77
- caller_dir = Path(filename).resolve().parent if filename else Path.cwd()
78
- p = caller_dir / p
125
+ filename = _definition_site_file(self)
126
+ # Working directory only as a last resort: the construction site
127
+ # has no file at all (exec'd or interactive code).
128
+ anchor = Path(filename).resolve().parent if filename else Path.cwd()
129
+ p = anchor / p
79
130
  # Frozen dataclass: bypass __setattr__ to store the resolved Path.
80
131
  object.__setattr__(self, "dependencies_from", p.resolve())
81
132
 
@@ -25,6 +25,12 @@ Public surface:
25
25
  ``taskOutput.taskId``, undeclared ``graphInput.inputName``,
26
26
  ``outputValues`` ↔ ``outputs`` correspondence, scalar metadata
27
27
  annotations, pure componentRefs) and the no-template-delimiter scan.
28
+ * :func:`check_annotations` plus :data:`DOCUMENT_ANNOTATION_POLICY` /
29
+ :data:`CALLER_ANNOTATION_POLICY` — the ONE place that says what a
30
+ ``metadata.annotations`` mapping may contain. Both the lenient check
31
+ applied to every pipeline DOCUMENT and the strict check applied to a
32
+ caller/config-supplied mapping are the same function under different
33
+ policies, so the two can never drift apart.
28
34
 
29
35
  Everything here is standalone — it never changes ``PipelineHydrator``
30
36
  behavior. ``compile_pipeline`` uses :func:`validate_dehydrated_pipeline`
@@ -34,6 +40,7 @@ from __future__ import annotations
34
40
 
35
41
  import json
36
42
  from collections.abc import Collection, Iterator, Mapping
43
+ from dataclasses import dataclass
37
44
  from functools import lru_cache
38
45
  from pathlib import Path
39
46
  from typing import Any
@@ -195,6 +202,152 @@ def assert_no_template_delimiters(
195
202
  )
196
203
 
197
204
 
205
+ # ---------------------------------------------------------------------------
206
+ # Annotation policy: the one definition of what an annotations mapping may
207
+ # contain, applied at two documented strictness levels.
208
+
209
+ #: Annotation-key prefix Tangle reserves for its own annotations.
210
+ RESERVED_ANNOTATION_KEY_PREFIX = "system/"
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class AnnotationPolicy:
215
+ """Which rules :func:`check_annotations` applies, and how it names the
216
+ mapping in diagnostics (``label``).
217
+
218
+ Every flag defaults to off, so a policy opts IN to strictness. With
219
+ ``require_string_values`` off, values fall back to the legacy
220
+ scalar-or-null rule rather than being unchecked.
221
+ """
222
+
223
+ label: str
224
+ require_string_values: bool = False
225
+ require_string_keys: bool = False
226
+ require_non_empty_keys: bool = False
227
+ reject_reserved_key_prefix: bool = False
228
+ reject_template_delimiters: bool = False
229
+ reject_non_mapping: bool = False
230
+
231
+
232
+ #: Applied to every pipeline document validated by
233
+ #: :func:`validate_dehydrated_pipeline`. Deliberately lenient: hand-authored
234
+ #: and legacy YAML reaches this path, and any flag enabled here would reject
235
+ #: documents that compile and run today.
236
+ DOCUMENT_ANNOTATION_POLICY = AnnotationPolicy(label="metadata.annotations")
237
+
238
+ #: Applied to a caller/config-supplied root annotations mapping (the
239
+ #: ``pipeline_annotations`` compile argument, and any downstream config reader
240
+ #: that wants to refuse a bad mapping early with its own file/key provenance:
241
+ #: ``check_annotations(mapping, policy=CALLER_ANNOTATION_POLICY,
242
+ #: error_cls=...)``). Strict ``str -> str`` because that input surface is new,
243
+ #: has no legacy, and is usually an untrusted config file — hence also
244
+ #: :func:`check_annotations`'s rule that a diagnostic names the key and the
245
+ #: type or delimiter but never echoes a value. ``None`` / ``{}`` means nothing
246
+ #: supplied and is a no-op, not a clear. ``reject_template_delimiters`` adds no
247
+ #: rule the output lacks — :func:`assert_no_template_delimiters` scans the
248
+ #: compiled output regardless; it moves the failure earlier, onto a message
249
+ #: that names the offending annotation key.
250
+ CALLER_ANNOTATION_POLICY = AnnotationPolicy(
251
+ label="pipeline_annotations",
252
+ require_string_values=True,
253
+ require_string_keys=True,
254
+ require_non_empty_keys=True,
255
+ reject_reserved_key_prefix=True,
256
+ reject_template_delimiters=True,
257
+ reject_non_mapping=True,
258
+ )
259
+
260
+
261
+ def check_annotations(
262
+ annotations: Any,
263
+ *,
264
+ policy: AnnotationPolicy,
265
+ error_cls: type[Exception] = SchemaValidationError,
266
+ ) -> dict[Any, Any]:
267
+ """Check an annotations mapping against ``policy`` and copy it.
268
+
269
+ Annotations are frequently UNTRUSTED input (a config file, a checked-in
270
+ YAML document), so every diagnostic names the offending KEY and the
271
+ offending TYPE or delimiter and NEVER echoes a value.
272
+
273
+ Args:
274
+ annotations: The mapping to check. ``None`` means "nothing supplied"
275
+ and is always accepted.
276
+ policy: Which rules apply — :data:`DOCUMENT_ANNOTATION_POLICY` or
277
+ :data:`CALLER_ANNOTATION_POLICY`.
278
+ error_cls: Exception type to raise, so a caller-input path can raise
279
+ its own precise type (``InvalidPipelineAnnotationsError``) while
280
+ the document path keeps raising
281
+ :class:`SchemaValidationError`. This module deliberately does
282
+ not import the authoring-layer error hierarchy.
283
+
284
+ Returns:
285
+ A plain ``dict`` copy in the caller's key order (empty for ``None``),
286
+ so later mutation of the supplied mapping cannot reach the compile.
287
+ No value is coerced or normalized — this function only accepts or
288
+ rejects.
289
+
290
+ Raises:
291
+ error_cls: on the first violation found.
292
+ """
293
+ if annotations is None:
294
+ return {}
295
+ if not isinstance(annotations, Mapping):
296
+ if not policy.reject_non_mapping:
297
+ return {}
298
+ raise error_cls(
299
+ f"{policy.label} must be a mapping of string keys to string "
300
+ f"values; got {type(annotations).__name__}."
301
+ )
302
+
303
+ checked: dict[Any, Any] = {}
304
+ for key, value in annotations.items():
305
+ if policy.require_string_keys and not isinstance(key, str):
306
+ raise error_cls(
307
+ f"{policy.label} keys must be strings; got a key of type "
308
+ f"{type(key).__name__}."
309
+ )
310
+ if policy.require_non_empty_keys and key == "":
311
+ raise error_cls(f"{policy.label} keys must not be empty.")
312
+ if policy.require_string_values:
313
+ # Report the type only — a rejected value may be untrusted.
314
+ if not isinstance(value, str):
315
+ raise error_cls(
316
+ f"{policy.label} value for key {key!r} must be a string; "
317
+ f"got {type(value).__name__}."
318
+ )
319
+ # bool is a subclass of int, so it is covered by int.
320
+ elif not (value is None or isinstance(value, (str, int, float))):
321
+ raise error_cls(
322
+ f"{policy.label}[{key!r}] must be a scalar (str/number/bool) "
323
+ f"or null, got {type(value).__name__!r}."
324
+ )
325
+ if policy.reject_reserved_key_prefix and isinstance(key, str):
326
+ if key.startswith(RESERVED_ANNOTATION_KEY_PREFIX):
327
+ raise error_cls(
328
+ f"{policy.label} key {key!r} uses the reserved "
329
+ f"{RESERVED_ANNOTATION_KEY_PREFIX!r} prefix, which Tangle "
330
+ "keeps for its own annotations. Choose a different key."
331
+ )
332
+ if policy.reject_template_delimiters:
333
+ # Scanned with the SAME tokens the compiled-output guard uses, so
334
+ # an input check and the output contract can never disagree.
335
+ for where, text in ((f"key {key!r}", key), (f"value for key {key!r}", value)):
336
+ if not isinstance(text, str):
337
+ continue
338
+ delim = next((d for _p, d in iter_template_delimiters(text)), None)
339
+ if delim is None:
340
+ continue
341
+ raise error_cls(
342
+ f"{policy.label} {where} contains the template delimiter "
343
+ f"{delim!r}. Annotations are literal metadata written "
344
+ "verbatim into the compiled pipeline, which must be fully "
345
+ "rendered — resolve the template before passing the value."
346
+ )
347
+ checked[key] = value
348
+ return checked
349
+
350
+
198
351
  # ---------------------------------------------------------------------------
199
352
  # Phase 5: dehydrated-pipeline shape detection + semantic validation.
200
353
  #
@@ -411,15 +564,12 @@ def _validate_semantics(data: Mapping[str, Any]) -> None:
411
564
  metadata = data.get("metadata")
412
565
  if isinstance(metadata, Mapping):
413
566
  annotations = metadata.get("annotations")
567
+ # A non-mapping ``annotations`` is left to JSON-Schema, exactly as
568
+ # before. Everything a DOCUMENT's annotations may contain is decided
569
+ # by the shared policy below, so this backstop and the strict
570
+ # caller-input check can never disagree about a shared rule.
414
571
  if isinstance(annotations, Mapping):
415
- for key, value in annotations.items():
416
- # bool is a subclass of int, so it is covered by int.
417
- if not (value is None or isinstance(value, (str, int, float))):
418
- raise SchemaValidationError(
419
- f"metadata.annotations[{key!r}] must be a scalar "
420
- f"(str/number/bool) or null, got "
421
- f"{type(value).__name__!r}."
422
- )
572
+ check_annotations(annotations, policy=DOCUMENT_ANNOTATION_POLICY)
423
573
 
424
574
 
425
575
  def validate_dehydrated_pipeline(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tangle-cli"
3
- version = "0.1.17"
3
+ version = "0.1.19"
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.17"
3
+ version = "0.1.19"
4
4
  description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
5
5
  readme = "README.md"
6
6
  authors = [