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,110 @@
1
+ """Native-free quickstart text for the root ``tangle`` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textwrap import dedent
6
+
7
+ from cyclopts import App
8
+
9
+
10
+ app = App(name="quickstart", help="Print a concise native-free guide to the Tangle CLI.")
11
+
12
+
13
+ QUICKSTART_TEXT = dedent(
14
+ """
15
+ Tangle CLI quickstart
16
+ =====================
17
+
18
+ Command families
19
+ ----------------
20
+ tangle api ...
21
+ Pure OpenAPI wrappers around a Tangle API. Commands are generated from
22
+ the checked-in official schema and can be extended from a live backend
23
+ schema cache. Use these when you want a direct backend endpoint call.
24
+
25
+ tangle sdk ...
26
+ Hand-written SDK commands for local workflows and compound operations.
27
+ Some commands are local-only (for example pipeline validation/layout and
28
+ component generation); others call the API through the generated client
29
+ while adding domain behavior such as hydration, submit payload shaping,
30
+ version checks, or config batching.
31
+
32
+ Common flags and environment
33
+ ----------------------------
34
+ API-backed commands commonly accept:
35
+ --base-url URL API base URL (or TANGLE_API_URL)
36
+ --token TOKEN bearer token (or TANGLE_API_TOKEN)
37
+ --auth-header VALUE full Authorization value, e.g. 'Basic ...' or
38
+ 'Bearer ...' (or TANGLE_API_AUTH_HEADER /
39
+ TANGLE_AUTH_HEADER)
40
+ -H, --header 'N: V' extra headers; repeatable (or TANGLE_API_HEADERS)
41
+ --config PATH YAML/JSON defaults; CLI values win over config
42
+ --log-type TYPE progress logs: console, none, file (SDK commands)
43
+
44
+ TANGLE_VERBOSE=1 enables redacted HTTP request/response diagnostics on
45
+ stderr. It is separate from normal progress logging and should not be
46
+ required for routine hydration/publish progress.
47
+
48
+ Protected API examples
49
+ ----------------------
50
+ tangle api refresh --base-url https://api.example \\
51
+ --auth-header 'Bearer ...' -H 'X-Gateway-Auth: ...'
52
+
53
+ tangle api pipeline-runs list --base-url https://api.example \\
54
+ --auth-header 'Basic ...' -H 'X-Api-Key: ...'
55
+
56
+ tangle sdk pipeline-runs submit pipeline.yaml --base-url https://api.example \\
57
+ --auth-header 'Bearer ...' --log-type console
58
+
59
+ Local SDK examples
60
+ ------------------
61
+ tangle sdk pipelines validate pipeline.yaml
62
+ tangle sdk pipelines hydrate pipeline.yaml --output hydrated.yaml
63
+ tangle sdk components generate from-python component.py --image python:3.12
64
+ tangle sdk components bump-version component.yaml
65
+
66
+ API-backed SDK examples
67
+ -----------------------
68
+ tangle sdk published-components search transformer --base-url https://api.example
69
+ tangle sdk published-components publish component.yaml --dry-run
70
+ tangle sdk pipeline-runs submit pipeline.yaml --dry-run --log-type none
71
+ tangle sdk pipeline-runs status RUN_ID --base-url https://api.example
72
+
73
+ Generated vs hand-written packages
74
+ ----------------------------------
75
+ tangle_cli is the hand-written package: CLI wiring, local SDK workflows,
76
+ dynamic schema discovery, codegen, logging, hydrator/resolver logic, and
77
+ extension hooks.
78
+
79
+ tangle_api is the generated/native package: checked-in Pydantic models,
80
+ endpoint operation methods, and the official OpenAPI snapshot. Local-only
81
+ SDK commands and this quickstart do not need it. Static API-backed commands
82
+ need tangle-cli[native] or an equivalent local tangle_api.generated package.
83
+
84
+ Generated model extensions use private generated bases plus stable public
85
+ subclasses, e.g. ComponentSpec(ComponentSpecExtensions,
86
+ _ComponentSpecGenerated). Extension bases are left of the generated base in
87
+ the MRO, and downstream --model-extension-module values can add/override
88
+ behavior while preserving generated fields and stable names.
89
+
90
+ Discover more
91
+ -------------
92
+ tangle --help
93
+ tangle api --help
94
+ tangle api refresh --help
95
+ tangle sdk --help
96
+ tangle sdk pipelines --help
97
+ tangle sdk pipeline-runs submit --help
98
+
99
+ See README.md for codegen/autogen instructions and extension surfaces:
100
+ hydrator resolvers, PipelineRunHooks, ComponentPublishHook, and shared CLI
101
+ options/logging helpers.
102
+ """
103
+ ).strip()
104
+
105
+
106
+ @app.default
107
+ def quickstart() -> None:
108
+ """Print a concise native-free guide to the Tangle CLI."""
109
+
110
+ print(QUICKSTART_TEXT)
tangle_cli/secrets.py ADDED
@@ -0,0 +1,156 @@
1
+ """Read/write helpers for Tangle secret metadata and values.
2
+
3
+ Secret values are accepted only for explicit create/update operations and are
4
+ never included in returned metadata dictionaries.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any, Protocol
11
+
12
+ from .handler import TangleCliHandler
13
+
14
+
15
+ class SecretClient(Protocol):
16
+ """Subset of the generated static client used by secret commands."""
17
+
18
+ def secrets_list(self) -> Any: ...
19
+
20
+ def secrets_create(
21
+ self,
22
+ secret_name: str,
23
+ secret_value: str,
24
+ description: str | None = None,
25
+ expires_at: str | None = None,
26
+ ) -> Any: ...
27
+
28
+ def secrets_update(
29
+ self,
30
+ secret_name: str,
31
+ secret_value: str,
32
+ description: str | None = None,
33
+ expires_at: str | None = None,
34
+ ) -> Any: ...
35
+
36
+ def secrets_delete(self, secret_name: str) -> Any: ...
37
+
38
+
39
+ class SecretValueError(ValueError):
40
+ """Raised when secret value CLI/config inputs are invalid."""
41
+
42
+
43
+ class SecretsManager(TangleCliHandler):
44
+ """Secret resource manager with injectable client construction.
45
+
46
+ Downstream packages can inject an authenticated client directly or provide a
47
+ lazy ``client_factory``. Returned dictionaries intentionally omit secret
48
+ values and only include metadata.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ client: SecretClient | None = None,
54
+ *,
55
+ client_factory: Any | None = None,
56
+ logger: Any | None = None,
57
+ **kwargs: Any,
58
+ ) -> None:
59
+ super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs)
60
+
61
+ @staticmethod
62
+ def resolve_secret_value(value: str | None, from_env: str | None) -> str:
63
+ """Resolve the secret value from either ``--value`` or ``--from-env``.
64
+
65
+ Error messages intentionally mention only the option/env-var name and
66
+ never include the secret value.
67
+ """
68
+
69
+ if value is not None and from_env is not None:
70
+ raise SecretValueError("specify either --value or --from-env, not both")
71
+ if from_env is not None:
72
+ resolved = os.environ.get(from_env)
73
+ if resolved is None:
74
+ raise SecretValueError(f"environment variable '{from_env}' is not set")
75
+ return resolved
76
+ if value is not None:
77
+ return value
78
+ raise SecretValueError("either --value or --from-env is required")
79
+
80
+ @staticmethod
81
+ def secret_metadata(secret: Any) -> dict[str, Any]:
82
+ """Return JSON-safe secret metadata, excluding any secret value fields."""
83
+
84
+ entry: dict[str, Any] = {}
85
+ for field in ("secret_name", "created_at", "updated_at", "expires_at", "description"):
86
+ value = _value_from_mapping_or_object(secret, field)
87
+ if value is not None:
88
+ entry[field] = str(value)
89
+ return entry
90
+
91
+ def list(self) -> dict[str, Any]:
92
+ """List secret metadata without exposing secret values."""
93
+
94
+ response = self._require_client().secrets_list()
95
+ raw_secrets = _value_from_mapping_or_object(response, "secrets", []) or []
96
+ secrets = [self.secret_metadata(secret) for secret in raw_secrets]
97
+ return {"status": "success", "count": len(secrets), "secrets": secrets}
98
+
99
+ def create(
100
+ self,
101
+ secret_name: str,
102
+ *,
103
+ value: str | None = None,
104
+ from_env: str | None = None,
105
+ description: str | None = None,
106
+ expires_at: str | None = None,
107
+ ) -> dict[str, Any]:
108
+ """Create a secret using generated static API operations."""
109
+
110
+ secret_value = self.resolve_secret_value(value, from_env)
111
+ secret = self._require_client().secrets_create(
112
+ secret_name,
113
+ secret_value,
114
+ description=description,
115
+ expires_at=expires_at,
116
+ )
117
+ return {"status": "success", "action": "created", "secret": self.secret_metadata(secret)}
118
+
119
+ def update(
120
+ self,
121
+ secret_name: str,
122
+ *,
123
+ value: str | None = None,
124
+ from_env: str | None = None,
125
+ description: str | None = None,
126
+ expires_at: str | None = None,
127
+ ) -> dict[str, Any]:
128
+ """Update a secret using generated static API operations."""
129
+
130
+ secret_value = self.resolve_secret_value(value, from_env)
131
+ secret = self._require_client().secrets_update(
132
+ secret_name,
133
+ secret_value,
134
+ description=description,
135
+ expires_at=expires_at,
136
+ )
137
+ return {"status": "success", "action": "updated", "secret": self.secret_metadata(secret)}
138
+
139
+ def delete(self, secret_name: str) -> dict[str, Any]:
140
+ """Delete a secret using generated static API operations."""
141
+
142
+ self._require_client().secrets_delete(secret_name)
143
+ return {"status": "success", "action": "deleted", "secret_name": secret_name}
144
+
145
+
146
+ def _value_from_mapping_or_object(value: Any, key: str, default: Any = None) -> Any:
147
+ if isinstance(value, dict):
148
+ return value.get(key, default)
149
+ return getattr(value, key, default)
150
+
151
+
152
+ __all__ = [
153
+ "SecretClient",
154
+ "SecretValueError",
155
+ "SecretsManager",
156
+ ]
@@ -0,0 +1,269 @@
1
+ """`tangle sdk secrets` command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Annotated, Any, Callable
7
+
8
+ from cyclopts import App, Parameter
9
+
10
+ from .args_container import ArgsContainer
11
+ from .cli_helpers import (
12
+ LazyTangleApiClient,
13
+ api_arg_specs,
14
+ include_env_credentials_for_args,
15
+ load_args_or_exit,
16
+ print_json,
17
+ )
18
+ from .cli_options import (
19
+ AuthHeaderOption,
20
+ BaseUrlOption,
21
+ ConfigOption,
22
+ HeaderOption,
23
+ LogTypeOption,
24
+ TokenOption,
25
+ )
26
+ from .logger import Logger, logger_for_log_type
27
+ from .secrets import SecretsManager, SecretValueError
28
+
29
+ ValueOption = Annotated[
30
+ str | None,
31
+ Parameter(
32
+ name="--value",
33
+ alias="-v",
34
+ help="Secret value. Prefer --from-env to avoid shell history exposure.",
35
+ ),
36
+ ]
37
+ FromEnvOption = Annotated[
38
+ str | None,
39
+ Parameter(
40
+ name="--from-env",
41
+ alias="-e",
42
+ help="Read secret value from this environment variable.",
43
+ ),
44
+ ]
45
+ DescriptionOption = Annotated[
46
+ str | None,
47
+ Parameter(name="--description", alias="-d", help="Secret description."),
48
+ ]
49
+ ExpiresAtOption = Annotated[
50
+ str | None,
51
+ Parameter(help="Expiration datetime (ISO 8601)."),
52
+ ]
53
+ ForceOption = Annotated[
54
+ bool,
55
+ Parameter(help="Skip confirmation prompt."),
56
+ ]
57
+
58
+ app = App(name="secrets", help="Manage Tangle secrets.")
59
+
60
+
61
+ def _client(args: ArgsContainer, *, cli_base_url: str | None, command_name: str) -> LazyTangleApiClient:
62
+ return LazyTangleApiClient(
63
+ base_url=args.base_url,
64
+ token=args.token,
65
+ auth_header=args.auth_header,
66
+ header=args.header,
67
+ include_env_credentials=include_env_credentials_for_args(args, cli_base_url),
68
+ command_name=command_name,
69
+ )
70
+
71
+
72
+ def _run_secret_action(
73
+ config: str | None,
74
+ cli_base_url: str | None,
75
+ specs: dict[str, tuple[Any, ...]],
76
+ fn: Callable[[Any, ArgsContainer, Logger], dict[str, Any]],
77
+ ) -> None:
78
+ results: list[dict[str, Any]] = []
79
+ for args in load_args_or_exit(config, **specs):
80
+ logger, finalize_logs = logger_for_log_type(getattr(args, "log_type", "console"))
81
+ try:
82
+ client = _client(args, cli_base_url=cli_base_url, command_name="secret commands")
83
+ try:
84
+ results.append(fn(client, args, logger))
85
+ except SecretValueError as exc:
86
+ raise SystemExit(str(exc)) from exc
87
+ finally:
88
+ finalize_logs()
89
+
90
+ print_json(results[0] if len(results) == 1 else {"status": "success", "results": results})
91
+
92
+
93
+ def _secret_mutation_specs(
94
+ *,
95
+ secret_name: str | None,
96
+ value: str | None,
97
+ from_env: str | None,
98
+ description: str | None,
99
+ expires_at: str | None,
100
+ base_url: str | None,
101
+ token: str | None,
102
+ auth_header: str | None,
103
+ header: list[str] | None,
104
+ log_type: str,
105
+ ) -> dict[str, tuple[Any, ...]]:
106
+ return {
107
+ "secret_name": ("secret_name", secret_name, None, False, True),
108
+ "value": (value, None),
109
+ "from_env": (from_env, None),
110
+ "description": (description, None),
111
+ "expires_at": (expires_at, None),
112
+ "log_type": (log_type, "console"),
113
+ **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header),
114
+ }
115
+
116
+
117
+ def _confirm_delete(secret_name: str) -> None:
118
+ prompt = f"Are you sure you want to delete secret '{secret_name}'? [y/N]: "
119
+ print(prompt, end="", file=sys.stderr, flush=True)
120
+ try:
121
+ response = input()
122
+ except EOFError as exc: # pragma: no cover - defensive for non-interactive shells
123
+ raise SystemExit("Delete cancelled") from exc
124
+ if response.strip().lower() not in {"y", "yes"}:
125
+ raise SystemExit("Delete cancelled")
126
+
127
+
128
+ @app.command(name="list")
129
+ def secrets_list(
130
+ *,
131
+ base_url: BaseUrlOption = None,
132
+ token: TokenOption = None,
133
+ auth_header: AuthHeaderOption = None,
134
+ header: HeaderOption = None,
135
+ config: ConfigOption = None,
136
+ log_type: LogTypeOption = "console",
137
+ ) -> None:
138
+ """List secret metadata. Secret values are never returned."""
139
+
140
+ specs = {
141
+ "log_type": (log_type, "console"),
142
+ **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header),
143
+ }
144
+
145
+ def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]:
146
+ result = SecretsManager(client=client).list()
147
+ logger.info(f"Listed {result['count']} secret(s).")
148
+ return result
149
+
150
+ _run_secret_action(config, base_url, specs, action)
151
+
152
+
153
+ @app.command(name="create")
154
+ def secrets_create(
155
+ secret_name: str | None = None,
156
+ *,
157
+ value: ValueOption = None,
158
+ from_env: FromEnvOption = None,
159
+ description: DescriptionOption = None,
160
+ expires_at: ExpiresAtOption = None,
161
+ base_url: BaseUrlOption = None,
162
+ token: TokenOption = None,
163
+ auth_header: AuthHeaderOption = None,
164
+ header: HeaderOption = None,
165
+ config: ConfigOption = None,
166
+ log_type: LogTypeOption = "console",
167
+ ) -> None:
168
+ """Create a new secret."""
169
+
170
+ specs = _secret_mutation_specs(
171
+ secret_name=secret_name,
172
+ value=value,
173
+ from_env=from_env,
174
+ description=description,
175
+ expires_at=expires_at,
176
+ base_url=base_url,
177
+ token=token,
178
+ auth_header=auth_header,
179
+ header=header,
180
+ log_type=log_type,
181
+ )
182
+
183
+ def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]:
184
+ result = SecretsManager(client=client).create(
185
+ args.secret_name,
186
+ value=args.value,
187
+ from_env=args.from_env,
188
+ description=args.description,
189
+ expires_at=args.expires_at,
190
+ )
191
+ logger.info(f"Created secret: {args.secret_name}")
192
+ return result
193
+
194
+ _run_secret_action(config, base_url, specs, action)
195
+
196
+
197
+ @app.command(name="update")
198
+ def secrets_update(
199
+ secret_name: str | None = None,
200
+ *,
201
+ value: ValueOption = None,
202
+ from_env: FromEnvOption = None,
203
+ description: DescriptionOption = None,
204
+ expires_at: ExpiresAtOption = None,
205
+ base_url: BaseUrlOption = None,
206
+ token: TokenOption = None,
207
+ auth_header: AuthHeaderOption = None,
208
+ header: HeaderOption = None,
209
+ config: ConfigOption = None,
210
+ log_type: LogTypeOption = "console",
211
+ ) -> None:
212
+ """Update an existing secret."""
213
+
214
+ specs = _secret_mutation_specs(
215
+ secret_name=secret_name,
216
+ value=value,
217
+ from_env=from_env,
218
+ description=description,
219
+ expires_at=expires_at,
220
+ base_url=base_url,
221
+ token=token,
222
+ auth_header=auth_header,
223
+ header=header,
224
+ log_type=log_type,
225
+ )
226
+
227
+ def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]:
228
+ result = SecretsManager(client=client).update(
229
+ args.secret_name,
230
+ value=args.value,
231
+ from_env=args.from_env,
232
+ description=args.description,
233
+ expires_at=args.expires_at,
234
+ )
235
+ logger.info(f"Updated secret: {args.secret_name}")
236
+ return result
237
+
238
+ _run_secret_action(config, base_url, specs, action)
239
+
240
+
241
+ @app.command(name="delete")
242
+ def secrets_delete(
243
+ secret_name: str | None = None,
244
+ *,
245
+ force: ForceOption = False,
246
+ base_url: BaseUrlOption = None,
247
+ token: TokenOption = None,
248
+ auth_header: AuthHeaderOption = None,
249
+ header: HeaderOption = None,
250
+ config: ConfigOption = None,
251
+ log_type: LogTypeOption = "console",
252
+ ) -> None:
253
+ """Delete a secret. Prompts for confirmation unless ``--force`` is used."""
254
+
255
+ specs = {
256
+ "secret_name": ("secret_name", secret_name, None, False, True),
257
+ "force": (force, False),
258
+ "log_type": (log_type, "console"),
259
+ **api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header),
260
+ }
261
+
262
+ def action(client: Any, args: ArgsContainer, logger: Logger) -> dict[str, Any]:
263
+ if not args.force:
264
+ _confirm_delete(args.secret_name)
265
+ result = SecretsManager(client=client).delete(args.secret_name)
266
+ logger.info(f"Deleted secret: {args.secret_name}")
267
+ return result
268
+
269
+ _run_secret_action(config, base_url, specs, action)