Repository: pytest-dev/pytest
Pull request: Handled CollectReport without duration attribute in terminal report
<!--
Thanks for submitting a PR, your contribution is really appreciated!

Here is a quick checklist that should be present in PRs.

- [ ] Include documentation when adding new features.
- [ ✅] Include new tests or update existing tests when applicable.
- [✅] Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.

If this change fixes an issue, please:

- [ ] Add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the [github docs](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for more information.

Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please:

- [ ] Create a new changelog file in the `changelog` folder, with a name like `<ISSUE NUMBER>.<TYPE>.rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/main/changelog/README.rst) for details.

  Write sentences in the **past or present tense**, examples:

  * *Improved verbose diff output with sequences.*
  * *Terminal summary statistics now use multiple colors.*

  Also make sure to end the sentence with a `.`.

- [ ] Add yourself to `AUTHORS` in alphabetical order.
-->

Fix #13478

This PR adds a regression test to fix a bug in console_output_style=times that, because it lacks the duration attribute, caused a crash when a CollectReport object (from a skipped test) was included. The test helps stop future regressions and verifies that the problem no longer exists.


Problem statement:
Crash with `console_output_style=times` and skipping tests
<!--
Thanks for submitting an issue!

Quick check-list while reporting bugs:
-->
Running `uv run pytest -o console_output_style=times tests/test_repro.py tests/test_repro_skip.py` gives me the following backtrace:
```
tests/test_repro.py .
INTERNALERROR> Traceback (most recent call last):
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/_pytest/main.py", line 289, in wrap_session
INTERNALERROR>     session.exitstatus = doit(config, session) or 0
INTERNALERROR>                          ^^^^^^^^^^^^^^^^^^^^^
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/_pytest/main.py", line 343, in _main
INTERNALERROR>     config.hook.pytest_runtestloop(session=session)
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/pluggy/_hooks.py", line 512, in __call__
INTERNALERROR>     return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
INTERNALERROR>            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/pluggy/_manager.py", line 120, in _hookexec
INTERNALERROR>     return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
INTERNALERROR>            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 167, in _multicall
INTERNALERROR>     raise exception
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 139, in _multicall
INTERNALERROR>     teardown.throw(exception)
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/_pytest/logging.py", line 801, in pytest_runtestloop
INTERNALERROR>     return (yield)  # Run all the tests.
INTERNALERROR>             ^^^^^
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 152, in _multicall
INTERNALERROR>     teardown.send(result)
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/_pytest/terminal.py", line 693, in pytest_runtestloop
INTERNALERROR>     self._write_progress_information_filling_space()
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/_pytest/terminal.py", line 758, in _write_progress_information_filling_space
INTERNALERROR>     msg = self._get_progress_information_message()
INTERNALERROR>           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/_pytest/terminal.py", line 734, in _get_progress_information_message
INTERNALERROR>     return format_node_duration(sum(r.duration for r in not_reported))
INTERNALERROR>                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
INTERNALERROR>   File "/code/.venv/lib/python3.12/site-packages/_pytest/terminal.py", line 734, in <genexpr>
INTERNALERROR>     return format_node_duration(sum(r.duration for r in not_reported))
INTERNALERROR>                                     ^^^^^^^^^^
INTERNALERROR> AttributeError: 'CollectReport' object has no attribute 'duration'. Did you mean: 'location'?

========================= 1 passed, 1 skipped in 0.01s =========================
```
Running either of them separately works:
```
$ uv run pytest -o console_output_style=times tests/test_repro.py
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.0, pluggy-1.6.0
rootdir: /code
configfile: pyproject.toml
plugins: pytest_httpserver-1.1.3, cov-6.1.1, hypothesis-6.133.2
collected 1 item

tests/test_repro.py .                                                   265.2us

============================== 1 passed in 0.06s ===============================
$  uv run pytest -o console_output_style=times tests/test_repro_skip.py
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.0, pluggy-1.6.0
rootdir: /code
configfile: pyproject.toml
plugins: pytest_httpserver-1.1.3, cov-6.1.1, hypothesis-6.133.2
collected 0 items / 2 skipped

============================== 2 skipped in 0.06s ==============================
```
This is the content of `test_repro.py`:
```python
def test_hello() -> None:
    pass
```
and this is the content of `test_repro_skip.py`:
```python
import pytest

name = pytest.importorskip("fakepackage")
```
The other `console_output_style`s do not give a backtrace for me.

Pytest 8.4.0 on Ubuntu 24.04.

```
$ uv pip list
Using Python 3.12.3 environment at: /env
Package                       Version
----------------------------- -------------------------------
accessible-pygments           0.0.5
affine                        2.4.0
alabaster                     1.0.0
alembic                       1.16.1
antimeridian                  0.4.1
astroid                       3.3.10
asttokens                     3.0.0
attrs                         25.3.0
autodocsumm                   0.2.14
babel                         2.17.0
beautifulsoup4                4.13.4
bleach                        6.2.0
boto3                         1.38.25
boto3-stubs                   1.38.25
botocore                      1.38.25
botocore-stubs                1.38.25
bottleneck                    1.5.0
cachetools                    6.0.0
certifi                       2025.4.26
cffi                          1.17.1
cftime                        1.6.4.post1
charset-normalizer            3.4.2
ciso8601                      2.3.2
click                         8.2.1
click-plugins                 1.1.1
cligj                         0.7.2
cloudpickle                   3.1.1
commonmark                    0.9.1
coverage                      7.8.2
cryptography                  45.0.3
dask                          2025.5.1
datacube                      1.9.5.dev13+g9e14c18c.d20250603
dc-tests-io                   1.0
dcio-example                  1.1
decorator                     5.2.1
defusedxml                    0.7.1
deprecat                      2.1.3
dill                          0.4.0
distributed                   2025.5.1
docutils                      0.21.2
executing                     2.2.0
fastjsonschema                2.21.1
fsspec                        2025.5.1
geoalchemy2                   0.17.1
greenlet                      3.2.2
hypothesis                    6.131.30
idna                          3.10
imagesize                     1.4.1
iniconfig                     2.1.0
ipython                       9.2.0
ipython-pygments-lexers       1.1.1
isort                         6.0.1
jedi                          0.19.2
jinja2                        3.1.6
jmespath                      1.0.1
jsonschema                    4.24.0
jsonschema-specifications     2025.4.1
jupyter-client                8.6.3
jupyter-core                  5.8.1
jupyterlab-pygments           0.3.0
lark                          1.2.2
locket                        1.0.0
mako                          1.3.10
markupsafe                    3.0.2
matplotlib-inline             0.1.7
mccabe                        0.7.0
mistune                       3.1.3
moto                          5.1.5
msgpack                       1.1.0
mypy                          1.15.0
mypy-extensions               1.1.0
nbclient                      0.10.2
nbconvert                     7.16.6
nbformat                      5.10.4
nbsphinx                      0.9.7
netcdf4                       1.7.2
numpy                         2.2.6
odc-geo                       0.4.10
packaging                     25.0
pandas                        2.2.3
pandas-stubs                  2.2.3.250527
pandocfilters                 1.5.1
parso                         0.8.4
partd                         1.4.2
pexpect                       4.9.0
pip                           25.1.1
platformdirs                  4.3.8
pluggy                        1.6.0
prompt-toolkit                3.0.51
psutil                        7.0.0
psycopg2                      2.9.10
ptyprocess                    0.7.0
pure-eval                     0.2.3
pycparser                     2.22
pydata-sphinx-theme           0.16.1
pygments                      2.19.1
pylint                        3.3.7
pyparsing                     3.2.3
pyproj                        3.7.1
pytest                        8.3.5
pytest-cov                    6.1.1
pytest-httpserver             1.1.3
python-dateutil               2.9.0.post0
pytz                          2025.2
pyyaml                        6.0.2
pyzmq                         26.4.0
rasterio                      1.4.3
recommonmark                  0.7.1
referencing                   0.36.2
requests                      2.32.3
responses                     0.25.7
rpds-py                       0.25.1
ruamel-yaml                   0.18.11
ruamel-yaml-clib              0.2.12
ruff                          0.11.11
s3transfer                    0.13.0
setuptools                    80.9.0
shapely                       2.1.1
six                           1.17.0
snowballstemmer               3.0.1
sortedcontainers              2.4.0
soupsieve                     2.7
sphinx                        8.1.3
sphinx-autodoc-typehints      3.0.1
sphinx-click                  6.0.0
sphinx-design                 0.6.1
sphinxcontrib-applehelp       2.0.0
sphinxcontrib-devhelp         2.0.0
sphinxcontrib-htmlhelp        2.1.0
sphinxcontrib-jsmath          1.0.1
sphinxcontrib-qthelp          2.0.0
sphinxcontrib-serializinghtml 2.0.0
sqlalchemy                    2.0.41
stack-data                    0.6.3
tblib                         3.1.0
tinycss2                      1.4.0
tomlkit                       0.13.2
toolz                         1.0.0
tornado                       6.5.1
traitlets                     5.14.3
types-affine                  0.1.0
types-awscrt                  0.27.2
types-cachetools              6.0.0.20250525
types-cffi                    1.17.0.20250523
types-jsonschema              4.24.0.20250528
types-psutil                  7.0.0.20250516
types-pyopenssl               24.1.0.20240722
types-python-dateutil         2.9.0.20250516
types-pytz                    2025.2.0.20250516
types-pyyaml                  6.0.12.20250516
types-redis                   4.6.0.20241004
types-s3transfer              0.13.0
types-setuptools              80.9.0.20250529
typing-extensions             4.13.2
tzdata                        2025.2
urllib3                       2.4.0
wcwidth                       0.2.13
webencodings                  0.5.1
werkzeug                      3.1.3
wrapt                         1.17.2
xarray                        2025.4.0
xmltodict                     0.14.2
zict                          3.0.0
```

- [X] a detailed description of the bug or problem you are having
- [X] output of `pip list` from the virtual environment you are using
- [X] pytest and operating system versions
- [X] minimal example if possible

Hints from issue discussion:
Looks like the code never acvouted for collection reports

We need more typechecks it seems and a test 

A ugly nasty workaround would be a session scope fixture to import

Commit message: [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Patch to review:
diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py
index 5f27c46b41e..a95f79ba6b6 100644
--- a/src/_pytest/terminal.py
+++ b/src/_pytest/terminal.py
@@ -734,7 +734,9 @@ def _get_progress_information_message(self) -> str:
             last_in_module = tests_completed == tests_in_module
             if self.showlongtestinfo or last_in_module:
                 self._timing_nodeids_reported.update(r.nodeid for r in not_reported)
-                return format_node_duration(sum(r.duration for r in not_reported))
+                return format_node_duration(
+                    sum(r.duration for r in not_reported if isinstance(r, TestReport))
+                )
             return ""
         if collected:
             return f" [{len(self._progress_nodeids_reported) * 100 // collected:3d}%]"
diff --git a/testing/test_terminal_report.py b/testing/test_terminal_report.py
new file mode 100644
index 00000000000..20170d2212e
--- /dev/null
+++ b/testing/test_terminal_report.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from _pytest.pytester import Pytester
+
+
+def test_console_output_style_times_with_skipped_and_passed(pytester: Pytester) -> None:
+    pytester.makepyfile(
+        test_repro="""
+            def test_hello():
+                pass
+        """,
+        test_repro_skip="""
+            import pytest
+            pytest.importorskip("fakepackage_does_not_exist")
+        """,
+    )
+
+    result = pytester.runpytest(
+        "test_repro.py",
+        "test_repro_skip.py",
+        "-o",
+        "console_output_style=times",
+    )
+
+    print("Captured stdout:")
+    print(result.stdout.str())
+    print("Captured stderr:")
+    print(result.stderr.str())
+
+    combined = result.stdout.lines + result.stderr.lines
+    assert any(
+        "'CollectReport' object has no attribute 'duration'" in line
+        for line in combined
+    )


Code review comments:
diff_hunk: @@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from _pytest.pytester import Pytester
+
+
+def test_console_output_style_times_with_skipped_and_passed(pytester: Pytester) -> None:
+    pytester.makepyfile(
+        test_repro="""
+            def test_hello():
+                pass
+        """,
+        test_repro_skip="""
+            import pytest
+            pytest.importorskip("fakepackage_does_not_exist")
+        """,
+    )
+
+    result = pytester.runpytest(
+        "test_repro.py",
+        "test_repro_skip.py",
+        "-o",
+        "console_output_style=times",
+    )
+
+    print("Captured stdout:")
+    print(result.stdout.str())
+    print("Captured stderr:")
+    print(result.stderr.str())
+
+    combined = result.stdout.lines + result.stderr.lines
+    assert any(
+        "'CollectReport' object has no attribute 'duration'" in line
+        for line in combined
+    )
path: testing/test_terminal_report.py
text: @user1:
Regardless, we should not assume that a specific message will never appear—this approach is too brittle. What if the message is reintroduced but slightly altered?

Instead, it’s better to verify the expected outcome of the run.


```suggestion
    result.stdout.fnmatch_lines(["* 1 passed, 1 skipped in *])
```

diff_hunk: @@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from _pytest.pytester import Pytester
+
+
+def test_console_output_style_times_with_skipped_and_passed(pytester: Pytester) -> None:
+    pytester.makepyfile(
+        test_repro="""
+            def test_hello():
+                pass
+        """,
+        test_repro_skip="""
+            import pytest
+            pytest.importorskip("fakepackage_does_not_exist")
+        """,
+    )
+
+    result = pytester.runpytest(
+        "test_repro.py",
+        "test_repro_skip.py",
+        "-o",
+        "console_output_style=times",
+    )
+
+    print("Captured stdout:")
+    print(result.stdout.str())
+    print("Captured stderr:")
+    print(result.stderr.str())
+
+    combined = result.stdout.lines + result.stderr.lines
+    assert any(
path: testing/test_terminal_report.py
text: @user1:
Not sure I follow, doesn't the change in `terminal.py` prevents this message from appearing in the command line?

@author:
this fix mainly prevents the AttributeError from showing up, which is expected now asthe bug is resolved.

@user1:
But the fact that the bug is fixed, means that the message does not show up, correct? `any` is testing that it **should** show up, which is wrong.