From 30a481ff23a69db3e358c93a2f70f4ec6a1b56c4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 22 Jul 2021 00:58:30 -0700 Subject: [PATCH 01/36] create barebones templatetags --- src/django_idom/static/js/idom.js | 2 ++ src/django_idom/templates/idom/head_content.html | 2 ++ src/django_idom/templates/idom/root.html | 4 ++++ src/django_idom/templatetags/__init__.py | 0 src/django_idom/templatetags/tags.py | 15 +++++++++++++++ 5 files changed, 23 insertions(+) create mode 100644 src/django_idom/static/js/idom.js create mode 100644 src/django_idom/templates/idom/head_content.html create mode 100644 src/django_idom/templates/idom/root.html create mode 100644 src/django_idom/templatetags/__init__.py create mode 100644 src/django_idom/templatetags/tags.py diff --git a/src/django_idom/static/js/idom.js b/src/django_idom/static/js/idom.js new file mode 100644 index 00000000..297f9808 --- /dev/null +++ b/src/django_idom/static/js/idom.js @@ -0,0 +1,2 @@ +// Build.js is going to need to be moved here via your deployment scripts +// This will allow developers to import IDOM the Django way \ No newline at end of file diff --git a/src/django_idom/templates/idom/head_content.html b/src/django_idom/templates/idom/head_content.html new file mode 100644 index 00000000..1140b2ab --- /dev/null +++ b/src/django_idom/templates/idom/head_content.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/django_idom/templates/idom/root.html b/src/django_idom/templates/idom/root.html new file mode 100644 index 00000000..fbd81269 --- /dev/null +++ b/src/django_idom/templates/idom/root.html @@ -0,0 +1,4 @@ + +
\ No newline at end of file diff --git a/src/django_idom/templatetags/__init__.py b/src/django_idom/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/django_idom/templatetags/tags.py b/src/django_idom/templatetags/tags.py new file mode 100644 index 00000000..9f8ffd52 --- /dev/null +++ b/src/django_idom/templatetags/tags.py @@ -0,0 +1,15 @@ +from django import template +from django.urls import reverse + + +register = template.Library() + +# Template tag that renders the IDOM scripts +@register.inclusion_tag("idom/head_content.html") +def idom_scripts(): + pass + +# Template tag that renders an empty idom root object +@register.inclusion_tag("idom/root.html") +def idom_root(html_id): + return {"html_id": html_id} From 8b0b363b1deb7641b25b52d309688c27d3f14106 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 22 Jul 2021 01:09:28 -0700 Subject: [PATCH 02/36] styling fix attempt re-adding tag stuff styling fix --- src/django_idom/templatetags/{tags.py => idom.py} | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename src/django_idom/templatetags/{tags.py => idom.py} (91%) diff --git a/src/django_idom/templatetags/tags.py b/src/django_idom/templatetags/idom.py similarity index 91% rename from src/django_idom/templatetags/tags.py rename to src/django_idom/templatetags/idom.py index 9f8ffd52..268b73b0 100644 --- a/src/django_idom/templatetags/tags.py +++ b/src/django_idom/templatetags/idom.py @@ -1,14 +1,15 @@ from django import template -from django.urls import reverse register = template.Library() + # Template tag that renders the IDOM scripts @register.inclusion_tag("idom/head_content.html") def idom_scripts(): pass + # Template tag that renders an empty idom root object @register.inclusion_tag("idom/root.html") def idom_root(html_id): From 4bcd9bfaea06e8c4e2136a97b0715bad324e3a77 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 25 Jul 2021 20:06:20 -0700 Subject: [PATCH 03/36] idom_view --- src/django_idom/templatetags/idom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 268b73b0..ac0a3686 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -12,5 +12,5 @@ def idom_scripts(): # Template tag that renders an empty idom root object @register.inclusion_tag("idom/root.html") -def idom_root(html_id): +def idom_view(html_id): return {"html_id": html_id} From f8f094ee1abeee6d3df835b7d03bee504739233b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 25 Jul 2021 21:28:42 -0700 Subject: [PATCH 04/36] skeleton of django installable app --- .gitignore | 3 + MANIFEST.in | 0 setup.py | 72 ++++++++++++++++--- src/django_idom/app_settings.py | 3 + src/django_idom/static/js/idom.js | 2 - .../templates/idom/head_content.html | 2 +- src/django_idom/templates/idom/root.html | 6 +- src/django_idom/templates/idom/view.html | 5 ++ src/django_idom/templatetags/idom.py | 9 +-- src/django_idom/view_loader.py | 29 ++++++++ src/django_idom/websocket_consumer.py | 34 +++++++-- {tests => src}/js/.gitignore | 0 {tests => src}/js/package-lock.json | 0 {tests => src}/js/package.json | 4 +- {tests => src}/js/rollup.config.js | 2 +- src/js/src/index.js | 18 +++++ tests/js/src/index.js | 13 ---- tests/test_app/asgi.py | 9 ++- tests/test_app/idom/__init__.py | 0 tests/test_app/idom/button.py | 6 ++ tests/test_app/idom/hello_world.py | 0 tests/test_app/settings.py | 1 + 22 files changed, 177 insertions(+), 41 deletions(-) create mode 100644 MANIFEST.in create mode 100644 src/django_idom/app_settings.py delete mode 100644 src/django_idom/static/js/idom.js create mode 100644 src/django_idom/templates/idom/view.html create mode 100644 src/django_idom/view_loader.py rename {tests => src}/js/.gitignore (100%) rename {tests => src}/js/package-lock.json (100%) rename {tests => src}/js/package.json (90%) rename {tests => src}/js/rollup.config.js (93%) create mode 100644 src/js/src/index.js delete mode 100644 tests/js/src/index.js create mode 100644 tests/test_app/idom/__init__.py create mode 100644 tests/test_app/idom/button.py create mode 100644 tests/test_app/idom/hello_world.py diff --git a/.gitignore b/.gitignore index 434278f2..1a06cffb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Django IDOM Build Artifacts +src/django_idom/static/js + # Django # logs *.log diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..e69de29b diff --git a/setup.py b/setup.py index dbdbbf67..adeb5937 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,25 @@ -import os +from __future__ import print_function + +import pipes +import shutil +import subprocess import sys +import traceback +from distutils import log +from distutils.command.build import build # type: ignore +from distutils.command.sdist import sdist # type: ignore from pathlib import Path from setuptools import find_packages, setup +from setuptools.command.develop import develop + + +if sys.platform == "win32": + from subprocess import list2cmdline +else: + + def list2cmdline(cmd_list): + return " ".join(map(pipes.quote, cmd_list)) # the name of the project @@ -31,6 +48,7 @@ "license": "MIT", "platforms": "Linux, Mac OS X, Windows", "keywords": ["interactive", "widgets", "DOM", "React"], + "include_package_data": True, "zip_safe": False, "classifiers": [ "Framework :: Django", @@ -52,14 +70,14 @@ # Library Version # ----------------------------------------------------------------------------- -with open(os.path.join(package_dir, "__init__.py")) as f: - for line in f.read().split("\n"): - if line.startswith("__version__ = "): - package["version"] = eval(line.split("=", 1)[1]) - break - else: - print("No version found in %s/__init__.py" % package_dir) - sys.exit(1) + +for line in (package_dir / "__init__.py").read_text().split("\n"): + if line.startswith("__version__ = "): + package["version"] = eval(line.split("=", 1)[1]) + break +else: + print("No version found in %s/__init__.py" % package_dir) + sys.exit(1) # ----------------------------------------------------------------------------- @@ -87,6 +105,42 @@ package["long_description_content_type"] = "text/markdown" +# ---------------------------------------------------------------------------- +# Build Javascript +# ---------------------------------------------------------------------------- + + +def build_javascript_first(cls): + class Command(cls): + def run(self): + log.info("Installing Javascript...") + try: + js_dir = str(src_dir / "js") + npm = shutil.which("npm") # this is required on windows + if npm is None: + raise RuntimeError("NPM is not installed.") + for args in (f"{npm} install", f"{npm} run build"): + args_list = args.split() + log.info(f"> {list2cmdline(args_list)}") + subprocess.run(args_list, cwd=js_dir, check=True) + except Exception: + log.error("Failed to install Javascript") + log.error(traceback.format_exc()) + raise + else: + log.info("Successfully installed Javascript") + super().run() + + return Command + + +package["cmdclass"] = { + "sdist": build_javascript_first(sdist), + "build": build_javascript_first(build), + "develop": build_javascript_first(develop), +} + + # ----------------------------------------------------------------------------- # Install It # ----------------------------------------------------------------------------- diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py new file mode 100644 index 00000000..074e9cb1 --- /dev/null +++ b/src/django_idom/app_settings.py @@ -0,0 +1,3 @@ +from django.conf import settings + +IDOM_WEBSOCKET_URL = getattr(settings, "IDOM_WEBSOCKET_URL", "_idom/") diff --git a/src/django_idom/static/js/idom.js b/src/django_idom/static/js/idom.js deleted file mode 100644 index 297f9808..00000000 --- a/src/django_idom/static/js/idom.js +++ /dev/null @@ -1,2 +0,0 @@ -// Build.js is going to need to be moved here via your deployment scripts -// This will allow developers to import IDOM the Django way \ No newline at end of file diff --git a/src/django_idom/templates/idom/head_content.html b/src/django_idom/templates/idom/head_content.html index 1140b2ab..27a0a4b4 100644 --- a/src/django_idom/templates/idom/head_content.html +++ b/src/django_idom/templates/idom/head_content.html @@ -1,2 +1,2 @@ - \ No newline at end of file + diff --git a/src/django_idom/templates/idom/root.html b/src/django_idom/templates/idom/root.html index fbd81269..958b1669 100644 --- a/src/django_idom/templates/idom/root.html +++ b/src/django_idom/templates/idom/root.html @@ -1,4 +1,8 @@ -
\ No newline at end of file +
+ diff --git a/src/django_idom/templates/idom/view.html b/src/django_idom/templates/idom/view.html new file mode 100644 index 00000000..8280a871 --- /dev/null +++ b/src/django_idom/templates/idom/view.html @@ -0,0 +1,5 @@ +
+ diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index ac0a3686..35763778 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -1,5 +1,7 @@ from django import template +from django_idom.app_settings import IDOM_WEBSOCKET_URL + register = template.Library() @@ -10,7 +12,6 @@ def idom_scripts(): pass -# Template tag that renders an empty idom root object -@register.inclusion_tag("idom/root.html") -def idom_view(html_id): - return {"html_id": html_id} +@register.inclusion_tag("idom/view.html") +def idom_view(view_id): + return {"idom_websocket_url": IDOM_WEBSOCKET_URL, "view_id": view_id} diff --git a/src/django_idom/view_loader.py b/src/django_idom/view_loader.py new file mode 100644 index 00000000..f1eec9f9 --- /dev/null +++ b/src/django_idom/view_loader.py @@ -0,0 +1,29 @@ +import logging +from importlib import import_module +from pathlib import Path +from typing import Dict + +from django.conf import settings +from idom.core.proto import ComponentConstructor + + +ALL_VIEWS: Dict[str, ComponentConstructor] = {} +logger = logging.getLogger(__name__) + +for app_name in settings.INSTALLED_APPS: + app_mod = import_module(app_name) + if not hasattr(app_mod, "idom"): + continue + + for idom_view_path in Path(app_mod.__file__).iterdir(): + if idom_view_path.suffix == ".py" and idom_view_path.is_file(): + idom_view_mod_name = ".".join([app_name, "idom", idom_view_path.stem]) + idom_view_mod = import_module(idom_view_mod_name) + + if hasattr(idom_view_mod, "Root") and callable(idom_view_mod.Root): + ALL_VIEWS[idom_view_mod_name] = idom_view_mod.Root + else: + logger.warning( + f"Expected module {idom_view_mod_name} to expose a 'Root' " + " attribute that is an IDOM component." + ) diff --git a/src/django_idom/websocket_consumer.py b/src/django_idom/websocket_consumer.py index 2fae2869..329e9ddd 100644 --- a/src/django_idom/websocket_consumer.py +++ b/src/django_idom/websocket_consumer.py @@ -1,20 +1,22 @@ """Anything used to construct a websocket endpoint""" import asyncio +import logging from typing import Any from channels.generic.websocket import AsyncJsonWebsocketConsumer from idom.core.dispatcher import dispatch_single_view from idom.core.layout import Layout, LayoutEvent -from idom.core.proto import ComponentConstructor + +from .view_loader import ALL_VIEWS + + +logger = logging.getLogger(__name__) class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" - def __init__( - self, component: ComponentConstructor, *args: Any, **kwargs: Any - ) -> None: - self._idom_component_constructor = component + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) async def connect(self) -> None: @@ -32,10 +34,30 @@ async def receive_json(self, content: Any, **kwargs: Any) -> None: await self._idom_recv_queue.put(LayoutEvent(**content)) async def _run_dispatch_loop(self): + # get the URL parameters and grab the view ID + view_id = ... + # get component ags from the URL params too + component_args = ... + + if view_id not in ALL_VIEWS: + logger.warning(f"Uknown IDOM view ID {view_id}") + return + + component_constructor = ALL_VIEWS[view_id] + + try: + component_instance = component_constructor(*component_args) + except Exception: + logger.exception( + f"Failed to construct component {component_constructor} " + f"with parameters {component_args}" + ) + return + self._idom_recv_queue = recv_queue = asyncio.Queue() try: await dispatch_single_view( - Layout(self._idom_component_constructor()), + Layout(component_instance), self.send_json, recv_queue.get, ) diff --git a/tests/js/.gitignore b/src/js/.gitignore similarity index 100% rename from tests/js/.gitignore rename to src/js/.gitignore diff --git a/tests/js/package-lock.json b/src/js/package-lock.json similarity index 100% rename from tests/js/package-lock.json rename to src/js/package-lock.json diff --git a/tests/js/package.json b/src/js/package.json similarity index 90% rename from tests/js/package.json rename to src/js/package.json index 3844cf32..832480a8 100644 --- a/tests/js/package.json +++ b/src/js/package.json @@ -1,6 +1,6 @@ { - "name": "tests", - "version": "1.0.0", + "name": "django-idom-client", + "version": "0.0.1", "description": "test app for idom_django websocket server", "main": "src/index.js", "files": [ diff --git a/tests/js/rollup.config.js b/src/js/rollup.config.js similarity index 93% rename from tests/js/rollup.config.js rename to src/js/rollup.config.js index ad597a61..2c1bb55f 100644 --- a/tests/js/rollup.config.js +++ b/src/js/rollup.config.js @@ -7,7 +7,7 @@ const { PRODUCTION } = process.env; export default { input: "src/index.js", output: { - file: "../test_app/static/build.js", + file: "../django_idom/static/js/idom.js", format: "esm", }, plugins: [ diff --git a/src/js/src/index.js b/src/js/src/index.js new file mode 100644 index 00000000..33df4ba6 --- /dev/null +++ b/src/js/src/index.js @@ -0,0 +1,18 @@ +import { mountLayoutWithWebSocket } from "idom-client-react"; + + +// Set up a websocket at the base endpoint +let LOCATION = window.location; +let WS_PROTOCOL = ""; +if (LOCATION.protocol == "https:") { + WS_PROTOCOL = "wss://"; +} else { + WS_PROTOCOL = "ws://"; +} +let WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/"; + + +export function mountViewToElement(idomWebsocketUrl, viewId) { + const fullWebsocketUrl = WS_ENDPOINT_URL + idomWebsocketUrl + mountLayoutWithWebSocket(document.getElementById(viewId), fullWebsocketUrl); +} diff --git a/tests/js/src/index.js b/tests/js/src/index.js deleted file mode 100644 index 613515dc..00000000 --- a/tests/js/src/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import { mountLayoutWithWebSocket } from "idom-client-react"; - -// Set up a websocket at the base endpoint -let LOCATION = window.location; -let WS_PROTOCOL = ""; -if (LOCATION.protocol == "https:") { - WS_PROTOCOL = "wss://"; -} else { - WS_PROTOCOL = "ws://"; -} -let WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host; - -mountLayoutWithWebSocket(document.getElementById("mount"), WS_ENDPOINT_URL); diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index 113b147f..c0355bef 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -9,10 +9,12 @@ import os +from django.conf import settings from django.conf.urls import url from django.core.asgi import get_asgi_application -from django_idom import IdomAsyncWebSocketConsumer # noqa: E402 +from django_idom import IdomAsyncWebSocketConsumer +from django_idom.app_settings import IDOM_WEBSOCKET_URL # noqa: E402 from .views import Root @@ -25,11 +27,14 @@ from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 +IDOM_WEBSOCKET_URL = settings.IDOM_WEBSOCKET_URL + + application = ProtocolTypeRouter( { "http": http_asgi_app, "websocket": URLRouter( - [url("", IdomAsyncWebSocketConsumer.as_asgi(component=Root))] + [url(IDOM_WEBSOCKET_URL, IdomAsyncWebSocketConsumer.as_asgi())] ), } ) diff --git a/tests/test_app/idom/__init__.py b/tests/test_app/idom/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/idom/button.py b/tests/test_app/idom/button.py new file mode 100644 index 00000000..54866181 --- /dev/null +++ b/tests/test_app/idom/button.py @@ -0,0 +1,6 @@ +import idom + + +@idom.component +def Root(): + ... diff --git a/tests/test_app/idom/hello_world.py b/tests/test_app/idom/hello_world.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index 73eda0fa..ac7d6ab9 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -37,6 +37,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "channels", # Websocket library + "django_idom", # Django compatible IDOM client "test_app", # This test application ] MIDDLEWARE = [ From 937da205da8f8555a232fd5a4b486dbdfc40d15e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 26 Jul 2021 01:32:57 -0700 Subject: [PATCH 05/36] get test_app runserver to work --- MANIFEST.in | 3 ++ noxfile.py | 1 - src/django_idom/__init__.py | 7 ++- src/django_idom/app_components.py | 50 +++++++++++++++++++ src/django_idom/app_settings.py | 3 ++ src/django_idom/templates/idom/root.html | 8 --- src/django_idom/templates/idom/view.html | 13 +++-- src/django_idom/templatetags/idom.py | 13 +++-- src/django_idom/view_loader.py | 29 ----------- src/django_idom/websocket_consumer.py | 36 +++++++++---- src/js/package-lock.json | 8 +-- src/js/src/index.js | 6 +-- tests/.gitignore | 2 +- tests/test_app/asgi.py | 14 +----- tests/test_app/idom.py | 24 +++++++++ tests/test_app/idom/__init__.py | 0 tests/test_app/idom/button.py | 6 --- tests/test_app/idom/hello_world.py | 0 tests/test_app/management/__init__.py | 0 .../test_app/management/commands/__init__.py | 0 .../test_app/management/commands/build_js.py | 16 ------ tests/test_app/templates/base.html | 6 +-- tests/test_app/views.py | 26 ---------- 23 files changed, 144 insertions(+), 127 deletions(-) create mode 100644 src/django_idom/app_components.py delete mode 100644 src/django_idom/templates/idom/root.html delete mode 100644 src/django_idom/view_loader.py create mode 100644 tests/test_app/idom.py delete mode 100644 tests/test_app/idom/__init__.py delete mode 100644 tests/test_app/idom/button.py delete mode 100644 tests/test_app/idom/hello_world.py delete mode 100644 tests/test_app/management/__init__.py delete mode 100644 tests/test_app/management/commands/__init__.py delete mode 100644 tests/test_app/management/commands/build_js.py diff --git a/MANIFEST.in b/MANIFEST.in index e69de29b..1faaf63a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include src/django_idom/static/js/idom.js +include src/django_idom/templates/head_content.html +include src/django_idom/templates/view.html diff --git a/noxfile.py b/noxfile.py index 4a943345..12ee62fa 100644 --- a/noxfile.py +++ b/noxfile.py @@ -52,7 +52,6 @@ def test_suite(session: Session) -> None: session.chdir(HERE / "tests") session.env["IDOM_DEBUG_MODE"] = "1" session.env["SELENIUM_HEADLESS"] = str(int("--headless" in session.posargs)) - session.run("python", "manage.py", "build_js") session.run("python", "manage.py", "test") diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index 60932c7a..cc31e4dc 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,5 +1,8 @@ -from .websocket_consumer import IdomAsyncWebSocketConsumer +from .websocket_consumer import ( + IdomAsyncWebSocketConsumer, + django_idom_websocket_consumer_url, +) __version__ = "0.0.1" -__all__ = ["IdomAsyncWebSocketConsumer"] +__all__ = ["IdomAsyncWebSocketConsumer", "django_idom_websocket_consumer_url"] diff --git a/src/django_idom/app_components.py b/src/django_idom/app_components.py new file mode 100644 index 00000000..f41e760f --- /dev/null +++ b/src/django_idom/app_components.py @@ -0,0 +1,50 @@ +import logging +from importlib import import_module +from typing import Dict + +from django.conf import settings +from idom.core.proto import ComponentConstructor + + +logger = logging.getLogger(__name__) +_LOADED_COMPONENTS: Dict[str, ComponentConstructor] = {} + + +def get_component(name: str) -> ComponentConstructor: + return _LOADED_COMPONENTS[name] + + +def has_component(name: str) -> bool: + return name in _LOADED_COMPONENTS + + +for app_mod_name in settings.INSTALLED_APPS: + idom_mod_name = f"{app_mod_name}.idom" + + try: + idom_mod = import_module(idom_mod_name) + except ImportError: + logger.debug(f"Skipping {idom_mod_name!r} - does not exist") + continue + + if not hasattr(idom_mod, "__all__"): + logger.warning( + f"'django_idom' expected module {idom_mod_name!r} to have an " + "'__all__' attribute that lists its publically available components." + ) + continue + + for component_name in idom_mod.__all__: + try: + component_constructor = getattr(idom_mod, component_name) + except AttributeError: + logger.warning( + f"Module {idom_mod_name!r} has no attribute {component_name!r}" + ) + continue + + if not callable(component_constructor): + logger.warning(f"'{idom_mod_name}.{component_name}' is not a component") + continue + + _LOADED_COMPONENTS[f"{app_mod_name}.{component_name}"] = component_constructor diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index 074e9cb1..6c586004 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -1,3 +1,6 @@ from django.conf import settings + IDOM_WEBSOCKET_URL = getattr(settings, "IDOM_WEBSOCKET_URL", "_idom/") +if not IDOM_WEBSOCKET_URL.endswith("/"): + IDOM_WEBSOCKET_URL += "/" diff --git a/src/django_idom/templates/idom/root.html b/src/django_idom/templates/idom/root.html deleted file mode 100644 index 958b1669..00000000 --- a/src/django_idom/templates/idom/root.html +++ /dev/null @@ -1,8 +0,0 @@ - -
- diff --git a/src/django_idom/templates/idom/view.html b/src/django_idom/templates/idom/view.html index 8280a871..02cc17ec 100644 --- a/src/django_idom/templates/idom/view.html +++ b/src/django_idom/templates/idom/view.html @@ -1,5 +1,12 @@ -
+{% load static %} +
diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 35763778..3f62edba 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from django import template from django_idom.app_settings import IDOM_WEBSOCKET_URL @@ -8,10 +10,15 @@ # Template tag that renders the IDOM scripts @register.inclusion_tag("idom/head_content.html") -def idom_scripts(): +def idom_head(): pass @register.inclusion_tag("idom/view.html") -def idom_view(view_id): - return {"idom_websocket_url": IDOM_WEBSOCKET_URL, "view_id": view_id} +def idom_view(view_id, view_params=""): + return { + "idom_websocket_url": IDOM_WEBSOCKET_URL, + "idom_mount_uuid": uuid4().hex, + "idom_view_id": view_id, + "idom_view_params": view_params, + } diff --git a/src/django_idom/view_loader.py b/src/django_idom/view_loader.py deleted file mode 100644 index f1eec9f9..00000000 --- a/src/django_idom/view_loader.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging -from importlib import import_module -from pathlib import Path -from typing import Dict - -from django.conf import settings -from idom.core.proto import ComponentConstructor - - -ALL_VIEWS: Dict[str, ComponentConstructor] = {} -logger = logging.getLogger(__name__) - -for app_name in settings.INSTALLED_APPS: - app_mod = import_module(app_name) - if not hasattr(app_mod, "idom"): - continue - - for idom_view_path in Path(app_mod.__file__).iterdir(): - if idom_view_path.suffix == ".py" and idom_view_path.is_file(): - idom_view_mod_name = ".".join([app_name, "idom", idom_view_path.stem]) - idom_view_mod = import_module(idom_view_mod_name) - - if hasattr(idom_view_mod, "Root") and callable(idom_view_mod.Root): - ALL_VIEWS[idom_view_mod_name] = idom_view_mod.Root - else: - logger.warning( - f"Expected module {idom_view_mod_name} to expose a 'Root' " - " attribute that is an IDOM component." - ) diff --git a/src/django_idom/websocket_consumer.py b/src/django_idom/websocket_consumer.py index 329e9ddd..841dc4ee 100644 --- a/src/django_idom/websocket_consumer.py +++ b/src/django_idom/websocket_consumer.py @@ -2,17 +2,35 @@ import asyncio import logging from typing import Any +from urllib.parse import parse_qsl from channels.generic.websocket import AsyncJsonWebsocketConsumer +from django.urls import path from idom.core.dispatcher import dispatch_single_view from idom.core.layout import Layout, LayoutEvent -from .view_loader import ALL_VIEWS +from .app_components import get_component, has_component +from .app_settings import IDOM_WEBSOCKET_URL logger = logging.getLogger(__name__) +def django_idom_websocket_consumer_url(*args, **kwargs): + """Return a URL resolver for :class:`IdomAsyncWebSocketConsumer` + + While this is relatively uncommon in most Django apps, because the URL of the + websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need + to allow users to configure the URL themselves + """ + return path( + IDOM_WEBSOCKET_URL + "/", + IdomAsyncWebSocketConsumer.as_asgi(), + *args, + **kwargs, + ) + + class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" @@ -34,23 +52,21 @@ async def receive_json(self, content: Any, **kwargs: Any) -> None: await self._idom_recv_queue.put(LayoutEvent(**content)) async def _run_dispatch_loop(self): - # get the URL parameters and grab the view ID - view_id = ... - # get component ags from the URL params too - component_args = ... + view_id = self.scope["url_route"]["kwargs"]["view_id"] - if view_id not in ALL_VIEWS: - logger.warning(f"Uknown IDOM view ID {view_id}") + if not has_component(view_id): + logger.warning(f"Uknown IDOM view ID {view_id!r}") return - component_constructor = ALL_VIEWS[view_id] + component_constructor = get_component(view_id) + component_kwargs = dict(parse_qsl(self.scope["query_string"])) try: - component_instance = component_constructor(*component_args) + component_instance = component_constructor(**component_kwargs) except Exception: logger.exception( f"Failed to construct component {component_constructor} " - f"with parameters {component_args}" + f"with parameters {component_kwargs}" ) return diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 6639bf96..72e7483c 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -1,12 +1,12 @@ { - "name": "tests", - "version": "1.0.0", + "name": "django-idom-client", + "version": "0.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "tests", - "version": "1.0.0", + "name": "django-idom-client", + "version": "0.0.1", "dependencies": { "idom-client-react": "^0.8.2" }, diff --git a/src/js/src/index.js b/src/js/src/index.js index 33df4ba6..a8a16bb3 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -12,7 +12,7 @@ if (LOCATION.protocol == "https:") { let WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/"; -export function mountViewToElement(idomWebsocketUrl, viewId) { - const fullWebsocketUrl = WS_ENDPOINT_URL + idomWebsocketUrl - mountLayoutWithWebSocket(document.getElementById(viewId), fullWebsocketUrl); +export function mountViewToElement(mountPoint, idomWebsocketUrl, viewId, queryParams) { + const fullWebsocketUrl = WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/"; + mountLayoutWithWebSocket(mountPoint, fullWebsocketUrl); } diff --git a/tests/.gitignore b/tests/.gitignore index bf4a6d58..60615839 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1 +1 @@ -test_app/static/build.js +*.sqlite3 diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index c0355bef..e9faad98 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -9,14 +9,9 @@ import os -from django.conf import settings -from django.conf.urls import url from django.core.asgi import get_asgi_application -from django_idom import IdomAsyncWebSocketConsumer -from django_idom.app_settings import IDOM_WEBSOCKET_URL # noqa: E402 - -from .views import Root +from django_idom import django_idom_websocket_consumer_url os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") @@ -27,14 +22,9 @@ from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 -IDOM_WEBSOCKET_URL = settings.IDOM_WEBSOCKET_URL - - application = ProtocolTypeRouter( { "http": http_asgi_app, - "websocket": URLRouter( - [url(IDOM_WEBSOCKET_URL, IdomAsyncWebSocketConsumer.as_asgi())] - ), + "websocket": URLRouter([django_idom_websocket_consumer_url()]), } ) diff --git a/tests/test_app/idom.py b/tests/test_app/idom.py new file mode 100644 index 00000000..1f6b0d8d --- /dev/null +++ b/tests/test_app/idom.py @@ -0,0 +1,24 @@ +import idom + + +__all__ = "HelloWorld", "Button" + + +@idom.component +def HelloWorld(): + return idom.html.h1({"id": "hello-world"}, "Hello World!") + + +@idom.component +def Button(): + count, set_count = idom.hooks.use_state(0) + return idom.html.div( + idom.html.button( + {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, + "Click me!", + ), + idom.html.p( + {"id": "counter-num", "data-count": count}, + f"Current count is: {count}", + ), + ) diff --git a/tests/test_app/idom/__init__.py b/tests/test_app/idom/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_app/idom/button.py b/tests/test_app/idom/button.py deleted file mode 100644 index 54866181..00000000 --- a/tests/test_app/idom/button.py +++ /dev/null @@ -1,6 +0,0 @@ -import idom - - -@idom.component -def Root(): - ... diff --git a/tests/test_app/idom/hello_world.py b/tests/test_app/idom/hello_world.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_app/management/__init__.py b/tests/test_app/management/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_app/management/commands/__init__.py b/tests/test_app/management/commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_app/management/commands/build_js.py b/tests/test_app/management/commands/build_js.py deleted file mode 100644 index 61af5ed9..00000000 --- a/tests/test_app/management/commands/build_js.py +++ /dev/null @@ -1,16 +0,0 @@ -import subprocess -from pathlib import Path - -from django.core.management.base import BaseCommand - - -HERE = Path(__file__).parent -JS_DIR = HERE.parent.parent.parent / "js" - - -class Command(BaseCommand): - help = "Build javascript source for test app" - - def handle(self, *args, **options): - subprocess.run(["npm", "install"], cwd=JS_DIR, check=True) - subprocess.run(["npm", "run", "build"], cwd=JS_DIR, check=True) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index d2a88607..e3180b1d 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static %} {% load idom %} @@ -15,7 +15,7 @@

