cadrumo.core.directory_scan module¶
Canonical directory-scan primitive built on os.scandir.
Every module in the tree that needs “the files under this directory” resolves
it here, rather than reaching for pathlib.Path.iterdir(),
pathlib.Path.glob(), or pathlib.Path.rglob() directly.
The case for centralising is consistency, not raw speed, and it is worth
being exact about that. os.scandir used to be dramatically faster than
pathlib.Path.rglob(), because pathlib materialised a Path per entry
and re-stat’ed it to decide whether to descend. Python 3.13 rewrote globbing
onto glob._Globber, which calls os.scandir itself and reuses the
directory entry rather than re-stat’ing – so that gap has largely closed.
Measured on the repository’s own tree (4,985 .py files, warm cache,
best of ten): a bare scandir walk 0.19s, this primitive 0.23s,
sorted(Path.rglob("*.py")) 0.27s. Parity, not an order of magnitude.
What this module still buys is real, and none of it is throughput:
One walk with one ordering rule, instead of every caller re-deciding whether to wrap
sorted()and what to sort.prune_directories, which
Path.rglobcannot express at all. Pruning happens BEFORE descending, so an excluded directory costs oneis_dircheck rather than a full subtree walk – which is what makes excluding a large data tree cheap rather than merely correct, and is where a genuine order-of-magnitude win is still available.An explicit, tested answer on the three semantics that silently differ between platforms and call styles: sort order, missing directories, and symlinks.
Nothing here is memoised. A cached listing of a directory under construction is simply wrong, and production callers write to the directories they scan; the one place in the tree that can safely cache a walk (the immutable source tree during a test session) owns that cache itself.
Two return shapes exist, and the split is deliberate:
scan_directory()materialises and sorts, for the overwhelmingly common caller that wanted a deterministic listing and was wrapping its ownsorted(...)around a glob.iter_directory()stays lazy, for the caller that asksany(...)or breaks out of a loop early. Handing those a sorted tuple would walk the whole tree to answer a question the first entry settles.
The default arguments reproduce the pathlib call they replace exactly, so a
mechanical migration cannot silently change what a call site sees:
scan_directory(root) is root.iterdir(),
scan_directory(root, pattern=p) is root.glob(p), and
scan_directory(root, pattern=p, recursive=True) is root.rglob(p).
Narrowing to files or directories, and pruning, are opt-in.
- class DirectoryEntryKind(*values)[source]¶
Bases:
StrEnumWhich directory entries a scan yields.
ALLis the default because it is what the pathlib calls this module replaces actually do:iterdir(),glob("*"), and evenglob("*.toml")all yield directories alongside files, and a directory whose name matches the pattern is a real occurrence rather than a hypothetical one. A scan that narrowed to files by default would silently drop those entries at every migrated call site.- FILES¶
Entries that are not directories.
- DIRECTORIES¶
Entries that are directories.
- ALL¶
Every entry, matching what
iterdir,globandrglobyield.
- scan_directory(root, *, pattern=None, recursive=False, select=DirectoryEntryKind.ALL, prune_directories=(), require_root=False)[source]¶
Return the entries under root, sorted, walked with
os.scandir.The eager shape. Ordering is
sorted()overPathobjects, which compares on the case-normalised string form – so on WindowsB.tomlsorts beforea.toml, exactly as it would have undersorted(root.glob(...)). Sorting the string forms instead would be case-sensitive and reorder mixed-case trees, which the determinism gates would catch as drift.- Parameters:
root (
Path) – Directory to scan. Results keep root’s relative or absolute form, as the pathlib calls do; nothing is resolved.pattern (
str|None) – Single-segment glob ("*.toml","test_*.py","modelo-*.json") matched against each entry’s bare name.Nonematches every entry. Seeiter_directory()for the matching semantics and the refused pattern shapes.recursive (
bool) – Descend into subdirectories. Descent is unconditional – pattern filters what is yielded, never what is entered, which is howrglobbehaves.select (
DirectoryEntryKind) – Narrow to files or to directories. Defaults toDirectoryEntryKind.ALL, the pathlib-identical shape.prune_directories (
Collection[str]) – Bare directory names never entered and never yielded, checked before descending. This is caller policy, not a default: no name is pruned unless named here, so a scan reproduces its pathlib equivalent until the caller opts out.require_root (
bool) – Raise the underlyingOSErrorwhen root is missing or is not a directory, instead of returning empty.
- Return type:
- Returns:
Sorted paths of every selected entry beneath root.
- Raises:
CoreValidationError – pattern is empty, spans path segments, or uses
**.OSError – root could not be scanned and require_root is set.
- iter_directory(root, *, pattern=None, recursive=False, select=DirectoryEntryKind.ALL, prune_directories=(), require_root=False)[source]¶
Yield the entries under root lazily, walked with
os.scandir.The lazy sibling of
scan_directory(), for the caller that stops early:any(iter_directory(root, pattern="*.pdf"))settles on the first match instead of walking the tree to build a listing it discards.Ordering is a pre-order traversal, with each directory’s own entries yielded in case-normalised name order and a subdirectory’s contents yielded at the point that subdirectory appears. That is deterministic, but it is NOT the ordering
scan_directory()returns: a global sort over full paths interleaves depths differently, and no lazy walk can produce it without materialising everything first. A caller that needs the sorted listing wants the eager shape.Argument validation is eager – a refused pattern raises from this call, not from the first
next()– while the walk itself is lazy. Each directory’s entries are read and its handle closed before any is yielded, so a consumer can delete a directory it is iterating without tripping a Windows sharing violation.Missing directories yield nothing rather than raising, unless require_root is set.
Path.globandPath.rglobsilently yield nothing for a missing directory whileos.scandirraisesFileNotFoundError, and every existing call site was written against the silent behaviour. AnOSErrorencountered below the root – an unreadable subdirectory, a directory removed mid-walk – always skips that subtree, which is what pathlib’s own walk does; only the root’s failure is configurable.Pattern matching goes through
fnmatch.translate()overos.path.normcase()-folded text, which reproducespathlib.Path.glob()exactly: case-insensitive on Windows, case-sensitive on POSIX, and matching leading-dot names that a shell would hide.fnmatch.fnmatchcase()would instead be case-sensitive everywhere and would quietly stop matching*.tomlagainst a.TOMLfile thatglobfinds today.Multi-segment patterns (
"*/*.toml") and**are refused rather than approximated. Recursion is spelled by recursive, and a pattern silently matched against bare names alone would find nothing at all for"*/*.toml"while looking like it worked.Symlinks are classified and traversed with
follow_symlinks=False, so a symlink loop cannot hang the walk – matchingrglob, which stopped following directory symlinks in Python 3.13. One divergence follows and is deliberate: a symlink pointing at a directory is not a directory here, so it appears underDirectoryEntryKind.FILESand not underDirectoryEntryKind.DIRECTORIES, whereasPath.is_dir()resolves the target and would say otherwise. The rule is exact: an entry is a directory precisely when the walk would descend into it.- Parameters:
root (
Path) – Directory to scan. Results keep root’s relative or absolute form; nothing is resolved.pattern (
str|None) – Single-segment glob matched against each entry’s bare name, orNoneto match every entry.recursive (
bool) – Descend into subdirectories.select (
DirectoryEntryKind) – Narrow to files or to directories.prune_directories (
Collection[str]) – Bare directory names never entered and never yielded.require_root (
bool) – Raise the underlyingOSErrorwhen root is missing or is not a directory, instead of yielding nothing.
- Yields:
Paths of every selected entry beneath root.
- Raises:
CoreValidationError – pattern is empty, spans path segments, or uses
**.OSError – root could not be scanned and require_root is set.
- Return type:
Iterator[Path]