tangle-cli 0.1.13__tar.gz → 0.1.14__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.13 → tangle_cli-0.1.14}/PKG-INFO +5 -1
  2. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/README.md +4 -0
  3. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/__init__.py +1 -1
  4. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py +451 -125
  5. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/pyproject.toml +1 -1
  6. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/pyproject.toml.orig +1 -1
  7. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/api_cli.py +0 -0
  8. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  9. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  10. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/args_container.py +0 -0
  11. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  12. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  13. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  14. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +0 -0
  15. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  16. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/client.py +0 -0
  17. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/component_from_func.py +0 -0
  18. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/component_generator.py +0 -0
  19. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  20. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/component_publisher.py +0 -0
  21. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  22. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  23. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  24. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/handler.py +0 -0
  25. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  26. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  27. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/models.py +0 -0
  28. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  29. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  30. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +0 -0
  31. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +0 -0
  32. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  33. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +0 -0
  34. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  35. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  36. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +0 -0
  37. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +0 -0
  38. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +0 -0
  39. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +0 -0
  40. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_spec_utils.py +0 -0
  41. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipeline_validation.py +0 -0
  42. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipelines.py +0 -0
  43. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +0 -0
  44. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +0 -0
  45. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  46. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/__init__.py +0 -0
  47. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/cfg.py +0 -0
  48. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/compiler_context.py +0 -0
  49. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/dynamic_data.py +0 -0
  50. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/emit.py +0 -0
  51. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +0 -0
  52. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/graph.py +0 -0
  53. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/ids.py +0 -0
  54. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/pipeline.py +0 -0
  55. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/placeholders.py +0 -0
  56. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/raw.py +0 -0
  57. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/ref.py +0 -0
  58. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/registered.py +0 -0
  59. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/subpipeline.py +0 -0
  60. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/task.py +0 -0
  61. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +0 -0
  62. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +0 -0
  63. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/python_pipeline/types.py +0 -0
  64. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/quickstart.py +0 -0
  65. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/schema_validation.py +0 -0
  66. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/schemas/__init__.py +0 -0
  67. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json +0 -0
  68. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/schemas/pipeline_schema.json +0 -0
  69. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  70. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  71. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  72. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
  73. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/OSS-CONVENTIONS.md +0 -0
  74. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/PORT-README.md +0 -0
  75. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/SKILL.md +0 -0
  76. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/agents/auth-wizard.md +0 -0
  77. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/agents/builder.md +0 -0
  78. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/agents/debugger.md +0 -0
  79. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/agents/reporter.md +0 -0
  80. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/agents/researcher.md +0 -0
  81. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/agents/reviewer.md +0 -0
  82. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/agents/scenario-builder.md +0 -0
  83. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/data-sources.md +0 -0
  84. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/event-log.md +0 -0
  85. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/example-scenarios/01-mslr-ranking.md +0 -0
  86. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/example-scenarios/02-text-classification.md +0 -0
  87. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/example-scenarios/INDEX.md +0 -0
  88. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/iterating-on-runs.md +0 -0
  89. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/knowledge-corpus.md +0 -0
  90. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/secrets.md +0 -0
  91. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/setup.md +0 -0
  92. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-0-initialize.md +0 -0
  93. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-1-analyze.md +0 -0
  94. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-2-hypothesize.md +0 -0
  95. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-3-submit.md +0 -0
  96. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-4-monitor.md +0 -0
  97. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-5-evaluate.md +0 -0
  98. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-6-synthesize.md +0 -0
  99. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/step-7-decide.md +0 -0
  100. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/skills/tangent/references/tangle-tools.md +0 -0
  101. {tangle_cli-0.1.13 → tangle_cli-0.1.14}/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.13
3
+ Version: 0.1.14
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>
@@ -483,6 +483,10 @@ Notes:
483
483
 
484
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.
485
485
 
486
+ Sidecar entries are deduplicated by the **generated component**, not by function name. The dedup key is the module-qualified function identity — a logical module namespace derived from the project-relative source layout (`package_a/tasks.py` -> `package_a.tasks`, `pkg/__init__.py` -> `pkg`), plus `__qualname__` and the function name — together with every generation-affecting option: image (explicit or resolved `image_id`), `mode`, `resolve_root`, `dependencies_from`, the `unwrap` schema, and the source file. Runtime `__module__` is deliberately not used, because pipeline scripts are imported under throwaway UUID module names.
487
+
488
+ Call sites sharing that whole identity collapse to one entry keyed by the readable hyphenated function name (`run_dbt` -> `run-dbt`). Otherwise every colliding variant — a shared helper re-decorated with different task-level options, or `package_a.tasks.run` alongside `package_b.tasks.run` — is emitted as `run-dbt--<hash>`, where `<hash>` is a content digest of the canonical identity; no variant keeps the unsuffixed name, and each task ref points at its own. Digests are anchored at the project source directory, so fragment names are unchanged by relocating the project, compiling into a different output directory, or reordering the pipeline's calls, and they never embed image, path, or credential-bearing values.
489
+
486
490
  ```python
487
491
  from cloud_pipelines import components
488
492
  from tangle_cli.python_pipeline import In, Out, pipeline, task
@@ -457,6 +457,10 @@ Notes:
457
457
 
458
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.
459
459
 
460
+ Sidecar entries are deduplicated by the **generated component**, not by function name. The dedup key is the module-qualified function identity — a logical module namespace derived from the project-relative source layout (`package_a/tasks.py` -> `package_a.tasks`, `pkg/__init__.py` -> `pkg`), plus `__qualname__` and the function name — together with every generation-affecting option: image (explicit or resolved `image_id`), `mode`, `resolve_root`, `dependencies_from`, the `unwrap` schema, and the source file. Runtime `__module__` is deliberately not used, because pipeline scripts are imported under throwaway UUID module names.
461
+
462
+ Call sites sharing that whole identity collapse to one entry keyed by the readable hyphenated function name (`run_dbt` -> `run-dbt`). Otherwise every colliding variant — a shared helper re-decorated with different task-level options, or `package_a.tasks.run` alongside `package_b.tasks.run` — is emitted as `run-dbt--<hash>`, where `<hash>` is a content digest of the canonical identity; no variant keeps the unsuffixed name, and each task ref points at its own. Digests are anchored at the project source directory, so fragment names are unchanged by relocating the project, compiling into a different output directory, or reordering the pipeline's calls, and they never embed image, path, or credential-bearing values.
463
+
460
464
  ```python
