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/templatetags/idom.py b/src/django_idom/templatetags/idom.py index cd83ebec..d6692b5b 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -1,17 +1,15 @@ 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, IDOM_WS_MAX_RECONNECT_DELAY, ) +from django_idom.utils import _register_component register = template.Library() @@ -33,26 +31,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 diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py new file mode 100644 index 00000000..178995c9 --- /dev/null +++ b/src/django_idom/utils.py @@ -0,0 +1,123 @@ +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 = re.compile(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.""" + 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) diff --git a/tests/test_app/tests.py b/tests/test_app/tests.py deleted file mode 100644 index 1b94fe47..00000000 --- a/tests/test_app/tests.py +++ /dev/null @@ -1,53 +0,0 @@ -import os - -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 - - -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 diff --git a/tests/test_app/tests/__init__.py b/tests/test_app/tests/__init__.py new file mode 100644 index 00000000..fff5a11e --- /dev/null +++ b/tests/test_app/tests/__init__.py @@ -0,0 +1 @@ +from . import * # noqa: F401, F403 diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py new file mode 100644 index 00000000..cb10cca9 --- /dev/null +++ b/tests/test_app/tests/test_components.py @@ -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 diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py new file mode 100644 index 00000000..3e418f1d --- /dev/null +++ b/tests/test_app/tests/test_regex.py @@ -0,0 +1,29 @@ +from django.test import TestCase + +from django_idom.utils import COMPONENT_REGEX + + +class RegexTests(TestCase): + def test_component_regex(self): + for component in { + r'{%idom_component "my.component"%}', + r"{%idom_component 'my.component'%}", + r'{% idom_component "my.component" %}', + r"{% idom_component 'my.component' %}", + r'{% idom_component "my.component" class="my_thing" %}', + r'{% idom_component "my.component" class="my_thing" attr="attribute" %}', + }: + self.assertRegex(component, COMPONENT_REGEX) + + for fake_component in { + r'{% not_a_real_thing "my.component" %}', + r"{% idom_component my.component %}", + r"""{% idom_component 'my.component" %}""", + r'{ idom_component "my.component" }', + r'{{ idom_component "my.component" }}', + r"idom_component", + r"{%%}", + r" ", + r"", + }: + self.assertNotRegex(fake_component, COMPONENT_REGEX)