Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type annotations #603

Merged
merged 28 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
04e152f
Do not assume that default manager is named `objects` in `join()`
mthuurne Apr 18, 2024
db93360
Call `Field.get_default()` instead of `_get_default()`
mthuurne Jun 11, 2024
885da69
Upgrade mypy and django-stubs
mthuurne May 1, 2024
0043fed
Enable postponed evaluation of annotations for all source modules
mthuurne Mar 16, 2023
632441e
Require full annotation in mypy configuration
mthuurne Mar 16, 2023
56ea527
Annotate the `tracker` module
mthuurne Mar 17, 2023
bde2d8f
Annotate the `managers` module
mthuurne Mar 20, 2023
172cc72
Annotate the `models` module
mthuurne Mar 20, 2023
ebfb345
Annotate the `fields` module
mthuurne Mar 20, 2023
2b0b482
Annotate the `choices` module
mthuurne Mar 20, 2023
713a3fe
Make type aliases compatible with old Python versions
mthuurne Apr 10, 2024
aeeb69a
Enable postponed evaluation of annotations for all test modules
mthuurne Mar 21, 2023
218843d
Add minimal annotations to unit tests
mthuurne Mar 22, 2023
23f1811
Annotate return type of test methods
mthuurne Mar 22, 2023
c83ef89
Annotate test helpers
mthuurne Mar 22, 2024
9d3940a
Annotate the `test_choices` module
mthuurne Mar 27, 2024
e4c8810
Add pytest to type check dependencies
mthuurne Apr 10, 2024
949d110
Annotate the `test_models` package
mthuurne Mar 27, 2024
7d6cad0
Annotate `test_field_tracker` module
mthuurne Mar 29, 2024
ecc7312
Override signature of query-returning methods in `InheritanceManager`
mthuurne Apr 4, 2024
3c31423
Drop uses of `JoinManager` from the tests
mthuurne Apr 16, 2024
8f0b4ee
Suppress mypy errors in field tracker tests
mthuurne Apr 16, 2024
23a756e
Annotate `test_fields` package
mthuurne Apr 16, 2024
f4653f0
Preserve tracked function's return type in `FieldTracker`
mthuurne Apr 17, 2024
1db7d6b
Fix type generics in `InheritanceIterable`
mthuurne Apr 17, 2024
0093760
Add type argument to `DescriptorWrapper`
mthuurne May 7, 2024
5fc37eb
Provide type arguments to field base classes
mthuurne May 7, 2024
9dc2247
Tell coverage tool to ignore lines intended for mypy only
mthuurne Jun 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
[run]
include = model_utils/*.py

[report]
exclude_also =
# Exclusive to mypy:
if TYPE_CHECKING:$
\.\.\.$
143 changes: 110 additions & 33 deletions model_utils/choices.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
from __future__ import annotations

import copy
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload

T = TypeVar("T")

if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence

# The type aliases defined here are evaluated when the django-stubs mypy plugin
# loads this module, so they must be able to execute under the lowest supported
# Python VM:
# - typing.List, typing.Tuple become obsolete in Pyton 3.9
# - typing.Union becomes obsolete in Pyton 3.10
from typing import List, Tuple, Union

from django_stubs_ext import StrOrPromise

# The type argument 'T' to 'Choices' is the database representation type.
_Double = Tuple[T, StrOrPromise]
_Triple = Tuple[T, str, StrOrPromise]
_Group = Tuple[StrOrPromise, Sequence["_Choice[T]"]]
_Choice = Union[_Double[T], _Triple[T], _Group[T]]
# Choices can only be given as a single string if 'T' is 'str'.
_GroupStr = Tuple[StrOrPromise, Sequence["_ChoiceStr"]]
_ChoiceStr = Union[str, _Double[str], _Triple[str], _GroupStr]
# Note that we only accept lists and tuples in groups, not arbitrary sequences.
# However, annotating it as such causes many problems.

_DoubleRead = Union[_Double[T], Tuple[StrOrPromise, Iterable["_DoubleRead[T]"]]]
_DoubleCollector = List[Union[_Double[T], Tuple[StrOrPromise, "_DoubleCollector[T]"]]]
_TripleCollector = List[Union[_Triple[T], Tuple[StrOrPromise, "_TripleCollector[T]"]]]


class Choices:
class Choices(Generic[T]):
"""
A class to encapsulate handy functionality for lists of choices
for a Django model field.
Expand Down Expand Up @@ -41,36 +73,60 @@ class Choices:

"""

def __init__(self, *choices):
@overload
def __init__(self: Choices[str], *choices: _ChoiceStr):
...

@overload
def __init__(self, *choices: _Choice[T]):
...

def __init__(self, *choices: _ChoiceStr | _Choice[T]):
# list of choices expanded to triples - can include optgroups
self._triples = []
self._triples: _TripleCollector[T] = []
# list of choices as (db, human-readable) - can include optgroups
self._doubles = []
self._doubles: _DoubleCollector[T] = []
# dictionary mapping db representation to human-readable
self._display_map = {}
self._display_map: dict[T, StrOrPromise | list[_Triple[T]]] = {}
# dictionary mapping Python identifier to db representation
self._identifier_map = {}
self._identifier_map: dict[str, T] = {}
# set of db representations
self._db_values = set()
self._db_values: set[T] = set()

self._process(choices)

def _store(self, triple, triple_collector, double_collector):
def _store(
self,
triple: tuple[T, str, StrOrPromise],
triple_collector: _TripleCollector[T],
double_collector: _DoubleCollector[T]
) -> None:
self._identifier_map[triple[1]] = triple[0]
self._display_map[triple[0]] = triple[2]
self._db_values.add(triple[0])
triple_collector.append(triple)
double_collector.append((triple[0], triple[2]))

def _process(self, choices, triple_collector=None, double_collector=None):
def _process(
self,
choices: Iterable[_ChoiceStr | _Choice[T]],
triple_collector: _TripleCollector[T] | None = None,
double_collector: _DoubleCollector[T] | None = None
) -> None:
if triple_collector is None:
triple_collector = self._triples
if double_collector is None:
double_collector = self._doubles

store = lambda c: self._store(c, triple_collector, double_collector)
def store(c: tuple[Any, str, StrOrPromise]) -> None:
self._store(c, triple_collector, double_collector)

for choice in choices:
# The type inference is not very accurate here:
# - we lied in the type aliases, stating groups contain an arbitrary Sequence
# rather than only list or tuple
# - there is no way to express that _ChoiceStr is only used when T=str
# - mypy 1.9.0 doesn't narrow types based on the value of len()
if isinstance(choice, (list, tuple)):
if len(choice) == 3:
store(choice)
Expand All @@ -79,13 +135,13 @@ def _process(self, choices, triple_collector=None, double_collector=None):
# option group
group_name = choice[0]
subchoices = choice[1]
tc = []
tc: _TripleCollector[T] = []
triple_collector.append((group_name, tc))
dc = []
dc: _DoubleCollector[T] = []
double_collector.append((group_name, dc))
self._process(subchoices, tc, dc)
else:
store((choice[0], choice[0], choice[1]))
store((choice[0], cast(str, choice[0]), cast('StrOrPromise', choice[1])))
else:
raise ValueError(
"Choices can't take a list of length %s, only 2 or 3"
Expand All @@ -94,54 +150,74 @@ def _process(self, choices, triple_collector=None, double_collector=None):
else:
store((choice, choice, choice))

def __len__(self):
def __len__(self) -> int:
return len(self._doubles)

def __iter__(self):
def __iter__(self) -> Iterator[_DoubleRead[T]]:
return iter(self._doubles)

def __reversed__(self):
def __reversed__(self) -> Iterator[_DoubleRead[T]]:
return reversed(self._doubles)

def __getattr__(self, attname):
def __getattr__(self, attname: str) -> T:
try:
return self._identifier_map[attname]
except KeyError:
raise AttributeError(attname)

def __getitem__(self, key):
def __getitem__(self, key: T) -> StrOrPromise | Sequence[_Triple[T]]:
return self._display_map[key]

def __add__(self, other):
@overload
def __add__(self: Choices[str], other: Choices[str] | Iterable[_ChoiceStr]) -> Choices[str]:
...

@overload
def __add__(self, other: Choices[T] | Iterable[_Choice[T]]) -> Choices[T]:
...

def __add__(self, other: Choices[Any] | Iterable[_ChoiceStr | _Choice[Any]]) -> Choices[Any]:
other_args: list[Any]
if isinstance(other, self.__class__):
other = other._triples
other_args = other._triples
else:
other = list(other)
return Choices(*(self._triples + other))
other_args = list(other)
return Choices(*(self._triples + other_args))

@overload
def __radd__(self: Choices[str], other: Iterable[_ChoiceStr]) -> Choices[str]:
...

@overload
def __radd__(self, other: Iterable[_Choice[T]]) -> Choices[T]:
...

def __radd__(self, other):
def __radd__(self, other: Iterable[_ChoiceStr] | Iterable[_Choice[T]]) -> Choices[Any]:
# radd is never called for matching types, so we don't check here
other = list(other)
return Choices(*(other + self._triples))
other_args = list(other)
# The exact type of 'other' depends on our type argument 'T', which
# is expressed in the overloading, but lost within this method body.
return Choices(*(other_args + self._triples)) # type: ignore[arg-type]

def __eq__(self, other):
def __eq__(self, other: object) -> bool:
if isinstance(other, self.__class__):
return self._triples == other._triples
return False

def __repr__(self):
def __repr__(self) -> str:
return '{}({})'.format(
self.__class__.__name__,
', '.join("%s" % repr(i) for i in self._triples)
)

def __contains__(self, item):
def __contains__(self, item: T) -> bool:
return item in self._db_values

def __deepcopy__(self, memo):
return self.__class__(*copy.deepcopy(self._triples, memo))
def __deepcopy__(self, memo: dict[int, Any] | None) -> Choices[T]:
args: list[Any] = copy.deepcopy(self._triples, memo)
return self.__class__(*args)

def subset(self, *new_identifiers):
def subset(self, *new_identifiers: str) -> Choices[T]:
identifiers = set(self._identifier_map.keys())

if not identifiers.issuperset(new_identifiers):
Expand All @@ -150,7 +226,8 @@ def subset(self, *new_identifiers):
identifiers.symmetric_difference(new_identifiers),
)

return self.__class__(*[
args: list[Any] = [
choice for choice in self._triples
if choice[1] in new_identifiers
])
]
return self.__class__(*args)
Loading
Loading