kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Make API calls from the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated, Any, cast
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from kstlib.cli.common import CommandResult, CommandStatus, console, exit_error, exit_with_result
|
|
11
|
+
from kstlib.limits import get_rapi_render_config
|
|
12
|
+
from kstlib.rapi import (
|
|
13
|
+
CredentialError,
|
|
14
|
+
EndpointAmbiguousError,
|
|
15
|
+
EndpointNotFoundError,
|
|
16
|
+
RapiClient,
|
|
17
|
+
RapiResponse,
|
|
18
|
+
RequestError,
|
|
19
|
+
ResponseTooLargeError,
|
|
20
|
+
load_rapi_config,
|
|
21
|
+
)
|
|
22
|
+
from kstlib.utils.serialization import is_xml_content, to_json, to_xml
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_args(
|
|
26
|
+
args: list[str],
|
|
27
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
28
|
+
"""Parse positional and keyword arguments.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
args: List of arguments like ["3", "foo=bar", "count=42"].
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Tuple of (positional_args, keyword_args).
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
>>> _parse_args(["3", "foo=bar", "count=42"])
|
|
38
|
+
(['3'], {'foo': 'bar', 'count': '42'})
|
|
39
|
+
>>> _parse_args(["value1", "value2"])
|
|
40
|
+
(['value1', 'value2'], {})
|
|
41
|
+
"""
|
|
42
|
+
positional: list[str] = []
|
|
43
|
+
keyword: dict[str, str] = {}
|
|
44
|
+
|
|
45
|
+
for arg in args:
|
|
46
|
+
if "=" in arg:
|
|
47
|
+
key, value = arg.split("=", 1)
|
|
48
|
+
keyword[key] = value
|
|
49
|
+
else:
|
|
50
|
+
positional.append(arg)
|
|
51
|
+
|
|
52
|
+
return positional, keyword
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_headers(headers: list[str]) -> dict[str, str]:
|
|
56
|
+
"""Parse header arguments.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
headers: List of headers like ["Accept: application/json", "X-Debug: true"].
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dictionary of header name to value.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
typer.Exit: If header format is invalid.
|
|
66
|
+
"""
|
|
67
|
+
result: dict[str, str] = {}
|
|
68
|
+
for header in headers:
|
|
69
|
+
if ":" not in header:
|
|
70
|
+
exit_error(f"Invalid header format: '{header}'\nExpected: 'Header-Name: value'")
|
|
71
|
+
name, value = header.split(":", 1)
|
|
72
|
+
result[name.strip()] = value.strip()
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_body(body: str | None) -> dict[str, Any] | list[Any] | None:
|
|
77
|
+
"""Parse JSON body string or load from file.
|
|
78
|
+
|
|
79
|
+
Supports reading from file with @filename syntax (like curl).
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
body: JSON string, @filename reference, or None.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Parsed JSON object or None.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
typer.Exit: If body is not valid JSON or file not found.
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
>>> _parse_body('{"key": "value"}')
|
|
92
|
+
{'key': 'value'}
|
|
93
|
+
>>> _parse_body('@data.json') # Reads from file
|
|
94
|
+
{'key': 'value'}
|
|
95
|
+
"""
|
|
96
|
+
if body is None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# Support @filename syntax (like curl)
|
|
100
|
+
if body.startswith("@"):
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
|
|
103
|
+
filepath = Path(body[1:])
|
|
104
|
+
try:
|
|
105
|
+
content = filepath.read_text(encoding="utf-8")
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
exit_error(f"Body file not found: {filepath}")
|
|
108
|
+
except OSError as e:
|
|
109
|
+
exit_error(f"Failed to read body file '{filepath}': {e}")
|
|
110
|
+
else:
|
|
111
|
+
content = body
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
return json.loads(content) # type: ignore[no-any-return]
|
|
115
|
+
except json.JSONDecodeError as e:
|
|
116
|
+
exit_error(f"Invalid JSON body: {e}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _format_output(
|
|
120
|
+
response: RapiResponse,
|
|
121
|
+
fmt: str,
|
|
122
|
+
quiet: bool,
|
|
123
|
+
out_file: str | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Format and print response output.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
response: The API response to format.
|
|
129
|
+
fmt: Output format (json, text, full).
|
|
130
|
+
quiet: Whether to suppress rich formatting.
|
|
131
|
+
out_file: Optional file path to write output to.
|
|
132
|
+
"""
|
|
133
|
+
# Load render config for pretty-print settings
|
|
134
|
+
render_config = get_rapi_render_config()
|
|
135
|
+
content_type = response.headers.get("content-type", "")
|
|
136
|
+
|
|
137
|
+
# Build output content
|
|
138
|
+
if fmt == "full":
|
|
139
|
+
result_data = {
|
|
140
|
+
"endpoint": response.endpoint_ref,
|
|
141
|
+
"status_code": response.status_code,
|
|
142
|
+
"ok": response.ok,
|
|
143
|
+
"elapsed": f"{response.elapsed:.3f}s",
|
|
144
|
+
"headers": dict(response.headers),
|
|
145
|
+
"data": response.data,
|
|
146
|
+
}
|
|
147
|
+
content = to_json(result_data, indent=render_config.json_indent or 2)
|
|
148
|
+
elif fmt == "text":
|
|
149
|
+
# Text format: apply XML pretty-print if enabled and content is XML
|
|
150
|
+
if render_config.xml_pretty and is_xml_content(response.text, content_type):
|
|
151
|
+
content = to_xml(response.text)
|
|
152
|
+
else:
|
|
153
|
+
content = response.text
|
|
154
|
+
elif response.data is not None:
|
|
155
|
+
# JSON data available: format with configured indent
|
|
156
|
+
content = to_json(response.data, indent=render_config.json_indent or 2)
|
|
157
|
+
elif render_config.xml_pretty and is_xml_content(response.text, content_type):
|
|
158
|
+
# No JSON data but XML detected: pretty-print if enabled
|
|
159
|
+
content = to_xml(response.text)
|
|
160
|
+
else:
|
|
161
|
+
# Raw text fallback
|
|
162
|
+
content = response.text
|
|
163
|
+
|
|
164
|
+
# Write to file or print
|
|
165
|
+
if out_file:
|
|
166
|
+
from pathlib import Path
|
|
167
|
+
|
|
168
|
+
Path(out_file).write_text(content, encoding="utf-8")
|
|
169
|
+
if not quiet:
|
|
170
|
+
console.print(f"[green]Output written to:[/green] {out_file}")
|
|
171
|
+
elif quiet or fmt == "text" or (fmt == "json" and response.data is None):
|
|
172
|
+
print(content)
|
|
173
|
+
else:
|
|
174
|
+
console.print_json(content)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def call(
|
|
178
|
+
endpoint: Annotated[
|
|
179
|
+
str,
|
|
180
|
+
typer.Argument(help="Endpoint reference (e.g., 'github.user' or 'api.endpoint')."),
|
|
181
|
+
],
|
|
182
|
+
args: Annotated[
|
|
183
|
+
list[str] | None,
|
|
184
|
+
typer.Argument(
|
|
185
|
+
help="Path/query params: positional for path, key=value for query.",
|
|
186
|
+
),
|
|
187
|
+
] = None,
|
|
188
|
+
body: Annotated[
|
|
189
|
+
str | None,
|
|
190
|
+
typer.Option(
|
|
191
|
+
"--body",
|
|
192
|
+
"-b",
|
|
193
|
+
help="JSON body or @filename to read from file.",
|
|
194
|
+
),
|
|
195
|
+
] = None,
|
|
196
|
+
header: Annotated[
|
|
197
|
+
list[str] | None,
|
|
198
|
+
typer.Option(
|
|
199
|
+
"--header",
|
|
200
|
+
"-H",
|
|
201
|
+
help="Custom header (can be repeated). Format: 'Name: value'.",
|
|
202
|
+
),
|
|
203
|
+
] = None,
|
|
204
|
+
fmt: Annotated[
|
|
205
|
+
str,
|
|
206
|
+
typer.Option(
|
|
207
|
+
"--format",
|
|
208
|
+
"-f",
|
|
209
|
+
help="Output format: json, text, or full.",
|
|
210
|
+
),
|
|
211
|
+
] = "json",
|
|
212
|
+
out: Annotated[
|
|
213
|
+
str | None,
|
|
214
|
+
typer.Option(
|
|
215
|
+
"--out",
|
|
216
|
+
"-o",
|
|
217
|
+
help="Write output to file (for scripting).",
|
|
218
|
+
),
|
|
219
|
+
] = None,
|
|
220
|
+
quiet: Annotated[
|
|
221
|
+
bool,
|
|
222
|
+
typer.Option(
|
|
223
|
+
"--quiet",
|
|
224
|
+
"-q",
|
|
225
|
+
help="Suppress status messages, only output response.",
|
|
226
|
+
),
|
|
227
|
+
] = False,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Make an API call to a configured endpoint.
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
# Simple GET (implicit call)
|
|
233
|
+
kstlib rapi github.user
|
|
234
|
+
|
|
235
|
+
# GET with path parameters
|
|
236
|
+
kstlib rapi github.repos-get owner=KaminoU repo=igcv3
|
|
237
|
+
|
|
238
|
+
# POST with JSON body from file (recommended for complex JSON)
|
|
239
|
+
kstlib rapi myapi.create-item -b @data.json
|
|
240
|
+
|
|
241
|
+
# Custom headers
|
|
242
|
+
kstlib rapi github.user -H "X-Debug: true"
|
|
243
|
+
|
|
244
|
+
# Output to file (for scripting)
|
|
245
|
+
kstlib rapi github.user -o user.json
|
|
246
|
+
|
|
247
|
+
# Full format with file output
|
|
248
|
+
kstlib rapi github.user -f full -o result.json
|
|
249
|
+
|
|
250
|
+
# Quiet mode (JSON only, no formatting)
|
|
251
|
+
kstlib rapi github.rate-limit -q
|
|
252
|
+
"""
|
|
253
|
+
# Parse arguments
|
|
254
|
+
positional_args, keyword_args = _parse_args(args or [])
|
|
255
|
+
headers = _parse_headers(header or [])
|
|
256
|
+
parsed_body = _parse_body(body)
|
|
257
|
+
|
|
258
|
+
# Validate output format
|
|
259
|
+
if fmt not in ("json", "text", "full"):
|
|
260
|
+
exit_error(f"Invalid output format: '{fmt}'\nValid formats: json, text, full")
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
# Create client and make call
|
|
264
|
+
config_manager = load_rapi_config()
|
|
265
|
+
client = RapiClient(config_manager=config_manager)
|
|
266
|
+
|
|
267
|
+
response = client.call(
|
|
268
|
+
endpoint,
|
|
269
|
+
*positional_args,
|
|
270
|
+
body=parsed_body,
|
|
271
|
+
headers=headers if headers else None,
|
|
272
|
+
**cast("dict[str, Any]", keyword_args),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Format and print output
|
|
276
|
+
_format_output(response, fmt, quiet, out)
|
|
277
|
+
|
|
278
|
+
# Exit with appropriate code
|
|
279
|
+
if not response.ok:
|
|
280
|
+
raise typer.Exit(code=1)
|
|
281
|
+
|
|
282
|
+
except EndpointNotFoundError as e:
|
|
283
|
+
exit_with_result(
|
|
284
|
+
CommandResult(
|
|
285
|
+
status=CommandStatus.ERROR,
|
|
286
|
+
message=f"Endpoint not found: {e.endpoint_ref}",
|
|
287
|
+
payload={"searched_apis": e.searched_apis} if e.searched_apis else None,
|
|
288
|
+
),
|
|
289
|
+
quiet=quiet,
|
|
290
|
+
exit_code=1,
|
|
291
|
+
cause=e,
|
|
292
|
+
)
|
|
293
|
+
except EndpointAmbiguousError as e:
|
|
294
|
+
exit_with_result(
|
|
295
|
+
CommandResult(
|
|
296
|
+
status=CommandStatus.ERROR,
|
|
297
|
+
message=f"Ambiguous endpoint: '{e.endpoint_name}' exists in multiple APIs",
|
|
298
|
+
payload={"matching_apis": e.matching_apis},
|
|
299
|
+
),
|
|
300
|
+
quiet=quiet,
|
|
301
|
+
exit_code=1,
|
|
302
|
+
cause=e,
|
|
303
|
+
)
|
|
304
|
+
except CredentialError as e:
|
|
305
|
+
exit_with_result(
|
|
306
|
+
CommandResult(
|
|
307
|
+
status=CommandStatus.ERROR,
|
|
308
|
+
message=f"Credential error: {e}",
|
|
309
|
+
payload={"credential_name": e.credential_name} if e.credential_name else None,
|
|
310
|
+
),
|
|
311
|
+
quiet=quiet,
|
|
312
|
+
exit_code=1,
|
|
313
|
+
cause=e,
|
|
314
|
+
)
|
|
315
|
+
except RequestError as e:
|
|
316
|
+
exit_with_result(
|
|
317
|
+
CommandResult(
|
|
318
|
+
status=CommandStatus.ERROR,
|
|
319
|
+
message=f"Request failed: {e}",
|
|
320
|
+
payload={
|
|
321
|
+
"status_code": e.status_code,
|
|
322
|
+
"retryable": e.retryable,
|
|
323
|
+
},
|
|
324
|
+
),
|
|
325
|
+
quiet=quiet,
|
|
326
|
+
exit_code=1,
|
|
327
|
+
cause=e,
|
|
328
|
+
)
|
|
329
|
+
except ResponseTooLargeError as e:
|
|
330
|
+
exit_with_result(
|
|
331
|
+
CommandResult(
|
|
332
|
+
status=CommandStatus.ERROR,
|
|
333
|
+
message=f"Response too large: {e.response_size} bytes (max: {e.max_size})",
|
|
334
|
+
),
|
|
335
|
+
quiet=quiet,
|
|
336
|
+
exit_code=1,
|
|
337
|
+
cause=e,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
__all__ = ["call"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""List available API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from kstlib.cli.common import console
|
|
11
|
+
from kstlib.rapi import load_rapi_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_endpoints(
|
|
15
|
+
api: Annotated[
|
|
16
|
+
str | None,
|
|
17
|
+
typer.Argument(help="Filter by API name (optional)."),
|
|
18
|
+
] = None,
|
|
19
|
+
verbose: Annotated[
|
|
20
|
+
bool,
|
|
21
|
+
typer.Option(
|
|
22
|
+
"--verbose",
|
|
23
|
+
"-v",
|
|
24
|
+
help="Show additional details (method, auth, headers).",
|
|
25
|
+
),
|
|
26
|
+
] = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""List all configured API endpoints.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
# List all endpoints
|
|
32
|
+
kstlib rapi list
|
|
33
|
+
|
|
34
|
+
# List endpoints for specific API
|
|
35
|
+
kstlib rapi list github
|
|
36
|
+
|
|
37
|
+
# Verbose output with methods and auth
|
|
38
|
+
kstlib rapi list -v
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
config_manager = load_rapi_config()
|
|
42
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
43
|
+
console.print(f"[red]Failed to load rapi config: {e}[/]")
|
|
44
|
+
raise typer.Exit(code=1) from e
|
|
45
|
+
|
|
46
|
+
apis = config_manager.apis
|
|
47
|
+
|
|
48
|
+
if not apis:
|
|
49
|
+
console.print("[yellow]No APIs configured in kstlib.conf.yml[/]")
|
|
50
|
+
console.print("[dim]Add APIs under 'rapi.api' section.[/]")
|
|
51
|
+
raise typer.Exit(code=0)
|
|
52
|
+
|
|
53
|
+
# Filter by API name if specified
|
|
54
|
+
if api:
|
|
55
|
+
if api not in apis:
|
|
56
|
+
console.print(f"[red]API '{api}' not found.[/]")
|
|
57
|
+
console.print(f"[dim]Available APIs: {', '.join(apis.keys())}[/]")
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
apis = {api: apis[api]}
|
|
60
|
+
|
|
61
|
+
# Build table
|
|
62
|
+
if verbose:
|
|
63
|
+
table = Table(title="Available Endpoints", show_lines=True)
|
|
64
|
+
table.add_column("Reference", style="cyan")
|
|
65
|
+
table.add_column("Method", style="green")
|
|
66
|
+
table.add_column("Path")
|
|
67
|
+
table.add_column("Query", style="yellow")
|
|
68
|
+
else:
|
|
69
|
+
table = Table(title="Available Endpoints")
|
|
70
|
+
table.add_column("Reference", style="cyan")
|
|
71
|
+
table.add_column("Path")
|
|
72
|
+
|
|
73
|
+
for api_name, api_config in sorted(apis.items()):
|
|
74
|
+
for ep_name, ep_config in sorted(api_config.endpoints.items()):
|
|
75
|
+
ref = f"{api_name}.{ep_name}"
|
|
76
|
+
|
|
77
|
+
# Build path display with query param indicator
|
|
78
|
+
path_display = f"[dim]{ep_config.path}[/]"
|
|
79
|
+
if ep_config.query:
|
|
80
|
+
path_display += f" [yellow]({len(ep_config.query)})[/]"
|
|
81
|
+
|
|
82
|
+
if verbose:
|
|
83
|
+
method = ep_config.method.upper()
|
|
84
|
+
# Show query param keys or "-"
|
|
85
|
+
query_info = ", ".join(ep_config.query.keys()) if ep_config.query else "-"
|
|
86
|
+
|
|
87
|
+
table.add_row(ref, method, path_display, query_info)
|
|
88
|
+
else:
|
|
89
|
+
table.add_row(ref, path_display)
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
|
|
93
|
+
# Summary
|
|
94
|
+
total_apis = len(apis)
|
|
95
|
+
total_endpoints = sum(len(api.endpoints) for api in apis.values())
|
|
96
|
+
console.print(f"\n[dim]{total_endpoints} endpoints across {total_apis} API(s)[/]")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["list_endpoints"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Show detailed information for a specific API endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from kstlib.cli.common import console
|
|
15
|
+
from kstlib.limits import HARD_MAX_DISPLAY_VALUE_LENGTH, HARD_MAX_ENDPOINT_REF_LENGTH
|
|
16
|
+
from kstlib.rapi import EndpointNotFoundError, load_rapi_config
|
|
17
|
+
from kstlib.rapi.config import _PATH_PARAM_PATTERN
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from kstlib.rapi.config import ApiConfig, EndpointConfig
|
|
21
|
+
|
|
22
|
+
# Allowed characters for endpoint reference: alphanum + underscore + dot + hyphen
|
|
23
|
+
_ENDPOINT_REF_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _truncate(value: str, max_length: int = HARD_MAX_DISPLAY_VALUE_LENGTH) -> str:
|
|
27
|
+
"""Truncate a string and append ellipsis if too long."""
|
|
28
|
+
if len(value) <= max_length:
|
|
29
|
+
return value
|
|
30
|
+
return value[: max_length - 3] + "..."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_endpoint_ref(endpoint_ref: str) -> None:
|
|
34
|
+
"""Validate endpoint reference for security (deep defense)."""
|
|
35
|
+
if len(endpoint_ref) > HARD_MAX_ENDPOINT_REF_LENGTH:
|
|
36
|
+
console.print(f"[red]Endpoint reference too long: {len(endpoint_ref)} > {HARD_MAX_ENDPOINT_REF_LENGTH}[/]")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
if not _ENDPOINT_REF_PATTERN.match(endpoint_ref):
|
|
40
|
+
console.print("[red]Endpoint reference contains invalid characters.[/]")
|
|
41
|
+
console.print("[dim]Allowed: alphanumeric, underscore, dot, hyphen[/]")
|
|
42
|
+
raise typer.Exit(code=1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_basic_info(api_config: ApiConfig, ep_config: EndpointConfig) -> None:
|
|
46
|
+
"""Print basic endpoint information table."""
|
|
47
|
+
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
48
|
+
info_table.add_column("Label", style="dim")
|
|
49
|
+
info_table.add_column("Value")
|
|
50
|
+
|
|
51
|
+
info_table.add_row("Path:", f"[green]{escape(ep_config.path)}[/]")
|
|
52
|
+
info_table.add_row("Method:", f"[yellow]{ep_config.method}[/]")
|
|
53
|
+
info_table.add_row("API:", api_config.name)
|
|
54
|
+
info_table.add_row("Base URL:", escape(api_config.base_url))
|
|
55
|
+
|
|
56
|
+
console.print(info_table)
|
|
57
|
+
console.print()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _print_path_params(path_params: list[str]) -> None:
|
|
61
|
+
"""Print path parameters section."""
|
|
62
|
+
console.print("[bold]Path Parameters:[/]")
|
|
63
|
+
if path_params:
|
|
64
|
+
for param in path_params:
|
|
65
|
+
suffix = " [dim](positional)[/]" if param.isdigit() else ""
|
|
66
|
+
console.print(f" [cyan]{{{param}}}[/]{suffix}")
|
|
67
|
+
else:
|
|
68
|
+
console.print(" [dim](none)[/]")
|
|
69
|
+
console.print()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _print_query_params(ep_config: EndpointConfig) -> None:
|
|
73
|
+
"""Print default query parameters section."""
|
|
74
|
+
console.print("[bold]Default Query Parameters:[/]")
|
|
75
|
+
if ep_config.query:
|
|
76
|
+
for key, value in ep_config.query.items():
|
|
77
|
+
safe_value = escape(_truncate(str(value)))
|
|
78
|
+
console.print(f" [cyan]{escape(key)}[/] = {safe_value}")
|
|
79
|
+
else:
|
|
80
|
+
console.print(" [dim](none)[/]")
|
|
81
|
+
console.print()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _print_headers(api_config: ApiConfig, ep_config: EndpointConfig) -> None:
|
|
85
|
+
"""Print headers section."""
|
|
86
|
+
console.print("[bold]Headers:[/]")
|
|
87
|
+
service_headers = len(api_config.headers) if api_config.headers else 0
|
|
88
|
+
ep_headers = len(ep_config.headers) if ep_config.headers else 0
|
|
89
|
+
console.print(f" Service: [dim]{service_headers} header(s)[/]")
|
|
90
|
+
console.print(f" Endpoint: [dim]{ep_headers} header(s)[/]")
|
|
91
|
+
console.print()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _print_auth(api_config: ApiConfig, ep_config: EndpointConfig) -> None:
|
|
95
|
+
"""Print authentication section."""
|
|
96
|
+
console.print("[bold]Authentication:[/]")
|
|
97
|
+
if api_config.auth_type:
|
|
98
|
+
console.print(" Required: [yellow]Yes[/]")
|
|
99
|
+
console.print(f" Type: [cyan]{api_config.auth_type}[/]")
|
|
100
|
+
if ep_config.auth is False:
|
|
101
|
+
console.print(" [dim]Note: Auth disabled for this endpoint[/]")
|
|
102
|
+
else:
|
|
103
|
+
console.print(" Required: [green]No[/]")
|
|
104
|
+
console.print()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _print_body_template(ep_config: EndpointConfig) -> None:
|
|
108
|
+
"""Print body template section."""
|
|
109
|
+
console.print("[bold]Body Template:[/]")
|
|
110
|
+
if ep_config.body_template:
|
|
111
|
+
body_str = json.dumps(ep_config.body_template, indent=2)
|
|
112
|
+
safe_body = escape(_truncate(body_str))
|
|
113
|
+
console.print(f" {safe_body}")
|
|
114
|
+
elif ep_config.method in ("POST", "PUT", "PATCH"):
|
|
115
|
+
console.print(" [dim](none - provide via --body)[/]")
|
|
116
|
+
else:
|
|
117
|
+
console.print(f" [dim](none - {ep_config.method} request)[/]")
|
|
118
|
+
console.print()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _print_examples(ep_config: EndpointConfig, path_params: list[str]) -> None:
|
|
122
|
+
"""Print usage examples section."""
|
|
123
|
+
console.print("[bold]Examples:[/]")
|
|
124
|
+
base_cmd = f"kstlib rapi {ep_config.full_ref}"
|
|
125
|
+
|
|
126
|
+
# Build path params string (required in ALL examples)
|
|
127
|
+
# Use escape() to prevent Rich from interpreting <param> as markup tags
|
|
128
|
+
path_args = ""
|
|
129
|
+
if path_params:
|
|
130
|
+
named_args = " ".join(escape(f"<{p}>") for p in path_params if not p.isdigit())
|
|
131
|
+
positional_args = " ".join(escape(f"<arg{p}>") for p in path_params if p.isdigit())
|
|
132
|
+
path_args = " ".join(filter(None, [named_args, positional_args]))
|
|
133
|
+
|
|
134
|
+
# Basic example with required path params
|
|
135
|
+
if path_args:
|
|
136
|
+
console.print(f" [dim]{base_cmd} {path_args}[/]")
|
|
137
|
+
else:
|
|
138
|
+
console.print(f" [dim]{base_cmd}[/]")
|
|
139
|
+
|
|
140
|
+
# Example with query param (path params are REQUIRED, query is optional)
|
|
141
|
+
if ep_config.query:
|
|
142
|
+
first_key = escape(next(iter(ep_config.query)))
|
|
143
|
+
if path_args:
|
|
144
|
+
console.print(f" [dim]{base_cmd} {path_args} {first_key}={escape('<value>')}[/]")
|
|
145
|
+
else:
|
|
146
|
+
console.print(f" [dim]{base_cmd} {first_key}={escape('<value>')}[/]")
|
|
147
|
+
|
|
148
|
+
# Example with body (path params are REQUIRED)
|
|
149
|
+
if ep_config.method in ("POST", "PUT", "PATCH"):
|
|
150
|
+
if path_args:
|
|
151
|
+
console.print(f' [dim]{base_cmd} {path_args} --body \'{{"key": "value"}}\'[/]')
|
|
152
|
+
else:
|
|
153
|
+
console.print(f' [dim]{base_cmd} --body \'{{"key": "value"}}\'[/]')
|
|
154
|
+
|
|
155
|
+
console.print()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def show_endpoint(
|
|
159
|
+
endpoint_ref: Annotated[
|
|
160
|
+
str,
|
|
161
|
+
typer.Argument(help="Endpoint reference (api.endpoint or short form)."),
|
|
162
|
+
],
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Show detailed information for an API endpoint.
|
|
165
|
+
|
|
166
|
+
Displays full configuration including path parameters, query parameters,
|
|
167
|
+
headers, authentication requirements, and usage examples.
|
|
168
|
+
|
|
169
|
+
Examples:
|
|
170
|
+
# Show endpoint details
|
|
171
|
+
kstlib rapi show httpbin.get_ip
|
|
172
|
+
|
|
173
|
+
# Short form (if unique)
|
|
174
|
+
kstlib rapi show get_ip
|
|
175
|
+
"""
|
|
176
|
+
_validate_endpoint_ref(endpoint_ref)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
config_manager = load_rapi_config()
|
|
180
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
181
|
+
console.print(f"[red]Failed to load rapi config: {e}[/]")
|
|
182
|
+
raise typer.Exit(code=1) from e
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
api_config, ep_config = config_manager.resolve(endpoint_ref)
|
|
186
|
+
except EndpointNotFoundError as e:
|
|
187
|
+
console.print(f"[red]Endpoint not found: {endpoint_ref}[/]")
|
|
188
|
+
console.print(f"[dim]Available APIs: {', '.join(e.searched_apis)}[/]")
|
|
189
|
+
raise typer.Exit(code=1) from e
|
|
190
|
+
|
|
191
|
+
path_params = _PATH_PARAM_PATTERN.findall(ep_config.path)
|
|
192
|
+
|
|
193
|
+
console.print()
|
|
194
|
+
console.print(Panel(f"[bold cyan]{ep_config.full_ref}[/]", expand=False))
|
|
195
|
+
console.print()
|
|
196
|
+
|
|
197
|
+
_print_basic_info(api_config, ep_config)
|
|
198
|
+
_print_path_params(path_params)
|
|
199
|
+
_print_query_params(ep_config)
|
|
200
|
+
_print_headers(api_config, ep_config)
|
|
201
|
+
_print_auth(api_config, ep_config)
|
|
202
|
+
_print_body_template(ep_config)
|
|
203
|
+
_print_examples(ep_config, path_params)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["show_endpoint"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""CLI commands for secrets management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .decrypt import decrypt
|
|
8
|
+
from .doctor import doctor, init
|
|
9
|
+
from .encrypt import encrypt
|
|
10
|
+
from .shred import shred
|
|
11
|
+
|
|
12
|
+
secrets_app = typer.Typer(help="Manage encrypted secrets and diagnostics.")
|
|
13
|
+
|
|
14
|
+
# Register commands on the secrets_app
|
|
15
|
+
secrets_app.command()(doctor)
|
|
16
|
+
secrets_app.command()(init)
|
|
17
|
+
secrets_app.command()(encrypt)
|
|
18
|
+
secrets_app.command()(decrypt)
|
|
19
|
+
secrets_app.command()(shred)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_cli(app: typer.Typer) -> None:
|
|
23
|
+
"""Register the secrets sub-commands on the root Typer app."""
|
|
24
|
+
app.add_typer(secrets_app, name="secrets")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"decrypt",
|
|
29
|
+
"doctor",
|
|
30
|
+
"encrypt",
|
|
31
|
+
"init",
|
|
32
|
+
"register_cli",
|
|
33
|
+
"secrets_app",
|
|
34
|
+
"shred",
|
|
35
|
+
]
|