diff --git a/Doc/library/os.rst b/Doc/library/os.rst index 1e54cfec609bd2..d05a237a012a99 100644 --- a/Doc/library/os.rst +++ b/Doc/library/os.rst @@ -2046,6 +2046,39 @@ features: If it's unavailable, using it will raise a :exc:`NotImplementedError`. +.. data:: AT_FDCWD + + .. availability:: Linux. + +.. data:: AT_EMPTY_PATH + + If *src_path* is an empty string, create a link to the file referenced by + *src_dir_fd* (which may have been obtained using the open(2) :data:`O_PATH` + flag). In this case, *src_dir_fd* can refer to any type of file except a + directory. This will generally not work if the file has a link count of + zero (files created with :data:`O_TMPFILE` and without :data:`O_EXCL` are an + exception). + + The caller must have the ``CAP_DAC_READ_SEARCH`` capability in order to use + this flag. + + .. availability:: Linux >= 2.6.39. + +.. data:: AT_SYMLINK_FOLLOW + + By default, :func:`linkat`, does not dereference *src_path* if it is a + symbolic link (like :func:`link`). The flag :data:`!AT_SYMLINK_FOLLOW` can + be specified in flags to cause *src_path* to be dereferenced if it is a + symbolic link. + + If procfs is mounted, this can be used as an alternative to + :data:`AT_EMPTY_PATH`, like this:: + + os.linkat(os.AT_FDCWD, "/proc/self/fd/", + dst_dir_fd, dst_name, os.AT_SYMLINK_FOLLOW) + + .. availability:: Linux >= 2.6.18. + .. function:: access(path, mode, *, dir_fd=None, effective_ids=False, follow_symlinks=True) @@ -2354,6 +2387,36 @@ features: Accepts a :term:`path-like object` for *src* and *dst*. +.. function:: linkat(src_dir_fd, src_path, dst_dir_fd, dst_path, flags, /) + + The :func:`!linkat` function operates in exactly the same way as + :func:`link`, except for the differences described here. + + If the pathname given in *src_path* is relative, then it is interpreted + relative to the directory referred to by the file descriptor *src_dir_fd* + (rather than relative to the current working directory of the calling + process, as is done by link() for a relative pathname). + + If *src_path* is relative and *src_dir_fd* is the special value + :data:`AT_FDCWD`, then *src_path* is interpreted relative to the current + working directory of the calling process (like :func:`link`). + + If *src_path* is absolute, then *src_dir_fd* is ignored. + + The interpretation of *dst_path* is as for *src_path*, except that a + relative pathname is interpreted relative to the directory referred to + by the file descriptor *dst_dir_fd*. + + The following values can be bitwise ORed in flags: + + * :data:`AT_EMPTY_PATH` + * :data:`AT_SYMLINK_FOLLOW` + + .. audit-event:: os.linkat src_dir_fd,src_path,dst_dir_fd,dst_path,flags os.linkat + + .. availability:: Unix. + + .. function:: listdir(path='.') Return a list containing the names of the entries in the directory given by diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 706a816f888b30..c958b079706e41 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -119,6 +119,14 @@ math (Contributed by Bénédikt Tran in :gh:`135853`.) +os +-- + +* Added :func:`os.linkat` to create a hard link with flags. + Added also constants :data:`os.AT_EMPTY_PATH`, :data:`os.AT_FDCWD` and + :data:`os.AT_SYMLINK_FOLLOW`. + (Contributed by Victor Stinner in :gh:`136413`.) + os.path ------- diff --git a/Include/internal/pycore_global_objects_fini_generated.h b/Include/internal/pycore_global_objects_fini_generated.h index c461bc1786ddf4..74aed1e23bf691 100644 --- a/Include/internal/pycore_global_objects_fini_generated.h +++ b/Include/internal/pycore_global_objects_fini_generated.h @@ -919,6 +919,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) { _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(dont_inherit)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(dst)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(dst_dir_fd)); + _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(dst_path)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(eager_start)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(effective_ids)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(element_factory)); @@ -1238,6 +1239,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) { _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(spam)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(src)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(src_dir_fd)); + _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(src_path)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(stacklevel)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(start)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(statement)); diff --git a/Include/internal/pycore_global_strings.h b/Include/internal/pycore_global_strings.h index 72c2051bd97660..c044c865e6013d 100644 --- a/Include/internal/pycore_global_strings.h +++ b/Include/internal/pycore_global_strings.h @@ -410,6 +410,7 @@ struct _Py_global_strings { STRUCT_FOR_ID(dont_inherit) STRUCT_FOR_ID(dst) STRUCT_FOR_ID(dst_dir_fd) + STRUCT_FOR_ID(dst_path) STRUCT_FOR_ID(eager_start) STRUCT_FOR_ID(effective_ids) STRUCT_FOR_ID(element_factory) @@ -729,6 +730,7 @@ struct _Py_global_strings { STRUCT_FOR_ID(spam) STRUCT_FOR_ID(src) STRUCT_FOR_ID(src_dir_fd) + STRUCT_FOR_ID(src_path) STRUCT_FOR_ID(stacklevel) STRUCT_FOR_ID(start) STRUCT_FOR_ID(statement) diff --git a/Include/internal/pycore_runtime_init_generated.h b/Include/internal/pycore_runtime_init_generated.h index d378fcae26cf35..de2f972bd3b8e6 100644 --- a/Include/internal/pycore_runtime_init_generated.h +++ b/Include/internal/pycore_runtime_init_generated.h @@ -917,6 +917,7 @@ extern "C" { INIT_ID(dont_inherit), \ INIT_ID(dst), \ INIT_ID(dst_dir_fd), \ + INIT_ID(dst_path), \ INIT_ID(eager_start), \ INIT_ID(effective_ids), \ INIT_ID(element_factory), \ @@ -1236,6 +1237,7 @@ extern "C" { INIT_ID(spam), \ INIT_ID(src), \ INIT_ID(src_dir_fd), \ + INIT_ID(src_path), \ INIT_ID(stacklevel), \ INIT_ID(start), \ INIT_ID(statement), \ diff --git a/Include/internal/pycore_unicodeobject_generated.h b/Include/internal/pycore_unicodeobject_generated.h index e516211f6c6cbc..1f116cab930972 100644 --- a/Include/internal/pycore_unicodeobject_generated.h +++ b/Include/internal/pycore_unicodeobject_generated.h @@ -1428,6 +1428,10 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) { _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); assert(PyUnicode_GET_LENGTH(string) != 1); + string = &_Py_ID(dst_path); + _PyUnicode_InternStatic(interp, &string); + assert(_PyUnicode_CheckConsistency(string, 1)); + assert(PyUnicode_GET_LENGTH(string) != 1); string = &_Py_ID(eager_start); _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); @@ -2704,6 +2708,10 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) { _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); assert(PyUnicode_GET_LENGTH(string) != 1); + string = &_Py_ID(src_path); + _PyUnicode_InternStatic(interp, &string); + assert(_PyUnicode_CheckConsistency(string, 1)); + assert(PyUnicode_GET_LENGTH(string) != 1); string = &_Py_ID(stacklevel); _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); diff --git a/Lib/test/test_os.py b/Lib/test/test_os.py index 5217037ae9d812..5b7873ed35068e 100644 --- a/Lib/test/test_os.py +++ b/Lib/test/test_os.py @@ -2655,6 +2655,74 @@ def test_unicode_name(self): self.file2 = self.file1 + "2" self._test_link(self.file1, self.file2) + +@unittest.skipUnless(hasattr(os, 'linkat'), 'requires os.linkat') +class LinkAtTests(unittest.TestCase): + @staticmethod + def linkat(*args): + try: + os.linkat(*args) + except OSError as exc: + if exc.errno == errno.ENOSYS: + self.skipTest(str(exc)) + raise + + def test_no_flags(self): + src = "linkat_src" + self.addCleanup(os_helper.unlink, src) + with open(src, "w", encoding='utf8') as fp: + fp.write("hello") + + dst = "linkat_dst" + self.addCleanup(os_helper.unlink, dst) + self.linkat(os.AT_FDCWD, src, os.AT_FDCWD, dst) # flags=0 + + with open(dst, encoding='utf8') as fp: + self.assertEqual(fp.read(), 'hello') + + def test_destination_exists(self): + src = "linkat_src" + self.addCleanup(os_helper.unlink, src) + open(src, "w").close() + + dst = "linkat_dst" + self.addCleanup(os_helper.unlink, dst) + open(dst, "w").close() + + with self.assertRaises(FileExistsError): + self.linkat(os.AT_FDCWD, src, os.AT_FDCWD, dst) # flags=0 + + @unittest.skipUnless(hasattr(os, 'O_TMPFILE'), 'need os.O_TMPFILE') + def check_flag(self, flag): + filename = os_helper.TESTFN + self.addCleanup(os_helper.unlink, filename) + + fd = os.open(os.path.curdir, os.O_WRONLY | os.O_TMPFILE, 0o600) + os.write(fd, b"hello") + if flag == os.AT_EMPTY_PATH: + os.linkat(fd, b"", + os.AT_FDCWD, filename, + os.AT_EMPTY_PATH) + else: + os.linkat(fd, f"/proc/self/fd/{fd}", + os.AT_FDCWD, filename, + os.AT_SYMLINK_FOLLOW) + os.close(fd) + + with open(filename, encoding='utf8') as fp: + self.assertEqual(fp.read(), 'hello') + + @unittest.skipUnless(hasattr(os, 'AT_EMPTY_PATH'), + 'need os.AT_EMPTY_PATH') + def test_empty_path(self): + self.check_flag(os.AT_EMPTY_PATH) + + @unittest.skipUnless(hasattr(os, 'AT_SYMLINK_FOLLOW'), + 'need os.AT_SYMLINK_FOLLOW') + def test_symlink_follow(self): + self.check_flag(os.AT_SYMLINK_FOLLOW) + + @unittest.skipIf(sys.platform == "win32", "Posix specific tests") class PosixUidGidTests(unittest.TestCase): # uid_t and gid_t are 32-bit unsigned integers on Linux diff --git a/Misc/NEWS.d/next/Library/2025-07-08-13-54-14.gh-issue-136413.imf6xb.rst b/Misc/NEWS.d/next/Library/2025-07-08-13-54-14.gh-issue-136413.imf6xb.rst new file mode 100644 index 00000000000000..6fc60cb5d31a6f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-08-13-54-14.gh-issue-136413.imf6xb.rst @@ -0,0 +1,4 @@ +Added :func:`os.linkat` to create a hard link with flags. Patch by Victor +Added also constants :data:`os.AT_EMPTY_PATH`, :data:`os.AT_FDCWD` and +:data:`os.AT_SYMLINK_FOLLOW`. +Stinner. diff --git a/Modules/clinic/posixmodule.c.h b/Modules/clinic/posixmodule.c.h index 3621a0625411d3..8d2525d5bd4d71 100644 --- a/Modules/clinic/posixmodule.c.h +++ b/Modules/clinic/posixmodule.c.h @@ -1580,6 +1580,98 @@ os_link(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwn #endif /* defined(HAVE_LINK) */ +#if defined(HAVE_LINKAT) + +PyDoc_STRVAR(os_linkat__doc__, +"linkat($module, /, src_dir_fd, src_path, dst_dir_fd, dst_path, flags=0)\n" +"--\n" +"\n" +"Create a hard link to a file with flags."); + +#define OS_LINKAT_METHODDEF \ + {"linkat", _PyCFunction_CAST(os_linkat), METH_FASTCALL|METH_KEYWORDS, os_linkat__doc__}, + +static PyObject * +os_linkat_impl(PyObject *module, int src_dir_fd, path_t *src_path, + int dst_dir_fd, path_t *dst_path, int flags); + +static PyObject * +os_linkat(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 5 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { &_Py_ID(src_dir_fd), &_Py_ID(src_path), &_Py_ID(dst_dir_fd), &_Py_ID(dst_path), &_Py_ID(flags), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"src_dir_fd", "src_path", "dst_dir_fd", "dst_path", "flags", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "linkat", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[5]; + Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 4; + int src_dir_fd = DEFAULT_DIR_FD; + path_t src_path = PATH_T_INITIALIZE_P("linkat", "src_path", 0, 0, 0, 0); + int dst_dir_fd = DEFAULT_DIR_FD; + path_t dst_path = PATH_T_INITIALIZE_P("linkat", "dst_path", 0, 0, 0, 0); + int flags = 0; + + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, + /*minpos*/ 4, /*maxpos*/ 5, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + if (!dir_fd_converter(args[0], &src_dir_fd)) { + goto exit; + } + if (!path_converter(args[1], &src_path)) { + goto exit; + } + if (!dir_fd_converter(args[2], &dst_dir_fd)) { + goto exit; + } + if (!path_converter(args[3], &dst_path)) { + goto exit; + } + if (!noptargs) { + goto skip_optional_pos; + } + flags = PyLong_AsInt(args[4]); + if (flags == -1 && PyErr_Occurred()) { + goto exit; + } +skip_optional_pos: + return_value = os_linkat_impl(module, src_dir_fd, &src_path, dst_dir_fd, &dst_path, flags); + +exit: + /* Cleanup for src_path */ + path_cleanup(&src_path); + /* Cleanup for dst_path */ + path_cleanup(&dst_path); + + return return_value; +} + +#endif /* defined(HAVE_LINKAT) */ + PyDoc_STRVAR(os_listdir__doc__, "listdir($module, /, path=None)\n" "--\n" @@ -12787,6 +12879,10 @@ os__emscripten_debugger(PyObject *module, PyObject *Py_UNUSED(ignored)) #define OS_LINK_METHODDEF #endif /* !defined(OS_LINK_METHODDEF) */ +#ifndef OS_LINKAT_METHODDEF + #define OS_LINKAT_METHODDEF +#endif /* !defined(OS_LINKAT_METHODDEF) */ + #ifndef OS_LISTDRIVES_METHODDEF #define OS_LISTDRIVES_METHODDEF #endif /* !defined(OS_LISTDRIVES_METHODDEF) */ @@ -13398,4 +13494,4 @@ os__emscripten_debugger(PyObject *module, PyObject *Py_UNUSED(ignored)) #ifndef OS__EMSCRIPTEN_DEBUGGER_METHODDEF #define OS__EMSCRIPTEN_DEBUGGER_METHODDEF #endif /* !defined(OS__EMSCRIPTEN_DEBUGGER_METHODDEF) */ -/*[clinic end generated code: output=ae64df0389746258 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=3bf4693908acf071 input=a9049054013a1b77]*/ diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index b570f81b7cf7c2..ff51903b2a68f7 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -4448,6 +4448,53 @@ os_link_impl(PyObject *module, path_t *src, path_t *dst, int src_dir_fd, #endif +#ifdef HAVE_LINKAT +/*[clinic input] + +os.linkat + + src_dir_fd : dir_fd + src_path : path_t + dst_dir_fd : dir_fd + dst_path : path_t + flags : int = 0 + +Create a hard link to a file with flags. +[clinic start generated code]*/ + +static PyObject * +os_linkat_impl(PyObject *module, int src_dir_fd, path_t *src_path, + int dst_dir_fd, path_t *dst_path, int flags) +/*[clinic end generated code: output=8582ba3975d7ac3f input=82fe7b9292aa5e10]*/ +{ + if (PySys_Audit("os.linkat", "iOiOi", + src_dir_fd, src_path->object, + dst_dir_fd, dst_path->object, + flags) < 0) { + return NULL; + } + + int result; + Py_BEGIN_ALLOW_THREADS + if (HAVE_LINKAT_RUNTIME) { + result = linkat(src_dir_fd, src_path->narrow, + dst_dir_fd, dst_path->narrow, + flags); + } + else { + errno = ENOSYS; + result = -1; + } + Py_END_ALLOW_THREADS + + if (result) + return path_error2(src_path, dst_path); + + Py_RETURN_NONE; +} +#endif + + #if defined(MS_WINDOWS) && !defined(HAVE_OPENDIR) static PyObject * _listdir_windows_no_opendir(path_t *path, PyObject *list) @@ -16992,6 +17039,7 @@ static PyMethodDef posix_methods[] = { OS_GETCWD_METHODDEF OS_GETCWDB_METHODDEF OS_LINK_METHODDEF + OS_LINKAT_METHODDEF OS_LISTDIR_METHODDEF OS_LISTDRIVES_METHODDEF OS_LISTMOUNTS_METHODDEF @@ -17828,6 +17876,16 @@ all_ins(PyObject *m) if (PyModule_AddIntConstant(m, "_LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR", LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR)) return -1; #endif +#ifdef AT_FDCWD + if (PyModule_AddIntMacro(m, AT_FDCWD)) return -1; +#endif +#ifdef AT_EMPTY_PATH + if (PyModule_AddIntMacro(m, AT_EMPTY_PATH)) return -1; +#endif +#ifdef AT_SYMLINK_FOLLOW + if (PyModule_AddIntMacro(m, AT_SYMLINK_FOLLOW)) return -1; +#endif + return 0; }