Skip to content

Commit

Permalink
Inspection types (#26)
Browse files Browse the repository at this point in the history
* adding HasName, rearrange

* adding HasAttributes

* add HasRepr

* fix test, improve test IDs
  • Loading branch information
samuelcolvin authored Mar 29, 2022
1 parent 947a23a commit ae3881c
Show file tree
Hide file tree
Showing 9 changed files with 356 additions and 103 deletions.
9 changes: 7 additions & 2 deletions dirty_equals/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,7 +26,6 @@
# base
'DirtyEquals',
'AnyThing',
'IsInstance',
'IsOneOf',
# boolean
'IsTrueLike',
Expand Down Expand Up @@ -60,6 +60,11 @@
'IsFloat',
'IsPositiveFloat',
'IsNegativeFloat',
# inspection
'HasAttributes',
'HasName',
'HasRepr',
'IsInstance',
# other
'FunctionCheck',
'IsJson',
Expand Down
59 changes: 3 additions & 56 deletions dirty_equals/_base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
17 changes: 3 additions & 14 deletions dirty_equals/_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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.
Expand All @@ -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
Expand Down
203 changes: 203 additions & 0 deletions dirty_equals/_inspection.py
Original file line number Diff line number Diff line change
@@ -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='<class.+'))
assert 42 == HasRepr('42')
assert 43 != HasRepr('42')
```
"""
self.expected_repr = expected_repr
super().__init__(expected_repr)

def __class_getitem__(cls, expected_repr: str) -> '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
27 changes: 26 additions & 1 deletion dirty_equals/_utils.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
9 changes: 9 additions & 0 deletions docs/types/inspection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Type Inspection

::: dirty_equals.IsInstance

::: dirty_equals.HasName

::: dirty_equals.HasRepr

::: dirty_equals.HasAttributes
Loading

0 comments on commit ae3881c

Please sign in to comment.