Skip to content

Commit 5426ed9

Browse files
authored
Fix Index.query and Index.scan typing issues (#748)
Index.query and Index.scan should return a ResultIterator which iterates over instances of the respective model (which is not known at type-check type unless explicitly specified as a generic parameter).
1 parent 6660ff2 commit 5426ed9

File tree

6 files changed

+64
-15
lines changed

6 files changed

+64
-15
lines changed

docs/release_notes.rst

+7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Release Notes
22
=============
33

4+
v4.3.1
5+
----------
6+
7+
* Fix Index.query and Index.scan typing regressions introduced in 4.2.0, which were causing false errors
8+
in type checkers
9+
10+
411
v4.3.0
512
----------
613

pynamodb/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
"""
88
__author__ = 'Jharrod LaFon'
99
__license__ = 'MIT'
10-
__version__ = '4.3.0'
10+
__version__ = '4.3.1'

pynamodb/indexes.pyi

+10-9
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
from typing import Any, Dict, List, Optional, Text, Type, TypeVar
1+
from typing import Any, Dict, List, Optional, Text, TypeVar, Generic
22

33
from pynamodb.expressions.condition import Condition
4+
from pynamodb.models import Model
45
from pynamodb.pagination import ResultIterator
56

6-
_T = TypeVar('_T', bound='Index')
7+
_M = TypeVar('_M', bound=Model)
78

89

910
class IndexMeta(type):
1011
def __init__(cls, name, bases, attrs) -> None: ...
1112

1213

13-
class Index(metaclass=IndexMeta):
14+
class Index(Generic[_M], metaclass=IndexMeta):
1415
Meta: Any
1516
def __init__(self) -> None: ...
1617
@classmethod
@@ -25,7 +26,7 @@ class Index(metaclass=IndexMeta):
2526
) -> int: ...
2627
@classmethod
2728
def query(
28-
cls: Type[_T],
29+
cls,
2930
hash_key,
3031
range_key_condition: Optional[Condition] = ...,
3132
filter_condition: Optional[Condition] = ...,
@@ -36,10 +37,10 @@ class Index(metaclass=IndexMeta):
3637
attributes_to_get: Optional[Any] = ...,
3738
page_size: Optional[int] = ...,
3839
rate_limit: Optional[float] = ...,
39-
) -> ResultIterator[_T]: ...
40+
) -> ResultIterator[_M]: ...
4041
@classmethod
4142
def scan(
42-
cls: Type[_T],
43+
cls,
4344
filter_condition: Optional[Condition] = ...,
4445
segment: Optional[int] = ...,
4546
total_segments: Optional[int] = ...,
@@ -49,10 +50,10 @@ class Index(metaclass=IndexMeta):
4950
consistent_read: Optional[bool] = ...,
5051
rate_limit: Optional[float] = ...,
5152
attributes_to_get: Optional[List[str]] = ...,
52-
) -> ResultIterator[_T]: ...
53+
) -> ResultIterator[_M]: ...
5354

54-
class GlobalSecondaryIndex(Index): ...
55-
class LocalSecondaryIndex(Index): ...
55+
class GlobalSecondaryIndex(Index[_M]): ...
56+
class LocalSecondaryIndex(Index[_M]): ...
5657

5758
class Projection(object):
5859
projection_type: Any

pynamodb/models.pyi

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
from .attributes import Attribute
32
from .exceptions import DoesNotExist as DoesNotExist
43
from typing import Any, Dict, Generic, Iterable, Iterator, List, Optional, Sequence, Tuple, Type, TypeVar, Text, Union

requirements-dev.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ python-dateutil==2.8.0
77

88
# only used in .travis.yml
99
coveralls
10-
mypy==0.740;python_version>="3.7"
10+
mypy==0.761;python_version>="3.7"
1111
pytest-cov

tests/test_mypy.py

+45-3
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class MyModel(Model):
3535
MyModel.query(12.3)
3636
MyModel.query(b'123')
3737
MyModel.query((1, 2, 3))
38-
MyModel.query({'1': '2'}) # E: Argument 1 to "query" of "Model" has incompatible type "Dict[str, str]"; expected "Union[str, bytes, float, Tuple[Any, ...]]"
38+
MyModel.query({'1': '2'}) # E: Argument 1 to "query" of "Model" has incompatible type "Dict[str, str]"; expected "Union[str, bytes, float, int, Tuple[Any, ...]]"
3939
4040
# test conditions
4141
MyModel.query(123, range_key_condition=(MyModel.my_attr == 5), filter_condition=(MyModel.my_attr == 5))
@@ -150,10 +150,52 @@ class MyModel(Model):
150150
151151
reveal_type(MyModel.my_list) # E: Revealed type is 'pynamodb.attributes.ListAttribute[__main__.MyMap]'
152152
reveal_type(MyModel().my_list) # E: Revealed type is 'builtins.list[__main__.MyMap*]'
153-
reveal_type(MyModel.my_list[0]) # E: Revealed type is 'Any' # E: Value of type "ListAttribute[MyMap]" is not indexable
153+
reveal_type(MyModel.my_list[0]) # E: Value of type "ListAttribute[MyMap]" is not indexable # E: Revealed type is 'Any'
154154
reveal_type(MyModel().my_list[0].my_sub_attr) # E: Revealed type is 'builtins.str'
155155
156156
# Untyped lists are not well supported yet
157-
reveal_type(MyModel.my_untyped_list[0]) # E: Revealed type is 'Any' # E: Cannot determine type of 'my_untyped_list'
157+
reveal_type(MyModel.my_untyped_list[0]) # E: Value of type "ListAttribute[Any]" is not indexable # E: Revealed type is 'Any'
158158
reveal_type(MyModel().my_untyped_list[0].my_sub_attr) # E: Revealed type is 'Any'
159159
""")
160+
161+
162+
def test_index_query_scan():
163+
assert_mypy_output("""
164+
from pynamodb.attributes import NumberAttribute
165+
from pynamodb.models import Model
166+
from pynamodb.indexes import GlobalSecondaryIndex
167+
from pynamodb.pagination import ResultIterator
168+
169+
class UntypedIndex(GlobalSecondaryIndex):
170+
bar = NumberAttribute(hash_key=True)
171+
172+
class TypedIndex(GlobalSecondaryIndex[MyModel]):
173+
bar = NumberAttribute(hash_key=True)
174+
175+
class MyModel(Model):
176+
foo = NumberAttribute(hash_key=True)
177+
bar = NumberAttribute()
178+
179+
untyped_index = UntypedIndex()
180+
typed_index = TypedIndex()
181+
182+
# Ensure old code keeps working
183+
untyped_result: ResultIterator = MyModel.untyped_index.query(123)
184+
model: MyModel = next(untyped_result)
185+
not_model: int = next(untyped_result) # this is legacy behavior so it's "fine"
186+
187+
# Allow users to specify which model their indices return
188+
typed_result: ResultIterator[MyModel] = MyModel.typed_index.query(123)
189+
my_model = next(typed_result)
190+
not_model = next(typed_result) # E: Incompatible types in assignment (expression has type "MyModel", variable has type "int")
191+
192+
# Ensure old code keeps working
193+
untyped_result = MyModel.untyped_index.scan()
194+
model = next(untyped_result)
195+
not_model = next(untyped_result) # this is legacy behavior so it's "fine"
196+
197+
# Allow users to specify which model their indices return
198+
untyped_result = MyModel.typed_index.scan()
199+
model = next(untyped_result)
200+
not_model = next(untyped_result) # E: Incompatible types in assignment (expression has type "MyModel", variable has type "int")
201+
""")

0 commit comments

Comments
 (0)