Skip to content

Commit

Permalink
πŸ§ͺ Rewire pytest fixtures avoiding import loops
Browse files Browse the repository at this point in the history
This patch also refactors and reduces the duplication of the previously
existing fixtures for retrieving different multidict module
implementations and makes the c-extension testing controllable by a CLI
option on the pytest level.

Fixes #837
  • Loading branch information
webknjaz committed Jan 12, 2024
1 parent da14427 commit aa8c76d
Show file tree
Hide file tree
Showing 44 changed files with 1,018 additions and 795 deletions.
8 changes: 3 additions & 5 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,12 +261,11 @@ jobs:
- name: Self-install
run: python -Im pip install '${{ steps.wheel-file.outputs.path }}'
- name: Run unittests
env:
MULTIDICT_NO_EXTENSIONS: ${{ matrix.no-extensions }}
run: >-
python -m pytest tests -vv
python -Im pytest tests -vv
--cov-report xml
--junitxml=.test-results/pytest/test.xml
--${{ matrix.no-extensions == 'Y' && 'no-' || '' }}c-extensions
- name: Produce markdown test summary from JUnit
if: >-
!cancelled()
Expand All @@ -284,11 +283,10 @@ jobs:
if: >-
!cancelled()
&& failure()
env:
MULTIDICT_NO_EXTENSIONS: ${{ matrix.no-extensions }}
run: >- # `exit 1` makes sure that the job remains red with flaky runs
python -Im
pytest --no-cov -vvvvv --lf -rA
--${{ matrix.no-extensions == 'Y' && 'no-' || '' }}c-extensions
&& exit 1
shell: bash
- name: Prepare coverage artifact
Expand Down
5 changes: 2 additions & 3 deletions .mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,5 @@ warn_unreachable = True
warn_unused_ignores = True
warn_return_any = True

[mypy-tests.*]
disallow_any_decorated = False
disallow_untyped_calls = False
[mypy-test_multidict]
disable_error_code = call-arg
15 changes: 15 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ repos:

- repo: local
hooks:
- id: top-level-tests-init-py
name: changelog filenames
language: fail
entry: >-
The `tests/__init__.py` module must not exist so `pytest` doesn't add the
project root to `sys.path` / `$PYTHONPATH`
files: >-
(?x)
^
tests/__init__\.py
$
types: []
types_or:
- file
- symlink
- id: changelogs-rst
name: changelog filenames
language: fail
Expand Down
22 changes: 12 additions & 10 deletions multidict/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,15 @@ class MultiMapping(Mapping[_S, _T_co]):
@abc.abstractmethod
def getone(self, key: _S, default: _D) -> _T_co | _D: ...

_Arg = (Mapping[str, _T] | Mapping[istr, _T] | dict[str, _T]
| dict[istr, _T] | MultiMapping[_T]
| Iterable[tuple[str, _T]] | Iterable[tuple[istr, _T]])
_Arg = (
Mapping[str, _T]
| Mapping[istr, _T]
| dict[str, _T]
| dict[istr, _T]
| MultiMapping[_T]
| Iterable[tuple[str, _T]]
| Iterable[tuple[istr, _T]]
)

class MutableMultiMapping(MultiMapping[_T], MutableMapping[_S, _T], Generic[_T]):
@abc.abstractmethod
Expand Down Expand Up @@ -112,9 +118,7 @@ class CIMultiDict(MutableMultiMapping[_T], Generic[_T]):
def popall(self, key: _S, default: _D) -> list[_T] | _D: ...

class MultiDictProxy(MultiMapping[_T], Generic[_T]):
def __init__(
self, arg: MultiMapping[_T] | MutableMultiMapping[_T]
) -> None: ...
def __init__(self, arg: MultiMapping[_T] | MutableMultiMapping[_T]) -> None: ...
def copy(self) -> MultiDict[_T]: ...
def __getitem__(self, k: _S) -> _T: ...
def __iter__(self) -> Iterator[_S]: ...
Expand All @@ -129,9 +133,7 @@ class MultiDictProxy(MultiMapping[_T], Generic[_T]):
def getone(self, key: _S, default: _D) -> _T | _D: ...

