Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adapt multidict C ext test implementation #25

Merged
merged 13 commits into from
Oct 6, 2024
12 changes: 7 additions & 5 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,14 +298,13 @@ jobs:
sed -i.bak 's/^\s\{2\}Cython\.Coverage$//g' .coveragerc
shell: bash
- name: Run unittests
env:
PROPCACHE_NO_EXTENSIONS: ${{ matrix.no-extensions }}
run: >-
python -Im
pytest
-v
--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 @@ -324,11 +323,14 @@ jobs:
if: >-
!cancelled()
&& failure()
env:
PROPCACHE_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
pytest
--no-cov
-vvvvv
--lf
-rA
--${{ matrix.no-extensions == 'Y' && 'no-' || '' }}c-extensions
&& exit 1
shell: bash
- name: Send coverage data to Codecov
Expand Down
8 changes: 4 additions & 4 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ graft requirements
graft tests
global-exclude *.pyc
global-exclude *.cache
exclude propcache/*.c
exclude propcache/*.html
exclude propcache/*.so
exclude propcache/*.pyd
exclude src/propcache/*.c
exclude src/propcache/*.html
exclude src/propcache/*.so
exclude src/propcache/*.pyd
bdraco marked this conversation as resolved.
Show resolved Hide resolved
bdraco marked this conversation as resolved.
Show resolved Hide resolved
prune docs/_build
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ include_package_data = True
# (see notes for the asterisk/`*` meaning)
* =
*.so
*.exp
bdraco marked this conversation as resolved.
Show resolved Hide resolved
*.lib
*.pyx

[options.exclude_package_data]
Expand Down
133 changes: 133 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import argparse
from dataclasses import dataclass
from functools import cached_property
from importlib import import_module
from sys import version_info as _version_info
from types import ModuleType
from typing import List, Type, Union

import pytest

C_EXT_MARK = pytest.mark.c_extension
PY_38_AND_BELOW = _version_info < (3, 9)
bdraco marked this conversation as resolved.
Show resolved Hide resolved


@dataclass(frozen=True)
class PropcacheImplementation:
"""A facade for accessing importable propcache module variants.

An instance essentially represents a c-extension or a pure-python module.
The actual underlying module is accessed dynamically through a property and
is cached.

It also has a text tag depending on what variant it is, and a string
representation suitable for use in Pytest's test IDs via parametrization.
"""

is_pure_python: bool
"""A flag showing whether this is a pure-python module or a C-extension."""

@cached_property
def tag(self) -> str:
"""Return a text representation of the pure-python attribute."""
return "pure-python" if self.is_pure_python else "c-extension"

@cached_property
def imported_module(self) -> ModuleType:
"""Return a loaded importable containing a propcache variant."""
importable_module = "_helpers_py" if self.is_pure_python else "_helpers_c"
return import_module(f"propcache.{importable_module}")

def __str__(self):
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"""Render the implementation facade instance as a string."""
return f"{self.tag}-module"


@pytest.fixture(
scope="session",
params=(
pytest.param(
PropcacheImplementation(is_pure_python=False),
marks=C_EXT_MARK,
),
PropcacheImplementation(is_pure_python=True),
),
ids=str,
)
def propcache_implementation(request: pytest.FixtureRequest) -> PropcacheImplementation:
"""Return a propcache variant facade."""
return request.param


@pytest.fixture(scope="session")
def propcache_module(
propcache_implementation: PropcacheImplementation,
) -> ModuleType:
"""Return a pre-imported module containing a propcache variant."""
return propcache_implementation.imported_module


def pytest_addoption(
parser: pytest.Parser,
pluginmanager: pytest.PytestPluginManager,
) -> None:
"""Define a new ``--c-extensions`` flag.

This lets the callers deselect tests executed against the C-extension
version of the ``multidict`` implementation.
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"""
del pluginmanager

action: Union[str, Type[argparse.Action]]
bdraco marked this conversation as resolved.
Show resolved Hide resolved
if PY_38_AND_BELOW:
action = "store_true"
else:
action = argparse.BooleanOptionalAction # type: ignore[attr-defined, unused-ignore] # noqa

parser.addoption(
"--c-extensions", # disabled with `--no-c-extensions`
action=action,
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_collection_modifyitems(
session: pytest.Session,
config: pytest.Config,
items: List[pytest.Item],
) -> None:
"""Deselect tests against C-extensions when requested via CLI."""
test_c_extensions = config.getoption("--c-extensions") is True

if test_c_extensions:
return

selected_tests: List[pytest.Item] = []
deselected_tests: List[pytest.Item] = []

for item in items:
c_ext = item.get_closest_marker(C_EXT_MARK.name) is not None

target_items_list = deselected_tests if c_ext else selected_tests
target_items_list.append(item)

config.hook.pytest_deselected(items=deselected_tests)
items[:] = selected_tests


def pytest_configure(config: pytest.Config) -> None:
"""Declare the C-extension marker in config."""
config.addinivalue_line(
"markers",
f"{C_EXT_MARK.name}: tests running against the C-extension implementation.",
)
147 changes: 68 additions & 79 deletions tests/test_cached_property.py
Original file line number Diff line number Diff line change
@@ -1,134 +1,123 @@
import platform
from operator import not_

import pytest

from propcache import _helpers, _helpers_py
from propcache._helpers import cached_property

IS_PYPY = platform.python_implementation() == "PyPy"
def test_cached_property(propcache_module) -> None:
class A:
def __init__(self):
self._cache = {}

@propcache_module.cached_property
def prop(self):
return 1

class CachedPropertyMixin:
cached_property = NotImplemented
a = A()
assert a.prop == 1

def test_cached_property(self) -> None:
class A:
def __init__(self):
self._cache = {}

@self.cached_property # type: ignore[misc]
def prop(self):
return 1
def test_cached_property_class(propcache_module) -> None:
class A:
def __init__(self):
"""Init."""
# self._cache not set because its never accessed in this test

a = A()
assert a.prop == 1
@propcache_module.cached_property
def prop(self):
"""Docstring."""

def test_cached_property_class(self) -> None:
class A:
def __init__(self):
"""Init."""
# self._cache not set because its never accessed in this test
assert isinstance(A.prop, propcache_module.cached_property)
assert A.prop.__doc__ == "Docstring."

@self.cached_property # type: ignore[misc]
def prop(self):
"""Docstring."""

assert isinstance(A.prop, self.cached_property)
assert A.prop.__doc__ == "Docstring."
def test_cached_property_without_cache(propcache_module) -> None:
class A:

def test_cached_property_without_cache(self) -> None:
class A:
__slots__ = ()

__slots__ = ()
def __init__(self):
pass

def __init__(self):
pass
@propcache_module.cached_property
def prop(self):
"""Mock property."""

@self.cached_property # type: ignore[misc]
def prop(self):
"""Mock property."""
a = A()

a = A()
with pytest.raises(AttributeError):
a.prop = 123

with pytest.raises(AttributeError):
a.prop = 123

def test_cached_property_check_without_cache(self) -> None:
class A:
def test_cached_property_check_without_cache(propcache_module) -> None:
class A:

__slots__ = ()
__slots__ = ()

def __init__(self):
pass
def __init__(self):
pass

@self.cached_property # type: ignore[misc]
def prop(self):
"""Mock property."""
@propcache_module.cached_property
def prop(self):
"""Mock property."""

a = A()
with pytest.raises((TypeError, AttributeError)):
assert a.prop == 1
a = A()
with pytest.raises((TypeError, AttributeError)):
assert a.prop == 1


class A:
def __init__(self):
self._cache = {}
def test_cached_property_caching(propcache_module) -> None:

@cached_property
def prop(self):
"""Docstring."""
return 1
class A:
def __init__(self):
self._cache = {}

@propcache_module.cached_property
def prop(self):
"""Docstring."""
return 1

def test_cached_property():
a = A()
assert 1 == a.prop


def test_cached_property_class():
assert isinstance(A.prop, cached_property)
assert "Docstring." == A.prop.__doc__


class TestPyCachedProperty(CachedPropertyMixin):
cached_property = _helpers_py.cached_property # type: ignore[assignment]
def test_cached_property_class_docstring(propcache_module) -> None:

class A:
def __init__(self):
"""Init."""

if (
not _helpers.NO_EXTENSIONS
and not IS_PYPY
and hasattr(_helpers, "cached_property_c")
):
@propcache_module.cached_property
def prop(self):
"""Docstring."""

class TestCCachedProperty(CachedPropertyMixin):
cached_property = _helpers.cached_property_c # type: ignore[assignment, attr-defined, unused-ignore] # noqa: E501
assert isinstance(A.prop, propcache_module.cached_property)
assert "Docstring." == A.prop.__doc__


def test_set_name():
def test_set_name(propcache_module) -> None:
"""Test that the __set_name__ method is called and checked."""

class A:

@cached_property
@propcache_module.cached_property
def prop(self):
"""Docstring."""

A.prop.__set_name__(A, "prop")

with pytest.raises(
TypeError, match=r"Cannot assign the same cached_property to two "
):
match = r"Cannot assign the same cached_property to two "
with pytest.raises(TypeError, match=match):
A.prop.__set_name__(A, "something_else")


def test_get_without_set_name():
def test_get_without_set_name(propcache_module) -> None:
"""Test that get without __set_name__ fails."""
cp = cached_property(not_)
cp = propcache_module.cached_property(not_)

class A:
"""A class."""

A.cp = cp
with pytest.raises(TypeError, match=r"Cannot use cached_property instance "):
_ = A().cp
A.cp = cp # type: ignore[attr-defined]
match = r"Cannot use cached_property instance "
with pytest.raises(TypeError, match=match):
_ = A().cp # type: ignore[attr-defined]
Loading
Loading