Skip to content

Glob API

pathapi.glob

Filename globbing over any pathapi Path.

Modelled on CPython's pathlib glob selectors, reworked to run over anything that implements _scandir()/iterdir()/is_dir()/name like pathlib.Path (LocalPath, MemPath, FTPPath, the archive paths).

RECURSIVE = '**' module-attribute

__all__ = ['RECURSIVE', 'full_match', 'glob', 'parse_pattern', 'select'] module-attribute

full_match(segments, pattern, case_sensitive)

Match segments against a glob pattern that may contain "" components (pathlib 3.13's PurePath.full_match semantics): a "" matches zero or more segments, except a trailing "" after other components, which needs at least one ("a/" does not match "a").

The root is a field of its own, as in the path's text: a rooted pattern names it with its leading "/", a lone "" never matches it (a lone "" matches one or more characters), and a "" followed by more components reaches it only together with a name after it ("/x" matches "/a/x", not "/x"). Empty and "." components are dropped from the pattern. A bracket expression never matches a separator (pathlib's regex lets a negated one).

Runs as a set-of-states automaton, O(len(segments) * len(pattern)), so repeated "**" cannot backtrack exponentially.

Source code in src/pathapi/glob/_patterns.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def full_match(segments: _ty.Sequence[str], pattern: str, case_sensitive: bool) -> bool:
    """Match `segments` against a glob pattern that may contain "**"
    components (pathlib 3.13's PurePath.full_match semantics): a "**" matches
    zero or more segments, except a trailing "**" after other components,
    which needs at least one ("a/**" does not match "a").

    The root is a field of its own, as in the path's text: a rooted pattern
    names it with its leading "/", a lone "*" never matches it (a lone "*"
    matches one or more characters), and a "**" followed by more components
    reaches it only together with a name after it ("**/x" matches "/a/x",
    not "/x"). Empty and "." components are dropped from the pattern. A
    bracket expression never matches a separator (pathlib's regex lets a
    negated one).

    Runs as a set-of-states automaton, O(len(segments) * len(pattern)), so
    repeated "**" cannot backtrack exponentially.
    """
    fields = _fields(segments)
    parts = [part for part in pattern.split("/") if part and part != "."]
    if pattern.startswith("/"):
        parts = ["", *parts] if parts else ["", ""]
    pats = _collapse_recursive(parts)
    end = len(pats)
    last = end - 1

    # A state is (index, stage). Stage 0: about to match pats[index]. Stage 1 is a "**"
    # that has taken one empty field (the root), which does not count yet; stage 2 has
    # taken enough and may take more or hand over to the next component.
    def closure(states: _ty.Set[_ty.Tuple[int, int]]) -> _ty.Set[_ty.Tuple[int, int]]:
        pending = list(states)
        while pending:
            i, stage = pending.pop()
            if stage == 2 or (
                stage == 0
                and i < end
                and pats[i] == RECURSIVE
                # A non-trailing (or sole) "**" may match no field at all.
                and (i < last or end == 1)
            ):
                following = (i + 1, 0)
                if following not in states:
                    states.add(following)
                    pending.append(following)
        return states

    states = closure({(0, 0)})
    for field in fields:
        advanced: _ty.Set[_ty.Tuple[int, int]] = set()
        for i, stage in states:
            if stage:
                advanced.add((i, 2))
            elif i == end:
                continue
            elif pats[i] == RECURSIVE:
                advanced.add((i, 1 if field == "" and i < last else 2))
            elif pats[i] == "*":
                if field:
                    advanced.add((i + 1, 0))
            elif compile_pattern(pats[i], case_sensitive).match(field):
                advanced.add((i + 1, 0))
        if not advanced:
            return False
        states = closure(advanced)
    return (end, 0) in states

glob(path, *, dironly=False, root_dir=None, recursive=False, include_hidden=False, case_sensitive=None, native=True, on_error=None, bound_loops=False, recurse_symlinks=False)

Return an iterator which yields the paths matching a pathname pattern.

path is the pattern itself, as a path (e.g. LocalPath("/x/**/*.py")). The pattern may contain simple shell-style wildcards a la fnmatch. Like the stdlib glob module, and unlike Path.glob(), hidden entries (names starting with a dot) are not matched by wildcards and not descended into by "**" unless include_hidden is true.

If recursive is true, the pattern '' will match zero or more directories and subdirectories; as the last component it also matches files on Python 3.13+ or with native=False, and directories only otherwise. recursive=None decides from the pattern itself, as Path.glob() does: a "" component enables it. native= follows the running interpreter's rules, or applies one rule on every version -- see parse_pattern(). recurse_symlinks=True lets "**" descend through symlinks to directories, see select().

The pattern is validated when this is called and the paths are selected lazily. The path's anchor (a drive, an extended-length drive prefix) is never a wildcard, whatever characters it holds.