class CIMultiDictProxy(MultiMapping[_T], Generic[_T]):
def __init__(
self, arg: MultiMapping[_T] | MutableMultiMapping[_T]
) -> None: ...
def __init__(self, arg: MultiMapping[_T] | MutableMultiMapping[_T]) -> None: ...
def __getitem__(self, k: _S) -> _T: ...
def __iter__(self) -> Iterator[_S]: ...
def __len__(self) -> int: ...
Expand All @@ -146,5 +148,5 @@ class CIMultiDictProxy(MultiMapping[_T], Generic[_T]):
def copy(self) -> CIMultiDict[_T]: ...

def getversion(
md: MultiDict[_T] | CIMultiDict[_T] | MultiDictProxy[_T] | CIMultiDictProxy[_T]
md: MultiDict[_T] | CIMultiDict[_T] | MultiDictProxy[_T] | CIMultiDictProxy[_T],
) -> int: ...
1 change: 1 addition & 0 deletions multidict/_multidict_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
if sys.version_info >= (3, 9):
GenericAlias = types.GenericAlias
else:

def GenericAlias(cls):
return cls

Expand Down
9 changes: 0 additions & 9 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,3 @@ norecursedirs = dist build .tox docs requirements tools
addopts = --doctest-modules --cov=multidict --cov-report term-missing:skip-covered --cov-report xml --junitxml=junit-test-results.xml -v
doctest_optionflags = ALLOW_UNICODE ELLIPSIS
junit_family = xunit2

[mypy]

[mypy-pytest]
ignore_missing_imports = true


[mypy-multidict._multidict]
ignore_missing_imports = true
Empty file removed tests/__init__.py
Empty file.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
159 changes: 147 additions & 12 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,160 @@
from __future__ import annotations

import argparse
import pickle
from dataclasses import dataclass
from importlib import import_module
from sys import version_info as _version_info
from types import ModuleType
from typing import Callable, Type

try:
from functools import cached_property # Python 3.8+
except ImportError:
from functools import lru_cache as _lru_cache

def cached_property(func):
return property(_lru_cache()(func))


import pytest

from multidict._compat import USE_EXTENSIONS
from multidict import MultiMapping, MutableMultiMapping


PY_38_AND_BELOW = _version_info < (3, 9)


@dataclass(frozen=True)
class MultidictImplementation:
is_pure_python: bool

@cached_property
def tag(self) -> str:
return "pure-python" if self.is_pure_python else "c-extension"

@cached_property
def imported_module(self) -> ModuleType:
importable_module = "_multidict_py" if self.is_pure_python else "_multidict"
return import_module(f"multidict.{importable_module}")

