From 49aaee7978c54211967392678072accc403d15f2 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 10 Oct 2025 19:08:55 +0100 Subject: [PATCH 1/4] pathlib ABCs: restore `relative_to()` and `is_relative_to()` (#138853) Restore `JoinablePath.[is_]relative_to()`, which were deleted in ef63cca494571f50906baae1d176469a3dcf8838. These methods are too useful to forgo. Restore old tests, and add new tests covering path classes with non-overridden `__eq__()` and `__hash__()`. Slightly simplify `PurePath.relative_to()` while we're in the area. No change to public APIs, because the pathlib ABCs are still private. --- Lib/pathlib/__init__.py | 7 ++-- Lib/pathlib/types.py | 27 +++++++++++++++ Lib/test/test_pathlib/test_join.py | 55 ++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index 8a892102cc00ea..6c07cd9ab010ad 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -490,16 +490,19 @@ def relative_to(self, other, *, walk_up=False): """ if not hasattr(other, 'with_segments'): other = self.with_segments(other) - for step, path in enumerate(chain([other], other.parents)): + parts = [] + for path in chain([other], other.parents): if path == self or path in self.parents: break elif not walk_up: raise ValueError(f"{str(self)!r} is not in the subpath of {str(other)!r}") elif path.name == '..': raise ValueError(f"'..' segment in {str(other)!r} cannot be walked") + else: + parts.append('..') else: raise ValueError(f"{str(self)!r} and {str(other)!r} have different anchors") - parts = ['..'] * step + self._tail[len(path._tail):] + parts.extend(self._tail[len(path._tail):]) return self._from_parsed_parts('', '', parts) def is_relative_to(self, other): diff --git a/Lib/pathlib/types.py b/Lib/pathlib/types.py index fea0dd305fe2a3..f21ce0774548f8 100644 --- a/Lib/pathlib/types.py +++ b/Lib/pathlib/types.py @@ -234,6 +234,33 @@ def parents(self): parent = split(path)[0] return tuple(parents) + def relative_to(self, other, *, walk_up=False): + """Return the relative path to another path identified by the passed + arguments. If the operation is not possible (because this is not + related to the other path), raise ValueError. + + The *walk_up* parameter controls whether `..` may be used to resolve + the path. + """ + parts = [] + for path in (other,) + other.parents: + if self.is_relative_to(path): + break + elif not walk_up: + raise ValueError(f"{self!r} is not in the subpath of {other!r}") + elif path.name == '..': + raise ValueError(f"'..' segment in {other!r} cannot be walked") + else: + parts.append('..') + else: + raise ValueError(f"{self!r} and {other!r} have different anchors") + return self.with_segments(*parts, *self.parts[len(path.parts):]) + + def is_relative_to(self, other): + """Return True if the path is relative to another path or False. + """ + return other == self or other in self.parents + def full_match(self, pattern): """ Return True if this path matches the given glob-style pattern. The diff --git a/Lib/test/test_pathlib/test_join.py b/Lib/test/test_pathlib/test_join.py index f1a24204b4c30a..2f4e79345f3652 100644 --- a/Lib/test/test_pathlib/test_join.py +++ b/Lib/test/test_pathlib/test_join.py @@ -354,6 +354,61 @@ def test_with_suffix(self): self.assertRaises(ValueError, P('a/b').with_suffix, '.d/.') self.assertRaises(TypeError, P('a/b').with_suffix, None) + def test_relative_to(self): + P = self.cls + p = P('a/b') + self.assertEqual(p.relative_to(P('')), P('a', 'b')) + self.assertEqual(p.relative_to(P('a')), P('b')) + self.assertEqual(p.relative_to(P('a/b')), P('')) + self.assertEqual(p.relative_to(P(''), walk_up=True), P('a', 'b')) + self.assertEqual(p.relative_to(P('a'), walk_up=True), P('b')) + self.assertEqual(p.relative_to(P('a/b'), walk_up=True), P('')) + self.assertEqual(p.relative_to(P('a/c'), walk_up=True), P('..', 'b')) + self.assertEqual(p.relative_to(P('a/b/c'), walk_up=True), P('..')) + self.assertEqual(p.relative_to(P('c'), walk_up=True), P('..', 'a', 'b')) + self.assertRaises(ValueError, p.relative_to, P('c')) + self.assertRaises(ValueError, p.relative_to, P('a/b/c')) + self.assertRaises(ValueError, p.relative_to, P('a/c')) + self.assertRaises(ValueError, p.relative_to, P('/a')) + self.assertRaises(ValueError, p.relative_to, P('../a')) + self.assertRaises(ValueError, p.relative_to, P('a/..')) + self.assertRaises(ValueError, p.relative_to, P('/a/..')) + self.assertRaises(ValueError, p.relative_to, P('/'), walk_up=True) + self.assertRaises(ValueError, p.relative_to, P('/a'), walk_up=True) + self.assertRaises(ValueError, p.relative_to, P('../a'), walk_up=True) + self.assertRaises(ValueError, p.relative_to, P('a/..'), walk_up=True) + self.assertRaises(ValueError, p.relative_to, P('/a/..'), walk_up=True) + class Q(self.cls): + __eq__ = object.__eq__ + __hash__ = object.__hash__ + q = Q('a/b') + self.assertTrue(q.relative_to(q)) + self.assertRaises(ValueError, q.relative_to, Q('')) + self.assertRaises(ValueError, q.relative_to, Q('a')) + self.assertRaises(ValueError, q.relative_to, Q('a'), walk_up=True) + self.assertRaises(ValueError, q.relative_to, Q('a/b')) + self.assertRaises(ValueError, q.relative_to, Q('c')) + + def test_is_relative_to(self): + P = self.cls + p = P('a/b') + self.assertTrue(p.is_relative_to(P(''))) + self.assertTrue(p.is_relative_to(P('a'))) + self.assertTrue(p.is_relative_to(P('a/b'))) + self.assertFalse(p.is_relative_to(P('c'))) + self.assertFalse(p.is_relative_to(P('a/b/c'))) + self.assertFalse(p.is_relative_to(P('a/c'))) + self.assertFalse(p.is_relative_to(P('/a'))) + class Q(self.cls): + __eq__ = object.__eq__ + __hash__ = object.__hash__ + q = Q('a/b') + self.assertTrue(q.is_relative_to(q)) + self.assertFalse(q.is_relative_to(Q(''))) + self.assertFalse(q.is_relative_to(Q('a'))) + self.assertFalse(q.is_relative_to(Q('a/b'))) + self.assertFalse(q.is_relative_to(Q('c'))) + class LexicalPathJoinTest(JoinTestBase, unittest.TestCase): cls = LexicalPath From aa840f500ca901deea29669df68992193a273a62 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych <89152624+StanFromIreland@users.noreply.github.com> Date: Fri, 10 Oct 2025 20:22:34 +0100 Subject: [PATCH 2/4] gh-138843: Removing "Unpacking" section from Download page (GH-139918) --- Doc/tools/templates/download.html | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Doc/tools/templates/download.html b/Doc/tools/templates/download.html index 523b505f59673e..f914ad862116d7 100644 --- a/Doc/tools/templates/download.html +++ b/Doc/tools/templates/download.html @@ -75,16 +75,6 @@

{% trans %}Download Python {{ dl_version }} documentation{% endtrans %}

See the directory listing for file sizes.{% endtrans %}

-

{% trans %}Unpacking{% endtrans %}

- -

{% trans %}Unix users should download the .tar.bz2 archives; these are bzipped tar -archives and can be handled in the usual way using tar and the bzip2 -program. The Info-ZIP unzip program can be -used to handle the ZIP archives if desired. The .tar.bz2 archives provide the -best compression and fastest download times.{% endtrans %}

- -

{% trans %}Windows users can use the ZIP archives since those are customary on that -platform. These are created on Unix using the Info-ZIP zip program.{% endtrans %}

{% trans %}Problems{% endtrans %}

{% set bugs = pathto('bugs') %} From d9b4eef71e7904fbe3a3786a908e493be7debbff Mon Sep 17 00:00:00 2001 From: Sam Gross Date: Fri, 10 Oct 2025 17:20:18 -0400 Subject: [PATCH 3/4] gh-139001: Fix thread-safety issue in `pathlib.Path` (gh-139066) Don't cache the joined path in `_raw_path` because the caching isn't thread safe. --- Lib/pathlib/__init__.py | 7 +----- Lib/test/test_pathlib/test_join.py | 22 +++++++++++++++++++ ...-09-17-12-07-21.gh-issue-139001.O6tseN.rst | 2 ++ 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index 6c07cd9ab010ad..51359cec8b0f9e 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -336,13 +336,8 @@ def _raw_path(self): return paths[0] elif paths: # Join path segments from the initializer. - path = self.parser.join(*paths) - # Cache the joined path. - paths.clear() - paths.append(path) - return path + return self.parser.join(*paths) else: - paths.append('') return '' @property diff --git a/Lib/test/test_pathlib/test_join.py b/Lib/test/test_pathlib/test_join.py index 2f4e79345f3652..4630210e492291 100644 --- a/Lib/test/test_pathlib/test_join.py +++ b/Lib/test/test_pathlib/test_join.py @@ -3,6 +3,8 @@ """ import unittest +import threading +from test.support import threading_helper from .support import is_pypi from .support.lexical_path import LexicalPath @@ -158,6 +160,26 @@ def test_parts(self): parts = p.parts self.assertEqual(parts, (sep, 'a', 'b')) + @threading_helper.requires_working_threading() + def test_parts_multithreaded(self): + P = self.cls + + NUM_THREADS = 10 + NUM_ITERS = 10 + + for _ in range(NUM_ITERS): + b = threading.Barrier(NUM_THREADS) + path = P('a') / 'b' / 'c' / 'd' / 'e' + expected = ('a', 'b', 'c', 'd', 'e') + + def check_parts(): + b.wait() + self.assertEqual(path.parts, expected) + + threads = [threading.Thread(target=check_parts) for _ in range(NUM_THREADS)] + with threading_helper.start_threads(threads): + pass + def test_parent(self): # Relative P = self.cls diff --git a/Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst b/Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst new file mode 100644 index 00000000000000..3ad5a1272df4af --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst @@ -0,0 +1,2 @@ +Fix race condition in :class:`pathlib.Path` on the internal ``_raw_paths`` +field. From ff7bb565d836162eed0851c36afa325a107a5a56 Mon Sep 17 00:00:00 2001 From: Dino Viehland Date: Fri, 10 Oct 2025 15:25:38 -0700 Subject: [PATCH 4/4] gh-139924: Add PyFunction_PYFUNC_EVENT_MODIFY_QUALNAME event for function watchers (#139925) Add PyFunction_PYFUNC_EVENT_MODIFY_QUALNAME event for function watchers --- Doc/c-api/function.rst | 3 +++ Include/cpython/funcobject.h | 3 ++- Lib/test/test_capi/test_watchers.py | 4 ++++ .../next/C_API/2025-10-10-20-59-07.gh-issue-139924.ALByCb.rst | 1 + Objects/funcobject.c | 2 ++ 5 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/C_API/2025-10-10-20-59-07.gh-issue-139924.ALByCb.rst diff --git a/Doc/c-api/function.rst b/Doc/c-api/function.rst index 5fb8567ef8c95f..764b2ac610be4d 100644 --- a/Doc/c-api/function.rst +++ b/Doc/c-api/function.rst @@ -175,6 +175,9 @@ There are a few functions specific to Python functions. .. versionadded:: 3.12 + - ``PyFunction_PYFUNC_EVENT_MODIFY_QUALNAME`` + + .. versionadded:: 3.15 .. c:type:: int (*PyFunction_WatchCallback)(PyFunction_WatchEvent event, PyFunctionObject *func, PyObject *new_value) diff --git a/Include/cpython/funcobject.h b/Include/cpython/funcobject.h index 598cd330bc9ca9..9e1599a7648564 100644 --- a/Include/cpython/funcobject.h +++ b/Include/cpython/funcobject.h @@ -134,7 +134,8 @@ PyAPI_FUNC(PyObject *) PyStaticMethod_New(PyObject *); V(DESTROY) \ V(MODIFY_CODE) \ V(MODIFY_DEFAULTS) \ - V(MODIFY_KWDEFAULTS) + V(MODIFY_KWDEFAULTS) \ + V(MODIFY_QUALNAME) typedef enum { #define PY_DEF_EVENT(EVENT) PyFunction_EVENT_##EVENT, diff --git a/Lib/test/test_capi/test_watchers.py b/Lib/test/test_capi/test_watchers.py index 8644479d83d5ed..bef72032513da5 100644 --- a/Lib/test/test_capi/test_watchers.py +++ b/Lib/test/test_capi/test_watchers.py @@ -514,6 +514,10 @@ def myfunc(): _testcapi.set_func_kwdefaults_via_capi(myfunc, new_kwdefaults) self.assertIn((_testcapi.PYFUNC_EVENT_MODIFY_KWDEFAULTS, myfunc, new_kwdefaults), events) + new_qualname = "foo.bar" + myfunc.__qualname__ = new_qualname + self.assertIn((_testcapi.PYFUNC_EVENT_MODIFY_QUALNAME, myfunc, new_qualname), events) + # Clear events reference to func events = [] del myfunc diff --git a/Misc/NEWS.d/next/C_API/2025-10-10-20-59-07.gh-issue-139924.ALByCb.rst b/Misc/NEWS.d/next/C_API/2025-10-10-20-59-07.gh-issue-139924.ALByCb.rst new file mode 100644 index 00000000000000..a53d5d068d7d86 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2025-10-10-20-59-07.gh-issue-139924.ALByCb.rst @@ -0,0 +1 @@ +Function watchers can now receive a PyFunction_PYFUNC_EVENT_MODIFY_QUALNAME event when a watched functions qualname is changed. diff --git a/Objects/funcobject.c b/Objects/funcobject.c index d8a10075578087..43198aaf8a7048 100644 --- a/Objects/funcobject.c +++ b/Objects/funcobject.c @@ -62,6 +62,7 @@ handle_func_event(PyFunction_WatchEvent event, PyFunctionObject *func, case PyFunction_EVENT_MODIFY_CODE: case PyFunction_EVENT_MODIFY_DEFAULTS: case PyFunction_EVENT_MODIFY_KWDEFAULTS: + case PyFunction_EVENT_MODIFY_QUALNAME: RARE_EVENT_INTERP_INC(interp, func_modification); break; default: @@ -747,6 +748,7 @@ func_set_qualname(PyObject *self, PyObject *value, void *Py_UNUSED(ignored)) "__qualname__ must be set to a string object"); return -1; } + handle_func_event(PyFunction_EVENT_MODIFY_QUALNAME, (PyFunctionObject *) op, value); Py_XSETREF(op->func_qualname, Py_NewRef(value)); return 0; }