latest changes

This commit is contained in:
Maxim Kurnikov
2018-11-26 23:58:34 +03:00
parent 348efcd371
commit f59cfe6371
34 changed files with 1558 additions and 132 deletions

View File

@@ -147,6 +147,12 @@ class DjangoDataDrivenTestCase(DataDrivenTestCase):
self.old_cwd = os.getcwd()
self.tmpdir = tempfile.TemporaryDirectory(prefix='mypy-test-')
tmpdir_root = os.path.join(self.tmpdir.name, 'tmp')
new_files = []
for path, contents in self.files:
new_files.append((path, contents.replace('<TMP>', tmpdir_root)))
self.files = new_files
os.chdir(self.tmpdir.name)
os.mkdir(test_temp_dir)
@@ -179,7 +185,7 @@ class DjangoDataDrivenTestCase(DataDrivenTestCase):
self.clean_up.append((True, d))
self.clean_up.append((False, path))
sys.path.insert(0, os.path.join(self.tmpdir.name, 'tmp'))
sys.path.insert(0, tmpdir_root)
def teardown(self):
if hasattr(self, 'old_environ'):

253
test/helpers.py Normal file
View File

@@ -0,0 +1,253 @@
import inspect
import os
import re
from typing import List, Callable, Optional, Tuple
import pytest # type: ignore # no pytest in typeshed
skip = pytest.mark.skip
# AssertStringArraysEqual displays special line alignment helper messages if
# the first different line has at least this many characters,
MIN_LINE_LENGTH_FOR_ALIGNMENT = 5
class TypecheckAssertionError(AssertionError):
def __init__(self, error_message: str, lineno: int):
self.error_message = error_message
self.lineno = lineno
def first_line(self):
return self.__class__.__name__ + '(message="Invalid output")'
def __str__(self):
return self.error_message
def _clean_up(a: List[str]) -> List[str]:
"""Remove common directory prefix from all strings in a.
This uses a naive string replace; it seems to work well enough. Also
remove trailing carriage returns.
"""
res = []
for s in a:
prefix = os.sep
ss = s
for p in prefix, prefix.replace(os.sep, '/'):
if p != '/' and p != '//' and p != '\\' and p != '\\\\':
ss = ss.replace(p, '')
# Ignore spaces at end of line.
ss = re.sub(' +$', '', ss)
res.append(re.sub('\\r$', '', ss))
return res
def _num_skipped_prefix_lines(a1: List[str], a2: List[str]) -> int:
num_eq = 0
while num_eq < min(len(a1), len(a2)) and a1[num_eq] == a2[num_eq]:
num_eq += 1
return max(0, num_eq - 4)
def _num_skipped_suffix_lines(a1: List[str], a2: List[str]) -> int:
num_eq = 0
while (num_eq < min(len(a1), len(a2))
and a1[-num_eq - 1] == a2[-num_eq - 1]):
num_eq += 1
return max(0, num_eq - 4)
def _add_aligned_message(s1: str, s2: str, error_message: str) -> str:
"""Align s1 and s2 so that the their first difference is highlighted.
For example, if s1 is 'foobar' and s2 is 'fobar', display the
following lines:
E: foobar
A: fobar
^
If s1 and s2 are long, only display a fragment of the strings around the
first difference. If s1 is very short, do nothing.
"""
# Seeing what went wrong is trivial even without alignment if the expected
# string is very short. In this case do nothing to simplify output.
if len(s1) < 4:
return error_message
maxw = 72 # Maximum number of characters shown
error_message += 'Alignment of first line difference:\n'
# sys.stderr.write('Alignment of first line difference:\n')
trunc = False
while s1[:30] == s2[:30]:
s1 = s1[10:]
s2 = s2[10:]
trunc = True
if trunc:
s1 = '...' + s1
s2 = '...' + s2
max_len = max(len(s1), len(s2))
extra = ''
if max_len > maxw:
extra = '...'
# Write a chunk of both lines, aligned.
error_message += ' E: {}{}\n'.format(s1[:maxw], extra)
# sys.stderr.write(' E: {}{}\n'.format(s1[:maxw], extra))
error_message += ' A: {}{}\n'.format(s2[:maxw], extra)
# sys.stderr.write(' A: {}{}\n'.format(s2[:maxw], extra))
# Write an indicator character under the different columns.
error_message += ' '
# sys.stderr.write(' ')
for j in range(min(maxw, max(len(s1), len(s2)))):
if s1[j:j + 1] != s2[j:j + 1]:
error_message += '^'
# sys.stderr.write('^') # Difference
break
else:
error_message += ' '
# sys.stderr.write(' ') # Equal
error_message += '\n'
return error_message
# sys.stderr.write('\n')
def assert_string_arrays_equal(expected: List[str], actual: List[str]) -> None:
"""Assert that two string arrays are equal.
Display any differences in a human-readable form.
"""
actual = _clean_up(actual)
error_message = ''
if actual != expected:
num_skip_start = _num_skipped_prefix_lines(expected, actual)
num_skip_end = _num_skipped_suffix_lines(expected, actual)
error_message += 'Expected:\n'
# sys.stderr.write('Expected:\n')
# If omit some lines at the beginning, indicate it by displaying a line
# with '...'.
if num_skip_start > 0:
error_message += ' ...\n'
# sys.stderr.write(' ...\n')
# Keep track of the first different line.
first_diff = -1
# Display only this many first characters of identical lines.
width = 75
for i in range(num_skip_start, len(expected) - num_skip_end):
if i >= len(actual) or expected[i] != actual[i]:
if first_diff < 0:
first_diff = i
error_message += ' {:<45} (diff)'.format(expected[i])
# sys.stderr.write(' {:<45} (diff)'.format(expected[i]))
else:
e = expected[i]
error_message += ' ' + e[:width]
# sys.stderr.write(' ' + e[:width])
if len(e) > width:
error_message += '...'
# sys.stderr.write('...')
error_message += '\n'
# sys.stderr.write('\n')
if num_skip_end > 0:
error_message += ' ...\n'
# sys.stderr.write(' ...\n')
error_message += 'Actual:\n'
# sys.stderr.write('Actual:\n')
if num_skip_start > 0:
error_message += ' ...\n'
# sys.stderr.write(' ...\n')
for j in range(num_skip_start, len(actual) - num_skip_end):
if j >= len(expected) or expected[j] != actual[j]:
error_message += ' {:<45} (diff)'.format(actual[j])
# sys.stderr.write(' {:<45} (diff)'.format(actual[j]))
else:
a = actual[j]
error_message += ' ' + a[:width]
# sys.stderr.write(' ' + a[:width])
if len(a) > width:
error_message += '...'
# sys.stderr.write('...')
error_message += '\n'
# sys.stderr.write('\n')
if actual == []:
error_message += ' (empty)\n'
# sys.stderr.write(' (empty)\n')
if num_skip_end > 0:
error_message += ' ...\n'
# sys.stderr.write(' ...\n')
error_message += '\n'
# sys.stderr.write('\n')
if first_diff >= 0 and first_diff < len(actual) and (
len(expected[first_diff]) >= MIN_LINE_LENGTH_FOR_ALIGNMENT
or len(actual[first_diff]) >= MIN_LINE_LENGTH_FOR_ALIGNMENT):
# Display message that helps visualize the differences between two
# long lines.
error_message = _add_aligned_message(expected[first_diff], actual[first_diff],
error_message)
first_failure = expected[first_diff]
if first_failure:
lineno = int(first_failure.split(' ')[0].strip(':').split(':')[1])
raise TypecheckAssertionError(error_message=f'Invalid output: \n{error_message}',
lineno=lineno)
def build_output_line(fname: str, lnum: int, severity: str, message: str, col=None) -> str:
if col is None:
return f'{fname}:{lnum + 1}: {severity}: {message}'
else:
return f'{fname}:{lnum + 1}:{col}: {severity}: {message}'
def expand_errors(input_lines: List[str], fname: str) -> List[str]:
"""Transform comments such as '# E: message' or
'# E:3: message' in input.
The result is lines like 'fnam:line: error: message'.
"""
output_lines = []
for lnum, line in enumerate(input_lines):
# The first in the split things isn't a comment
for possible_err_comment in line.split(' # ')[1:]:
m = re.search(
r'^([ENW]):((?P<col>\d+):)? (?P<message>.*)$',
possible_err_comment.strip())
if m:
if m.group(1) == 'E':
severity = 'error'
elif m.group(1) == 'N':
severity = 'note'
elif m.group(1) == 'W':
severity = 'warning'
col = m.group('col')
output_lines.append(build_output_line(fname, lnum, severity,
message=m.group("message"),
col=col))
return output_lines
def get_func_first_lnum(attr: Callable[..., None]) -> Optional[Tuple[int, List[str]]]:
lines, _ = inspect.getsourcelines(attr)
for lnum, line in enumerate(lines):
no_space_line = line.strip()
if f'def {attr.__name__}' in no_space_line:
return lnum, lines[lnum + 1:]
raise ValueError(f'No line "def {attr.__name__}" found')