461
465
  from cloud_pipelines import components
462
466
  from tangle_cli.python_pipeline import In, Out, pipeline, task
@@ -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.13"
17
+ __version__ = "0.1.14"
18
18
 
19
19
  __all__ = ["TangleDynamicDiscoveryClient", "__version__"]
@@ -479,17 +479,25 @@ def _compile_pipeline_fn(
479
479
  )
480
480
  if task_refs:
481
481
  components_path = output_path.with_name(output_path.stem + ".components.yaml")
482
- components_entries = _build_local_from_python_components(
482
+ # ONE plan drives both halves: entries and componentRef fragments must
483
+ # agree, and a fragment depends on how many distinct components the
484
+ # function generates pipeline-wide (a shared helper called with
485
+ # different task-level images emits one entry per configuration).
486
+ sidecar_plan = _plan_task_sidecar(
483
487
  task_refs,
484
488
  components_yaml_dir=components_path.parent,
489
+ # Identity is anchored at the pipeline SOURCE dir, not the output
490
+ # dir, so fragment names survive compiling elsewhere (e.g. the
491
+ # hidden submit bundle) and relocating the project.
492
+ identity_root=base_dir,
485
493
  image_overrides=ctx.image_overrides,
486
494
  unwrapped_input_keys=builder.task_unwrapped_input_keys,
487
495
  )
496
+ components_entries = sidecar_plan.entries
488
497
  _rewrite_task_componentref_urls(
489
498
  body_dict=body_dict,
490
- task_refs=task_refs,
499
+ fragment_by_task=sidecar_plan.fragment_by_task,
491
500
  components_yaml_name=components_path.name,
492
- unwrapped_input_keys=builder.task_unwrapped_input_keys,
493
501
  )
494
502
 
495
503
  # 4b. A CHILD artifact is written under ``<root>.subgraphs/``, away from
@@ -1155,6 +1163,32 @@ def _unwrapped_schema_for_task(
1155
1163
  raise CompileError(str(exc)) from exc
1156
1164
 
1157
1165
 
1166
+ def _canonical_json(payload: Any) -> str:
1167
+ """Canonical JSON encoding used for hashing and deterministic ordering.
1168
+
1169
+ Args:
1170
+ payload: Any JSON-serialisable structure. Mapping keys are sorted so
1171
+ the encoding never depends on Python dict insertion order.
1172
+
1173
+ Returns:
1174
+ A compact, key-sorted JSON string.
1175
+ """
1176
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
1177
+
1178
+
1179
+ def _stable_payload_hash(payload: Any) -> str:
1180
+ """Short deterministic SHA-256 prefix for a JSON-serialisable payload.
1181
+
1182
+ Args:
1183
+ payload: Any JSON-serialisable structure. The digest is stable across
1184
+ processes (unlike :func:`hash`) and independent of dict ordering.
1185
+
1186
+ Returns:
1187
+ A 10-character lowercase hex digest prefix.
1188
+ """
1189
+ return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()[:10]
1190
+
1191
+
1158
1192
  def _unwrapped_schema_hash(schema: Mapping[str, Any]) -> str:
1159
1193
  """Hash a persisted unwrap schema for sidecar fragment disambiguation.
1160
1194
 
@@ -1167,26 +1201,46 @@ def _unwrapped_schema_hash(schema: Mapping[str, Any]) -> str:
1167
1201
  ``combine--0123abcd`` so different key sets for the same function do
1168
1202
  not collide in the generated components sidecar.
1169
1203
  """
1170
- payload = json.dumps(schema, sort_keys=True, separators=(",", ":"), default=str)
1171
- return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:10]
1204
+ return _stable_payload_hash(schema)
1205
+
1206
+
1207
+ def _task_fragment_base(ref: CallableRef) -> str:
1208
+ """Return the LEGACY, un-disambiguated sidecar fragment for a ``@task``.
1209
+
1210
+ Args:
1211
+ ref: The ``@task`` callable reference.
1212
+
1213
+ Returns:
1214
+ The hyphenated function name, matching the ``local_from_python``
1215
+ resolver's output-filename convention (``my_task`` -> ``my-task``).
1216
+ This is the fragment used verbatim when the function generates exactly
1217
+ ONE component across the whole pipeline; see
1218
+ :func:`_plan_task_sidecar` for the multi-variant disambiguation.
1219
+ """
1220
+ assert ref._task_function_name is not None # only @task refs reach here
1221
+ return ref._task_function_name.replace("_", "-")
1172
1222
 
1173
1223
 
1174
1224
  def _fragment_for_task(ref: CallableRef, unwrapped_schema: Mapping[str, Any] | None = None) -> str:
1175
- """Return the stable components-sidecar fragment for a ``@task`` call.
1225
+ """Return the legacy single-variant fragment for a ``@task`` call.
1176
1226
 
1177
1227
  Args:
1178
1228
  ref: The ``@task`` callable reference.
1179
1229
  unwrapped_schema: Optional persisted unwrap schema for this call site.
1180
1230
 
1181
1231
  Returns:
1182
- The hyphenated function name for normal tasks, matching the
1183
- ``local_from_python`` resolver's output-filename convention
1184
- (``my_task`` -> ``my-task``). For unwrapped tasks, returns the function
1185
- fragment plus a schema hash so different key sets do not collide into
1186
- one component schema.
1232
+ The hyphenated function name for normal tasks. For unwrapped tasks,
1233
+ returns the function fragment plus a schema hash so different key sets
1234
+ do not collide into one component schema.
1235
+
1236
+ Note:
1237
+ This is only the SINGLE-VARIANT naming. When one function generates
1238
+ several DIFFERENT components in one pipeline (e.g. a shared ``run_dbt``
1239
+ called with different task-level ``image``/``dependencies_from``),
1240
+ :func:`_plan_task_sidecar` appends a component-identity hash instead so
1241
+ the variants do not collapse into the first one emitted.
1187
1242
  """
1188
- assert ref._task_function_name is not None # only @task refs reach here
1189
- base = ref._task_function_name.replace("_", "-")
1243
+ base = _task_fragment_base(ref)
1190
1244
  if unwrapped_schema:
1191
1245
  return f"{base}--{_unwrapped_schema_hash(unwrapped_schema)}"
1192
1246
  return base
@@ -1530,163 +1584,435 @@ def _relocate_relative_local_url(url: str, source_dir: Path, sidecar_dir: Path)
1530
1584
  return None
1531
1585
 
1532
1586
 
1533
- def _build_local_from_python_components(
1587
+ @dataclass(frozen=True)
1588
+ class TaskSidecarPlan:
1589
+ """Resolved ``@task`` sidecar entries plus the per-task fragment map.
1590
+
1591
+ Attributes:
1592
+ entries: The ``<stem>.components.yaml`` content, ``{fragment:
1593
+ {local_from_python: {...}}}``, in first-seen task order.
1594
+ fragment_by_task: Graph ``task_id`` -> the sidecar fragment that task's
1595
+ ``componentRef`` must point at. Sidecar emission and componentRef
1596
+ rewriting MUST share this map: fragment naming depends on how many
1597
+ distinct components one function generates across the whole
1598
+ pipeline, so it cannot be recomputed per call site in isolation.
1599
+ """
1600
+
1601
+ entries: dict[str, Any]
1602
+ fragment_by_task: dict[str, str]
1603
+
1604
+
1605
+ def _local_from_python_payload(
1606
+ ref: CallableRef,
1607
+ *,
1608
+ source: Path,
1609
+ components_yaml_dir: Path,
1610
+ image_overrides: Mapping[str, str] | None,
1611
+ unwrapped_schema: Mapping[str, Any],
1612
+ ) -> dict[str, Any]:
1613
+ """Build the ``local_from_python`` block for ONE traced ``@task`` call.
1614
+
1615
+ Every field that changes what ``regenerate_yaml`` produces at hydrate time
1616
+ belongs in this payload — the payload IS the component's identity (see
1617
+ :func:`_plan_task_sidecar`).
1618
+
1619
+ Args:
1620
+ ref: The ``@task`` callable reference for this call site.
1621
+ source: The ``@task`` source file (already existence-checked).
1622
+ components_yaml_dir: Directory the generated sidecar will live in;
1623
+ local paths are emitted relative to it.
1624
+ image_overrides: Optional compile-time ``--image ID=REF`` overrides.
1625
+ unwrapped_schema: Persisted unwrap schema for this call site, or an
1626
+ empty mapping when the task does not use ``@task(unwrap=...)``.
1627
+
1628
+ Returns:
1629
+ The ``local_from_python`` mapping for this call site.
1630
+
1631
+ Raises:
1632
+ CompileError: when ``image_id`` does not resolve, or a referenced
1633
+ ``resolve_root`` / ``dependencies_from`` path is unreachable.
1634
+ """
1635
+ local_from_python: dict[str, Any] = {}
1636
+ if ref._task_image is not None:
1637
+ local_from_python["image"] = ref._task_image
1638
+ elif ref._task_image_id is not None:
1639
+ resolved_image = resolve_image_id(ref._task_image_id, image_overrides)
1640
+ if resolved_image is None:
1641
+ raise CompileError(
1642
+ f"@task image_id={ref._task_image_id!r} on function "
1643
+ f"{ref._task_function_name!r} did not resolve to an image. "
1644
+ f"Pass --image {ref._task_image_id}=IMAGE to `tangle sdk pipelines compile`, "
1645
+ f"or register a default with register_image_id({ref._task_image_id!r}, IMAGE)."
1646
+ )
1647
+ local_from_python["image"] = resolved_image
1648
+ # Always pin the function name. Without it the hydrator defaults
1649
+ # to the file stem and extracts the wrong symbol.
1650
+ assert ref._task_function_name is not None
1651
+ local_from_python["function"] = ref._task_function_name
1652
+ if ref._task_mode is not None:
1653
+ local_from_python["mode"] = ref._task_mode
1654
+ if ref._task_resolve_root is not None:
1655
+ resolve_root = ref._task_resolve_root
1656
+ if not resolve_root.exists():
1657
+ raise CompileError(
1658
+ f"@task resolve_root is unreachable: {resolve_root}. "
1659
+ "Point resolve_root at an existing directory or drop it."
1660
+ )
1661
+ local_from_python["resolve_root"] = _relpath_posix(resolve_root, components_yaml_dir)
1662
+ if ref._task_dependencies_from is not None:
1663
+ deps = ref._task_dependencies_from
1664
+ if not deps.exists():
1665
+ raise CompileError(
1666
+ f"@task dependencies_from file is unreachable: {deps}. "
1667
+ "Point dependencies_from at an existing file or drop it."
1668
+ )
1669
+ local_from_python["dependencies_from"] = _relpath_posix(deps, components_yaml_dir)
1670
+ if unwrapped_schema:
1671
+ local_from_python["unwrapped_inputs"] = dict(unwrapped_schema)
1672
+ local_from_python["file"] = _relpath_posix(source, components_yaml_dir)
1673
+ return local_from_python
1674
+
1675
+
1676
+ def _logical_module_id(source: Path, *, identity_root: Path) -> str:
1677
+ """Derive a STABLE logical module namespace for a ``@task`` source file.
1678
+
1679
+ Runtime ``__module__`` is unusable as an identity: the compile driver
1680
+ imports the pipeline script under a throwaway
1681
+ ``_tangle_user_pipeline_<uuid>`` module name, so a ``@task`` defined in the
1682
+ script would get a different "module" on every compile while a ``@task``
1683
+ imported from a sibling helper would get a real dotted name. The namespace
1684
+ is therefore derived from the source LAYOUT instead, which is what an
1685
+ import would have produced anyway:
1686
+
1687
+ * Walk up while the directory is a package (``__init__.py`` present) to
1688
+ build the dotted package chain — ``pkg_a/tasks.py`` -> ``pkg_a.tasks``,
1689
+ and ``pkg_a/__init__.py`` -> ``pkg_a`` (the ``__init__`` segment is the
1690
+ package itself, never a child module).
1691
+ * The first NON-package ancestor is the import root. Two identically named
1692
+ modules under different import roots are genuinely different modules, so
1693
+ that root is recorded as a ``<root>:`` prefix, expressed RELATIVE to
1694
+ ``identity_root`` (never an absolute machine path).
1695
+
1696
+ Args:
1697
+ source: Absolute path of the ``@task`` source file.
1698
+ identity_root: Stable project anchor (the pipeline's own source
1699
+ directory) that the import root is expressed relative to.
1700
+
1701
+ Returns:
1702
+ ``"<import-root-rel>:<dotted.module>"``, e.g. ``".:pkg_a.tasks"`` or
1703
+ ``"./src:pipeline"``. Stable when the project is relocated or compiled
1704
+ into a different output directory.
1705
+
1706
+ Raises:
1707
+ CompileError: when no relative path to ``identity_root`` can be formed
1708
+ (see :func:`_relpath_posix`).
1709
+ """
1710
+ parts: list[str] = []
1711
+ if source.stem != "__init__":
1712
+ parts.append(source.stem)
1713
+ directory = source.parent
1714
+ while (directory / "__init__.py").exists():
1715
+ parts.append(directory.name)
1716
+ parent = directory.parent
1717
+ if parent == directory: # filesystem root — stop rather than loop
1718
+ break
1719
+ directory = parent
1720
+ dotted = ".".join(reversed(parts))
1721
+ return f"{_relpath_posix(directory, identity_root)}:{dotted}"
1722
+
1723
+
1724
+ def _emitted_form_rank(payload: Mapping[str, Any]) -> tuple[int, str]:
1725
+ """Sort key selecting the canonical spelling of one generated component.
1726
+
1727
+ Several call sites can describe the SAME component with different emitted
1728
+ text — writing ``mode="inline"`` explicitly versus leaving the implicit
1729
+ default out. Ranking by key count first prefers the leanest spelling, so a
1730
+ pipeline that already omits a redundant default keeps its existing sidecar
1731
+ bytes when a sibling call site spells that default out.
1732
+
1733
+ Args:
1734
+ payload: An emitted ``local_from_python`` block.
1735
+
1736
+ Returns:
1737
+ ``(key count, canonical JSON)`` — total and independent of call order.
1738
+ """
1739
+ return (len(payload), _canonical_json(payload))
1740
+
1741
+
1742
+ def _task_component_identity(
1743
+ ref: CallableRef,
1744
+ *,
1745
+ source: Path,
1746
+ identity_root: Path,
1747
+ image_overrides: Mapping[str, str] | None,
1748
+ unwrapped_schema: Mapping[str, Any],
1749
+ ) -> dict[str, Any]:
1750
+ """Build the canonical dedup identity for one traced ``@task`` call.
1751
+
1752
+ The identity is the MODULE-QUALIFIED FUNCTION IDENTITY plus every
1753
+ generation-affecting option. Two call sites produce the same component —
1754
+ and therefore share one sidecar entry — exactly when this payload matches.
1755
+
1756
+ Paths inside the identity are anchored at ``identity_root`` (the pipeline's
1757
+ own source directory) rather than at the sidecar directory, so the identity
1758
+ is invariant under relocating the project or compiling into a different
1759
+ output directory, and never embeds an absolute machine path.
1760
+
1761
+ Args:
1762
+ ref: The ``@task`` callable reference for this call site.
1763
+ source: The ``@task`` source file.
1764
+ identity_root: Stable project anchor for path-derived identity parts.
1765
+ image_overrides: Optional compile-time ``--image ID=REF`` overrides.
1766
+ unwrapped_schema: Persisted unwrap schema, or an empty mapping.
1767
+
1768
+ Returns:
1769
+ A JSON-serialisable identity payload. Hash it with
1770
+ :func:`_stable_payload_hash`; render it with
1771
+ :func:`_task_identity_label` for diagnostics.
1772
+
1773
+ Raises:
1774
+ CompileError: see :func:`_local_from_python_payload` and
1775
+ :func:`_logical_module_id`.
1776
+ """
1777
+ # ``__qualname__`` is forwarded onto the ref by @task. It distinguishes
1778
+ # same-named functions nested in different scopes within ONE module;
1779
+ # it equals the function name for the module-level authoring surface.
1780
+ qualname = getattr(ref, "__qualname__", None) or ref._task_function_name
1781
+ generation = _local_from_python_payload(
1782
+ ref,
1783
+ source=source,
1784
+ components_yaml_dir=identity_root,
1785
+ image_overrides=image_overrides,
1786
+ unwrapped_schema=unwrapped_schema,
1787
+ )
1788
+ # Normalise fields whose OMISSION is defined to mean a specific value, so
1789
+ # two spellings of one component do not hash apart. Only ``mode`` has such
1790
+ # an implicit default in the emitted contract: the hydrator reads
1791
+ # ``gen_config.get("mode", "inline")`` and ``CallableRef`` generates with
1792
+ # ``self._task_mode or "inline"``, so ``@task()`` and ``@task(mode="inline")``
1793
+ # regenerate byte-identical components.
1794
+ #
1795
+ # Audited and deliberately NOT normalised:
1796
+ # * ``dependencies_from`` — omission means "auto-discover next to the
1797
+ # source AT HYDRATE TIME". Compile-time discovery could disagree with the
1798
+ # hydrate-time layout, so an explicit path is not provably the same
1799
+ # component as an omission.
1800
+ # * ``resolve_root`` — not inert in inline mode: ``component_from_func``
1801
+ # emits a ``tangle_cli_generation_resolve_root`` annotation whenever it is
1802
+ # set, so it changes the generated component regardless of mode.
1803
+ # * ``image`` — omission means "whatever the generator defaults to", which
1804
+ # is not a value this layer can canonicalise.
1805
+ generation["mode"] = generation.get("mode") or "inline"
1806
+ return {
1807
+ "module": _logical_module_id(source, identity_root=identity_root),
1808
+ "qualname": qualname,
1809
+ "function": ref._task_function_name,
1810
+ # Generation config, anchored at the stable project root. This mirrors
1811
+ # the emitted block field-for-field so a new generation-affecting
1812
+ # option cannot be added to the sidecar without also splitting dedup.
1813
+ "generation": generation,
1814
+ }
1815
+
1816
+
1817
+ def _task_identity_label(identity_payload: Mapping[str, Any]) -> str:
1818
+ """Human-readable ``<module>::<qualname>`` label for an identity payload.
1819
+
1820
+ Args:
1821
+ identity_payload: A :func:`_task_component_identity` payload.
1822
+
1823
+ Returns:
1824
+ A diagnostic label. Only the logical module/function identity is
1825
+ rendered — never image, path, or credential-bearing generation values.
1826
+ """
1827
+ module = str(identity_payload.get("module", ""))
1828
+ _root, _sep, dotted = module.partition(":")
1829
+ qualname = identity_payload.get("qualname") or identity_payload.get("function")
1830
+ return f"{dotted or module}::{qualname}"
1831
+
1832
+
1833
+ def _plan_task_sidecar(
1534
1834
  task_refs: list[tuple[str, CallableRef]],
1535
1835
  *,
1536
1836
  components_yaml_dir: Path,
1837
+ identity_root: Path,
1537
1838
  image_overrides: Mapping[str, str] | None = None,
1538
1839
  unwrapped_input_keys: Mapping[str, dict[str, list[str]]] | None = None,
1539
- ) -> dict[str, Any]:
1540
- """Build the ``<stem>.components.yaml`` content for @task refs.
1840
+ ) -> TaskSidecarPlan:
1841
+ """Plan the ``<stem>.components.yaml`` entries and per-task fragments.
1842
+
1843
+ Dedup is by GENERATED COMPONENT IDENTITY — module-qualified function
1844
+ identity plus every generation-affecting option, with implicit defaults
1845
+ canonicalised (``@task()`` and ``@task(mode="inline")`` are ONE component)
1846
+ — never by bare function name. Two call sites collapse into one entry only when the whole
1847
+ :func:`_task_component_identity` payload matches: logical module namespace,
1848
+ ``__qualname__``, function name, image (explicit or resolved ``image_id``),
1849
+ mode, resolve_root, dependencies_from, the persisted unwrap schema, and the
1850
+ source file. Consequently:
1851
+
1852
+ * A shared helper such as ``run_dbt`` invoked with different task-level
1853
+ images or dependency files emits one entry PER distinct configuration,
1854
+ and each graph task is rewritten to its OWN fragment instead of silently
1855
+ inheriting the first call site's component.
1856
+ * ``pkg_a/tasks.py::run`` and ``pkg_b/tasks.py::run`` are different
1857
+ components and both get emitted. This is NOT an error: the bare function
1858
+ name is not an identity, so two packages may each define ``run``.
1859
+ * Repeated identical calls still dedup to exactly one entry.
1860
+
1861
+ Fragment naming:
1862
+
1863
+ * Exactly ONE identity for a function's readable base -> the legacy
1864
+ fragment (``run_dbt`` -> ``run-dbt``, or ``combine--<schema-hash>`` for
1865
+ an unwrapped task). Existing single-component pipelines keep
1866
+ byte-identical sidecars.
1867
+ * SEVERAL identities sharing one base -> ``<base>--<identity>`` for EVERY
1868
+ colliding variant; no arbitrary "first" variant keeps the unsuffixed
1869
+ name. ``identity`` is a SHA-256 prefix over the canonical JSON identity
1870
+ payload, so it is content-addressed (never :func:`hash`, never
1871
+ dict/API/trace ordering), stable across processes, invariant under
1872
+ project relocation and output-directory changes, and it never leaks
1873
+ image, path, or credential-bearing values into the fragment name.
1874
+ Colliding variants are also EMITTED in sorted fragment order so the
1875
+ sidecar text does not depend on which variant was traced first.
1541
1876
 
1542
1877
  Args:
1543
1878
  task_refs: Traced ``(task_id, CallableRef)`` records for ``@task``
1544
1879
  calls in the pipeline graph.
1545
1880
  components_yaml_dir: Directory where the generated sidecar will live;
1546
- local paths are written relative to this directory.
1881
+ emitted local paths are written relative to this directory.
1882
+ identity_root: Stable project anchor for path-derived IDENTITY parts —
1883
+ the pipeline's OWN source directory. Required (not defaulted to
1884
+ ``components_yaml_dir``) because anchoring identity at the output
1885
+ directory would make fragment names change when the same pipeline
1886
+ is compiled elsewhere, e.g. into the hidden submit bundle.
1547
1887
  image_overrides: Optional compile-time ``--image ID=REF`` overrides.
1548
1888
  unwrapped_input_keys: Optional trace metadata for ``@task(unwrap=...)``
1549
- calls. When present, this function writes
1550
- ``local_from_python.unwrapped_inputs`` so hydrate can regenerate the
1551
- exact same flattened input schema without call-site context.
1889
+ calls. When present, ``local_from_python.unwrapped_inputs`` is
1890
+ written so hydrate regenerates the same flattened input schema
1891
+ without call-site context.
1552
1892
 
1553
1893
  Returns:
1554
- An ordered map ``{fragment: {name?, local_from_python:
1555
- {image?, function, mode?, resolve_root?, dependencies_from?, file}}}``, DEDUPED by FUNCTION
1556
- (the fragment = hyphenated function name). The SAME @task function
1557
- called from multiple task sites collapses to one entry; TWO DISTINCT
1558
- @task functions defined in ONE file each get their own entry (they
1559
- share the same ``file:`` but carry different ``function:`` keys and
1560
- distinct fragments). This matches how ``_rewrite_task_componentref_urls``
1561
- points each task at its OWN function fragment — deduping by source path
1562
- instead would drop every function but the first and leave the others'
1563
- ``resolve://...#<fragment>`` refs dangling.
1564
-
1565
- The ``function`` field is always emitted so hydrate's
1566
- ``regenerate_yaml`` extracts the right function: it otherwise defaults
1567
- to the file STEM, which is wrong whenever the @task function name
1568
- differs from the source filename (the common case).
1569
-
1570
- Paths in ``local_from_python.{file,dependencies_from}`` are POSIX and
1571
- relative to ``components_yaml_dir`` so the sidecar is portable: as
1572
- long as the layout under that directory matches at compile- and
1573
- hydrate-time, the paths resolve correctly.
1894
+ A :class:`TaskSidecarPlan`. Paths in
1895
+ ``local_from_python.{file,dependencies_from,resolve_root}`` are POSIX
1896
+ and relative to ``components_yaml_dir`` so the bundle stays portable.
1897
+
1898
+ The component ``name`` is NOT emitted. A top-level ``name`` on a
1899
+ resolve entry means "resolve a published component by this name" to the
1900
+ hydrator (``PipelineHydrator._resolve_primary``), which would let a
1901
+ same-named library component silently win over this local ``@task``.
1902
+ The component's name comes from its source docstring
1903
+ (``Metadata: Name:``) at hydrate time, read by ``regenerate_yaml``.
1574
1904
 
1575
1905
  Raises:
1576
- CompileError: when two distinct source files map to the same
1577
- fragment (function-name collision), when a referenced local
1578
- file (the @task source or its ``dependencies_from``) is
1579
- unreachable, or when a relative path cannot be formed (see
1580
- :func:`_relpath_posix`).
1906
+ CompileError: when a referenced local file (the ``@task`` source or its
1907
+ ``dependencies_from`` / ``resolve_root``) is unreachable, or when a
1908
+ relative path cannot be formed (see :func:`_relpath_posix`).
1581
1909
  """
