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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Secure file deletion utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import secrets
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
DEFAULT_CHUNK_SIZE = 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SecureDeleteMethod(str, Enum):
|
|
19
|
+
"""Available strategies for securely deleting files."""
|
|
20
|
+
|
|
21
|
+
AUTO = "auto"
|
|
22
|
+
COMMAND = "command"
|
|
23
|
+
OVERWRITE = "overwrite"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SecureDeleteReport:
|
|
28
|
+
"""Summary result produced by :func:`secure_delete`."""
|
|
29
|
+
|
|
30
|
+
success: bool
|
|
31
|
+
method: SecureDeleteMethod
|
|
32
|
+
passes: int
|
|
33
|
+
command: Sequence[str] | None = None
|
|
34
|
+
message: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Sequence
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def secure_delete(
|
|
42
|
+
target: Path | str,
|
|
43
|
+
*,
|
|
44
|
+
passes: int = 3,
|
|
45
|
+
method: SecureDeleteMethod | str = SecureDeleteMethod.AUTO,
|
|
46
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
47
|
+
zero_last_pass: bool = True,
|
|
48
|
+
) -> SecureDeleteReport:
|
|
49
|
+
"""Securely remove ``target`` from disk.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
target: File path that must be removed.
|
|
53
|
+
passes: Number of overwrite passes to perform when relying on the
|
|
54
|
+
built-in overwrite implementation. Values lower than ``1`` raise
|
|
55
|
+
``ValueError``.
|
|
56
|
+
method: Preferred strategy. ``auto`` attempts to use a platform shred
|
|
57
|
+
command and falls back to overwriting when none is available or
|
|
58
|
+
when the command fails. ``command`` forces the usage of a system
|
|
59
|
+
command and reports an error if it is not available. ``overwrite``
|
|
60
|
+
forces the Python overwrite implementation.
|
|
61
|
+
chunk_size: Size, in bytes, of the chunks written during the overwrite
|
|
62
|
+
loop. Defaults to 1 MiB.
|
|
63
|
+
zero_last_pass: If ``True``, the final overwrite pass writes zeros
|
|
64
|
+
instead of random data.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A :class:`SecureDeleteReport` describing the outcome.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If ``passes`` is lower than ``1`` or if ``target`` does not
|
|
71
|
+
reference a regular file.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
Securely remove a cleartext file once it is no longer needed::
|
|
75
|
+
|
|
76
|
+
>>> from pathlib import Path
|
|
77
|
+
>>> from kstlib.utils.secure_delete import secure_delete, SecureDeleteMethod
|
|
78
|
+
>>> path = Path("secret.txt")
|
|
79
|
+
>>> _ = path.write_text("classified") # doctest: +SKIP
|
|
80
|
+
>>> report = secure_delete(path, method=SecureDeleteMethod.OVERWRITE, passes=1) # doctest: +SKIP
|
|
81
|
+
>>> report.success # doctest: +SKIP
|
|
82
|
+
True
|
|
83
|
+
"""
|
|
84
|
+
path = Path(target)
|
|
85
|
+
|
|
86
|
+
if passes < 1:
|
|
87
|
+
raise ValueError("passes must be >= 1")
|
|
88
|
+
|
|
89
|
+
if not path.exists():
|
|
90
|
+
return SecureDeleteReport(
|
|
91
|
+
success=True,
|
|
92
|
+
method=SecureDeleteMethod(method),
|
|
93
|
+
passes=passes,
|
|
94
|
+
message="Target already removed.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
raise ValueError("secure_delete only supports regular files")
|
|
99
|
+
|
|
100
|
+
resolved_method = SecureDeleteMethod(method)
|
|
101
|
+
|
|
102
|
+
if resolved_method in {SecureDeleteMethod.AUTO, SecureDeleteMethod.COMMAND}:
|
|
103
|
+
command = _build_platform_command(path, passes, zero_last_pass)
|
|
104
|
+
if command is not None:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
command,
|
|
107
|
+
check=False,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
)
|
|
111
|
+
if result.returncode == 0:
|
|
112
|
+
return SecureDeleteReport(
|
|
113
|
+
success=True,
|
|
114
|
+
method=SecureDeleteMethod.COMMAND,
|
|
115
|
+
passes=passes,
|
|
116
|
+
command=command,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if resolved_method == SecureDeleteMethod.COMMAND:
|
|
120
|
+
message = result.stderr.strip() or result.stdout.strip() or "command failed"
|
|
121
|
+
return SecureDeleteReport(
|
|
122
|
+
success=False,
|
|
123
|
+
method=SecureDeleteMethod.COMMAND,
|
|
124
|
+
passes=passes,
|
|
125
|
+
command=command,
|
|
126
|
+
message=message,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
overwrite_report = _overwrite_and_remove(path, passes, chunk_size, zero_last_pass)
|
|
130
|
+
if resolved_method == SecureDeleteMethod.COMMAND and not overwrite_report.success:
|
|
131
|
+
overwrite_report.method = SecureDeleteMethod.COMMAND
|
|
132
|
+
return overwrite_report
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _build_platform_command(path: Path, passes: int, zero_last_pass: bool) -> list[str] | None:
|
|
136
|
+
"""Return a platform-specific secure delete command when available."""
|
|
137
|
+
system = platform.system().lower()
|
|
138
|
+
shred_path = shutil.which("shred")
|
|
139
|
+
if shred_path:
|
|
140
|
+
command = [shred_path, "--force", "--remove"]
|
|
141
|
+
if passes:
|
|
142
|
+
command.append(f"--iterations={passes}")
|
|
143
|
+
if zero_last_pass:
|
|
144
|
+
command.append("--zero")
|
|
145
|
+
command.append(str(path))
|
|
146
|
+
return command
|
|
147
|
+
|
|
148
|
+
if system == "darwin":
|
|
149
|
+
srm_path = shutil.which("srm")
|
|
150
|
+
if srm_path:
|
|
151
|
+
command = [srm_path, "-f"]
|
|
152
|
+
if passes > 1:
|
|
153
|
+
command.append("-m")
|
|
154
|
+
if zero_last_pass:
|
|
155
|
+
command.append("-z")
|
|
156
|
+
command.append(str(path))
|
|
157
|
+
return command
|
|
158
|
+
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _overwrite_and_remove(
|
|
163
|
+
path: Path,
|
|
164
|
+
passes: int,
|
|
165
|
+
chunk_size: int,
|
|
166
|
+
zero_last_pass: bool,
|
|
167
|
+
) -> SecureDeleteReport:
|
|
168
|
+
"""Overwrite ``path`` with random data before unlinking it."""
|
|
169
|
+
try:
|
|
170
|
+
file_size = path.stat().st_size
|
|
171
|
+
if file_size == 0:
|
|
172
|
+
path.unlink(missing_ok=True)
|
|
173
|
+
return SecureDeleteReport(
|
|
174
|
+
success=True,
|
|
175
|
+
method=SecureDeleteMethod.OVERWRITE,
|
|
176
|
+
passes=passes,
|
|
177
|
+
message="Zero-length file removed.",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
with path.open("r+b", buffering=0) as handle:
|
|
181
|
+
for index in range(passes):
|
|
182
|
+
handle.seek(0)
|
|
183
|
+
remaining = file_size
|
|
184
|
+
while remaining > 0:
|
|
185
|
+
chunk = min(chunk_size, remaining)
|
|
186
|
+
data = bytes(chunk) if index == passes - 1 and zero_last_pass else secrets.token_bytes(chunk)
|
|
187
|
+
handle.write(data)
|
|
188
|
+
remaining -= chunk
|
|
189
|
+
handle.flush()
|
|
190
|
+
os.fsync(handle.fileno())
|
|
191
|
+
|
|
192
|
+
path.unlink(missing_ok=True)
|
|
193
|
+
return SecureDeleteReport(
|
|
194
|
+
success=True,
|
|
195
|
+
method=SecureDeleteMethod.OVERWRITE,
|
|
196
|
+
passes=passes,
|
|
197
|
+
message="File overwritten and removed.",
|
|
198
|
+
)
|
|
199
|
+
except OSError as error:
|
|
200
|
+
return SecureDeleteReport(
|
|
201
|
+
success=False,
|
|
202
|
+
method=SecureDeleteMethod.OVERWRITE,
|
|
203
|
+
passes=passes,
|
|
204
|
+
message=str(error),
|
|
205
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Serialization utilities for JSON, YAML, and XML output.
|
|
2
|
+
|
|
3
|
+
This module provides consistent serialization helpers used across kstlib,
|
|
4
|
+
particularly for CLI output formatting.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from kstlib.utils.serialization import to_json, to_xml
|
|
8
|
+
>>> data = {"name": "test", "count": 42}
|
|
9
|
+
>>> print(to_json(data))
|
|
10
|
+
{
|
|
11
|
+
"name": "test",
|
|
12
|
+
"count": 42
|
|
13
|
+
}
|
|
14
|
+
>>> xml = '<?xml version="1.0"?><root><item>test</item></root>'
|
|
15
|
+
>>> print(to_xml(xml)) # doctest: +NORMALIZE_WHITESPACE
|
|
16
|
+
<?xml version="1.0" ?>
|
|
17
|
+
<root>
|
|
18
|
+
<item>test</item>
|
|
19
|
+
</root>
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
from xml.dom import minidom
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _default_encoder(obj: Any) -> Any:
|
|
35
|
+
"""Default JSON encoder for complex types.
|
|
36
|
+
|
|
37
|
+
Handles:
|
|
38
|
+
- datetime objects (ISO format)
|
|
39
|
+
- Enum values
|
|
40
|
+
- Objects with __dict__ attribute
|
|
41
|
+
- Objects with to_dict() method
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
obj: Object to encode.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
JSON-serializable representation.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
TypeError: If object is not serializable.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(obj, datetime):
|
|
53
|
+
return obj.isoformat()
|
|
54
|
+
if isinstance(obj, Enum):
|
|
55
|
+
return obj.value
|
|
56
|
+
if hasattr(obj, "to_dict"):
|
|
57
|
+
return obj.to_dict()
|
|
58
|
+
if hasattr(obj, "__dict__"):
|
|
59
|
+
return obj.__dict__
|
|
60
|
+
msg = f"Object of type {type(obj).__name__} is not JSON serializable"
|
|
61
|
+
raise TypeError(msg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def to_json(
|
|
65
|
+
data: Any,
|
|
66
|
+
*,
|
|
67
|
+
indent: int = 2,
|
|
68
|
+
sort_keys: bool = False,
|
|
69
|
+
default: Callable[[Any], Any] | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
r"""Serialize data to JSON string.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
data: Data to serialize.
|
|
75
|
+
indent: Indentation level (default: 2).
|
|
76
|
+
sort_keys: Sort dictionary keys (default: False).
|
|
77
|
+
default: Custom encoder function (default: built-in handler).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
JSON string.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
>>> to_json({"key": "value"})
|
|
84
|
+
'{\n "key": "value"\n}'
|
|
85
|
+
"""
|
|
86
|
+
encoder = default if default is not None else _default_encoder
|
|
87
|
+
return json.dumps(data, indent=indent, sort_keys=sort_keys, default=encoder)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def to_xml(
|
|
91
|
+
xml_string: str,
|
|
92
|
+
*,
|
|
93
|
+
indent: str = " ",
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Pretty-print an XML string.
|
|
96
|
+
|
|
97
|
+
Uses xml.dom.minidom for formatting. Falls back to original string
|
|
98
|
+
if parsing fails.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
xml_string: Raw XML string to format.
|
|
102
|
+
indent: Indentation string (default: 2 spaces).
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Formatted XML string with proper indentation.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> xml = '<?xml version="1.0"?><root><item>test</item></root>'
|
|
109
|
+
>>> formatted = to_xml(xml)
|
|
110
|
+
>>> '<root>' in formatted and ' <item>' in formatted
|
|
111
|
+
True
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
# S318: Safe here - only used for display formatting, not for processing untrusted data
|
|
115
|
+
dom = minidom.parseString(xml_string.encode("utf-8")) # noqa: S318
|
|
116
|
+
# toprettyxml adds extra blank lines, clean them up
|
|
117
|
+
pretty = dom.toprettyxml(indent=indent)
|
|
118
|
+
# Remove extra blank lines that minidom creates
|
|
119
|
+
lines = [line for line in pretty.split("\n") if line.strip()]
|
|
120
|
+
return "\n".join(lines)
|
|
121
|
+
except Exception:
|
|
122
|
+
# If parsing fails, return original string
|
|
123
|
+
return xml_string
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_xml_content(content: str, content_type: str | None = None) -> bool:
|
|
127
|
+
"""Check if content is XML based on content-type header or content inspection.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
content: The content string to check.
|
|
131
|
+
content_type: Optional Content-Type header value.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if content appears to be XML.
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
>>> is_xml_content('<root/>', 'application/xml')
|
|
138
|
+
True
|
|
139
|
+
>>> is_xml_content('<?xml version="1.0"?><root/>')
|
|
140
|
+
True
|
|
141
|
+
>>> is_xml_content('{"key": "value"}')
|
|
142
|
+
False
|
|
143
|
+
"""
|
|
144
|
+
# Check content-type header first
|
|
145
|
+
if content_type:
|
|
146
|
+
ct_lower = content_type.lower()
|
|
147
|
+
if "xml" in ct_lower:
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
# Fallback: check content starts with XML markers
|
|
151
|
+
stripped = content.strip()
|
|
152
|
+
return stripped.startswith(("<?xml", "<"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def to_yaml_like(data: dict[str, Any], *, indent: int = 0) -> str:
|
|
156
|
+
"""Format dictionary as YAML-like readable output.
|
|
157
|
+
|
|
158
|
+
This is a simplified formatter for CLI output, not full YAML.
|
|
159
|
+
Handles nested dicts, lists, and converts timestamps to ISO format.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
data: Dictionary to format.
|
|
163
|
+
indent: Base indentation level.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
YAML-like formatted string.
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> print(to_yaml_like({"name": "test", "items": ["a", "b"]}))
|
|
170
|
+
name: test
|
|
171
|
+
items:
|
|
172
|
+
- a
|
|
173
|
+
- b
|
|
174
|
+
"""
|
|
175
|
+
lines: list[str] = []
|
|
176
|
+
prefix = " " * indent
|
|
177
|
+
|
|
178
|
+
for key, value in data.items():
|
|
179
|
+
if isinstance(value, dict):
|
|
180
|
+
lines.append(f"{prefix}{key}:")
|
|
181
|
+
lines.append(to_yaml_like(value, indent=indent + 1))
|
|
182
|
+
elif isinstance(value, list):
|
|
183
|
+
lines.append(f"{prefix}{key}:")
|
|
184
|
+
for item in value:
|
|
185
|
+
if isinstance(item, dict):
|
|
186
|
+
# Nested dict in list
|
|
187
|
+
lines.append(f"{prefix} -")
|
|
188
|
+
lines.append(to_yaml_like(item, indent=indent + 2))
|
|
189
|
+
else:
|
|
190
|
+
lines.append(f"{prefix} - {item}")
|
|
191
|
+
elif isinstance(value, datetime):
|
|
192
|
+
lines.append(f"{prefix}{key}: {value.isoformat()}")
|
|
193
|
+
elif isinstance(value, Enum):
|
|
194
|
+
lines.append(f"{prefix}{key}: {value.value}")
|
|
195
|
+
elif value is None:
|
|
196
|
+
lines.append(f"{prefix}{key}: ~")
|
|
197
|
+
elif isinstance(value, bool):
|
|
198
|
+
lines.append(f"{prefix}{key}: {str(value).lower()}")
|
|
199
|
+
else:
|
|
200
|
+
lines.append(f"{prefix}{key}: {value}")
|
|
201
|
+
|
|
202
|
+
return "\n".join(lines)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_output(
|
|
206
|
+
data: Any,
|
|
207
|
+
*,
|
|
208
|
+
output_format: str = "yaml",
|
|
209
|
+
) -> str:
|
|
210
|
+
"""Format data for CLI output.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
data: Data to format.
|
|
214
|
+
output_format: Output format - "json" or "yaml" (default: "yaml").
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Formatted string ready for display.
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
ValueError: If output_format is not recognized.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
>>> print(format_output({"key": "value"}, output_format="json"))
|
|
224
|
+
{
|
|
225
|
+
"key": "value"
|
|
226
|
+
}
|
|
227
|
+
"""
|
|
228
|
+
fmt = output_format.lower()
|
|
229
|
+
|
|
230
|
+
if fmt == "json":
|
|
231
|
+
return to_json(data)
|
|
232
|
+
if fmt in ("yaml", "text"):
|
|
233
|
+
if isinstance(data, dict):
|
|
234
|
+
return to_yaml_like(data)
|
|
235
|
+
return str(data)
|
|
236
|
+
|
|
237
|
+
msg = f"Unknown output format: {output_format}. Use 'json' or 'yaml'."
|
|
238
|
+
raise ValueError(msg)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
__all__ = [
|
|
242
|
+
"format_output",
|
|
243
|
+
"is_xml_content",
|
|
244
|
+
"to_json",
|
|
245
|
+
"to_xml",
|
|
246
|
+
"to_yaml_like",
|
|
247
|
+
]
|
kstlib/utils/text.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Text manipulation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
else: # pragma: no cover - runtime alias for delayed evaluation
|
|
12
|
+
Mapping = importlib.import_module("collections.abc").Mapping
|
|
13
|
+
|
|
14
|
+
_PLACEHOLDER_PATTERN = re.compile(r"{{\s*(?P<key>[\w.\-]+)\s*}}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def replace_placeholders(template: str, values: Mapping[str, Any] | None = None, /, **kwargs: Any) -> str:
|
|
18
|
+
"""Replace ``{{ placeholder }}`` tokens within *template*.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
template: Raw template string containing placeholder patterns.
|
|
22
|
+
values: Optional mapping used to look up replacement values. When provided,
|
|
23
|
+
it is merged with the keyword arguments, giving precedence to the latter.
|
|
24
|
+
**kwargs: Additional placeholder values.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Rendered template with matching placeholders substituted by their string
|
|
28
|
+
representation. Missing placeholders are left untouched to simplify
|
|
29
|
+
incremental rendering.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> replace_placeholders("Hello {{ name }}!", name="Ada")
|
|
33
|
+
'Hello Ada!'
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
combined: dict[str, Any] = {}
|
|
37
|
+
if values:
|
|
38
|
+
combined.update(dict(values))
|
|
39
|
+
if kwargs:
|
|
40
|
+
combined.update(kwargs)
|
|
41
|
+
|
|
42
|
+
def _replace(match: re.Match[str]) -> str:
|
|
43
|
+
key = match.group("key")
|
|
44
|
+
if key not in combined:
|
|
45
|
+
return match.group(0)
|
|
46
|
+
value = combined[key]
|
|
47
|
+
if value is None:
|
|
48
|
+
return ""
|
|
49
|
+
if isinstance(value, str | int | float | bool):
|
|
50
|
+
return str(value)
|
|
51
|
+
return "[object]"
|
|
52
|
+
|
|
53
|
+
return _PLACEHOLDER_PATTERN.sub(_replace, template)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["replace_placeholders"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Validation utilities used across kstlib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from email.utils import formataddr, parseaddr
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
else: # pragma: no cover - runtime alias for delayed evaluation
|
|
14
|
+
Iterable = importlib.import_module("collections.abc").Iterable
|
|
15
|
+
|
|
16
|
+
_EMAIL_PATTERN = re.compile(r"^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$")
|
|
17
|
+
|
|
18
|
+
LOCAL_PART_MAX_LENGTH = 64
|
|
19
|
+
DOMAIN_MAX_LENGTH = 255
|
|
20
|
+
LABEL_MAX_LENGTH = 63
|
|
21
|
+
MIN_LABEL_COUNT = 2
|
|
22
|
+
MIN_TLD_LENGTH = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationError(ValueError):
|
|
26
|
+
"""Raised when user supplied values fail validation."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class EmailAddress:
|
|
31
|
+
"""Normalized representation of an email address."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
address: str
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def formatted(self) -> str:
|
|
38
|
+
"""Return ``"Name <email@domain>"`` if a display name is present."""
|
|
39
|
+
if not self.name:
|
|
40
|
+
return self.address
|
|
41
|
+
sanitized = self.name.replace("\r", " ").replace("\n", " ").strip()
|
|
42
|
+
if not sanitized:
|
|
43
|
+
return self.address
|
|
44
|
+
sanitized = sanitized.replace('"', "'")
|
|
45
|
+
return formataddr((sanitized, self.address))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_email_address(value: str) -> EmailAddress:
|
|
49
|
+
"""Parse *value* into a validated :class:`EmailAddress`.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
value: Raw email string, optionally containing a display name.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A normalized :class:`EmailAddress` instance.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValidationError: If the string does not contain a valid address.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
>>> parse_email_address("Ada Lovelace <ADA@example.COM>").formatted
|
|
62
|
+
'Ada Lovelace <ada@example.com>'
|
|
63
|
+
>>> parse_email_address("foo@bar")
|
|
64
|
+
Traceback (most recent call last):
|
|
65
|
+
...
|
|
66
|
+
kstlib.utils.validators.ValidationError: Invalid email address: 'foo@bar'
|
|
67
|
+
"""
|
|
68
|
+
if not value:
|
|
69
|
+
raise ValidationError("Email address cannot be empty")
|
|
70
|
+
|
|
71
|
+
name, address = parseaddr(value)
|
|
72
|
+
address = address.strip().lower()
|
|
73
|
+
|
|
74
|
+
if name:
|
|
75
|
+
start = value.find("<")
|
|
76
|
+
end = value.rfind(">")
|
|
77
|
+
candidate = value[start + 1 : end].strip() if start != -1 and end != -1 and end > start else address
|
|
78
|
+
else:
|
|
79
|
+
candidate = value.strip()
|
|
80
|
+
|
|
81
|
+
if candidate.lower() != address:
|
|
82
|
+
raise ValidationError(f"Invalid email address: {value!r}")
|
|
83
|
+
|
|
84
|
+
if not _EMAIL_PATTERN.match(address):
|
|
85
|
+
raise ValidationError(f"Invalid email address: {value!r}")
|
|
86
|
+
|
|
87
|
+
local_part, _, domain_part = address.partition("@")
|
|
88
|
+
if len(local_part) == 0 or len(local_part) > LOCAL_PART_MAX_LENGTH:
|
|
89
|
+
raise ValidationError(f"Invalid email address: {value!r}")
|
|
90
|
+
|
|
91
|
+
if len(domain_part) == 0 or len(domain_part) > DOMAIN_MAX_LENGTH:
|
|
92
|
+
raise ValidationError(f"Invalid email address: {value!r}")
|
|
93
|
+
|
|
94
|
+
labels = domain_part.split(".")
|
|
95
|
+
if len(labels) < MIN_LABEL_COUNT or any(len(label) == 0 or len(label) > LABEL_MAX_LENGTH for label in labels):
|
|
96
|
+
raise ValidationError(f"Invalid email address: {value!r}")
|
|
97
|
+
|
|
98
|
+
if len(labels[-1]) < MIN_TLD_LENGTH:
|
|
99
|
+
raise ValidationError(f"Invalid email address: {value!r}")
|
|
100
|
+
|
|
101
|
+
name = name.strip()
|
|
102
|
+
return EmailAddress(name=name, address=address)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_address_list(values: Iterable[str]) -> list[EmailAddress]:
|
|
106
|
+
"""Validate and normalize a sequence of email addresses.
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
>>> normalize_address_list([
|
|
110
|
+
... "Ada Lovelace <ada@example.com>",
|
|
111
|
+
... "grace@example.net",
|
|
112
|
+
... ]) # doctest: +NORMALIZE_WHITESPACE
|
|
113
|
+
[EmailAddress(name='Ada Lovelace', address='ada@example.com'),
|
|
114
|
+
EmailAddress(name='', address='grace@example.net')]
|
|
115
|
+
"""
|
|
116
|
+
return [parse_email_address(value) for value in values]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"EmailAddress",
|
|
121
|
+
"ValidationError",
|
|
122
|
+
"normalize_address_list",
|
|
123
|
+
"parse_email_address",
|
|
124
|
+
]
|