Skip to content

Commit 7369341

Browse files
feat(runfiles): create pathlib api for runfiles library (#3694)
This adds a pathlib.Path-based API for interacting with runfiles. This makes it easier to interact with runfiles. Fixes #3296 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 6e72de2 commit 7369341

File tree

4 files changed

+270
-9
lines changed

4 files changed

+270
-9
lines changed

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@ BEGIN_UNRELEASED_TEMPLATE
4747
END_UNRELEASED_TEMPLATE
4848
-->
4949

50+
{#v0-0-0}
51+
## Unreleased
52+
53+
[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
54+
55+
{#v0-0-0-removed}
56+
### Removed
57+
* Nothing removed.
58+
59+
{#v0-0-0-changed}
60+
### Changed
61+
* Nothing changed.
62+
63+
{#v0-0-0-fixed}
64+
### Fixed
65+
* Nothing fixed.
66+
67+
{#v0-0-0-added}
68+
### Added
69+
* (runfiles) Added a pathlib-compatible API: {obj}`Runfiles.root()`
70+
Fixes [#3296](https://github.com/bazel-contrib/rules_python/issues/3296).
71+
5072
{#v2-0-0}
5173
## [2.0.0] - 2026-04-09
5274

python/runfiles/runfiles.py

Lines changed: 137 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@
2525
import collections.abc
2626
import inspect
2727
import os
28+
import pathlib
2829
import posixpath
2930
import sys
3031
from collections import defaultdict
31-
from typing import Dict, Optional, Tuple, Union
32+
from typing import Dict, List, Optional, Tuple, Union
3233

3334

3435
class _RepositoryMapping:
@@ -137,7 +138,127 @@ def is_empty(self) -> bool:
137138
Returns:
138139
True if there are no mappings, False otherwise
139140
"""
140-
return len(self._exact_mappings) == 0 and len(self._grouped_prefixed_mappings) == 0
141+
return (
142+
len(self._exact_mappings) == 0 and len(self._grouped_prefixed_mappings) == 0
143+
)
144+
145+
146+
class Path(pathlib.PurePath):
147+
"""A pathlib-like path object for runfiles.
148+
149+
This class extends `pathlib.PurePath` and resolves paths
150+
using the associated `Runfiles` instance when converted to a string.
151+
"""
152+
153+
# For Python < 3.12 compatibility when subclassing PurePath directly
154+
_flavour = getattr(type(pathlib.PurePath()), "_flavour", None)
155+
156+
def __new__(
157+
cls,
158+
*args: Union[str, os.PathLike],
159+
runfiles: Optional["Runfiles"] = None,
160+
source_repo: Optional[str] = None,
161+
) -> "Path":
162+
"""Private constructor. Use Runfiles.root() to create instances."""
163+
obj = super().__new__(cls, *args)
164+
# Type checkers might complain about adding attributes to PurePath,
165+
# but this is standard for pathlib subclasses.
166+
obj._runfiles = runfiles # type: ignore
167+
obj._source_repo = source_repo # type: ignore
168+
return obj
169+
170+
def __init__(
171+
self,
172+
*args: Union[str, os.PathLike],
173+
runfiles: Optional["Runfiles"] = None,
174+
source_repo: Optional[str] = None,
175+
) -> None:
176+
pass
177+
178+
def with_segments(self, *pathsegments: Union[str, os.PathLike]) -> "Path":
179+
"""Used by Python 3.12+ pathlib to create new path objects."""
180+
return type(self)(
181+
*pathsegments,
182+
runfiles=self._runfiles, # type: ignore
183+
source_repo=self._source_repo, # type: ignore
184+
)
185+
186+
# 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":
196+
obj = super()._make_child(args) # type: ignore
197+
obj._runfiles = self._runfiles # type: ignore
198+
obj._source_repo = self._source_repo # type: ignore
199+
return obj
200+
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+
212+
@property
213+
def parents(self) -> Tuple["Path", ...]:
214+
return tuple(
215+
type(self)(
216+
p,
217+
runfiles=getattr(self, "_runfiles", None),
218+
source_repo=getattr(self, "_source_repo", None),
219+
)
220+
for p in super().parents
221+
)
222+
223+
@property
224+
def parent(self) -> "Path":
225+
return type(self)(
226+
super().parent,
227+
runfiles=getattr(self, "_runfiles", None),
228+
source_repo=getattr(self, "_source_repo", None),
229+
)
230+
231+
def with_name(self, name: str) -> "Path":
232+
return type(self)(
233+
super().with_name(name),
234+
runfiles=getattr(self, "_runfiles", None),
235+
source_repo=getattr(self, "_source_repo", None),
236+
)
237+
238+
def with_suffix(self, suffix: str) -> "Path":
239+
return type(self)(
240+
super().with_suffix(suffix),
241+
runfiles=getattr(self, "_runfiles", None),
242+
source_repo=getattr(self, "_source_repo", None),
243+
)
244+
245+
def __repr__(self) -> str:
246+
return 'runfiles.Path({!r})'.format(super().__str__())
247+
248+
def __str__(self) -> str:
249+
path_posix = super().__str__().replace("\\", "/")
250+
if not path_posix or path_posix == ".":
251+
# pylint: disable=protected-access
252+
return self._runfiles._python_runfiles_root # type: ignore
253+
resolved = self._runfiles.Rlocation(path_posix, source_repo=self._source_repo) # type: ignore
254+
return resolved if resolved is not None else super().__str__()
255+
256+
def __fspath__(self) -> str:
257+
return str(self)
258+
259+
def runfiles_root(self) -> "Path":
260+
"""Returns a Path object representing the runfiles root."""
261+
return self._runfiles.root(source_repo=self._source_repo) # type: ignore
141262

142263

143264
class _ManifestBased:
@@ -254,6 +375,16 @@ def __init__(self, strategy: Union[_ManifestBased, _DirectoryBased]) -> None:
254375
strategy.RlocationChecked("_repo_mapping")
255376
)
256377

378+
def root(self, source_repo: Optional[str] = None) -> Path:
379+
"""Returns a Path object representing the runfiles root.
380+
381+
The repository mapping used by the returned Path object is that of the
382+
caller of this method.
383+
"""
384+
if source_repo is None and not self._repo_mapping.is_empty():
385+
source_repo = self.CurrentRepository(frame=2)
386+
return Path(runfiles=self, source_repo=source_repo)
387+
257388
def Rlocation(self, path: str, source_repo: Optional[str] = None) -> Optional[str]:
258389
"""Returns the runtime path of a runfile.
259390
@@ -325,9 +456,7 @@ def Rlocation(self, path: str, source_repo: Optional[str] = None) -> Optional[st
325456

326457
# Look up the target repository using the repository mapping
327458
if target_canonical is not None:
328-
return self._strategy.RlocationChecked(
329-
target_canonical + "/" + remainder
330-
)
459+
return self._strategy.RlocationChecked(target_canonical + "/" + remainder)
331460

332461
# No mapping found - assume target_repo is already canonical or
333462
# we're not using Bzlmod
@@ -396,10 +525,9 @@ def CurrentRepository(self, frame: int = 1) -> str:
396525
# TODO: This doesn't cover the case of a script being run from an
397526
# external repository, which could be heuristically detected
398527
# by parsing the script's path.
399-
if (
400-
(sys.version_info.minor <= 10 or sys.platform == "win32")
401-
and sys.path[0] != self._python_runfiles_root
402-
):
528+
if (sys.version_info.minor <= 10 or sys.platform == "win32") and sys.path[
529+
0
530+
] != self._python_runfiles_root:
403531
return ""
404532
raise ValueError(
405533
"{} does not lie under the runfiles root {}".format(

tests/runfiles/BUILD.bazel

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

17+
py_test(
18+
name = "pathlib_test",
19+
srcs = ["pathlib_test.py"],
20+
deps = ["//python/runfiles"],
21+
)
22+
1723
build_test(
1824
name = "publishing",
1925
targets = [

tests/runfiles/pathlib_test.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import os
2+
import pathlib
3+
import tempfile
4+
import unittest
5+
6+
from python.runfiles import runfiles
7+
8+
9+
class PathlibTest(unittest.TestCase):
10+
def setUp(self) -> None:
11+
self.tmpdir = tempfile.TemporaryDirectory(dir=os.environ.get("TEST_TMPDIR"))
12+
# Runfiles paths are expected to be posix paths internally when we construct the strings for assertions
13+
self.root_dir = pathlib.Path(self.tmpdir.name).as_posix()
14+
15+
def tearDown(self) -> None:
16+
self.tmpdir.cleanup()
17+
18+
def test_path_api(self) -> None:
19+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
20+
assert r is not None
21+
root = r.root()
22+
23+
# Test basic joining
24+
p = root / "repo/pkg/file.txt"
25+
self.assertEqual(str(p), f"{self.root_dir}/repo/pkg/file.txt")
26+
27+
# Test PurePath API
28+
self.assertEqual(p.name, "file.txt")
29+
self.assertEqual(p.suffix, ".txt")
30+
self.assertEqual(p.parent.name, "pkg")
31+
self.assertEqual(p.parts, ("repo", "pkg", "file.txt"))
32+
self.assertEqual(p.stem, "file")
33+
self.assertEqual(p.suffixes, [".txt"])
34+
35+
# Test multiple joins
36+
p2 = root / "repo" / "pkg" / "file.txt"
37+
self.assertEqual(p, p2)
38+
39+
# Test joins with pathlib objects
40+
p3 = root / pathlib.PurePath("repo/pkg/file.txt")
41+
self.assertEqual(p, p3)
42+
43+
def test_root(self) -> None:
44+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
45+
assert r is not None
46+
self.assertEqual(str(r.root()), self.root_dir)
47+
48+
def test_runfiles_root_method(self) -> None:
49+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
50+
assert r is not None
51+
p = r.root() / "foo/bar"
52+
self.assertEqual(p.runfiles_root(), r.root())
53+
self.assertEqual(str(p.runfiles_root()), self.root_dir)
54+
55+
def test_os_path_like(self) -> None:
56+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
57+
assert r is not None
58+
p = r.root() / "foo"
59+
self.assertEqual(os.fspath(p), f"{self.root_dir}/foo")
60+
61+
def test_equality_and_hash(self) -> None:
62+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
63+
assert r is not None
64+
p1 = r.root() / "foo"
65+
p2 = r.root() / "foo"
66+
p3 = r.root() / "bar"
67+
68+
self.assertEqual(p1, p2)
69+
self.assertNotEqual(p1, p3)
70+
self.assertEqual(hash(p1), hash(p2))
71+
72+
def test_join_path(self) -> None:
73+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
74+
assert r is not None
75+
p = r.root().joinpath("repo", "file")
76+
self.assertEqual(str(p), f"{self.root_dir}/repo/file")
77+
78+
def test_parents(self) -> None:
79+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
80+
assert r is not None
81+
p = r.root() / "a/b/c"
82+
parents = list(p.parents)
83+
self.assertEqual(len(parents), 3)
84+
self.assertEqual(str(parents[0]), f"{self.root_dir}/a/b")
85+
self.assertEqual(str(parents[1]), f"{self.root_dir}/a")
86+
self.assertEqual(str(parents[2]), self.root_dir)
87+
88+
def test_with_methods(self) -> None:
89+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
90+
assert r is not None
91+
p = r.root() / "foo/bar.txt"
92+
self.assertEqual(str(p.with_name("baz.py")), f"{self.root_dir}/foo/baz.py")
93+
self.assertEqual(str(p.with_suffix(".dat")), f"{self.root_dir}/foo/bar.dat")
94+
95+
def test_match(self) -> None:
96+
r = runfiles.Create({"RUNFILES_DIR": self.root_dir})
97+
assert r is not None
98+
p = r.root() / "foo/bar.txt"
99+
self.assertTrue(p.match("*.txt"))
100+
self.assertTrue(p.match("foo/*.txt"))
101+
self.assertFalse(p.match("bar/*.txt"))
102+
103+
104+
if __name__ == "__main__":
105+
unittest.main()

0 commit comments

Comments
 (0)