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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.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
|
kstlib/utils/__init__.py
ADDED
|
@@ -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
|