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/ui/tables.py ADDED
@@ -0,0 +1,382 @@
1
+ """Config-driven helpers for rendering Rich tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # pylint: disable=too-many-arguments
6
+ import asyncio
7
+ import copy
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any
10
+
11
+ from box import Box
12
+ from rich import box as rich_box
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from kstlib.config import ConfigNotLoadedError, get_config
18
+ from kstlib.ui.exceptions import TableRenderingError
19
+ from kstlib.utils.dict import deep_merge
20
+
21
+ DEFAULT_TABLE_CONFIG: dict[str, Any] = {
22
+ "defaults": {
23
+ "table": {
24
+ "title": None,
25
+ "title_style": None,
26
+ "caption": None,
27
+ "caption_style": None,
28
+ "box": "SIMPLE",
29
+ "show_header": True,
30
+ "header_style": "bold cyan",
31
+ "show_lines": False,
32
+ "row_styles": None,
33
+ "expand": True,
34
+ "pad_edge": False,
35
+ "highlight": False,
36
+ },
37
+ "columns": [
38
+ {
39
+ "header": "Key",
40
+ "key": "key",
41
+ "justify": "left",
42
+ "style": "bold white",
43
+ "overflow": "fold",
44
+ "no_wrap": False,
45
+ },
46
+ {
47
+ "header": "Value",
48
+ "key": "value",
49
+ "justify": "left",
50
+ "style": None,
51
+ "overflow": "fold",
52
+ "no_wrap": False,
53
+ },
54
+ ],
55
+ },
56
+ "presets": {
57
+ "inventory": {
58
+ "table": {
59
+ "title": "Inventory",
60
+ "box": "ROUNDED",
61
+ "show_lines": True,
62
+ "header_style": "bold yellow",
63
+ },
64
+ },
65
+ "metrics": {
66
+ "table": {
67
+ "title": "Metrics",
68
+ "box": "SIMPLE_HEAD",
69
+ "header_style": "bold green",
70
+ },
71
+ },
72
+ },
73
+ }
74
+
75
+
76
+ class TableBuilder:
77
+ """Render Rich tables from configuration presets.
78
+
79
+ Tables follow the same configuration cascade used across kstlib:
80
+ ``kwargs > config preset > defaults``. Column definitions can be specified in
81
+ defaults, presets, or passed at runtime. Data may be provided as a sequence of
82
+ mappings or explicit row sequences.
83
+
84
+ Example:
85
+ Render a simple table with data dictionaries::
86
+
87
+ >>> from kstlib.ui.tables import TableBuilder
88
+ >>> builder = TableBuilder()
89
+ >>> data = [{"key": "Name", "value": "Alice"}, {"key": "Age", "value": "30"}]
90
+ >>> table = builder.render_table(data=data)
91
+ >>> table.row_count
92
+ 2
93
+
94
+ Using a preset and custom columns::
95
+
96
+ >>> columns = [
97
+ ... {"header": "Metric", "key": "metric"},
98
+ ... {"header": "Value", "key": "val", "justify": "right"},
99
+ ... ]
100
+ >>> data = [{"metric": "CPU", "val": "42%"}]
101
+ >>> table = builder.render_table("metrics", data=data, columns=columns)
102
+ """
103
+
104
+ def __init__(self, config: Mapping[str, Any] | Box | None = None, console: Console | None = None) -> None:
105
+ """Store optional console and resolve configuration cascade."""
106
+ self.console = console
107
+ self._config = self._prepare_config(config)
108
+
109
+ def render_table(
110
+ self,
111
+ kind: str | None = None,
112
+ *,
113
+ data: Sequence[Mapping[str, Any]] | None = None,
114
+ rows: Sequence[Sequence[Any]] | None = None,
115
+ columns: Sequence[Mapping[str, Any]] | None = None,
116
+ **overrides: Any,
117
+ ) -> Table:
118
+ """Build a ``Table`` instance according to the configuration cascade.
119
+
120
+ Args:
121
+ kind: Name of the preset to apply.
122
+ data: Sequence of mapping-like objects used to populate the table.
123
+ rows: Explicit rows as iterables; bypasses automatic extraction.
124
+ columns: Runtime column definitions. Replaces configured columns when
125
+ provided.
126
+ **overrides: Additional overrides applied on top of the resolved config.
127
+
128
+ Returns:
129
+ Configured Rich ``Table`` instance.
130
+
131
+ Raises:
132
+ TableRenderingError: If neither ``data`` nor ``rows`` can populate the
133
+ table.
134
+ """
135
+ resolved = self._resolve_table_config(kind, overrides, columns)
136
+ table_config = resolved["table"]
137
+ column_config = resolved.get("columns", [])
138
+
139
+ table = self._create_table(table_config)
140
+ self._add_columns(table, column_config)
141
+ self._populate_rows(table, column_config, data=data, rows=rows)
142
+ return table
143
+
144
+ def print_table(
145
+ self,
146
+ kind: str | None = None,
147
+ *,
148
+ data: Sequence[Mapping[str, Any]] | None = None,
149
+ rows: Sequence[Sequence[Any]] | None = None,
150
+ columns: Sequence[Mapping[str, Any]] | None = None,
151
+ console: Console | None = None,
152
+ **overrides: Any,
153
+ ) -> Table:
154
+ """Render and print a table synchronously."""
155
+ target_console = self._ensure_console(console)
156
+ table = self.render_table(
157
+ kind,
158
+ data=data,
159
+ rows=rows,
160
+ columns=columns,
161
+ **overrides,
162
+ )
163
+ target_console.print(table)
164
+ return table
165
+
166
+ async def print_table_async(
167
+ self,
168
+ kind: str | None = None,
169
+ *,
170
+ data: Sequence[Mapping[str, Any]] | None = None,
171
+ rows: Sequence[Sequence[Any]] | None = None,
172
+ columns: Sequence[Mapping[str, Any]] | None = None,
173
+ console: Console | None = None,
174
+ **overrides: Any,
175
+ ) -> Table:
176
+ """Render and print a table from an async context using a worker thread."""
177
+ target_console = self._ensure_console(console)
178
+ return await asyncio.to_thread(
179
+ self.print_table,
180
+ kind,
181
+ data=data,
182
+ rows=rows,
183
+ columns=columns,
184
+ console=target_console,
185
+ **overrides,
186
+ )
187
+
188
+ # ------------------------------------------------------------------
189
+ # Configuration resolution
190
+ # ------------------------------------------------------------------
191
+
192
+ def _prepare_config(self, config: Mapping[str, Any] | Box | None) -> dict[str, Any]:
193
+ base_config = copy.deepcopy(DEFAULT_TABLE_CONFIG)
194
+ user_config = self._load_runtime_config(config)
195
+ if user_config:
196
+ deep_merge(base_config, user_config)
197
+ return base_config
198
+
199
+ def _load_runtime_config(self, config: Mapping[str, Any] | Box | None) -> dict[str, Any]:
200
+ if config is None:
201
+ try:
202
+ config = get_config()
203
+ except ConfigNotLoadedError:
204
+ return {}
205
+ if isinstance(config, Box):
206
+ config_mapping: Mapping[str, Any] = config.to_dict()
207
+ else:
208
+ config_mapping = dict(config)
209
+
210
+ ui_config = config_mapping.get("ui", {})
211
+ if not isinstance(ui_config, Mapping):
212
+ return {}
213
+
214
+ tables_config = ui_config.get("tables", {})
215
+ if isinstance(tables_config, Box):
216
+ return tables_config.to_dict()
217
+ if isinstance(tables_config, Mapping):
218
+ return dict(tables_config)
219
+ return {}
220
+
221
+ def _resolve_table_config(
222
+ self,
223
+ kind: str | None,
224
+ overrides: Mapping[str, Any],
225
+ runtime_columns: Sequence[Mapping[str, Any]] | None,
226
+ ) -> dict[str, Any]:
227
+ defaults = copy.deepcopy(self._config["defaults"])
228
+ if not isinstance(defaults, dict):
229
+ raise TableRenderingError("Table defaults configuration must be a mapping")
230
+
231
+ config: dict[str, Any] = defaults
232
+ preset: Mapping[str, Any] = {}
233
+ raw_presets = self._config.get("presets", {})
234
+ if isinstance(raw_presets, Mapping):
235
+ candidate = raw_presets.get(kind or "", {})
236
+ if isinstance(candidate, Mapping):
237
+ preset = candidate
238
+
239
+ deep_merge(config, preset)
240
+
241
+ if runtime_columns is not None:
242
+ config["columns"] = [dict(column) for column in runtime_columns]
243
+
244
+ if overrides:
245
+ normalized = self._normalize_overrides(overrides)
246
+ deep_merge(config, normalized)
247
+
248
+ return config
249
+
250
+ @staticmethod
251
+ def _normalize_overrides(overrides: Mapping[str, Any]) -> dict[str, Any]:
252
+ normalized: dict[str, Any] = {"table": {}, "columns": None}
253
+ table_overrides = normalized["table"]
254
+
255
+ for key, value in overrides.items():
256
+ if key == "table" and isinstance(value, Mapping):
257
+ table_overrides.update(dict(value))
258
+ continue
259
+ if key == "columns" and isinstance(value, Sequence):
260
+ normalized["columns"] = [dict(column) for column in value]
261
+ continue
262
+ table_overrides[key] = value
263
+
264
+ if normalized["columns"] is None:
265
+ normalized.pop("columns", None)
266
+ return normalized
267
+
268
+ # ------------------------------------------------------------------
269
+ # Table construction
270
+ # ------------------------------------------------------------------
271
+
272
+ def _create_table(self, table_config: Mapping[str, Any]) -> Table:
273
+ box_name = table_config.get("box", "SIMPLE")
274
+ box_obj = self._resolve_box(box_name)
275
+ return Table(
276
+ title=table_config.get("title"),
277
+ caption=table_config.get("caption"),
278
+ title_style=table_config.get("title_style"),
279
+ caption_style=table_config.get("caption_style"),
280
+ show_header=table_config.get("show_header", True),
281
+ header_style=table_config.get("header_style"),
282
+ show_lines=table_config.get("show_lines", False),
283
+ row_styles=table_config.get("row_styles"),
284
+ expand=table_config.get("expand", True),
285
+ pad_edge=table_config.get("pad_edge", False),
286
+ highlight=table_config.get("highlight", False),
287
+ box=box_obj,
288
+ )
289
+
290
+ def _add_columns(self, table: Table, columns: Sequence[Mapping[str, Any]]) -> None:
291
+ if not columns:
292
+ return
293
+ for column in columns:
294
+ header = str(column.get("header", ""))
295
+ column_kwargs: dict[str, Any] = {
296
+ "style": column.get("style"),
297
+ "no_wrap": column.get("no_wrap", False),
298
+ }
299
+ justify = column.get("justify")
300
+ if justify is not None:
301
+ column_kwargs["justify"] = justify
302
+ overflow = column.get("overflow")
303
+ if overflow is not None:
304
+ column_kwargs["overflow"] = overflow
305
+ ratio = column.get("ratio")
306
+ if ratio is not None:
307
+ column_kwargs["ratio"] = ratio
308
+ width = column.get("width")
309
+ if width is not None:
310
+ column_kwargs["width"] = width
311
+ min_width = column.get("min_width")
312
+ if min_width is not None:
313
+ column_kwargs["min_width"] = min_width
314
+ max_width = column.get("max_width")
315
+ if max_width is not None:
316
+ column_kwargs["max_width"] = max_width
317
+ table.add_column(header, **column_kwargs)
318
+
319
+ def _populate_rows(
320
+ self,
321
+ table: Table,
322
+ columns: Sequence[Mapping[str, Any]],
323
+ *,
324
+ data: Sequence[Mapping[str, Any]] | None,
325
+ rows: Sequence[Sequence[Any]] | None,
326
+ ) -> None:
327
+ if rows is not None:
328
+ for row in rows:
329
+ table.add_row(*[self._render_cell(value) for value in row])
330
+ return
331
+
332
+ if data is None:
333
+ raise TableRenderingError("Table requires either 'data' or 'rows'.")
334
+
335
+ for item in data:
336
+ rendered_row = [self._render_cell(self._extract_value(item, column)) for column in columns]
337
+ table.add_row(*rendered_row)
338
+
339
+ def _extract_value(self, item: Mapping[str, Any], column: Mapping[str, Any]) -> Any:
340
+ key = column.get("key")
341
+ if key is None:
342
+ header = column.get("header")
343
+ if header is None:
344
+ return ""
345
+ return item.get(str(header), "")
346
+
347
+ if isinstance(key, str) and "." in key:
348
+ return self._extract_dotted_key(item, key)
349
+ return item.get(key, "")
350
+
351
+ @staticmethod
352
+ def _extract_dotted_key(item: Mapping[str, Any], key: str) -> Any:
353
+ current: Any = item
354
+ for part in key.split("."):
355
+ if isinstance(current, Mapping):
356
+ current = current.get(part)
357
+ else:
358
+ return ""
359
+ return current
360
+
361
+ @staticmethod
362
+ def _render_cell(value: Any) -> Text:
363
+ if isinstance(value, Text):
364
+ return value
365
+ return Text(str(value) if value is not None else "")
366
+
367
+ @staticmethod
368
+ def _resolve_box(box_name: str | None) -> Any:
369
+ candidate = box_name or "SIMPLE"
370
+ try:
371
+ return getattr(rich_box, candidate)
372
+ except AttributeError as exc: # pragma: no cover - defensive guard
373
+ raise TableRenderingError(f"Unknown box style '{candidate}'") from exc
374
+
375
+ def _ensure_console(self, console: Console | None) -> Console:
376
+ if console is not None:
377
+ return console
378
+ if self.console is None:
379
+ self.console = Console()
380
+ if self.console is None: # pragma: no cover - defensive guard
381
+ raise TableRenderingError("Console instance could not be created")
382
+ return self.console
@@ -0,0 +1,48 @@
1
+ """Utility helpers shared across kstlib modules."""
2
+
3
+ # pylint: disable=duplicate-code
4
+ from kstlib.utils.dict import deep_merge
5
+ from kstlib.utils.formatting import (
6
+ format_bytes,
7
+ format_count,
8
+ format_duration,
9
+ format_time_delta,
10
+ format_timestamp,
11
+ parse_size_string,
12
+ )
13
+ from kstlib.utils.http_trace import (
14
+ DEFAULT_SENSITIVE_KEYS,
15
+ HTTPTraceLogger,
16
+ create_trace_event_hooks,
17
+ )
18
+ from kstlib.utils.lazy import lazy_factory
19
+ from kstlib.utils.secure_delete import SecureDeleteMethod, SecureDeleteReport, secure_delete
20
+ from kstlib.utils.text import replace_placeholders
21
+ from kstlib.utils.validators import (
22
+ EmailAddress,
23
+ ValidationError,
24
+ normalize_address_list,
25
+ parse_email_address,
26
+ )
27
+
28
+ __all__ = [
29
+ "DEFAULT_SENSITIVE_KEYS",
30
+ "EmailAddress",
31
+ "HTTPTraceLogger",
32
+ "SecureDeleteMethod",
33
+ "SecureDeleteReport",
34
+ "ValidationError",
35
+ "create_trace_event_hooks",
36
+ "deep_merge",
37
+ "format_bytes",
38
+ "format_count",
39
+ "format_duration",
40
+ "format_time_delta",
41
+ "format_timestamp",
42
+ "lazy_factory",
43
+ "normalize_address_list",
44
+ "parse_email_address",
45
+ "parse_size_string",
46
+ "replace_placeholders",
47
+ "secure_delete",
48
+ ]
kstlib/utils/dict.py ADDED
@@ -0,0 +1,36 @@
1
+ """Dictionary utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+
10
+ def deep_merge(
11
+ base: dict[str, Any],
12
+ updates: Mapping[str, Any],
13
+ *,
14
+ deep_copy: bool = False,
15
+ ) -> dict[str, Any]:
16
+ """Recursively merge updates into base dictionary (in place).
17
+
18
+ Args:
19
+ base: Base dictionary to update (modified in place).
20
+ updates: Dictionary with updates to merge.
21
+ deep_copy: If True, deep copy values before assignment.
22
+
23
+ Returns:
24
+ The modified base dictionary (for chaining).
25
+
26
+ Examples:
27
+ >>> base = {"a": {"x": 1}, "b": 2}
28
+ >>> deep_merge(base, {"a": {"y": 2}, "c": 3})
29
+ {'a': {'x': 1, 'y': 2}, 'b': 2, 'c': 3}
30
+ """
31
+ for key, value in updates.items():
32
+ if key in base and isinstance(base[key], dict) and isinstance(value, Mapping):
33
+ deep_merge(base[key], value, deep_copy=deep_copy)
34
+ else:
35
+ base[key] = copy.deepcopy(value) if deep_copy else value
36
+ return base