From 5c5a96cef522879821a9a161c1e79b1ff8fe2330 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 9 Aug 2026 15:02:07 +0300 Subject: [PATCH] gh-85004: Add the printname parameter in os.readlink() On Windows it makes the function return the print name of the link -- the target path as it was specified when the link was created. --- Doc/library/os.rst | 12 +++++- Doc/whatsnew/3.16.rst | 5 +++ .../pycore_global_objects_fini_generated.h | 1 + Include/internal/pycore_global_strings.h | 1 + .../internal/pycore_runtime_init_generated.h | 1 + .../internal/pycore_unicodeobject_generated.h | 4 ++ Lib/test/test_os/test_os.py | 27 ++++++++++++ Lib/test/test_os/test_windows.py | 6 +++ ...6-08-09-12-00-00.gh-issue-85004.Kp3rZq.rst | 3 ++ Modules/clinic/posixmodule.c.h | 36 +++++++++++----- Modules/posixmodule.c | 42 +++++++++++++++---- 11 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-09-12-00-00.gh-issue-85004.Kp3rZq.rst diff --git a/Doc/library/os.rst b/Doc/library/os.rst index 0a4a02c45b533bd..480446744004ff9 100644 --- a/Doc/library/os.rst +++ b/Doc/library/os.rst @@ -2758,7 +2758,7 @@ features: .. availability:: Unix. -.. function:: readlink(path, *, dir_fd=None) +.. function:: readlink(path, *, dir_fd=None, printname=False) Return a string representing the path to which the symbolic link points. The result may be either an absolute or relative pathname; if it is relative, it @@ -2773,6 +2773,13 @@ features: This function can also support :ref:`paths relative to directory descriptors `. + On Windows, if *printname* is true, return the *print name* of the link -- + the target path as it was specified when the link was created -- + instead of the *substitute name* used by the system to resolve the link, + which typically includes the ``\\?\`` prefix. + The substitute name is returned if the link has no print name. + *printname* is ignored on non-Windows platforms. + When trying to resolve a path that may contain links, use :func:`~os.path.realpath` to properly handle recursion and platform differences. @@ -2795,6 +2802,9 @@ features: substitution path (which typically includes ``\\?\`` prefix) rather than the optional "print name" field that was previously returned. + .. versionchanged:: next + Added the *printname* parameter. + .. function:: remove(path, *, dir_fd=None) Remove (delete) the file *path*. If *path* is a directory, an diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 210bcafe65f5e9c..433e293d68fb3db 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -414,6 +414,11 @@ os process via a pidfd. Available on Linux 5.6+. (Contributed by Maurycy Pawłowski-Wieroński in :gh:`149464`.) +* :func:`os.readlink` has a new *printname* parameter + to return the print name of a link on Windows -- + the target path as it was specified when the link was created. + (Contributed by Serhiy Storchaka in :gh:`85004`.) + pydoc ----- diff --git a/Include/internal/pycore_global_objects_fini_generated.h b/Include/internal/pycore_global_objects_fini_generated.h index 6df1c01f151f68e..3d6131394e5f856 100644 --- a/Include/internal/pycore_global_objects_fini_generated.h +++ b/Include/internal/pycore_global_objects_fini_generated.h @@ -2002,6 +2002,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) { _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(prec)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(preserve_exc)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(print_file_and_line)); + _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(printname)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(priority)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(progress)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(progress_callback)); diff --git a/Include/internal/pycore_global_strings.h b/Include/internal/pycore_global_strings.h index 873344fbdcb67b0..ee9eafe4479ed67 100644 --- a/Include/internal/pycore_global_strings.h +++ b/Include/internal/pycore_global_strings.h @@ -725,6 +725,7 @@ struct _Py_global_strings { STRUCT_FOR_ID(prec) STRUCT_FOR_ID(preserve_exc) STRUCT_FOR_ID(print_file_and_line) + STRUCT_FOR_ID(printname) STRUCT_FOR_ID(priority) STRUCT_FOR_ID(progress) STRUCT_FOR_ID(progress_callback) diff --git a/Include/internal/pycore_runtime_init_generated.h b/Include/internal/pycore_runtime_init_generated.h index 378f27ca17b5079..086e42a88658a48 100644 --- a/Include/internal/pycore_runtime_init_generated.h +++ b/Include/internal/pycore_runtime_init_generated.h @@ -2000,6 +2000,7 @@ extern "C" { INIT_ID(prec), \ INIT_ID(preserve_exc), \ INIT_ID(print_file_and_line), \ + INIT_ID(printname), \ INIT_ID(priority), \ INIT_ID(progress), \ INIT_ID(progress_callback), \ diff --git a/Include/internal/pycore_unicodeobject_generated.h b/Include/internal/pycore_unicodeobject_generated.h index daf6840aa47f328..2b4c2ba2ba7e188 100644 --- a/Include/internal/pycore_unicodeobject_generated.h +++ b/Include/internal/pycore_unicodeobject_generated.h @@ -2680,6 +2680,10 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) { _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); assert(PyUnicode_GET_LENGTH(string) != 1); + string = &_Py_ID(printname); + _PyUnicode_InternStatic(interp, &string); + assert(_PyUnicode_CheckConsistency(string, 1)); + assert(PyUnicode_GET_LENGTH(string) != 1); string = &_Py_ID(priority); _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index 328a0dbeb99f8fa..ce038cb36564673 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -3236,6 +3236,33 @@ def test_bytes(self): self.assertPathEqual(path, self.filelinkb_target) self.assertIsInstance(path, bytes) + @os_helper.skip_unless_symlink + def test_printname(self): + # The print name is the target as it was specified, without the + # "\\?\" prefix which Windows adds to the substitute name. + os.symlink(self.filelink_target, self.filelink) + self.addCleanup(os_helper.unlink, self.filelink) + self.assertEqual(os.readlink(self.filelink, printname=True), + self.filelink_target) + self.assertPathEqual(os.readlink(self.filelink), self.filelink_target) + + @os_helper.skip_unless_symlink + def test_printname_bytes(self): + os.symlink(self.filelinkb_target, self.filelinkb) + self.addCleanup(os_helper.unlink, self.filelinkb) + path = os.readlink(self.filelinkb, printname=True) + self.assertEqual(path, self.filelinkb_target) + self.assertIsInstance(path, bytes) + + @os_helper.skip_unless_symlink + def test_printname_relative(self): + # A relative target is not converted to the substitute name. + os.symlink('relative-target', self.filelink) + self.addCleanup(os_helper.unlink, self.filelink) + self.assertEqual(os.readlink(self.filelink, printname=True), + 'relative-target') + self.assertEqual(os.readlink(self.filelink), 'relative-target') + @os_helper.skip_unless_symlink class NonLocalSymlinkTests(unittest.TestCase): diff --git a/Lib/test/test_os/test_windows.py b/Lib/test/test_os/test_windows.py index b21dd8a4dca6609..d3c8467d9d5a210 100644 --- a/Lib/test/test_os/test_windows.py +++ b/Lib/test/test_os/test_windows.py @@ -445,6 +445,12 @@ def test_create_junction(self): self.assertEqual(os.path.normcase("\\\\?\\" + self.junction_target), os.path.normcase(os.readlink(self.junction))) + def test_readlink_printname(self): + _winapi.CreateJunction(self.junction_target, self.junction) + self.assertEqual(os.path.normcase(self.junction_target), + os.path.normcase(os.readlink(self.junction, + printname=True))) + def test_unlink_removes_junction(self): _winapi.CreateJunction(self.junction_target, self.junction) self.assertTrue(os.path.exists(self.junction)) diff --git a/Misc/NEWS.d/next/Library/2026-08-09-12-00-00.gh-issue-85004.Kp3rZq.rst b/Misc/NEWS.d/next/Library/2026-08-09-12-00-00.gh-issue-85004.Kp3rZq.rst new file mode 100644 index 000000000000000..4f1cf8eada1bb71 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-09-12-00-00.gh-issue-85004.Kp3rZq.rst @@ -0,0 +1,3 @@ +Add the *printname* parameter in :func:`os.readlink`. On Windows it makes the +function return the print name of the link -- the target path as it was +specified when the link was created. diff --git a/Modules/clinic/posixmodule.c.h b/Modules/clinic/posixmodule.c.h index ac9b63dec9eb440..f278c788f9ae72f 100644 --- a/Modules/clinic/posixmodule.c.h +++ b/Modules/clinic/posixmodule.c.h @@ -6645,7 +6645,7 @@ os_unshare(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject * #if (defined(HAVE_READLINK) || defined(MS_WINDOWS)) PyDoc_STRVAR(os_readlink__doc__, -"readlink($module, /, path, *, dir_fd=None)\n" +"readlink($module, /, path, *, dir_fd=None, printname=False)\n" "--\n" "\n" "Return a string representing the path to which the symbolic link points.\n" @@ -6655,13 +6655,19 @@ PyDoc_STRVAR(os_readlink__doc__, "that directory.\n" "\n" "dir_fd may not be implemented on your platform. If it is unavailable,\n" -"using it will raise a NotImplementedError."); +"using it will raise a NotImplementedError.\n" +"\n" +"On Windows, if printname is true, return the print name of the link --\n" +"the target path as it was specified when the link was created -- instead\n" +"of the substitute name used by the system to resolve the link.\n" +"\n" +"printname is ignored on non-Windows platforms."); #define OS_READLINK_METHODDEF \ {"readlink", _PyCFunction_CAST(os_readlink), METH_FASTCALL|METH_KEYWORDS, os_readlink__doc__}, static PyObject * -os_readlink_impl(PyObject *module, path_t *path, int dir_fd); +os_readlink_impl(PyObject *module, path_t *path, int dir_fd, int printname); static PyObject * os_readlink(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) @@ -6669,7 +6675,7 @@ os_readlink(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject PyObject *return_value = NULL; #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) - #define NUM_KEYWORDS 2 + #define NUM_KEYWORDS 3 static struct { PyGC_Head _this_is_not_used; PyObject_VAR_HEAD @@ -6678,7 +6684,7 @@ os_readlink(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject } _kwtuple = { .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) .ob_hash = -1, - .ob_item = { &_Py_ID(path), &_Py_ID(dir_fd), }, + .ob_item = { &_Py_ID(path), &_Py_ID(dir_fd), &_Py_ID(printname), }, }; #undef NUM_KEYWORDS #define KWTUPLE (&_kwtuple.ob_base.ob_base) @@ -6687,17 +6693,18 @@ os_readlink(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject # define KWTUPLE NULL #endif // !Py_BUILD_CORE - static const char * const _keywords[] = {"path", "dir_fd", NULL}; + static const char * const _keywords[] = {"path", "dir_fd", "printname", NULL}; static _PyArg_Parser _parser = { .keywords = _keywords, .fname = "readlink", .kwtuple = KWTUPLE, }; #undef KWTUPLE - PyObject *argsbuf[2]; + PyObject *argsbuf[3]; Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 1; path_t path = PATH_T_INITIALIZE_P("readlink", "path", 0, 0, 0, 0); int dir_fd = DEFAULT_DIR_FD; + int printname = 0; args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf); @@ -6710,11 +6717,20 @@ os_readlink(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject if (!noptargs) { goto skip_optional_kwonly; } - if (!READLINKAT_DIR_FD_CONVERTER(args[1], &dir_fd)) { + if (args[1]) { + if (!READLINKAT_DIR_FD_CONVERTER(args[1], &dir_fd)) { + goto exit; + } + if (!--noptargs) { + goto skip_optional_kwonly; + } + } + printname = PyObject_IsTrue(args[2]); + if (printname < 0) { goto exit; } skip_optional_kwonly: - return_value = os_readlink_impl(module, &path, dir_fd); + return_value = os_readlink_impl(module, &path, dir_fd, printname); exit: /* Cleanup for path */ @@ -13734,4 +13750,4 @@ os__emscripten_log(PyObject *module, PyObject *const *args, Py_ssize_t nargs, Py #ifndef OS__EMSCRIPTEN_LOG_METHODDEF #define OS__EMSCRIPTEN_LOG_METHODDEF #endif /* !defined(OS__EMSCRIPTEN_LOG_METHODDEF) */ -/*[clinic end generated code: output=d641f02a97057666 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=fef260320724ce1e input=a9049054013a1b77]*/ diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index db65d5862440655..05863e860e7846f 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -10930,6 +10930,7 @@ os.readlink path: path_t * dir_fd: dir_fd(requires='readlinkat') = None + printname: bool = False Return a string representing the path to which the symbolic link points. @@ -10939,11 +10940,17 @@ that directory. dir_fd may not be implemented on your platform. If it is unavailable, using it will raise a NotImplementedError. + +On Windows, if printname is true, return the print name of the link -- +the target path as it was specified when the link was created -- instead +of the substitute name used by the system to resolve the link. + +printname is ignored on non-Windows platforms. [clinic start generated code]*/ static PyObject * -os_readlink_impl(PyObject *module, path_t *path, int dir_fd) -/*[clinic end generated code: output=d21b732a2e814030 input=03d10130870dbca8]*/ +os_readlink_impl(PyObject *module, path_t *path, int dir_fd, int printname) +/*[clinic end generated code: output=f4a4454719a32798 input=9877a2bcf1aa0726]*/ { #if defined(HAVE_READLINK) char buffer[MAXPATHLEN+1]; @@ -11019,24 +11026,41 @@ os_readlink_impl(PyObject *module, path_t *path, int dir_fd) wchar_t *name = NULL; Py_ssize_t nameLen = 0; + /* The print name is optional, fall back to the substitute name. */ + int is_printname = 0; if (rdb->ReparseTag == IO_REPARSE_TAG_SYMLINK) { - name = (wchar_t *)((char*)rdb->SymbolicLinkReparseBuffer.PathBuffer + - rdb->SymbolicLinkReparseBuffer.SubstituteNameOffset); - nameLen = rdb->SymbolicLinkReparseBuffer.SubstituteNameLength / sizeof(wchar_t); + USHORT offset = rdb->SymbolicLinkReparseBuffer.SubstituteNameOffset; + USHORT length = rdb->SymbolicLinkReparseBuffer.SubstituteNameLength; + if (printname && rdb->SymbolicLinkReparseBuffer.PrintNameLength) { + offset = rdb->SymbolicLinkReparseBuffer.PrintNameOffset; + length = rdb->SymbolicLinkReparseBuffer.PrintNameLength; + is_printname = 1; + } + name = (wchar_t *)((char*)rdb->SymbolicLinkReparseBuffer.PathBuffer + + offset); + nameLen = length / sizeof(wchar_t); } else if (rdb->ReparseTag == IO_REPARSE_TAG_MOUNT_POINT) { - name = (wchar_t *)((char*)rdb->MountPointReparseBuffer.PathBuffer + - rdb->MountPointReparseBuffer.SubstituteNameOffset); - nameLen = rdb->MountPointReparseBuffer.SubstituteNameLength / sizeof(wchar_t); + USHORT offset = rdb->MountPointReparseBuffer.SubstituteNameOffset; + USHORT length = rdb->MountPointReparseBuffer.SubstituteNameLength; + if (printname && rdb->MountPointReparseBuffer.PrintNameLength) { + offset = rdb->MountPointReparseBuffer.PrintNameOffset; + length = rdb->MountPointReparseBuffer.PrintNameLength; + is_printname = 1; + } + name = (wchar_t *)((char*)rdb->MountPointReparseBuffer.PathBuffer + + offset); + nameLen = length / sizeof(wchar_t); } else { PyErr_SetString(PyExc_ValueError, "not a symbolic link"); } if (name) { - if (nameLen > 4 && wcsncmp(name, L"\\??\\", 4) == 0) { + /* Only the substitute name is in the NT namespace. */ + if (!is_printname && nameLen > 4 && wcsncmp(name, L"\\??\\", 4) == 0) { /* Our buffer is mutable, so this is okay */ name[1] = L'\\'; }