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