diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e31503..3fb1429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,12 @@ jobs: - uses: astral-sh/ruff-action@v3 - run: ruff check - run: ruff format --check + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5.4.0 + with: + python-version: '3.12' + - run: uvx poetry install --extras=ssr + - run: uvx poetry run mypy . diff --git a/inertia/helpers.py b/inertia/helpers.py index 6bbde8d..2b5b6c2 100644 --- a/inertia/helpers.py +++ b/inertia/helpers.py @@ -1,4 +1,7 @@ -def deep_transform_callables(prop): +from typing import Any + + +def deep_transform_callables(prop: Any) -> Any: if not isinstance(prop, dict): return prop() if callable(prop) else prop @@ -6,12 +9,3 @@ def deep_transform_callables(prop): prop[key] = deep_transform_callables(prop[key]) return prop - - -def validate_type(value, name, expected_type): - if not isinstance(value, expected_type): - raise TypeError( - f"Expected {expected_type.__name__} for {name}, got {type(value).__name__}" - ) - - return value diff --git a/inertia/http.py b/inertia/http.py index ab9e850..896029a 100644 --- a/inertia/http.py +++ b/inertia/http.py @@ -1,12 +1,13 @@ from functools import wraps from http import HTTPStatus from json import dumps as json_encode +from typing import Any, Callable from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest, HttpResponse from django.template.loader import render_to_string -from .helpers import deep_transform_callables, validate_type +from .helpers import deep_transform_callables from .prop_classes import DeferredProp, IgnoreOnFirstLoadProp, MergeableProp from .settings import settings @@ -15,7 +16,7 @@ # a mock module import requests except ImportError: - requests = None + requests = None # type: ignore[assignment] INERTIA_REQUEST_ENCRYPT_HISTORY = "_inertia_encrypt_history" @@ -26,51 +27,55 @@ class InertiaRequest(HttpRequest): - def __init__(self, request): + def __init__(self, request: HttpRequest): super().__init__() self.__dict__.update(request.__dict__) @property - def inertia(self): + def inertia(self) -> dict[str, Any]: inertia_attr = self.__dict__.get("inertia") return ( inertia_attr.all() if inertia_attr and hasattr(inertia_attr, "all") else {} ) - def is_a_partial_render(self, component): + def is_a_partial_render(self, component: str) -> bool: return ( "X-Inertia-Partial-Data" in self.headers and self.headers.get("X-Inertia-Partial-Component", "") == component ) - def partial_keys(self): + def partial_keys(self) -> list[str]: return self.headers.get("X-Inertia-Partial-Data", "").split(",") - def reset_keys(self): + def reset_keys(self) -> list[str]: return self.headers.get("X-Inertia-Reset", "").split(",") - def is_inertia(self): + def is_inertia(self) -> bool: return "X-Inertia" in self.headers - def should_encrypt_history(self): - return validate_type( - getattr( - self, - INERTIA_REQUEST_ENCRYPT_HISTORY, - settings.INERTIA_ENCRYPT_HISTORY, - ), - expected_type=bool, - name="encrypt_history", + def should_encrypt_history(self) -> bool: + should_encrypt = getattr( + self, INERTIA_REQUEST_ENCRYPT_HISTORY, settings.INERTIA_ENCRYPT_HISTORY ) + if not isinstance(should_encrypt, bool): + raise TypeError( + f"Expected bool for encrypt_history, got {type(should_encrypt).__name__}" + ) + return should_encrypt class BaseInertiaResponseMixin: - def page_data(self): - clear_history = validate_type( - self.request.session.pop(INERTIA_SESSION_CLEAR_HISTORY, False), - expected_type=bool, - name="clear_history", - ) + request: InertiaRequest + component: str + props: dict[str, Any] + template_data: dict[str, Any] + + def page_data(self) -> dict[str, Any]: + clear_history = self.request.session.pop(INERTIA_SESSION_CLEAR_HISTORY, False) + if not isinstance(clear_history, bool): + raise TypeError( + f"Expected bool for clear_history, got {type(clear_history).__name__}" + ) _page = { "component": self.component, @@ -91,7 +96,7 @@ def page_data(self): return _page - def build_props(self): + def build_props(self) -> Any: _props = { **(self.request.inertia), **self.props, @@ -107,18 +112,18 @@ def build_props(self): return deep_transform_callables(_props) - def build_deferred_props(self): + def build_deferred_props(self) -> dict[str, Any] | None: if self.request.is_a_partial_render(self.component): return None - _deferred_props = {} + _deferred_props: dict[str, Any] = {} for key, prop in self.props.items(): if isinstance(prop, DeferredProp): _deferred_props.setdefault(prop.group, []).append(key) return _deferred_props - def build_merge_props(self): + def build_merge_props(self) -> list[str]: return [ key for key, prop in self.props.items() @@ -129,7 +134,7 @@ def build_merge_props(self): ) ] - def build_first_load(self, data): + def build_first_load(self, data: Any) -> str: context, template = self.build_first_load_context_and_template(data) try: @@ -151,7 +156,9 @@ def build_first_load(self, data): using=None, ) - def build_first_load_context_and_template(self, data): + def build_first_load_context_and_template( + self, data: Any + ) -> tuple[dict[str, Any], str]: if settings.INERTIA_SSR_ENABLED: try: response = requests.post( @@ -178,14 +185,14 @@ class InertiaResponse(BaseInertiaResponseMixin, HttpResponse): def __init__( self, - request, - component, - props=None, - template_data=None, - headers=None, - *args, - **kwargs, - ): + request: HttpRequest, + component: str, + props: dict[str, Any] | None = None, + template_data: dict[str, Any] | None = None, + headers: dict[str, Any] | None = None, + *args: Any, + **kwargs: Any, + ) -> None: self.request = InertiaRequest(request) self.component = component self.props = props or {} @@ -208,19 +215,30 @@ def __init__( else: content = self.build_first_load(data) - super().__init__( - *args, - content=content, - headers=_headers, - **kwargs, - ) + if args: + super().__init__( + *args, + headers=_headers, + **kwargs, + ) + else: + super().__init__( + content=content, + headers=_headers, + **kwargs, + ) -def render(request, component, props=None, template_data=None): +def render( + request: HttpRequest, + component: str, + props: dict[str, Any] | None = None, + template_data: dict[str, Any] | None = None, +) -> InertiaResponse: return InertiaResponse(request, component, props or {}, template_data or {}) -def location(location): +def location(location: str) -> HttpResponse: return HttpResponse( "", status=HTTPStatus.CONFLICT, @@ -230,18 +248,27 @@ def location(location): ) -def encrypt_history(request, value=True): +def encrypt_history(request: HttpRequest, value: bool = True) -> None: setattr(request, INERTIA_REQUEST_ENCRYPT_HISTORY, value) -def clear_history(request): +def clear_history(request: HttpRequest) -> None: request.session[INERTIA_SESSION_CLEAR_HISTORY] = True -def inertia(component): - def decorator(func): +def inertia( + component: str, +) -> Callable[ + [Callable[..., HttpResponse | InertiaResponse | dict[str, Any]]], + Callable[..., HttpResponse], +]: + def decorator( + func: Callable[..., HttpResponse | InertiaResponse | dict[str, Any]], + ) -> Callable[..., HttpResponse]: @wraps(func) - def process_inertia_response(request, *args, **kwargs): + def process_inertia_response( + request: HttpRequest, *args: Any, **kwargs: Any + ) -> HttpResponse: props = func(request, *args, **kwargs) # if a response is returned, return it diff --git a/inertia/middleware.py b/inertia/middleware.py index 874c56e..37170bf 100644 --- a/inertia/middleware.py +++ b/inertia/middleware.py @@ -1,4 +1,7 @@ +from typing import Callable + from django.contrib import messages +from django.http import HttpRequest, HttpResponse from django.middleware.csrf import get_token from .http import location @@ -6,10 +9,10 @@ class InertiaMiddleware: - 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) -> HttpResponse: response = self.get_response(request) # Inertia requests don't ever render templates, so they skip the typical Django @@ -27,28 +30,33 @@ def __call__(self, request): return response - def is_non_post_redirect(self, request, response): + def is_non_post_redirect( + self, request: HttpRequest, response: HttpResponse + ) -> bool: return self.is_redirect_request(response) and request.method in [ "PUT", "PATCH", "DELETE", ] - def is_inertia_request(self, request): + def is_inertia_request(self, request: HttpRequest) -> bool: return "X-Inertia" in request.headers - def is_redirect_request(self, response): + def is_redirect_request(self, response: HttpResponse) -> bool: return response.status_code in [301, 302] - def is_stale(self, request): + def is_stale(self, request: HttpRequest) -> bool: return ( request.headers.get("X-Inertia-Version", settings.INERTIA_VERSION) != settings.INERTIA_VERSION ) - def is_stale_inertia_get(self, request): + def is_stale_inertia_get(self, request: HttpRequest) -> bool: return request.method == "GET" and self.is_stale(request) - def force_refresh(self, request): - messages.get_messages(request).used = False + def force_refresh(self, request: HttpRequest) -> HttpResponse: + # If the storage middleware is not defined, get_messages returns an empty list + storage = messages.get_messages(request) + if not isinstance(storage, list): + storage.used = False return location(request.build_absolute_uri()) diff --git a/inertia/prop_classes.py b/inertia/prop_classes.py index 2563796..0ae8d07 100644 --- a/inertia/prop_classes.py +++ b/inertia/prop_classes.py @@ -1,17 +1,18 @@ from abc import ABC, abstractmethod +from typing import Any class CallableProp: - def __init__(self, prop): + def __init__(self, prop: Any) -> None: self.prop = prop - def __call__(self): + def __call__(self) -> Any: return self.prop() if callable(self.prop) else self.prop class MergeableProp(ABC): @abstractmethod - def should_merge(self): + def should_merge(self) -> bool: pass @@ -24,15 +25,15 @@ class OptionalProp(CallableProp, IgnoreOnFirstLoadProp): class DeferredProp(CallableProp, MergeableProp, IgnoreOnFirstLoadProp): - def __init__(self, prop, group, merge=False): + def __init__(self, prop: Any, group: str, merge: bool = False) -> None: super().__init__(prop) self.group = group self.merge = merge - def should_merge(self): + def should_merge(self) -> bool: return self.merge class MergeProp(CallableProp, MergeableProp): - def should_merge(self): + def should_merge(self) -> bool: return True diff --git a/inertia/settings.py b/inertia/settings.py index 69dbab1..46ed6f9 100644 --- a/inertia/settings.py +++ b/inertia/settings.py @@ -1,3 +1,5 @@ +from typing import Any + from django.conf import settings as django_settings from .utils import InertiaJsonEncoder @@ -12,7 +14,7 @@ class InertiaSettings: INERTIA_SSR_ENABLED = False INERTIA_ENCRYPT_HISTORY = False - def __getattribute__(self, name): + def __getattribute__(self, name: str) -> Any: try: return getattr(django_settings, name) except AttributeError: diff --git a/inertia/share.py b/inertia/share.py index 4204ad5..ff67b9e 100644 --- a/inertia/share.py +++ b/inertia/share.py @@ -1,22 +1,26 @@ +from typing import Any + +from django.http import HttpRequest + __all__ = ["share"] class InertiaShare: - def __init__(self): - self.props = {} + def __init__(self) -> None: + self.props: dict[str, Any] = {} - def set(self, **kwargs): + def set(self, **kwargs: Any) -> None: self.props = { **self.props, **kwargs, } - def all(self): + def all(self) -> dict[str, Any]: return self.props -def share(request, **kwargs): +def share(request: HttpRequest, **kwargs: Any) -> None: if not hasattr(request, "inertia"): - request.inertia = InertiaShare() + request.inertia = InertiaShare() # type: ignore[attr-defined] - request.inertia.set(**kwargs) + request.inertia.set(**kwargs) # type: ignore[attr-defined] diff --git a/inertia/tests/testapp/urls.py b/inertia/tests/testapp/urls.py index dd88e0f..7afcead 100644 --- a/inertia/tests/testapp/urls.py +++ b/inertia/tests/testapp/urls.py @@ -14,7 +14,7 @@ path("defer-group/", views.defer_group_test), path("merge/", views.merge_test), path("complex-props/", views.complex_props_test), - path("share/", views.share_test), + path("share/", views.share_test), # type: ignore[arg-type] path("inertia-redirect/", views.inertia_redirect_test), path("external-redirect/", views.external_redirect_test), path("encrypt-history/", views.encrypt_history_test), diff --git a/inertia/utils.py b/inertia/utils.py index 7b2cb86..fc79896 100644 --- a/inertia/utils.py +++ b/inertia/utils.py @@ -1,4 +1,5 @@ import warnings +from typing import Any from django.core.serializers.json import DjangoJSONEncoder from django.db import models @@ -8,31 +9,30 @@ from .prop_classes import DeferredProp, MergeProp, OptionalProp -def model_to_dict(model): +def model_to_dict(model: models.Model) -> dict[str, Any]: return base_model_to_dict(model, exclude=("password",)) class InertiaJsonEncoder(DjangoJSONEncoder): - def default(self, value): - if hasattr(value.__class__, "InertiaMeta"): + def default(self, o: Any) -> Any: + if hasattr(o.__class__, "InertiaMeta"): return { - field: getattr(value, field) - for field in value.__class__.InertiaMeta.fields + field: getattr(o, field) for field in o.__class__.InertiaMeta.fields } - if isinstance(value, models.Model): - return model_to_dict(value) + if isinstance(o, models.Model): + return model_to_dict(o) - if isinstance(value, QuerySet): + if isinstance(o, QuerySet): return [ - (model_to_dict(obj) if isinstance(value.model, models.Model) else obj) - for obj in value + (model_to_dict(obj) if isinstance(o.model, models.Model) else obj) + for obj in o ] - return super().default(value) + return super().default(o) -def lazy(prop): +def lazy(prop: Any) -> OptionalProp: warnings.warn( "lazy is deprecated and will be removed in a future version. Please use optional instead.", DeprecationWarning, @@ -41,13 +41,13 @@ def lazy(prop): return optional(prop) -def optional(prop): +def optional(prop: Any) -> OptionalProp: return OptionalProp(prop) -def defer(prop, group="default", merge=False): +def defer(prop: Any, group: str = "default", merge: bool = False) -> DeferredProp: return DeferredProp(prop, group=group, merge=merge) -def merge(prop): +def merge(prop: Any) -> MergeProp: return MergeProp(prop) diff --git a/pyproject.toml b/pyproject.toml index 5ffe1ed..bcf0f9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ pytest = "^8.3.5" pytest-cov = "^6.0.0" pytest-django = "^4.10.0" ruff = "^0.11.2" +mypy = "^1.17.0" +django-stubs = {extras = ["compatible-mypy"], version = "^5.2.2"} +types-requests = "^2.32.4.20250611" [tool.ruff.lint] select = [ @@ -54,3 +57,14 @@ unfixable = ["B", "SIM"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "inertia.tests.settings" django_find_project = false + +[tool.mypy] +plugins = ["mypy_django_plugin.main"] +strict = true +exclude = [ + "inertia/test.*" +] + +[tool.django-stubs] +django_settings_module = "inertia.tests.settings" +