Skip to content

Commit 2738bf7

Browse files
authored
Merge pull request #1 from ikonst/rewrite_null_attr
rework a bunch of things
2 parents 9453404 + f7adc60 commit 2738bf7

File tree

9 files changed

+230
-134
lines changed

9 files changed

+230
-134
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@
1111
.coverage
1212
.mypy_cache
1313
.pytest_cache
14+
build/
15+
dist/
16+
venv/

.pre-commit-config.yaml

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,43 +14,20 @@ repos:
1414
- id: check-yaml
1515
- id: debug-statements
1616
- id: end-of-file-fixer
17-
- id: flake8
18-
additional_dependencies:
19-
- flake8>=3.6.0,<4
20-
- flake8-bugbear
21-
- flake8-builtins
22-
- flake8-comprehensions
23-
- flake8-commas
2417
- id: trailing-whitespace
25-
- repo: https://github.com/asottile/yesqa
26-
rev: v0.0.8
18+
- repo: https://gitlab.com/pycqa/flake8
19+
rev: 3.8.1
2720
hooks:
28-
- id: yesqa
21+
- id: flake8
2922
additional_dependencies:
30-
- flake8>=3.6.0,<4
31-
- flake8-bugbear
32-
- flake8-builtins
33-
- flake8-comprehensions
34-
- flake8-commas
23+
- flake8-bugbear==18.8.0
24+
- flake8-comprehensions==1.4.1
25+
- flake8-tidy-imports==1.1.0
3526
- repo: https://github.com/asottile/pyupgrade
3627
rev: v1.11.0
3728
hooks:
3829
- id: pyupgrade
39-
# Switch to standard pre-commit mypy when a version of mypy is released that has:
40-
# - mypy.plugin.Plugin.lookup_fully_qualified
41-
# - typeshed with https://github.com/ikonst/typeshed/tree/pynamodb-attr-nullable
42-
- repo: local
30+
- repo: https://github.com/pre-commit/mirrors-mypy
31+
rev: v0.770
4332
hooks:
4433
- id: mypy
45-
name: mypy
46-
entry: mypy
47-
language: python
48-
'types': [python]
49-
args: ["--ignore-missing-imports", "--scripts-are-modules", "--show-traceback"]
50-
additional_dependencies: [
51-
'-U', 'git+git://github.com/ikonst/mypy.git@5a8dffb5bd94bda615703f6994d39ea1c7c02ef5',
52-
]
53-
exclude: >
54-
(?x)^(
55-
tests/.*|
56-
)$

