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.
@@ -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 (valid only when `M_out == M_in`)."""
154
- # Passthrough -- correct only when M_out == M_in. A subclass with a
155
- # different M_out MUST override this; the cast cannot catch a missing
156
- # override, since generics are erased at runtime.
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 (built once)."""
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
@@ -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 DirectoryNotFoundError, NotAFileError
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,
@@ -243,7 +243,7 @@ def make_dirs(
243
243
 
244
244
 
245
245
  def dir_empty_unsafe(path: StrPath) -> bool:
246
- """Check if a directory is empty, skipping the existence check.
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
- """Check if directories are empty, skipping existence checks.
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
- """Check if a directory is empty.
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
- """Check if directories are empty.
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
- """Check if a directory is not empty, skipping the existence check.
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
- """Check if directories are not empty, skipping existence checks.
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
- """Check if a directory is not empty.
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
- """Check if directories are not empty.
335
+ """Test whether directories are not empty.
336
336
 
337
337
  Args:
338
338
  paths: A sequence of directory paths to check.
@@ -1,8 +1,17 @@
1
- """Filesystem exception types (`DirectoryNotFoundError`, `NotAFileError`)."""
1
+ """Filesystem exception types (`NotAFileError`, `UnsupportedExtensionError`, ...)."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __all__ = ("DirectoryNotFoundError", "NotAFileError")
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)
@@ -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
- """Check if a given path exists and return it.
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
- """Check if a given path exists and is a file, and return it.
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
- """Check if a given path exists and is a directory, and return it.
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
- """Check if all of the given paths exist and return them.
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
- """Check if all of the given paths exist and are files, and return them.
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
- """Check if all of the given paths exist and are directories, and return them.
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._depth != (1, 1):
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, root: StrPath, *, root_as_top: bool = False, dry_run: bool = False
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`: by default `root` is the
25
- *container*, so `tree`'s top node is created directly inside it (a
26
- nonexistent `root` is created first). Only *enumerable* nodes are
27
- materialized -- a node is creatable when its `name` is an `Expandable`
28
- filter (`Literal`, `OneOf`, `Template`, `Without`, and the `str` /
29
- `list[str]` sugar) **and** it sits at a fixed `depth` of 1. Open-ended
30
- names (`Glob`, `Regex`, ...) and non-fixed depths are acceptance patterns,
31
- not generators: they are skipped when optional, but a `required` one
32
- cannot be satisfied and raises. Files are created empty (scaffold builds
33
- the skeleton, not its contents).
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
- root_as_top: Treat `root` *itself* as the realized top rather than its
50
- container. The top must be an `Entry` (a `Group` raises) and is
51
- created only when its name matches `root`'s leaf name -- otherwise
52
- it is skipped, or raises when `required`. A `Directory` top creates
53
- `root` and its children; a `File` top creates `root` as an empty
54
- file.
55
- dry_run: When True, touch nothing on disk and return the paths that
56
- *would* be created. The same checks run, so an unsatisfiable
57
- `required` node or a type conflict still raises -- a faithful
58
- preview.
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 (`dry_run` returns the
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 a target path
66
- exists with the wrong kind.
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 scaffold run: walks a spec, accumulating the paths it makes.
76
-
77
- Bundles the `dry_run` flag and the `created` accumulator so the recursive
78
- walk does not thread them through every call.
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__(self, *, dry_run: bool) -> None:
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
- f"cannot scaffold required {entry!r} at {root}: "
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 # optional top whose name does not match `root`: skip
135
+ return
116
136
 
117
137
  self._ensure_dir(root.parent) # the container that holds `root`
118
- if isinstance(entry, Directory):
119
- self._make_dir(root)
120
- for child in entry.children:
121
- self._dispatch(child, root)
122
- else:
123
- self._make_file(root)
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
- if isinstance(node, File):
128
- self._file(node, parent)
129
- elif isinstance(node, Directory):
130
- self._directory(node, parent)
131
- elif isinstance(node, Together):
132
- self._together(node, parent)
133
- else:
134
- self._exclusive(cast("Exclusive", node), parent)
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
- f"cannot scaffold required {node!r}: its name is not "
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
- isinstance(entry.name, Expandable)
154
- and entry.min_depth == 1
155
- and entry.max_depth == 1
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
- if isinstance(node, Entry):
161
- return self._creatable(node)
162
- if isinstance(node, Together):
163
- return all(self._node_creatable(member) for member in node.members)
164
- exclusive = cast("Exclusive", node)
165
- return any(
166
- all(self._node_creatable(member) for member in side)
167
- for side in exclusive.alternatives
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 ValueError(msg)
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 ValueError(msg)
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
- if not all(self._node_creatable(member) for member in node.members):
213
- # all-or-nothing: a non-creatable member skips the whole set
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 all(self._node_creatable(member) for member in side):
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
- """Whether `entry` accepts `candidate` at `depth`.
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
- """Whether the directory's `allow_extra` or the blanket `extra_level` ignores `name`.
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` overrides it.
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
@@ -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. `ext` is a single
388
- extension or an iterable of acceptable ones (e.g. `("jpg", "jpeg")`); the
389
- leading dot on each is optional, so `"bin"` and `".bin"` behave the same.
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 (first) extension when `path` has no
404
- suffix, instead of raising. Defaults to False.
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, or `path`'s final suffix is none of the
411
- accepted extensions -- except the no-suffix case resolved by
412
- `add=True`.
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 = [e.removeprefix(".") for e in 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
- if add and not path.suffix:
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 path.suffix.lower() not in {f".{e.lower()}" for e in exts}:
426
- wanted = " / ".join(f".{e}" for e in exts)
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
@@ -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 arithmetic
151
- progression (three or more terms) to the equivalent `range(...)`.
150
+ """Render a materialized axis, compacting an integer progression to `range(...)`.
152
151
 
153
- `range` is a valid axis input, so the compact form re-creates the same
154
- axis. Anything else -- too short, irregular, or non-integer -- is shown
155
- as its plain tuple.
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)
@@ -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` (logical conjunction)."""
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` (disjunction)."""
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` (logical negation)."""
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", "ms", "us",
82
- "ns". Defaults to "s".
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
- """Store the elapsed time in `elapsed`."""
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", "ms", "us",
294
- "ns". Defaults to "s".
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. Pauses inside the
432
- block are excluded. A span is recorded only on clean exit -- if
433
- the block raises, nothing is recorded and the exception propagates.
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, or is paused on
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.8.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, and a small exception hierarchy.
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=dRweL9sTbdJSBE2nG0MYGEAyooB76FXnwXvZZomV4UM,16497
6
- kaparoo/data/sequences/templates.py,sha256=KVqNKaCYMlGIW4h5NZeQf9iMDLqP8YJTPhgOQoT4-XE,7237
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=SScOaANqX9Nagy_Bl_RPDoEYdM-DBjfUt6pB4H-gIHI,1729
9
- kaparoo/filesystem/directory.py,sha256=4ZR13SncBV6t1n1zy1gZHujHB3qOdyYNHJO5bKGO9Lc,11434
10
- kaparoo/filesystem/exceptions.py,sha256=JX9DebAKMlnowZqsDFC3HQkSRDisKp5ruGaWLRNNa-U,503
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=o5bHXBvAEQHIpj6ElWa8KHyvuz_QllDbcpcXHXhV_mE,12822
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=6wXKBs3SxDBDrx6GAOm-SUgLr87YS8zV0TTyw7VvHo4,14513
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=UbvNnKKca3QT2nnrORPmINUIFcA1yqsErO1YBpuAHlI,9431
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=gE0DWFO7FV5yZdY85XlcQkmnknTBFTMCK-KgUSxUEHw,2965
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=dZHhnXAtQ5Uyp2j2FDzTiHGCuHcKQena5bphownKJug,21722
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=qXrckMZg_WWY9hnQdT3SHL0evB__u--KnpoN__2Tx1s,13112
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=QE3vPOLiO2A8OlLQ4hBiSfdfGA5pijgAf4M1SIr5v3U,11865
34
- kaparoo/filters/logical.py,sha256=TCcBWlw_gMbVqCQsj-Iwely8UNyZw1yBeO9GqfL8HZM,3385
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=EfRYVa1ar-WSAj7lgBhYBVFzRzczOOXfMsghFU_k4ys,16791
45
- kaparoo_python-0.8.0.dist-info/licenses/LICENSE,sha256=hb6LWYP2rtcoz4V2HpawmblDfHwjwsg9N3cz0c5JQJE,1067
46
- kaparoo_python-0.8.0.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
47
- kaparoo_python-0.8.0.dist-info/METADATA,sha256=6yHnQwLztaGTwq6zETowszZJFWWp5RAMSAonPkmc_zM,6301
48
- kaparoo_python-0.8.0.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.11.21
2
+ Generator: uv 0.11.23
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any