Skip to content

Commit 754505f

Browse files
committed
Rework nullable attribute support
1 parent 9453404 commit 754505f

File tree

8 files changed

+147
-129
lines changed

8 files changed

+147
-129
lines changed

.gitignore

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

.pre-commit-config.yaml

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,43 +14,29 @@ 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-
)$
34+
35+
# - repo: local
36+
# hooks:
37+
# - id: mypy
38+
# args: ["--ignore-missing-imports", "--scripts-are-modules", "--show-traceback"]
39+
# exclude: >
40+
# (?x)^(
41+
# tests/.*|
42+
# )$

pynamodb_mypy/plugin.py

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,68 @@
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_attribute_hook(self, fullname: str
19+
) -> Optional[Callable[[mypy.plugin.AttributeContext], mypy.types.Type]]:
20+
sym = self.lookup_fully_qualified(fullname)
21+
if sym and sym.type and _is_attribute_marked_nullable(sym.type):
22+
return lambda ctx: mypy.types.UnionType([ctx.default_attr_type, mypy.types.NoneType()])
2323
return None
2424

2525

26-
def _get_attribute_underlying_type(attribute_class: TypeInfo) -> Optional[mypy.types.Type]:
27-
"""
28-
For attribute classes, will return the underlying type.
29-
e.g. for `class MyAttribute(Attribute[int])`, this will return `int`.
30-
"""
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
26+
def _is_attribute_type_node(type_node: mypy.nodes.TypeInfo) -> bool:
27+
return any(base.type.fullname == ATTR_FULL_NAME for base in type_node.bases)
28+
29+
30+
def _attribute_marked_as_nullable(t: mypy.types.Instance) -> mypy.types.Instance:
31+
return t.copy_modified(args=t.args + [mypy.types.NoneType()])
3532

3633

37-
def _attribute_instantiation_hook(ctx: FunctionContext,
38-
underlying_type: mypy.types.Type) -> mypy.types.Type:
34+
def _is_attribute_marked_nullable(t: mypy.types.Type) -> bool:
35+
return (
36+
isinstance(t, mypy.types.Instance) and
37+
_is_attribute_type_node(t.type) and
38+
# In lieu of being able to attach metadata to an instance,
39+
# having a None "fake" type argument is our way of marking the attribute as nullable
40+
bool(t.args) and isinstance(t.args[-1], mypy.types.NoneType)
41+
)
42+
43+
44+
def _get_bool_literal(n: mypy.nodes.Node) -> Optional[bool]:
45+
return {
46+
'builtins.False': False,
47+
'builtins.True': True,
48+
}.get(n.fullname or '') if isinstance(n, mypy.nodes.NameExpr) else None
49+
50+
51+
def _attribute_instantiation_hook(ctx: mypy.plugin.FunctionContext) -> mypy.types.Type:
3952
"""
4053
Handles attribute instantiation, e.g. MyAttribute(null=True)
4154
"""
4255
args = dict(zip(ctx.callee_arg_names, ctx.args))
4356

44-
# If initializer is passed null=True, wrap in _NullableAttribute
45-
# to make the underlying type optional
57+
# If initializer is passed null=True, mark attribute type instance as nullable
4658
null_arg_exprs = args.get('null')
59+
nullable = False
4760
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
61+
null_literal = _get_bool_literal(null_arg_exprs[0])
62+
if null_literal is not None:
63+
nullable = null_literal
64+
else:
65+
ctx.api.fail("'null' argument is not constant False or True, cannot deduce optionality", ctx.context)
66+
67+
assert isinstance(ctx.default_return_type, mypy.types.Instance)
68+
return _attribute_marked_as_nullable(ctx.default_return_type) if nullable else ctx.default_return_type

setup.cfg

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
[metadata]
2+
name = pynamodb-mypy
3+
version = 0.0.2
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/lyft/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+
where = pynamodb_mypy
25+
exclude =
26+
tests
27+
128
[flake8]
229
format = pylint
330
exclude = .svc,CVS,.bzr,.hg,.git,__pycache__,venv
@@ -21,3 +48,6 @@ disallow_untyped_defs = True
2148
ignore_missing_imports = True
2249
strict_optional = True
2350
warn_no_return = True
51+
52+
[mypy-tests.*]
53+
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: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,53 @@
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',
2527
'--config-file', config_file,
26-
])
28+
]
29+
if use_pdb:
30+
mypy_args.append('--pdb')
31+
stdout, stderr, exit_status = mypy.api.run(mypy_args)
2732
if stderr:
2833
print(stderr, file=sys.stderr) # allow "printf debugging" of the plugin
2934

3035
# Group errors by line
31-
errors_by_line: Dict[int, List[str]] = defaultdict(list)
36+
messages_by_line: Dict[int, List[Tuple[str, str]]] = defaultdict(list)
3237
for line in stdout.split('\n'):
3338
m = error_pattern.match(line)
3439
if m:
35-
errors_by_line[int(m.group(1))].append(m.group(2))
40+
messages_by_line[int(m.group('line'))].append((m.group('level'), m.group('message')))
3641
elif line:
37-
print(line) # allow "printf debugging"
42+
# print(line) # allow "printf debugging"
43+
pass
3844

3945
# Reconstruct the "actual" program with "error" comments
40-
error_comment_pattern = re.compile(r'(\s+# E: .*)?$')
46+
error_comment_pattern = re.compile(r'(\s+# (N|W|E): .*)?$')
4147
for line_no, line in enumerate(program.split('\n'), start=1):
4248
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))
49+
messages = messages_by_line.get(line_no)
50+
if messages:
51+
messages_str = ''.join(f' # {level[0].upper()}: {message}' for level, message in messages)
52+
yield f'{line}{messages_str}'
4653
else:
4754
yield line
4855

4956

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

0 commit comments

Comments
 (0)