OPTIONAL_CYTHON = (
()
if USE_EXTENSIONS
else pytest.mark.skip(reason="No extensions available")
def __str__(self):
return f"{self.tag}-module"


@pytest.fixture(
scope="session",
params=(
MultidictImplementation(is_pure_python=False),
MultidictImplementation(is_pure_python=True),
),
ids=str,
)
def multidict_implementation(request) -> MultidictImplementation:
multidict_implementation = request.param
test_c_extensions = request.config.getoption("--c-extensions") is True

if not test_c_extensions and not multidict_implementation.is_pure_python:
pytest.skip("C-extension testing not requested")

return multidict_implementation


@pytest.fixture( # type: ignore[call-overload]
@pytest.fixture(scope="session")
def multidict_module(
multidict_implementation: MultidictImplementation,
) -> ModuleType:
return multidict_implementation.imported_module


@pytest.fixture(
scope="session",
params=[
pytest.param("multidict._multidict", marks=OPTIONAL_CYTHON), # type: ignore
"multidict._multidict_py",
],
params=("MultiDict", "CIMultiDict"),
ids=("case-sensitive", "case-insensitive"),
)
def _multidict(request):
return pytest.importorskip(request.param)
def any_multidict_class_name(request: pytest.FixtureRequest) -> str:
return request.param


@pytest.fixture(scope="session")
def multidict_class(
any_multidict_class_name: str,
multidict_module: ModuleType,
) -> Type[MutableMultiMapping[str]]:
return getattr(multidict_module, any_multidict_class_name)


@pytest.fixture(scope="session")
def case_sensitive_multidict_class(
multidict_module: ModuleType,
) -> Type[MutableMultiMapping[str]]:
return multidict_module.MultiDict


@pytest.fixture(scope="session")
def case_insensitive_multidict_class(
multidict_module: ModuleType,
) -> Type[MutableMultiMapping[str]]:
return multidict_module.CIMultiDict


@pytest.fixture(scope="session")
def case_insensitive_str_class(multidict_module: ModuleType) -> Type[str]:
return multidict_module.istr


@pytest.fixture(scope="session")
def any_multidict_proxy_class_name(any_multidict_class_name: str) -> str:
return f"{any_multidict_class_name}Proxy"


@pytest.fixture(scope="session")
def any_multidict_proxy_class(
any_multidict_proxy_class_name: str,
multidict_module: ModuleType,
) -> Type[MultiMapping[str]]:
return getattr(multidict_module, any_multidict_proxy_class_name)


@pytest.fixture(scope="session")
def case_sensitive_multidict_proxy_class(
multidict_module: ModuleType,
) -> Type[MutableMultiMapping[str]]:
return multidict_module.MultiDictProxy


@pytest.fixture(scope="session")
def case_insensitive_multidict_proxy_class(
multidict_module: ModuleType,
) -> Type[MutableMultiMapping[str]]:
return multidict_module.CIMultiDictProxy


@pytest.fixture(scope="session")
def multidict_getversion_callable(multidict_module: ModuleType) -> Callable:
return multidict_module.getversion


def pytest_addoption(
parser: pytest.Parser,
pluginmanager: pytest.PytestPluginManager,
) -> None:
del pluginmanager
parser.addoption(
"--c-extensions", # disabled with `--no-c-extensions`
action="store_true" if PY_38_AND_BELOW else argparse.BooleanOptionalAction,
default=True,
dest="c_extensions",
help="Test C-extensions (on by default)",
)

if PY_38_AND_BELOW:
parser.addoption(
"--no-c-extensions",
action="store_false",
dest="c_extensions",
help="Skip testing C-extensions (on by default)",
)


def pytest_generate_tests(metafunc):
Expand Down
29 changes: 11 additions & 18 deletions tests/gen_pickles.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
import pickle

from multidict._compat import USE_EXTENSIONS
from multidict._multidict_py import CIMultiDict as PyCIMultiDict # noqa
from multidict._multidict_py import MultiDict as PyMultiDict # noqa
import multidict

try:
from multidict._multidict import ( # type: ignore # noqa
CIMultiDict,
MultiDict,
)
except ImportError:
pass


def write(name, proto):
cls = globals()[name]
def write(tag, cls, proto):
d = cls([("a", 1), ("a", 2)])
with open("{}.pickle.{}".format(name.lower(), proto), "wb") as f:
file_basename = f"{cls.__name__.lower()}-{tag}"
with open(f"{file_basename}.pickle.{proto}", "wb") as f:
pickle.dump(d, f, proto)


def generate():
if not USE_EXTENSIONS:
raise RuntimeError("C Extension is required")
_impl_map = {
"c-extension": multidict._multidict,
"pure-python": multidict._multidict_py,
}
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
for name in ("MultiDict", "CIMultiDict", "PyMultiDict", "PyCIMultiDict"):
write(name, proto)
for tag, impl in _impl_map.items():
for cls in impl.CIMultiDict, impl.MultiDict:
write(tag, cls, proto)


if __name__ == "__main__":
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit aa8c76d

Please sign in to comment.