diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 0413d5f905d771..5d5d77f29f6eb1 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -13,16 +13,17 @@ on: - "Lib/test/libregrtest/**" - "Lib/tomllib/**" - "Misc/mypy/**" - - "Tools/build/mypy.ini" - "Tools/build/check_extension_modules.py" + - "Tools/build/check_warnings.py" - "Tools/build/compute-changes.py" - "Tools/build/deepfreeze.py" + - "Tools/build/generate-build-details.py" - "Tools/build/generate_sbom.py" - "Tools/build/generate_stdlib_module_names.py" - - "Tools/build/generate-build-details.py" - - "Tools/build/verify_ensurepip_wheels.py" - - "Tools/build/update_file.py" + - "Tools/build/mypy.ini" - "Tools/build/umarshal.py" + - "Tools/build/update_file.py" + - "Tools/build/verify_ensurepip_wheels.py" - "Tools/cases_generator/**" - "Tools/clinic/**" - "Tools/jit/**" diff --git a/Doc/using/mac.rst b/Doc/using/mac.rst index f88f3c2e0785e4..2fd6eace2b5dda 100644 --- a/Doc/using/mac.rst +++ b/Doc/using/mac.rst @@ -39,7 +39,7 @@ Current installers provide a `universal2 binary `_ build of Python which runs natively on all Macs (Apple Silicon and Intel) that are supported by a wide range of macOS versions, -currently typically from at least **macOS 10.13 High Sierra** on. +currently typically from at least **macOS 10.15 Catalina** on. The downloaded file is a standard macOS installer package file (``.pkg``). File integrity information (checksum, size, sigstore signature, etc) for each file is included diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 9f01b52f1aff3b..6c5ab1bb1a1078 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -204,6 +204,10 @@ Other language changes controlled by :ref:`environment variables `. (Contributed by Peter Bierma in :gh:`134170`.) +* The :meth:`~object.__repr__` of :class:`ImportError` and :class:`ModuleNotFoundError` + now shows "name" and "path" as ``name=`` and ``path=`` if they were given + as keyword arguments at construction time. + (Contributed by Serhiy Storchaka, Oleg Iarygin, and Yoav Nir in :gh:`74185`.) New modules =========== diff --git a/Lib/_py_warnings.py b/Lib/_py_warnings.py index cbaa94458629ac..5070caea6bb054 100644 --- a/Lib/_py_warnings.py +++ b/Lib/_py_warnings.py @@ -449,9 +449,12 @@ def warn(message, category=None, stacklevel=1, source=None, # Check category argument if category is None: category = UserWarning - if not (isinstance(category, type) and issubclass(category, Warning)): - raise TypeError("category must be a Warning subclass, " - "not '{:s}'".format(type(category).__name__)) + elif not isinstance(category, type): + raise TypeError(f"category must be a Warning subclass, not " + f"'{type(category).__name__}'") + elif not issubclass(category, Warning): + raise TypeError(f"category must be a Warning subclass, not " + f"class '{category.__name__}'") if not isinstance(skip_file_prefixes, tuple): # The C version demands a tuple for implementation performance. raise TypeError('skip_file_prefixes must be a tuple of strs.') diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 57d0656487d4db..59f77f91d85e5c 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2079,6 +2079,50 @@ def test_copy_pickle(self): self.assertEqual(exc.name, orig.name) self.assertEqual(exc.path, orig.path) + def test_repr(self): + exc = ImportError() + self.assertEqual(repr(exc), "ImportError()") + + exc = ImportError('test') + self.assertEqual(repr(exc), "ImportError('test')") + + exc = ImportError('test', 'case') + self.assertEqual(repr(exc), "ImportError('test', 'case')") + + exc = ImportError(name='somemodule') + self.assertEqual(repr(exc), "ImportError(name='somemodule')") + + exc = ImportError('test', name='somemodule') + self.assertEqual(repr(exc), "ImportError('test', name='somemodule')") + + exc = ImportError(path='somepath') + self.assertEqual(repr(exc), "ImportError(path='somepath')") + + exc = ImportError('test', path='somepath') + self.assertEqual(repr(exc), "ImportError('test', path='somepath')") + + exc = ImportError(name='somename', path='somepath') + self.assertEqual(repr(exc), + "ImportError(name='somename', path='somepath')") + + exc = ImportError('test', name='somename', path='somepath') + self.assertEqual(repr(exc), + "ImportError('test', name='somename', path='somepath')") + + exc = ModuleNotFoundError('test', name='somename', path='somepath') + self.assertEqual(repr(exc), + "ModuleNotFoundError('test', name='somename', path='somepath')") + + def test_ModuleNotFoundError_repr_with_failed_import(self): + with self.assertRaises(ModuleNotFoundError) as cm: + import does_not_exist # type: ignore[import] # noqa: F401 + + self.assertEqual(cm.exception.name, "does_not_exist") + self.assertIsNone(cm.exception.path) + + self.assertEqual(repr(cm.exception), + "ModuleNotFoundError(\"No module named 'does_not_exist'\", name='does_not_exist')") + def run_script(source): if isinstance(source, str): diff --git a/Lib/test/test_warnings/__init__.py b/Lib/test/test_warnings/__init__.py index f89e94449b3031..694cfc97064c30 100644 --- a/Lib/test/test_warnings/__init__.py +++ b/Lib/test/test_warnings/__init__.py @@ -596,25 +596,19 @@ def test_warning_classes(self): class MyWarningClass(Warning): pass - class NonWarningSubclass: - pass - # passing a non-subclass of Warning should raise a TypeError - with self.assertRaises(TypeError) as cm: + expected = "category must be a Warning subclass, not 'str'" + with self.assertRaisesRegex(TypeError, expected): self.module.warn('bad warning category', '') - self.assertIn('category must be a Warning subclass, not ', - str(cm.exception)) - with self.assertRaises(TypeError) as cm: - self.module.warn('bad warning category', NonWarningSubclass) - self.assertIn('category must be a Warning subclass, not ', - str(cm.exception)) + expected = "category must be a Warning subclass, not class 'int'" + with self.assertRaisesRegex(TypeError, expected): + self.module.warn('bad warning category', int) # check that warning instances also raise a TypeError - with self.assertRaises(TypeError) as cm: + expected = "category must be a Warning subclass, not '.*MyWarningClass'" + with self.assertRaisesRegex(TypeError, expected): self.module.warn('bad warning category', MyWarningClass()) - self.assertIn('category must be a Warning subclass, not ', - str(cm.exception)) with self.module.catch_warnings(): self.module.resetwarnings() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-07-19-10-35-31.gh-issue-74185.7hPCA5.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-07-19-10-35-31.gh-issue-74185.7hPCA5.rst new file mode 100644 index 00000000000000..d149e7b2878574 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-07-19-10-35-31.gh-issue-74185.7hPCA5.rst @@ -0,0 +1,4 @@ +The :meth:`~object.__repr__` of :class:`ImportError` and :class:`ModuleNotFoundError` +now shows "name" and "path" as ``name=`` and ``path=`` if they were given +as keyword arguments at construction time. +Patch by Serhiy Storchaka, Oleg Iarygin, and Yoav Nir diff --git a/Misc/NEWS.d/next/Library/2025-08-14-10-27-07.gh-issue-125854.vDzFcZ.rst b/Misc/NEWS.d/next/Library/2025-08-14-10-27-07.gh-issue-125854.vDzFcZ.rst new file mode 100644 index 00000000000000..40925a4ab19fbc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-08-14-10-27-07.gh-issue-125854.vDzFcZ.rst @@ -0,0 +1 @@ +Improve error messages for invalid category in :func:`warnings.warn`. diff --git a/Objects/exceptions.c b/Objects/exceptions.c index b17cac83551670..531ee48eaf8a24 100644 --- a/Objects/exceptions.c +++ b/Objects/exceptions.c @@ -1864,6 +1864,62 @@ ImportError_reduce(PyObject *self, PyObject *Py_UNUSED(ignored)) return res; } +static PyObject * +ImportError_repr(PyObject *self) +{ + int hasargs = PyTuple_GET_SIZE(((PyBaseExceptionObject *)self)->args) != 0; + PyImportErrorObject *exc = PyImportErrorObject_CAST(self); + if (exc->name == NULL && exc->path == NULL) { + return BaseException_repr(self); + } + PyUnicodeWriter *writer = PyUnicodeWriter_Create(0); + if (writer == NULL) { + goto error; + } + PyObject *r = BaseException_repr(self); + if (r == NULL) { + goto error; + } + if (PyUnicodeWriter_WriteSubstring( + writer, r, 0, PyUnicode_GET_LENGTH(r) - 1) < 0) + { + Py_DECREF(r); + goto error; + } + Py_DECREF(r); + if (exc->name) { + if (hasargs) { + if (PyUnicodeWriter_WriteASCII(writer, ", ", 2) < 0) { + goto error; + } + } + if (PyUnicodeWriter_Format(writer, "name=%R", exc->name) < 0) { + goto error; + } + hasargs = 1; + } + if (exc->path) { + if (hasargs) { + if (PyUnicodeWriter_WriteASCII(writer, ", ", 2) < 0) { + goto error; + } + } + if (PyUnicodeWriter_Format(writer, "path=%R", exc->path) < 0) { + goto error; + } + } + + if (PyUnicodeWriter_WriteChar(writer, ')') < 0) { + goto error; + } + + return PyUnicodeWriter_Finish(writer); + +error: + PyUnicodeWriter_Discard(writer); + return NULL; +} + static PyMemberDef ImportError_members[] = { {"msg", _Py_T_OBJECT, offsetof(PyImportErrorObject, msg), 0, PyDoc_STR("exception message")}, @@ -1881,12 +1937,26 @@ static PyMethodDef ImportError_methods[] = { {NULL} }; -ComplexExtendsException(PyExc_Exception, ImportError, - ImportError, 0 /* new */, - ImportError_methods, ImportError_members, - 0 /* getset */, ImportError_str, - "Import can't find module, or can't find name in " - "module."); +static PyTypeObject _PyExc_ImportError = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "ImportError", + .tp_basicsize = sizeof(PyImportErrorObject), + .tp_dealloc = ImportError_dealloc, + .tp_repr = ImportError_repr, + .tp_str = ImportError_str, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, + .tp_doc = PyDoc_STR( + "Import can't find module, " + "or can't find name in module."), + .tp_traverse = ImportError_traverse, + .tp_clear = ImportError_clear, + .tp_methods = ImportError_methods, + .tp_members = ImportError_members, + .tp_base = &_PyExc_Exception, + .tp_dictoffset = offsetof(PyImportErrorObject, dict), + .tp_init = ImportError_init, +}; +PyObject *PyExc_ImportError = (PyObject *)&_PyExc_ImportError; /* * ModuleNotFoundError extends ImportError diff --git a/Python/_warnings.c b/Python/_warnings.c index 12e6172b0cf828..243a5e88e9dbbc 100644 --- a/Python/_warnings.c +++ b/Python/_warnings.c @@ -823,11 +823,7 @@ warn_explicit(PyThreadState *tstate, PyObject *category, PyObject *message, /* Normalize message. */ Py_INCREF(message); /* DECREF'ed in cleanup. */ - rc = PyObject_IsInstance(message, PyExc_Warning); - if (rc == -1) { - goto cleanup; - } - if (rc == 1) { + if (PyObject_TypeCheck(message, (PyTypeObject *)PyExc_Warning)) { text = PyObject_Str(message); if (text == NULL) goto cleanup; @@ -1124,26 +1120,25 @@ setup_context(Py_ssize_t stack_level, static PyObject * get_category(PyObject *message, PyObject *category) { - int rc; - - /* Get category. */ - rc = PyObject_IsInstance(message, PyExc_Warning); - if (rc == -1) - return NULL; - - if (rc == 1) - category = (PyObject*)Py_TYPE(message); - else if (category == NULL || category == Py_None) - category = PyExc_UserWarning; + if (PyObject_TypeCheck(message, (PyTypeObject *)PyExc_Warning)) { + /* Ignore the category argument. */ + return (PyObject*)Py_TYPE(message); + } + if (category == NULL || category == Py_None) { + return PyExc_UserWarning; + } /* Validate category. */ - rc = PyObject_IsSubclass(category, PyExc_Warning); - /* category is not a subclass of PyExc_Warning or - PyObject_IsSubclass raised an error */ - if (rc == -1 || rc == 0) { + if (!PyType_Check(category)) { + PyErr_Format(PyExc_TypeError, + "category must be a Warning subclass, not '%T'", + category); + return NULL; + } + if (!PyType_IsSubtype((PyTypeObject *)category, (PyTypeObject *)PyExc_Warning)) { PyErr_Format(PyExc_TypeError, - "category must be a Warning subclass, not '%s'", - Py_TYPE(category)->tp_name); + "category must be a Warning subclass, not class '%N'", + (PyTypeObject *)category); return NULL; } diff --git a/Tools/build/check_warnings.py b/Tools/build/check_warnings.py index 3f49d8e7f2ee48..44ccf9708ad72f 100644 --- a/Tools/build/check_warnings.py +++ b/Tools/build/check_warnings.py @@ -8,21 +8,29 @@ import sys from collections import defaultdict from pathlib import Path -from typing import NamedTuple +from typing import NamedTuple, TypedDict class IgnoreRule(NamedTuple): file_path: str - count: int + count: int # type: ignore[assignment] ignore_all: bool = False is_directory: bool = False +class CompileWarning(TypedDict): + file: str + line: str + column: str + message: str + option: str + + def parse_warning_ignore_file(file_path: str) -> set[IgnoreRule]: """ Parses the warning ignore file and returns a set of IgnoreRules """ - files_with_expected_warnings = set() + files_with_expected_warnings: set[IgnoreRule] = set() with Path(file_path).open(encoding="UTF-8") as ignore_rules_file: files_with_expected_warnings = set() for i, line in enumerate(ignore_rules_file): @@ -46,7 +54,7 @@ def parse_warning_ignore_file(file_path: str) -> set[IgnoreRule]: ) sys.exit(1) if ignore_all: - count = 0 + count = "0" files_with_expected_warnings.add( IgnoreRule( @@ -61,7 +69,7 @@ def extract_warnings_from_compiler_output( compiler_output: str, compiler_output_type: str, path_prefix: str = "", -) -> list[dict]: +) -> list[CompileWarning]: """ Extracts warnings from the compiler output based on compiler output type. Removes path prefix from file paths if provided. @@ -78,8 +86,12 @@ def extract_warnings_from_compiler_output( r"(?P.*):(?P\d+):(?P\d+): warning: " r"(?P.*) (?P