Skip to content

Commit

Permalink
Merge pull request #91 from evo-company/add-unhashable-hints
Browse files Browse the repository at this point in the history
add more hints for unhashable types
  • Loading branch information
kindermax committed Feb 8, 2023
2 parents 1c45037 + 22e0121 commit 8b5598c
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 22 deletions.
4 changes: 3 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ services:
environment:
PYTHONPATH: .
PYTHONBUFFERED: 1
PYTHONWARNINGS: |
ignore::DeprecationWarning
docs:
<<: *base
Expand Down Expand Up @@ -43,7 +45,7 @@ services:
<<: *test-base
depends_on:
- pg
entrypoint: py.test
entrypoint: py.test -v

pg:
image: postgres:13-alpine
1 change: 1 addition & 0 deletions docs/changelog/changes_07.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Changes in 0.7
- Added result cache - it is possible now to specify ``@cached`` directive to cache parts of the query. :ref:`Check cache documentation <caching-doc>`
- ``Link(requires=['a', 'b'])`` can be specified as a list of strings. It is useful when you want to require multiple fields at once. It will pass a list of dicts to the resolver.
- Added support for Python 3.11
- Added hints when failing on unhashable return values

Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
30 changes: 20 additions & 10 deletions hiku/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,21 +346,31 @@ def link_ref_many(
}


HASH_HINT = "\nHint: Consider adding __hash__ method or use hashable type."
DATACLASS_HINT = "\nHint: Use @dataclass(frozen=True) to make object hashable."
HASH_HINT = "\nHint: Consider adding __hash__ method or use hashable type for '{!r}'.".format # noqa: E501
SEQ_HINT = "\nHint: Consider using tuple instead of '{}'.".format
DATACLASS_FROZEN_HINT = "\nHint: Use @dataclass(frozen=True) on '{}'.".format
DATACLASS_FIELD_HINT = "\nHint: Field '{}.{}' of type '{}' is not hashable.".format # noqa: E501


def _hashable_hint(obj: Any) -> str:
if isinstance(obj, Sequence):
if isinstance(obj, (list, set)):
return SEQ_HINT(type(obj).__name__)

if isinstance(obj, Sequence) and not isinstance(obj, str):
return _hashable_hint(obj[0])

if (
dataclasses.is_dataclass(obj)
and not getattr(obj, dataclasses._PARAMS).frozen # type: ignore[attr-defined] # noqa: E501
):
return DATACLASS_HINT
if dataclasses.is_dataclass(obj):
if not getattr(obj, dataclasses._PARAMS).frozen: # type: ignore[attr-defined] # noqa: E501
return DATACLASS_FROZEN_HINT(obj.__class__.__name__)

for f in dataclasses.fields(obj):
val = getattr(obj, f.name)
if not _is_hashable(val):
return DATACLASS_FIELD_HINT(
obj.__class__.__name__, f.name, type(val).__name__
)

return HASH_HINT
return HASH_HINT(obj)


