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
@@ -0,0 +1,338 @@
1
+ """Human-friendly formatting utilities powered by humanize and pendulum."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from datetime import timedelta
8
+ from typing import TYPE_CHECKING
9
+
10
+ import humanize
11
+ import pendulum
12
+
13
+ if TYPE_CHECKING:
14
+ from datetime import datetime
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ # Hard limits for datetime formatting (defined here to avoid circular import)
19
+ # These values match the ones in limits.py for consistency
20
+ _HARD_MAX_DATETIME_FORMAT_LENGTH = 64
21
+ _HARD_MAX_TIMEZONE_LENGTH = 64
22
+ _HARD_MIN_EPOCH_TIMESTAMP = 0 # Unix epoch start
23
+ _HARD_MAX_EPOCH_TIMESTAMP = 4102444800 # Year 2100
24
+
25
+ __all__ = [
26
+ "format_bytes",
27
+ "format_count",
28
+ "format_duration",
29
+ "format_time_delta",
30
+ "format_timestamp",
31
+ "parse_size_string",
32
+ ]
33
+
34
+ #: Default datetime format (ISO-like).
35
+ DEFAULT_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss"
36
+
37
+ #: Allowed characters in datetime format strings (deep defense).
38
+ _DATETIME_FORMAT_PATTERN = re.compile(r"^[a-zA-Z0-9\s\-/:.,\[\]()]+$")
39
+
40
+
41
+ def _get_datetime_config() -> tuple[str, str]:
42
+ """Get datetime format and timezone from config (lazy load).
43
+
44
+ Returns:
45
+ Tuple of (format_string, timezone_string).
46
+ """
47
+ try:
48
+ from kstlib.config import get_config
49
+ from kstlib.config.exceptions import ConfigNotLoadedError
50
+ except ImportError:
51
+ return DEFAULT_DATETIME_FORMAT, "local"
52
+
53
+ try:
54
+ config = get_config()
55
+ dt_config = config.get("datetime", {}) # type: ignore[no-untyped-call]
56
+ fmt = dt_config.get("format", DEFAULT_DATETIME_FORMAT)
57
+ tz = dt_config.get("timezone", "local")
58
+ return str(fmt), str(tz)
59
+ except ConfigNotLoadedError:
60
+ return DEFAULT_DATETIME_FORMAT, "local"
61
+
62
+
63
+ def _validate_format_string(fmt: str) -> str:
64
+ """Validate and sanitize datetime format string (deep defense).
65
+
66
+ Args:
67
+ fmt: Format string to validate.
68
+
69
+ Returns:
70
+ Validated format string, or default if invalid.
71
+ """
72
+ if not fmt or not isinstance(fmt, str):
73
+ return DEFAULT_DATETIME_FORMAT
74
+
75
+ if len(fmt) > _HARD_MAX_DATETIME_FORMAT_LENGTH:
76
+ log.warning(
77
+ "Datetime format too long (%d > %d), using default",
78
+ len(fmt),
79
+ _HARD_MAX_DATETIME_FORMAT_LENGTH,
80
+ )
81
+ return DEFAULT_DATETIME_FORMAT
82
+
83
+ if not _DATETIME_FORMAT_PATTERN.match(fmt):
84
+ log.warning("Datetime format contains invalid characters, using default")
85
+ return DEFAULT_DATETIME_FORMAT
86
+
87
+ return fmt
88
+
89
+
90
+ def _validate_timezone(tz: str) -> str:
91
+ """Validate timezone string (deep defense).
92
+
93
+ Args:
94
+ tz: Timezone string to validate.
95
+
96
+ Returns:
97
+ Validated timezone string, or "local" if invalid.
98
+ """
99
+ if not tz or not isinstance(tz, str):
100
+ return "local"
101
+
102
+ if len(tz) > _HARD_MAX_TIMEZONE_LENGTH:
103
+ log.warning(
104
+ "Timezone string too long (%d > %d), using local",
105
+ len(tz),
106
+ _HARD_MAX_TIMEZONE_LENGTH,
107
+ )
108
+ return "local"
109
+
110
+ if tz.lower() == "local":
111
+ return "local"
112
+
113
+ # Validate against pendulum's known timezones
114
+ try:
115
+ pendulum.timezone(tz)
116
+ return tz
117
+ except Exception:
118
+ log.warning("Unknown timezone '%s', using local", tz)
119
+ return "local"
120
+
121
+
122
+ def format_timestamp(
123
+ epoch: float | str | None,
124
+ fmt: str | None = None,
125
+ tz: str | None = None,
126
+ ) -> str:
127
+ """Format an epoch timestamp as a human-readable datetime string.
128
+
129
+ Converts Unix epoch timestamps to formatted datetime strings using
130
+ pendulum for timezone-aware formatting. Configuration can be loaded
131
+ from kstlib.conf.yml or provided explicitly.
132
+
133
+ Args:
134
+ epoch: Unix timestamp (seconds since 1970-01-01 UTC).
135
+ Accepts int, float, or string representation.
136
+ Returns "(invalid)" if None or unparseable.
137
+ fmt: Datetime format string (pendulum tokens).
138
+ If None, uses config value or "YYYY-MM-DD HH:mm:ss".
139
+ tz: Timezone for display ("local", "UTC", or IANA name).
140
+ If None, uses config value or "local".
141
+
142
+ Returns:
143
+ Formatted datetime string, or "(invalid)" on error.
144
+
145
+ Examples:
146
+ >>> format_timestamp(1706234567, tz="UTC")
147
+ '2024-01-26 02:02:47'
148
+ >>> format_timestamp(1706234567, fmt="DD/MM/YYYY", tz="UTC")
149
+ '26/01/2024'
150
+ >>> format_timestamp(None)
151
+ '(invalid)'
152
+ """
153
+ # Handle None or empty
154
+ if epoch is None or epoch == "":
155
+ return "(invalid)"
156
+
157
+ # Convert string to numeric
158
+ if isinstance(epoch, str):
159
+ try:
160
+ epoch = float(epoch)
161
+ except ValueError:
162
+ log.warning("Cannot parse epoch string: %r", epoch)
163
+ return "(invalid)"
164
+
165
+ # Validate epoch bounds (deep defense)
166
+ if epoch < _HARD_MIN_EPOCH_TIMESTAMP or epoch > _HARD_MAX_EPOCH_TIMESTAMP:
167
+ log.warning(
168
+ "Epoch timestamp out of bounds: %s (valid: %d-%d)",
169
+ epoch,
170
+ _HARD_MIN_EPOCH_TIMESTAMP,
171
+ _HARD_MAX_EPOCH_TIMESTAMP,
172
+ )
173
+ return "(invalid)"
174
+
175
+ # Get config values if not provided
176
+ config_fmt, config_tz = _get_datetime_config()
177
+ fmt = _validate_format_string(fmt or config_fmt)
178
+ tz = _validate_timezone(tz or config_tz)
179
+
180
+ try:
181
+ # Create pendulum datetime from epoch
182
+ dt = pendulum.from_timestamp(epoch)
183
+
184
+ # Convert to target timezone
185
+ # pendulum.local_timezone is a module, not a function - use tz.local_timezone()
186
+ from pendulum.tz import local_timezone
187
+
188
+ local_tz = local_timezone()
189
+ dt = dt.in_timezone(tz) if tz != "local" else dt.in_timezone(local_tz)
190
+
191
+ return dt.format(fmt)
192
+ except Exception as e:
193
+ log.warning("Error formatting timestamp %s: %s", epoch, e)
194
+ return "(invalid)"
195
+
196
+
197
+ def format_bytes(size: float, binary: bool = True) -> str:
198
+ """Format a byte count as a human-readable string.
199
+
200
+ Args:
201
+ size: Size in bytes (int or float).
202
+ binary: If True, use binary units (KiB, MiB). If False, use SI units (KB, MB).
203
+
204
+ Returns:
205
+ Human-readable size string (e.g., "25.0 MiB" or "25.0 MB").
206
+
207
+ Examples:
208
+ >>> format_bytes(25 * 1024 * 1024)
209
+ '25.0 MiB'
210
+ >>> format_bytes(25 * 1000 * 1000, binary=False)
211
+ '25.0 MB'
212
+ """
213
+ return humanize.naturalsize(size, binary=binary)
214
+
215
+
216
+ def format_count(value: int) -> str:
217
+ """Format a count with comma separators for readability.
218
+
219
+ Args:
220
+ value: Integer count to format.
221
+
222
+ Returns:
223
+ Comma-separated string (e.g., "1,000,000").
224
+
225
+ Examples:
226
+ >>> format_count(1000000)
227
+ '1,000,000'
228
+ """
229
+ return humanize.intcomma(value)
230
+
231
+
232
+ def format_duration(seconds: float) -> str:
233
+ """Format a duration in seconds as a human-readable string.
234
+
235
+ Args:
236
+ seconds: Duration in seconds.
237
+
238
+ Returns:
239
+ Human-readable duration (e.g., "5 minutes", "2 hours").
240
+
241
+ Examples:
242
+ >>> format_duration(300)
243
+ '5 minutes'
244
+ >>> format_duration(3661)
245
+ 'an hour'
246
+ """
247
+ delta = timedelta(seconds=seconds)
248
+ return humanize.naturaldelta(delta)
249
+
250
+
251
+ def format_time_delta(dt: datetime, other: datetime | None = None) -> str:
252
+ """Format a datetime as a relative time string.
253
+
254
+ Args:
255
+ dt: Target datetime.
256
+ other: Reference datetime (defaults to now).
257
+
258
+ Returns:
259
+ Relative time string (e.g., "2 hours ago", "in 3 days").
260
+
261
+ Examples:
262
+ >>> from datetime import datetime, timedelta
263
+ >>> past = datetime.now() - timedelta(hours=2)
264
+ >>> format_time_delta(past)
265
+ '2 hours ago'
266
+ """
267
+ return humanize.naturaltime(dt, when=other)
268
+
269
+
270
+ #: Size unit multipliers for parsing human-readable size strings.
271
+ _SIZE_UNITS: dict[str, int] = {
272
+ "b": 1,
273
+ "k": 1024,
274
+ "kb": 1024,
275
+ "kib": 1024,
276
+ "m": 1024**2,
277
+ "mb": 1024**2,
278
+ "mib": 1024**2,
279
+ "g": 1024**3,
280
+ "gb": 1024**3,
281
+ "gib": 1024**3,
282
+ "t": 1024**4,
283
+ "tb": 1024**4,
284
+ "tib": 1024**4,
285
+ }
286
+
287
+ #: Regex pattern for parsing size strings like "25M", "100 MiB", "1.5GB".
288
+ _SIZE_PATTERN = __import__("re").compile(r"^\s*([\d.]+)\s*([a-zA-Z]*)\s*$")
289
+
290
+
291
+ def parse_size_string(value: str | float) -> int:
292
+ """Parse a human-readable size string into bytes.
293
+
294
+ Accepts raw integers, floats, or strings with optional units.
295
+ Supported units: B, K, KB, KiB, M, MB, MiB, G, GB, GiB, T, TB, TiB.
296
+
297
+ Args:
298
+ value: Size as int, float, or string with optional unit suffix.
299
+
300
+ Returns:
301
+ Size in bytes as an integer.
302
+
303
+ Raises:
304
+ ValueError: If the string format is invalid or the unit is unknown.
305
+
306
+ Examples:
307
+ >>> parse_size_string(1024)
308
+ 1024
309
+ >>> parse_size_string("25M")
310
+ 26214400
311
+ >>> parse_size_string("100 MiB")
312
+ 104857600
313
+ >>> parse_size_string("1.5GB")
314
+ 1610612736
315
+ """
316
+ # Handle numeric types directly
317
+ if isinstance(value, int | float):
318
+ return int(value)
319
+
320
+ # Parse string format
321
+ match = _SIZE_PATTERN.match(value)
322
+ if not match:
323
+ raise ValueError(f"Invalid size format: {value!r}")
324
+
325
+ numeric_str, unit_str = match.groups()
326
+ try:
327
+ numeric_value = float(numeric_str)
328
+ except ValueError as exc:
329
+ raise ValueError(f"Invalid numeric value: {numeric_str!r}") from exc
330
+
331
+ if not unit_str:
332
+ return int(numeric_value)
333
+
334
+ multiplier = _SIZE_UNITS.get(unit_str.lower())
335
+ if multiplier is None:
336
+ raise ValueError(f"Unknown size unit: {unit_str!r}")
337
+
338
+ return int(numeric_value * multiplier)
@@ -0,0 +1,237 @@
1
+ """HTTP trace logging utilities with sensitive data redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+ from urllib.parse import parse_qs
8
+
9
+ if TYPE_CHECKING:
10
+ import logging
11
+
12
+ import httpx
13
+
14
+ # Default sensitive keys to redact in request bodies
15
+ DEFAULT_SENSITIVE_KEYS: frozenset[str] = frozenset(
16
+ {
17
+ "client_secret",
18
+ "code",
19
+ "refresh_token",
20
+ "access_token",
21
+ "code_verifier",
22
+ "password",
23
+ "api_key",
24
+ "secret",
25
+ "token",
26
+ }
27
+ )
28
+
29
+
30
+ class HTTPTraceLogger:
31
+ """Reusable HTTP trace logger with sensitive data redaction.
32
+
33
+ This class provides httpx event hooks for logging HTTP requests and responses
34
+ at TRACE level with automatic redaction of sensitive data.
35
+
36
+ Args:
37
+ logger: Logger instance to use for trace output.
38
+ trace_level: Logging level for trace messages (default: 5 for TRACE).
39
+ sensitive_keys: Set of keys to redact in request bodies.
40
+ pretty_print: Whether to pretty-print JSON responses.
41
+ max_body_length: Maximum response body length before truncation.
42
+
43
+ Examples:
44
+ >>> import logging
45
+ >>> import httpx
46
+ >>> from kstlib.utils.http_trace import HTTPTraceLogger
47
+ >>> tracer = HTTPTraceLogger(logging.getLogger(__name__))
48
+ >>> client = httpx.Client(
49
+ ... event_hooks={
50
+ ... "request": [tracer.on_request],
51
+ ... "response": [tracer.on_response],
52
+ ... }
53
+ ... )
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ logger: logging.Logger,
59
+ *,
60
+ trace_level: int = 5,
61
+ sensitive_keys: frozenset[str] | None = None,
62
+ pretty_print: bool = True,
63
+ max_body_length: int = 2000,
64
+ ) -> None:
65
+ """Initialize the HTTP trace logger."""
66
+ self._logger = logger
67
+ self._trace_level = trace_level
68
+ self._sensitive_keys = sensitive_keys or DEFAULT_SENSITIVE_KEYS
69
+ self._pretty_print = pretty_print
70
+ self._max_body_length = max_body_length
71
+
72
+ @property
73
+ def sensitive_keys(self) -> frozenset[str]:
74
+ """Return the set of sensitive keys being redacted."""
75
+ return self._sensitive_keys
76
+
77
+ def configure(
78
+ self,
79
+ *,
80
+ pretty_print: bool | None = None,
81
+ max_body_length: int | None = None,
82
+ ) -> None:
83
+ """Update trace configuration at runtime.
84
+
85
+ Args:
86
+ pretty_print: Whether to pretty-print JSON responses.
87
+ max_body_length: Maximum response body length before truncation.
88
+ """
89
+ if pretty_print is not None:
90
+ self._pretty_print = pretty_print
91
+ if max_body_length is not None:
92
+ self._max_body_length = max_body_length
93
+
94
+ def on_request(self, request: httpx.Request) -> None:
95
+ """httpx event hook for outgoing requests (TRACE logging).
96
+
97
+ Redacts sensitive data in request body and Authorization headers.
98
+
99
+ Args:
100
+ request: The outgoing HTTP request.
101
+ """
102
+ if not self._logger.isEnabledFor(self._trace_level):
103
+ return
104
+
105
+ body_str = self._redact_request_body(request.content)
106
+ safe_headers = {k: v for k, v in request.headers.items() if k.lower() != "authorization"}
107
+
108
+ self._logger.log(
109
+ self._trace_level,
110
+ "[HTTP] %s %s | headers=%s | body=%s",
111
+ request.method,
112
+ request.url,
113
+ dict(safe_headers) or "{}",
114
+ body_str,
115
+ )
116
+
117
+ def on_response(self, response: httpx.Response) -> None:
118
+ """httpx event hook for incoming responses (TRACE logging).
119
+
120
+ Optionally pretty-prints JSON and truncates long bodies.
121
+
122
+ Args:
123
+ response: The incoming HTTP response.
124
+ """
125
+ if not self._logger.isEnabledFor(self._trace_level):
126
+ return
127
+
128
+ body = self._format_response_body(response)
129
+
130
+ self._logger.log(
131
+ self._trace_level,
132
+ "[HTTP] %s %s | status=%d | body=\n%s",
133
+ response.request.method,
134
+ response.request.url,
135
+ response.status_code,
136
+ body,
137
+ )
138
+
139
+ def _redact_request_body(self, content: bytes | None) -> str:
140
+ """Redact sensitive values from request body.
141
+
142
+ Args:
143
+ content: Raw request body bytes.
144
+
145
+ Returns:
146
+ String representation with sensitive values redacted.
147
+ """
148
+ if not content:
149
+ return "{}"
150
+
151
+ try:
152
+ body_data = parse_qs(content.decode("utf-8"))
153
+ safe_data: dict[str, Any] = {}
154
+
155
+ for key, values in body_data.items():
156
+ val = values[0] if len(values) == 1 else values
157
+ if key in self._sensitive_keys:
158
+ safe_data[key] = f"[REDACTED:{len(str(val))}chars]"
159
+ else:
160
+ safe_data[key] = val
161
+
162
+ return str(safe_data) if safe_data else "{}"
163
+ except Exception: # pylint: disable=broad-exception-caught
164
+ return "[binary or unparseable]"
165
+
166
+ def _format_response_body(self, response: httpx.Response) -> str:
167
+ """Format response body for logging.
168
+
169
+ Args:
170
+ response: The HTTP response.
171
+
172
+ Returns:
173
+ Formatted body string, possibly pretty-printed and truncated.
174
+ """
175
+ try:
176
+ response.read() # Ensure body is available
177
+ body = response.text
178
+
179
+ if self._pretty_print and body:
180
+ try:
181
+ parsed = json.loads(body)
182
+ body = json.dumps(parsed, indent=2, ensure_ascii=False)
183
+ except (json.JSONDecodeError, TypeError):
184
+ pass
185
+
186
+ if len(body) > self._max_body_length:
187
+ body = f"{body[: self._max_body_length]}\n... [truncated, {len(body)} total chars]"
188
+
189
+ return body
190
+ except Exception: # pylint: disable=broad-exception-caught
191
+ return "[unable to read body]"
192
+
193
+
194
+ # Type alias for httpx event hooks - uses internal types for accurate typing
195
+ EventHooksDict = dict[str, list["httpx._types.RequestHook | httpx._types.ResponseHook"]] # type: ignore[name-defined] # noqa: SLF001
196
+
197
+
198
+ def create_trace_event_hooks(
199
+ logger: logging.Logger,
200
+ trace_level: int = 5,
201
+ ) -> tuple[EventHooksDict, bool]:
202
+ """Create httpx event hooks for TRACE logging.
203
+
204
+ This helper centralizes the common pattern of setting up HTTP trace logging
205
+ with HTTPTraceLogger for httpx clients.
206
+
207
+ Args:
208
+ logger: Logger instance to use for trace output.
209
+ trace_level: Logging level for trace messages (default: 5 for TRACE).
210
+
211
+ Returns:
212
+ Tuple of (event_hooks dict, trace_enabled bool).
213
+ The event_hooks dict can be passed directly to httpx.AsyncClient().
214
+
215
+ Examples:
216
+ >>> import logging
217
+ >>> import httpx
218
+ >>> from kstlib.utils.http_trace import create_trace_event_hooks
219
+ >>> log = logging.getLogger(__name__)
220
+ >>> hooks, enabled = create_trace_event_hooks(log)
221
+ >>> async with httpx.AsyncClient(event_hooks=hooks) as client: # doctest: +SKIP
222
+ ... response = await client.get("https://example.com") # doctest: +SKIP
223
+ """
224
+ trace_enabled = logger.isEnabledFor(trace_level)
225
+ event_hooks: EventHooksDict = {}
226
+
227
+ if trace_enabled:
228
+ tracer = HTTPTraceLogger(logger, trace_level=trace_level)
229
+ event_hooks = {
230
+ "request": [tracer.on_request],
231
+ "response": [tracer.on_response],
232
+ }
233
+
234
+ return event_hooks, trace_enabled
235
+
236
+
237
+ __all__ = ["DEFAULT_SENSITIVE_KEYS", "HTTPTraceLogger", "create_trace_event_hooks"]
kstlib/utils/lazy.py ADDED
@@ -0,0 +1,49 @@
1
+ """Lazy loading utilities for deferred module imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from functools import wraps
7
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def lazy_factory(module_path: str, class_name: str) -> Callable[[Callable[..., T]], Callable[..., T]]:
16
+ """Decorator for lazy loading of classes in factory functions.
17
+
18
+ Defers the import of the specified class until the factory is actually called,
19
+ reducing startup time when the factory is registered but not used.
20
+
21
+ Args:
22
+ module_path: Full dotted path to the module containing the class.
23
+ class_name: Name of the class to import from the module.
24
+
25
+ Returns:
26
+ A decorator that wraps the factory function with lazy import behavior.
27
+
28
+ Example:
29
+ >>> @lazy_factory("kstlib.secrets.providers.sops", "SOPSProvider")
30
+ ... def _sops_factory(**kwargs):
31
+ ... ... # Body is ignored, class is instantiated automatically
32
+ >>>
33
+ >>> # SOPSProvider is only imported when _sops_factory() is called
34
+ >>> provider = _sops_factory(path="secrets.sops.yml")
35
+ """
36
+
37
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
38
+ @wraps(func)
39
+ def wrapper(**kwargs: Any) -> T:
40
+ module = importlib.import_module(module_path)
41
+ cls = getattr(module, class_name)
42
+ return cast("T", cls(**kwargs))
43
+
44
+ return wrapper
45
+
46
+ return decorator
47
+
48
+
49
+ __all__ = ["lazy_factory"]