Skip to content

Commit

Permalink
importlib-style pytest
Browse files Browse the repository at this point in the history
explicit sys.path monkeypatching only where needed
TODO: verify against CI / editable installes / tox venv
  • Loading branch information
pajod committed Dec 15, 2023
1 parent c995c7f commit edba9e4
Show file tree
Hide file tree
Showing 15 changed files with 70 additions and 37 deletions.
11 changes: 6 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ build:
venv/bin/pip install -r requirements_dev.txt

test:
venv/bin/python setup.py test
venv/bin/python -m pytest

coverage:
venv/bin/python setup.py test --cov
venv/bin/python -m coverage run -m pytest
venv/bin/python -m coverage xml

clean:
@rm -rf .Python MANIFEST build dist venv* *.egg-info *.egg
@find . -type f -name "*.py[co]" -delete
@find . -type d -name "__pycache__" -delete
# like @rm -rf, but safer: only untracked git-ignored
# any desirable difference between the two should instead be added to .gitignore
@git clean -X -f -- .Python MANIFEST build dist "venv*" "*.egg-info" "*.egg" __pycache__

.PHONY: build clean coverage test
13 changes: 10 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ testing = [
"eventlet",
"coverage",
"pytest",
"pytest-cov",
]

[project.scripts]
Expand All @@ -72,10 +71,18 @@ gunicorn = "gunicorn.app.wsgiapp:run"
main = "gunicorn.app.pasterapp:serve"

[tool.pytest.ini_options]
# can override these: python -m pytest --override-ini="addopts="
minversion = "7.2.0"
# seconds until still-running tests are dumped
faulthandler_timeout = 5
norecursedirs = ["examples", "lib", "local", "src"]
testpaths = ["tests/"]
addopts = "--assert=plain --cov=gunicorn --cov-report=xml"
# --assert=plain stops rewriting asserts for better expression info
# --import-mode=importlib disables error-prone sys.path modifications
# --strict-markers raises on unknown markers
# can override this: python -m pytest --override-ini="addopts="
addopts = "--import-mode=importlib --strict-markers"
# main selling point of pytest-cov was to workaround --import-mode=prepend
# required_plugins = pytest-cov

[tool.setuptools]
# FIXME: zip-safe = long-overdue cleanup
Expand Down
5 changes: 4 additions & 1 deletion requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
gevent
eventlet
coverage
# pytest 8.0 intends to drop Python 3.7
# pytest 7.2.0 starts using Python 3.11 stdlib tomllib
pytest>=7.2.0
pytest-cov
# pytest 6.0 supports modern importlib, so we do not need pytest-cov to fixup sys.path
# pytest-cov
File renamed without changes.
Empty file.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
46 changes: 26 additions & 20 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import re
import sys
from pathlib import Path

import pytest

Expand All @@ -16,27 +17,28 @@
from gunicorn import glogging
from gunicorn.instrument import statsd

dirname = os.path.dirname(__file__)
tests_dir = Path(__file__).parent
examples_dir = tests_dir / ".." / "examples"
def cfg_module():
return 'config.test_cfg'
def alt_cfg_module():
return 'config.test_cfg_alt'
def cfg_file():
return os.path.join(dirname, "config", "test_cfg.py")
return tests_dir / "importable" / "config" / "test_cfg.py"
def alt_cfg_file():
return os.path.join(dirname, "config", "test_cfg_alt.py")
return tests_dir / "importable" / "config" / "test_cfg_alt.py"
def cfg_file_with_wsgi_app():
return os.path.join(dirname, "config", "test_cfg_with_wsgi_app.py")
def paster_ini():
return os.path.join(dirname, "..", "examples", "frameworks", "pylonstest", "nose.ini")

return tests_dir / "importable" / "config" / "test_cfg_with_wsgi_app.py"
def paster_ni():
return examples_dir / "frameworks" / "pylonstest" / "nose.ini"

class AltArgs:
def __init__(self, args=None):
self.args = args or []
self.orig = sys.argv

def __enter__(self):
assert all(isinstance(arg, str) for arg in self.args)
sys.argv = self.args

def __exit__(self, exc_type, exc_inst, traceback):
Expand Down Expand Up @@ -226,7 +228,7 @@ def test_app_config():


def test_load_config():
with AltArgs(["prog_name", "-c", cfg_file()]):
with AltArgs(["prog_name", "-c", "%s" % cfg_file()]):
app = NoConfigApp()
assert app.cfg.bind == ["unix:/tmp/bar/baz"]
assert app.cfg.workers == 3
Expand All @@ -241,7 +243,9 @@ def test_load_config_explicit_file():
assert app.cfg.proc_name == "fooey"


def test_load_config_module():
def test_load_config_module(monkeypatch):
monkeypatch.syspath_prepend(Path(__file__).parent / "importable")

with AltArgs(["prog_name", "-c", "python:%s" % cfg_module()]):
app = NoConfigApp()
assert app.cfg.bind == ["unix:/tmp/bar/baz"]
Expand All @@ -250,13 +254,15 @@ def test_load_config_module():


def test_cli_overrides_config():
with AltArgs(["prog_name", "-c", cfg_file(), "-b", "blarney"]):
with AltArgs(["prog_name", "-c", "%s" % cfg_file(), "-b", "blarney"]):
app = NoConfigApp()
assert app.cfg.bind == ["blarney"]
assert app.cfg.proc_name == "fooey"