def _check_store_links(
Expand Down Expand Up @@ -486,7 +496,7 @@ def __init__(
graph: Graph,
query: QueryNode,
ctx: 'Context',
cache: CacheInfo = None
cache: Optional[CacheInfo] = None
) -> None:
self._queue = queue
self._task_set = task_set
Expand Down
12 changes: 8 additions & 4 deletions hiku/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ class Option(AbstractOption):
Option('size', Integer, default=100)
Example with TypeRef type(ref can point either to Node or data type)::
Option('filter', TypeRef['FilterInput'])
"""
def __init__(
self,
Expand Down Expand Up @@ -268,11 +272,11 @@ def get_type_enum(type_: TypingMeta) -> t.Tuple[Const, str]:

RootLinkOne = RootLinkT[LR]
RootLinkMaybe = RootLinkT[MaybeLink[LR]]
RootLinkMany = RootLinkT[List[LR]]
RootLinkMany = RootLinkT[List[LR]] # type: ignore[type-var]

LinkOne = LinkT[LT, List[LR]]
LinkMaybe = LinkT[LT, List[MaybeLink[LR]]]
LinkMany = LinkT[LT, List[List[LR]]]
LinkOne = LinkT[LT, List[LR]] # type: ignore[type-var]
LinkMaybe = LinkT[LT, List[MaybeLink[LR]]] # type: ignore[type-var]
LinkMany = LinkT[LT, List[List[LR]]] # type: ignore[type-var]

LinkFunc = t.Union[
RootLinkOne,
Expand Down
3 changes: 2 additions & 1 deletion hiku/validate/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ def visit_record(self, type_: RecordMeta) -> None:
return None

def visit_typeref(self, type_: TypeRefMeta) -> None:
assert type_.__type_name__ in self._data_types, type_.__type_name__
assert type_.__type_name__ in self._data_types, \
f'"{type_.__type_name__}" type is not present in graph data_types'
self.visit(self._data_types[type_.__type_name__])


Expand Down
2 changes: 1 addition & 1 deletion lets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ commands:
test:
description: Run tests
depends: [_build-tests]
cmd: docker-compose run --rm pytest
cmd: [docker-compose, run, --rm, pytest]

bench:
description: Run benchmarks
Expand Down
108 changes: 103 additions & 5 deletions tests/test_hashable.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest

from typing import List
from dataclasses import dataclass

from hiku.builder import build, Q
Expand Down Expand Up @@ -62,7 +63,7 @@ def map_field(f, user):
Link('info', TypeRef['userInfo'], direct_link, requires='_data'),
]),
Root([
Link('user', TypeRef['user'], link_user, requires=None),
Link('user', TypeRef['user'], link_user, requires=None),
]),
])

Expand Down Expand Up @@ -112,7 +113,7 @@ def link_tags(ids):
Link('tags', Sequence[TypeRef['tags']], link_tags, requires='id'),
]),
Root([
Link('user', TypeRef['user'], link_user, requires=None),
Link('user', TypeRef['user'], link_user, requires=None),
]),
])

Expand Down Expand Up @@ -151,7 +152,7 @@ def map_field(f, user):
Field('id', Integer, user_fields),
]),
Root([
Link('user', TypeRef['user'], link_user, requires=None),
Link('user', TypeRef['user'], link_user, requires=None),
]),
])

Expand Down Expand Up @@ -182,8 +183,8 @@ def user_fields(fields, ids):
Field('id', Integer, user_fields),
]),
Root([
Field('_user', Any, user_data),
Link('user', TypeRef['user'], direct_link, requires='_user'),
Field('_user', Any, user_data),
Link('user', TypeRef['user'], direct_link, requires='_user'),
]),
])

Expand Down Expand Up @@ -229,3 +230,100 @@ def link_options(opts):
r"Can't store link values, node: '__root__', link: 'user', "
r"expected: hashable object, returned:(.*User)"
)


@pytest.mark.parametrize('typ, expected', [
(list, 'list'),
(set, 'set'),
])
def test_hint_unhashble_type(typ, expected):
GRAPH = Graph([
Node('user', [
Field('tags', Sequence[String], lambda fields, ids: None),
]),
Root([
Link(
'user',
TypeRef['user'],
lambda: typ(['tag1']),
requires=None,
),
]),
])

with pytest.raises(TypeError) as err:
execute(GRAPH, build([Q.user[Q.tags]]))

err.match(f"Hint: Consider using tuple instead of '{expected}'.")


def test_hint_unhashble_type_in_tuple():
GRAPH = Graph([
Node('user', [
Field('tags', Sequence[String], lambda fields, ids: [[]]),
]),
Root([
Link(
'user',
TypeRef['user'],
lambda: tuple([{}, {}]),
requires=None,
),
]),
])

with pytest.raises(TypeError) as err:
execute(GRAPH, build([Q.user[Q.tags]]))

err.match(
"Hint: Consider adding __hash__ method or use hashable type for '{}'.")


def test_hint_frozen_dataclass():
@dataclass
class User:
tags: List[str]

GRAPH = Graph([
Node('user', [
Field('tags', Sequence[String], lambda fields, ids: None),
]),
Root([
Link(
'user',
TypeRef['user'],
lambda: User(['tag1']),
requires=None,
),
]),
])

with pytest.raises(TypeError) as err:
execute(GRAPH, build([Q.user[Q.tags]]))

err.match("Hint: Use @dataclass\\(frozen=True\\) on 'User'.")


def test_hint_dataclass_unhashable_field():
@dataclass(frozen=True)
class User:
tags: List[str]

GRAPH = Graph([
Node('user', [
Field('tags', Sequence[String], lambda fields, ids: None),
]),
Root([
Link(
'user',
TypeRef['user'],
lambda: User(['tag1']),
requires=None,
),
]),
])

with pytest.raises(TypeError) as err:
execute(GRAPH, build([Q.user[Q.tags]]))

err.match("Hint: Field 'User.tags' of type 'list' is not hashable.")

0 comments on commit 8b5598c

Please sign in to comment.