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,269 @@
1
+ import pathlib
2
+ import sys
3
+ from typing import Annotated, Any
4
+
5
+ from cyclopts import App, Parameter
6
+
7
+ from .cli_helpers import load_args_or_exit, optional_path
8
+ from .cli_options import ConfigOption, LogTypeOption
9
+ from .logger import logger_for_log_type
10
+
11
+ app = App(name="components", help="Work with Tangle component definitions.")
12
+
13
+ generate_app = App(name="generate", help="Generate component definition files.")
14
+ app.command(generate_app)
15
+
16
+ component_references_app = App(
17
+ name="component-references", help="Work with component reference metadata."
18
+ )
19
+ app.command(component_references_app)
20
+
21
+ annotations_app = App(name="annotations", help="Work with component annotations.")
22
+ app.command(annotations_app)
23
+
24
+ # region components
25
+
26
+
27
+ @app.command(name="validate")
28
+ def components_validate(component_path: str):
29
+ raise NotImplementedError()
30
+
31
+
32
+ @app.command(name="set-container-image")
33
+ def components_set_container_image(component_path: str):
34
+ raise NotImplementedError()
35
+
36
+
37
+ # endregion
38
+
39
+
40
+ # region components/annotations
41
+
42
+
43
+ def _missing_required_args(command_name: str, provided: dict[str, object]) -> None:
44
+ """Print help for truly empty commands, but error on partial invocations."""
45
+
46
+ if all(value is None for value in provided.values()):
47
+ annotations_app.help_print([command_name])
48
+ raise SystemExit(0)
49
+
50
+ missing = [name for name, value in provided.items() if value is None]
51
+ print(f"Missing required argument(s): {', '.join(missing)}", file=sys.stderr)
52
+ raise SystemExit(1)
53
+
54
+
55
+ @annotations_app.command(name="set")
56
+ def components_annotations_set(
57
+ component_path: str | None = None,
58
+ key: str | None = None,
59
+ value: str | None = None,
60
+ output_component_path: str | None = None,
61
+ ):
62
+ """Sets annotation value in component file."""
63
+ if component_path is None or key is None or value is None:
64
+ _missing_required_args(
65
+ "set",
66
+ {"component_path": component_path, "key": key, "value": value},
67
+ )
68
+ raise NotImplementedError()
69
+
70
+
71
+ @annotations_app.command(name="get")
72
+ def components_annotations_get(
73
+ component_path: str | None = None, keys: list[str] | None = None
74
+ ):
75
+ """Gets annotation values from component file."""
76
+ if component_path is None or keys is None:
77
+ _missing_required_args("get", {"component_path": component_path, "keys": keys})
78
+ raise NotImplementedError()
79
+
80
+
81
+ # endregion
82
+
83
+
84
+ # region components/generate
85
+
86
+
87
+ @generate_app.command(name="from-template", show=False)
88
+ def components_generate_from_template(
89
+ template_name: str,
90
+ output_component_path: pathlib.Path,
91
+ ):
92
+ raise NotImplementedError()
93
+
94
+
95
+ def _components_generate_from_python_impl(
96
+ *,
97
+ python_file: pathlib.Path | None = None,
98
+ output: pathlib.Path | None = None,
99
+ name: str | None = None,
100
+ function_name: str | None = None,
101
+ image: str | None = None,
102
+ dependencies_from: pathlib.Path | None = None,
103
+ strip_code: bool | None = None,
104
+ use_legacy_naming: bool | None = None,
105
+ mode: str | None = None,
106
+ resolve_root: pathlib.Path | None = None,
107
+ config: str | None = None,
108
+ log_type: str = "console",
109
+ ) -> None:
110
+ all_args = load_args_or_exit(
111
+ config,
112
+ python_file=("python_file", python_file, None, False, True, optional_path),
113
+ output=(output, None, optional_path),
114
+ name=(name, None),
115
+ function_name=("function", function_name, None, False),
116
+ image=(image, None),
117
+ dependencies_from=(dependencies_from, None, optional_path),
118
+ strip_code=(strip_code, None),
119
+ use_legacy_naming=(use_legacy_naming, None),
120
+ mode=(mode, None),
121
+ resolve_root=(resolve_root, None, optional_path),
122
+ log_type=(log_type, "console"),
123
+ )
124
+ for args in all_args:
125
+ logger, finalize_logs = logger_for_log_type(args.log_type)
126
+ from .component_generator import ComponentGenerator
127
+
128
+ generator = ComponentGenerator(logger=logger, verbose=True)
129
+ selected_mode = args.mode or "inline"
130
+ if selected_mode not in {"inline", "bundle"}:
131
+ raise SystemExit("--mode must be 'inline' or 'bundle'")
132
+ python_path = pathlib.Path(args.python_file)
133
+ output_path = generator.determine_output_path(
134
+ python_path,
135
+ args.output,
136
+ output_is_dir=False,
137
+ use_legacy_naming=bool(args.use_legacy_naming),
138
+ )
139
+ try:
140
+ success = generator.regenerate_yaml(
141
+ python_file=python_path,
142
+ output_path=output_path,
143
+ function_name=args.function_name,
144
+ custom_name=args.name,
145
+ image=args.image,
146
+ dependencies_from=args.dependencies_from,
147
+ strip_code=bool(args.strip_code),
148
+ mode=selected_mode,
149
+ resolve_root=args.resolve_root,
150
+ )
151
+ if not success:
152
+ raise SystemExit(1)
153
+ finally:
154
+ finalize_logs()
155
+
156
+
157
+ @generate_app.command(name="from-python")
158
+ def components_generate_from_python(
159
+ python_file: pathlib.Path | None = None,
160
+ *,
161
+ output: pathlib.Path | None = None,
162
+ name: str | None = None,
163
+ function_name: Annotated[
164
+ str | None,
165
+ Parameter(name="--function", alias="-f", help="Function name to extract."),
166
+ ] = None,
167
+ image: str | None = None,
168
+ dependencies_from: pathlib.Path | None = None,
169
+ strip_code: bool | None = None,
170
+ use_legacy_naming: bool | None = None,
171
+ mode: str | None = None,
172
+ resolve_root: pathlib.Path | None = None,
173
+ config: ConfigOption = None,
174
+ log_type: LogTypeOption = "console",
175
+ ) -> None:
176
+ """Generate a component YAML file from a local Python function."""
177
+
178
+ _components_generate_from_python_impl(
179
+ python_file=python_file,
180
+ output=output,
181
+ name=name,
182
+ function_name=function_name,
183
+ image=image,
184
+ dependencies_from=dependencies_from,
185
+ strip_code=strip_code,
186
+ use_legacy_naming=use_legacy_naming,
187
+ mode=mode,
188
+ resolve_root=resolve_root,
189
+ config=config,
190
+ log_type=log_type,
191
+ )
192
+
193
+
194
+ @generate_app.command(name="from-python-function")
195
+ def components_generate_from_python_function(
196
+ python_file: pathlib.Path | None = None,
197
+ *,
198
+ output: pathlib.Path | None = None,
199
+ name: str | None = None,
200
+ function_name: Annotated[
201
+ str | None,
202
+ Parameter(name="--function", alias="-f", help="Function name to extract."),
203
+ ] = None,
204
+ image: str | None = None,
205
+ dependencies_from: pathlib.Path | None = None,
206
+ strip_code: bool | None = None,
207
+ use_legacy_naming: bool | None = None,
208
+ mode: str | None = None,
209
+ resolve_root: pathlib.Path | None = None,
210
+ config: ConfigOption = None,
211
+ log_type: LogTypeOption = "console",
212
+ ) -> None:
213
+ """Compatibility alias for `generate from-python`."""
214
+
215
+ _components_generate_from_python_impl(
216
+ python_file=python_file,
217
+ output=output,
218
+ name=name,
219
+ function_name=function_name,
220
+ image=image,
221
+ dependencies_from=dependencies_from,
222
+ strip_code=strip_code,
223
+ use_legacy_naming=use_legacy_naming,
224
+ mode=mode,
225
+ resolve_root=resolve_root,
226
+ config=config,
227
+ log_type=log_type,
228
+ )
229
+
230
+
231
+ # endregion
232
+
233
+
234
+ @app.command(name="bump-version")
235
+ def components_bump_version(
236
+ yaml_file: pathlib.Path | None = None,
237
+ *,
238
+ set_version: str | None = None,
239
+ update_timestamp: bool | None = None,
240
+ config: ConfigOption = None,
241
+ log_type: LogTypeOption = "console",
242
+ ) -> None:
243
+ """Bump version metadata in a component YAML file."""
244
+
245
+ all_args = load_args_or_exit(
246
+ config,
247
+ yaml_file=("yaml_file", yaml_file, None, False, True, optional_path),
248
+ set_version=(set_version, None),
249
+ update_timestamp=(update_timestamp, None),
250
+ log_type=(log_type, "console"),
251
+ )
252
+ result: dict[str, Any] = {}
253
+ from .version_manager import bump_version
254
+
255
+ for args in all_args:
256
+ logger, finalize_logs = logger_for_log_type(args.log_type)
257
+ try:
258
+ result = bump_version(
259
+ args.yaml_file,
260
+ set_version=args.set_version,
261
+ update_timestamp=bool(args.update_timestamp),
262
+ logger=logger,
263
+ )
264
+ if result.get("status") != "success":
265
+ raise SystemExit(1)
266
+ finally:
267
+ finalize_logs()
268
+ if result:
269
+ print(result)
@@ -0,0 +1,296 @@
1
+ """Programmatic dynamic-discovery client for Tangle backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .api_schema import (
11
+ OperationCommand,
12
+ fetch_schema,
13
+ load_cached_schema,
14
+ operation_aliases,
15
+ operation_map,
16
+ refresh_schema,
17
+ resolve_operation,
18
+ )
19
+ from .api_transport import (
20
+ DEFAULT_TIMEOUT_SECONDS,
21
+ _normalize_base_url,
22
+ default_base_url,
23
+ request_operation,
24
+ )
25
+
26
+
27
+ class TangleDynamicDiscoveryClient:
28
+ """Dynamic-discovery client generated from a Tangle OpenAPI schema.
29
+
30
+ The client intentionally reuses the same schema cache, operation naming,
31
+ parameter mapping, URL construction, and auth/header handling as
32
+ ``tangle api ...``. No network or cache work happens at import time; choose
33
+ one of the ``from_*`` constructors to provide or load a schema.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ schema: dict[str, Any],
39
+ *,
40
+ base_url: str | None = None,
41
+ headers: dict[str, str] | None = None,
42
+ token: str | None = None,
43
+ auth_header: str | None = None,
44
+ header: list[str] | str | None = None,
45
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
46
+ ) -> None:
47
+ self.schema = schema
48
+ self.base_url = _normalize_base_url(base_url or default_base_url())
49
+ self.headers = dict(headers or {})
50
+ self.token = token
51
+ self.auth_header = auth_header
52
+ self.header = _header_list(header)
53
+ self.timeout = timeout
54
+ self._operations = operation_map(schema)
55
+ self._aliases = self._build_alias_map(self._operations)
56
+ self._groups = self._build_groups(self._operations)
57
+
58
+ @classmethod
59
+ def from_schema(
60
+ cls,
61
+ schema: dict[str, Any],
62
+ *,
63
+ base_url: str | None = None,
64
+ headers: dict[str, str] | None = None,
65
+ token: str | None = None,
66
+ auth_header: str | None = None,
67
+ header: list[str] | str | None = None,
68
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
69
+ ) -> TangleDynamicDiscoveryClient:
70
+ """Create a client from an already loaded OpenAPI schema."""
71
+
72
+ return cls(
73
+ schema,
74
+ base_url=base_url,
75
+ headers=headers,
76
+ token=token,
77
+ auth_header=auth_header,
78
+ header=header,
79
+ timeout=timeout,
80
+ )
81
+
82
+ @classmethod
83
+ def from_cache(
84
+ cls,
85
+ base_url: str | None = None,
86
+ *,
87
+ headers: dict[str, str] | None = None,
88
+ token: str | None = None,
89
+ auth_header: str | None = None,
90
+ header: list[str] | str | None = None,
91
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
92
+ ) -> TangleDynamicDiscoveryClient:
93
+ """Create a client from the local schema cache without network access."""
94
+
95
+ normalized_base_url = _normalize_base_url(base_url or default_base_url())
96
+ schema = load_cached_schema(normalized_base_url)
97
+ if schema is None:
98
+ raise FileNotFoundError(
99
+ f"No cached OpenAPI schema for {normalized_base_url}; "
100
+ "call TangleDynamicDiscoveryClient.from_cache_or_refresh(...) or run `tangle api refresh`."
101
+ )
102
+ return cls.from_schema(
103
+ schema,
104
+ base_url=normalized_base_url,
105
+ headers=headers,
106
+ token=token,
107
+ auth_header=auth_header,
108
+ header=header,
109
+ timeout=timeout,
110
+ )
111
+
112
+ @classmethod
113
+ def from_url(
114
+ cls,
115
+ base_url: str | None = None,
116
+ *,
117
+ headers: dict[str, str] | None = None,
118
+ token: str | None = None,
119
+ auth_header: str | None = None,
120
+ header: list[str] | str | None = None,
121
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
122
+ ) -> TangleDynamicDiscoveryClient:
123
+ """Fetch ``/openapi.json`` and create a client without writing the cache."""
124
+
125
+ normalized_base_url = _normalize_base_url(base_url or default_base_url())
126
+ schema = fetch_schema(
127
+ normalized_base_url,
128
+ token=token,
129
+ header=header,
130
+ auth_header=auth_header,
131
+ headers=headers,
132
+ )
133
+ return cls.from_schema(
134
+ schema,
135
+ base_url=normalized_base_url,
136
+ headers=headers,
137
+ token=token,
138
+ auth_header=auth_header,
139
+ header=header,
140
+ timeout=timeout,
141
+ )
142
+
143
+ @classmethod
144
+ def from_cache_or_refresh(
145
+ cls,
146
+ base_url: str | None = None,
147
+ *,
148
+ headers: dict[str, str] | None = None,
149
+ token: str | None = None,
150
+ auth_header: str | None = None,
151
+ header: list[str] | str | None = None,
152
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
153
+ ) -> TangleDynamicDiscoveryClient:
154
+ """Create a client from cache, fetching and caching the schema on miss."""
155
+
156
+ normalized_base_url = _normalize_base_url(base_url or default_base_url())
157
+ schema = load_cached_schema(normalized_base_url)
158
+ if schema is None:
159
+ schema, _ = refresh_schema(
160
+ normalized_base_url,
161
+ token=token,
162
+ header=header,
163
+ auth_header=auth_header,
164
+ headers=headers,
165
+ )
166
+ return cls.from_schema(
167
+ schema,
168
+ base_url=normalized_base_url,
169
+ headers=headers,
170
+ token=token,
171
+ auth_header=auth_header,
172
+ header=header,
173
+ timeout=timeout,
174
+ )
175
+
176
+ @property
177
+ def operations(self) -> tuple[str, ...]:
178
+ """Canonical operation names exposed by this schema."""
179
+
180
+ return tuple(sorted(self._operations))
181
+
182
+ def request(self, operation_name: str, **params: Any) -> httpx.Response:
183
+ """Perform an operation and return the raw ``httpx.Response``.
184
+
185
+ Operation parameters are passed as keyword arguments. Per-call overrides
186
+ for ``base_url``, ``token``, ``auth_header``, ``header``, ``headers``,
187
+ ``body``, and ``timeout`` are also supported.
188
+ """
189
+
190
+ operation = self._resolve(operation_name)
191
+ base_url = params.pop("base_url", self.base_url)
192
+ token = params.pop("token", self.token)
193
+ auth_header = params.pop("auth_header", self.auth_header)
194
+ header_override = params.pop("header", None)
195
+ header = self.header + _header_list(header_override)
196
+ headers_override = params.pop("headers", None)
197
+ headers = {**self.headers, **dict(headers_override or {})}
198
+ body = params.pop("body", None)
199
+ timeout = params.pop("timeout", self.timeout)
200
+ return request_operation(
201
+ operation,
202
+ params,
203
+ base_url=base_url,
204
+ token=token,
205
+ auth_header=auth_header,
206
+ header_entries=header,
207
+ headers=headers,
208
+ body=body,
209
+ timeout=timeout,
210
+ )
211
+
212
+ def call(self, operation_name: str, **params: Any) -> Any:
213
+ """Perform an operation and decode the response body.
214
+
215
+ JSON responses return decoded JSON; text responses return ``str``;
216
+ non-text responses return ``bytes``; empty responses return ``None``.
217
+ """
218
+
219
+ return decode_response(self.request(operation_name, **params))
220
+
221
+ def __getattr__(self, name: str) -> _OperationGroup:
222
+ canonical_group = self._groups.get(name) or self._groups.get(name.replace("_", "-"))
223
+ if canonical_group is None:
224
+ raise AttributeError(name)
225
+ return _OperationGroup(self, canonical_group)
226
+
227
+ def _resolve(self, operation_name: str) -> OperationCommand:
228
+ return self._aliases.get(operation_name) or resolve_operation(self._operations, operation_name)
229
+
230
+ @staticmethod
231
+ def _build_alias_map(
232
+ operations: dict[str, OperationCommand]
233
+ ) -> dict[str, OperationCommand]:
234
+ aliases: dict[str, OperationCommand] = {}
235
+ for name, operation in operations.items():
236
+ for alias in operation_aliases(name):
237
+ aliases.setdefault(alias, operation)
238
+ return aliases
239
+
240
+ @staticmethod
241
+ def _build_groups(
242
+ operations: dict[str, OperationCommand]
243
+ ) -> dict[str, str]:
244
+ groups: dict[str, str] = {}
245
+ for operation in operations.values():
246
+ groups.setdefault(operation.group_name, operation.group_name)
247
+ groups.setdefault(operation.group_name.replace("-", "_"), operation.group_name)
248
+ return groups
249
+
250
+
251
+ class _OperationGroup:
252
+ """Dynamic operation namespace returned by ``client.<resource>``."""
253
+
254
+ def __init__(self, client: TangleDynamicDiscoveryClient, group_name: str) -> None:
255
+ self._client = client
256
+ self._group_name = group_name
257
+
258
+ def call(self, command_name: str, **params: Any) -> Any:
259
+ return self._client.call(f"{self._group_name}.{command_name}", **params)
260
+
261
+ def request(self, command_name: str, **params: Any) -> httpx.Response:
262
+ return self._client.request(f"{self._group_name}.{command_name}", **params)
263
+
264
+ def __getattr__(self, name: str) -> Callable[..., Any]:
265
+ operation_name = f"{self._group_name}.{name}"
266
+ try:
267
+ self._client._resolve(operation_name)
268
+ except KeyError as exc:
269
+ raise AttributeError(name) from exc
270
+
271
+ def operation(**params: Any) -> Any:
272
+ return self._client.call(operation_name, **params)
273
+
274
+ operation.__name__ = name
275
+ return operation
276
+
277
+
278
+ def _header_list(header: list[str] | str | None) -> list[str]:
279
+ if header is None:
280
+ return []
281
+ if isinstance(header, str):
282
+ return [header]
283
+ return list(header)
284
+
285
+
286
+ def decode_response(response: httpx.Response) -> Any:
287
+ """Decode an ``httpx.Response`` into JSON, text, bytes, or ``None``."""
288
+
289
+ if not response.content:
290
+ return None
291
+ content_type = response.headers.get("Content-Type", "").lower()
292
+ if "json" in content_type:
293
+ return response.json()
294
+ if content_type.startswith("text/") or "charset=" in content_type:
295
+ return response.text
296
+ return response.content