-
-
Notifications
You must be signed in to change notification settings - Fork 678
feat(runfiles): create pathlib api for runfiles library #3694
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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({!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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to
Suggested change
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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( | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use the template :)