tangle-cli 0.0.1a1__py3-none-any.whl
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.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Generate Tangle component YAML files from local Python functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
# Pin the default runtime image by digest so generated component YAML is reproducible.
|
|
11
|
+
# The tag documents the Python line; the digest pins the linux/amd64 image
|
|
12
|
+
# used by Tangle execution. Authors can still pass --image to choose a
|
|
13
|
+
# different runtime explicitly.
|
|
14
|
+
DEFAULT_CONTAINER_IMAGE = "python:3.12@sha256:b8163b64b37051de76577219aa4d5e9b95dc12a2e6c8cb438793c7adb3026016"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ComponentGenerator:
|
|
18
|
+
"""Generic Python-function component generation orchestration.
|
|
19
|
+
|
|
20
|
+
The heavy Python-function introspection and YAML construction lives in
|
|
21
|
+
:mod:`tangle_cli.component_from_func`. This class owns the surrounding
|
|
22
|
+
authoring workflow: dependency discovery, output-path derivation, existing
|
|
23
|
+
image reuse, partial-output cleanup, and logging. Downstreams should
|
|
24
|
+
subclass or compose this class rather than wrapping module globals.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
default_container_image = DEFAULT_CONTAINER_IMAGE
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
logger: Any | None = None,
|
|
33
|
+
verbose: bool = False,
|
|
34
|
+
default_container_image: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.logger = logger
|
|
37
|
+
self.verbose = verbose
|
|
38
|
+
if default_container_image is not None:
|
|
39
|
+
self.default_container_image = default_container_image
|
|
40
|
+
|
|
41
|
+
def _log(self, message: str, *, err: bool = False) -> None:
|
|
42
|
+
if self.logger is not None:
|
|
43
|
+
log_method = getattr(self.logger, "error", None) if err else getattr(self.logger, "info", None)
|
|
44
|
+
if log_method is not None:
|
|
45
|
+
log_method(message)
|
|
46
|
+
return
|
|
47
|
+
if self.verbose:
|
|
48
|
+
print(message)
|
|
49
|
+
|
|
50
|
+
def find_dependencies_file(self, python_file: Path) -> Path | None:
|
|
51
|
+
"""Find a dependency file for a Python component source file.
|
|
52
|
+
|
|
53
|
+
Looks for a component-specific TOML file next to the Python file, then a
|
|
54
|
+
``pyproject.toml`` in the file's directory or up to three parent directories.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
file_dir = python_file.parent
|
|
58
|
+
file_base = python_file.stem
|
|
59
|
+
toml_variations = [
|
|
60
|
+
file_dir / f"{file_base.replace('_', '-')}.toml",
|
|
61
|
+
file_dir / f"{file_base}.toml",
|
|
62
|
+
]
|
|
63
|
+
for toml_file in toml_variations:
|
|
64
|
+
if toml_file.exists():
|
|
65
|
+
return toml_file
|
|
66
|
+
|
|
67
|
+
search_dirs = [
|
|
68
|
+
file_dir,
|
|
69
|
+
file_dir.parent,
|
|
70
|
+
file_dir.parent.parent,
|
|
71
|
+
file_dir.parent.parent.parent,
|
|
72
|
+
]
|
|
73
|
+
for search_dir in search_dirs:
|
|
74
|
+
pyproject = search_dir / "pyproject.toml"
|
|
75
|
+
if pyproject.exists():
|
|
76
|
+
return pyproject
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def determine_output_path(
|
|
80
|
+
self,
|
|
81
|
+
input_file: Path,
|
|
82
|
+
output: Path | None = None,
|
|
83
|
+
output_is_dir: bool = False,
|
|
84
|
+
use_legacy_naming: bool = False,
|
|
85
|
+
) -> Path:
|
|
86
|
+
"""Determine the YAML output path for a generated component."""
|
|
87
|
+
|
|
88
|
+
base_name = input_file.stem.replace("_", "-")
|
|
89
|
+
if output:
|
|
90
|
+
output_name = base_name + ".yaml"
|
|
91
|
+
if output.is_dir() or output_is_dir or (not output.suffix and not output.exists()):
|
|
92
|
+
return output / output_name
|
|
93
|
+
return output
|
|
94
|
+
|
|
95
|
+
if use_legacy_naming:
|
|
96
|
+
legacy_name = input_file.stem + ".component.yaml"
|
|
97
|
+
output_dir = input_file.parent / "generated"
|
|
98
|
+
return output_dir / legacy_name
|
|
99
|
+
|
|
100
|
+
return input_file.parent / (base_name + ".yaml")
|
|
101
|
+
|
|
102
|
+
def extract_image_from_yaml(self, yaml_path: Path) -> str | None:
|
|
103
|
+
"""Extract an existing component container image, if any."""
|
|
104
|
+
|
|
105
|
+
if not yaml_path.exists():
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
with yaml_path.open(encoding="utf-8") as f:
|
|
109
|
+
existing_yaml = yaml.safe_load(f)
|
|
110
|
+
impl = existing_yaml.get("implementation", {}) if isinstance(existing_yaml, dict) else {}
|
|
111
|
+
return impl.get("container", {}).get("image")
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def generate_component_yaml(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
file_path: Path,
|
|
119
|
+
output_path: Path,
|
|
120
|
+
container_image: str,
|
|
121
|
+
function_name: str | None = None,
|
|
122
|
+
dependencies_from: Path | None = None,
|
|
123
|
+
mode: Literal["inline", "bundle"] = "inline",
|
|
124
|
+
custom_name: str | None = None,
|
|
125
|
+
custom_annotations: dict[str, str] | None = None,
|
|
126
|
+
strip_code: bool = False,
|
|
127
|
+
strip_source_path: bool = False,
|
|
128
|
+
resolve_root: Path | None = None,
|
|
129
|
+
emit_generation_annotations: bool = True,
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""Generate component YAML from a Python function source file."""
|
|
132
|
+
|
|
133
|
+
from tangle_cli.component_from_func import generate_component_yaml
|
|
134
|
+
|
|
135
|
+
return generate_component_yaml(
|
|
136
|
+
file_path=file_path,
|
|
137
|
+
output_path=output_path,
|
|
138
|
+
container_image=container_image,
|
|
139
|
+
function_name=function_name,
|
|
140
|
+
dependencies_from=dependencies_from,
|
|
141
|
+
mode=mode,
|
|
142
|
+
custom_name=custom_name,
|
|
143
|
+
custom_annotations=custom_annotations,
|
|
144
|
+
strip_code=strip_code,
|
|
145
|
+
strip_source_path=strip_source_path,
|
|
146
|
+
resolve_root=resolve_root,
|
|
147
|
+
emit_generation_annotations=emit_generation_annotations,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def regenerate_yaml(
|
|
151
|
+
self,
|
|
152
|
+
python_file: Path,
|
|
153
|
+
output_path: Path | None = None,
|
|
154
|
+
function_name: str | None = None,
|
|
155
|
+
custom_name: str | None = None,
|
|
156
|
+
image: str | None = None,
|
|
157
|
+
dependencies_from: Path | None = None,
|
|
158
|
+
strip_code: bool = False,
|
|
159
|
+
strip_source_path: bool = False,
|
|
160
|
+
mode: str = "inline",
|
|
161
|
+
resolve_root: Path | None = None,
|
|
162
|
+
emit_generation_annotations: bool = True,
|
|
163
|
+
) -> bool:
|
|
164
|
+
"""Regenerate a YAML component from a Python function source file."""
|
|
165
|
+
|
|
166
|
+
if not python_file.exists():
|
|
167
|
+
self._log(f" ❌ File not found: {python_file}", err=True)
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
final_output = output_path or self.determine_output_path(python_file)
|
|
171
|
+
resolved_image = image or self.extract_image_from_yaml(final_output) or self.default_container_image
|
|
172
|
+
deps_file = dependencies_from or self.find_dependencies_file(python_file)
|
|
173
|
+
if deps_file:
|
|
174
|
+
self._log(f" Found dependencies: {deps_file}")
|
|
175
|
+
|
|
176
|
+
final_output.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
return self.run_generation(
|
|
178
|
+
python_file=python_file,
|
|
179
|
+
final_output=final_output,
|
|
180
|
+
image=resolved_image,
|
|
181
|
+
func_name=function_name,
|
|
182
|
+
deps_file=deps_file,
|
|
183
|
+
custom_name=custom_name,
|
|
184
|
+
strip_code=strip_code,
|
|
185
|
+
strip_source_path=strip_source_path,
|
|
186
|
+
mode=mode,
|
|
187
|
+
resolve_root=resolve_root,
|
|
188
|
+
emit_generation_annotations=emit_generation_annotations,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def run_generation(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
python_file: Path,
|
|
195
|
+
final_output: Path,
|
|
196
|
+
image: str,
|
|
197
|
+
func_name: str | None,
|
|
198
|
+
deps_file: Path | None,
|
|
199
|
+
custom_name: str | None,
|
|
200
|
+
strip_code: bool,
|
|
201
|
+
strip_source_path: bool,
|
|
202
|
+
mode: str = "inline",
|
|
203
|
+
resolve_root: Path | None = None,
|
|
204
|
+
emit_generation_annotations: bool = True,
|
|
205
|
+
) -> bool:
|
|
206
|
+
"""Execute component generation and clean up partial output on failure."""
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
function_detail = f" function {func_name!r}" if func_name else ""
|
|
210
|
+
self._log(f" Generating component from {python_file.name}{function_detail}...")
|
|
211
|
+
success = self.generate_component_yaml(
|
|
212
|
+
file_path=python_file,
|
|
213
|
+
output_path=final_output,
|
|
214
|
+
container_image=image,
|
|
215
|
+
function_name=func_name,
|
|
216
|
+
dependencies_from=deps_file,
|
|
217
|
+
mode=mode, # type: ignore[arg-type]
|
|
218
|
+
custom_name=custom_name,
|
|
219
|
+
strip_code=strip_code,
|
|
220
|
+
strip_source_path=strip_source_path,
|
|
221
|
+
resolve_root=resolve_root,
|
|
222
|
+
emit_generation_annotations=emit_generation_annotations,
|
|
223
|
+
)
|
|
224
|
+
if not success:
|
|
225
|
+
self._log(" ❌ Failed to generate component", err=True)
|
|
226
|
+
return False
|
|
227
|
+
self._log(f" ✅ Generated: {final_output}")
|
|
228
|
+
return True
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
if exc.__class__.__name__ == "AuthoringStripError":
|
|
231
|
+
if final_output.exists():
|
|
232
|
+
final_output.unlink()
|
|
233
|
+
raise
|
|
234
|
+
self._log(f" ❌ Error: {exc}", err=True)
|
|
235
|
+
if final_output.exists():
|
|
236
|
+
final_output.unlink()
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def find_dependencies_file(python_file: Path) -> Path | None:
|
|
241
|
+
"""Find a dependency file for a Python component source file."""
|
|
242
|
+
|
|
243
|
+
return ComponentGenerator().find_dependencies_file(python_file)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def determine_output_path(
|
|
247
|
+
input_file: Path,
|
|
248
|
+
output: Path | None = None,
|
|
249
|
+
output_is_dir: bool = False,
|
|
250
|
+
use_legacy_naming: bool = False,
|
|
251
|
+
) -> Path:
|
|
252
|
+
"""Determine the YAML output path for a generated component."""
|
|
253
|
+
|
|
254
|
+
return ComponentGenerator().determine_output_path(
|
|
255
|
+
input_file,
|
|
256
|
+
output,
|
|
257
|
+
output_is_dir,
|
|
258
|
+
use_legacy_naming,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def regenerate_yaml(
|
|
263
|
+
python_file: Path,
|
|
264
|
+
output_path: Path | None = None,
|
|
265
|
+
function_name: str | None = None,
|
|
266
|
+
custom_name: str | None = None,
|
|
267
|
+
image: str | None = None,
|
|
268
|
+
dependencies_from: Path | None = None,
|
|
269
|
+
strip_code: bool = False,
|
|
270
|
+
strip_source_path: bool = False,
|
|
271
|
+
verbose: bool = False,
|
|
272
|
+
mode: str = "inline",
|
|
273
|
+
resolve_root: Path | None = None,
|
|
274
|
+
logger: Any | None = None,
|
|
275
|
+
) -> bool:
|
|
276
|
+
"""Regenerate a YAML component from a Python function source file."""
|
|
277
|
+
|
|
278
|
+
return ComponentGenerator(logger=logger, verbose=verbose).regenerate_yaml(
|
|
279
|
+
python_file=python_file,
|
|
280
|
+
output_path=output_path,
|
|
281
|
+
function_name=function_name,
|
|
282
|
+
custom_name=custom_name,
|
|
283
|
+
image=image,
|
|
284
|
+
dependencies_from=dependencies_from,
|
|
285
|
+
strip_code=strip_code,
|
|
286
|
+
strip_source_path=strip_source_path,
|
|
287
|
+
mode=mode,
|
|
288
|
+
resolve_root=resolve_root,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
__all__ = [
|
|
293
|
+
"ComponentGenerator",
|
|
294
|
+
"DEFAULT_CONTAINER_IMAGE",
|
|
295
|
+
"determine_output_path",
|
|
296
|
+
"find_dependencies_file",
|
|
297
|
+
"regenerate_yaml",
|
|
298
|
+
]
|