From a9db22ac0165b9e4fa5718e15e6a80f7a612f8a4 Mon Sep 17 00:00:00 2001
From: Varun Chawla <varun_6april@hotmail.com>
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 5c9e6601e8a..d155e9b248f 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 699b97223b201e79c4fd51886a61d46996b40272 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 74886f67d51f9895779590c415ad1775e298c25c Mon Sep 17 00:00:00 2001
From: Varun Chawla <varun_6april@hotmail.com>
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.
---
 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 d155e9b248f..a21cdc9b769 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