299
test/pytest_plugin.py Normal file
View File

@@ -0,0 +1,299 @@
import dataclasses
import inspect
import os
import sys
import tempfile
import textwrap
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator, Any, Optional, cast, List, Type, Callable, Dict
import pytest
from _pytest._code.code import ReprFileLocation, ReprEntry, ExceptionInfo
from decorator import decorate
from mypy import api as mypy_api
from test import vistir
from test.helpers import assert_string_arrays_equal, TypecheckAssertionError, expand_errors, get_func_first_lnum
def reveal_type(obj: Any) -> None:
# noop method, just to get rid of "method is not resolved" errors
pass
def output(output_lines: str):
def decor(func: Callable[..., None]):
func.out = output_lines
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return decorate(func, wrapper)
return decor
def get_class_that_defined_method(meth) -> Type['MypyTypecheckTestCase']:
if inspect.ismethod(meth):
for cls in inspect.getmro(meth.__self__.__class__):
if cls.__dict__.get(meth.__name__) is meth:
return cls
meth = meth.__func__ # fallback to __qualname__ parsing
if inspect.isfunction(meth):
cls = getattr(inspect.getmodule(meth),
meth.__qualname__.split('.<locals>', 1)[0].rsplit('.', 1)[0])
if issubclass(cls, MypyTypecheckTestCase):
return cls
return getattr(meth, '__objclass__', None) # handle special descriptor objects
def file(filename: str, make_parent_packages=False):
def decor(func: Callable[..., None]):
func.filename = filename
func.make_parent_packages = make_parent_packages
return func
return decor
def env(**environ):
def decor(func: Callable[..., None]):
func.env = environ
return func
return decor
@dataclasses.dataclass
class CreateFile:
sources: str
make_parent_packages: bool = False
class MypyTypecheckMeta(type):
def __new__(mcs, name, bases, attrs):
cls = super().__new__(mcs, name, bases, attrs)
cls.files: Dict[str, CreateFile] = {}
for name, attr in attrs.items():
if inspect.isfunction(attr):
filename = getattr(attr, 'filename', None)
if not filename:
continue
make_parent_packages = getattr(attr, 'make_parent_packages', False)
sources = textwrap.dedent(''.join(get_func_first_lnum(attr)[1]))
if sources.strip() == 'pass':
sources = ''
cls.files[filename] = CreateFile(sources, make_parent_packages)
return cls
class MypyTypecheckTestCase(metaclass=MypyTypecheckMeta):
files = None
def ini_file(self) -> str:
return """
[mypy]
"""
def _get_ini_file_contents(self) -> Optional[str]:
raw_ini_file = self.ini_file()
if not raw_ini_file:
return raw_ini_file
return raw_ini_file.strip() + '\n'
class TraceLastReprEntry(ReprEntry):
def toterminal(self, tw):
self.reprfileloc.toterminal(tw)
for line in self.lines:
red = line.startswith("E ")
tw.line(line, bold=True, red=red)
return
def fname_to_module(fpath: Path, root_path: Path) -> Optional[str]:
try:
relpath = fpath.relative_to(root_path).with_suffix('')
return str(relpath).replace(os.sep, '.')
except ValueError:
return None
class MypyTypecheckItem(pytest.Item):
root_directory = '/run/testdata'
def __init__(self,
name: str,
parent: 'MypyTestsCollector',
klass: Type[MypyTypecheckTestCase],
source_code: str,
first_lineno: int,
ini_file_contents: Optional[str] = None,
expected_output_lines: Optional[List[str]] = None,
files: Optional[Dict[str, CreateFile]] = None,
custom_environment: Optional[Dict[str, Any]] = None):
super().__init__(name=name, parent=parent)
self.klass = klass
self.source_code = source_code
self.first_lineno = first_lineno
self.ini_file_contents = ini_file_contents
self.expected_output_lines = expected_output_lines
self.files = files
self.custom_environment = custom_environment
@contextmanager
def temp_directory(self) -> Path:
with tempfile.TemporaryDirectory(prefix='mypy-pytest-',
dir=self.root_directory) as tmpdir_name:
yield Path(self.root_directory) / tmpdir_name
def runtest(self):
with self.temp_directory() as tmpdir_path:
if not self.source_code:
return
if self.ini_file_contents:
mypy_ini_fpath = tmpdir_path / 'mypy.ini'
mypy_ini_fpath.write_text(self.ini_file_contents)
test_specific_modules = []
for fname, create_file in self.files.items():
fpath = tmpdir_path / fname
if create_file.make_parent_packages:
fpath.parent.mkdir(parents=True, exist_ok=True)
for parent in fpath.parents:
try:
parent.relative_to(tmpdir_path)
if parent != tmpdir_path:
parent_init_file = parent / '__init__.py'
parent_init_file.write_text('')
test_specific_modules.append(fname_to_module(parent,
root_path=tmpdir_path))
except ValueError:
break
fpath.write_text(create_file.sources)
test_specific_modules.append(fname_to_module(fpath,
root_path=tmpdir_path))
with vistir.temp_environ(), vistir.temp_path():
for key, val in (self.custom_environment or {}).items():
os.environ[key] = val
sys.path.insert(0, str(tmpdir_path))
mypy_cmd_options = self.prepare_mypy_cmd_options(config_file_path=mypy_ini_fpath)
main_fpath = tmpdir_path / 'main.py'
main_fpath.write_text(self.source_code)
mypy_cmd_options.append(str(main_fpath))
stdout, _, _ = mypy_api.run(mypy_cmd_options)
output_lines = []
for line in stdout.splitlines():
if ':' not in line:
continue
out_fpath, res_line = line.split(':', 1)
line = os.path.relpath(out_fpath, start=tmpdir_path) + ':' + res_line
output_lines.append(line.strip().replace('.py', ''))
for module in test_specific_modules:
if module in sys.modules:
del sys.modules[module]
raise ValueError
assert_string_arrays_equal(expected=self.expected_output_lines,
actual=output_lines)
def prepare_mypy_cmd_options(self, config_file_path: Path) -> List[str]:
mypy_cmd_options = [
'--show-traceback',
'--no-silence-site-packages'
]
python_version = '.'.join([str(part) for part in sys.version_info[:2]])
mypy_cmd_options.append(f'--python-version={python_version}')
if self.ini_file_contents:
mypy_cmd_options.append(f'--config-file={config_file_path}')
return mypy_cmd_options
def repr_failure(self, excinfo: ExceptionInfo) -> str:
if excinfo.errisinstance(SystemExit):
# We assume that before doing exit() (which raises SystemExit) we've printed
# enough context about what happened so that a stack trace is not useful.
# In particular, uncaught exceptions during semantic analysis or type checking
# call exit() and they already print out a stack trace.
return excinfo.exconly(tryshort=True)
elif excinfo.errisinstance(TypecheckAssertionError):
# with traceback removed
exception_repr = excinfo.getrepr(style='short')
exception_repr.reprcrash.message = ''
repr_file_location = ReprFileLocation(path=inspect.getfile(self.klass),
lineno=self.first_lineno + excinfo.value.lineno,
message='')
repr_tb_entry = TraceLastReprEntry(filelocrepr=repr_file_location,
lines=exception_repr.reprtraceback.reprentries[-1].lines[1:],
style='short',
reprlocals=None,
reprfuncargs=None)
exception_repr.reprtraceback.reprentries = [repr_tb_entry]
return exception_repr
else:
return super().repr_failure(excinfo, style='short')
def reportinfo(self):
return self.fspath, None, get_class_qualname(self.klass) + '::' + self.name
def get_class_qualname(klass: type) -> str:
return klass.__module__ + '.' + klass.__name__
def extract_test_output(attr: Callable[..., None]) -> List[str]:
out_data: str = getattr(attr, 'out', None)
out_lines = []
if out_data:
for line in out_data.split('\n'):
line = line.strip()
out_lines.append(line)
return out_lines
class MypyTestsCollector(pytest.Class):
def get_ini_file_contents(self, contents: str) -> str:
return contents.strip() + '\n'
def collect(self) -> Iterator[pytest.Item]:
current_testcase = cast(MypyTypecheckTestCase, self.obj())
ini_file_contents = self.get_ini_file_contents(current_testcase.ini_file())
for attr_name in dir(current_testcase):
if attr_name.startswith('_test_'):
attr = getattr(self.obj, attr_name)
if inspect.isfunction(attr):
first_line_lnum, source_lines = get_func_first_lnum(attr)
func_first_line_in_file = inspect.getsourcelines(attr)[1] + first_line_lnum
output_from_decorator = extract_test_output(attr)
output_from_comments = expand_errors(source_lines, 'main')
custom_env = getattr(attr, 'env', None)
main_source_code = textwrap.dedent(''.join(source_lines))
yield MypyTypecheckItem(name=attr_name,
parent=self,
klass=current_testcase.__class__,
source_code=main_source_code,
first_lineno=func_first_line_in_file,
ini_file_contents=ini_file_contents,
expected_output_lines=output_from_comments
+ output_from_decorator,
files=current_testcase.__class__.files,
custom_environment=custom_env)
def pytest_pycollect_makeitem(collector: Any, name: str, obj: Any) -> Optional[MypyTestsCollector]:
# Only classes derived from DataSuite contain test cases, not the DataSuite class itself
if (isinstance(obj, type)
and issubclass(obj, MypyTypecheckTestCase)
and obj is not MypyTypecheckTestCase):
# 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 MypyTestsCollector(name, parent=collector)

