diff --git a/changelog/4344.bugfix.rst b/changelog/4344.bugfix.rst new file mode 100644 index 000000000..644a6f030 --- /dev/null +++ b/changelog/4344.bugfix.rst @@ -0,0 +1 @@ +Fix RuntimeError/StopIteration when trying to collect package with "__init__.py" only. diff --git a/changelog/5684.trivial.rst b/changelog/5684.trivial.rst new file mode 100644 index 000000000..393fa3205 --- /dev/null +++ b/changelog/5684.trivial.rst @@ -0,0 +1 @@ +Replace manual handling of ``OSError.errno`` in the codebase by new ``OSError`` subclasses (``PermissionError``, ``FileNotFoundError``, etc.). diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 41d51adee..84edb5705 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -33,7 +33,19 @@ Running ``pytest`` can result in six different exit codes: :Exit code 4: pytest command line usage error :Exit code 5: No tests were collected -They are represented by the :class:`_pytest.main.ExitCode` enum. +They are represented by the :class:`_pytest.main.ExitCode` enum. The exit codes being a part of the public API can be imported and accessed directly using: + +.. code-block:: python + + from pytest import ExitCode + +.. note:: + + If you would like to customize the exit code in some scenarios, specially when + no tests are collected, consider using the + `pytest-custom_exit_code `__ + plugin. + Getting help on version, option names, environment variables -------------------------------------------------------------- diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 7bd46eeb6..3ef92704b 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -116,24 +116,11 @@ class AssertionRewritingHook: write = not sys.dont_write_bytecode cache_dir = os.path.join(os.path.dirname(fn), "__pycache__") if write: - try: - os.mkdir(cache_dir) - except OSError: - e = sys.exc_info()[1].errno - if e == errno.EEXIST: - # Either the __pycache__ directory already exists (the - # common case) or it's blocked by a non-dir node. In the - # latter case, we'll ignore it in _write_pyc. - pass - elif e in {errno.ENOENT, errno.ENOTDIR}: - # One of the path components was not a directory, likely - # because we're in a zip file. - write = False - elif e in {errno.EACCES, errno.EROFS, errno.EPERM}: - state.trace("read only directory: %r" % os.path.dirname(fn)) - write = False - else: - raise + ok = try_mkdir(cache_dir) + if not ok: + write = False + state.trace("read only directory: {}".format(os.path.dirname(fn))) + cache_name = os.path.basename(fn)[:-3] + PYC_TAIL pyc = os.path.join(cache_dir, cache_name) # Notice that even if we're in a read-only directory, I'm going @@ -1026,3 +1013,26 @@ warn_explicit( else: res = load_names[0] return res, self.explanation_param(self.pop_format_context(expl_call)) + + +def try_mkdir(cache_dir): + """Attempts to create the given directory, returns True if successful""" + try: + os.mkdir(cache_dir) + except FileExistsError: + # Either the __pycache__ directory already exists (the + # common case) or it's blocked by a non-dir node. In the + # latter case, we'll ignore it in _write_pyc. + return True + except (FileNotFoundError, NotADirectoryError): + # One of the path components was not a directory, likely + # because we're in a zip file. + return False + except PermissionError: + return False + except OSError as e: + # as of now, EROFS doesn't have an equivalent OSError-subclass + if e.errno == errno.EROFS: + return False + raise + return True diff --git a/src/_pytest/main.py b/src/_pytest/main.py index a5283d737..23eff52dd 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -562,7 +562,13 @@ class Session(nodes.FSCollector): # Module itself, so just use that. If this special case isn't taken, then all # the files in the package will be yielded. if argpath.basename == "__init__.py": - yield next(m[0].collect()) + try: + yield next(m[0].collect()) + except StopIteration: + # The package collects nothing with only an __init__.py + # file in it, which gets ignored by the default + # "python_files" option. + pass return yield from m diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 1c0c45b14..19f9c062f 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,5 +1,4 @@ import atexit -import errno import fnmatch import itertools import operator @@ -163,14 +162,8 @@ def create_cleanup_lock(p): lock_path = get_lock_path(p) try: fd = os.open(str(lock_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) - except OSError as e: - if e.errno == errno.EEXIST: - raise EnvironmentError( - "cannot create lockfile in {path}".format(path=p) - ) from e - - else: - raise + except FileExistsError as e: + raise EnvironmentError("cannot create lockfile in {path}".format(path=p)) from e else: pid = os.getpid() spid = str(pid).encode() diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index b8242b37d..9f0979f77 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1,4 +1,5 @@ import ast +import errno import glob import importlib import os @@ -7,6 +8,7 @@ import stat import sys import textwrap import zipfile +from functools import partial import py @@ -1528,3 +1530,43 @@ class TestAssertionPass: ) def test_get_assertion_exprs(src, expected): assert _get_assertion_exprs(src) == expected + + +def test_try_mkdir(monkeypatch, tmp_path): + from _pytest.assertion.rewrite import try_mkdir + + p = tmp_path / "foo" + + # create + assert try_mkdir(str(p)) + assert p.is_dir() + + # already exist + assert try_mkdir(str(p)) + + # monkeypatch to simulate all error situations + def fake_mkdir(p, *, exc): + assert isinstance(p, str) + raise exc + + monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=FileNotFoundError())) + assert not try_mkdir(str(p)) + + monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=NotADirectoryError())) + assert not try_mkdir(str(p)) + + monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=PermissionError())) + assert not try_mkdir(str(p)) + + err = OSError() + err.errno = errno.EROFS + monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=err)) + assert not try_mkdir(str(p)) + + # unhandled OSError should raise + err = OSError() + err.errno = errno.ECHILD + monkeypatch.setattr(os, "mkdir", partial(fake_mkdir, exc=err)) + with pytest.raises(OSError) as exc_info: + try_mkdir(str(p)) + assert exc_info.value.errno == errno.ECHILD diff --git a/testing/test_collection.py b/testing/test_collection.py index 864125c40..dee07d5c7 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1203,6 +1203,18 @@ def test_collect_pkg_init_and_file_in_args(testdir): ) +def test_collect_pkg_init_only(testdir): + subdir = testdir.mkdir("sub") + init = subdir.ensure("__init__.py") + init.write("def test_init(): pass") + + result = testdir.runpytest(str(init)) + result.stdout.fnmatch_lines(["*no tests ran in*"]) + + result = testdir.runpytest("-v", "-o", "python_files=*.py", str(init)) + result.stdout.fnmatch_lines(["sub/__init__.py::test_init PASSED*", "*1 passed in*"]) + + @pytest.mark.skipif( not hasattr(py.path.local, "mksymlinkto"), reason="symlink not available on this platform",