diff --git a/django-stubs/db/models/fields/related.pyi b/django-stubs/db/models/fields/related.pyi index b1e998f..e9b8d9f 100644 --- a/django-stubs/db/models/fields/related.pyi +++ b/django-stubs/db/models/fields/related.pyi @@ -33,7 +33,7 @@ from django.db.models.fields.reverse_related import ( ) from django.db.models.query_utils import PathInfo, Q -from django.db.models.expressions import F +from django.db.models.expressions import Combinable if TYPE_CHECKING: from django.db.models.manager import RelatedManager @@ -105,11 +105,12 @@ class ForeignObject(RelatedField): class ForeignKey(RelatedField, Generic[_T]): def __init__(self, to: Union[Type[_T], str], on_delete: Any, related_name: str = ..., **kwargs): ... - def __set__(self, instance, value: Union[Model, F]) -> None: ... + def __set__(self, instance, value: Union[Model, Combinable]) -> None: ... def __get__(self, instance, owner) -> _T: ... class OneToOneField(RelatedField, Generic[_T]): def __init__(self, to: Union[Type[_T], str], on_delete: Any, related_name: str = ..., **kwargs): ... + def __set__(self, instance, value: Union[Model, Combinable]) -> None: ... def __get__(self, instance, owner) -> _T: ... class ManyToManyField(RelatedField, Generic[_T]): diff --git a/mypy_django_plugin/config.py b/mypy_django_plugin/config.py index 6374482..c43b22c 100644 --- a/mypy_django_plugin/config.py +++ b/mypy_django_plugin/config.py @@ -1,5 +1,5 @@ from configparser import ConfigParser -from typing import Optional +from typing import List, Optional from dataclasses import dataclass @@ -15,7 +15,11 @@ class Config: ini_config.read(fpath) if not ini_config.has_section('mypy_django_plugin'): raise ValueError('Invalid config file: no [mypy_django_plugin] section') - return Config(django_settings_module=ini_config.get('mypy_django_plugin', 'django_settings', - fallback=None), + + django_settings = ini_config.get('mypy_django_plugin', 'django_settings', + fallback=None) + if django_settings: + django_settings = django_settings.strip() + return Config(django_settings_module=django_settings, ignore_missing_settings=ini_config.get('mypy_django_plugin', 'ignore_missing_settings', fallback=False)) diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index 0da9d68..162f051 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -2,11 +2,10 @@ import os from typing import Callable, Dict, Optional, cast from mypy.checker import TypeChecker -from mypy.nodes import TypeInfo +from mypy.nodes import MemberExpr, TypeInfo from mypy.options import Options -from mypy.plugin import ClassDefContext, FunctionContext, MethodContext, Plugin, AttributeContext -from mypy.types import Instance, Type, TypeType, AnyType, TypeOfAny - +from mypy.plugin import AttributeContext, ClassDefContext, FunctionContext, MethodContext, Plugin +from mypy.types import AnyType, Instance, Type, TypeOfAny, TypeType from mypy_django_plugin import helpers, monkeypatch from mypy_django_plugin.config import Config from mypy_django_plugin.plugins import init_create @@ -14,7 +13,7 @@ from mypy_django_plugin.plugins.fields import determine_type_of_array_field, rec from mypy_django_plugin.plugins.migrations import determine_model_cls_from_string_for_migrations, get_string_value_from_expr from mypy_django_plugin.plugins.models import process_model_class from mypy_django_plugin.plugins.related_fields import extract_to_parameter_as_get_ret_type_for_related_field, reparametrize_with -from mypy_django_plugin.plugins.settings import AddSettingValuesToDjangoConfObject +from mypy_django_plugin.plugins.settings import AddSettingValuesToDjangoConfObject, SettingContext, get_settings_metadata def transform_model_class(ctx: ClassDefContext) -> None: @@ -106,6 +105,25 @@ def extract_and_return_primary_key_of_bound_related_field_parameter(ctx: Attribu return ctx.default_attr_type +class ExtractSettingType: + def __init__(self, module_fullname: str): + self.module_fullname = module_fullname + + def __call__(self, ctx: AttributeContext) -> Type: + api = cast(TypeChecker, ctx.api) + original_module = api.modules.get(self.module_fullname) + if original_module is None: + return ctx.default_attr_type + + definition = ctx.context + if isinstance(definition, MemberExpr): + sym = original_module.names.get(definition.name) + if sym and sym.type: + return sym.type + + return ctx.default_attr_type + + class DjangoPlugin(Plugin): def __init__(self, options: Options) -> None: super().__init__(options) @@ -116,17 +134,17 @@ class DjangoPlugin(Plugin): config_fpath = os.environ.get('MYPY_DJANGO_CONFIG', 'mypy_django.ini') if config_fpath and os.path.exists(config_fpath): self.config = Config.from_config_file(config_fpath) - self.django_settings = self.config.django_settings_module + self.django_settings_module = self.config.django_settings_module else: self.config = Config() - self.django_settings = None + self.django_settings_module = None if 'DJANGO_SETTINGS_MODULE' in os.environ: - self.django_settings = os.environ['DJANGO_SETTINGS_MODULE'] + self.django_settings_module = os.environ['DJANGO_SETTINGS_MODULE'] settings_modules = ['django.conf.global_settings'] - if self.django_settings: - settings_modules.append(self.django_settings) + if self.django_settings_module: + settings_modules.append(self.django_settings_module) monkeypatch.add_modules_as_a_source_seed_files(settings_modules) monkeypatch.inject_modules_as_dependencies_for_django_conf_settings(settings_modules) @@ -197,8 +215,8 @@ class DjangoPlugin(Plugin): if fullname == helpers.DUMMY_SETTINGS_BASE_CLASS: settings_modules = ['django.conf.global_settings'] - if self.django_settings: - settings_modules.append(self.django_settings) + if self.django_settings_module: + settings_modules.append(self.django_settings_module) return AddSettingValuesToDjangoConfObject(settings_modules, self.config.ignore_missing_settings) @@ -209,11 +227,14 @@ class DjangoPlugin(Plugin): def get_attribute_hook(self, fullname: str ) -> Optional[Callable[[AttributeContext], Type]]: - # sym = self.lookup_fully_qualified(helpers.MODEL_CLASS_FULLNAME) - # if sym and isinstance(sym.node, TypeInfo): - # if fullname.rpartition('.')[-1] in helpers.get_related_field_primary_key_names(sym.node): - return extract_and_return_primary_key_of_bound_related_field_parameter + module, _, name = fullname.rpartition('.') + sym = self.lookup_fully_qualified('django.conf.LazySettings') + if sym and isinstance(sym.node, TypeInfo): + metadata = get_settings_metadata(sym.node) + if module == 'builtins.object' and name in metadata: + return ExtractSettingType(module_fullname=metadata[name]) + return extract_and_return_primary_key_of_bound_related_field_parameter def plugin(version): diff --git a/mypy_django_plugin/plugins/init_create.py b/mypy_django_plugin/plugins/init_create.py index ee9cc6a..d963d7f 100644 --- a/mypy_django_plugin/plugins/init_create.py +++ b/mypy_django_plugin/plugins/init_create.py @@ -127,16 +127,17 @@ def extract_expected_types(ctx: FunctionContext, model: TypeInfo) -> Dict[str, T if name in {'_meta', 'pk'}: continue if isinstance(sym.node, Var): - if isinstance(sym.node.type, Instance): + if sym.node.type is None or isinstance(sym.node.type, AnyType): + # types are not ready, fallback to Any + expected_types[name] = AnyType(TypeOfAny.from_unimported_type) + expected_types[name + '_id'] = AnyType(TypeOfAny.from_unimported_type) + + elif isinstance(sym.node.type, Instance): tp = sym.node.type field_type = extract_field_setter_type(tp) if field_type is None: continue - choices_type_fullname = extract_choices_type(model, name) - if choices_type_fullname: - field_type = UnionType([field_type, ctx.api.named_generic_type(choices_type_fullname, [])]) - if tp.type.fullname() in {helpers.FOREIGN_KEY_FULLNAME, helpers.ONETOONE_FIELD_FULLNAME}: ref_to_model = tp.args[0] primary_key_type = AnyType(TypeOfAny.special_form) @@ -145,9 +146,7 @@ def extract_expected_types(ctx: FunctionContext, model: TypeInfo) -> Dict[str, T if typ: primary_key_type = typ expected_types[name + '_id'] = primary_key_type - if field_type: expected_types[name] = field_type - elif isinstance(sym.node.type, AnyType): - expected_types[name] = sym.node.type + return expected_types diff --git a/mypy_django_plugin/plugins/settings.py b/mypy_django_plugin/plugins/settings.py index 3f8172b..472fb60 100644 --- a/mypy_django_plugin/plugins/settings.py +++ b/mypy_django_plugin/plugins/settings.py @@ -1,9 +1,10 @@ -from typing import List, Optional, cast +from typing import Iterable, List, Optional, cast -from mypy.nodes import ClassDef, Context, MypyFile, SymbolNode, SymbolTableNode, Var +from mypy.nodes import ClassDef, Context, ImportAll, MypyFile, SymbolNode, SymbolTableNode, TypeInfo, Var from mypy.plugin import ClassDefContext from mypy.semanal import SemanticAnalyzerPass2 -from mypy.types import Instance, NoneTyp, Type, UnionType +from mypy.types import AnyType, Instance, NoneTyp, Type, TypeOfAny, UnionType +from mypy.util import correct_relative_import def get_error_context(node: SymbolNode) -> Context: @@ -36,14 +37,44 @@ def make_sym_copy_of_setting(sym: SymbolTableNode) -> Optional[SymbolTableNode]: return None -def load_settings_from_module(settings_classdef: ClassDef, module: MypyFile) -> None: - for name, sym in module.names.items(): - if name.isupper() and isinstance(sym.node, Var): - if sym.type is not None: - copied = make_sym_copy_of_setting(sym) - if copied is None: - continue - settings_classdef.info.names[name] = copied +def get_settings_metadata(lazy_settings_info: TypeInfo): + return lazy_settings_info.metadata.setdefault('django', {}).setdefault('settings', {}) + + +def load_settings_from_names(settings_classdef: ClassDef, + modules: Iterable[MypyFile], + api: SemanticAnalyzerPass2) -> None: + settings_metadata = get_settings_metadata(settings_classdef.info) + + for module in modules: + for name, sym in module.names.items(): + if name.isupper() and isinstance(sym.node, Var): + if sym.type is not None: + copied = make_sym_copy_of_setting(sym) + if copied is None: + continue + settings_classdef.info.names[name] = copied + else: + var = Var(name, AnyType(TypeOfAny.unannotated)) + var.info = api.named_type('__builtins__.object').type + settings_classdef.info.names[name] = SymbolTableNode(sym.kind, var) + + settings_metadata[name] = module.fullname() + + +def get_import_star_modules(api: SemanticAnalyzerPass2, module: MypyFile) -> List[str]: + import_star_modules = [] + for module_import in module.imports: + # relative import * are not resolved by mypy + if isinstance(module_import, ImportAll) and module_import.relative: + absolute_import_path, correct = correct_relative_import(module.fullname(), module_import.relative, module_import.id, + is_cur_package_init_file=False) + if not correct: + return [] + for path in [absolute_import_path] + get_import_star_modules(api, module=api.modules.get(absolute_import_path)): + if path not in import_star_modules: + import_star_modules.append(path) + return import_star_modules class AddSettingValuesToDjangoConfObject: @@ -55,7 +86,9 @@ class AddSettingValuesToDjangoConfObject: api = cast(SemanticAnalyzerPass2, ctx.api) for module_name in self.settings_modules: module = api.modules[module_name] - load_settings_from_module(ctx.cls, module=module) + star_deps = [api.modules[star_dep] + for star_dep in reversed(get_import_star_modules(api, module))] + load_settings_from_names(ctx.cls, modules=star_deps + [module], api=api) if self.ignore_missing_settings: ctx.cls.info.fallback_to_any = True diff --git a/test-data/typecheck/settings.test b/test-data/typecheck/settings.test index 31d2321..33c6d3e 100644 --- a/test-data/typecheck/settings.test +++ b/test-data/typecheck/settings.test @@ -2,13 +2,18 @@ from django.conf import settings reveal_type(settings.ROOT_DIR) # E: Revealed type is 'builtins.str' +reveal_type(settings.APPS_DIR) # E: Revealed type is 'pathlib.Path' reveal_type(settings.OBJ) # E: Revealed type is 'django.utils.functional.LazyObject' reveal_type(settings.NUMBERS) # E: Revealed type is 'builtins.list[builtins.str]' reveal_type(settings.DICT) # E: Revealed type is 'builtins.dict[Any, Any]' [env DJANGO_SETTINGS_MODULE=mysettings] -[file mysettings.py] -SECRET_KEY = 112233 +[file base.py] +from pathlib import Path ROOT_DIR = '/etc' +APPS_DIR = Path(ROOT_DIR) +[file mysettings.py] +from base import * +SECRET_KEY = 112233 NUMBERS = ['one', 'two'] DICT = {} # type: ignore from django.utils.functional import LazyObject