kaparoo-python 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/PKG-INFO +3 -2
  2. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/README.md +2 -1
  3. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/data/sequences/composers.py +5 -4
  4. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/data/sequences/templates.py +2 -2
  5. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/README.md +41 -10
  6. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/__init__.py +12 -1
  7. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/directory.py +8 -8
  8. kaparoo_python-0.9.0/kaparoo/filesystem/exceptions.py +67 -0
  9. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/existence.py +6 -6
  10. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/README.md +26 -3
  11. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/entry.py +11 -1
  12. kaparoo_python-0.9.0/kaparoo/filesystem/hierarchy/scaffold.py +269 -0
  13. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/traverse/_utils.py +1 -4
  14. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/traverse/validate.py +9 -3
  15. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/utils.py +63 -30
  16. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/enumerable.py +5 -5
  17. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/logical.py +3 -3
  18. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/utils/timer.py +25 -20
  19. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/pyproject.toml +1 -1
  20. kaparoo_python-0.8.0/kaparoo/filesystem/exceptions.py +0 -18
  21. kaparoo_python-0.8.0/kaparoo/filesystem/hierarchy/scaffold.py +0 -227
  22. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/LICENSE +0 -0
  23. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/__init__.py +0 -0
  24. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/data/README.md +0 -0
  25. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/data/__init__.py +0 -0
  26. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/data/sequences/__init__.py +0 -0
  27. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/data/sequences/base.py +0 -0
  28. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/data/sequences/utils.py +0 -0
  29. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/exclude.py +0 -0
  30. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/__init__.py +0 -0
  31. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/base.py +0 -0
  32. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/conditions.py +0 -0
  33. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/group.py +0 -0
  34. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/traverse/__init__.py +0 -0
  35. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/traverse/locate.py +0 -0
  36. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/hierarchy/utils.py +0 -0
  37. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/search/README.md +0 -0
  38. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/search/__init__.py +0 -0
  39. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/search/classes.py +0 -0
  40. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/search/wrappers.py +0 -0
  41. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/staged.py +0 -0
  42. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/types.py +0 -0
  43. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filesystem/units.py +0 -0
  44. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/README.md +0 -0
  45. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/__init__.py +0 -0
  46. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/base.py +0 -0
  47. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/multi_pattern.py +0 -0
  48. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/pattern.py +0 -0
  49. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/types.py +0 -0
  50. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/filters/utils.py +0 -0
  51. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/py.typed +0 -0
  52. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/utils/README.md +0 -0
  53. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/utils/__init__.py +0 -0
  54. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/utils/aggregate.py +0 -0
  55. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/utils/checks.py +0 -0
  56. {kaparoo_python-0.8.0 → kaparoo_python-0.9.0}/kaparoo/utils/optional.py +0 -0
@@ -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
 
@@ -33,7 +33,8 @@ Each submodule ships its own README with focused examples.
33
33
  `ensure_*` validators, `make_dir(s)` (with a destructive `clean` reset
34
34
  option), `dir_empty(s)`, `reserve_path(s)` guards for not-yet-existing
35
35
  destinations, `StagedFile` / `StagedDirectory` for safe (atomic) writes,
36
- path stringification, and a small exception hierarchy.
36
+ path stringification, extension helpers (`ensure_file_extension`,
37
+ `file_extension`, `normalize_extension(s)`), and a small exception hierarchy.
37
38
 
38
39
  ### [`kaparoo.filesystem.search`](https://github.com/kaparoo/kaparoo-python/tree/main/kaparoo/filesystem/search)
39
40
 
