fastglob 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.
fastglob/__init__.py ADDED
@@ -0,0 +1,347 @@
1
+ """fastglob — Python-compatibility wrapper around the fastglob engine.
2
+
3
+ This is a SEPARATE package: it never shadows the stdlib ``glob`` module
4
+ (``import glob`` still gives you the standard library). Usage:
5
+
6
+ import fastglob
7
+ fastglob.glob("*.py", recursive=True, include_hidden=False)
8
+ fastglob.iglob("a/**/b.txt", recursive=True)
9
+ fastglob.escape("a*b") -> "a[*]b"
10
+ fastglob.escape(b"a*b") -> b"a[*]b" # bytes pattern -> bytes result
11
+
12
+ Documented mechanics (keep these in mind when using in hot loops):
13
+
14
+ * Every call SHELLS OUT to the ``fastglob`` binary (one process per call).
15
+ The engine does all traversal/matching natively; this package only
16
+ marshals arguments and decodes NUL-delimited, byte-exact output.
17
+ * The binary is located via ``$FASTGLOB_BIN`` if set, otherwise
18
+ ``<repo>/src/target/release/fastglob`` (build it with ``make build``).
19
+ * ``dir_fd`` is passed to the child by dup()ing the fd with CLOEXEC
20
+ cleared and passing ``--dir-fd N`` plus ``pass_fds`` (PEP 446: fds from
21
+ ``os.open`` are close-on-exec and ``subprocess`` closes fds >= 3 in the
22
+ child unless listed in ``pass_fds``).
23
+ * Result TYPE follows the PATTERN type (stdlib parity — VERIFIED against
24
+ CPython 3.12: ``glob.escape(b'a*b') == b'a[*]b'``, ``glob(b'*.py')``
25
+ yields bytes elements; the reference branches on
26
+ ``isinstance(pathname, bytes)``): str/PathLike patterns give str results
27
+ decoded via ``os.fsdecode`` (surrogateescape), so arbitrary byte
28
+ filenames round-trip exactly; bytes patterns give raw bytes results —
29
+ the engine output is already byte-exact, so bytes mode simply skips
30
+ decoding (no lossy round-trip).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import fcntl
36
+ import os
37
+ import subprocess
38
+ from pathlib import Path
39
+ from typing import Iterator, cast, overload
40
+
41
+ __all__ = ["escape", "glob", "iglob"]
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ # Filesystem path arguments: str, bytes, or a PathLike whose ``__fspath__``
46
+ # returns str or bytes (stdlib parity — CPython glob accepts all three).
47
+ # Bare ``os.PathLike`` would be ``os.PathLike[Any]`` (implicit Any), which
48
+ # mypy --strict (disallow_any_generics) rejects.
49
+ _PathArg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
50
+
51
+
52
+ def _bin() -> str:
53
+ b = os.environ.get("FASTGLOB_BIN")
54
+ if b:
55
+ return b
56
+ repo = Path(__file__).resolve().parent.parent.parent
57
+ return str(repo / "src" / "target" / "release" / "fastglob")
58
+
59
+
60
+ def _fs(x: _PathArg) -> bytes:
61
+ return os.fsencode(x)
62
+
63
+
64
+ def _wants_bytes(x: _PathArg) -> bool:
65
+ """True when results for path argument ``x`` must be bytes-typed.
66
+
67
+ Mirrors the reference implementation's type preservation (CPython
68
+ Lib/glob.py branches on ``isinstance(pathname, bytes)``; VERIFIED live
69
+ on CPython 3.12: ``glob.escape(b'a*b') == b'a[*]b'``, ``glob(b'*.py')``
70
+ yields bytes elements): plain bytes/bytearray follow bytes; a PathLike
71
+ follows whatever its ``__fspath__`` returns, exactly like ``os.fspath``
72
+ typing. str (and str-fspath PathLike) -> False.
73
+ """
74
+ if isinstance(x, (bytes, bytearray)):
75
+ return True
76
+ if hasattr(x, "__fspath__"):
77
+ return isinstance(os.fspath(x), bytes)
78
+ return False
79
+
80
+
81
+ def _run(args: list[bytes], pass_fds: tuple[int, ...] = ()) -> bytes:
82
+ p = subprocess.run(
83
+ [_bin().encode(), *args],
84
+ capture_output=True,
85
+ pass_fds=pass_fds,
86
+ )
87
+ if p.returncode != 0:
88
+ msg = p.stderr.decode("utf-8", "replace").strip()
89
+ raise RuntimeError(f"fastglob exited {p.returncode}: {msg}")
90
+ return p.stdout
91
+
92
+
93
+ def _paths(out: bytes, *, as_bytes: bool = False) -> list[str] | list[bytes]:
94
+ """NUL-split byte-exact engine output into a result list.
95
+
96
+ Inputs:
97
+ out: raw engine stdout (records separated by NUL)
98
+ as_bytes: False (default) -> decode each record via ``os.fsdecode``
99
+ (surrogateescape), returning List[str]; True -> return the raw
100
+ records as List[bytes]. Used when the caller's pattern was
101
+ bytes so results preserve the input type (Ct38): no decoding,
102
+ no lossy round-trip.
103
+ Output:
104
+ List[str] or List[bytes] — one element per engine record
105
+ Errors:
106
+ None (pure split/decode)
107
+ """
108
+ parts = out.split(b"\0")
109
+ if parts and parts[-1] == b"":
110
+ parts = parts[:-1]
111
+ if as_bytes:
112
+ return list(parts)
113
+ return [os.fsdecode(x) for x in parts]
114
+
115
+
116
+ def _call(
117
+ pathname: _PathArg,
118
+ root_dir: _PathArg | None,
119
+ dir_fd: int | None,
120
+ recursive: bool,
121
+ include_hidden: bool,
122
+ as_bytes: bool = False,
123
+ ) -> list[str] | list[bytes]:
124
+ """Execute the fastglob engine with the given pattern and options.
125
+
126
+ Inputs:
127
+ pathname: glob pattern as str/bytes/PathLike. The RESULT type follows
128
+ the PATTERN type (Ct38): bytes pattern -> List[bytes] (raw engine
129
+ records); str or PathLike -> List[str] via fsdecode.
130
+ root_dir: optional filesystem origin shift (None = cwd). Its type is
131
+ independent of the result type — e.g. root_dir=str with a bytes
132
+ pattern is supported and yields bytes results.
133
+ dir_fd: optional open directory fd to resolve relative patterns against
134
+ recursive: when True, ``**`` matches zero or more directories
135
+ include_hidden: when True, ``*``/``?``/``**`` match dot-prefixed names
136
+ Output:
137
+ Matching pathnames as List[str] or List[bytes], duplicates preserved,
138
+ order unspecified
139
+ Errors:
140
+ RuntimeError if the engine exits non-zero (misuse -> exit 2)
141
+ TypeError/ValueError if pathname/root_dir is not str/bytes/PathLike,
142
+ or dir_fd is not a valid open int fd
143
+ """
144
+ want_bytes = _wants_bytes(pathname)
145
+ args: list[bytes] = []
146
+ if recursive:
147
+ args.append(b"--recursive")
148
+ if include_hidden:
149
+ args.append(b"--include-hidden")
150
+ if root_dir is not None:
151
+ args += [b"--root-dir", _fs(root_dir)]
152
+ args += [b"--null", b"--", _fs(pathname)]
153
+
154
+ if dir_fd is not None:
155
+ # Validate dir_fd type and value before use (Level A contract).
156
+ if not isinstance(dir_fd, int):
157
+ raise TypeError(f"dir_fd must be int, got {type(dir_fd).__name__}")
158
+ if dir_fd < 0:
159
+ raise ValueError(f"dir_fd must be non-negative, got {dir_fd}")
160
+ # Validate that dir_fd is an open directory fd (fstat).
161
+ try:
162
+ os.fstat(dir_fd)
163
+ except OSError as e:
164
+ raise ValueError(f"dir_fd {dir_fd} is not a valid open fd: {e}") from e
165
+ # Atomic inheritable dup: prefer F_DUPFD (CLOEXEC cleared) which is
166
+ # atomic; fallback to dup()+set_inheritable which is two syscalls but
167
+ # uses the Python-level atomic helper (PEP 446). The old dup()+fcntl
168
+ # F_SETFD clearing was non-atomic (race window between dup and fcntl).
169
+ try:
170
+ # Use F_DUPFD to atomically dup with CLOEXEC cleared (new fd >=3).
171
+ d = fcntl.fcntl(dir_fd, fcntl.F_DUPFD, 3)
172
+ # Ensure inheritable (CLOEXEC cleared) — F_DUPFD already clears,
173
+ # but explicitly set for portability.
174
+ os.set_inheritable(d, True)
175
+ except (OSError, AttributeError):
176
+ d = os.dup(dir_fd)
177
+ try:
178
+ os.set_inheritable(d, True)
179
+ except OSError:
180
+ os.close(d)
181
+ raise
182
+ try:
183
+ args += [b"--dir-fd", str(d).encode()]
184
+ out = _run(args, pass_fds=(d,))
185
+ finally:
186
+ os.close(d)
187
+ else:
188
+ out = _run(args)
189
+ return _paths(out, as_bytes=want_bytes)
190
+
191
+
192
+ @overload
193
+ def glob(
194
+ pathname: str | os.PathLike[str],
195
+ *,
196
+ root_dir: _PathArg | None = None,
197
+ dir_fd: int | None = None,
198
+ recursive: bool = False,
199
+ include_hidden: bool = False,
200
+ ) -> list[str]: ...
201
+
202
+
203
+ @overload
204
+ def glob(
205
+ pathname: bytes | os.PathLike[bytes],
206
+ *,
207
+ root_dir: _PathArg | None = None,
208
+ dir_fd: int | None = None,
209
+ recursive: bool = False,
210
+ include_hidden: bool = False,
211
+ ) -> list[bytes]: ...
212
+
213
+
214
+ def glob(
215
+ pathname: _PathArg,
216
+ *,
217
+ root_dir: _PathArg | None = None,
218
+ dir_fd: int | None = None,
219
+ recursive: bool = False,
220
+ include_hidden: bool = False,
221
+ ) -> list[str] | list[bytes]:
222
+ """Return a list of paths matching ``pathname`` (stdlib-glob kwargs).
223
+
224
+ Type contract (stdlib parity — VERIFIED against CPython 3.12:
225
+ ``glob.glob(b'*.py')`` yields bytes elements):
226
+ str or PathLike pattern -> List[str] (fsdecode/surrogateescape)
227
+ bytes pattern -> List[bytes] (raw engine bytes)
228
+
229
+ Example (result type follows the PATTERN type; root_dir type is
230
+ independent — only the pattern decides str-vs-bytes)::
231
+
232
+ fastglob.glob("*.py", root_dir="src") # ['x.py', ...] (str)
233
+ fastglob.glob(b"*.py", root_dir=b"src") # [b'x.py', ...] (bytes)
234
+ fastglob.glob(b"*", root_dir="/tmp") # [b'file', ...] (bytes)
235
+
236
+ Not supported (raises TypeError, like the stdlib): patterns that are
237
+ neither str, bytes, nor PathLike.
238
+
239
+ Inputs:
240
+ pathname: pattern (str/bytes/PathLike)
241
+ root_dir: optional root directory shift (str/bytes/PathLike or None)
242
+ dir_fd: optional directory fd (int) for relative resolution
243
+ recursive: enable ``**`` zero-or-more-dirs matching
244
+ include_hidden: allow ``*``/``?``/``**`` to match dotfiles
245
+ Output:
246
+ List[str] or List[bytes] — matching pathnames (duplicates preserved,
247
+ order unspecified)
248
+ Errors:
249
+ Propagates RuntimeError/TypeError/ValueError from ``_call``
250
+ """
251
+ return _call(
252
+ pathname,
253
+ root_dir,
254
+ dir_fd,
255
+ recursive,
256
+ include_hidden,
257
+ as_bytes=_wants_bytes(pathname),
258
+ )
259
+
260
+
261
+ @overload
262
+ def iglob(
263
+ pathname: str | os.PathLike[str],
264
+ *,
265
+ root_dir: _PathArg | None = None,
266
+ dir_fd: int | None = None,
267
+ recursive: bool = False,
268
+ include_hidden: bool = False,
269
+ ) -> Iterator[str]: ...
270
+
271
+
272
+ @overload
273
+ def iglob(
274
+ pathname: bytes | os.PathLike[bytes],
275
+ *,
276
+ root_dir: _PathArg | None = None,
277
+ dir_fd: int | None = None,
278
+ recursive: bool = False,
279
+ include_hidden: bool = False,
280
+ ) -> Iterator[bytes]: ...
281
+
282
+
283
+ def iglob(
284
+ pathname: _PathArg,
285
+ *,
286
+ root_dir: _PathArg | None = None,
287
+ dir_fd: int | None = None,
288
+ recursive: bool = False,
289
+ include_hidden: bool = False,
290
+ ) -> Iterator[str] | Iterator[bytes]:
291
+ """Yield paths matching ``pathname`` (one binary call per iglob).
292
+
293
+ Same type contract as ``glob``: str/PathLike pattern yields str items,
294
+ bytes pattern yields bytes items (stdlib parity, VERIFIED on CPython
295
+ 3.12). Example::
296
+
297
+ next(fastglob.iglob(b"*")) # -> bytes
298
+ next(fastglob.iglob("*")) # -> str
299
+
300
+ Inputs: same as ``glob``.
301
+ Output: Iterator[str] or Iterator[bytes] — lazily yields each match
302
+ (materialized via one engine call)
303
+ Errors: same as ``glob``
304
+ """
305
+ items = _call(
306
+ pathname,
307
+ root_dir,
308
+ dir_fd,
309
+ recursive,
310
+ include_hidden,
311
+ as_bytes=_wants_bytes(pathname),
312
+ )
313
+ # _call's union return erases the per-element type; the runtime contract
314
+ # (Ct38, test-verified) is that items is all-str or all-bytes.
315
+ return cast("Iterator[str] | Iterator[bytes]", (p for p in items))
316
+
317
+
318
+ @overload
319
+ def escape(pathname: str | os.PathLike[str]) -> str: ...
320
+
321
+
322
+ @overload
323
+ def escape(pathname: bytes | os.PathLike[bytes]) -> bytes: ...
324
+
325
+
326
+ def escape(pathname: _PathArg) -> str | bytes:
327
+ """Escape all glob special characters (port of ``glob.escape``).
328
+
329
+ Type contract (stdlib parity — VERIFIED against CPython 3.12:
330
+ ``glob.escape(b'a*b') == b'a[*]b'``):
331
+
332
+ >>> escape('a*b')
333
+ 'a[*]b'
334
+ >>> escape(b'a*b')
335
+ b'a[*]b'
336
+
337
+ PathLike input follows its ``__fspath__`` typing (str-fspath -> str
338
+ result, bytes-fspath -> bytes result); anything else raises TypeError.
339
+ Escaping always operates on the engine's byte-exact output: bytes
340
+ results are returned raw (no lossy round-trip); str results are decoded
341
+ via fsdecode (surrogateescape).
342
+ """
343
+ out = _run([b"escape", b"--null", b"--", _fs(pathname)])
344
+ first = out.split(b"\0")[0]
345
+ if _wants_bytes(pathname):
346
+ return first
347
+ return os.fsdecode(first)
fastglob/py.typed ADDED
File without changes
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.5
2
+ Name: fastglob
3
+ Version: 0.1.0
4
+ Summary: Python compatibility wrapper around the fastglob engine
5
+ Project-URL: Repository, https://github.com/moeshawky/fastglob
6
+ Author-email: Moe Shawky <moe@libermoe.com>
7
+ Maintainer-email: Moe Shawky <moe@libermoe.com>
8
+ License-Expression: MIT
9
+ Keywords: filesystem,glob,linux,pathname,pattern-matching
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.8
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy>=1.4; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
20
+ Requires-Dist: pytest>=8; extra == 'dev'
21
+ Requires-Dist: ruff>=0.15; extra == 'dev'
@@ -0,0 +1,5 @@
1
+ fastglob/__init__.py,sha256=IwycW5oZ2PrZHt_2XBQA8dC9JXhuDtFOKwOl8VPAR7Q,12256
2
+ fastglob/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ fastglob-0.1.0.dist-info/METADATA,sha256=kguIvnBGFBDPFfcfjqufoUIAzz7ZKyTGB--_gQZOgdE,837
4
+ fastglob-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ fastglob-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any