Add mypy support (#396), thanks @JuroOravec

This commit is contained in:
Juro Oravec 2024-03-15 23:31:15 +01:00 committed by GitHub
parent 4090c928ee
commit b14dec9777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 104 additions and 58 deletions

View file

@ -37,6 +37,8 @@ def import_file(path):
MODULE_PATH = path
MODULE_NAME = Path(path).stem
spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
if spec is None:
raise ValueError(f"Cannot import file '{path}' - invalid path")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
spec.loader.exec_module(module) # type: ignore

View file

@ -1,7 +1,7 @@
import difflib
import inspect
from collections import ChainMap
from typing import Any, ClassVar, Dict, Iterable, Optional, Set, Tuple, Union
from typing import Any, ClassVar, Dict, Iterable, List, Optional, Set, Tuple, Union
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media, MediaDefiningClass
@ -40,7 +40,7 @@ from django_components.templatetags.component_tags import (
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
def __new__(mcs, name, bases, attrs):
if "Media" in attrs:
media = attrs["Media"]
media: Component.Media = attrs["Media"]
# Allow: class Media: css = "style.css"
if hasattr(media, "css") and isinstance(media.css, str):
@ -54,7 +54,7 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
if hasattr(media, "css") and isinstance(media.css, dict):
for media_type, path_list in media.css.items():
if isinstance(path_list, str):
media.css[media_type] = [path_list]
media.css[media_type] = [path_list] # type: ignore
# Allow: class Media: js = "script.js"
if hasattr(media, "js") and isinstance(media.js, str):
@ -67,20 +67,20 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return.
template_name: ClassVar[Optional[str]] = None
template: ClassVar[Optional[str]] = None
js: ClassVar[Optional[str]] = None
css: ClassVar[Optional[str]] = None
template: Optional[str] = None
js: Optional[str] = None
css: Optional[str] = None
media: Media
class Media:
css = {}
js = []
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
js: Optional[Union[str, List[str]]] = None
def __init__(
self,
registered_name: Optional[str] = None,
outer_context: Optional[Context] = None,
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]] = (),
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]] = (), # type: ignore
):
self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context()
@ -142,7 +142,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
self,
context_data: Dict[str, Any],
slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: Optional[bool] = True,
escape_slots_content: bool = True,
) -> str:
context = Context(context_data)
template = self.get_template(context)
@ -160,7 +160,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
self,
context_data: Dict[str, Any],
slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: Optional[bool] = True,
escape_slots_content: bool = True,
*args,
**kwargs,
):
@ -173,7 +173,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
def _fill_slots(
self,
slots_data: Dict[SlotName, str],
escape_content: bool,
escape_content: bool = True,
):
"""Fill component slots outside of template rendering."""
self.fill_content = [
@ -195,7 +195,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
named_fills_content = {}
else:
default_fill_content = None
named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in self.fill_content}
named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in list(self.fill_content)}
# If value is `None`, then slot is unfilled.
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}

View file

