Skip to content

Commit 50372fa

Browse files
authored
Merge pull request #427 from kszmigiel/feature/manager
Added dynamic class hook for `from_queryset` manager
2 parents 3170b5d + 164f83d commit 50372fa

File tree

14 files changed

+616
-34
lines changed

14 files changed

+616
-34
lines changed

django-stubs/contrib/admin/options.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ class ModelAdmin(BaseModelAdmin):
135135
delete_selected_confirmation_template: str = ...
136136
object_history_template: str = ...
137137
popup_response_template: str = ...
138-
actions: Sequence[Callable[[ModelAdmin, HttpRequest, QuerySet], None]] = ...
138+
actions: Sequence[Union[Callable[[ModelAdmin, HttpRequest, QuerySet], None], str]] = ...
139139
action_form: Any = ...
140140
actions_on_top: bool = ...
141141
actions_on_bottom: bool = ...

django-stubs/core/paginator.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ class Paginator:
2929
orphans: int = ...,
3030
allow_empty_first_page: bool = ...,
3131
) -> None: ...
32-
def validate_number(self, number: Optional[Union[float, str]]) -> int: ...
33-
def get_page(self, number: Optional[int]) -> Page: ...
32+
def validate_number(self, number: Optional[Union[int, float, str]]) -> int: ...
33+
def get_page(self, number: Optional[Union[int, float, str]]) -> Page: ...
3434
def page(self, number: Union[int, str]) -> Page: ...
3535
@property
3636
def count(self) -> int: ...

django-stubs/db/models/enums.pyi

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ class ChoicesMeta(enum.EnumMeta):
1010

1111
class Choices(enum.Enum, metaclass=ChoicesMeta):
1212
def __str__(self): ...
13+
@property
14+
def label(self) -> str: ...
15+
@property
16+
def value(self) -> Any: ...
1317

1418
# fake
1519
class _IntegerChoicesMeta(ChoicesMeta):
@@ -18,7 +22,9 @@ class _IntegerChoicesMeta(ChoicesMeta):
1822
labels: List[str] = ...
1923
values: List[int] = ...
2024

21-
class IntegerChoices(int, Choices, metaclass=_IntegerChoicesMeta): ...
25+
class IntegerChoices(int, Choices, metaclass=_IntegerChoicesMeta):
26+
@property
27+
def value(self) -> int: ...
2228

2329
# fake
2430
class _TextChoicesMeta(ChoicesMeta):
@@ -27,4 +33,6 @@ class _TextChoicesMeta(ChoicesMeta):
2733
labels: List[str] = ...
2834
values: List[str] = ...
2935

30-
class TextChoices(str, Choices, metaclass=_TextChoicesMeta): ...
36+
class TextChoices(str, Choices, metaclass=_TextChoicesMeta):
37+
@property
38+
def value(self) -> str: ...

django-stubs/forms/fields.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Field:
1414
initial: Any
1515
label: Optional[str]
1616
required: bool
17-
widget: Type[Widget] = ...
17+
widget: Union[Type[Widget], Widget] = ...
1818
hidden_widget: Any = ...
1919
default_validators: Any = ...
2020
default_error_messages: Any = ...

django-stubs/http/response.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,13 @@ class HttpResponse(HttpResponseBase):
8383
context: Context
8484
resolver_match: ResolverMatch
8585
def json(self) -> Any: ...
86+
def getvalue(self) -> bytes: ...
8687

8788
class StreamingHttpResponse(HttpResponseBase):
8889
content: Any
8990
streaming_content: Iterator[Any]
9091
def __init__(self, streaming_content: Iterable[Any] = ..., *args: Any, **kwargs: Any) -> None: ...
91-
def getvalue(self) -> Any: ...
92+
def getvalue(self) -> bytes: ...
9293

9394
class FileResponse(StreamingHttpResponse):
9495
client: Client

django-stubs/utils/functional.pyi

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,8 @@ class SimpleLazyObject(LazyObject):
5656
def __copy__(self) -> List[int]: ...
5757
def __deepcopy__(self, memo: Dict[Any, Any]) -> List[int]: ...
5858

59-
def partition(predicate: Callable, values: List[Model]) -> Tuple[List[Model], List[Model]]: ...
59+
_PartitionMember = TypeVar("_PartitionMember")
60+
61+
def partition(
62+
predicate: Callable, values: List[_PartitionMember]
63+
) -> Tuple[List[_PartitionMember], List[_PartitionMember]]: ...

