diff --git a/subjugate/__init__.py b/subjugate/__init__.py index f102a9c..3e4d95b 100644 --- a/subjugate/__init__.py +++ b/subjugate/__init__.py @@ -1 +1,54 @@ __version__ = "0.0.1" + +from abc import ABC, abstractmethod +from django.urls import reverse +from django.apps import apps +from django.utils.encoding import iri_to_uri +from urllib.parse import quote, urljoin +from django.utils.lorem_ipsum import paragraphs, words +from types import SimpleNamespace + + +class SubjugateTemplate(ABC): + def __init__(self, engine, context, request): + self.engine = engine + self.context = context + self.request = request + self.vars = SimpleNamespace(**self.context.flatten()) + + def extend(self, template_name, **kwargs): + return self.engine.get_template(template_name).render(self.context, self.request, **kwargs) + + def csrf_token(self): + return self.context.get("csrf_token", "") if self.context else "" + + def url(self, path): + return reverse(path) + + def static(self, path): + # Damn, I thought doing this was janky as hell but it's official! + if apps.is_installed("django.contrib.staticfiles"): + from django.contrib.staticfiles.storage import staticfiles_storage + + return staticfiles_storage.url(path) + else: + try: + from django.conf import settings + except ImportError: + prefix = "" + else: + prefix = iri_to_uri(getattr(settings, "STATIC_URL", "")) + return urljoin(prefix, quote(path)) + + def filter(self, name, value): + return self.engine.filters[name](value) + + def lorem_words(self, count=1, common=True): + return words(count, common).split(" ") + + def lorem_paragraphs(self, count=1, common=True): + return paragraphs(count, common) + + @abstractmethod + def render(**kwargs): + ... diff --git a/subjugate/template/__init__.py b/subjugate/template/__init__.py new file mode 100644 index 0000000..991faa4 --- /dev/null +++ b/subjugate/template/__init__.py @@ -0,0 +1,77 @@ +from importlib.util import module_from_spec +from django.template.context import Context, make_context +from django.template.exceptions import TemplateSyntaxError + +from subjugate import SubjugateTemplate as SubjugateUserTemplate + +import inspect +import re +import importlib.machinery + + +class SubjugateTemplate: + def find_template_class(self, module, base_class): + userclass = next( + (cls for _, cls in inspect.getmembers(module, inspect.isclass) + if cls.__module__ == module.__name__ and issubclass(cls, base_class)), + None + ) + + if not userclass: + raise TemplateSyntaxError("Templates must have a subjugate of SubjugateTemplate") + + return userclass + + def code_to_module(self, code, template_name): + module_name = self.modulify(template_name) + spec = importlib.machinery.ModuleSpec( + name=module_name, + loader=None, + origin=self.origin + ) + module = module_from_spec(spec) + exec(code, module.__dict__) + return module + + def modulify(self, template_name: str): + # Remove any extensions. + noext = re.sub(r"\..*$", "", template_name) + + # Split on any non asciibetical characters. + words = re.split(r"\W+", noext) + + # Capitalize each word and join them back together. + base = ".".join((w.lower() for w in words)) + + # Prefix the class to ensure it doesn't start with a number. + return f"subjugate.usertemplates.{base}" + + def classify(self, template_name: str): + # Remove any extensions. + noext = re.sub(r"\..*$", "", template_name) + + # Split on any non asciibetical characters. + words = re.split(r"\W+", noext) + + # Capitalize each word and join them back together. + base = "".join((w.capitalize() for w in words)) + + # Prefix the class to ensure it doesn't start with a number. + return f"SubjugateTemplate{base}" + + def __init__(self, contents, origin, template_name, engine): + self.contents= contents + self.origin = origin + self.template_name = template_name + self.engine = engine + self.module = self.code_to_module(self.contents, template_name) + self.userclass = self.find_template_class(self.module, SubjugateUserTemplate) + + def render(self, context, request, **kwargs): + if isinstance(context, dict) or context is None: + context = make_context(context, request, autoescape=self.engine.autoescape) + + return self.userclass(self.engine, context, request).render(**kwargs) + + def html(self, context=None, request=None): + return self.render(context, request).render() diff --git a/subjugate/template/backends/subjugate.py b/subjugate/template/backends/subjugate.py new file mode 100644 index 0000000..6661ee9 --- /dev/null +++ b/subjugate/template/backends/subjugate.py @@ -0,0 +1,14 @@ +from subjugate.template.engine import GenericEngine +from subjugate.template import SubjugateTemplate + +class SubjugateTemplates(GenericEngine): + template_cls = SubjugateTemplate + app_dirname = "subjugate" + + def __init__(self, params): + params = params.copy() + self.dirs = list(params.pop('DIRS')) or [] + self.app_dirs = bool(params.pop('APP_DIRS')) + options = params.pop('OPTIONS') + + super().__init__(self.dirs, self.app_dirs, **options) diff --git a/subjugate/template/engine.py b/subjugate/template/engine.py new file mode 100644 index 0000000..5ab3f5b --- /dev/null +++ b/subjugate/template/engine.py @@ -0,0 +1,111 @@ +from typing import List, Tuple, Union +from django.core.exceptions import ImproperlyConfigured +from django.utils.functional import cached_property +from django.utils.module_loading import import_string +from django.template import Template +from django.template.engine import Engine +from django.template.backends.django import DjangoTemplates + +from subjugate.template.loaders import GenericLoader + + +def derive_from_generic(loader_class, template_cls): + generic_loader = type( + f"{GenericLoader.__name__}{template_cls.__name__}", + (GenericLoader,), + {"template_cls": template_cls}, + ) + + return type(f"Generic{loader_class.__name__}", (loader_class, generic_loader), {}) + + +# This might seem weird but the engine class MUST be a subclass of +# of DjangoTemplates to support cache clearing on modification. +# +# See autoreload.py +class GenericEngine(Engine, DjangoTemplates): + template_cls = Template + app_dirname = "template" + dirs_loader = "django.template.loaders.filesystem.Loader" + appdirs_loader = "subjugate.template.loaders.app_directories.Loader" + cache_loader = "django.template.loaders.cached.Loader" + + def __init__( + self, + dirs=[], + app_dirs=False, + builtins=None, + context_processors=[], + file_charset="UTF-8", + debug=False, + loaders=None, + libraries=None, + autoescape=True, + **_, + ): + self.app_dirs = app_dirs + self.context_processors = context_processors + self.dirs = dirs + self.file_charset = file_charset + self.debug = debug + self.autoescape = autoescape + self.engine = self + + if loaders is None: + self.loaders = self.get_default_loaders() + else: + self.loaders = loaders + + if libraries is None: + libraries = {} + if builtins is None: + builtins = [] + + self.libraries = libraries + self.template_libraries = self.get_template_libraries(libraries) + self.template_builtins = self.get_template_builtins(builtins + self.default_builtins) + + self.filters = {} + for lib in self.template_builtins: + self.filters.update(lib.filters) + + def __repr__(self): + return f"<{self.__class__.__qualname__}>" + + @cached_property + def template_context_processors(self): + return tuple([import_string(path) for path in self.context_processors]) + + def find_template_loader(self, loader): + if isinstance(loader, (tuple, list)): + loader, *args = loader + else: + args = [] + + if isinstance(loader, str): + loader_class = import_string(loader) + derived_clss = derive_from_generic(loader_class, self.template_cls) + return derived_clss(self, *args) + else: + raise ImproperlyConfigured( + "Invalid value in template loaders configuration: %r" % loader + ) + + def get_default_loaders(self): + loaders: List[Union[str, List, Tuple]] = [self.dirs_loader] + if self.app_dirs: + loaders.append((self.appdirs_loader, self.app_dirname)) + + return [(self.cache_loader, loaders)] + + def get_template_loaders(self, template_loaders): + return [ld for tl in template_loaders if (ld := self.find_template_loader(tl))] + + def from_string(self, template_code): + self.template_cls(template_code, engine=self) + + def get_template(self, template_name): + template, origin = self.find_template(template_name) + if not hasattr(template, "render"): + template = self.template_cls(template, origin, template_name, engine=self) + return template diff --git a/subjugate/template/loaders/__init__.py b/subjugate/template/loaders/__init__.py new file mode 100644 index 0000000..e6434b7 --- /dev/null +++ b/subjugate/template/loaders/__init__.py @@ -0,0 +1,5 @@ +from .base import GenericLoader + +__all__ = [ + "GenericLoader", +] diff --git a/subjugate/template/loaders/app_directories.py b/subjugate/template/loaders/app_directories.py new file mode 100644 index 0000000..5f2e8c1 --- /dev/null +++ b/subjugate/template/loaders/app_directories.py @@ -0,0 +1,11 @@ +from django.template.loaders import filesystem +from django.template.utils import get_app_template_dirs + + +class Loader(filesystem.Loader): + def __init__(self, engine, app_dirname, *args): + self.app_dirname = app_dirname + super().__init__(engine, *args) + + def get_dirs(self): + return get_app_template_dirs(self.app_dirname) diff --git a/subjugate/template/loaders/base.py b/subjugate/template/loaders/base.py new file mode 100644 index 0000000..a160795 --- /dev/null +++ b/subjugate/template/loaders/base.py @@ -0,0 +1,41 @@ +from django.template.loaders import base +from django.template import TemplateDoesNotExist, Template + +# Despite deriving from base.Loader this class is actually more generic and can +# be used to leverage the Django template loader ecosystem for any kind of +# template. +class GenericLoader(base.Loader): + template_cls = Template + + def __init__(self, engine): + self.engine = engine + + def get_contents(self, _): + return NotImplemented("Subclasses must implement get_contents") + + def get_template(self, template_name, skip=None): + tried = [] + + for origin in self.get_template_sources(template_name): + if skip is not None and origin in skip: + tried.append((origin, "Skipped to avoid recursion")) + continue + + try: + contents = self.get_contents(origin) + except TemplateDoesNotExist: + tried.append((origin, "Source does not exist")) + continue + else: + return self.template_cls( + contents, + origin, + origin.template_name, + self.engine, + ) + + raise TemplateDoesNotExist(template_name, tried=tried) + + def from_string(self, template_code): + return self.template_cls(template_code, self.engine) +