View File

View File

@@ -0,0 +1,9 @@
from test.pytest_plugin import MypyTypecheckTestCase
class BaseDjangoPluginTestCase(MypyTypecheckTestCase):
def ini_file(self):
return """
[mypy]
plugins = mypy_django_plugin.main
"""

View File

@@ -0,0 +1,21 @@
from test.pytest_plugin import reveal_type
from test.pytest_tests.base import BaseDjangoPluginTestCase
class TestBasicModelFields(BaseDjangoPluginTestCase):
def test_model_field_classes_present_as_primitives(self):
from django.db import models
class User(models.Model):
id = models.AutoField(primary_key=True)
small_int = models.SmallIntegerField()
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
text = models.TextField()
user = User()
reveal_type(user.id) # E: Revealed type is 'builtins.int'
reveal_type(user.small_int) # E: Revealed type is 'builtins.int'
reveal_type(user.name) # E: Revealed type is 'builtins.str'
reveal_type(user.slug) # E: Revealed type is 'builtins.str'
reveal_type(user.text) # E: Revealed type is 'builtins.str'

View File

@@ -1,16 +1,9 @@
from test.pytest_plugin import MypyTypecheckTestCase, reveal_type
from test.pytest_plugin import reveal_type
from test.pytest_tests.base import BaseDjangoPluginTestCase
class BaseDjangoPluginTestCase(MypyTypecheckTestCase):
def ini_file(self):
return """
[mypy]
plugins = mypy_django_plugin.main
"""
class MyTestCase(BaseDjangoPluginTestCase):
def check_foreign_key_field(self):
class TestForeignKey(BaseDjangoPluginTestCase):
def test_foreign_key_field(self):
from django.db import models
class Publisher(models.Model):
@@ -26,7 +19,7 @@ class MyTestCase(BaseDjangoPluginTestCase):
publisher = Publisher()
reveal_type(publisher.books) # E: Revealed type is 'django.db.models.query.QuerySet[main.Book]'
def check_every_foreign_key_creates_field_name_with_appended_id(self):
def test_every_foreign_key_creates_field_name_with_appended_id(self):
from django.db import models
class Publisher(models.Model):
@@ -39,7 +32,7 @@ class MyTestCase(BaseDjangoPluginTestCase):
book = Book()
reveal_type(book.publisher_id) # E: Revealed type is 'builtins.int'
def check_foreign_key_different_order_of_params(self):
def test_foreign_key_different_order_of_params(self):
from django.db import models
class Publisher(models.Model):
@@ -47,10 +40,50 @@ class MyTestCase(BaseDjangoPluginTestCase):
class Book(models.Model):
publisher = models.ForeignKey(on_delete=models.CASCADE, to=Publisher,
related_name='books')
related_name='books')
book = Book()
reveal_type(book.publisher) # E: Revealed type is 'main.Publisher*'
publisher = Publisher()
reveal_type(publisher.books) # E: Revealed type is 'django.db.models.query.QuerySet[main.Book]'
class TestOneToOneField(BaseDjangoPluginTestCase):
def test_onetoone_field(self):
from django.db import models
class User(models.Model):
pass
class Profile(models.Model):
user = models.OneToOneField(to=User, on_delete=models.CASCADE, related_name='profile')
profile = Profile()
reveal_type(profile.user) # E: Revealed type is 'main.User*'
user = User()
reveal_type(user.profile) # E: Revealed type is 'main.Profile'
def test_onetoone_field_with_underscore_id(self):
from django.db import models
class User(models.Model):
pass
class Profile(models.Model):
user = models.OneToOneField(to=User, on_delete=models.CASCADE, related_name='profile')
profile = Profile()
reveal_type(profile.user_id) # E: Revealed type is 'builtins.int'
def test_parameter_to_keyword_may_be_absent(self):
from django.db import models
class User(models.Model):
pass
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
reveal_type(User().profile) # E: Revealed type is 'main.Profile'