.travis.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
sudo: false
22
language: python
33
python:
4-
- '3.6'
4+
- '3.8'
55
cache:
66
directories:
77
- $HOME/.cache/pip
@@ -18,8 +18,8 @@ jobs:
1818
provider: pypi
1919
user: ikonst
2020
password:
21-
secure: "TlSgflmwfHh6pc03DuL9FOpieuDr2E2e4iaOtCnSa5kWdMldxOWVVUmjWJ0xDrL6nS+Hr5ZygAJzppVbiCvWiOC8curLu+Pl2eLc+Mf9WG9Mw1G2DiU8ci+uJdYCE6Dpak8OKG9lUu0O3XBDy+oZIEEaiWVdjBrBVplK0le8pFNB6fNu4Nru2JOGunHyQUfDmD+80m5tm8Mo32Y0Xryi30m7yRrLBlqn7eu2YnRcuhcY904edtuL36smSWNMl4rc6+IhxcQil11TlQD+DoyGlqxohYhaELgFs/2afYdJJJ/zq4qQ2ZJ8c6qwnKWxclLeoC8pGmDuhJo9zmyHuAJWeU+PZ1/ldyTjC1ttinJcbCZNlqZI7cz/slzxKwmcuTTXHRbnxS9VYv/n7csz8V0yoXAHww0ifXGXLl91sIo8jUrsc0ZEpcbLzESH7tR6wrOa3pBxV+yMrDwidsqDfnTGMyJ4PhC2zikM4HDwNuqsciUIYlY34fIcid4/1eep6AEMhHKtmfcLrBU6eg2WAaUnf1XaGmwK5J60zp0yX2fGo0XTxHm8ETKaTkgrnMhmaLvQ9BJ2Zx+a1sery0xYj/jIshMY4DZBMAjH5rvSV1T0SQslr0IBAGcyWV3jG9gwPTb5dtJmAX/WSYx1Sb52kbjYlUuBj7ffLADCjfVzKFWQxeQ="
21+
secure: "d5zNL/guwonRQ7fI11ebzSTYLcrsTiVJROImJixYsr8G/NbM7/XEaRKr3nn/1br+LMd9TS89SeIY5MT1yAsCUYj8+zuACoOzi9lZslAwBi3sp2BhOve9T5LJhwHDA8WPehNq76jPaixN+0F9ytjpR4VE1xV6nlBjlXk5omqUUICFsbBjLAWS/rAFTsb2VmaECyF9dosdH26dXHb0oYJa3cpMbCHt/zbrAuFlaRG2qentgXv8PPCKBPxowGfTCUH/F25e/i7zzE1wbQBDcH4ZfkbvDFND6pMqb7Hfpumg1CZGrvgKEf1RmpKOCXHZWHDlm1C7H6hWaMACTw/AaHIEGNY2LHim3zEr19W6PYiEakVfGkfOXOo16pVSd65uxEp+C0+GB5KCimN57w7ZE9ucooEiaZMRfhIr8PX+4AGHfbLSj+6g0xDpg1x0woyPEvdzc1ZsZQJr4GY7OuI/4qqWrV48D+Hz5R2dVOxGFvaIYnYRGL3F/RmdPnO6B+IjXpQWHdm1FmhJM2jDG1wxXVqSTZmgEmUYKv8K5t0K/+f5SlJZ2tZOmbtDa3+Yo/1iTohCr0+OiDPojQY82EQmTiL8Ffr5aqpydwg8mQS6DX3m8w7vRzFvjlgoVQQ8ghP9BINhn2dvRMZdGfE/q8qqpL5qBIuLyHiU3Rnc6s1IxEbpw+Y="
2222
on:
2323
tags: true
2424
distributions: sdist
25-
repo: lyft/pynamodb-mypy
25+
repo: pynamodb/pynamodb-mypy

pynamodb_mypy/plugin.py

Lines changed: 99 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,123 @@
11
from typing import Callable
22
from typing import Optional
33

4+
import mypy.nodes
5+
import mypy.plugin
46
import mypy.types
5-
from mypy.nodes import NameExpr
6-
from mypy.nodes import TypeInfo
7-
from mypy.plugin import FunctionContext
8-
from mypy.plugin import Plugin
97

108
ATTR_FULL_NAME = 'pynamodb.attributes.Attribute'
11-
NULL_ATTR_WRAPPER_FULL_NAME = 'pynamodb.attributes._NullableAttributeWrapper'
129

1310

14-
class PynamodbPlugin(Plugin):
15-
def get_function_hook(self, fullname: str) -> Optional[Callable[[FunctionContext], mypy.types.Type]]:
11+
class PynamodbPlugin(mypy.plugin.Plugin):
12+
def get_function_hook(self, fullname: str) -> Optional[Callable[[mypy.plugin.FunctionContext], mypy.types.Type]]:
1613
sym = self.lookup_fully_qualified(fullname)
17-
if sym and isinstance(sym.node, TypeInfo):
18-
attr_underlying_type = _get_attribute_underlying_type(sym.node)
19-
if attr_underlying_type:
20-
_underlying_type = attr_underlying_type # https://github.com/python/mypy/issues/4297
21-
return lambda ctx: _attribute_instantiation_hook(ctx, _underlying_type)
14+
if sym and isinstance(sym.node, mypy.nodes.TypeInfo) and _is_attribute_type_node(sym.node):
15+
return _attribute_instantiation_hook
16+
return None
2217