@ -1,5 +1,5 @@
import sys
from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Type, Union
from typing import TYPE_CHECKING, Iterable, List, Optional, Set, Tuple, Type, Union
if sys.version_info[:2] < (3, 9):
from typing import ChainMap
@ -194,7 +194,7 @@ class SlotNode(Node, TemplateAwareNodeMixin):
extra_context = {}
try:
slot_fill_content: Optional[FillContent] = filled_slots_map[(self.name, self.template)]
slot_fill_content: FillContent = filled_slots_map[(self.name, self.template)]
except KeyError:
if self.is_required:
raise TemplateSyntaxError(
@ -375,16 +375,17 @@ class ComponentNode(Node):
# Note that outer component context is used to resolve variables in
# fill tag.
resolved_name = fill_node.name_fexp.resolve(context)
resolved_alias: Optional[str]
if fill_node.alias_fexp:
resolved_alias: str = fill_node.alias_fexp.resolve(context)
if not resolved_alias.isidentifier():
resolved_alias = fill_node.alias_fexp.resolve(context)
if resolved_alias and not resolved_alias.isidentifier():
raise TemplateSyntaxError(
f"Fill tag alias '{fill_node.alias_fexp.var}' in component "
f"{resolved_component_name} does not resolve to "
f"a valid Python identifier. Got: '{resolved_alias}'."
)
else:
resolved_alias: None = None
resolved_alias = None
fill_content.append((resolved_name, fill_node.nodelist, resolved_alias))
component: Component = component_cls(
@ -426,14 +427,15 @@ def do_component(parser, token):
component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component")
body: NodeList = parser.parse(parse_until=["endcomponent"])
parser.delete_first_token()
fill_nodes = ()
fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = []
if block_has_content(body):
for parse_fn in (
try_parse_as_default_fill,
try_parse_as_named_fill_tag_set,
):
fill_nodes = parse_fn(body)
if fill_nodes:
curr_fill_nodes = parse_fn(body)
if curr_fill_nodes:
fill_nodes = curr_fill_nodes
break
else:
raise TemplateSyntaxError(
@ -457,7 +459,7 @@ def try_parse_as_named_fill_tag_set(
nodelist: NodeList,
) -> Optional[Iterable[NamedFillNode]]:
result = []
seen_name_fexps = set()
seen_name_fexps: Set[FilterExpression] = set()
for node in nodelist:
if isinstance(node, NamedFillNode):
if node.name_fexp in seen_name_fexps:
@ -465,6 +467,7 @@ def try_parse_as_named_fill_tag_set(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{node.name_fexp}'."
)
seen_name_fexps.add(node.name_fexp)
result.append(node)
elif isinstance(node, CommentNode):
pass
@ -544,9 +547,13 @@ def do_if_filled_block(parser, token):
bits = token.split_contents()
starting_tag = bits[0]
slot_name, is_positive = parse_if_filled_bits(bits)
nodelist = parser.parse(("elif_filled", "else_filled", "endif_filled"))
nodelist: NodeList = parser.parse(("elif_filled", "else_filled", "endif_filled"))
branches: List[_IfSlotFilledBranchNode] = [
IfSlotFilledConditionBranchNode(slot_name=slot_name, nodelist=nodelist, is_positive=is_positive)
IfSlotFilledConditionBranchNode(
slot_name=slot_name, # type: ignore
nodelist=nodelist,
is_positive=is_positive,
)
]
token = parser.next_token()
@ -555,9 +562,13 @@ def do_if_filled_block(parser, token):
while token.contents.startswith("elif_filled"):
bits = token.split_contents()
slot_name, is_positive = parse_if_filled_bits(bits)
nodelist: NodeList = parser.parse(("elif_filled", "else_filled", "endif_filled"))
nodelist = parser.parse(("elif_filled", "else_filled", "endif_filled"))
branches.append(
IfSlotFilledConditionBranchNode(slot_name=slot_name, nodelist=nodelist, is_positive=is_positive)
IfSlotFilledConditionBranchNode(
slot_name=slot_name, # type: ignore
nodelist=nodelist,
is_positive=is_positive,
)
)
token = parser.next_token()

View file

@ -1,8 +1,8 @@
try:
from typing import Annotated
from typing import Annotated # type: ignore
except ImportError:
class Annotated:
class Annotated: # type: ignore
def __init__(self, type_, *args, **kwargs):
self.type_ = type_
self.metadata = args, kwargs
@ -13,7 +13,7 @@ except ImportError:
def __getitem__(self, params):
if not isinstance(params, tuple):
params = (params,)
return Annotated(self.type_, *params, **self.metadata[1])
return Annotated(self.type_, *params, **self.metadata[1]) # type: ignore
css = Annotated[str, "css"]

0
py.typed Normal file
View file

View file

@ -35,4 +35,11 @@ exclude = [
'.env',
'.venv',
'.tox',
]
[tool.mypy]
check_untyped_defs = true
ignore_missing_imports = true
exclude = [
'test_structures'
]

View file

@ -5,4 +5,5 @@ flake8
flake8-pyproject
isort
pre-commit
black
black
mypy

View file

@ -40,8 +40,12 @@ isort==5.13.2
# via -r requirements-dev.in
mccabe==0.7.0
# via flake8
mypy==1.9.0
# via -r requirements-dev.in
mypy-extensions==1.0.0
# via black
# via
# black
# mypy
nodeenv==1.8.0
# via pre-commit
packaging==23.2
@ -77,6 +81,8 @@ sqlparse==0.4.4
# via django
tox==4.14.1
# via -r requirements-dev.in
typing-extensions==4.10.0
# via mypy
virtualenv==20.25.0
# via
# pre-commit

View file

@ -1,6 +1,7 @@
import os
import secrets
from pathlib import Path
from typing import List
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@ -16,7 +17,7 @@ SECRET_KEY = os.environ.get("SECRET_KEY", secrets.token_hex(100))
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS: List[str] = []
INSTALLED_APPS = [

View file

@ -1,10 +1,14 @@
import re
import textwrap
from collections import defaultdict
from typing import Dict, List, Tuple
from urllib import request
Version = Tuple[int, ...]
VersionMapping = Dict[Version, List[Version]]
def get_supported_versions(url):
def get_supported_versions(url: str):
with request.urlopen(url) as response:
response_content = response.read()
@ -39,7 +43,7 @@ def get_supported_versions(url):
return parse_supported_versions(content)
def get_latest_version(url):
def get_latest_version(url: str):
with request.urlopen(url) as response:
response_content = response.read()
@ -48,12 +52,12 @@ def get_latest_version(url):
return version_to_tuple(version_string)
def version_to_tuple(version_string):
def version_to_tuple(version_string: str):
return tuple(int(num) for num in version_string.split("."))
def build_python_to_django(django_to_python, latest_version):
python_to_django = defaultdict(list)
def build_python_to_django(django_to_python: VersionMapping, latest_version: Version):
python_to_django: VersionMapping = defaultdict(list)
for django_version, python_versions in django_to_python.items():
for python_version in python_versions:
if django_version <= latest_version:
@ -67,21 +71,21 @@ def env_format(version_tuple, divider=""):
return divider.join(str(num) for num in version_tuple)
def build_tox_envlist(python_to_django):
lines = [
def build_tox_envlist(python_to_django: VersionMapping):
lines_data = [
(
env_format(python_version),
",".join(env_format(version) for version in django_versions),
)
for python_version, django_versions in python_to_django.items()
]
lines = [f"py{a}-django{{{b}}}" for a, b in lines]
lines = [f"py{a}-django{{{b}}}" for a, b in lines_data]
version_lines = "\n".join([version for version in lines])
return "envlist = \n" + textwrap.indent(version_lines, prefix=" ")
def build_gh_actions_envlist(python_to_django):
lines = [
def build_gh_actions_envlist(python_to_django: VersionMapping):
lines_data = [
(
env_format(python_version, divider="."),
env_format(python_version),
@ -89,18 +93,18 @@ def build_gh_actions_envlist(python_to_django):
)
for python_version, django_versions in python_to_django.items()
]
lines = [f"{a}: py{b}-django{{{c}}}" for a, b, c in lines]
lines = [f"{a}: py{b}-django{{{c}}}" for a, b, c in lines_data]
version_lines = "\n".join([version for version in lines])
return "python = \n" + textwrap.indent(version_lines, prefix=" ")
def build_deps_envlist(python_to_django):
def build_deps_envlist(python_to_django: VersionMapping):
all_django_versions = set()
for django_versions in python_to_django.values():
for django_version in django_versions:
all_django_versions.add(django_version)
lines = [
lines_data = [
(
env_format(django_version),
env_format(django_version, divider="."),
@ -108,11 +112,11 @@ def build_deps_envlist(python_to_django):
)
for django_version in sorted(all_django_versions)
]
lines = [f"django{a}: Django>={b},<{c}" for a, b, c in sorted(lines)]
lines = [f"django{a}: Django>={b},<{c}" for a, b, c in sorted(lines_data)]
return "deps = \n" + textwrap.indent("\n".join(lines), prefix=" ")
def build_pypi_classifiers(python_to_django):
def build_pypi_classifiers(python_to_django: VersionMapping):
classifiers = []
all_python_versions = python_to_django.keys()
@ -130,7 +134,7 @@ def build_pypi_classifiers(python_to_django):
return textwrap.indent("classifiers=[\n", prefix=" " * 4) + textwrap.indent("\n".join(classifiers), prefix=" " * 8)
def build_readme(python_to_django):
def build_readme(python_to_django: VersionMapping):
print(
textwrap.dedent(
"""\
@ -139,19 +143,19 @@ def build_readme(python_to_django):
""".rstrip()
)
)
lines = [
lines_data = [
(
env_format(python_version, divider="."),
", ".join(env_format(version, divider=".") for version in django_versions),
)
for python_version, django_versions in python_to_django.items()
]
lines = [f"| {a: <14} | {b: <24} |" for a, b in lines]
lines = [f"| {a: <14} | {b: <24} |" for a, b in lines_data]
version_lines = "\n".join([version for version in lines])
return version_lines
def build_pyenv(python_to_django):
def build_pyenv(python_to_django: VersionMapping):
lines = []
all_python_versions = python_to_django.keys()
for python_version in all_python_versions:
@ -164,13 +168,13 @@ def build_pyenv(python_to_django):
return "\n".join(lines)
def build_ci_python_versions(python_to_django):
def build_ci_python_versions(python_to_django: Dict[str, str]):
# Outputs python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11']
lines = [
f"'{env_format(python_version, divider='.')}'" for python_version, django_versions in python_to_django.items()
]
lines = " " * 8 + f"python-version: [{', '.join(lines)}]"
return lines
lines_formatted = " " * 8 + f"python-version: [{', '.join(lines)}]"
return lines_formatted
def main():

View file

@ -8,6 +8,9 @@ VERSION = "0.51"
setup(
name="django_components",
packages=find_packages(exclude=["tests"]),
package_data={
"django_components": ["py.typed"],
},
version=VERSION,
description="A way to create simple reusable template components in Django.",
long_description=open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf8").read(),

View file

@ -48,6 +48,10 @@ class VariableDisplay(component.Component):
class IncrementerComponent(component.Component):
template_name = "incrementer.html"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.call_count = 0
def get_context_data(self, value=0):
value = int(value)
if hasattr(self, "call_count"):

View file

@ -1,6 +1,6 @@
import re
import textwrap
from typing import Callable, Iterable, Optional
from typing import Callable, Optional
from django.template import Context, Template, TemplateSyntaxError
@ -1250,7 +1250,7 @@ class IterationFillTest(SimpleTestCase):
class ComponentSimpleSlotInALoop(django_components.component.Component):
template_name = "template_with_slot_in_a_loop.html"
def get_context_data(self, objects: Iterable) -> dict:
def get_context_data(self, objects, *args, **kwargs) -> dict:
return {
"objects": objects,
}

View file

@ -13,6 +13,7 @@ envlist =
flake8
isort
coverage
mypy
requires = virtualenv<20.22.0
# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions
@ -25,7 +26,7 @@ python =
3.9: py39-django{32,40,41,42}
3.10: py310-django{32,40,41,42,50}
3.11: py311-django{41,42,50}
3.12: py312-django{42,50}, flake8, isort, coverage
3.12: py312-django{42,50}, flake8, isort, coverage, mypy
fail_on_no_env = True
[testenv]
@ -65,3 +66,9 @@ deps = pytest-coverage
commands =
coverage run --branch -m pytest
coverage report -m --fail-under=93
[testenv:mypy]
changedir = {toxinidir}
deps = mypy
commands =
mypy .