1582
- seen_fragments: dict[str, Path] = {}
1583
- entries: dict[str, Any] = {}
1910
+ checked_sources: set[Path] = set()
1911
+ # base fragment -> identity digest -> (emitted payload, identity payload).
1912
+ variants: dict[str, dict[str, tuple[dict[str, Any], dict[str, Any]]]] = {}
1913
+ # (base, identity digest) -> the legacy single-variant fragment.
1914
+ legacy_fragments: dict[tuple[str, str], str] = {}
1915
+ task_identities: list[tuple[str, str, str]] = []
1916
+
1584
1917
  for task_id, ref in task_refs:
1585
1918
  source = ref._task_source_path
1586
1919
  if source is None: # defensive — only @task refs are recorded here
1587
1920
  continue
1588
- unwrapped_schema = _unwrapped_schema_for_task(ref, task_id, unwrapped_input_keys)
1589
- fragment = _fragment_for_task(ref, unwrapped_schema)
1590
-
1591
- prior_source = seen_fragments.get(fragment)
1592
- if prior_source is not None:
1593
- # Already emitted this fragment. Fine when it is the SAME source
1594
- # (the same @task called from multiple sites). A DIFFERENT
1595
- # source sharing the function name would silently collide on the
1596
- # resolve:// fragment, so reject it loudly.
1597
- if prior_source != source:
1921
+ if source not in checked_sources:
1922
+ if not source.exists():
1598
1923
  raise CompileError(
1599
- "two distinct @task source files map to the same sidecar "
1600
- f"fragment {fragment!r}: {prior_source} and {source}. "
1601
- "Rename one of the @task functions so each has a unique "
1602
- "name (the function name becomes the resolve:// fragment)."
1924
+ f"@task source file is unreachable: {source}. Compile into "
1925
+ "the pipeline source directory, or reference the component "
1926
+ "with an absolute file://, gs://, or resolve:// URL."
1603
1927
  )
