Skip to content

Supply root metadata annotations from the caller (v0.1.19) - #62

Merged
Volv-G merged 1 commit into
masterfrom
piforge/tangle-pipeline-crud/tangle-cli-v0-1-19-config-pipeli-60ecc4b
Sep 23, 2026
Merged

Volv-G merged 1 commit into
masterfrom
piforge/tangle-pipeline-crud/tangle-cli-v0-1-19-config-pipeli-60ecc4b

Conversation

@Volv-G

@Volv-G Volv-G commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

(AI-assisted)

What

@pipeline(annotations=...) is the only route to a compiled pipeline's root metadata.annotations, so the block must be hard-coded in source. This adds a pipeline_annotations keyword to the three compile entry points so a caller — in practice a downstream distribution reading its own per-environment config — can supply the part of the block that varies by environment:

compile_pipeline_file(
    "pipeline.py",
    "pipeline.yaml",
    pipeline_annotations={"environment": "staging", "owner": "search-platform"},
)

Same keyword on compile_pipeline and PipelineCompiler.compile_file. No CLI flag: the source route already exists, and what is missing is a programmatic/config route.

How

  • Per-key merge, caller wins. Source keeps its invariants (author, version); only the varying subset is passed in. Emitted key order stays source-first.
  • None / {} is a byte-identical no-op, not a destructive clear — there is no tier below this one to clear.
  • Root only. subpipeline children inherit nothing, so their sidecar filenames, bytes and component digests are untouched. Pinned by a test that compares child sidecar name and bytes with and without the keyword.
  • Carried on CompileContext exactly as image_overrides is, and deliberately absent from PipelineCompileKey / overrides_fingerprint, which hash compile identity rather than content — folding it in would shift child sidecar filenames and dedup identity.
  • Merged into GraphBuilder.annotations before emit_pipeline, so values flow through the ordinary emit-time guards.

One place for annotation rules

schema_validation.check_annotations(mapping, policy=..., error_cls=...) is now the single definition, applied under a named AnnotationPolicy:

  • DOCUMENT_ANNOTATION_POLICY replaces the inline scalar check in _validate_semantics. Behaviour unchanged — scalar-or-null values, no key rules, same message text — so hand-authored and legacy YAML keep validating exactly as before.
  • CALLER_ANNOTATION_POLICY governs the new input surface and is strict because it is new, has no legacy, and is usually an untrusted config file: str -> str, non-empty keys, no reserved system/ prefix, no {{ / {% / {#.

Delimiters are scanned with the same tokens as the compiled-output guard, so an input check and the output contract cannot disagree; rejecting at input time only moves the failure earlier, onto a message that names the annotation key instead of a JSON path in the emitted YAML. Diagnostics name the key and the type or delimiter and never echo a value. The compile path raises the dedicated InvalidPipelineAnnotationsError (a CompileError) so a downstream config reader can catch precisely and re-raise with its own file/key provenance instead of catching CompileError broadly.

Failure modes

  • Root metadata is descriptive only — it is not read by the orchestrator, so it cannot influence placement, routing, scheduling or run identity. Anything execution-bearing wants pipeline-run annotations instead. Stated in the README section.
  • Values land in the compiled YAML, the stored definition and API responses: labels only, never secrets.
  • A metadata-only change produces a new saved-pipeline version, since root metadata rides inside root_pipeline_task.
  • Compile entry points only: on a config-accepting command that does not compile Python there is nothing to write root metadata into.

Review focus

  • The is_root gate at the merge site (root-only is what keeps component digests provably untouched).
  • The deliberate asymmetry between the two policies, and that the document one is not tightened.
  • That no diagnostic can echo an annotation value.

Tophatting

uv run --frozen pytest tests/test_pipeline_annotations.py

41 tests: merge/collision/order, byte-identical no-op for None and {}, child sidecar name+bytes identity and non-inheritance, compile -> hydrate survival, handler and facade keywords, 12 hostile-mapping cases asserting the rejected value never appears in the message, legacy scalar acceptance (str/int/float/bool/null) preserved, the document policy not tightened by caller rules, and no-bypass coverage showing annotations that never went through the caller entry point are still validated before anything is written.

Checklist

  • Full suite green locally on Python 3.13: 1589 passed.
  • Version bumped to 0.1.19 in pyproject.toml, packages/tangle-cli/src/tangle_cli/__init__.py, tests/test_packaging.py, and the uv.lock editable self-entry (the lock diff is the two-line version change only).
  • README documents the keyword, the precedence rules, the descriptive-only caveat, and the shared validation API.

*(AI-assisted)*

`@pipeline(annotations=...)` is the only route to a compiled pipeline's root
`metadata.annotations`, so the block has to be hard-coded in source. Add a
`pipeline_annotations` keyword to the three compile entry points
(`compile_pipeline`, `PipelineCompiler.compile_file`, `compile_pipeline_file`)
so a caller — in practice a downstream distribution reading its own
per-environment config — can supply the part of the block that varies by
environment.

Semantics: per-key merge with the caller winning on collision, so source keeps
its invariants and only the varying subset is passed in; `None` / `{}` is a
byte-identical no-op rather than a destructive clear; ROOT ONLY, so
`subpipeline` children inherit nothing and their sidecar names, bytes and
component digests are untouched. The value rides on `CompileContext` the way
`image_overrides` does and is deliberately absent from `PipelineCompileKey`,
which hashes identity rather than content. The merge happens on the
`GraphBuilder` before `emit_pipeline`, so the values pass the ordinary
emit-time guards.

Annotation rules now live in one place, `schema_validation.check_annotations`,
applied under a named `AnnotationPolicy`. `DOCUMENT_ANNOTATION_POLICY`
replaces the inline scalar check in `_validate_semantics` and is unchanged in
behaviour — hand-authored and legacy YAML keep validating exactly as before.
`CALLER_ANNOTATION_POLICY` governs the new input surface, which is strict
because it is new, has no legacy, and is usually an untrusted config file:
`str -> str`, non-empty keys, no reserved `system/` prefix, and no `{{` / `{%`
/ `{#`, scanned with the same tokens as the compiled-output guard so an input
check and the output contract cannot disagree. Diagnostics name the key and
the type or delimiter and never echo a value. The compile path raises the
dedicated `InvalidPipelineAnnotationsError`, so a downstream config reader can
catch precisely and re-raise with its own file/key provenance.
@Volv-G
Volv-G requested a review from Ark-kun as a code owner September 23, 2026 23:14
@Volv-G
Volv-G merged commit a8e60e3 into master Sep 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant