tangle-cli 0.1.16__tar.gz → 0.1.17__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 (104) hide show
  1. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/PKG-INFO +56 -1
  2. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/README.md +55 -0
  3. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/__init__.py +1 -1
  4. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/args_container.py +22 -7
  5. tangle_cli-0.1.17/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +203 -0
  6. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/pyproject.toml +1 -1
  7. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/pyproject.toml.orig +1 -1
  8. tangle_cli-0.1.16/packages/tangle-cli/src/tangle_cli/python_pipeline/task_env.py +0 -88
  9. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/api_cli.py +0 -0
  10. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/api_schema.py +0 -0
  11. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/api_transport.py +0 -0
  12. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/artifacts.py +0 -0
  13. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/artifacts_cli.py +0 -0
  14. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/authenticated_identity.py +0 -0
  15. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/cli.py +0 -0
  16. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/cli_helpers.py +0 -0
  17. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/cli_options.py +0 -0
  18. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/client.py +0 -0
  19. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_from_func.py +0 -0
  20. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_generator.py +0 -0
  21. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_inspector.py +0 -0
  22. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/component_publisher.py +0 -0
  23. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/components_cli.py +0 -0
  24. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/dynamic_discovery_client.py +0 -0
  25. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/generated_model_extensions.py +0 -0
  26. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/handler.py +0 -0
  27. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/hydration_trust.py +0 -0
  28. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/logger.py +0 -0
  29. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/models.py +0 -0
  30. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/module_bundler.py +0 -0
  31. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/openapi/__init__.py +0 -0
  32. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +0 -0
  33. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/openapi/parser.py +0 -0
  34. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_compiler.py +0 -0
  35. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_dehydrator.py +0 -0
  36. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_hydrator.py +0 -0
  37. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_annotations.py +0 -0
  38. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_details.py +0 -0
  39. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py +0 -0
  40. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_run_search.py +0 -0
  41. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_runner.py +0 -0
  42. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py +0 -0
  43. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_spec_utils.py +0 -0
  44. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipeline_validation.py +0 -0
  45. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipelines.py +0 -0
  46. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/pipelines_cli.py +0 -0
  47. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/published_components_cli.py +0 -0
  48. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/py.typed +0 -0
  49. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/__init__.py +0 -0
  50. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/cfg.py +0 -0
  51. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/compiler_context.py +0 -0
  52. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/dynamic_data.py +0 -0
  53. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/emit.py +0 -0
  54. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/errors.py +0 -0
  55. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/graph.py +0 -0
  56. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/ids.py +0 -0
  57. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/pipeline.py +0 -0
  58. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/placeholders.py +0 -0
  59. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/publish.py +0 -0
  60. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/raw.py +0 -0
  61. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/ref.py +0 -0
  62. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/registered.py +0 -0
  63. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/subpipeline.py +0 -0
  64. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/task.py +0 -0
  65. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/trace.py +0 -0
  66. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/python_pipeline/types.py +0 -0
  67. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/quickstart.py +0 -0
  68. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schema_validation.py +0 -0
  69. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schemas/__init__.py +0 -0
  70. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schemas/dehydrated_pipeline_schema.json +0 -0
  71. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/schemas/pipeline_schema.json +0 -0
  72. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/secrets.py +0 -0
  73. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/secrets_cli.py +0 -0
  74. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/utils.py +0 -0
  75. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/packages/tangle-cli/src/tangle_cli/version_manager.py +0 -0
  76. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/OSS-CONVENTIONS.md +0 -0
  77. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/PORT-README.md +0 -0
  78. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/SKILL.md +0 -0
  79. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/agents/auth-wizard.md +0 -0
  80. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/agents/builder.md +0 -0
  81. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/agents/debugger.md +0 -0
  82. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/agents/reporter.md +0 -0
  83. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/agents/researcher.md +0 -0
  84. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/agents/reviewer.md +0 -0
  85. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/agents/scenario-builder.md +0 -0
  86. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/data-sources.md +0 -0
  87. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/event-log.md +0 -0
  88. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/example-scenarios/01-mslr-ranking.md +0 -0
  89. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/example-scenarios/02-text-classification.md +0 -0
  90. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/example-scenarios/INDEX.md +0 -0
  91. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/iterating-on-runs.md +0 -0
  92. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/knowledge-corpus.md +0 -0
  93. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/secrets.md +0 -0
  94. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/setup.md +0 -0
  95. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-0-initialize.md +0 -0
  96. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-1-analyze.md +0 -0
  97. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-2-hypothesize.md +0 -0
  98. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-3-submit.md +0 -0
  99. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-4-monitor.md +0 -0
  100. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-5-evaluate.md +0 -0
  101. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-6-synthesize.md +0 -0
  102. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/step-7-decide.md +0 -0
  103. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/skills/tangent/references/tangle-tools.md +0 -0
  104. {tangle_cli-0.1.16 → tangle_cli-0.1.17}/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.16
3
+ Version: 0.1.17
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>
@@ -640,6 +640,61 @@ def score(...):
640
640
  ...
641
641
  ```
642
642
 
643
+ Declare the environment in a config file instead with `TaskEnv.from_config(path)`. A relative `path` resolves against the **calling pipeline module**, never the working directory, so the same script selects the same config wherever `tangle` runs from:
644
+
645
+ ```python
646
+ EVAL = TaskEnv.from_config("envs.yaml") # relative to THIS file
647
+ ```
648
+
649
+ ```yaml
650
+ # envs.yaml
651
+ image: python:3.12
652
+ dependencies_from: pyproject.toml # relative to THIS config file
653
+ ```
654
+
655
+ The file is read with the same loader `--config` uses, so the `_select` directive works here identically — the case is chosen exactly and case-sensitively by an environment variable, and an unset or unmatched variable is a hard error unless an explicit `default` branch is authored (no implicit default, no implicit production):
656
+
657
+ ```yaml
658
+ _select:
659
+ env: DEPLOY_ENVIRONMENT
660
+ cases:
661
+ production:
662
+ image: registry.example/scoring:prod
663
+ staging:
664
+ image: registry.example/scoring:staging
665
+ default:
666
+ image: python:3.12
667
+ ```
668
+
669
+ The document must resolve to exactly one object, and its keys must be named parameters of the generated `__init__` of the class `from_config` was called on — `InitVar` pseudo-fields included, `ClassVar`s and `field(init=False)` excluded — so these files stay environment-only: pipeline concerns such as file paths, versioning, annotations, schedules, or subscriptions have no field to land in and are rejected with the allowed field names. `from_config` is generic — any `TaskEnv` dataclass subclass inherits it unchanged, returns its own type, and is validated against its own fields:
670
+
671
+ ```python
672
+ from tangle_cli.python_pipeline import TaskEnv
673
+
674
+ @dataclass(frozen=True)
675
+ class GpuEnv(TaskEnv):
676
+ accelerator: str = ""
677
+
678
+ def __post_init__(self):
679
+ super().__post_init__()
680
+ if self.accelerator not in ("gpu", "tpu"):
681
+ raise ValueError("GpuEnv.accelerator must be one of: gpu, tpu")
682
+
683
+ GPU = GpuEnv.from_config("envs.yaml") # accepts image, dependencies_from, accelerator
684
+ ```
685
+
686
+ Failures raise `CompileError` naming the resolved config path. A config file is untrusted input, so **no diagnostic echoes a config value**. Keys are rendered through a capped, control-character-scrubbing renderer, and a constructor's own validation text is never quoted — it could embed a value directly, nested inside a structure, or transformed (lower cased, sliced, re-encoded). The rejected exception is also kept off `__cause__`/`__context__`, since `traceback.format_exception` would otherwise print it into the same CI log. The message names the class and the fields present, and points at constructing the class directly to see the validation error:
687
+
688
+ ```
689
+ GpuEnv.from_config: /repo/envs.yaml case is not a valid GpuEnv (fields present:
690
+ accelerator, image). Its validation message is withheld because it can contain
691
+ config values; construct GpuEnv(...) directly to see it.
692
+ ```
693
+
694
+ Calling `GpuEnv(...)` directly is unaffected and raises the ordinary `ValueError` with its full message.
695
+
696
+ See `examples/python_pipeline/task_env_from_config/` for a runnable example.
697
+
643
698
  Use `@task(image_id="eval-slim")` when source should carry a logical image name instead of a concrete registry reference. Downstream code can register defaults with `register_image_id(...)`, and callers can override at compile time with repeatable `--image ID=REF`:
644
699
 
645
700
  ```bash
@@ -614,6 +614,61 @@ def score(...):
614
614
  ...
615
615
  ```
616
616
 
617
+ Declare the environment in a config file instead with `TaskEnv.from_config(path)`. A relative `path` resolves against the **calling pipeline module**, never the working directory, so the same script selects the same config wherever `tangle` runs from:
618
+
619
+ ```python
620
+ EVAL = TaskEnv.from_config("envs.yaml") # relative to THIS file
621
+ ```
622
+
623
+ ```yaml
624
+ # envs.yaml
625
+ image: python:3.12
626
+ dependencies_from: pyproject.toml # relative to THIS config file
627
+ ```
628
+
629
+ The file is read with the same loader `--config` uses, so the `_select` directive works here identically — the case is chosen exactly and case-sensitively by an environment variable, and an unset or unmatched variable is a hard error unless an explicit `default` branch is authored (no implicit default, no implicit production):
630
+
631
+ ```yaml
632
+ _select:
633
+ env: DEPLOY_ENVIRONMENT
634
+ cases:
635
+ production:
636
+ image: registry.example/scoring:prod
637
+ staging:
638
+ image: registry.example/scoring:staging
639
+ default:
640
+ image: python:3.12
641
+ ```
642
+
643
+ The document must resolve to exactly one object, and its keys must be named parameters of the generated `__init__` of the class `from_config` was called on — `InitVar` pseudo-fields included, `ClassVar`s and `field(init=False)` excluded — so these files stay environment-only: pipeline concerns such as file paths, versioning, annotations, schedules, or subscriptions have no field to land in and are rejected with the allowed field names. `from_config` is generic — any `TaskEnv` dataclass subclass inherits it unchanged, returns its own type, and is validated against its own fields:
644
+
645
+ ```python
646
+ from tangle_cli.python_pipeline import TaskEnv
647
+
648
+ @dataclass(frozen=True)
649
+ class GpuEnv(TaskEnv):
650
+ accelerator: str = ""
651
+
652
+ def __post_init__(self):
653
+ super().__post_init__()
654
+ if self.accelerator not in ("gpu", "tpu"):
655
+ raise ValueError("GpuEnv.accelerator must be one of: gpu, tpu")
656
+
657
+ GPU = GpuEnv.from_config("envs.yaml") # accepts image, dependencies_from, accelerator
658
+ ```
659
+
660
+ Failures raise `CompileError` naming the resolved config path. A config file is untrusted input, so **no diagnostic echoes a config value**. Keys are rendered through a capped, control-character-scrubbing renderer, and a constructor's own validation text is never quoted — it could embed a value directly, nested inside a structure, or transformed (lower cased, sliced, re-encoded). The rejected exception is also kept off `__cause__`/`__context__`, since `traceback.format_exception` would otherwise print it into the same CI log. The message names the class and the fields present, and points at constructing the class directly to see the validation error:
661
+
662
+ ```
663
+ GpuEnv.from_config: /repo/envs.yaml case is not a valid GpuEnv (fields present:
664
+ accelerator, image). Its validation message is withheld because it can contain
665
+ config values; construct GpuEnv(...) directly to see it.
666
+ ```
667
+
668
+ Calling `GpuEnv(...)` directly is unaffected and raises the ordinary `ValueError` with its full message.
669
+
670
+ See `examples/python_pipeline/task_env_from_config/` for a runnable example.
671
+
617
672
  Use `@task(image_id="eval-slim")` when source should carry a logical image name instead of a concrete registry reference. Downstream code can register defaults with `register_image_id(...)`, and callers can override at compile time with repeatable `--image ID=REF`:
618
673
 
619
674
  ```bash
@@ -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.16"
17
+ __version__ = "0.1.17"
18
18
 
19
19
  __all__ = ["TangleDynamicDiscoveryClient", "__version__"]
@@ -41,10 +41,17 @@ class ConfigFileError(Exception):
41
41
  """Raised when there is an error loading or resolving a config file."""
42
42
 
43
43
 
44
- def _render_case_key(key: str) -> str:
45
- """Render an already-validated configured case key for diagnostics."""
44
+ def _render_config_key(key: Any) -> str:
45
+ """Render a config-document key safely for a diagnostic message.
46
46
 
47
- rendered = "".join(char if char.isprintable() else "?" for char in key)
47
+ Config keys come from user files, so a diagnostic must never echo them
48
+ verbatim: non-printable characters are replaced and the result is length
49
+ capped before being quoted. Accepts non-string keys (YAML permits them) so
50
+ callers can report a bad key without first proving it is a string.
51
+ """
52
+
53
+ text = key if isinstance(key, str) else str(key)
54
+ rendered = "".join(char if char.isprintable() else "?" for char in text)
48
55
  if len(rendered) > _MAX_RENDERED_CASE_KEY_LENGTH:
49
56
  rendered = rendered[: _MAX_RENDERED_CASE_KEY_LENGTH - 3] + "..."
50
57
  return repr(rendered)
@@ -172,8 +179,14 @@ class ArgsContainer:
172
179
  if seen is not None and depth <= seen[0]:
173
180
  return seen[2]
174
181
 
182
+ # Every key reaching a diagnostic goes through the ONE capped/scrubbed
183
+ # renderer: these keys are user input, and an unbounded repr() would
184
+ # let a hostile document paste control characters or a wall of text
185
+ # into a compile/CI log.
175
186
  siblings = sorted(
176
- repr(key) for key in node if not (isinstance(key, str) and key.startswith("_"))
187
+ _render_config_key(key)
188
+ for key in node
189
+ if not (isinstance(key, str) and key.startswith("_"))
177
190
  )
178
191
  if siblings:
179
192
  raise ConfigFileError(
@@ -189,7 +202,9 @@ class ArgsContainer:
189
202
  selector_dict = cast(dict[Any, Any], selector)
190
203
 
191
204
  unexpected = sorted(
192
- repr(key) for key in selector_dict if key not in ("env", "cases", "default")
205
+ _render_config_key(key)
206
+ for key in selector_dict
207
+ if key not in ("env", "cases", "default")
193
208
  )
194
209
  if unexpected:
195
210
  raise ConfigFileError(
@@ -231,7 +246,7 @@ class ArgsContainer:
231
246
  # Every branch document is shape-checked, not just the selected one,
232
247
  # so a malformed selector fails identically in every environment.
233
248
  ArgsContainer._validate_branch_document(
234
- case_value, f"{SELECT_KEY} case {_render_case_key(case_key)}", depth, memo
249
+ case_value, f"{SELECT_KEY} case {_render_config_key(case_key)}", depth, memo
235
250
  )
236
251
 
237
252
  default_branch: Any = None
@@ -241,7 +256,7 @@ class ArgsContainer:
241
256
  default_branch, f"{SELECT_KEY}.default", depth, memo
242
257
  )
243
258
 
244
- allowed = ", ".join(_render_case_key(key) for key in sorted(cases_dict))
259
+ allowed = ", ".join(_render_config_key(key) for key in sorted(cases_dict))
245
260
  summary: _SelectorSummary = (env_name, cases_dict, default_branch, allowed)
246
261
  if memo is not None:
247
262
  previous = memo.get(node_id)
@@ -0,0 +1,203 @@
1
+ """``TaskEnv`` — declare image + dependencies once, reuse across ``@task``s.
2
+
3
+ A :class:`TaskEnv` bundles the container ``image`` and an optional
4
+ ``dependencies_from`` file so a pipeline author can declare the execution
5
+ environment once and reference it from many ``@task`` components via
6
+ ``@task(env=...)``. It is the Python equivalent of a ``local_from_python``
7
+ YAML anchor.
8
+
9
+ ``TaskEnv`` is **authoring-only**. ``@task(env=...)`` expands it at decoration
10
+ time into the existing ``CallableRef._task_image`` /
11
+ ``CallableRef._task_dependencies_from`` metadata, so the compiler, hydrator,
12
+ and runner never see a ``TaskEnv`` object.
13
+
14
+ Example::
15
+
16
+ from pathlib import Path
17
+ from tangle_cli.python_pipeline import TaskEnv, task
18
+
19
+ TRAINING = TaskEnv(
20
+ image="python:3.12",
21
+ dependencies_from=Path(__file__).parent / "pyproject.toml",
22
+ )
23
+
24
+ @task(env=TRAINING)
25
+ def train_model(...):
26
+ ...
27
+
28
+ An environment can also live in a config file, loaded with
29
+ :meth:`TaskEnv.from_config`, which any ``TaskEnv`` dataclass subclass
30
+ inherits unchanged::
31
+
32
+ ENV = TaskEnv.from_config("tangle/envs.yaml") # relative to THIS file
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import inspect
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import TypeVar
40
+
41
+ _TaskEnvT = TypeVar("_TaskEnvT", bound="TaskEnv")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TaskEnv:
46
+ """Reusable execution environment for ``@task`` components.
47
+
48
+ Attributes:
49
+ image: Container image for the component. Required.
50
+ dependencies_from: Optional path to a ``pyproject.toml`` (or any file
51
+ the hydrator understands) declaring pip dependencies. A relative
52
+ path is resolved at the ``TaskEnv`` *definition site*, so a shared
53
+ ``_envs.py`` resolves intuitively; pass an absolute ``Path`` to
54
+ avoid that. When omitted, the hydrator's existing dependency
55
+ discovery still applies.
56
+ """
57
+
58
+ image: str
59
+ dependencies_from: str | Path | None = None
60
+
61
+ def __post_init__(self) -> None:
62
+ if not isinstance(self.image, str) or not self.image:
63
+ raise ValueError("TaskEnv.image must be a non-empty string")
64
+ if self.dependencies_from is None:
65
+ return
66
+
67
+ p = Path(self.dependencies_from)
68
+ 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
79
+ # Frozen dataclass: bypass __setattr__ to store the resolved Path.
80
+ object.__setattr__(self, "dependencies_from", p.resolve())
81
+
82
+ @classmethod
83
+ def from_config(cls: type[_TaskEnvT], path: str | Path) -> _TaskEnvT:
84
+ """Build the env from a config file, returning an instance of ``cls``.
85
+
86
+ Every ``TaskEnv`` dataclass subclass inherits this and is validated
87
+ against its own fields; nothing here is specific to one kind of
88
+ environment.
89
+
90
+ A relative ``path`` resolves against the **calling file's** directory,
91
+ never the working directory, so a pipeline script selects the same
92
+ config wherever the CLI is run from. Call this directly on the class —
93
+ a subclass that wraps or delegates to it is not supported.
94
+
95
+ The file is read with the loader ``--config`` uses, so its ``_select``
96
+ directive behaves identically here, and it must resolve to exactly one
97
+ object. The object's keys must be named parameters of ``cls``'s
98
+ ``__init__``, which keeps an environment config environment-only:
99
+ pipeline concerns such as versioning or schedules have no field to land
100
+ in. A relative ``dependencies_from`` inside the object is anchored to
101
+ the config file's directory rather than to this module.
102
+
103
+ Raises:
104
+ CompileError: for every failure — a missing file, an unresolvable
105
+ relative path, a load or ``_select`` error, a multi-object
106
+ document, an unknown or missing field, or a value the subclass
107
+ rejects.
108
+
109
+ Note:
110
+ A config file is untrusted input, so no diagnostic raised here
111
+ echoes a config *value* — not in the message, and not through
112
+ ``__cause__``/``__context__``, which a rendered traceback would
113
+ print into the same CI log. Construct ``cls(...)`` directly to see
114
+ a constructor's own validation message.
115
+ """
116
+
117
+ # Lazy import: the authoring surface should not pay for the YAML/CLI
118
+ # config stack unless a pipeline actually loads an env from a file.
119
+ from tangle_cli.args_container import (
120
+ SELECT_KEY,
121
+ ArgsContainer,
122
+ ConfigFileError,
123
+ _render_config_key,
124
+ )
125
+ from tangle_cli.python_pipeline.errors import CompileError
126
+
127
+ label = f"{cls.__name__}.from_config"
128
+ config_path = Path(path)
129
+ if not config_path.is_absolute():
130
+ frame = inspect.currentframe()
131
+ caller = frame.f_back if frame is not None else None
132
+ filename = caller.f_globals.get("__file__") if caller is not None else None
133
+ if not isinstance(filename, str) or not filename:
134
+ raise CompileError(
135
+ f"{label}({str(path)!r}): a relative path resolves against the "
136
+ "calling file, which could not be determined here — pass an "
137
+ "absolute path."
138
+ )
139
+ config_path = (Path(filename).resolve().parent / config_path).resolve()
140
+ if not config_path.exists():
141
+ raise CompileError(
142
+ f"{label}: {config_path} does not exist. Declare the environment "
143
+ f"there (optionally a {SELECT_KEY} over an environment variable, "
144
+ f"with a default case for local runs), or construct "
145
+ f"{cls.__name__}(...) directly."
146
+ )
147
+
148
+ try:
149
+ documents = ArgsContainer._load_config_file(config_path)
150
+ except ConfigFileError as exc:
151
+ raise CompileError(f"{label} could not resolve {config_path}: {exc}") from exc
152
+ if len(documents) != 1 or not isinstance(documents[0], dict):
153
+ raise CompileError(
154
+ f"{label}: {config_path} must resolve to ONE environment object "
155
+ f"(one {SELECT_KEY} case), got a multi-config document."
156
+ )
157
+
158
+ case = dict(documents[0])
159
+ # The signature, not dataclasses.fields(): it is what cls(**case)
160
+ # actually accepts — InitVar pseudo-fields in, ClassVar and
161
+ # field(init=False) out. *args/**kwargs are excluded so a typo stays
162
+ # fail-closed.
163
+ parameters = {
164
+ name: parameter
165
+ for name, parameter in inspect.signature(cls).parameters.items()
166
+ if parameter.kind
167
+ in (parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY)
168
+ }
169
+ unknown = sorted(_render_config_key(key) for key in set(case) - set(parameters))
170
+ if unknown:
171
+ raise CompileError(
172
+ f"{label}: {config_path} case has unknown field(s) "
173
+ f"{', '.join(unknown)}. Allowed fields: {', '.join(parameters)}."
174
+ )
175
+ missing = sorted(
176
+ name
177
+ for name, parameter in parameters.items()
178
+ if parameter.default is parameter.empty and name not in case
179
+ )
180
+ if missing:
181
+ raise CompileError(
182
+ f"{label}: {config_path} case is missing required field(s): "
183
+ f"{', '.join(missing)}."
184
+ )
185
+
186
+ dependencies = case.get("dependencies_from")
187
+ if isinstance(dependencies, (str, Path)) and not Path(dependencies).is_absolute():
188
+ case["dependencies_from"] = (config_path.parent / Path(dependencies)).resolve()
189
+
190
+ try:
191
+ return cls(**case)
192
+ except (TypeError, ValueError):
193
+ # Re-raised below, OUTSIDE this handler: the constructor's message
194
+ # may quote a config value, and `from exc` would publish it on
195
+ # __cause__ while a raise in-handler would publish it on
196
+ # __context__ — both get printed by traceback.format_exception.
197
+ pass
198
+ raise CompileError(
199
+ f"{label}: {config_path} case is not a valid {cls.__name__} "
200
+ f"(fields present: {', '.join(sorted(case))}). Its validation "
201
+ f"message is withheld because it can contain config values; "
202
+ f"construct {cls.__name__}(...) directly to see it."
203
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tangle-cli"
3
- version = "0.1.16"
3
+ version = "0.1.17"
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.16"
3
+ version = "0.1.17"
4
4
  description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,88 +0,0 @@
1
- """``TaskEnv`` — declare image + dependencies once, reuse across ``@task``s.
2
-
3
- A :class:`TaskEnv` bundles the container ``image`` and an optional
4
- ``dependencies_from`` file so a pipeline author can declare the execution
5
- environment once and reference it from many ``@task`` components via
6
- ``@task(env=...)``. It is the Python equivalent of a ``local_from_python``
7
- YAML anchor.
8
-
9
- ``TaskEnv`` is **authoring-only**. ``@task(env=...)`` expands it at
10
- decoration time into the existing ``CallableRef._task_image`` /
11
- ``CallableRef._task_dependencies_from`` metadata. The compiler, hydrator,
12
- and downstream runner never see a ``TaskEnv`` object — no downstream
13
- component learns the word ``env``.
14
-
15
- Example::
16
-
17
- from pathlib import Path
18
- from tangle_cli.python_pipeline import TaskEnv, task
19
-
20
- TRAINING = TaskEnv(
21
- image="python:3.12",
22
- dependencies_from=Path(__file__).parent / "pyproject.toml",
23
- )
24
-
25
- @task(env=TRAINING)
26
- def train_model(...):
27
- # docstring carries: Metadata / Name: Train Model
28
- ...
29
-
30
- The component name comes from the function's docstring ``Metadata: Name:``
31
- block (auto-derived from the function name if absent); the pipeline block name
32
- (task id) comes from the call-site variable name, or ``.named("Block Name")``
33
- for an explicit label.
34
- """
35
- from __future__ import annotations
36
-
37
- import inspect
38
- from dataclasses import dataclass
39
- from pathlib import Path
40
-
41
-
42
- @dataclass(frozen=True)
43
- class TaskEnv:
44
- """Reusable execution environment for ``@task`` components.
45
-
46
- Bundles the container image and optional dependencies file so a
47
- pipeline author can declare them once and reference the env from many
48
- tasks. This is the Python equivalent of a ``local_from_python`` YAML
49
- anchor.
50
-
51
- Attributes:
52
- image: Container image for the component. Required — naming the
53
- image once is the main point of ``TaskEnv``.
54
- dependencies_from: Optional path to a ``pyproject.toml`` (or any
55
- file the hydrator understands) declaring pip
56
- dependencies. A relative path is resolved at the ``TaskEnv``
57
- *definition site* (the module where ``TaskEnv(...)`` is
58
- written), so a shared ``_envs.py`` resolves intuitively.
59
- Authors can pass an absolute ``Path`` to avoid frame-based
60
- ambiguity. When omitted, the existing hydrator/generator
61
- dependency discovery still applies.
62
- """
63
-
64
- image: str
65
- dependencies_from: str | Path | None = None
66
-
67
- def __post_init__(self) -> None:
68
- if not isinstance(self.image, str) or not self.image:
69
- raise ValueError("TaskEnv.image must be a non-empty string")
70
- if self.dependencies_from is None:
71
- return
72
-
73
- p = Path(self.dependencies_from)
74
- if not p.is_absolute():
75
- # Resolve a relative ``dependencies_from`` at the TaskEnv
76
- # DEFINITION SITE. Walk frames: __post_init__ -> generated
77
- # dataclass __init__ -> the caller that wrote ``TaskEnv(...)``.
78
- frame = inspect.currentframe()
79
- caller = (
80
- frame.f_back.f_back
81
- if frame and frame.f_back and frame.f_back.f_back
82
- else None
83
- )
84
- filename = caller.f_globals.get("__file__") if caller else None
85
- caller_dir = Path(filename).resolve().parent if filename else Path.cwd()
86
- p = caller_dir / p
87
- # Frozen dataclass: bypass __setattr__ to store the resolved Path.
88
- object.__setattr__(self, "dependencies_from", p.resolve())