Source code in src/pathapi/glob/_selection.py
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def glob(
    path: _Globable,
    *,
    dironly: bool = False,
    root_dir: _Globable | None = None,
    recursive: bool | None = False,
    include_hidden: bool = False,
    case_sensitive: bool | None = None,
    native: bool = True,
    on_error: "_ty.Callable[[OSError], None] | None" = None,
    bound_loops: bool = False,
    recurse_symlinks: bool = False,
) -> _ty.Iterator[_Globable]:
    """Return an iterator which yields the paths matching a pathname pattern.

    `path` is the pattern itself, as a path (e.g. `LocalPath("/x/**/*.py")`).
    The pattern may contain simple shell-style wildcards a la fnmatch. Like
    the stdlib `glob` module, and unlike `Path.glob()`, hidden entries (names
    starting with a dot) are not matched by wildcards and not descended into
    by "**" unless `include_hidden` is true.

    If recursive is true, the pattern '**' will match zero or more
    directories and subdirectories; as the last component it also matches
    files on Python 3.13+ or with `native=False`, and directories only
    otherwise. `recursive=None` decides
    from the pattern itself, as `Path.glob()` does: a "**" component enables
    it. `native=` follows the running interpreter's rules, or applies one
    rule on every version -- see `parse_pattern()`. `recurse_symlinks=True`
    lets "**" descend through symlinks to directories, see `select()`.

    The pattern is validated when this is called and the paths are selected
    lazily. The path's anchor (a drive, an extended-length drive prefix) is
    never a wildcard, whatever characters it holds.
    """
    segments = list(path.segments)
    if len(segments) > 1 and segments[-1] == "":
        segments.pop()
        if native and not _TRAILING_SLASH_SELECTS_DIRS:
            pass  # pathlib ignores a trailing separator before 3.11
        else:
            dironly = True
    if recursive is None:
        recursive = RECURSIVE in segments
    if native and not _PARTIAL_DOUBLESTAR_ALLOWED:
        for segment in segments:
            if RECURSIVE in segment and segment != RECURSIVE:
                raise ValueError(
                    "Invalid pattern: '**' can only be an entire path component"
                )
    anchor = getattr(path, "anchor", "")
    searched = 1 if segments and anchor and segments[0] == anchor else 0
    first_wildcard = next(
        (
            i
            for i, seg in enumerate(segments)
            if i >= searched and WILDCARD_PATTERN.search(seg)
        ),
        max(len(segments) - 1, 0),
    )
    base = path.with_segments(*segments[:first_wildcard])
    if root_dir is not None:
        base = root_dir / base
    parts = [part for part in segments[first_wildcard:] if part not in ("", ".")]
    return _expand(
        base,
        parts,
        dironly=dironly,
        recursive=recursive,
        include_hidden=include_hidden,
        case_sensitive=case_sensitive,
        native=native,
        on_error=on_error,
        bound_loops=bound_loops,
        recurse_symlinks=recurse_symlinks,
    )

parse_pattern(pattern, *, native=True)

Split a relative glob pattern (a /-separated string or a Pathname) into its components, and report whether it ended with a separator (directories only).

Raises ValueError for an empty pattern and NonRelativePatternError for an absolute one, as pathlib.Path.glob() does. Empty and "." components are dropped.

native=True (the default) follows the running interpreter on the two rules pathlib changed mid-series: a trailing "/" is ignored before 3.11 (it selects directories only from 3.11), and a component that merely CONTAINS "" ("a") raises ValueError before 3.13. native=False applies one rule on every version instead -- a trailing "/" always selects directories only, "a**" is always a plain wildcard -- so a pattern gives the same answer on every interpreter and every backend.

Source code in src/pathapi/glob/_patterns.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def parse_pattern(
    pattern: "str | _ty.Any", *, native: bool = True
) -> _ty.Tuple[_ty.List[str], bool]:
    """Split a relative glob `pattern` (a `/`-separated string or a
    `Pathname`) into its components, and report whether it ended with a
    separator (directories only).

    Raises `ValueError` for an empty pattern and `NonRelativePatternError`
    for an absolute one, as `pathlib.Path.glob()` does. Empty and "."
    components are dropped.

    `native=True` (the default) follows the running interpreter on the two
    rules pathlib changed mid-series: a trailing "/" is ignored before 3.11
    (it selects directories only from 3.11), and a component that merely
    CONTAINS "**" ("a**") raises `ValueError` before 3.13. `native=False`
    applies one rule on every version instead -- a trailing "/" always
    selects directories only, "a**" is always a plain wildcard -- so a
    pattern gives the same answer on every interpreter and every backend.
    """
    if isinstance(pattern, str):
        rooted = pattern.startswith("/")
        segments = pattern.split("/")
    else:
        segments = list(pattern.segments)
        rooted = bool(getattr(pattern, "anchor", "")) or bool(
            getattr(pattern, "source", None)
        )
    if rooted:
        raise NonRelativePatternError("Non-relative patterns are unsupported")
    parts = [part for part in segments if part not in ("", ".")]
    if not parts:
        raise ValueError(f"Unacceptable pattern: {str(pattern)!r}")
    if native and not _PARTIAL_DOUBLESTAR_ALLOWED:
        for part in parts:
            if RECURSIVE in part and part != RECURSIVE:
                # pathlib's own message, so an `except ValueError` that reads
                # it sees the same text it does on this interpreter.
                raise ValueError(
                    "Invalid pattern: '**' can only be an entire path component"
                )
    trailing_sep = bool(segments) and segments[-1] == ""
    if native and not _TRAILING_SLASH_SELECTS_DIRS:
        trailing_sep = False
    return parts, trailing_sep

