https://github.com/python/cpython/commit/4b0e334843b76043c8e69b16ceb4c8478f134c01
commit: 4b0e334843b76043c8e69b16ceb4c8478f134c01
branch: main
author: OOTS <[email protected]>
committer: encukou <[email protected]>
date: 2026-10-13T16:26:22Z
summary:

gh-157899: Add support for fds pointing to symlinks in os.readlink(). 
(GH-157915)

files:
A Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst
M Doc/library/os.rst
M Doc/whatsnew/3.16.rst
M Lib/os.py
M Lib/test/test_os/test_posix.py
M Modules/clinic/posixmodule.c.h
M Modules/posixmodule.c
M configure
M configure.ac
M pyconfig.h.in

diff --git a/Doc/library/os.rst b/Doc/library/os.rst
index 9eba3bafae0cb6e..d1441bb544cdb1c 100644
--- a/Doc/library/os.rst
+++ b/Doc/library/os.rst
@@ -2799,14 +2799,23 @@ features:
    may be converted to an absolute pathname using
    ``os.path.join(os.path.dirname(path), result)``.
 
-   If the *path* is a string object (directly or indirectly through a
-   :class:`PathLike` interface), the result will also be a string object,
-   and the call may raise a UnicodeDecodeError. If the *path* is a bytes
-   object (direct or indirectly), the result will be a bytes object.
-
    This function can also support :ref:`paths relative to directory descriptors
    <dir_fd>`.
 
+   On Linux, Android, macOS and iOS, *path* can also be a file descriptor 
referring
+   to a symbolic link. In that case, *dir_fd* must be ``None``.
+   (On Linux and Android, such a file descriptor must be obtained through
+   :func:`os.open` with ``os.O_RDONLY | os.O_PATH | os.O_NOFOLLOW``.
+   On macOS and iOS, :func:`os.open` must be called with
+   ``os.O_RDONLY | os.O_SYMLINK``.)
+   On other operating systems, a ``NotImplementedError`` is raised if *path*
+   is an integer.
+
+   If the *path* is a string object (directly or indirectly through a
+   :class:`PathLike` interface) or a file descriptor, the result will be a
+   string object, and the call may raise a UnicodeDecodeError. If the *path*
+   is a bytes object (direct or indirectly), the result will be a bytes object.
+
    When trying to resolve a path that may contain links, use
    :func:`~os.path.realpath` to properly handle recursion and platform
    differences.
@@ -2829,6 +2838,10 @@ features:
       substitution path (which typically includes ``\\?\`` prefix) rather
       than the optional "print name" field that was previously returned.
 
+   .. versionchanged:: next
+      Accepts file descriptors pointing to symbolic links as *path* on
+      Linux, Android, macOS and iOS.
+
 .. 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 d9e12df91adefac..a1d7d1eaae7d526 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -531,6 +531,10 @@ os
   now raises :exc:`PermissionError` instead of the functions being unavailable.
   (Contributed by Md Arif in :gh:`152936`.)
 
+* :func:`os.readlink` now accepts a file descriptor referring to a symlink on
+  Linux, Android, macOS and iOS.
+  (Contributed by OOTS in :gh:`157899`.)
+
 
 pydoc
 -----
diff --git a/Lib/os.py b/Lib/os.py
index 98d476f98419b20..f999150150ccba1 100644
--- a/Lib/os.py
+++ b/Lib/os.py
@@ -161,6 +161,9 @@ def _add(str, fn):
         _add("HAVE_FSTATVFS", "statvfs")
     if _exists("statx"):
         _set.add(statx)
+    _add("HAVE_FREADLINK", "readlink")
+    if sys.platform in ["linux", "android"] and "O_PATH" in _globals:
+        _add("HAVE_READLINKAT", "readlink")
     supports_fd = _set
 
     _set = set()