1604
- continue
1928
+ checked_sources.add(source)
1605
1929
 
1606
- if not source.exists():
1607
- raise CompileError(
1608
- f"@task source file is unreachable: {source}. Compile into "
1609
- "the pipeline source directory, or reference the component "
1610
- "with an absolute file://, gs://, or resolve:// URL."
1611
- )
1930
+ unwrapped_schema = _unwrapped_schema_for_task(ref, task_id, unwrapped_input_keys)
1931
+ base = _task_fragment_base(ref)
1932
+ emitted = _local_from_python_payload(
1933
+ ref,
1934
+ source=source,
1935
+ components_yaml_dir=components_yaml_dir,
1936
+ image_overrides=image_overrides,
1937
+ unwrapped_schema=unwrapped_schema,
1938
+ )
1939
+ identity_payload = _task_component_identity(
1940
+ ref,
1941
+ source=source,
1942
+ identity_root=identity_root,
1943
+ image_overrides=image_overrides,
1944
+ unwrapped_schema=unwrapped_schema,
1945
+ )
1946
+ identity = _stable_payload_hash(identity_payload)
1947
+ by_identity = variants.setdefault(base, {})
1948
+ previous = by_identity.get(identity)
1949
+ if previous is None:
1950
+ by_identity[identity] = (emitted, identity_payload)
1951
+ elif _emitted_form_rank(emitted) < _emitted_form_rank(previous[0]):
1952
+ # Same component, different SPELLING (e.g. one call site omits
1953
+ # ``mode`` while another writes ``mode="inline"``). Pick the
1954
+ # canonical representative rather than whichever was traced first,
1955
+ # so the sidecar text does not depend on call order.
1956
+ by_identity[identity] = (emitted, previous[1])
1957
+ legacy_fragments.setdefault((base, identity), _fragment_for_task(ref, unwrapped_schema))
1958
+ task_identities.append((task_id, base, identity))
1612
1959
 
