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,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
+ ]