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.
Files changed (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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,)