1613
- local_from_python: dict[str, Any] = {}
1614
- if ref._task_image is not None:
1615
- local_from_python["image"] = ref._task_image
1616
- elif ref._task_image_id is not None:
1617
- resolved_image = resolve_image_id(ref._task_image_id, image_overrides)
1618
- if resolved_image is None:
1619
- raise CompileError(
1620
- f"@task image_id={ref._task_image_id!r} on function "
1621
- f"{ref._task_function_name!r} did not resolve to an image. "
1622
- f"Pass --image {ref._task_image_id}=IMAGE to `tangle sdk pipelines compile`, "
1623
- f"or register a default with register_image_id({ref._task_image_id!r}, IMAGE)."
1624
- )
1625
- local_from_python["image"] = resolved_image
1626
- # Always pin the function name. Without it the hydrator defaults
1627
- # to the file stem and extracts the wrong symbol.
1628
- assert ref._task_function_name is not None
1629
- local_from_python["function"] = ref._task_function_name
1630
- if ref._task_mode is not None:
1631
- local_from_python["mode"] = ref._task_mode
1632
- if ref._task_resolve_root is not None:
1633
- resolve_root = ref._task_resolve_root
1634
- if not resolve_root.exists():
1635
- raise CompileError(
1636
- f"@task resolve_root is unreachable: {resolve_root}. "
1637
- "Point resolve_root at an existing directory or drop it."
1638
- )
1639
- local_from_python["resolve_root"] = _relpath_posix(resolve_root, components_yaml_dir)
1640
- if ref._task_dependencies_from is not None:
1641
- deps = ref._task_dependencies_from
1642
- if not deps.exists():
1960
+ entries: dict[str, Any] = {}
1961
+ fragment_by_identity: dict[tuple[str, str], str] = {}
1962
+ identity_labels: dict[str, str] = {}
1963
+ for base, by_identity in variants.items():
1964
+ if len(by_identity) == 1:
1965
+ identity, (emitted, _identity_payload) = next(iter(by_identity.items()))
1966
+ named = [(legacy_fragments[(base, identity)], identity, emitted)]
1967
+ else:
1968
+ # EVERY colliding variant is suffixed — the trace-order "first" one
1969
+ # does not get to keep the bare name — and they are emitted in
1970
+ # sorted order so the sidecar text is call-order independent.
1971
+ named = sorted(
1972
+ (f"{base}--{identity}", identity, emitted)
1973
+ for identity, (emitted, _identity_payload) in by_identity.items()
1974
+ )
1975
+ for fragment, identity, emitted in named:
1976
+ if fragment in entries: # defensive — digests are content-addressed
1643
1977
  raise CompileError(
1644
- f"@task dependencies_from file is unreachable: {deps}. "
1645
- "Point dependencies_from at an existing file or drop it."
1978
+ f"internal error: sidecar fragment {fragment!r} was generated twice; "
1979
+ f"colliding @task identities: {identity_labels[fragment]!r} and "
1980
+ f"{_task_identity_label(by_identity[identity][1])!r}."
1646
1981
  )
