diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 037e3c2f..6c803e3e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Documentation - url: https://idom-docs.herokuapp.com/ - about: Refer to the documentation before starting a discussion - - name: Community Support - url: https://github.com/idom-team/idom/discussions - about: Report issues, request features, and ask questions + - name: Start a Discussion + url: https://github.com/idom-team/django-idom/discussions + about: Report issues, request features, ask questions, and share ideas diff --git a/.github/ISSUE_TEMPLATE/issue-form.yml b/.github/ISSUE_TEMPLATE/issue-form.yml new file mode 100644 index 00000000..342316ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-form.yml @@ -0,0 +1,23 @@ +name: Plan a Task +description: Create a detailed plan of action (ONLY START AFTER DISCUSSION PLEASE 🙏). +labels: ["flag: triage"] +body: +- type: textarea + attributes: + label: Current Situation + description: Discuss how things currently are, why they require action, and any relevant prior discussion/context. + validations: + required: false +- type: textarea + attributes: + label: Proposed Actions + description: Describe what ought to be done, and why that will address the reasons for action mentioned above. + validations: + required: false +- type: textarea + attributes: + label: Work Items + description: | + An itemized list or detailed description of the work to be done to based on the proposed actions above. + validations: + required: false diff --git a/.github/workflows/publish-js.yml b/.github/workflows/publish-js.yml new file mode 100644 index 00000000..7c4049e6 --- /dev/null +++ b/.github/workflows/publish-js.yml @@ -0,0 +1,29 @@ +# This workflows will upload a Javscript Package using NPM to npmjs.org when a release is created +# For more information see: https://docs.github.com/en/actions/guides/publishing-nodejs-packages + +name: Publish Javascript + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + # Setup .npmrc file to publish to npm + - uses: actions/setup-node@v2 + with: + node-version: "14.x" + registry-url: "https://registry.npmjs.org" + - name: Prepare Release + working-directory: ./src/js + run: | + npm install -g npm@7.22.0 + npm install + - name: Publish Release + working-directory: ./src/js + run: npm run publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }} diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml new file mode 100644 index 00000000..4b4de80e --- /dev/null +++ b/.github/workflows/publish-py.yml @@ -0,0 +1,36 @@ +# This workflows will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Publish Python + +on: + release: + types: [published] + +jobs: + release-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2-beta + with: + node-version: "14.x" + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install latest NPM + run: | + npm install -g npm@7.22.0 + npm --version + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements/build-pkg.txt + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build --sdist --wheel --outdir dist . + twine upload dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d3097b33..2675c040 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v2 - uses: nanasess/setup-chromedriver@master diff --git a/.gitignore b/.gitignore index 1a06cffb..2477a484 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Django IDOM Build Artifacts -src/django_idom/static/js +src/django_idom/static/ # Django # logs @@ -55,6 +55,7 @@ wheels/ *.egg *.manifest *.spec +MANIFEST # Installer logs pip-log.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ac99bf59 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + +## [Unreleased] + +### Added + +- Nothing (yet) + +## [0.0.2] - 2022-01-30 + +### Added + +- Ability to declare the HTML class of the top-level component `div` +- `name = ...` parameter to IDOM HTTP paths for use with `django.urls.reverse()` +- Cache versioning to automatically invalidate old web module files from the cache backend +- Automatic pre-population of the IDOM component registry +- Type hinting for `IdomWebsocket` + +### Changed + +- Fetching web modules from disk and/or cache is now fully async +- Static files are now contained within a `django_idom/` parent folder +- Upgraded IDOM to version `0.36.0` +- Minimum Django version required is now `4.0` +- Minimum Python version required is now `3.8` + +### Removed + +- `IDOM_WEB_MODULES_PATH` has been replaced with Django `include(...)` +- `IDOM_WS_MAX_RECONNECT_DELAY` has been renamed to `IDOM_WS_MAX_RECONNECT_TIMEOUT` +- `idom_web_modules` cache backend has been renamed to `idom` + +### Fixed + +- Increase test timeout values to prevent false positives +- Windows compatibility for building Django-IDOM + +### Security + +- Fixed potential directory travesal attack on the IDOM web modules URL + +## [0.0.1] - 2021-08-18 + +### Added + +- Support for IDOM within the Django + +[unreleased]: https://github.com/idom-team/django-idom/compare/0.0.2...HEAD +[0.0.2]: https://github.com/idom-team/django-idom/compare/0.0.1...0.0.2 +[0.0.1]: https://github.com/idom-team/django-idom/releases/tag/0.0.1 diff --git a/MANIFEST.in b/MANIFEST.in index a43e500c..5b9e5fe2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -recursive-include src/django_idom/static/ * -recursive-include src/django_idom/templates/ *.html +recursive-include src/django_idom/static * +recursive-include src/django_idom/templates *.html diff --git a/README.md b/README.md index 08c80f6d..9d3120e6 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,142 @@ -# Django IDOM +# Django IDOM · [![Tests](https://github.com/idom-team/django-idom/workflows/Test/badge.svg?event=push)](https://github.com/idom-team/django-idom/actions?query=workflow%3ATest) [![PyPI Version](https://img.shields.io/pypi/v/django-idom.svg)](https://pypi.python.org/pypi/django-idom) [![License](https://img.shields.io/badge/License-MIT-purple.svg)](https://github.com/idom-team/django-idom/blob/main/LICENSE) - - Tests - - - Version Info - - - License: MIT - - -`django-idom` allows you to integrate [IDOM](https://github.com/idom-team/idom) into -Django applications. IDOM is a pure Python library inspired by -[ReactJS](https://reactjs.org/) for creating responsive web interfaces. +`django-idom` allows Django to integrate with [IDOM](https://github.com/idom-team/idom), a reactive Python web framework for building **interactive websites without needing a single line of Javascript**. **You can try IDOM now in a Jupyter Notebook:** - Binder +# Quick Example -# Install Django IDOM - -```bash -pip install django-idom -``` +## `example_app/components.py` -# Django Integration +This is where you'll define your [IDOM](https://github.com/idom-team/idom) components. Ultimately though, you should +feel free to organize your component modules as you wish. Any components created will ultimately be referenced +by Python dotted path in `your-template.html`. -To integrate IDOM into your application you'll need to modify or add the following files to `your_project`: +```python +from idom import component, html +from django_idom import IdomWebsocket +# Components are CamelCase by ReactJS convention +@component +def Hello(websocket: IdomWebsocket, greeting_recipient: str): + return html.header(f"Hello {greeting_recipient}!") ``` -your_project/ -├── __init__.py -├── asgi.py -├── settings.py -├── urls.py -└── example_app/ - ├── __init__.py - ├── idom.py - ├── templates/ - │ └── your-template.html - └── urls.py -``` - -## `asgi.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`. +## [`example_app/templates/your-template.html`](https://docs.djangoproject.com/en/dev/topics/templates/) -_Note: If you wish to change the route where this websocket is served from, see the -available [settings](#settings.py)._ +In your templates, you may add IDOM components into your HTML by using the `idom_component` +template tag. This tag requires the dotted path to the component function. -```python +Additonally, you can pass in keyworded arguments into your component function. -import os +In context this will look a bit like the following... -from django.core.asgi import get_asgi_application +```jinja +{% load idom %} -from django_idom import IDOM_WEB_MODULES_PATH + + + + Example Project + -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") + + {% idom_component "my_django_project.example_app.components.Hello" greeting_recipient="World" %} + + +``` -# Fetch ASGI application before importing dependencies that require ORM models. -http_asgi_app = get_asgi_application() +# Installation -from channels.routing import ProtocolTypeRouter, URLRouter +Install `django-idom` via pip. -application = ProtocolTypeRouter( - { - "http": http_asgi_app, - "websocket": URLRouter( - # add a path for IDOM's websocket - [IDOM_WEB_MODULES_PATH] - ), - } -) +```bash +pip install django-idom ``` -## `settings.py` +You'll also need to modify a few files in your Django project. + +## [`settings.py`](https://docs.djangoproject.com/en/dev/topics/settings/) -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: +In your settings you'll need to add `channels` and `django_idom` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS). ```python INSTALLED_APPS = [ ..., + "channels", "django_idom", ] + +# Ensure ASGI_APPLICATION is set properly based on your project name! +ASGI_APPLICATION = "my_django_project.asgi.application" ``` -You may configure additional options as well: +**Optional:** You can also configure IDOM settings. ```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 +# If "idom" cache is not configured, then we'll use "default" instead 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": ...}, + "idom": {"BACKEND": ...}, } + +# Maximum seconds between two reconnection attempts that would cause the client give up. +# 0 will disable reconnection. +IDOM_WS_MAX_RECONNECT_TIMEOUT: int = 604800 + +# The URL for IDOM to serve websockets +IDOM_WEBSOCKET_URL: str = "idom/" ``` -## `urls.py` +## [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) -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). +Add IDOM HTTP paths to your `urlpatterns`. ```python -from django_idom import IDOM_WEB_MODULES_PATH +from django.urls import include, path urlpatterns = [ - IDOM_WEB_MODULES_PATH, + path("idom/", include("django_idom.http.urls")), ... ] ``` -## `example_app/components.py` +## [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) -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. The components created here will -ultimately be referenced by name in `your-template.html`. `your-template.html`. +Register IDOM's websocket using `IDOM_WEBSOCKET_PATH`. -```python -import idom - -@idom.component -def Hello(greeting_recipient): # component names are camelcase by convention - return Header(f"Hello {greeting_recipient}!") -``` - -## `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_component` template tag. This tag which requires the name of a component -to render (of the form `module_name.ComponentName`) and keyword arguments you'd like to -pass it from the template. - -```python -idom_component module_name.ComponentName param_1="something" param_2="something-else" -``` - -In context this will look a bit like the following... - -```jinja - -{% load static %} -{% load idom %} - - - - - ... - {% idom_component "your_project.example_app.components.Hello" greeting_recipient="World" %} - - -``` - -## `example_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). +_Note: If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html)._ ```python -from django.http import HttpResponse -from django.template import loader - -def your_view(request): - context = {} - return HttpResponse( - loader.get_template("your-template.html").render(context, request) - ) -``` - -## `example_app/urls.py` +import os +from django.core.asgi import get_asgi_application -Include your view in the list of urlpatterns +# Ensure DJANGO_SETTINGS_MODULE is set properly based on your project name! +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "my_django_project.settings") +django_asgi_app = get_asgi_application() -```python -from django.urls import path -from .views import your_view # define this view like any other HTML template view +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.sessions import SessionMiddlewareStack +from django_idom import IDOM_WEBSOCKET_PATH -urlpatterns = [ - path("", your_view), - ... -] +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": SessionMiddlewareStack( + AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH])) + ), + } +) ``` # Developer Guide diff --git a/requirements/build-pkg.txt b/requirements/build-pkg.txt new file mode 100644 index 00000000..82f40eaf --- /dev/null +++ b/requirements/build-pkg.txt @@ -0,0 +1,3 @@ +twine +wheel +build diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index b823d656..43b36f85 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,2 +1,3 @@ -channels<4.0.0 # Django websocket features -idom<1.0.0 # Python React +channels <4.0.0 +idom >=0.36.0, <0.37.0 +aiofile >=3.0, <4.0 diff --git a/requirements/test-env.txt b/requirements/test-env.txt index c100c316..6f2e151e 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,6 +1,3 @@ django selenium - -# required due issue with channels: -# https://github.com/django/channels/issues/1639#issuecomment-817994671 -twisted<21 +twisted diff --git a/setup.py b/setup.py index adeb5937..9957e4ae 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ def list2cmdline(cmd_list): package = { "name": name, - "python_requires": ">=3.7", + "python_requires": ">=3.8", "packages": find_packages(str(src_dir)), "package_dir": {"": "src"}, "description": "Control the web with Python", @@ -52,15 +52,14 @@ def list2cmdline(cmd_list): "zip_safe": False, "classifiers": [ "Framework :: Django", - "Framework :: Django :: 3.1", - "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", "Operating System :: OS Independent", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Multimedia :: Graphics", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Environment :: Web Environment", ], } diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index 90a4aba1..88821fcd 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,5 +1,6 @@ -from .paths import IDOM_WEB_MODULES_PATH, IDOM_WEBSOCKET_PATH +from .websocket.consumer import IdomWebsocket +from .websocket.paths import IDOM_WEBSOCKET_PATH -__version__ = "0.0.1" -__all__ = ["IDOM_WEB_MODULES_PATH", "IDOM_WEBSOCKET_PATH"] +__version__ = "0.0.2" +__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket"] diff --git a/src/django_idom/apps.py b/src/django_idom/apps.py new file mode 100644 index 00000000..6a963664 --- /dev/null +++ b/src/django_idom/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + +from django_idom.utils import ComponentPreloader + + +class DjangoIdomConfig(AppConfig): + name = "django_idom" + + def ready(self): + # Populate the IDOM component registry when Django is ready + ComponentPreloader().register_all() diff --git a/src/django_idom/config.py b/src/django_idom/config.py index 9b2d9f0a..c024d77d 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -1,27 +1,19 @@ from typing import Dict from django.conf import settings -from django.core.cache import DEFAULT_CACHE_ALIAS +from django.core.cache import DEFAULT_CACHE_ALIAS, caches from idom.core.proto import ComponentConstructor IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {} -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/" +IDOM_WEBSOCKET_URL = getattr(settings, "IDOM_WEBSOCKET_URL", "idom/") +IDOM_WS_MAX_RECONNECT_TIMEOUT = getattr( + settings, "IDOM_WS_MAX_RECONNECT_TIMEOUT", 604800 +) -_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 +# Determine if using Django caching or LRU cache +if "idom" in getattr(settings, "CACHES", {}): + IDOM_CACHE = caches["idom"] 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 -) + IDOM_CACHE = caches[DEFAULT_CACHE_ALIAS] diff --git a/src/django_idom/http/__init__.py b/src/django_idom/http/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/django_idom/http/urls.py b/src/django_idom/http/urls.py new file mode 100644 index 00000000..019a603e --- /dev/null +++ b/src/django_idom/http/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + + +app_name = "idom" + +urlpatterns = [ + path( + "web_module/", + views.web_modules_file, + name="web_modules", + ) +] diff --git a/src/django_idom/http/views.py b/src/django_idom/http/views.py new file mode 100644 index 00000000..552d7418 --- /dev/null +++ b/src/django_idom/http/views.py @@ -0,0 +1,34 @@ +import os + +from aiofile import async_open +from django.core.exceptions import SuspiciousOperation +from django.http import HttpRequest, HttpResponse +from idom.config import IDOM_WED_MODULES_DIR + +from django_idom.config import IDOM_CACHE + + +async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: + """Gets JavaScript required for IDOM modules at runtime. These modules are + returned from cache if available.""" + web_modules_dir = IDOM_WED_MODULES_DIR.current + path = web_modules_dir.joinpath(*file.split("/")).absolute() + + # Prevent attempts to walk outside of the web modules dir + if str(web_modules_dir) != os.path.commonpath((path, web_modules_dir)): + raise SuspiciousOperation( + "Attempt to access a directory outside of IDOM_WED_MODULES_DIR." + ) + + # Fetch the file from cache, if available + last_modified_time = os.stat(path).st_mtime + cache_key = f"django_idom:web_module:{str(path).lstrip(str(web_modules_dir))}" + response = await IDOM_CACHE.aget(cache_key, version=last_modified_time) + if response is None: + async with async_open(path, "r") as fp: + response = HttpResponse(await fp.read(), content_type="text/javascript") + await IDOM_CACHE.adelete(cache_key) + await IDOM_CACHE.aset( + cache_key, response, timeout=None, version=last_modified_time + ) + return response diff --git a/src/django_idom/paths.py b/src/django_idom/paths.py deleted file mode 100644 index 62a346e1..00000000 --- a/src/django_idom/paths.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.urls import path - -from . import views -from .config import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL -from .websocket_consumer import 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. -""" - - -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. -""" diff --git a/src/django_idom/templates/idom/view.html b/src/django_idom/templates/idom/component.html similarity index 64% rename from src/django_idom/templates/idom/view.html rename to src/django_idom/templates/idom/component.html index adfc3a6d..fc8ba6f8 100644 --- a/src/django_idom/templates/idom/view.html +++ b/src/django_idom/templates/idom/component.html @@ -1,12 +1,13 @@ {% load static %} -
+