View File

@@ -0,0 +1,28 @@
from test.pytest_plugin import reveal_type, output
from test.pytest_tests.base import BaseDjangoPluginTestCase
class TestObjectsQueryset(BaseDjangoPluginTestCase):
def test_every_model_has_objects_queryset_available(self):
from django.db import models
class User(models.Model):
pass
reveal_type(User.objects) # E: Revealed type is 'django.db.models.query.QuerySet[main.User]'
@output("""
main:10: error: Revealed type is 'Any'
main:10: error: "Type[ModelMixin]" has no attribute "objects"
""")
def test_objects_get_returns_model_instance(self):
from django.db import models
class ModelMixin(models.Model):
class Meta:
abstract = True
class User(ModelMixin):
pass
reveal_type(User.objects.get()) # E: Revealed type is 'main.User*'

View File

@@ -0,0 +1,37 @@
from test.pytest_plugin import reveal_type, file, env
from test.pytest_tests.base import BaseDjangoPluginTestCase
class TestParseSettingsFromFile(BaseDjangoPluginTestCase):
@env(DJANGO_SETTINGS_MODULE='mysettings')
def test_case(self):
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')
def mysettings_py_file(self):
SECRET_KEY = 112233
ROOT_DIR = '/etc'
NUMBERS = ['one', 'two']
DICT = {} # type: ignore
from django.utils.functional import LazyObject
OBJ = LazyObject()
class TestSettingInitializableToNone(BaseDjangoPluginTestCase):
@env(DJANGO_SETTINGS_MODULE='mysettings')
def test_case(self):
from django.conf import settings
reveal_type(settings.NONE_SETTING) # E: Revealed type is 'builtins.object'
@file('mysettings.py')
def mysettings_py_file(self):
SECRET_KEY = 112233
NONE_SETTING = None

