mirror of
https://github.com/davidhalter/django-stubs.git
synced 2025-12-06 12:14:28 +08:00
add support for django.conf.settings.SETTING_NAME
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
pytest_plugins = [
|
||||
'mypy.test.data'
|
||||
'test.data'
|
||||
]
|
||||
@@ -1,4 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
from django.utils.functional import LazyObject
|
||||
|
||||
settings = ... # type: Any
|
||||
|
||||
# required for plugin to be able to distinguish this specific instance of LazySettings from others
|
||||
class _DjangoConfLazyObject(LazyObject): ...
|
||||
|
||||
|
||||
class LazySettings(_DjangoConfLazyObject):
|
||||
configured: bool
|
||||
def configure(self, default_settings: Any = ..., **options: Any) -> Any: ...
|
||||
|
||||
settings: LazySettings = ...
|
||||
57
django-stubs/utils/functional.pyi
Normal file
57
django-stubs/utils/functional.pyi
Normal file
@@ -0,0 +1,57 @@
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
from django.db.models.base import Model
|
||||
|
||||
|
||||
def curry(_curried_func: Any, *args: Any, **kwargs: Any): ...
|
||||
|
||||
class cached_property:
|
||||
func: Callable = ...
|
||||
__doc__: Any = ...
|
||||
name: str = ...
|
||||
def __init__(self, func: Callable, name: Optional[str] = ...) -> None: ...
|
||||
def __get__(self, instance: Any, cls: Type[Any] = ...) -> Any: ...
|
||||
|
||||
class Promise: ...
|
||||
|
||||
def lazy(func: Union[Callable, Type[str]], *resultclasses: Any) -> Callable: ...
|
||||
def lazystr(text: Any): ...
|
||||
def keep_lazy(*resultclasses: Any) -> Callable: ...
|
||||
def keep_lazy_text(func: Callable) -> Callable: ...
|
||||
|
||||
empty: Any
|
||||
|
||||
def new_method_proxy(func: Any): ...
|
||||
|
||||
class LazyObject:
|
||||
def __init__(self) -> None: ...
|
||||
__getattr__: Any = ...
|
||||
def __setattr__(self, name: str, value: Any) -> None: ...
|
||||
def __delattr__(self, name: str) -> None: ...
|
||||
def __reduce__(self) -> Tuple[Callable, Tuple[Model]]: ...
|
||||
def __copy__(self): ...
|
||||
def __deepcopy__(self, memo: Any): ...
|
||||
__bytes__: Any = ...
|
||||
__bool__: Any = ...
|
||||
__dir__: Any = ...
|
||||
__class__: Any = ...
|
||||
__eq__: Any = ...
|
||||
__ne__: Any = ...
|
||||
__hash__: Any = ...
|
||||
__getitem__: Any = ...
|
||||
__setitem__: Any = ...
|
||||
__delitem__: Any = ...
|
||||
__iter__: Any = ...
|
||||
__len__: Any = ...
|
||||
__contains__: Any = ...
|
||||
|
||||
def unpickle_lazyobject(wrapped: Model) -> Model: ...
|
||||
|
||||
class SimpleLazyObject(LazyObject):
|
||||
def __init__(self, func: Callable) -> None: ...
|
||||
def __copy__(self) -> List[int]: ...
|
||||
def __deepcopy__(self, memo: Dict[Any, Any]) -> List[int]: ...
|
||||
|
||||
def partition(
|
||||
predicate: Callable, values: List[Model]
|
||||
) -> Tuple[List[Model], List[Model]]: ...
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Dict, Optional, Type, Tuple, NamedTuple
|
||||
from typing import Dict, Optional, NamedTuple
|
||||
|
||||
from mypy.nodes import SymbolTableNode, Var, Expression, MemberExpr
|
||||
from mypy.semanal import SemanticAnalyzerPass2
|
||||
from mypy.types import Type
|
||||
from mypy.nodes import SymbolTableNode, Var, Expression
|
||||
from mypy.plugin import FunctionContext
|
||||
from mypy.types import Instance
|
||||
from mypy.types import Instance, UnionType, NoneTyp
|
||||
|
||||
MODEL_CLASS_FULLNAME = 'django.db.models.base.Model'
|
||||
QUERYSET_CLASS_FULLNAME = 'django.db.models.query.QuerySet'
|
||||
@@ -13,7 +15,6 @@ ONETOONE_FIELD_FULLNAME = 'django.db.models.fields.related.OneToOneField'
|
||||
def create_new_symtable_node(name: str, kind: int, instance: Instance) -> SymbolTableNode:
|
||||
new_var = Var(name, instance)
|
||||
new_var.info = instance.type
|
||||
|
||||
return SymbolTableNode(kind, new_var,
|
||||
plugin_generated=True)
|
||||
|
||||
@@ -53,3 +54,14 @@ def get_call_signature_or_none(ctx: FunctionContext) -> Optional[Dict[str, Argum
|
||||
result[arg_name] = (arg[0], arg_type[0])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def make_optional(typ: Type) -> Type:
|
||||
return UnionType.make_simplified_union([typ, NoneTyp()])
|
||||
|
||||
|
||||
def make_required(typ: Type) -> Type:
|
||||
if not isinstance(typ, UnionType):
|
||||
return typ
|
||||
items = [item for item in typ.items if not isinstance(item, NoneTyp)]
|
||||
return UnionType.make_union(items)
|
||||
@@ -1,5 +1,8 @@
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
from django.conf import Settings
|
||||
from mypy.options import Options
|
||||
from mypy.plugin import Plugin, FunctionContext, ClassDefContext
|
||||
from mypy.types import Type
|
||||
|
||||
@@ -8,6 +11,7 @@ from mypy_django_plugin.plugins.objects_queryset import set_objects_queryset_to_
|
||||
from mypy_django_plugin.plugins.postgres_fields import determine_type_of_array_field
|
||||
from mypy_django_plugin.plugins.related_fields import set_related_name_instance_for_onetoonefield, \
|
||||
set_related_name_manager_for_foreign_key, set_fieldname_attrs_for_related_fields
|
||||
from mypy_django_plugin.plugins.setup_settings import DjangoConfSettingsInitializerHook
|
||||
|
||||
|
||||
base_model_classes = {helpers.MODEL_CLASS_FULLNAME}
|
||||
@@ -21,6 +25,15 @@ def transform_model_class(ctx: ClassDefContext) -> None:
|
||||
|
||||
|
||||
class DjangoPlugin(Plugin):
|
||||
def __init__(self,
|
||||
options: Options) -> None:
|
||||
super().__init__(options)
|
||||
self.django_settings = None
|
||||
|
||||
django_settings_module = os.environ.get('DJANGO_SETTINGS_MODULE')
|
||||
if django_settings_module:
|
||||
self.django_settings = Settings(django_settings_module)
|
||||
|
||||
def get_function_hook(self, fullname: str
|
||||
) -> Optional[Callable[[FunctionContext], Type]]:
|
||||
if fullname == helpers.FOREIGN_KEY_FULLNAME:
|
||||
@@ -37,6 +50,8 @@ class DjangoPlugin(Plugin):
|
||||
) -> Optional[Callable[[ClassDefContext], None]]:
|
||||
if fullname in base_model_classes:
|
||||
return transform_model_class
|
||||
if fullname == 'django.conf._DjangoConfLazyObject':
|
||||
return DjangoConfSettingsInitializerHook(settings=self.django_settings)
|
||||
return None
|
||||
|
||||
|
||||
34
mypy_django_plugin/plugins/setup_settings.py
Normal file
34
mypy_django_plugin/plugins/setup_settings.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import cast, Any
|
||||
|
||||
from django.conf import Settings
|
||||
from mypy.nodes import MDEF, TypeInfo, SymbolTable
|
||||
from mypy.plugin import ClassDefContext
|
||||
from mypy.semanal import SemanticAnalyzerPass2
|
||||
from mypy.types import Instance, AnyType, TypeOfAny
|
||||
|
||||
from mypy_django_plugin import helpers
|
||||
|
||||
|
||||
def get_obj_type_name(value: Any) -> str:
|
||||
return type(value).__module__ + '.' + type(value).__qualname__
|
||||
|
||||
|
||||
class DjangoConfSettingsInitializerHook(object):
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def __call__(self, ctx: ClassDefContext) -> None:
|
||||
api = cast(SemanticAnalyzerPass2, ctx.api)
|
||||
for name, value in self.settings.__dict__.items():
|
||||
if name.isupper():
|
||||
if value is None:
|
||||
ctx.cls.info.names[name] = helpers.create_new_symtable_node(name, MDEF,
|
||||
instance=api.builtin_type('builtins.object'))
|
||||
continue
|
||||
|
||||
type_fullname = get_obj_type_name(value)
|
||||
sym = api.lookup_fully_qualified_or_none(type_fullname)
|
||||
if sym is not None:
|
||||
args = len(sym.node.type_vars) * [AnyType(TypeOfAny.from_omitted_generics)]
|
||||
ctx.cls.info.names[name] = helpers.create_new_symtable_node(name, MDEF,
|
||||
instance=Instance(sym.node, args))
|
||||
0
test/__init__.py
Normal file
0
test/__init__.py
Normal file
244
test/data.py
Normal file
244
test/data.py
Normal file
@@ -0,0 +1,244 @@
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any, Optional, Iterator, Dict, List, Tuple, Set
|
||||
|
||||
import pytest
|
||||
from mypy.test.config import test_temp_dir
|
||||
from mypy.test.data import DataDrivenTestCase, DataSuite, add_test_name_suffix, parse_test_data, \
|
||||
expand_errors, expand_variables, fix_win_path
|
||||
|
||||
|
||||
def parse_test_case(case: 'DataDrivenTestCase') -> None:
|
||||
"""Parse and prepare a single case from suite with test case descriptions.
|
||||
|
||||
This method is part of the setup phase, just before the test case is run.
|
||||
"""
|
||||
test_items = parse_test_data(case.data, case.name)
|
||||
base_path = case.suite.base_path
|
||||
if case.suite.native_sep:
|
||||
join = os.path.join
|
||||
else:
|
||||
join = posixpath.join # type: ignore
|
||||
|
||||
out_section_missing = case.suite.required_out_section
|
||||
|
||||
files = [] # type: List[Tuple[str, str]] # path and contents
|
||||
output_files = [] # type: List[Tuple[str, str]] # path and contents for output files
|
||||
output = [] # type: List[str] # Regular output errors
|
||||
output2 = {} # type: Dict[int, List[str]] # Output errors for incremental, runs 2+
|
||||
deleted_paths = {} # type: Dict[int, Set[str]] # from run number of paths
|
||||
stale_modules = {} # type: Dict[int, Set[str]] # from run number to module names
|
||||
rechecked_modules = {} # type: Dict[ int, Set[str]] # from run number module names
|
||||
triggered = [] # type: List[str] # Active triggers (one line per incremental step)
|
||||
|
||||
# Process the parsed items. Each item has a header of form [id args],
|
||||
# optionally followed by lines of text.
|
||||
item = first_item = test_items[0]
|
||||
for item in test_items[1:]:
|
||||
if item.id == 'file' or item.id == 'outfile':
|
||||
# Record an extra file needed for the test case.
|
||||
assert item.arg is not None
|
||||
contents = expand_variables('\n'.join(item.data))
|
||||
file_entry = (join(base_path, item.arg), contents)
|
||||
if item.id == 'file':
|
||||
files.append(file_entry)
|
||||
else:
|
||||
output_files.append(file_entry)
|
||||
elif item.id in ('builtins', 'builtins_py2'):
|
||||
# Use an alternative stub file for the builtins module.
|
||||
assert item.arg is not None
|
||||
mpath = join(os.path.dirname(case.file), item.arg)
|
||||
fnam = 'builtins.pyi' if item.id == 'builtins' else '__builtin__.pyi'
|
||||
with open(mpath) as f:
|
||||
files.append((join(base_path, fnam), f.read()))
|
||||
elif item.id == 'typing':
|
||||
# Use an alternative stub file for the typing module.
|
||||
assert item.arg is not None
|
||||
src_path = join(os.path.dirname(case.file), item.arg)
|
||||
with open(src_path) as f:
|
||||
files.append((join(base_path, 'typing.pyi'), f.read()))
|
||||
elif re.match(r'stale[0-9]*$', item.id):
|
||||
passnum = 1 if item.id == 'stale' else int(item.id[len('stale'):])
|
||||
assert passnum > 0
|
||||
modules = (set() if item.arg is None else {t.strip() for t in item.arg.split(',')})
|
||||
stale_modules[passnum] = modules
|
||||
elif re.match(r'rechecked[0-9]*$', item.id):
|
||||
passnum = 1 if item.id == 'rechecked' else int(item.id[len('rechecked'):])
|
||||
assert passnum > 0
|
||||
modules = (set() if item.arg is None else {t.strip() for t in item.arg.split(',')})
|
||||
rechecked_modules[passnum] = modules
|
||||
elif item.id == 'delete':
|
||||
# File to delete during a multi-step test case
|
||||
assert item.arg is not None
|
||||
m = re.match(r'(.*)\.([0-9]+)$', item.arg)
|
||||
assert m, 'Invalid delete section: {}'.format(item.arg)
|
||||
num = int(m.group(2))
|
||||
assert num >= 2, "Can't delete during step {}".format(num)
|
||||
full = join(base_path, m.group(1))
|
||||
deleted_paths.setdefault(num, set()).add(full)
|
||||
elif re.match(r'out[0-9]*$', item.id):
|
||||
tmp_output = [expand_variables(line) for line in item.data]
|
||||
if os.path.sep == '\\':
|
||||
tmp_output = [fix_win_path(line) for line in tmp_output]
|
||||
if item.id == 'out' or item.id == 'out1':
|
||||
output = tmp_output
|
||||
else:
|
||||
passnum = int(item.id[len('out'):])
|
||||
assert passnum > 1
|
||||
output2[passnum] = tmp_output
|
||||
out_section_missing = False
|
||||
elif item.id == 'triggered' and item.arg is None:
|
||||
triggered = item.data
|
||||
elif item.id == 'env':
|
||||
env_vars_to_set = item.arg
|
||||
for env in env_vars_to_set.split(';'):
|
||||
try:
|
||||
name, value = env.split('=')
|
||||
os.environ[name] = value
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
raise ValueError(
|
||||
'Invalid section header {} in {} at line {}'.format(
|
||||
item.id, case.file, item.line))
|
||||
|
||||
if out_section_missing:
|
||||
raise ValueError(
|
||||
'{}, line {}: Required output section not found'.format(
|
||||
case.file, first_item.line))
|
||||
|
||||
for passnum in stale_modules.keys():
|
||||
if passnum not in rechecked_modules:
|
||||
# If the set of rechecked modules isn't specified, make it the same as the set
|
||||
# of modules with a stale public interface.
|
||||
rechecked_modules[passnum] = stale_modules[passnum]
|
||||
if (passnum in stale_modules
|
||||
and passnum in rechecked_modules
|
||||
and not stale_modules[passnum].issubset(rechecked_modules[passnum])):
|
||||
raise ValueError(
|
||||
('Stale modules after pass {} must be a subset of rechecked '
|
||||
'modules ({}:{})').format(passnum, case.file, first_item.line))
|
||||
|
||||
input = first_item.data
|
||||
expand_errors(input, output, 'main')
|
||||
for file_path, contents in files:
|
||||
expand_errors(contents.split('\n'), output, file_path)
|
||||
|
||||
case.input = input
|
||||
case.output = output
|
||||
case.output2 = output2
|
||||
case.lastline = item.line
|
||||
case.files = files
|
||||
case.output_files = output_files
|
||||
case.expected_stale_modules = stale_modules
|
||||
case.expected_rechecked_modules = rechecked_modules
|
||||
case.deleted_paths = deleted_paths
|
||||
case.triggered = triggered or []
|
||||
|
||||
|
||||
class DjangoDataDrivenTestCase(DataDrivenTestCase):
|
||||
def setup(self) -> None:
|
||||
self.old_environ = os.environ.copy()
|
||||
|
||||
parse_test_case(case=self)
|
||||
self.old_cwd = os.getcwd()
|
||||
|
||||
self.tmpdir = tempfile.TemporaryDirectory(prefix='mypy-test-')
|
||||
|
||||
os.chdir(self.tmpdir.name)
|
||||
os.mkdir(test_temp_dir)
|
||||
encountered_files = set()
|
||||
self.clean_up = []
|
||||
for paths in self.deleted_paths.values():
|
||||
for path in paths:
|
||||
self.clean_up.append((False, path))
|
||||
encountered_files.add(path)
|
||||
for path, content in self.files:
|
||||
dir = os.path.dirname(path)
|
||||
for d in self.add_dirs(dir):
|
||||
self.clean_up.append((True, d))
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
if path not in encountered_files:
|
||||
self.clean_up.append((False, path))
|
||||
encountered_files.add(path)
|
||||
if re.search(r'\.[2-9]$', path):
|
||||
# Make sure new files introduced in the second and later runs are accounted for
|
||||
renamed_path = path[:-2]
|
||||
if renamed_path not in encountered_files:
|
||||
encountered_files.add(renamed_path)
|
||||
self.clean_up.append((False, renamed_path))
|
||||
for path, _ in self.output_files:
|
||||
# Create directories for expected output and mark them to be cleaned up at the end
|
||||
# of the test case.
|
||||
dir = os.path.dirname(path)
|
||||
for d in self.add_dirs(dir):
|
||||
self.clean_up.append((True, d))
|
||||
self.clean_up.append((False, path))
|
||||
|
||||
sys.path.insert(0, os.path.join(self.tmpdir.name, 'tmp'))
|
||||
|
||||
def teardown(self):
|
||||
if hasattr(self, 'old_environ'):
|
||||
os.environ = self.old_environ
|
||||
super().teardown()
|
||||
|
||||
|
||||
def split_test_cases(parent: 'DataSuiteCollector', suite: 'DataSuite',
|
||||
file: str) -> Iterator[DjangoDataDrivenTestCase]:
|
||||
"""Iterate over raw test cases in file, at collection time, ignoring sub items.
|
||||
|
||||
The collection phase is slow, so any heavy processing should be deferred to after
|
||||
uninteresting tests are filtered (when using -k PATTERN switch).
|
||||
"""
|
||||
with open(file, encoding='utf-8') as f:
|
||||
data = f.read()
|
||||
cases = re.split(r'^\[case ([a-zA-Z_0-9]+)'
|
||||
r'(-writescache)?'
|
||||
r'(-only_when_cache|-only_when_nocache)?'
|
||||
r'(-skip)?'
|
||||
r'\][ \t]*$\n', data,
|
||||
flags=re.DOTALL | re.MULTILINE)
|
||||
line_no = cases[0].count('\n') + 1
|
||||
|
||||
for i in range(1, len(cases), 5):
|
||||
name, writescache, only_when, skip, data = cases[i:i + 5]
|
||||
yield DjangoDataDrivenTestCase(parent, suite, file,
|
||||
name=add_test_name_suffix(name, suite.test_name_suffix),
|
||||
writescache=bool(writescache),
|
||||
only_when=only_when,
|
||||
skip=bool(skip),
|
||||
data=data,
|
||||
line=line_no)
|
||||
line_no += data.count('\n') + 1
|
||||
|
||||
|
||||
class DataSuiteCollector(pytest.Class): # type: ignore # inheriting from Any
|
||||
def collect(self) -> Iterator[pytest.Item]: # type: ignore
|
||||
"""Called by pytest on each of the object returned from pytest_pycollect_makeitem"""
|
||||
|
||||
# obj is the object for which pytest_pycollect_makeitem returned self.
|
||||
suite = self.obj # type: DataSuite
|
||||
for f in suite.files:
|
||||
yield from split_test_cases(self, suite, os.path.join(suite.data_prefix, f))
|
||||
|
||||
|
||||
# This function name is special to pytest. See
|
||||
# http://doc.pytest.org/en/latest/writing_plugins.html#collection-hooks
|
||||
def pytest_pycollect_makeitem(collector: Any, name: str,
|
||||
obj: object) -> 'Optional[Any]':
|
||||
"""Called by pytest on each object in modules configured in conftest.py files.
|
||||
|
||||
collector is pytest.Collector, returns Optional[pytest.Class]
|
||||
"""
|
||||
if isinstance(obj, type):
|
||||
# Only classes derived from DataSuite contain test cases, not the DataSuite class itself
|
||||
if issubclass(obj, DataSuite) and obj is not DataSuite:
|
||||
# Non-None result means this obj is a test case.
|
||||
# The collect method of the returned DataSuiteCollector instance will be called later,
|
||||
# with self.obj being obj.
|
||||
return DataSuiteCollector(name, parent=collector)
|
||||
return None
|
||||
@@ -1,3 +1,3 @@
|
||||
[mypy]
|
||||
plugins =
|
||||
mypy_django_plugin.plugin
|
||||
mypy_django_plugin.main
|
||||
|
||||
31
test/test-data/check-parse-settings.test
Normal file
31
test/test-data/check-parse-settings.test
Normal file
@@ -0,0 +1,31 @@
|
||||
[case testParseSettingsFromFile]
|
||||
from django.conf import settings
|
||||
|
||||
reveal_type(settings.ROOT_DIR) # E: Revealed type is 'builtins.str'
|
||||
reveal_type(settings.OBJ) # E: Revealed type is 'django.utils.functional.LazyObject'
|
||||
reveal_type(settings.NUMBERS) # E: Revealed type is 'builtins.list[Any]'
|
||||
reveal_type(settings.DICT) # E: Revealed type is 'builtins.dict[Any, Any]'
|
||||
|
||||
[file mysettings.py]
|
||||
SECRET_KEY = 112233
|
||||
ROOT_DIR = '/etc'
|
||||
NUMBERS = ['one', 'two']
|
||||
DICT = {}
|
||||
|
||||
from django.utils.functional import LazyObject
|
||||
OBJ = LazyObject()
|
||||
|
||||
[env DJANGO_SETTINGS_MODULE=mysettings]
|
||||
[out]
|
||||
|
||||
[case testSettingIsInitializedToNone]
|
||||
from django.conf import settings
|
||||
|
||||
reveal_type(settings.NONE_SETTING) # E: Revealed type is 'builtins.object'
|
||||
|
||||
[file mysettings_not_none.py]
|
||||
SECRET_KEY = 112233
|
||||
NONE_SETTING = None
|
||||
|
||||
[env DJANGO_SETTINGS_MODULE=mysettings_not_none]
|
||||
[out]
|
||||
@@ -17,7 +17,8 @@ class DjangoTestSuite(DataSuite):
|
||||
'check-objects-queryset.test',
|
||||
'check-model-fields.test',
|
||||
'check-postgres-fields.test',
|
||||
'check-model-relations.test'
|
||||
'check-model-relations.test',
|
||||
'check-parse-settings.test'
|
||||
]
|
||||
data_prefix = str(TEST_DATA_DIR)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user