select(base, parts, *, dironly=False, recursive=True, include_hidden=True, case_sensitive=None, native=True, on_error=None, bound_loops=False, recurse_symlinks=False)

Yield the paths under base matching the pattern components parts (see parse_pattern()); the engine behind Path.glob().

Directory listings go through _scandir(); an OSError from listing a missing or non-directory path selects nothing. "" (when recursive) decides recursion from the listing's non-following stat, so it never descends into a directory symlink and always terminates. A trailing "" also selects files on Python 3.13+ and directories only before, which is the third rule native=False pins: it then selects files on every version (3.13's rule, the one current pathlib applies).

With recurse_symlinks=True "**" also descends through a symlink to a directory, as pathlib 3.13 does, and ends where that would loop: a link to a directory the descent is already inside (st_dev/st_ino) is an entry but is not entered, and so is any link beyond the 40th in one descent where the stat has no identity.

Source code in src/pathapi/glob/_selection.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def select(
    base: _Globable,
    parts: _ty.Sequence[str],
    *,
    dironly: bool = False,
    recursive: bool = True,
    include_hidden: bool = True,
    case_sensitive: bool | None = None,
    native: bool = True,
    on_error: "_ty.Callable[[OSError], None] | None" = None,
    bound_loops: bool = False,
    recurse_symlinks: bool = False,
) -> _ty.Iterator[_Globable]:
    """Yield the paths under `base` matching the pattern components `parts`
    (see `parse_pattern()`); the engine behind `Path.glob()`.

    Directory listings go through `_scandir()`; an `OSError` from listing a
    missing or non-directory path selects nothing. "**" (when `recursive`)
    decides recursion from the listing's non-following stat, so it never
    descends into a directory symlink and always terminates. A trailing "**"
    also selects files on Python 3.13+ and directories only before, which is
    the third rule `native=False` pins: it then selects files on every
    version (3.13's rule, the one current pathlib applies).

    With `recurse_symlinks=True` "**" also descends through a symlink to a
    directory, as pathlib 3.13 does, and ends where that would loop: a link
    to a directory the descent is already inside (`st_dev`/`st_ino`) is an
    entry but is not entered, and so is any link beyond the 40th in one
    descent where the stat has no identity.
    """
    # An explicit `case_sensitive` asks for names to be compared, so a literal
    # is looked for in the listing and yields the name as stored, as pathlib
    # does when it is given. "." and ".." are never names to find.
    explicit_case = case_sensitive is not None or (native and _LITERALS_ARE_LISTED)
    if case_sensitive is None:
        case_sensitive = getattr(base, "_is_case_sensitive", True)
    if recursive:
        parts = _collapse_recursive(parts)
    steps: _ty.List[_ty.Tuple[str, _ty.Any]] = []
    for part in parts:
        if recursive and part == RECURSIVE:
            steps.append((part, None))
        elif part in _SPECIAL_PARTS:
            steps.append((part, False))
        elif explicit_case or WILDCARD_PATTERN.search(part):
            steps.append((part, compile_pattern(part, case_sensitive)))
        else:
            steps.append((part, False))
    opts = _Options(
        dironly,
        include_hidden,
        _DOUBLESTAR_SELECTS_FILES if native else True,
        on_error,
        bound_loops,
        _INTERMEDIATE_LITERAL_IS_CHECKED and native,
        _FINAL_LITERAL_IS_NOT_FOLLOWED or not native,
        recurse_symlinks,
        _DIRONLY_LITERAL_IS_LEXISTS and native,
    )
    selected = _select(base, steps, 0, opts, None)
    if sum(1 for _, kind in steps if kind is None) < 2:
        yield from selected
        return
    # Two separate "**" can reach one path along several splits.
    seen = set()
    for path in selected:
        if path not in seen:
            seen.add(path)
            yield path