Skip to content

gh-127350: Add Py_fopen() function #127821

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Doc/c-api/sys.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,38 @@ Operating System Utilities
The function now uses the UTF-8 encoding on Windows if
:c:member:`PyPreConfig.legacy_windows_fs_encoding` is zero.

.. c:function:: FILE* Py_fopen(PyObject *path, const char *mode)

Similar to :c:func:`!fopen`, but *path* is a Python object and
an exception is set on error.

*path* must be a :class:`str` object, a :class:`bytes` object,
or a :term:`path-like object`.

On success, return the new file pointer.
On error, set an exception and return ``NULL``.

The file must be closed by :c:func:`Py_fclose` rather than calling directly
:c:func:`!fclose`.

The file descriptor is created non-inheritable (:pep:`446`).

The caller must hold the GIL.

.. versionadded:: next


.. c:function:: int Py_fclose(FILE *file)

Close a file that was opened by :c:func:`Py_fopen`.

On success, return ``0``.
On error, return ``EOF`` and ``errno`` is set to indicate the error.
In either case, any further access (including another call to
:c:func:`Py_fclose`) to the stream results in undefined behavior.

.. versionadded:: next


.. _systemfunctions:

Expand Down
6 changes: 6 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,12 @@ New features
:monitoring-event:`BRANCH_LEFT` and :monitoring-event:`BRANCH_RIGHT`
events, respectively.

* Add :c:func:`Py_fopen` function to open a file. Similar to the
:c:func:`!fopen` function, but the *path* parameter is a Python object and an
exception is set on error. Add also :c:func:`Py_fclose` function to close a
file.
(Contributed by Victor Stinner in :gh:`127350`.)


Porting to Python 3.14
----------------------
Expand Down
10 changes: 8 additions & 2 deletions Include/cpython/fileutils.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
# error "this header file must not be included directly"
#endif