18+
def get_method_signature_hook(self, fullname: str
19+
) -> Optional[Callable[[mypy.plugin.MethodSigContext], mypy.types.CallableType]]:
20+
class_name, method_name = fullname.rsplit('.', 1)
21+
sym = self.lookup_fully_qualified(class_name)
22+
if sym is not None and sym.node is not None and _is_attribute_type_node(sym.node):
23+
if method_name == '__get__':
24+
return _get_method_sig_hook
25+
elif method_name == '__set__':
26+
return _set_method_sig_hook
2327
return None
2428

2529

26-
def _get_attribute_underlying_type(attribute_class: TypeInfo) -> Optional[mypy.types.Type]:
30+
def _is_attribute_type_node(node: mypy.nodes.Node) -> bool:
31+
return (
32+
isinstance(node, mypy.nodes.TypeInfo) and
33+
node.has_base(ATTR_FULL_NAME)
34+
)
35+
36+
37+
def _attribute_marked_as_nullable(t: mypy.types.Instance) -> mypy.types.Instance:
38+
return t.copy_modified(args=t.args + [mypy.types.NoneType()])
39+
40+
41+
def _is_attribute_marked_nullable(t: mypy.types.Type) -> bool:
42+
return (
43+
isinstance(t, mypy.types.Instance) and
44+
_is_attribute_type_node(t.type) and
45+
# In lieu of being able to attach metadata to an instance,
46+
# having a None "fake" type argument is our way of marking the attribute as nullable
47+
bool(t.args) and isinstance(t.args[-1], mypy.types.NoneType)
48+
)
49+
50+
51+
def _get_bool_literal(node: mypy.nodes.Node) -> Optional[bool]:
52+
return {
53+
'builtins.False': False,
54+
'builtins.True': True,
55+
}.get(node.fullname or '') if isinstance(node, mypy.nodes.NameExpr) else None
56+
57+
58+
def _make_optional(t: mypy.types.Type) -> mypy.types.UnionType:
59+
"""Wraps a type in optionality"""
60+
return mypy.types.UnionType([t, mypy.types.NoneType()])
61+
62+
63+
def _unwrap_optional(t: mypy.types.Type) -> mypy.types.Type:
64+
"""Unwraps a potentially optional type"""
65+
if not isinstance(t, mypy.types.UnionType): # pragma: no cover
66+
return t
67+
t = mypy.types.UnionType([item for item in t.items if not isinstance(item, mypy.types.NoneType)])
68+
if len(t.items) == 0: # pragma: no cover
69+
return mypy.types.NoneType()
70+
elif len(t.items) == 1:
71+
return t.items[0]
72+
else:
73+
return t # pragma: no cover
74+
75+
76+
def _get_method_sig_hook(ctx: mypy.plugin.MethodSigContext) -> mypy.types.CallableType:
2777
"""
28-
For attribute classes, will return the underlying type.
29-
e.g. for `class MyAttribute(Attribute[int])`, this will return `int`.
78+
Patches up the signature of Attribute.__get__ to respect attribute's nullability.
3079
"""
31-
for base_instance in attribute_class.bases:
32-
if base_instance.type.fullname() == ATTR_FULL_NAME:
33-
return base_instance.args[0]
34-
return None
80+
sig = ctx.default_signature
81+
if not _is_attribute_marked_nullable(ctx.type):
82+
return sig
83+
try:
84+
(instance_type, owner_type) = sig.arg_types
85+
except ValueError: # pragma: no cover
86+
return sig
87+
if isinstance(instance_type, mypy.types.NoneType): # class attribute access
88+
return sig
89+
return sig.copy_modified(ret_type=_make_optional(sig.ret_type))
3590

3691