1647
- local_from_python["dependencies_from"] = _relpath_posix(deps, components_yaml_dir)
1648
- if unwrapped_schema:
1649
- local_from_python["unwrapped_inputs"] = unwrapped_schema
1650
- local_from_python["file"] = _relpath_posix(source, components_yaml_dir)
1651
-
1652
- seen_fragments[fragment] = source
1653
- # The component name is NOT emitted here. A top-level ``name`` on a
1654
- # resolve entry means "resolve a published component by this name" to
1655
- # the hydrator (PipelineHydrator._resolve_primary), which would let a
1656
- # same-named library component silently win over this local @task. The
1657
- # component's name comes from its source docstring (``Metadata: Name:``)
1658
- # at hydrate time, read by regenerate_yaml.
1659
- entry: dict[str, Any] = {"local_from_python": local_from_python}
1660
- entries[fragment] = entry
1661
- return entries
1982
+ fragment_by_identity[(base, identity)] = fragment
1983
+ identity_labels[fragment] = _task_identity_label(by_identity[identity][1])
1984
+ entries[fragment] = {"local_from_python": emitted}
1985
+
1986
+ fragment_by_task = {
1987
+ task_id: fragment_by_identity[(base, identity)] for task_id, base, identity in task_identities
1988
+ }
1989
+ return TaskSidecarPlan(entries=entries, fragment_by_task=fragment_by_task)
1662
1990
 
