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.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,470 @@
1
+ """Version bumping for Tangle component YAML and Python source files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from collections.abc import Callable
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from tangle_cli import utils
14
+ from tangle_cli.component_from_func import extract_file_metadata, find_function_in_source
15
+ from tangle_cli.component_generator import ComponentGenerator
16
+ from tangle_cli.logger import Logger, get_default_logger
17
+
18
+ ReferenceContentGetter = Callable[[str], str | None]
19
+
20
+
21
+ class VersionManager:
22
+ """Manage version updates for component YAML and Python source files."""
23
+
24
+ def __init__(self, logger: Logger | None = None) -> None:
25
+ self.log = logger or get_default_logger()
26
+
27
+ def parse_version(self, version_str: str) -> tuple[int, ...]:
28
+ """Parse a major/minor[/patch] version string into integer parts."""
29
+
30
+ parts = str(version_str).strip().strip("\"'").split(".")
31
+ if len(parts) == 1:
32
+ return (int(parts[0]), 0)
33
+ if len(parts) == 2:
34
+ return (int(parts[0]), int(parts[1]))
35
+ return (int(parts[0]), int(parts[1]), int(parts[2]))
36
+
37
+ def increment_version(self, version_str: str) -> str:
38
+ """Increment patch for x.y.z versions, otherwise increment minor."""
39
+
40
+ parts = self.parse_version(version_str)
41
+ if len(parts) == 3:
42
+ return f"{parts[0]}.{parts[1]}.{parts[2] + 1}"
43
+ return f"{parts[0]}.{parts[1] + 1}"
44
+
45
+ def _get_yaml_version(self, content: str) -> str | None:
46
+ try:
47
+ data = yaml.safe_load(content)
48
+ return utils.get_version_from_data(data)
49
+ except Exception:
50
+ return None
51
+
52
+ def update_yaml_file(
53
+ self,
54
+ file_path: str,
55
+ new_version: str | None = None,
56
+ reference_content_getter: ReferenceContentGetter | None = None,
57
+ update_timestamp: bool = False,
58
+ ) -> bool:
59
+ """Update version metadata in a YAML component file."""
60
+
61
+ with open(file_path, encoding="utf-8") as f:
62
+ content = f.read()
63
+ data = yaml.safe_load(content) or {}
64
+ old_version = utils.get_version_from_data(data)
65
+
66
+ if new_version is None:
67
+ ref_version = None
68
+ if reference_content_getter:
69
+ ref_content = reference_content_getter(file_path)
70
+ if ref_content:
71
+ ref_version = self._get_yaml_version(ref_content)
72
+ if ref_version:
73
+ new_version = self.increment_version(ref_version)
74
+ self.log.info(f" 📊 Reference version: {ref_version} → bumping to {new_version}")
75
+ if new_version is None:
76
+ if old_version:
77
+ new_version = self.increment_version(old_version)
78
+ self.log.info(f" 📊 Local version: {old_version} → bumping to {new_version}")
79
+ else:
80
+ new_version = "0.1"
81
+ self.log.info(" 📝 No existing version - using 0.1")
82
+ else:
83
+ parts = self.parse_version(new_version)
84
+ new_version = ".".join(str(part) for part in parts)
85
+
86
+ self.log.info(f" {Path(file_path).name}:")
87
+ self.log.info(f" Current version: {old_version or 'none'}")
88
+ self.log.info(f" New version: {new_version}")
89
+
90
+ if not isinstance(data, dict):
91
+ self.log.warn(" ⚠️ Could not update YAML - root value is not a mapping")
92
+ return False
93
+
94
+ if "metadata" not in data or data["metadata"] is None:
95
+ data["metadata"] = {}
96
+ if not isinstance(data["metadata"], dict):
97
+ self.log.warn(" ⚠️ Could not update YAML - metadata is not a mapping")
98
+ return False
99
+ if "annotations" not in data["metadata"] or data["metadata"]["annotations"] is None:
100
+ data["metadata"]["annotations"] = {}
101
+ if not isinstance(data["metadata"]["annotations"], dict):
102
+ self.log.warn(" ⚠️ Could not update YAML - metadata.annotations is not a mapping")
103
+ return False
104
+
105
+ annotations = data["metadata"]["annotations"]
106
+ annotations["version"] = new_version
107
+ data.pop("version", None)
108
+ if update_timestamp:
109
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
110
+ self.log.info(f" Timestamp: {timestamp}")
111
+ annotations["updated_at"] = timestamp
112
+ else:
113
+ existing_timestamp = data.get("updated_at") or annotations.get("updated_at")
114
+ if existing_timestamp:
115
+ annotations["updated_at"] = existing_timestamp
116
+ data.pop("updated_at", None)
117
+ with open(file_path, "w", encoding="utf-8") as f:
118
+ f.write(utils.dump_yaml(data))
119
+
120
+ self.log.info(" ✅ Updated")
121
+ return True
122
+
123
+ def update_python_file(
124
+ self,
125
+ python_file: str,
126
+ new_version: str | None = None,
127
+ reference_content_getter: ReferenceContentGetter | None = None,
128
+ update_timestamp: bool = False,
129
+ function_name: str | None = None,
130
+ ) -> bool:
131
+ """Update a Python component function docstring Metadata section."""
132
+
133
+ python_path = Path(python_file)
134
+ if function_name and not _has_exact_public_function(python_path, function_name):
135
+ self.log.warn(f" ⚠️ Function '{function_name}' not found in {python_path.name}")
136
+ return False
137
+ metadata, actual_func_name = extract_file_metadata(python_path, function_name)
138
+ if not actual_func_name:
139
+ self.log.warn(f" ⚠️ No function found in {python_path.name}")
140
+ return False
141
+
142
+ current_version = metadata.get("version")
143
+ if new_version:
144
+ final_version = new_version
145
+ else:
146
+ ref_version = None
147
+ if reference_content_getter:
148
+ ref_content = reference_content_getter(python_file)
149
+ if ref_content:
150
+ import tempfile
151
+
152
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp:
153
+ tmp.write(ref_content)
154
+ tmp_path = Path(tmp.name)
155
+ try:
156
+ ref_metadata, _ = extract_file_metadata(tmp_path, actual_func_name)
157
+ ref_version = ref_metadata.get("version")
158
+ finally:
159
+ tmp_path.unlink()
160
+ if ref_version:
161
+ final_version = self.increment_version(ref_version)
162
+ self.log.info(f" 📊 Reference version: {ref_version} → bumping to {final_version}")
163
+ elif current_version:
164
+ final_version = self.increment_version(current_version)
165
+ self.log.info(f" 📊 Local version: {current_version} → bumping to {final_version}")
166
+ else:
167
+ final_version = "0.1"
168
+ self.log.info(" 📝 No existing version - using 0.1")
169
+
170
+ current_timestamp = (
171
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
172
+ if update_timestamp
173
+ else None
174
+ )
175
+ self.log.info(f" {python_path.name}:")
176
+ self.log.info(f" Current version: {current_version or 'none'}")
177
+ self.log.info(f" New version: {final_version}")
178
+ if current_timestamp:
179
+ self.log.info(f" Timestamp: {current_timestamp}")
180
+
181
+ with open(python_file, encoding="utf-8") as f:
182
+ content = f.read()
183
+ new_content = self._update_function_docstring_metadata(
184
+ python_path,
185
+ content,
186
+ actual_func_name,
187
+ final_version,
188
+ current_timestamp,
189
+ )
190
+ if new_content == content:
191
+ self.log.warn(" ⚠️ Could not update docstring - no Metadata section found")
192
+ return False
193
+ with open(python_file, "w", encoding="utf-8") as f:
194
+ f.write(new_content)
195
+ self.log.info(" ✅ Updated")
196
+ return True
197
+
198
+ def _update_function_docstring_metadata(
199
+ self,
200
+ python_path: Path,
201
+ content: str,
202
+ function_name: str,
203
+ version: str,
204
+ timestamp: str | None = None,
205
+ ) -> str:
206
+ _, func_node = find_function_in_source(python_path, function_name)
207
+ if not func_node or not func_node.body:
208
+ return content
209
+ doc_node = func_node.body[0]
210
+ value = getattr(doc_node, "value", None)
211
+ if not (
212
+ getattr(doc_node, "lineno", None)
213
+ and getattr(doc_node, "end_lineno", None)
214
+ and isinstance(getattr(value, "value", None), str)
215
+ ):
216
+ return content
217
+
218
+ lines = content.splitlines(keepends=True)
219
+ start = doc_node.lineno - 1
220
+ end = doc_node.end_lineno
221
+ docstring_source = "".join(lines[start:end])
222
+ updated_docstring = self._update_docstring_metadata(docstring_source, version, timestamp)
223
+ if updated_docstring == docstring_source:
224
+ return content
225
+ return "".join([*lines[:start], updated_docstring, *lines[end:]])
226
+
227
+ def _update_docstring_metadata(
228
+ self,
229
+ content: str,
230
+ version: str,
231
+ timestamp: str | None = None,
232
+ ) -> str:
233
+ metadata_pattern = re.compile(
234
+ r"(Metadata:\s*\n)"
235
+ r"(\s+)"
236
+ r"(?:.*?\n)*?"
237
+ r"(?=\s*(?:Args:|Returns:|Raises:|Yields:|Note:|Example:|\"\"\"|\'\'\')|\Z)",
238
+ re.IGNORECASE | re.MULTILINE,
239
+ )
240
+
241
+ def replace_metadata(match: re.Match) -> str:
242
+ header = match.group(1)
243
+ indent = match.group(2)
244
+ result = f"{header}{indent}version: {version}\n"
245
+ if timestamp:
246
+ result += f"{indent}updated_at: {timestamp}\n"
247
+ return result
248
+
249
+ return metadata_pattern.sub(replace_metadata, content, count=1)
250
+
251
+
252
+ def _has_exact_public_function(python_path: Path, function_name: str) -> bool:
253
+ """Return whether *python_path* defines exactly this public function."""
254
+
255
+ try:
256
+ tree = ast.parse(python_path.read_text(encoding="utf-8"))
257
+ except (OSError, SyntaxError):
258
+ return False
259
+ return any(
260
+ isinstance(node, ast.FunctionDef) and node.name == function_name and not node.name.startswith("_")
261
+ for node in ast.iter_child_nodes(tree)
262
+ )
263
+
264
+
265
+ def _common_generation_dir(yaml_path: Path, annotations: dict[str, str]) -> Path | None:
266
+ component_yaml_path = annotations.get("component_yaml_path")
267
+ if not component_yaml_path:
268
+ return None
269
+ yaml_rel = Path(component_yaml_path)
270
+ if yaml_rel.is_absolute():
271
+ return None
272
+ common_dir = yaml_path.resolve().parent
273
+ for part in yaml_rel.parent.parts:
274
+ if part not in ("", "."):
275
+ common_dir = common_dir.parent
276
+ return common_dir
277
+
278
+
279
+ def _resolve_annotated_path(yaml_path: Path, annotations: dict[str, str], annotation_key: str) -> Path | None:
280
+ raw_path = annotations.get(annotation_key)
281
+ if not raw_path:
282
+ return None
283
+ annotated_path = Path(raw_path)
284
+ if annotated_path.is_absolute():
285
+ return annotated_path if annotated_path.exists() else None
286
+
287
+ candidates: list[Path] = []
288
+ common_dir = _common_generation_dir(yaml_path, annotations)
289
+ if common_dir:
290
+ candidates.append(common_dir / annotated_path)
291
+ candidates.append(yaml_path.parent / annotated_path)
292
+
293
+ seen: set[Path] = set()
294
+ for candidate in candidates:
295
+ resolved = candidate.resolve()
296
+ if resolved in seen:
297
+ continue
298
+ seen.add(resolved)
299
+ if resolved.exists():
300
+ return resolved
301
+ return None
302
+
303
+
304
+ def _resolve_python_source_path(yaml_path: Path, annotations: dict[str, str]) -> Path | None:
305
+ """Resolve a component YAML's annotated Python source path.
306
+
307
+ New generated YAML records both ``python_original_code_path`` and
308
+ ``component_yaml_path`` relative to a common ancestor. Older YAML may store
309
+ only the source basename, sometimes beside the YAML or under a sibling
310
+ ``sources`` directory. Try the structured common-ancestor form first, then
311
+ legacy locations.
312
+ """
313
+
314
+ raw_python_path = annotations.get("python_original_code_path")
315
+ if not raw_python_path:
316
+ return None
317
+
318
+ python_path = Path(raw_python_path)
319
+ if python_path.is_absolute():
320
+ return python_path if python_path.exists() else None
321
+
322
+ candidates: list[Path] = []
323
+ common_dir = _common_generation_dir(yaml_path, annotations)
324
+ if common_dir:
325
+ candidates.append(common_dir / python_path)
326
+
327
+ yaml_dir = yaml_path.parent
328
+ candidates.extend(
329
+ [
330
+ yaml_dir / python_path,
331
+ yaml_dir / "sources" / python_path.name,
332
+ yaml_dir / python_path.name,
333
+ ]
334
+ )
335
+
336
+ seen: set[Path] = set()
337
+ for candidate in candidates:
338
+ resolved = candidate.resolve()
339
+ if resolved in seen:
340
+ continue
341
+ seen.add(resolved)
342
+ if resolved.exists():
343
+ return resolved
344
+ return None
345
+
346
+
347
+ def bump_version(
348
+ yaml_file: str | Path,
349
+ set_version: str | None = None,
350
+ reference_content_getter: ReferenceContentGetter | None = None,
351
+ update_timestamp: bool = False,
352
+ logger: Logger | None = None,
353
+ ) -> dict[str, str | None]:
354
+ """Bump component version in a YAML file.
355
+
356
+ If the YAML references a local Python source via
357
+ ``metadata.annotations.python_original_code_path``, updates that source and
358
+ regenerates the YAML. Otherwise updates YAML metadata directly.
359
+ """
360
+
361
+ log = logger or get_default_logger()
362
+ yaml_path = Path(yaml_file)
363
+ if not yaml_path.exists():
364
+ log.error(f"❌ File not found: {yaml_file}")
365
+ return {"status": "failed", "error": f"File not found: {yaml_file}"}
366
+ if yaml_path.suffix not in [".yaml", ".yml"]:
367
+ log.error(f"❌ Not a YAML file: {yaml_file}")
368
+ return {"status": "failed", "error": f"Not a YAML file: {yaml_file}"}
369
+
370
+ version_manager = VersionManager(logger=log)
371
+ with open(yaml_path, encoding="utf-8") as f:
372
+ yaml_content = yaml.safe_load(f) or {}
373
+ old_version = utils.get_version_from_data(yaml_content)
374
+
375
+ annotations: dict[str, str] = {}
376
+ metadata = yaml_content.get("metadata") if isinstance(yaml_content, dict) else None
377
+ if isinstance(metadata, dict) and isinstance(metadata.get("annotations"), dict):
378
+ annotations = metadata["annotations"]
379
+ python_path = annotations.get("python_original_code_path")
380
+ has_original_code = "python_original_code" in annotations
381
+ generation_function_name = annotations.get("tangle_cli_generation_function_name")
382
+ generation_mode = annotations.get("tangle_cli_generation_mode") or (
383
+ "bundle" if annotations.get("bundled_modules") else "inline"
384
+ )
385
+ if generation_mode not in {"inline", "bundle"}:
386
+ error = f"Unsupported generation mode: {generation_mode}"
387
+ log.error(f"❌ {error}")
388
+ return {"status": "failed", "yaml_file": str(yaml_path), "error": error}
389
+ custom_name = (
390
+ yaml_content.get("name")
391
+ if isinstance(yaml_content, dict) and isinstance(yaml_content.get("name"), str)
392
+ else None
393
+ )
394
+
395
+ dependencies_from = None
396
+ if annotations.get("tangle_cli_generation_dependencies_from"):
397
+ dependencies_from = _resolve_annotated_path(
398
+ yaml_path,
399
+ annotations,
400
+ "tangle_cli_generation_dependencies_from",
401
+ )
402
+ if dependencies_from is None:
403
+ error = f"Dependency file not found: {annotations['tangle_cli_generation_dependencies_from']}"
404
+ log.error(f"❌ {error}")
405
+ return {"status": "failed", "yaml_file": str(yaml_path), "error": error}
406
+
407
+ resolve_root = None
408
+ if annotations.get("tangle_cli_generation_resolve_root"):
409
+ resolve_root = _resolve_annotated_path(
410
+ yaml_path,
411
+ annotations,
412
+ "tangle_cli_generation_resolve_root",
413
+ )
414
+ if resolve_root is None:
415
+ error = f"Resolve root not found: {annotations['tangle_cli_generation_resolve_root']}"
416
+ log.error(f"❌ {error}")
417
+ return {"status": "failed", "yaml_file": str(yaml_path), "error": error}
418
+
419
+ if python_path:
420
+ python_full_path = _resolve_python_source_path(yaml_path, annotations)
421
+ if python_full_path:
422
+ log.info(f" 📍 Found Python source: {python_full_path.name}")
423
+ success = version_manager.update_python_file(
424
+ str(python_full_path),
425
+ new_version=set_version,
426
+ reference_content_getter=reference_content_getter,
427
+ update_timestamp=update_timestamp,
428
+ function_name=generation_function_name,
429
+ )
430
+ if success:
431
+ log.info(" 🔄 Regenerating YAML...")
432
+ success = ComponentGenerator(logger=log).regenerate_yaml(
433
+ python_full_path,
434
+ output_path=yaml_path,
435
+ function_name=generation_function_name,
436
+ custom_name=custom_name,
437
+ dependencies_from=dependencies_from,
438
+ strip_code=not has_original_code,
439
+ mode=generation_mode,
440
+ resolve_root=resolve_root,
441
+ )
442
+ else:
443
+ log.error(f"❌ Python source not found: {python_path}")
444
+ return {
445
+ "status": "failed",
446
+ "yaml_file": str(yaml_path),
447
+ "error": f"Python source not found: {python_path}",
448
+ }
449
+ else:
450
+ success = version_manager.update_yaml_file(
451
+ str(yaml_path),
452
+ new_version=set_version,
453
+ reference_content_getter=reference_content_getter,
454
+ update_timestamp=update_timestamp,
455
+ )
456
+
457
+ if not success:
458
+ return {"status": "failed", "yaml_file": str(yaml_path), "error": "Version update failed"}
459
+
460
+ with open(yaml_path, encoding="utf-8") as f:
461
+ new_version = utils.get_version_from_data(yaml.safe_load(f))
462
+ return {
463
+ "status": "success",
464
+ "yaml_file": str(yaml_path),
465
+ "old_version": old_version,
466
+ "new_version": new_version,
467
+ }
468
+
469
+
470
+ __all__ = ["ReferenceContentGetter", "VersionManager", "bump_version"]