taipanstack 0.1.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.
- taipanstack/__init__.py +53 -0
- taipanstack/config/__init__.py +25 -0
- taipanstack/config/generators.py +357 -0
- taipanstack/config/models.py +316 -0
- taipanstack/config/version_config.py +227 -0
- taipanstack/core/__init__.py +47 -0
- taipanstack/core/compat.py +329 -0
- taipanstack/core/optimizations.py +392 -0
- taipanstack/core/result.py +199 -0
- taipanstack/security/__init__.py +55 -0
- taipanstack/security/decorators.py +369 -0
- taipanstack/security/guards.py +362 -0
- taipanstack/security/sanitizers.py +321 -0
- taipanstack/security/validators.py +342 -0
- taipanstack/utils/__init__.py +24 -0
- taipanstack/utils/circuit_breaker.py +268 -0
- taipanstack/utils/filesystem.py +417 -0
- taipanstack/utils/logging.py +328 -0
- taipanstack/utils/metrics.py +272 -0
- taipanstack/utils/retry.py +300 -0
- taipanstack/utils/subprocess.py +344 -0
- taipanstack-0.1.0.dist-info/METADATA +350 -0
- taipanstack-0.1.0.dist-info/RECORD +25 -0
- taipanstack-0.1.0.dist-info/WHEEL +4 -0
- taipanstack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safe filesystem operations.
|
|
3
|
+
|
|
4
|
+
Provides secure wrappers around file operations with path validation,
|
|
5
|
+
atomic writes, and proper error handling using Result types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TypeAlias
|
|
18
|
+
|
|
19
|
+
from taipanstack.core.result import Err, Ok, Result
|
|
20
|
+
from taipanstack.security.guards import SecurityError, guard_path_traversal
|
|
21
|
+
from taipanstack.security.sanitizers import sanitize_filename
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class FileNotFoundErr:
|
|
26
|
+
"""Error when file is not found."""
|
|
27
|
+
|
|
28
|
+
path: Path
|
|
29
|
+
message: str = ""
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
"""Set default message."""
|
|
33
|
+
object.__setattr__(
|
|
34
|
+
self, "message", self.message or f"File not found: {self.path}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class NotAFileErr:
|
|
40
|
+
"""Error when path is not a file."""
|
|
41
|
+
|
|
42
|
+
path: Path
|
|
43
|
+
message: str = ""
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
"""Set default message."""
|
|
47
|
+
object.__setattr__(self, "message", self.message or f"Not a file: {self.path}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class FileTooLargeErr:
|
|
52
|
+
"""Error when file exceeds size limit."""
|
|
53
|
+
|
|
54
|
+
path: Path
|
|
55
|
+
size: int
|
|
56
|
+
max_size: int
|
|
57
|
+
message: str = ""
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
"""Set default message."""
|
|
61
|
+
object.__setattr__(
|
|
62
|
+
self,
|
|
63
|
+
"message",
|
|
64
|
+
self.message or f"File too large: {self.size} bytes (max: {self.max_size})",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Union type for safe_read errors
|
|
69
|
+
ReadFileError: TypeAlias = (
|
|
70
|
+
FileNotFoundErr | NotAFileErr | FileTooLargeErr | SecurityError
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def safe_read(
|
|
75
|
+
path: Path | str,
|
|
76
|
+
*,
|
|
77
|
+
base_dir: Path | str | None = None,
|
|
78
|
+
encoding: str = "utf-8",
|
|
79
|
+
max_size_bytes: int | None = 10 * 1024 * 1024, # 10MB default
|
|
80
|
+
) -> Result[str, ReadFileError]:
|
|
81
|
+
"""Read a file safely with path validation.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
path: Path to the file to read.
|
|
85
|
+
base_dir: Base directory to constrain to.
|
|
86
|
+
encoding: File encoding.
|
|
87
|
+
max_size_bytes: Maximum file size to read (None for no limit).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Ok(str): File contents on success.
|
|
91
|
+
Err(ReadFileError): Error details on failure.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
>>> match safe_read("config.json"):
|
|
95
|
+
... case Ok(content):
|
|
96
|
+
... data = json.loads(content)
|
|
97
|
+
... case Err(FileNotFoundErr(path=p)):
|
|
98
|
+
... print(f"Missing: {p}")
|
|
99
|
+
... case Err(FileTooLargeErr(size=s)):
|
|
100
|
+
... print(f"Too big: {s} bytes")
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
path = Path(path)
|
|
104
|
+
|
|
105
|
+
# Validate path
|
|
106
|
+
try:
|
|
107
|
+
if base_dir is not None:
|
|
108
|
+
path = guard_path_traversal(path, Path(base_dir))
|
|
109
|
+
elif ".." in str(path):
|
|
110
|
+
path = guard_path_traversal(path, Path.cwd())
|
|
111
|
+
except SecurityError as e:
|
|
112
|
+
return Err(e)
|
|
113
|
+
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return Err(FileNotFoundErr(path=path))
|
|
116
|
+
|
|
117
|
+
if not path.is_file():
|
|
118
|
+
return Err(NotAFileErr(path=path))
|
|
119
|
+
|
|
120
|
+
# Check file size
|
|
121
|
+
if max_size_bytes is not None:
|
|
122
|
+
file_size = path.stat().st_size
|
|
123
|
+
if file_size > max_size_bytes:
|
|
124
|
+
return Err(
|
|
125
|
+
FileTooLargeErr(path=path, size=file_size, max_size=max_size_bytes)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return Ok(path.read_text(encoding=encoding))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def safe_write(
|
|
132
|
+
path: Path | str,
|
|
133
|
+
content: str,
|
|
134
|
+
*,
|
|
135
|
+
base_dir: Path | str | None = None,
|
|
136
|
+
encoding: str = "utf-8",
|
|
137
|
+
create_parents: bool = True,
|
|
138
|
+
backup: bool = True,
|
|
139
|
+
atomic: bool = True,
|
|
140
|
+
) -> Path:
|
|
141
|
+
"""Write to a file safely with path validation.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
path: Path to write to.
|
|
145
|
+
content: Content to write.
|
|
146
|
+
base_dir: Base directory to constrain to.
|
|
147
|
+
encoding: File encoding.
|
|
148
|
+
create_parents: Create parent directories if needed.
|
|
149
|
+
backup: Create backup of existing file.
|
|
150
|
+
atomic: Use atomic write (write to temp, then rename).
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Path to the written file.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
SecurityError: If path validation fails.
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
path = Path(path)
|
|
160
|
+
|
|
161
|
+
# Validate path
|
|
162
|
+
if base_dir is not None:
|
|
163
|
+
base = Path(base_dir).resolve()
|
|
164
|
+
# For new files, validate the parent
|
|
165
|
+
if not path.exists():
|
|
166
|
+
parent = path.parent
|
|
167
|
+
guard_path_traversal(parent, base)
|
|
168
|
+
else:
|
|
169
|
+
guard_path_traversal(path, base)
|
|
170
|
+
elif ".." in str(path):
|
|
171
|
+
guard_path_traversal(path, Path.cwd())
|
|
172
|
+
|
|
173
|
+
# Sanitize filename
|
|
174
|
+
safe_name = sanitize_filename(path.name)
|
|
175
|
+
path = path.parent / safe_name
|
|
176
|
+
|
|
177
|
+
# Create parents if needed
|
|
178
|
+
if create_parents:
|
|
179
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
# Create backup if file exists
|
|
182
|
+
if backup and path.exists():
|
|
183
|
+
backup_path = path.with_suffix(f"{path.suffix}.bak")
|
|
184
|
+
shutil.copy2(path, backup_path)
|
|
185
|
+
|
|
186
|
+
# Write file
|
|
187
|
+
if atomic:
|
|
188
|
+
# Write to temp file first, then rename
|
|
189
|
+
_fd, temp_path = tempfile.mkstemp(
|
|
190
|
+
dir=path.parent,
|
|
191
|
+
prefix=f".{path.name}.",
|
|
192
|
+
suffix=".tmp",
|
|
193
|
+
)
|
|
194
|
+
try:
|
|
195
|
+
# Close the file descriptor immediately - required for Windows
|
|
196
|
+
os.close(_fd)
|
|
197
|
+
temp_file = Path(temp_path)
|
|
198
|
+
temp_file.write_text(content, encoding=encoding)
|
|
199
|
+
# Preserve permissions if original exists
|
|
200
|
+
if path.exists():
|
|
201
|
+
shutil.copymode(path, temp_file)
|
|
202
|
+
# On Windows, we need to remove the target first if it exists
|
|
203
|
+
if path.exists():
|
|
204
|
+
path.unlink()
|
|
205
|
+
temp_file.rename(path)
|
|
206
|
+
except Exception:
|
|
207
|
+
# Clean up temp file on error
|
|
208
|
+
with contextlib.suppress(OSError):
|
|
209
|
+
Path(temp_path).unlink(missing_ok=True)
|
|
210
|
+
raise
|
|
211
|
+
else:
|
|
212
|
+
path.write_text(content, encoding=encoding)
|
|
213
|
+
|
|
214
|
+
return path.resolve()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def ensure_dir(
|
|
218
|
+
path: Path | str,
|
|
219
|
+
*,
|
|
220
|
+
base_dir: Path | str | None = None,
|
|
221
|
+
mode: int = 0o755,
|
|
222
|
+
) -> Path:
|
|
223
|
+
"""Ensure a directory exists, creating it if needed.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
path: Path to the directory.
|
|
227
|
+
base_dir: Base directory to constrain to.
|
|
228
|
+
mode: Directory permissions.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Path to the directory.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
SecurityError: If path validation fails.
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
path = Path(path)
|
|
238
|
+
|
|
239
|
+
# Validate path
|
|
240
|
+
if base_dir is not None:
|
|
241
|
+
path = guard_path_traversal(path, Path(base_dir), allow_symlinks=True)
|
|
242
|
+
elif ".." in str(path):
|
|
243
|
+
path = guard_path_traversal(path, Path.cwd())
|
|
244
|
+
|
|
245
|
+
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
|
246
|
+
return path.resolve()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def safe_copy(
|
|
250
|
+
src: Path | str,
|
|
251
|
+
dst: Path | str,
|
|
252
|
+
*,
|
|
253
|
+
base_dir: Path | str | None = None,
|
|
254
|
+
overwrite: bool = False,
|
|
255
|
+
) -> Path:
|
|
256
|
+
"""Copy a file safely.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
src: Source file path.
|
|
260
|
+
dst: Destination file path.
|
|
261
|
+
base_dir: Base directory to constrain both paths to.
|
|
262
|
+
overwrite: Allow overwriting existing file.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Path to the destination file.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
SecurityError: If path validation fails.
|
|
269
|
+
FileExistsError: If destination exists and overwrite=False.
|
|
270
|
+
|
|
271
|
+
"""
|
|
272
|
+
src = Path(src)
|
|
273
|
+
dst = Path(dst)
|
|
274
|
+
|
|
275
|
+
# Validate paths
|
|
276
|
+
if base_dir is not None:
|
|
277
|
+
base = Path(base_dir)
|
|
278
|
+
src = guard_path_traversal(src, base)
|
|
279
|
+
# For dst, validate parent if file doesn't exist
|
|
280
|
+
if dst.exists():
|
|
281
|
+
dst = guard_path_traversal(dst, base)
|
|
282
|
+
else:
|
|
283
|
+
guard_path_traversal(dst.parent, base)
|
|
284
|
+
|
|
285
|
+
if not src.exists():
|
|
286
|
+
raise FileNotFoundError(f"Source file not found: {src}")
|
|
287
|
+
|
|
288
|
+
if dst.exists() and not overwrite:
|
|
289
|
+
raise FileExistsError(f"Destination already exists: {dst}")
|
|
290
|
+
|
|
291
|
+
# Ensure parent directory exists
|
|
292
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
|
|
294
|
+
shutil.copy2(src, dst)
|
|
295
|
+
return dst.resolve()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def safe_delete(
|
|
299
|
+
path: Path | str,
|
|
300
|
+
*,
|
|
301
|
+
base_dir: Path | str | None = None,
|
|
302
|
+
missing_ok: bool = True,
|
|
303
|
+
recursive: bool = False,
|
|
304
|
+
) -> bool:
|
|
305
|
+
"""Delete a file or directory safely.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
path: Path to delete.
|
|
309
|
+
base_dir: Base directory to constrain to.
|
|
310
|
+
missing_ok: Don't raise if path doesn't exist.
|
|
311
|
+
recursive: Allow deleting directories recursively.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
True if something was deleted.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
SecurityError: If path validation fails.
|
|
318
|
+
FileNotFoundError: If path doesn't exist and missing_ok=False.
|
|
319
|
+
|
|
320
|
+
"""
|
|
321
|
+
path = Path(path)
|
|
322
|
+
|
|
323
|
+
# Validate path
|
|
324
|
+
if base_dir is not None:
|
|
325
|
+
path = guard_path_traversal(path, Path(base_dir))
|
|
326
|
+
elif ".." in str(path):
|
|
327
|
+
path = guard_path_traversal(path, Path.cwd())
|
|
328
|
+
|
|
329
|
+
if not path.exists():
|
|
330
|
+
if missing_ok:
|
|
331
|
+
return False
|
|
332
|
+
raise FileNotFoundError(f"Path not found: {path}")
|
|
333
|
+
|
|
334
|
+
if path.is_dir():
|
|
335
|
+
if recursive:
|
|
336
|
+
shutil.rmtree(path)
|
|
337
|
+
else:
|
|
338
|
+
path.rmdir()
|
|
339
|
+
else:
|
|
340
|
+
path.unlink()
|
|
341
|
+
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def get_file_hash(
|
|
346
|
+
path: Path | str,
|
|
347
|
+
*,
|
|
348
|
+
algorithm: str = "sha256",
|
|
349
|
+
base_dir: Path | str | None = None,
|
|
350
|
+
) -> str:
|
|
351
|
+
"""Get hash of a file.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
path: Path to the file.
|
|
355
|
+
algorithm: Hash algorithm (sha256, md5, etc).
|
|
356
|
+
base_dir: Base directory to constrain to.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Hex digest of the file hash.
|
|
360
|
+
|
|
361
|
+
"""
|
|
362
|
+
path = Path(path)
|
|
363
|
+
|
|
364
|
+
# Validate path
|
|
365
|
+
if base_dir is not None:
|
|
366
|
+
path = guard_path_traversal(path, Path(base_dir))
|
|
367
|
+
|
|
368
|
+
hasher = hashlib.new(algorithm)
|
|
369
|
+
|
|
370
|
+
with path.open("rb") as f:
|
|
371
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
372
|
+
hasher.update(chunk)
|
|
373
|
+
|
|
374
|
+
return hasher.hexdigest()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def find_files(
|
|
378
|
+
directory: Path | str,
|
|
379
|
+
pattern: str = "*",
|
|
380
|
+
*,
|
|
381
|
+
base_dir: Path | str | None = None,
|
|
382
|
+
recursive: bool = True,
|
|
383
|
+
include_hidden: bool = False,
|
|
384
|
+
) -> list[Path]:
|
|
385
|
+
"""Find files matching a pattern.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
directory: Directory to search in.
|
|
389
|
+
pattern: Glob pattern to match.
|
|
390
|
+
base_dir: Base directory to constrain to.
|
|
391
|
+
recursive: Search recursively.
|
|
392
|
+
include_hidden: Include hidden files (starting with .).
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
List of matching file paths.
|
|
396
|
+
|
|
397
|
+
"""
|
|
398
|
+
directory = Path(directory)
|
|
399
|
+
|
|
400
|
+
# Validate path
|
|
401
|
+
if base_dir is not None:
|
|
402
|
+
directory = guard_path_traversal(directory, Path(base_dir))
|
|
403
|
+
|
|
404
|
+
if not directory.exists():
|
|
405
|
+
return []
|
|
406
|
+
|
|
407
|
+
if recursive:
|
|
408
|
+
files = list(directory.rglob(pattern))
|
|
409
|
+
else:
|
|
410
|
+
files = list(directory.glob(pattern))
|
|
411
|
+
|
|
412
|
+
# Filter hidden files if needed
|
|
413
|
+
if not include_hidden:
|
|
414
|
+
files = [f for f in files if not any(p.startswith(".") for p in f.parts)]
|
|
415
|
+
|
|
416
|
+
# Only return files, not directories
|
|
417
|
+
return [f for f in files if f.is_file()]
|