Skip to content

Auto populate IDOM component registry #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Nov 2, 2021
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/django_idom/apps.py
Original file line number Diff line number Diff line change
@@ -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()
32 changes: 2 additions & 30 deletions src/django_idom/templatetags/idom.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import json
import sys
from importlib import import_module
from urllib.parse import urlencode
from uuid import uuid4

from django import template

from django_idom.config import (
IDOM_REGISTERED_COMPONENTS,
IDOM_WEB_MODULES_URL,
IDOM_WEBSOCKET_URL,
)
from django_idom.config import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL
from django_idom.utils import _register_component


register = template.Library()
Expand All @@ -31,26 +26,3 @@ def idom_component(_component_id_, **kwargs):
"idom_component_id": _component_id_,
"idom_component_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
124 changes: 124 additions & 0 deletions src/django_idom/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import logging
import os
import re
from fnmatch import fnmatch
from importlib import import_module
from typing import Set

from django.template import engines
from django.utils.encoding import smart_str

from django_idom.config import IDOM_REGISTERED_COMPONENTS


COMPONENT_REGEX_PATTERN = r"{% *idom_component ((\"[^\"']*\")|('[^\"']*')).*?%}"
_logger = logging.getLogger(__name__)


def _register_component(full_component_name: str) -> None:
if full_component_name in IDOM_REGISTERED_COMPONENTS:
return

module_name, component_name = full_component_name.rsplit(".", 1)

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


class ComponentPreloader:
def register_all(self):
"""Registers all IDOM components found within Django templates."""
# Get all template folder paths
paths = self._get_paths()
# Get all HTML template files
templates = self._get_templates(paths)
# Get all components
components = self._get_components(templates)
# Register all components
self._register_components(components)

def _get_loaders(self):
"""Obtains currently configured template loaders."""
template_source_loaders = []
for e in engines.all():
if hasattr(e, "engine"):
template_source_loaders.extend(
e.engine.get_template_loaders(e.engine.loaders)
)
loaders = []
for loader in template_source_loaders:
if hasattr(loader, "loaders"):
loaders.extend(loader.loaders)
else:
loaders.append(loader)
return loaders

def _get_paths(self) -> Set:
"""Obtains a set of all template directories."""
paths = set()
for loader in self._get_loaders():
try:
module = import_module(loader.__module__)
get_template_sources = getattr(module, "get_template_sources", None)
if get_template_sources is None:
get_template_sources = loader.get_template_sources
paths.update(smart_str(origin) for origin in get_template_sources(""))
except (ImportError, AttributeError, TypeError):
pass

return paths

def _get_templates(self, paths: Set) -> Set:
"""Obtains a set of all HTML template paths."""
extensions = [".html"]
templates = set()
for path in paths:
for root, dirs, files in os.walk(path, followlinks=False):
templates.update(
os.path.join(root, name)
for name in files
if not name.startswith(".")
and any(fnmatch(name, "*%s" % glob) for glob in extensions)
)

return templates

def _get_components(self, templates: Set) -> Set:
"""Obtains a set of all IDOM components by parsing HTML templates."""
component_regex = re.compile(COMPONENT_REGEX_PATTERN)
components = set()
for template in templates:
try:
with open(template, "r", encoding="utf-8") as template_file:
match = component_regex.findall(template_file.read())
if not match:
continue
components.update(
[group[0].replace('"', "").replace("'", "") for group in match]
)
except Exception:
pass

return components

def _register_components(self, components: Set) -> None:
"""Registers all IDOM components in an iterable."""
for component in components:
try:
_register_component(component)
_logger.info("IDOM has registered component %s", component)
except Exception:
_logger.warning("IDOM failed to register component %s", component)
53 changes: 0 additions & 53 deletions tests/test_app/tests.py

This file was deleted.

1 change: 1 addition & 0 deletions tests/test_app/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import * # noqa: F401, F403
57 changes: 57 additions & 0 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import os
import sys

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


# These tests are broken on Windows due to Selenium
if sys.platform != "win32":

class TestIdomCapabilities(ChannelsLiveServerTestCase):
def setUp(self):
self.driver = make_driver(5, 5)
self.driver.get(self.live_server_url)

def tearDown(self) -> None:
self.driver.quit()

def wait(self, timeout=10):
return WebDriverWait(self.driver, timeout)

def wait_until(self, condition, timeout=10):
return self.wait(timeout).until(lambda driver: condition())

def test_hello_world(self):
self.driver.find_element_by_id("hello-world")

def test_counter(self):
button = self.driver.find_element_by_id("counter-inc")
count = self.driver.find_element_by_id("counter-num")

for i in range(5):
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(20).until(
expected_conditions.visibility_of_element_located(
(By.CLASS_NAME, "VictoryContainer")
)
)


def make_driver(page_load_timeout, implicit_wait_timeout):
options = webdriver.ChromeOptions()
options.headless = bool(int(os.environ.get("SELENIUM_HEADLESS", 0)))
driver = webdriver.Chrome(options=options)
driver.set_page_load_timeout(page_load_timeout)
driver.implicitly_wait(implicit_wait_timeout)
return driver
41 changes: 41 additions & 0 deletions tests/test_app/tests/test_regex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from django.test import TestCase

from django_idom.utils import COMPONENT_REGEX_PATTERN


class RegexTests(TestCase):
def test_component_regex(self):
self.assertRegex(r'{%idom_component "my.component"%}', COMPONENT_REGEX_PATTERN)
self.assertRegex(r"{%idom_component 'my.component'%}", COMPONENT_REGEX_PATTERN)
self.assertRegex(
r'{% idom_component "my.component" %}', COMPONENT_REGEX_PATTERN
)
self.assertRegex(
r"{% idom_component 'my.component' %}", COMPONENT_REGEX_PATTERN
)
self.assertRegex(
r'{% idom_component "my.component" class="my_thing" %}',
COMPONENT_REGEX_PATTERN,
)
self.assertRegex(
r'{% idom_component "my.component" class="my_thing" attr="attribute" %}',
COMPONENT_REGEX_PATTERN,
)
self.assertNotRegex(
r'{% not_a_real_thing "my.component" %}', COMPONENT_REGEX_PATTERN
)
self.assertNotRegex(
r"{% idom_component my.component %}", COMPONENT_REGEX_PATTERN
)
self.assertNotRegex(
r"""{% idom_component 'my.component" %}""", COMPONENT_REGEX_PATTERN
)
self.assertNotRegex(
r'{ idom_component "my.component" }', COMPONENT_REGEX_PATTERN
)
self.assertNotRegex(
r'{{ idom_component "my.component" }}', COMPONENT_REGEX_PATTERN
)
self.assertNotRegex(r"idom_component", COMPONENT_REGEX_PATTERN)
self.assertNotRegex(r"{%%}", COMPONENT_REGEX_PATTERN)
self.assertNotRegex(r"", COMPONENT_REGEX_PATTERN)