From 35530d8930b43cdfba61632d729acc573f4d92ef Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 11 Apr 2026 00:53:32 -0700 Subject: [PATCH 1/5] basic impl and tests --- CHANGELOG.md | 8 ++ python/runfiles/runfiles.py | 146 +++++++++++++++++++++++++++++++-- tests/runfiles/BUILD.bazel | 6 ++ tests/runfiles/pathlib_test.py | 105 ++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 tests/runfiles/pathlib_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b8356f00..8a774063a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,14 @@ BEGIN_UNRELEASED_TEMPLATE END_UNRELEASED_TEMPLATE --> +## Unreleased + +### Added +* (runfiles) Added a {obj}`Path` class that implements the `pathlib.PurePath` API + for idiomatic path manipulation of runfiles. It is created by calling the + {obj}`Runfiles.root` instance method. The {obj}`Path` class also has a + {obj}`Path.runfiles_root` method to return the root of the runfiles tree. + {#v2-0-0} ## [2.0.0] - 2026-04-09 diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index f98646b1c2..34616f16fa 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -25,10 +25,11 @@ import collections.abc import inspect import os +import pathlib import posixpath import sys from collections import defaultdict -from typing import Dict, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union class _RepositoryMapping: @@ -137,7 +138,127 @@ def is_empty(self) -> bool: Returns: True if there are no mappings, False otherwise """ - return len(self._exact_mappings) == 0 and len(self._grouped_prefixed_mappings) == 0 + return ( + len(self._exact_mappings) == 0 and len(self._grouped_prefixed_mappings) == 0 + ) + + +class Path(pathlib.PurePath): + """A pathlib-like path object for runfiles. + + This class extends `pathlib.PurePath` and resolves paths + using the associated `Runfiles` instance when converted to a string. + """ + + # For Python < 3.12 compatibility when subclassing PurePath directly + _flavour = getattr(type(pathlib.PurePath()), "_flavour", None) + + def __new__( + cls, + *args: Union[str, os.PathLike], + runfiles: Optional["Runfiles"] = None, + source_repo: Optional[str] = None, + ) -> "Path": + """Private constructor. Use Runfiles.root() to create instances.""" + obj = super().__new__(cls, *args) + # Type checkers might complain about adding attributes to PurePath, + # but this is standard for pathlib subclasses. + obj._runfiles = runfiles # type: ignore + obj._source_repo = source_repo # type: ignore + return obj + + def __init__( + self, + *args: Union[str, os.PathLike], + runfiles: Optional["Runfiles"] = None, + source_repo: Optional[str] = None, + ) -> None: + pass + + def with_segments(self, *pathsegments: Union[str, os.PathLike]) -> "Path": + """Used by Python 3.12+ pathlib to create new path objects.""" + return type(self)( + *pathsegments, + runfiles=self._runfiles, # type: ignore + source_repo=self._source_repo, # type: ignore + ) + + # For Python < 3.12 + @classmethod + def _from_parts(cls, args: Tuple[str, ...]) -> "Path": + obj = super()._from_parts(args) # type: ignore + # These will be set by the calling instance later, or we can't set them here + # properly without context. Usually pathlib calls this from an instance + # method like _make_child, which we also might need to override. + return obj + + def _make_child(self, args: Tuple[str, ...]) -> "Path": + obj = super()._make_child(args) # type: ignore + obj._runfiles = self._runfiles # type: ignore + obj._source_repo = self._source_repo # type: ignore + return obj + + @classmethod + def _from_parsed_parts(cls, drv: str, root: str, parts: List[str]) -> "Path": + obj = super()._from_parsed_parts(drv, root, parts) # type: ignore + return obj + + def _make_child_relpath(self, part: str) -> "Path": + obj = super()._make_child_relpath(part) # type: ignore + obj._runfiles = self._runfiles # type: ignore + obj._source_repo = self._source_repo # type: ignore + return obj + + @property + def parents(self) -> Tuple["Path", ...]: + return tuple( + type(self)( + p, + runfiles=getattr(self, "_runfiles", None), + source_repo=getattr(self, "_source_repo", None), + ) + for p in super().parents + ) + + @property + def parent(self) -> "Path": + return type(self)( + super().parent, + runfiles=getattr(self, "_runfiles", None), + source_repo=getattr(self, "_source_repo", None), + ) + + def with_name(self, name: str) -> "Path": + return type(self)( + super().with_name(name), + runfiles=getattr(self, "_runfiles", None), + source_repo=getattr(self, "_source_repo", None), + ) + + def with_suffix(self, suffix: str) -> "Path": + return type(self)( + super().with_suffix(suffix), + runfiles=getattr(self, "_runfiles", None), + source_repo=getattr(self, "_source_repo", None), + ) + + def __repr__(self) -> str: + return 'runfiles.Path("{}")'.format(super().__str__()) + + def __str__(self) -> str: + path_posix = super().__str__().replace("\\", "/") + if not path_posix or path_posix == ".": + # pylint: disable=protected-access + return self._runfiles._python_runfiles_root # type: ignore + resolved = self._runfiles.Rlocation(path_posix, source_repo=self._source_repo) # type: ignore + return resolved if resolved is not None else super().__str__() + + def __fspath__(self) -> str: + return str(self) + + def runfiles_root(self) -> "Path": + """Returns a Path object representing the runfiles root.""" + return self._runfiles.root(source_repo=self._source_repo) # type: ignore class _ManifestBased: @@ -254,6 +375,16 @@ def __init__(self, strategy: Union[_ManifestBased, _DirectoryBased]) -> None: strategy.RlocationChecked("_repo_mapping") ) + def root(self, source_repo: Optional[str] = None) -> Path: + """Returns a Path object representing the runfiles root. + + The repository mapping used by the returned Path object is that of the + caller of this method. + """ + if source_repo is None and not self._repo_mapping.is_empty(): + source_repo = self.CurrentRepository(frame=2) + return Path(runfiles=self, source_repo=source_repo) + def Rlocation(self, path: str, source_repo: Optional[str] = None) -> Optional[str]: """Returns the runtime path of a runfile. @@ -325,9 +456,7 @@ def Rlocation(self, path: str, source_repo: Optional[str] = None) -> Optional[st # Look up the target repository using the repository mapping if target_canonical is not None: - return self._strategy.RlocationChecked( - target_canonical + "/" + remainder - ) + return self._strategy.RlocationChecked(target_canonical + "/" + remainder) # No mapping found - assume target_repo is already canonical or # we're not using Bzlmod @@ -396,10 +525,9 @@ def CurrentRepository(self, frame: int = 1) -> str: # TODO: This doesn't cover the case of a script being run from an # external repository, which could be heuristically detected # by parsing the script's path. - if ( - (sys.version_info.minor <= 10 or sys.platform == "win32") - and sys.path[0] != self._python_runfiles_root - ): + if (sys.version_info.minor <= 10 or sys.platform == "win32") and sys.path[ + 0 + ] != self._python_runfiles_root: return "" raise ValueError( "{} does not lie under the runfiles root {}".format( diff --git a/tests/runfiles/BUILD.bazel b/tests/runfiles/BUILD.bazel index 84602d2bd6..7d675c7d7c 100644 --- a/tests/runfiles/BUILD.bazel +++ b/tests/runfiles/BUILD.bazel @@ -14,6 +14,12 @@ py_test( deps = ["//python/runfiles"], ) +py_test( + name = "pathlib_test", + srcs = ["pathlib_test.py"], + deps = ["//python/runfiles"], +) + build_test( name = "publishing", targets = [ diff --git a/tests/runfiles/pathlib_test.py b/tests/runfiles/pathlib_test.py new file mode 100644 index 0000000000..ec8e9b9f88 --- /dev/null +++ b/tests/runfiles/pathlib_test.py @@ -0,0 +1,105 @@ +import os +import pathlib +import tempfile +import unittest + +from python.runfiles import runfiles + + +class PathlibTest(unittest.TestCase): + def setUp(self) -> None: + self.tmpdir = tempfile.TemporaryDirectory(dir=os.environ.get("TEST_TMPDIR")) + # Runfiles paths are expected to be posix paths internally when we construct the strings for assertions + self.root_dir = pathlib.Path(self.tmpdir.name).as_posix() + + def tearDown(self) -> None: + self.tmpdir.cleanup() + + def testPathApi(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + root = r.root() + + # Test basic joining + p = root / "repo/pkg/file.txt" + self.assertEqual(str(p), f"{self.root_dir}/repo/pkg/file.txt") + + # Test PurePath API + self.assertEqual(p.name, "file.txt") + self.assertEqual(p.suffix, ".txt") + self.assertEqual(p.parent.name, "pkg") + self.assertEqual(p.parts, ("repo", "pkg", "file.txt")) + self.assertEqual(p.stem, "file") + self.assertEqual(p.suffixes, [".txt"]) + + # Test multiple joins + p2 = root / "repo" / "pkg" / "file.txt" + self.assertEqual(p, p2) + + # Test joins with pathlib objects + p3 = root / pathlib.PurePath("repo/pkg/file.txt") + self.assertEqual(p, p3) + + def testRoot(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + self.assertEqual(str(r.root()), self.root_dir) + + def testRunfilesRootMethod(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + p = r.root() / "foo/bar" + self.assertEqual(p.runfiles_root(), r.root()) + self.assertEqual(str(p.runfiles_root()), self.root_dir) + + def testOsPathLike(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + p = r.root() / "foo" + self.assertEqual(os.fspath(p), f"{self.root_dir}/foo") + + def testEqualityAndHash(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + p1 = r.root() / "foo" + p2 = r.root() / "foo" + p3 = r.root() / "bar" + + self.assertEqual(p1, p2) + self.assertNotEqual(p1, p3) + self.assertEqual(hash(p1), hash(p2)) + + def testJoinPath(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + p = r.root().joinpath("repo", "file") + self.assertEqual(str(p), f"{self.root_dir}/repo/file") + + def testParents(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + p = r.root() / "a/b/c" + parents = list(p.parents) + self.assertEqual(len(parents), 3) + self.assertEqual(str(parents[0]), f"{self.root_dir}/a/b") + self.assertEqual(str(parents[1]), f"{self.root_dir}/a") + self.assertEqual(str(parents[2]), self.root_dir) + + def testWithMethods(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + p = r.root() / "foo/bar.txt" + self.assertEqual(str(p.with_name("baz.py")), f"{self.root_dir}/foo/baz.py") + self.assertEqual(str(p.with_suffix(".dat")), f"{self.root_dir}/foo/bar.dat") + + def testMatch(self) -> None: + r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) + assert r is not None + p = r.root() / "foo/bar.txt" + self.assertTrue(p.match("*.txt")) + self.assertTrue(p.match("foo/*.txt")) + self.assertFalse(p.match("bar/*.txt")) + + +if __name__ == "__main__": + unittest.main() From 5fe5e253614cb42f8dd554c898595cd53552a8f1 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 11 Apr 2026 02:19:04 -0700 Subject: [PATCH 2/5] add changelog ref --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a774063a1..e027f94de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ END_UNRELEASED_TEMPLATE for idiomatic path manipulation of runfiles. It is created by calling the {obj}`Runfiles.root` instance method. The {obj}`Path` class also has a {obj}`Path.runfiles_root` method to return the root of the runfiles tree. + Fixes [#3296](https://github.com/bazel-contrib/rules_python/issues/3296). {#v2-0-0} ## [2.0.0] - 2026-04-09 From 1bc33b083b8d1f8f41edc46983c3287c0094124d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 11 Apr 2026 11:06:43 -0700 Subject: [PATCH 3/5] use !r in repr Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- python/runfiles/runfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index 34616f16fa..f7be6ef42f 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -243,7 +243,7 @@ def with_suffix(self, suffix: str) -> "Path": ) def __repr__(self) -> str: - return 'runfiles.Path("{}")'.format(super().__str__()) + return 'runfiles.Path({!r})'.format(super().__str__()) def __str__(self) -> str: path_posix = super().__str__().replace("\\", "/") From 5369ad0870fc090d71dc01e2b41be3f4a91fdcff Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 12 Apr 2026 21:41:28 -0700 Subject: [PATCH 4/5] snake case tets and fixed changelog --- CHANGELOG.md | 16 ++++++++++++++++ tests/runfiles/pathlib_test.py | 18 +++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e027f94de7..125051093b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,24 @@ BEGIN_UNRELEASED_TEMPLATE END_UNRELEASED_TEMPLATE --> +{#v0-0-0} ## Unreleased +[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 + +{#v0-0-0-removed} +### Removed +* Nothing removed. + +{#v0-0-0-changed} +### Changed +* Nothing changed. + +{#v0-0-0-fixed} +### Fixed +* Nothing fixed. + +{#v0-0-0-added} ### Added * (runfiles) Added a {obj}`Path` class that implements the `pathlib.PurePath` API for idiomatic path manipulation of runfiles. It is created by calling the diff --git a/tests/runfiles/pathlib_test.py b/tests/runfiles/pathlib_test.py index ec8e9b9f88..553c8e4410 100644 --- a/tests/runfiles/pathlib_test.py +++ b/tests/runfiles/pathlib_test.py @@ -15,7 +15,7 @@ def setUp(self) -> None: def tearDown(self) -> None: self.tmpdir.cleanup() - def testPathApi(self) -> None: + def test_path_api(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None root = r.root() @@ -40,25 +40,25 @@ def testPathApi(self) -> None: p3 = root / pathlib.PurePath("repo/pkg/file.txt") self.assertEqual(p, p3) - def testRoot(self) -> None: + def test_root(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None self.assertEqual(str(r.root()), self.root_dir) - def testRunfilesRootMethod(self) -> None: + def test_runfiles_root_method(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None p = r.root() / "foo/bar" self.assertEqual(p.runfiles_root(), r.root()) self.assertEqual(str(p.runfiles_root()), self.root_dir) - def testOsPathLike(self) -> None: + def test_os_path_like(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None p = r.root() / "foo" self.assertEqual(os.fspath(p), f"{self.root_dir}/foo") - def testEqualityAndHash(self) -> None: + def test_equality_and_hash(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None p1 = r.root() / "foo" @@ -69,13 +69,13 @@ def testEqualityAndHash(self) -> None: self.assertNotEqual(p1, p3) self.assertEqual(hash(p1), hash(p2)) - def testJoinPath(self) -> None: + def test_join_path(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None p = r.root().joinpath("repo", "file") self.assertEqual(str(p), f"{self.root_dir}/repo/file") - def testParents(self) -> None: + def test_parents(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None p = r.root() / "a/b/c" @@ -85,14 +85,14 @@ def testParents(self) -> None: self.assertEqual(str(parents[1]), f"{self.root_dir}/a") self.assertEqual(str(parents[2]), self.root_dir) - def testWithMethods(self) -> None: + def test_with_methods(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None p = r.root() / "foo/bar.txt" self.assertEqual(str(p.with_name("baz.py")), f"{self.root_dir}/foo/baz.py") self.assertEqual(str(p.with_suffix(".dat")), f"{self.root_dir}/foo/bar.dat") - def testMatch(self) -> None: + def test_match(self) -> None: r = runfiles.Create({"RUNFILES_DIR": self.root_dir}) assert r is not None p = r.root() / "foo/bar.txt" From c342cee70c860d4b5abc103940b4202007d289a6 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 12 Apr 2026 21:51:48 -0700 Subject: [PATCH 5/5] cleanup changelog text, stupid ai slop description --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 125051093b..a85317a20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,10 +66,7 @@ END_UNRELEASED_TEMPLATE {#v0-0-0-added} ### Added -* (runfiles) Added a {obj}`Path` class that implements the `pathlib.PurePath` API - for idiomatic path manipulation of runfiles. It is created by calling the - {obj}`Runfiles.root` instance method. The {obj}`Path` class also has a - {obj}`Path.runfiles_root` method to return the root of the runfiles tree. +* (runfiles) Added a pathlib-compatible API: {obj}`Runfiles.root()` Fixes [#3296](https://github.com/bazel-contrib/rules_python/issues/3296). {#v2-0-0}