Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ BEGIN_UNRELEASED_TEMPLATE
END_UNRELEASED_TEMPLATE
-->

## Unreleased
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the template :)


### 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.
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 on lines +248 to +254
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The __str__ method assumes that self._runfiles is not None. However, since Path is a public class, it can be instantiated directly without a Runfiles instance (e.g., runfiles.Path("foo")), in which case self._runfiles will be None and this method will raise an AttributeError. It should fallback to the default string representation if no Runfiles instance is associated.

Suggested change
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 __str__(self) -> str:
if self._runfiles is None:
return super().__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
Comment on lines +259 to +261
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to __str__, runfiles_root will crash if self._runfiles is None. It should check for the presence of the runfiles instance and perhaps raise a more descriptive error if it's missing.

Suggested change
def runfiles_root(self) -> "Path":
"""Returns a Path object representing the runfiles root."""
return self._runfiles.root(source_repo=self._source_repo) # type: ignore
def runfiles_root(self) -> "Path":
"""Returns a Path object representing the runfiles root."""
if self._runfiles is None:
raise ValueError("Path object is not associated with a Runfiles instance")
return self._runfiles.root(source_repo=self._source_repo) # type: ignore



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 testPathApi(self) -> None:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, maybe it is personal preference but itwould be nice to have the tests named in snake case here except for the special methods.

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()