@@ -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
@@ -9,6 +9,7 @@
9
9
  - [Exception hierarchy](#exception-hierarchy)
10
10
  - [Creating and emptying directories](#creating-and-emptying-directories)
11
11
  - [Path manipulation](#path-manipulation)
12
+ - [Extensions](#extensions)
12
13
  - [Reserving a destination](#reserving-a-destination)
13
14
  - [Safe (atomic) writes](#safe-atomic-writes)
14
15
  - [Platform notes](#platform-notes)
@@ -22,10 +23,12 @@
22
23
  `dir_not_empty(s)` with validation, plus `_unsafe` variants that skip
23
24
  pre-checks
24
25
  - [`utils`](./utils.py) — `stringify_path(s)`, `wrap_path(s)`,
25
- `reserve_path(s)`, `ensure_file_extension`
26
+ `reserve_path(s)`, and the extension helpers `ensure_file_extension` /
27
+ `file_extension` / `normalize_extension(s)`
26
28
  - [`staged`](./staged.py) — `StagedFile` / `StagedDirectory`, safe
27
29
  (atomic) writers usable as a context manager or explicitly
28
- - [`exceptions`](./exceptions.py) — `DirectoryNotFoundError`, `NotAFileError`
30
+ - [`exceptions`](./exceptions.py) — `DirectoryNotFoundError`, `NotAFileError`,
31
+ `UnsupportedExtensionError`
29
32
  - [`types`](./types.py) — `StrPath`, `StrPaths` type aliases
30
33
  - [`units`](./units.py) — byte-size multipliers (`KB` / `MB` / `GB` / `TB`,
31
34
  `KIB` / `MIB` / `GIB` / `TIB`)
@@ -78,6 +81,10 @@ except FileNotFoundError: # catches DirectoryNotFoundError too
78
81
  ...
79
82
  ```
80
83
 
84
+ `UnsupportedExtensionError` (raised by `ensure_file_extension`, see
85
+ [Extensions](#extensions)) likewise subclasses `ValueError`, so an
86
+ `except ValueError` catches it.
87
+
81
88
  ## Creating and emptying directories
82
89
 
83
90
  ```python
@@ -136,25 +143,49 @@ stringify_paths(["data/a.txt", "data/b.txt"], after="data") # ["a.txt", "b.txt"
136
143
  wrap_path("logs", prepend="var", append="server.log") # var/logs/server.log
137
144
  ```
138
145
 
146
+ ## Extensions
147
+
148
+ `normalize_extension` cleans up an extension string — it strips surrounding
149
+ whitespace and leading dots, keeping case unless `lowercase=True`.
150
+ `normalize_extensions` maps it over an iterable (empties and duplicates are
151
+ deliberately kept — that policy is the caller's). `file_extension` reads a
152
+ path's trailing suffix(es): the last `level` of them, dot-joined and
153
+ normalized (lower-cased by default).
154
+
155
+ ```python
156
+ from kaparoo.filesystem import (
157
+ file_extension, normalize_extension, normalize_extensions,
158
+ )
159
+
160
+ normalize_extension(" .TAR ") # "TAR" (case kept)
161
+ normalize_extension(".PNG", lowercase=True) # "png"
162
+ normalize_extensions([".jpg", "PNG"], lowercase=True) # ["jpg", "png"]
163
+
164
+ file_extension("photo.JPG") # "jpg" (lower-cased)
165
+ file_extension("data.tar.gz", level=2) # "tar.gz"
166
+ file_extension("README") # "" (no suffix)
167
+ ```
168
+
139
169
  `ensure_file_extension` is a pure (no filesystem) extension check: it
140
- requires a `.<ext>` final suffix, raising `ValueError` otherwise. `ext` may
141
- be a single extension or an iterable of acceptable ones; the leading dot is
142
- optional and the match is case-insensitive. `add=True` mirrors `make` on
143
- `ensure_dir_exists` it appends the (first) extension when the path has no
144
- suffix (`np.save`-style) instead of raising; a *wrong* suffix still raises.
170
+ requires a `.<ext>` final suffix, raising `UnsupportedExtensionError` (a
171
+ `ValueError` subclass) otherwise. `ext` may be a single extension or an
172
+ iterable of acceptable ones; the leading dot is optional and the match is
173
+ case-insensitive. `add=True` mirrors `make` on `ensure_dir_exists` it
174
+ appends the (first) extension when the path has no suffix (`np.save`-style)
175
+ instead of raising; a *wrong* suffix still raises.
145
176
 
146
177
  ```python
147
178
  from kaparoo.filesystem import ensure_file_extension
148
179
 
149
180
  ensure_file_extension("data.bin", "bin") # Path("data.bin")
150
- ensure_file_extension("data.txt", "bin") # ValueError
151
- ensure_file_extension("out/00000_phase", "bin") # ValueError (no suffix)
181
+ ensure_file_extension("data.txt", "bin") # UnsupportedExtensionError
182
+ ensure_file_extension("out/00000_phase", "bin") # UnsupportedExtensionError (no suffix)
152
183
 
153
184
  # Any of several accepted extensions:
154
185
  ensure_file_extension("img.jpeg", ("jpg", "jpeg", "png")) # Path("img.jpeg")
155
186
 
156
187
  ensure_file_extension("out/00000_phase", "bin", add=True) # Path("out/00000_phase.bin")
157
- ensure_file_extension("out/data.txt", "bin", add=True) # ValueError (wrong suffix)
188
+ ensure_file_extension("out/data.txt", "bin", add=True) # UnsupportedExtensionError (wrong suffix)
158
189
  ```
159
190
 
160
191
  ## Reserving a destination
@@ -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.
@@ -0,0 +1,67 @@
1
+ """Filesystem exception types (`NotAFileError`, `UnsupportedExtensionError`, ...)."""
2
+
3
+ from __future__ import annotations
4
+
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
15
+
16
+
17
+ class DirectoryNotFoundError(FileNotFoundError):
18
+ """Raised when a directory does not exist.
19
+
20
+ Note:
21
+ Since this inherits from `FileNotFoundError`, catch it before
22
+ `FileNotFoundError` in any combined `except` block.
23
+ """
24
+
25
+
26
+ class NotAFileError(OSError):
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.
@@ -630,9 +630,32 @@ cannot be satisfied and raises. `Together` creates all its members
630
630
  Files are created **empty** (the skeleton, not its contents). Creation is
631
631
  **idempotent**: an existing directory is descended unchanged and an existing
632
632
  file is never clobbered, so only newly created paths are returned and a
633
- re-run is a no-op. A path that exists with the wrong kind (a file where a
634
- directory is described, or vice versa) is a conflict and raises. `dry_run`
635
- runs every check but no write, returning the paths that *would* be created.
633
+ re-run is a no-op. A path that exists with the wrong kind raises
634
+ `NotADirectoryError` (a file where a directory is described) or `NotAFileError`
635
+ (a directory where a file is described). `dry_run` runs every check but no
636
+ write, returning the paths that *would* be created.
637
+
638
+ Failure is **best-effort**: a mid-run raise — a conflict, an unsatisfiable
639
+ `required` node, or an `on_create` callback that raises — leaves the paths
640
+ already created in place. There is no rollback, but because creation is
641
+ idempotent a re-run resumes safely.
642
+
643
+ Two options shape what gets written. **`on_create`** is a callback run once
644
+ for each file *actually* created — `on_create(path, file_node)`, the seam for
645
+ writing a file's content (scaffold only makes the skeleton). It is not called
646
+ for an untouched existing file, under `dry_run`, or with `dirs_only`.
647
+ **`dirs_only=True`** writes only the directory skeleton, skipping every file
648
+ (including `required` ones); pairing it with `on_create` raises `ValueError`.
649
+
650
+ ```python
651
+ # Fill each created file from its spec node, then build the tree.
652
+ def fill(path, node):
653
+ if path.name == "README.md":
654
+ path.write_text("# Project\n")
655
+
656
+ scaffold(spec, "/tmp/out", on_create=fill)
657
+ scaffold(spec, "/tmp/out", dirs_only=True) # directories only, no files
658
+ ```
636
659
 
637
660
  ## See also
638
661
 
@@ -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