diff --git a/Lib/test/test_os/test_posix.py b/Lib/test/test_os/test_posix.py
index f13ad46aac45aa1..7b8f8d2ddadd96b 100644
--- a/Lib/test/test_os/test_posix.py
+++ b/Lib/test/test_os/test_posix.py
@@ -1881,6 +1881,74 @@ def test_readlink_dir_fd(self):
             self.addCleanup(posix.unlink, fullname)
             self.assertEqual(posix.readlink(name, dir_fd=dir_fd), 'symlink')
 
+
+    _support_readlink_with_fd = (
+        hasattr(os, 'readlink')
+        and os.readlink in os.supports_fd
+    )
+
+    def _open_symlink_as_fd(self, path):
+        open_flags = os.O_RDONLY
+        if hasattr(os, "O_SYMLINK"):  # macOS/iOS
+            open_flags |= os.O_SYMLINK
+        elif hasattr(os, "O_NOFOLLOW") and hasattr(os, "O_PATH"):  # 
Linux/Android
+            open_flags |= os.O_NOFOLLOW | os.O_PATH
+        else:
+            self.fail("lacking open flags for this test")
+        return os.open(path, open_flags)
+
+    @unittest.skipUnless(_support_readlink_with_fd,
+                         "feature not supported on this platform")
+    def test_readlink_with_fd(self):
+        with self.prepare() as (dir_fd, name, fullname):
+            os.symlink("symlink", fullname)
+            self.addCleanup(posix.unlink, fullname)
+            fd = self._open_symlink_as_fd(fullname)
+            self.addCleanup(os.close, fd)
+            self.assertEqual(os.readlink(fd), "symlink")
+
+    @unittest.skipUnless(_support_readlink_with_fd,
+                         "feature not supported on this platform")
+    def test_readlink_with_fd_not_referring_to_symlink_throws(self):
+        with self.prepare_file() as (dir_fd, name, fullname):
+            fd = os.open(fullname, os.O_RDONLY)
+            self.addCleanup(os.close, fd)
+            # on Linux/Android, readlinkat("", fd, ...) fails with ENOENT, 
which
+            # Python translates to a FileNotFoundError, a subclass of OSError.
+            # On macOS, freadlink(fd, ...) fails with EINVAL, which gets raised
+            # as a OSError.
+            # So catching OSError here covers both cases.
+            with self.assertRaises(OSError):
+                os.readlink(fd)
+
+    @unittest.skipUnless(_support_readlink_with_fd,
+                         "feature not supported on this platform")
+    def test_readlink_with_fd_throws_if_both_fd_and_dir_fd_are_given(self):
+        with self.prepare() as (dir_fd, name, fullname):
+            os.symlink("symlink", fullname)
+            self.addCleanup(posix.unlink, fullname)
+            fd = self._open_symlink_as_fd(fullname)
+            self.addCleanup(os.close, fd)
+            with self.assertRaises(ValueError):
+                os.readlink(fd, dir_fd=dir_fd)
+
+    @unittest.skipIf(_support_readlink_with_fd,
+                     "feature is supported on this platform")
+    def test_readlink_with_fd_throws_not_implemented_error(self):
+        # on unsupported platforms, we may not even be able to get a
+        # file descriptor for a symlink, so use a fd for an ordinary file
+        os_helper.create_empty_file(os_helper.TESTFN)
+        self.addCleanup(os_helper.unlink, os_helper.TESTFN)
+        fd = os.open(os_helper.TESTFN, os.O_RDONLY)
+        self.addCleanup(os.close, fd)
+        with self.assertRaises(NotImplementedError):
+            os.readlink(fd)
+
+    @unittest.skipUnless(sys.platform in ["linux", "android", "darwin", "ios"],
+                         "feature not supported on this platform")
+    def test_readlink_is_marked_as_supports_fd(self):
+        self.assertIn(os.readlink, os.supports_fd)
+
     @unittest.skipUnless(os.rename in os.supports_dir_fd, "test needs dir_fd 
support in os.rename()")
     def test_rename_dir_fd(self):
         with self.prepare_file() as (dir_fd, name, fullname), \
@@ -2620,6 +2688,17 @@ def test_readlink(self):
             with self.assertRaisesRegex(NotImplementedError, "dir_fd 
unavailable"):
                 os.readlink("path",  dir_fd=0)
 
+    def test_freadlink(self):
+        self._verify_available("HAVE_FREADLINK")
+        if self.mac_ver >= (13, 0):
+            self.assertIn("HAVE_FREADLINK", posix._have_functions)
+
+        else:
+            self.assertNotIn("HAVE_FREADLINK", posix._have_functions)
+            errmsg = "readlink cannot read file descriptors on this platform"
+            with self.assertRaisesRegex(NotImplementedError, errmsg):
+                os.readlink(0)
+
     def test_symlink(self):
         self._verify_available("HAVE_SYMLINKAT")
         if self.mac_ver >= (10, 10):
diff --git 
a/Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst 
b/Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst
new file mode 100644
index 000000000000000..d70cef97fb20762
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst
@@ -0,0 +1,2 @@
+:func:`os.readlink` now accepts a file descriptor referring to a
+symlink on Linux, Android, macOS and iOS.
diff --git a/Modules/clinic/posixmodule.c.h b/Modules/clinic/posixmodule.c.h
index 7f4bab7de9488c8..3c3e46e065f3ea7 100644
--- a/Modules/clinic/posixmodule.c.h
+++ b/Modules/clinic/posixmodule.c.h
@@ -6647,7 +6647,10 @@ 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 Linux, Android, macOS and iOS, path may be a file descriptor\n"
+"referring to a symlink. If it is, dir_fd must be None.");
 
 #define OS_READLINK_METHODDEF    \
     {"readlink", _PyCFunction_CAST(os_readlink), METH_FASTCALL|METH_KEYWORDS, 
os_readlink__doc__},
@@ -6688,7 +6691,7 @@ os_readlink(PyObject *module, PyObject *const *args, 
Py_ssize_t nargs, PyObject
     #undef KWTUPLE
     PyObject *argsbuf[2];
     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);
+    path_t path = PATH_T_INITIALIZE_P("readlink", "path", 0, 0, 0, 1);
     int dir_fd = DEFAULT_DIR_FD;
 
     args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser,
@@ -13760,4 +13763,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=f1113bf7c270d271 input=a9049054013a1b77]*/
+/*[clinic end generated code: output=8bf88a95035a0188 input=a9049054013a1b77]*/
diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c
index 028270e7b841fda..8b226d73a276061 100644
--- a/Modules/posixmodule.c
+++ b/Modules/posixmodule.c
@@ -505,6 +505,7 @@ static const unsigned int _Py_STATX_KNOWN = 
(STATX_BASIC_STATS | STATX_BTIME
 #  define HAVE_UNLINKAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *)
 #  define HAVE_OPENAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *)
 #  define HAVE_READLINKAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *)
+#  define HAVE_FREADLINK_RUNTIME __builtin_available(macOS 13.0, iOS 16.0, *)
 #  define HAVE_SYMLINKAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *)
 #  define HAVE_FUTIMENS_RUNTIME __builtin_available(macOS 10.13, iOS 11.0, 
tvOS 11.0, watchOS 4.0, *)
 #  define HAVE_UTIMENSAT_RUNTIME __builtin_available(macOS 10.13, iOS 11.0, 
