From b9f4e596a4e77f61c414c23dc5234699be08c315 Mon Sep 17 00:00:00 2001 From: Gabriel Dugny Date: Sun, 24 Mar 2024 23:29:52 +0100 Subject: [PATCH] feat: add type hints everywhere --- pyproject.toml | 5 + src/django_components/__init__.py | 9 +- src/django_components/app_settings.py | 13 +-- src/django_components/apps.py | 2 +- src/django_components/component.py | 57 +++++++---- .../management/commands/startcomponent.py | 7 +- .../management/commands/upgradecomponent.py | 7 +- src/django_components/middleware.py | 22 +++-- src/django_components/template_loader.py | 6 +- .../templatetags/component_tags.py | 94 ++++++++++--------- src/django_components/types.py | 10 +- src/django_components/utils.py | 4 +- 12 files changed, 138 insertions(+), 98 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 168db0db..e1be8f7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,11 @@ exclude = [ 'build', ] +[[tool.mypy.overrides]] +module = "django_components.*" +disallow_untyped_defs = true + + [tool.pytest.ini_options] testpaths = [ "tests" diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index b7f9ae82..12a573c8 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -2,6 +2,7 @@ import importlib import importlib.util import sys from pathlib import Path +from typing import Union import django from django.utils.module_loading import autodiscover_modules @@ -12,7 +13,7 @@ if django.VERSION < (3, 2): default_app_config = "django_components.apps.ComponentsConfig" -def autodiscover(): +def autodiscover() -> None: from django_components.app_settings import app_settings if app_settings.AUTODISCOVER: @@ -24,11 +25,11 @@ def autodiscover(): for path in component_filepaths: import_file(path) - for path in app_settings.LIBRARIES: - importlib.import_module(path) + for path_lib in app_settings.LIBRARIES: + importlib.import_module(path_lib) -def import_file(path): +def import_file(path: Union[str, Path]) -> None: MODULE_PATH = path MODULE_NAME = Path(path).stem spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index d4550e98..71e525fc 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import List from django.conf import settings @@ -9,27 +10,27 @@ class ContextBehavior(Enum): class AppSettings: - def __init__(self): + def __init__(self) -> None: self.settings = getattr(settings, "COMPONENTS", {}) @property - def AUTODISCOVER(self): + def AUTODISCOVER(self) -> bool: return self.settings.setdefault("autodiscover", True) @property - def LIBRARIES(self): + def LIBRARIES(self) -> List: return self.settings.setdefault("libraries", []) @property - def TEMPLATE_CACHE_SIZE(self): + def TEMPLATE_CACHE_SIZE(self) -> int: return self.settings.setdefault("template_cache_size", 128) @property - def CONTEXT_BEHAVIOR(self): + def CONTEXT_BEHAVIOR(self) -> ContextBehavior: raw_value = self.settings.setdefault("context_behavior", ContextBehavior.GLOBAL.value) return self._validate_context_behavior(raw_value) - def _validate_context_behavior(self, raw_value): + def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior: try: return ContextBehavior(raw_value) except ValueError: diff --git a/src/django_components/apps.py b/src/django_components/apps.py index 6cf3cb52..b96c1190 100644 --- a/src/django_components/apps.py +++ b/src/django_components/apps.py @@ -4,5 +4,5 @@ from django.apps import AppConfig class ComponentsConfig(AppConfig): name = "django_components" - def ready(self): + def ready(self) -> None: self.module.autodiscover() diff --git a/src/django_components/component.py b/src/django_components/component.py index d840db87..12e9310e 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -2,7 +2,22 @@ import difflib import inspect import os from collections import ChainMap -from typing import Any, ClassVar, Dict, Iterable, List, Optional, Set, Tuple, Union +from pathlib import Path +from typing import ( + Any, + ClassVar, + Dict, + Iterable, + List, + Mapping, + MutableMapping, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, +) from django.core.exceptions import ImproperlyConfigured from django.forms.widgets import Media, MediaDefiningClass @@ -12,7 +27,7 @@ from django.template.context import Context from django.template.exceptions import TemplateSyntaxError from django.template.loader import get_template from django.utils.html import escape -from django.utils.safestring import mark_safe +from django.utils.safestring import SafeString, mark_safe from django.views import View # Global registry var and register() function moved to separate module. @@ -39,9 +54,11 @@ from django_components.templatetags.component_tags import ( ) from django_components.utils import search +_T = TypeVar("_T") + class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass): - def __new__(mcs, name, bases, attrs): + def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type: if "Media" in attrs: media: Component.Media = attrs["Media"] @@ -68,7 +85,7 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass): return super().__new__(mcs, name, bases, attrs) -def _resolve_component_relative_files(attrs: dict): +def _resolve_component_relative_files(attrs: MutableMapping) -> None: """ Check if component's HTML, JS and CSS files refer to files in the same directory as the component class. If so, modify the attributes so the class Django's rendering @@ -96,7 +113,7 @@ def _resolve_component_relative_files(attrs: dict): # Check if filepath refers to a file that's in the same directory as the component class. # If yes, modify the path to refer to the relative file. # If not, don't modify anything. - def resolve_file(filepath: str): + def resolve_file(filepath: str) -> str: maybe_resolved_filepath = os.path.join(comp_dir_abs, filepath) component_import_filepath = os.path.join(comp_dir_rel, filepath) @@ -128,7 +145,9 @@ def _resolve_component_relative_files(attrs: dict): media.js = [resolve_file(filepath) for filepath in media.js] -def _get_dir_path_from_component_module_path(component_module_path: str, candidate_dirs: List[str]): +def _get_dir_path_from_component_module_path( + component_module_path: str, candidate_dirs: Union[List[str], List[Path]] +) -> Tuple[str, str]: # Transform python module notation "pkg.module.name" to file path "pkg/module/name" # Thus, we should get file path relative to Django project root comp_path = os.sep.join(component_module_path.split(".")) @@ -185,19 +204,19 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): self.outer_context: Context = outer_context or Context() self.fill_content = fill_content - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs: Any) -> None: cls.class_hash = hash(inspect.getfile(cls) + cls.__name__) - def get_context_data(self, *args, **kwargs) -> Dict[str, Any]: + def get_context_data(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: return {} - def get_template_name(self, context) -> Optional[str]: + def get_template_name(self, context: Mapping) -> Optional[str]: return self.template_name - def get_template_string(self, context) -> Optional[str]: + def get_template_string(self, context: Mapping) -> Optional[str]: return self.template - def render_dependencies(self): + def render_dependencies(self) -> SafeString: """Helper function to render all dependencies for a component.""" dependencies = [] @@ -211,19 +230,19 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): return mark_safe("\n".join(dependencies)) - def render_css_dependencies(self): + def render_css_dependencies(self) -> SafeString: """Render only CSS dependencies available in the media class or provided as a string.""" if self.css is not None: return mark_safe(f"") return mark_safe("\n".join(self.media.render_css())) - def render_js_dependencies(self): + def render_js_dependencies(self) -> SafeString: """Render only JS dependencies available in the media class or provided as a string.""" if self.js is not None: return mark_safe(f"") return mark_safe("\n".join(self.media.render_js())) - def get_template(self, context) -> Template: + def get_template(self, context: Mapping) -> Template: template_string = self.get_template_string(context) if template_string is not None: return Template(template_string) @@ -260,9 +279,9 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): context_data: Dict[str, Any], slots_data: Optional[Dict[SlotName, str]] = None, escape_slots_content: bool = True, - *args, - **kwargs, - ): + *args: Any, + **kwargs: Any, + ) -> HttpResponse: return HttpResponse( self.render(context_data, slots_data, escape_slots_content), *args, @@ -273,7 +292,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): self, slots_data: Dict[SlotName, str], escape_content: bool = True, - ): + ) -> None: """Fill component slots outside of template rendering.""" self.fill_content = [ ( @@ -342,7 +361,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): f"even though none of its slots is marked as 'default'." ) - unfilled_slots: Set[str] = set(k for k, v in slot_name2fill_content.items() if v is None) + unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None} unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys() # Check that 'required' slots are filled. diff --git a/src/django_components/management/commands/startcomponent.py b/src/django_components/management/commands/startcomponent.py index 6054ebbd..3e3b0c01 100644 --- a/src/django_components/management/commands/startcomponent.py +++ b/src/django_components/management/commands/startcomponent.py @@ -1,14 +1,15 @@ import os from textwrap import dedent +from typing import Any from django.conf import settings -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand, CommandError, CommandParser class Command(BaseCommand): help = "Creates a new component" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument("name", type=str, help="The name of the component to create") parser.add_argument( "--path", @@ -51,7 +52,7 @@ class Command(BaseCommand): default=False, ) - def handle(self, *args, **kwargs): + def handle(self, *args: Any, **kwargs: Any) -> None: name = kwargs["name"] if name: diff --git a/src/django_components/management/commands/upgradecomponent.py b/src/django_components/management/commands/upgradecomponent.py index 5c912e06..ab31fa1a 100644 --- a/src/django_components/management/commands/upgradecomponent.py +++ b/src/django_components/management/commands/upgradecomponent.py @@ -1,9 +1,10 @@ import os import re from pathlib import Path +from typing import Any from django.conf import settings -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from django.template.engine import Engine from django_components.template_loader import Loader @@ -12,10 +13,10 @@ from django_components.template_loader import Loader class Command(BaseCommand): help = "Updates component and component_block tags to the new syntax" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument("--path", type=str, help="Path to search for components") - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: current_engine = Engine.get_default() loader = Loader(current_engine) dirs = loader.get_dirs() diff --git a/src/django_components/middleware.py b/src/django_components/middleware.py index 4655398c..3005827a 100644 --- a/src/django_components/middleware.py +++ b/src/django_components/middleware.py @@ -1,11 +1,17 @@ import re +from collections.abc import Callable +from typing import TYPE_CHECKING, Iterable from django.conf import settings from django.forms import Media -from django.http import StreamingHttpResponse +from django.http import HttpRequest, HttpResponse, StreamingHttpResponse +from django.http.response import HttpResponseBase from django_components.component_registry import registry +if TYPE_CHECKING: + from django_components.component import Component + RENDERED_COMPONENTS_CONTEXT_KEY = "_COMPONENT_DEPENDENCIES" CSS_DEPENDENCY_PLACEHOLDER = '' JS_DEPENDENCY_PLACEHOLDER = '' @@ -24,10 +30,10 @@ class ComponentDependencyMiddleware: dependency_regex = COMPONENT_COMMENT_REGEX - def __init__(self, get_response): + def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None: self.get_response = get_response - def __call__(self, request): + def __call__(self, request: HttpRequest) -> HttpResponseBase: response = self.get_response(request) if ( getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False) @@ -38,7 +44,7 @@ class ComponentDependencyMiddleware: return response -def process_response_content(content): +def process_response_content(content: bytes) -> bytes: component_names_seen = {match.group("name") for match in COMPONENT_COMMENT_REGEX.finditer(content)} all_components = [registry.get(name.decode("utf-8"))("") for name in component_names_seen] all_media = join_media(all_components) @@ -47,7 +53,7 @@ def process_response_content(content): return PLACEHOLDER_REGEX.sub(DependencyReplacer(css_dependencies, js_dependencies), content) -def add_module_attribute_to_scripts(scripts): +def add_module_attribute_to_scripts(scripts: str) -> str: return re.sub(SCRIPT_TAG_REGEX, '