View File

@@ -0,0 +1,27 @@
from test.pytest_plugin import reveal_type
from test.pytest_tests.base import BaseDjangoPluginTestCase
class TestArrayField(BaseDjangoPluginTestCase):
def test_descriptor_access(self):
from django.db import models
from django.contrib.postgres.fields import ArrayField
class User(models.Model):
array = ArrayField(base_field=models.Field())
user = User()
reveal_type(user.array) # E: Revealed type is 'builtins.list[Any]'
def test_base_field_parsed_into_generic_attribute(self):
from django.db import models
from django.contrib.postgres.fields import ArrayField
class User(models.Model):
members = ArrayField(base_field=models.IntegerField())
members_as_text = ArrayField(base_field=models.CharField(max_length=255))
user = User()
reveal_type(user.members) # E: Revealed type is 'builtins.list[builtins.int*]'
reveal_type(user.members_as_text) # E: Revealed type is 'builtins.list[builtins.str*]'

View File

@@ -0,0 +1,74 @@
from test.pytest_plugin import file, reveal_type, env
from test.pytest_tests.base import BaseDjangoPluginTestCase
class TestForeignKey(BaseDjangoPluginTestCase):
@env(DJANGO_SETTINGS_MODULE='mysettings')
def _test_to_parameter_could_be_specified_as_string(self):
from apps.myapp.models import Publisher
publisher = Publisher()
reveal_type(publisher.books) # E: Revealed type is 'django.db.models.query.QuerySet[apps.myapp2.models.Book]'
# @env(DJANGO_SETTINGS_MODULE='mysettings')
# def _test_creates_underscore_id_attr(self):
# from apps.myapp2.models import Book
#
# book = Book()
# reveal_type(book.publisher) # E: Revealed type is 'apps.myapp.models.Publisher'
# reveal_type(book.publisher_id) # E: Revealed type is 'builtins.int'
@file('mysettings.py')
def mysettings(self):
SECRET_KEY = '112233'
ROOT_DIR = '<TMP>'
APPS_DIR = '<TMP>/apps'
INSTALLED_APPS = ('apps.myapp', 'apps.myapp2')
@file('apps/myapp/models.py', make_parent_packages=True)
def apps_myapp_models(self):
from django.db import models
class Publisher(models.Model):
pass
@file('apps/myapp2/models.py', make_parent_packages=True)
def apps_myapp2_models(self):
from django.db import models
class Book(models.Model):
publisher = models.ForeignKey(to='myapp.Publisher', on_delete=models.CASCADE,
related_name='books')
class TestOneToOneField(BaseDjangoPluginTestCase):
@env(DJANGO_SETTINGS_MODULE='mysettings')
def test_to_parameter_could_be_specified_as_string(self):
from apps.myapp.models import User
user = User()
reveal_type(user.profile) # E: Revealed type is 'apps.myapp2.models.Profile'
@file('mysettings.py')
def mysettings(self):
SECRET_KEY = '112233'
ROOT_DIR = '<TMP>'
APPS_DIR = '<TMP>/apps'
INSTALLED_APPS = ('apps.myapp', 'apps.myapp2')
@file('apps/myapp/models.py', make_parent_packages=True)
def apps_myapp_models(self):
from django.db import models
class User(models.Model):
pass
@file('apps/myapp2/models.py', make_parent_packages=True)
def apps_myapp2_models(self):
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(to='myapp.User', on_delete=models.CASCADE,
related_name='profile')