// Used by _testcapi which must not use the internal C API
PyAPI_FUNC(FILE*) _Py_fopen_obj(
PyAPI_FUNC(FILE*) Py_fopen(
PyObject *path,
const char *mode);

// Deprecated alias to Py_fopen() kept for backward compatibility
Py_DEPRECATED(3.14) PyAPI_FUNC(FILE*) _Py_fopen_obj(
PyObject *path,
const char *mode);

PyAPI_FUNC(int) Py_fclose(FILE *file);
67 changes: 67 additions & 0 deletions Lib/test/test_capi/test_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import os
import unittest
from test import support
from test.support import import_helper, os_helper

_testcapi = import_helper.import_module('_testcapi')


class CAPIFileTest(unittest.TestCase):
def test_py_fopen(self):
# Test Py_fopen() and Py_fclose()

with open(__file__, "rb") as fp:
source = fp.read()

for filename in (__file__, os.fsencode(__file__)):
with self.subTest(filename=filename):
data = _testcapi.py_fopen(filename, "rb")
self.assertEqual(data, source[:256])

data = _testcapi.py_fopen(os_helper.FakePath(filename), "rb")
self.assertEqual(data, source[:256])

filenames = [
os_helper.TESTFN,
os.fsencode(os_helper.TESTFN),
]
# TESTFN_UNDECODABLE cannot be used to create a file on macOS/WASI.
if os_helper.TESTFN_UNENCODABLE is not None:
filenames.append(os_helper.TESTFN_UNENCODABLE)
for filename in filenames:
with self.subTest(filename=filename):
try:
with open(filename, "wb") as fp:
fp.write(source)

data = _testcapi.py_fopen(filename, "rb")
self.assertEqual(data, source[:256])
finally:
os_helper.unlink(filename)

# embedded null character/byte in the filename
with self.assertRaises(ValueError):
_testcapi.py_fopen("a\x00b", "rb")
with self.assertRaises(ValueError):
_testcapi.py_fopen(b"a\x00b", "rb")

# non-ASCII mode failing with "Invalid argument"
with self.assertRaises(OSError):
_testcapi.py_fopen(__file__, "\xe9")
Copy link
Member

@serhiy-storchaka serhiy-storchaka Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"\xe9" is encoded to b'\xc3\xa9'. Please test also with non-UTF-8 bytes. You may get different error on Windows. Actually, it may depend on the locale.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not possible to pass non-UTF-8 bytes, PySys_Audit() decodes the mode from UTF-8 in strict mode:

    if (PySys_Audit("open", "Osi", path, mode, 0) < 0) {
        return NULL;
    }

I don't think that it's worth to "support" non-UTF-8 just for the test, whereas it's rejected anyway by fopen().


# invalid filename type
for invalid_type in (123, object()):
with self.subTest(filename=invalid_type):
with self.assertRaises(TypeError):
_testcapi.py_fopen(invalid_type, "rb")

if support.MS_WINDOWS:
with self.assertRaises(OSError):
# On Windows, the file mode is limited to 10 characters
_testcapi.py_fopen(__file__, "rt+, ccs=UTF-8")

# CRASHES py_fopen(__file__, None)


if __name__ == "__main__":
unittest.main()
3 changes: 1 addition & 2 deletions Lib/test/test_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -1325,8 +1325,7 @@ def test_load_verify_cadata(self):
def test_load_dh_params(self):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_dh_params(DHFILE)
if os.name != 'nt':
ctx.load_dh_params(BYTES_DHFILE)
ctx.load_dh_params(BYTES_DHFILE)
self.assertRaises(TypeError, ctx.load_dh_params)
self.assertRaises(TypeError, ctx.load_dh_params, None)
with self.assertRaises(FileNotFoundError) as cm:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Add :c:func:`Py_fopen` function to open a file. Similar to the :c:func:`!fopen`
function, but the *path* parameter is a Python object and an exception is set
on error. Add also :c:func:`Py_fclose` function to close a file, function
needed for Windows support.
Patch by Victor Stinner.
2 changes: 1 addition & 1 deletion Modules/_ssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -4377,7 +4377,7 @@ _ssl__SSLContext_load_dh_params_impl(PySSLContext *self, PyObject *filepath)
FILE *f;
DH *dh;

f = _Py_fopen_obj(filepath, "rb");
f = Py_fopen(filepath, "rb");
if (f == NULL)
return NULL;

Expand Down
4 changes: 2 additions & 2 deletions Modules/_ssl/debughelpers.c
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ _PySSLContext_set_keylog_filename(PySSLContext *self, PyObject *arg, void *c) {
return 0;
}

/* _Py_fopen_obj() also checks that arg is of proper type. */
fp = _Py_fopen_obj(arg, "a" PY_STDIOTEXTMODE);
/* Py_fopen() also checks that arg is of proper type. */
fp = Py_fopen(arg, "a" PY_STDIOTEXTMODE);
if (fp == NULL)
return -1;

Expand Down
48 changes: 48 additions & 0 deletions Modules/_testcapi/clinic/file.c.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions Modules/_testcapi/file.c
Original file line number Diff line number Diff line change
@@ -1,8 +1,43 @@
// clinic/file.c.h uses internal pycore_modsupport.h API
#define PYTESTCAPI_NEED_INTERNAL_API

#include "parts.h"
#include "util.h"
#include "clinic/file.c.h"

/*[clinic input]
module _testcapi
[clinic start generated code]*/
/*[clinic end generated code: output=da39a3ee5e6b4b0d input=6361033e795369fc]*/

/*[clinic input]
_testcapi.py_fopen

path: object
mode: str
/

Call Py_fopen(), fread(256) and Py_fclose(). Return read bytes.
[clinic start generated code]*/

static PyObject *
_testcapi_py_fopen_impl(PyObject *module, PyObject *path, const char *mode)
/*[clinic end generated code: output=5a900af000f759de input=d7e7b8f0fd151953]*/
{
FILE *fp = Py_fopen(path, mode);
if (fp == NULL) {
return NULL;
}

char buffer[256];
size_t size = fread(buffer, 1, Py_ARRAY_LENGTH(buffer), fp);
Py_fclose(fp);

return PyBytes_FromStringAndSize(buffer, size);
}

static PyMethodDef test_methods[] = {
_TESTCAPI_PY_FOPEN_METHODDEF
{NULL},
};

Expand Down
8 changes: 4 additions & 4 deletions Modules/_testcapi/object.c
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ call_pyobject_print(PyObject *self, PyObject * args)
return NULL;
}

fp = _Py_fopen_obj(filename, "w+");
fp = Py_fopen(filename, "w+");

if (Py_IsTrue(print_raw)) {
flags = Py_PRINT_RAW;
Expand All @@ -41,7 +41,7 @@ pyobject_print_null(PyObject *self, PyObject *args)
return NULL;
}

fp = _Py_fopen_obj(filename, "w+");
fp = Py_fopen(filename, "w+");

if (PyObject_Print(NULL, fp, 0) < 0) {
fclose(fp);
Expand Down Expand Up @@ -72,7 +72,7 @@ pyobject_print_noref_object(PyObject *self, PyObject *args)
return NULL;
}

