pathapi 0.1.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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
pathapi/glob/_entries.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Listing a directory for the glob engine, and telling a directory from a link."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os as _os
|
|
6
|
+
import pathlib as _pathlib
|
|
7
|
+
import typing as _ty
|
|
8
|
+
|
|
9
|
+
if _ty.TYPE_CHECKING:
|
|
10
|
+
from ..protocols._bundles import Walkable as _Walkable
|
|
11
|
+
|
|
12
|
+
_Globable = _ty.TypeVar("_Globable", bound=_Walkable)
|
|
13
|
+
else:
|
|
14
|
+
|
|
15
|
+
class _Globable(_ty.Protocol): ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _report_unlistable(path: _Globable, on_error) -> None:
|
|
19
|
+
"""Tell `on_error` why `path` has nothing to select from (a missing path,
|
|
20
|
+
a file), in the words listing it would use."""
|
|
21
|
+
if on_error is not None:
|
|
22
|
+
_scan(path, on_error)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _scan(directory: _Globable, on_error=None):
|
|
26
|
+
"""(child, non-following stat or None) per entry; nothing if listing
|
|
27
|
+
fails. Materialized so a lazily-raising listing is still caught here.
|
|
28
|
+
|
|
29
|
+
`on_error`, when given, is called as `on_error(error)` with the failure
|
|
30
|
+
-- the same contract as `Path.walk(on_error=)` and `os.walk`. Raising
|
|
31
|
+
from it propagates, which is how a caller turns an unreadable directory
|
|
32
|
+
back into an error; returning treats it as "nothing here", which is what
|
|
33
|
+
happens with no hook at all. `error.filename` is filled in with the
|
|
34
|
+
directory when the backend left it empty, so one argument is enough to
|
|
35
|
+
say where.
|
|
36
|
+
"""
|
|
37
|
+
scandir = getattr(directory, "_scandir", None)
|
|
38
|
+
try:
|
|
39
|
+
if scandir is None:
|
|
40
|
+
return [(child, None) for child in directory.iterdir()]
|
|
41
|
+
return [(_child(directory, name, stat), stat) for name, stat in scandir()]
|
|
42
|
+
except OSError as error:
|
|
43
|
+
if on_error is not None:
|
|
44
|
+
if not getattr(error, "filename", None):
|
|
45
|
+
try:
|
|
46
|
+
error.filename = str(directory)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
on_error(error)
|
|
50
|
+
return ()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _identity(path: _Globable) -> "tuple | None":
|
|
54
|
+
"""`(st_dev, st_ino)` of what `path` resolves to, or None when the
|
|
55
|
+
backend cannot say. Follows links deliberately: a Windows junction
|
|
56
|
+
reports `is_symlink() == False`, so its own metadata is no help --
|
|
57
|
+
`stat()` returning the TARGET's identity is what makes a loop visible.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
st = path.stat()
|
|
61
|
+
except (OSError, NotImplementedError, ValueError):
|
|
62
|
+
return None
|
|
63
|
+
dev = getattr(st, "st_dev", None)
|
|
64
|
+
ino = getattr(st, "st_ino", None)
|
|
65
|
+
# `st_ino` is zero where the filesystem reports no file identity; every
|
|
66
|
+
# directory would then share one key and look like its own ancestor.
|
|
67
|
+
if dev is None or not ino:
|
|
68
|
+
return None
|
|
69
|
+
return (dev, ino)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_hidden(path: _Globable) -> bool:
|
|
73
|
+
"""Whether the final component of `path` starts with a dot."""
|
|
74
|
+
return path.name.startswith(".")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _child(directory: _Globable, name: str, stat=None) -> _Globable:
|
|
78
|
+
"""The child of `directory` called exactly `name`, a name its listing
|
|
79
|
+
returned. A caller that has not checked it with `providers.is_safe_child_name()`
|
|
80
|
+
must, since a URI path joined with `/` resolves `..` and splits at `/`."""
|
|
81
|
+
if getattr(directory, "_pop_stat_hint", None) is not None:
|
|
82
|
+
# A URI path: the name is attached as one segment, and seeding the
|
|
83
|
+
# listing's stat saves a round trip per entry.
|
|
84
|
+
return directory._make_child_relpath(name, stat_hint=stat)
|
|
85
|
+
return directory / name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _names_a_directory(path: _Globable, lexists: bool) -> bool:
|
|
89
|
+
"""Whether the literal `path`, given with a trailing separator, is a
|
|
90
|
+
directory. `lexists` is pathlib 3.13+'s test (see
|
|
91
|
+
`_DIRONLY_LITERAL_IS_LEXISTS`), made on the file `path` is; any other path
|
|
92
|
+
is a directory when it follows to one."""
|
|
93
|
+
if lexists and isinstance(path, _pathlib.Path):
|
|
94
|
+
return _os.path.lexists(_os.fspath(path) + _os.sep)
|
|
95
|
+
return path.is_dir()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _entry_is_dir(path: _Globable, stat, *, follow_symlinks: bool) -> bool:
|
|
99
|
+
if stat is not None and not (follow_symlinks and stat.is_symlink()):
|
|
100
|
+
return stat.is_dir()
|
|
101
|
+
if follow_symlinks:
|
|
102
|
+
return path.is_dir()
|
|
103
|
+
return path.is_dir() and not path.is_symlink()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Glob patterns: splitting a pattern into components and matching field by field."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch as _fnmatch
|
|
6
|
+
import functools as _func
|
|
7
|
+
import re as _re
|
|
8
|
+
import sys as _sys
|
|
9
|
+
import typing as _ty
|
|
10
|
+
|
|
11
|
+
from ..exceptions import NonRelativePatternError
|
|
12
|
+
|
|
13
|
+
RECURSIVE = "**"
|
|
14
|
+
ANY_PATTERN = _re.compile(_fnmatch.translate("*"))
|
|
15
|
+
WILDCARD_PATTERN = _re.compile("([*?[])")
|
|
16
|
+
WILCARD_PATTERN = WILDCARD_PATTERN # misspelled alias, kept for importers
|
|
17
|
+
|
|
18
|
+
# Rules the running interpreter changed mid-series: honoured when `native=True`
|
|
19
|
+
# (the default), smoothed over when it is False.
|
|
20
|
+
#: A trailing "/" selects directories only from 3.11; before, pathlib ignores it.
|
|
21
|
+
_TRAILING_SLASH_SELECTS_DIRS = _sys.version_info >= (3, 11)
|
|
22
|
+
#: "**" recurses only as the WHOLE component; "a**" raises before 3.13 and is a
|
|
23
|
+
#: plain wildcard from 3.13.
|
|
24
|
+
_PARTIAL_DOUBLESTAR_ALLOWED = _sys.version_info >= (3, 13)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@_func.lru_cache(maxsize=256, typed=True)
|
|
28
|
+
def compile_pattern(pat: str, case_sensitive: bool):
|
|
29
|
+
# re.NOFLAG is 3.11+
|
|
30
|
+
flags = 0 if case_sensitive else _re.IGNORECASE
|
|
31
|
+
return _re.compile(_fnmatch.translate(pat), flags)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _collapse_recursive(parts: _ty.Iterable[str]) -> _ty.List[str]:
|
|
35
|
+
"""Drop repeated consecutive "**" (they select the same paths)."""
|
|
36
|
+
collapsed: _ty.List[str] = []
|
|
37
|
+
for part in parts:
|
|
38
|
+
if part == RECURSIVE and collapsed and collapsed[-1] == RECURSIVE:
|
|
39
|
+
continue
|
|
40
|
+
collapsed.append(part)
|
|
41
|
+
return collapsed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fields(segments: _ty.Sequence[str]) -> _ty.List[str]:
|
|
45
|
+
"""`segments` as the fields of the path's "/"-joined text: the root is a
|
|
46
|
+
leading empty field (`["", ""]` for the root alone), the empty path has
|
|
47
|
+
none. Empty and "." segments are not names."""
|
|
48
|
+
rooted = bool(segments) and segments[0] == ""
|
|
49
|
+
names = [s for s in (segments[1:] if rooted else segments) if s and s != "."]
|
|
50
|
+
if rooted:
|
|
51
|
+
return ["", *names] if names else ["", ""]
|
|
52
|
+
return names
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def full_match(segments: _ty.Sequence[str], pattern: str, case_sensitive: bool) -> bool:
|
|
56
|
+
"""Match `segments` against a glob pattern that may contain "**"
|
|
57
|
+
components (pathlib 3.13's PurePath.full_match semantics): a "**" matches
|
|
58
|
+
zero or more segments, except a trailing "**" after other components,
|
|
59
|
+
which needs at least one ("a/**" does not match "a").
|
|
60
|
+
|
|
61
|
+
The root is a field of its own, as in the path's text: a rooted pattern
|
|
62
|
+
names it with its leading "/", a lone "*" never matches it (a lone "*"
|
|
63
|
+
matches one or more characters), and a "**" followed by more components
|
|
64
|
+
reaches it only together with a name after it ("**/x" matches "/a/x",
|
|
65
|
+
not "/x"). Empty and "." components are dropped from the pattern. A
|
|
66
|
+
bracket expression never matches a separator (pathlib's regex lets a
|
|
67
|
+
negated one).
|
|
68
|
+
|
|
69
|
+
Runs as a set-of-states automaton, O(len(segments) * len(pattern)), so
|
|
70
|
+
repeated "**" cannot backtrack exponentially.
|
|
71
|
+
"""
|
|
72
|
+
fields = _fields(segments)
|
|
73
|
+
parts = [part for part in pattern.split("/") if part and part != "."]
|
|
74
|
+
if pattern.startswith("/"):
|
|
75
|
+
parts = ["", *parts] if parts else ["", ""]
|
|
76
|
+
pats = _collapse_recursive(parts)
|
|
77
|
+
end = len(pats)
|
|
78
|
+
last = end - 1
|
|
79
|
+
|
|
80
|
+
# A state is (index, stage). Stage 0: about to match pats[index]. Stage 1 is a "**"
|
|
81
|
+
# that has taken one empty field (the root), which does not count yet; stage 2 has
|
|
82
|
+
# taken enough and may take more or hand over to the next component.
|
|
83
|
+
def closure(states: _ty.Set[_ty.Tuple[int, int]]) -> _ty.Set[_ty.Tuple[int, int]]:
|
|
84
|
+
pending = list(states)
|
|
85
|
+
while pending:
|
|
86
|
+
i, stage = pending.pop()
|
|
87
|
+
if stage == 2 or (
|
|
88
|
+
stage == 0
|
|
89
|
+
and i < end
|
|
90
|
+
and pats[i] == RECURSIVE
|
|
91
|
+
# A non-trailing (or sole) "**" may match no field at all.
|
|
92
|
+
and (i < last or end == 1)
|
|
93
|
+
):
|
|
94
|
+
following = (i + 1, 0)
|
|
95
|
+
if following not in states:
|
|
96
|
+
states.add(following)
|
|
97
|
+
pending.append(following)
|
|
98
|
+
return states
|
|
99
|
+
|
|
100
|
+
states = closure({(0, 0)})
|
|
101
|
+
for field in fields:
|
|
102
|
+
advanced: _ty.Set[_ty.Tuple[int, int]] = set()
|
|
103
|
+
for i, stage in states:
|
|
104
|
+
if stage:
|
|
105
|
+
advanced.add((i, 2))
|
|
106
|
+
elif i == end:
|
|
107
|
+
continue
|
|
108
|
+
elif pats[i] == RECURSIVE:
|
|
109
|
+
advanced.add((i, 1 if field == "" and i < last else 2))
|
|
110
|
+
elif pats[i] == "*":
|
|
111
|
+
if field:
|
|
112
|
+
advanced.add((i + 1, 0))
|
|
113
|
+
elif compile_pattern(pats[i], case_sensitive).match(field):
|
|
114
|
+
advanced.add((i + 1, 0))
|
|
115
|
+
if not advanced:
|
|
116
|
+
return False
|
|
117
|
+
states = closure(advanced)
|
|
118
|
+
return (end, 0) in states
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_pattern(
|
|
122
|
+
pattern: "str | _ty.Any", *, native: bool = True
|
|
123
|
+
) -> _ty.Tuple[_ty.List[str], bool]:
|
|
124
|
+
"""Split a relative glob `pattern` (a `/`-separated string or a
|
|
125
|
+
`Pathname`) into its components, and report whether it ended with a
|
|
126
|
+
separator (directories only).
|
|
127
|
+
|
|
128
|
+
Raises `ValueError` for an empty pattern and `NonRelativePatternError`
|
|
129
|
+
for an absolute one, as `pathlib.Path.glob()` does. Empty and "."
|
|
130
|
+
components are dropped.
|
|
131
|
+
|
|
132
|
+
`native=True` (the default) follows the running interpreter on the two
|
|
133
|
+
rules pathlib changed mid-series: a trailing "/" is ignored before 3.11
|
|
134
|
+
(it selects directories only from 3.11), and a component that merely
|
|
135
|
+
CONTAINS "**" ("a**") raises `ValueError` before 3.13. `native=False`
|
|
136
|
+
applies one rule on every version instead -- a trailing "/" always
|
|
137
|
+
selects directories only, "a**" is always a plain wildcard -- so a
|
|
138
|
+
pattern gives the same answer on every interpreter and every backend.
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(pattern, str):
|
|
141
|
+
rooted = pattern.startswith("/")
|
|
142
|
+
segments = pattern.split("/")
|
|
143
|
+
else:
|
|
144
|
+
segments = list(pattern.segments)
|
|
145
|
+
rooted = bool(getattr(pattern, "anchor", "")) or bool(
|
|
146
|
+
getattr(pattern, "source", None)
|
|
147
|
+
)
|
|
148
|
+
if rooted:
|
|
149
|
+
raise NonRelativePatternError("Non-relative patterns are unsupported")
|
|
150
|
+
parts = [part for part in segments if part not in ("", ".")]
|
|
151
|
+
if not parts:
|
|
152
|
+
raise ValueError(f"Unacceptable pattern: {str(pattern)!r}")
|
|
153
|
+
if native and not _PARTIAL_DOUBLESTAR_ALLOWED:
|
|
154
|
+
for part in parts:
|
|
155
|
+
if RECURSIVE in part and part != RECURSIVE:
|
|
156
|
+
# pathlib's own message, so an `except ValueError` that reads
|
|
157
|
+
# it sees the same text it does on this interpreter.
|
|
158
|
+
raise ValueError(
|
|
159
|
+
"Invalid pattern: '**' can only be an entire path component"
|
|
160
|
+
)
|
|
161
|
+
trailing_sep = bool(segments) and segments[-1] == ""
|
|
162
|
+
if native and not _TRAILING_SLASH_SELECTS_DIRS:
|
|
163
|
+
trailing_sep = False
|
|
164
|
+
return parts, trailing_sep
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The recursive walk behind "**": loop and symlink guards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing as _ty
|
|
6
|
+
|
|
7
|
+
from ._entries import _Globable, _entry_is_dir, _identity, _is_hidden, _scan
|
|
8
|
+
|
|
9
|
+
#: Symlinks one descent may pass through when `recurse_symlinks=True` and the
|
|
10
|
+
#: backend's stat carries no identity to tell a loop by: what the operating
|
|
11
|
+
#: system allows in resolving one path.
|
|
12
|
+
_MAX_LINK_TRAVERSALS = 40
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _recurse(
|
|
16
|
+
top: _Globable,
|
|
17
|
+
include_hidden: bool,
|
|
18
|
+
with_files: bool,
|
|
19
|
+
on_error=None,
|
|
20
|
+
bound_loops: bool = False,
|
|
21
|
+
recurse_symlinks: bool = False,
|
|
22
|
+
) -> _ty.Iterator[_Globable]:
|
|
23
|
+
"""Yield `top` and every directory below it (plus every other entry when
|
|
24
|
+
`with_files`), never descending through a directory symlink unless
|
|
25
|
+
`recurse_symlinks` (see `_follow_guard()`).
|
|
26
|
+
|
|
27
|
+
With `bound_loops`, a directory whose identity (`st_dev`, `st_ino`) is
|
|
28
|
+
already on the CURRENT DESCENT PATH is skipped: that is what a loop is
|
|
29
|
+
-- a directory reachable below itself -- and it bounds a Windows
|
|
30
|
+
junction loop, which no symlink check can see (a junction reports
|
|
31
|
+
`is_symlink() == False`).
|
|
32
|
+
|
|
33
|
+
The ancestor chain is the whole rule, not a set of everything seen. One
|
|
34
|
+
directory deliberately reachable under two SIBLING names (a shared
|
|
35
|
+
config layer junctioned in as `site-a` and `site-b`) is not a loop: the
|
|
36
|
+
walk terminates, and both names must expand. A `visited` set spanning
|
|
37
|
+
the traversal dropped the second one silently. `find -L` draws the line
|
|
38
|
+
in the same place.
|
|
39
|
+
|
|
40
|
+
A backend whose stat has no identity cannot be bounded this way and is
|
|
41
|
+
walked unbounded.
|
|
42
|
+
"""
|
|
43
|
+
yield top
|
|
44
|
+
top_key = _identity(top) if bound_loops else None
|
|
45
|
+
# Each stack entry carries the identities of the directories the walk is
|
|
46
|
+
# currently inside -- pushed on descent, dropped with the branch -- and,
|
|
47
|
+
# for `recurse_symlinks`, the directories themselves and the links passed.
|
|
48
|
+
stack = [(top, (top_key,) if top_key is not None else (), (top,), 0)]
|
|
49
|
+
while stack:
|
|
50
|
+
directory, ancestors, chain, links = stack.pop()
|
|
51
|
+
for child, stat in _scan(directory, on_error):
|
|
52
|
+
if not include_hidden and _is_hidden(child):
|
|
53
|
+
continue
|
|
54
|
+
is_dir = _entry_is_dir(child, stat, follow_symlinks=recurse_symlinks)
|
|
55
|
+
child_ancestors, child_chain, child_links = ancestors, chain, links
|
|
56
|
+
enter = is_dir
|
|
57
|
+
if is_dir:
|
|
58
|
+
if recurse_symlinks:
|
|
59
|
+
# A link that would loop is still an entry, but not entered.
|
|
60
|
+
enter, child_chain, child_links = _follow_guard(
|
|
61
|
+
child, stat, chain, links
|
|
62
|
+
)
|
|
63
|
+
if enter:
|
|
64
|
+
descend, child_ancestors = _loop_guard(
|
|
65
|
+
child, ancestors, bound_loops
|
|
66
|
+
)
|
|
67
|
+
if not descend:
|
|
68
|
+
continue
|
|
69
|
+
if is_dir or with_files:
|
|
70
|
+
yield child
|
|
71
|
+
if enter:
|
|
72
|
+
stack.append((child, child_ancestors, child_chain, child_links))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _recurse_dirs(
|
|
76
|
+
top: _Globable,
|
|
77
|
+
include_hidden: bool,
|
|
78
|
+
on_error=None,
|
|
79
|
+
bound_loops: bool = False,
|
|
80
|
+
recurse_symlinks: bool = False,
|
|
81
|
+
) -> _ty.Iterator[_ty.Tuple[_Globable, _ty.Sequence]]:
|
|
82
|
+
"""`_recurse()` over directories only, as `(directory, its listing)`: the
|
|
83
|
+
caller lists each one next, and gets the listing that found the
|
|
84
|
+
directories below it instead of asking the backend again. Same order,
|
|
85
|
+
same loop rule. A directory waiting its turn keeps its subdirectories,
|
|
86
|
+
not its whole listing."""
|
|
87
|
+
entries = _scan(top, on_error)
|
|
88
|
+
yield top, entries
|
|
89
|
+
top_key = _identity(top) if bound_loops else None
|
|
90
|
+
stack = [
|
|
91
|
+
(
|
|
92
|
+
_subdirectories(entries, include_hidden, recurse_symlinks),
|
|
93
|
+
(top_key,) if top_key is not None else (),
|
|
94
|
+
(top,),
|
|
95
|
+
0,
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
while stack:
|
|
99
|
+
children, ancestors, chain, links = stack.pop()
|
|
100
|
+
for child, stat in children:
|
|
101
|
+
child_chain, child_links = chain, links
|
|
102
|
+
if recurse_symlinks:
|
|
103
|
+
descend, child_chain, child_links = _follow_guard(
|
|
104
|
+
child, stat, chain, links
|
|
105
|
+
)
|
|
106
|
+
if not descend:
|
|
107
|
+
continue
|
|
108
|
+
descend, child_ancestors = _loop_guard(child, ancestors, bound_loops)
|
|
109
|
+
if not descend:
|
|
110
|
+
continue
|
|
111
|
+
entries = _scan(child, on_error)
|
|
112
|
+
yield child, entries
|
|
113
|
+
stack.append(
|
|
114
|
+
(
|
|
115
|
+
_subdirectories(entries, include_hidden, recurse_symlinks),
|
|
116
|
+
child_ancestors,
|
|
117
|
+
child_chain,
|
|
118
|
+
child_links,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _subdirectories(
|
|
124
|
+
entries, include_hidden: bool, recurse_symlinks: bool = False
|
|
125
|
+
) -> "list[tuple[_Globable, _ty.Any]]":
|
|
126
|
+
"""`(child, its listing stat)` for each directory among `entries`; through
|
|
127
|
+
a symlink to a directory only when `recurse_symlinks`."""
|
|
128
|
+
return [
|
|
129
|
+
(child, stat)
|
|
130
|
+
for child, stat in entries
|
|
131
|
+
if (include_hidden or not _is_hidden(child))
|
|
132
|
+
and _entry_is_dir(child, stat, follow_symlinks=recurse_symlinks)
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _follow_guard(child: _Globable, stat, chain: tuple, links: int):
|
|
137
|
+
"""`(descend, chain, links)` for a directory `child` of the last
|
|
138
|
+
directory of `chain` (the directories the descent is inside, the top
|
|
139
|
+
first), `links` of which were entered through a symlink.
|
|
140
|
+
|
|
141
|
+
A directory reached through a symlink is not entered when it is one of
|
|
142
|
+
the directories already in `chain` -- a loop, which would match everything
|
|
143
|
+
under it again and again. Where the stat has no identity (most remote
|
|
144
|
+
backends) a link is not entered after 40 of them. A directory reached
|
|
145
|
+
without a link cannot close a loop by itself and costs no stat."""
|
|
146
|
+
if not _is_link(child, stat):
|
|
147
|
+
return True, chain + (child,), links
|
|
148
|
+
key = _identity(child)
|
|
149
|
+
if key is not None:
|
|
150
|
+
if any(_identity(directory) == key for directory in chain):
|
|
151
|
+
return False, chain, links
|
|
152
|
+
elif links >= _MAX_LINK_TRAVERSALS:
|
|
153
|
+
return False, chain, links
|
|
154
|
+
return True, chain + (child,), links + 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_link(path: _Globable, stat) -> bool:
|
|
158
|
+
"""Whether `path` is a symlink, by the listing's non-following stat when
|
|
159
|
+
there is one."""
|
|
160
|
+
if stat is not None:
|
|
161
|
+
return stat.is_symlink()
|
|
162
|
+
try:
|
|
163
|
+
return path.is_symlink()
|
|
164
|
+
except (OSError, NotImplementedError, ValueError):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _loop_guard(child: _Globable, ancestors: tuple, bound_loops: bool):
|
|
169
|
+
"""`(descend, identities of the directories child is inside)`. A child
|
|
170
|
+
that is one of its own ancestors is neither descended into nor yielded:
|
|
171
|
+
the next pattern component would match everything under it a second
|
|
172
|
+
time, through the loop."""
|
|
173
|
+
if not bound_loops:
|
|
174
|
+
return True, ancestors
|
|
175
|
+
key = _identity(child)
|
|
176
|
+
if key is None:
|
|
177
|
+
return True, ancestors
|
|
178
|
+
if key in ancestors:
|
|
179
|
+
return False, ancestors
|
|
180
|
+
return True, ancestors + (key,)
|