Skip to content

Commit 2e178c2

Browse files
authored
feat(runfiles): implement runfiles.Path io methods (bazel-contrib#3716)
The runfiles.Path class is currently a PurePath subclass, which means it only does string manipulation for constructing paths. This change adds functionality to actually access files. Fixes bazel-contrib#3296
1 parent 1c8ca73 commit 2e178c2

3 files changed

Lines changed: 305 additions & 66 deletions

File tree

python/runfiles/runfiles.py

Lines changed: 175 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@
2929
import posixpath
3030
import sys
3131
from collections import defaultdict
32-
from typing import Dict, List, Optional, Tuple, Union
32+
from typing import Dict, Generator, Iterable, List, Optional, Tuple, Union
33+
34+
if sys.version_info >= (3, 11):
35+
from typing import Self
36+
elif sys.version_info >= (3, 10):
37+
from typing import TypeAlias
38+
39+
Self: TypeAlias = "Path" # type: ignore
40+
else:
41+
from typing import Any as Self
3342

3443

3544
class _RepositoryMapping:
@@ -143,25 +152,30 @@ def is_empty(self) -> bool:
143152
)
144153

145154

146-
class Path(pathlib.PurePath):
155+
class Path(pathlib.Path):
147156
"""A pathlib-like path object for runfiles.
148157
149-
This class extends `pathlib.PurePath` and resolves paths
158+
This class extends `pathlib.Path` and resolves paths
150159
using the associated `Runfiles` instance when converted to a string.
151160
"""
152161

153-
# For Python < 3.12 compatibility when subclassing PurePath directly
154-
_flavour = getattr(type(pathlib.PurePath()), "_flavour", None)
162+
# Mypy isn't smart enough to realize `self` in the methods
163+
# refers to our Path class instead of pathlib.Path
164+
_runfiles: Optional["Runfiles"]
165+
_source_repo: Optional[str]
166+
167+
# For Python < 3.12 compatibility when subclassing Path directly
168+
_flavour = getattr(type(pathlib.Path()), "_flavour", None)
155169

156170
def __new__(
157171
cls,
158172
*args: Union[str, os.PathLike],
159173
runfiles: Optional["Runfiles"] = None,
160174
source_repo: Optional[str] = None,
161-
) -> "Path":
175+
) -> Self:
162176
"""Private constructor. Use Runfiles.root() to create instances."""
163177
obj = super().__new__(cls, *args)
164-
# Type checkers might complain about adding attributes to PurePath,
178+
# Type checkers might complain about adding attributes to Path,
165179
# but this is standard for pathlib subclasses.
166180
obj._runfiles = runfiles # type: ignore
167181
obj._source_repo = source_repo # type: ignore
@@ -173,90 +187,205 @@ def __init__(
173187
runfiles: Optional["Runfiles"] = None,
174188
source_repo: Optional[str] = None,
175189
) -> None:
176-
pass
190+
# In Python 3.12+, pathlib was refactored and Path.__init__ now accepts
191+
# *args. Prior to 3.12, Path did not define __init__, so
192+
# super().__init__(*args) would fall through to object.__init__, which
193+
# raises a TypeError because it takes no arguments.
194+
if sys.version_info >= (3, 12):
195+
super().__init__(*args)
196+
else:
197+
super().__init__()
198+
199+
# We override resolve() and absolute() to ensure that in Python < 3.12,
200+
# where pathlib internally uses object.__new__ instead of our custom
201+
# __new__ or with_segments(), the runfiles state is preserved. We delegate
202+
# to self._as_path() because super().resolve() creates intermediate objects
203+
# that would otherwise crash during internal stat() calls.
204+
# override
205+
def resolve(self, strict: bool = False) -> Self:
206+
return type(self)(
207+
self._as_path().resolve(strict=strict),
208+
runfiles=self._runfiles,
209+
source_repo=self._source_repo,
210+
)
177211