def test_cli_overrides_config_module():
def test_cli_overrides_config_module(monkeypatch):
monkeypatch.syspath_prepend(Path(__file__).parent / "importable")

with AltArgs(["prog_name", "-c", "python:%s" % cfg_module(), "-b", "blarney"]):
app = NoConfigApp()
assert app.cfg.bind == ["blarney"]
Expand Down Expand Up @@ -369,15 +375,15 @@ def test_load_enviroment_variables_config(monkeypatch):
assert app.cfg.workers == 4

def test_config_file_environment_variable(monkeypatch):
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--config=" + alt_cfg_file())
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--config=%s" % alt_cfg_file())
with AltArgs():
app = NoConfigApp()
assert app.cfg.proc_name == "not-fooey"
assert app.cfg.config == alt_cfg_file()
with AltArgs(["prog_name", "--config", cfg_file()]):
assert app.cfg.config == "%s" % alt_cfg_file()
with AltArgs(["prog_name", "--config", "%s" % cfg_file()]):
app = NoConfigApp()
assert app.cfg.proc_name == "fooey"
assert app.cfg.config == cfg_file()
assert app.cfg.config == "%s" % cfg_file()

def test_invalid_enviroment_variables_config(monkeypatch, capsys):
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--foo=bar")
Expand All @@ -390,16 +396,16 @@ def test_invalid_enviroment_variables_config(monkeypatch, capsys):

def test_cli_overrides_enviroment_variables_module(monkeypatch):
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--workers=4")
with AltArgs(["prog_name", "-c", cfg_file(), "--workers", "3"]):
with AltArgs(["prog_name", "-c", "%s" % cfg_file(), "--workers", "3"]):
app = NoConfigApp()
assert app.cfg.workers == 3


@pytest.mark.parametrize("options, expected", [
(["app:app"], 'app:app'),
(["-c", cfg_file(), "app:app"], 'app:app'),
(["-c", cfg_file_with_wsgi_app(), "app:app"], 'app:app'),
(["-c", cfg_file_with_wsgi_app()], 'app1:app1'),
(["-c", "%s" % cfg_file(), "app:app"], 'app:app'),
(["-c", "%s" % cfg_file_with_wsgi_app(), "app:app"], 'app:app'),
(["-c", "%s" % cfg_file_with_wsgi_app()], 'app1:app1'),
])
def test_wsgi_app_config(options, expected):
cmdline = ["prog_name"]
Expand All @@ -411,7 +417,7 @@ def test_wsgi_app_config(options, expected):

@pytest.mark.parametrize("options", [
([]),
(["-c", cfg_file()]),
(["-c", "%s" % cfg_file()]),
])
def test_non_wsgi_app(options, capsys):
cmdline = ["prog_name"]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_http.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import io
import t
from . import t
import pytest
from unittest import mock

Expand Down
2 changes: 1 addition & 1 deletion tests/test_invalid_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest

import treq
from . import treq

dirname = os.path.dirname(__file__)
reqdir = os.path.join(dirname, "requests", "invalid")
Expand Down
13 changes: 9 additions & 4 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
import os
from pathlib import Path

import pytest

Expand Down Expand Up @@ -70,14 +71,16 @@ def test_warn(capsys):
"support:create_app(count=3)",
],
)
def test_import_app_good(value):
def test_import_app_good(monkeypatch, value):
monkeypatch.syspath_prepend(Path(__file__).parent / "importable")

assert util.import_app(value)


@pytest.mark.parametrize(
("value", "exc_type", "msg"),
[
("a:app", ImportError, "No module"),
("nonexisting:app", ImportError, "No module"),
("support:create_app(", AppImportError, "Failed to parse"),
("support:create.app()", AppImportError, "Function reference"),
("support:create_app(Gunicorn)", AppImportError, "literal values"),
Expand All @@ -89,15 +92,17 @@ def test_import_app_good(value):
("support:HOST", AppImportError, "callable"),
],
)
def test_import_app_bad(value, exc_type, msg):
def test_import_app_bad(monkeypatch, value, exc_type, msg):
monkeypatch.syspath_prepend(Path(__file__).parent / "importable")

with pytest.raises(exc_type) as exc_info:
util.import_app(value)

assert msg in str(exc_info.value)


def test_import_app_py_ext(monkeypatch):
monkeypatch.chdir(os.path.dirname(__file__))
monkeypatch.chdir(Path(__file__).parent / "importable")

with pytest.raises(ImportError) as exc_info:
util.import_app("support.py")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_valid_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest

import treq
from . import treq

dirname = os.path.dirname(__file__)
reqdir = os.path.join(dirname, "requests", "valid")
Expand Down
13 changes: 12 additions & 1 deletion tests/treq.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@
from gunicorn.util import split_request_uri

dirname = os.path.dirname(__file__)
random.seed()

# Choices:
# a) stdlib secrets module
# will help expose more bad tests
# may flip-flip between same test working and failing
# b) stdlib random (library name is a misnomer) with fixed seed
# will produce repeatably results
# may miss bad tests aligning with some properties of the generator
# c) stdlib random with arbitrary seed
# only the downsides of a+b
# nonsensical
random.seed(a=0, version=2)


def uri(data):
Expand Down

0 comments on commit edba9e4

Please sign in to comment.