splurge-dsv 2025.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.
@@ -0,0 +1,432 @@
1
+ """
2
+ Resource management utilities with context managers.
3
+
4
+ This module provides context managers and resource management utilities
5
+ for safe handling of file operations, streams, and other resources.
6
+
7
+ Copyright (c) 2025 Jim Schilling
8
+
9
+ Please preserve this header and all related material when sharing!
10
+
11
+ This module is licensed under the MIT License.
12
+ """
13
+
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Iterator, Any, IO
17
+
18
+ from splurge_dsv.exceptions import (
19
+ SplurgeResourceAcquisitionError,
20
+ SplurgeResourceReleaseError,
21
+ SplurgeFileNotFoundError,
22
+ SplurgeFilePermissionError,
23
+ SplurgeFileEncodingError
24
+ )
25
+ from splurge_dsv.path_validator import PathValidator
26
+
27
+
28
+ # Module-level constants for resource management
29
+ DEFAULT_BUFFERING = -1 # Default buffering for file operations
30
+ DEFAULT_ENCODING = "utf-8" # Default text encoding
31
+ DEFAULT_MODE = "r" # Default file mode for reading
32
+
33
+
34
+ def _safe_open_file(
35
+ file_path: Path,
36
+ *,
37
+ mode: str,
38
+ encoding: str | None = None,
39
+ errors: str | None = None,
40
+ newline: str | None = None,
41
+ buffering: int = DEFAULT_BUFFERING
42
+ ) -> IO[Any]:
43
+ """
44
+ Safely open a file with proper error handling.
45
+
46
+ This function provides centralized file opening with consistent error handling
47
+ that converts standard file operation exceptions to custom exceptions.
48
+
49
+ Args:
50
+ file_path: Path to the file
51
+ mode: File open mode
52
+ encoding: Text encoding (for text mode)
53
+ errors: Error handling for encoding
54
+ newline: Newline handling
55
+ buffering: Buffer size
56
+
57
+ Returns:
58
+ File handle
59
+
60
+ Raises:
61
+ SplurgeFileNotFoundError: If file is not found
62
+ SplurgeFilePermissionError: If permission is denied
63
+ SplurgeFileEncodingError: If encoding error occurs
64
+ SplurgeResourceAcquisitionError: If other file operation fails
65
+ """
66
+ try:
67
+ if 'b' in mode:
68
+ # Binary mode
69
+ return open(
70
+ file_path,
71
+ mode=mode,
72
+ buffering=buffering
73
+ )
74
+ else:
75
+ # Text mode
76
+ return open(
77
+ file_path,
78
+ mode=mode,
79
+ encoding=encoding,
80
+ errors=errors,
81
+ newline=newline,
82
+ buffering=buffering
83
+ )
84
+ except FileNotFoundError as e:
85
+ raise SplurgeFileNotFoundError(
86
+ f"File not found: {file_path}",
87
+ details=str(e)
88
+ )
89
+ except PermissionError as e:
90
+ raise SplurgeFilePermissionError(
91
+ f"Permission denied: {file_path}",
92
+ details=str(e)
93
+ )
94
+ except UnicodeDecodeError as e:
95
+ raise SplurgeFileEncodingError(
96
+ f"Encoding error reading file: {file_path}",
97
+ details=str(e)
98
+ )
99
+ except OSError as e:
100
+ raise SplurgeResourceAcquisitionError(
101
+ f"Failed to open file: {file_path}",
102
+ details=str(e)
103
+ )
104
+
105
+
106
+ class ResourceManager:
107
+ """
108
+ Generic resource manager that implements the ResourceManagerProtocol.
109
+
110
+ This class provides a base implementation for resource management
111
+ with acquire/release semantics.
112
+ """
113
+
114
+ def __init__(self) -> None:
115
+ """Initialize the resource manager."""
116
+ self._resource: Any | None = None
117
+ self._is_acquired_flag: bool = False
118
+
119
+ def acquire(self) -> Any:
120
+ """
121
+ Acquire the managed resource.
122
+
123
+ Returns:
124
+ The acquired resource
125
+
126
+ Raises:
127
+ NotImplementedError: If _create_resource is not implemented by subclass
128
+ SplurgeResourceAcquisitionError: If resource cannot be acquired
129
+ """
130
+ if self._is_acquired_flag:
131
+ raise SplurgeResourceAcquisitionError(
132
+ "Resource is already acquired",
133
+ details="Cannot acquire resource that is already in use"
134
+ )
135
+
136
+ try:
137
+ self._resource = self._create_resource()
138
+ self._is_acquired_flag = True
139
+ return self._resource
140
+ except NotImplementedError:
141
+ # Re-raise NotImplementedError without wrapping it
142
+ raise
143
+ except Exception as e:
144
+ raise SplurgeResourceAcquisitionError(
145
+ "Failed to acquire resource",
146
+ details=str(e)
147
+ )
148
+
149
+ def release(self) -> None:
150
+ """
151
+ Release the managed resource.
152
+
153
+ Raises:
154
+ SplurgeResourceReleaseError: If resource cannot be released
155
+ """
156
+ if not self._is_acquired_flag:
157
+ return # Nothing to release
158
+
159
+ try:
160
+ self._cleanup_resource()
161
+ self._resource = None
162
+ self._is_acquired_flag = False
163
+ except Exception as e:
164
+ raise SplurgeResourceReleaseError(
165
+ "Failed to release resource",
166
+ details=str(e)
167
+ )
168
+
169
+ def is_acquired(self) -> bool:
170
+ """
171
+ Check if the resource is currently acquired.
172
+
173
+ Returns:
174
+ True if resource is acquired, False otherwise
175
+ """
176
+ return self._is_acquired_flag
177
+
178
+ def _create_resource(self) -> Any:
179
+ """
180
+ Create the resource to be managed.
181
+
182
+ This method should be overridden by subclasses to provide
183
+ specific resource creation logic.
184
+
185
+ Returns:
186
+ The created resource
187
+
188
+ Raises:
189
+ NotImplementedError: If not overridden by subclass
190
+ """
191
+ raise NotImplementedError("Subclasses must implement _create_resource")
192
+
193
+ def _cleanup_resource(self) -> None:
194
+ """
195
+ Clean up the managed resource.
196
+
197
+ This method should be overridden by subclasses to provide
198
+ specific resource cleanup logic.
199
+ """
200
+ if self._resource is not None and hasattr(self._resource, 'close'):
201
+ self._resource.close()
202
+
203
+
204
+ class FileResourceManager:
205
+ """
206
+ Context manager for safe file operations with automatic cleanup.
207
+
208
+ This class provides context managers for reading and writing files
209
+ with proper error handling and resource cleanup.
210
+ """
211
+
212
+ def __init__(
213
+ self,
214
+ file_path: str | Path,
215
+ *,
216
+ mode: str = DEFAULT_MODE,
217
+ encoding: str | None = DEFAULT_ENCODING,
218
+ errors: str | None = None,
219
+ newline: str | None = None,
220
+ buffering: int = DEFAULT_BUFFERING
221
+ ) -> None:
222
+ """
223
+ Initialize FileResourceManager.
224
+
225
+ Args:
226
+ file_path: Path to the file
227
+ mode: File open mode ('r', 'w', 'a', etc.)
228
+ encoding: Text encoding (for text mode)
229
+ errors: Error handling for encoding
230
+ newline: Newline handling
231
+ buffering: Buffer size
232
+
233
+ Raises:
234
+ SplurgePathValidationError: If file path is invalid
235
+ SplurgeResourceAcquisitionError: If file cannot be opened
236
+ """
237
+ self._file_path = PathValidator.validate_path(
238
+ file_path,
239
+ must_exist=(mode in ['r', 'rb']),
240
+ must_be_file=True,
241
+ must_be_readable=(mode in ['r', 'rb'])
242
+ )
243
+ self.mode = mode
244
+ self.encoding = encoding
245
+ self.errors = errors
246
+ self.newline = newline
247
+ self.buffering = buffering
248
+ self._file_handle: IO[Any] | None = None
249
+
250
+ def __enter__(self) -> IO[Any]:
251
+ """
252
+ Open the file and return the file handle.
253
+
254
+ Returns:
255
+ File handle
256
+
257
+ Raises:
258
+ SplurgeFileNotFoundError: If file is not found
259
+ SplurgeFilePermissionError: If permission is denied
260
+ SplurgeFileEncodingError: If encoding error occurs
261
+ SplurgeResourceAcquisitionError: If other file operation fails
262
+ """
263
+ self._file_handle = _safe_open_file(
264
+ self.file_path,
265
+ mode=self.mode,
266
+ encoding=self.encoding,
267
+ errors=self.errors,
268
+ newline=self.newline,
269
+ buffering=self.buffering
270
+ )
271
+ return self._file_handle
272
+
273
+ def __exit__(
274
+ self,
275
+ exc_type: type | None,
276
+ exc_val: Exception | None,
277
+ exc_tb: Any | None
278
+ ) -> None:
279
+ """
280
+ Close the file handle and cleanup resources.
281
+
282
+ Args:
283
+ exc_type: Exception type if an exception occurred
284
+ exc_val: Exception value if an exception occurred
285
+ exc_tb: Exception traceback if an exception occurred
286
+ """
287
+ if self._file_handle is not None:
288
+ try:
289
+ self._file_handle.close()
290
+ except OSError as e:
291
+ raise SplurgeResourceReleaseError(
292
+ f"Failed to close file: {self.file_path}",
293
+ details=str(e)
294
+ )
295
+ finally:
296
+ self._file_handle = None
297
+
298
+ @property
299
+ def file_path(self) -> Path | None:
300
+ """Get the path of the temporary file."""
301
+ return self._file_path
302
+
303
+
304
+ class StreamResourceManager:
305
+ """
306
+ Context manager for stream operations.
307
+
308
+ This class provides context managers for managing data streams
309
+ with proper cleanup and error handling.
310
+ """
311
+
312
+ def __init__(
313
+ self,
314
+ stream: Iterator[Any],
315
+ *,
316
+ auto_close: bool = True
317
+ ) -> None:
318
+ """
319
+ Initialize StreamResourceManager.
320
+
321
+ Args:
322
+ stream: Iterator to manage
323
+ auto_close: Whether to automatically close the stream
324
+ """
325
+ self.stream = stream
326
+ self.auto_close = auto_close
327
+ self._is_closed = False
328
+
329
+ def __enter__(self) -> Iterator[Any]:
330
+ """
331
+ Return the stream.
332
+
333
+ Returns:
334
+ Stream iterator
335
+ """
336
+ return self.stream
337
+
338
+ def __exit__(
339
+ self,
340
+ exc_type: type | None,
341
+ exc_val: Exception | None,
342
+ exc_tb: Any | None
343
+ ) -> None:
344
+ """
345
+ Clean up the stream.
346
+
347
+ Args:
348
+ exc_type: Exception type if an exception occurred
349
+ exc_val: Exception value if an exception occurred
350
+ exc_tb: Exception traceback if an exception occurred
351
+ """
352
+ if self.auto_close and hasattr(self.stream, 'close'):
353
+ try:
354
+ self.stream.close()
355
+ except Exception as e:
356
+ raise SplurgeResourceReleaseError(
357
+ "Failed to close stream",
358
+ details=str(e)
359
+ )
360
+
361
+ # Mark as closed after context manager exits, regardless of close method
362
+ self._is_closed = True
363
+
364
+ @property
365
+ def is_closed(self) -> bool:
366
+ """Check if the stream is closed."""
367
+ return self._is_closed
368
+
369
+
370
+ @contextmanager
371
+ def safe_file_operation(
372
+ file_path: str | Path,
373
+ *,
374
+ mode: str = DEFAULT_MODE,
375
+ encoding: str | None = DEFAULT_ENCODING,
376
+ errors: str | None = None,
377
+ newline: str | None = None,
378
+ buffering: int = DEFAULT_BUFFERING
379
+ ) -> Iterator[IO[Any]]:
380
+ """
381
+ Context manager for safe file operations.
382
+
383
+ Args:
384
+ file_path: Path to the file
385
+ mode: File open mode
386
+ encoding: Text encoding (for text mode)
387
+ errors: Error handling for encoding
388
+ newline: Newline handling
389
+ buffering: Buffer size
390
+
391
+ Yields:
392
+ File handle
393
+
394
+ Raises:
395
+ SplurgePathValidationError: If file path is invalid
396
+ SplurgeResourceAcquisitionError: If file cannot be opened
397
+ SplurgeResourceReleaseError: If file cannot be closed
398
+ """
399
+ manager = FileResourceManager(
400
+ file_path,
401
+ mode=mode,
402
+ encoding=encoding,
403
+ errors=errors,
404
+ newline=newline,
405
+ buffering=buffering
406
+ )
407
+ with manager as file_handle:
408
+ yield file_handle
409
+
410
+
411
+ @contextmanager
412
+ def safe_stream_operation(
413
+ stream: Iterator[Any],
414
+ *,
415
+ auto_close: bool = True
416
+ ) -> Iterator[Iterator[Any]]:
417
+ """
418
+ Context manager for safe stream operations.
419
+
420
+ Args:
421
+ stream: Iterator to manage
422
+ auto_close: Whether to automatically close the stream
423
+
424
+ Yields:
425
+ Stream iterator
426
+
427
+ Raises:
428
+ SplurgeResourceReleaseError: If stream cannot be closed
429
+ """
430
+ manager = StreamResourceManager(stream, auto_close=auto_close)
431
+ with manager as stream_handle:
432
+ yield stream_handle
@@ -0,0 +1,136 @@
1
+ """
2
+ string_tokenizer.py
3
+
4
+ A utility module for string tokenization operations.
5
+ Provides methods to split strings into tokens and manipulate string boundaries.
6
+
7
+ Copyright (c) 2025 Jim Schilling
8
+
9
+ Please preserve this header and all related material when sharing!
10
+
11
+ This module is licensed under the MIT License.
12
+ """
13
+
14
+ from splurge_dsv.exceptions import SplurgeParameterError
15
+
16
+
17
+ class StringTokenizer:
18
+ """
19
+ Utility class for string tokenization operations.
20
+
21
+ This class provides methods to:
22
+ - Split strings into tokens based on delimiters
23
+ - Process multiple strings into token lists
24
+ - Remove matching characters from string boundaries
25
+ """
26
+
27
+ DEFAULT_STRIP = True
28
+
29
+ @staticmethod
30
+ def parse(
31
+ content: str | None,
32
+ *,
33
+ delimiter: str,
34
+ strip: bool = DEFAULT_STRIP
35
+ ) -> list[str]:
36
+ """
37
+ Split a string into tokens based on a delimiter.
38
+
39
+ Args:
40
+ content (str | None): The input string to tokenize
41
+ delimiter (str): The character(s) to split the string on
42
+ strip (bool, optional): Whether to strip whitespace from tokens. Defaults to True.
43
+
44
+ Returns:
45
+ list[str]: List of tokens, preserving empty tokens
46
+
47
+ Raises:
48
+ SplurgeParameterError: If delimiter is empty or None
49
+
50
+ Example:
51
+ >>> StringTokenizer.parse("a,b,c", delimiter=",")
52
+ ['a', 'b', 'c']
53
+ >>> StringTokenizer.parse("a,,c", delimiter=",")
54
+ ['a', '', 'c']
55
+ """
56
+ if content is None:
57
+ return []
58
+
59
+ if delimiter is None or delimiter == "":
60
+ raise SplurgeParameterError("delimiter cannot be empty or None")
61
+
62
+ if strip and not content.strip():
63
+ return []
64
+
65
+ result: list[str] = content.split(delimiter)
66
+ if strip:
67
+ result = [token.strip() for token in result]
68
+ return result
69
+
70
+ @classmethod
71
+ def parses(
72
+ cls,
73
+ content: list[str],
74
+ *,
75
+ delimiter: str,
76
+ strip: bool = DEFAULT_STRIP
77
+ ) -> list[list[str]]:
78
+ """
79
+ Process multiple strings into lists of tokens.
80
+
81
+ Args:
82
+ content (list[str]): List of strings to tokenize
83
+ delimiter (str): The character(s) to split each string on
84
+ strip (bool, optional): Whether to strip whitespace from tokens. Defaults to True.
85
+
86
+ Returns:
87
+ list[list[str]]: List of token lists, one for each input string
88
+
89
+ Raises:
90
+ SplurgeParameterError: If delimiter is empty or None
91
+
92
+ Example:
93
+ >>> StringTokenizer.parses(["a,b", "c,d"], delimiter=",")
94
+ [['a', 'b'], ['c', 'd']]
95
+ """
96
+ if delimiter is None or delimiter == "":
97
+ raise SplurgeParameterError("delimiter cannot be empty or None")
98
+
99
+ return [cls.parse(text, delimiter=delimiter, strip=strip) for text in content]
100
+
101
+ @staticmethod
102
+ def remove_bookends(
103
+ content: str,
104
+ *,
105
+ bookend: str,
106
+ strip: bool = DEFAULT_STRIP
107
+ ) -> str:
108
+ """
109
+ Remove matching characters from both ends of a string.
110
+
111
+ Args:
112
+ content (str): The input string to process
113
+ bookend (str): The character(s) to remove from both ends
114
+ strip (bool, optional): Whether to strip whitespace first. Defaults to True.
115
+
116
+ Returns:
117
+ str: The string with matching bookends removed
118
+
119
+ Raises:
120
+ SplurgeParameterError: If bookend is empty or None
121
+
122
+ Example:
123
+ >>> StringTokenizer.remove_bookends("'hello'", bookend="'")
124
+ 'hello'
125
+ """
126
+ if bookend is None or bookend == "":
127
+ raise SplurgeParameterError("bookend cannot be empty or None")
128
+
129
+ value: str = content.strip() if strip else content
130
+ if (
131
+ value.startswith(bookend)
132
+ and value.endswith(bookend)
133
+ and len(value) > 2 * len(bookend) - 1
134
+ ):
135
+ return value[len(bookend) : -len(bookend)]
136
+ return value