Skip to content

Commit

Permalink
Merge pull request #11 from MartinGotelli/pydantic_matcher
Browse files Browse the repository at this point in the history
feat: Add pylint package and matchers
  • Loading branch information
MartinGotelli authored Aug 24, 2024
2 parents fa89640 + 3f3fac6 commit 618e6ca
Show file tree
Hide file tree
Showing 26 changed files with 613 additions and 18 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ jobs:
- name: Test with pytest
run: |
make coverage
- name: Min versions tests
run: |
pip install -r requirements_min.txt
make coverage
- name: Spelling check with codespell
run: |
make spell
Expand Down
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ install_hooks:
install_shell_support:
@scripts/install_shell_support.sh

quality: lint vulture spell black tests
quality: lint vulture spell black compat-coverage coverage

format:
black $(FOLDERS)
Expand All @@ -42,6 +42,11 @@ black:
coverage:
coverage run && coverage report --skip-covered

compat-coverage:
pip install -r requirements_min.txt > /dev/null 2>&1
-@make coverage
pip install -r requirements_dev.txt > /dev/null 2>&1

coverage_html:
coverage run && coverage html
open htmlcov/index.html
Expand Down Expand Up @@ -130,7 +135,7 @@ validate_env:
fi

requirements_dev:
poetry export --without-hashes --with=dev -o $(or $(OUTFILE),requirements_dev.txt)
poetry export --without-hashes --with=dev --all-extras -o $(or $(OUTFILE),requirements_dev.txt)

requirements_prod:
poetry export --without-hashes -o $(or $(OUTFILE),requirements.txt)
poetry export --without-hashes --all-extras -o $(or $(OUTFILE),requirements.txt)
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ packages = [

[tool.poetry.dependencies]
python = "^3.10"
pytest = ">=7.0.0, <9.0.0"
pytest = ">=7.0, <9.0"
pydantic = {version = ">=1.0, <3.0", optional = true}

[tool.poetry.extras]
pydantic = ["pydantic"]

[tool.poetry.group.dev.dependencies]
black = "^24.8.0"
Expand Down
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11"
iniconfig==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
packaging==24.1 ; python_version >= "3.10" and python_version < "4.0"
pluggy==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
pydantic-core==2.20.1 ; python_version >= "3.10" and python_version < "4.0"
pydantic==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
pytest==8.3.2 ; python_version >= "3.10" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.10" and python_version < "3.11"
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
7 changes: 5 additions & 2 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
astroid==3.2.4 ; python_version >= "3.10" and python_version < "4.0"
black==24.8.0 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0"
Expand All @@ -14,9 +15,11 @@ packaging==24.1 ; python_version >= "3.10" and python_version < "4.0"
pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==4.2.2 ; python_version >= "3.10" and python_version < "4.0"
pluggy==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
pydantic-core==2.20.1 ; python_version >= "3.10" and python_version < "4.0"
pydantic==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
pylint==3.2.6 ; python_version >= "3.10" and python_version < "4.0"
pytest==8.3.2 ; python_version >= "3.10" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.10" and python_version < "3.11"
tomlkit==0.13.0 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "3.11"
tomlkit==0.13.2 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
vulture==2.11 ; python_version >= "3.10" and python_version < "4.0"
4 changes: 4 additions & 0 deletions requirements_min.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0"
py==1.11.0 ; python_version >= "3.10" and python_version < "4.0"
pydantic==1.10.16 ; python_version >= "3.10" and python_version < "4.0"
pytest==7.0.0 ; python_version >= "3.10" and python_version < "4.0"
6 changes: 6 additions & 0 deletions src/pytest_matchers/pytest_matchers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
anything,
between,
case,
contains,
different_value,
has_attribute,
if_false,
Expand All @@ -20,3 +21,8 @@
one_of,
same_value,
)

try:
from .pydantic.main import is_pydantic, is_pydantic_v1
except ImportError: # pragma: no cover
pass
12 changes: 9 additions & 3 deletions src/pytest_matchers/pytest_matchers/main.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
from typing import Any, Callable, Type

