From 60e92def4b59e8b3984ca574ce9337dc4887bf5a Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 7 Feb 2026 18:03:30 -0800 Subject: [PATCH 1/3] Improve error message for module-level __getattr__ issues When a module defines a custom __getattr__ that returns None instead of raising AttributeError, pytest now provides a helpful error message explaining the likely cause and suggesting the fix. The previous error was: "got None instead of Mark" The new error includes: "this is likely caused by a module-level __getattr__ that does not raise AttributeError for missing attributes" Fixes #8265 --- changelog/8265.improvement.rst | 5 +++++ src/_pytest/mark/structures.py | 9 +++++++++ testing/test_mark.py | 26 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 changelog/8265.improvement.rst diff --git a/changelog/8265.improvement.rst b/changelog/8265.improvement.rst new file mode 100644 index 00000000000..ddab7166b82 --- /dev/null +++ b/changelog/8265.improvement.rst @@ -0,0 +1,5 @@ +Improved error message when a module-level ``__getattr__`` fails to raise ``AttributeError`` for missing attributes. + +When a module defines a custom ``__getattr__`` that returns ``None`` instead of raising ``AttributeError``, +pytest now provides a more helpful error message explaining the likely cause and suggesting the fix, +rather than the cryptic "got None instead of Mark" message. diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 0fa6e8babba..1be007dadd5 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -463,6 +463,15 @@ def normalize_mark_list( for mark in mark_list: mark_obj = getattr(mark, "mark", mark) if not isinstance(mark_obj, Mark): + # Provide a helpful error message for a common mistake: + # a module-level __getattr__ that doesn't raise AttributeError + if mark_obj is None: + raise TypeError( + "got None instead of Mark - " + "this is likely caused by a module-level __getattr__ " + "that does not raise AttributeError for missing attributes. " + "Make sure __getattr__ raises AttributeError when the attribute is not found." + ) raise TypeError(f"got {mark_obj!r} instead of Mark") yield mark_obj diff --git a/testing/test_mark.py b/testing/test_mark.py index 67219313183..a711497c9f6 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1344,3 +1344,29 @@ def test_fixture_disallowed_between_marks() -> None: @pytest.mark.usefixtures("tmp_path") def foo(): raise NotImplementedError() + + +def test_module_getattr_without_attributeerror(pytester: Pytester) -> None: + """ + Test that a helpful error message is provided when a module-level + __getattr__ fails to raise AttributeError. + + Regression test for https://github.com/pytest-dev/pytest/issues/8265 + """ + pytester.makepyfile( + """ + def __getattr__(key): + # Bug: should raise AttributeError, but returns None + return None + + def test_something(): + assert True + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines([ + "*TypeError*got None instead of Mark*", + "*module-level __getattr__*", + "*AttributeError*", + ]) + assert result.ret != 0 From 1f4930a22d8b87a367dbc5462f331c4faa6d7ae8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 02:04:07 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_mark.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/testing/test_mark.py b/testing/test_mark.py index a711497c9f6..31dbd917bfc 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1350,7 +1350,7 @@ def test_module_getattr_without_attributeerror(pytester: Pytester) -> None: """ Test that a helpful error message is provided when a module-level __getattr__ fails to raise AttributeError. - + Regression test for https://github.com/pytest-dev/pytest/issues/8265 """ pytester.makepyfile( @@ -1364,9 +1364,11 @@ def test_something(): """ ) result = pytester.runpytest() - result.stdout.fnmatch_lines([ - "*TypeError*got None instead of Mark*", - "*module-level __getattr__*", - "*AttributeError*", - ]) + result.stdout.fnmatch_lines( + [ + "*TypeError*got None instead of Mark*", + "*module-level __getattr__*", + "*AttributeError*", + ] + ) assert result.ret != 0 From 1c4ae4642bb6842e39e7f918a8d37696da38aaec Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 8 Feb 2026 19:29:00 -0800 Subject: [PATCH 3/3] Move __getattr__ None check from normalize_mark_list to get_unpacked_marks Per reviewer feedback, move the check for module-level __getattr__ returning None to get_unpacked_marks() (the helper that fetches the mark list) instead of normalize_mark_list(). Also change from raising a TypeError to emitting a PytestCollectionWarning, so the module can still be collected normally. Co-Authored-By: Claude Opus 4.6 --- changelog/8265.improvement.rst | 7 +++---- src/_pytest/mark/structures.py | 23 +++++++++++++---------- testing/test_mark.py | 14 +++++++------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/changelog/8265.improvement.rst b/changelog/8265.improvement.rst index ddab7166b82..e59b12052fe 100644 --- a/changelog/8265.improvement.rst +++ b/changelog/8265.improvement.rst @@ -1,5 +1,4 @@ -Improved error message when a module-level ``__getattr__`` fails to raise ``AttributeError`` for missing attributes. +Emit a ``PytestCollectionWarning`` when a module-level ``__getattr__`` returns ``None`` for ``pytestmark`` instead of raising ``AttributeError``. -When a module defines a custom ``__getattr__`` that returns ``None`` instead of raising ``AttributeError``, -pytest now provides a more helpful error message explaining the likely cause and suggesting the fix, -rather than the cryptic "got None instead of Mark" message. +Previously this caused a cryptic ``TypeError: got None instead of Mark`` error. +Now pytest issues a helpful warning and continues collecting the module normally. diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 1be007dadd5..612b559aaa6 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -30,6 +30,7 @@ from _pytest.outcomes import fail from _pytest.raises import AbstractRaises from _pytest.scope import _ScopeName +from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestUnknownMarkWarning @@ -443,7 +444,18 @@ def get_unpacked_marks( mark_list.append(item) else: mark_attribute = getattr(obj, "pytestmark", []) - if isinstance(mark_attribute, list): + if mark_attribute is None: + warnings.warn( + "Module defines a `__getattr__` which returns None for " + "'pytestmark' instead of raising AttributeError. " + "Make sure `__getattr__` raises AttributeError for " + "attributes it does not provide. " + "See https://github.com/pytest-dev/pytest/issues/8265", + PytestCollectionWarning, + stacklevel=2, + ) + mark_list = [] + elif isinstance(mark_attribute, list): mark_list = mark_attribute else: mark_list = [mark_attribute] @@ -463,15 +475,6 @@ def normalize_mark_list( for mark in mark_list: mark_obj = getattr(mark, "mark", mark) if not isinstance(mark_obj, Mark): - # Provide a helpful error message for a common mistake: - # a module-level __getattr__ that doesn't raise AttributeError - if mark_obj is None: - raise TypeError( - "got None instead of Mark - " - "this is likely caused by a module-level __getattr__ " - "that does not raise AttributeError for missing attributes. " - "Make sure __getattr__ raises AttributeError when the attribute is not found." - ) raise TypeError(f"got {mark_obj!r} instead of Mark") yield mark_obj diff --git a/testing/test_mark.py b/testing/test_mark.py index 31dbd917bfc..4aca6d52085 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1348,8 +1348,8 @@ def foo(): def test_module_getattr_without_attributeerror(pytester: Pytester) -> None: """ - Test that a helpful error message is provided when a module-level - __getattr__ fails to raise AttributeError. + Test that a helpful warning is emitted when a module-level + __getattr__ returns None instead of raising AttributeError. Regression test for https://github.com/pytest-dev/pytest/issues/8265 """ @@ -1363,12 +1363,12 @@ def test_something(): assert True """ ) - result = pytester.runpytest() + result = pytester.runpytest("-W", "always::pytest.PytestCollectionWarning") result.stdout.fnmatch_lines( [ - "*TypeError*got None instead of Mark*", - "*module-level __getattr__*", - "*AttributeError*", + "*PytestCollectionWarning*__getattr__*returns None*AttributeError*", ] ) - assert result.ret != 0 + # The module is buggy (__getattr__ returns None for all attributes), + # so no tests are collected, but pytest should NOT crash with a TypeError. + assert result.ret != ExitCode.INTERNAL_ERROR