178-
def with_segments(self, *pathsegments: Union[str, os.PathLike]) -> "Path":
212+
# override
213+
def absolute(self) -> Self:
214+
return type(self)(
215+
self._as_path().absolute(),
216+
runfiles=self._runfiles,
217+
source_repo=self._source_repo,
218+
)
219+
220+
# override
221+
def with_segments(self, *pathsegments: Union[str, os.PathLike]) -> Self:
179222
"""Used by Python 3.12+ pathlib to create new path objects."""
180223
return type(self)(
181224
*pathsegments,
182-
runfiles=self._runfiles, # type: ignore
183-
source_repo=self._source_repo, # type: ignore
225+
runfiles=self._runfiles,
226+
source_repo=self._source_repo,
184227
)
185228

186229
# For Python < 3.12
187-
@classmethod
188-
def _from_parts(cls, args: Tuple[str, ...]) -> "Path":
189-
obj = super()._from_parts(args) # type: ignore
190-
# These will be set by the calling instance later, or we can't set them here
191-
# properly without context. Usually pathlib calls this from an instance
192-
# method like _make_child, which we also might need to override.
193-
return obj
194-
195-
def _make_child(self, args: Tuple[str, ...]) -> "Path":
230+
# override
231+
def _make_child(self, args: Tuple[str, ...]) -> Self:
196232
obj = super()._make_child(args) # type: ignore
197233
obj._runfiles = self._runfiles # type: ignore
198234
obj._source_repo = self._source_repo # type: ignore
199235
return obj
200236

201-
@classmethod
202-
def _from_parsed_parts(cls, drv: str, root: str, parts: List[str]) -> "Path":
203-
obj = super()._from_parsed_parts(drv, root, parts) # type: ignore
204-
return obj
205-
206-
def _make_child_relpath(self, part: str) -> "Path":
207-
obj = super()._make_child_relpath(part) # type: ignore
208-
obj._runfiles = self._runfiles # type: ignore
209-
obj._source_repo = self._source_repo # type: ignore
210-
return obj
211-
237+
# override
212238
@property
213-
def parents(self) -> Tuple["Path", ...]:
239+
def parents(self) -> Tuple[Self, ...]:
214240
return tuple(
215241
type(self)(
216242
p,
217-
runfiles=getattr(self, "_runfiles", None),
218-
source_repo=getattr(self, "_source_repo", None),
243+
runfiles=self._runfiles,
244+
source_repo=self._source_repo,
219245
)
220246
for p in super().parents
221247
)
222248

249+
# override
223250
@property
224-
def parent(self) -> "Path":
251+
def parent(self) -> Self:
225252
return type(self)(
226253
super().parent,
227-
runfiles=getattr(self, "_runfiles", None),
228-
source_repo=getattr(self, "_source_repo", None),
254+
runfiles=self._runfiles,
255+
source_repo=self._source_repo,
229256
)
230257

231-
def with_name(self, name: str) -> "Path":
258+
@property
259+
def runfile_path(self) -> str:
260+
"""Returns the runfiles-root relative path."""
261+
path_posix = super().__str__().replace("\\", "/")
262+
if path_posix == ".":
263+
return ""
264+
return path_posix
265+
266+
# override
267+
def with_name(self, name: str) -> Self:
232268
return type(self)(
233269
super().with_name(name),
234-
runfiles=getattr(self, "_runfiles", None),
235-
source_repo=getattr(self, "_source_repo", None),
270+
runfiles=self._runfiles,
271+
source_repo=self._source_repo,
236272
)
237273

238-
def with_suffix(self, suffix: str) -> "Path":
274+
# override
275+
def with_suffix(self, suffix: str) -> Self:
239276
return type(self)(
240277
super().with_suffix(suffix),
241-
runfiles=getattr(self, "_runfiles", None),
242-
source_repo=getattr(self, "_source_repo", None),
278+
runfiles=self._runfiles,
279+
source_repo=self._source_repo,
280+
)
281+
282+
def _as_path(self) -> pathlib.Path:
283+
return pathlib.Path(str(self))
284+
285+
# override
286+
def stat(self, *, follow_symlinks: bool = True) -> os.stat_result:
287+
return self._as_path().stat(follow_symlinks=follow_symlinks)
288+
289+
# override
290+
def lstat(self) -> os.stat_result:
291+
return self._as_path().lstat()
292+
293+
# override
294+
def exists(self) -> bool:
295+
return self._as_path().exists()
296+
297+
# override
298+
def is_dir(self) -> bool:
299+
return self._as_path().is_dir()
300+
301+
# override
302+
def is_file(self) -> bool:
303+
return self._as_path().is_file()
304+
305+
# override
306+
def is_symlink(self) -> bool:
307+
return self._as_path().is_symlink()
308+
309+
# override
310+
def is_block_device(self) -> bool:
311+
return self._as_path().is_block_device()
312+
313+
# override
314+
def is_char_device(self) -> bool:
315+
return self._as_path().is_char_device()
316+
317+
# override
318+
def is_fifo(self) -> bool:
319+
return self._as_path().is_fifo()
320+
321+
# override
322+
def is_socket(self) -> bool:
323+
return self._as_path().is_socket()
324+
325+
# override
326+
def open(
327+
self,
328+
mode: str = "r",
329+
buffering: int = -1,
330+
encoding: Optional[str] = None,
331+
errors: Optional[str] = None,
332+
newline: Optional[str] = None,
333+
):
334+
return self._as_path().open(
335+
mode=mode,
336+
buffering=buffering,
337+
encoding=encoding,
338+
errors=errors,
339+
newline=newline,
243340
)
244341

