diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a579201..cab02d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,13 +24,28 @@ Using the following categories, list your changes in this order: - Nothing (yet) -## [2.0.1]- 2022-10-18 +## [2.1.0] - 2022-11-01 + +## Changed + +- Minimum `channels` version is now `4.0.0`. + +### Fixed + +- Change type hint on `view_to_component` callable to have `request` argument be optional. +- Change type hint on `view_to_component` to represent it as a decorator with paranthesis (ex `@view_to_component(compatibility=True)`) + +## Security + +- Add note to docs about potential information exposure via `view_to_component` when using `compatibility=True`. + +## [2.0.1] - 2022-10-18 ### Fixed -- Ability to use `key=...` parameter on all prefabricated components +- Ability to use `key=...` parameter on all prefabricated components. -## [2.0.0]- 2022-10-17 +## [2.0.0] - 2022-10-17 ### Added @@ -155,7 +170,8 @@ Using the following categories, list your changes in this order: - Support for IDOM within the Django -[unreleased]: https://github.com/idom-team/django-idom/compare/2.0.1...HEAD +[unreleased]: https://github.com/idom-team/django-idom/compare/2.1.0...HEAD +[2.1.0]: https://github.com/idom-team/django-idom/compare/2.0.1...2.1.0 [2.0.1]: https://github.com/idom-team/django-idom/compare/2.0.0...2.0.1 [2.0.0]: https://github.com/idom-team/django-idom/compare/1.2.0...2.0.0 [1.2.0]: https://github.com/idom-team/django-idom/compare/1.1.0...1.2.0 diff --git a/docs/src/contribute/running-tests.md b/docs/src/contribute/running-tests.md index ece8d1a7..f1ff6cc1 100644 --- a/docs/src/contribute/running-tests.md +++ b/docs/src/contribute/running-tests.md @@ -1,15 +1,26 @@ -This repo uses [Nox](https://nox.thea.codes/en/stable/) to run scripts which can be found in `noxfile.py`. For a full test of available scripts run `nox -l`. To run the full test suite simple execute: +This repo uses [Nox](https://nox.thea.codes/en/stable/) to run tests. For a full test of available scripts run `nox -l`. + +If you plan to run tests, you'll need to install the following dependencies first: + +- [Python 3.8+](https://www.python.org/downloads/) +- [Git](https://git-scm.com/downloads) + +Once done, you should clone this repository: + +```bash +git clone https://github.com/idom-team/django-idom.git +cd django-idom +pip install -r ./requirements/test-run.txt --upgrade +``` + +Then, by running the command below you can run the full test suite: ``` nox -s test ``` -If you do not want to run the tests in the background: +Or, if you want to run the tests in the foreground: ``` nox -s test -- --headed ``` - -!!! warning "Most tests will not run on Windows" - - Due to [bugs within Django Channels](https://github.com/django/channels/issues/1207), functional tests are not run on Windows. In order for Windows users to test Django-IDOM functionality, you will need to run tests via [Windows Subsystem for Linux](https://code.visualstudio.com/docs/remote/wsl). diff --git a/docs/src/features/components.md b/docs/src/features/components.md index c8a110f9..eca86f66 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -4,7 +4,7 @@ ## View To Component -Convert any Django view into a IDOM component by usng this decorator. Compatible with sync/async [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/). +Convert any Django view into a IDOM component by usng this decorator. Compatible with [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/). Views can be sync or async. === "components.py" @@ -41,11 +41,42 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible | --- | --- | | `_ViewComponentConstructor` | A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` and returns an IDOM component. | -??? warning "Existing limitations" +??? Warning "Potential information exposure when using `compatibility = True`" + + When using `compatibility` mode, IDOM automatically exposes a URL to your view. + + It is your responsibility to ensure priveledged information is not leaked via this method. + + This can be done via directly writing conditionals into your view, or by adding decorators such as [user_passes_test](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views prior to using `view_to_component`. + + === "Function Based View" + + ```python + ... + + @view_to_component(compatibility=True) + @user_passes_test(lambda u: u.is_superuser) + def example_view(request): + ... + ``` + + === "Class Based View" + + ```python + ... + + @view_to_component(compatibility=True) + @method_decorator(user_passes_test(lambda u: u.is_superuser), name="dispatch") + class ExampleView(TemplateView): + ... + ``` + +??? info "Existing limitations" There are currently several limitations of using `view_to_component` that may be resolved in a future version of `django_idom`. - Requires manual intervention to change request methods beyond `GET`. + - IDOM events cannot conveniently be attached to converted view HTML. - Does not currently load any HTML contained with a `` tag - Has no option to automatically intercept local anchor link (ex. `#!html `) click events @@ -77,9 +108,9 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible ??? question "How do I transform views from external libraries?" - === "components.py" + In order to convert external views, you can utilize `view_to_component` as a function, rather than a decorator. - In order to convert external views, you can utilize `view_to_component` as a function, rather than a decorator. + === "components.py" ```python linenums="1" from idom import component, html @@ -87,77 +118,85 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible from django_idom.components import view_to_component from some_library.views import example_view - converted_view = view_to_component(example_view) + example_vtc = view_to_component(example_view) @component def my_component(): return html.div( - converted_view(), + example_vtc(), ) ``` -??? question "How do I provide `args` and `kwargs` to a view?" +??? question "How do I provide `request`, `args`, and `kwargs` to a view?" - You can use the `args` and `kwargs` parameters to provide positional and keyworded arguments to a view. + **`Request`** + + You can use the `request` parameter to provide the view a custom request object. === "components.py" ```python linenums="1" from idom import component, html - from django.http import HttpResponse + from django.http import HttpResponse, HttpRequest from django_idom.components import view_to_component + example_request = HttpRequest() + example_request.method = "PUT" + @view_to_component - def hello_world_view(request, arg1, arg2, key1=None, key2=None): - return HttpResponse(f"Hello World! {arg1} {arg2} {key1} {key2}") + def hello_world_view(request): + return HttpResponse(f"Hello World! {request.method}") @component def my_component(): return html.div( hello_world_view( - None, # Your request object (optional) - "value_1", - "value_2", - key1="abc", - key2="123", + example_request, ), ) ``` -??? question "How do I provide a custom `request` object to a view?" + --- - You can use the `request` parameter to provide the view a custom request object. + **`args` and `kwargs`** + + You can use the `args` and `kwargs` parameters to provide positional and keyworded arguments to a view. === "components.py" ```python linenums="1" from idom import component, html - from django.http import HttpResponse, HttpRequest + from django.http import HttpResponse from django_idom.components import view_to_component - example_request = HttpRequest() - example_request.method = "PUT" - @view_to_component - def hello_world_view(request): - return HttpResponse(f"Hello World! {request.method}") + def hello_world_view(request, arg1, arg2, key1=None, key2=None): + return HttpResponse(f"Hello World! {arg1} {arg2} {key1} {key2}") @component def my_component(): return html.div( hello_world_view( - example_request, + None, # Your request object (optional) + "value_1", + "value_2", + key1="abc", + key2="123", ), ) ``` -??? question "What is `compatibility` mode?" +??? question "How do I use `strict_parseing`, `compatibility`, and `transforms`?" - For views that rely on HTTP responses other than `GET` (such as `PUT`, `POST`, `PATCH`, etc), you should consider using compatibility mode to render your view within an iframe. + **`strict_parsing`** - Any view can be rendered within compatibility mode. However, the `transforms`, `strict_parsing`, `request`, `args`, and `kwargs` arguments do not apply to compatibility mode. + By default, an exception will be generated if your view's HTML does not perfectly adhere to HTML5. - Please note that by default the iframe is unstyled, and thus won't look pretty until you add some CSS. + However, there are some circumstances where you may not have control over the original HTML, so you may be unable to fix it. Or you may be relying on non-standard HTML tags such as `#!html Hello World `. + + In these scenarios, you may want to rely on best-fit parsing by setting the `strict_parsing` parameter to `False`. + + Note that best-fit parsing is designed to be similar to how web browsers would handle non-standard or broken HTML. === "components.py" @@ -166,9 +205,9 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible from django.http import HttpResponse from django_idom.components import view_to_component - @view_to_component(compatibility=True) + @view_to_component(strict_parsing=False) def hello_world_view(request): - return HttpResponse("Hello World!") + return HttpResponse(" Hello World ") @component def my_component(): @@ -177,13 +216,17 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible ) ``` -??? question "What is `strict_parsing`?" - By default, an exception will be generated if your view's HTML does not perfectly adhere to HTML5. - However, there are some circumstances where you may not have control over the original HTML, so you may be unable to fix it. Or you may be relying on non-standard HTML tags such as `#!html Hello World `. + --- - In these scenarios, you may want to rely on best-fit parsing by setting the `strict_parsing` parameter to `False`. + **`compatibility`** + + For views that rely on HTTP responses other than `GET` (such as `PUT`, `POST`, `PATCH`, etc), you should consider using compatibility mode to render your view within an iframe. + + Any view can be rendered within compatibility mode. However, the `transforms`, `strict_parsing`, `request`, `args`, and `kwargs` arguments do not apply to compatibility mode. + + Please note that by default the iframe is unstyled, and thus won't look pretty until you add some CSS. === "components.py" @@ -192,9 +235,9 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible from django.http import HttpResponse from django_idom.components import view_to_component - @view_to_component(strict_parsing=False) + @view_to_component(compatibility=True) def hello_world_view(request): - return HttpResponse(" Hello World ") + return HttpResponse("Hello World!") @component def my_component(): @@ -203,9 +246,9 @@ Convert any Django view into a IDOM component by usng this decorator. Compatible ) ``` - Note that best-fit parsing is very similar to how web browsers will handle broken HTML. + --- -??? question "What is `transforms`?" + **`transforms`** After your view has been turned into [VDOM](https://idom-docs.herokuapp.com/docs/reference/specifications.html#vdom) (python dictionaries), `view_to_component` will call your `transforms` functions on every VDOM node. diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md index e9e67e39..b35f43ae 100644 --- a/docs/src/features/hooks.md +++ b/docs/src/features/hooks.md @@ -324,12 +324,6 @@ You can expect this hook to provide strings such as `/idom/my_path`. return html.div(my_location) ``` -??? info "This hook's behavior will be changed in a future update" - - This hook will be updated to return the browser's currently active path. This change will come in alongside IDOM URL routing support. - - Check out [idom-team/idom-router#2](https://github.com/idom-team/idom-router/issues/2) for more information. - ??? example "See Interface" **Parameters** @@ -342,6 +336,12 @@ You can expect this hook to provide strings such as `/idom/my_path`. | --- | --- | | `Location` | A object containing the current URL's `pathname` and `search` query. | +??? info "This hook's behavior will be changed in a future update" + + This hook will be updated to return the browser's currently active path. This change will come in alongside IDOM URL routing support. + + Check out [idom-team/idom-router#2](https://github.com/idom-team/idom-router/issues/2) for more information. + ## Use Origin This is a shortcut that returns the Websocket's `origin`. diff --git a/docs/src/getting-started/learn-more.md b/docs/src/getting-started/learn-more.md index e9aa7419..c2d98e1b 100644 --- a/docs/src/getting-started/learn-more.md +++ b/docs/src/getting-started/learn-more.md @@ -8,4 +8,4 @@ Additionally, the vast majority of tutorials/guides you find for React can be ap | Learn More | | --- | -| [Django-IDOM Advanced Usage](../features/components.md){ .md-button } [IDOM Hooks, Events, and More](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html){ .md-button } [Ask Questions on GitHub Discussions](https://github.com/idom-team/idom/discussions){ .md-button .md-button--primary } | +| [Django-IDOM Advanced Usage](../features/components.md){ .md-button } [IDOM Core Documentation](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/index.html){ .md-button } [Ask Questions on GitHub Discussions](https://github.com/idom-team/idom/discussions){ .md-button .md-button--primary } | diff --git a/docs/src/installation/index.md b/docs/src/installation/index.md index 61782278..a59816fc 100644 --- a/docs/src/installation/index.md +++ b/docs/src/installation/index.md @@ -29,7 +29,7 @@ In your settings you'll need to add `django_idom` to [`INSTALLED_APPS`](https:// Django-IDOM requires ASGI in order to use Websockets. - If you haven't enabled ASGI on your **Django project** yet, you'll need to add `channels` to `INSTALLED_APPS` and set your `ASGI_APPLICATION` variable. + If you haven't enabled ASGI on your **Django project** yet, you'll need to install `channels[daphne]`, add `daphne` to `INSTALLED_APPS`, then set your `ASGI_APPLICATION` variable. Read the [Django Channels Docs](https://channels.readthedocs.io/en/stable/installation.html) for more info. @@ -37,7 +37,7 @@ In your settings you'll need to add `django_idom` to [`INSTALLED_APPS`](https:// ```python linenums="1" INSTALLED_APPS = [ - "channels", + "daphne", ... ] ASGI_APPLICATION = "example_project.asgi.application" diff --git a/noxfile.py b/noxfile.py index c0c63855..1e55dabf 100644 --- a/noxfile.py +++ b/noxfile.py @@ -63,7 +63,7 @@ def test_suite(session: Session) -> None: def test_types(session: Session) -> None: install_requirements_file(session, "check-types") install_requirements_file(session, "pkg-deps") - session.run("mypy", "--show-error-codes", "src/django_idom") + session.run("mypy", "--show-error-codes", "src/django_idom", "tests/test_app") @nox.session diff --git a/pyproject.toml b/pyproject.toml index 0103531f..6bf3a9fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,3 +16,4 @@ ignore_missing_imports = true warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true +check_untyped_defs = true diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 8fb71a85..d2329eb6 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,4 +1,4 @@ -channels >=3.0.0 +channels >=4.0.0 idom >=0.40.2, <0.41.0 aiofile >=3.0 typing_extensions diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index 04bb0519..406f04db 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -3,7 +3,7 @@ from django_idom.websocket.paths import IDOM_WEBSOCKET_PATH -__version__ = "2.0.1" +__version__ = "2.1.0" __all__ = [ "IDOM_WEBSOCKET_PATH", "IdomWebsocket", diff --git a/src/django_idom/components.py b/src/django_idom/components.py index 8c9ff3fa..163eb47b 100644 --- a/src/django_idom/components.py +++ b/src/django_idom/components.py @@ -2,10 +2,8 @@ import json import os -from inspect import iscoroutinefunction -from typing import Any, Callable, Dict, Protocol, Sequence +from typing import Any, Callable, Protocol, Sequence, Union, cast, overload -from channels.db import database_sync_to_async from django.contrib.staticfiles.finders import find from django.http import HttpRequest from django.urls import reverse @@ -15,12 +13,12 @@ from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES from django_idom.types import ViewComponentIframe -from django_idom.utils import _generate_obj_name +from django_idom.utils import _generate_obj_name, render_view class _ViewComponentConstructor(Protocol): def __call__( - self, request: HttpRequest | None, *args: Any, **kwargs: Any + self, request: HttpRequest | None = None, *args: Any, **kwargs: Any ) -> ComponentType: ... @@ -33,91 +31,92 @@ def _view_to_component( strict_parsing: bool, request: HttpRequest | None, args: Sequence | None, - kwargs: Dict | None, + kwargs: dict | None, ): - converted_view, set_converted_view = hooks.use_state(None) - args = args or () - kwargs = kwargs or {} - request_obj = request - if not request_obj: - request_obj = HttpRequest() - request_obj.method = "GET" - if compatibility: - dotted_path = f"{view.__module__}.{view.__name__}" # type: ignore[union-attr] - dotted_path = dotted_path.replace("<", "").replace(">", "") - IDOM_VIEW_COMPONENT_IFRAMES[dotted_path] = ViewComponentIframe( - view, args, kwargs - ) + converted_view, set_converted_view = hooks.use_state( + cast(Union[VdomDict, None], None) + ) + _args: Sequence = args or () + _kwargs: dict = kwargs or {} + if request: + _request: HttpRequest = request else: - dotted_path = None + _request = HttpRequest() + _request.method = "GET" # Render the view render within a hook @hooks.use_effect( dependencies=[ - json.dumps(vars(request_obj), default=lambda x: _generate_obj_name(x)), - json.dumps([args, kwargs], default=lambda x: _generate_obj_name(x)), + json.dumps(vars(_request), default=lambda x: _generate_obj_name(x)), + json.dumps([_args, _kwargs], default=lambda x: _generate_obj_name(x)), ] ) async def async_render(): """Render the view in an async hook to avoid blocking the main thread.""" - # Render Check 1: Compatibility mode + # Compatibility mode doesn't require a traditional render if compatibility: - set_converted_view( - html.iframe( - { - "src": reverse("idom:view_to_component", args=[dotted_path]), - "loading": "lazy", - } - ) - ) return - # Render Check 2: Async function view - elif iscoroutinefunction(view): - view_html = await view(request_obj, *args, **kwargs) - - # Render Check 3: Async class view - elif getattr(view, "view_is_async", False): - view_or_template_view = await view.as_view()(request_obj, *args, **kwargs) - if getattr(view_or_template_view, "render", None): # TemplateView - view_html = await view_or_template_view.render() - else: # View - view_html = view_or_template_view - - # Render Check 4: Sync class view - elif getattr(view, "as_view", None): - async_cbv = database_sync_to_async(view.as_view()) - view_or_template_view = await async_cbv(request_obj, *args, **kwargs) - if getattr(view_or_template_view, "render", None): # TemplateView - view_html = await database_sync_to_async(view_or_template_view.render)() - else: # View - view_html = view_or_template_view - - # Render Check 5: Sync function view - else: - view_html = await database_sync_to_async(view)(request_obj, *args, **kwargs) - - # Signal that the view has been rendered + # Render the view + response = await render_view(view, _request, _args, _kwargs) set_converted_view( utils.html_to_vdom( - view_html.content.decode("utf-8").strip(), + response.content.decode("utf-8").strip(), *transforms, strict=strict_parsing, ) ) + # Render in compatibility mode, if needed + if compatibility: + dotted_path = f"{view.__module__}.{view.__name__}".replace("<", "").replace(">", "") # type: ignore + IDOM_VIEW_COMPONENT_IFRAMES[dotted_path] = ViewComponentIframe( + view, _args, _kwargs + ) + return html.iframe( + { + "src": reverse("idom:view_to_component", args=[dotted_path]), + "loading": "lazy", + } + ) + # Return the view if it's been rendered via the `async_render` hook return converted_view +# Type hints for: +# 1. example = view_to_component(my_view, ...) +# 2. @view_to_component +@overload +def view_to_component( + view: Callable | View, + compatibility: bool = False, + transforms: Sequence[Callable[[VdomDict], Any]] = (), + strict_parsing: bool = True, +) -> _ViewComponentConstructor: + ... + + +# Type hints for: +# 1. @view_to_component(...) +@overload +def view_to_component( + view: None = ..., + compatibility: bool = False, + transforms: Sequence[Callable[[VdomDict], Any]] = (), + strict_parsing: bool = True, +) -> Callable[[Callable], _ViewComponentConstructor]: + ... + + # TODO: Might want to intercept href clicks and form submit events. # Form events will probably be accomplished through the upcoming DjangoForm. def view_to_component( - view: Callable | View = None, # type: ignore[assignment] + view: Callable | View | None = None, compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> _ViewComponentConstructor: +) -> _ViewComponentConstructor | Callable[[Callable], _ViewComponentConstructor]: """Converts a Django view to an IDOM component. Keyword Args: diff --git a/src/django_idom/decorators.py b/src/django_idom/decorators.py index 5800d220..6d01c990 100644 --- a/src/django_idom/decorators.py +++ b/src/django_idom/decorators.py @@ -11,7 +11,7 @@ def auth_required( component: Callable | None = None, auth_attribute: str = "is_active", - fallback: ComponentType | VdomDict | None = None, + fallback: ComponentType | Callable | VdomDict | None = None, ) -> Callable: """If the user passes authentication criteria, the decorated component will be rendered. Otherwise, the fallback component will be rendered. diff --git a/src/django_idom/http/views.py b/src/django_idom/http/views.py index 033e9ffd..262634f9 100644 --- a/src/django_idom/http/views.py +++ b/src/django_idom/http/views.py @@ -1,13 +1,12 @@ import os -from inspect import iscoroutinefunction from aiofile import async_open -from channels.db import database_sync_to_async from django.core.exceptions import SuspiciousOperation -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseNotFound from idom.config import IDOM_WED_MODULES_DIR from django_idom.config import IDOM_CACHE, IDOM_VIEW_COMPONENT_IFRAMES +from django_idom.utils import render_view async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: @@ -44,27 +43,10 @@ async def view_to_component_iframe( # Get the view from IDOM_REGISTERED_IFRAMES iframe = IDOM_VIEW_COMPONENT_IFRAMES.get(view_path) if not iframe: - raise ValueError(f"No view registered for component {view_path}.") + return HttpResponseNotFound() - # Render Check 1: Async function view - if iscoroutinefunction(iframe.view): - response = await iframe.view(request, *iframe.args, **iframe.kwargs) # type: ignore[operator] - - # Render Check 2: Async class view - elif getattr(iframe.view, "view_is_async", False): - response = await iframe.view.as_view()(request, *iframe.args, **iframe.kwargs) # type: ignore[misc, union-attr] - - # Render Check 3: Sync class view - elif getattr(iframe.view, "as_view", None): - response = await database_sync_to_async(iframe.view.as_view())( # type: ignore[union-attr] - request, *iframe.args, **iframe.kwargs - ) - - # Render Check 4: Sync function view - else: - response = await database_sync_to_async(iframe.view)( - request, *iframe.args, **iframe.kwargs - ) + # Render the view + response = await render_view(iframe.view, request, iframe.args, iframe.kwargs) # Ensure page can be rendered as an iframe response["X-Frame-Options"] = "SAMEORIGIN" diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py index 6b34145d..8394589e 100644 --- a/src/django_idom/utils.py +++ b/src/django_idom/utils.py @@ -6,10 +6,14 @@ import re from fnmatch import fnmatch from importlib import import_module -from typing import Any, Callable +from inspect import iscoroutinefunction +from typing import Any, Callable, Sequence +from channels.db import database_sync_to_async +from django.http import HttpRequest, HttpResponse from django.template import engines from django.utils.encoding import smart_str +from django.views import View from django_idom.config import IDOM_REGISTERED_COMPONENTS @@ -30,6 +34,44 @@ ) +async def render_view( + view: Callable | View, + request: HttpRequest, + args: Sequence, + kwargs: dict, +) -> HttpResponse: + """Ingests a Django view (class or function) and returns an HTTP response object.""" + # Render Check 1: Async function view + if iscoroutinefunction(view) and callable(view): + response = await view(request, *args, **kwargs) + + # Render Check 2: Async class view + elif getattr(view, "view_is_async", False): + # django-stubs does not support async views yet, so we have to ignore types here + view_or_template_view = await view.as_view()(request, *args, **kwargs) # type: ignore + if getattr(view_or_template_view, "render", None): # TemplateView + response = await view_or_template_view.render() + else: # View + response = view_or_template_view + + # Render Check 3: Sync class view + elif getattr(view, "as_view", None): + # MyPy does not know how to properly interpret this as a `View` type + # And `isinstance(view, View)` does not work due to some weird Django internal shenanigans + async_cbv = database_sync_to_async(view.as_view()) # type: ignore + view_or_template_view = await async_cbv(request, *args, **kwargs) + if getattr(view_or_template_view, "render", None): # TemplateView + response = await database_sync_to_async(view_or_template_view.render)() + else: # View + response = view_or_template_view + + # Render Check 4: Sync function view + else: + response = await database_sync_to_async(view)(request, *args, **kwargs) + + return response + + def _register_component(dotted_path: str) -> None: if dotted_path in IDOM_REGISTERED_COMPONENTS: return @@ -70,7 +112,7 @@ def _get_loaders(self): for e in engines.all(): if hasattr(e, "engine"): template_source_loaders.extend( - e.engine.get_template_loaders(e.engine.loaders) + e.engine.get_template_loaders(e.engine.loaders) # type: ignore ) loaders = [] for loader in template_source_loaders: diff --git a/src/django_idom/websocket/consumer.py b/src/django_idom/websocket/consumer.py index cdc3eb9f..c4c78971 100644 --- a/src/django_idom/websocket/consumer.py +++ b/src/django_idom/websocket/consumer.py @@ -71,7 +71,7 @@ async def _run_dispatch_loop(self): ) return - self._idom_recv_queue = recv_queue = asyncio.Queue() + self._idom_recv_queue = recv_queue = asyncio.Queue() # type: ignore try: await serve_json_patch( Layout(WebsocketContext(component_instance, value=socket)), diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py index aa897829..da45013d 100644 --- a/tests/test_app/admin.py +++ b/tests/test_app/admin.py @@ -1,6 +1,5 @@ from django.contrib import admin - -from .models import TodoItem +from test_app.models import TodoItem @admin.register(TodoItem) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f0a405f0..01e0abd3 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -200,7 +200,7 @@ def todo_list(): elif add_item.error: mutation_status = html.h2(f"Error when adding - {add_item.error}") else: - mutation_status = "" + mutation_status = "" # type: ignore def on_submit(event): if event["key"] == "Enter": @@ -279,7 +279,7 @@ def _render_items(items, toggle_item): @component def view_to_component_sync_func_compatibility(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, + {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_sync_func_compatibility(key="test"), html.hr(), ) @@ -288,7 +288,7 @@ def view_to_component_sync_func_compatibility(): @component def view_to_component_async_func_compatibility(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, + {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_async_func_compatibility(), html.hr(), ) @@ -297,7 +297,7 @@ def view_to_component_async_func_compatibility(): @component def view_to_component_sync_class_compatibility(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, + {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_sync_class_compatibility(), html.hr(), ) @@ -306,7 +306,7 @@ def view_to_component_sync_class_compatibility(): @component def view_to_component_async_class_compatibility(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, + {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_async_class_compatibility(), html.hr(), ) @@ -315,7 +315,7 @@ def view_to_component_async_class_compatibility(): @component def view_to_component_template_view_class_compatibility(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, + {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_template_view_class_compatibility(), html.hr(), ) @@ -328,11 +328,11 @@ def view_to_component_request(): def on_click(_): post_request = HttpRequest() post_request.method = "POST" - set_request(post_request) + set_request(post_request) # type: ignore return html._( html.button( - {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, + {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, # type: ignore "Click me", ), _view_to_component_request(request=request), @@ -348,7 +348,7 @@ def on_click(_): return html._( html.button( - {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, + {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, # type: ignore "Click me", ), _view_to_component_args(None, success), @@ -364,7 +364,7 @@ def on_click(_): return html._( html.button( - {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, + {"id": f"{inspect.currentframe().f_code.co_name}_btn", "onClick": on_click}, # type: ignore "Click me", ), _view_to_component_kwargs(success=success), @@ -376,7 +376,7 @@ def view_to_component_decorator(request): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, + {"test_name": inspect.currentframe().f_code.co_name}, # type: ignore ) @@ -385,5 +385,5 @@ def view_to_component_decorator_args(request): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, + {"test_name": inspect.currentframe().f_code.co_name}, # type: ignore ) diff --git a/tests/test_app/migrations/0001_initial.py b/tests/test_app/migrations/0001_initial.py index e05fedd6..69498ba5 100644 --- a/tests/test_app/migrations/0001_initial.py +++ b/tests/test_app/migrations/0001_initial.py @@ -7,16 +7,23 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] # type: ignore operations = [ migrations.CreateModel( - name='TodoItem', + name="TodoItem", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('done', models.BooleanField()), - ('text', models.CharField(max_length=1000)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("done", models.BooleanField()), + ("text", models.CharField(max_length=1000)), ], ), ] diff --git a/tests/test_app/models.py b/tests/test_app/models.py index 93d3efd2..7b6fc0f8 100644 --- a/tests/test_app/models.py +++ b/tests/test_app/models.py @@ -2,5 +2,5 @@ class TodoItem(models.Model): - done = models.BooleanField() - text = models.CharField(max_length=1000) + done = models.BooleanField() # type: ignore + text = models.CharField(max_length=1000) # type: ignore diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index 8f5ff109..6cb4dc82 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -30,6 +30,7 @@ # Application definition INSTALLED_APPS = [ + "daphne", # Overrides `runserver` command with an ASGI server "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 3038eb46..0c996ac4 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -27,7 +27,7 @@ class AccessUser: has_module_perms = has_perm = __getattr__ = lambda s, *a, **kw: True -admin.site.has_permission = lambda r: setattr(r, "user", AccessUser()) or True +admin.site.has_permission = lambda r: setattr(r, "user", AccessUser()) or True # type: ignore urlpatterns = [ path("", base_template), diff --git a/tests/test_app/views.py b/tests/test_app/views.py index cec726f5..2d16ae97 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -6,15 +6,14 @@ def base_template(request): - context = {} - return render(request, "base.html", context) + return render(request, "base.html", {}) def view_to_component_sync_func(request): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, + {"test_name": inspect.currentframe().f_code.co_name}, # type:ignore ) @@ -22,7 +21,7 @@ async def view_to_component_async_func(request): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, + {"test_name": inspect.currentframe().f_code.co_name}, # type:ignore ) @@ -55,7 +54,7 @@ def view_to_component_sync_func_compatibility(request): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, + {"test_name": inspect.currentframe().f_code.co_name}, # type:ignore ) @@ -63,7 +62,7 @@ async def view_to_component_async_func_compatibility(request): return await database_sync_to_async(render)( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, + {"test_name": inspect.currentframe().f_code.co_name}, # type:ignore ) @@ -97,7 +96,7 @@ def view_to_component_script(request): request, "view_to_component_script.html", { - "test_name": inspect.currentframe().f_code.co_name, + "test_name": inspect.currentframe().f_code.co_name, # type:ignore "status": "false", }, ) @@ -108,14 +107,14 @@ def view_to_component_request(request): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, + {"test_name": inspect.currentframe().f_code.co_name}, # type:ignore ) return render( request, "view_to_component.html", { - "test_name": inspect.currentframe().f_code.co_name, + "test_name": inspect.currentframe().f_code.co_name, # type:ignore "status": "false", "success": "false", }, @@ -126,7 +125,10 @@ def view_to_component_args(request, success): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name, "status": success}, + { + "test_name": inspect.currentframe().f_code.co_name, # type:ignore + "status": success, + }, ) @@ -134,5 +136,8 @@ def view_to_component_kwargs(request, success="false"): return render( request, "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name, "status": success}, + { + "test_name": inspect.currentframe().f_code.co_name, # type:ignore + "status": success, + }, )