From 7aed8ec9615f487d43e3538fd415fbc70febc5f7 Mon Sep 17 00:00:00 2001 From: Gabriel Lopes Rodrigues Date: Tue, 15 Jul 2025 22:27:33 -0300 Subject: [PATCH 1/5] Add type hints using basedpyright --- inertia/http.py | 12 +++++++++--- inertia/middleware.py | 6 +++++- inertia/prop_classes.py | 2 +- inertia/test.py | 21 +++++++++++---------- inertia/tests/testapp/urls.py | 2 +- inertia/tests/testapp/views.py | 2 +- inertia/utils.py | 19 +++++++++---------- pyproject.toml | 5 +++++ 8 files changed, 42 insertions(+), 27 deletions(-) diff --git a/inertia/http.py b/inertia/http.py index ab9e850..a78fc88 100644 --- a/inertia/http.py +++ b/inertia/http.py @@ -1,6 +1,7 @@ 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 @@ -65,6 +66,11 @@ def should_encrypt_history(self): class BaseInertiaResponseMixin: + request: InertiaRequest + component: str + props: dict[str, Any] + template_data: dict[str, Any] + def page_data(self): clear_history = validate_type( self.request.session.pop(INERTIA_SESSION_CLEAR_HISTORY, False), @@ -147,7 +153,7 @@ def build_first_load(self, data): "inertia_layout": layout, **context, }, - self.request, + self.request.request, using=None, ) @@ -238,8 +244,8 @@ def clear_history(request): request.session[INERTIA_SESSION_CLEAR_HISTORY] = True -def inertia(component): - def decorator(func): +def inertia(component: str): + def decorator(func: Callable[..., HttpResponse | InertiaResponse | dict[str, Any]]): @wraps(func) def process_inertia_response(request, *args, **kwargs): props = func(request, *args, **kwargs) diff --git a/inertia/middleware.py b/inertia/middleware.py index 874c56e..342e54c 100644 --- a/inertia/middleware.py +++ b/inertia/middleware.py @@ -50,5 +50,9 @@ def is_stale_inertia_get(self, request): return request.method == "GET" and self.is_stale(request) def force_refresh(self, request): - messages.get_messages(request).used = False + # 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..b36b76a 100644 --- a/inertia/prop_classes.py +++ b/inertia/prop_classes.py @@ -11,7 +11,7 @@ def __call__(self): class MergeableProp(ABC): @abstractmethod - def should_merge(self): + def should_merge(self) -> bool: pass diff --git a/inertia/test.py b/inertia/test.py index 000bd99..4196555 100644 --- a/inertia/test.py +++ b/inertia/test.py @@ -9,7 +9,7 @@ class ClientWithLastResponse: - def __init__(self, client): + def __init__(self, client: Client): self.client = client self.last_response = None @@ -29,10 +29,6 @@ def setUp(self): def last_response(self): return self.inertia.last_response or self.client.last_response - def assertJSONResponse(self, response, json_obj): - self.assertEqual(response.headers["Content-Type"], "application/json") - self.assertEqual(response.json(), json_obj) - class InertiaTestCase(BaseInertiaTestCase, TestCase): def setUp(self): @@ -47,11 +43,12 @@ def tearDown(self): self.mock_inertia.stop() def page(self): - page_data = ( - self.mock_render.call_args[0][1]["page"] - if self.mock_render.call_args - else self.last_response().content - ) + if self.mock_render.call_args: + page_data = self.mock_render.call_args[0][1]["page"] + elif response := self.last_response(): + page_data = response.content + else: + page_data = "" return loads(page_data) @@ -75,6 +72,10 @@ def template_data(self): def component(self): return self.page()["component"] + def assertJSONResponse(self, response, json_obj): + self.assertEqual(response.headers["Content-Type"], "application/json") + self.assertEqual(response.json(), json_obj) + def assertIncludesProps(self, props): self.assertDictEqual(self.props(), {**self.props(), **props}) diff --git a/inertia/tests/testapp/urls.py b/inertia/tests/testapp/urls.py index dd88e0f..e0d278c 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 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/tests/testapp/views.py b/inertia/tests/testapp/views.py index 26c9f1f..0235172 100644 --- a/inertia/tests/testapp/views.py +++ b/inertia/tests/testapp/views.py @@ -132,7 +132,7 @@ def encrypt_history_false_test(request): @inertia("TestComponent") def encrypt_history_type_error_test(request): - encrypt_history(request, "foo") + encrypt_history(request, "foo") # pyright: ignore[reportArgumentType] return {} diff --git a/inertia/utils.py b/inertia/utils.py index 7b2cb86..35386b1 100644 --- a/inertia/utils.py +++ b/inertia/utils.py @@ -13,23 +13,22 @@ def model_to_dict(model): class InertiaJsonEncoder(DjangoJSONEncoder): - def default(self, value): - if hasattr(value.__class__, "InertiaMeta"): + def default(self, o): + 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): diff --git a/pyproject.toml b/pyproject.toml index 5ffe1ed..c0ba93f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,8 @@ pytest = "^8.3.5" pytest-cov = "^6.0.0" pytest-django = "^4.10.0" ruff = "^0.11.2" +django-stubs = "^5.2.1" +basedpyright = "^1.30.1" [tool.ruff.lint] select = [ @@ -54,3 +56,6 @@ unfixable = ["B", "SIM"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "inertia.tests.settings" django_find_project = false + +[tool.basedpyright] +typeCheckingMode = "standard" From ca44a068ca97925c9f9fd626462e15bd4139fd21 Mon Sep 17 00:00:00 2001 From: Gabriel Lopes Rodrigues Date: Mon, 28 Jul 2025 21:56:23 -0300 Subject: [PATCH 2/5] Revert "Add type hints using basedpyright" This reverts commit 7aed8ec9615f487d43e3538fd415fbc70febc5f7. --- inertia/http.py | 12 +++--------- inertia/middleware.py | 6 +----- inertia/prop_classes.py | 2 +- inertia/test.py | 21 ++++++++++----------- inertia/tests/testapp/urls.py | 2 +- inertia/tests/testapp/views.py | 2 +- inertia/utils.py | 19 ++++++++++--------- pyproject.toml | 5 ----- 8 files changed, 27 insertions(+), 42 deletions(-) diff --git a/inertia/http.py b/inertia/http.py index a78fc88..ab9e850 100644 --- a/inertia/http.py +++ b/inertia/http.py @@ -1,7 +1,6 @@ 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 @@ -66,11 +65,6 @@ def should_encrypt_history(self): class BaseInertiaResponseMixin: - request: InertiaRequest - component: str - props: dict[str, Any] - template_data: dict[str, Any] - def page_data(self): clear_history = validate_type( self.request.session.pop(INERTIA_SESSION_CLEAR_HISTORY, False), @@ -153,7 +147,7 @@ def build_first_load(self, data): "inertia_layout": layout, **context, }, - self.request.request, + self.request, using=None, ) @@ -244,8 +238,8 @@ def clear_history(request): request.session[INERTIA_SESSION_CLEAR_HISTORY] = True -def inertia(component: str): - def decorator(func: Callable[..., HttpResponse | InertiaResponse | dict[str, Any]]): +def inertia(component): + def decorator(func): @wraps(func) def process_inertia_response(request, *args, **kwargs): props = func(request, *args, **kwargs) diff --git a/inertia/middleware.py b/inertia/middleware.py index 342e54c..874c56e 100644 --- a/inertia/middleware.py +++ b/inertia/middleware.py @@ -50,9 +50,5 @@ def is_stale_inertia_get(self, request): return request.method == "GET" and self.is_stale(request) def force_refresh(self, request): - # 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 - + messages.get_messages(request).used = False return location(request.build_absolute_uri()) diff --git a/inertia/prop_classes.py b/inertia/prop_classes.py index b36b76a..2563796 100644 --- a/inertia/prop_classes.py +++ b/inertia/prop_classes.py @@ -11,7 +11,7 @@ def __call__(self): class MergeableProp(ABC): @abstractmethod - def should_merge(self) -> bool: + def should_merge(self): pass diff --git a/inertia/test.py b/inertia/test.py index 4196555..000bd99 100644 --- a/inertia/test.py +++ b/inertia/test.py @@ -9,7 +9,7 @@ class ClientWithLastResponse: - def __init__(self, client: Client): + def __init__(self, client): self.client = client self.last_response = None @@ -29,6 +29,10 @@ def setUp(self): def last_response(self): return self.inertia.last_response or self.client.last_response + def assertJSONResponse(self, response, json_obj): + self.assertEqual(response.headers["Content-Type"], "application/json") + self.assertEqual(response.json(), json_obj) + class InertiaTestCase(BaseInertiaTestCase, TestCase): def setUp(self): @@ -43,12 +47,11 @@ def tearDown(self): self.mock_inertia.stop() def page(self): - if self.mock_render.call_args: - page_data = self.mock_render.call_args[0][1]["page"] - elif response := self.last_response(): - page_data = response.content - else: - page_data = "" + page_data = ( + self.mock_render.call_args[0][1]["page"] + if self.mock_render.call_args + else self.last_response().content + ) return loads(page_data) @@ -72,10 +75,6 @@ def template_data(self): def component(self): return self.page()["component"] - def assertJSONResponse(self, response, json_obj): - self.assertEqual(response.headers["Content-Type"], "application/json") - self.assertEqual(response.json(), json_obj) - def assertIncludesProps(self, props): self.assertDictEqual(self.props(), {**self.props(), **props}) diff --git a/inertia/tests/testapp/urls.py b/inertia/tests/testapp/urls.py index e0d278c..dd88e0f 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), # type: ignore + path("share/", views.share_test), 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/tests/testapp/views.py b/inertia/tests/testapp/views.py index 0235172..26c9f1f 100644 --- a/inertia/tests/testapp/views.py +++ b/inertia/tests/testapp/views.py @@ -132,7 +132,7 @@ def encrypt_history_false_test(request): @inertia("TestComponent") def encrypt_history_type_error_test(request): - encrypt_history(request, "foo") # pyright: ignore[reportArgumentType] + encrypt_history(request, "foo") return {} diff --git a/inertia/utils.py b/inertia/utils.py index 35386b1..7b2cb86 100644 --- a/inertia/utils.py +++ b/inertia/utils.py @@ -13,22 +13,23 @@ def model_to_dict(model): class InertiaJsonEncoder(DjangoJSONEncoder): - def default(self, o): - if hasattr(o.__class__, "InertiaMeta"): + def default(self, value): + if hasattr(value.__class__, "InertiaMeta"): return { - field: getattr(o, field) for field in o.__class__.InertiaMeta.fields + field: getattr(value, field) + for field in value.__class__.InertiaMeta.fields } - if isinstance(o, models.Model): - return model_to_dict(o) + if isinstance(value, models.Model): + return model_to_dict(value) - if isinstance(o, QuerySet): + if isinstance(value, QuerySet): return [ - (model_to_dict(obj) if isinstance(o.model, models.Model) else obj) - for obj in o + (model_to_dict(obj) if isinstance(value.model, models.Model) else obj) + for obj in value ] - return super().default(o) + return super().default(value) def lazy(prop): diff --git a/pyproject.toml b/pyproject.toml index c0ba93f..5ffe1ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,6 @@ pytest = "^8.3.5" pytest-cov = "^6.0.0" pytest-django = "^4.10.0" ruff = "^0.11.2" -django-stubs = "^5.2.1" -basedpyright = "^1.30.1" [tool.ruff.lint] select = [ @@ -56,6 +54,3 @@ unfixable = ["B", "SIM"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "inertia.tests.settings" django_find_project = false - -[tool.basedpyright] -typeCheckingMode = "standard" From f5cfca067f2ece471ba2c67a4a56a81598b328b5 Mon Sep 17 00:00:00 2001 From: Gabriel Lopes Rodrigues Date: Tue, 29 Jul 2025 21:41:06 -0300 Subject: [PATCH 3/5] Add type hints using mypy --- inertia/helpers.py | 14 ++-- inertia/http.py | 127 +++++++++++++++++++++------------- inertia/middleware.py | 26 ++++--- inertia/prop_classes.py | 13 ++-- inertia/settings.py | 4 +- inertia/share.py | 18 +++-- inertia/tests/testapp/urls.py | 2 +- inertia/utils.py | 30 ++++---- pyproject.toml | 14 ++++ 9 files changed, 149 insertions(+), 99 deletions(-) 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" + From dd65e9fe56fbc226a21d27d83f1d58bcd88cf60f Mon Sep 17 00:00:00 2001 From: Gabriel Lopes Rodrigues Date: Tue, 29 Jul 2025 21:52:40 -0300 Subject: [PATCH 4/5] Add mypy job on CI --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e31503..75d2aa4 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 + with: + python-version: '3.12' + - run: uvx poetry install --extras=ssr + - run: uvx poetry run mypy . From c26d2bf24ebf766223a9e15c20528517f4ddc027 Mon Sep 17 00:00:00 2001 From: Gabriel Lopes Rodrigues Date: Tue, 29 Jul 2025 23:23:32 -0300 Subject: [PATCH 5/5] Fix missing setup-uv version on CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75d2aa4..3fb1429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv + - uses: astral-sh/setup-uv@v5.4.0 with: python-version: '3.12' - run: uvx poetry install --extras=ssr