Compare commits
6 commits
4720ec7c10
...
bb920f9474
Author | SHA1 | Date | |
---|---|---|---|
ItsDrike | bb920f9474 | ||
ItsDrike | 04ca4a486d | ||
ItsDrike | 7c95528c6a | ||
ItsDrike | 80d42cd68c | ||
ItsDrike | 8cb7ad48bc | ||
ItsDrike | 5a167c358e |
13
poetry.lock
generated
13
poetry.lock
generated
|
@ -536,6 +536,17 @@ files = [
|
||||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.12.2"
|
||||||
|
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||||
|
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "20.26.3"
|
version = "20.26.3"
|
||||||
|
@ -559,4 +570,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.11"
|
python-versions = "^3.11"
|
||||||
content-hash = "c7e77528e9eca8213b1d2a0d7d353b7f47900832569b2396f9026300939dac14"
|
content-hash = "334a7105baa770bfd69d0391602995e317ccc0faa3d34539acbf7e9aa5355fee"
|
||||||
|
|
|
@ -9,6 +9,7 @@ packages = [{ include = "src" }]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.11"
|
python = "^3.11"
|
||||||
|
typing-extensions = "^4.12.2"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
poethepoet = "^0.25.0"
|
poethepoet = "^0.25.0"
|
||||||
|
@ -41,7 +42,6 @@ ignore = [
|
||||||
"CPY", # flake8-copyright
|
"CPY", # flake8-copyright
|
||||||
"EM", # flake8-errmsg
|
"EM", # flake8-errmsg
|
||||||
"SLF", # flake8-self
|
"SLF", # flake8-self
|
||||||
"TCH", # flake8-type-checking
|
|
||||||
"ARG", # flake8-unused-arguments
|
"ARG", # flake8-unused-arguments
|
||||||
"TD", # flake8-todos
|
"TD", # flake8-todos
|
||||||
"FIX", # flake8-fixme
|
"FIX", # flake8-fixme
|
||||||
|
@ -85,6 +85,7 @@ ignore = [
|
||||||
"PGH003", # Using specific rule codes in type ignores
|
"PGH003", # Using specific rule codes in type ignores
|
||||||
"E731", # Don't asign a lambda expression, use a def
|
"E731", # Don't asign a lambda expression, use a def
|
||||||
"S311", # Use `secrets` for random number generation, not `random`
|
"S311", # Use `secrets` for random number generation, not `random`
|
||||||
|
"TRY003", # Avoid specifying long messages outside the exception class
|
||||||
|
|
||||||
# Redundant rules with ruff-format:
|
# Redundant rules with ruff-format:
|
||||||
"E111", # Indentation of a non-multiple of 4 spaces
|
"E111", # Indentation of a non-multiple of 4 spaces
|
||||||
|
@ -148,3 +149,67 @@ reportUnnecessaryContains = "error"
|
||||||
reportUnnecessaryTypeIgnoreComment = "error"
|
reportUnnecessaryTypeIgnoreComment = "error"
|
||||||
reportImplicitOverride = "error"
|
reportImplicitOverride = "error"
|
||||||
reportShadowedImports = "error"
|
reportShadowedImports = "error"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "6.0"
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "--strict-markers --cov --no-cov-on-fail"
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
precision = 2
|
||||||
|
fail_under = 20
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
skip_empty = false
|
||||||
|
sort = "cover"
|
||||||
|
exclude_lines = [
|
||||||
|
"\\#\\s*pragma: no cover",
|
||||||
|
"^\\s*if (typing\\.)?TYPE_CHECKING:",
|
||||||
|
"^\\s*@(abc\\.)?abstractmethod",
|
||||||
|
"^\\s*@(typing\\.)?overload",
|
||||||
|
"^\\s*def __repr__\\(",
|
||||||
|
"^\\s*class .*\\bProtocol\\):",
|
||||||
|
"^\\s*raise NotImplementedError",
|
||||||
|
"^\\s*return NotImplemented",
|
||||||
|
"^\\s*\\.\\.\\.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
relative_files = true
|
||||||
|
parallel = true
|
||||||
|
branch = true
|
||||||
|
timid = false
|
||||||
|
source = ["src"]
|
||||||
|
|
||||||
|
[tool.poe.tasks.precommit]
|
||||||
|
cmd = "pre-commit install"
|
||||||
|
help = "install pre-commit hooks"
|
||||||
|
|
||||||
|
[tool.poe.tasks.lint]
|
||||||
|
cmd = "pre-commit run --all-files"
|
||||||
|
help = "Run all project linters (via pre-commit)"
|
||||||
|
|
||||||
|
[tool.poe.tasks.ruff]
|
||||||
|
cmd = "ruff check --fix ."
|
||||||
|
help = "Run ruff linter, with automatic issue fixing"
|
||||||
|
|
||||||
|
[tool.poe.tasks.ruff-format]
|
||||||
|
cmd = "ruff format ."
|
||||||
|
help = "Run ruff formatter"
|
||||||
|
|
||||||
|
[tool.poe.tasks.test]
|
||||||
|
cmd = "pytest -v --failed-first"
|
||||||
|
help = "Run pytest tests"
|
||||||
|
|
||||||
|
[tool.poe.tasks.retest]
|
||||||
|
cmd = "pytest -v --last-failed"
|
||||||
|
help = "Run previously failed tests using pytest"
|
||||||
|
|
||||||
|
[tool.poe.tasks.test-nocov]
|
||||||
|
cmd = "pytest -v --no-cov --failed-first"
|
||||||
|
help = "Run pytest tests without coverage"
|
||||||
|
|
||||||
|
[tool.poe.tasks.retest-nocov]
|
||||||
|
cmd = "pytest -v --no-cov --last-failed"
|
||||||
|
help = "Run previously failed tests using pytest without coverage"
|
||||||
|
|
105
tests/helpers.py
Normal file
105
tests/helpers.py
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest.mock
|
||||||
|
from typing import Any, Generic, TYPE_CHECKING, TypeVar
|
||||||
|
|
||||||
|
from typing_extensions import ParamSpec
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
P = ParamSpec("P")
|
||||||
|
T_Mock = TypeVar("T_Mock", bound=unittest.mock.Mock)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"synchronize",
|
||||||
|
"UnpropagatingMockMixin",
|
||||||
|
"CustomMockMixin",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def synchronize(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
|
||||||
|
"""Take an asynchronous function, and return a synchronous alternative.
|
||||||
|
|
||||||
|
This is needed because we sometimes want to test asynchronous behavior in a synchronous test function,
|
||||||
|
where we can't simply await something. This function uses `asyncio.run` and generates a wrapper
|
||||||
|
around the original asynchronous function, that awaits the result in a blocking synchronous way,
|
||||||
|
returning the obtained value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||||
|
return asyncio.run(f(*args, **kwargs))
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class UnpropagatingMockMixin(Generic[T_Mock]):
|
||||||
|
"""Provides common functionality for our :class:`~unittest.mock.Mock` classes.
|
||||||
|
|
||||||
|
By default, mock objects propagate themselves by returning a new instance of the same mock
|
||||||
|
class, with same initialization attributes. This is done whenever we're accessing new
|
||||||
|
attributes that mock class.
|
||||||
|
|
||||||
|
This propagation makes sense for simple mocks without any additional restrictions, however when
|
||||||
|
dealing with limited mocks to some ``spec_set``, it doesn't usually make sense to propagate
|
||||||
|
those same ``spec_set`` restrictions, since we generally don't have attributes/methods of a
|
||||||
|
class be of/return the same class.
|
||||||
|
|
||||||
|
This mixin class stops this propagation, and instead returns instances of specified mock class,
|
||||||
|
defined in :attr:`.child_mock_type` class variable, which is by default set to
|
||||||
|
:class:`~unittest.mock.MagicMock`, as it can safely represent most objects.
|
||||||
|
|
||||||
|
.. note:
|
||||||
|
This propagation handling will only be done for the mock classes that inherited from this
|
||||||
|
mixin class. That means if the :attr:`.child_mock_type` is one of the regular mock classes,
|
||||||
|
and the mock is propagated, a regular mock class is returned as that new attribute. This
|
||||||
|
regular class then won't have the same overrides, and will therefore propagate itself, like
|
||||||
|
any other mock class would.
|
||||||
|
|
||||||
|
If you wish to counteract this, you can set the :attr:`.child_mock_type` to a mock class
|
||||||
|
that also inherits from this mixin class, perhaps to your class itself, overriding any
|
||||||
|
propagation recursively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
child_mock_type: T_Mock = unittest.mock.MagicMock
|
||||||
|
|
||||||
|
# Since this is a mixin class, we can access some attributes defined in mock classes safely.
|
||||||
|
# Define the types of these variables here, for proper static type analysis.
|
||||||
|
_mock_sealed: bool
|
||||||
|
_extract_mock_name: Callable[[], str]
|
||||||
|
|
||||||
|
def _get_child_mock(self, **kwargs) -> T_Mock:
|
||||||
|
"""Make :attr:`.child_mock_type`` instances instead of instances of the same class.
|
||||||
|
|
||||||
|
By default, this method creates a new mock instance of the same original class, and passes
|
||||||
|
over the same initialization arguments. This overrides that behavior to instead create an
|
||||||
|
instance of :attr:`.child_mock_type` class.
|
||||||
|
"""
|
||||||
|
# Mocks can be sealed, in which case we wouldn't want to allow propagation of any kind
|
||||||
|
# and rather raise an AttributeError, informing that given attr isn't accessible
|
||||||
|
if self._mock_sealed:
|
||||||
|
mock_name = self._extract_mock_name()
|
||||||
|
obj_name = f"{mock_name}.{kwargs['name']}" if "name" in kwargs else f"{mock_name}()"
|
||||||
|
raise AttributeError(f"Can't access {obj_name}, mock is sealed.")
|
||||||
|
|
||||||
|
# Propagate any other children as simple `unittest.mock.Mock` instances
|
||||||
|
# rather than `self.__class__` instances
|
||||||
|
return self.child_mock_type(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomMockMixin(UnpropagatingMockMixin):
|
||||||
|
"""Provides common functionality for our custom mock types.
|
||||||
|
|
||||||
|
* Stops propagation of same ``spec_set`` restricted mock in child mocks
|
||||||
|
(see :class:`.UnpropagatingMockMixin` for more info)
|
||||||
|
* Allows using the ``spec_set`` attribute as class attribute
|
||||||
|
"""
|
||||||
|
|
||||||
|
spec_set = None
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
if "spec_set" in kwargs:
|
||||||
|
self.spec_set = kwargs.pop("spec_set")
|
||||||
|
super().__init__(spec_set=self.spec_set, **kwargs) # type: ignore # Mixin class, this __init__ is valid
|
Loading…
Reference in a new issue