IDOM Test Page

-
- +
{% idom_view "test_app.HelloWorld" %}
+
{% idom_view "test_app.Button" %}
diff --git a/tests/test_app/views.py b/tests/test_app/views.py index a996eb1e..eb73ee53 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,4 +1,3 @@ -import idom from django.http import HttpResponse from django.template import loader @@ -7,28 +6,3 @@ def base_template(request): template = loader.get_template("base.html") context = {} return HttpResponse(template.render(context, request)) - - -@idom.component -def Root(): - return idom.html.div(HelloWorld(), Counter()) - - -@idom.component -def HelloWorld(): - return idom.html.h1({"id": "hello-world"}, "Hello World!") - - -@idom.component -def Counter(): - count, set_count = idom.hooks.use_state(0) - return idom.html.div( - idom.html.button( - {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, - "Click me!", - ), - idom.html.p( - {"id": "counter-num", "data-count": count}, - f"Current count is: {count}", - ), - ) From e76408b42bcc93d776c41cea74b03899f528f909 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Jul 2021 01:05:37 -0700 Subject: [PATCH 06/36] fix MANIFEST.in to include static/templates --- MANIFEST.in | 5 ++--- noxfile.py | 12 ++++++++++-- src/django_idom/app_settings.py | 8 ++++++++ src/django_idom/templatetags/idom.py | 6 +++--- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 1faaf63a..a43e500c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,2 @@ -include src/django_idom/static/js/idom.js -include src/django_idom/templates/head_content.html -include src/django_idom/templates/view.html +recursive-include src/django_idom/static/ * +recursive-include src/django_idom/templates/ *.html diff --git a/noxfile.py b/noxfile.py index 12ee62fa..ebcd38de 100644 --- a/noxfile.py +++ b/noxfile.py @@ -51,8 +51,16 @@ def test_suite(session: Session) -> None: session.chdir(HERE / "tests") session.env["IDOM_DEBUG_MODE"] = "1" - session.env["SELENIUM_HEADLESS"] = str(int("--headless" in session.posargs)) - session.run("python", "manage.py", "test") + + posargs = session.posargs[:] + if "--headless" in posargs: + posargs.remove("--headless") + session.env["SELENIUM_HEADLESS"] = "1" + + if "--no-debug-mode" not in posargs: + posargs.append("--debug-mode") + + session.run("python", "manage.py", "test", *posargs) @nox.session diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index 6c586004..f97fefed 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -1,5 +1,13 @@ +from pathlib import Path + from django.conf import settings +APP_DIR = Path(__file__).parent + +TEMPLATE_FILE_PATHS = { + file.stem: str(file.absolute()) + for file in (APP_DIR / "templates" / "idom").iterdir() +} IDOM_WEBSOCKET_URL = getattr(settings, "IDOM_WEBSOCKET_URL", "_idom/") if not IDOM_WEBSOCKET_URL.endswith("/"): diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 3f62edba..8e3d50f4 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -2,19 +2,19 @@ from django import template -from django_idom.app_settings import IDOM_WEBSOCKET_URL +from django_idom.app_settings import IDOM_WEBSOCKET_URL, TEMPLATE_FILE_PATHS register = template.Library() # Template tag that renders the IDOM scripts -@register.inclusion_tag("idom/head_content.html") +@register.inclusion_tag(TEMPLATE_FILE_PATHS["head_content"]) def idom_head(): pass -@register.inclusion_tag("idom/view.html") +@register.inclusion_tag(TEMPLATE_FILE_PATHS["view"]) def idom_view(view_id, view_params=""): return { "idom_websocket_url": IDOM_WEBSOCKET_URL, From f008f6bdcfe4bb0c510de7facd7a29ffdee1359b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Jul 2021 01:17:49 -0700 Subject: [PATCH 07/36] fix style --- src/django_idom/app_settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index f97fefed..cb93910b 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -2,6 +2,7 @@ from django.conf import settings + APP_DIR = Path(__file__).parent TEMPLATE_FILE_PATHS = { From b4f256b7b39b591b9ebeb1c91c041501278d323c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Jul 2021 01:57:30 -0700 Subject: [PATCH 08/36] require a my_app.idom.components attribute --- src/django_idom/app_components.py | 20 +++++++++++--------- tests/test_app/components.py | 21 +++++++++++++++++++++ tests/test_app/idom.py | 27 +++++---------------------- 3 files changed, 37 insertions(+), 31 deletions(-) create mode 100644 tests/test_app/components.py diff --git a/src/django_idom/app_components.py b/src/django_idom/app_components.py index f41e760f..1b129d6c 100644 --- a/src/django_idom/app_components.py +++ b/src/django_idom/app_components.py @@ -27,24 +27,26 @@ def has_component(name: str) -> bool: logger.debug(f"Skipping {idom_mod_name!r} - does not exist") continue - if not hasattr(idom_mod, "__all__"): + if not hasattr(idom_mod, "components"): logger.warning( f"'django_idom' expected module {idom_mod_name!r} to have an " - "'__all__' attribute that lists its publically available components." + "'components' attribute that lists its publically available components." ) continue - for component_name in idom_mod.__all__: - try: - component_constructor = getattr(idom_mod, component_name) - except AttributeError: + for component_constructor in idom_mod.components: + if not callable(component_constructor): logger.warning( - f"Module {idom_mod_name!r} has no attribute {component_name!r}" + f"{component_constructor} is not a callable component constructor" ) continue - if not callable(component_constructor): - logger.warning(f"'{idom_mod_name}.{component_name}' is not a component") + try: + component_name = getattr(component_constructor, "__name__") + except AttributeError: + logger.warning( + f"Component constructor {component_constructor} has not attribute '__name__'" + ) continue _LOADED_COMPONENTS[f"{app_mod_name}.{component_name}"] = component_constructor diff --git a/tests/test_app/components.py b/tests/test_app/components.py new file mode 100644 index 00000000..c0042570 --- /dev/null +++ b/tests/test_app/components.py @@ -0,0 +1,21 @@ +import idom + + +@idom.component +def HelloWorld(): + return idom.html.h1({"id": "hello-world"}, "Hello World!") + + +@idom.component +def Button(): + count, set_count = idom.hooks.use_state(0) + return idom.html.div( + idom.html.button( + {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, + "Click me!", + ), + idom.html.p( + {"id": "counter-num", "data-count": count}, + f"Current count is: {count}", + ), + ) diff --git a/tests/test_app/idom.py b/tests/test_app/idom.py index 1f6b0d8d..007f171e 100644 --- a/tests/test_app/idom.py +++ b/tests/test_app/idom.py @@ -1,24 +1,7 @@ -import idom +from .components import Button, HelloWorld -__all__ = "HelloWorld", "Button" - - -@idom.component -def HelloWorld(): - return idom.html.h1({"id": "hello-world"}, "Hello World!") - - -@idom.component -def Button(): - count, set_count = idom.hooks.use_state(0) - return idom.html.div( - idom.html.button( - {"id": "counter-inc", "onClick": lambda event: set_count(count + 1)}, - "Click me!", - ), - idom.html.p( - {"id": "counter-num", "data-count": count}, - f"Current count is: {count}", - ), - ) +components = [ + HelloWorld, + Button, +] From a0b75e03f35f2473b7ec4bca48c4ead0f6032adc Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Jul 2021 16:56:42 -0700 Subject: [PATCH 09/36] parametrized components + serve web modules --- src/django_idom/__init__.py | 7 ++---- src/django_idom/app_paths.py | 29 ++++++++++++++++++++++++ src/django_idom/app_settings.py | 6 ++--- src/django_idom/templates/idom/view.html | 1 + src/django_idom/templatetags/idom.py | 21 +++++++++++++---- src/django_idom/views.py | 7 ++++++ src/django_idom/websocket_consumer.py | 22 ++++-------------- src/js/package-lock.json | 18 +++++++-------- src/js/package.json | 6 ++--- src/js/src/index.js | 21 +++++++++++++---- tests/test_app/asgi.py | 4 ++-- tests/test_app/components.py | 15 ++++++++++++ tests/test_app/idom.py | 4 +++- tests/test_app/templates/base.html | 2 ++ tests/test_app/tests.py | 18 ++++++++++++++- tests/test_app/urls.py | 7 +++++- 16 files changed, 137 insertions(+), 51 deletions(-) create mode 100644 src/django_idom/app_paths.py create mode 100644 src/django_idom/views.py diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index cc31e4dc..23c959aa 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,8 +1,5 @@ -from .websocket_consumer import ( - IdomAsyncWebSocketConsumer, - django_idom_websocket_consumer_url, -) +from .app_paths import django_idom_web_modules_path, django_idom_websocket_consumer_path __version__ = "0.0.1" -__all__ = ["IdomAsyncWebSocketConsumer", "django_idom_websocket_consumer_url"] +__all__ = ["django_idom_websocket_consumer_path", "django_idom_web_modules_path"] diff --git a/src/django_idom/app_paths.py b/src/django_idom/app_paths.py new file mode 100644 index 00000000..2afc1d2f --- /dev/null +++ b/src/django_idom/app_paths.py @@ -0,0 +1,29 @@ +from django.urls import path + +from . import views +from .app_settings import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL +from .websocket_consumer import IdomAsyncWebSocketConsumer + + +def django_idom_websocket_consumer_path(*args, **kwargs): + """Return a URL resolver for :class:`IdomAsyncWebSocketConsumer` + + While this is relatively uncommon in most Django apps, because the URL of the + websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need + to allow users to configure the URL themselves + """ + return path( + IDOM_WEBSOCKET_URL + "/", + IdomAsyncWebSocketConsumer.as_asgi(), + *args, + **kwargs, + ) + + +def django_idom_web_modules_path(*args, **kwargs): + return path( + IDOM_WEB_MODULES_URL + "", + views.web_modules_file, + *args, + **kwargs, + ) diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index cb93910b..14b9f07e 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -10,6 +10,6 @@ for file in (APP_DIR / "templates" / "idom").iterdir() } -IDOM_WEBSOCKET_URL = getattr(settings, "IDOM_WEBSOCKET_URL", "_idom/") -if not IDOM_WEBSOCKET_URL.endswith("/"): - IDOM_WEBSOCKET_URL += "/" +IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") +IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" +IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/" diff --git a/src/django_idom/templates/idom/view.html b/src/django_idom/templates/idom/view.html index 02cc17ec..f11534ef 100644 --- a/src/django_idom/templates/idom/view.html +++ b/src/django_idom/templates/idom/view.html @@ -6,6 +6,7 @@ mountViewToElement( mountPoint, "{{ idom_websocket_url }}", + "{{ idom_web_modules_url }}", "{{ idom_view_id }}", "{{ idom_view_params }}" ); diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 8e3d50f4..da941f76 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -1,8 +1,15 @@ +import json +from urllib.parse import urlencode from uuid import uuid4 from django import template -from django_idom.app_settings import IDOM_WEBSOCKET_URL, TEMPLATE_FILE_PATHS +from django_idom.app_settings import ( + IDOM_WEB_MODULES_URL, + IDOM_WEBSOCKET_URL, + TEMPLATE_FILE_PATHS, +) +from ..app_components import has_component register = template.Library() @@ -15,10 +22,16 @@ def idom_head(): @register.inclusion_tag(TEMPLATE_FILE_PATHS["view"]) -def idom_view(view_id, view_params=""): +def idom_view(_component_id_, **kwargs): + if not has_component(_component_id_): + raise ValueError(f"No component {_component_id_!r} exists") + + json_kwargs = json.dumps(kwargs, separators=(",", ":")) + return { "idom_websocket_url": IDOM_WEBSOCKET_URL, + "idom_web_modules_url": IDOM_WEB_MODULES_URL, "idom_mount_uuid": uuid4().hex, - "idom_view_id": view_id, - "idom_view_params": view_params, + "idom_view_id": _component_id_, + "idom_view_params": urlencode({"kwargs": json_kwargs}), } diff --git a/src/django_idom/views.py b/src/django_idom/views.py new file mode 100644 index 00000000..908b5534 --- /dev/null +++ b/src/django_idom/views.py @@ -0,0 +1,7 @@ +from django.http import HttpResponse +from idom.config import IDOM_WED_MODULES_DIR + + +def web_modules_file(request, file: str) -> HttpResponse: + file_path = IDOM_WED_MODULES_DIR.current.joinpath(*file.split("/")) + return HttpResponse(file_path.read_text(), content_type="text/javascript") diff --git a/src/django_idom/websocket_consumer.py b/src/django_idom/websocket_consumer.py index 841dc4ee..d29b9f73 100644 --- a/src/django_idom/websocket_consumer.py +++ b/src/django_idom/websocket_consumer.py @@ -1,36 +1,20 @@ """Anything used to construct a websocket endpoint""" import asyncio +import json import logging from typing import Any from urllib.parse import parse_qsl from channels.generic.websocket import AsyncJsonWebsocketConsumer -from django.urls import path from idom.core.dispatcher import dispatch_single_view from idom.core.layout import Layout, LayoutEvent from .app_components import get_component, has_component -from .app_settings import IDOM_WEBSOCKET_URL logger = logging.getLogger(__name__) -def django_idom_websocket_consumer_url(*args, **kwargs): - """Return a URL resolver for :class:`IdomAsyncWebSocketConsumer` - - While this is relatively uncommon in most Django apps, because the URL of the - websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need - to allow users to configure the URL themselves - """ - return path( - IDOM_WEBSOCKET_URL + "/", - IdomAsyncWebSocketConsumer.as_asgi(), - *args, - **kwargs, - ) - - class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" @@ -59,7 +43,9 @@ async def _run_dispatch_loop(self): return component_constructor = get_component(view_id) - component_kwargs = dict(parse_qsl(self.scope["query_string"])) + + query_dict = dict(parse_qsl(self.scope["query_string"].decode())) + component_kwargs = json.loads(query_dict.get("kwargs", "{}")) try: component_instance = component_constructor(**component_kwargs) diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 72e7483c..6ff0f5ee 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -8,7 +8,7 @@ "name": "django-idom-client", "version": "0.0.1", "dependencies": { - "idom-client-react": "^0.8.2" + "idom-client-react": "^0.8.5" }, "devDependencies": { "prettier": "^2.2.1", @@ -100,16 +100,16 @@ "integrity": "sha512-VRdvxX3tmrXuT/Ovt59NMp/ORMFi4bceFMDjos1PV4E0mV+5votuID8R60egR9A4U8nLt238R/snlJGz3UYiTQ==" }, "node_modules/idom-client-react": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.2.tgz", - "integrity": "sha512-pK4FjyfVIaOVA/R0sj6Ulvpo3FATFU11TbnoqgzGbXjjY7kYPuR3x2pa/M6MY/Ot9yUb2Nsz9Gr1Vj8QPJ6GwA==", + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.5.tgz", + "integrity": "sha512-/S/J+BPGnQ4YrXWBD0mT0IqigDHrXglTX91qDyIjg6aoQwcprJ8ms9WFwdXcx0/QY85s08+/FP4FnF8lcqwx9w==", "dependencies": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" }, "peerDependencies": { - "react": "^16.13.1", - "react-dom": "^16.13.1" + "react": ">=16", + "react-dom": ">=16" } }, "node_modules/is-core-module": { @@ -405,9 +405,9 @@ "integrity": "sha512-VRdvxX3tmrXuT/Ovt59NMp/ORMFi4bceFMDjos1PV4E0mV+5votuID8R60egR9A4U8nLt238R/snlJGz3UYiTQ==" }, "idom-client-react": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.2.tgz", - "integrity": "sha512-pK4FjyfVIaOVA/R0sj6Ulvpo3FATFU11TbnoqgzGbXjjY7kYPuR3x2pa/M6MY/Ot9yUb2Nsz9Gr1Vj8QPJ6GwA==", + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.8.5.tgz", + "integrity": "sha512-/S/J+BPGnQ4YrXWBD0mT0IqigDHrXglTX91qDyIjg6aoQwcprJ8ms9WFwdXcx0/QY85s08+/FP4FnF8lcqwx9w==", "requires": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" diff --git a/src/js/package.json b/src/js/package.json index 832480a8..087045c9 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -10,14 +10,14 @@ "build": "rollup --config", "format": "prettier --ignore-path .gitignore --write ." }, - "dependencies": { - "idom-client-react": "^0.8.2" - }, "devDependencies": { "prettier": "^2.2.1", "rollup": "^2.35.1", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-replace": "^2.2.0" + }, + "dependencies": { + "idom-client-react": "^0.8.5" } } diff --git a/src/js/src/index.js b/src/js/src/index.js index a8a16bb3..bddf2a55 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -1,6 +1,5 @@ import { mountLayoutWithWebSocket } from "idom-client-react"; - // Set up a websocket at the base endpoint let LOCATION = window.location; let WS_PROTOCOL = ""; @@ -11,8 +10,22 @@ if (LOCATION.protocol == "https:") { } let WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/"; +export function mountViewToElement( + mountPoint, + idomWebsocketUrl, + idomWebModulesUrl, + viewId, + queryParams +) { + const fullWebsocketUrl = + WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/?" + queryParams; + + const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl + const loadImportSource = (source, sourceType) => { + return import( + sourceType == "NAME" ? `${fullWebModulesUrl}${source}` : source + ); + }; -export function mountViewToElement(mountPoint, idomWebsocketUrl, viewId, queryParams) { - const fullWebsocketUrl = WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/"; - mountLayoutWithWebSocket(mountPoint, fullWebsocketUrl); + mountLayoutWithWebSocket(mountPoint, fullWebsocketUrl, loadImportSource); } diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index e9faad98..625e9236 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -11,7 +11,7 @@ from django.core.asgi import get_asgi_application -from django_idom import django_idom_websocket_consumer_url +from django_idom import django_idom_websocket_consumer_path os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") @@ -25,6 +25,6 @@ application = ProtocolTypeRouter( { "http": http_asgi_app, - "websocket": URLRouter([django_idom_websocket_consumer_url()]), + "websocket": URLRouter([django_idom_websocket_consumer_path()]), } ) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index c0042570..f4fbd9e5 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -19,3 +19,18 @@ def Button(): f"Current count is: {count}", ), ) + + +@idom.component +def ParametrizedComponent(x, y): + total = x + y + return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) + + +victory = idom.web.module_from_template("react", "victory", fallback="...") +VictoryBar = idom.web.export(victory, "VictoryBar") + + +@idom.component +def SimpleBarChart(): + return VictoryBar() diff --git a/tests/test_app/idom.py b/tests/test_app/idom.py index 007f171e..f7f9c10a 100644 --- a/tests/test_app/idom.py +++ b/tests/test_app/idom.py @@ -1,7 +1,9 @@ -from .components import Button, HelloWorld +from .components import Button, HelloWorld, ParametrizedComponent, SimpleBarChart components = [ HelloWorld, Button, + ParametrizedComponent, + SimpleBarChart, ] diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index e3180b1d..0968e6c1 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -17,5 +17,7 @@

IDOM Test Page

{% idom_view "test_app.HelloWorld" %}
{% idom_view "test_app.Button" %}
+
{% idom_view "test_app.ParametrizedComponent" x=123 y=456 %}
+
{% idom_view "test_app.SimpleBarChart" %}
diff --git a/tests/test_app/tests.py b/tests/test_app/tests.py index 1a80ee5a..5520e7f4 100644 --- a/tests/test_app/tests.py +++ b/tests/test_app/tests.py @@ -2,6 +2,8 @@ from channels.testing import ChannelsLiveServerTestCase from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.ui import WebDriverWait @@ -13,8 +15,11 @@ def setUp(self): def tearDown(self) -> None: self.driver.quit() + def wait(self, timeout=5): + return WebDriverWait(self.driver, timeout) + def wait_until(self, condition, timeout=5): - WebDriverWait(self.driver, timeout).until(lambda driver: condition()) + return self.wait(timeout).until(lambda driver: condition()) def test_hello_world(self): self.driver.find_element_by_id("hello-world") @@ -27,6 +32,17 @@ def test_counter(self): self.wait_until(lambda: count.get_attribute("data-count") == str(i)) button.click() + def test_parametrized_component(self): + element = self.driver.find_element_by_id("parametrized-component") + self.assertEqual(element.get_attribute("data-value"), "579") + + def test_component_from_web_module(self): + self.wait(10).until( + expected_conditions.visibility_of_element_located( + (By.CLASS_NAME, "VictoryContainer") + ) + ) + def make_driver(page_load_timeout, implicit_wait_timeout): options = webdriver.ChromeOptions() diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 4449e447..8d89a833 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -19,7 +19,12 @@ """ from django.urls import path +from django_idom import django_idom_web_modules_path + from .views import base_template -urlpatterns = [path("", base_template)] +urlpatterns = [ + path("", base_template), + django_idom_web_modules_path(), +] From cd12a7c52a1da5f856b9b73b0f5e0d3bdc58ad5d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Jul 2021 22:14:08 -0700 Subject: [PATCH 10/36] add IDOM_IGNORED_DJANGO_APPS option --- src/django_idom/app_components.py | 20 ++++++++++++++++---- src/django_idom/app_settings.py | 2 ++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/django_idom/app_components.py b/src/django_idom/app_components.py index 1b129d6c..465bc817 100644 --- a/src/django_idom/app_components.py +++ b/src/django_idom/app_components.py @@ -5,6 +5,8 @@ from django.conf import settings from idom.core.proto import ComponentConstructor +from .app_settings import IDOM_IGNORED_DJANGO_APPS + logger = logging.getLogger(__name__) _LOADED_COMPONENTS: Dict[str, ComponentConstructor] = {} @@ -19,6 +21,10 @@ def has_component(name: str) -> bool: for app_mod_name in settings.INSTALLED_APPS: + if app_mod_name in IDOM_IGNORED_DJANGO_APPS: + logger.debug(f"{idom_mod_name!r} skipped by IDOM_IGNORED_DJANGO_APPS") + continue + idom_mod_name = f"{app_mod_name}.idom" try: @@ -36,17 +42,23 @@ def has_component(name: str) -> bool: for component_constructor in idom_mod.components: if not callable(component_constructor): - logger.warning( + raise ValueError( f"{component_constructor} is not a callable component constructor" ) - continue try: component_name = getattr(component_constructor, "__name__") except AttributeError: - logger.warning( + raise ValueError( f"Component constructor {component_constructor} has not attribute '__name__'" ) - continue + + full_component_name = f"{app_mod_name}.{component_name}" + + if full_component_name in _LOADED_COMPONENTS: + raise ValueError( + f"Component constructor named {component_name!r} has already been " + f"declared by the app {app_mod_name!r}" + ) _LOADED_COMPONENTS[f"{app_mod_name}.{component_name}"] = component_constructor diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index 14b9f07e..283125fa 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -10,6 +10,8 @@ for file in (APP_DIR / "templates" / "idom").iterdir() } +IDOM_IGNORED_DJANGO_APPS = set(getattr(settings, "IDOM_IGNORED_DJANGO_APPS", [])) + IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/" From 9f4412d01fa26d7df65f79da1fd776afc9ce82a0 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Jul 2021 23:12:14 -0700 Subject: [PATCH 11/36] add basic docs to README --- README.md | 176 +++++++++++++++++++++++---- src/django_idom/app_components.py | 6 +- src/django_idom/app_settings.py | 2 +- src/django_idom/templatetags/idom.py | 1 + 4 files changed, 156 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 943a2aed..f74c6eab 100644 --- a/README.md +++ b/README.md @@ -10,32 +10,25 @@ License: MIT -A package for building highly interactive user interfaces in pure Python inspired by -[ReactJS](https://reactjs.org/). +`django-idom` allows you to integrate [IDOM](https://github.com/idom-team/idom), a +package for building highly interactive user interfaces in pure Python inspired by +[ReactJS](https://reactjs.org/), into Django applications. -**Be sure to [read the IDOM Documentation](https://idom-docs.herokuapp.com)!** - -If you have ideas or find a bug, be sure to post an -[issue](https://github.com/idom-team/django-idom/issues) -or create a -[pull request](https://github.com/idom-team/django-idom/pulls). Thanks in advance! +**You can try IDOM now in a Jupyter Notebook:** + + Binder + -

- - Try it Now - Binder - -

+For more information on IDOM refer to [its documentation](https://idom-docs.herokuapp.com). -Click the badge above to get started! It will take you to a [Jupyter Notebooks](https://jupyter.org/) -hosted by [Binder](https://mybinder.org/) with some great examples. -### Or Install it Now +# Install Django IDOM ```bash pip install django-idom @@ -43,10 +36,143 @@ pip install django-idom # Django Integration -This version of IDOM can be directly integrated into Django. For example +To integrate IDOM into your application you'll need to modify or add the following files to `your_app`: + +``` +your_app/ +├── asgi.py +├── components.py +├── idom.py +├── settings.py +├── templates/ +│ ├── your-template.html +└── urls.py +``` + +## `asgi.py` + +To start, we'll need to use [`channels`](https://channels.readthedocs.io/en/stable/) to +create a `ProtocolTypeRouter` that will become the top of our ASGI application stack. +Under the `"websocket"` protocol, we'll then add a path for IDOM's websocket consumer +using `django_idom_websocket_consumer_path`. If you wish to change the route where this +websocket is served from see the [settings](#configuration-options). + +```python + +import os + +from django.core.asgi import get_asgi_application + +from django_idom import django_idom_websocket_consumer_path + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") + +# Fetch ASGI application before importing dependencies that require ORM models. +http_asgi_app = get_asgi_application() + +from channels.routing import ProtocolTypeRouter, URLRouter + +application = ProtocolTypeRouter( + { + "http": http_asgi_app, + "websocket": URLRouter( + # add a path for IDOM's websocket + [django_idom_websocket_consumer_path()] + ), + } +) +``` + +## `components.py` + +This is where, by a convention similar to that of +[`views.py`](https://docs.djangoproject.com/en/3.2/topics/http/views/), you'll define +your [IDOM](https://github.com/idom-team/idom) components. Ultimately though, you should +feel free to organize your component modules you wish. + +```python +import idom + +@idom.component +def Hello(name): # component names are camelcase by convention + return idom.html.h1(f"Hello {name}!") +``` + +## `idom.py` + +This file is automatically discovered by `django-idom` when scanning the list of +[`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-INSTALLED_APPS). +All apps that export components will contain this module. + +Inside this module must be a `components` list that is imported from +[`components.py`](#components.py): + +```python +from .components import Hello + +components = [Hello] +``` + +## `settings.py` + +In your settings you'll need to add `django_idom` to the +[`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-INSTALLED_APPS) +list: ```python -# Example code goes here +INSTALLED_APPS = [ + ..., + "django_idom", +] ``` -For examples on how to use IDOM, [read the IDOM Documentation](https://idom-docs.herokuapp.com). +## `templates/your-template.html` + +In your templates, you may inject a view of an IDOM component into your templated HTML +by using the `idom_view` Jinja tag. You can even pass parameters to your component from +the template via keyword arguments: + +```html + +{% load static %} +{% load idom %} + + + + + ... + {% idom_view "test_app.Hello" name="World" %} + + +``` + +Your view for this template can be defined just like any other + +## `urls.py` + +To your list of URLs you'll need to include IDOM's static web modules path using +`django_idom_web_modules_path`: + +```python +from django.urls import path +from django_idom import django_idom_web_modules_path +from .views import your_template + + +urlpatterns = [ + path("", your_template), + django_idom_web_modules_path(), +] +``` + +# Configuration Options + +You may configure additional options in your `settings.py` file + +```python +# the base URL for all IDOM-releated resources +IDOM_BASE_URL: str = "_idom/" + +# ignore these apps during component collection +IDOM_IGNORED_DJANGO_APPS: set[str] = {"some_app", "some_other_app"} +``` diff --git a/src/django_idom/app_components.py b/src/django_idom/app_components.py index 465bc817..36c7daee 100644 --- a/src/django_idom/app_components.py +++ b/src/django_idom/app_components.py @@ -5,7 +5,7 @@ from django.conf import settings from idom.core.proto import ComponentConstructor -from .app_settings import IDOM_IGNORED_DJANGO_APPS +from .app_settings import IDOM_IGNORE_INSTALLED_APPS logger = logging.getLogger(__name__) @@ -21,8 +21,8 @@ def has_component(name: str) -> bool: for app_mod_name in settings.INSTALLED_APPS: - if app_mod_name in IDOM_IGNORED_DJANGO_APPS: - logger.debug(f"{idom_mod_name!r} skipped by IDOM_IGNORED_DJANGO_APPS") + if app_mod_name in IDOM_IGNORE_INSTALLED_APPS: + logger.debug(f"{app_mod_name!r} skipped by IDOM_IGNORE_INSTALLED_APPS") continue idom_mod_name = f"{app_mod_name}.idom" diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index 283125fa..d8f16aee 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -10,7 +10,7 @@ for file in (APP_DIR / "templates" / "idom").iterdir() } -IDOM_IGNORED_DJANGO_APPS = set(getattr(settings, "IDOM_IGNORED_DJANGO_APPS", [])) +IDOM_IGNORE_INSTALLED_APPS = set(getattr(settings, "IDOM_IGNORE_INSTALLED_APPS", [])) IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index da941f76..3c512f09 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -9,6 +9,7 @@ IDOM_WEBSOCKET_URL, TEMPLATE_FILE_PATHS, ) + from ..app_components import has_component From 34452e44bcbe103b3458ffd87fa4789635be7842 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 29 Jul 2021 10:38:15 -0700 Subject: [PATCH 12/36] minor doc improvements --- README.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f74c6eab..c12c45df 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ License: MIT -`django-idom` allows you to integrate [IDOM](https://github.com/idom-team/idom), a -package for building highly interactive user interfaces in pure Python inspired by -[ReactJS](https://reactjs.org/), into Django applications. +`django-idom` allows you to integrate [IDOM](https://github.com/idom-team/idom) into +Django applications. IDOM being a package for building responsive user interfaces in +pure Python which is inspired by [ReactJS](https://reactjs.org/). For more information +on IDOM refer to [its documentation](https://idom-docs.herokuapp.com). **You can try IDOM now in a Jupyter Notebook:** -For more information on IDOM refer to [its documentation](https://idom-docs.herokuapp.com). - # Install Django IDOM @@ -129,8 +128,15 @@ INSTALLED_APPS = [ ## `templates/your-template.html` In your templates, you may inject a view of an IDOM component into your templated HTML -by using the `idom_view` Jinja tag. You can even pass parameters to your component from -the template via keyword arguments: +by using the `idom_view` Jinja tag. This tag which requires the name of a component to +render (of the form `app_name.ComponentName`) and keyword arguments you'd like to pass +it from the template. + +```python +idom_view app_name.ComponentName param_1="something" param_2="something-else" +``` + +In context this will look a bit like the following... ```html @@ -146,7 +152,8 @@ the template via keyword arguments: ``` -Your view for this template can be defined just like any other +Your view for this template can be defined just +[like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something). ## `urls.py` @@ -156,7 +163,7 @@ To your list of URLs you'll need to include IDOM's static web modules path using ```python from django.urls import path from django_idom import django_idom_web_modules_path -from .views import your_template +from .views import your_template # define this view like any other HTML template urlpatterns = [ @@ -173,6 +180,6 @@ You may configure additional options in your `settings.py` file # the base URL for all IDOM-releated resources IDOM_BASE_URL: str = "_idom/" -# ignore these apps during component collection -IDOM_IGNORED_DJANGO_APPS: set[str] = {"some_app", "some_other_app"} +# ignore these INSTALLED_APPS during component collection +IDOM_IGNORE_INSTALLED_APPS: set[str] = {"some_app", "some_other_app"} ``` From 3e07d5a7d2f0fcb8452dd73f58b4ee853157b3d3 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 29 Jul 2021 10:57:23 -0700 Subject: [PATCH 13/36] more doc updates --- CODE_OF_CONDUCT.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++ README.md | 44 +++++++++++++++++++++++++++ noxfile.py | 4 ++- 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..4b763b80 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at ryan.morshead@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..060079c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Ryan S. Morshead + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c12c45df..3f194ac1 100644 --- a/README.md +++ b/README.md @@ -183,3 +183,47 @@ IDOM_BASE_URL: str = "_idom/" # ignore these INSTALLED_APPS during component collection IDOM_IGNORE_INSTALLED_APPS: set[str] = {"some_app", "some_other_app"} ``` + +# Developer Guide + +If you plan to make code changes to this repository, you'll need to install the +following dependencies first: + +- [NPM](https://docs.npmjs.com/try-the-latest-stable-version-of-npm) for + installing and managing Javascript +- [ChromeDriver](https://chromedriver.chromium.org/downloads) for testing with + [Selenium](https://www.seleniumhq.org/) + +Once done, you should clone this repository: + +```bash +git clone https://github.com/idom-team/django-idom.git +cd django-idom +``` + +Then, by running the command below you can: + +- Install an editable version of the Python code + +- Download, build, and install Javascript dependencies + +```bash +pip install -e . -r requirements.txt +``` + +Finally, to verify that everything is working properly, you'll want to run the test suite. + +## Running The Tests + +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: + +``` +nox -s test +``` + +To run the tests using a headless browser: + +``` +nox -s test -- --headless +``` diff --git a/noxfile.py b/noxfile.py index ebcd38de..98b80393 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,7 +15,8 @@ @nox.session(reuse_venv=True) -def manage(session: Session) -> None: +def test_app(session: Session) -> None: + """Run a manage.py command for tests/test_app""" session.install("-r", "requirements.txt") session.install("idom[stable]") session.install("-e", ".") @@ -30,6 +31,7 @@ def manage(session: Session) -> None: @nox.session(reuse_venv=True) def format(session: Session) -> None: + """Run automatic code formatters""" install_requirements_file(session, "check-style") session.run("black", ".") session.run("isort", ".") From 0ff84ecf916ced5a833361b7a585a24b9d72da2a Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 4 Aug 2021 23:41:17 -0700 Subject: [PATCH 14/36] make logger private --- src/django_idom/app_components.py | 8 ++++---- src/django_idom/websocket_consumer.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/django_idom/app_components.py b/src/django_idom/app_components.py index 36c7daee..43f9dda6 100644 --- a/src/django_idom/app_components.py +++ b/src/django_idom/app_components.py @@ -8,7 +8,7 @@ from .app_settings import IDOM_IGNORE_INSTALLED_APPS -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) _LOADED_COMPONENTS: Dict[str, ComponentConstructor] = {} @@ -22,7 +22,7 @@ def has_component(name: str) -> bool: for app_mod_name in settings.INSTALLED_APPS: if app_mod_name in IDOM_IGNORE_INSTALLED_APPS: - logger.debug(f"{app_mod_name!r} skipped by IDOM_IGNORE_INSTALLED_APPS") + _logger.debug(f"{app_mod_name!r} skipped by IDOM_IGNORE_INSTALLED_APPS") continue idom_mod_name = f"{app_mod_name}.idom" @@ -30,11 +30,11 @@ def has_component(name: str) -> bool: try: idom_mod = import_module(idom_mod_name) except ImportError: - logger.debug(f"Skipping {idom_mod_name!r} - does not exist") + _logger.debug(f"Skipping {idom_mod_name!r} - does not exist") continue if not hasattr(idom_mod, "components"): - logger.warning( + _logger.warning( f"'django_idom' expected module {idom_mod_name!r} to have an " "'components' attribute that lists its publically available components." ) diff --git a/src/django_idom/websocket_consumer.py b/src/django_idom/websocket_consumer.py index d29b9f73..f78a29e6 100644 --- a/src/django_idom/websocket_consumer.py +++ b/src/django_idom/websocket_consumer.py @@ -12,7 +12,7 @@ from .app_components import get_component, has_component -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer): @@ -39,7 +39,7 @@ async def _run_dispatch_loop(self): view_id = self.scope["url_route"]["kwargs"]["view_id"] if not has_component(view_id): - logger.warning(f"Uknown IDOM view ID {view_id!r}") + _logger.warning(f"Uknown IDOM view ID {view_id!r}") return component_constructor = get_component(view_id) @@ -50,7 +50,7 @@ async def _run_dispatch_loop(self): try: component_instance = component_constructor(**component_kwargs) except Exception: - logger.exception( + _logger.exception( f"Failed to construct component {component_constructor} " f"with parameters {component_kwargs}" ) From fbb0037939210f314c7c6ae0def4a0b62d86e3ea Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 4 Aug 2021 23:46:47 -0700 Subject: [PATCH 15/36] use string path to template --- src/django_idom/app_settings.py | 7 ------- src/django_idom/templates/idom/head_content.html | 2 -- src/django_idom/templatetags/idom.py | 14 ++------------ 3 files changed, 2 insertions(+), 21 deletions(-) delete mode 100644 src/django_idom/templates/idom/head_content.html diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index d8f16aee..b369e8ef 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -3,13 +3,6 @@ from django.conf import settings -APP_DIR = Path(__file__).parent - -TEMPLATE_FILE_PATHS = { - file.stem: str(file.absolute()) - for file in (APP_DIR / "templates" / "idom").iterdir() -} - IDOM_IGNORE_INSTALLED_APPS = set(getattr(settings, "IDOM_IGNORE_INSTALLED_APPS", [])) IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") diff --git a/src/django_idom/templates/idom/head_content.html b/src/django_idom/templates/idom/head_content.html deleted file mode 100644 index 27a0a4b4..00000000 --- a/src/django_idom/templates/idom/head_content.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 3c512f09..8d8a8f3e 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -4,11 +4,7 @@ from django import template -from django_idom.app_settings import ( - IDOM_WEB_MODULES_URL, - IDOM_WEBSOCKET_URL, - TEMPLATE_FILE_PATHS, -) +from django_idom.app_settings import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL from ..app_components import has_component @@ -16,13 +12,7 @@ register = template.Library() -# Template tag that renders the IDOM scripts -@register.inclusion_tag(TEMPLATE_FILE_PATHS["head_content"]) -def idom_head(): - pass - - -@register.inclusion_tag(TEMPLATE_FILE_PATHS["view"]) +@register.inclusion_tag("idom/view.html") def idom_view(_component_id_, **kwargs): if not has_component(_component_id_): raise ValueError(f"No component {_component_id_!r} exists") From 407b506ebe507b71b04139f0590f12079f787c43 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 5 Aug 2021 00:22:53 -0700 Subject: [PATCH 16/36] rename URL resolver functions --- README.md | 12 ++++++------ src/django_idom/__init__.py | 4 ++-- src/django_idom/app_paths.py | 12 +++++++++--- tests/test_app/asgi.py | 4 ++-- tests/test_app/urls.py | 4 ++-- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3f194ac1..b966bf02 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ your_app/ To start, we'll need to use [`channels`](https://channels.readthedocs.io/en/stable/) to create a `ProtocolTypeRouter` that will become the top of our ASGI application stack. Under the `"websocket"` protocol, we'll then add a path for IDOM's websocket consumer -using `django_idom_websocket_consumer_path`. If you wish to change the route where this +using `idom_websocket_path`. If you wish to change the route where this websocket is served from see the [settings](#configuration-options). ```python @@ -62,7 +62,7 @@ import os from django.core.asgi import get_asgi_application -from django_idom import django_idom_websocket_consumer_path +from django_idom import idom_websocket_path os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") @@ -76,7 +76,7 @@ application = ProtocolTypeRouter( "http": http_asgi_app, "websocket": URLRouter( # add a path for IDOM's websocket - [django_idom_websocket_consumer_path()] + [idom_websocket_path()] ), } ) @@ -158,17 +158,17 @@ Your view for this template can be defined just ## `urls.py` To your list of URLs you'll need to include IDOM's static web modules path using -`django_idom_web_modules_path`: +`idom_web_modules_path`: ```python from django.urls import path -from django_idom import django_idom_web_modules_path +from django_idom import idom_web_modules_path from .views import your_template # define this view like any other HTML template urlpatterns = [ path("", your_template), - django_idom_web_modules_path(), + idom_web_modules_path(), ] ``` diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index 23c959aa..516c045b 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,5 +1,5 @@ -from .app_paths import django_idom_web_modules_path, django_idom_websocket_consumer_path +from .app_paths import idom_web_modules_path, idom_websocket_path __version__ = "0.0.1" -__all__ = ["django_idom_websocket_consumer_path", "django_idom_web_modules_path"] +__all__ = ["idom_websocket_path", "idom_web_modules_path"] diff --git a/src/django_idom/app_paths.py b/src/django_idom/app_paths.py index 2afc1d2f..0385f5f8 100644 --- a/src/django_idom/app_paths.py +++ b/src/django_idom/app_paths.py @@ -5,12 +5,12 @@ from .websocket_consumer import IdomAsyncWebSocketConsumer -def django_idom_websocket_consumer_path(*args, **kwargs): +def idom_websocket_path(*args, **kwargs): """Return a URL resolver for :class:`IdomAsyncWebSocketConsumer` While this is relatively uncommon in most Django apps, because the URL of the websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need - to allow users to configure the URL themselves + to allow users to configure the URL themselves. """ return path( IDOM_WEBSOCKET_URL + "/", @@ -20,7 +20,13 @@ def django_idom_websocket_consumer_path(*args, **kwargs): ) -def django_idom_web_modules_path(*args, **kwargs): +def idom_web_modules_path(*args, **kwargs): + """Return a URL resolver for static web modules required by IDOM + + While this is relatively uncommon in most Django apps, because the URL of the + websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need + to allow users to configure the URL themselves. + """ return path( IDOM_WEB_MODULES_URL + "", views.web_modules_file, diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index 625e9236..ceb7a6cc 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -11,7 +11,7 @@ from django.core.asgi import get_asgi_application -from django_idom import django_idom_websocket_consumer_path +from django_idom import idom_websocket_path os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") @@ -25,6 +25,6 @@ application = ProtocolTypeRouter( { "http": http_asgi_app, - "websocket": URLRouter([django_idom_websocket_consumer_path()]), + "websocket": URLRouter([idom_websocket_path()]), } ) diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 8d89a833..ea0d9392 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -19,12 +19,12 @@ """ from django.urls import path -from django_idom import django_idom_web_modules_path +from django_idom import idom_web_modules_path from .views import base_template urlpatterns = [ path("", base_template), - django_idom_web_modules_path(), + idom_web_modules_path(), ] From fa95bb103b68261e9035e437de2acbb7ee03ac0c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 5 Aug 2021 00:25:09 -0700 Subject: [PATCH 17/36] fix flake8 --- src/django_idom/app_settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/django_idom/app_settings.py b/src/django_idom/app_settings.py index b369e8ef..a6fe8993 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/app_settings.py @@ -1,5 +1,3 @@ -from pathlib import Path - from django.conf import settings From 9da3de819ac1ca3a52b9237c130d4b2481f15b59 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 5 Aug 2021 00:29:50 -0700 Subject: [PATCH 18/36] correct template tag description --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b966bf02..2a0ff821 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,9 @@ INSTALLED_APPS = [ ## `templates/your-template.html` In your templates, you may inject a view of an IDOM component into your templated HTML -by using the `idom_view` Jinja tag. This tag which requires the name of a component to -render (of the form `app_name.ComponentName`) and keyword arguments you'd like to pass -it from the template. +by using the `idom_view` template tag. This tag which requires the name of a component +to render (of the form `app_name.ComponentName`) and keyword arguments you'd like to +pass it from the template. ```python idom_view app_name.ComponentName param_1="something" param_2="something-else" From a05d0efcb3ab0ba2bfdfd581afc7bd75f9af93f2 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 5 Aug 2021 01:48:09 -0700 Subject: [PATCH 19/36] update app organization in README --- README.md | 115 ++++++++++++++++++++---------- src/django_idom/app_components.py | 22 +++--- tests/test_app/views.py | 3 +- 3 files changed, 93 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 2a0ff821..f686eff9 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,17 @@ To integrate IDOM into your application you'll need to modify or add the followi ``` your_app/ +├── __init__.py ├── asgi.py -├── components.py -├── idom.py ├── settings.py -├── templates/ -│ ├── your-template.html -└── urls.py +├── urls.py +└── sub_app/ + ├── __init__.py + ├── components.py + ├── idom.py + ├── templates/ + │ └── your-template.html + └── urls.py ``` ## `asgi.py` @@ -54,7 +58,7 @@ To start, we'll need to use [`channels`](https://channels.readthedocs.io/en/stab create a `ProtocolTypeRouter` that will become the top of our ASGI application stack. Under the `"websocket"` protocol, we'll then add a path for IDOM's websocket consumer using `idom_websocket_path`. If you wish to change the route where this -websocket is served from see the [settings](#configuration-options). +websocket is served from see the available [settings](#settings.py). ```python @@ -82,7 +86,44 @@ application = ProtocolTypeRouter( ) ``` -## `components.py` +## `settings.py` + +In your settings you'll need to add `django_idom` to the +[`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-INSTALLED_APPS) +list: + +```python +INSTALLED_APPS = [ + ..., + "django_idom", +] +``` + +You may configure additional options as well: + +```python +# the base URL for all IDOM-releated resources +IDOM_BASE_URL: str = "_idom/" + +# ignore these INSTALLED_APPS during component collection +IDOM_IGNORE_INSTALLED_APPS: list[str] = ["some_app", "some_other_app"] +``` + +## `urls.py` + +You'll need to include IDOM's static web modules path using `idom_web_modules_path`. +Similarly to the `idom_websocket_path()`, these resources will be used globally. + +```python +from django_idom import idom_web_modules_path + +urlpatterns = [ + idom_web_modules_path(), + ... +] +``` + +## `sub_app/components.py` This is where, by a convention similar to that of [`views.py`](https://docs.djangoproject.com/en/3.2/topics/http/views/), you'll define @@ -97,35 +138,36 @@ def Hello(name): # component names are camelcase by convention return idom.html.h1(f"Hello {name}!") ``` -## `idom.py` +## `sub_app/idom.py` This file is automatically discovered by `django-idom` when scanning the list of [`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-INSTALLED_APPS). All apps that export components will contain this module. Inside this module must be a `components` list that is imported from -[`components.py`](#components.py): +[`components.py`](#sub_appcomponents.py): ```python from .components import Hello -components = [Hello] +components = [ + Hello, + ... +] ``` -## `settings.py` - -In your settings you'll need to add `django_idom` to the -[`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-INSTALLED_APPS) -list: +You may alternately reference the components with strings for the purpose of renaming: ```python -INSTALLED_APPS = [ - ..., - "django_idom", +from .components import Hello as SomeOtherName + +components = [ + "SomeOtherName", + ... ] ``` -## `templates/your-template.html` +## `sub_app/templates/your-template.html` In your templates, you may inject a view of an IDOM component into your templated HTML by using the `idom_view` template tag. This tag which requires the name of a component @@ -147,7 +189,7 @@ In context this will look a bit like the following... ... - {% idom_view "test_app.Hello" name="World" %} + {% idom_view "your_app.sub_app.Hello" name="World" %} ``` @@ -155,33 +197,32 @@ In context this will look a bit like the following... Your view for this template can be defined just [like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something). -## `urls.py` - -To your list of URLs you'll need to include IDOM's static web modules path using -`idom_web_modules_path`: +## `sub_app/views.py` ```python -from django.urls import path -from django_idom import idom_web_modules_path -from .views import your_template # define this view like any other HTML template +from django.http import HttpResponse +from django.template import loader -urlpatterns = [ - path("", your_template), - idom_web_modules_path(), -] +def your_template(request): + context = {} + return HttpResponse( + loader.get_template("your-template.html").render(context, request) + ) ``` -# Configuration Options +## `sub_app/urls.py` -You may configure additional options in your `settings.py` file +Include your replate in the list of urlpatterns ```python -# the base URL for all IDOM-releated resources -IDOM_BASE_URL: str = "_idom/" +from django.urls import path +from .views import your_template # define this view like any other HTML template -# ignore these INSTALLED_APPS during component collection -IDOM_IGNORE_INSTALLED_APPS: set[str] = {"some_app", "some_other_app"} +urlpatterns = [ + path("", your_template), + ... +] ``` # Developer Guide diff --git a/src/django_idom/app_components.py b/src/django_idom/app_components.py index 43f9dda6..1f861d6a 100644 --- a/src/django_idom/app_components.py +++ b/src/django_idom/app_components.py @@ -40,19 +40,25 @@ def has_component(name: str) -> bool: ) continue - for component_constructor in idom_mod.components: + for component_value in idom_mod.components: + if isinstance(component_value, str): + component_name = component_value + component_constructor = getattr(idom_mod, component_name) + else: + component_constructor = component_value + + try: + component_name = getattr(component_constructor, "__name__") + except AttributeError: + raise ValueError( + f"Component constructor {component_constructor} has no attribute '__name__'" + ) + if not callable(component_constructor): raise ValueError( f"{component_constructor} is not a callable component constructor" ) - try: - component_name = getattr(component_constructor, "__name__") - except AttributeError: - raise ValueError( - f"Component constructor {component_constructor} has not attribute '__name__'" - ) - full_component_name = f"{app_mod_name}.{component_name}" if full_component_name in _LOADED_COMPONENTS: diff --git a/tests/test_app/views.py b/tests/test_app/views.py index eb73ee53..248235a7 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -3,6 +3,5 @@ def base_template(request): - template = loader.get_template("base.html") context = {} - return HttpResponse(template.render(context, request)) + return HttpResponse(loader.get_template("base.html").render(context, request)) From 937244cc637cef15c64b52af2b8671478c7cecb5 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 11 Aug 2021 15:18:36 -0700 Subject: [PATCH 20/36] switch to decorator collection method --- src/django_idom/__init__.py | 5 +- src/django_idom/app_components.py | 70 ------------------- src/django_idom/components.py | 48 +++++++++++++ .../{app_settings.py => config.py} | 5 +- src/django_idom/{app_paths.py => paths.py} | 2 +- src/django_idom/templatetags/idom.py | 11 +-- src/django_idom/views.py | 4 +- src/django_idom/websocket_consumer.py | 8 +-- tests/test_app/components.py | 12 ++-- tests/test_app/idom.py | 9 --- tests/test_app/templates/base.html | 10 +-- tests/test_app/views.py | 2 + 12 files changed, 84 insertions(+), 102 deletions(-) delete mode 100644 src/django_idom/app_components.py create mode 100644 src/django_idom/components.py rename src/django_idom/{app_settings.py => config.py} (58%) rename src/django_idom/{app_paths.py => paths.py} (94%) delete mode 100644 tests/test_app/idom.py diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index 516c045b..cc58e87e 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,5 +1,6 @@ -from .app_paths import idom_web_modules_path, idom_websocket_path +from .components import register_component +from .paths import idom_web_modules_path, idom_websocket_path __version__ = "0.0.1" -__all__ = ["idom_websocket_path", "idom_web_modules_path"] +__all__ = ["idom_websocket_path", "idom_web_modules_path", "register_component"] diff --git a/src/django_idom/app_components.py b/src/django_idom/app_components.py deleted file mode 100644 index 1f861d6a..00000000 --- a/src/django_idom/app_components.py +++ /dev/null @@ -1,70 +0,0 @@ -import logging -from importlib import import_module -from typing import Dict - -from django.conf import settings -from idom.core.proto import ComponentConstructor - -from .app_settings import IDOM_IGNORE_INSTALLED_APPS - - -_logger = logging.getLogger(__name__) -_LOADED_COMPONENTS: Dict[str, ComponentConstructor] = {} - - -def get_component(name: str) -> ComponentConstructor: - return _LOADED_COMPONENTS[name] - - -def has_component(name: str) -> bool: - return name in _LOADED_COMPONENTS - - -for app_mod_name in settings.INSTALLED_APPS: - if app_mod_name in IDOM_IGNORE_INSTALLED_APPS: - _logger.debug(f"{app_mod_name!r} skipped by IDOM_IGNORE_INSTALLED_APPS") - continue - - idom_mod_name = f"{app_mod_name}.idom" - - try: - idom_mod = import_module(idom_mod_name) - except ImportError: - _logger.debug(f"Skipping {idom_mod_name!r} - does not exist") - continue - - if not hasattr(idom_mod, "components"): - _logger.warning( - f"'django_idom' expected module {idom_mod_name!r} to have an " - "'components' attribute that lists its publically available components." - ) - continue - - for component_value in idom_mod.components: - if isinstance(component_value, str): - component_name = component_value - component_constructor = getattr(idom_mod, component_name) - else: - component_constructor = component_value - - try: - component_name = getattr(component_constructor, "__name__") - except AttributeError: - raise ValueError( - f"Component constructor {component_constructor} has no attribute '__name__'" - ) - - if not callable(component_constructor): - raise ValueError( - f"{component_constructor} is not a callable component constructor" - ) - - full_component_name = f"{app_mod_name}.{component_name}" - - if full_component_name in _LOADED_COMPONENTS: - raise ValueError( - f"Component constructor named {component_name!r} has already been " - f"declared by the app {app_mod_name!r}" - ) - - _LOADED_COMPONENTS[f"{app_mod_name}.{component_name}"] = component_constructor diff --git a/src/django_idom/components.py b/src/django_idom/components.py new file mode 100644 index 00000000..550aee8e --- /dev/null +++ b/src/django_idom/components.py @@ -0,0 +1,48 @@ +import inspect +from typing import Any, Optional + +from idom.core.component import component as component_deco + +from .config import IDOM_REGISTERED_COMPONENTS + + +def register_component( + component: Optional[Any] = None, + is_render_function: bool = True, + module_name: Optional[str] = None, +) -> Any: + module_name = module_name or _get_outer_frame_module_name() + if module_name is None: + raise ValueError("Could not infer module name - provide it explicitely") + + def setup(component: Any) -> Any: + if is_render_function: + component = component_deco(component) + + try: + component_name = component.__name__ + except AttributeError: + raise AttributeError("Component constructor must have a __name__ attribute") + + IDOM_REGISTERED_COMPONENTS[f"{module_name}.{component_name}"] = component + + return component + + if component is not None: + return setup(component) + else: + return setup + + +def _get_outer_frame_module_name() -> Optional[str]: + frame = inspect.currentframe() + + if frame is None: + return None + + for i in range(2): + frame = frame.f_back + if frame is None: + return None + + return frame.f_globals.get("__name__") diff --git a/src/django_idom/app_settings.py b/src/django_idom/config.py similarity index 58% rename from src/django_idom/app_settings.py rename to src/django_idom/config.py index a6fe8993..4cea020e 100644 --- a/src/django_idom/app_settings.py +++ b/src/django_idom/config.py @@ -1,7 +1,10 @@ +from typing import Dict + from django.conf import settings +from idom.core.proto import ComponentConstructor -IDOM_IGNORE_INSTALLED_APPS = set(getattr(settings, "IDOM_IGNORE_INSTALLED_APPS", [])) +IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" diff --git a/src/django_idom/app_paths.py b/src/django_idom/paths.py similarity index 94% rename from src/django_idom/app_paths.py rename to src/django_idom/paths.py index 0385f5f8..df4dd863 100644 --- a/src/django_idom/app_paths.py +++ b/src/django_idom/paths.py @@ -1,7 +1,7 @@ from django.urls import path from . import views -from .app_settings import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL +from .config import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL from .websocket_consumer import IdomAsyncWebSocketConsumer diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 8d8a8f3e..74dca9aa 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -4,9 +4,11 @@ from django import template -from django_idom.app_settings import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL - -from ..app_components import has_component +from django_idom.config import ( + IDOM_REGISTERED_COMPONENTS, + IDOM_WEB_MODULES_URL, + IDOM_WEBSOCKET_URL, +) register = template.Library() @@ -14,7 +16,8 @@ @register.inclusion_tag("idom/view.html") def idom_view(_component_id_, **kwargs): - if not has_component(_component_id_): + if _component_id_ not in IDOM_REGISTERED_COMPONENTS: + print(list(IDOM_REGISTERED_COMPONENTS)) raise ValueError(f"No component {_component_id_!r} exists") json_kwargs = json.dumps(kwargs, separators=(",", ":")) diff --git a/src/django_idom/views.py b/src/django_idom/views.py index 908b5534..c7d11781 100644 --- a/src/django_idom/views.py +++ b/src/django_idom/views.py @@ -1,7 +1,7 @@ -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from idom.config import IDOM_WED_MODULES_DIR -def web_modules_file(request, file: str) -> HttpResponse: +def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: file_path = IDOM_WED_MODULES_DIR.current.joinpath(*file.split("/")) return HttpResponse(file_path.read_text(), content_type="text/javascript") diff --git a/src/django_idom/websocket_consumer.py b/src/django_idom/websocket_consumer.py index f78a29e6..31f1aa38 100644 --- a/src/django_idom/websocket_consumer.py +++ b/src/django_idom/websocket_consumer.py @@ -9,7 +9,7 @@ from idom.core.dispatcher import dispatch_single_view from idom.core.layout import Layout, LayoutEvent -from .app_components import get_component, has_component +from .config import IDOM_REGISTERED_COMPONENTS _logger = logging.getLogger(__name__) @@ -38,12 +38,12 @@ async def receive_json(self, content: Any, **kwargs: Any) -> None: async def _run_dispatch_loop(self): view_id = self.scope["url_route"]["kwargs"]["view_id"] - if not has_component(view_id): + try: + component_constructor = IDOM_REGISTERED_COMPONENTS[view_id] + except KeyError: _logger.warning(f"Uknown IDOM view ID {view_id!r}") return - component_constructor = get_component(view_id) - query_dict = dict(parse_qsl(self.scope["query_string"].decode())) component_kwargs = json.loads(query_dict.get("kwargs", "{}")) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f4fbd9e5..00961f17 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,12 +1,14 @@ import idom +from django_idom import register_component -@idom.component + +@register_component def HelloWorld(): return idom.html.h1({"id": "hello-world"}, "Hello World!") -@idom.component +@register_component def Button(): count, set_count = idom.hooks.use_state(0) return idom.html.div( @@ -21,16 +23,16 @@ def Button(): ) -@idom.component +@register_component def ParametrizedComponent(x, y): total = x + y return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) -victory = idom.web.module_from_template("react", "victory", fallback="...") +victory = idom.web.module_from_template("react", "victory-line", fallback="...") VictoryBar = idom.web.export(victory, "VictoryBar") -@idom.component +@register_component def SimpleBarChart(): return VictoryBar() diff --git a/tests/test_app/idom.py b/tests/test_app/idom.py deleted file mode 100644 index f7f9c10a..00000000 --- a/tests/test_app/idom.py +++ /dev/null @@ -1,9 +0,0 @@ -from .components import Button, HelloWorld, ParametrizedComponent, SimpleBarChart - - -components = [ - HelloWorld, - Button, - ParametrizedComponent, - SimpleBarChart, -] diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 0968e6c1..d4434fba 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -15,9 +15,11 @@

IDOM Test Page

-
{% idom_view "test_app.HelloWorld" %}
-
{% idom_view "test_app.Button" %}
-
{% idom_view "test_app.ParametrizedComponent" x=123 y=456 %}
-
{% idom_view "test_app.SimpleBarChart" %}
+
{% idom_view "test_app.views.HelloWorld" %}
+
{% idom_view "test_app.views.Button" %}
+
+ {% idom_view "test_app.views.ParametrizedComponent" x=123 y=456 %} +
+
{% idom_view "test_app.views.SimpleBarChart" %}
diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 248235a7..adc5e7ba 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,6 +1,8 @@ from django.http import HttpResponse from django.template import loader +import test_app.components + def base_template(request): context = {} From af6601dd6f8624b910c925fc0dbaf7fa025a8bb7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 11 Aug 2021 18:22:16 -0700 Subject: [PATCH 21/36] load components using template names --- README.md | 49 +++++----------------------- src/django_idom/__init__.py | 3 +- src/django_idom/components.py | 48 --------------------------- src/django_idom/config.py | 1 + src/django_idom/templatetags/idom.py | 29 ++++++++++++++-- tests/test_app/components.py | 12 +++---- tests/test_app/templates/base.html | 8 ++--- tests/test_app/views.py | 2 -- 8 files changed, 46 insertions(+), 106 deletions(-) delete mode 100644 src/django_idom/components.py diff --git a/README.md b/README.md index f686eff9..22f35e18 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ your_app/ ├── urls.py └── sub_app/ ├── __init__.py - ├── components.py ├── idom.py ├── templates/ │ └── your-template.html @@ -58,7 +57,7 @@ To start, we'll need to use [`channels`](https://channels.readthedocs.io/en/stab create a `ProtocolTypeRouter` that will become the top of our ASGI application stack. Under the `"websocket"` protocol, we'll then add a path for IDOM's websocket consumer using `idom_websocket_path`. If you wish to change the route where this -websocket is served from see the available [settings](#settings.py). +websocket is served from, see the available [settings](#settings.py). ```python @@ -104,15 +103,13 @@ You may configure additional options as well: ```python # the base URL for all IDOM-releated resources IDOM_BASE_URL: str = "_idom/" - -# ignore these INSTALLED_APPS during component collection -IDOM_IGNORE_INSTALLED_APPS: list[str] = ["some_app", "some_other_app"] ``` ## `urls.py` You'll need to include IDOM's static web modules path using `idom_web_modules_path`. -Similarly to the `idom_websocket_path()`, these resources will be used globally. +Similarly to the `idom_websocket_path()`. If you wish to change the route where this +websocket is served from, see the available [settings](#settings.py). ```python from django_idom import idom_web_modules_path @@ -128,54 +125,26 @@ urlpatterns = [ This is where, by a convention similar to that of [`views.py`](https://docs.djangoproject.com/en/3.2/topics/http/views/), you'll define your [IDOM](https://github.com/idom-team/idom) components. Ultimately though, you should -feel free to organize your component modules you wish. +feel free to organize your component modules you wish. The components created here will +ultimately be referenced by name in `your-template.html`. `your-template.html`. ```python import idom @idom.component def Hello(name): # component names are camelcase by convention - return idom.html.h1(f"Hello {name}!") -``` - -## `sub_app/idom.py` - -This file is automatically discovered by `django-idom` when scanning the list of -[`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-INSTALLED_APPS). -All apps that export components will contain this module. - -Inside this module must be a `components` list that is imported from -[`components.py`](#sub_appcomponents.py): - -```python -from .components import Hello - -components = [ - Hello, - ... -] -``` - -You may alternately reference the components with strings for the purpose of renaming: - -```python -from .components import Hello as SomeOtherName - -components = [ - "SomeOtherName", - ... -] + return Header(f"Hello {name}!") ``` ## `sub_app/templates/your-template.html` In your templates, you may inject a view of an IDOM component into your templated HTML by using the `idom_view` template tag. This tag which requires the name of a component -to render (of the form `app_name.ComponentName`) and keyword arguments you'd like to +to render (of the form `module_name.ComponentName`) and keyword arguments you'd like to pass it from the template. ```python -idom_view app_name.ComponentName param_1="something" param_2="something-else" +idom_view module_name.ComponentName param_1="something" param_2="something-else" ``` In context this will look a bit like the following... @@ -189,7 +158,7 @@ In context this will look a bit like the following... ... - {% idom_view "your_app.sub_app.Hello" name="World" %} + {% idom_view "your_app.sub_app.components.Hello" name="World" %} ``` diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index cc58e87e..01ac6529 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,6 +1,5 @@ -from .components import register_component from .paths import idom_web_modules_path, idom_websocket_path __version__ = "0.0.1" -__all__ = ["idom_websocket_path", "idom_web_modules_path", "register_component"] +__all__ = ["idom_websocket_path", "idom_web_modules_path"] diff --git a/src/django_idom/components.py b/src/django_idom/components.py deleted file mode 100644 index 550aee8e..00000000 --- a/src/django_idom/components.py +++ /dev/null @@ -1,48 +0,0 @@ -import inspect -from typing import Any, Optional - -from idom.core.component import component as component_deco - -from .config import IDOM_REGISTERED_COMPONENTS - - -def register_component( - component: Optional[Any] = None, - is_render_function: bool = True, - module_name: Optional[str] = None, -) -> Any: - module_name = module_name or _get_outer_frame_module_name() - if module_name is None: - raise ValueError("Could not infer module name - provide it explicitely") - - def setup(component: Any) -> Any: - if is_render_function: - component = component_deco(component) - - try: - component_name = component.__name__ - except AttributeError: - raise AttributeError("Component constructor must have a __name__ attribute") - - IDOM_REGISTERED_COMPONENTS[f"{module_name}.{component_name}"] = component - - return component - - if component is not None: - return setup(component) - else: - return setup - - -def _get_outer_frame_module_name() -> Optional[str]: - frame = inspect.currentframe() - - if frame is None: - return None - - for i in range(2): - frame = frame.f_back - if frame is None: - return None - - return frame.f_globals.get("__name__") diff --git a/src/django_idom/config.py b/src/django_idom/config.py index 4cea020e..3b4b8c97 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -5,6 +5,7 @@ IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} +IDOM_IGNORE_INSTALLED_APPS = set(getattr(settings, "IDOM_IGNORE_INSTALLED_APPS", [])) IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 74dca9aa..9764a7db 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -1,4 +1,6 @@ import json +import sys +from importlib import import_module from urllib.parse import urlencode from uuid import uuid4 @@ -16,9 +18,7 @@ @register.inclusion_tag("idom/view.html") def idom_view(_component_id_, **kwargs): - if _component_id_ not in IDOM_REGISTERED_COMPONENTS: - print(list(IDOM_REGISTERED_COMPONENTS)) - raise ValueError(f"No component {_component_id_!r} exists") + _register_component(_component_id_) json_kwargs = json.dumps(kwargs, separators=(",", ":")) @@ -29,3 +29,26 @@ def idom_view(_component_id_, **kwargs): "idom_view_id": _component_id_, "idom_view_params": urlencode({"kwargs": json_kwargs}), } + + +def _register_component(full_component_name: str) -> None: + module_name, component_name = full_component_name.rsplit(".", 1) + + if module_name in sys.modules: + module = sys.modules[module_name] + else: + try: + module = import_module(module_name) + except ImportError as error: + raise RuntimeError( + f"Failed to import {module_name!r} while loading {component_name!r}" + ) from error + + try: + component = getattr(module, component_name) + except AttributeError as error: + raise RuntimeError( + f"Module {module_name!r} has no component named {component_name!r}" + ) from error + + IDOM_REGISTERED_COMPONENTS[full_component_name] = component diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 00961f17..f242b9f1 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,14 +1,12 @@ import idom -from django_idom import register_component - -@register_component +@idom.component def HelloWorld(): return idom.html.h1({"id": "hello-world"}, "Hello World!") -@register_component +@idom.component def Button(): count, set_count = idom.hooks.use_state(0) return idom.html.div( @@ -23,16 +21,16 @@ def Button(): ) -@register_component +@idom.component def ParametrizedComponent(x, y): total = x + y return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) -victory = idom.web.module_from_template("react", "victory-line", fallback="...") +victory = idom.web.module_from_template("react", "victory-bar", fallback="...") VictoryBar = idom.web.export(victory, "VictoryBar") -@register_component +@idom.component def SimpleBarChart(): return VictoryBar() diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index d4434fba..98b2fe8f 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -15,11 +15,11 @@

IDOM Test Page

-
{% idom_view "test_app.views.HelloWorld" %}
-
{% idom_view "test_app.views.Button" %}
+
{% idom_view "test_app.components.HelloWorld" %}
+
{% idom_view "test_app.components.Button" %}
- {% idom_view "test_app.views.ParametrizedComponent" x=123 y=456 %} + {% idom_view "test_app.components.ParametrizedComponent" x=123 y=456 %}
-
{% idom_view "test_app.views.SimpleBarChart" %}
+
{% idom_view "test_app.components.SimpleBarChart" %}
diff --git a/tests/test_app/views.py b/tests/test_app/views.py index adc5e7ba..248235a7 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,8 +1,6 @@ from django.http import HttpResponse from django.template import loader -import test_app.components - def base_template(request): context = {} From 60384af6f487240d84fc3d9f27c44775b1be4c16 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 11 Aug 2021 18:29:07 -0700 Subject: [PATCH 22/36] minor README tweaks --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 22f35e18..cf0f5449 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ your_app/ ├── asgi.py ├── settings.py ├── urls.py -└── sub_app/ +└── example_app/ ├── __init__.py ├── idom.py ├── templates/ @@ -120,7 +120,7 @@ urlpatterns = [ ] ``` -## `sub_app/components.py` +## `example_app/components.py` This is where, by a convention similar to that of [`views.py`](https://docs.djangoproject.com/en/3.2/topics/http/views/), you'll define @@ -136,7 +136,7 @@ def Hello(name): # component names are camelcase by convention return Header(f"Hello {name}!") ``` -## `sub_app/templates/your-template.html` +## `example_app/templates/your-template.html` In your templates, you may inject a view of an IDOM component into your templated HTML by using the `idom_view` template tag. This tag which requires the name of a component @@ -149,7 +149,7 @@ idom_view module_name.ComponentName param_1="something" param_2="something-else" In context this will look a bit like the following... -```html +```jinja {% load static %} {% load idom %} @@ -158,15 +158,15 @@ In context this will look a bit like the following... ... - {% idom_view "your_app.sub_app.components.Hello" name="World" %} + {% idom_view "your_app.example_app.components.Hello" name="World" %} ``` -Your view for this template can be defined just -[like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something). +## `example_app/views.py` -## `sub_app/views.py` +You can then serve `your-template.html` from a view just +[like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something). ```python from django.http import HttpResponse @@ -180,7 +180,7 @@ def your_template(request): ) ``` -## `sub_app/urls.py` +## `example_app/urls.py` Include your replate in the list of urlpatterns From f72b2d6430f45234e09844258001bc31ca324d78 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 11 Aug 2021 18:33:22 -0700 Subject: [PATCH 23/36] remove unused config option --- src/django_idom/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/django_idom/config.py b/src/django_idom/config.py index 3b4b8c97..4cea020e 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -5,7 +5,6 @@ IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} -IDOM_IGNORE_INSTALLED_APPS = set(getattr(settings, "IDOM_IGNORE_INSTALLED_APPS", [])) IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" From 4bcdeb522ad86d46ad7e3bee13ad51bdc32026fc Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 11 Aug 2021 18:37:38 -0700 Subject: [PATCH 24/36] use different param name in README ex --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cf0f5449..88a9fc5f 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,8 @@ ultimately be referenced by name in `your-template.html`. `your-template.html`. import idom @idom.component -def Hello(name): # component names are camelcase by convention - return Header(f"Hello {name}!") +def Hello(greeting_recipient): # component names are camelcase by convention + return Header(f"Hello {greeting_recipient}!") ``` ## `example_app/templates/your-template.html` @@ -158,7 +158,7 @@ In context this will look a bit like the following... ... - {% idom_view "your_app.example_app.components.Hello" name="World" %} + {% idom_view "your_app.example_app.components.Hello" greeting_recipient="World" %} ``` From 536968a6b4e35ccdc7a95168c80cb04a20603d64 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 17:54:29 -0700 Subject: [PATCH 25/36] cache and asyncify web module loading --- README.md | 12 +++++++ noxfile.py | 9 ++--- src/django_idom/config.py | 16 +++++++++ src/django_idom/templates/idom/view.html | 2 +- src/django_idom/views.py | 43 ++++++++++++++++++++++-- src/js/rollup.config.js | 2 +- 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 88a9fc5f..11037125 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,18 @@ You may configure additional options as well: ```python # the base URL for all IDOM-releated resources IDOM_BASE_URL: str = "_idom/" + +# Set cache size limit for loading JS files for IDOM. +# Only applies when not using Django's caching framework (see below). +IDOM_WEB_MODULE_LRU_CACHE_SIZE: int | None = None + +# Configure a cache for loading JS files +CACHES = { + # Configure a cache for loading JS files for IDOM + "idom_web_modules": {"BACKEND": ...}, + # If the above cache is not configured, then we'll use the "default" instead + "default": {"BACKEND": ...}, +} ``` ## `urls.py` diff --git a/noxfile.py b/noxfile.py index 98b80393..b28665df 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,17 +15,12 @@ @nox.session(reuse_venv=True) -def test_app(session: Session) -> None: +def manage(session: Session) -> None: """Run a manage.py command for tests/test_app""" - session.install("-r", "requirements.txt") + session.install("-r", "requirements/test-env.txt") session.install("idom[stable]") session.install("-e", ".") session.chdir("tests") - - build_js_on_commands = ["runserver"] - if set(session.posargs).intersection(build_js_on_commands): - session.run("python", "manage.py", "build_js") - session.run("python", "manage.py", *session.posargs) diff --git a/src/django_idom/config.py b/src/django_idom/config.py index 4cea020e..9d8e83bc 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -2,6 +2,7 @@ from django.conf import settings from idom.core.proto import ComponentConstructor +from django.core.cache import DEFAULT_CACHE_ALIAS IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} @@ -9,3 +10,18 @@ IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/" + +_CACHES = getattr(settings, "CACHES", {}) +if _CACHES: + if "idom_web_modules" in getattr(settings, "CACHES", {}): + IDOM_WEB_MODULE_CACHE = "idom_web_modules" + else: + IDOM_WEB_MODULE_CACHE = DEFAULT_CACHE_ALIAS +else: + IDOM_WEB_MODULE_CACHE = None + + +# the LRU cache size for the route serving IDOM_WEB_MODULES_DIR files +IDOM_WEB_MODULE_LRU_CACHE_SIZE = getattr( + settings, "IDOM_WEB_MODULE_LRU_CACHE_SIZE", None +) diff --git a/src/django_idom/templates/idom/view.html b/src/django_idom/templates/idom/view.html index f11534ef..052da65c 100644 --- a/src/django_idom/templates/idom/view.html +++ b/src/django_idom/templates/idom/view.html @@ -1,7 +1,7 @@ {% load static %}
diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 9764a7db..d294d2da 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -17,7 +17,7 @@ @register.inclusion_tag("idom/view.html") -def idom_view(_component_id_, **kwargs): +def idom_component(_component_id_, **kwargs): _register_component(_component_id_) json_kwargs = json.dumps(kwargs, separators=(",", ":")) @@ -26,8 +26,8 @@ def idom_view(_component_id_, **kwargs): "idom_websocket_url": IDOM_WEBSOCKET_URL, "idom_web_modules_url": IDOM_WEB_MODULES_URL, "idom_mount_uuid": uuid4().hex, - "idom_view_id": _component_id_, - "idom_view_params": urlencode({"kwargs": json_kwargs}), + "idom_component_id": _component_id_, + "idom_component_params": urlencode({"kwargs": json_kwargs}), } diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 98b2fe8f..c6205f82 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -15,11 +15,12 @@

IDOM Test Page

-
{% idom_view "test_app.components.HelloWorld" %}
-
{% idom_view "test_app.components.Button" %}
+
{% idom_component "test_app.components.HelloWorld" %}
+
{% idom_component "test_app.components.Button" %}
- {% idom_view "test_app.components.ParametrizedComponent" x=123 y=456 %} + {% idom_component "test_app.components.ParametrizedComponent" x=123 y=456 + %}
-
{% idom_view "test_app.components.SimpleBarChart" %}
+
{% idom_component "test_app.components.SimpleBarChart" %}
From 7093252173a8079ad062771651c468de6f2363d7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:02:04 -0700 Subject: [PATCH 27/36] README rename your_template to your_view --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 66fcdcb9..bed74d74 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ from django.http import HttpResponse from django.template import loader -def your_template(request): +def your_view(request): context = {} return HttpResponse( loader.get_template("your-template.html").render(context, request) @@ -198,10 +198,10 @@ Include your replate in the list of urlpatterns ```python from django.urls import path -from .views import your_template # define this view like any other HTML template +from .views import your_view # define this view like any other HTML template urlpatterns = [ - path("", your_template), + path("", your_view), ... ] ``` From 66b7cb3cbf6e33110df6a77dd3c1ecf24c2e3430 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:02:33 -0700 Subject: [PATCH 28/36] fix README typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bed74d74..dba4cd33 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ def your_view(request): ## `example_app/urls.py` -Include your replate in the list of urlpatterns +Include your template in the list of urlpatterns ```python from django.urls import path From 4a4fa74c7746af947655ed029cbc2ba9405f45b1 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:08:59 -0700 Subject: [PATCH 29/36] make websocket and web module paths consts --- README.md | 14 ++++++------- src/django_idom/__init__.py | 4 ++-- src/django_idom/config.py | 2 +- src/django_idom/paths.py | 40 +++++++++++++++---------------------- src/django_idom/views.py | 4 ++-- tests/test_app/asgi.py | 4 ++-- tests/test_app/urls.py | 7 ++----- 7 files changed, 32 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index dba4cd33..28f4eb05 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ your_app/ To start, we'll need to use [`channels`](https://channels.readthedocs.io/en/stable/) to create a `ProtocolTypeRouter` that will become the top of our ASGI application stack. Under the `"websocket"` protocol, we'll then add a path for IDOM's websocket consumer -using `idom_websocket_path`. If you wish to change the route where this +using `IDOM_WEB_MODULES_PATH`. If you wish to change the route where this websocket is served from, see the available [settings](#settings.py). ```python @@ -65,7 +65,7 @@ import os from django.core.asgi import get_asgi_application -from django_idom import idom_websocket_path +from django_idom import IDOM_WEB_MODULES_PATH os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") @@ -79,7 +79,7 @@ application = ProtocolTypeRouter( "http": http_asgi_app, "websocket": URLRouter( # add a path for IDOM's websocket - [idom_websocket_path()] + [IDOM_WEB_MODULES_PATH] ), } ) @@ -119,15 +119,15 @@ CACHES = { ## `urls.py` -You'll need to include IDOM's static web modules path using `idom_web_modules_path`. -Similarly to the `idom_websocket_path()`. If you wish to change the route where this +You'll need to include IDOM's static web modules path using `IDOM_WEB_MODULES_PATH`. +Similarly to the `IDOM_WEBSOCKET_PATH`. If you wish to change the route where this websocket is served from, see the available [settings](#settings.py). ```python -from django_idom import idom_web_modules_path +from django_idom import IDOM_WEB_MODULES_PATH urlpatterns = [ - idom_web_modules_path(), + IDOM_WEB_MODULES_PATH, ... ] ``` diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index 01ac6529..90a4aba1 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,5 +1,5 @@ -from .paths import idom_web_modules_path, idom_websocket_path +from .paths import IDOM_WEB_MODULES_PATH, IDOM_WEBSOCKET_PATH __version__ = "0.0.1" -__all__ = ["idom_websocket_path", "idom_web_modules_path"] +__all__ = ["IDOM_WEB_MODULES_PATH", "IDOM_WEBSOCKET_PATH"] diff --git a/src/django_idom/config.py b/src/django_idom/config.py index 9d8e83bc..9b2d9f0a 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -1,8 +1,8 @@ from typing import Dict from django.conf import settings -from idom.core.proto import ComponentConstructor from django.core.cache import DEFAULT_CACHE_ALIAS +from idom.core.proto import ComponentConstructor IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} diff --git a/src/django_idom/paths.py b/src/django_idom/paths.py index df4dd863..62a346e1 100644 --- a/src/django_idom/paths.py +++ b/src/django_idom/paths.py @@ -5,31 +5,23 @@ from .websocket_consumer import IdomAsyncWebSocketConsumer -def idom_websocket_path(*args, **kwargs): - """Return a URL resolver for :class:`IdomAsyncWebSocketConsumer` +IDOM_WEBSOCKET_PATH = path( + IDOM_WEBSOCKET_URL + "/", IdomAsyncWebSocketConsumer.as_asgi() +) +"""A URL resolver for :class:`IdomAsyncWebSocketConsumer` - While this is relatively uncommon in most Django apps, because the URL of the - websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need - to allow users to configure the URL themselves. - """ - return path( - IDOM_WEBSOCKET_URL + "/", - IdomAsyncWebSocketConsumer.as_asgi(), - *args, - **kwargs, - ) +While this is relatively uncommon in most Django apps, because the URL of the +websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need +to allow users to configure the URL themselves. +""" -def idom_web_modules_path(*args, **kwargs): - """Return a URL resolver for static web modules required by IDOM +IDOM_WEB_MODULES_PATH = path( + IDOM_WEB_MODULES_URL + "", views.web_modules_file +) +"""A URL resolver for static web modules required by IDOM - While this is relatively uncommon in most Django apps, because the URL of the - websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need - to allow users to configure the URL themselves. - """ - return path( - IDOM_WEB_MODULES_URL + "", - views.web_modules_file, - *args, - **kwargs, - ) +While this is relatively uncommon in most Django apps, because the URL of the +websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need +to allow users to configure the URL themselves. +""" diff --git a/src/django_idom/views.py b/src/django_idom/views.py index 942c2355..6ee19851 100644 --- a/src/django_idom/views.py +++ b/src/django_idom/views.py @@ -1,10 +1,10 @@ -import os import asyncio import functools +import os +from django.core.cache import caches from django.http import HttpRequest, HttpResponse from idom.config import IDOM_WED_MODULES_DIR -from django.core.cache import caches from .config import IDOM_WEB_MODULE_CACHE, IDOM_WEB_MODULE_LRU_CACHE_SIZE diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index ceb7a6cc..dcb8112c 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -11,7 +11,7 @@ from django.core.asgi import get_asgi_application -from django_idom import idom_websocket_path +from django_idom import IDOM_WEBSOCKET_PATH os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") @@ -25,6 +25,6 @@ application = ProtocolTypeRouter( { "http": http_asgi_app, - "websocket": URLRouter([idom_websocket_path()]), + "websocket": URLRouter([IDOM_WEBSOCKET_PATH]), } ) diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index ea0d9392..cd4f3da0 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -19,12 +19,9 @@ """ from django.urls import path -from django_idom import idom_web_modules_path +from django_idom import IDOM_WEB_MODULES_PATH from .views import base_template -urlpatterns = [ - path("", base_template), - idom_web_modules_path(), -] +urlpatterns = [path("", base_template), IDOM_WEB_MODULES_PATH] From b2099952da95f07aa201d9aaecc786fdde4cf613 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:10:08 -0700 Subject: [PATCH 30/36] correct terminology --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 28f4eb05..27cc9679 100644 --- a/README.md +++ b/README.md @@ -194,11 +194,11 @@ def your_view(request): ## `example_app/urls.py` -Include your template in the list of urlpatterns +Include your view in the list of urlpatterns ```python from django.urls import path -from .views import your_view # define this view like any other HTML template +from .views import your_view # define this view like any other HTML template view urlpatterns = [ path("", your_view), From b9934deb0b7838e471d7df6d8a8d6afed3f5e7a5 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:12:09 -0700 Subject: [PATCH 31/36] slim down README asgi.py description --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 27cc9679..3e9ff1f1 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,13 @@ your_app/ ## `asgi.py` -To start, we'll need to use [`channels`](https://channels.readthedocs.io/en/stable/) to -create a `ProtocolTypeRouter` that will become the top of our ASGI application stack. -Under the `"websocket"` protocol, we'll then add a path for IDOM's websocket consumer -using `IDOM_WEB_MODULES_PATH`. If you wish to change the route where this -websocket is served from, see the available [settings](#settings.py). +Follow the [`channels`](https://channels.readthedocs.io/en/stable/) +[installation guide](https://channels.readthedocs.io/en/stable/installation.html) in +order to create ASGI websockets within Django. Then, we will add a path for IDOM's +websocket consumer using `IDOM_WEBSOCKET_PATH`. + +_Note: If you wish to change the route where this websocket is served from, see the +available [settings](#settings.py)._ ```python From fa70766b1d47d3247c614b5745aafa8d683ddf24 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:13:24 -0700 Subject: [PATCH 32/36] better summarize what IDOM is --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3e9ff1f1..d180aec3 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,8 @@ `django-idom` allows you to integrate [IDOM](https://github.com/idom-team/idom) into -Django applications. IDOM being a package for building responsive user interfaces in -pure Python which is inspired by [ReactJS](https://reactjs.org/). For more information -on IDOM refer to [its documentation](https://idom-docs.herokuapp.com). +Django applications. IDOM is a pure Python library inspired by +[ReactJS](https://reactjs.org/) for creating responsive web interfaces. **You can try IDOM now in a Jupyter Notebook:** Date: Wed, 18 Aug 2021 18:14:13 -0700 Subject: [PATCH 33/36] bump copyright year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 060079c0..2a6bd62d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019 Ryan S. Morshead +Copyright (c) 2021 Ryan S. Morshead Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From aed7fc77ff377ba7224e0e2844c4ed36ebe2937e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:18:33 -0700 Subject: [PATCH 34/36] fix template formatting --- tests/test_app/templates/base.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index c6205f82..6677c1b5 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -17,10 +17,7 @@

IDOM Test Page

{% idom_component "test_app.components.HelloWorld" %}
{% idom_component "test_app.components.Button" %}
-
- {% idom_component "test_app.components.ParametrizedComponent" x=123 y=456 - %} -
+
{% idom_component "test_app.components.ParametrizedComponent" x=123 y=456 %}
{% idom_component "test_app.components.SimpleBarChart" %}
From 68aa6435c0322e9e385485e54378984186c296e4 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:21:47 -0700 Subject: [PATCH 35/36] rename your_app to your_project --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d180aec3..08c80f6d 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ pip install django-idom # Django Integration -To integrate IDOM into your application you'll need to modify or add the following files to `your_app`: +To integrate IDOM into your application you'll need to modify or add the following files to `your_project`: ``` -your_app/ +your_project/ ├── __init__.py ├── asgi.py ├── settings.py @@ -171,7 +171,7 @@ In context this will look a bit like the following... ... - {% idom_component "your_app.example_app.components.Hello" greeting_recipient="World" %} + {% idom_component "your_project.example_app.components.Hello" greeting_recipient="World" %} ``` From 83e3f7cd9c26af560b2a741d8da21f1356f5661e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 18 Aug 2021 18:28:42 -0700 Subject: [PATCH 36/36] add CODEOWNERS --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..fb6655d0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @idom-team/django