fp = _Py_fopen_obj(filename, "w+");
fp = Py_fopen(filename, "w+");

if (PyObject_Print(test_string, fp, 0) < 0){
fclose(fp);
Expand Down Expand Up @@ -103,7 +103,7 @@ pyobject_print_os_error(PyObject *self, PyObject *args)
}

// open file in read mode to induce OSError
fp = _Py_fopen_obj(filename, "r");
fp = Py_fopen(filename, "r");

if (PyObject_Print(test_string, fp, 0) < 0) {
fclose(fp);
Expand Down
12 changes: 6 additions & 6 deletions Modules/_testcapimodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -1744,7 +1744,7 @@ pymarshal_write_long_to_file(PyObject* self, PyObject *args)
&value, &filename, &version))
return NULL;

fp = _Py_fopen_obj(filename, "wb");
fp = Py_fopen(filename, "wb");
if (fp == NULL) {
PyErr_SetFromErrno(PyExc_OSError);
return NULL;
Expand All @@ -1769,7 +1769,7 @@ pymarshal_write_object_to_file(PyObject* self, PyObject *args)
&obj, &filename, &version))
return NULL;

fp = _Py_fopen_obj(filename, "wb");
fp = Py_fopen(filename, "wb");
if (fp == NULL) {
PyErr_SetFromErrno(PyExc_OSError);
return NULL;
Expand All @@ -1793,7 +1793,7 @@ pymarshal_read_short_from_file(PyObject* self, PyObject *args)
if (!PyArg_ParseTuple(args, "O:pymarshal_read_short_from_file", &filename))
return NULL;

fp = _Py_fopen_obj(filename, "rb");
fp = Py_fopen(filename, "rb");
if (fp == NULL) {
PyErr_SetFromErrno(PyExc_OSError);
return NULL;
Expand All @@ -1818,7 +1818,7 @@ pymarshal_read_long_from_file(PyObject* self, PyObject *args)
if (!PyArg_ParseTuple(args, "O:pymarshal_read_long_from_file", &filename))
return NULL;

fp = _Py_fopen_obj(filename, "rb");
fp = Py_fopen(filename, "rb");
if (fp == NULL) {
PyErr_SetFromErrno(PyExc_OSError);
return NULL;
Expand All @@ -1840,7 +1840,7 @@ pymarshal_read_last_object_from_file(PyObject* self, PyObject *args)
if (!PyArg_ParseTuple(args, "O:pymarshal_read_last_object_from_file", &filename))
return NULL;

FILE *fp = _Py_fopen_obj(filename, "rb");
FILE *fp = Py_fopen(filename, "rb");
if (fp == NULL) {
PyErr_SetFromErrno(PyExc_OSError);
return NULL;
Expand All @@ -1863,7 +1863,7 @@ pymarshal_read_object_from_file(PyObject* self, PyObject *args)
if (!PyArg_ParseTuple(args, "O:pymarshal_read_object_from_file", &filename))
return NULL;

FILE *fp = _Py_fopen_obj(filename, "rb");
FILE *fp = Py_fopen(filename, "rb");
if (fp == NULL) {
PyErr_SetFromErrno(PyExc_OSError);
return NULL;
Expand Down
4 changes: 2 additions & 2 deletions Modules/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ pymain_run_file_obj(PyObject *program_name, PyObject *filename,
return pymain_exit_err_print();
}

FILE *fp = _Py_fopen_obj(filename, "rb");
FILE *fp = Py_fopen(filename, "rb");
if (fp == NULL) {
// Ignore the OSError
PyErr_Clear();
Expand Down Expand Up @@ -465,7 +465,7 @@ pymain_run_startup(PyConfig *config, int *exitcode)
goto error;
}

FILE *fp = _Py_fopen_obj(startup, "r");
FILE *fp = Py_fopen(startup, "r");
if (fp == NULL) {
int save_errno = errno;
PyErr_Clear();
Expand Down
2 changes: 1 addition & 1 deletion Python/errors.c
Original file line number Diff line number Diff line change
Expand Up @@ -1981,7 +1981,7 @@ _PyErr_ProgramDecodedTextObject(PyObject *filename, int lineno, const char* enco
return NULL;
}

FILE *fp = _Py_fopen_obj(filename, "r" PY_STDIOTEXTMODE);
FILE *fp = Py_fopen(filename, "r" PY_STDIOTEXTMODE);
if (fp == NULL) {
PyErr_Clear();
return NULL;
Expand Down
Loading
Loading