37-
def _attribute_instantiation_hook(ctx: FunctionContext,
38-
underlying_type: mypy.types.Type) -> mypy.types.Type:
92+
def _set_method_sig_hook(ctx: mypy.plugin.MethodSigContext) -> mypy.types.CallableType:
93+
"""
94+
Patches up the signature of Attribute.__set__ to respect attribute's nullability.
95+
"""
96+
sig = ctx.default_signature
97+
if _is_attribute_marked_nullable(ctx.type):
98+
return sig
99+
try:
100+
(instance_type, value_type) = sig.arg_types
101+
except ValueError: # pragma: no cover
102+
return sig
103+
return sig.copy_modified(arg_types=[instance_type, _unwrap_optional(value_type)])
104+
105+
106+
def _attribute_instantiation_hook(ctx: mypy.plugin.FunctionContext) -> mypy.types.Type:
39107
"""
40108
Handles attribute instantiation, e.g. MyAttribute(null=True)
41109
"""
42110
args = dict(zip(ctx.callee_arg_names, ctx.args))
43111

44-
# If initializer is passed null=True, wrap in _NullableAttribute
45-
# to make the underlying type optional
112+
# If initializer is passed null=True, mark attribute type instance as nullable
46113
null_arg_exprs = args.get('null')
114+
nullable = False
47115
if null_arg_exprs and len(null_arg_exprs) == 1:
48-
(null_arg_expr,) = null_arg_exprs
49-
if (
50-
not isinstance(null_arg_expr, NameExpr) or
51-
null_arg_expr.fullname not in ('builtins.False', 'builtins.True')
52-
):
53-
ctx.api.fail("'null' argument is not constant False or True, "
54-
"cannot deduce optionality", ctx.context)
55-
return ctx.default_return_type
56-
57-
if null_arg_expr.fullname == 'builtins.True':
58-
return ctx.api.named_generic_type(NULL_ATTR_WRAPPER_FULL_NAME, [
59-
ctx.default_return_type,
60-
underlying_type,
61-
])
62-
63-
return ctx.default_return_type
116+
null_literal = _get_bool_literal(null_arg_exprs[0])
117+
if null_literal is not None:
118+
nullable = null_literal
119+
else:
120+
ctx.api.fail("'null' argument is not constant False or True, cannot deduce optionality", ctx.context)
121+
122+
assert isinstance(ctx.default_return_type, mypy.types.Instance)
123+
return _attribute_marked_as_nullable(ctx.default_return_type) if nullable else ctx.default_return_type

setup.cfg

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
[metadata]
2+
name = pynamodb-mypy
3+
version = 0.0.4
4+
description = mypy plugin for PynamoDB
5+
long_description = file: README.md
6+
long_description_content_type = text/markdown
7+
url = https://www.github.com/pynamodb/pynamodb-mypy
8+
classifiers =
9+
Programming Language :: Python :: 3
10+
Programming Language :: Python :: 3 :: Only
11+
Programming Language :: Python :: 3.6
12+
Programming Language :: Python :: 3.7
13+
Programming Language :: Python :: 3.8
14+
maintainer = Ilya Konstantinov
15+
maintainer_email = ilya.konstantinov@gmail.com
16+
17+
[options]
18+
packages = find:
19+
install_requires =
20+
mypy>=0.770
21+
python_requires = >=3.6
22+
23+
[options.packages.find]
24+
exclude =
25+
tests
26+
127
[flake8]
228
format = pylint
329
exclude = .svc,CVS,.bzr,.hg,.git,__pycache__,venv
@@ -21,3 +47,6 @@ disallow_untyped_defs = True
2147
ignore_missing_imports = True
2248
strict_optional = True
2349
warn_no_return = True
50+
51+
[mypy-tests.*]
52+
disallow_untyped_defs = False