from pytest_matchers.matchers import (
And,
Anything,
Between,
Case,
Contains,
Datetime,
DatetimeString,
Dict,
DifferentValue,
HasAttribute,
If,
IsInstance,
List,
Number,
String,
JSON,
List,
Matcher,
Number,
Or,
SameValue,
StrictDict,
String,
UUID,
)

Expand Down Expand Up @@ -64,6 +66,10 @@ def between(
return Between(min_value, max_value, inclusive, min_inclusive, max_inclusive)


def contains(*values: Any) -> And:
return And(*[Contains(value) for value in values])


def is_datetime(
min_value: Any = None,
max_value: Any = None,
Expand Down
8 changes: 5 additions & 3 deletions src/pytest_matchers/pytest_matchers/matchers/has_attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ def __init__(self, attribute_name: str, expected_value: Any = None):
super().__init__()
self._attribute_name = attribute_name
self._expected_value = expected_value
self._expected_value_matcher = as_matcher(expected_value) if expected_value else None
self._expected_value_matcher = (
as_matcher(expected_value) if expected_value is not None else None
)

def matches(self, value: Any) -> bool:
return hasattr(value, self._attribute_name) and matches_or_none(
Expand All @@ -27,9 +29,9 @@ def __repr__(self) -> str:

def concatenated_repr(self) -> str:
base_repr = f"with {repr(self._attribute_name)}"
if self._expected_value:
if self._expected_value is not None:
if isinstance(self._expected_value, Matcher):
return f"{base_repr} expecting {self._expected_value.concatenated_repr()}"
return f"{base_repr} expected {self._expected_value.concatenated_repr()}"
return f"{base_repr} being {repr(self._expected_value)}"
return base_repr

Expand Down
Empty file.
6 changes: 1 addition & 5 deletions src/pytest_matchers/pytest_matchers/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ def _matches_operation(operation, actual: Any, expected: Matcher) -> bool:
@pytest.hookimpl()
def pytest_assertrepr_compare(config, op: str, left: Any, right: Any):
del config
if (
MatcherDetector(left).uses_matchers()
or MatcherDetector(right).uses_matchers()
and _matches_operation(op, left, right)
):
if MatcherDetector(right).uses_matchers() and _matches_operation(op, left, right):
return [
f"{left!r} {op} {right!r}",
"",
Expand Down
Empty file.
43 changes: 43 additions & 0 deletions src/pytest_matchers/pytest_matchers/pydantic/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from typing import Type

from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1

from pytest_matchers.pydantic.matchers import PydanticModel


def is_pydantic(
model_class: Type[BaseModel | BaseModelV1] | None = None,
*,
strict: bool = None,
exempt_defaults: bool = True,
attributes: dict = None,
**kwargs,
):
model_class = model_class or BaseModel
if strict is None:
strict = bool(attributes) or bool(kwargs)
return PydanticModel(
model_class,
strict=strict,
exempt_defaults=exempt_defaults,
attributes=attributes,
**kwargs,
)


def is_pydantic_v1(
model_class: Type[BaseModelV1] | None = None,
*,
strict: bool = None,
exempt_defaults: bool = True,
attributes: dict = None,
**kwargs,
):
return is_pydantic(
model_class or BaseModelV1,
strict=strict,
exempt_defaults=exempt_defaults,
attributes=attributes,
**kwargs,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .pydantic_model import PydanticModel
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from typing import Any, Type

from pydantic import BaseModel
from pydantic.fields import FieldInfo
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1.fields import ModelField

from pytest_matchers.matchers import HasAttribute, Matcher
from pytest_matchers.utils.matcher_utils import is_instance_matcher
from pytest_matchers.utils.repr_utils import concat_reprs


def _model_fields(model_class: Type[BaseModel | BaseModelV1]):
if issubclass(model_class, BaseModelV1):
return model_class.__fields__
return model_class.model_fields


def _is_required(field: FieldInfo | ModelField) -> bool:
if isinstance(field, ModelField):
return field.required
return field.is_required()


def _default_value(field: FieldInfo | ModelField) -> Any:
if field.default_factory is None:
return field.default
return field.default_factory()


def _version(model_class: Type) -> str | None:
is_pydantic = hasattr(model_class, "__pydantic_complete__") or hasattr(
model_class, "__fields__"
)
if is_pydantic:
if hasattr(model_class, "__pydantic_complete__"):
return "v2"
return "v1"
return None


class PydanticModel(Matcher):
def __init__(
self,
model_class: Type[BaseModel | BaseModelV1],
*,
strict: bool = True,
exempt_defaults: bool = True,
attributes: dict = None,
**kwargs,
):
super().__init__()
self._model_class = model_class
self._is_instance_matcher = is_instance_matcher(model_class)
self._strict = strict
self._exempt_defaults = exempt_defaults
self._attributes = kwargs
self._attributes.update(attributes or {})
self._assert_matching_all_attributes()

def _assert_matching_all_attributes(self):
if not self._strict:
return
fields = _model_fields(self._model_class)
if self._exempt_defaults:
fields = {name: field for name, field in fields.items() if _is_required(field)}
attribute_names = set(self._attributes.keys())
model_attribute_names = set(fields.keys())
missing_attributes = model_attribute_names - attribute_names
if missing_attributes:
raise ValueError(
f"Attributes {missing_attributes} are not present in "
f"{self._model_class.__name__}.\n"
"Consider using strict=False"
)

def _optional_matchers(self, value: Any = None) -> list[Matcher]:
if value is None:
model_class = self._model_class
else:
model_class = type(value)
if self._strict and self._exempt_defaults:
fields = _model_fields(model_class)
return [
HasAttribute(name, _default_value(field))
for name, field in fields.items()
if not _is_required(field) and name not in self._attributes
]
return []

def _attribute_matchers(self, value: Any = None) -> list[Matcher]:
return [
HasAttribute(name, value) for name, value in self._attributes.items()
] + self._optional_matchers(value)

def matches(self, value: Any) -> bool:
return self._matches_instance(value) and all(
matcher == value for matcher in self._attribute_matchers(value)
)

def _matches_instance(self, value: Any) -> bool:
expected_model_version = _version(self._model_class)
value_version = _version(type(value))
return expected_model_version == value_version and self._is_instance_matcher == value

def __repr__(self) -> str:
return concat_reprs(
f"To be a pydantic model instance of {repr(self._model_class.__name__)}",
*self._attribute_matchers(),
)
25 changes: 25 additions & 0 deletions src/tests/examples/test_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
is_instance,
is_list,
is_number,
is_pydantic,
is_strict_dict,
is_string,
is_uuid,
one_of,
same_value,
)
from src.tests.conftest import CustomEqual
from src.tests.pydantic.conftest import BaseModel


def test_deal_with_custom_equals():
Expand Down Expand Up @@ -215,3 +217,26 @@ def test_uuids():
for uuid_value in (uuid3(NAMESPACE_DNS, "python.org"), uuid4()):
assert uuid_value == is_uuid(version=one_of(3, 4))
assert uuid5(NAMESPACE_DNS, "python.org") != is_uuid(version=one_of(3, 4))


def test_pydantic():
class Animal(BaseModel): # pylint: disable=too-few-public-methods
age: int
name: str

class Human(Animal): # pylint: disable=too-few-public-methods
money: float = 0

class Dog(Animal): # pylint: disable=too-few-public-methods
breed: str = "Mongrel"

messi = Human(age=33, name="Messi")
lassie = Dog(age=3, name="Lassie", breed="Collie")
tweety = Animal(age=1, name="Tweety")

assert_match(messi, is_pydantic())
assert_match(lassie, is_pydantic())
assert_match(messi, is_pydantic(Human))
assert_match(lassie, is_pydantic(Dog, age=3, name="Lassie", breed="Collie"))
assert_match(tweety, is_pydantic(age=1))
assert_match(messi, is_pydantic(Human, age=33, strict=False))
Loading

0 comments on commit 618e6ca

Please sign in to comment.