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,921 @@
1
+ """Publish components to the Tangle API.
2
+
3
+ This module intentionally mirrors the generic publisher behavior from
4
+ ``tangle-deploy`` while depending only on OSS ``tangle_cli`` primitives and the
5
+ checked-in/generated static API client. Provider-specific auth wrappers,
6
+ notification plumbing, and a separate ``publish-all`` CLI are kept downstream.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import os
13
+ from collections.abc import Callable, Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Protocol
18
+
19
+ import tangle_cli.utils as utils
20
+
21
+ from .handler import TangleCliHandler
22
+ from .logger import Logger
23
+
24
+ if TYPE_CHECKING:
25
+ from tangle_api.generated.models import ComponentSpec
26
+
27
+
28
+ class ProcessingOutcome(str, Enum):
29
+ """Outcome of processing one component publish operation."""
30
+
31
+ SKIP = "skip"
32
+ PROCEED = "proceed"
33
+ SUCCESS = "success"
34
+ ERROR = "error"
35
+
36
+
37
+ @dataclass
38
+ class ProcessingResult:
39
+ """Result for one component publish/deprecate processing step."""
40
+
41
+ outcome: ProcessingOutcome
42
+ local_version: str | None = None
43
+ latest_version: str | None = None
44
+ spec: Any = None
45
+ reason: str | None = None
46
+ digest: str | None = None
47
+ response: Any = None
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ payload: dict[str, Any] = {
51
+ "status": self.outcome.value,
52
+ "outcome": self.outcome.value,
53
+ "local_version": self.local_version,
54
+ "latest_version": self.latest_version,
55
+ "reason": self.reason,
56
+ "digest": self.digest,
57
+ "response": _to_plain(self.response),
58
+ }
59
+ if self.spec is not None:
60
+ payload["name"] = getattr(self.spec, "name", None)
61
+ return {key: value for key, value in payload.items() if value is not None}
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ComponentPublishContext:
66
+ """Structured context passed to component publish hooks.
67
+
68
+ The context is additive: hooks may keep the original historical signatures,
69
+ or add a keyword-only ``context`` parameter to receive publisher metadata,
70
+ batch configuration, and accumulated per-component results.
71
+ """
72
+
73
+ publisher: "ComponentPublisher"
74
+ dry_run: bool
75
+ git_remote_sha: str | None = None
76
+ git_remote_branch: str | None = None
77
+ git_remote_url: str | None = None
78
+ git_repo: str | None = None
79
+ git_root: str | None = None
80
+ published_by: str | None = None
81
+ batch_config: Sequence[Mapping[str, Any]] | None = None
82
+ component_config: Mapping[str, Any] | None = None
83
+ component_path: str | None = None
84
+ result: ProcessingResult | None = None
85
+ results: Sequence[tuple[str, ProcessingResult]] = field(default_factory=tuple)
86
+
87
+
88
+ class ComponentPublishHook(Protocol):
89
+ """Extension hook for downstream publishers.
90
+
91
+ Downstream packages can implement one or more methods to observe publish
92
+ batches (for example, to send notification summaries) without OSS importing
93
+ or knowing about those systems. Implementations that need richer metadata may
94
+ add ``context: ComponentPublishContext | None = None`` as a keyword
95
+ parameter; hooks without that parameter continue to work.
96
+ """
97
+
98
+ def before_batch(
99
+ self,
100
+ components_config: Sequence[Mapping[str, Any]],
101
+ context: ComponentPublishContext | None = None,
102
+ ) -> None: ...
103
+
104
+ def after_component(
105
+ self,
106
+ component_path: str,
107
+ result: ProcessingResult,
108
+ context: ComponentPublishContext | None = None,
109
+ ) -> None: ...
110
+
111
+ def after_batch(
112
+ self,
113
+ results: Sequence[tuple[str, ProcessingResult]],
114
+ context: ComponentPublishContext | None = None,
115
+ ) -> None: ...
116
+
117
+
118
+ # ============================================================================
119
+ # Publisher
120
+ # ============================================================================
121
+
122
+
123
+ class ComponentPublisher(TangleCliHandler):
124
+ """Publisher for Tangle components."""
125
+
126
+ component_spec_model: type[Any] | None = None
127
+
128
+ def __init__(
129
+ self,
130
+ dry_run: bool = False,
131
+ git_remote_sha: str | None = None,
132
+ git_remote_branch: str | None = None,
133
+ git_remote_url: str | None = None,
134
+ git_repo: str | None = None,
135
+ git_root: str | Path | None = None,
136
+ published_by: str | None = None,
137
+ client: Any = None,
138
+ client_factory: Callable[[], Any] | None = None,
139
+ hooks: Sequence[ComponentPublishHook] | None = None,
140
+ logger: Logger | None = None,
141
+ base_url: str | None = None,
142
+ ) -> None:
143
+ """Initialize the ComponentPublisher.
144
+
145
+ Args mirror the generic ``tangle-deploy`` publisher shape, with
146
+ provider-specific notification/auth fields intentionally omitted.
147
+ ``client_factory`` is a downstream seam for lazily constructing a custom
148
+ authenticated client; subclasses may also override :meth:`_get_client`
149
+ for more control.
150
+ """
151
+
152
+ super().__init__(
153
+ dry_run=dry_run,
154
+ client=client,
155
+ client_factory=client_factory,
156
+ logger=logger,
157
+ base_url=base_url,
158
+ )
159
+ self.published_by = published_by
160
+ self.hooks = list(hooks or [])
161
+ self.results: list[tuple[str, ProcessingResult]] = []
162
+
163
+ git_info = utils.get_git_info(Path.cwd(), logger=self.log)
164
+ self._git_root = str(git_root or git_info.get("_git_root") or "") or None
165
+ self.git_remote_sha = git_remote_sha or git_info.get("git_remote_sha")
166
+ self.git_remote_branch = git_remote_branch or git_info.get("git_remote_branch")
167
+ self.git_remote_url = git_remote_url or git_info.get("git_remote_url")
168
+ self.git_repo = git_repo
169
+
170
+ def _component_spec_model(self) -> type[Any]:
171
+ if self.component_spec_model is not None:
172
+ return self.component_spec_model
173
+ try:
174
+ from tangle_api.generated.models import ComponentSpec
175
+ except ModuleNotFoundError as exc:
176
+ if exc.name == "tangle_api":
177
+ raise RuntimeError(
178
+ "Native generated Tangle API bindings are required for component publishing. "
179
+ "Install tangle-cli[native] or provide a local tangle_api.generated package."
180
+ ) from exc
181
+ raise
182
+ return ComponentSpec
183
+
184
+ def component_digest(self, component: Any) -> str | None:
185
+ """Return a published component digest from mapping or object shapes."""
186
+
187
+ if isinstance(component, Mapping):
188
+ digest = component.get("digest")
189
+ return str(digest) if digest else None
190
+ digest = getattr(component, "digest", None)
191
+ return str(digest) if digest else None
192
+
193
+ def current_user_id(self, client: Any) -> str | None:
194
+ """Return the current Tangle user id for owner-scoped lookups."""
195
+
196
+ try:
197
+ user_info = client.users_me()
198
+ except Exception:
199
+ return None
200
+ if user_info is None:
201
+ return None
202
+ if isinstance(user_info, Mapping):
203
+ value = user_info.get("id")
204
+ else:
205
+ value = getattr(user_info, "id", None)
206
+ return str(value) if value else None
207
+
208
+ def perform_version_check(self, spec: Any) -> ProcessingResult:
209
+ """Perform owner-scoped version checking for a component.
210
+
211
+ If ``published_by`` is omitted, the current authenticated user is
212
+ resolved via ``client.users_me().id``. Failure to determine an owner is
213
+ an error so callers do not accidentally compare/deprecate components
214
+ owned by others.
215
+ """
216
+
217
+ local_version = spec.version
218
+ self.log.info(f" Local version: {local_version}")
219
+
220
+ latest_version = None
221
+
222
+ if self.dry_run:
223
+ test_version = os.environ.get("TEST_LATEST_VERSION")
224
+ if test_version:
225
+ latest_version = test_version
226
+ self.log.info(f" Remote version (test): {latest_version}")
227
+ else:
228
+ client = self._get_client()
229
+ if client is None:
230
+ return ProcessingResult(
231
+ outcome=ProcessingOutcome.ERROR,
232
+ local_version=str(local_version),
233
+ latest_version=None,
234
+ reason="Failed to create API client",
235
+ )
236
+
237
+ filter_by = self.published_by or self.current_user_id(client)
238
+ if not filter_by:
239
+ self.log.error(
240
+ "❌ Cannot determine current user — aborting to avoid deprecating components owned by others"
241
+ )
242
+ return ProcessingResult(
243
+ outcome=ProcessingOutcome.ERROR,
244
+ local_version=str(local_version),
245
+ latest_version=None,
246
+ reason="Cannot determine current user for author filtering",
247
+ )
248
+
249
+ existing_components = client.find_existing_components(
250
+ spec.search_names,
251
+ verbose=False,
252
+ published_by=filter_by,
253
+ )
254
+
255
+ if existing_components:
256
+ for component in existing_components:
257
+ digest = self.component_digest(component)
258
+ if not digest:
259
+ continue
260
+ try:
261
+ full_spec = client.get_component_spec(digest)
262
+ remote_version = full_spec.version if full_spec else None
263
+ if remote_version and (
264
+ not latest_version or utils.compare_versions(remote_version, latest_version) > 0
265
+ ):
266
+ latest_version = remote_version
267
+ except Exception as exc:
268
+ self.log.warn(f" Warning: Failed to get version for component {digest[:16]}: {exc}")
269
+ continue
270
+
271
+ if latest_version:
272
+ self.log.info(f" Remote version: {latest_version}")
273
+ else:
274
+ self.log.info(
275
+ f" ℹ️ Found {len(existing_components)} component(s) but couldn't extract version"
276
+ )
277
+
278
+ should_proceed = not latest_version or utils.compare_versions(local_version, latest_version) != 0
279
+
280
+ if should_proceed:
281
+ is_older = latest_version is not None and utils.compare_versions(latest_version, local_version) > 0
282
+ version_suffix = " (older)" if is_older else ""
283
+ self.log.info(
284
+ " ➡️ Version "
285
+ + (f"{latest_version}{version_suffix}" if latest_version else "new")
286
+ + f" → {local_version}"
287
+ )
288
+ return ProcessingResult(
289
+ outcome=ProcessingOutcome.PROCEED,
290
+ local_version=local_version,
291
+ latest_version=latest_version,
292
+ spec=spec,
293
+ )
294
+
295
+ self.log.info(f" ⏭️ Skipping: Version {local_version} unchanged")
296
+
297
+ return ProcessingResult(
298
+ outcome=ProcessingOutcome.SKIP,
299
+ local_version=local_version,
300
+ latest_version=latest_version,
301
+ spec=spec,
302
+ reason=f"Version {local_version} unchanged (matches remote)",
303
+ )
304
+
305
+ def deprecate_old_components(
306
+ self,
307
+ existing_components: Sequence[Any],
308
+ new_digest: str,
309
+ ) -> int:
310
+ """Deprecate previous component versions after a successful publish.
311
+
312
+ ``existing_components`` must already be owner-scoped by the caller.
313
+ This method refuses to operate without a client and skips the newly
314
+ published digest to avoid self-deprecation.
315
+ """
316
+
317
+ if not existing_components:
318
+ return 0
319
+
320
+ client = self._get_client()
321
+ if not client:
322
+ self.log.warn(" ⚠️ Cannot deprecate components without TangleApiClient")
323
+ return 0
324
+
325
+ self.log.info(f" Deprecating {len(existing_components)} previous version(s)...")
326
+ deprecation_count = 0
327
+
328
+ for old_component in existing_components:
329
+ old_digest = self.component_digest(old_component)
330
+ if old_digest and old_digest != new_digest:
331
+ try:
332
+ result = client.published_components_update(
333
+ digest=old_digest,
334
+ deprecated=True,
335
+ superseded_by=new_digest,
336
+ )
337
+ if result:
338
+ deprecation_count += 1
339
+ self.log.info(f" ✅ Successfully deprecated component {old_digest[:16]}...")
340
+ else:
341
+ self.log.warn(
342
+ f" ⚠️ No response from deprecation request for component {old_digest[:16]}..."
343
+ )
344
+ except Exception as exc:
345
+ self.log.warn(f" ⚠️ Warning: Failed to deprecate component {old_digest[:16]}...: {exc}")
346
+
347
+ if deprecation_count > 0:
348
+ self.log.info(f" ✅ Deprecated {deprecation_count} old version(s)")
349
+
350
+ return deprecation_count
351
+
352
+ def load_component_spec(
353
+ self,
354
+ component_path: str | Path,
355
+ *,
356
+ annotations: Mapping[str, str] | None = None,
357
+ ) -> "ComponentSpec":
358
+ """Load a component YAML file into the generated ``ComponentSpec`` model."""
359
+
360
+ text = read_component_yaml_text(component_path)
361
+ return self._component_spec_model().from_yaml(text, annotations=dict(annotations or {}))
362
+
363
+ def prepare_component_for_publish(
364
+ self,
365
+ component_path: str | Path,
366
+ *,
367
+ image: str | None = None,
368
+ name: str | None = None,
369
+ description: str | None = None,
370
+ annotations: Mapping[str, str] | None = None,
371
+ ) -> "ComponentSpec":
372
+ """Load and apply generic publish-time overrides/metadata."""
373
+
374
+ spec = self.load_component_spec(component_path, annotations=annotations)
375
+ if name:
376
+ spec.name = name
377
+ spec.data["name"] = name
378
+ if description:
379
+ spec.description = description
380
+ spec.data["description"] = description
381
+ component_yaml_path = None
382
+ if self._git_root:
383
+ try:
384
+ component_yaml_path = str(Path(component_path).resolve().relative_to(Path(self._git_root).resolve()))
385
+ except ValueError:
386
+ pass
387
+ spec.update_fields(
388
+ git_remote_sha=self.git_remote_sha,
389
+ git_remote_branch=self.git_remote_branch,
390
+ git_remote_url=self.git_remote_url,
391
+ image=image,
392
+ component_yaml_path=component_yaml_path,
393
+ )
394
+ return spec
395
+
396
+ def deprecate_component(
397
+ self,
398
+ digest: str,
399
+ superseded_by: str | None = None,
400
+ ) -> dict[str, Any]:
401
+ """Deprecate a published component by digest."""
402
+
403
+ client = self._get_client()
404
+ if not client:
405
+ return {
406
+ "success": False,
407
+ "digest": digest,
408
+ "error": "Failed to create TangleApiClient",
409
+ }
410
+
411
+ try:
412
+ result = client.published_components_update(
413
+ digest=digest,
414
+ deprecated=True,
415
+ superseded_by=superseded_by,
416
+ )
417
+ self.log.info(f"✅ Deprecated component {digest[:16]}...")
418
+ if superseded_by:
419
+ self.log.info(f" Superseded by: {superseded_by[:16]}...")
420
+
421
+ return {
422
+ "success": True,
423
+ "digest": digest,
424
+ "superseded_by": superseded_by,
425
+ "response": _to_plain(result),
426
+ }
427
+ except Exception as exc:
428
+ self.log.error(f"❌ Failed to deprecate component {digest[:16]}...: {exc}")
429
+ return {
430
+ "success": False,
431
+ "digest": digest,
432
+ "error": str(exc),
433
+ }
434
+
435
+ def publish_component(
436
+ self,
437
+ file_path: str | Path,
438
+ image: str | None = None,
439
+ name: str | None = None,
440
+ description: str | None = None,
441
+ annotations: dict[str, str] | None = None,
442
+ ) -> ProcessingResult:
443
+ """Publish a component to the Tangle Component Library with version checking."""
444
+
445
+ try:
446
+ path = Path(file_path)
447
+ local_yaml_content = read_component_yaml_text(path)
448
+ except Exception as exc:
449
+ self.log.error(f"❌ Failed to read file {file_path}: {exc}")
450
+ return ProcessingResult(
451
+ outcome=ProcessingOutcome.ERROR,
452
+ local_version=None,
453
+ latest_version=None,
454
+ reason=f"Failed to read file {file_path}: {exc}",
455
+ )
456
+
457
+ try:
458
+ spec = self._component_spec_model().from_yaml(local_yaml_content, annotations=dict(annotations or {}))
459
+ if spec.version is None:
460
+ self.log.warn(" ⏭️ Skipping: Component version is required but not found in YAML")
461
+ return ProcessingResult(
462
+ outcome=ProcessingOutcome.SKIP,
463
+ local_version=None,
464
+ latest_version=None,
465
+ spec=spec,
466
+ reason="Component version is required but not found in YAML",
467
+ )
468
+ except ValueError as exc:
469
+ self.log.error(f" ❌ {exc}")
470
+ return ProcessingResult(
471
+ outcome=ProcessingOutcome.ERROR,
472
+ local_version=None,
473
+ latest_version=None,
474
+ reason=str(exc),
475
+ )
476
+
477
+ if name:
478
+ spec.name = name
479
+ spec.data["name"] = name
480
+ if description:
481
+ spec.description = description
482
+ spec.data["description"] = description
483
+
484
+ client = self._get_client()
485
+ if not client and not self.dry_run:
486
+ self.log.error("❌ Failed to create TangleApiClient")
487
+ return ProcessingResult(
488
+ outcome=ProcessingOutcome.ERROR,
489
+ local_version=None,
490
+ latest_version=None,
491
+ spec=spec,
492
+ reason="Failed to create TangleApiClient",
493
+ )
494
+
495
+ version_check_result = self.perform_version_check(spec=spec)
496
+
497
+ if version_check_result.outcome == ProcessingOutcome.SKIP:
498
+ self.log.info(f" ⏭️ Skipping API publish: {version_check_result.reason}")
499
+ return version_check_result
500
+ if version_check_result.outcome == ProcessingOutcome.ERROR:
501
+ self.log.error(f" ❌ Cannot proceed due to error: {version_check_result.reason}")
502
+ return version_check_result
503
+
504
+ component_yaml_path = None
505
+ if self._git_root:
506
+ try:
507
+ component_yaml_path = str(Path(file_path).resolve().relative_to(Path(self._git_root).resolve()))
508
+ except ValueError:
509
+ pass
510
+
511
+ spec.update_fields(
512
+ self.git_remote_sha,
513
+ self.git_remote_branch,
514
+ git_remote_url=self.git_remote_url,
515
+ image=image,
516
+ component_yaml_path=component_yaml_path,
517
+ )
518
+
519
+ spec_annotations = (getattr(spec, "data", None) or {}).get("metadata", {}).get("annotations")
520
+ if self._git_root and spec_annotations:
521
+ utils.normalize_annotation_paths(Path(file_path), self._git_root, spec_annotations)
522
+
523
+ local_yaml_content = spec.to_yaml()
524
+
525
+ if self.dry_run:
526
+ self.log.info(f"[DRY-RUN] Would publish component: {spec.name}")
527
+ return ProcessingResult(
528
+ outcome=ProcessingOutcome.SUCCESS,
529
+ local_version=version_check_result.local_version,
530
+ latest_version=version_check_result.latest_version,
531
+ spec=spec,
532
+ reason=f"Dry-run: would publish {spec.name}",
533
+ response={"name": spec.name, "text": local_yaml_content},
534
+ )
535
+
536
+ filter_by = self.published_by or self.current_user_id(client)
537
+ if not filter_by:
538
+ self.log.error(
539
+ "❌ Cannot determine current user — aborting to avoid deprecating components owned by others"
540
+ )
541
+ return ProcessingResult(
542
+ outcome=ProcessingOutcome.ERROR,
543
+ local_version=version_check_result.local_version,
544
+ latest_version=version_check_result.latest_version,
545
+ spec=spec,
546
+ reason="Cannot determine current user for author filtering",
547
+ )
548
+ existing_components = client.find_existing_components(spec.search_names, verbose=True, published_by=filter_by)
549
+
550
+ try:
551
+ result = client.published_components_create(name=spec.name, text=local_yaml_content)
552
+ plain_result = _to_plain(result)
553
+ new_digest = plain_result.get("digest") if isinstance(plain_result, Mapping) else None
554
+
555
+ if new_digest:
556
+ self.log.info(f"✅ Published: {spec.name} (digest: {str(new_digest)[:16]}...)")
557
+ self.deprecate_old_components(existing_components, str(new_digest))
558
+ return ProcessingResult(
559
+ outcome=ProcessingOutcome.SUCCESS,
560
+ local_version=version_check_result.local_version,
561
+ latest_version=version_check_result.latest_version,
562
+ spec=spec,
563
+ reason=f"Successfully published with digest: {new_digest}",
564
+ digest=str(new_digest),
565
+ response=result,
566
+ )
567
+
568
+ self.log.warn("⚠️ Component published but no digest returned")
569
+ return ProcessingResult(
570
+ outcome=ProcessingOutcome.ERROR,
571
+ local_version=version_check_result.local_version,
572
+ latest_version=version_check_result.latest_version,
573
+ spec=spec,
574
+ reason="Component published but no digest returned",
575
+ response=result,
576
+ )
577
+ except Exception as exc:
578
+ self.log.error(f"❌ Request failed: {exc}")
579
+ return ProcessingResult(
580
+ outcome=ProcessingOutcome.ERROR,
581
+ local_version=version_check_result.local_version,
582
+ latest_version=version_check_result.latest_version,
583
+ spec=spec,
584
+ reason=f"Request failed: {exc}",
585
+ )
586
+
587
+ def publish_components(self, components_config: list[dict[str, Any]]) -> int:
588
+ """Publish components with per-component configuration to the Tangle API."""
589
+
590
+ self.log.info("\n" + "=" * 60)
591
+ self.log.info(f"📤 Publishing {len(components_config)} component(s) to Tangle API")
592
+ self.log.info("=" * 60)
593
+
594
+ batch_context = self._publish_context(batch_config=components_config)
595
+ self._run_hook("before_batch", components_config, context=batch_context)
596
+ all_results: list[tuple[str, ProcessingResult]] = []
597
+
598
+ for config in components_config:
599
+ component_path = config.get("component_path")
600
+ image = config.get("image")
601
+ custom_name = config.get("name")
602
+ custom_description = config.get("description")
603
+ custom_annotations = config.get("annotations")
604
+
605
+ if not component_path:
606
+ self.log.error(f"\n❌ Error: Missing 'component_path' in configuration: {config}")
607
+ error_result = ProcessingResult(
608
+ outcome=ProcessingOutcome.ERROR,
609
+ local_version=None,
610
+ latest_version=None,
611
+ reason="Missing 'component_path' in configuration",
612
+ )
613
+ all_results.append(("<missing_path>", error_result))
614
+ self._run_hook(
615
+ "after_component",
616
+ "<missing_path>",
617
+ error_result,
618
+ context=self._publish_context(
619
+ batch_config=components_config,
620
+ component_config=config,
621
+ component_path="<missing_path>",
622
+ result=error_result,
623
+ results=all_results,
624
+ ),
625
+ )
626
+ continue
627
+
628
+ component_name = custom_name or Path(component_path).stem
629
+ self.log.info(f"\n📦 Publishing component: {component_name}")
630
+ self.log.info(f" Source: {component_path}")
631
+ if image:
632
+ self.log.info(f" Image: {image}")
633
+ if custom_name:
634
+ self.log.info(f" Custom name: {custom_name}")
635
+ if custom_description:
636
+ desc_preview = custom_description[:50] + ("..." if len(custom_description) > 50 else "")
637
+ self.log.info(f" Custom description: {desc_preview}")
638
+ if custom_annotations:
639
+ self.log.info(f" Custom annotations: {list(custom_annotations.keys())}")
640
+
641
+ try:
642
+ result = self.publish_component(
643
+ component_path,
644
+ image=image,
645
+ name=custom_name,
646
+ description=custom_description,
647
+ annotations=custom_annotations,
648
+ )
649
+ except Exception as exc:
650
+ result = ProcessingResult(
651
+ outcome=ProcessingOutcome.ERROR,
652
+ local_version=None,
653
+ latest_version=None,
654
+ reason=f"Unexpected error: {exc}",
655
+ )
656
+ self.log.error(f" ❌ Unexpected error: {exc}")
657
+ all_results.append((str(component_path), result))
658
+ self._run_hook(
659
+ "after_component",
660
+ str(component_path),
661
+ result,
662
+ context=self._publish_context(
663
+ batch_config=components_config,
664
+ component_config=config,
665
+ component_path=str(component_path),
666
+ result=result,
667
+ results=all_results,
668
+ ),
669
+ )
670
+
671
+ success_count = sum(1 for _, result in all_results if result.outcome == ProcessingOutcome.SUCCESS)
672
+ skip_count = sum(1 for _, result in all_results if result.outcome == ProcessingOutcome.SKIP)
673
+ error_count = sum(1 for _, result in all_results if result.outcome == ProcessingOutcome.ERROR)
674
+
675
+ self.log.info("\n" + "=" * 60)
676
+ self.log.info("📊 Tangle API Publish Summary")
677
+ self.log.info("=" * 60)
678
+ self.log.info(f"Total components found: {len(all_results)}")
679
+ self.log.info(f"Successfully published: {success_count}")
680
+ self.log.info(f"Skipped (version check): {skip_count}")
681
+ self.log.info(f"Failed: {error_count}")
682
+
683
+ error_results = [(path, result) for path, result in all_results if result.outcome == ProcessingOutcome.ERROR]
684
+ if error_results:
685
+ self.log.error("\n❌ Error details:")
686
+ for path, result in error_results:
687
+ component_name = result.spec.name if result.spec else Path(path).stem
688
+ self.log.error(f" • {component_name}: {result.reason}")
689
+
690
+ self.results = all_results
691
+ self._run_hook(
692
+ "after_batch",
693
+ all_results,
694
+ context=self._publish_context(batch_config=components_config, results=all_results),
695
+ )
696
+
697
+ if len(all_results) == 0:
698
+ self.log.warn("\n⚠️ No components specified in configuration")
699
+ return 1
700
+ if error_count > 0:
701
+ if error_count == len(all_results):
702
+ self.log.error("\n❌ All components failed to publish")
703
+ else:
704
+ self.log.error(f"\n❌ {error_count} component(s) failed to publish")
705
+ return 1
706
+ return 0
707
+
708
+ def _publish_context(
709
+ self,
710
+ *,
711
+ batch_config: Sequence[Mapping[str, Any]] | None = None,
712
+ component_config: Mapping[str, Any] | None = None,
713
+ component_path: str | None = None,
714
+ result: ProcessingResult | None = None,
715
+ results: Sequence[tuple[str, ProcessingResult]] | None = None,
716
+ ) -> ComponentPublishContext:
717
+ return ComponentPublishContext(
718
+ publisher=self,
719
+ dry_run=self.dry_run,
720
+ git_remote_sha=self.git_remote_sha,
721
+ git_remote_branch=self.git_remote_branch,
722
+ git_remote_url=self.git_remote_url,
723
+ git_repo=self.git_repo,
724
+ git_root=self._git_root,
725
+ published_by=self.published_by,
726
+ batch_config=batch_config,
727
+ component_config=component_config,
728
+ component_path=component_path,
729
+ result=result,
730
+ results=tuple(results or ()),
731
+ )
732
+
733
+ def _run_hook(self, method_name: str, *args: Any, context: ComponentPublishContext | None = None) -> None:
734
+ for hook in self.hooks:
735
+ method = getattr(hook, method_name, None)
736
+ if not method:
737
+ continue
738
+ if context is not None and _hook_accepts_context(method):
739
+ method(*args, context=context)
740
+ else:
741
+ method(*args)
742
+
743
+
744
+ # ============================================================================
745
+ # Internal helpers
746
+ # ============================================================================
747
+
748
+
749
+ def read_component_yaml_text(component_path: str | Path) -> str:
750
+ """Read component YAML text from disk."""
751
+
752
+ return Path(component_path).read_text(encoding="utf-8")
753
+
754
+
755
+ def deprecate_old_components(
756
+ existing_components: Sequence[Any],
757
+ new_digest: str,
758
+ client: Any = None,
759
+ logger: Logger | None = None,
760
+ ) -> int:
761
+ """Deprecate old versions of a component after publishing a new one."""
762
+
763
+ return ComponentPublisher(client=client, logger=logger).deprecate_old_components(
764
+ existing_components,
765
+ new_digest,
766
+ )
767
+
768
+
769
+ def perform_version_check(
770
+ spec: Any,
771
+ dry_run: bool,
772
+ client: Any = None,
773
+ logger: Logger | None = None,
774
+ published_by: str | None = None,
775
+ ) -> ProcessingResult:
776
+ """Perform owner-scoped version checking for a component."""
777
+
778
+ return ComponentPublisher(
779
+ dry_run=dry_run,
780
+ client=client,
781
+ logger=logger,
782
+ published_by=published_by,
783
+ ).perform_version_check(spec)
784
+
785
+
786
+ def publish_component_to_tangle(
787
+ file_path: str | Path,
788
+ dry_run: bool = False,
789
+ git_remote_sha: str | None = None,
790
+ git_remote_branch: str | None = None,
791
+ git_remote_url: str | None = None,
792
+ git_repo: str | None = None,
793
+ image: str | None = None,
794
+ name: str | None = None,
795
+ description: str | None = None,
796
+ annotations: dict[str, str] | None = None,
797
+ client: Any = None,
798
+ client_factory: Callable[[], Any] | None = None,
799
+ published_by: str | None = None,
800
+ ) -> ProcessingResult:
801
+ """Publish one component using ``ComponentPublisher.publish_component``."""
802
+
803
+ publisher = ComponentPublisher(
804
+ dry_run=dry_run,
805
+ client=client,
806
+ client_factory=client_factory,
807
+ git_remote_sha=git_remote_sha,
808
+ git_remote_branch=git_remote_branch,
809
+ git_remote_url=git_remote_url,
810
+ git_repo=git_repo,
811
+ published_by=published_by,
812
+ )
813
+ return publisher.publish_component(
814
+ file_path,
815
+ image=image,
816
+ name=name,
817
+ description=description,
818
+ annotations=annotations,
819
+ )
820
+
821
+
822
+ def publish_component(client: Any, component_path: str | Path, **kwargs: Any) -> ProcessingResult:
823
+ """Compatibility wrapper around ``ComponentPublisher`` for one component."""
824
+
825
+ publisher = ComponentPublisher(
826
+ dry_run=bool(kwargs.pop("dry_run", False)),
827
+ git_remote_sha=kwargs.pop("git_remote_sha", None),
828
+ git_remote_branch=kwargs.pop("git_remote_branch", None),
829
+ git_remote_url=kwargs.pop("git_remote_url", None),
830
+ git_root=kwargs.pop("git_root", None),
831
+ git_repo=kwargs.pop("git_repo", None),
832
+ published_by=kwargs.pop("published_by", None),
833
+ client=client,
834
+ client_factory=kwargs.pop("client_factory", None),
835
+ logger=kwargs.pop("logger", None),
836
+ )
837
+ return publisher.publish_component(component_path, **kwargs)
838
+
839
+
840
+ def deprecate_component(
841
+ client: Any,
842
+ digest: str,
843
+ *,
844
+ superseded_by: str | None = None,
845
+ logger: Logger | None = None,
846
+ ) -> dict[str, Any]:
847
+ """Compatibility wrapper around ``ComponentPublisher.deprecate_component``."""
848
+
849
+ return ComponentPublisher(client=client, logger=logger).deprecate_component(
850
+ digest,
851
+ superseded_by=superseded_by,
852
+ )
853
+
854
+
855
+ def prepare_component_for_publish(
856
+ component_path: str | Path,
857
+ *,
858
+ image: str | None = None,
859
+ name: str | None = None,
860
+ description: str | None = None,
861
+ annotations: Mapping[str, str] | None = None,
862
+ git_remote_sha: str | None = None,
863
+ git_remote_branch: str | None = None,
864
+ git_remote_url: str | None = None,
865
+ git_root: str | Path | None = None,
866
+ ) -> "ComponentSpec":
867
+ """Load and apply generic publish-time overrides/metadata."""
868
+
869
+ return ComponentPublisher(
870
+ git_remote_sha=git_remote_sha,
871
+ git_remote_branch=git_remote_branch,
872
+ git_remote_url=git_remote_url,
873
+ git_root=git_root,
874
+ ).prepare_component_for_publish(
875
+ component_path,
876
+ image=image,
877
+ name=name,
878
+ description=description,
879
+ annotations=annotations,
880
+ )
881
+
882
+
883
+ def _hook_accepts_context(method: Any) -> bool:
884
+ try:
885
+ signature = inspect.signature(method)
886
+ except (TypeError, ValueError):
887
+ return False
888
+ for parameter in signature.parameters.values():
889
+ if parameter.kind == inspect.Parameter.VAR_KEYWORD:
890
+ return True
891
+ if parameter.name == "context":
892
+ return True
893
+ return False
894
+
895
+
896
+ def _to_plain(value: Any) -> Any:
897
+ if hasattr(value, "to_dict"):
898
+ return value.to_dict()
899
+ if hasattr(value, "model_dump"):
900
+ return value.model_dump(by_alias=True, exclude_none=True)
901
+ if isinstance(value, Mapping):
902
+ return {key: _to_plain(item) for key, item in value.items()}
903
+ if isinstance(value, list):
904
+ return [_to_plain(item) for item in value]
905
+ return value
906
+
907
+
908
+ __all__ = [
909
+ "ComponentPublishContext",
910
+ "ComponentPublishHook",
911
+ "ComponentPublisher",
912
+ "ProcessingOutcome",
913
+ "ProcessingResult",
914
+ "deprecate_component",
915
+ "deprecate_old_components",
916
+ "perform_version_check",
917
+ "prepare_component_for_publish",
918
+ "publish_component",
919
+ "publish_component_to_tangle",
920
+ "read_component_yaml_text",
921
+ ]