diff --git a/dirty_equals/__init__.py b/dirty_equals/__init__.py index a696e85..ba8214e 100644 --- a/dirty_equals/__init__.py +++ b/dirty_equals/__init__.py @@ -1,7 +1,8 @@ -from ._base import AnyThing, DirtyEquals, IsInstance, IsOneOf +from ._base import AnyThing, DirtyEquals, IsOneOf from ._boolean import IsFalseLike, IsTrueLike from ._datetime import IsDate, IsDatetime, IsNow, IsToday from ._dict import IsDict, IsIgnoreDict, IsPartialDict, IsStrictDict +from ._inspection import HasAttributes, HasName, HasRepr, IsInstance from ._numeric import ( IsApprox, IsFloat, @@ -25,7 +26,6 @@ # base 'DirtyEquals', 'AnyThing', - 'IsInstance', 'IsOneOf', # boolean 'IsTrueLike', @@ -60,6 +60,11 @@ 'IsFloat', 'IsPositiveFloat', 'IsNegativeFloat', + # inspection + 'HasAttributes', + 'HasName', + 'HasRepr', + 'IsInstance', # other 'FunctionCheck', 'IsJson', diff --git a/dirty_equals/_base.py b/dirty_equals/_base.py index e447619..2bb07ec 100644 --- a/dirty_equals/_base.py +++ b/dirty_equals/_base.py @@ -1,5 +1,5 @@ from abc import ABCMeta -from typing import TYPE_CHECKING, Any, Dict, Generic, Iterable, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, Generic, Iterable, Optional, Tuple, TypeVar try: from typing import Protocol @@ -10,9 +10,9 @@ from ._utils import Omit if TYPE_CHECKING: - from typing import TypeAlias + from typing import TypeAlias, Union # noqa: F401 -__all__ = 'DirtyEqualsMeta', 'DirtyEquals', 'IsInstance', 'AnyThing', 'IsOneOf' +__all__ = 'DirtyEqualsMeta', 'DirtyEquals', 'AnyThing', 'IsOneOf' class DirtyEqualsMeta(ABCMeta): @@ -184,59 +184,6 @@ def _repr_ne(v: InstanceOrType) -> str: return v._repr_ne() -ExpectedType = TypeVar('ExpectedType', bound=Union[type, Tuple[Union[type, Tuple[Any, ...]], ...]]) - - -class IsInstance(DirtyEquals[ExpectedType]): - """ - A type which checks that the value is an instance of the expected type. - """ - - def __init__(self, expected_type: ExpectedType, *, only_direct_instance: bool = False): - """ - Args: - expected_type: The type to check against. - only_direct_instance: whether instances of subclasses of `expected_type` should be considered equal. - - !!! note - `IsInstance` can be parameterized or initialised with a type - - `IsInstance[Foo]` is exactly equivalent to `IsInstance(Foo)`. - - This allows usage to be analogous to type hints. - - Example: - ```py title="IsInstance" - from dirty_equals import IsInstance - - class Foo: - pass - - class Bar(Foo): - pass - - assert Foo() == IsInstance[Foo] - assert Foo() == IsInstance(Foo) - assert Foo != IsInstance[Bar] - - assert Bar() == IsInstance[Foo] - assert Foo() == IsInstance(Foo, only_direct_instance=True) - assert Bar() != IsInstance(Foo, only_direct_instance=True) - ``` - """ - self.expected_type = expected_type - self.only_direct_instance = only_direct_instance - super().__init__(expected_type) - - def __class_getitem__(cls, expected_type: ExpectedType) -> 'IsInstance[ExpectedType]': - return cls(expected_type) - - def equals(self, other: Any) -> bool: - if self.only_direct_instance: - return type(other) == self.expected_type - else: - return isinstance(other, self.expected_type) - - class AnyThing(DirtyEquals[Any]): """ A type which matches any value. `AnyThing` isn't generally very useful on its own, but can be used within diff --git a/dirty_equals/_dict.py b/dirty_equals/_dict.py index 28f4416..0731279 100644 --- a/dirty_equals/_dict.py +++ b/dirty_equals/_dict.py @@ -3,6 +3,7 @@ from typing import Any, Callable, Container, Dict, Optional, Union, overload from ._base import DirtyEquals, DirtyEqualsMeta +from ._utils import get_dict_arg NotGiven = object() @@ -25,7 +26,7 @@ def __init__(self, **expected: Any): def __init__(self, *expected_args: Dict[Any, Any], **expected_kwargs: Any): """ - Can be created from either key word arguments or an existing dictionary (same as `dict()`). + Can be created from either keyword arguments or an existing dictionary (same as `dict()`). `IsDict` is not particularly useful on its own, but it can be subclassed or modified with [`.settings(...)`][dirty_equals.IsDict.settings] to facilitate powerful comparison of dictionaries. @@ -37,19 +38,7 @@ def __init__(self, *expected_args: Dict[Any, Any], **expected_kwargs: Any): assert {1: 2, 3: 4} == IsDict({1: 2, 3: 4}) ``` """ - if expected_kwargs: - self.expected_values = expected_kwargs - if expected_args: - raise TypeError('IsDict requires either a single argument or kwargs, not both') - elif not expected_args: - self.expected_values = {} - elif len(expected_args) == 1: - self.expected_values = expected_args[0] - else: - raise TypeError(f'IsDict expected at most 1 argument, got {len(expected_args)}') - - if not isinstance(self.expected_values, dict): - raise TypeError(f'expected_values must be a dict, got {type(self.expected_values)}') + self.expected_values = get_dict_arg('IsDict', expected_args, expected_kwargs) self.strict = False self.partial = False diff --git a/dirty_equals/_inspection.py b/dirty_equals/_inspection.py new file mode 100644 index 0000000..0e20154 --- /dev/null +++ b/dirty_equals/_inspection.py @@ -0,0 +1,203 @@ +from typing import Any, Dict, Tuple, TypeVar, Union, overload + +from ._base import DirtyEquals +from ._utils import get_dict_arg + +ExpectedType = TypeVar('ExpectedType', bound=Union[type, Tuple[Union[type, Tuple[Any, ...]], ...]]) + + +class IsInstance(DirtyEquals[ExpectedType]): + """ + A type which checks that the value is an instance of the expected type. + """ + + def __init__(self, expected_type: ExpectedType, *, only_direct_instance: bool = False): + """ + Args: + expected_type: The type to check against. + only_direct_instance: whether instances of subclasses of `expected_type` should be considered equal. + + !!! note + `IsInstance` can be parameterized or initialised with a type - + `IsInstance[Foo]` is exactly equivalent to `IsInstance(Foo)`. + + This allows usage to be analogous to type hints. + + Example: + ```py title="IsInstance" + from dirty_equals import IsInstance + + class Foo: + pass + + class Bar(Foo): + pass + + assert Foo() == IsInstance[Foo] + assert Foo() == IsInstance(Foo) + assert Foo != IsInstance[Bar] + + assert Bar() == IsInstance[Foo] + assert Foo() == IsInstance(Foo, only_direct_instance=True) + assert Bar() != IsInstance(Foo, only_direct_instance=True) + ``` + """ + self.expected_type = expected_type + self.only_direct_instance = only_direct_instance + super().__init__(expected_type) + + def __class_getitem__(cls, expected_type: ExpectedType) -> 'IsInstance[ExpectedType]': + return cls(expected_type) + + def equals(self, other: Any) -> bool: + if self.only_direct_instance: + return type(other) == self.expected_type + else: + return isinstance(other, self.expected_type) + + +T = TypeVar('T') + + +class HasName(DirtyEquals[T]): + """ + A type which checks that the value has the given `__name__` attribute. + """ + + def __init__(self, expected_name: str, *, allow_instances: bool = True): + """ + Args: + expected_name: The name to check against. + allow_instances: whether instances of classes with the given name should be considered equal, + (e.g. whether `other.__class__.__name__ == expected_name` should be checked). + + Example: + ```py title="HasName" + from dirty_equals import HasName, IsStr + + class Foo: + pass + + assert Foo == HasName('Foo') + assert Foo == HasName['Foo'] + assert Foo() == HasName('Foo') + assert Foo() != HasName('Foo', allow_instances=False) + assert Foo == HasName(IsStr(regex='F..')) + assert Foo != HasName('Bar') + assert int == HasName('int') + assert int == HasName('int') + ``` + """ + self.expected_name = expected_name + self.allow_instances = allow_instances + kwargs = {} + if allow_instances: + kwargs['allow_instances'] = allow_instances + super().__init__(expected_name, allow_instances=allow_instances) + + def __class_getitem__(cls, expected_name: str) -> 'HasName[T]': + return cls(expected_name) + + def equals(self, other: Any) -> bool: + direct_name = getattr(other, '__name__', None) + if direct_name is not None and direct_name == self.expected_name: + return True + + if self.allow_instances: + cls = getattr(other, '__class__', None) + if cls is not None: # pragma: no branch + cls_name = getattr(cls, '__name__', None) + if cls_name is not None and cls_name == self.expected_name: + return True + + return False + + +class HasRepr(DirtyEquals[T]): + """ + A type which checks that the value has the given `repr()` value. + """ + + def __init__(self, expected_repr: str): + """ + Args: + expected_repr: The expected repr value. + + Example: + ```py title="HasRepr" + from dirty_equals import HasRepr, IsStr + + class Foo: + def __repr__(self): + return 'This is a Foo' + + assert Foo() == HasRepr('This is a Foo') + assert Foo() == HasRepr['This is a Foo'] + assert Foo == HasRepr(IsStr(regex=' 'HasRepr[T]': + return cls(expected_repr) + + def equals(self, other: Any) -> bool: + return repr(other) == self.expected_repr + + +class HasAttributes(DirtyEquals[Any]): + """ + A type which checks that the value has the given attributes. + + This is a partial check - e.g. the attributes provided to check do not need to be exhaustive. + """ + + @overload + def __init__(self, expected: Dict[Any, Any]): + ... + + @overload + def __init__(self, **expected: Any): + ... + + def __init__(self, *expected_args: Dict[Any, Any], **expected_kwargs: Any): + """ + Can be created from either keyword arguments or an existing dictionary (same as `dict()`). + + Example: + ```py title="HasAttributes" + from dirty_equals import HasAttributes, IsInt, IsStr, AnyThing + + class Foo: + def __init__(self, a, b): + self.a = a + self.b = b + + def spam(self): + pass + + assert Foo(1, 2) == HasAttributes(a=1, b=2) + assert Foo(1, 2) == HasAttributes(a=1) + assert Foo(1, 's') == HasAttributes(a=IsInt, b=IsStr) + assert Foo(1, 2) != HasAttributes(a=IsInt, b=IsStr) + assert Foo(1, 2) != HasAttributes(a=1, b=2, c=3) + assert Foo(1, 2) == HasAttributes(a=1, b=2, spam=AnyThing) + ``` + """ + self.expected_attrs = get_dict_arg('HasAttributes', expected_args, expected_kwargs) + super().__init__(**self.expected_attrs) + + def equals(self, other: Any) -> bool: + for attr, expected_value in self.expected_attrs.items(): + # done like this to avoid problems with `AnyThing` equaling `None` or `DefaultAttr` + try: + value = getattr(other, attr) + except AttributeError: + return False + else: + if value != expected_value: + return False + return True diff --git a/dirty_equals/_utils.py b/dirty_equals/_utils.py index 5247da4..70fbf01 100644 --- a/dirty_equals/_utils.py +++ b/dirty_equals/_utils.py @@ -1,4 +1,6 @@ -__all__ = 'plain_repr', 'PlainRepr', 'Omit' +__all__ = 'plain_repr', 'PlainRepr', 'Omit', 'get_dict_arg' + +from typing import Any, Dict, Tuple class PlainRepr: @@ -19,3 +21,26 @@ def plain_repr(v: str) -> PlainRepr: # used to omit arguments from repr Omit = object() + + +def get_dict_arg( + name: str, expected_args: Tuple[Dict[Any, Any], ...], expected_kwargs: Dict[str, Any] +) -> Dict[Any, Any]: + """ + Used to enforce init logic similar to `dict(...)`. + """ + if expected_kwargs: + value = expected_kwargs + if expected_args: + raise TypeError(f'{name} requires either a single argument or kwargs, not both') + elif not expected_args: + value = {} + elif len(expected_args) == 1: + value = expected_args[0] + + if not isinstance(value, dict): + raise TypeError(f'expected_values must be a dict, got {type(value)}') + else: + raise TypeError(f'{name} expected at most 1 argument, got {len(expected_args)}') + + return value diff --git a/docs/types/inspection.md b/docs/types/inspection.md new file mode 100644 index 0000000..c879eb6 --- /dev/null +++ b/docs/types/inspection.md @@ -0,0 +1,9 @@ +# Type Inspection + +::: dirty_equals.IsInstance + +::: dirty_equals.HasName + +::: dirty_equals.HasRepr + +::: dirty_equals.HasAttributes diff --git a/mkdocs.yml b/mkdocs.yml index 212bb43..7689024 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - types/dict.md - types/sequence.md - types/string.md + - types/inspection.md - types/boolean.md - types/other.md - types/custom.md diff --git a/tests/test_base.py b/tests/test_base.py index 29bcf93..c0163b6 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,6 +1,6 @@ import pytest -from dirty_equals import Contains, IsApprox, IsInstance, IsInt, IsNegative, IsOneOf, IsPositive, IsStr +from dirty_equals import Contains, IsApprox, IsInt, IsNegative, IsOneOf, IsPositive, IsStr def test_or(): @@ -52,35 +52,6 @@ def test_value_ne(): v.value -class Foo: - pass - - -def test_is_instance_of(): - assert Foo() == IsInstance(Foo) - assert Foo() == IsInstance[Foo] - assert 1 != IsInstance[Foo] - - -class Bar(Foo): - pass - - -def test_is_instance_of_inherit(): - assert Bar() == IsInstance(Foo) - assert Foo() == IsInstance(Foo, only_direct_instance=True) - assert Bar() != IsInstance(Foo, only_direct_instance=True) - - assert Foo != IsInstance(Foo) - assert Bar != IsInstance(Foo) - assert type != IsInstance(Foo) - - -def test_is_instance_of_repr(): - assert repr(IsInstance) == 'IsInstance' - assert repr(IsInstance(Foo)) == "IsInstance()" - - def test_dict_compare(): v = {'foo': 1, 'bar': 2, 'spam': 3} assert v == {'foo': IsInt, 'bar': IsPositive, 'spam': ~IsStr} diff --git a/tests/test_inspection.py b/tests/test_inspection.py new file mode 100644 index 0000000..eb791f4 --- /dev/null +++ b/tests/test_inspection.py @@ -0,0 +1,103 @@ +import pytest + +from dirty_equals import AnyThing, HasAttributes, HasName, HasRepr, IsInstance, IsInt, IsStr + + +class Foo: + def __init__(self, a=1, b=2): + self.a = a + self.b = b + + def spam(self): + pass + + +def dirty_repr(value): + if hasattr(value, 'equals'): + return repr(value) + return '' + + +def test_is_instance_of(): + assert Foo() == IsInstance(Foo) + assert Foo() == IsInstance[Foo] + assert 1 != IsInstance[Foo] + + +class Bar(Foo): + def __repr__(self): + return f'Bar(a={self.a}, b={self.b})' + + +def test_is_instance_of_inherit(): + assert Bar() == IsInstance(Foo) + assert Foo() == IsInstance(Foo, only_direct_instance=True) + assert Bar() != IsInstance(Foo, only_direct_instance=True) + + assert Foo != IsInstance(Foo) + assert Bar != IsInstance(Foo) + assert type != IsInstance(Foo) + + +def test_is_instance_of_repr(): + assert repr(IsInstance) == 'IsInstance' + assert repr(IsInstance(Foo)) == "IsInstance()" + + +def even(x): + return x % 2 == 0 + + +@pytest.mark.parametrize( + 'value,dirty', + [ + (Foo, HasName('Foo')), + (Foo, HasName['Foo']), + (Foo(), HasName('Foo')), + (Foo(), ~HasName('Foo', allow_instances=False)), + (Bar, ~HasName('Foo')), + (int, HasName('int')), + (42, HasName('int')), + (even, HasName('even')), + (Foo().spam, HasName('spam')), + (Foo.spam, HasName('spam')), + (Foo, HasName(IsStr(regex='F..'))), + (Bar, ~HasName(IsStr(regex='F..'))), + ], + ids=dirty_repr, +) +def test_has_name(value, dirty): + assert value == dirty + + +@pytest.mark.parametrize( + 'value,dirty', + [ + (Foo(1, 2), HasAttributes(a=1, b=2)), + (Foo(1, 's'), HasAttributes(a=IsInt, b=IsStr)), + (Foo(1, 2), ~HasAttributes(a=IsInt, b=IsStr)), + (Foo(1, 2), ~HasAttributes(a=1, b=2, c=3)), + (Foo(1, 2), HasAttributes(a=1, b=2, spam=AnyThing)), + (Foo(1, 2), ~HasAttributes(a=1, b=2, missing=AnyThing)), + ], + ids=dirty_repr, +) +def test_has_attributes(value, dirty): + assert value == dirty + + +@pytest.mark.parametrize( + 'value,dirty', + [ + (Bar(1, 2), HasRepr('Bar(a=1, b=2)')), + (Bar(1, 2), HasRepr['Bar(a=1, b=2)']), + (4, ~HasRepr('Bar(a=1, b=2)')), + (Foo(), HasRepr(IsStr(regex=r''))), + (Foo, HasRepr("")), + (42, HasRepr('42')), + (43, ~HasRepr('42')), + ], + ids=dirty_repr, +) +def test_has_repr(value, dirty): + assert value == dirty