From f6468bfac4aa606634a0d40c0348308d5e40651f Mon Sep 17 00:00:00 2001 From: MrBin99 Date: Fri, 29 Jul 2022 14:36:04 +0200 Subject: [PATCH 01/29] feat(multi-config): rework how configuration definition is handled --- django_vite/templatetags/django_vite.py | 456 +++++++++++++++++------- 1 file changed, 326 insertions(+), 130 deletions(-) diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index 75b0e98..e4a17b6 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import Dict, List +from typing import Dict, List, NamedTuple, Optional, Type, Union from urllib.parse import urljoin from django import template @@ -10,66 +10,78 @@ register = template.Library() -# If using in development or production mode. -DJANGO_VITE_DEV_MODE = getattr(settings, "DJANGO_VITE_DEV_MODE", False) - -# Default Vite server protocol (http or https) -DJANGO_VITE_DEV_SERVER_PROTOCOL = getattr( - settings, "DJANGO_VITE_DEV_SERVER_PROTOCOL", "http" -) - -# Default vite server hostname. -DJANGO_VITE_DEV_SERVER_HOST = getattr( - settings, "DJANGO_VITE_DEV_SERVER_HOST", "localhost" -) - -# Default Vite server port. -DJANGO_VITE_DEV_SERVER_PORT = getattr( - settings, "DJANGO_VITE_DEV_SERVER_PORT", 3000 -) - -# Default Vite server path to HMR script. -DJANGO_VITE_WS_CLIENT_URL = getattr( - settings, "DJANGO_VITE_WS_CLIENT_URL", "@vite/client" -) - -# Location of Vite compiled assets (only used in Vite production mode). -# Must be included in your "STATICFILES_DIRS". -# In Django production mode this folder need to be collected as static -# files using "python manage.py collectstatic". -DJANGO_VITE_ASSETS_PATH = Path(getattr(settings, "DJANGO_VITE_ASSETS_PATH")) - -# Prefix for STATIC_URL -DJANGO_VITE_STATIC_URL_PREFIX = getattr( - settings, "DJANGO_VITE_STATIC_URL_PREFIX", "" -) - -DJANGO_VITE_STATIC_ROOT = ( - DJANGO_VITE_ASSETS_PATH - if DJANGO_VITE_DEV_MODE - else Path(settings.STATIC_ROOT) / DJANGO_VITE_STATIC_URL_PREFIX -) - -# Path to your manifest file generated by Vite. -# Should by in "DJANGO_VITE_ASSETS_PATH". -DJANGO_VITE_MANIFEST_PATH = getattr( - settings, - "DJANGO_VITE_MANIFEST_PATH", - DJANGO_VITE_STATIC_ROOT / "manifest.json", -) - -# Motif in the 'manifest.json' to find the polyfills generated by Vite. -DJANGO_VITE_LEGACY_POLYFILLS_MOTIF = getattr( - settings, "DJANGO_VITE_LEGACY_POLYFILLS_MOTIF", "legacy-polyfills" -) - -DJANGO_VITE_STATIC_URL = urljoin( - settings.STATIC_URL, DJANGO_VITE_STATIC_URL_PREFIX -) - -# Make sure 'DJANGO_VITE_STATIC_URL' finish with a '/' -if DJANGO_VITE_STATIC_URL[-1] != "/": - DJANGO_VITE_STATIC_URL += "/" +class DjangoViteManifest(NamedTuple): + """ + Represent an entry for a file inside the "manifest.json". + """ + + file: str + src: str + isEntry: bool + css: Optional[List[str]] = [] + imports: Optional[List[str]] = [] + + +class DjangoViteConfig(NamedTuple): + """ + Represent the Django Vite configuration structure. + """ + + # Location of Vite compiled assets (only used in Vite production mode). + assets_path: Union[Path, str] + + # If using in development or production mode. + dev_mode: bool = False + + # Default Vite server protocol (http or https) + dev_server_protocol: str = "http" + + # Default vite server hostname. + dev_server_host: str = "localhost" + + # Default Vite server port. + dev_server_port: int = 3000 + + # Default Vite server path to HMR script. + ws_client_url: str = "@vite/client" + + # Prefix for STATIC_URL. + static_url_prefix: str = "" + + # Motif in the "manifest.json" to find the polyfills generated by Vite. + legacy_polyfills_motif: str = "legacy-polyfills" + + # Path to your manifest file generated by Vite. + manifest_path: Union[Path, str] = "" + + @property + def static_root(self) -> Union[Path, str]: + """ + Compute the static root URL of assets. + + Returns: + Union[Path, str] -- Static root URL. + """ + + return ( + self.assets_path + if self.dev_mode + else Path(settings.STATIC_ROOT) / self.static_url_prefix + ) + + def get_computed_manifest_path(self) -> Union[Path, str]: + """ + Compute the path to the "manifest.json". + + Returns: + Union[Path, str] -- Path to the "manifest.json". + """ + + return ( + self.manifest_path + if self.manifest_path + else self.static_root / "manifest.json" + ) class DjangoViteAssetLoader: @@ -79,12 +91,17 @@ class DjangoViteAssetLoader: _instance = None + _configs = Dict[str, Type[DjangoViteConfig]] + _manifests: Dict[str, Type[DjangoViteManifest]] + _static_urls: Dict[str, str] + def __init__(self) -> None: raise RuntimeError("Use the instance() method instead.") def generate_vite_asset( self, path: str, + config_key: str, **kwargs: Dict[str, str], ) -> str: """ @@ -95,6 +112,7 @@ def generate_vite_asset( Arguments: path {str} -- Path to a Vite JS/TS asset to include. + config_key {str} -- Key of the configuration to use. Returns: str -- All tags to import this file in your HTML page. @@ -112,29 +130,35 @@ def generate_vite_asset( this asset in your page. """ - if DJANGO_VITE_DEV_MODE: + config = self._get_config(config_key) + static_url = self._get_static_url(config_key) + + if config.dev_mode: return DjangoViteAssetLoader._generate_script_tag( - DjangoViteAssetLoader._generate_vite_server_url(path), + DjangoViteAssetLoader._generate_vite_server_url( + path, static_url, config + ), {"type": "module"}, ) - if not self._manifest or path not in self._manifest: + manifest = self._get_manifest(config_key) + + if path not in manifest: raise RuntimeError( f"Cannot find {path} in Vite manifest " - f"at {DJANGO_VITE_MANIFEST_PATH}" + f"at {config.get_computed_manifest_path()}" ) tags = [] - manifest_entry = self._manifest[path] scripts_attrs = {"type": "module", "crossorigin": "", **kwargs} # Add dependent CSS - tags.extend(self._generate_css_files_of_asset(path, [])) + tags.extend(self._generate_css_files_of_asset(path, config_key, [])) # Add the script by itself tags.append( DjangoViteAssetLoader._generate_script_tag( - urljoin(DJANGO_VITE_STATIC_URL, manifest_entry["file"]), + urljoin(static_url, manifest[path].file), attrs=scripts_attrs, ) ) @@ -142,13 +166,17 @@ def generate_vite_asset( return "\n".join(tags) def _generate_css_files_of_asset( - self, path: str, already_processed: List[str] + self, + path: str, + config_key: str, + already_processed: List[str], ) -> List[str]: """ Generates all CSS tags for dependencies of an asset. Arguments: path {str} -- Path to an asset in the 'manifest.json'. + config_key {str} -- Key of the configuration to use. already_processed {list} -- List of already processed CSS file. Returns: @@ -156,36 +184,37 @@ def _generate_css_files_of_asset( """ tags = [] - manifest_entry = self._manifest[path] - - if "imports" in manifest_entry: - for import_path in manifest_entry["imports"]: - tags.extend( - self._generate_css_files_of_asset( - import_path, already_processed - ) + static_url = self._get_static_url(config_key) + manifest = self._get_manifest(config_key) + manifest_entry = manifest[path] + + for import_path in manifest_entry.imports: + tags.extend( + self._generate_css_files_of_asset( + import_path, already_processed ) + ) - if "css" in manifest_entry: - for css_path in manifest_entry["css"]: - if css_path not in already_processed: - tags.append( - DjangoViteAssetLoader._generate_stylesheet_tag( - urljoin(DJANGO_VITE_STATIC_URL, css_path) - ) + for css_path in manifest_entry.css: + if css_path not in already_processed: + tags.append( + DjangoViteAssetLoader._generate_stylesheet_tag( + urljoin(static_url, css_path) ) + ) - already_processed.append(css_path) + already_processed.append(css_path) return tags - def generate_vite_asset_url(self, path: str) -> str: + def generate_vite_asset_url(self, path: str, config_key: str) -> str: """ Generates only the URL of an asset managed by ViteJS. Warning, this function does not generate URLs for dependant assets. Arguments: path {str} -- Path to a Vite asset. + config_key {str} -- Key of the configuration to use. Raises: RuntimeError: If cannot find the asset path in the @@ -195,19 +224,27 @@ def generate_vite_asset_url(self, path: str) -> str: str -- The URL of this asset. """ - if DJANGO_VITE_DEV_MODE: - return DjangoViteAssetLoader._generate_vite_server_url(path) + config = self._get_config(config_key) + static_url = self._get_static_url(config_key) + + if config.dev_mode: + return DjangoViteAssetLoader._generate_vite_server_url( + path, config + ) + + manifest = self._get_manifest(config_key) - if not self._manifest or path not in self._manifest: + if path not in manifest: raise RuntimeError( f"Cannot find {path} in Vite manifest " - f"at {DJANGO_VITE_MANIFEST_PATH}" + f"at {config.get_computed_manifest_path()}" ) - return urljoin(DJANGO_VITE_STATIC_URL, self._manifest[path]["file"]) + return urljoin(static_url, manifest[path].file) def generate_vite_legacy_polyfills( self, + config_key: str, **kwargs: Dict[str, str], ) -> str: """ @@ -216,6 +253,9 @@ def generate_vite_legacy_polyfills( This tag must be included at end of the before including other legacy scripts. + Arguments: + config_key {str} -- Key of the configuration to use. + Keyword Arguments: **kwargs {Dict[str, str]} -- Adds new attributes to generated script tags. @@ -228,26 +268,31 @@ def generate_vite_legacy_polyfills( str -- The script tag to the polyfills. """ - if DJANGO_VITE_DEV_MODE: + config = self._get_config(config_key) + manifest = self._get_manifest(config_key) + static_url = self._get_static_url(config_key) + + if config.dev_mode: return "" scripts_attrs = {"nomodule": "", "crossorigin": "", **kwargs} - for path, content in self._manifest.items(): - if DJANGO_VITE_LEGACY_POLYFILLS_MOTIF in path: + for path, content in manifest.items(): + if config.legacy_polyfills_motif in path: return DjangoViteAssetLoader._generate_script_tag( - urljoin(DJANGO_VITE_STATIC_URL, content["file"]), + urljoin(static_url, content.file), attrs=scripts_attrs, ) raise RuntimeError( f"Vite legacy polyfills not found in manifest " - f"at {DJANGO_VITE_MANIFEST_PATH}" + f"at {config.get_computed_manifest_path()}" ) def generate_vite_legacy_asset( self, path: str, + config_key: str, **kwargs: Dict[str, str], ) -> str: """ @@ -258,6 +303,7 @@ def generate_vite_legacy_asset( Arguments: path {str} -- Path to a Vite asset to include (must contains '-legacy' in its name). + config_key {str} -- Key of the configuration to use. Keyword Arguments: **kwargs {Dict[str, str]} -- Adds new attributes to generated @@ -271,48 +317,127 @@ def generate_vite_legacy_asset( str -- The script tag of this legacy asset . """ - if DJANGO_VITE_DEV_MODE: + config = self._get_config(config_key) + static_url = self._get_static_url(config_key) + + if config.dev_mode: return "" - if not self._manifest or path not in self._manifest: + manifest = self._get_manifest(config_key) + + if path not in manifest: raise RuntimeError( f"Cannot find {path} in Vite manifest " - f"at {DJANGO_VITE_MANIFEST_PATH}" + f"at {config.get_computed_manifest_path()}" ) - manifest_entry = self._manifest[path] scripts_attrs = {"nomodule": "", "crossorigin": "", **kwargs} return DjangoViteAssetLoader._generate_script_tag( - urljoin(DJANGO_VITE_STATIC_URL, manifest_entry["file"]), + urljoin(static_url, manifest[path].file), attrs=scripts_attrs, ) - def _parse_manifest(self) -> None: + def _get_config(self, config_key: str) -> Type[DjangoViteConfig]: + """ + Get configuration object registered with the key passed in + parameter. + + Arguments: + config_key {str} -- Key of the configuration to retrieve. + + Raises: + RuntimeError: If no configuration exists for this key. + + Returns: + Type[DjangoViteConfig] -- The configuration. + """ + + if config_key not in self._configs: + raise RuntimeError(f'Cannot find "{config_key}" configuration') + + return self._configs[config_key] + + def _parse_manifest( + self, config_key: str + ) -> Dict[str, Type[DjangoViteManifest]]: """ Read and parse the Vite manifest file. + Arguments: + config_key {str} -- Key of the configuration to use. + Raises: RuntimeError: if cannot load the file or JSON in file is malformed. """ + config = self._get_config(config_key) + try: - manifest_file = open(DJANGO_VITE_MANIFEST_PATH, "r") - manifest_content = manifest_file.read() - manifest_file.close() - self._manifest = json.loads(manifest_content) + with open( + config.get_computed_manifest_path(), "r" + ) as manifest_file: + manifest_content = manifest_file.read() + manifest_json = json.loads(manifest_content) + + return { + k: DjangoViteManifest(**v) + for k, v in manifest_json.items() + } + except Exception as error: raise RuntimeError( f"Cannot read Vite manifest file at " - f"{DJANGO_VITE_MANIFEST_PATH} : {str(error)}" + f"{config.get_computed_manifest_path()} : {str(error)}" + ) + + def _get_manifest( + self, config_key: str + ) -> Dict[str, Type[DjangoViteManifest]]: + """ + Load if needed and parse the "manifest.json" of the specified + configuration. + + Arguments: + config_key {str} -- Key of the configuration to use. + + Returns: + Dict[str, Type[DjangoViteManifest]] -- Parsed content of + the "manifest.json" + """ + + if config_key not in self._manifests: + self._manifests[config_key] = self._parse_manifest(config_key) + + return self._manifests[config_key] + + def _get_static_url(self, config_key: str) -> str: + """ + Build the static URL of a specified configuration. + + Arguments: + config_key {str} -- Key of the configuration to use. + + Returns: + str -- The static URL. + """ + + if config_key not in self._static_urls: + config = self._get_config(config_key) + static_url = urljoin(settings.STATIC_URL, config.static_url_prefix) + + self._static_urls[config_key] = ( + static_url if static_url[-1] == "/" else static_url + "/" ) + return self._static_urls[config_key] + @classmethod def instance(cls): """ Singleton. - Uses singleton to keep parsed manifest in memory after - the first time it's loaded. + Uses singleton to keep parsed manifests in memory after + the first time they are loaded. Returns: DjangoViteAssetLoader -- only instance of the class. @@ -320,30 +445,74 @@ def instance(cls): if cls._instance is None: cls._instance = cls.__new__(cls) - cls._instance._manifest = None - - # Manifest is only used in production. - if not DJANGO_VITE_DEV_MODE: - cls._instance._parse_manifest() + cls._instance._configs = {} + cls._instance._manifests = {} + cls._instance._static_urls = {} + + if hasattr(settings, "DJANGO_VITE"): + config = getattr(settings, "DJANGO_VITE") + + for config_key, config_values in config.items(): + if isinstance(config_values, DjangoViteConfig): + cls._instance._configs[config_key] = config_values + elif isinstance(config_values, dict): + cls._instance._configs[config_key] = DjangoViteConfig( + **config_values + ) + else: + raise RuntimeError( + f"Cannot read configuration for key '{config_key}'" + ) + else: + # Warning : This branch will be remove in further + # releases. Please use new way of handling configuration. + + _config_keys = { + "DJANGO_VITE_DEV_MODE": "dev_mode", + "DJANGO_VITE_DEV_SERVER_PROTOCOL": "dev_server_protocol", + "DJANGO_VITE_DEV_SERVER_HOST": "dev_server_host", + "DJANGO_VITE_DEV_SERVER_PORT": "dev_server_port", + "DJANGO_VITE_WS_CLIENT_URL": "ws_client_url", + "DJANGO_VITE_ASSETS_PATH": "assets_path", + "DJANGO_VITE_STATIC_URL_PREFIX": "static_url_prefix", + "DJANGO_VITE_MANIFEST_PATH": "manifest_path", + "DJANGO_VITE_LEGACY_POLYFILLS_MOTIF": "legacy_polyfills_motif", + } + + config = { + _config_keys[setting_key]: getattr(settings, setting_key) + for setting_key in dir(settings) + if setting_key in _config_keys.keys() + } + + cls._instance._configs["default"] = DjangoViteConfig(**config) return cls._instance @classmethod - def generate_vite_ws_client(cls) -> str: + def generate_vite_ws_client(cls, config_key: str) -> str: """ Generates the script tag for the Vite WS client for HMR. Only used in development, in production this method returns an empty string. + Arguments: + config_key {str} -- Key of the configuration to use. + Returns: str -- The script tag or an empty string. """ - if not DJANGO_VITE_DEV_MODE: + config = cls._get_config(config_key) + static_url = cls._get_static_url(config_key) + + if not config.dev_mode: return "" return cls._generate_script_tag( - cls._generate_vite_server_url(DJANGO_VITE_WS_CLIENT_URL), + cls._generate_vite_server_url( + config.ws_client_url, static_url, config + ), {"type": "module"}, ) @@ -384,21 +553,26 @@ def _generate_stylesheet_tag(href: str) -> str: return f'' @staticmethod - def _generate_vite_server_url(path: str) -> str: + def _generate_vite_server_url( + path: str, + static_url: str, + config: Type[DjangoViteConfig], + ) -> str: """ Generates an URL to and asset served by the Vite development server. Keyword Arguments: path {str} -- Path to the asset. + config {Type[DjangoViteConfig]} -- Config object to use. Returns: str -- Full URL to the asset. """ return urljoin( - f"{DJANGO_VITE_DEV_SERVER_PROTOCOL}://" - f"{DJANGO_VITE_DEV_SERVER_HOST}:{DJANGO_VITE_DEV_SERVER_PORT}", - urljoin(DJANGO_VITE_STATIC_URL, path), + f"{config.dev_server_protocol}://" + f"{config.dev_server_host}:{config.dev_server_port}", + urljoin(static_url, path), ) @@ -408,23 +582,27 @@ def _generate_vite_server_url(path: str) -> str: @register.simple_tag @mark_safe -def vite_hmr_client() -> str: +def vite_hmr_client(config: str = "default") -> str: """ Generates the script tag for the Vite WS client for HMR. Only used in development, in production this method returns an empty string. + Arguments: + config {str} -- Configuration to use. + Returns: str -- The script tag or an empty string. """ - return DjangoViteAssetLoader.generate_vite_ws_client() + return DjangoViteAssetLoader.generate_vite_ws_client(config) @register.simple_tag @mark_safe def vite_asset( path: str, + config: str = "default", **kwargs: Dict[str, str], ) -> str: """ @@ -435,6 +613,7 @@ def vite_asset( Arguments: path {str} -- Path to a Vite JS/TS asset to include. + config {str} -- Configuration to use. Returns: str -- All tags to import this file in your HTML page. @@ -453,18 +632,22 @@ def vite_asset( """ assert path is not None + assert config is not None - return DjangoViteAssetLoader.instance().generate_vite_asset(path, **kwargs) + return DjangoViteAssetLoader.instance().generate_vite_asset( + path, config, **kwargs + ) @register.simple_tag -def vite_asset_url(path: str) -> str: +def vite_asset_url(path: str, config: str = "default") -> str: """ Generates only the URL of an asset managed by ViteJS. Warning, this function does not generate URLs for dependant assets. Arguments: path {str} -- Path to a Vite asset. + config {str} -- Configuration to use. Raises: RuntimeError: If cannot find the asset path in the @@ -475,19 +658,27 @@ def vite_asset_url(path: str) -> str: """ assert path is not None + assert config is not None - return DjangoViteAssetLoader.instance().generate_vite_asset_url(path) + return DjangoViteAssetLoader.instance().generate_vite_asset_url( + path, config + ) @register.simple_tag @mark_safe -def vite_legacy_polyfills(**kwargs: Dict[str, str]) -> str: +def vite_legacy_polyfills( + config: str = "default", **kwargs: Dict[str, str] +) -> str: """ Generates a ' @@ -567,9 +553,7 @@ def vite_legacy_polyfills(**kwargs: Dict[str, str]) -> str: str -- The script tag to the polyfills. """ - return DjangoViteAssetLoader.instance().generate_vite_legacy_polyfills( - **kwargs - ) + return DjangoViteAssetLoader.instance().generate_vite_legacy_polyfills(**kwargs) @register.simple_tag @@ -601,9 +585,7 @@ def vite_legacy_asset( assert path is not None - return DjangoViteAssetLoader.instance().generate_vite_legacy_asset( - path, **kwargs - ) + return DjangoViteAssetLoader.instance().generate_vite_legacy_asset(path, **kwargs) @register.simple_tag From 1b68f58a307634532715e7a0c5c5b93619485a8d Mon Sep 17 00:00:00 2001 From: Thijs Kramer Date: Fri, 2 Jun 2023 11:23:03 +0200 Subject: [PATCH 09/29] add troves to clarify for which django and python versions Django-Vite is intended --- setup.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index ecbbd11..35e7831 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="django-vite", version="2.1.2", - description="Integration of ViteJS in a Django project.", + description="Integration of Vite in a Django project.", long_description=README, long_description_content_type="text/markdown", author="MrBin99", @@ -20,13 +20,23 @@ include_package_data=True, packages=find_packages(), requires=[ - "Django (>=1.11)", + "Django (>=3.2)", ], install_requires=[ - "Django>=1.11", + "Django>=3.2", ], classifiers=[ "License :: OSI Approved :: Apache Software License", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], - extras_require={"dev": ["black", "flake8"]}, + extras_require={"dev": ["black"]}, ) From 9c1ed2cd42af014f76e5a1addcaa17e57a53fdd1 Mon Sep 17 00:00:00 2001 From: Thijs Kramer Date: Mon, 5 Jun 2023 21:07:52 +0200 Subject: [PATCH 10/29] fix linting errors --- django_vite/templatetags/django_vite.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index d8a14f7..4ff55de 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -301,15 +301,14 @@ def _parse_manifest(self) -> None: """ try: - manifest_file = open(DJANGO_VITE_MANIFEST_PATH, "r") - manifest_content = manifest_file.read() - manifest_file.close() + with open(DJANGO_VITE_MANIFEST_PATH, "r") as manifest_file: + manifest_content = manifest_file.read() self._manifest = json.loads(manifest_content) except Exception as error: raise RuntimeError( f"Cannot read Vite manifest file at " f"{DJANGO_VITE_MANIFEST_PATH} : {str(error)}" - ) + ) from error @classmethod def instance(cls): From 05d21215c847c1857beeb73259e3e5bf89ce8184 Mon Sep 17 00:00:00 2001 From: Thijs Kramer Date: Mon, 5 Jun 2023 22:24:38 +0200 Subject: [PATCH 11/29] add tests for templatetags --- ...test_singleton.py => test_asset_loader.py} | 0 tests/test_templatetags.py | 150 ++++++++++++++++++ tox.ini | 1 + 3 files changed, 151 insertions(+) rename tests/{test_singleton.py => test_asset_loader.py} (100%) create mode 100644 tests/test_templatetags.py diff --git a/tests/test_singleton.py b/tests/test_asset_loader.py similarity index 100% rename from tests/test_singleton.py rename to tests/test_asset_loader.py diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py new file mode 100644 index 0000000..79bb5c3 --- /dev/null +++ b/tests/test_templatetags.py @@ -0,0 +1,150 @@ +import pytest +from bs4 import BeautifulSoup +from django.template import Context, Template, TemplateSyntaxError + + +@pytest.fixture() +def override_setting(monkeypatch): + def _override_setting(setting, value): + monkeypatch.setattr( + f"django_vite.templatetags.django_vite.{setting}", + value, + ) + + return _override_setting + + +def test_vite_hmr_client_returns_script_tag(): + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "http://localhost:3000/static/@vite/client" + assert script_tag["type"] == "module" + + +def test_vite_hmr_client_kwargs(): + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client blocking="render" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag.has_attr("blocking") + assert script_tag["blocking"] == "render" + + +def test_vite_hmr_client_returns_nothing_with_dev_mode_off(settings, monkeypatch): + settings.DJANGO_VITE_DEV_MODE = False + monkeypatch.setattr( + "django_vite.templatetags.django_vite.DJANGO_VITE_DEV_MODE", + settings.DJANGO_VITE_DEV_MODE, + ) + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + assert str(soup).strip() == "" + + +def test_vite_hmr_client_uses_correct_settings(override_setting): + override_setting("DJANGO_VITE_DEV_SERVER_PROTOCOL", "https") + override_setting("DJANGO_VITE_DEV_SERVER_HOST", "127.0.0.2") + override_setting("DJANGO_VITE_DEV_SERVER_PORT", "5174") + override_setting("DJANGO_VITE_STATIC_URL", "static/") + override_setting("DJANGO_VITE_WS_CLIENT_URL", "foo/bar") + + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "https://127.0.0.2:5174/static/foo/bar" + + +def test_vite_asset_returns_script_tags(): + template = Template( + """ + {% load django_vite %} + {% vite_asset "src/entry.tsx" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "http://localhost:3000/static/src/entry.tsx" + assert script_tag["type"] == "module" + + +def test_vite_asset_raises_without_path(): + with pytest.raises(TemplateSyntaxError): + Template( + """ + {% load django_vite %} + {% vite_asset %} + """ + ) + + +def test_vite_react_refresh_happy_flow(): + template = Template( + """ + {% load django_vite %} + {% vite_react_refresh %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.script + assert not script_tag.has_attr("src") + assert script_tag.has_attr("type") + assert script_tag["type"] == "module" + assert "__vite_plugin_react_preamble_installed__" in script_tag.text + assert "http://localhost:3000/static/@react-refresh" in script_tag.text + + +def test_vite_react_refresh_returns_nothing_with_dev_mode_off(settings, monkeypatch): + settings.DJANGO_VITE_DEV_MODE = False + monkeypatch.setattr( + "django_vite.templatetags.django_vite.DJANGO_VITE_DEV_MODE", + settings.DJANGO_VITE_DEV_MODE, + ) + template = Template( + """ + {% load django_vite %} + {% vite_react_refresh %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + assert str(soup).strip() == "" + + +def test_vite_react_refresh_url_setting(override_setting): + override_setting("DJANGO_VITE_REACT_REFRESH_URL", "foobar") + template = Template( + """ + {% load django_vite %} + {% vite_react_refresh %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.script + assert "http://localhost:3000/static/foobar" in script_tag.text diff --git a/tox.ini b/tox.ini index c746865..193fa9c 100644 --- a/tox.ini +++ b/tox.ini @@ -17,6 +17,7 @@ python = [testenv] description = run unit tests deps = + beautifulsoup4 pytest>=7 pytest-cov pytest-django From ebc5f68ce671f4ed82949ec945de962ff0b93d98 Mon Sep 17 00:00:00 2001 From: Niicck Date: Fri, 13 Oct 2023 17:02:48 -0700 Subject: [PATCH 12/29] config-ify DJANGO_VITE_REACT_REFRESH_URL --- django_vite/templatetags/django_vite.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index 7b74564..89d4992 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -55,6 +55,9 @@ class DjangoViteConfig(NamedTuple): # Path to your manifest file generated by Vite. manifest_path: Union[Path, str] = "" + # Default Vite server path to React RefreshRuntime for @vitejs/plugin-react. + react_refresh_url: str = "@react-refresh" + @property def static_root(self) -> Union[Path, str]: """ @@ -763,7 +766,7 @@ def _generate_vite_server_url( ) @classmethod - def generate_vite_react_refresh_url(cls) -> str: + def generate_vite_react_refresh_url(self, config_key: str = "default") -> str: """ Generates the script for the Vite React Refresh for HMR. Only used in development, in production this method returns @@ -772,13 +775,14 @@ def generate_vite_react_refresh_url(cls) -> str: Returns: str -- The script or an empty string. """ + config = self._get_config(config_key) - if not settings.get("DJANGO_VITE_DEV_MODE"): + if not config.dev_mode: return "" return f"""""" @staticmethod - def _generate_production_server_url(path: str) -> str: + def _generate_production_server_url( + path: str, static_url_prefix="" + ) -> str: """ Generates an URL to an asset served during production. @@ -764,8 +749,8 @@ def _generate_production_server_url(path: str) -> str: """ production_server_url = path - if prefix := settings.get("DJANGO_VITE_STATIC_URL_PREFIX", ""): - if not settings.get("DJANGO_VITE_STATIC_URL_PREFIX", "").endswith("/"): + if prefix := static_url_prefix: + if not static_url_prefix.endswith("/"): prefix += "/" production_server_url = urljoin(prefix, path) @@ -779,7 +764,9 @@ def _generate_production_server_url(path: str) -> str: @register.simple_tag @mark_safe -def vite_hmr_client(config: str = "default", **kwargs: Dict[str, str]) -> str: +def vite_hmr_client( + config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str] +) -> str: """ Generates the script tag for the Vite WS client for HMR. Only used in development, in production this method returns @@ -796,14 +783,14 @@ def vite_hmr_client(config: str = "default", **kwargs: Dict[str, str]) -> str: script tags. """ - return DjangoViteAssetLoader.generate_vite_ws_client(config, **kwargs) + return DjangoViteAssetLoader.generate_vite_ws_client(config_key, **kwargs) @register.simple_tag @mark_safe def vite_asset( path: str, - config: str = "default", + config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str], ) -> str: """ @@ -833,10 +820,10 @@ def vite_asset( """ assert path is not None - assert config is not None + assert config_key is not None return DjangoViteAssetLoader.instance().generate_vite_asset( - path, config, **kwargs + path, config_key, **kwargs ) @@ -869,7 +856,7 @@ def vite_preload_asset( @register.simple_tag -def vite_asset_url(path: str, config: str = "default") -> str: +def vite_asset_url(path: str, config_key: str = DEFAULT_CONFIG_KEY) -> str: """ Generates only the URL of an asset managed by ViteJS. Warning, this function does not generate URLs for dependant assets. @@ -885,19 +872,16 @@ def vite_asset_url(path: str, config: str = "default") -> str: Returns: str -- The URL of this asset. """ - assert path is not None - assert config is not None - return DjangoViteAssetLoader.instance().generate_vite_asset_url( - path, config + path, config_key ) @register.simple_tag @mark_safe def vite_legacy_polyfills( - config: str = "default", **kwargs: Dict[str, str] + config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str] ) -> str: """ Generates a ' @@ -680,9 +665,7 @@ def _generate_stylesheet_preload_tag(href: str) -> str: @staticmethod def _generate_preload_tag(href: str, attrs: Dict[str, str]) -> str: - attrs_str = " ".join( - [f'{key}="{value}"' for key, value in attrs.items()] - ) + attrs_str = " ".join([f'{key}="{value}"' for key, value in attrs.items()]) return f'' @@ -735,9 +718,7 @@ def generate_vite_react_refresh_url( """ @staticmethod - def _generate_production_server_url( - path: str, static_url_prefix="" - ) -> str: + def _generate_production_server_url(path: str, static_url_prefix="") -> str: """ Generates an URL to an asset served during production. @@ -873,9 +854,7 @@ def vite_asset_url(path: str, config_key: str = DEFAULT_CONFIG_KEY) -> str: str -- The URL of this asset. """ assert path is not None - return DjangoViteAssetLoader.instance().generate_vite_asset_url( - path, config_key - ) + return DjangoViteAssetLoader.instance().generate_vite_asset_url(path, config_key) @register.simple_tag diff --git a/pyproject.toml b/pyproject.toml index a8f43fe..a674447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,2 @@ [tool.black] -line-length = 79 +line-length = 85 From b72a032ee3c347222c98c16964793828da117d4c Mon Sep 17 00:00:00 2001 From: Niicck Date: Fri, 13 Oct 2023 23:31:36 -0700 Subject: [PATCH 17/29] config-ify preload_vite_asset --- django_vite/templatetags/django_vite.py | 65 +++++++++++++++---------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index d737e82..7cd6b9d 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -165,7 +165,7 @@ def generate_vite_asset( scripts_attrs = {"type": "module", "crossorigin": "", **kwargs} # Add dependent CSS - tags.extend(self._generate_css_files_of_asset(path, config_key, [])) + tags.extend(self._load_css_files_of_asset(path, [], config_key)) # Add the script by itself tags.append( @@ -201,6 +201,7 @@ def generate_vite_asset( def preload_vite_asset( self, path: str, + config_key: str = DEFAULT_CONFIG_KEY, ) -> str: """ Generates a tag for this JS/TS asset, a @@ -211,6 +212,7 @@ def preload_vite_asset( Arguments: path {str} -- Path to a Vite JS/TS asset to preload. + config_key {str} -- Key of the configuration to use. Returns: str -- All tags to preload this file in your HTML page. @@ -223,18 +225,20 @@ def preload_vite_asset( str -- all tags to preload this asset. """ - if settings.DJANGO_VITE_DEV_MODE: + tags = [] + config = self._get_config(config_key) + manifest = self._get_manifest(config_key) + manifest_entry = manifest[path] + + if not config.dev_mode: return "" - if not self._manifest or path not in self._manifest: + if path not in manifest: raise RuntimeError( f"Cannot find {path} in Vite manifest " - f"at {settings.DJANGO_VITE_MANIFEST_PATH}" + f"at {config.get_computed_manifest_path()}" ) - tags = [] - manifest_entry = self._manifest[path] - # Add the script by itself script_attrs = { "type": "text/javascript", @@ -243,8 +247,10 @@ def preload_vite_asset( "as": "script", } - manifest_file = manifest_entry["file"] - url = DjangoViteAssetLoader._generate_production_server_url(manifest_file) + manifest_file = manifest_entry.file + url = DjangoViteAssetLoader._generate_production_server_url( + manifest_file, config.static_url_prefix + ) tags.append( DjangoViteAssetLoader._generate_preload_tag( url, @@ -253,13 +259,14 @@ def preload_vite_asset( ) # Add dependent CSS - tags.extend(self._preload_css_files_of_asset(path, [])) + tags.extend(self._preload_css_files_of_asset(path, [], config_key)) # Preload imports - for dep in manifest_entry.get("imports", []): - dep_manifest_entry = self._manifest[dep] - dep_file = dep_manifest_entry["file"] - url = DjangoViteAssetLoader._generate_production_server_url(dep_file) + for dependency_path in manifest_entry.imports: + dependency_file = manifest[dependency_path].file + url = DjangoViteAssetLoader._generate_production_server_url( + dependency_file, config.static_url_prefix + ) tags.append( DjangoViteAssetLoader._generate_preload_tag( url, @@ -270,29 +277,37 @@ def preload_vite_asset( return "\n".join(tags) def _preload_css_files_of_asset( - self, path: str, already_processed: List[str] + self, + path: str, + already_processed: List[str], + config_key: str = DEFAULT_CONFIG_KEY, ) -> List[str]: return self._generate_css_files_of_asset( path, already_processed, DjangoViteAssetLoader._generate_stylesheet_preload_tag, + config_key, ) def _load_css_files_of_asset( - self, path: str, already_processed: List[str] + self, + path: str, + already_processed: List[str], + config_key: str = DEFAULT_CONFIG_KEY, ) -> List[str]: return self._generate_css_files_of_asset( path, already_processed, DjangoViteAssetLoader._generate_stylesheet_tag, + config_key, ) def _generate_css_files_of_asset( self, path: str, already_processed: List[str], - config_key: str, tag_generator: Callable, + config_key: str = DEFAULT_CONFIG_KEY, ) -> List[str]: """ Generates all CSS tags for dependencies of an asset. @@ -314,7 +329,7 @@ def _generate_css_files_of_asset( for import_path in manifest_entry.imports: tags.extend( self._generate_css_files_of_asset( - import_path, already_processed, tag_generator + import_path, already_processed, tag_generator, config_key ) ) @@ -454,10 +469,11 @@ def generate_vite_legacy_asset( scripts_attrs = {"nomodule": "", "crossorigin": "", **kwargs} + url = DjangoViteAssetLoader._generate_production_server_url( + manifest[path].file, config.static_url_prefix + ) return DjangoViteAssetLoader._generate_script_tag( - DjangoViteAssetLoader._generate_production_server_url( - manifest[path].file, config.static_url_prefix - ), + url, attrs=scripts_attrs, ) @@ -702,6 +718,7 @@ def generate_vite_react_refresh_url( Returns: str -- The script or an empty string. + config_key {str} -- Key of the configuration to use. """ config = self._get_config(config_key) @@ -810,9 +827,7 @@ def vite_asset( @register.simple_tag @mark_safe -def vite_preload_asset( - path: str, -) -> str: +def vite_preload_asset(path: str, config_key: str = DEFAULT_CONFIG_KEY) -> str: """ Generates preloadmodule tag for this JS/TS asset and preloads all of its CSS and JS dependencies by reading the manifest @@ -833,7 +848,7 @@ def vite_preload_asset( assert path is not None - return DjangoViteAssetLoader.instance().preload_vite_asset(path) + return DjangoViteAssetLoader.instance().preload_vite_asset(path, config_key) @register.simple_tag From 1ff26b83ab2e5c1f9ffc794920576a29ef75f27d Mon Sep 17 00:00:00 2001 From: Niicck Date: Fri, 13 Oct 2023 23:34:34 -0700 Subject: [PATCH 18/29] remove unused 'already_processed' arg from _preload_css_files_of_asset and _load_css_files_of_asset --- django_vite/templatetags/django_vite.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index 7cd6b9d..fe25c65 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -165,7 +165,7 @@ def generate_vite_asset( scripts_attrs = {"type": "module", "crossorigin": "", **kwargs} # Add dependent CSS - tags.extend(self._load_css_files_of_asset(path, [], config_key)) + tags.extend(self._load_css_files_of_asset(path, config_key)) # Add the script by itself tags.append( @@ -259,7 +259,7 @@ def preload_vite_asset( ) # Add dependent CSS - tags.extend(self._preload_css_files_of_asset(path, [], config_key)) + tags.extend(self._preload_css_files_of_asset(path, config_key)) # Preload imports for dependency_path in manifest_entry.imports: @@ -279,12 +279,11 @@ def preload_vite_asset( def _preload_css_files_of_asset( self, path: str, - already_processed: List[str], config_key: str = DEFAULT_CONFIG_KEY, ) -> List[str]: return self._generate_css_files_of_asset( path, - already_processed, + [], DjangoViteAssetLoader._generate_stylesheet_preload_tag, config_key, ) @@ -292,12 +291,11 @@ def _preload_css_files_of_asset( def _load_css_files_of_asset( self, path: str, - already_processed: List[str], config_key: str = DEFAULT_CONFIG_KEY, ) -> List[str]: return self._generate_css_files_of_asset( path, - already_processed, + [], DjangoViteAssetLoader._generate_stylesheet_tag, config_key, ) From a71580ded2b8c7921e7889d3570585ed593ffbbc Mon Sep 17 00:00:00 2001 From: Niicck Date: Fri, 13 Oct 2023 23:37:22 -0700 Subject: [PATCH 19/29] remove _static_urls since static_url is now a property of DjangoViteConfig --- django_vite/templatetags/django_vite.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index fe25c65..e23b717 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -106,7 +106,6 @@ class DjangoViteAssetLoader: _configs = Dict[str, Type[DjangoViteConfig]] _manifests: Dict[str, Type[DjangoViteManifest]] - _static_urls: Dict[str, str] def __init__(self) -> None: raise RuntimeError("Use the instance() method instead.") @@ -556,7 +555,6 @@ def instance(cls): cls._instance = cls.__new__(cls) cls._instance._configs = {} cls._instance._manifests = {} - cls._instance._static_urls = {} if hasattr(settings, "DJANGO_VITE"): config = getattr(settings, "DJANGO_VITE") From 8e7f9e3493f79108504f70a9380a942dbe88f5eb Mon Sep 17 00:00:00 2001 From: Niicck Date: Sat, 14 Oct 2023 08:47:57 -0700 Subject: [PATCH 20/29] fix missed merge conflict issues --- django_vite/templatetags/django_vite.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index e23b717..91af64d 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -144,7 +144,6 @@ def generate_vite_asset( """ config = self._get_config(config_key) - static_url = config.static_url if config.dev_mode: return DjangoViteAssetLoader._generate_script_tag( @@ -169,7 +168,9 @@ def generate_vite_asset( # Add the script by itself tags.append( DjangoViteAssetLoader._generate_script_tag( - urljoin(static_url, manifest[path].file), + DjangoViteAssetLoader._generate_production_server_url( + manifest[path].file, config.static_url_prefix + ), attrs=scripts_attrs, ) ) @@ -183,7 +184,7 @@ def generate_vite_asset( } for dep in manifest.imports: - dep_manifest_entry = self._manifest[dep] + dep_manifest_entry = manifest[dep] dep_file = dep_manifest_entry["file"] url = DjangoViteAssetLoader._generate_production_server_url( dep_file, config.static_url_prefix @@ -261,10 +262,11 @@ def preload_vite_asset( tags.extend(self._preload_css_files_of_asset(path, config_key)) # Preload imports - for dependency_path in manifest_entry.imports: - dependency_file = manifest[dependency_path].file + for dep in manifest_entry.imports: + dep_manifest_entry = manifest[dep] + dep_file = dep_manifest_entry.file url = DjangoViteAssetLoader._generate_production_server_url( - dependency_file, config.static_url_prefix + dep_file, config.static_url_prefix ) tags.append( DjangoViteAssetLoader._generate_preload_tag( From 94a6fcb9b6f8868665d71ca5e11db15c893550e6 Mon Sep 17 00:00:00 2001 From: Niicck Date: Sat, 14 Oct 2023 18:16:36 -0700 Subject: [PATCH 21/29] create patch_settings fixture --- tests/conftest.py | 38 +++++ tests/data/staticfiles/manifest.json | 3 + tests/settings.py | 11 +- tests/test_templatetags.py | 150 ------------------ tests/tests/templatetags/test_vite_asset.py | 27 ++++ .../tests/templatetags/test_vite_asset_url.py | 0 .../templatetags/test_vite_hmr_client.py | 70 ++++++++ .../templatetags/test_vite_legacy_asset.py | 0 .../test_vite_legacy_polyfills.py | 0 .../templatetags/test_vite_react_refresh.py | 50 ++++++ tests/{ => tests}/test_asset_loader.py | 0 tox.ini | 21 +++ 12 files changed, 216 insertions(+), 154 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/data/staticfiles/manifest.json delete mode 100644 tests/test_templatetags.py create mode 100644 tests/tests/templatetags/test_vite_asset.py create mode 100644 tests/tests/templatetags/test_vite_asset_url.py create mode 100644 tests/tests/templatetags/test_vite_hmr_client.py create mode 100644 tests/tests/templatetags/test_vite_legacy_asset.py create mode 100644 tests/tests/templatetags/test_vite_legacy_polyfills.py create mode 100644 tests/tests/templatetags/test_vite_react_refresh.py rename tests/{ => tests}/test_asset_loader.py (100%) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..469c143 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +from typing import Dict, Any +import pytest +from importlib import reload +from django_vite.templatetags import django_vite + + +@pytest.fixture() +def patch_settings(settings): + """ + 1. Patch new settings into django.conf.settings. + 2. Reload django_vite module so that variables on the module level that use settings + get recalculated. + 3. Restore the original settings once the test is over. + + TODO: refactor django_vite so that we don't set variables on the module level using + settings. + """ + __PYTEST_EMPTY__ = "__PYTEST_EMPTY__" + original_settings_cache = {} + + def _patch_settings(new_settings: Dict[str, Any]): + for key, value in new_settings.items(): + original_settings_cache[key] = getattr(settings, key, __PYTEST_EMPTY__) + setattr(settings, key, value) + + reload(django_vite) + django_vite.DjangoViteAssetLoader.instance() + + yield _patch_settings + + for key, value in original_settings_cache.items(): + if value == __PYTEST_EMPTY__: + delattr(settings, key) + else: + setattr(settings, key, value) + + reload(django_vite) + django_vite.DjangoViteAssetLoader.instance() diff --git a/tests/data/staticfiles/manifest.json b/tests/data/staticfiles/manifest.json new file mode 100644 index 0000000..2a40f18 --- /dev/null +++ b/tests/data/staticfiles/manifest.json @@ -0,0 +1,3 @@ +{ + "placeholder": "placeholder" +} diff --git a/tests/settings.py b/tests/settings.py index 189a195..0801bd6 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,13 +1,11 @@ import os +from pathlib import Path -BASE_DIR = os.path.dirname(__file__) +BASE_DIR = Path(__file__).resolve().parent STATIC_URL = "static" -DJANGO_VITE_DEV_MODE = True -DJANGO_VITE_ASSETS_PATH = "/" USE_TZ = True - INSTALLED_APPS = [ "django_vite", ] @@ -26,3 +24,8 @@ }, }, ] + +# django-vite defaults +DJANGO_VITE_DEV_MODE = True +DJANGO_VITE_ASSETS_PATH = "/" +STATIC_ROOT = BASE_DIR / "data" / "staticfiles" diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py deleted file mode 100644 index 79bb5c3..0000000 --- a/tests/test_templatetags.py +++ /dev/null @@ -1,150 +0,0 @@ -import pytest -from bs4 import BeautifulSoup -from django.template import Context, Template, TemplateSyntaxError - - -@pytest.fixture() -def override_setting(monkeypatch): - def _override_setting(setting, value): - monkeypatch.setattr( - f"django_vite.templatetags.django_vite.{setting}", - value, - ) - - return _override_setting - - -def test_vite_hmr_client_returns_script_tag(): - template = Template( - """ - {% load django_vite %} - {% vite_hmr_client %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - script_tag = soup.find("script") - assert script_tag["src"] == "http://localhost:3000/static/@vite/client" - assert script_tag["type"] == "module" - - -def test_vite_hmr_client_kwargs(): - template = Template( - """ - {% load django_vite %} - {% vite_hmr_client blocking="render" %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - script_tag = soup.find("script") - assert script_tag.has_attr("blocking") - assert script_tag["blocking"] == "render" - - -def test_vite_hmr_client_returns_nothing_with_dev_mode_off(settings, monkeypatch): - settings.DJANGO_VITE_DEV_MODE = False - monkeypatch.setattr( - "django_vite.templatetags.django_vite.DJANGO_VITE_DEV_MODE", - settings.DJANGO_VITE_DEV_MODE, - ) - template = Template( - """ - {% load django_vite %} - {% vite_hmr_client %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - assert str(soup).strip() == "" - - -def test_vite_hmr_client_uses_correct_settings(override_setting): - override_setting("DJANGO_VITE_DEV_SERVER_PROTOCOL", "https") - override_setting("DJANGO_VITE_DEV_SERVER_HOST", "127.0.0.2") - override_setting("DJANGO_VITE_DEV_SERVER_PORT", "5174") - override_setting("DJANGO_VITE_STATIC_URL", "static/") - override_setting("DJANGO_VITE_WS_CLIENT_URL", "foo/bar") - - template = Template( - """ - {% load django_vite %} - {% vite_hmr_client %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - script_tag = soup.find("script") - assert script_tag["src"] == "https://127.0.0.2:5174/static/foo/bar" - - -def test_vite_asset_returns_script_tags(): - template = Template( - """ - {% load django_vite %} - {% vite_asset "src/entry.tsx" %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - script_tag = soup.find("script") - assert script_tag["src"] == "http://localhost:3000/static/src/entry.tsx" - assert script_tag["type"] == "module" - - -def test_vite_asset_raises_without_path(): - with pytest.raises(TemplateSyntaxError): - Template( - """ - {% load django_vite %} - {% vite_asset %} - """ - ) - - -def test_vite_react_refresh_happy_flow(): - template = Template( - """ - {% load django_vite %} - {% vite_react_refresh %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - script_tag = soup.script - assert not script_tag.has_attr("src") - assert script_tag.has_attr("type") - assert script_tag["type"] == "module" - assert "__vite_plugin_react_preamble_installed__" in script_tag.text - assert "http://localhost:3000/static/@react-refresh" in script_tag.text - - -def test_vite_react_refresh_returns_nothing_with_dev_mode_off(settings, monkeypatch): - settings.DJANGO_VITE_DEV_MODE = False - monkeypatch.setattr( - "django_vite.templatetags.django_vite.DJANGO_VITE_DEV_MODE", - settings.DJANGO_VITE_DEV_MODE, - ) - template = Template( - """ - {% load django_vite %} - {% vite_react_refresh %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - assert str(soup).strip() == "" - - -def test_vite_react_refresh_url_setting(override_setting): - override_setting("DJANGO_VITE_REACT_REFRESH_URL", "foobar") - template = Template( - """ - {% load django_vite %} - {% vite_react_refresh %} - """ - ) - html = template.render(Context({})) - soup = BeautifulSoup(html, "html.parser") - script_tag = soup.script - assert "http://localhost:3000/static/foobar" in script_tag.text diff --git a/tests/tests/templatetags/test_vite_asset.py b/tests/tests/templatetags/test_vite_asset.py new file mode 100644 index 0000000..c09f5d2 --- /dev/null +++ b/tests/tests/templatetags/test_vite_asset.py @@ -0,0 +1,27 @@ +import pytest +from bs4 import BeautifulSoup +from django.template import Context, Template, TemplateSyntaxError + + +def test_vite_asset_returns_script_tags(): + template = Template( + """ + {% load django_vite %} + {% vite_asset "src/entry.tsx" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "http://localhost:3000/static/src/entry.tsx" + assert script_tag["type"] == "module" + + +def test_vite_asset_raises_without_path(): + with pytest.raises(TemplateSyntaxError): + Template( + """ + {% load django_vite %} + {% vite_asset %} + """ + ) diff --git a/tests/tests/templatetags/test_vite_asset_url.py b/tests/tests/templatetags/test_vite_asset_url.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests/templatetags/test_vite_hmr_client.py b/tests/tests/templatetags/test_vite_hmr_client.py new file mode 100644 index 0000000..5f35364 --- /dev/null +++ b/tests/tests/templatetags/test_vite_hmr_client.py @@ -0,0 +1,70 @@ +from bs4 import BeautifulSoup +from django.template import Context, Template + + +def test_vite_hmr_client_returns_script_tag(): + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "http://localhost:3000/static/@vite/client" + assert script_tag["type"] == "module" + + +def test_vite_hmr_client_kwargs(): + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client blocking="render" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag.has_attr("blocking") + assert script_tag["blocking"] == "render" + + +def test_vite_hmr_client_returns_nothing_with_dev_mode_off(patch_settings): + patch_settings( + { + "DJANGO_VITE_DEV_MODE": False, + } + ) + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + assert str(soup).strip() == "" + + +def test_vite_hmr_client_uses_correct_settings(patch_settings): + patch_settings( + { + "DJANGO_VITE_DEV_SERVER_PROTOCOL": "https", + "DJANGO_VITE_DEV_SERVER_HOST": "127.0.0.2", + "DJANGO_VITE_DEV_SERVER_PORT": "5174", + "DJANGO_VITE_STATIC_URL": "static/", + "DJANGO_VITE_WS_CLIENT_URL": "foo/bar", + } + ) + + template = Template( + """ + {% load django_vite %} + {% vite_hmr_client %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "https://127.0.0.2:5174/static/foo/bar" diff --git a/tests/tests/templatetags/test_vite_legacy_asset.py b/tests/tests/templatetags/test_vite_legacy_asset.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests/templatetags/test_vite_legacy_polyfills.py b/tests/tests/templatetags/test_vite_legacy_polyfills.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests/templatetags/test_vite_react_refresh.py b/tests/tests/templatetags/test_vite_react_refresh.py new file mode 100644 index 0000000..4787112 --- /dev/null +++ b/tests/tests/templatetags/test_vite_react_refresh.py @@ -0,0 +1,50 @@ +from bs4 import BeautifulSoup +from django.template import Context, Template + + +def test_vite_react_refresh_happy_flow(): + template = Template( + """ + {% load django_vite %} + {% vite_react_refresh %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.script + assert not script_tag.has_attr("src") + assert script_tag.has_attr("type") + assert script_tag["type"] == "module" + assert "__vite_plugin_react_preamble_installed__" in script_tag.text + assert "http://localhost:3000/static/@react-refresh" in script_tag.text + + +def test_vite_react_refresh_returns_nothing_with_dev_mode_off(patch_settings): + patch_settings( + { + "DJANGO_VITE_DEV_MODE": False, + } + ) + template = Template( + """ + {% load django_vite %} + {% vite_react_refresh %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + assert str(soup).strip() == "" + + +def test_vite_react_refresh_url_setting(patch_settings): + patch_settings({"DJANGO_VITE_REACT_REFRESH_URL": "foobar"}) + template = Template( + """ + {% load django_vite %} + {% vite_react_refresh %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.script + assert "http://localhost:3000/static/foobar" in script_tag.text diff --git a/tests/test_asset_loader.py b/tests/tests/test_asset_loader.py similarity index 100% rename from tests/test_asset_loader.py rename to tests/tests/test_asset_loader.py diff --git a/tox.ini b/tox.ini index 193fa9c..fb651ac 100644 --- a/tox.ini +++ b/tox.ini @@ -32,6 +32,27 @@ commands = ignore_outcome = django-latest: True +[testenv:debugpy] +description = run unit tests with debugpy +basepython = python3.8 +deps = + beautifulsoup4 + pytest>=7 + debugpy + pytest-django + pytest-sugar + Django>=3.2,<4.0 +setenv = + DEBUGPY_PORT_PYTEST = 56789 +commands = + python \ + -m debugpy --listen localhost:{env:DEBUGPY_PORT_PYTEST} --wait-for-client \ + -m pytest \ + -o addopts="" \ + {posargs:tests} +ignore_outcome = + django-latest: True + [testenv:codestyle] basepython = python3 commands = From da1dae6ef3f57eb750a5ab41e2ab8563b4967310 Mon Sep 17 00:00:00 2001 From: Niicck Date: Sun, 15 Oct 2023 09:54:05 -0700 Subject: [PATCH 22/29] 100% test coverage --- django_vite/apps.py | 5 +- django_vite/exceptions.py | 10 ++ django_vite/templatetags/django_vite.py | 29 ++--- pyproject.toml | 4 + tests/conftest.py | 18 ++- .../custom-motif-polyfills-manifest.json | 24 ++++ .../staticfiles/custom/prefix/manifest.json | 72 ++++++++++++ tests/data/staticfiles/manifest.json | 71 +++++++++++- .../data/staticfiles/polyfills-manifest.json | 24 ++++ tests/settings.py | 6 +- tests/tests/templatetags/test_vite_asset.py | 103 +++++++++++++++++- .../tests/templatetags/test_vite_asset_url.py | 43 ++++++++ .../templatetags/test_vite_hmr_client.py | 9 +- .../templatetags/test_vite_legacy_asset.py | 49 +++++++++ .../test_vite_legacy_polyfills.py | 78 +++++++++++++ .../templatetags/test_vite_preload_asset.py | 45 ++++++++ .../templatetags/test_vite_react_refresh.py | 30 ++++- tests/tests/test_asset_loader.py | 25 +++++ 18 files changed, 608 insertions(+), 37 deletions(-) create mode 100644 django_vite/exceptions.py create mode 100644 tests/data/staticfiles/custom-motif-polyfills-manifest.json create mode 100644 tests/data/staticfiles/custom/prefix/manifest.json create mode 100644 tests/data/staticfiles/polyfills-manifest.json create mode 100644 tests/tests/templatetags/test_vite_preload_asset.py diff --git a/django_vite/apps.py b/django_vite/apps.py index 98dae96..29280ad 100644 --- a/django_vite/apps.py +++ b/django_vite/apps.py @@ -4,6 +4,7 @@ from django.core.checks import Warning, register from .templatetags.django_vite import DjangoViteAssetLoader +from .exceptions import DjangoViteManifestError class DjangoViteAppConfig(AppConfig): @@ -11,7 +12,7 @@ class DjangoViteAppConfig(AppConfig): verbose_name = "Django Vite" def ready(self) -> None: - with suppress(RuntimeError): + with suppress(DjangoViteManifestError): # Create Loader instance at startup to prevent threading problems, # but do not crash while doing so. DjangoViteAssetLoader.instance() @@ -25,7 +26,7 @@ def check_loader_instance(**kwargs): # Make Loader instance at startup to prevent threading problems DjangoViteAssetLoader.instance() return [] - except RuntimeError as exception: + except DjangoViteManifestError as exception: return [ Warning( exception, diff --git a/django_vite/exceptions.py b/django_vite/exceptions.py new file mode 100644 index 0000000..1030ca8 --- /dev/null +++ b/django_vite/exceptions.py @@ -0,0 +1,10 @@ +class DjangoViteManifestError(RuntimeError): + """Manifest parsing failed.""" + + pass + + +class DjangoViteAssetNotFoundError(RuntimeError): + """Vite Asset could not be found.""" + + pass diff --git a/django_vite/templatetags/django_vite.py b/django_vite/templatetags/django_vite.py index 56601eb..52deacf 100644 --- a/django_vite/templatetags/django_vite.py +++ b/django_vite/templatetags/django_vite.py @@ -8,8 +8,9 @@ from django.conf import settings from django.utils.safestring import mark_safe -register = template.Library() +from django_vite.exceptions import DjangoViteManifestError, DjangoViteAssetNotFoundError +register = template.Library() # If using in development or production mode. DJANGO_VITE_DEV_MODE = getattr(settings, "DJANGO_VITE_DEV_MODE", False) @@ -104,7 +105,7 @@ def generate_vite_asset( script tags. Raises: - RuntimeError: If cannot find the file path in the + DjangoViteAssetNotFoundError: If cannot find the file path in the manifest (only in production). Returns: @@ -119,7 +120,7 @@ def generate_vite_asset( ) if not self._manifest or path not in self._manifest: - raise RuntimeError( + raise DjangoViteAssetNotFoundError( f"Cannot find {path} in Vite manifest " f"at {DJANGO_VITE_MANIFEST_PATH}" ) @@ -180,7 +181,7 @@ def preload_vite_asset( str -- All tags to preload this file in your HTML page. Raises: - RuntimeError: If cannot find the file path in the + DjangoViteAssetNotFoundError: if cannot find the file path in the manifest. Returns: @@ -191,7 +192,7 @@ def preload_vite_asset( return "" if not self._manifest or path not in self._manifest: - raise RuntimeError( + raise DjangoViteAssetNotFoundError( f"Cannot find {path} in Vite manifest " f"at {DJANGO_VITE_MANIFEST_PATH}" ) @@ -297,7 +298,7 @@ def generate_vite_asset_url(self, path: str) -> str: path {str} -- Path to a Vite asset. Raises: - RuntimeError: If cannot find the asset path in the + DjangoViteAssetNotFoundError: If cannot find the asset path in the manifest (only in production). Returns: @@ -308,7 +309,7 @@ def generate_vite_asset_url(self, path: str) -> str: return DjangoViteAssetLoader._generate_vite_server_url(path) if not self._manifest or path not in self._manifest: - raise RuntimeError( + raise DjangoViteAssetNotFoundError( f"Cannot find {path} in Vite manifest " f"at {DJANGO_VITE_MANIFEST_PATH}" ) @@ -332,7 +333,7 @@ def generate_vite_legacy_polyfills( script tags. Raises: - RuntimeError: If polyfills path not found inside + DjangoViteAssetNotFoundError: If polyfills path not found inside the 'manifest.json' (only in production). Returns: @@ -353,7 +354,7 @@ def generate_vite_legacy_polyfills( attrs=scripts_attrs, ) - raise RuntimeError( + raise DjangoViteAssetNotFoundError( f"Vite legacy polyfills not found in manifest " f"at {DJANGO_VITE_MANIFEST_PATH}" ) @@ -377,7 +378,7 @@ def generate_vite_legacy_asset( script tags. Raises: - RuntimeError: If cannot find the asset path in the + DjangoViteAssetNotFoundError: If cannot find the asset path in the manifest (only in production). Returns: @@ -388,7 +389,7 @@ def generate_vite_legacy_asset( return "" if not self._manifest or path not in self._manifest: - raise RuntimeError( + raise DjangoViteAssetNotFoundError( f"Cannot find {path} in Vite manifest " f"at {DJANGO_VITE_MANIFEST_PATH}" ) @@ -408,7 +409,8 @@ def _parse_manifest(self) -> None: Read and parse the Vite manifest file. Raises: - RuntimeError: if cannot load the file or JSON in file is malformed. + DjangoViteManifestError: if cannot load the file or JSON in file is + malformed. """ try: @@ -416,7 +418,7 @@ def _parse_manifest(self) -> None: manifest_content = manifest_file.read() self._manifest = json.loads(manifest_content) except Exception as error: - raise RuntimeError( + raise DjangoViteManifestError( f"Cannot read Vite manifest file at " f"{DJANGO_VITE_MANIFEST_PATH} : {str(error)}" ) from error @@ -663,7 +665,6 @@ def vite_preload_asset( manifest (only in production). """ - assert path is not None return DjangoViteAssetLoader.instance().preload_vite_asset(path) diff --git a/pyproject.toml b/pyproject.toml index 0614b47..c332ae0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,12 @@ addopts = ''' --cov-report html --cov-report term-missing --cov-branch + --cov-fail-under=100 ''' +[tool.black] +line-length = 88 + [tool.ruff] select = [ "E", # pycodestyle diff --git a/tests/conftest.py b/tests/conftest.py index 469c143..2eedec1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,16 @@ from typing import Dict, Any import pytest from importlib import reload +from django.apps import apps from django_vite.templatetags import django_vite +def reload_django_vite(): + reload(django_vite) + django_vite_app_config = apps.get_app_config("django_vite") + django_vite_app_config.ready() + + @pytest.fixture() def patch_settings(settings): """ @@ -22,9 +29,7 @@ def _patch_settings(new_settings: Dict[str, Any]): for key, value in new_settings.items(): original_settings_cache[key] = getattr(settings, key, __PYTEST_EMPTY__) setattr(settings, key, value) - - reload(django_vite) - django_vite.DjangoViteAssetLoader.instance() + reload_django_vite() yield _patch_settings @@ -33,6 +38,9 @@ def _patch_settings(new_settings: Dict[str, Any]): delattr(settings, key) else: setattr(settings, key, value) + reload_django_vite() - reload(django_vite) - django_vite.DjangoViteAssetLoader.instance() + +@pytest.fixture() +def dev_mode_off(patch_settings): + patch_settings({"DJANGO_VITE_DEV_MODE": False}) diff --git a/tests/data/staticfiles/custom-motif-polyfills-manifest.json b/tests/data/staticfiles/custom-motif-polyfills-manifest.json new file mode 100644 index 0000000..c954ade --- /dev/null +++ b/tests/data/staticfiles/custom-motif-polyfills-manifest.json @@ -0,0 +1,24 @@ +{ + "../../vite/custom-motif-legacy": { + "file": "assets/polyfills-legacy-6e7a4b9c.js", + "isEntry": true, + "src": "../../vite/custom-motif-legacy" + }, + "src/entry-legacy.ts": { + "file": "assets/entry-legacy-4c50596f.js", + "isEntry": true, + "src": "src/entry-legacy.ts" + }, + "src/entry.css": { + "file": "assets/entry-5e7d9c21.css", + "src": "src/entry.css" + }, + "src/entry.ts": { + "css": [ + "assets/entry-5e7d9c21.css" + ], + "file": "assets/entry-8a2f6b3d.js", + "isEntry": true, + "src": "src/entry.ts" + } +} diff --git a/tests/data/staticfiles/custom/prefix/manifest.json b/tests/data/staticfiles/custom/prefix/manifest.json new file mode 100644 index 0000000..fbe4a74 --- /dev/null +++ b/tests/data/staticfiles/custom/prefix/manifest.json @@ -0,0 +1,72 @@ +{ + "src/entry.ts": { + "css": ["assets/entry-74d0d5dd.css"], + "file": "assets/entry-29e38a60.js", + "imports": [ + "_vue.esm-bundler-96356fb1.js", + "_index-62f37ad0.js", + "_vue.esm-bundler-49e6b475.js", + "__plugin-vue_export-helper-c27b6911.js", + "_messages-a0a9e13b.js", + "_use-outside-click-224980bf.js", + "_use-resolve-button-type-c5656cba.js", + "_use-event-listener-153dc639.js", + "_hidden-84ddb9a5.js", + "_apiClient-01d20438.js", + "_pinia-5d7892fd.js" + ], + "isEntry": true, + "src": "entry.ts" + }, + "src/entry.css": { + "file": "assets/entry-74d0d5dd.css", + "src": "entry.css" + }, + "src/extra.css": { + "file": "assets/extra-a9f3b2c1.css", + "src": "extra.css" + }, + "_vue.esm-bundler-96356fb1.js": { + "file": "vue.esm-bundler-96356fb1.js" + }, + "_index-62f37ad0.js": { + "file": "index-62f37ad0.js", + "imports": ["_vue.esm-bundler-49e6b475.js"] + }, + "_vue.esm-bundler-49e6b475.js": { + "file": "vue.esm-bundler-49e6b475.js", + "imports": ["_vue.esm-bundler-96356fb1.js"] + }, + "__plugin-vue_export-helper-c27b6911.js": { + "file": "_plugin-vue_export-helper-c27b6911.js" + }, + "_messages-a0a9e13b.js": { + "css": ["assets/extra-a9f3b2c1.css"], + "file": "messages-a0a9e13b.js", + "imports": ["_pinia-5d7892fd.js", "_vue.esm-bundler-96356fb1.js"] + }, + "_pinia-5d7892fd.js": { + "file": "pinia-5d7892fd.js", + "imports": ["_vue.esm-bundler-96356fb1.js"] + }, + "_use-outside-click-224980bf.js": { + "file": "use-outside-click-224980bf.js", + "imports": ["_vue.esm-bundler-96356fb1.js"] + }, + "_use-resolve-button-type-c5656cba.js": { + "file": "use-resolve-button-type-c5656cba.js", + "imports": ["_vue.esm-bundler-96356fb1.js", "_use-outside-click-224980bf.js"] + }, + "_use-event-listener-153dc639.js": { + "file": "use-event-listener-153dc639.js", + "imports": ["_vue.esm-bundler-96356fb1.js", "_use-outside-click-224980bf.js"] + }, + "_hidden-84ddb9a5.js": { + "file": "hidden-84ddb9a5.js", + "imports": ["_vue.esm-bundler-96356fb1.js", "_use-outside-click-224980bf.js"] + }, + "_apiClient-01d20438.js": { + "css": ["assets/extra-a9f3b2c1.css"], + "file": "apiClient-01d20438.js" + } +} diff --git a/tests/data/staticfiles/manifest.json b/tests/data/staticfiles/manifest.json index 2a40f18..fbe4a74 100644 --- a/tests/data/staticfiles/manifest.json +++ b/tests/data/staticfiles/manifest.json @@ -1,3 +1,72 @@ { - "placeholder": "placeholder" + "src/entry.ts": { + "css": ["assets/entry-74d0d5dd.css"], + "file": "assets/entry-29e38a60.js", + "imports": [ + "_vue.esm-bundler-96356fb1.js", + "_index-62f37ad0.js", + "_vue.esm-bundler-49e6b475.js", + "__plugin-vue_export-helper-c27b6911.js", + "_messages-a0a9e13b.js", + "_use-outside-click-224980bf.js", + "_use-resolve-button-type-c5656cba.js", + "_use-event-listener-153dc639.js", + "_hidden-84ddb9a5.js", + "_apiClient-01d20438.js", + "_pinia-5d7892fd.js" + ], + "isEntry": true, + "src": "entry.ts" + }, + "src/entry.css": { + "file": "assets/entry-74d0d5dd.css", + "src": "entry.css" + }, + "src/extra.css": { + "file": "assets/extra-a9f3b2c1.css", + "src": "extra.css" + }, + "_vue.esm-bundler-96356fb1.js": { + "file": "vue.esm-bundler-96356fb1.js" + }, + "_index-62f37ad0.js": { + "file": "index-62f37ad0.js", + "imports": ["_vue.esm-bundler-49e6b475.js"] + }, + "_vue.esm-bundler-49e6b475.js": { + "file": "vue.esm-bundler-49e6b475.js", + "imports": ["_vue.esm-bundler-96356fb1.js"] + }, + "__plugin-vue_export-helper-c27b6911.js": { + "file": "_plugin-vue_export-helper-c27b6911.js" + }, + "_messages-a0a9e13b.js": { + "css": ["assets/extra-a9f3b2c1.css"], + "file": "messages-a0a9e13b.js", + "imports": ["_pinia-5d7892fd.js", "_vue.esm-bundler-96356fb1.js"] + }, + "_pinia-5d7892fd.js": { + "file": "pinia-5d7892fd.js", + "imports": ["_vue.esm-bundler-96356fb1.js"] + }, + "_use-outside-click-224980bf.js": { + "file": "use-outside-click-224980bf.js", + "imports": ["_vue.esm-bundler-96356fb1.js"] + }, + "_use-resolve-button-type-c5656cba.js": { + "file": "use-resolve-button-type-c5656cba.js", + "imports": ["_vue.esm-bundler-96356fb1.js", "_use-outside-click-224980bf.js"] + }, + "_use-event-listener-153dc639.js": { + "file": "use-event-listener-153dc639.js", + "imports": ["_vue.esm-bundler-96356fb1.js", "_use-outside-click-224980bf.js"] + }, + "_hidden-84ddb9a5.js": { + "file": "hidden-84ddb9a5.js", + "imports": ["_vue.esm-bundler-96356fb1.js", "_use-outside-click-224980bf.js"] + }, + "_apiClient-01d20438.js": { + "css": ["assets/extra-a9f3b2c1.css"], + "file": "apiClient-01d20438.js" + } } diff --git a/tests/data/staticfiles/polyfills-manifest.json b/tests/data/staticfiles/polyfills-manifest.json new file mode 100644 index 0000000..ddebd6a --- /dev/null +++ b/tests/data/staticfiles/polyfills-manifest.json @@ -0,0 +1,24 @@ +{ + "../../vite/legacy-polyfills-legacy": { + "file": "assets/polyfills-legacy-f4c2b91e.js", + "isEntry": true, + "src": "../../vite/legacy-polyfills-legacy" + }, + "src/entry-legacy.ts": { + "file": "assets/entry-legacy-32083566.js", + "isEntry": true, + "src": "src/entry-legacy.ts" + }, + "src/entry.css": { + "file": "assets/entry-74d0d5dd.css", + "src": "src/entry.css" + }, + "src/entry.ts": { + "css": [ + "assets/entry-74d0d5dd.css" + ], + "file": "assets/entry-2e8a3a7a.js", + "isEntry": true, + "src": "src/entry.ts" + } +} diff --git a/tests/settings.py b/tests/settings.py index 0801bd6..541df85 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -3,7 +3,7 @@ BASE_DIR = Path(__file__).resolve().parent -STATIC_URL = "static" +STATIC_URL = "/static/" USE_TZ = True INSTALLED_APPS = [ @@ -25,7 +25,7 @@ }, ] -# django-vite defaults +STATIC_ROOT = BASE_DIR / "data" / "staticfiles" + DJANGO_VITE_DEV_MODE = True DJANGO_VITE_ASSETS_PATH = "/" -STATIC_ROOT = BASE_DIR / "data" / "staticfiles" diff --git a/tests/tests/templatetags/test_vite_asset.py b/tests/tests/templatetags/test_vite_asset.py index c09f5d2..7923954 100644 --- a/tests/tests/templatetags/test_vite_asset.py +++ b/tests/tests/templatetags/test_vite_asset.py @@ -1,22 +1,40 @@ import pytest from bs4 import BeautifulSoup from django.template import Context, Template, TemplateSyntaxError +from django_vite.exceptions import DjangoViteAssetNotFoundError -def test_vite_asset_returns_script_tags(): +def test_vite_asset_returns_dev_tags(): template = Template( """ {% load django_vite %} - {% vite_asset "src/entry.tsx" %} + {% vite_asset "src/entry.ts" %} """ ) html = template.render(Context({})) soup = BeautifulSoup(html, "html.parser") script_tag = soup.find("script") - assert script_tag["src"] == "http://localhost:3000/static/src/entry.tsx" + assert script_tag["src"] == "http://localhost:3000/static/src/entry.ts" assert script_tag["type"] == "module" +@pytest.mark.usefixtures("dev_mode_off") +def test_vite_asset_returns_production_tags(): + template = Template( + """ + {% load django_vite %} + {% vite_asset "src/entry.ts" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "assets/entry-29e38a60.js" + assert script_tag["type"] == "module" + links = soup.find_all("link") + assert len(links) == 13 + + def test_vite_asset_raises_without_path(): with pytest.raises(TemplateSyntaxError): Template( @@ -25,3 +43,82 @@ def test_vite_asset_raises_without_path(): {% vite_asset %} """ ) + + +@pytest.mark.usefixtures("dev_mode_off") +def test_vite_asset_raises_nonexistent_entry(): + with pytest.raises(DjangoViteAssetNotFoundError): + template = Template( + """ + {% load django_vite %} + {% vite_asset "src/fake.ts" %} + """ + ) + template.render(Context({})) + + +@pytest.mark.parametrize("prefix", ["custom/prefix", "custom/prefix/"]) +def test_vite_asset_dev_prefix(prefix, patch_settings): + patch_settings( + { + "DJANGO_VITE_STATIC_URL_PREFIX": prefix, + } + ) + template = Template( + """ + {% load django_vite %} + {% vite_asset "src/entry.ts" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert ( + script_tag["src"] == "http://localhost:3000/static/custom/prefix/src/entry.ts" + ) + assert script_tag["type"] == "module" + + +@pytest.mark.usefixtures("dev_mode_off") +@pytest.mark.parametrize("prefix", ["custom/prefix", "custom/prefix/"]) +def test_vite_asset_production_prefix(prefix, patch_settings): + patch_settings( + { + "DJANGO_VITE_STATIC_URL_PREFIX": prefix, + } + ) + template = Template( + """ + {% load django_vite %} + {% vite_asset "src/entry.ts" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "custom/prefix/assets/entry-29e38a60.js" + assert script_tag["type"] == "module" + links = soup.find_all("link") + assert len(links) == 13 + + +@pytest.mark.usefixtures("dev_mode_off") +def test_vite_asset_production_staticfiles_storage(patch_settings): + patch_settings( + { + "INSTALLED_APPS": ["django_vite", "django.contrib.staticfiles"], + } + ) + template = Template( + """ + {% load django_vite %} + {% vite_asset "src/entry.ts" %} + """ + ) + html = template.render(Context({})) + soup = BeautifulSoup(html, "html.parser") + script_tag = soup.find("script") + assert script_tag["src"] == "/static/assets/entry-29e38a60.js" + assert script_tag["type"] == "module" + links = soup.find_all("link") + assert len(links) == 13 diff --git a/tests/tests/templatetags/test_vite_asset_url.py b/tests/tests/templatetags/test_vite_asset_url.py index e69de29..e0eef07 100644 --- a/tests/tests/templatetags/test_vite_asset_url.py +++ b/tests/tests/templatetags/test_vite_asset_url.py @@ -0,0 +1,43 @@ +import pytest +from bs4 import BeautifulSoup +from django.template import Context, Template +from django_vite.exceptions import DjangoViteAssetNotFoundError + + +def test_vite_asset_url_returns_dev_url(): + template = Template( + """ + {% load django_vite %} + ' + + @staticmethod + def _generate_stylesheet_tag(href: str) -> str: + """ + Generates an HTML stylesheet tag for CSS. + + Arguments: + href {str} -- CSS file URL. + + Returns: + str -- CSS link tag. + """ + + return f'' + + def _generate_stylesheet_preload_tag(href: str) -> str: + """ + Generates an HTML preload tag for CSS. + + Arguments: + href {str} -- CSS file URL. + + Returns: + str -- CSS link tag. + """ + + return f'' + + @staticmethod + def _generate_preload_tag(href: str, attrs: Dict[str, str]) -> str: + attrs_str = " ".join([f'{key}="{value}"' for key, value in attrs.items()]) + + return f'' + + @staticmethod + def _generate_vite_server_url( + path: str, + config: Type[DjangoViteConfig], + ) -> str: + """ + Generates an URL to and asset served by the Vite development server. + + Keyword Arguments: + path {str} -- Path to the asset. + config {Type[DjangoViteConfig]} -- Config object to use. + + Returns: + str -- Full URL to the asset. + """ + + return urljoin( + f"{config.dev_server_protocol}://" + f"{config.dev_server_host}:{config.dev_server_port}", + urljoin(config.static_url, path), + ) + + def generate_vite_react_refresh_url( + self, config_key: str = DEFAULT_CONFIG_KEY + ) -> str: + """ + Generates the script for the Vite React Refresh for HMR. + Only used in development, in production this method returns + an empty string. + + Returns: + str -- The script or an empty string. + config_key {str} -- Key of the configuration to use. + """ + config = self._get_config(config_key) + + if not config.dev_mode: + return "" + + return f"""""" + + @staticmethod + def _generate_production_server_url(path: str, static_url_prefix="") -> str: + """ + Generates an URL to an asset served during production. + + Keyword Arguments: + path {str} -- Path to the asset. + + Returns: + str -- Full URL to the asset. + """ + + production_server_url = path + if prefix := static_url_prefix: + if not static_url_prefix.endswith("/"): + prefix += "/" + production_server_url = urljoin(prefix, path) + + if apps.is_installed("django.contrib.staticfiles"): + from django.contrib.staticfiles.storage import staticfiles_storage + + return staticfiles_storage.url(production_server_url) + + return production_server_url + + +@register.simple_tag +@mark_safe +def vite_hmr_client( + config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str] +) -> str: + """ + Generates the script tag for the Vite WS client for HMR. + Only used in development, in production this method returns + an empty string. + + Arguments: + config {str} -- Configuration to use. + + Returns: + str -- The script tag or an empty string. + + Keyword Arguments: + **kwargs {Dict[str, str]} -- Adds new attributes to generated + script tags. + """ + + return DjangoViteAssetLoader.instance().generate_vite_ws_client( + config_key, **kwargs + ) + + +@register.simple_tag +@mark_safe +def vite_asset( + path: str, + config_key: str = DEFAULT_CONFIG_KEY, + **kwargs: Dict[str, str], +) -> str: + """ + Generates a ' + + @staticmethod + def _generate_stylesheet_tag(href: str) -> str: + """ + Generates an HTML stylesheet tag for CSS. + + Arguments: + href {str} -- CSS file URL. + + Returns: + str -- CSS link tag. + """ + + return f'' + + def _generate_stylesheet_preload_tag(href: str) -> str: + """ + Generates an HTML preload tag for CSS. + + Arguments: + href {str} -- CSS file URL. + + Returns: + str -- CSS link tag. + """ + + return f'' + + @staticmethod + def _generate_preload_tag(href: str, attrs: Dict[str, str]) -> str: + attrs_str = " ".join([f'{key}="{value}"' for key, value in attrs.items()]) + + return f'' + + @staticmethod + def _generate_vite_server_url( + path: str, + config: Type[DjangoViteConfig], + ) -> str: + """ + Generates an URL to and asset served by the Vite development server. + + Keyword Arguments: + path {str} -- Path to the asset. + config {Type[DjangoViteConfig]} -- Config object to use. + + Returns: + str -- Full URL to the asset. + """ + + return urljoin( + f"{config.dev_server_protocol}://" + f"{config.dev_server_host}:{config.dev_server_port}", + urljoin(config.static_url, path), + ) + + def generate_vite_react_refresh_url( + self, config_key: str = DEFAULT_CONFIG_KEY + ) -> str: + """ + Generates the script for the Vite React Refresh for HMR. + Only used in development, in production this method returns + an empty string. + + Returns: + str -- The script or an empty string. + config_key {str} -- Key of the configuration to use. + """ + config = self._get_config(config_key) + + if not config.dev_mode: + return "" + + return f"""""" + + @staticmethod + def _generate_production_server_url(path: str, static_url_prefix="") -> str: + """ + Generates an URL to an asset served during production. + + Keyword Arguments: + path {str} -- Path to the asset. + + Returns: + str -- Full URL to the asset. + """ + + production_server_url = path + if prefix := static_url_prefix: + if not static_url_prefix.endswith("/"): + prefix += "/" + production_server_url = urljoin(prefix, path) + + if apps.is_installed("django.contrib.staticfiles"): + from django.contrib.staticfiles.storage import staticfiles_storage + + return staticfiles_storage.url(production_server_url) + + return production_server_url + + +@register.simple_tag +@mark_safe +def vite_hmr_client( + config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str] +) -> str: + """ + Generates the script tag for the Vite WS client for HMR. + Only used in development, in production this method returns + an empty string. + + Arguments: + config {str} -- Configuration to use. + + Returns: + str -- The script tag or an empty string. + + Keyword Arguments: + **kwargs {Dict[str, str]} -- Adds new attributes to generated + script tags. + """ + + return DjangoViteAssetLoader.instance().generate_vite_ws_client( + config_key, **kwargs + ) + + +@register.simple_tag +@mark_safe +def vite_asset( + path: str, + config_key: str = DEFAULT_CONFIG_KEY, + **kwargs: Dict[str, str], +) -> str: + """ + Generates a ' - - @staticmethod - def _generate_stylesheet_tag(href: str) -> str: - """ - Generates an HTML stylesheet tag for CSS. - - Arguments: - href {str} -- CSS file URL. - - Returns: - str -- CSS link tag. - """ - - return f'' + url = self._get_dev_server_url(self.ws_client_url) - def _generate_stylesheet_preload_tag(href: str) -> str: - """ - Generates an HTML preload tag for CSS. - - Arguments: - href {str} -- CSS file URL. - - Returns: - str -- CSS link tag. - """ - - return f'' - - @staticmethod - def _generate_preload_tag(href: str, attrs: Dict[str, str]) -> str: - attrs_str = " ".join([f'{key}="{value}"' for key, value in attrs.items()]) - - return f'' - - @staticmethod - def _generate_vite_server_url( - path: str, - config: Type[DjangoViteConfig], - ) -> str: - """ - Generates an URL to and asset served by the Vite development server. - - Keyword Arguments: - path {str} -- Path to the asset. - config {Type[DjangoViteConfig]} -- Config object to use. - - Returns: - str -- Full URL to the asset. - """ - - return urljoin( - f"{config.dev_server_protocol}://" - f"{config.dev_server_host}:{config.dev_server_port}", - urljoin(config.static_url, path), + return TagGenerator.script( + url, + attrs={"type": "module", **kwargs}, ) - def generate_vite_react_refresh_url( - self, config_key: str = DEFAULT_CONFIG_KEY - ) -> str: + def generate_vite_react_refresh_url(self) -> str: """ Generates the script for the Vite React Refresh for HMR. Only used in development, in production this method returns @@ -725,234 +546,217 @@ def generate_vite_react_refresh_url( str -- The script or an empty string. config_key {str} -- Key of the configuration to use. """ - config = self._get_config(config_key) - if not config.dev_mode: + if not self.dev_mode: return "" + url = self._get_dev_server_url(self.react_refresh_url) + return f"""""" - @staticmethod - def _generate_production_server_url(path: str, static_url_prefix="") -> str: - """ - Generates an URL to an asset served during production. - - Keyword Arguments: - path {str} -- Path to the asset. - - Returns: - str -- Full URL to the asset. - """ - - production_server_url = path - if prefix := static_url_prefix: - if not static_url_prefix.endswith("/"): - prefix += "/" - production_server_url = urljoin(prefix, path) - - if apps.is_installed("django.contrib.staticfiles"): - from django.contrib.staticfiles.storage import staticfiles_storage - - return staticfiles_storage.url(production_server_url) - - return production_server_url - -@register.simple_tag -@mark_safe -def vite_hmr_client( - config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str] -) -> str: - """ - Generates the script tag for the Vite WS client for HMR. - Only used in development, in production this method returns - an empty string. - - Arguments: - config {str} -- Configuration to use. - - Returns: - str -- The script tag or an empty string. - - Keyword Arguments: - **kwargs {Dict[str, str]} -- Adds new attributes to generated - script tags. - """ - - return DjangoViteAssetLoader.instance().generate_vite_ws_client( - config_key, **kwargs - ) - - -@register.simple_tag -@mark_safe -def vite_asset( - path: str, - config_key: str = DEFAULT_CONFIG_KEY, - **kwargs: Dict[str, str], -) -> str: +class DjangoViteAssetLoader: """ - Generates a ' @staticmethod - def _generate_stylesheet_tag(href: str) -> str: + def stylesheet(href: str) -> Tag: """ Generates an HTML stylesheet tag for CSS. @@ -672,7 +38,8 @@ def _generate_stylesheet_tag(href: str) -> str: return f'' - def _generate_stylesheet_preload_tag(href: str) -> str: + @staticmethod + def stylesheet_preload(href: str) -> Tag: """ Generates an HTML preload tag for CSS. @@ -686,273 +53,7 @@ def _generate_stylesheet_preload_tag(href: str) -> str: return f'' @staticmethod - def _generate_preload_tag(href: str, attrs: Dict[str, str]) -> str: + def preload(href: str, attrs: Dict[str, str]) -> Tag: attrs_str = " ".join([f'{key}="{value}"' for key, value in attrs.items()]) return f'' - - @staticmethod - def _generate_vite_server_url( - path: str, - config: Type[DjangoViteConfig], - ) -> str: - """ - Generates an URL to and asset served by the Vite development server. - - Keyword Arguments: - path {str} -- Path to the asset. - config {Type[DjangoViteConfig]} -- Config object to use. - - Returns: - str -- Full URL to the asset. - """ - - return urljoin( - f"{config.dev_server_protocol}://" - f"{config.dev_server_host}:{config.dev_server_port}", - urljoin(config.static_url, path), - ) - - def generate_vite_react_refresh_url( - self, config_key: str = DEFAULT_CONFIG_KEY - ) -> str: - """ - Generates the script for the Vite React Refresh for HMR. - Only used in development, in production this method returns - an empty string. - - Returns: - str -- The script or an empty string. - config_key {str} -- Key of the configuration to use. - """ - config = self._get_config(config_key) - - if not config.dev_mode: - return "" - - return f"""""" - - @staticmethod - def _generate_production_server_url(path: str, static_url_prefix="") -> str: - """ - Generates an URL to an asset served during production. - - Keyword Arguments: - path {str} -- Path to the asset. - - Returns: - str -- Full URL to the asset. - """ - - production_server_url = path - if prefix := static_url_prefix: - if not static_url_prefix.endswith("/"): - prefix += "/" - production_server_url = urljoin(prefix, path) - - if apps.is_installed("django.contrib.staticfiles"): - from django.contrib.staticfiles.storage import staticfiles_storage - - return staticfiles_storage.url(production_server_url) - - return production_server_url - - -@register.simple_tag -@mark_safe -def vite_hmr_client( - config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str] -) -> str: - """ - Generates the script tag for the Vite WS client for HMR. - Only used in development, in production this method returns - an empty string. - - Arguments: - config {str} -- Configuration to use. - - Returns: - str -- The script tag or an empty string. - - Keyword Arguments: - **kwargs {Dict[str, str]} -- Adds new attributes to generated - script tags. - """ - - return DjangoViteAssetLoader.instance().generate_vite_ws_client( - config_key, **kwargs - ) - - -@register.simple_tag -@mark_safe -def vite_asset( - path: str, - config_key: str = DEFAULT_CONFIG_KEY, - **kwargs: Dict[str, str], -) -> str: - """ - Generates a ' - - @staticmethod - def _generate_stylesheet_tag(href: str) -> str: - """ - Generates an HTML stylesheet tag for CSS. - - Arguments: - href {str} -- CSS file URL. - - Returns: - str -- CSS link tag. - """ - - return f'' - - def _generate_stylesheet_preload_tag(href: str) -> str: - """ - Generates an HTML preload tag for CSS. - - Arguments: - href {str} -- CSS file URL. - - Returns: - str -- CSS link tag. - """ - - return f'' - - @staticmethod - def _generate_preload_tag(href: str, attrs: Dict[str, str]) -> str: - attrs_str = " ".join([f'{key}="{value}"' for key, value in attrs.items()]) - - return f'' - - @staticmethod - def _generate_vite_server_url( - path: str, - config: Type[DjangoViteConfig], - ) -> str: - """ - Generates an URL to and asset served by the Vite development server. - - Keyword Arguments: - path {str} -- Path to the asset. - config {Type[DjangoViteConfig]} -- Config object to use. - - Returns: - str -- Full URL to the asset. - """ - - return urljoin( - f"{config.dev_server_protocol}://" - f"{config.dev_server_host}:{config.dev_server_port}", - urljoin(config.static_url, path), - ) - - def generate_vite_react_refresh_url( - self, config_key: str = DEFAULT_CONFIG_KEY - ) -> str: - """ - Generates the script for the Vite React Refresh for HMR. - Only used in development, in production this method returns - an empty string. - - Returns: - str -- The script or an empty string. - config_key {str} -- Key of the configuration to use. - """ - config = self._get_config(config_key) - - if not config.dev_mode: - return "" - - return f"""""" - - @staticmethod - def _generate_production_server_url(path: str, static_url_prefix="") -> str: - """ - Generates an URL to an asset served during production. - - Keyword Arguments: - path {str} -- Path to the asset. - - Returns: - str -- Full URL to the asset. - """ - - production_server_url = path - if prefix := static_url_prefix: - if not static_url_prefix.endswith("/"): - prefix += "/" - production_server_url = urljoin(prefix, path) - - if apps.is_installed("django.contrib.staticfiles"): - from django.contrib.staticfiles.storage import staticfiles_storage - - return staticfiles_storage.url(production_server_url) - - return production_server_url - - @register.simple_tag @mark_safe -def vite_hmr_client( - config_key: str = DEFAULT_CONFIG_KEY, **kwargs: Dict[str, str] -) -> str: +def vite_hmr_client(app: str = DEFAULT_APP_NAME, **kwargs: Dict[str, str]) -> str: """ Generates the script tag for the Vite WS client for HMR. Only used in development, in production this method returns @@ -786,16 +28,14 @@ def vite_hmr_client( script tags. """ - return DjangoViteAssetLoader.instance().generate_vite_ws_client( - config_key, **kwargs - ) + return DjangoViteAssetLoader.instance().generate_vite_ws_client(app, **kwargs) @register.simple_tag @mark_safe def vite_asset( path: str, - config_key: str = DEFAULT_CONFIG_KEY, + app: str = DEFAULT_APP_NAME, **kwargs: Dict[str, str], ) -> str: """ @@ -823,18 +63,13 @@ def vite_asset( str -- The