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_PATH = path
MODULE_NAME = Path(path).stem MODULE_NAME = Path(path).stem
spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) 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) module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module 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 difflib
import inspect import inspect
from collections import ChainMap 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.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media, MediaDefiningClass from django.forms.widgets import Media, MediaDefiningClass
@ -40,7 +40,7 @@ from django_components.templatetags.component_tags import (
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass): class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
def __new__(mcs, name, bases, attrs): def __new__(mcs, name, bases, attrs):
if "Media" in attrs: if "Media" in attrs:
media = attrs["Media"] media: Component.Media = attrs["Media"]
# Allow: class Media: css = "style.css" # Allow: class Media: css = "style.css"
if hasattr(media, "css") and isinstance(media.css, str): 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): if hasattr(media, "css") and isinstance(media.css, dict):
for media_type, path_list in media.css.items(): for media_type, path_list in media.css.items():
if isinstance(path_list, str): 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" # Allow: class Media: js = "script.js"
if hasattr(media, "js") and isinstance(media.js, str): 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 # Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return. # non-null return.
template_name: ClassVar[Optional[str]] = None template_name: ClassVar[Optional[str]] = None
template: ClassVar[Optional[str]] = None template: Optional[str] = None
js: ClassVar[Optional[str]] = None js: Optional[str] = None
css: ClassVar[Optional[str]] = None css: Optional[str] = None
media: Media media: Media
class Media: class Media:
css = {} css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
js = [] js: Optional[Union[str, List[str]]] = None
def __init__( def __init__(
self, self,
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
outer_context: Optional[Context] = 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.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context() self.outer_context: Context = outer_context or Context()
@ -142,7 +142,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
self, self,
context_data: Dict[str, Any], context_data: Dict[str, Any],
slots_data: Optional[Dict[SlotName, str]] = None, slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: Optional[bool] = True, escape_slots_content: bool = True,
) -> str: ) -> str:
context = Context(context_data) context = Context(context_data)
template = self.get_template(context) template = self.get_template(context)
@ -160,7 +160,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
self, self,
context_data: Dict[str, Any], context_data: Dict[str, Any],
slots_data: Optional[Dict[SlotName, str]] = None, slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: Optional[bool] = True, escape_slots_content: bool = True,
*args, *args,
**kwargs, **kwargs,
): ):
@ -173,7 +173,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
def _fill_slots( def _fill_slots(
self, self,
slots_data: Dict[SlotName, str], slots_data: Dict[SlotName, str],
escape_content: bool, escape_content: bool = True,
): ):
"""Fill component slots outside of template rendering.""" """Fill component slots outside of template rendering."""
self.fill_content = [ self.fill_content = [
@ -195,7 +195,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
named_fills_content = {} named_fills_content = {}
else: else:
default_fill_content = None 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. # If value is `None`, then slot is unfilled.
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {} slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}

View file

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

View file

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

0
py.typed Normal file
View file

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
from typing import List
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS: List[str] = []
INSTALLED_APPS = [ INSTALLED_APPS = [

View file

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

View file

@ -8,6 +8,9 @@ VERSION = "0.51"
setup( setup(
name="django_components", name="django_components",
packages=find_packages(exclude=["tests"]), packages=find_packages(exclude=["tests"]),
package_data={
"django_components": ["py.typed"],
},
version=VERSION, version=VERSION,
description="A way to create simple reusable template components in Django.", 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(), 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): class IncrementerComponent(component.Component):
template_name = "incrementer.html" template_name = "incrementer.html"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.call_count = 0
def get_context_data(self, value=0): def get_context_data(self, value=0):
value = int(value) value = int(value)
if hasattr(self, "call_count"): if hasattr(self, "call_count"):

View file

@ -1,6 +1,6 @@
import re import re
import textwrap import textwrap
from typing import Callable, Iterable, Optional from typing import Callable, Optional
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
@ -1250,7 +1250,7 @@ class IterationFillTest(SimpleTestCase):
class ComponentSimpleSlotInALoop(django_components.component.Component): class ComponentSimpleSlotInALoop(django_components.component.Component):
template_name = "template_with_slot_in_a_loop.html" 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 { return {
"objects": objects, "objects": objects,
} }

View file

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