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/client.py ADDED
@@ -0,0 +1,875 @@
1
+ """HTTP client for RAPI module.
2
+
3
+ This module provides the RapiClient class for making REST API calls
4
+ with config-driven endpoints, multi-source credentials, and detailed logging.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Any
16
+ from urllib.parse import urlencode
17
+
18
+ import httpx
19
+
20
+ from kstlib.limits import get_rapi_limits
21
+ from kstlib.rapi.config import (
22
+ ApiConfig,
23
+ EndpointConfig,
24
+ HmacConfig,
25
+ RapiConfigManager,
26
+ load_rapi_config,
27
+ )
28
+ from kstlib.rapi.credentials import CredentialRecord, CredentialResolver
29
+ from kstlib.rapi.exceptions import (
30
+ RequestError,
31
+ ResponseTooLargeError,
32
+ )
33
+ from kstlib.ssl import build_ssl_context
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Mapping
37
+
38
+ from kstlib.logging import TRACE_LEVEL, get_logger
39
+
40
+ log = get_logger(__name__)
41
+
42
+
43
+ def _log_trace(msg: str, *args: Any) -> None:
44
+ """Log at TRACE level."""
45
+ log.log(TRACE_LEVEL, msg, *args)
46
+
47
+
48
+ @dataclass
49
+ class RapiResponse:
50
+ """Response from an API call.
51
+
52
+ Attributes:
53
+ status_code: HTTP status code.
54
+ headers: Response headers.
55
+ data: Parsed JSON response (or None if not JSON).
56
+ text: Raw response text.
57
+ elapsed: Request duration in seconds.
58
+ endpoint_ref: Full endpoint reference used.
59
+
60
+ Examples:
61
+ >>> response = RapiResponse(status_code=200, data={"ip": "1.2.3.4"})
62
+ >>> response.ok
63
+ True
64
+ >>> response.data["ip"]
65
+ '1.2.3.4'
66
+ """
67
+
68
+ status_code: int
69
+ headers: dict[str, str] = field(default_factory=dict)
70
+ data: Any = None
71
+ text: str = ""
72
+ elapsed: float = 0.0
73
+ endpoint_ref: str = ""
74
+
75
+ @property
76
+ def ok(self) -> bool:
77
+ """Return True if status code indicates success (2xx)."""
78
+ return 200 <= self.status_code < 300
79
+
80
+
81
+ class RapiClient:
82
+ """Config-driven REST API client.
83
+
84
+ Makes HTTP requests to configured API endpoints with automatic
85
+ credential resolution, header merging, and detailed logging.
86
+
87
+ Supports loading configuration from:
88
+ - kstlib.conf.yml (default)
89
+ - External ``*.rapi.yml`` files (via from_file)
90
+ - Auto-discovery of ``*.rapi.yml`` in current directory (via discover)
91
+
92
+ Args:
93
+ config_manager: Optional RapiConfigManager (loads from config if None).
94
+ credentials_config: Optional credentials configuration.
95
+
96
+ Examples:
97
+ >>> client = RapiClient() # doctest: +SKIP
98
+ >>> response = client.call("httpbin.get_ip") # doctest: +SKIP
99
+ >>> response.data # doctest: +SKIP
100
+ {'origin': '...'}
101
+
102
+ >>> client = RapiClient.from_file("github.rapi.yml") # doctest: +SKIP
103
+ >>> client = RapiClient.discover() # doctest: +SKIP
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ config_manager: RapiConfigManager | None = None,
109
+ credentials_config: Mapping[str, Any] | None = None,
110
+ *,
111
+ ssl_verify: bool | None = None,
112
+ ssl_ca_bundle: str | None = None,
113
+ ) -> None:
114
+ """Initialize RapiClient.
115
+
116
+ Args:
117
+ config_manager: Optional RapiConfigManager instance.
118
+ credentials_config: Optional credentials configuration.
119
+ ssl_verify: Override SSL verification (True/False).
120
+ If None, uses global config from kstlib.conf.yml.
121
+ ssl_ca_bundle: Override CA bundle path.
122
+ If None, uses global config from kstlib.conf.yml.
123
+ """
124
+ self._config_manager = config_manager or load_rapi_config()
125
+
126
+ # Merge credentials: inline from config_manager + explicit credentials_config
127
+ merged_credentials: dict[str, Any] = {}
128
+ if hasattr(self._config_manager, "credentials_config"):
129
+ merged_credentials.update(self._config_manager.credentials_config)
130
+ if credentials_config:
131
+ merged_credentials.update(credentials_config)
132
+
133
+ self._credential_resolver = CredentialResolver(merged_credentials or None)
134
+ self._limits = get_rapi_limits()
135
+
136
+ # Build SSL context (cascade: kwargs > global config > default)
137
+ self._ssl_context = build_ssl_context(
138
+ ssl_verify=ssl_verify,
139
+ ssl_ca_bundle=ssl_ca_bundle,
140
+ )
141
+
142
+ log.debug(
143
+ "RapiClient initialized (timeout=%.1fs, max_retries=%d)",
144
+ self._limits.timeout,
145
+ self._limits.max_retries,
146
+ )
147
+
148
+ @classmethod
149
+ def from_file(
150
+ cls,
151
+ path: str,
152
+ credentials_config: Mapping[str, Any] | None = None,
153
+ ) -> RapiClient:
154
+ """Create client from a ``*.rapi.yml`` file.
155
+
156
+ Loads API configuration from an external YAML file with simplified format.
157
+
158
+ Args:
159
+ path: Path to the ``*.rapi.yml`` file.
160
+ credentials_config: Additional credentials (merged with inline).
161
+
162
+ Returns:
163
+ Configured RapiClient instance.
164
+
165
+ Raises:
166
+ FileNotFoundError: If file does not exist.
167
+ ValueError: If file format is invalid.
168
+
169
+ Examples:
170
+ >>> client = RapiClient.from_file("github.rapi.yml") # doctest: +SKIP
171
+ >>> response = client.call("github.user") # doctest: +SKIP
172
+ """
173
+ config_manager = RapiConfigManager.from_file(path)
174
+ return cls(config_manager, credentials_config)
175
+
176
+ @classmethod
177
+ def discover(
178
+ cls,
179
+ directory: str | None = None,
180
+ pattern: str = "*.rapi.yml",
181
+ credentials_config: Mapping[str, Any] | None = None,
182
+ ) -> RapiClient:
183
+ """Create client by auto-discovering ``*.rapi.yml`` files.
184
+
185
+ Searches for files matching the pattern in the specified directory
186
+ (defaults to current working directory) and loads all found configs.
187
+
188
+ Args:
189
+ directory: Directory to search in (default: current directory).
190
+ pattern: Glob pattern for files (default: ``*.rapi.yml``).
191
+ credentials_config: Additional credentials (merged with inline).
192
+
193
+ Returns:
194
+ Configured RapiClient instance.
195
+
196
+ Raises:
197
+ FileNotFoundError: If no matching files found.
198
+
199
+ Examples:
200
+ >>> client = RapiClient.discover() # doctest: +SKIP
201
+ >>> client = RapiClient.discover("./apis/") # doctest: +SKIP
202
+ """
203
+ config_manager = RapiConfigManager.discover(directory, pattern)
204
+ return cls(config_manager, credentials_config)
205
+
206
+ @property
207
+ def config_manager(self) -> RapiConfigManager:
208
+ """Get the configuration manager.
209
+
210
+ Returns:
211
+ RapiConfigManager instance.
212
+ """
213
+ return self._config_manager
214
+
215
+ def list_apis(self) -> list[str]:
216
+ """List all configured API names.
217
+
218
+ Returns:
219
+ List of API names.
220
+ """
221
+ return self._config_manager.list_apis()
222
+
223
+ def list_endpoints(self, api_name: str | None = None) -> list[str]:
224
+ """List endpoint references.
225
+
226
+ Args:
227
+ api_name: Filter by API name (optional).
228
+
229
+ Returns:
230
+ List of full endpoint references (api.endpoint).
231
+ """
232
+ return self._config_manager.list_endpoints(api_name)
233
+
234
+ def call(
235
+ self,
236
+ endpoint_ref: str,
237
+ *args: Any,
238
+ body: Any = None,
239
+ headers: Mapping[str, str] | None = None,
240
+ timeout: float | None = None,
241
+ **kwargs: Any,
242
+ ) -> RapiResponse:
243
+ """Make a synchronous API call.
244
+
245
+ Args:
246
+ endpoint_ref: Endpoint reference (full: api.endpoint or short: endpoint).
247
+ *args: Positional arguments for path parameters.
248
+ body: Request body (dict for JSON, str for raw).
249
+ headers: Runtime headers (override service/endpoint headers).
250
+ timeout: Request timeout (uses config default if None).
251
+ **kwargs: Keyword arguments for path parameters and query params.
252
+
253
+ Returns:
254
+ RapiResponse with parsed data.
255
+
256
+ Raises:
257
+ RequestError: If request fails after retries.
258
+ ResponseTooLargeError: If response exceeds max size.
259
+
260
+ Examples:
261
+ >>> client = RapiClient() # doctest: +SKIP
262
+ >>> client.call("httpbin.get_ip") # doctest: +SKIP
263
+ >>> client.call("httpbin.delayed", 5) # doctest: +SKIP
264
+ >>> client.call("httpbin.post_data", body={"key": "value"}) # doctest: +SKIP
265
+ """
266
+ log.debug("Calling endpoint: %s", endpoint_ref)
267
+
268
+ # Resolve endpoint
269
+ api_config, endpoint_config = self._config_manager.resolve(endpoint_ref)
270
+ _log_trace("Resolved to: %s", endpoint_config.full_ref)
271
+
272
+ # Build request
273
+ request = self._build_request(
274
+ api_config,
275
+ endpoint_config,
276
+ args,
277
+ kwargs,
278
+ body,
279
+ headers,
280
+ )
281
+
282
+ # Execute with retries
283
+ effective_timeout = timeout if timeout is not None else self._limits.timeout
284
+ return self._execute_with_retry(request, endpoint_config, effective_timeout)
285
+
286
+ async def call_async(
287
+ self,
288
+ endpoint_ref: str,
289
+ *args: Any,
290
+ body: Any = None,
291
+ headers: Mapping[str, str] | None = None,
292
+ timeout: float | None = None,
293
+ **kwargs: Any,
294
+ ) -> RapiResponse:
295
+ """Make an asynchronous API call.
296
+
297
+ Args:
298
+ endpoint_ref: Endpoint reference (full: api.endpoint or short: endpoint).
299
+ *args: Positional arguments for path parameters.
300
+ body: Request body (dict for JSON, str for raw).
301
+ headers: Runtime headers (override service/endpoint headers).
302
+ timeout: Request timeout (uses config default if None).
303
+ **kwargs: Keyword arguments for path parameters and query params.
304
+
305
+ Returns:
306
+ RapiResponse with parsed data.
307
+
308
+ Raises:
309
+ RequestError: If request fails after retries.
310
+ ResponseTooLargeError: If response exceeds max size.
311
+ """
312
+ log.debug("Calling endpoint (async): %s", endpoint_ref)
313
+
314
+ # Resolve endpoint
315
+ api_config, endpoint_config = self._config_manager.resolve(endpoint_ref)
316
+ _log_trace("Resolved to: %s", endpoint_config.full_ref)
317
+
318
+ # Build request
319
+ request = self._build_request(
320
+ api_config,
321
+ endpoint_config,
322
+ args,
323
+ kwargs,
324
+ body,
325
+ headers,
326
+ )
327
+
328
+ # Execute with retries
329
+ effective_timeout = timeout if timeout is not None else self._limits.timeout
330
+ return await self._execute_with_retry_async(
331
+ request,
332
+ endpoint_config,
333
+ effective_timeout,
334
+ )
335
+
336
+ def _extract_query_params(
337
+ self,
338
+ endpoint_config: EndpointConfig,
339
+ kwargs: dict[str, Any],
340
+ ) -> dict[str, str]:
341
+ """Extract query parameters from kwargs (excluding path params)."""
342
+ import re
343
+
344
+ query_params = dict(endpoint_config.query)
345
+ path_params: set[str] = set()
346
+
347
+ for match in re.finditer(r"\{([a-zA-Z_][a-zA-Z0-9_]*|\d+)\}", endpoint_config.path):
348
+ param = match.group(1)
349
+ if not param.isdigit():
350
+ path_params.add(param)
351
+
352
+ for key, value in kwargs.items():
353
+ if key not in path_params:
354
+ query_params[key] = str(value)
355
+
356
+ return query_params
357
+
358
+ def _prepare_body(
359
+ self,
360
+ body: Any,
361
+ headers: dict[str, str],
362
+ ) -> bytes | None:
363
+ """Prepare request body and set Content-Type header if needed."""
364
+ if body is None:
365
+ return None
366
+
367
+ content: bytes | None = None
368
+ if isinstance(body, dict):
369
+ content = json.dumps(body).encode("utf-8")
370
+ if "Content-Type" not in headers:
371
+ headers["Content-Type"] = "application/json"
372
+ elif isinstance(body, str):
373
+ content = body.encode("utf-8")
374
+ elif isinstance(body, bytes):
375
+ content = body
376
+
377
+ if content:
378
+ _log_trace("Request body size: %d bytes", len(content))
379
+ # Log body content (truncate if too large)
380
+ body_preview = content.decode("utf-8", errors="replace")
381
+ if len(body_preview) > 1000:
382
+ _log_trace(">>> Body: %s... [truncated]", body_preview[:1000])
383
+ else:
384
+ _log_trace(">>> Body: %s", body_preview)
385
+
386
+ return content
387
+
388
+ def _build_request(
389
+ self,
390
+ api_config: ApiConfig,
391
+ endpoint_config: EndpointConfig,
392
+ args: tuple[Any, ...],
393
+ kwargs: dict[str, Any],
394
+ body: Any,
395
+ runtime_headers: Mapping[str, str] | None,
396
+ ) -> httpx.Request:
397
+ """Build HTTP request from configuration.
398
+
399
+ Args:
400
+ api_config: API service configuration.
401
+ endpoint_config: Endpoint configuration.
402
+ args: Positional path parameters.
403
+ kwargs: Keyword parameters (path + query).
404
+ body: Request body.
405
+ runtime_headers: Runtime header overrides.
406
+
407
+ Returns:
408
+ Prepared httpx.Request.
409
+ """
410
+ # Build URL with path parameter substitution
411
+ _log_trace("Path template: %s", endpoint_config.path)
412
+ if args:
413
+ _log_trace("Path args (positional): %s", args)
414
+ if kwargs:
415
+ _log_trace("Path/query kwargs: %s", kwargs)
416
+ path = endpoint_config.build_path(*args, **kwargs)
417
+ url = f"{api_config.base_url}{path}"
418
+
419
+ # Extract query params from kwargs
420
+ query_params = self._extract_query_params(endpoint_config, kwargs)
421
+
422
+ _log_trace("Final URL: %s", url)
423
+ if query_params:
424
+ _log_trace("Query params: %s", query_params)
425
+
426
+ # Merge headers (service < endpoint < runtime)
427
+ merged_headers = self._merge_headers(
428
+ api_config.headers,
429
+ endpoint_config.headers,
430
+ dict(runtime_headers) if runtime_headers else {},
431
+ )
432
+
433
+ # Prepare body first (needed for HMAC signing if sign_body=True)
434
+ content = self._prepare_body(body, merged_headers)
435
+
436
+ # Apply authentication (may modify headers and query_params for HMAC)
437
+ # Skip auth if endpoint explicitly disables it (auth: false)
438
+ if api_config.credentials and endpoint_config.auth:
439
+ self._apply_auth(merged_headers, api_config, query_params, content)
440
+
441
+ # Create request
442
+ request = httpx.Request(
443
+ method=endpoint_config.method,
444
+ url=url,
445
+ params=query_params if query_params else None,
446
+ headers=merged_headers,
447
+ content=content,
448
+ )
449
+
450
+ self._log_request(request)
451
+ return request
452
+
453
+ def _merge_headers(
454
+ self,
455
+ service_headers: dict[str, str],
456
+ endpoint_headers: dict[str, str],
457
+ runtime_headers: dict[str, str],
458
+ ) -> dict[str, str]:
459
+ """Merge headers from three levels.
460
+
461
+ Order: service < endpoint < runtime (later overrides earlier).
462
+
463
+ Args:
464
+ service_headers: Service-level headers.
465
+ endpoint_headers: Endpoint-level headers.
466
+ runtime_headers: Runtime headers.
467
+
468
+ Returns:
469
+ Merged headers dictionary.
470
+ """
471
+ merged = {}
472
+ merged.update(service_headers)
473
+ merged.update(endpoint_headers)
474
+ merged.update(runtime_headers)
475
+
476
+ _log_trace(
477
+ "Headers merged: service=%d, endpoint=%d, runtime=%d -> total=%d",
478
+ len(service_headers),
479
+ len(endpoint_headers),
480
+ len(runtime_headers),
481
+ len(merged),
482
+ )
483
+
484
+ return merged
485
+
486
+ def _apply_auth(
487
+ self,
488
+ headers: dict[str, str],
489
+ api_config: ApiConfig,
490
+ query_params: dict[str, str] | None = None,
491
+ body_content: bytes | None = None,
492
+ ) -> None:
493
+ """Apply authentication to headers and query params.
494
+
495
+ Args:
496
+ headers: Headers dict to modify.
497
+ api_config: API config with credentials reference.
498
+ query_params: Query params dict to modify (for HMAC signing).
499
+ body_content: Request body content (for HMAC signing).
500
+ """
501
+ if not api_config.credentials:
502
+ return
503
+
504
+ try:
505
+ cred = self._credential_resolver.resolve(api_config.credentials)
506
+ except Exception as e:
507
+ log.warning("Failed to resolve credential '%s': %s", api_config.credentials, e)
508
+ return
509
+
510
+ auth_type = api_config.auth_type or "bearer"
511
+
512
+ if auth_type == "bearer":
513
+ headers["Authorization"] = f"Bearer {cred.value}"
514
+ _log_trace("Applied Bearer auth")
515
+ elif auth_type == "basic":
516
+ auth_str = f"{cred.value}:{cred.secret}" if cred.secret else f"{cred.value}:"
517
+ encoded = base64.b64encode(auth_str.encode()).decode()
518
+ headers["Authorization"] = f"Basic {encoded}"
519
+ _log_trace("Applied Basic auth")
520
+ elif auth_type == "api_key":
521
+ headers["X-API-Key"] = cred.value
522
+ _log_trace("Applied API Key auth")
523
+ elif auth_type == "hmac":
524
+ self._apply_hmac_auth(
525
+ headers,
526
+ api_config,
527
+ cred,
528
+ query_params if query_params is not None else {},
529
+ body_content,
530
+ )
531
+ else:
532
+ log.warning("Unknown auth_type: %s", auth_type)
533
+
534
+ def _apply_hmac_auth(
535
+ self,
536
+ headers: dict[str, str],
537
+ api_config: ApiConfig,
538
+ cred: CredentialRecord,
539
+ query_params: dict[str, str],
540
+ body_content: bytes | None,
541
+ ) -> None:
542
+ """Apply HMAC authentication.
543
+
544
+ Supports various exchange APIs like Binance (SHA256, hex) and
545
+ Kraken (SHA512, base64).
546
+
547
+ Args:
548
+ headers: Headers dict to modify.
549
+ api_config: API config with HMAC configuration.
550
+ cred: Resolved credential with API key and secret.
551
+ query_params: Query params dict to modify (timestamp/signature added).
552
+ body_content: Request body content (for signing if sign_body=True).
553
+
554
+ Raises:
555
+ ValueError: If secret is not available in credentials.
556
+ """
557
+ if not cred.secret:
558
+ raise ValueError("HMAC auth requires secret_key in credentials")
559
+
560
+ hmac_cfg = api_config.hmac_config or HmacConfig()
561
+
562
+ # 1. Generate timestamp or nonce
563
+ ts_value = str(int(time.time() * 1000))
564
+ ts_field = hmac_cfg.nonce_field or hmac_cfg.timestamp_field
565
+
566
+ # Add timestamp/nonce to query params
567
+ query_params[ts_field] = ts_value
568
+
569
+ # 2. Build payload to sign
570
+ if hmac_cfg.sign_body and body_content:
571
+ payload = body_content.decode("utf-8", errors="replace")
572
+ else:
573
+ # Query string with timestamp (same order as httpx will send)
574
+ payload = urlencode(query_params)
575
+
576
+ # 3. Generate signature
577
+ hash_func = hashlib.sha512 if hmac_cfg.algorithm == "sha512" else hashlib.sha256
578
+
579
+ signature = hmac.new(
580
+ cred.secret.encode("utf-8"),
581
+ payload.encode("utf-8"),
582
+ hash_func,
583
+ )
584
+
585
+ if hmac_cfg.signature_format == "base64":
586
+ sig_value = base64.b64encode(signature.digest()).decode("utf-8")
587
+ else:
588
+ sig_value = signature.hexdigest()
589
+
590
+ # 4. Add signature to query params
591
+ query_params[hmac_cfg.signature_field] = sig_value
592
+
593
+ # 5. Set API key header if configured
594
+ if hmac_cfg.key_header:
595
+ headers[hmac_cfg.key_header] = cred.value
596
+
597
+ _log_trace(
598
+ "Applied HMAC auth (algorithm=%s, format=%s)",
599
+ hmac_cfg.algorithm,
600
+ hmac_cfg.signature_format,
601
+ )
602
+
603
+ def _log_request(self, request: httpx.Request) -> None:
604
+ """Log request details at TRACE level."""
605
+ _log_trace(">>> %s %s", request.method, request.url)
606
+
607
+ # Log headers (redact sensitive ones)
608
+ for name, value in request.headers.items():
609
+ if name.lower() in ("authorization", "x-api-key", "cookie"):
610
+ _log_trace(">>> %s: [REDACTED]", name)
611
+ else:
612
+ _log_trace(">>> %s: %s", name, value)
613
+
614
+ def _log_response(self, response: httpx.Response, elapsed: float) -> None:
615
+ """Log response details at TRACE level."""
616
+ _log_trace("<<< %d %s (%.3fs)", response.status_code, response.reason_phrase, elapsed)
617
+ _log_trace("<<< Content-Type: %s", response.headers.get("content-type", "unknown"))
618
+ _log_trace("<<< Content-Length: %s", response.headers.get("content-length", "unknown"))
619
+ # Log response body (truncate if too large)
620
+ try:
621
+ body_text = response.text
622
+ if len(body_text) > 2000:
623
+ _log_trace("<<< Body: %s... [truncated, %d bytes total]", body_text[:2000], len(body_text))
624
+ else:
625
+ _log_trace("<<< Body: %s", body_text)
626
+ except Exception:
627
+ _log_trace("<<< Body: [unable to decode]")
628
+
629
+ def _execute_with_retry(
630
+ self,
631
+ request: httpx.Request,
632
+ endpoint_config: EndpointConfig,
633
+ timeout: float,
634
+ ) -> RapiResponse:
635
+ """Execute request with retry logic.
636
+
637
+ Args:
638
+ request: Prepared HTTP request.
639
+ endpoint_config: Endpoint configuration.
640
+ timeout: Request timeout in seconds.
641
+
642
+ Returns:
643
+ RapiResponse.
644
+
645
+ Raises:
646
+ RequestError: If all retries fail.
647
+ ResponseTooLargeError: If response is too large.
648
+ """
649
+ last_error: Exception | None = None
650
+ delay = self._limits.retry_delay
651
+
652
+ for attempt in range(self._limits.max_retries + 1):
653
+ if attempt > 0:
654
+ log.debug("Retry %d/%d after %.1fs", attempt, self._limits.max_retries, delay)
655
+ _log_trace("Waiting %.1fs before retry...", delay)
656
+ time.sleep(delay)
657
+ delay *= self._limits.retry_backoff
658
+
659
+ _log_trace("Attempt %d/%d", attempt + 1, self._limits.max_retries + 1)
660
+
661
+ try:
662
+ start_time = time.monotonic()
663
+ with httpx.Client(timeout=timeout, verify=self._ssl_context) as client:
664
+ response = client.send(request)
665
+ elapsed = time.monotonic() - start_time
666
+
667
+ self._log_response(response, elapsed)
668
+
669
+ # Check response size
670
+ content_length = response.headers.get("content-length")
671
+ if content_length and int(content_length) > self._limits.max_response_size:
672
+ raise ResponseTooLargeError(
673
+ int(content_length),
674
+ self._limits.max_response_size,
675
+ )
676
+
677
+ # Parse response
678
+ return self._parse_response(response, endpoint_config, elapsed)
679
+
680
+ except httpx.TimeoutException as e:
681
+ log.warning("Request timeout (attempt %d): %s", attempt + 1, e)
682
+ last_error = e
683
+ except httpx.NetworkError as e:
684
+ log.warning("Network error (attempt %d): %s", attempt + 1, e)
685
+ last_error = e
686
+ except ResponseTooLargeError:
687
+ raise
688
+ except httpx.HTTPStatusError as e:
689
+ # Don't retry client errors (4xx), only server errors (5xx)
690
+ if 400 <= e.response.status_code < 500:
691
+ return self._parse_response(e.response, endpoint_config, 0.0)
692
+ log.warning("HTTP error (attempt %d): %s", attempt + 1, e)
693
+ last_error = e
694
+
695
+ # All retries exhausted
696
+ raise RequestError(
697
+ f"Request failed after {self._limits.max_retries + 1} attempts: {last_error}",
698
+ retryable=False,
699
+ )
700
+
701
+ async def _execute_with_retry_async(
702
+ self,
703
+ request: httpx.Request,
704
+ endpoint_config: EndpointConfig,
705
+ timeout: float,
706
+ ) -> RapiResponse:
707
+ """Execute async request with retry logic.
708
+
709
+ Args:
710
+ request: Prepared HTTP request.
711
+ endpoint_config: Endpoint configuration.
712
+ timeout: Request timeout in seconds.
713
+
714
+ Returns:
715
+ RapiResponse.
716
+
717
+ Raises:
718
+ RequestError: If all retries fail.
719
+ ResponseTooLargeError: If response is too large.
720
+ """
721
+ import asyncio
722
+
723
+ last_error: Exception | None = None
724
+ delay = self._limits.retry_delay
725
+
726
+ for attempt in range(self._limits.max_retries + 1):
727
+ if attempt > 0:
728
+ log.debug("Retry %d/%d after %.1fs", attempt, self._limits.max_retries, delay)
729
+ _log_trace("Waiting %.1fs before retry...", delay)
730
+ await asyncio.sleep(delay)
731
+ delay *= self._limits.retry_backoff
732
+
733
+ _log_trace("Attempt %d/%d", attempt + 1, self._limits.max_retries + 1)
734
+
735
+ try:
736
+ start_time = time.monotonic()
737
+ async with httpx.AsyncClient(timeout=timeout, verify=self._ssl_context) as client:
738
+ response = await client.send(request)
739
+ elapsed = time.monotonic() - start_time
740
+
741
+ self._log_response(response, elapsed)
742
+
743
+ # Check response size
744
+ content_length = response.headers.get("content-length")
745
+ if content_length and int(content_length) > self._limits.max_response_size:
746
+ raise ResponseTooLargeError(
747
+ int(content_length),
748
+ self._limits.max_response_size,
749
+ )
750
+
751
+ # Parse response
752
+ return self._parse_response(response, endpoint_config, elapsed)
753
+
754
+ except httpx.TimeoutException as e:
755
+ log.warning("Request timeout (attempt %d): %s", attempt + 1, e)
756
+ last_error = e
757
+ except httpx.NetworkError as e:
758
+ log.warning("Network error (attempt %d): %s", attempt + 1, e)
759
+ last_error = e
760
+ except ResponseTooLargeError:
761
+ raise
762
+ except httpx.HTTPStatusError as e:
763
+ # Don't retry client errors (4xx)
764
+ if 400 <= e.response.status_code < 500:
765
+ return self._parse_response(e.response, endpoint_config, 0.0)
766
+ log.warning("HTTP error (attempt %d): %s", attempt + 1, e)
767
+ last_error = e
768
+
769
+ # All retries exhausted
770
+ raise RequestError(
771
+ f"Request failed after {self._limits.max_retries + 1} attempts: {last_error}",
772
+ retryable=False,
773
+ )
774
+
775
+ def _parse_response(
776
+ self,
777
+ response: httpx.Response,
778
+ endpoint_config: EndpointConfig,
779
+ elapsed: float,
780
+ ) -> RapiResponse:
781
+ """Parse HTTP response into RapiResponse.
782
+
783
+ Args:
784
+ response: Raw HTTP response.
785
+ endpoint_config: Endpoint configuration.
786
+ elapsed: Request duration in seconds.
787
+
788
+ Returns:
789
+ Parsed RapiResponse.
790
+ """
791
+ text = response.text
792
+ data: Any = None
793
+
794
+ # Try to parse as JSON
795
+ content_type = response.headers.get("content-type", "")
796
+ if "application/json" in content_type or text.startswith(("{", "[")):
797
+ try:
798
+ data = response.json()
799
+ except json.JSONDecodeError:
800
+ log.debug("Response is not valid JSON")
801
+
802
+ return RapiResponse(
803
+ status_code=response.status_code,
804
+ headers=dict(response.headers),
805
+ data=data,
806
+ text=text,
807
+ elapsed=elapsed,
808
+ endpoint_ref=endpoint_config.full_ref,
809
+ )
810
+
811
+
812
+ def call(
813
+ endpoint_ref: str,
814
+ *args: Any,
815
+ body: Any = None,
816
+ headers: Mapping[str, str] | None = None,
817
+ **kwargs: Any,
818
+ ) -> RapiResponse:
819
+ """Convenience function for quick API calls.
820
+
821
+ Creates a temporary RapiClient and makes the call.
822
+
823
+ Args:
824
+ endpoint_ref: Endpoint reference.
825
+ *args: Positional path parameters.
826
+ body: Request body.
827
+ headers: Runtime headers.
828
+ **kwargs: Keyword parameters.
829
+
830
+ Returns:
831
+ RapiResponse.
832
+
833
+ Examples:
834
+ >>> from kstlib.rapi import call # doctest: +SKIP
835
+ >>> response = call("httpbin.get_ip") # doctest: +SKIP
836
+ """
837
+ client = RapiClient()
838
+ return client.call(endpoint_ref, *args, body=body, headers=headers, **kwargs)
839
+
840
+
841
+ async def call_async(
842
+ endpoint_ref: str,
843
+ *args: Any,
844
+ body: Any = None,
845
+ headers: Mapping[str, str] | None = None,
846
+ **kwargs: Any,
847
+ ) -> RapiResponse:
848
+ """Convenience function for async API calls.
849
+
850
+ Creates a temporary RapiClient and makes the async call.
851
+
852
+ Args:
853
+ endpoint_ref: Endpoint reference.
854
+ *args: Positional path parameters.
855
+ body: Request body.
856
+ headers: Runtime headers.
857
+ **kwargs: Keyword parameters.
858
+
859
+ Returns:
860
+ RapiResponse.
861
+
862
+ Examples:
863
+ >>> from kstlib.rapi import call_async # doctest: +SKIP
864
+ >>> response = await call_async("httpbin.get_ip") # doctest: +SKIP
865
+ """
866
+ client = RapiClient()
867
+ return await client.call_async(endpoint_ref, *args, body=body, headers=headers, **kwargs)
868
+
869
+
870
+ __all__ = [
871
+ "RapiClient",
872
+ "RapiResponse",
873
+ "call",
874
+ "call_async",
875
+ ]