1663
1991
 
1664
1992
  def _rewrite_task_componentref_urls(
1665
1993
  *,
1666
1994
  body_dict: dict[str, Any],
1667
- task_refs: list[tuple[str, CallableRef]],
1995
+ fragment_by_task: Mapping[str, str],
1668
1996
  components_yaml_name: str,
1669
- unwrapped_input_keys: Mapping[str, dict[str, list[str]]] | None = None,
1670
1997
  ) -> None:
1671
1998
  """Rewrite each @task task's ``componentRef`` to a pure resolve URL.
1672
1999
 
1673
2000
  Args:
1674
2001
  body_dict: Emitted dehydrated pipeline body to mutate in place.
1675
- task_refs: Traced ``(task_id, CallableRef)`` records for ``@task``
1676
- calls in the pipeline graph.
2002
+ fragment_by_task: ``TaskSidecarPlan.fragment_by_task`` — the SAME map
2003
+ used to emit the sidecar, so a task whose component differs from a
2004
+ same-function sibling points at its own fragment.
1677
2005
  components_yaml_name: Filename of the generated components sidecar.
1678
- unwrapped_input_keys: Optional trace metadata for ``@task(unwrap=...)``
1679
- calls. The same persisted schema used for sidecar emission is used
1680
- here to compute schema-hashed fragments consistently.
1681
2006
 
1682
2007
  Returns:
1683
2008
  None. ``body_dict`` is mutated so each task's componentRef becomes
1684
2009
  ``{"url": "resolve://./<components_yaml_name>#<fragment>"}``.
2010
+
2011
+ Raises:
2012
+ CompileError: when a traced ``@task`` has no matching graph task.
1685
2013
  """
1686
2014
  tasks = body_dict.get("implementation", {}).get("graph", {}).get("tasks", {})
1687
- for task_id, ref in task_refs:
1688
- unwrapped_schema = _unwrapped_schema_for_task(ref, task_id, unwrapped_input_keys)
1689
- fragment = _fragment_for_task(ref, unwrapped_schema)
2015
+ for task_id, fragment in fragment_by_task.items():
1690
2016
  url = f"resolve://./{components_yaml_name}#{fragment}"
1691
2017
  if task_id not in tasks:
1692
2018
  raise CompileError(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tangle-cli"
3
- version = "0.1.13"
3
+ version = "0.1.14"
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.13"
3
+ version = "0.1.14"
4
4
  description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
5
5
  readme = "README.md"
6
6
  authors = [