kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__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 (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/rapi/config.py ADDED
@@ -0,0 +1,861 @@
1
+ """Configuration management for RAPI module.
2
+
3
+ This module handles loading and resolving endpoint configurations
4
+ from kstlib.conf.yml or external ``*.rapi.yml`` files.
5
+
6
+ Supports:
7
+ - Loading from kstlib.conf.yml (default)
8
+ - Loading from external YAML files (``*.rapi.yml``)
9
+ - Auto-discovery of ``*.rapi.yml`` files in current directory
10
+ - Include patterns in kstlib.conf.yml
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from kstlib.rapi.exceptions import EndpointAmbiguousError, EndpointNotFoundError
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping, Sequence
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ # Pattern for path parameters: {param} or {0}, {1}
29
+ _PATH_PARAM_PATTERN = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*|\d+)\}")
30
+
31
+
32
+ # Deep defense: allowed values for HMAC config (hardcoded limits)
33
+ _ALLOWED_HMAC_ALGORITHMS = frozenset({"sha256", "sha512"})
34
+ _ALLOWED_SIGNATURE_FORMATS = frozenset({"hex", "base64"})
35
+ _MAX_FIELD_NAME_LENGTH = 64 # Max length for field names (timestamp_field, etc.)
36
+ _MAX_HEADER_NAME_LENGTH = 128 # Max length for header names
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class HmacConfig:
41
+ """HMAC signing configuration.
42
+
43
+ Supports various exchange APIs like Binance (SHA256) and Kraken (SHA512).
44
+
45
+ Attributes:
46
+ algorithm: Hash algorithm (sha256, sha512).
47
+ timestamp_field: Query param name for timestamp.
48
+ nonce_field: Query param name for nonce (alternative to timestamp).
49
+ signature_field: Query param name for signature.
50
+ signature_format: Output format (hex, base64).
51
+ key_header: Header name for API key.
52
+ sign_body: If True, sign request body instead of query string.
53
+
54
+ Examples:
55
+ >>> config = HmacConfig(algorithm="sha512", signature_format="base64")
56
+ >>> config.algorithm
57
+ 'sha512'
58
+ """
59
+
60
+ algorithm: str = "sha256"
61
+ timestamp_field: str = "timestamp"
62
+ nonce_field: str | None = None
63
+ signature_field: str = "signature"
64
+ signature_format: str = "hex"
65
+ key_header: str | None = None
66
+ sign_body: bool = False
67
+
68
+ def __post_init__(self) -> None:
69
+ """Validate HMAC config values (deep defense)."""
70
+ # Validate algorithm
71
+ if self.algorithm not in _ALLOWED_HMAC_ALGORITHMS:
72
+ raise ValueError(f"Invalid HMAC algorithm: {self.algorithm!r}. Allowed: {sorted(_ALLOWED_HMAC_ALGORITHMS)}")
73
+
74
+ # Validate signature format
75
+ if self.signature_format not in _ALLOWED_SIGNATURE_FORMATS:
76
+ raise ValueError(
77
+ f"Invalid signature format: {self.signature_format!r}. Allowed: {sorted(_ALLOWED_SIGNATURE_FORMATS)}"
78
+ )
79
+
80
+ # Validate field name lengths
81
+ if len(self.timestamp_field) > _MAX_FIELD_NAME_LENGTH:
82
+ raise ValueError(f"timestamp_field too long: {len(self.timestamp_field)} > {_MAX_FIELD_NAME_LENGTH}")
83
+ if len(self.signature_field) > _MAX_FIELD_NAME_LENGTH:
84
+ raise ValueError(f"signature_field too long: {len(self.signature_field)} > {_MAX_FIELD_NAME_LENGTH}")
85
+ if self.nonce_field and len(self.nonce_field) > _MAX_FIELD_NAME_LENGTH:
86
+ raise ValueError(f"nonce_field too long: {len(self.nonce_field)} > {_MAX_FIELD_NAME_LENGTH}")
87
+ if self.key_header and len(self.key_header) > _MAX_HEADER_NAME_LENGTH:
88
+ raise ValueError(f"key_header too long: {len(self.key_header)} > {_MAX_HEADER_NAME_LENGTH}")
89
+
90
+
91
+ def _extract_credentials_from_rapi(
92
+ data: dict[str, Any],
93
+ api_name: str,
94
+ file_path: Path,
95
+ ) -> tuple[str | None, dict[str, Any]]:
96
+ """Extract credentials configuration from RAPI file data.
97
+
98
+ Args:
99
+ data: Parsed YAML data.
100
+ api_name: Name of the API.
101
+ file_path: Path to the file (for resolving relative paths).
102
+
103
+ Returns:
104
+ Tuple of (credentials_ref, credentials_config).
105
+ """
106
+ credentials_config: dict[str, Any] = {}
107
+ credentials_ref: str | None = None
108
+
109
+ if "credentials" not in data:
110
+ return None, {}
111
+
112
+ cred_data = data["credentials"]
113
+ if isinstance(cred_data, dict):
114
+ # Inline credentials definition
115
+ credentials_ref = f"_rapi_{api_name}_cred"
116
+ # Resolve relative paths in credentials (expand ~ first)
117
+ if "path" in cred_data:
118
+ cred_path = Path(cred_data["path"]).expanduser()
119
+ if cred_path.is_absolute():
120
+ # Already absolute (or was ~ expanded to absolute)
121
+ cred_data["path"] = str(cred_path)
122
+ else:
123
+ # Relative path: resolve against file location
124
+ cred_data["path"] = str(file_path.parent / cred_data["path"])
125
+ credentials_config[credentials_ref] = cred_data
126
+ elif isinstance(cred_data, str):
127
+ # Reference to existing credential
128
+ credentials_ref = cred_data
129
+
130
+ return credentials_ref, credentials_config
131
+
132
+
133
+ def _extract_auth_config(
134
+ data: dict[str, Any],
135
+ ) -> tuple[str | None, HmacConfig | None]:
136
+ """Extract auth type and HMAC config from RAPI file data.
137
+
138
+ Args:
139
+ data: Parsed YAML data.
140
+
141
+ Returns:
142
+ Tuple of (auth_type, HmacConfig or None).
143
+ """
144
+ if "auth" not in data:
145
+ return None, None
146
+
147
+ auth_data = data["auth"]
148
+ if isinstance(auth_data, str):
149
+ return auth_data, None
150
+
151
+ if not isinstance(auth_data, dict):
152
+ return None, None
153
+
154
+ auth_type = auth_data.get("type")
155
+
156
+ # Parse HMAC config if auth type is hmac
157
+ hmac_config: HmacConfig | None = None
158
+ if auth_type == "hmac":
159
+ hmac_config = HmacConfig(
160
+ algorithm=auth_data.get("algorithm", "sha256"),
161
+ timestamp_field=auth_data.get("timestamp_field", "timestamp"),
162
+ nonce_field=auth_data.get("nonce_field"),
163
+ signature_field=auth_data.get("signature_field", "signature"),
164
+ signature_format=auth_data.get("signature_format", "hex"),
165
+ key_header=auth_data.get("key_header"),
166
+ sign_body=auth_data.get("sign_body", False),
167
+ )
168
+
169
+ return auth_type, hmac_config
170
+
171
+
172
+ def _parse_rapi_file(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
173
+ """Parse a ``*.rapi.yml`` file into internal config format.
174
+
175
+ Converts the simplified format:
176
+ ```yaml
177
+ name: github
178
+ base_url: "https://api.github.com"
179
+ credentials:
180
+ type: sops
181
+ path: "./tokens/github.sops.json"
182
+ auth:
183
+ type: bearer
184
+ endpoints:
185
+ user:
186
+ path: "/user"
187
+ ```
188
+
189
+ Into the internal format:
190
+ ```python
191
+ {
192
+ "api": {
193
+ "github": {
194
+ "base_url": "...",
195
+ "credentials": "_github_cred",
196
+ "auth_type": "bearer",
197
+ "endpoints": {...}
198
+ }
199
+ }
200
+ }
201
+ ```
202
+
203
+ Args:
204
+ path: Path to the ``*.rapi.yml`` file.
205
+
206
+ Returns:
207
+ Tuple of (api_config, credentials_config).
208
+
209
+ Raises:
210
+ TypeError: If file format is invalid.
211
+ ValueError: If required fields are missing.
212
+ """
213
+ import yaml
214
+
215
+ content = path.read_text(encoding="utf-8")
216
+ data = yaml.safe_load(content)
217
+
218
+ if not isinstance(data, dict):
219
+ raise TypeError(f"Invalid RAPI config format in {path}: expected dict")
220
+
221
+ # Extract API name (or derive from filename)
222
+ api_name = data.get("name")
223
+ if not api_name:
224
+ api_name = path.stem.replace(".rapi", "")
225
+ log.debug("API name not specified, derived from filename: %s", api_name)
226
+
227
+ # Validate required fields
228
+ base_url = data.get("base_url")
229
+ if not base_url:
230
+ raise ValueError(f"Missing 'base_url' in {path}")
231
+
232
+ # Extract credentials and auth
233
+ credentials_ref, credentials_config = _extract_credentials_from_rapi(data, api_name, path)
234
+ auth_type, hmac_config = _extract_auth_config(data)
235
+
236
+ # Build API config
237
+ api_config: dict[str, Any] = {
238
+ "api": {
239
+ api_name: {
240
+ "base_url": base_url,
241
+ "credentials": credentials_ref,
242
+ "auth_type": auth_type,
243
+ "hmac_config": hmac_config,
244
+ "headers": data.get("headers", {}),
245
+ "endpoints": data.get("endpoints", {}),
246
+ }
247
+ }
248
+ }
249
+
250
+ log.debug(
251
+ "Parsed %s: api=%s, %d endpoints, credentials=%s",
252
+ path.name,
253
+ api_name,
254
+ len(data.get("endpoints", {})),
255
+ "inline" if credentials_ref and credentials_ref.startswith("_rapi_") else credentials_ref,
256
+ )
257
+
258
+ return api_config, credentials_config
259
+
260
+
261
+ @dataclass(frozen=True, slots=True)
262
+ class EndpointConfig:
263
+ """Configuration for a single API endpoint.
264
+
265
+ Attributes:
266
+ name: Endpoint name (e.g., "get_ip").
267
+ api_name: Parent API name (e.g., "httpbin").
268
+ path: URL path template (e.g., "/delay/{seconds}").
269
+ method: HTTP method (GET, POST, PUT, DELETE, PATCH).
270
+ query: Default query parameters.
271
+ headers: Endpoint-level headers (merged with service headers).
272
+ body_template: Default body template for POST/PUT.
273
+ auth: Whether to apply API-level authentication to this endpoint.
274
+ Set to False for public endpoints that don't require auth.
275
+
276
+ Examples:
277
+ >>> config = EndpointConfig(
278
+ ... name="get_ip",
279
+ ... api_name="httpbin",
280
+ ... path="/ip",
281
+ ... method="GET",
282
+ ... )
283
+ >>> config.full_ref
284
+ 'httpbin.get_ip'
285
+ """
286
+
287
+ name: str
288
+ api_name: str
289
+ path: str
290
+ method: str = "GET"
291
+ query: dict[str, str] = field(default_factory=dict)
292
+ headers: dict[str, str] = field(default_factory=dict)
293
+ body_template: dict[str, Any] | None = None
294
+ auth: bool = True
295
+
296
+ @property
297
+ def full_ref(self) -> str:
298
+ """Return full reference: api_name.endpoint_name."""
299
+ return f"{self.api_name}.{self.name}"
300
+
301
+ def build_path(self, *args: Any, **kwargs: Any) -> str:
302
+ """Build path with positional and keyword arguments.
303
+
304
+ Args:
305
+ *args: Positional arguments for {0}, {1}, etc.
306
+ **kwargs: Keyword arguments for {name} placeholders.
307
+
308
+ Returns:
309
+ Formatted path string.
310
+
311
+ Raises:
312
+ ValueError: If required parameters are missing.
313
+
314
+ Examples:
315
+ >>> config = EndpointConfig(
316
+ ... name="delay",
317
+ ... api_name="httpbin",
318
+ ... path="/delay/{seconds}",
319
+ ... )
320
+ >>> config.build_path(seconds=5)
321
+ '/delay/5'
322
+ >>> config.build_path(5)
323
+ '/delay/5'
324
+ """
325
+ path = self.path
326
+
327
+ # Find all placeholders
328
+ placeholders = _PATH_PARAM_PATTERN.findall(path)
329
+
330
+ for placeholder in placeholders:
331
+ if placeholder.isdigit():
332
+ # Positional: {0}, {1}
333
+ idx = int(placeholder)
334
+ if idx < len(args):
335
+ path = path.replace(f"{{{placeholder}}}", str(args[idx]))
336
+ else:
337
+ raise ValueError(f"Missing positional argument {idx} for path {self.path}")
338
+ elif placeholder in kwargs:
339
+ # Named: {name}
340
+ path = path.replace(f"{{{placeholder}}}", str(kwargs[placeholder]))
341
+ elif len(args) > 0:
342
+ # Try to use first positional arg for first named placeholder
343
+ path = path.replace(f"{{{placeholder}}}", str(args[0]))
344
+ args = args[1:]
345
+ else:
346
+ raise ValueError(f"Missing parameter '{placeholder}' for path {self.path}")
347
+
348
+ return path
349
+
350
+
351
+ @dataclass(frozen=True, slots=True)
352
+ class ApiConfig:
353
+ """Configuration for an API service.
354
+
355
+ Attributes:
356
+ name: API service name (e.g., "httpbin").
357
+ base_url: Base URL for the API.
358
+ credentials: Name of credential config to use.
359
+ auth_type: Authentication type (bearer, basic, api_key, hmac).
360
+ hmac_config: HMAC signing configuration (required when auth_type is hmac).
361
+ headers: Service-level headers (applied to all endpoints).
362
+ endpoints: Dictionary of endpoint configurations.
363
+
364
+ Examples:
365
+ >>> api = ApiConfig(
366
+ ... name="httpbin",
367
+ ... base_url="https://httpbin.org",
368
+ ... endpoints={},
369
+ ... )
370
+ """
371
+
372
+ name: str
373
+ base_url: str
374
+ credentials: str | None = None
375
+ auth_type: str | None = None
376
+ hmac_config: HmacConfig | None = None
377
+ headers: dict[str, str] = field(default_factory=dict)
378
+ endpoints: dict[str, EndpointConfig] = field(default_factory=dict)
379
+
380
+
381
+ class RapiConfigManager:
382
+ """Manage RAPI configuration and endpoint resolution.
383
+
384
+ Loads API and endpoint configurations from kstlib.conf.yml and provides
385
+ resolution methods supporting both full references (api.endpoint) and
386
+ short references (endpoint only, auto-resolved if unique).
387
+
388
+ Supports loading from:
389
+ - kstlib.conf.yml (default)
390
+ - External ``*.rapi.yml`` files (via from_file/from_files)
391
+ - Auto-discovery of ``*.rapi.yml`` in current directory (via discover)
392
+
393
+ Args:
394
+ rapi_config: The 'rapi' section from configuration.
395
+ credentials_config: Inline credentials extracted from ``*.rapi.yml`` files.
396
+
397
+ Examples:
398
+ >>> manager = RapiConfigManager({"api": {"httpbin": {"base_url": "..."}}})
399
+ >>> endpoint = manager.resolve("httpbin.get_ip") # doctest: +SKIP
400
+
401
+ >>> manager = RapiConfigManager.from_file("github.rapi.yml") # doctest: +SKIP
402
+ >>> manager = RapiConfigManager.discover() # doctest: +SKIP
403
+ """
404
+
405
+ def __init__(
406
+ self,
407
+ rapi_config: Mapping[str, Any] | None = None,
408
+ credentials_config: Mapping[str, Any] | None = None,
409
+ ) -> None:
410
+ """Initialize RapiConfigManager.
411
+
412
+ Args:
413
+ rapi_config: The 'rapi' section from configuration.
414
+ credentials_config: Inline credentials from ``*.rapi.yml`` files.
415
+ """
416
+ self._config = rapi_config or {}
417
+ self._credentials_config = dict(credentials_config) if credentials_config else {}
418
+ self._apis: dict[str, ApiConfig] = {}
419
+ self._endpoint_index: dict[str, list[str]] = {} # endpoint_name -> [api_names]
420
+ self._source_files: list[Path] = [] # Track loaded files for debugging
421
+
422
+ self._load_apis()
423
+
424
+ @classmethod
425
+ def from_file(
426
+ cls,
427
+ path: str | Path,
428
+ base_dir: Path | None = None,
429
+ ) -> RapiConfigManager:
430
+ """Load configuration from a single ``*.rapi.yml`` file.
431
+
432
+ The file format is simplified compared to kstlib.conf.yml,
433
+ with top-level keys: name, base_url, credentials, auth, headers, endpoints.
434
+
435
+ Args:
436
+ path: Path to the ``*.rapi.yml`` file.
437
+ base_dir: Base directory for resolving relative paths in credentials.
438
+
439
+ Returns:
440
+ Configured RapiConfigManager instance.
441
+
442
+ Raises:
443
+ FileNotFoundError: If file does not exist.
444
+ ValueError: If file format is invalid.
445
+
446
+ Examples:
447
+ >>> manager = RapiConfigManager.from_file("github.rapi.yml") # doctest: +SKIP
448
+ """
449
+ return cls.from_files([path], base_dir=base_dir)
450
+
451
+ @classmethod
452
+ def from_files(
453
+ cls,
454
+ paths: Sequence[str | Path],
455
+ base_dir: Path | None = None,
456
+ ) -> RapiConfigManager:
457
+ """Load configuration from multiple ``*.rapi.yml`` files.
458
+
459
+ Args:
460
+ paths: List of paths to ``*.rapi.yml`` files.
461
+ base_dir: Base directory for resolving relative paths.
462
+
463
+ Returns:
464
+ Configured RapiConfigManager instance with merged configs.
465
+
466
+ Raises:
467
+ FileNotFoundError: If any file does not exist.
468
+ ValueError: If any file format is invalid.
469
+
470
+ Examples:
471
+ >>> manager = RapiConfigManager.from_files([
472
+ ... "github.rapi.yml",
473
+ ... "slack.rapi.yml",
474
+ ... ]) # doctest: +SKIP
475
+ """
476
+ merged_api_config: dict[str, Any] = {"api": {}}
477
+ merged_credentials: dict[str, Any] = {}
478
+ source_files: list[Path] = []
479
+
480
+ for file_path in paths:
481
+ path = Path(file_path)
482
+ if not path.is_absolute() and base_dir:
483
+ path = base_dir / path
484
+
485
+ if not path.exists():
486
+ raise FileNotFoundError(f"RAPI config file not found: {path}")
487
+
488
+ log.debug("Loading RAPI config from: %s", path)
489
+ api_config, credentials = _parse_rapi_file(path)
490
+
491
+ # Merge API config
492
+ for api_name, api_data in api_config.get("api", {}).items():
493
+ if api_name in merged_api_config["api"]:
494
+ log.warning("API '%s' redefined in %s, overwriting", api_name, path)
495
+ merged_api_config["api"][api_name] = api_data
496
+
497
+ # Merge credentials
498
+ merged_credentials.update(credentials)
499
+ source_files.append(path)
500
+
501
+ manager = cls(merged_api_config, merged_credentials)
502
+ manager._source_files = source_files
503
+ return manager
504
+
505
+ @classmethod
506
+ def discover(
507
+ cls,
508
+ directory: str | Path | None = None,
509
+ pattern: str = "*.rapi.yml",
510
+ ) -> RapiConfigManager:
511
+ """Auto-discover and load ``*.rapi.yml`` files from a directory.
512
+
513
+ Searches for files matching the pattern in the specified directory
514
+ (defaults to current working directory).
515
+
516
+ Args:
517
+ directory: Directory to search in (default: current directory).
518
+ pattern: Glob pattern for files (default: ``*.rapi.yml``).
519
+
520
+ Returns:
521
+ Configured RapiConfigManager instance.
522
+
523
+ Raises:
524
+ FileNotFoundError: If no matching files found.
525
+
526
+ Examples:
527
+ >>> manager = RapiConfigManager.discover() # doctest: +SKIP
528
+ >>> manager = RapiConfigManager.discover("./apis/") # doctest: +SKIP
529
+ """
530
+ search_dir = Path(directory) if directory else Path.cwd()
531
+
532
+ if not search_dir.exists():
533
+ raise FileNotFoundError(f"Directory not found: {search_dir}")
534
+
535
+ # Find all matching files
536
+ files = list(search_dir.glob(pattern))
537
+
538
+ if not files:
539
+ raise FileNotFoundError(f"No RAPI config files found matching '{pattern}' in {search_dir}")
540
+
541
+ log.info("Discovered %d RAPI config file(s) in %s", len(files), search_dir)
542
+ for f in files:
543
+ log.debug(" - %s", f.name)
544
+
545
+ return cls.from_files(files, base_dir=search_dir)
546
+
547
+ @property
548
+ def credentials_config(self) -> dict[str, Any]:
549
+ """Get inline credentials config extracted from ``*.rapi.yml`` files.
550
+
551
+ Returns:
552
+ Dictionary of credentials configurations.
553
+ """
554
+ return self._credentials_config
555
+
556
+ @property
557
+ def source_files(self) -> list[Path]:
558
+ """Get list of source files loaded.
559
+
560
+ Returns:
561
+ List of Path objects for loaded files.
562
+ """
563
+ return self._source_files
564
+
565
+ def _load_apis(self) -> None:
566
+ """Load API configurations from config."""
567
+ api_section = self._config.get("api", {})
568
+
569
+ for api_name, api_data in api_section.items():
570
+ if not isinstance(api_data, dict):
571
+ log.warning("Skipping invalid API config: %s", api_name)
572
+ continue
573
+
574
+ base_url = api_data.get("base_url", "")
575
+ if not base_url:
576
+ log.warning("API '%s' missing base_url, skipping", api_name)
577
+ continue
578
+
579
+ # Parse endpoints
580
+ endpoints: dict[str, EndpointConfig] = {}
581
+ endpoints_data = api_data.get("endpoints", {})
582
+
583
+ for ep_name, ep_data in endpoints_data.items():
584
+ if not isinstance(ep_data, dict):
585
+ log.warning("Skipping invalid endpoint: %s.%s", api_name, ep_name)
586
+ continue
587
+
588
+ endpoint = EndpointConfig(
589
+ name=ep_name,
590
+ api_name=api_name,
591
+ path=ep_data.get("path", f"/{ep_name}"),
592
+ method=ep_data.get("method", "GET").upper(),
593
+ query=dict(ep_data.get("query", {})),
594
+ headers=dict(ep_data.get("headers", {})),
595
+ body_template=ep_data.get("body"),
596
+ auth=ep_data.get("auth", True),
597
+ )
598
+ endpoints[ep_name] = endpoint
599
+
600
+ # Index for short reference lookup
601
+ if ep_name not in self._endpoint_index:
602
+ self._endpoint_index[ep_name] = []
603
+ self._endpoint_index[ep_name].append(api_name)
604
+
605
+ log.debug("Loaded endpoint: %s.%s", api_name, ep_name)
606
+
607
+ # Create API config
608
+ api_config = ApiConfig(
609
+ name=api_name,
610
+ base_url=base_url.rstrip("/"),
611
+ credentials=api_data.get("credentials"),
612
+ auth_type=api_data.get("auth_type"),
613
+ hmac_config=api_data.get("hmac_config"),
614
+ headers=dict(api_data.get("headers", {})),
615
+ endpoints=endpoints,
616
+ )
617
+ self._apis[api_name] = api_config
618
+ log.debug("Loaded API: %s (%d endpoints)", api_name, len(endpoints))
619
+
620
+ def _merge_apis(
621
+ self,
622
+ other: RapiConfigManager,
623
+ *,
624
+ overwrite: bool = False,
625
+ ) -> None:
626
+ """Merge APIs from another manager into this one.
627
+
628
+ Args:
629
+ other: Source manager to merge from.
630
+ overwrite: If True, overwrite existing APIs. If False, skip conflicts.
631
+ """
632
+ for api_name, api_config in other.apis.items():
633
+ if api_name in self._apis and not overwrite:
634
+ log.warning(
635
+ "API '%s' in include conflicts with inline config, keeping inline",
636
+ api_name,
637
+ )
638
+ continue
639
+
640
+ self._apis[api_name] = api_config
641
+
642
+ # Update endpoint index
643
+ for ep_name in api_config.endpoints:
644
+ if ep_name not in self._endpoint_index:
645
+ self._endpoint_index[ep_name] = []
646
+ if api_name not in self._endpoint_index[ep_name]:
647
+ self._endpoint_index[ep_name].append(api_name)
648
+
649
+ # Merge credentials
650
+ for cred_name, cred_config in other.credentials_config.items():
651
+ if cred_name not in self._credentials_config:
652
+ self._credentials_config[cred_name] = cred_config
653
+
654
+ def resolve(self, endpoint_ref: str) -> tuple[ApiConfig, EndpointConfig]:
655
+ """Resolve endpoint reference to configuration.
656
+
657
+ Supports both full references (api.endpoint) and short references
658
+ (endpoint only). Short references are auto-resolved if the endpoint
659
+ name is unique across all APIs.
660
+
661
+ Args:
662
+ endpoint_ref: Full reference (api.endpoint) or short (endpoint).
663
+
664
+ Returns:
665
+ Tuple of (ApiConfig, EndpointConfig).
666
+
667
+ Raises:
668
+ EndpointNotFoundError: If endpoint cannot be found.
669
+ EndpointAmbiguousError: If short reference matches multiple APIs.
670
+
671
+ Examples:
672
+ >>> manager = RapiConfigManager({...}) # doctest: +SKIP
673
+ >>> api, endpoint = manager.resolve("httpbin.get_ip") # doctest: +SKIP
674
+ >>> api, endpoint = manager.resolve("get_ip") # doctest: +SKIP
675
+ """
676
+ log.debug("Resolving endpoint reference: %s", endpoint_ref)
677
+
678
+ if "." in endpoint_ref:
679
+ # Full reference: api.endpoint
680
+ return self._resolve_full(endpoint_ref)
681
+
682
+ # Short reference: endpoint only
683
+ return self._resolve_short(endpoint_ref)
684
+
685
+ def _resolve_full(self, endpoint_ref: str) -> tuple[ApiConfig, EndpointConfig]:
686
+ """Resolve full reference (api.endpoint)."""
687
+ parts = endpoint_ref.split(".", 1)
688
+ if len(parts) != 2:
689
+ raise EndpointNotFoundError(endpoint_ref, list(self._apis))
690
+
691
+ api_name, endpoint_name = parts
692
+
693
+ if api_name not in self._apis:
694
+ raise EndpointNotFoundError(
695
+ endpoint_ref,
696
+ list(self._apis),
697
+ )
698
+
699
+ api_config = self._apis[api_name]
700
+
701
+ if endpoint_name not in api_config.endpoints:
702
+ raise EndpointNotFoundError(
703
+ endpoint_ref,
704
+ [api_name],
705
+ )
706
+
707
+ endpoint_config = api_config.endpoints[endpoint_name]
708
+ log.debug("Resolved full reference: %s", endpoint_config.full_ref)
709
+
710
+ return api_config, endpoint_config
711
+
712
+ def _resolve_short(self, endpoint_name: str) -> tuple[ApiConfig, EndpointConfig]:
713
+ """Resolve short reference (endpoint only, auto-resolve if unique)."""
714
+ if endpoint_name not in self._endpoint_index:
715
+ raise EndpointNotFoundError(endpoint_name, list(self._apis))
716
+
717
+ matching_apis = self._endpoint_index[endpoint_name]
718
+
719
+ if len(matching_apis) > 1:
720
+ raise EndpointAmbiguousError(endpoint_name, matching_apis)
721
+
722
+ api_name = matching_apis[0]
723
+ api_config = self._apis[api_name]
724
+ endpoint_config = api_config.endpoints[endpoint_name]
725
+
726
+ log.debug(
727
+ "Resolved short reference '%s' to '%s'",
728
+ endpoint_name,
729
+ endpoint_config.full_ref,
730
+ )
731
+
732
+ return api_config, endpoint_config
733
+
734
+ def get_api(self, api_name: str) -> ApiConfig | None:
735
+ """Get API configuration by name.
736
+
737
+ Args:
738
+ api_name: API service name.
739
+
740
+ Returns:
741
+ ApiConfig or None if not found.
742
+ """
743
+ return self._apis.get(api_name)
744
+
745
+ def list_apis(self) -> list[str]:
746
+ """List all configured API names.
747
+
748
+ Returns:
749
+ List of API names.
750
+ """
751
+ return list(self._apis)
752
+
753
+ @property
754
+ def apis(self) -> dict[str, ApiConfig]:
755
+ """Get all configured APIs.
756
+
757
+ Returns:
758
+ Dictionary mapping API names to ApiConfig objects.
759
+ """
760
+ return self._apis
761
+
762
+ def list_endpoints(self, api_name: str | None = None) -> list[str]:
763
+ """List endpoint references.
764
+
765
+ Args:
766
+ api_name: Filter by API name (optional).
767
+
768
+ Returns:
769
+ List of full endpoint references.
770
+ """
771
+ if api_name:
772
+ api = self._apis.get(api_name)
773
+ if not api:
774
+ return []
775
+ return [f"{api_name}.{ep}" for ep in api.endpoints]
776
+
777
+ # All endpoints
778
+ result: list[str] = []
779
+ for api in self._apis.values():
780
+ result.extend(f"{api.name}.{ep}" for ep in api.endpoints)
781
+ return result
782
+
783
+
784
+ def load_rapi_config() -> RapiConfigManager:
785
+ """Load RAPI configuration from kstlib.conf.yml with include support.
786
+
787
+ Supports including external ``*.rapi.yml`` files via glob patterns:
788
+
789
+ .. code-block:: yaml
790
+
791
+ rapi:
792
+ include:
793
+ - "./apis/``*.rapi.yml``"
794
+ - "~/.config/kstlib/``*.rapi.yml``"
795
+ api:
796
+ httpbin:
797
+ base_url: "https://httpbin.org"
798
+ # ...
799
+
800
+ Returns:
801
+ Configured RapiConfigManager instance with merged configs.
802
+
803
+ Examples:
804
+ >>> manager = load_rapi_config() # doctest: +SKIP
805
+ """
806
+ from kstlib.config import get_config
807
+
808
+ config = get_config()
809
+ rapi_section = dict(config.get("rapi", {})) # type: ignore[no-untyped-call]
810
+
811
+ log.debug("Loading RAPI config from kstlib.conf.yml")
812
+
813
+ # Process includes if present
814
+ include_patterns = rapi_section.pop("include", None)
815
+
816
+ # Create manager for inline config first
817
+ manager = RapiConfigManager(rapi_section)
818
+
819
+ # Merge included files if any
820
+ if include_patterns:
821
+ included_files = _resolve_include_patterns(include_patterns)
822
+ if included_files:
823
+ log.info("Including %d external RAPI config file(s)", len(included_files))
824
+ included_manager = RapiConfigManager.from_files(included_files)
825
+ # Merge included APIs (inline config takes precedence)
826
+ manager._merge_apis(included_manager, overwrite=False)
827
+
828
+ return manager
829
+
830
+
831
+ def _resolve_include_patterns(patterns: list[str] | str) -> list[Path]:
832
+ """Resolve include patterns to file paths.
833
+
834
+ Args:
835
+ patterns: Glob pattern or list of patterns.
836
+
837
+ Returns:
838
+ List of resolved file paths.
839
+ """
840
+ if isinstance(patterns, str):
841
+ patterns = [patterns]
842
+
843
+ files: list[Path] = []
844
+ for pattern in patterns:
845
+ expanded = Path(pattern).expanduser()
846
+ if expanded.is_absolute():
847
+ matches = list(expanded.parent.glob(expanded.name))
848
+ else:
849
+ matches = list(Path.cwd().glob(pattern))
850
+ files.extend(matches)
851
+
852
+ return files
853
+
854
+
855
+ __all__ = [
856
+ "ApiConfig",
857
+ "EndpointConfig",
858
+ "HmacConfig",
859
+ "RapiConfigManager",
860
+ "load_rapi_config",
861
+ ]