Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ BEGIN_UNRELEASED_TEMPLATE
END_UNRELEASED_TEMPLATE
-->

{#v0-0-0}
## Unreleased
Comment thread
rickeylev marked this conversation as resolved.

[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 pathlib-compatible API: {obj}`Runfiles.root()`
Fixes [#3296](https://github.com/bazel-contrib/rules_python/issues/3296).

{#v2-0-0}
## [2.0.0] - 2026-04-09

Expand Down
146 changes: 137 additions & 9 deletions python/runfiles/runfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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({!r})'.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__()
Comment thread
rickeylev marked this conversation as resolved.

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
Comment thread
rickeylev marked this conversation as resolved.


class _ManifestBased:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions tests/runfiles/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
105 changes: 105 additions & 0 deletions tests/runfiles/pathlib_test.py
Original file line number Diff line number Diff line change
@@ -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 test_path_api(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 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 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 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 test_equality_and_hash(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 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 test_parents(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 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 test_match(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()