342+
# override
343+
def read_bytes(self) -> bytes:
344+
return self._as_path().read_bytes()
345+
346+
# override
347+
def read_text(
348+
self, encoding: Optional[str] = None, errors: Optional[str] = None
349+
) -> str:
350+
return self._as_path().read_text(encoding=encoding, errors=errors)
351+
352+
# override
353+
def iterdir(self) -> Generator[Self, None, None]:
354+
resolved = self._as_path()
355+
for p in resolved.iterdir():
356+
yield self / p.name
357+
358+
# override
359+
def glob(self, pattern: str) -> Generator[Self, None, None]:
360+
resolved = self._as_path()
361+
for p in resolved.glob(pattern):
362+
yield self / p.relative_to(resolved)
363+
364+
# override
365+
def rglob(self, pattern: str) -> Generator[Self, None, None]:
366+
resolved = self._as_path()
367+
for p in resolved.rglob(pattern):
368+
yield self / p.relative_to(resolved)
369+
245370
def __repr__(self) -> str:
246-
return 'runfiles.Path({!r})'.format(super().__str__())
371+
return "runfiles.Path({!r})".format(self.runfile_path)
247372

248373
def __str__(self) -> str:
249374
path_posix = super().__str__().replace("\\", "/")
250375
if not path_posix or path_posix == ".":
251376
# pylint: disable=protected-access
252377
return self._runfiles._python_runfiles_root # type: ignore
253378
resolved = self._runfiles.Rlocation(path_posix, source_repo=self._source_repo) # type: ignore
254-
return resolved if resolved is not None else super().__str__()
379+
if resolved is not None:
380+
return resolved
381+
382+
# pylint: disable=protected-access
383+
return posixpath.join(self._runfiles._python_runfiles_root, path_posix) # type: ignore
255384

256385
def __fspath__(self) -> str:
257386
return str(self)
258387

259-
def runfiles_root(self) -> "Path":
388+
def runfiles_root(self) -> Self:
260389
"""Returns a Path object representing the runfiles root."""
261390
return self._runfiles.root(source_repo=self._source_repo) # type: ignore
262391

tests/runfiles/BUILD.bazel

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,34 @@ py_test(
1414
deps = ["//python/runfiles"],
1515
)
1616

17+
py_test(
18+
name = "runfiles_min_python_test",
19+
srcs = ["runfiles_test.py"],
20+
data = [
21+
"//tests/support:current_build_settings",
22+
],
23+
env = {
24+
"BZLMOD_ENABLED": "1" if BZLMOD_ENABLED else "0",
25+
},
26+
main = "runfiles_test.py",
27+
python_version = "3.10",
28+
deps = ["//python/runfiles"],
29+
)
30+
1731
py_test(
1832
name = "pathlib_test",
1933
srcs = ["pathlib_test.py"],
2034
deps = ["//python/runfiles"],
2135
)
2236

37+
py_test(
38+
name = "pathlib_min_python_test",
39+
srcs = ["pathlib_test.py"],
40+
main = "pathlib_test.py",
41+
python_version = "3.10",
42+
deps = ["//python/runfiles"],
43+
)
44+
2345
build_test(
2446
name = "publishing",
2547
targets = [

0 commit comments

Comments
 (0)