kaparoo-python 0.8.0__py3-none-any.whl → 0.9.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.
- kaparoo/data/sequences/composers.py +5 -4
- kaparoo/data/sequences/templates.py +2 -2
- kaparoo/filesystem/__init__.py +12 -1
- kaparoo/filesystem/directory.py +8 -8
- kaparoo/filesystem/exceptions.py +51 -2
- kaparoo/filesystem/existence.py +6 -6
- kaparoo/filesystem/hierarchy/entry.py +11 -1
- kaparoo/filesystem/hierarchy/scaffold.py +132 -90
- kaparoo/filesystem/hierarchy/traverse/_utils.py +1 -4
- kaparoo/filesystem/hierarchy/traverse/validate.py +9 -3
- kaparoo/filesystem/utils.py +63 -30
- kaparoo/filters/enumerable.py +5 -5
- kaparoo/filters/logical.py +3 -3
- kaparoo/utils/timer.py +25 -20
- {kaparoo_python-0.8.0.dist-info → kaparoo_python-0.9.0.dist-info}/METADATA +3 -2
- {kaparoo_python-0.8.0.dist-info → kaparoo_python-0.9.0.dist-info}/RECORD +18 -18
- {kaparoo_python-0.8.0.dist-info → kaparoo_python-0.9.0.dist-info}/WHEEL +1 -1
- {kaparoo_python-0.8.0.dist-info → kaparoo_python-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -150,10 +150,11 @@ class TransformedSequence[T_in, M_in, T_out = T_in, M_out = M_in](
|
|
|
150
150
|
|
|
151
151
|
@override
|
|
152
152
|
def get_meta(self, index: int) -> M_out:
|
|
153
|
-
"""Pass `source`'s metadata through unchanged
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
"""Pass `source`'s metadata through unchanged.
|
|
154
|
+
|
|
155
|
+
A subclass whose `M_out` differs from `M_in` must override this.
|
|
156
|
+
"""
|
|
157
|
+
# The cast cannot catch a missing override -- generics are erased at runtime.
|
|
157
158
|
return cast("M_out", self._source.get_meta(index))
|
|
158
159
|
|
|
159
160
|
|
|
@@ -46,7 +46,7 @@ class FileListSequence[T, M = Path](DataSequence[T, M]):
|
|
|
46
46
|
|
|
47
47
|
@cached_property
|
|
48
48
|
def files(self) -> tuple[Path, ...]:
|
|
49
|
-
"""Immutable snapshot of the full file paths, in order
|
|
49
|
+
"""Immutable snapshot of the full file paths, in order."""
|
|
50
50
|
return tuple(self.get_file(i) for i in range(len(self)))
|
|
51
51
|
|
|
52
52
|
def get_file(self, index: int) -> Path:
|
|
@@ -130,7 +130,7 @@ class FileFolderSequence[T, M = Path](FileListSequence[T, M]):
|
|
|
130
130
|
|
|
131
131
|
@abstractmethod
|
|
132
132
|
def list_files(self, root: Path) -> list[Path]:
|
|
133
|
-
"""Return the full Path of every file to expose, in order.
|
|
133
|
+
"""Return the full `Path` of every file to expose, in order.
|
|
134
134
|
|
|
135
135
|
Called once from `__init__` after `root` has been validated.
|
|
136
136
|
Every returned path must be under `root`; construction raises
|
kaparoo/filesystem/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@ __all__ = (
|
|
|
3
3
|
"NotAFileError",
|
|
4
4
|
"StagedDirectory",
|
|
5
5
|
"StagedFile",
|
|
6
|
+
"UnsupportedExtensionError",
|
|
6
7
|
"dir_empty",
|
|
7
8
|
"dir_empty_unsafe",
|
|
8
9
|
"dir_exists",
|
|
@@ -21,9 +22,12 @@ __all__ = (
|
|
|
21
22
|
"ensure_path_exists",
|
|
22
23
|
"ensure_paths_exist",
|
|
23
24
|
"file_exists",
|
|
25
|
+
"file_extension",
|
|
24
26
|
"files_exist",
|
|
25
27
|
"make_dir",
|
|
26
28
|
"make_dirs",
|
|
29
|
+
"normalize_extension",
|
|
30
|
+
"normalize_extensions",
|
|
27
31
|
"path_exists",
|
|
28
32
|
"paths_exist",
|
|
29
33
|
"reserve_path",
|
|
@@ -49,7 +53,11 @@ from kaparoo.filesystem.directory import (
|
|
|
49
53
|
make_dir,
|
|
50
54
|
make_dirs,
|
|
51
55
|
)
|
|
52
|
-
from kaparoo.filesystem.exceptions import
|
|
56
|
+
from kaparoo.filesystem.exceptions import (
|
|
57
|
+
DirectoryNotFoundError,
|
|
58
|
+
NotAFileError,
|
|
59
|
+
UnsupportedExtensionError,
|
|
60
|
+
)
|
|
53
61
|
from kaparoo.filesystem.existence import (
|
|
54
62
|
dir_exists,
|
|
55
63
|
dirs_exist,
|
|
@@ -68,6 +76,9 @@ from kaparoo.filesystem.search import search_dirs, search_files, search_paths
|
|
|
68
76
|
from kaparoo.filesystem.staged import StagedDirectory, StagedFile
|
|
69
77
|
from kaparoo.filesystem.utils import (
|
|
70
78
|
ensure_file_extension,
|
|
79
|
+
file_extension,
|
|
80
|
+
normalize_extension,
|
|
81
|
+
normalize_extensions,
|
|
71
82
|
reserve_path,
|
|
72
83
|
reserve_paths,
|
|
73
84
|
stringify_path,
|
kaparoo/filesystem/directory.py
CHANGED
|
@@ -243,7 +243,7 @@ def make_dirs(
|
|
|
243
243
|
|
|
244
244
|
|
|
245
245
|
def dir_empty_unsafe(path: StrPath) -> bool:
|
|
246
|
-
"""
|
|
246
|
+
"""Test whether a directory is empty, skipping the existence check.
|
|
247
247
|
|
|
248
248
|
The caller must guarantee `path` is an existing directory; otherwise the
|
|
249
249
|
underlying `os.scandir` raises (`FileNotFoundError`, `NotADirectoryError`).
|
|
@@ -253,7 +253,7 @@ def dir_empty_unsafe(path: StrPath) -> bool:
|
|
|
253
253
|
|
|
254
254
|
|
|
255
255
|
def dirs_empty_unsafe(paths: StrPaths, *, root: StrPath | None = None) -> bool:
|
|
256
|
-
"""
|
|
256
|
+
"""Test whether directories are empty, skipping existence checks.
|
|
257
257
|
|
|
258
258
|
Each path carries the same caller obligation as `dir_empty_unsafe`.
|
|
259
259
|
"""
|
|
@@ -261,7 +261,7 @@ def dirs_empty_unsafe(paths: StrPaths, *, root: StrPath | None = None) -> bool:
|
|
|
261
261
|
|
|
262
262
|
|
|
263
263
|
def dir_empty(path: StrPath) -> bool:
|
|
264
|
-
"""
|
|
264
|
+
"""Test whether a directory is empty.
|
|
265
265
|
|
|
266
266
|
Args:
|
|
267
267
|
path: The directory path to check.
|
|
@@ -278,7 +278,7 @@ def dir_empty(path: StrPath) -> bool:
|
|
|
278
278
|
|
|
279
279
|
|
|
280
280
|
def dirs_empty(paths: StrPaths, *, root: StrPath | None = None) -> bool:
|
|
281
|
-
"""
|
|
281
|
+
"""Test whether directories are empty.
|
|
282
282
|
|
|
283
283
|
Args:
|
|
284
284
|
paths: A sequence of directory paths to check.
|
|
@@ -299,7 +299,7 @@ def dirs_empty(paths: StrPaths, *, root: StrPath | None = None) -> bool:
|
|
|
299
299
|
|
|
300
300
|
|
|
301
301
|
def dir_not_empty_unsafe(path: StrPath) -> bool:
|
|
302
|
-
"""
|
|
302
|
+
"""Test whether a directory is not empty, skipping the existence check.
|
|
303
303
|
|
|
304
304
|
Same caller obligation as `dir_empty_unsafe`.
|
|
305
305
|
"""
|
|
@@ -307,7 +307,7 @@ def dir_not_empty_unsafe(path: StrPath) -> bool:
|
|
|
307
307
|
|
|
308
308
|
|
|
309
309
|
def dirs_not_empty_unsafe(paths: StrPaths, *, root: StrPath | None = None) -> bool:
|
|
310
|
-
"""
|
|
310
|
+
"""Test whether directories are not empty, skipping existence checks.
|
|
311
311
|
|
|
312
312
|
Each path carries the same caller obligation as `dir_empty_unsafe`.
|
|
313
313
|
"""
|
|
@@ -315,7 +315,7 @@ def dirs_not_empty_unsafe(paths: StrPaths, *, root: StrPath | None = None) -> bo
|
|
|
315
315
|
|
|
316
316
|
|
|
317
317
|
def dir_not_empty(path: StrPath) -> bool:
|
|
318
|
-
"""
|
|
318
|
+
"""Test whether a directory is not empty.
|
|
319
319
|
|
|
320
320
|
Args:
|
|
321
321
|
path: The directory path to check.
|
|
@@ -332,7 +332,7 @@ def dir_not_empty(path: StrPath) -> bool:
|
|
|
332
332
|
|
|
333
333
|
|
|
334
334
|
def dirs_not_empty(paths: StrPaths, *, root: StrPath | None = None) -> bool:
|
|
335
|
-
"""
|
|
335
|
+
"""Test whether directories are not empty.
|
|
336
336
|
|
|
337
337
|
Args:
|
|
338
338
|
paths: A sequence of directory paths to check.
|
kaparoo/filesystem/exceptions.py
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
"""Filesystem exception types (`
|
|
1
|
+
"""Filesystem exception types (`NotAFileError`, `UnsupportedExtensionError`, ...)."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__all__ = (
|
|
5
|
+
__all__ = (
|
|
6
|
+
"DirectoryNotFoundError",
|
|
7
|
+
"NotAFileError",
|
|
8
|
+
"UnsupportedExtensionError",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterable
|
|
6
15
|
|
|
7
16
|
|
|
8
17
|
class DirectoryNotFoundError(FileNotFoundError):
|
|
@@ -16,3 +25,43 @@ class DirectoryNotFoundError(FileNotFoundError):
|
|
|
16
25
|
|
|
17
26
|
class NotAFileError(OSError):
|
|
18
27
|
"""Raised when a path exists but is not a file."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsupportedExtensionError(ValueError):
|
|
31
|
+
"""Raised when an extension is none of the supported ones.
|
|
32
|
+
|
|
33
|
+
A `ValueError` subclass, so existing `except ValueError` handlers still
|
|
34
|
+
catch it. `ext` and each entry of `supported` are normalized alike --
|
|
35
|
+
surrounding whitespace and leading dots are stripped, with case preserved
|
|
36
|
+
-- after which `supported` is de-duplicated and empty entries are dropped
|
|
37
|
+
(so `".jpg"`, `"jpg "`, and `"jpg"` collapse to a single `"jpg"`). The
|
|
38
|
+
optional `kind` (whitespace-stripped) names what the extension is for and,
|
|
39
|
+
when given, is woven into the message as `for <kind>`.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If `supported` names no usable extension -- empty, or
|
|
43
|
+
every entry normalizes to "".
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, ext: str, supported: Iterable[str], kind: str = "") -> None:
|
|
47
|
+
def normalize(ext: str) -> str:
|
|
48
|
+
return ext.strip().lstrip(".")
|
|
49
|
+
|
|
50
|
+
supported = (normalize(ext) for ext in supported)
|
|
51
|
+
supported = tuple(dict.fromkeys(ext for ext in supported if ext))
|
|
52
|
+
if not supported:
|
|
53
|
+
msg = "supported must list at least one extension"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
self.ext = normalize(ext)
|
|
57
|
+
self.supported = supported
|
|
58
|
+
self.kind = kind.strip()
|
|
59
|
+
|
|
60
|
+
msg = f"unsupported extension {self.ext!r}"
|
|
61
|
+
|
|
62
|
+
if self.kind:
|
|
63
|
+
msg += f" for {self.kind}"
|
|
64
|
+
|
|
65
|
+
msg += f" (supported: {', '.join(repr(ext) for ext in self.supported)})"
|
|
66
|
+
|
|
67
|
+
super().__init__(msg)
|
kaparoo/filesystem/existence.py
CHANGED
|
@@ -63,7 +63,7 @@ def ensure_path_exists(path: StrPath, *, stringify: bool) -> Path | str: ...
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def ensure_path_exists(path: StrPath, *, stringify: bool = False) -> Path | str:
|
|
66
|
-
"""
|
|
66
|
+
"""Ensure a given path exists and return it.
|
|
67
67
|
|
|
68
68
|
Args:
|
|
69
69
|
path: The path to check for existence.
|
|
@@ -94,7 +94,7 @@ def ensure_file_exists(path: StrPath, *, stringify: bool) -> Path | str: ...
|
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
def ensure_file_exists(path: StrPath, *, stringify: bool = False) -> Path | str:
|
|
97
|
-
"""
|
|
97
|
+
"""Ensure a given path exists and is a file, and return it.
|
|
98
98
|
|
|
99
99
|
Args:
|
|
100
100
|
path: The file path to check for existence.
|
|
@@ -146,7 +146,7 @@ def ensure_dir_exists(
|
|
|
146
146
|
def ensure_dir_exists(
|
|
147
147
|
path: StrPath, *, make: bool | int = False, stringify: bool = False
|
|
148
148
|
) -> Path | str:
|
|
149
|
-
"""
|
|
149
|
+
"""Ensure a given path exists and is a directory, and return it.
|
|
150
150
|
|
|
151
151
|
Args:
|
|
152
152
|
path: The directory path to check for existence.
|
|
@@ -269,7 +269,7 @@ def ensure_paths_exist(
|
|
|
269
269
|
def ensure_paths_exist(
|
|
270
270
|
paths: StrPaths, *, root: StrPath | None = None, stringify: bool = False
|
|
271
271
|
) -> list[Path] | list[str]:
|
|
272
|
-
"""
|
|
272
|
+
"""Ensure all of the given paths exist and return them.
|
|
273
273
|
|
|
274
274
|
Args:
|
|
275
275
|
paths: The paths to check for existence.
|
|
@@ -311,7 +311,7 @@ def ensure_files_exist(
|
|
|
311
311
|
def ensure_files_exist(
|
|
312
312
|
paths: StrPaths, *, root: StrPath | None = None, stringify: bool = False
|
|
313
313
|
) -> list[Path] | list[str]:
|
|
314
|
-
"""
|
|
314
|
+
"""Ensure all of the given paths exist and are files, and return them.
|
|
315
315
|
|
|
316
316
|
Args:
|
|
317
317
|
paths: The file paths to check for existence.
|
|
@@ -370,7 +370,7 @@ def ensure_dirs_exist(
|
|
|
370
370
|
make: bool | int = False,
|
|
371
371
|
stringify: bool = False,
|
|
372
372
|
) -> list[Path] | list[str]:
|
|
373
|
-
"""
|
|
373
|
+
"""Ensure all of the given paths exist and are directories, and return them.
|
|
374
374
|
|
|
375
375
|
Args:
|
|
376
376
|
paths: The directory paths to check for existence.
|
|
@@ -205,6 +205,16 @@ class Entry(Node, ABC):
|
|
|
205
205
|
"""The deepest level below the parent (`None` is unbounded)."""
|
|
206
206
|
return self._depth[1]
|
|
207
207
|
|
|
208
|
+
@property
|
|
209
|
+
def is_direct_child(self) -> bool:
|
|
210
|
+
"""Whether the entry is pinned to exactly depth 1 (a direct child).
|
|
211
|
+
|
|
212
|
+
True only when `min_depth` and `max_depth` are both 1 -- the default,
|
|
213
|
+
unranged position. Any wider or deeper depth (`depth=2`, `depth=None`,
|
|
214
|
+
`depth=(1, 3)`, ...) is False.
|
|
215
|
+
"""
|
|
216
|
+
return self.min_depth == 1 and self.max_depth == 1
|
|
217
|
+
|
|
208
218
|
@property
|
|
209
219
|
def required(self) -> bool:
|
|
210
220
|
"""Whether this entry must be present (vs optional)."""
|
|
@@ -256,7 +266,7 @@ class Entry(Node, ABC):
|
|
|
256
266
|
`Filter` / `Condition` `_payload`, which is the *whole* field set.
|
|
257
267
|
"""
|
|
258
268
|
payload: dict[str, Any] = {}
|
|
259
|
-
if self.
|
|
269
|
+
if not self.is_direct_child:
|
|
260
270
|
payload["depth"] = list(self._depth)
|
|
261
271
|
if self._required:
|
|
262
272
|
payload["required"] = True
|
|
@@ -7,79 +7,101 @@ __all__ = ("scaffold",)
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import TYPE_CHECKING, cast
|
|
9
9
|
|
|
10
|
+
from kaparoo.filesystem.exceptions import NotAFileError
|
|
10
11
|
from kaparoo.filesystem.hierarchy.entry import Directory, Entry, File
|
|
11
12
|
from kaparoo.filesystem.hierarchy.group import Exclusive, Group, Together
|
|
12
13
|
from kaparoo.filters import Expandable
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
|
|
15
18
|
from kaparoo.filesystem.hierarchy.base import Node
|
|
16
19
|
from kaparoo.filesystem.types import StrPath
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
def scaffold(
|
|
20
|
-
tree: Node,
|
|
23
|
+
tree: Node,
|
|
24
|
+
root: StrPath,
|
|
25
|
+
*,
|
|
26
|
+
on_create: Callable[[Path, File], None] | None = None,
|
|
27
|
+
dirs_only: bool = False,
|
|
28
|
+
root_as_top: bool = False,
|
|
29
|
+
dry_run: bool = False,
|
|
21
30
|
) -> list[Path]:
|
|
22
31
|
"""Create the structure `tree` describes under `root`, returning new paths.
|
|
23
32
|
|
|
24
|
-
The write counterpart of `locate` / `validate
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
Groups resolve to a single concrete shape: `Together` creates all its
|
|
36
|
-
members (or, if any is non-creatable, the whole group -- preserving
|
|
37
|
-
all-or-nothing -- unless `required`, which raises); `Exclusive` creates
|
|
38
|
-
the first alternative that is fully creatable (declaration order is the
|
|
39
|
-
priority), skipping non-creatable leading ones and raising only when none
|
|
40
|
-
is creatable and the group is `required`.
|
|
41
|
-
|
|
42
|
-
Creation is idempotent: an existing directory is descended without
|
|
43
|
-
change and an existing file is left untouched (never clobbered); only the
|
|
44
|
-
paths newly created are returned, in creation order. A path that exists
|
|
45
|
-
with the wrong kind (a file where a directory is described, or vice
|
|
46
|
-
versa) is a conflict and raises.
|
|
33
|
+
The write counterpart of `locate` / `validate`. Only *creatable* nodes are
|
|
34
|
+
materialized -- an enumerable `name` (`Literal` / `OneOf` / `Template` /
|
|
35
|
+
`Without` and the `str` / `list[str]` sugar) at a fixed `depth` of 1; open
|
|
36
|
+
names (`Glob`, `Regex`) and ranged depths are acceptance patterns, skipped
|
|
37
|
+
when optional and raising when `required`. Creation is idempotent and never
|
|
38
|
+
clobbers; files are created empty. Failure is best-effort: a mid-run raise
|
|
39
|
+
(a conflict, an unsatisfiable `required` node, or an `on_create` that
|
|
40
|
+
raises) leaves the paths already created in place -- there is no rollback,
|
|
41
|
+
but an idempotent re-run resumes safely. See the submodule README for the
|
|
42
|
+
group rules and worked examples.
|
|
47
43
|
|
|
48
44
|
Args:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
on_create: Callback `on_create(path, file_node)` run once per file
|
|
46
|
+
actually created -- the seam for writing its content. Not called
|
|
47
|
+
for an untouched existing file, under `dry_run`, or with `dirs_only`.
|
|
48
|
+
dirs_only: Create only the directory skeleton, skipping every file
|
|
49
|
+
(including `required` ones). Defaults to False.
|
|
50
|
+
root_as_top: Treat `root` itself as the top node -- an `Entry` created
|
|
51
|
+
only when its name matches `root`'s leaf name -- rather than the
|
|
52
|
+
container holding it. Defaults to False.
|
|
53
|
+
dry_run: Return the paths that *would* be created without touching disk;
|
|
54
|
+
the same checks still raise. Defaults to False.
|
|
59
55
|
|
|
60
56
|
Returns:
|
|
61
|
-
The newly created paths, in creation order
|
|
62
|
-
paths that would be created).
|
|
57
|
+
The newly created paths, in creation order.
|
|
63
58
|
|
|
64
59
|
Raises:
|
|
65
|
-
ValueError: If a `required` node is not creatable, or
|
|
66
|
-
|
|
60
|
+
ValueError: If a `required` node is not creatable, or `on_create` is
|
|
61
|
+
combined with `dirs_only`.
|
|
62
|
+
NotADirectoryError: If the spec describes a directory where a file
|
|
63
|
+
exists.
|
|
64
|
+
NotAFileError: If the spec describes a file where a directory exists.
|
|
67
65
|
TypeError: If `root_as_top` is set and `tree`'s top node is a `Group`.
|
|
68
66
|
"""
|
|
69
|
-
worker = Scaffolder(dry_run=dry_run)
|
|
67
|
+
worker = Scaffolder(dry_run=dry_run, dirs_only=dirs_only, on_create=on_create)
|
|
70
68
|
worker.visit(tree, Path(root), root_as_top=root_as_top)
|
|
71
69
|
return worker.created
|
|
72
70
|
|
|
73
71
|
|
|
74
72
|
class Scaffolder:
|
|
75
|
-
"""A single
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
"""A single, stateful walk that realizes a hierarchy spec on disk.
|
|
74
|
+
|
|
75
|
+
One instance drives one `scaffold` run: `visit` enters at the top -- `root`
|
|
76
|
+
as the container, or `root` itself with `root_as_top` -- and recurses,
|
|
77
|
+
dispatching each `Node` to a kind-specific handler. Only *creatable* nodes
|
|
78
|
+
are materialized: an `Entry` whose `name` is `Expandable` at a fixed depth
|
|
79
|
+
of 1, a `Together` whose members are all creatable, or an `Exclusive` with
|
|
80
|
+
at least one fully-creatable alternative (the first such, in order).
|
|
81
|
+
Non-creatable nodes are skipped, or raise when `required`. The walk assumes
|
|
82
|
+
`Node` is the closed `Entry` / `Group` world -- `_dispatch` and
|
|
83
|
+
`_node_creatable` reject anything else.
|
|
84
|
+
|
|
85
|
+
The run-wide options (`dry_run`, `dirs_only`, `on_create`) and the `created`
|
|
86
|
+
accumulator live on the instance so the recursion need not thread them
|
|
87
|
+
through every call. An instance is single-use: `created` holds the paths
|
|
88
|
+
made (or, under `dry_run`, that would be made) by its one walk.
|
|
79
89
|
"""
|
|
80
90
|
|
|
81
|
-
def __init__(
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
dry_run: bool,
|
|
95
|
+
dirs_only: bool = False,
|
|
96
|
+
on_create: Callable[[Path, File], None] | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
if dirs_only and on_create is not None:
|
|
99
|
+
msg = "on_create cannot be combined with dirs_only (no files are created)"
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
|
|
82
102
|
self.dry_run = dry_run
|
|
103
|
+
self.dirs_only = dirs_only
|
|
104
|
+
self.on_create = on_create
|
|
83
105
|
self.created: list[Path] = []
|
|
84
106
|
|
|
85
107
|
def visit(self, tree: Node, root: Path, *, root_as_top: bool = False) -> None:
|
|
@@ -107,31 +129,33 @@ class Scaffolder:
|
|
|
107
129
|
entry = cast("Entry", tree)
|
|
108
130
|
if not entry.name.matches(root.name):
|
|
109
131
|
if entry.required:
|
|
110
|
-
msg =
|
|
111
|
-
|
|
112
|
-
f"its name does not match {root.name!r}"
|
|
113
|
-
)
|
|
132
|
+
msg = f"cannot scaffold required {entry!r} at {root}:"
|
|
133
|
+
msg += f" its name does not match {root.name!r}"
|
|
114
134
|
raise ValueError(msg)
|
|
115
|
-
return
|
|
135
|
+
return
|
|
116
136
|
|
|
117
137
|
self._ensure_dir(root.parent) # the container that holds `root`
|
|
118
|
-
|
|
119
|
-
self.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
match entry:
|
|
139
|
+
case File() if not self.dirs_only:
|
|
140
|
+
self._make_file(root, entry)
|
|
141
|
+
case Directory():
|
|
142
|
+
self._make_dir(root)
|
|
143
|
+
for child in entry.children:
|
|
144
|
+
self._dispatch(child, root)
|
|
124
145
|
|
|
125
146
|
def _dispatch(self, node: Node, parent: Path) -> None:
|
|
126
147
|
"""Dispatch one node to its kind-specific handler."""
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
match node:
|
|
149
|
+
case File():
|
|
150
|
+
self._file(node, parent)
|
|
151
|
+
case Directory():
|
|
152
|
+
self._directory(node, parent)
|
|
153
|
+
case Together():
|
|
154
|
+
self._together(node, parent)
|
|
155
|
+
case Exclusive():
|
|
156
|
+
self._exclusive(node, parent)
|
|
157
|
+
case _:
|
|
158
|
+
raise self._unexpected(node)
|
|
135
159
|
|
|
136
160
|
def _ensure_dir(self, container: Path) -> None:
|
|
137
161
|
"""Create `container` (with parents) when missing, unless `dry_run`."""
|
|
@@ -141,61 +165,79 @@ class Scaffolder:
|
|
|
141
165
|
def _skip_or_raise(self, node: Node, *, required: bool) -> None:
|
|
142
166
|
"""Skip a non-creatable node, or raise when it is `required`."""
|
|
143
167
|
if required:
|
|
144
|
-
msg =
|
|
145
|
-
|
|
146
|
-
f"enumerable, or its depth is not fixed at 1."
|
|
147
|
-
)
|
|
168
|
+
msg = f"cannot scaffold required {node!r}: it is not creatable."
|
|
169
|
+
msg += " A creatable node has an enumerable name and a fixed depth of 1."
|
|
148
170
|
raise ValueError(msg)
|
|
149
171
|
|
|
172
|
+
def _unexpected(self, node: Node) -> TypeError:
|
|
173
|
+
"""A `TypeError` for a node outside the closed `Entry` / `Group` world."""
|
|
174
|
+
name = type(node).__name__
|
|
175
|
+
return TypeError(
|
|
176
|
+
f"unsupported node type {name!r}: "
|
|
177
|
+
"expected File, Directory, Together, or Exclusive"
|
|
178
|
+
)
|
|
179
|
+
|
|
150
180
|
def _creatable(self, entry: Entry) -> bool:
|
|
151
181
|
"""Whether `entry` has a concrete name and a fixed depth of 1."""
|
|
152
|
-
return (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
182
|
+
return isinstance(entry.name, Expandable) and entry.is_direct_child
|
|
183
|
+
|
|
184
|
+
def _all_creatable(self, nodes: Iterable[Node]) -> bool:
|
|
185
|
+
"""Whether every node in `nodes` can be fully materialized."""
|
|
186
|
+
return all(self._node_creatable(node) for node in nodes)
|
|
157
187
|
|
|
158
188
|
def _node_creatable(self, node: Node) -> bool:
|
|
159
189
|
"""Whether `node` (an entry or nested group) can be fully materialized."""
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def _make_file(self, path: Path) -> None:
|
|
171
|
-
"""Create `path` as an empty file (idempotent); record it when new.
|
|
190
|
+
match node:
|
|
191
|
+
case Entry():
|
|
192
|
+
return self._creatable(node)
|
|
193
|
+
case Together():
|
|
194
|
+
return self._all_creatable(node.members)
|
|
195
|
+
case Exclusive():
|
|
196
|
+
return any(self._all_creatable(side) for side in node.alternatives)
|
|
197
|
+
case _:
|
|
198
|
+
raise self._unexpected(node)
|
|
199
|
+
|
|
200
|
+
def _make_file(self, path: Path, node: File) -> None:
|
|
201
|
+
"""Create `path` as an empty file (idempotent); record it when new.
|
|
202
|
+
|
|
203
|
+
A file actually created (not an existing one left untouched, and not
|
|
204
|
+
under `dry_run`) is then passed to the `on_create` hook -- the seam for
|
|
205
|
+
filling its content.
|
|
206
|
+
"""
|
|
172
207
|
if path.exists():
|
|
173
208
|
if path.is_dir():
|
|
174
209
|
msg = f"cannot scaffold file {path}: a directory exists there."
|
|
175
|
-
raise
|
|
210
|
+
raise NotAFileError(msg)
|
|
176
211
|
return # idempotent: leave the existing file untouched
|
|
212
|
+
|
|
177
213
|
if not self.dry_run:
|
|
178
214
|
path.touch()
|
|
215
|
+
|
|
179
216
|
self.created.append(path)
|
|
217
|
+
if self.on_create is not None and not self.dry_run:
|
|
218
|
+
self.on_create(path, node)
|
|
180
219
|
|
|
181
220
|
def _make_dir(self, path: Path) -> None:
|
|
182
221
|
"""Create `path` as a directory (idempotent); record it when new."""
|
|
183
222
|
if path.exists():
|
|
184
223
|
if not path.is_dir():
|
|
185
224
|
msg = f"cannot scaffold directory {path}: a file exists there."
|
|
186
|
-
raise
|
|
225
|
+
raise NotADirectoryError(msg)
|
|
187
226
|
else:
|
|
188
227
|
if not self.dry_run:
|
|
189
228
|
path.mkdir()
|
|
190
229
|
self.created.append(path)
|
|
191
230
|
|
|
192
231
|
def _file(self, node: File, parent: Path) -> None:
|
|
232
|
+
if self.dirs_only:
|
|
233
|
+
return # dirs_only: skip every file, required or not
|
|
234
|
+
|
|
193
235
|
if not self._creatable(node):
|
|
194
236
|
self._skip_or_raise(node, required=node.required)
|
|
195
237
|
return
|
|
196
238
|
|
|
197
239
|
for name in cast("Expandable", node.name).expand():
|
|
198
|
-
self._make_file(parent / name)
|
|
240
|
+
self._make_file(parent / name, node)
|
|
199
241
|
|
|
200
242
|
def _directory(self, node: Directory, parent: Path) -> None:
|
|
201
243
|
if not self._creatable(node):
|
|
@@ -209,8 +251,8 @@ class Scaffolder:
|
|
|
209
251
|
self._dispatch(child, path)
|
|
210
252
|
|
|
211
253
|
def _together(self, node: Together, parent: Path) -> None:
|
|
212
|
-
|
|
213
|
-
|
|
254
|
+
# all-or-nothing: a non-creatable member skips the whole set
|
|
255
|
+
if not self._node_creatable(node):
|
|
214
256
|
self._skip_or_raise(node, required=node.required)
|
|
215
257
|
return
|
|
216
258
|
|
|
@@ -219,7 +261,7 @@ class Scaffolder:
|
|
|
219
261
|
|
|
220
262
|
def _exclusive(self, node: Exclusive, parent: Path) -> None:
|
|
221
263
|
for side in node.alternatives:
|
|
222
|
-
if
|
|
264
|
+
if self._all_creatable(side):
|
|
223
265
|
for member in side:
|
|
224
266
|
self._dispatch(member, parent)
|
|
225
267
|
return
|
|
@@ -15,9 +15,6 @@ if TYPE_CHECKING:
|
|
|
15
15
|
def _unique(pairs: Iterable[tuple[Path, Node]]) -> Iterator[tuple[Path, Node]]:
|
|
16
16
|
"""Stream `pairs`, suppressing ones already seen.
|
|
17
17
|
|
|
18
|
-
Args:
|
|
19
|
-
pairs: The `(path, node)` pairs to deduplicate.
|
|
20
|
-
|
|
21
18
|
Yields:
|
|
22
19
|
Each distinct pair in first-seen order (backed by a `seen` set).
|
|
23
20
|
"""
|
|
@@ -74,7 +71,7 @@ def _walk_depths(
|
|
|
74
71
|
|
|
75
72
|
|
|
76
73
|
def _entry_accepts(entry: Entry, candidate: Path, depth: int) -> bool:
|
|
77
|
-
"""
|
|
74
|
+
"""Test whether `entry` accepts `candidate` at `depth`.
|
|
78
75
|
|
|
79
76
|
Adds the positional `accepts_depth` gate to `entry.matches` (name + kind) --
|
|
80
77
|
the single source of the gates shared by `locate` and `validate`, run
|
|
@@ -159,6 +159,10 @@ def validate(
|
|
|
159
159
|
|
|
160
160
|
Raises:
|
|
161
161
|
TypeError: If `root_as_top` and `tree`'s top is a `Group`.
|
|
162
|
+
OSError: If a matched path cannot be read while checking its
|
|
163
|
+
`condition` (e.g. it was made inaccessible after `locate` matched
|
|
164
|
+
it). Condition checks read the filesystem, so an I/O error surfaces
|
|
165
|
+
rather than being absorbed into the report.
|
|
162
166
|
"""
|
|
163
167
|
root = Path(root)
|
|
164
168
|
excluder = build_excluder(exclude, root)
|
|
@@ -354,7 +358,7 @@ def _scan_under(
|
|
|
354
358
|
def _ignores_extra(
|
|
355
359
|
name: str, *, allow_extra: bool | Filter, extra_level: bool | Filter
|
|
356
360
|
) -> bool:
|
|
357
|
-
"""
|
|
361
|
+
"""Test whether `allow_extra` or the blanket `extra_level` ignores `name`.
|
|
358
362
|
|
|
359
363
|
For each setting, `True` / `False` ignore all / none and a `Filter` ignores
|
|
360
364
|
only matching names; the two are combined with OR.
|
|
@@ -386,7 +390,8 @@ def _scan_entries(
|
|
|
386
390
|
setting) ignores a candidate matching no entry, it is kept out of `seen`,
|
|
387
391
|
so it (and its subtree) is ignored rather than reported `unexpected`; each
|
|
388
392
|
matched `Directory` is descended with its own `allow_extra` but the same
|
|
389
|
-
`extra_level`, so a strict subdirectory stays strict unless `extra_level`
|
|
393
|
+
`extra_level`, so a strict subdirectory stays strict unless `extra_level`
|
|
394
|
+
overrides it.
|
|
390
395
|
|
|
391
396
|
Args:
|
|
392
397
|
entries: The leaf entries expected at or below `parent` (flattened).
|
|
@@ -593,7 +598,8 @@ def conformer(
|
|
|
593
598
|
extra, unspecified entries.
|
|
594
599
|
|
|
595
600
|
Returns:
|
|
596
|
-
A predicate that is `True` for a path realizing `spec`'s top.
|
|
601
|
+
A predicate that is `True` for a path realizing `spec`'s top. Like
|
|
602
|
+
`validate`, it surfaces a condition's filesystem errors as `OSError`.
|
|
597
603
|
"""
|
|
598
604
|
tops = flatten_entries(spec)
|
|
599
605
|
excluder = None # conformer judges each candidate strictly; no exclusions
|
kaparoo/filesystem/utils.py
CHANGED
|
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
__all__ = (
|
|
6
6
|
"ensure_file_extension",
|
|
7
|
+
"file_extension",
|
|
8
|
+
"normalize_extension",
|
|
9
|
+
"normalize_extensions",
|
|
7
10
|
"reserve_path",
|
|
8
11
|
"reserve_paths",
|
|
9
12
|
"stringify_path",
|
|
@@ -16,6 +19,8 @@ import os
|
|
|
16
19
|
from pathlib import Path
|
|
17
20
|
from typing import TYPE_CHECKING, overload
|
|
18
21
|
|
|
22
|
+
from kaparoo.filesystem.exceptions import UnsupportedExtensionError
|
|
23
|
+
|
|
19
24
|
if TYPE_CHECKING:
|
|
20
25
|
from collections.abc import Iterable
|
|
21
26
|
from typing import Literal
|
|
@@ -57,10 +62,6 @@ def stringify_path(
|
|
|
57
62
|
) -> str:
|
|
58
63
|
"""Convert a path to a string and optionally trim shared head/tail parts.
|
|
59
64
|
|
|
60
|
-
The result is normalized to POSIX form (`/` separators) on every platform;
|
|
61
|
-
a path left with no components (e.g. trimmed against itself) stringifies to
|
|
62
|
-
`"."`.
|
|
63
|
-
|
|
64
65
|
Args:
|
|
65
66
|
path: The path to be converted to a string.
|
|
66
67
|
after: The leading base path to make `path` relative to. If provided,
|
|
@@ -68,6 +69,10 @@ def stringify_path(
|
|
|
68
69
|
before: The trailing path to trim from `path`. If provided, returns
|
|
69
70
|
only the part of `path` before `before`. Defaults to None.
|
|
70
71
|
|
|
72
|
+
Returns:
|
|
73
|
+
A POSIX-form string (`/` separators on every platform); `"."` when
|
|
74
|
+
nothing remains after trimming.
|
|
75
|
+
|
|
71
76
|
Raises:
|
|
72
77
|
ValueError: If `path` does not start with `after`,
|
|
73
78
|
or does not end with `before`.
|
|
@@ -81,9 +86,6 @@ def stringify_paths(
|
|
|
81
86
|
) -> list[str]:
|
|
82
87
|
"""Convert a sequence of paths to strings and optionally trim shared parts.
|
|
83
88
|
|
|
84
|
-
Each result is normalized to POSIX form (`/` separators) on every platform;
|
|
85
|
-
see `stringify_path` for the per-path behavior.
|
|
86
|
-
|
|
87
89
|
Args:
|
|
88
90
|
paths: The sequence of paths to be converted to strings.
|
|
89
91
|
after: The leading base path to make each path relative to. If provided,
|
|
@@ -91,6 +93,10 @@ def stringify_paths(
|
|
|
91
93
|
before: The trailing path to trim from each path. If provided, returns
|
|
92
94
|
only the part of each path before `before`. Defaults to None.
|
|
93
95
|
|
|
96
|
+
Returns:
|
|
97
|
+
A sequence of POSIX-form strings (`/` separators on every platform);
|
|
98
|
+
`"."` when nothing remains after trimming.
|
|
99
|
+
|
|
94
100
|
Raises:
|
|
95
101
|
ValueError: If any of `paths` does not start with `after`,
|
|
96
102
|
or does not end with `before`.
|
|
@@ -379,52 +385,79 @@ def reserve_paths(
|
|
|
379
385
|
# ========================== #
|
|
380
386
|
|
|
381
387
|
|
|
388
|
+
def normalize_extension(ext: str, *, lowercase: bool = False) -> str:
|
|
389
|
+
"""Strip surrounding whitespace and leading dots from an extension; lower-case when asked."""
|
|
390
|
+
ext = ext.strip().lstrip(".")
|
|
391
|
+
return ext.lower() if lowercase else ext
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def normalize_extensions(exts: Iterable[str], *, lowercase: bool = False) -> list[str]:
|
|
395
|
+
"""Strip surrounding whitespace and leading dots from each extension; lower-case when asked."""
|
|
396
|
+
return [normalize_extension(ext, lowercase=lowercase) for ext in exts]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def file_extension(path: StrPath, *, level: int = 1, lowercase: bool = True) -> str:
|
|
400
|
+
"""Return the last (up to) `level` suffix(es) of `path`, dot-joined and normalized.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
path: The path to read the extension from.
|
|
404
|
+
level: How many trailing suffixes to join. `level=2` turns `data.tar.gz`
|
|
405
|
+
into `"tar.gz"`. Defaults to 1.
|
|
406
|
+
lowercase: Whether to lower-case the result. Defaults to True.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
The dot-joined suffixes without a leading dot, or `""` when `path` has
|
|
410
|
+
no suffix.
|
|
411
|
+
|
|
412
|
+
Raises:
|
|
413
|
+
ValueError: If `level` is less than 1.
|
|
414
|
+
"""
|
|
415
|
+
if level < 1:
|
|
416
|
+
msg = f"level must be >= 1, got {level}"
|
|
417
|
+
raise ValueError(msg)
|
|
418
|
+
suffix = "".join(Path(path).suffixes[-level:])
|
|
419
|
+
return normalize_extension(suffix, lowercase=lowercase)
|
|
420
|
+
|
|
421
|
+
|
|
382
422
|
def ensure_file_extension(
|
|
383
423
|
path: StrPath, ext: str | Iterable[str], *, add: bool = False
|
|
384
424
|
) -> Path:
|
|
385
425
|
"""Return `path` as a `Path`, requiring a case-insensitive `.<ext>` suffix.
|
|
386
426
|
|
|
387
|
-
A pure path check that never touches the filesystem.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
Only the final suffix is considered: `archive.tar.gz` matches `ext="gz"`,
|
|
391
|
-
not `ext="tar.gz"`.
|
|
392
|
-
|
|
393
|
-
`add` mirrors `make` on `ensure_dir_exists`: when False (the default) a
|
|
394
|
-
path with no suffix raises like any other mismatch; when True, the missing
|
|
395
|
-
suffix is appended -- the *first* of `ext` when several are given, so pass
|
|
396
|
-
an ordered list/tuple if that matters. A *wrong* suffix always raises,
|
|
397
|
-
regardless of `add`.
|
|
427
|
+
A pure path check that never touches the filesystem. Only the final suffix
|
|
428
|
+
is considered, so `archive.tar.gz` matches `ext="gz"`, not `ext="tar.gz"`.
|
|
429
|
+
A wrong suffix always raises; `add` only fills in a missing one.
|
|
398
430
|
|
|
399
431
|
Args:
|
|
400
432
|
path: The path to check.
|
|
401
433
|
ext: The required extension, or an iterable of acceptable ones, each
|
|
402
|
-
with or without a leading dot.
|
|
403
|
-
add: Whether to append the
|
|
404
|
-
|
|
434
|
+
with or without a leading dot. Compared case-insensitively.
|
|
435
|
+
add: Whether to append the first of `ext` when `path` has no suffix,
|
|
436
|
+
instead of raising. Defaults to False.
|
|
405
437
|
|
|
406
438
|
Returns:
|
|
407
439
|
The path as a Path object, guaranteed to end in an accepted `.<ext>`.
|
|
408
440
|
|
|
409
441
|
Raises:
|
|
410
|
-
ValueError: If `ext` is empty
|
|
411
|
-
|
|
412
|
-
|
|
442
|
+
ValueError: If `ext` is empty.
|
|
443
|
+
UnsupportedExtensionError: If `path`'s final suffix is none of the
|
|
444
|
+
accepted extensions, except the no-suffix case resolved by
|
|
445
|
+
`add=True`. A `ValueError` subclass.
|
|
413
446
|
"""
|
|
414
447
|
exts = [ext] if isinstance(ext, str) else list(ext)
|
|
415
|
-
exts =
|
|
448
|
+
exts = list(dict.fromkeys(normalize_extensions(exts, lowercase=True)))
|
|
416
449
|
|
|
417
450
|
if not exts:
|
|
418
451
|
msg = "ext must name at least one extension"
|
|
419
452
|
raise ValueError(msg)
|
|
420
453
|
|
|
421
454
|
path = Path(path)
|
|
422
|
-
|
|
455
|
+
path_ext = file_extension(path, lowercase=True)
|
|
456
|
+
|
|
457
|
+
if add and not path_ext:
|
|
423
458
|
return path.with_suffix(f".{exts[0]}")
|
|
424
459
|
|
|
425
|
-
if
|
|
426
|
-
|
|
427
|
-
msg = f"{path.name} must have a {wanted} extension (got {path.suffix!r})"
|
|
428
|
-
raise ValueError(msg)
|
|
460
|
+
if path_ext not in exts:
|
|
461
|
+
raise UnsupportedExtensionError(path_ext, exts)
|
|
429
462
|
|
|
430
463
|
return path
|
kaparoo/filters/enumerable.py
CHANGED
|
@@ -147,12 +147,12 @@ class OneOfFilter(Expandable):
|
|
|
147
147
|
|
|
148
148
|
|
|
149
149
|
def _axis_repr(axis: tuple[object, ...]) -> str:
|
|
150
|
-
"""Render a materialized axis, compacting an integer
|
|
151
|
-
progression (three or more terms) to the equivalent `range(...)`.
|
|
150
|
+
"""Render a materialized axis, compacting an integer progression to `range(...)`.
|
|
152
151
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
as its plain
|
|
152
|
+
An arithmetic progression of three or more integers becomes the
|
|
153
|
+
equivalent `range(...)` (a valid axis input, so it re-creates the same
|
|
154
|
+
axis); anything too short, irregular, or non-integer shows as its plain
|
|
155
|
+
tuple.
|
|
156
156
|
"""
|
|
157
157
|
if len(axis) >= 3 and all(type(v) is int for v in axis):
|
|
158
158
|
values = cast("tuple[int, ...]", axis)
|
kaparoo/filters/logical.py
CHANGED
|
@@ -64,7 +64,7 @@ class NaryLogicalFilter(LogicalFilter, ABC):
|
|
|
64
64
|
@register_filter("and")
|
|
65
65
|
@dataclass(frozen=True, repr=False)
|
|
66
66
|
class AndFilter(NaryLogicalFilter):
|
|
67
|
-
"""A filter matching strings that satisfy ALL of `children
|
|
67
|
+
"""A filter matching strings that satisfy ALL of `children`."""
|
|
68
68
|
|
|
69
69
|
@override
|
|
70
70
|
def matches(self, target: str) -> bool:
|
|
@@ -74,7 +74,7 @@ class AndFilter(NaryLogicalFilter):
|
|
|
74
74
|
@register_filter("or")
|
|
75
75
|
@dataclass(frozen=True, repr=False)
|
|
76
76
|
class OrFilter(NaryLogicalFilter):
|
|
77
|
-
"""A filter matching strings that satisfy AT LEAST ONE of `children
|
|
77
|
+
"""A filter matching strings that satisfy AT LEAST ONE of `children`."""
|
|
78
78
|
|
|
79
79
|
@override
|
|
80
80
|
def matches(self, target: str) -> bool:
|
|
@@ -84,7 +84,7 @@ class OrFilter(NaryLogicalFilter):
|
|
|
84
84
|
@register_filter("not")
|
|
85
85
|
@dataclass(frozen=True, repr=False)
|
|
86
86
|
class NotFilter(LogicalFilter):
|
|
87
|
-
"""A filter matching strings that do NOT satisfy `child
|
|
87
|
+
"""A filter matching strings that do NOT satisfy `child`."""
|
|
88
88
|
|
|
89
89
|
child: Filter
|
|
90
90
|
|
kaparoo/utils/timer.py
CHANGED
|
@@ -30,7 +30,7 @@ class SpanRecord(TypedDict):
|
|
|
30
30
|
lap) or by `measure` (the span of a wrapped block).
|
|
31
31
|
|
|
32
32
|
Attributes:
|
|
33
|
-
label: The span's name. May carry a " (N)" suffix when produced
|
|
33
|
+
label: The span's name. May carry a `" (N)"` suffix when produced
|
|
34
34
|
under `on_same_label="separate"`.
|
|
35
35
|
duration: Length of this span, in the timer's `unit` and rounded
|
|
36
36
|
by `ndigits` if given. For `lap`, the time since the previous
|
|
@@ -78,8 +78,8 @@ class Timer(ContextDecorator):
|
|
|
78
78
|
"""Initialize the timer with a reporting unit and optional precision.
|
|
79
79
|
|
|
80
80
|
Args:
|
|
81
|
-
unit: The time unit for reported values. One of "s"
|
|
82
|
-
"ns"
|
|
81
|
+
unit: The time unit for reported values. One of `"s"`, `"ms"`,
|
|
82
|
+
`"us"`, `"ns"`. Defaults to `"s"`.
|
|
83
83
|
ndigits: The number of decimal places to round reported values to.
|
|
84
84
|
If None, no rounding is applied. Defaults to None.
|
|
85
85
|
|
|
@@ -236,7 +236,7 @@ class Timer(ContextDecorator):
|
|
|
236
236
|
self._started = False
|
|
237
237
|
|
|
238
238
|
def _finalize(self) -> None:
|
|
239
|
-
"""
|
|
239
|
+
"""Capture the block's final elapsed duration on `with`-block exit."""
|
|
240
240
|
elapsed_ns = time.perf_counter_ns() - self._start_time
|
|
241
241
|
self._elapsed = self._format_time(elapsed_ns)
|
|
242
242
|
|
|
@@ -290,15 +290,15 @@ class SpanTimer(Timer):
|
|
|
290
290
|
"""Initialize the span timer.
|
|
291
291
|
|
|
292
292
|
Args:
|
|
293
|
-
unit: The time unit for reported values. One of "s"
|
|
294
|
-
"ns"
|
|
293
|
+
unit: The time unit for reported values. One of `"s"`, `"ms"`,
|
|
294
|
+
`"us"`, `"ns"`. Defaults to `"s"`.
|
|
295
295
|
ndigits: The number of decimal places to round reported values
|
|
296
296
|
to. If None, no rounding is applied. Defaults to None.
|
|
297
297
|
on_same_label: Behavior when a label passed to `lap` has been
|
|
298
|
-
used before in the same `with` block. "merge" records the
|
|
298
|
+
used before in the same `with` block. `"merge"` records the
|
|
299
299
|
label verbatim so duplicates aggregate in `summary`,
|
|
300
|
-
"separate" appends a " (N)" suffix so repeats stay distinct,
|
|
301
|
-
"reject" raises `ValueError`. Defaults to "merge"
|
|
300
|
+
`"separate"` appends a `" (N)"` suffix so repeats stay distinct,
|
|
301
|
+
`"reject"` raises `ValueError`. Defaults to `"merge"`.
|
|
302
302
|
|
|
303
303
|
Raises:
|
|
304
304
|
ValueError: If `unit` or `on_same_label` is not one of the
|
|
@@ -335,10 +335,6 @@ class SpanTimer(Timer):
|
|
|
335
335
|
not included. Each record's `duration` is already rounded by
|
|
336
336
|
`ndigits` (when set); this property sums those rounded values and
|
|
337
337
|
rounds the sum once more.
|
|
338
|
-
|
|
339
|
-
Returns:
|
|
340
|
-
A mapping from label to total `duration` for that label, in the
|
|
341
|
-
timer's `unit`.
|
|
342
338
|
"""
|
|
343
339
|
grouped: dict[str, float] = defaultdict(float)
|
|
344
340
|
for record in self._records:
|
|
@@ -412,7 +408,7 @@ class SpanTimer(Timer):
|
|
|
412
408
|
|
|
413
409
|
Raises:
|
|
414
410
|
RuntimeError: If the timer has not been started, or is paused.
|
|
415
|
-
ValueError: If `on_same_label` is "reject" and `label` has
|
|
411
|
+
ValueError: If `on_same_label` is `"reject"` and `label` has
|
|
416
412
|
already been used in this `with` block.
|
|
417
413
|
"""
|
|
418
414
|
self._ensure_started()
|
|
@@ -428,9 +424,12 @@ class SpanTimer(Timer):
|
|
|
428
424
|
|
|
429
425
|
Unlike `lap`, which splits the timeline into contiguous spans,
|
|
430
426
|
`measure` times only the wrapped region; time spent outside any
|
|
431
|
-
`measure` block is attributed to no span.
|
|
432
|
-
block are excluded
|
|
433
|
-
the block raises
|
|
427
|
+
`measure` block is attributed to no span. Paused intervals inside the
|
|
428
|
+
block are excluded, but pause/resume must balance within it: a bare
|
|
429
|
+
`pause()` left open at the block's end raises -- wrap the excluded
|
|
430
|
+
region in `suspend()` instead. A span is recorded only on clean exit
|
|
431
|
+
-- if the block raises, nothing is recorded and the exception
|
|
432
|
+
propagates.
|
|
434
433
|
Repeated labels follow `on_same_label`, exactly as `lap`. Do not
|
|
435
434
|
nest `measure` blocks: each resets the shared baseline, so an outer
|
|
436
435
|
block would record only the span after its inner block ends.
|
|
@@ -446,9 +445,9 @@ class SpanTimer(Timer):
|
|
|
446
445
|
None. The wrapped block runs while the span is timed.
|
|
447
446
|
|
|
448
447
|
Raises:
|
|
449
|
-
RuntimeError: If the timer has not been started,
|
|
450
|
-
entry.
|
|
451
|
-
ValueError: If `on_same_label` is "reject" and `label` has
|
|
448
|
+
RuntimeError: If the timer has not been started, is paused on
|
|
449
|
+
entry, or is still paused at the block's end.
|
|
450
|
+
ValueError: If `on_same_label` is `"reject"` and `label` has
|
|
452
451
|
already been used in this `with` block.
|
|
453
452
|
"""
|
|
454
453
|
self._ensure_started()
|
|
@@ -458,4 +457,10 @@ class SpanTimer(Timer):
|
|
|
458
457
|
|
|
459
458
|
self._last_time = time.perf_counter_ns()
|
|
460
459
|
yield
|
|
460
|
+
if self._paused:
|
|
461
|
+
msg = (
|
|
462
|
+
"measure() block ended while paused; balance pause/resume "
|
|
463
|
+
"inside it, or wrap the excluded part in `suspend()`."
|
|
464
|
+
)
|
|
465
|
+
raise RuntimeError(msg)
|
|
461
466
|
self.lap(label)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kaparoo-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Personally common and useful Python features
|
|
5
5
|
Keywords: filesystem,pathlib,paths,glob,filters,pattern-matching,dataset,sequence,batching,timer,aggregation,metrics,utilities,typed
|
|
6
6
|
Author: Jaewoo Park
|
|
@@ -61,7 +61,8 @@ Each submodule ships its own README with focused examples.
|
|
|
61
61
|
`ensure_*` validators, `make_dir(s)` (with a destructive `clean` reset
|
|
62
62
|
option), `dir_empty(s)`, `reserve_path(s)` guards for not-yet-existing
|
|
63
63
|
destinations, `StagedFile` / `StagedDirectory` for safe (atomic) writes,
|
|
64
|
-
path stringification,
|
|
64
|
+
path stringification, extension helpers (`ensure_file_extension`,
|
|
65
|
+
`file_extension`, `normalize_extension(s)`), and a small exception hierarchy.
|
|
65
66
|
|
|
66
67
|
### [`kaparoo.filesystem.search`](https://github.com/kaparoo/kaparoo-python/tree/main/kaparoo/filesystem/search)
|
|
67
68
|
|
|
@@ -2,24 +2,24 @@ kaparoo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
2
2
|
kaparoo/data/__init__.py,sha256=dgbmVOVq_As2ovxRw_JQRdVjQ8d1UTkMFt_A50YfCfM,508
|
|
3
3
|
kaparoo/data/sequences/__init__.py,sha256=3WPq8yDzGvGFXRxu0Dtbp0WPLga32qMDDkL_CZlmFpE,674
|
|
4
4
|
kaparoo/data/sequences/base.py,sha256=dOCrhL-m3Mp2ZJmBYLKdlhgp4n05-zu0B_jcTlt6BPM,3313
|
|
5
|
-
kaparoo/data/sequences/composers.py,sha256=
|
|
6
|
-
kaparoo/data/sequences/templates.py,sha256=
|
|
5
|
+
kaparoo/data/sequences/composers.py,sha256=ZiOphwjuJc-VcVlDcp4bWBi18lLEA2Dg1I_tvei8UO8,16420
|
|
6
|
+
kaparoo/data/sequences/templates.py,sha256=YJnEBHU62ChwU-lc1U06iAZBCK1fNDSW5KBJmLqWeHU,7226
|
|
7
7
|
kaparoo/data/sequences/utils.py,sha256=D5sa7431EZkTJ9k3m2-YKopNxl5Tt0Kyv_XjcrXIryQ,2344
|
|
8
|
-
kaparoo/filesystem/__init__.py,sha256=
|
|
9
|
-
kaparoo/filesystem/directory.py,sha256=
|
|
10
|
-
kaparoo/filesystem/exceptions.py,sha256=
|
|
8
|
+
kaparoo/filesystem/__init__.py,sha256=jBfSbMMMSpAlhkYrm6weR-jxlBZnAjwG1g--THa0PFU,1954
|
|
9
|
+
kaparoo/filesystem/directory.py,sha256=tV3pv_b7xIxx9TqMLB_z_r60cw8VoH5S0gI8CvaM6UI,11466
|
|
10
|
+
kaparoo/filesystem/exceptions.py,sha256=VfDrjzj28ERorJtVfyJPDSXKTiYuCl6aNz8gWGFfUsA,2171
|
|
11
11
|
kaparoo/filesystem/exclude.py,sha256=3z0EkM0IQHJyBuVn8cRMKDvXBTNy4yNr6D9hmXjYZjo,3947
|
|
12
|
-
kaparoo/filesystem/existence.py,sha256=
|
|
12
|
+
kaparoo/filesystem/existence.py,sha256=fRqo9UnMIAQhnpF4e7jtcNovuszLVdRFFw0rVSg86tQ,12810
|
|
13
13
|
kaparoo/filesystem/hierarchy/__init__.py,sha256=Z4FVrYdrjNJnNjRUBrFrAwTIV8UruDMWaZ6j7C_rY2U,757
|
|
14
14
|
kaparoo/filesystem/hierarchy/base.py,sha256=i1xVcdOrRoQ_lYdOILI1LznJIaJ8HpQm9SuBb6BrgZo,3624
|
|
15
15
|
kaparoo/filesystem/hierarchy/conditions.py,sha256=IaIudfpeAbs9ZRM48xmmGETv7P50kXjyd1NLzEpW4IM,15652
|
|
16
|
-
kaparoo/filesystem/hierarchy/entry.py,sha256=
|
|
16
|
+
kaparoo/filesystem/hierarchy/entry.py,sha256=NSrdPXhMLyepts0LBl9f_Kjpx8heOrQ_oLpolKxl788,14914
|
|
17
17
|
kaparoo/filesystem/hierarchy/group.py,sha256=L4mx86VK_QdRi9eeEplEKCDlf1-VPe8Z1uxL_xrK5OQ,10421
|
|
18
|
-
kaparoo/filesystem/hierarchy/scaffold.py,sha256=
|
|
18
|
+
kaparoo/filesystem/hierarchy/scaffold.py,sha256=WNJEvt_oTlp0gNFIkwkYSw915mz-FsGDBQmkon2jdYo,11141
|
|
19
19
|
kaparoo/filesystem/hierarchy/traverse/__init__.py,sha256=_jLWdaC7H1vz3iZGz7BoFpEuXy9WuTEKHBqdzTTQqAg,443
|
|
20
|
-
kaparoo/filesystem/hierarchy/traverse/_utils.py,sha256=
|
|
20
|
+
kaparoo/filesystem/hierarchy/traverse/_utils.py,sha256=7bJhvm2e3mIKaxcP0M4biCWKKVfrbRoSDaIrQUAjWcs,2903
|
|
21
21
|
kaparoo/filesystem/hierarchy/traverse/locate.py,sha256=e-raO5m7bXLp8FUmaXb9lqSlO5Ay-Tv1tPh_b_z4bnM,6493
|
|
22
|
-
kaparoo/filesystem/hierarchy/traverse/validate.py,sha256=
|
|
22
|
+
kaparoo/filesystem/hierarchy/traverse/validate.py,sha256=LRTrP5UpI2Z8iXRH0qOvELq7eIPusw5BkgZysKm8MJM,22081
|
|
23
23
|
kaparoo/filesystem/hierarchy/utils.py,sha256=4WEyu3-1gBb6YNQI1ODpGGdJ5X1btPs8Z3YFrnR-40g,1289
|
|
24
24
|
kaparoo/filesystem/search/__init__.py,sha256=ypByMvVrUG96S37Efo-TuYko3UN9VJGejwQq6PoSsHQ,182
|
|
25
25
|
kaparoo/filesystem/search/classes.py,sha256=z76J2mQExAr5ah47Qau4bemVPnUNmKxRXTPzWT9hJv0,7384
|
|
@@ -27,11 +27,11 @@ kaparoo/filesystem/search/wrappers.py,sha256=OE8C_TxauBuEprhKnMBKZSDG4flFHVilZxI
|
|
|
27
27
|
kaparoo/filesystem/staged.py,sha256=fLQsh-YtCy6-KNUROMhhLEXnoVfuKDSEPxu1pJVLI9M,19743
|
|
28
28
|
kaparoo/filesystem/types.py,sha256=BWTstlVTrdF4503E2wiXco0SN_pDJ_KTXhC1gXcNmvI,265
|
|
29
29
|
kaparoo/filesystem/units.py,sha256=Bpk2aA-FGFR1d3He9Vl4J5R8MhcYg71Qd6E4th3qd8E,615
|
|
30
|
-
kaparoo/filesystem/utils.py,sha256=
|
|
30
|
+
kaparoo/filesystem/utils.py,sha256=rFN0lmD5goxinPCVmp6b6lIXq-7p1R4NAuiS-ZyHQd8,14140
|
|
31
31
|
kaparoo/filters/__init__.py,sha256=XaRcOqt6UmmRFN6d_HHjLuDOvrEXjg3z_awfKYDQj10,1671
|
|
32
32
|
kaparoo/filters/base.py,sha256=Nn8ufDXgPrN5aptqR6GzRgFlt3Ss4gHgLvc3LxhNHxE,4073
|
|
33
|
-
kaparoo/filters/enumerable.py,sha256=
|
|
34
|
-
kaparoo/filters/logical.py,sha256=
|
|
33
|
+
kaparoo/filters/enumerable.py,sha256=y2RRK_r3CmjGD7GDUlXo-gFmrmHHOQJPCjWrkdNgW78,11868
|
|
34
|
+
kaparoo/filters/logical.py,sha256=XVTshFYkLPqz6B3E8gvyuRB8Di6bvU18XoGaRp4ncfU,3330
|
|
35
35
|
kaparoo/filters/multi_pattern.py,sha256=k75J8k-ZK9nYRFPTqEQQr8lO9f19T8DA9n9gFFivQHA,4743
|
|
36
36
|
kaparoo/filters/pattern.py,sha256=ty3k1tB2hmLNABfzQj79vZiL5fLc-PuE6167Dq5NXfY,6289
|
|
37
37
|
kaparoo/filters/types.py,sha256=aNPYPimM9pr1Z9dUGuPHyk_457Q32vFSvjLWrzQrHU0,2185
|
|
@@ -41,8 +41,8 @@ kaparoo/utils/__init__.py,sha256=GHOu3smf_16ugrnYzICCGC9H7PwyOuPRYHNnK4lCXBg,100
|
|
|
41
41
|
kaparoo/utils/aggregate.py,sha256=OG359CMn3mQLV_Wh_ODiuJXsg0Fi1RuCGQMrcMFvEiA,24290
|
|
42
42
|
kaparoo/utils/checks.py,sha256=4Th2zfC-AkESmyLjUYBLx-iCkBbNNCm9gO4LEjIP2Wo,3756
|
|
43
43
|
kaparoo/utils/optional.py,sha256=zXy_AkfrhsO8ziRSjO_i4kH9XRziGxzj-LVKA90mFPI,3535
|
|
44
|
-
kaparoo/utils/timer.py,sha256=
|
|
45
|
-
kaparoo_python-0.
|
|
46
|
-
kaparoo_python-0.
|
|
47
|
-
kaparoo_python-0.
|
|
48
|
-
kaparoo_python-0.
|
|
44
|
+
kaparoo/utils/timer.py,sha256=UOTJYNdBXE8ovaBjDeyU1hQxynD9kkno-Vq51wNZtdc,17191
|
|
45
|
+
kaparoo_python-0.9.0.dist-info/licenses/LICENSE,sha256=hb6LWYP2rtcoz4V2HpawmblDfHwjwsg9N3cz0c5JQJE,1067
|
|
46
|
+
kaparoo_python-0.9.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
|
|
47
|
+
kaparoo_python-0.9.0.dist-info/METADATA,sha256=aYgplAxb9DJU2A_JIu4wSRlhPIJV-bqtTzUgKJhAE2Y,6390
|
|
48
|
+
kaparoo_python-0.9.0.dist-info/RECORD,,
|
|
File without changes
|