View File

@@ -14,11 +14,14 @@ MYPY_INI_PATH = ROOT_DIR / 'test' / 'plugins.ini'
class DjangoTestSuite(DataSuite):
files = [
'check-objects-queryset.test',
'check-model-fields.test',
'check-postgres-fields.test',
'check-model-relations.test',
'check-parse-settings.test'
# 'check-objects-queryset.test',
# 'check-model-fields.test',
# 'check-postgres-fields.test',
# 'check-model-relations.test',
# 'check-parse-settings.test',
# 'check-to-attr-as-string-one-to-one-field.test',
'check-to-attr-as-string-foreign-key.test',
# 'check-foreign-key-as-string-creates-underscore-id-attr.test'
]
data_prefix = str(TEST_DATA_DIR)

43
test/vistir.py Normal file
View File

@@ -0,0 +1,43 @@
# Borrowed from Pew.
# See https://github.com/berdario/pew/blob/master/pew/_utils.py#L82
import os
import sys
from pathlib import Path
from decorator import contextmanager
@contextmanager
def temp_environ():
"""Allow the ability to set os.environ temporarily"""
environ = dict(os.environ)
try:
yield
finally:
os.environ.clear()
os.environ.update(environ)
@contextmanager
def temp_path():
"""A context manager which allows the ability to set sys.path temporarily"""
path = [p for p in sys.path]
try:
yield
finally:
sys.path = [p for p in path]
@contextmanager
def cd(path):
"""Context manager to temporarily change working directories"""
if not path:
return
prev_cwd = Path.cwd().as_posix()
if isinstance(path, Path):
path = path.as_posix()
os.chdir(str(path))
try:
yield
finally:
os.chdir(prev_cwd)