mirror of
https://github.com/davidhalter/django-stubs.git
synced 2026-02-07 10:20:59 +08:00
latest changes
This commit is contained in:
@@ -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
253
test/helpers.py
Normal 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
299
test/pytest_plugin.py
Normal 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)
|
||||
0
test/pytest_tests/__init__.py
Normal file
0
test/pytest_tests/__init__.py
Normal file
9
test/pytest_tests/base.py
Normal file
9
test/pytest_tests/base.py
Normal 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
|
||||
"""
|
||||
21
test/pytest_tests/test_model_fields.py
Normal file
21
test/pytest_tests/test_model_fields.py
Normal 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'
|
||||
@@ -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'
|
||||
|
||||
28
test/pytest_tests/test_objects_queryset.py
Normal file
28
test/pytest_tests/test_objects_queryset.py
Normal 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*'
|
||||
37
test/pytest_tests/test_parse_settings.py
Normal file
37
test/pytest_tests/test_parse_settings.py
Normal 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
|
||||
27
test/pytest_tests/test_postgres_fields.py
Normal file
27
test/pytest_tests/test_postgres_fields.py
Normal 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*]'
|
||||
|
||||
74
test/pytest_tests/test_to_attr_as_string.py
Normal file
74
test/pytest_tests/test_to_attr_as_string.py
Normal 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')
|
||||
@@ -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
43
test/vistir.py
Normal 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)
|
||||
Reference in New Issue
Block a user