Source code for ox.assets.base

from __future__ import annotations

from dataclasses import dataclass
from functools import cached_property
import itertools
import logging
from typing import Generator, Iterable
from pathlib import Path


from django.conf import settings
from django.templatetags.static import static

from ox.utils.functional import Owned


__all__ = ("Asset", "Assets", "unique_dfs")


logger = logging.getLogger()


[docs] @dataclass(frozen=True) class Asset(Owned): """A single Asset's dependency. It target a specific static module and can provide: - javascript and development javascript distribution file - css directory """ name: str """Asset's package/module name, used as is for generated import map and in order to find packages in ``node_modules``.""" js: str """Static directory name, defaults to :py:attr:`~name`.""" css: str """Include this javascript file.""" static_dir: str """Include this css file.""" dist: str """Distribution path. The file will be looked up there.""" def __init__( self, name: str, js: str = "", css: str = "", dev_js: str = "", static_dir: str = "", dist: str = "dist", ): self.__dict__.update( { "name": name, "static_dir": static_dir or name, "js": settings.DEBUG and dev_js or js, "css": css, "dist": dist, } )
[docs] class Assets(Owned): """ This class represent a package for a Django application. It is responsible to: - provide a list of CSS, JS to include; - generate the import map (in ``ox/core/base.html``); - provide list of directories and dependencies to include in statics; - provide list of exported files to include into the rendered templates; A package can be related to a Django application or not: - related to a Django app: in this case it is expected that the package resides in ``app_dir/assets`` (see :py:class:`ox.utils.functional.Owned`, and :py:meth:`contribute`); - not related to a Django app: in this case, a path to the package is provided; """ name: str = "" """ Package name """ path: Path | None = None """ Path of the package (or workspace's packages dir). The actual package path is retrieved using :py:attr:`package_path`. """ includes: list[Asset] | None = None """ Exported assets. """ dependencies: list[Asset] | None = None """ Dependencies. """ def __init__(self, name="", path=None, includes=None, dependencies=None, base_dir=None, owner=None): self.name = name self.path = path self.includes = includes or [] self.dependencies = dependencies or [] owner and self.contribute(owner)
[docs] @cached_property def package_path(self) -> Path: """Get the actual path to package directory. :raises RuntimeError: assets is related to an app and the assets directory \ does not exists or when not related to a path and no :py:attr:`path` \ is provided. """ if self.path: return self.path if self._owner: # First look in app's `assets` directory path = Path(self._owner.path) / "assets" / "package.json" if path and path.exists(): return path.parent raise RuntimeError(f"Assets directory ({path}) does not exists for `{self._owner.name}`.") raise RuntimeError("Assets is not linked to an application and no path is provided.")
[docs] def contribute(self, owner): """TODO""" self = super().contribute(owner) # ensure cached properties are cleaned up for key in ("package_path", "import_map", "css_urls", "js_urls"): if key in self.__dict__: del self.__dict__[key] self.name = self.name or owner.npm_package or owner.label return self
[docs] def get_locations(self) -> Generator[tuple[str, Path]]: """Get locations of npm packages. Return a generator that yield tuples of ``(prefix, path_to_dist)``. It doesn't yield values from inner :py:class:`Assets` instances. """ path = self.package_path / "dist" if path.is_dir(): yield self.name, path path = self.package_path / "node_modules" for asset in self.dependencies: if isinstance(asset, Asset): location = path / asset.name / asset.dist if location.is_dir(): yield (asset.static_dir, location) else: logger.warn(f"Directory does not exists for asset {asset.name}: {location}")
[docs] @cached_property def css_urls(self) -> set[str]: """A list of CSS static urls.""" return {url for _, url in self.get_urls("css")}
[docs] @cached_property def js_urls(self) -> set[str]: """A list of JS static urls.""" return {url for _, url in self.get_urls("js")}
[docs] @cached_property def import_map(self) -> dict[str, str]: """A list of CSS static urls.""" return {"imports": dict(self.get_urls("js"))}
[docs] def get_urls(self, attr: str) -> Generator[tuple[str, str]]: """Iter over assets and yield tuples ``(asset.name, attribute)``.""" return (val for val in itertools.chain(self.get_dependencies_urls(attr), self.get_includes_urls(attr)))
[docs] def get_dependencies_urls(self, attr: str) -> Generator[tuple[str, str]]: """Return urls of dependencies, based on ``attr`` attribute value.""" for asset in self.dependencies: if isinstance(asset, Assets): yield from asset.get_urls(attr) elif val := self.get_dependency_url(asset, attr): yield val
[docs] def get_dependency_url(self, asset: Asset, attr: str) -> tuple[str, str] | None: """Return url of an""" if val := getattr(asset, attr): return asset.name, static(f"{asset.static_dir}/{val}")
[docs] def get_includes_urls(self, attr: str) -> Generator[tuple[str, str]]: """Return urls of includes, based on ``attr`` attribute value.""" # For includes, the algorithm is slightly different, as it takes # `self.name` as prefix. # Assets is not handled here as it does not make sense. This may # change in the future. for asset in self.includes: if val := self.get_include_url(asset, attr): yield val
def get_include_url(self, asset: Asset, attr: str) -> tuple[str, str] | None: if val := getattr(asset, attr): if asset.static_dir: prefix = self.name + "/" + asset.static_dir else: prefix = self.name return prefix, static(f"{self.name}/{val}")
def unique_dfs(assets_list: Iterable[Assets]) -> list[Assets]: """Run a DFS lookup over assets lists and dependencies. :return list of unique Assets instance. """ items, todo = [], list(assets_list) while todo: item = todo.pop(0) if item not in items: todo.extend(dep for dep in item.dependencies if isinstance(dep, Assets) and dep not in todo) items.append(item) return items