From c867c3ba93d1d27a5067a68ae19018e289619747 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 21:40:53 -0700 Subject: [PATCH 1/5] fix(runfiles): update Path method signatures for Python 3.14 compatibility Align runfiles.Path method signatures with Python 3.14 typeshed stubs to prevent type checker errors under newer Python versions. This updates method signatures (including glob, rglob, exists, is_dir, is_file, and read_text) and applies typing.override decorators where available. --- python/runfiles/runfiles.py | 123 ++++++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 33 deletions(-) diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index 1c6dca6088..c939cfe66e 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -31,9 +31,20 @@ import posixpath import sys from collections import defaultdict -from collections.abc import Generator +from collections.abc import Generator, Iterator from typing import cast +if sys.version_info >= (3, 12): + from typing import override +else: + from typing import TypeVar + + _FuncT = TypeVar("_FuncT") + + def override(func: _FuncT) -> _FuncT: + return func + + if sys.version_info >= (3, 11): from typing import Self elif sys.version_info >= (3, 10): @@ -202,7 +213,7 @@ def __init__( # __new__ or with_segments(), the runfiles state is preserved. We delegate # to self._as_path() because super().resolve() creates intermediate objects # that would otherwise crash during internal stat() calls. - # override + @override def resolve(self, strict: bool = False) -> Self: return type(self)( self._as_path().resolve(strict=strict), @@ -210,7 +221,7 @@ def resolve(self, strict: bool = False) -> Self: source_repo=self._source_repo, ) - # override + @override def absolute(self) -> Self: return type(self)( self._as_path().absolute(), @@ -218,7 +229,7 @@ def absolute(self) -> Self: source_repo=self._source_repo, ) - # override + @override def with_segments(self, *pathsegments: str | os.PathLike) -> Self: """Used by Python 3.12+ pathlib to create new path objects.""" return type(self)( @@ -228,7 +239,6 @@ def with_segments(self, *pathsegments: str | os.PathLike) -> Self: ) # For Python < 3.12 - # override def _make_child(self, args: tuple[str, ...]) -> Self: # _make_child is an internal CPython method in Python < 3.12 omitted from # typeshed stubs. We ignore [missing-attribute] for pyrefly. @@ -237,8 +247,8 @@ def _make_child(self, args: tuple[str, ...]) -> Self: obj._source_repo = self._source_repo return cast(Self, obj) - # override @property + @override def parents(self) -> tuple[Self, ...]: return tuple( type(self)( @@ -249,8 +259,8 @@ def parents(self) -> tuple[Self, ...]: for p in super().parents ) - # override @property + @override def parent(self) -> Self: return type(self)( super().parent, @@ -266,7 +276,7 @@ def runfile_path(self) -> str: return "" return path_posix - # override + @override def with_name(self, name: str) -> Self: return type(self)( super().with_name(name), @@ -274,7 +284,7 @@ def with_name(self, name: str) -> Self: source_repo=self._source_repo, ) - # override + @override def with_suffix(self, suffix: str) -> Self: return type(self)( super().with_suffix(suffix), @@ -285,49 +295,55 @@ def with_suffix(self, suffix: str) -> Self: def _as_path(self) -> pathlib.Path: return pathlib.Path(str(self)) - # override + @override def stat(self, *, follow_symlinks: bool = True) -> os.stat_result: return self._as_path().stat(follow_symlinks=follow_symlinks) - # override + @override def lstat(self) -> os.stat_result: return self._as_path().lstat() - # override - def exists(self) -> bool: + @override + def exists(self, *, follow_symlinks: bool = True) -> bool: + if not follow_symlinks and sys.version_info >= (3, 12): + return self._as_path().exists(follow_symlinks=follow_symlinks) return self._as_path().exists() - # override - def is_dir(self) -> bool: + @override + def is_dir(self, *, follow_symlinks: bool = True) -> bool: + if not follow_symlinks and sys.version_info >= (3, 12): + return self._as_path().is_dir(follow_symlinks=follow_symlinks) return self._as_path().is_dir() - # override - def is_file(self) -> bool: + @override + def is_file(self, *, follow_symlinks: bool = True) -> bool: + if not follow_symlinks and sys.version_info >= (3, 12): + return self._as_path().is_file(follow_symlinks=follow_symlinks) return self._as_path().is_file() - # override + @override def is_symlink(self) -> bool: return self._as_path().is_symlink() - # override + @override def is_block_device(self) -> bool: return self._as_path().is_block_device() - # override + @override def is_char_device(self) -> bool: return self._as_path().is_char_device() - # override + @override def is_fifo(self) -> bool: return self._as_path().is_fifo() - # override + @override def is_socket(self) -> bool: return self._as_path().is_socket() # Path.open in pathlib has multiple overloads in typeshed. We use a # simplified delegation signature here. - # override + @override def open( # pyrefly: ignore[bad-override] self, mode: str = "r", @@ -344,30 +360,71 @@ def open( # pyrefly: ignore[bad-override] newline=newline, ) - # override + @override def read_bytes(self) -> bytes: return self._as_path().read_bytes() - # override - def read_text(self, encoding: str | None = None, errors: str | None = None) -> str: + @override + def read_text( + self, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> str: + if newline is not None: + return self._as_path().read_text( + encoding=encoding, errors=errors, newline=newline + ) return self._as_path().read_text(encoding=encoding, errors=errors) - # override + @override def iterdir(self) -> Generator[Self, None, None]: resolved = self._as_path() for p in resolved.iterdir(): yield self / p.name - # override - def glob(self, pattern: str) -> Generator[Self, None, None]: + @override + def glob( + self, + pattern: str, + *, + case_sensitive: bool | None = None, + recurse_symlinks: bool = False, + ) -> Iterator[Self]: resolved = self._as_path() - for p in resolved.glob(pattern): + if sys.version_info >= (3, 13): + it = resolved.glob( + pattern, + case_sensitive=case_sensitive, + recurse_symlinks=recurse_symlinks, + ) + elif sys.version_info >= (3, 12): + it = resolved.glob(pattern, case_sensitive=case_sensitive) + else: + it = resolved.glob(pattern) + for p in it: yield self / p.relative_to(resolved) - # override - def rglob(self, pattern: str) -> Generator[Self, None, None]: + @override + def rglob( + self, + pattern: str, + *, + case_sensitive: bool | None = None, + recurse_symlinks: bool = False, + ) -> Iterator[Self]: resolved = self._as_path() - for p in resolved.rglob(pattern): + if sys.version_info >= (3, 13): + it = resolved.rglob( + pattern, + case_sensitive=case_sensitive, + recurse_symlinks=recurse_symlinks, + ) + elif sys.version_info >= (3, 12): + it = resolved.rglob(pattern, case_sensitive=case_sensitive) + else: + it = resolved.rglob(pattern) + for p in it: yield self / p.relative_to(resolved) def __repr__(self) -> str: From 8b54285c193354235f304f3b5e680efabf340856 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 22:07:19 -0700 Subject: [PATCH 2/5] test(runfiles): run tests across Python 3.10-3.14 and fix 3.12 match() Test runfiles and pathlib test suites across all supported Python versions (3.10 through 3.14) using pytest_test's python_versions attribute instead of separate targets. Additionally override Path.match() to delegate to _as_path(), fixing a Python 3.12 compatibility bug where pattern matching failed on runfile path objects. --- python/runfiles/runfiles.py | 11 +++++++++++ tests/runfiles/BUILD.bazel | 35 +++++++++++++---------------------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index c939cfe66e..0de11963c6 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -427,6 +427,17 @@ def rglob( for p in it: yield self / p.relative_to(resolved) + @override + def match( + self, + path_pattern: str, + *, + case_sensitive: bool | None = None, + ) -> bool: + if sys.version_info >= (3, 12): + return self._as_path().match(path_pattern, case_sensitive=case_sensitive) + return self._as_path().match(path_pattern) + def __repr__(self) -> str: return "runfiles.Path({!r})".format(self.runfile_path) diff --git a/tests/runfiles/BUILD.bazel b/tests/runfiles/BUILD.bazel index 04d7f1aafb..5e8d4208aa 100644 --- a/tests/runfiles/BUILD.bazel +++ b/tests/runfiles/BUILD.bazel @@ -1,5 +1,4 @@ load("@bazel_skylib//rules:build_test.bzl", "build_test") -load("@rules_python//python:py_test.bzl", "py_test") load("@rules_python//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") @@ -12,34 +11,26 @@ pytest_test( env = { "BZLMOD_ENABLED": "1" if BZLMOD_ENABLED else "0", }, - deps = ["//python/runfiles"], -) - -py_test( - name = "runfiles_min_python_test", - srcs = ["runfiles_test.py"], - data = [ - "//tests/support:current_build_settings", + python_versions = [ + "3.10", + "3.11", + "3.12", + "3.13", + "3.14", ], - env = { - "BZLMOD_ENABLED": "1" if BZLMOD_ENABLED else "0", - }, - main = "runfiles_test.py", - python_version = "3.10", deps = ["//python/runfiles"], ) pytest_test( name = "pathlib_test", srcs = ["pathlib_test.py"], - deps = ["//python/runfiles"], -) - -py_test( - name = "pathlib_min_python_test", - srcs = ["pathlib_test.py"], - main = "pathlib_test.py", - python_version = "3.10", + python_versions = [ + "3.10", + "3.11", + "3.12", + "3.13", + "3.14", + ], deps = ["//python/runfiles"], ) From 57b8f991f659a6a43c5495b10d58208b151da4d9 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 22:10:36 -0700 Subject: [PATCH 3/5] docs(runfiles): add news entry for Path signature updates and match fix Document the updated runfiles.Path method signatures for Python 3.14 compatibility and the fix for Path.match on Python 3.12+. --- news/runfiles_py314_compat.fixed.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 news/runfiles_py314_compat.fixed.md diff --git a/news/runfiles_py314_compat.fixed.md b/news/runfiles_py314_compat.fixed.md new file mode 100644 index 0000000000..d255c85048 --- /dev/null +++ b/news/runfiles_py314_compat.fixed.md @@ -0,0 +1,3 @@ +(runfiles) Updated {obj}`runfiles.Path` method signatures for Python 3.14 +typeshed compatibility and fixed `Path.match` on Python 3.12+. +([#4023](https://github.com/bazel-contrib/rules_python/issues/4023)) From 0f936c224669df3e17b264c0f4fe732fadc728b2 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 22:59:53 -0700 Subject: [PATCH 4/5] fix(runfiles): fix Python version checks in Path methods and WORKSPACE pytest_test deps Update runfiles.Path.is_dir, is_file, and read_text to check for Python 3.13+ before forwarding keyword arguments added in 3.13, ensuring Pyrefly compatibility across Python 3.10-3.14. Include exceptiongroup in pytest_test default dependencies to fix Python 3.10 test execution under WORKSPACE mode. --- news/runfiles_py314_compat.fixed.md | 2 +- python/runfiles/runfiles.py | 14 ++++++++------ tests/support/pytest_test/BUILD.bazel | 6 ++++++ tests/support/pytest_test/pytest_test.bzl | 4 ++++ 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/news/runfiles_py314_compat.fixed.md b/news/runfiles_py314_compat.fixed.md index d255c85048..accf48cf95 100644 --- a/news/runfiles_py314_compat.fixed.md +++ b/news/runfiles_py314_compat.fixed.md @@ -1,3 +1,3 @@ (runfiles) Updated {obj}`runfiles.Path` method signatures for Python 3.14 -typeshed compatibility and fixed `Path.match` on Python 3.12+. +typeshed compatibility and fixed {obj}`runfiles.Path.match` on Python 3.12+. ([#4023](https://github.com/bazel-contrib/rules_python/issues/4023)) diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py index 0de11963c6..0c56702216 100644 --- a/python/runfiles/runfiles.py +++ b/python/runfiles/runfiles.py @@ -311,13 +311,13 @@ def exists(self, *, follow_symlinks: bool = True) -> bool: @override def is_dir(self, *, follow_symlinks: bool = True) -> bool: - if not follow_symlinks and sys.version_info >= (3, 12): + if not follow_symlinks and sys.version_info >= (3, 13): return self._as_path().is_dir(follow_symlinks=follow_symlinks) return self._as_path().is_dir() @override def is_file(self, *, follow_symlinks: bool = True) -> bool: - if not follow_symlinks and sys.version_info >= (3, 12): + if not follow_symlinks and sys.version_info >= (3, 13): return self._as_path().is_file(follow_symlinks=follow_symlinks) return self._as_path().is_file() @@ -371,9 +371,11 @@ def read_text( errors: str | None = None, newline: str | None = None, ) -> str: - if newline is not None: + if sys.version_info >= (3, 13) and newline is not None: return self._as_path().read_text( - encoding=encoding, errors=errors, newline=newline + encoding=encoding, + errors=errors, + newline=newline, ) return self._as_path().read_text(encoding=encoding, errors=errors) @@ -384,7 +386,7 @@ def iterdir(self) -> Generator[Self, None, None]: yield self / p.name @override - def glob( + def glob( # pyrefly: ignore[bad-override] self, pattern: str, *, @@ -406,7 +408,7 @@ def glob( yield self / p.relative_to(resolved) @override - def rglob( + def rglob( # pyrefly: ignore[bad-override] self, pattern: str, *, diff --git a/tests/support/pytest_test/BUILD.bazel b/tests/support/pytest_test/BUILD.bazel index 4e6f6dd168..07aa8c4b80 100644 --- a/tests/support/pytest_test/BUILD.bazel +++ b/tests/support/pytest_test/BUILD.bazel @@ -27,3 +27,9 @@ alias( name = "default_pytest_bazel", actual = "@pypi//pytest_bazel", ) + +# These aliases are used to avoid duplicate targets in the deps list +alias( + name = "default_exceptiongroup", + actual = "@pypi//exceptiongroup", +) diff --git a/tests/support/pytest_test/pytest_test.bzl b/tests/support/pytest_test/pytest_test.bzl index bbdfffb24f..eeffe400b7 100644 --- a/tests/support/pytest_test/pytest_test.bzl +++ b/tests/support/pytest_test/pytest_test.bzl @@ -4,6 +4,9 @@ load("//python:py_test.bzl", "py_test") _DEFAULT_PYTEST = Label("//tests/support/pytest_test:default_pytest") _DEFAULT_PYTEST_BAZEL = Label("//tests/support/pytest_test:default_pytest_bazel") +_DEFAULT_EXCEPTIONGROUP = Label( + "//tests/support/pytest_test:default_exceptiongroup", +) def pytest_test( *, @@ -114,6 +117,7 @@ def _single_pytest_test( deps = deps + [ pytest, pytest_bazel, + _DEFAULT_EXCEPTIONGROUP, ], **kwargs ) From e59f51189e5b1e5258ab4dd6160cfb9e1ca3c07c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 23:03:22 -0700 Subject: [PATCH 5/5] fix(runfiles): use runtime 3.13 check in read_text and mark pytest_test as bzlmod-only Replace Pyrefly ignore comment in Path.read_text with a sys.version_info >= (3, 13) check before passing the newline keyword argument. Revert exceptiongroup dependency from pytest_test macro and restrict multi-version runfiles pytest targets to Bzlmod mode via SUPPORTS_BZLMOD. --- tests/runfiles/BUILD.bazel | 3 +++ tests/support/pytest_test/BUILD.bazel | 6 ------ tests/support/pytest_test/pytest_test.bzl | 4 ---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/runfiles/BUILD.bazel b/tests/runfiles/BUILD.bazel index 5e8d4208aa..08944efd90 100644 --- a/tests/runfiles/BUILD.bazel +++ b/tests/runfiles/BUILD.bazel @@ -1,5 +1,6 @@ load("@bazel_skylib//rules:build_test.bzl", "build_test") load("@rules_python//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility +load("//tests/support:support.bzl", "SUPPORTS_BZLMOD") load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") pytest_test( @@ -18,6 +19,7 @@ pytest_test( "3.13", "3.14", ], + target_compatible_with = SUPPORTS_BZLMOD, deps = ["//python/runfiles"], ) @@ -31,6 +33,7 @@ pytest_test( "3.13", "3.14", ], + target_compatible_with = SUPPORTS_BZLMOD, deps = ["//python/runfiles"], ) diff --git a/tests/support/pytest_test/BUILD.bazel b/tests/support/pytest_test/BUILD.bazel index 07aa8c4b80..4e6f6dd168 100644 --- a/tests/support/pytest_test/BUILD.bazel +++ b/tests/support/pytest_test/BUILD.bazel @@ -27,9 +27,3 @@ alias( name = "default_pytest_bazel", actual = "@pypi//pytest_bazel", ) - -# These aliases are used to avoid duplicate targets in the deps list -alias( - name = "default_exceptiongroup", - actual = "@pypi//exceptiongroup", -) diff --git a/tests/support/pytest_test/pytest_test.bzl b/tests/support/pytest_test/pytest_test.bzl index eeffe400b7..bbdfffb24f 100644 --- a/tests/support/pytest_test/pytest_test.bzl +++ b/tests/support/pytest_test/pytest_test.bzl @@ -4,9 +4,6 @@ load("//python:py_test.bzl", "py_test") _DEFAULT_PYTEST = Label("//tests/support/pytest_test:default_pytest") _DEFAULT_PYTEST_BAZEL = Label("//tests/support/pytest_test:default_pytest_bazel") -_DEFAULT_EXCEPTIONGROUP = Label( - "//tests/support/pytest_test:default_exceptiongroup", -) def pytest_test( *, @@ -117,7 +114,6 @@ def _single_pytest_test( deps = deps + [ pytest, pytest_bazel, - _DEFAULT_EXCEPTIONGROUP, ], **kwargs )