setup.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,2 @@
1-
from setuptools import find_packages
21
from setuptools import setup
3-
4-
setup(
5-
name='pynamodb-mypy',
6-
version='0.0.1',
7-
description='mypy plugin for PynamoDB',
8-
url='https://www.github.com/lyft/pynamodb-mypy',
9-
maintainer='Ilya Konstantinov',
10-
maintainer_email='ilya.konstantinov@gmail.com',
11-
packages=find_packages(exclude=['tests/*']),
12-
install_requires=[
13-
'mypy>=0.660',
14-
# TODO: update version after https://github.com/pynamodb/PynamoDB/pull/579 is released
15-
'pynamodb',
16-
],
17-
python_requires='>=3',
18-
)
2+
setup()

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import pytest
2+
3+
4+
@pytest.fixture
5+
def assert_mypy_output(pytestconfig):
6+
from .mypy_helpers import assert_mypy_output
7+
return lambda program: assert_mypy_output(program, use_pdb=pytestconfig.getoption('usepdb'))

tests/mypy_helpers.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,55 @@
88
from typing import Dict
99
from typing import Iterable
1010
from typing import List
11+
from typing import Tuple
1112

1213
import mypy.api
1314

1415

15-
def _run_mypy(program: str) -> Iterable[str]:
16+
def _run_mypy(program: str, *, use_pdb: bool) -> Iterable[str]:
1617
with TemporaryDirectory() as tempdirname:
1718
with open('{}/__main__.py'.format(tempdirname), 'w') as f:
1819
f.write(program)
1920
config_file = tempdirname + '/mypy.ini'
2021
shutil.copyfile(os.path.dirname(__file__) + '/mypy.ini', config_file)
21-
error_pattern = re.compile(r'^{}:(\d+): error: (.*)$'.format(re.escape(f.name)))
22-
stdout, stderr, exit_status = mypy.api.run([
22+
error_pattern = re.compile(fr'^{re.escape(f.name)}:'
23+
r'(?P<line>\d+): (?P<level>note|warning|error): (?P<message>.*)$')
24+
mypy_args = [
2325
f.name,
2426
'--show-traceback',
27+
'--raise-exceptions',
28+
'--show-error-codes',
2529
'--config-file', config_file,
26-
])
30+
]
31+
if use_pdb:
32+
mypy_args.append('--pdb')
33+
stdout, stderr, exit_status = mypy.api.run(mypy_args)
2734
if stderr:
2835
print(stderr, file=sys.stderr) # allow "printf debugging" of the plugin
2936

3037
# Group errors by line
31-
errors_by_line: Dict[int, List[str]] = defaultdict(list)
38+
messages_by_line: Dict[int, List[Tuple[str, str]]] = defaultdict(list)
3239
for line in stdout.split('\n'):
3340
m = error_pattern.match(line)
3441
if m:
35-
errors_by_line[int(m.group(1))].append(m.group(2))
42+
messages_by_line[int(m.group('line'))].append((m.group('level'), m.group('message')))
3643
elif line:
37-
print(line) # allow "printf debugging"
44+
# print(line) # allow "printf debugging"
45+
pass
3846

3947
# Reconstruct the "actual" program with "error" comments
40-
error_comment_pattern = re.compile(r'(\s+# E: .*)?$')
48+
error_comment_pattern = re.compile(r'(\s+# (N|W|E): .*)?$')
4149
for line_no, line in enumerate(program.split('\n'), start=1):
4250
line = error_comment_pattern.sub('', line)
43-
errors = errors_by_line.get(line_no)
44-
if errors:
45-
yield '{}{}'.format(line, ''.join(' # E: {}'.format(error) for error in errors))
51+
messages = messages_by_line.get(line_no)
52+
if messages:
53+
messages_str = ''.join(f' # {level[0].upper()}: {message}' for level, message in messages)
54+
yield f'{line}{messages_str}'
4655
else:
4756
yield line
4857

4958

50-
def assert_mypy_output(program: str) -> None:
59+
def assert_mypy_output(program: str, *, use_pdb: bool) -> None:
5160
expected = dedent(program).strip()
52-
actual = '\n'.join(_run_mypy(expected))
61+
actual = '\n'.join(_run_mypy(expected, use_pdb=use_pdb))
5362
assert actual == expected

0 commit comments

Comments
 (0)