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,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
|
+
]
|