mypy_django_plugin/lib/chk_helpers.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from mypy import checker
44
from mypy.checker import TypeChecker
5+
from mypy.mro import calculate_mro
56
from mypy.nodes import (
6-
GDEF, MDEF, Expression, MypyFile, SymbolTableNode, TypeInfo, Var,
7+
GDEF, MDEF, Block, ClassDef, Expression, MypyFile, SymbolTable, SymbolTableNode, TypeInfo, Var,
78
)
89
from mypy.plugin import (
910
AttributeContext, CheckerPluginInterface, FunctionContext, MethodContext,
@@ -21,9 +22,17 @@ def add_new_class_for_current_module(current_module: MypyFile,
2122
fields: Optional[Dict[str, MypyType]] = None
2223
) -> TypeInfo:
2324
new_class_unique_name = checker.gen_unique_name(name, current_module.names)
24-
new_typeinfo = helpers.new_typeinfo(new_class_unique_name,
25-
bases=bases,
26-
module_name=current_module.fullname)
25+
26+
# make new class expression
27+
classdef = ClassDef(new_class_unique_name, Block([]))
28+
classdef.fullname = current_module.fullname + '.' + new_class_unique_name
29+
30+
# make new TypeInfo
31+
new_typeinfo = TypeInfo(SymbolTable(), classdef, current_module.fullname)
32+
new_typeinfo.bases = bases
33+
calculate_mro(new_typeinfo)
34+
new_typeinfo.calculate_metaclass_type()
35+
2736
# add fields
2837
if fields:
2938
for field_name, field_type in fields.items():
@@ -32,8 +41,8 @@ def add_new_class_for_current_module(current_module: MypyFile,
3241
var._fullname = new_typeinfo.fullname + '.' + field_name
3342
new_typeinfo.names[field_name] = SymbolTableNode(MDEF, var, plugin_generated=True)
3443

44+
classdef.info = new_typeinfo
3545
current_module.names[new_class_unique_name] = SymbolTableNode(GDEF, new_typeinfo, plugin_generated=True)
36-
current_module.defs.append(new_typeinfo.defn)
3746
return new_typeinfo
3847

3948

mypy_django_plugin/lib/helpers.py

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
from mypy.checker import TypeChecker
77
from mypy.mro import calculate_mro
88
from mypy.nodes import (
9-
Block, CallExpr, ClassDef, Context, Expression, MemberExpr, MypyFile, NameExpr, PlaceholderNode, StrExpr,
10-
SymbolTable, SymbolTableNode, TypeInfo, Var,
9+
GDEF, Argument, Block, CallExpr, ClassDef, Context, Expression, FuncDef, MemberExpr, MypyFile, NameExpr,
10+
PlaceholderNode, StrExpr, SymbolTable, SymbolTableNode, TypeInfo, Var,
1111
)
1212
from mypy.plugin import (
1313
AttributeContext, ClassDefContext, DynamicClassDefContext, FunctionContext, MethodContext,
1414
)
15+
from mypy.plugins.common import add_method_to_class
1516
from mypy.semanal import SemanticAnalyzer
16-
from mypy.types import AnyType, Instance, NoneTyp, ProperType
17+
from mypy.types import AnyType, CallableType, Instance, NoneTyp, ProperType
1718
from mypy.types import Type as MypyType
1819
from mypy.types import TypeOfAny, UnionType
1920

@@ -94,6 +95,80 @@ def lookup_typeinfo_or_defer(self, fullname: str, *,
9495

9596
return sym.node
9697

98+
def copy_method_to_another_class(
99+
self,
100+
ctx: ClassDefContext,
101+
self_type: Instance,
102+
new_method_name: str,
103+
method_node: FuncDef) -> None:
104+
if method_node.type is None:
105+
if not self.defer_till_next_iteration(reason='method_node.type is None'):
106+
raise new_helpers.TypeInfoNotFound(method_node.fullname)
107+
108+
arguments, return_type = build_unannotated_method_args(method_node)
109+
add_method_to_class(
110+
ctx.api,
111+
ctx.cls,
112+
new_method_name,
113+
args=arguments,
114+
return_type=return_type,
115+
self_type=self_type)
116+
return
117+
118+
method_type = cast(CallableType, method_node.type)
119+
if not isinstance(method_type, CallableType) and not self.defer_till_next_iteration(
120+
reason='method_node.type is not CallableType'):
121+
raise new_helpers.TypeInfoNotFound(method_node.fullname)
122+
123+
arguments = []
124+
bound_return_type = self.semanal_api.anal_type(
125+
method_type.ret_type,
126+
allow_placeholder=True)
127+
128+
if bound_return_type is None and self.defer_till_next_iteration():
129+
raise new_helpers.TypeInfoNotFound(method_node.fullname + ' return type')
130+
131+
assert bound_return_type is not None
132+
133+
if isinstance(bound_return_type, PlaceholderNode):
134+
raise new_helpers.TypeInfoNotFound('return type ' + method_node.fullname)
135+
136+
for arg_name, arg_type, original_argument in zip(
137+
method_type.arg_names[1:],
138+
method_type.arg_types[1:],
139+
method_node.arguments[1:]):
140+
bound_arg_type = self.semanal_api.anal_type(arg_type, allow_placeholder=True)
141+
if bound_arg_type is None and not self.defer_till_next_iteration(reason='bound_arg_type is None'):
142+
error_msg = 'of {} argument of {}'.format(arg_name, method_node.fullname)
143+
raise new_helpers.TypeInfoNotFound(error_msg)
144+
145+
assert bound_arg_type is not None
146+
147+
if isinstance(bound_arg_type, PlaceholderNode) and self.defer_till_next_iteration(
148+
reason='bound_arg_type is None'):
149+
raise new_helpers.TypeInfoNotFound('of ' + arg_name + ' argument of ' + method_node.fullname)
150+
151+
var = Var(
152+
name=original_argument.variable.name,
153+
type=arg_type)
154+
var.line = original_argument.variable.line
155+
var.column = original_argument.variable.column
156+
argument = Argument(
157+
variable=var,
158+
type_annotation=bound_arg_type,
159+
initializer=original_argument.initializer,
160+
kind=original_argument.kind)
161+
argument.set_line(original_argument)
162+
arguments.append(argument)
163+
164+
add_method_to_class(
165+
ctx.api,
166+
ctx.cls,
167+
new_method_name,
168+
args=arguments,
169+
return_type=bound_return_type,
170+
self_type=self_type)
171+
97172
def new_typeinfo(self, name: str, bases: List[Instance], module_fullname: Optional[str] = None) -> TypeInfo:
98173
class_def = ClassDef(name, Block([]))
99174
class_def.fullname = self.semanal_api.qualified_name(name)
@@ -118,11 +193,46 @@ def __call__(self, ctx: DynamicClassDefContext) -> None:
118193
self.semanal_api = cast(SemanticAnalyzer, ctx.api)
119194
self.create_new_dynamic_class()
120195

196+
def generate_manager_info_and_module(self, base_manager_info: TypeInfo) -> Tuple[TypeInfo, MypyFile]:
197+
new_manager_info = self.semanal_api.basic_new_typeinfo(
198+
self.class_name,
199+
basetype_or_fallback=Instance(
200+
base_manager_info,
201+
[AnyType(TypeOfAny.unannotated)])
202+
)
203+
new_manager_info.line = self.call_expr.line
204+
new_manager_info.defn.line = self.call_expr.line
205+
new_manager_info.metaclass_type = new_manager_info.calculate_metaclass_type()
206+
207+
current_module = self.semanal_api.cur_mod_node
208+
current_module.names[self.class_name] = SymbolTableNode(
209+
GDEF,
210+
new_manager_info,
211+
plugin_generated=True)
212+
return new_manager_info, current_module
213+
121214
@abstractmethod
122215
def create_new_dynamic_class(self) -> None:
123216
raise NotImplementedError
124217

125218

219+
class DynamicClassFromMethodCallback(DynamicClassPluginCallback):
220+
callee: MemberExpr
221+
222+
def __call__(self, ctx: DynamicClassDefContext) -> None:
223+
self.class_name = ctx.name
224+
self.call_expr = ctx.call
225+
226+
assert ctx.call.callee is not None
227+
if not isinstance(ctx.call.callee, MemberExpr):
228+
# throw error?
229+
return
230+
self.callee = ctx.call.callee
231+
232+
self.semanal_api = cast(SemanticAnalyzer, ctx.api)
233+
self.create_new_dynamic_class()
234+
235+
126236
class ClassDefPluginCallback(SemanalPluginCallback):
127237
reason: Expression
128238
class_defn: ClassDef
@@ -396,3 +506,12 @@ def get_nested_meta_node_for_current_class(info: TypeInfo) -> Optional[TypeInfo]
396506
if metaclass_sym is not None and isinstance(metaclass_sym.node, TypeInfo):
397507
return metaclass_sym.node
398508
return None
509+
510+
511+
def build_unannotated_method_args(method_node: FuncDef) -> Tuple[List[Argument], MypyType]:
512+
prepared_arguments = []
513+
for argument in method_node.arguments[1:]:
514+
argument.type_annotation = AnyType(TypeOfAny.unannotated)
515+
prepared_arguments.append(argument)
516+
return_type = AnyType(TypeOfAny.unannotated)
517+
return prepared_arguments, return_type

mypy_django_plugin/main.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from mypy.nodes import MypyFile, TypeInfo
77
from mypy.options import Options
88
from mypy.plugin import (
9-
AttributeContext, ClassDefContext, FunctionContext, MethodContext, Plugin,
9+
AttributeContext, ClassDefContext, DynamicClassDefContext, FunctionContext, MethodContext, Plugin,
1010
)
1111
from mypy.types import Type as MypyType
1212

@@ -19,6 +19,9 @@
1919
from mypy_django_plugin.transformers.init_create import (
2020
ModelCreateCallback, ModelInitCallback,
2121
)
22+
from mypy_django_plugin.transformers.managers import (
23+
ManagerFromQuerySetCallback,
24+
)
2225
from mypy_django_plugin.transformers.meta import MetaGetFieldCallback
2326
from mypy_django_plugin.transformers.models import ModelCallback
2427
from mypy_django_plugin.transformers.orm_lookups import (
@@ -249,6 +252,15 @@ def get_attribute_hook(self, fullname: str
249252

250253
return None
251254

255+
def get_dynamic_class_hook(self, fullname: str
256+
) -> Optional[Callable[[DynamicClassDefContext], None]]:
257+
if fullname.endswith('from_queryset'):
258+
class_name, _, _ = fullname.rpartition('.')
259+
info = self._get_typeinfo_or_none(class_name)
260+
if info and info.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME):
261+
return ManagerFromQuerySetCallback(self)
262+
return None
263+
252264

253265
def plugin(version):
254266
return NewSemanalDjangoPlugin
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from typing import List, Tuple
2+
3+
from mypy.nodes import Argument, FuncDef, NameExpr, StrExpr, TypeInfo
4+
from mypy.plugin import ClassDefContext
5+
from mypy.types import AnyType, Instance
6+
from mypy.types import Type as MypyType
7+
from mypy.types import TypeOfAny
8+
9+
from mypy_django_plugin.lib import fullnames, helpers
10+
11+
12+
def build_unannotated_method_args(method_node: FuncDef) -> Tuple[List[Argument], MypyType]:
13+
prepared_arguments = []
14+
for argument in method_node.arguments[1:]:
15+
argument.type_annotation = AnyType(TypeOfAny.unannotated)
16+
prepared_arguments.append(argument)
17+
return_type = AnyType(TypeOfAny.unannotated)
18+
return prepared_arguments, return_type
19+
20+
21+
class ManagerFromQuerySetCallback(helpers.DynamicClassFromMethodCallback):
22+
def create_new_dynamic_class(self) -> None:
23+
24+
base_manager_info = self.callee.expr.node # type: ignore
25+
26+
if base_manager_info is None and not self.defer_till_next_iteration(reason='base_manager_info is None'):
27+
# what exception should be thrown here?
28+
return
29+
30+
assert isinstance(base_manager_info, TypeInfo)
31+
32+
new_manager_info, current_module = self.generate_manager_info_and_module(base_manager_info)
33+
34+
passed_queryset = self.call_expr.args[0]
35+
assert isinstance(passed_queryset, NameExpr)
36+
37+
derived_queryset_fullname = passed_queryset.fullname
38+
assert derived_queryset_fullname is not None
39+
40+
sym = self.semanal_api.lookup_fully_qualified_or_none(derived_queryset_fullname)
41+
assert sym is not None
42+
if sym.node is None and not self.defer_till_next_iteration(reason='sym.node is None'):
43+
# inherit from Any to prevent false-positives, if queryset class cannot be resolved
44+
new_manager_info.fallback_to_any = True
45+
return
46+
47+
derived_queryset_info = sym.node
48+
assert isinstance(derived_queryset_info, TypeInfo)
49+
50+
if len(self.call_expr.args) > 1:
51+
expr = self.call_expr.args[1]
52+
assert isinstance(expr, StrExpr)
53+
custom_manager_generated_name = expr.value
54+
else:
55+
custom_manager_generated_name = base_manager_info.name + 'From' + derived_queryset_info.name
56+
57+
custom_manager_generated_fullname = '.'.join(['django.db.models.manager', custom_manager_generated_name])
58+
if 'from_queryset_managers' not in base_manager_info.metadata:
59+
base_manager_info.metadata['from_queryset_managers'] = {}
60+
base_manager_info.metadata['from_queryset_managers'][
61+
custom_manager_generated_fullname] = new_manager_info.fullname
62+
class_def_context = ClassDefContext(
63+
cls=new_manager_info.defn,
64+
reason=self.call_expr, api=self.semanal_api)
65+
self_type = Instance(new_manager_info, [])
66+
# we need to copy all methods in MRO before django.db.models.query.QuerySet
67+
for class_mro_info in derived_queryset_info.mro:
68+
if class_mro_info.fullname == fullnames.QUERYSET_CLASS_FULLNAME:
69+
break
70+
for name, sym in class_mro_info.names.items():
71+
if isinstance(sym.node, FuncDef):
72+
self.copy_method_to_another_class(
73+
class_def_context,
74+
self_type,
75+
new_method_name=name,
76+
method_node=sym.node)

0 commit comments

Comments
 (0)