tvOS 11.0, watchOS 4.0, *)
@@ -571,6 +572,10 @@ static const unsigned int _Py_STATX_KNOWN = 
(STATX_BASIC_STATS | STATX_BTIME
 #    define HAVE_READLINKAT_RUNTIME (readlinkat != NULL)
 #  endif
 
+#  ifdef _Py_HAVE_FREADLINK
+#    define HAVE_FREADLINK_RUNTIME (freadlink != NULL)
+#  endif
+
 #  ifdef HAVE_SYMLINKAT
 #    define HAVE_SYMLINKAT_RUNTIME (symlinkat != NULL)
 #  endif
@@ -10979,11 +10984,19 @@ os_unshare_impl(PyObject *module, int flags)
 #endif
 
 
+
 #if defined(HAVE_READLINK) || defined(MS_WINDOWS)
+
+#if (defined(__linux__) || defined(__ANDROID__)) && defined(O_PATH)
+// readlinkat(fd, "", ...) reads the symlink that fd refers to.
+// supported since Linux 2.6.39 (same version that O_PATH was introduced).
+#define _Py_READLINKAT_SUPPORTS_EMPTY_PATH
+#endif
+
 /*[clinic input]
 os.readlink
 
-    path: path_t
+    path: path_t(allow_fd=True)
     *
     dir_fd: dir_fd(requires='readlinkat') = None
 
@@ -10995,48 +11008,74 @@ that directory.
 
 dir_fd may not be implemented on your platform.  If it is unavailable,
 using it will raise a NotImplementedError.
+
+On Linux, Android, macOS and iOS, path may be a file descriptor
+referring to a symlink. If it is, dir_fd must be None.
 [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]*/
+/*[clinic end generated code: output=d21b732a2e814030 input=d90bddfeee86f673]*/
 {
 #if defined(HAVE_READLINK)
     char buffer[MAXPATHLEN+1];
     ssize_t length;
-#ifdef HAVE_READLINKAT
-    int readlinkat_unavailable = 0;
-#endif
 
-    Py_BEGIN_ALLOW_THREADS
+    if (path_and_dir_fd_invalid("readlink", path, dir_fd)) {
+        return NULL;
+    }
+
+    if (path->is_fd) {
+#if defined(_Py_HAVE_FREADLINK)
+        if (HAVE_FREADLINK_RUNTIME) {
+            Py_BEGIN_ALLOW_THREADS
+            length = freadlink(path->fd, buffer, MAXPATHLEN);
+            Py_END_ALLOW_THREADS
+        } else
+#elif defined(HAVE_READLINKAT) && defined(_Py_READLINKAT_SUPPORTS_EMPTY_PATH)
+        // linux/android:
+        // readlinkat(fd, "", ...) reads the link that fd refers to.
+        if (HAVE_READLINKAT_RUNTIME) {
+            Py_BEGIN_ALLOW_THREADS
+            length = readlinkat(path->fd, "", buffer, MAXPATHLEN);
+            Py_END_ALLOW_THREADS
+        } else
+#endif
+        {
+            PyErr_SetString(PyExc_NotImplementedError,
+                "readlink cannot read file descriptors on this platform");
+            return NULL;
+        }
+    } else
 #ifdef HAVE_READLINKAT
     if (dir_fd != DEFAULT_DIR_FD) {
         if (HAVE_READLINKAT_RUNTIME) {
+            Py_BEGIN_ALLOW_THREADS
             length = readlinkat(dir_fd, path->narrow, buffer, MAXPATHLEN);
+            Py_END_ALLOW_THREADS
         } else {
-            readlinkat_unavailable = 1;
+            argument_unavailable_error(NULL, "dir_fd");
+            return NULL;
         }
     } else
 #endif
+    {
+        Py_BEGIN_ALLOW_THREADS
         length = readlink(path->narrow, buffer, MAXPATHLEN);
-    Py_END_ALLOW_THREADS
-
-#ifdef HAVE_READLINKAT
-    if (readlinkat_unavailable) {
-        argument_unavailable_error(NULL, "dir_fd");
-        return NULL;
+        Py_END_ALLOW_THREADS
     }
-#endif
 
     if (length < 0) {
         return path_error(path);
     }
     buffer[length] = '\0';
 
-    if (PyUnicode_Check(path->object))
+    if (path->is_fd || PyUnicode_Check(path->object)) {
         return PyUnicode_DecodeFSDefaultAndSize(buffer, length);
-    else
+    }
+    else {
         return PyBytes_FromStringAndSize(buffer, length);
+    }
 #elif defined(MS_WINDOWS)
     DWORD n_bytes_returned;
     DWORD io_result = 0;
@@ -11045,6 +11084,12 @@ os_readlink_impl(PyObject *module, path_t *path, int 
dir_fd)
     _Py_REPARSE_DATA_BUFFER *rdb = (_Py_REPARSE_DATA_BUFFER *)target_buffer;
     PyObject *result = NULL;
 
+    if (path->is_fd) {
+        PyErr_SetString(PyExc_NotImplementedError,
+            "readlink cannot read file descriptors on this platform");
+        return NULL;
+    }
+
     /* First get a handle to the reparse point */
     Py_BEGIN_ALLOW_THREADS
     reparse_point_handle = CreateFileW(
@@ -18878,6 +18923,10 @@ PROBE(probe_openat, HAVE_OPENAT_RUNTIME)
 PROBE(probe_readlinkat, HAVE_READLINKAT_RUNTIME)
 #endif
 
+#ifdef _Py_HAVE_FREADLINK
+PROBE(probe_freadlink, HAVE_FREADLINK_RUNTIME)
+#endif
+
 #ifdef HAVE_SYMLINKAT
 PROBE(probe_symlinkat, HAVE_SYMLINKAT_RUNTIME)
 #endif
@@ -18945,6 +18994,10 @@ static const struct have_function {
     { "HAVE_FPATHCONF", NULL },
 #endif
 
+#ifdef _Py_HAVE_FREADLINK
+    { "HAVE_FREADLINK", probe_freadlink },
+#endif
+
 #ifdef HAVE_FSTATAT
     { "HAVE_FSTATAT", probe_fstatat },
 #endif
diff --git a/configure b/configure
index c777d8117c66c4c..7a4c4ac9e1faeb1 100755
--- a/configure
+++ b/configure
@@ -21732,6 +21732,48 @@ fi
 
 
 
+  { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for freadlink" >&5
+printf %s "checking for freadlink... " >&6; }
+if test ${ac_cv_func_freadlink+y}
+then :
+  printf %s "(cached) " >&6
+else case e in #(
+  e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+#include <unistd.h>
+int
+main (void)
+{
+void *x=freadlink
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_compile "$LINENO"
+then :
+  ac_cv_func_freadlink=yes
+else case e in #(
+  e) ac_cv_func_freadlink=no ;;
+esac
+fi
+rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext
+   ;;
+esac
+fi
+{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_func_freadlink" 
>&5
+printf "%s\n" "$ac_cv_func_freadlink" >&6; }
+  if test "x$ac_cv_func_freadlink" = xyes
+then :
+
+printf "%s\n" "#define _Py_HAVE_FREADLINK 1" >>confdefs.h
+
+fi
+
+
+
+
+
+
   { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for strerror_r" >&5
 printf %s "checking for strerror_r... " >&6; }
 if test ${ac_cv_func_strerror_r+y}
diff --git a/configure.ac b/configure.ac
index a8760c94ac151aa..6ac3c887ae2917e 100644
--- a/configure.ac
+++ b/configure.ac
@@ -5584,6 +5584,7 @@ AC_CHECK_FUNCS([ \
   wait wait3 wait4 waitid waitpid wcscoll wcsftime wcsxfrm wmemcmp writev \
 ])
 
+PY_CHECK_FUNC_PRIVATE([freadlink], [@%:@include <unistd.h>])
 PY_CHECK_FUNC_PRIVATE([strerror_r], [@%:@include <string.h>])
 
 
diff --git a/pyconfig.h.in b/pyconfig.h.in
index 08306a1dc8ba135..65e235c8fd6c936 100644
--- a/pyconfig.h.in
+++ b/pyconfig.h.in
@@ -2201,6 +2201,9 @@
 /* Defined if _Float16 C type is supported */
 #undef _Py_HAVE_FLOAT16
 
+/* Define if you have the 'freadlink' function. */
+#undef _Py_HAVE_FREADLINK
+
 /* Define if you have a working iconv() function. */
 #undef _Py_HAVE_ICONV
 

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to