diff --git a/build/lib/django_mako_plus/__init__.py b/build/lib/django_mako_plus/__init__.py new file mode 100644 index 00000000..403e0453 --- /dev/null +++ b/build/lib/django_mako_plus/__init__.py @@ -0,0 +1,83 @@ +# +# Author: Conan Albrecht +# License: Apache Open Source License +# + + +# pointer to our app config +# Django looks for this exact variable name + + +# the version +from .version import __version__ + + +# the router, middleware, and view function decorator +from .middleware import RequestInitMiddleware +from .router import view_function, app_resolver, dmp_path + + +# converter decorator +from .converter import parameter_converter, ParameterConverter + + +# the middleware and template +# the template engine +from .engine import MakoTemplates + + +# the exceptions +from .exceptions import BaseRedirectException +from .exceptions import RedirectException +from .exceptions import PermanentRedirectException +from .exceptions import JavascriptRedirectException +from .exceptions import InternalRedirectException +from .exceptions import ConverterHttp404 +from .exceptions import ConverterException + + +# filters and tags +from .filters import django_syntax, jinja2_syntax, alternate_syntax +from .templatetags.django_mako_plus import dmp_include + +# used internally in compiled templates for autoescaping +# (needs to be exposed publicly so templates can see it) +from .template import ExpressionPostProcessor + +# the http responses +from .http import HttpResponseJavascriptRedirect + + +# the convenience functions +# +# Instead of these functions, consider using request.dmp.render() and request.dmp.render_to_string(), +# which are monkey-patched onto every DMP-enabled app at load time. See the documentation +# for information on why we do this. +# +from .convenience import render_template +from .convenience import render_template_for_path +from .convenience import get_template +from .convenience import get_template_for_path +from .convenience import get_template_loader +from .convenience import get_template_loader_for_path + + +# the utilities +from .util import merge_dicts + + +# the urls +# I'm specifically not including urls.py here because I want it imported +# as late as possible (after all the apps are set up). Django will import it +# when it processes the project's urls.py file. + + +# html content shortcuts +from .provider import links +from .provider import template_links, template_obj_links +# html content providers +from .provider.base import BaseProvider +from .provider.compile import CompileProvider, CompileScssProvider, CompileLessProvider +from .provider.context import JsContextProvider, jscontext +from .provider.link import LinkProvider, CssLinkProvider, JsLinkProvider +from .provider.webpack import WebpackJsLinkProvider diff --git a/build/lib/django_mako_plus/__main__.py b/build/lib/django_mako_plus/__main__.py new file mode 100644 index 00000000..96a8d1ae --- /dev/null +++ b/build/lib/django_mako_plus/__main__.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +from django.core import management +import os.path +import sys +import functools + +__doc__ = ''' +Starts a new DMP-style project. This is the DMP equivalent of "django-admin.py startproject". + +Example: + + django_mako_plus dmp_startproject [project name] + +if the above doesn't work, try: + + python -m django_mako_plus dmp_startproject [project name] + +Background: The dmp_startproject command has a bit of a chicken-and-egg problem. +It creates the project, but the command isn't available until the project is created +and DMP is in INSTALLED_APPS. Can't create until it is created... +Django solves this with django-admin.py, a global Python script that can be +executed directly from the command line. +''' + +DMP_MANAGEMENT_PATH = os.path.join(os.path.dirname(__file__), 'management') + + + +def main(): + # Django is hard coded to return only its own commands when a project doesn't + # exist yet. Since I want DMP to be able to create projects, I'm monkey-patching + # Django's get_commands() function so DMP gets added. This is the least-offensive + # way I could see to do this since Django really isn't built to allow other commands + # pre-project. This only happens when `django_mako_plus` is run directly and not + # when `manage.py` or `django-admin.py` are run. + orig_get_commands = management.get_commands + @functools.lru_cache(maxsize=None) + def new_get_commands(): + commands = {} + commands.update(orig_get_commands()) + commands.update({ name: 'django_mako_plus' for name in management.find_commands(DMP_MANAGEMENT_PATH) }) + return commands + management.get_commands = new_get_commands + + # mimic the code in django-admin.py + management.execute_from_command_line() + + +## runner! +if __name__ == '__main__': + main() diff --git a/build/lib/django_mako_plus/app_template/__init__.py b/build/lib/django_mako_plus/app_template/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/django_mako_plus/app_template/admin.py b/build/lib/django_mako_plus/app_template/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/build/lib/django_mako_plus/app_template/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/build/lib/django_mako_plus/app_template/apps.py b/build/lib/django_mako_plus/app_template/apps.py new file mode 100644 index 00000000..8d1a0177 --- /dev/null +++ b/build/lib/django_mako_plus/app_template/apps.py @@ -0,0 +1,5 @@ +{{ unicode_literals }}from django.apps import AppConfig + + +class {{ camel_case_app_name }}Config(AppConfig): + name = '{{ app_name }}' diff --git a/build/lib/django_mako_plus/app_template/media/python.png b/build/lib/django_mako_plus/app_template/media/python.png new file mode 100644 index 00000000..8385f800 Binary files /dev/null and b/build/lib/django_mako_plus/app_template/media/python.png differ diff --git a/build/lib/django_mako_plus/app_template/migrations/__init__.py b/build/lib/django_mako_plus/app_template/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/django_mako_plus/app_template/models.py b/build/lib/django_mako_plus/app_template/models.py new file mode 100644 index 00000000..7a54b3e3 --- /dev/null +++ b/build/lib/django_mako_plus/app_template/models.py @@ -0,0 +1,3 @@ +{{ unicode_literals }}from django.db import models + +# Create your models here. diff --git a/build/lib/django_mako_plus/app_template/scripts/index.js b/build/lib/django_mako_plus/app_template/scripts/index.js new file mode 100644 index 00000000..ab875974 --- /dev/null +++ b/build/lib/django_mako_plus/app_template/scripts/index.js @@ -0,0 +1,6 @@ +(function(context) { + + // utc_epoch comes from index.py + console.log('Current epoch in UTC is ' + context.utc_epoch); + +})(DMP_CONTEXT.get()); diff --git a/build/lib/django_mako_plus/app_template/styles/base.css b/build/lib/django_mako_plus/app_template/styles/base.css new file mode 100644 index 00000000..cdd296be --- /dev/null +++ b/build/lib/django_mako_plus/app_template/styles/base.css @@ -0,0 +1,46 @@ +html, body { + margin: 0; + padding: 0; + font-family: sans-serif; + color: #777788; +} + +.clearfix { + clear: both; +} + +header { + padding: 50px 20px; + text-align: center; +} + +header > .title { + display: inline-block; + color: #3771A1; + font-size: 40px; + font-weight: bold; + text-shadow: 2px 2px 3px rgba(0, 0, 0, 0.2); + vertical-align: middle; +} + +header img { + vertical-align: middle; + margin-right: 24px; +} + +main { + margin: 0; + padding: 15px; +} + +footer { + margin-top: 40px; + border-top: 1px solid #CCCCCC; + text-align: right; +} + +footer a { + display: inline-block; + color: #777788; + margin: 15px 10% 0 0; +} diff --git a/build/lib/django_mako_plus/app_template/styles/index.css b/build/lib/django_mako_plus/app_template/styles/index.css new file mode 100644 index 00000000..bd576402 --- /dev/null +++ b/build/lib/django_mako_plus/app_template/styles/index.css @@ -0,0 +1,7 @@ +main { + text-align: center; +} + +h4.utc-time { + color: #3771A1; +} diff --git a/build/lib/django_mako_plus/app_template/templates/base.htm b/build/lib/django_mako_plus/app_template/templates/base.htm new file mode 100644 index 00000000..281dc14a --- /dev/null +++ b/build/lib/django_mako_plus/app_template/templates/base.htm @@ -0,0 +1,36 @@ +## this is the skeleton of all pages on in this app - it defines the basic html tags + + + + + + DMP + + ## add any site-wide scripts or CSS here; for example, jquery: + + + ## render the static file links with the same name as this template + + ${ django_mako_plus.links(self) } + + + + +
+ python +
Welcome to
DMP!
+
+ +
+ <%block name="content"> + Site content goes here in sub-templates. + +
+ + + + + diff --git a/build/lib/django_mako_plus/app_template/templates/base_ajax.htm b/build/lib/django_mako_plus/app_template/templates/base_ajax.htm new file mode 100644 index 00000000..28a35b6a --- /dev/null +++ b/build/lib/django_mako_plus/app_template/templates/base_ajax.htm @@ -0,0 +1,11 @@ +## this is the skeleton of all *ajax* pages on our site - page snippets that are retrieved with Ajax. +## it's primary function is to insert the CSS and JS for the ajax file template inheritance + +## render the static file links with the same name as this template +${ django_mako_plus.links(self) } + +## render the ajax content +<%block name="content"> + Sub-templates should place their ajax content here. + + diff --git a/build/lib/django_mako_plus/app_template/templates/index.html b/build/lib/django_mako_plus/app_template/templates/index.html new file mode 100644 index 00000000..ce5f36d4 --- /dev/null +++ b/build/lib/django_mako_plus/app_template/templates/index.html @@ -0,0 +1,8 @@ +<%inherit file="base.htm" /> + +<%block name="content"> +
+

Congratulations -- you've successfully created a new DMP app!

+

Current time in UTC: ${ utc_time }

+
+ diff --git a/build/lib/django_mako_plus/app_template/tests.py b/build/lib/django_mako_plus/app_template/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/build/lib/django_mako_plus/app_template/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/build/lib/django_mako_plus/app_template/views/__init__.py b/build/lib/django_mako_plus/app_template/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/django_mako_plus/app_template/views/index.py b/build/lib/django_mako_plus/app_template/views/index.py new file mode 100644 index 00000000..354cd17f --- /dev/null +++ b/build/lib/django_mako_plus/app_template/views/index.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django_mako_plus import view_function, jscontext +from datetime import datetime, timezone + +@view_function +def process_request(request): + utc_time = datetime.utcnow() + context = { + # sent to index.html: + 'utc_time': utc_time, + # sent to index.html and index.js: + jscontext('utc_epoch'): utc_time.timestamp(), + } + return request.dmp.render('index.html', context) \ No newline at end of file diff --git a/build/lib/django_mako_plus/apps.py b/build/lib/django_mako_plus/apps.py new file mode 100644 index 00000000..a69ad7b5 --- /dev/null +++ b/build/lib/django_mako_plus/apps.py @@ -0,0 +1,94 @@ +from django.apps import apps, AppConfig +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.template import engines + +from .defaults import DEFAULT_OPTIONS +from .provider.runner import ProviderRun +from .signals import dmp_signal_register_app + +import threading + + +class Config(AppConfig): + name = 'django_mako_plus' + label = 'django_mako_plus' + verbose_name = 'Django Mako Plus Templating Engine' + + def ready(self): + '''Called by Django when the app is ready for use.''' + # set up the options + self.options = {} + self.options.update(DEFAULT_OPTIONS) + for template_engine in settings.TEMPLATES: + if template_engine.get('BACKEND', '').startswith('django_mako_plus'): + self.options.update(template_engine.get('OPTIONS', {})) + + # dmp-enabled apps registry + self.registration_lock = threading.RLock() + self.registered_apps = {} + + # init the template engine + self.engine = engines['django_mako_plus'] + + # default imports on every compiled template + self.template_imports = [ + 'import django_mako_plus', + 'import django.utils.html', # used in template.py + ] + self.template_imports.extend(self.options['DEFAULT_TEMPLATE_IMPORTS']) + + # initialize the list of providers + ProviderRun.initialize_providers() + + # set up the parameter converters (can't import until apps are set up) + from .converter.base import ParameterConverter + ParameterConverter._sort_converters(app_ready=True) + + + def register_app(self, app=None): + ''' + Registers an app as a "DMP-enabled" app. Normally, DMP does this + automatically when included in urls.py. + + If app is None, the DEFAULT_APP is registered. + ''' + app = app or self.options['DEFAULT_APP'] + if not app: + raise ImproperlyConfigured('An app name is required because DEFAULT_APP is empty - please use a ' + 'valid app name or set the DEFAULT_APP in settings') + if isinstance(app, str): + app = apps.get_app_config(app) + + # since this only runs at startup, this lock doesn't affect performance + with self.registration_lock: + # short circuit if already registered + if app.name in self.registered_apps: + return + + # first time for this app, so add to our dictionary + self.registered_apps[app.name] = app + + # set up the template, script, and style renderers + # these create and cache just by accessing them + self.engine.get_template_loader(app, 'templates', create=True) + self.engine.get_template_loader(app, 'scripts', create=True) + self.engine.get_template_loader(app, 'styles', create=True) + + # send the registration signal + if self.options['SIGNALS']: + dmp_signal_register_app.send(sender=self, app_config=app) + + + def get_registered_apps(self): + '''Returns a sequence of apps that are registered with DMP''' + return self.registered_apps.values() + + + def is_registered_app(self, app): + '''Returns true if the given app/app name is registered with DMP''' + if app is None: + return False + if isinstance(app, AppConfig): + app = app.name + return app in self.registered_apps diff --git a/build/lib/django_mako_plus/command.py b/build/lib/django_mako_plus/command.py new file mode 100644 index 00000000..4043c29f --- /dev/null +++ b/build/lib/django_mako_plus/command.py @@ -0,0 +1,47 @@ +from .util import log + +import subprocess +from collections import namedtuple + + +################################################################ +### Run a shell command + +ReturnInfo = namedtuple('ReturnInfo', ( 'code', 'stdout', 'stderr' )) + + +def run_command(*args, raise_exception=True, cwd=None): + ''' + Runs a command, piping all output to the DMP log. + The args should be separate arguments so paths and subcommands can have spaces in them: + + ret = run_command('ls', '-l', '/Users/me/My Documents') + print(ret.code) + print(ret.stdout) + print(ret.stderr) + + On Windows, the PATH is not followed. This can be overcome with: + + import shutil + run_command(shutil.which('program'), '-l', '/Users/me/My Documents') + ''' + args = [ str(a) for a in args ] + log.info('running %s', ' '.join(args)) + p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, cwd=cwd) + stdout, stderr = p.communicate() + returninfo = ReturnInfo(p.returncode, stdout.decode('utf8'), stderr.decode('utf8')) + if stdout: + log.info('%s', returninfo.stdout) + if raise_exception and returninfo.code != 0: + raise CommandError(' '.join(args), returninfo) + return returninfo + + +class CommandError(Exception): + def __init__(self, command, returninfo): + self.command = command + self.returninfo = returninfo + super().__init__('CommandError') + + def __str__(self): + return '[return value: {}] {}; {}'.format(self.returninfo.code, self.returninfo.stdout[:1000], self.returninfo.stderr[:1000]) diff --git a/build/lib/django_mako_plus/context_processors.py b/build/lib/django_mako_plus/context_processors.py new file mode 100644 index 00000000..f4feeacc --- /dev/null +++ b/build/lib/django_mako_plus/context_processors.py @@ -0,0 +1,30 @@ +################################################################ +### A set of request processors that add variables to the +### context (parameters) when templates are rendered. +### + +from django.conf import settings as conf_settings +from django.template.backends.utils import csrf_input_lazy, csrf_token_lazy + + + +def settings(request): + '''Adds the settings dictionary to the request''' + return { 'settings': conf_settings } + + +def csrf(request): + ''' + Adds the "csrf_input" and "csrf_token" variables to the request. + + Following Django's lead, this processor is included in DMP's + default context processors list. It does not need to be listed + in settings.py. + + To include the control in your forms, + use ${ csrf_input }. + ''' + return { + 'csrf_input': csrf_input_lazy(request), + 'csrf_token': csrf_token_lazy(request), + } diff --git a/build/lib/django_mako_plus/convenience.py b/build/lib/django_mako_plus/convenience.py new file mode 100644 index 00000000..f38ea6c2 --- /dev/null +++ b/build/lib/django_mako_plus/convenience.py @@ -0,0 +1,56 @@ +from django.apps import apps +import os, os.path + + +############################################################## +### Convenience functions +### These are imported into __init__.py + +def get_template_loader(app, subdir='templates'): + ''' + Convenience method that calls get_template_loader() on the DMP + template engine instance. + ''' + dmp = apps.get_app_config('django_mako_plus') + return dmp.engine.get_template_loader(app, subdir, create=True) + + +def get_template(app, template_name, subdir="templates"): + ''' + Convenience method that retrieves a template given the app and + name of the template. + ''' + dmp = apps.get_app_config('django_mako_plus') + return dmp.engine.get_template_loader(app, subdir, create=True).get_template(template_name) + + +def render_template(request, app, template_name, context=None, subdir="templates", def_name=None): + ''' + Convenience method that directly renders a template, given the app and template names. + ''' + return get_template(app, template_name, subdir).render(context, request, def_name) + + +def get_template_loader_for_path(path, use_cache=True): + ''' + Convenience method that calls get_template_loader_for_path() on the DMP + template engine instance. + ''' + dmp = apps.get_app_config('django_mako_plus') + return dmp.engine.get_template_loader_for_path(path, use_cache) + + +def get_template_for_path(path, use_cache=True): + ''' + Convenience method that retrieves a template given a direct path to it. + ''' + dmp = apps.get_app_config('django_mako_plus') + app_path, template_name = os.path.split(path) + return dmp.engine.get_template_loader_for_path(app_path, use_cache=use_cache).get_template(template_name) + + +def render_template_for_path(request, path, context=None, use_cache=True, def_name=None): + ''' + Convenience method that directly renders a template, given a direct path to it. + ''' + return get_template_for_path(path, use_cache).render(context, request, def_name) diff --git a/build/lib/django_mako_plus/converter/__init__.py b/build/lib/django_mako_plus/converter/__init__.py new file mode 100644 index 00000000..e58aaeaa --- /dev/null +++ b/build/lib/django_mako_plus/converter/__init__.py @@ -0,0 +1,11 @@ + +# public items in this package +from .parameter import ViewParameter +from .decorators import parameter_converter +from .base import ParameterConverter + + +# import the default converters +# this must come at the end of the file so view_function above is loaded +# it doesn't matter what's imported -- the file just needs to load +from .converters import __name__ as _ diff --git a/build/lib/django_mako_plus/converter/base.py b/build/lib/django_mako_plus/converter/base.py new file mode 100644 index 00000000..9d9c2135 --- /dev/null +++ b/build/lib/django_mako_plus/converter/base.py @@ -0,0 +1,195 @@ +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 + +from ..util import log +from ..exceptions import ConverterHttp404, ConverterException, BaseRedirectException +from .parameter import ViewParameter +from .info import ConverterFunctionInfo + +import logging +import inspect +from operator import attrgetter +import functools + + +class ParameterConverter(object): + ''' + Converts parameters using functions registered to types they convert. + + To create a converter for a new type, simply create a function + that is decorated with @parameter_converter + + Customizing Parameter Conversion: + + The primary way to customize conversion is to create new @parameter_converter + functions in your codebase. This allows you to add new type converters. + You can even create functions for types already handled by the built-in + converters. Your functions will override the built-in ones. + + If you need to customize more than just a few types, override: + + convert_value(): where individual parameters are converted. + + __call__(): the controller that iterates the parameter values + and calls convert_value(). + ''' + + # the registry of converters (populated by the @converter_function decorator) + converters = [] + # this variable prevents sorting until Django is ready (because during sorting + # we switch "myapp.MyModel" to the actual model instance) + _sorting_enabled = False + + def __init__(self, view_function): + self.view_function = view_function + + # inspect the parameters on the function (or functions if a class-based view) + self.view_parameters = {} + view_class = getattr(self.view_function, 'view_class', None) + if view_class is None: # regular view function + self.view_parameters[None] = self._collect_parameters(self.view_function) + + else: # class-based view + for http_mthd in view_class.http_method_names: + func = getattr(view_class, http_mthd, None) + if func is not None: + self.view_parameters[http_mthd] = self._collect_parameters(func, True) + # Django's View class aliases head to get using this logic + if 'get' in self.view_parameters and 'head' not in self.view_parameters: + self.view_parameters['head'] = self.view_parameters['get'] + + + def _collect_parameters(self, func, class_based=False): + func_parameters = list(inspect.signature(func).parameters.values()) + # when using class-based views, methods that have decorators might be partials, + # which makes it difficult to know whether the `self` parameter is present. + # this heuristic is the best way I can figure out to skip the self parameter if there. + if class_based and len(func_parameters) > 0 and func_parameters[0].name == 'self': + func_parameters = func_parameters[1:] + + params = [] + for i, p in enumerate(func_parameters): + params.append(ViewParameter( + name=p.name, + position=i, + kind=p.kind, + type=p.annotation, + default=p.default, + )) + return tuple(params) + + + @classmethod + def _register_converter(cls, conv_func, conv_type): + '''Triggered by the @converter_function decorator''' + cls.converters.append(ConverterFunctionInfo(conv_func, conv_type, len(cls.converters))) + cls._sort_converters() + + + @classmethod + def _sort_converters(cls, app_ready=False): + '''Sorts the converter functions''' + # app_ready is True when called from DMP's AppConfig.ready() + # we can't sort before then because models aren't ready + cls._sorting_enabled = cls._sorting_enabled or app_ready + if cls._sorting_enabled: + for converter in cls.converters: + converter.prepare_sort_key() + cls.converters.sort(key=attrgetter('sort_key')) + + + def convert_parameters(self, request, *args, **kwargs): + ''' + Iterates the urlparams and converts them according to the + type hints in the current view function. This is the primary + function of the class. + ''' + args = list(args) + urlparam_i = 0 + + parameters = self.view_parameters.get(request.method.lower()) or self.view_parameters.get(None) + if parameters is not None: + # add urlparams into the arguments and convert the values + for parameter_i, parameter in enumerate(parameters): + # skip request object, *args, **kwargs + if parameter_i == 0 or parameter.kind is inspect.Parameter.VAR_POSITIONAL or parameter.kind is inspect.Parameter.VAR_KEYWORD: + pass + # value in kwargs? + elif parameter.name in kwargs: + kwargs[parameter.name] = self.convert_value(kwargs[parameter.name], parameter, request) + # value in args? + elif parameter_i - 1 < len(args): + args[parameter_i - 1] = self.convert_value(args[parameter_i - 1], parameter, request) + # urlparam value? + elif urlparam_i < len(request.dmp.urlparams): + kwargs[parameter.name] = self.convert_value(request.dmp.urlparams[urlparam_i], parameter, request) + urlparam_i += 1 + # can we assign a default value? + elif parameter.default is not inspect.Parameter.empty: + kwargs[parameter.name] = self.convert_value(parameter.default, parameter, request) + # fallback is None + else: + kwargs[parameter.name] = self.convert_value(None, parameter, request) + + return args, kwargs + + + def convert_value(self, value, parameter, request): + ''' + Converts a parameter value in the view function call. + + value: value from request.dmp.urlparams to convert + The value will always be a string, even if empty '' (never None). + + parameter: an instance of django_mako_plus.ViewParameter that holds this parameter's + name, type, position, etc. + + request: the current request object. + + "converter functions" register with this class using the @parameter_converter + decorator. See converters.py for the built-in converters. + + This function goes through the list of registered converter functions, + selects the most-specific one that matches the parameter.type, and + calls it to convert the value. + + If the converter function raises a ValueError, it is caught and + switched to an Http404 to tell the browser that the requested URL + doesn't resolve to a page. + + Other useful exceptions that converter functions can raise are: + + Any extension of BaseRedirectException (RedirectException, + InternalRedirectException, JavascriptRedirectException, ...) + Http404: returns a Django Http404 response + ''' + try: + # we don't convert anything without type hints + if parameter.type is inspect.Parameter.empty: + if log.isEnabledFor(logging.DEBUG): + log.debug('skipping conversion of parameter `%s` because it has no type hint', parameter.name) + return value + + # find the converter method for this type + # I'm iterating through the list to find the most specific match first + # The list is sorted by specificity so subclasses come before their superclasses + for ci in self.converters: + if issubclass(parameter.type, ci.convert_type): + if log.isEnabledFor(logging.DEBUG): + log.debug('converting parameter `%s` using %s', parameter.name, ci.convert_func) + return ci.convert_func(value, parameter) + + # if we get here, there wasn't a converter or this type + raise ImproperlyConfigured(message='No parameter converter exists for type: {}. Do you need to add an @parameter_converter function for the type?'.format(parameter.type)) + + except (BaseRedirectException, Http404): + log.info('Exception raised during conversion of parameter %s (%s): %s', parameter.position, parameter.name, e) + raise # allow these to pass through to the router + + except ValueError as e: + log.info('ValueError raised during conversion of parameter %s (%s): %s', parameter.position, parameter.name, e) + raise ConverterHttp404(value, parameter, 'A parameter could not be converted - see the logs for more detail') from e + + except Exception as e: + log.info('Exception raised during conversion of parameter %s (%s): %s', parameter.position, parameter.name, e) + raise ConverterException(value, parameter, 'A parameter could not be converted - see the logs for more detail') from e diff --git a/build/lib/django_mako_plus/converter/converters.py b/build/lib/django_mako_plus/converter/converters.py new file mode 100644 index 00000000..5e95d53c --- /dev/null +++ b/build/lib/django_mako_plus/converter/converters.py @@ -0,0 +1,178 @@ +from django.db.models import Model +from django.conf import settings +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.http import HttpRequest + +from .decorators import parameter_converter + +import inspect +import datetime +import decimal +import base64 + + +### object (fallback if nothing else matches) ### + +@parameter_converter(object) +def convert_object(value, parameter): + ''' + Fallback converter when nothing else matches: + '', None convert to parameter default + Anything else is returned as-is + ''' + return _check_default(value, parameter, ( '', None )) + + +### str (a passthrough) ### + +@parameter_converter(str) +def convert_str(value, parameter): + ''' + Converts to string: + '', None convert to parameter default + Anything else is returned as-is (url params are already strings) + ''' + return _check_default(value, parameter, ( '', None )) + + +### int ### + +@parameter_converter(int) +def convert_int(value, parameter): + ''' + Converts to int or float: + '', '-', None convert to parameter default + Anything else uses int() or float() constructor + ''' + value = _check_default(value, parameter, ( '', '-', None )) + if value is None or isinstance(value, int): + return value + try: + return int(value) + except Exception as e: + raise ValueError(str(e)) + + +### float ### + +@parameter_converter(float) +def convert_float(value, parameter): + ''' + Converts to int or float: + '', '-', None convert to parameter default + Anything else uses int() or float() constructor + ''' + value = _check_default(value, parameter, ( '', '-', None )) + if value is None or isinstance(value, float): + return value + try: + return float(value) + except Exception as e: + raise ValueError(str(e)) + + +### decimal.Decimal ### + +@parameter_converter(decimal.Decimal) +def convert_decimal(value, parameter): + ''' + Converts to decimal.Decimal: + '', '-', None convert to parameter default + Anything else uses Decimal constructor + ''' + value = _check_default(value, parameter, ( '', '-', None )) + if value is None or isinstance(value, decimal.Decimal): + return value + try: + return decimal.Decimal(value) + except Exception as e: + raise ValueError(str(e)) + + +### bool ### + +@parameter_converter(bool) +def convert_boolean(value, parameter, default=False): + ''' + Converts to boolean (only the first char of the value is used): + '', '-', None convert to parameter default + 'f', 'F', '0', False always convert to False + Anything else converts to True. + ''' + value = _check_default(value, parameter, ( '', '-', None )) + if isinstance(value, bool): + return value + if isinstance(value, str) and len(value) > 0: + value = value[0] + return value not in ( 'f', 'F', '0', False, None ) + + +### datetime.datetime ### + +@parameter_converter(datetime.datetime) +def convert_datetime(value, parameter): + ''' + Converts to datetime.datetime: + '', '-', None convert to parameter default + The first matching format in settings.DATETIME_INPUT_FORMATS converts to datetime + ''' + value = _check_default(value, parameter, ( '', '-', None )) + if value is None or isinstance(value, datetime.datetime): + return value + for fmt in settings.DATETIME_INPUT_FORMATS: + try: + return datetime.datetime.strptime(value, fmt) + except (ValueError, TypeError): + continue + raise ValueError("`{}` does not match a format in settings.DATETIME_INPUT_FORMATS".format(value)) + + +### datetime.date ### + +@parameter_converter(datetime.date) +def convert_date(value, parameter): + ''' + Converts to datetime.date: + '', '-', None convert to parameter default + The first matching format in settings.DATE_INPUT_FORMATS converts to datetime + ''' + value = _check_default(value, parameter, ( '', '-', None )) + if value is None or isinstance(value, datetime.date): + return value + for fmt in settings.DATE_INPUT_FORMATS: + try: + return datetime.datetime.strptime(value, fmt).date() + except (ValueError, TypeError): + continue + raise ValueError("`{}` does not match a format in settings.DATE_INPUT_FORMATS".format(value)) + + +### Model: any Django model by its id ### + +@parameter_converter(Model) # django models.Model +def convert_id_to_model(value, parameter): + ''' + Converts to a Model object. + '', '-', '0', None convert to parameter default + Anything else is assumed an object id and sent to `.get(id=value)`. + ''' + value = _check_default(value, parameter, ( '', '-', '0', None )) + if isinstance(value, (int, str)): # only convert if we have the id + try: + return parameter.type.objects.get(id=value) + except (MultipleObjectsReturned, ObjectDoesNotExist) as e: + raise ValueError(str(e)) + return value + + +################################### +### Helpers + +def _check_default(value, parameter, default_chars): + '''Returns the default if the value is "empty"''' + # not using a set here because it fails when value is unhashable + if value in default_chars: + if parameter.default is inspect.Parameter.empty: + raise ValueError('Value was empty, but no default value is given in view function for parameter: {} ({})'.format(parameter.position, parameter.name)) + return parameter.default + return value diff --git a/build/lib/django_mako_plus/converter/decorators.py b/build/lib/django_mako_plus/converter/decorators.py new file mode 100644 index 00000000..2acf3cc5 --- /dev/null +++ b/build/lib/django_mako_plus/converter/decorators.py @@ -0,0 +1,13 @@ +from .base import ParameterConverter + +### Decorator that denotes a converter function ### + +def parameter_converter(*convert_types): + ''' + Decorator that denotes a function as a url parameter converter. + ''' + def inner(func): + for ct in convert_types: + ParameterConverter._register_converter(func, ct) + return func + return inner diff --git a/build/lib/django_mako_plus/converter/info.py b/build/lib/django_mako_plus/converter/info.py new file mode 100644 index 00000000..596334a3 --- /dev/null +++ b/build/lib/django_mako_plus/converter/info.py @@ -0,0 +1,36 @@ +from django.apps import apps +from django.core.exceptions import ImproperlyConfigured + + +import inspect +import sys + + + +class ConverterFunctionInfo(object): + '''Holds information about a converter function''' + def __init__(self, convert_func, convert_type, source_order): + self.convert_func = convert_func + self.convert_type = convert_type + self.source_order = source_order + self.sort_key = 0 + + + def prepare_sort_key(self): + ''' + Triggered by view_function._sort_converters when our sort key should be created. + This can't be called in the constructor because Django models might not be ready yet. + ''' + if isinstance(self.convert_type, str): + try: + app_name, model_name = self.convert_type.split('.') + except ValueError: + raise ImproperlyConfigured('"{}" is not a valid converter type. String-based converter types must be specified in "app.Model" format.'.format(self.convert_type)) + try: + self.convert_type = apps.get_model(app_name, model_name) + except LookupError as e: + raise ImproperlyConfigured('"{}" is not a valid model name. {}'.format(self.convert_type, e)) + + # we reverse sort by ( len(mro), source code order ) so subclasses match first + # on same types, last declared method sorts first + self.sort_key = ( -1 * len(inspect.getmro(self.convert_type)), -1 * self.source_order ) diff --git a/build/lib/django_mako_plus/converter/parameter.py b/build/lib/django_mako_plus/converter/parameter.py new file mode 100644 index 00000000..7d3fd691 --- /dev/null +++ b/build/lib/django_mako_plus/converter/parameter.py @@ -0,0 +1,32 @@ + +##################################### +### ViewParameter + +class ViewParameter(object): + ''' + A data class that represents a view parameter on a view function. + An instance of this class is created for each parameter in a view function + (except the initial request object argument). + ''' + def __init__(self, name, position, kind, type, default): + ''' + name: The name of the parameter. + position: The position of this parameter. + kind: The kind of argument (positional, keyword, etc.). See inspect module. + type: The expected type of this parameter. Converters use this type to + convert urlparam strings to the right type. + default: Any default value, specified in function type hints. If no default is + specified in the function, this is `inspect.Parameter.empty`. + ''' + self.name = name + self.position = position + self.kind = kind + self.type = type + self.default = default + + def __repr__(self): + return ''.format( + self.name, + self.type.__qualname__ if self.type is not None else '', + self.default, + ) diff --git a/build/lib/django_mako_plus/decorators.py b/build/lib/django_mako_plus/decorators.py new file mode 100644 index 00000000..0fc5599e --- /dev/null +++ b/build/lib/django_mako_plus/decorators.py @@ -0,0 +1,271 @@ + +import functools + +########################################################## +### An extensible decorator superclass that supports: +### 1. @mydecorator +### 2. @mydecorator() +### 3. @mydecorator(1, 2, 3) +### 4. @mydecorator(a=1, b=2) +### 5. @mydecorator(1, 2, 3, a=1, b=2) +### +### AND +### +### A. It works on regular functions. +### B. It works on unbound class methods. +### C. It works on bound class methods. +### +### Author: Conan Albrecht +### Date: 2018-05-05 +### +### See the examples at the end of this file. +### +### The reason I argue for a metaclass approach: +### +### When a decorator is hit by python, it either 1) needs to call the decorator function with the +### other function, or 2) if arguments, run the decorator pre-function, which returns the real decorator, +### and then #1 with that. +### +### So in other words, the decorator needs to act like two entirely separate things. Since a metaclass’ +### job is to create the object, it seems a clean approach to have the metaclass create the right kind +### of thing needed. +### +### Since this decision is being made *before* the decorator is created, the decorator class itself +### can act as just one type of object: a decorator. The decorator’s `__init__` constructor is super +### clean because it doesn’t have to check whether we’re in pre-decorator or regular decorator mode. +### If the decorator constructor is running, it *is decorating* a function. +### + +class BaseDecoratorMeta(type): + ''' + Metaclass that either creates the decorator object or creates a + factory to create the decorator object. + ''' + def __call__(self, *args, **kwargs): + # delegate the creation of instances to a factory + def factory(func): + instance = super(BaseDecoratorMeta, self).__call__(func, *args, **kwargs) + # using custom updated=() because functools updates __dict__. This replaces + # instance.decorator_function with the real function--making it point + # directly at the final function rather than at the next decorator in line + # (if we have multiple decorators chained together). + functools.update_wrapper(instance, func, updated=()) + return instance + + # if args has a single callable and kwargs is empty, we'll assume + # python is calling the decorator: args[0] is the function being decorated. + # this means the syntax was `@decorator` with no arguments. + if len(args) == 1 and callable(args[0]) and len(kwargs) == 0: + func, args = args[0], () + return factory(func) + + # if we get here, the syntax was `@decorator(...)` -- with arguments. + # python hasn't yet called the decorator. we'll return the factory + # function so python can call it + return factory + + +class BaseDecorator(object, metaclass=BaseDecoratorMeta): + ''' + A decorator base class that can be called with an arbitrary number of + arguments and keyword arguments, or with none at all. + + decorator_function: Reference to the next decorator in the chain, or + to the final function if this is the only/last decorator + decorator_args: Positional arguments the decorator was called with (if any) + decorator_kwargs: Named arguments the decorator was called with (if any) + + Get a reference to the real function at the end of the decorator chain with: + import inspect + f = inspect.unwrap(self.decorator_function) + + # when there is only one decorator in place, self.decorator_function and + # inspect.unwrap(self.decorator_function) are the same: the real function. + + If you need to override __init__, follow this pattern: + class MyDecorator(BaseDecorator): + def __init__(self, decorator_function, p1, p2=None, *args, **kwargs): + super().__init__(decorator_function, *args, **kwargs) + self.p1 = p1 + self.p2 = p2 + ''' + def __init__(self, decorator_function, *decorator_args, **decorator_kwargs): + self.decorator_function = decorator_function + self.decorator_args = decorator_args + self.decorator_kwargs = decorator_kwargs + + def __get__(self, instance, type=None): + # If we get here, the decorator was placed on a class method. In this case, + # decorator_function is actually an unbound function not yet a bound method. + # Python calls this descriptor when the method is called. + # When it does so, we need to set the self variable so it gets "bound". + return functools.partial(self, instance) + + def __call__(self, *args, **kwargs): + '''Subclasses should override this method''' + return self.decorator_function(*args, **kwargs) + + + + +####################################################### +### Examples and Simple Testing + +if __name__ == '__main__': + + import unittest + + ### Our Decorator ### + + class my_decorator(BaseDecorator): + def __call__(self, *args, **kwargs): + # do something useful here (the purpose of the decorator) + + # call the decorated function + result = self.decorator_function(*args, **kwargs) + + # return some things to be tested + return self, result + + + ### Examples and Tests of Regular Function Decorating ### + + @my_decorator + def without_args(): + return 'without_args called' + + @my_decorator() + def with_empty_args(): + return 'with_empty_args called' + + @my_decorator(1, 2, 3) + def with_args(): + return 'with_args called' + + @my_decorator(option1='value1', option2=4) + def with_kwargs(): + return 'with_kwargs called' + + @my_decorator(1, 2, 3, option1='value1', option2=4) + def with_args_and_kwargs(): + return 'with_args_and_kwargs called' + + class TestFunctionDecorating(unittest.TestCase): + def test_without_args(self): + '''Decorating without any arguments or parenthasis''' + dec, result = without_args() + self.assertEqual(result, 'without_args called') + self.assertEqual(dec.decorator_args, ()) + self.assertEqual(dec.decorator_kwargs, {}) + + def test_with_empty_args(self): + '''Decorating with empty arguments''' + dec, result = with_empty_args() + self.assertEqual(result, 'with_empty_args called') + self.assertEqual(dec.decorator_args, ()) + self.assertEqual(dec.decorator_kwargs, {}) + + def test_with_args(self): + '''Decorating with positional arguments''' + dec, result = with_args() + self.assertEqual(result, 'with_args called') + self.assertEqual(dec.decorator_args, (1, 2, 3)) + self.assertEqual(dec.decorator_kwargs, {}) + + def test_with_kwargs(self): + '''Decorating with positional arguments''' + dec, result = with_kwargs() + self.assertEqual(result, 'with_kwargs called') + self.assertEqual(dec.decorator_args, ()) + self.assertEqual(dec.decorator_kwargs, { 'option1':'value1', 'option2':4 }) + + def test_with_args_and_kwargs(self): + '''Decorating with positional arguments''' + dec, result = with_args_and_kwargs() + self.assertEqual(result, 'with_args_and_kwargs called') + self.assertEqual(dec.decorator_args, (1, 2, 3)) + self.assertEqual(dec.decorator_kwargs, { 'option1':'value1', 'option2':4 }) + + + ### Examples and Tests of Method Decorating ### + + class MyTestClass(object): + @my_decorator + def without_args(self): + return 'without_args called' + + @my_decorator() + def with_empty_args(self): + return 'with_empty_args called' + + @my_decorator(1, 2, 3) + def with_args(self): + return 'with_args called' + + @my_decorator(option1='value1', option2=4) + def with_kwargs(self): + return 'with_kwargs called' + + @my_decorator(1, 2, 3, option1='value1', option2=4) + def with_args_and_kwargs(self): + return 'with_args_and_kwargs called' + + class TestMethodDecorating(unittest.TestCase): + + def setUp(self): + self.my_test_class = MyTestClass() + + def test_without_args(self): + '''Decorating without any arguments or parenthasis''' + dec, result = self.my_test_class.without_args() + self.assertEqual(result, 'without_args called') + self.assertEqual(dec.decorator_args, ()) + self.assertEqual(dec.decorator_kwargs, {}) + + def test_with_empty_args(self): + '''Decorating with empty arguments''' + dec, result = self.my_test_class.with_empty_args() + self.assertEqual(result, 'with_empty_args called') + self.assertEqual(dec.decorator_args, ()) + self.assertEqual(dec.decorator_kwargs, {}) + + def test_with_args(self): + '''Decorating with positional arguments''' + dec, result = self.my_test_class.with_args() + self.assertEqual(result, 'with_args called') + self.assertEqual(dec.decorator_args, (1, 2, 3)) + self.assertEqual(dec.decorator_kwargs, {}) + + def test_with_kwargs(self): + '''Decorating with positional arguments''' + dec, result = self.my_test_class.with_kwargs() + self.assertEqual(result, 'with_kwargs called') + self.assertEqual(dec.decorator_args, ()) + self.assertEqual(dec.decorator_kwargs, { 'option1':'value1', 'option2':4 }) + + def test_with_args_and_kwargs(self): + '''Decorating with positional arguments''' + dec, result = self.my_test_class.with_args_and_kwargs() + self.assertEqual(result, 'with_args_and_kwargs called') + self.assertEqual(dec.decorator_args, (1, 2, 3)) + self.assertEqual(dec.decorator_kwargs, { 'option1':'value1', 'option2':4 }) + + + + class TestInvalidSyntax(unittest.TestCase): + + def test_invalid_syntax(self): + ''' + Invalid: the only decorator syntax not supported is a single function value + because python thinks it's the decorated function when it's actually meant + to be the pre-decorator call. + ''' + with self.assertRaises(TypeError): + # we can't tell wether to decorate `sum` or `with_single_callable` here: + @my_decorator(sum) + def with_single_callable(): + print('with_single_callable() called') + with_single_callable() + + # trigger the testing + unittest.main() diff --git a/build/lib/django_mako_plus/defaults.py b/build/lib/django_mako_plus/defaults.py new file mode 100644 index 00000000..a51f240c --- /dev/null +++ b/build/lib/django_mako_plus/defaults.py @@ -0,0 +1,91 @@ +import os, shutil + +# this dict of options is merged with the current project's TEMPLATES entry for DMP +DEFAULT_OPTIONS = { + # the default app and page to render in Mako when the url is too short + # if None (no default app), DMP will not capture short URLs + 'DEFAULT_APP': 'homepage', + 'DEFAULT_PAGE': 'index', + + # functions to automatically add variables to the params/context before templates are rendered + 'CONTEXT_PROCESSORS': [ + 'django.template.context_processors.static', # adds "STATIC_URL" from settings.py + 'django.template.context_processors.debug', # adds debug and sql_queries + 'django.template.context_processors.request', # adds "request" object + 'django.contrib.auth.context_processors.auth', # adds "user" and "perms" objects + 'django.contrib.messages.context_processors.messages', # adds messages from the messages framework + 'django_mako_plus.context_processors.settings', # adds "settings" dictionary + ], + + # identifies where the Mako template cache will be stored, relative to each template directory + 'TEMPLATES_CACHE_DIR': '__dmpcache__', + + # the default encoding of template files + 'DEFAULT_TEMPLATE_ENCODING': 'utf-8', + + # imports for every template + 'DEFAULT_TEMPLATE_IMPORTS': [ + # alternative syntax blocks within your Mako templates + # 'from django_mako_plus import django_syntax, jinja2_syntax, alternate_syntax', + + # the next two lines are just examples of including common imports in templates + # 'from datetime import datetime', + # 'import os, os.path, re, json', + ], + + # whether autoescaping of expressions is on or off + 'AUTOESCAPE': True, + + # the converter class to use for parameter conversion + # this should be ParameterConverter or a subclass of it + 'PARAMETER_CONVERTER': 'django_mako_plus.converter.ParameterConverter', + + # whether to send the custom DMP signals -- set to False for a slight speed-up in router processing + # determines whether DMP will send its custom signals during the process + 'SIGNALS': False, + + # static file providers (see "static file" docs for full options here) + 'CONTENT_PROVIDERS': [ + # adds JS context - this should normally be listed FIRST + { 'provider': 'django_mako_plus.JsContextProvider' }, + + # Sass compiler and link generator + # { 'provider': 'django_mako_plus.CompileScssProvider', + # 'sourcepath': lambda p: os.path.join(p.app_config.name, 'styles', p.template_relpath + '.scss'), + # 'targetpath': lambda p: os.path.join(p.app_config.name, 'styles', p.template_relpath + '.scss.css'), + # 'command': lambda p: [ shutil.which('sass'), f'--load-path="{BASE_DIR}"', p.sourcepath, p.targetpath ] }, + # { 'provider': 'django_mako_plus.CssLinkProvider', + # 'filepath': lambda p: os.path.join(p.app_config.name, 'styles', p.template_relpath + '.scss.css') }, + + # Less compiler and link generator + # { 'provider': 'django_mako_plus.CompileLessProvider', + # 'sourcepath': lambda p: os.path.join(p.app_config.name, 'styles', p.template_relpath + '.less'), + # 'targetpath': lambda p: os.path.join(p.app_config.name, 'styles', p.template_relpath + '.less.css'), + # 'command': lambda p: [ shutil.which('lessc'), f'--source-map', p.sourcepath, p.targetpath ] }, + # { 'provider': 'django_mako_plus.CssLinkProvider', + # 'filepath': lambda p: os.path.join(p.app_config.name, 'styles', p.template_relpath + '.less.css') }, + + # generic compiler and link generator (see DMP docs, same options as other entries here) + # { 'provider': 'django_mako_plus.CompileProvider' }, + # { 'provider': 'django_mako_plus.LinkProvider' }, + + # link generators for regular JS and CSS: app/scripts/*.js and app/styles/*.css + { 'provider': 'django_mako_plus.CssLinkProvider' }, + { 'provider': 'django_mako_plus.JsLinkProvider' }, + + # link generators for app/scripts/__bundle__.js (webpack bundler) + # { 'provider': 'django_mako_plus.WebpackJsLinkProvider' }, + ], + + # webpack file discovery, used by `manage.py dmp_webpack` to generate __entry__.js files + 'WEBPACK_PROVIDERS': [ + # finders for app/scripts/*.js and app/styles/*.css + { 'provider': 'django_mako_plus.JsLinkProvider' }, + { 'provider': 'django_mako_plus.CssLinkProvider' }, + ], + + # additional template dirs to search + 'TEMPLATES_DIRS': [ + # '/var/somewhere/templates/', + ], +} diff --git a/build/lib/django_mako_plus/engine.py b/build/lib/django_mako_plus/engine.py new file mode 100644 index 00000000..88a2630a --- /dev/null +++ b/build/lib/django_mako_plus/engine.py @@ -0,0 +1,164 @@ +from django.apps import apps, AppConfig +from django.core.exceptions import ImproperlyConfigured +from django.template import TemplateDoesNotExist +from django.template.backends.base import BaseEngine +from django.utils.module_loading import import_string +from mako.template import Template + +from .template import MakoTemplateLoader, MakoTemplateAdapter + +import itertools +import os +import os.path +import re + + +# Following Django's lead, hard coding the CSRF processor +BUILTIN_CONTEXT_PROCESSORS = ( + 'django_mako_plus.context_processors.csrf', +) + +# regex to split the template name in get_template() +# "myapp/mytemplate.html#myblock" gives groups: +# ('myapp', '/mytemplate.html', 'mytemplate.html', '#myblock', 'myblock') +RE_TEMPLATE_NAME = re.compile('([^/?#]*)?(/([^#]*))?(#(.*))?') + + +######################################################### +### The main engine + + +class MakoTemplates(BaseEngine): + ''' + The primary Mako interface that plugs into the Django templating system. + This is referenced in settings.py -> TEMPLATES. + ''' + def __init__(self, params): + '''Constructor''' + # ensure DMP is listed as an app (common install error) + try: + self.dmp = apps.get_app_config('django_mako_plus') + except LookupError: + raise ImproperlyConfigured("`django_mako_plus` must be listed in INSTALLED_APPS before it can be used") + + # cache for our template loaders + self.template_loaders = {} + + # set up the context processors + context_processors = [] + for processor in itertools.chain(BUILTIN_CONTEXT_PROCESSORS, self.dmp.options['CONTEXT_PROCESSORS']): + context_processors.append(import_string(processor)) + self.template_context_processors = tuple(context_processors) + + # super constructor + params.pop('OPTIONS', None) # the super doesn't like OPTIONS in there + super(MakoTemplates, self).__init__(params) + + + def from_string(self, template_code): + ''' + Compiles a template from the given string. + This is one of the required methods of Django template engines. + ''' + dmp = apps.get_app_config('django_mako_plus') + mako_template = Template(template_code, imports=dmp.template_imports, input_encoding=dmp.options['DEFAULT_TEMPLATE_ENCODING']) + return MakoTemplateAdapter(mako_template) + + + def get_template(self, template_name): + ''' + Retrieves a template object from the pattern "app_name/template.html". + This is one of the required methods of Django template engines. + + Because DMP templates are always app-specific (Django only searches + a global set of directories), the template_name MUST be in the format: + "app_name/template.html" (even on Windows). DMP splits the template_name + string on the slash to get the app name and template name. + + Template rendering can be limited to a specific def/block within the template + by specifying `#def_name`, e.g. `myapp/mytemplate.html#myblockname`. + ''' + dmp = apps.get_app_config('django_mako_plus') + match = RE_TEMPLATE_NAME.match(template_name) + if match is None or match.group(1) is None or match.group(3) is None: + raise TemplateDoesNotExist('Invalid template_name format for a DMP template. This method requires that the template name be in app_name/template.html format (separated by slash).') + if not dmp.is_registered_app(match.group(1)): + raise TemplateDoesNotExist('Not a DMP app, so deferring to other template engines for this template') + return self.get_template_loader(match.group(1)).get_template(match.group(3), def_name=match.group(5)) + + + def get_template_loader(self, app, subdir='templates', create=False): + ''' + Returns a template loader object for the given app name in the given subdir. + For example, get_template_loader('homepage', 'styles') will return + a loader for the styles/ directory in the homepage app. + + The app parameter can be either an app name or an AppConfig instance. + The subdir parameter is normally 'templates', 'scripts', or 'styles', + but it can be any subdirectory name of the given app. + + Normally, you should not have to call this method. Django automatically + generates two shortcut functions for every DMP-registered apps, + and these shortcut functions are the preferred way to render templates. + + This method is useful when you want a custom template loader to a directory + that does not conform to the app_dir/templates/* pattern. + + If the loader is not found in the DMP cache, one of two things occur: + 1. If create=True, it is created automatically and returned. This overrides + the need to register the app as a DMP app. + 2. If create=False, a TemplateDoesNotExist is raised. This is the default + behavior. + ''' + # ensure we have an AppConfig + if app is None: + raise TemplateDoesNotExist("Cannot locate loader when app is None") + if not isinstance(app, AppConfig): + app = apps.get_app_config(app) + # get the loader with the path of this app+subdir + path = os.path.join(app.path, subdir) + + # if create=False, the loader must already exist in the cache + if not create: + dmp = apps.get_app_config('django_mako_plus') + if not dmp.is_registered_app(app): + raise ValueError("{} is not registered with DMP [hint: check urls.py for include('django_mako_plus.urls')].".format(app)) + + # return the template by path + return self.get_template_loader_for_path(path, use_cache=True) + + + def get_template_loader_for_path(self, path, use_cache=True): + ''' + Returns a template loader object for the given directory path. + For example, get_template_loader('/var/mytemplates/') will return + a loader for that specific directory. + + Normally, you should not have to call this method. Django automatically + adds request.dmp.render() and request.dmp.render_to_string() on each + request. + + This method is useful when you want a custom template loader for a specific + directory that may be outside your project directory or that is otherwise + not contained in a normal Django app. If the directory is inside an app, + call get_template_loader() instead. + + Unless use_cache=False, this method caches template loaders in the DMP + cache for later use. + ''' + # get from the cache if we are able + if use_cache: + try: + return self.template_loaders[path] + except KeyError: + pass # not there, so we'll create + + # create the loader + loader = MakoTemplateLoader(path, None) + + # cache if we are allowed + if use_cache: + self.template_loaders[path] = loader + + # return + return loader diff --git a/build/lib/django_mako_plus/exceptions.py b/build/lib/django_mako_plus/exceptions.py new file mode 100644 index 00000000..dfd89f77 --- /dev/null +++ b/build/lib/django_mako_plus/exceptions.py @@ -0,0 +1,145 @@ +from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect +from django.http import Http404 + +from .http import HttpResponseJavascriptRedirect, REDIRECT_HEADER_KEY + + +############################################################### +### Exceptions used to direct the controller + + + +class BaseRedirectException(Exception): + ''' + Superclass of DMP redirect exceptions + ''' + + +class InternalRedirectException(BaseRedirectException): + ''' + View functions can throw this exception to indicate that a new view + should be called by the HtmlPageServer. The current view function + will end immediately, and processing will be passed to the new view function. + ''' + def __init__(self, redirect_module, redirect_function): + ''' + Indicates the new view to be called. The view should be given relative to the project root. + The parameters should be strings, not the actual module or function reference. + + Both parameters should be strings, example: + + raise InternalRedirectException('homepage.views.someview', 'process_request') + ''' + super().__init__() + self.redirect_module = redirect_module + self.redirect_function = redirect_function + + +class RedirectException(BaseRedirectException): + ''' + Immediately stops processing of a view function or template and redirects to the given page + using the standard 302 response status header. + + After the redirect_to parameter, you can use any of the normal HttpResponse constructor arguments. + + A custom header is set in the response. This allows middleware, your web server, or + calling JS code to adjust the redirect if needed. + ''' + def __init__(self, redirect_to, *args, **kwargs): + super().__init__() + self.redirect_to = redirect_to + self.args = args + self.kwargs = kwargs + + def get_response(self, request, *args, **kwargs): + '''Returns the redirect response for this exception.''' + # normal process + response = HttpResponseRedirect(self.redirect_to) + response[REDIRECT_HEADER_KEY] = self.redirect_to + return response + + +class PermanentRedirectException(RedirectException): + ''' + Immediately stops processing of a view function or template and redirects to the given page + using the standard 301 response status header. + + After the redirect_to parameter, you can use any of the normal HttpResponse constructor arguments. + + A custom header is set in the response. This allows middleware, your web server, or + calling JS code to adjust the redirect if needed. + + ''' + def get_response(self, request): + '''Returns the redirect response for this exception.''' + response = HttpResponsePermanentRedirect(self.redirect_to, *self.args, **self.kwargs) + response[REDIRECT_HEADER_KEY] = self.redirect_to + return response + + +class JavascriptRedirectException(RedirectException): + ''' + Immediately stops processing of a view function or template and redirects to the given page. + + Sends a regular HTTP 200 OK response that contains Javascript to + redirect the browser: + + . + + If redirect_to is empty, it redirects to the current location (essentially refreshing + the current page): + + . + + Normally, redirecting should be done via HTTP 302 rather than Javascript. + Use this class when your only choice is through Javascript. + + For example, suppose you need to redirect the top-level page from an Ajax response. + Ajax redirects normally only redirects the Ajax itself (not the page that initiated the call), + and this default behavior is usually what is needed. However, there are instances when the + entire page must be redirected, even if the call is Ajax-based. + + After the redirect_to parameter, you can use any of the normal HttpResponse constructor arguments. + + If you need to omit the surrounding . + + If redirect_to is empty, it redirects to the current location (essentially refreshing + the current page): + + . + + Normally, redirecting should be done via HTTP 302 rather than Javascript. + Use this class when your only choice is through Javascript. + + For example, suppose you need to redirect the top-level page from an Ajax response. + Ajax redirects normally only redirects the Ajax itself (not the page that initiated the call), + and this default behavior is usually what is needed. However, there are instances when the + entire page must be redirected, even if the call is Ajax-based. + + After the redirect_to parameter, you can use any of the normal HttpResponse constructor arguments. + + If you need to omit the surrounding '.format(script) + # call the super + super().__init__(script, *args, **kwargs) + # add the custom header + self[REDIRECT_HEADER_KEY] = redirect_to or 'window.location.href' diff --git a/build/lib/django_mako_plus/management/__init__.py b/build/lib/django_mako_plus/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/django_mako_plus/management/commands/__init__.py b/build/lib/django_mako_plus/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/django_mako_plus/management/commands/dmp.py b/build/lib/django_mako_plus/management/commands/dmp.py new file mode 100644 index 00000000..d59b3712 --- /dev/null +++ b/build/lib/django_mako_plus/management/commands/dmp.py @@ -0,0 +1,39 @@ +from django.apps import apps +from django.core.management.base import BaseCommand, CommandError +from django.conf import settings +from django_mako_plus.management.mixins import DMPCommandMixIn + +import os, os.path, shutil +import sys +import argparse + +# this command was placed here in Sept 2018 to help users know about the change. +# it can probably be removed sometime in Summer, 2019. + + +class Command(DMPCommandMixIn, BaseCommand): + help = 'Message to inform users of the change back to dmp_* commands.' + + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument(dest='all', nargs=argparse.REMAINDER, help='Wildcard to catch all remaining arguments') + + def handle(self, *args, **options): + try: + pos = sys.argv.index('dmp') # should be 1 + guess = 'Our guess at the right command is:\n\n {}'.format(' '.join( + sys.argv[:pos] + \ + [ sys.argv[pos] + '_' + sys.argv[pos+1] ] + \ + sys.argv[pos+2:] + )) + except: # `dmp` not there, or no subcommand + guess = '' + raise CommandError(''' + +DMP command usage changed in v5.6: `manage.py dmp *` commands are now `manage.py dmp_*`. + +As much as we liked the former syntax, it had to be tied to Django internals. It broke +whenever Django changed its internal command structure. Apologies for the change. + +{} + '''.format(guess)) diff --git a/build/lib/django_mako_plus/management/commands/dmp_cleanup.py b/build/lib/django_mako_plus/management/commands/dmp_cleanup.py new file mode 100644 index 00000000..ca0312f9 --- /dev/null +++ b/build/lib/django_mako_plus/management/commands/dmp_cleanup.py @@ -0,0 +1,91 @@ +from django.apps import apps +from django.core.management.base import BaseCommand, CommandError +from django.conf import settings +from django_mako_plus.management.mixins import DMPCommandMixIn + +import os, os.path, shutil + + + + +class Command(DMPCommandMixIn, BaseCommand): + help = 'Removes compiled template cache folders in your DMP-enabled app directories.' + can_import_settings = True + + + def add_arguments(self, parser): + super().add_arguments(parser) + + parser.add_argument( + 'appname', + type=str, + nargs='*', + help='The name of one or more DMP apps. If omitted, all DMP apps are processed.' + ) + parser.add_argument( + '--trial-run', + action='store_true', + dest='trial_run', + default=False, + help='Display the folders that would be removed without actually removing them.' + ) + + + def handle(self, *args, **options): + dmp = apps.get_app_config('django_mako_plus') + + # save the options for later + if options.get('trial_run'): + self.message("Trial run: dmp_cleanup would have deleted the following folders:", level=1) + + # ensure we have a base directory + try: + if not os.path.isdir(os.path.abspath(settings.BASE_DIR)): + raise CommandError('Your settings.py BASE_DIR setting is not a valid directory. Please check your settings.py file for the BASE_DIR variable.') + except AttributeError as e: + print(e) + raise CommandError('Your settings.py file is missing the BASE_DIR setting.') + + # check each dmp-enabled app + for config in dmp.get_registered_apps(): + self.message('Cleaning up app: {}'.format(config.name), level=1) + for subdir in ( 'templates', 'scripts', 'styles' ): + self.deep_clean(os.path.join(config.path, subdir), options.get('trial_run')) + + def deep_clean(self, path, trial_run): + dmp = apps.get_app_config('django_mako_plus') + if not os.path.exists(path): + return + + # clean this directory + cache_dir = os.path.join(path, dmp.options['TEMPLATES_CACHE_DIR']) + if os.path.exists(cache_dir): + self.message('Removing {}'.format(pretty_relpath(cache_dir, settings.BASE_DIR)), level=2) + if not trial_run: + shutil.rmtree(cache_dir) + else: + self.message('Skipping {} because it does not exist'.format(pretty_relpath(cache_dir, settings.BASE_DIR)), level=2) + + # recurse to subdirectories + for name in os.listdir(path): + subpath = os.path.join(path, name) + if os.path.isdir(subpath): + self.deep_clean(subpath, trial_run) + + + + + +##################################################### +### Utility functions + + + +def pretty_relpath(path, start): + ''' + Returns a relative path, but only if it doesn't start with a non-pretty parent directory ".." + ''' + relpath = os.path.relpath(path, start) + if relpath.startswith('..'): + return path + return relpath diff --git a/build/lib/django_mako_plus/management/commands/dmp_collectstatic.py b/build/lib/django_mako_plus/management/commands/dmp_collectstatic.py new file mode 100644 index 00000000..64733fdd --- /dev/null +++ b/build/lib/django_mako_plus/management/commands/dmp_collectstatic.py @@ -0,0 +1,243 @@ +from django.apps import apps +from django.core.management.base import CommandError +from django.contrib.staticfiles.management.commands.collectstatic import Command as CollectStaticCommand +from django.conf import settings +from django_mako_plus.management.mixins import DMPCommandMixIn + +import os, os.path, shutil, fnmatch + +try: + from rjsmin import jsmin +except ImportError: + jsmin = None +try: + from rcssmin import cssmin +except ImportError: + cssmin = None + + + +TYPE_DIRECTORY = 0 # file is a directory +TYPE_FILE = 1 # file is a regular file + + +class Rule(object): + def __init__(self, pattern, level, filetype, score): + self.pattern = pattern + self.level = level + self.filetype = filetype + self.score = score + + def match(self, fname, flevel, ftype): + '''Returns the result score if the file matches this rule''' + # if filetype is the same + # and level isn't set or level is the same + # and pattern matche the filename + if self.filetype == ftype and (self.level is None or self.level == flevel) and fnmatch.fnmatch(fname, self.pattern): + return self.score + return 0 + + + + + +class Command(DMPCommandMixIn, CollectStaticCommand): + help = 'Collects static files, such as media, scripts, and styles, to a common directory root. This is done to prepare for deployment.' + can_import_settings = True + + def add_arguments(self, parser): + super().add_arguments(parser) + + parser.add_argument( + '--overwrite', + action='store_true', + dest='overwrite', + default=False, + help='Overwrite existing files in the directory when necessary.' + ) + parser.add_argument( + '--no-minify', + action='store_true', + dest='no_minify', + help='Do not minify *.css with rcssmin and *.js with rjsmin.', + ) + parser.add_argument( + '--include-dir', + action='append', + dest='include_dir', + help='Include directories matching this pattern. Unix-style wildcards are acceptable, such as "*partial*". This option can be specified more than once.' + ) + parser.add_argument( + '--include-file', + action='append', + dest='include_file', + help='Include files matching this pattern. Unix-style wildcards are acceptable, such as "*.txt". This option can be specified more than once.' + ) + parser.add_argument( + '--skip-dir', + action='append', + dest='skip_dir', + help='Skip directories matching this pattern. Unix-style wildcards are acceptable, such as "*partial*". This option can be specified more than once.' + ) + parser.add_argument( + '--skip-file', + action='append', + dest='skip_file', + help='Skip files matching this pattern. Unix-style wildcards are acceptable, such as "*.txt". This option can be specified more than once.' + ) + + + def handle(self, *args, **options): + dmp = apps.get_app_config('django_mako_plus') + self.options = options + + # ensure we have a base directory + try: + if not os.path.isdir(os.path.abspath(settings.BASE_DIR)): + raise CommandError('Your settings.py BASE_DIR setting is not a valid directory. Please check your settings.py file for the BASE_DIR variable.') + except AttributeError as e: + print(e) + raise CommandError('Your settings.py file is missing the BASE_DIR setting. Aborting app creation.') + + # get the destination directory, and ensure it doesn't already exist + # if dest_root starts with a /, it is an absolute directory + # if dest_root doesn't start with a /, it goes relative to the BASE_DIR + try: + dest_root = os.path.join(os.path.abspath(settings.BASE_DIR), settings.STATIC_ROOT) + if os.path.exists(dest_root) and not self.options.get('overwrite'): + raise CommandError('The destination directory for static files (%s) already exists. Please delete it or run this command with the --overwrite option.' % dest_root) + except AttributeError: + raise CommandError('Your settings.py file is missing the STATIC_ROOT setting. Exiting without collecting the static files.') + + # create the directory - we assume it either doesn't exist, or the --overwrite is specified + if not os.path.isdir(dest_root): + os.makedirs(dest_root) + + # set up the rules + self.rules = self.create_rules() + + # go through the DMP apps and collect the static files + for config in dmp.get_registered_apps(): + self.message('Processing app {}'.format(config.name), 1) + self.copy_dir(config.path, os.path.abspath(os.path.join(dest_root, config.name))) + + + def create_rules(self): + '''Adds rules for the command line options''' + dmp = apps.get_app_config('django_mako_plus') + # the default + rules = [ + # files are included by default + Rule('*', level=None, filetype=TYPE_FILE, score=1), + # files at the app level are skipped + Rule('*', level=0, filetype=TYPE_FILE, score=-2), + # directories are recursed by default + Rule('*', level=None, filetype=TYPE_DIRECTORY, score=1), + # directories at the app level are skipped + Rule('*', level=0, filetype=TYPE_DIRECTORY, score=-2), + + # media, scripts, styles directories are what we want to copy + Rule('media', level=0, filetype=TYPE_DIRECTORY, score=6), + Rule('scripts', level=0, filetype=TYPE_DIRECTORY, score=6), + Rule('styles', level=0, filetype=TYPE_DIRECTORY, score=6), + + # ignore the template cache directories + Rule(dmp.options['TEMPLATES_CACHE_DIR'], level=None, filetype=TYPE_DIRECTORY, score=-3), + # ignore python cache directories + Rule('__pycache__', level=None, filetype=TYPE_DIRECTORY, score=-3), + # ignore compiled python files + Rule('*.pyc', level=None, filetype=TYPE_FILE, score=-3), + ] + # include rules have score of 50 because they trump all initial rules + for pattern in (self.options.get('include_dir') or []): + self.message('Setting rule - recurse directories: {}'.format(pattern), 1) + rules.append(Rule(pattern, level=None, filetype=TYPE_DIRECTORY, score=50)) + for pattern in (self.options.get('include_file') or []): + self.message('Setting rule - include files: {}'.format(pattern), 1) + rules.append(Rule(pattern, level=None, filetype=TYPE_FILE, score=50)) + # skip rules have score of 100 because they trump everything, including the includes from the command line + for pattern in (self.options.get('skip_dir') or []): + self.message('Setting rule - skip directories: {}'.format(pattern), 1) + rules.append(Rule(pattern, level=None, filetype=TYPE_DIRECTORY, score=-100)) + for pattern in (self.options.get('skip_file') or []): + self.message('Setting rule - skip files: {}'.format(pattern), 1) + rules.append(Rule(pattern, level=None, filetype=TYPE_FILE, score=-100)) + return rules + + + def copy_dir(self, source, dest, level=0): + '''Copies the static files from one directory to another. If this command is run, we assume the user wants to overwrite any existing files.''' + encoding = settings.DEFAULT_CHARSET or 'utf8' + msglevel = 2 if level == 0 else 3 + self.message('Directory: {}'.format(source), msglevel, level) + + # create a directory for this app + if not os.path.exists(dest): + self.message('Creating directory: {}'.format(dest), msglevel, level+1) + os.mkdir(dest) + + # go through the files in this app + for fname in os.listdir(source): + source_path = os.path.join(source, fname) + dest_path = os.path.join(dest, fname) + ext = os.path.splitext(fname)[1].lower() + + # get the score for this file + score = 0 + for rule in self.rules: + score += rule.match(fname, level, TYPE_DIRECTORY if os.path.isdir(source_path) else TYPE_FILE) + + # if score is not above zero, we skip this file + if score <= 0: + self.message('Skipping file with score {}: {}'.format(score, source_path), msglevel, level+1) + continue + + ### if we get here, we need to copy the file ### + + # if a directory, recurse to it + if os.path.isdir(source_path): + self.message('Creating directory with score {}: {}'.format(score, source_path), msglevel, level+1) + # create it in the destination and recurse + if not os.path.exists(dest_path): + os.mkdir(dest_path) + elif not os.path.isdir(dest_path): # could be a file or link + os.unlink(dest_path) + os.mkdir(dest_path) + self.copy_dir(source_path, dest_path, level+1) + + # if a regular Javscript file, run through the static file processors (scripts group) + elif ext == '.js' and not self.options.get('no_minify') and jsmin: + self.message('Including and minifying file with score {}: {}'.format(score, source_path), msglevel, level+1) + with open(source_path, encoding=encoding) as fin: + with open(dest_path, 'w', encoding=encoding) as fout: + minified = minify(fin.read(), jsmin) + fout.write(minified) + + + # if a CSS file, run through the static file processors (styles group) + elif ext == '.css' and not self.options.get('no_minify') and cssmin: + self.message('Including and minifying file with score {}: {}'.format(score, source_path), msglevel, level+1) + with open(source_path, encoding=encoding) as fin: + with open(dest_path, 'w', encoding=encoding) as fout: + minified = minify(fin.read(), cssmin) + fout.write(minified) + + # otherwise, just copy the file + else: + self.message('Including file with score {}: {}'.format(score, source_path), msglevel, level+1) + shutil.copy2(source_path, dest_path) + + + +############################################## +### Utility functions + +def minify(text, minifier): + '''Minifies the source text (if needed)''' + # there really isn't a good way to know if a file is already minified. + # our heuristic is if source is more than 50 bytes greater of dest OR + # if a hard return is found in the first 50 chars, we assume it is not minified. + minified = minifier(text) + if abs(len(text) - len(minified)) > 50 or '\n' in text[:50]: + return minified + return text diff --git a/build/lib/django_mako_plus/management/commands/dmp_makemessages.py b/build/lib/django_mako_plus/management/commands/dmp_makemessages.py new file mode 100644 index 00000000..24b82663 --- /dev/null +++ b/build/lib/django_mako_plus/management/commands/dmp_makemessages.py @@ -0,0 +1,100 @@ +from django.apps import apps +from django.core.management.commands.makemessages import Command as MakeMessagesCommand +from django.template.exceptions import TemplateSyntaxError + +from django_mako_plus.convenience import get_template_for_path +from django_mako_plus.management.mixins import DMPCommandMixIn + +import os +import os.path + + +class Command(DMPCommandMixIn, MakeMessagesCommand): + help = ( + "Makes messages for Mako templates. The native makemessages in Django doesn't understand " + "Mako files, so this command compiles all your templates so it can find the compiled .py versions of the templates." + ) + + + SEARCH_DIRS = [ + os.path.join('{app_path}', 'templates') + ] + + + def add_arguments(self, parser): + super().add_arguments(parser) + + parser.add_argument( + '--template-dir', + default=[], + dest='template_dir', + action='append', + help="Precompile all Mako templates in the given subdirectory of each app. Deep search is used, so children of a subdirectory are automatically included. May be specified multiple times. Ex: --template-dir=templates" + ) + parser.add_argument( + '--extra-gettext-option', + default=[], + dest='extra_gettext_option', + action='append', + help="Add an additional option to be passed to gettext. Ex: --extra-gettext-option='--keyword=mytrans'" + ) + parser.add_argument( + '--ignore-template-errors', + action='store_true', + dest='ignore_template_errors', + default=False, + help='Ignore any template errors raised when compiling Mako templates' + ) + + + def handle(self, *args, **options): + dmp = apps.get_app_config('django_mako_plus') + self.options = options + if self.options.get('template_dir', []): + self.SEARCH_DIRS = [] + for subdir in self.options.get('template_dir', []): + self.SEARCH_DIRS.append(os.path.join('{app_path}', subdir)) + + # go through each dmp_enabled app and compile its mako templates + for app_config in dmp.get_registered_apps(): + self.compile_mako_files(app_config) + + # add any extra xgettext_options (the regular makemessages doesn't do this, and I need to include other aliases like _(), _z(), etc. + for opt in options.get('extra_gettext_option', []): + self.xgettext_options.append(opt) + + # call the superclass command + return MakeMessagesCommand.handle(self, *args, **options) + + + def compile_mako_files(self, app_config): + '''Compiles the Mako templates within the apps of this system''' + # go through the files in the templates, scripts, and styles directories + for subdir_name in self.SEARCH_DIRS: + subdir = subdir_name.format( + app_path=app_config.path, + app_name=app_config.name, + ) + + def recurse_path(path): + self.message('searching for Mako templates in {}'.format(path), 1) + if os.path.exists(path): + for filename in os.listdir(path): + filepath = os.path.join(path, filename) + _, ext = os.path.splitext(filename) + if filename.startswith('__'): # __dmpcache__, __pycache__ + continue + + elif os.path.isdir(filepath): + recurse_path(filepath) + + elif ext.lower() in ( '.htm', '.html', '.mako' ): + # create the template object, which creates the compiled .py file + self.message('compiling {}'.format(filepath), 2) + try: + get_template_for_path(filepath) + except TemplateSyntaxError: + if not self.options.get('ignore_template_errors'): + raise + + recurse_path(subdir) diff --git a/build/lib/django_mako_plus/management/commands/dmp_startapp.py b/build/lib/django_mako_plus/management/commands/dmp_startapp.py new file mode 100644 index 00000000..01fd118f --- /dev/null +++ b/build/lib/django_mako_plus/management/commands/dmp_startapp.py @@ -0,0 +1,53 @@ +from django.apps import apps +from django.core.management.commands.startapp import Command as StartAppCommand +from django_mako_plus.management.mixins import DMPCommandMixIn + +import os, os.path, platform + + +NOT_SET = object() + + +class Command(DMPCommandMixIn, StartAppCommand): + help = ( + "Creates a DMP app directory structure for the given app name in " + "the current directory or optionally in the given directory." + ) + requires_system_checks = [] + + def add_arguments(self, parser): + super().add_arguments(parser) + self.get_action_by_dest(parser, 'template').default = NOT_SET + + + def handle(self, *args, **options): + dmp = apps.get_app_config('django_mako_plus') + if options.get('template') is NOT_SET: + # set the template to a DMP app + options['template'] = 'http://cdn.rawgit.com/doconix/django-mako-plus/master/app_template.zip' + # attempt to use a local DMP install instead of the online repo as specified above + dmp_dir = dmp.path + if dmp_dir: + template_dir = os.path.join(dmp_dir, 'app_template') + if os.path.exists(template_dir): + options['template'] = template_dir + + # ensure we have the extensions we need + options['extensions'] = list(set(options.get('extensions') + [ 'py', 'htm', 'html' ])) + + # call the super + StartAppCommand.handle(self, *args, **options) + + pyexec = 'python' if platform.system() == 'Windows' else 'python3' + self.message("""App {name} created successfully! + +What's next? + 1. Add your new app to the list in settings.py: + INSTALLED_APPS = [ + ... + '{name}', + ] + 2. {pyexec} manage.py runserver + 3. Take a browser to http://localhost:8000/ + +""".format(name=options.get('name'), pyexec=pyexec)) diff --git a/build/lib/django_mako_plus/management/commands/dmp_startproject.py b/build/lib/django_mako_plus/management/commands/dmp_startproject.py new file mode 100644 index 00000000..a57dfe96 --- /dev/null +++ b/build/lib/django_mako_plus/management/commands/dmp_startproject.py @@ -0,0 +1,43 @@ +from django.apps import apps +from django.core.management.commands.startproject import Command as StartProjectCommand +from django_mako_plus.management.mixins import DMPCommandMixIn + +import os, os.path, platform + + +NOT_SET = object() + + +class Command(DMPCommandMixIn, StartProjectCommand): + help = ( + "Creates a DMP project directory structure for the given project " + "name in the current directory or optionally in the given directory." + ) + requires_system_checks = [] + + def add_arguments(self, parser): + super().add_arguments(parser) + self.get_action_by_dest(parser, 'template').default = NOT_SET + + def handle(self, *args, **options): + if options.get('template') is NOT_SET: + # set the template to a DMP app + options['template'] = 'http://cdn.rawgit.com/doconix/django-mako-plus/master/project_template.zip' + # attempt to use a local DMP install instead of the online repo as specified above + # this should work unless the installation type is not normal + template_dir = os.path.join(self.get_dmp_path(), 'project_template') + if os.path.exists(template_dir): + options['template'] = template_dir + + # call the super + StartProjectCommand.handle(self, *args, **options) + + # display a message to help the new kids + pyexec = 'python' if platform.system() == 'Windows' else 'python3' + self.message("""Project {name} created successfully! + +What's next? + 1. cd {name} + 2. {pyexec} manage.py dmp_startapp homepage + +""".format(name=options.get('name'), pyexec=pyexec)) diff --git a/build/lib/django_mako_plus/management/commands/dmp_webpack.py b/build/lib/django_mako_plus/management/commands/dmp_webpack.py new file mode 100644 index 00000000..857c9e27 --- /dev/null +++ b/build/lib/django_mako_plus/management/commands/dmp_webpack.py @@ -0,0 +1,211 @@ +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.management.base import BaseCommand, CommandError +from django_mako_plus import LinkProvider +from django_mako_plus.template import create_mako_context +from django_mako_plus.provider.runner import ProviderRun +from django_mako_plus.management.mixins import DMPCommandMixIn +from mako.template import Template as MakoTemplate +import os +import os.path +from collections import OrderedDict + + + +class Command(DMPCommandMixIn, BaseCommand): + help = 'Removes compiled template cache folders in your DMP-enabled app directories.' + can_import_settings = True + + def __init__(self, *args, running_inline=False, **kwargs): + # special option when called from providers/webpack.py + self.running_inline = running_inline + super().__init__(*args, **kwargs) + + + def add_arguments(self, parser): + super().add_arguments(parser) + + parser.add_argument( + '--overwrite', + action='store_true', + dest='overwrite', + default=False, + help='Overwrite existing __entry__.js if it exists' + ) + parser.add_argument( + '--single', + type=str, + metavar='FILENAME', + help='Instead of per-app entry files, create a single file that includes the JS for all listed apps' + ) + parser.add_argument( + 'appname', + type=str, + nargs='*', + help='The name of one or more DMP apps. If omitted, all DMP apps are processed' + ) + + + def handle(self, *args, **options): + dmp = apps.get_app_config('django_mako_plus') + self.options = options + WebpackProviderRun.initialize_providers() + for pci in WebpackProviderRun.CONTENT_PROVIDERS: + if not issubclass(pci.cls, LinkProvider): + raise ImproperlyConfigured('Invalid provider {} listed in {}: must extend django_mako_plus.LinkProvider'.format(pci.cls.__qualname__, WebpackProviderRun.SETTINGS_KEY)) + + # ensure we have a base directory + try: + if not os.path.isdir(os.path.abspath(settings.BASE_DIR)): + raise CommandError('Your settings.py BASE_DIR setting is not a valid directory. Please check your settings.py file for the BASE_DIR variable.') + except AttributeError: + raise CommandError('Your settings.py file is missing the BASE_DIR setting.') + + # the apps to process + enapps = [] + for appname in options.get('appname'): + enapps.append(apps.get_app_config(appname)) + if len(enapps) == 0: + enapps = dmp.get_registered_apps() + + # main runner for per-app files + created = False + if options.get('single') is None: + for app in enapps: + self.message('Searching `{}` app...'.format(app.name), level=3) + filename = os.path.join(app.path, 'scripts', '__entry__.js') + created = self.create_entry_file(filename, self.generate_script_map(app), [ app ]) or created + + # main runner for one sitewide file + else: + script_map = {} + for app in enapps: + self.message('Searching `{}` app...'.format(app.name), level=3) + script_map.update(self.generate_script_map(app)) + created = self.create_entry_file(options.get('single'), script_map, enapps) or created + + + def create_entry_file(self, filename, script_map, enapps): + '''Creates an entry file for the given script map''' + if len(script_map) == 0: + return + + # create the entry file + template = MakoTemplate(''' +<%! import os %> +// dynamic imports are within functions so they don't happen until called +DMP_CONTEXT.loadBundle({ + %for (app, template), script_paths in script_map.items(): + + "${ app }/${ template }": () => [ + %for path in script_paths: + import(/* webpackMode: "eager" */ "./${ os.path.relpath(path, os.path.dirname(filename)) }"), + %endfor + ], + %endfor + +}) +''') + content = template.render( + enapps=enapps, + script_map=script_map, + filename=filename, + ).strip() + + # ensure the parent directories exist + if not os.path.exists(os.path.dirname(filename)): + os.makedirs(os.path.dirname(filename)) + + # if the file exists, then consider the options + file_exists = os.path.exists(filename) + if file_exists and self.running_inline: + # running inline means that we're in debug mode and webpack is likely watching, so + # we don't want to recreate the entry file (and cause webpack to constantly reload) + # unless we have changes + with open(filename, 'r') as fin: + if content == fin.read(): + return False + if file_exists and not self.options.get('overwrite'): + raise CommandError('Refusing to destroy existing file: {} (use --overwrite option or remove the file)'.format(filename)) + + # if we get here, write the file + self.message('Creating {}'.format(os.path.relpath(filename, settings.BASE_DIR)), level=3) + with open(filename, 'w') as fout: + fout.write(content) + return True + + def generate_script_map(self, config): + ''' + Maps templates in this app to their scripts. This function deep searches + app/templates/* for the templates of this app. Returns the following + dictionary with absolute paths: + + { + ( 'appname', 'template1' ): [ '/abs/path/to/scripts/template1.js', '/abs/path/to/scripts/supertemplate1.js' ], + ( 'appname', 'template2' ): [ '/abs/path/to/scripts/template2.js', '/abs/path/to/scripts/supertemplate2.js', '/abs/path/to/scripts/supersuper2.js' ], + ... + } + + Any files or subdirectories starting with double-underscores (e.g. __dmpcache__) are skipped. + ''' + script_map = OrderedDict() + template_root = os.path.join(os.path.relpath(config.path, settings.BASE_DIR), 'templates') + def recurse(folder): + subdirs = [] + if os.path.exists(folder): + for filename in os.listdir(folder): + if filename.startswith('__'): + continue + filerel = os.path.join(folder, filename) + if os.path.isdir(filerel): + subdirs.append(filerel) + + elif os.path.isfile(filerel): + template_name = os.path.relpath(filerel, template_root) + scripts = self.template_scripts(config, template_name) + key = ( config.name, os.path.splitext(template_name)[0] ) + self.message('Found template: {}; static files: {}'.format(key, scripts), level=3) + script_map[key] = scripts + + for subdir in subdirs: + recurse(subdir) + + recurse(template_root) + return script_map + + + def template_scripts(self, config, template_name): + ''' + Returns a list of scripts used by the given template object AND its ancestors. + + This runs a ProviderRun on the given template (as if it were being displayed). + This allows the WEBPACK_PROVIDERS to provide the JS files to us. + ''' + dmp = apps.get_app_config('django_mako_plus') + template_obj = dmp.engine.get_template_loader(config, create=True).get_mako_template(template_name, force=True) + mako_context = create_mako_context(template_obj) + inner_run = WebpackProviderRun(mako_context['self']) + inner_run.run() + scripts = [] + for tpl in inner_run.templates: + for p in tpl.providers: + if os.path.exists(p.absfilepath): + scripts.append(p.absfilepath) + return scripts + + + +############################################################################### +### Specialized provider run for the above management command + +class WebpackProviderRun(ProviderRun): + SETTINGS_KEY = 'WEBPACK_PROVIDERS' + + def _get_template_inheritance(self): + ''' + Normally, this returns a list of the template inheritance of tself, starting with the oldest ancestor. + But for the webpack one, we just want the template itself, without any ancestors. + This gives the static files for this exact template (not including all ancestors). + ''' + return [ self.tself.template ] diff --git a/build/lib/django_mako_plus/management/mixins.py b/build/lib/django_mako_plus/management/mixins.py new file mode 100644 index 00000000..0162ec8a --- /dev/null +++ b/build/lib/django_mako_plus/management/mixins.py @@ -0,0 +1,60 @@ +import os, os.path + + + +######################################### +### Mixin for all DMP commands + +class DMPCommandMixIn(object): + '''Some extra SWAG that all DMP commands get''' + # needs to be true so Django initializes urls.py (which registers the dmp apps) + requires_system_checks = True + + def add_arguments(self, parser): + super().add_arguments(parser) + + # django also provides a verbosity parameter + # these two are just convenience params to it + parser.add_argument( + '--verbose', + action='store_true', + dest='verbose', + default=False, + help='Set verbosity to level 3 (see --verbosity).', + ) + parser.add_argument( + '--quiet', + action='store_true', + dest='quiet', + default=False, + help='Set verbosity to level 0, which silences all messages (see --verbosity).', + ) + + + def get_action_by_dest(self, parser, dest): + '''Retrieves the given parser action object by its dest= attribute''' + for action in parser._actions: + if action.dest == dest: + return action + return None + + + def execute(self, *args, **options): + '''Placing this in execute because then subclass handle() don't have to call super''' + if options['verbose']: + options['verbosity'] = 3 + if options['quiet']: + options['verbosity'] = 0 + self.verbosity = options.get('verbosity', 1) + super().execute(*args, **options) + + + def get_dmp_path(self): + '''Returns the absolute path to DMP. Apps do not have to be loaded yet''' + return os.path.dirname(os.path.dirname(__file__)) + + + def message(self, msg='', level=1, tab=0): + '''Print a message to the console''' + if self.verbosity >= level: + self.stdout.write('{}{}'.format(' ' * tab, msg)) diff --git a/build/lib/django_mako_plus/middleware.py b/build/lib/django_mako_plus/middleware.py new file mode 100644 index 00000000..fc1094c3 --- /dev/null +++ b/build/lib/django_mako_plus/middleware.py @@ -0,0 +1,40 @@ + +# try to import MiddlewareMixIn (Django 1.10+) +try: + from django.utils.deprecation import MiddlewareMixin +except ImportError: + # create a dummy MiddlewareMixin if older Django + MiddlewareMixin = object + +from .router import RequestViewWrapper, RoutingData + + + + +########################################################## +### Middleware the prepares the request for +### use with the controller. + + +class RequestInitMiddleware(MiddlewareMixin): + ''' + A required middleware class that adds a RoutingData object to the request + at the earliest possible moment. + + Note that VIEW middleware functions can not only read the RouteData variables, but they can + adjust values as well. This power should be used with great responsibility, but it allows + middleware to adjust the app, page, function, and url params if needed. + ''' + # This singleton is set on the request object early in the request (during middleware). + # Once urls.py has processed, request.dmp is changed to a populated RoutingData object. + INITIAL_ROUTING_DATA = RoutingData() + + + def process_request(self, request): + request.dmp = self.INITIAL_ROUTING_DATA + + def process_view(self, request, view_func, view_args, view_kwargs): + # view_func will be a RequestViewWrapper when our resolver (DMPResolver) matched + if isinstance(view_func, RequestViewWrapper): + view_func.routing_data.request = request + request.dmp = view_func.routing_data diff --git a/build/lib/django_mako_plus/models.py b/build/lib/django_mako_plus/models.py new file mode 100644 index 00000000..eca3badb --- /dev/null +++ b/build/lib/django_mako_plus/models.py @@ -0,0 +1 @@ +# this app has no models; file here just to conform to Django diff --git a/build/lib/django_mako_plus/project_template/manage.py-tpl b/build/lib/django_mako_plus/project_template/manage.py-tpl new file mode 100644 index 00000000..b61c0d32 --- /dev/null +++ b/build/lib/django_mako_plus/project_template/manage.py-tpl @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/build/lib/django_mako_plus/project_template/project_name/__init__.py-tpl b/build/lib/django_mako_plus/project_template/project_name/__init__.py-tpl new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/django_mako_plus/project_template/project_name/settings.py-tpl b/build/lib/django_mako_plus/project_template/project_name/settings.py-tpl new file mode 100644 index 00000000..3c6fc608 --- /dev/null +++ b/build/lib/django_mako_plus/project_template/project_name/settings.py-tpl @@ -0,0 +1,164 @@ +""" +Django settings for {{ project_name }} project. + +Generated by 'django-admin startproject' using Django {{ django_version }}. + +For more information on this file, see +https://docs.djangoproject.com/en/{{ docs_version }}/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/ +""" + +import os +import shutil + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '{{ secret_key }}' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Necessary setting for content types framework +DEFAULT_CONTENT_TYPE = '' + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_mako_plus', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django_mako_plus.RequestInitMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = '{{ project_name }}.urls' + +TEMPLATES = [ + { + 'NAME': 'django_mako_plus', + 'BACKEND': 'django_mako_plus.MakoTemplates', + 'OPTIONS': { + # see the DMP documentation, "configuration options" page for available options + }, + }, + { + 'NAME': 'django', + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = '{{ project_name }}.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/{{ docs_version }}/howto/static-files/ + +STATIC_URL = '/static/' +STATICFILES_DIRS = ( + # SECURITY WARNING: this next line must be commented out at deployment + BASE_DIR, +) +STATIC_ROOT = os.path.join(BASE_DIR, 'static') + + +# A logger for DMP +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'django_mako_plus_simple': { + 'format': '%(levelname)s::DMP %(message)s' + }, + }, + 'handlers': { + 'django_mako_plus_console':{ + 'class':'logging.StreamHandler', + 'formatter': 'django_mako_plus_simple' + }, + }, + 'loggers': { + 'django_mako_plus': { + 'handlers': ['django_mako_plus_console'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, +} diff --git a/build/lib/django_mako_plus/project_template/project_name/urls.py-tpl b/build/lib/django_mako_plus/project_template/project_name/urls.py-tpl new file mode 100644 index 00000000..72972372 --- /dev/null +++ b/build/lib/django_mako_plus/project_template/project_name/urls.py-tpl @@ -0,0 +1,28 @@ +"""{{ project_name }} URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/{{ docs_version }}/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.urls import include, path, re_path +from django.contrib import admin + +urlpatterns = [ + # the built-in Django administrator + re_path(r'^admin/', admin.site.urls), + + # urls for any third-party apps go here + + # the DMP router - this should normally be the last URL listed + path('', include('django_mako_plus.urls')), +] + diff --git a/build/lib/django_mako_plus/project_template/project_name/wsgi.py-tpl b/build/lib/django_mako_plus/project_template/project_name/wsgi.py-tpl new file mode 100644 index 00000000..0d68b956 --- /dev/null +++ b/build/lib/django_mako_plus/project_template/project_name/wsgi.py-tpl @@ -0,0 +1,16 @@ +""" +WSGI config for {{ project_name }} project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings") + +application = get_wsgi_application() diff --git a/build/lib/django_mako_plus/provider/__init__.py b/build/lib/django_mako_plus/provider/__init__.py new file mode 100644 index 00000000..d5fe1554 --- /dev/null +++ b/build/lib/django_mako_plus/provider/__init__.py @@ -0,0 +1,55 @@ +from django.apps import apps +from django.template import Context +from django.utils.safestring import mark_safe + +from .runner import ProviderRun +from ..template import create_mako_context + + +######################################################### +### Primary functions + +def links(tself, group=None): + '''Returns the HTML for the given provider group (or all groups if None)''' + pr = ProviderRun(tself, group) + pr.run() + return mark_safe(pr.getvalue()) + + +def template_links(request, app, template_name, context=None, group=None, force=True): + ''' + Returns the HTML for the given provider group, using an app and template name. + This method should not normally be used (use links() instead). The use of + this method is when provider need to be called from regular python code instead + of from within a rendering template environment. + ''' + if isinstance(app, str): + app = apps.get_app_config(app) + if context is None: + context = {} + dmp = apps.get_app_config('django_mako_plus') + template_obj = dmp.engine.get_template_loader(app, create=True).get_mako_template(template_name, force=force) + return template_obj_links(request, template_obj, context, group) + + +def template_obj_links(request, template_obj, context=None, group=None): + ''' + Returns the HTML for the given provider group, using a template object. + This method should not normally be used (use links() instead). The use of + this method is when provider need to be called from regular python code instead + of from within a rendering template environment. + ''' + # the template_obj can be a MakoTemplateAdapter or a Mako Template + # if our DMP-defined MakoTemplateAdapter, switch to the embedded Mako Template + template_obj = getattr(template_obj, 'mako_template', template_obj) + # create a mako context so it seems like we are inside a render + context_dict = { + 'request': request, + } + if isinstance(context, Context): + for d in context: + context_dict.update(d) + elif context is not None: + context_dict.update(context) + mako_context = create_mako_context(template_obj, **context_dict) + return links(mako_context['self'], group=group) diff --git a/build/lib/django_mako_plus/provider/base.py b/build/lib/django_mako_plus/provider/base.py new file mode 100644 index 00000000..3113cd5a --- /dev/null +++ b/build/lib/django_mako_plus/provider/base.py @@ -0,0 +1,157 @@ +from django.apps import apps +from django.conf import settings +import os, inspect +import logging +from ..util import log + + +############################################################## +### Abstract Provider Base + +class BaseProvider(object): + ''' + Abstract base provider class. Instances of this class are created by ProviderRun's constructor. + + Note that the app can only be inferred for templates in project apps (below settings.BASE_DIR). + The app has to be inferred because mako creates templates internally during the render runtime, + and I don't want to hack into Mako internals. + ''' + DEFAULT_OPTIONS = { + # the group this provider is part of. this only matters when + # the html page limits the providers that will be called with + # ${ django_mako_plus.links(group="...") } + 'group': 'styles', + + # whether enabled (see "Dev vs. Prod" in the DMP docs) + 'enabled': True, + } + + def __init__(self, provider_run, template, index, options): + # the following are always set + self.provider_run = provider_run # the object in charge of this run of providers for the given template + self.template = template # Mako template object + self.index = index # position of this provider in the list for this run + self.options = options # the combined options from the provider, its supers, and settings.py + + # the only time these remain None is if we can't infer the app. that happens when: + # 1. the template was created from a string (and has no filename), or + # 2. the template file is located outside of an app directory + self.app_config = None # AppConfig the template resides in, if possible to infer. + self.template_ext = None # Template filename extension + self.template_relpath = None # Template path, relative to app/templates/ and without extension, if possible to infer + self.template_name = None # Template filename without extension, if possible to infer + if self.template.filename is not None: + # try to infer the app + fn_no_ext, self.template_ext = os.path.splitext(template.filename) + relpath = os.path.relpath(fn_no_ext, settings.BASE_DIR) + if not relpath.startswith('..'): # can't infer reliably outside of project dir + try: + path_parts = os.path.normpath(relpath).split(os.path.sep) + self.app_config = apps.get_app_config(path_parts[0]) + self.template_relpath = '/'.join(path_parts[2:]) + self.template_name = path_parts[-1] + except LookupError: # template isn't under an app + pass + + def __repr__(self): + return '<{}/{}:{}{}>'.format( + self.app_config.name if self.app_config is not None else 'unknown', + self.template_relpath if self.template_relpath is not None else self.template, + self.__class__.__qualname__, + '' if self.options['enabled'] else ' (disabled)', + ) + + @property + def group(self): + return self.options['group'] + + def provide(self): + ''' + Generate the content and do the work of this provider. + Use self.write() to output content. + ''' + pass + + def write(self, content): + '''Writes content to the response''' + # really just a redirect to the provider run + self.provider_run.write(content) + + + # in these next methods, the concept of "related providers" means the providers + # of the same class type in the same position. + # Suppose we have index.html inheriting from base.htm and we have three providers + # listed in settings. We get six total providers: + # + # base.htm -> JsContextProvider, CssLinkProvider, JsLinkProvider + # | + # index.html -> JsContextProvider, CssLinkProvider, JsLinkProvider + # + # In this example, the two JsContextProviders are "related providers", + # the two CssLinkProviders are "related providers", and the two + # JsLinkProviders are "related providers". + + def iter_related(self): + ''' + Generator function that iterates this object's related providers, + which includes this provider. + ''' + for tpl in self.provider_run.templates: + yield tpl.providers[self.index] + + def get_first(self): + ''' + Returns the first provider in the related providers to this one. + This is the provider instance for the base template (e.g. base.htm). + This is useful when a provider class needs to do things at the start of a run. + ''' + return self.provider_run.templates[0].providers[self.index] + + def is_first(self): + ''' + Returns true if this provider is first to run among its related providers. + This is the provider associated with the base template (e.g. base.htm). + ''' + return self.provider_run.templates[0].template is self.template + + def get_last(self): + ''' + Returns the last provider in the related providers to this one. + This is the provider instance for the main template (e.g. index.html). + This is useful when a provider class needs to do things at the end of a run. + ''' + return self.provider_run.templates[-1].providers[self.index] + + def is_last(self): + ''' + Returns true if this provider is last to run among its related providers. + This is the provider associated with the main template (e.g. index.htm). + ''' + return self.provider_run.templates[-1].template is self.template + + + + # Each provider can cache one item in the template to make + # things faster at production. Raises an AttributeError if nothing has + # been cached. + # + # Why store things in the template? Because Mako already uses a cache + # for templates, so this keeps the values alive as long as a template object is. + # + # Note that: + # 1. Values can't be stored in provider objects because new objects are created + # on each provider run. + # 2. Values can't be stored as class variables because the same class can + # be listed more than once in settings. + # 3. The options dict IS unique to a given provider entry, but there's no need + # to manage another cache there when Mako is doing it. + + def get_cache_item(self): + '''Gets the cached item. Raises AttributeError if it hasn't been set.''' + if settings.DEBUG: + raise AttributeError('Caching disabled in DEBUG mode') + return getattr(self.template, self.options['template_cache_key']) + + def set_cache_item(self, item): + '''Sets the cached item''' + setattr(self.template, self.options['template_cache_key'], item) diff --git a/build/lib/django_mako_plus/provider/compile.py b/build/lib/django_mako_plus/provider/compile.py new file mode 100644 index 00000000..ebe8c218 --- /dev/null +++ b/build/lib/django_mako_plus/provider/compile.py @@ -0,0 +1,186 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +import os +import os.path +import shutil +import collections +import logging +from .base import BaseProvider +from ..util import log +from ..command import run_command + + + +class CompileProvider(BaseProvider): + ''' + Runs a command, such as compiling *.scss or *.less, when an output file + timestamp is older than the source file. + + When settings.DEBUG=True, checks for a recompile every request. + When settings.DEBUG=False, checks for a recompile only once per server run. + ''' + DEFAULT_OPTIONS = { + 'group': 'styles', + + # source path to search for, relative to the project directory. possible values are: + # 1. None: a default path is used, such as "{app}/{subdir}/{filename.ext}", prefixed + # with the static root at production; see subclasses for their default filenames. + # 2. function, lambda, or other callable: called as func(provider) and + # should return a string + # 3. str: used directly + 'sourcepath': None, + + # target path to search for, relative to the project directory. possible values are: + # should resolve to one exact file. possible values: + # 1. None: a default path is used, such as "{app}/{subdir}/{filename.ext}", prefixed + # with the static root at production; see subclasses for their default filenames. + # 2. function, lambda, or other callable: called as func(provider) and + # should return a string + # 3. str: used directly + 'targetpath': None, + + # explicitly sets the command to be run. possible values: + # 1. None or []: the default command is run + # 2. function, lambda, or other callable: called as func(provider), expects list as return + # 3. list: used directly in the call to subprocess module + 'command': [], + } + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # create the paths + try: + self.sourcepath, self.targetpath = self.get_cache_item() + checked_previously = True + except AttributeError: + self.sourcepath = os.path.join(settings.BASE_DIR if settings.DEBUG else settings.STATIC_ROOT, self.build_sourcepath()) + self.targetpath = os.path.join(settings.BASE_DIR if settings.DEBUG else settings.STATIC_ROOT, self.build_targetpath()) + self.set_cache_item((self.sourcepath, self.targetpath)) + checked_previously = False + + if not settings.DEBUG and checked_previously: + log.debug('%s created for %s [checked previously]', self, self.sourcepath) + return + + # do we need to compile? + if not os.path.exists(self.sourcepath): + log.debug('%s created for %s [nonexistent file]', self, self.sourcepath) + elif not self.needs_compile: + log.debug('%s created for %s [already up-to-date]', self, self.sourcepath) + else: + log.debug('%s created for %s [compiling]', self, self.sourcepath) + if not os.path.exists(os.path.dirname(self.targetpath)): + os.makedirs(os.path.dirname(self.targetpath)) + run_command(*self.build_command()) + + def build_sourcepath(self): + # if defined in settings, run the function or return the string + if self.options['sourcepath'] is not None: + return self.options['sourcepath'](self) if callable(self.options['sourcepath']) else self.options['sourcepath'] + # build the default + if self.app_config is None: + log.warn('{} skipped: template %s not in project subdir and `targetpath` not in settings', (self.__class__.__qualname__, self.template_relpath)) + return self.build_default_sourcepath() + + def build_default_sourcepath(self): + # this method is overridden in CompileScssProvider and CompileLessProvider lower in this file + raise ImproperlyConfigured('{} must set `sourcepath` in options (or a subclass can override build_default_sourcepath).'.format(self.__class__.__qualname__)) + + def build_targetpath(self): + # if defined in settings, run the function or return the string + if self.options['targetpath'] is not None: + return self.options['targetpath'](self) if callable(self.options['targetpath']) else self.options['targetpath'] + # build the default + if self.app_config is None: + log.warn('{} skipped: template %s not in project subdir and `targetpath` not in settings', (self.__class__.__qualname__, self.template_relpath)) + return self.build_default_targetpath() + + def build_default_targetpath(self): + # this method is overridden in CompileScssProvider and CompileLessProvider lower in this file + raise ImproperlyConfigured('{} must set `targetpath` in options (or a subclass can override build_default_targetpath).'.format(self.__class__.__qualname__)) + + def build_command(self): + '''Returns the command to run, as a list (see subprocess module)''' + # if defined in settings, run the function or return the string + if self.options['command']: + return self.options['command'](self) if callable(self.options['command']) else self.options['command'] + # build the default + return self.build_default_command() + + def build_default_command(self): + # this method is overridden in CompileScssProvider and CompileLessProvider lower in this file + raise ImproperlyConfigured('{} must set `command` in options (or a subclass can override build_default_command).'.format(self.__class__.__qualname__)) + + @property + def needs_compile(self): + '''Returns True if self.sourcepath is newer than self.targetpath''' + try: + source_mtime = os.stat(self.sourcepath).st_mtime + except OSError: # no source for this template, so just return + return False + try: + target_mtime = os.stat(self.targetpath).st_mtime + except OSError: # target doesn't exist, so compile + return True + # both source and target exist, so compile if source newer + return source_mtime > target_mtime + + +################### +### Sass + +class CompileScssProvider(CompileProvider): + '''Specialized CompileProvider for SCSS''' + def build_default_sourcepath(self): + return os.path.join( + self.app_config.name, + 'styles', + self.template_relpath + '.scss', + ) + + def build_default_targetpath(self): + # posixpath because URLs use forward slash + return os.path.join( + self.app_config.name, + 'styles', + self.template_relpath + '.css', + ) + + def build_default_command(self): + return [ + shutil.which('sass'), + '--load-path={}'.format(settings.BASE_DIR), + self.sourcepath, + self.targetpath, + ] + + +##################### +### Less + +class CompileLessProvider(CompileProvider): + '''Specialized CompileProvider that contains settings for *.less files.''' + def build_default_sourcepath(self): + return os.path.join( + self.app_config.name, + 'styles', + self.template_relpath + '.less', + ) + + def build_default_targetpath(self): + # posixpath because URLs use forward slash + return os.path.join( + self.app_config.name, + 'styles', + self.template_relpath + '.css', + ) + + def build_default_command(self): + return [ + shutil.which('lessc'), + '--source-map', + self.sourcepath, + self.targetpath, + ] diff --git a/build/lib/django_mako_plus/provider/context.py b/build/lib/django_mako_plus/provider/context.py new file mode 100644 index 00000000..02a279a9 --- /dev/null +++ b/build/lib/django_mako_plus/provider/context.py @@ -0,0 +1,68 @@ +from django.utils.module_loading import import_string +import json +import logging +from ..version import __version__ +from ..util import log +from .base import BaseProvider + +################################### +### JS Context Provider + +class JsContextProvider(BaseProvider): + ''' + Adds all js_context() variables to DMP_CONTEXT. + ''' + DEFAULT_OPTIONS = { + # the group this provider is part of. this only matters when + # the html page limits the providers that will be called with + # ${ django_mako_plus.links(group="...") } + 'group': 'scripts', + # the encoder to use for the JSON structure + 'encoder': 'django.core.serializers.json.DjangoJSONEncoder', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.encoder = import_string(self.options['encoder']) + if log.isEnabledFor(logging.DEBUG): + log.debug('%s created', repr(self)) + + def provide(self): + # we output on the first run through - the context is only needed once + if not self.is_first(): + return + + # generate the context dictionary + data = { + 'id': self.provider_run.uid, + 'version': __version__, + 'templates': [ '{}/{}'.format(p.app_config.name, p.template_relpath) for p in self.iter_related() ], + 'app': self.provider_run.request.dmp.app if self.provider_run.request is not None else None, + 'page': self.provider_run.request.dmp.page if self.provider_run.request is not None else None, + 'log': log.isEnabledFor(logging.DEBUG), + 'values': { + 'id': self.provider_run.uid, + }, + } + for k in self.provider_run.context.keys(): + if isinstance(k, jscontext): + value = self.provider_run.context[k] + data['values'][k] = value.__jscontext__() if callable(getattr(value, '__jscontext__', None)) else value + + # output the script + self.write('') + + +class jscontext(str): + ''' + Marks a key in the context dictionary as a JS context item. + JS context items are sent to the template like normal, + but they are also added to the runtime JS namespace. + + See the tutorial for more information on this function. + ''' + # no code needed, just using the class for identity diff --git a/build/lib/django_mako_plus/provider/link.py b/build/lib/django_mako_plus/provider/link.py new file mode 100644 index 00000000..aebb395a --- /dev/null +++ b/build/lib/django_mako_plus/provider/link.py @@ -0,0 +1,197 @@ +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings +from django.forms.utils import flatatt +import logging +import os +from ..util import crc32 +from ..util import log +from .base import BaseProvider + + +DUPLICATES_KEY = '_LinkProvider_Filename_Cache_' + +##################################################### +### LinkProvider abstract base class + +class LinkProvider(BaseProvider): + ''' + Renders links like and , prefixed + # with the static url at production; see subclasses for the default link format. + # 2. function, lambda, or other callable: called as func(provider) and + # should return a string + # 3. str: inserted directly into the template + 'link': None, + + # extra attributes for the link element + 'link_attrs': {}, + + # if a template is rendered more than once in a request, should we link more than once? + # defaults are: css=False, js=True, bundled_js=False + 'skip_duplicates': False, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + try: + self.filepath, self.absfilepath, self.mtime, self.version_id = self.get_cache_item() + + except AttributeError: + self.filepath = self.build_source_filepath() + self.absfilepath = os.path.join( + settings.BASE_DIR if settings.DEBUG else settings.STATIC_ROOT, + self.filepath, + ) + # file time and version hash + try: + self.mtime = int(os.stat(self.absfilepath).st_mtime) + # version_id combines file modification time and the CRC32 checksum of file bytes + self.version_id = (self.mtime << 32) | crc32(self.absfilepath) + except FileNotFoundError: + self.mtime = 0 + self.version_id = 0 + self.set_cache_item((self.filepath, self.absfilepath, self.mtime, self.version_id)) + + if log.isEnabledFor(logging.DEBUG): + log.debug('%s created for %s: [%s]', repr(self), self.filepath, 'will link' if self.mtime > 0 else 'will skip nonexistent file') + + + ### Source Filepath Building Methods ### + + def build_source_filepath(self): + # if defined in settings, run the function or return the string + if self.options['filepath'] is not None: + return self.options['filepath'](self) if callable(self.options['filepath']) else self.options['filepath'] + # build the default + if self.app_config is None: + log.warn('{} skipped: template %s not in project subdir and `filepath` not in settings', (self.__class__.__qualname__, self.template_relpath)) + return self.build_default_filepath() + + def build_default_filepath(self): + # this method is overridden in CssLinkProvider and JsLinkProvider lower in this file + raise ImproperlyConfigured('{} must set `filepath` in options (or a subclass can override build_default_filepath).'.format(self.__class__.__qualname__)) + + + ### Target Link Building Methods ### + + def build_target_link(self): + # if defined in settings, run the function or return the string + if self.options['link'] is not None: + return self.options['link'](self) if callable(self.options['link']) else self.options['link'] + # build the default + if self.app_config is None: + log.warn('{} skipped: template %s not in project subdir and `sourcepath` not in settings', (self.__class__.__qualname__, self.template_relpath)) + return self.build_default_link() + + def build_default_link(self): + # this method is overridden in CssLinkProvider and JsLinkProvider lower in this file + raise ImproperlyConfigured('{} must set `link` in options (or a subclass can override build_default_link).'.format(self.__class__.__qualname__)) + + + ### Provider Run Methods ### + + def get_already_generated(self): + # try to cache in the request, but if request is None, use the provider run + # note that the single key string skips duplicates across different instances, not just within this instance + placeholder = self.provider_run.request.dmp if self.provider_run.request is not None else self.provider_run + try: + return getattr(placeholder, DUPLICATES_KEY) + except AttributeError: + cache = set() + setattr(placeholder, DUPLICATES_KEY, cache) + return cache + + def provide(self): + # short circuit if the file doesn't exist + if self.mtime == 0: + return + # short circut if we're skipping duplicates and we've already seen this one + if self.options['skip_duplicates']: + already_generated = self.get_already_generated() + if self.absfilepath in already_generated: + if log.isEnabledFor(logging.DEBUG): + log.debug('%s skipped duplicate %s', repr(self), self.filepath) + return + already_generated.add(self.absfilepath) + + # if we get here, generate the link + if log.isEnabledFor(logging.DEBUG): + log.debug('%s linking %s', repr(self), self.filepath) + self.write(self.build_target_link()) + + +##################################### +### CssLinkProvider + +class CssLinkProvider(LinkProvider): + '''Generates a CSS ''' + DEFAULT_OPTIONS = { + 'group': 'styles', + # if a template is rendered more than once in a request, we usually don't + # need to include the css again. + 'skip_duplicates': True, + } + + def build_default_filepath(self): + '''Called when 'filepath' is not defined in the settings''' + return os.path.join( + self.app_config.name, + 'styles', + self.template_relpath + '.css', + ) + + def build_default_link(self): + '''Called when 'link' is not defined in the settings''' + attrs = {} + attrs["rel"] = "stylesheet" + attrs["href"] ="{}?{:x}".format( + os.path.join(settings.STATIC_URL, self.filepath).replace(os.path.sep, '/'), + self.version_id, + ) + attrs.update(self.options['link_attrs']) + attrs["data-context"] = self.provider_run.uid # can't be overridden + return ''.format(flatatt(attrs)) + + +############################################## +### JsLinkProvider + +class JsLinkProvider(LinkProvider): + '''Generates a JS '.format(flatatt(attrs)) diff --git a/build/lib/django_mako_plus/provider/runner.py b/build/lib/django_mako_plus/provider/runner.py new file mode 100644 index 00000000..931118a3 --- /dev/null +++ b/build/lib/django_mako_plus/provider/runner.py @@ -0,0 +1,101 @@ +from django.apps import apps +from django.conf import settings +from django.utils.module_loading import import_string +from collections import namedtuple +import io +import inspect +from uuid import uuid1 +from ..template import template_inheritance +from ..util import qualified_name, b58enc + + + +# __init__() below creates a list of templates, each of which has a list of providers +# this named tuple adds a small amount of extra clarity to it. +# I could use a dict or OrderedDict, but I need order AND fast indexing +TemplateProviderList = namedtuple("TemplateProviderList", [ 'template', 'providers' ]) + +# ProviderRun.initialize_providers() creates a list of these to hold provider options from settings.py +# I can't keep the options inside the provider class itself because a given class can be listed +# more than once in settings.py (with different options). +ProviderEntry = namedtuple("ProviderEntry", [ 'cls', 'options' ]) + + + +#################################################### +### Main runner for providers + +class ProviderRun(object): + '''A run through the providers for tself and its ancestors''' + SETTINGS_KEY = 'CONTENT_PROVIDERS' + CONTENT_PROVIDERS = [] + + @classmethod + def initialize_providers(cls): + '''Initializes the providers (called from dmp app ready())''' + dmp = apps.get_app_config('django_mako_plus') + # regular content providers + cls.CONTENT_PROVIDERS = [] + for provider_settings in dmp.options[cls.SETTINGS_KEY]: + # import the class for this provider + assert 'provider' in provider_settings, "Invalid entry in settings.py: CONTENT_PROVIDERS item must have 'provider' key" + provider_cls = import_string(provider_settings['provider']) + # combine options from all of its bases, then from settings.py + options = {} + for base in reversed(inspect.getmro(provider_cls)): + options.update(getattr(base, 'DEFAULT_OPTIONS', {})) + options.update(provider_settings) + # add to the list + if options['enabled']: + pe = ProviderEntry(provider_cls, options) + pe.options['template_cache_key'] = '_dmp_provider_{}_'.format(id(pe)) + cls.CONTENT_PROVIDERS.append(pe) + + + def __init__(self, tself, group=None): + ''' + tself: `self` object from a Mako template (available during rendering). + group: provider group to include (defaults to all groups if None) + ''' + # a unique context id for this run + self.uid = b58enc(uuid1().int) + self.tself = tself + self.request = tself.context.get('request') + self.context = tself.context + self.buffer = io.StringIO() + + # get the ProviderClassInfo objects that are used in this group + group_pes = [ pe for pe in self.CONTENT_PROVIDERS if group is None or pe.options['group'] == group ] + + # Create a map of template -> providers for this run + # { + # base.htm: [ JsLinkProvider(), CssLinkProvider(), ... ] + # app_base.htm: [ JsLinkProvider(), CssLinkProvider(), ... ] + # index.html: [ JsLinkProvider(), CssLinkProvider(), ... ] + # } + self.templates = [] + for tmpl in self._get_template_inheritance(): + tpl = TemplateProviderList(tmpl, []) + for index, pe in enumerate(group_pes): + tpl.providers.append(pe.cls(self, tmpl, index, pe.options)) + self.templates.append(tpl) + + def _get_template_inheritance(self): + '''Returns a list of the template inheritance of tself, starting with the oldest ancestor''' + return reversed(list(template_inheritance(self.tself))) + + def run(self): + '''Performs the run through the templates and their providers''' + for tpl in self.templates: + for provider in tpl.providers: + provider.provide() + + def write(self, content): + '''Provider instances use this to write to the buffer''' + self.buffer.write(content) + if settings.DEBUG: + self.buffer.write('\n') + + def getvalue(self): + '''Returns the buffer string''' + return self.buffer.getvalue() diff --git a/build/lib/django_mako_plus/provider/webpack.py b/build/lib/django_mako_plus/provider/webpack.py new file mode 100644 index 00000000..7b804638 --- /dev/null +++ b/build/lib/django_mako_plus/provider/webpack.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.core.management import call_command +from django.forms.utils import flatatt +import os +import os.path +import posixpath +from ..util import merge_dicts +from .link import CssLinkProvider, JsLinkProvider +from ..management.commands.dmp_webpack import Command as WebpackCommand + + + +class WebpackJsLinkProvider(JsLinkProvider): + '''Generates a JS '.format(flatatt(attrs)) + + def provide(self): + # this must come after the regular JsLinkProvider script because the JsLinkProvider doesn't always + # output a link (duplicates get skipped) + super().provide() + if self.is_last(): + self.write(''.format( + uid=self.provider_run.uid, + )) diff --git a/build/lib/django_mako_plus/router/__init__.py b/build/lib/django_mako_plus/router/__init__.py new file mode 100644 index 00000000..38be95dd --- /dev/null +++ b/build/lib/django_mako_plus/router/__init__.py @@ -0,0 +1,3 @@ +from .data import RoutingData +from .decorators import view_function, RequestViewWrapper +from .resolver import app_resolver, dmp_path diff --git a/build/lib/django_mako_plus/router/data.py b/build/lib/django_mako_plus/router/data.py new file mode 100644 index 00000000..e76c371e --- /dev/null +++ b/build/lib/django_mako_plus/router/data.py @@ -0,0 +1,109 @@ +from django.apps import apps + +from ..decorators import BaseDecorator +from .urlparams import URLParamList +from .discover import get_view_function + +from urllib.parse import unquote + + +class RoutingData(object): + ''' + The routing information for a request. This is created during url resolution when a pattern + matches (see resolver.py). + + During middleware, this is not available + request.dmp.app The Django application (such as "homepage"), as a string. + request.dmp.page The view module (such as "index" for index.py), as a string. + request.dmp.function The function within the view module to be called (usually "process_request"), + as a string. + request.dmp.module The module path in Python terms (such as homepage.views.index), as a string. + request.dmp.callable The view callable (function, method, etc.) to be called by the router. + request.dmp.view_type The type of view: function, class, or template. + request.dmp.urlparams A list of the remaining url parts, as a list of strings. Parameter conversion + uses the values in this list. + + ''' + def __init__(self, app=None, page=None, function=None, urlparams=None): + '''These variables are set by the process_view method above''' + # the request object is set later by the middleware so the render methods work + self.request = None + + # period and dash cannot be in python names, but we allow dash in app, dash in page, and dash/period in function + self.app = app.replace('-', '_') if app is not None else None + self.page = page.replace('-', '_') if page is not None else None + if function and function != 'process_request': + self.function = function.replace('.', '_').replace('-', '_') + fallback_template = '{}.{}.html'.format(page, function) + else: + self.function = 'process_request' + fallback_template = '{}.html'.format(page) + + # set the module and function + # the return of get_router_function might be a function, a class-based view, or a template + if self.app is not None and self.page is not None: + self.module = '.'.join([ self.app, 'views', self.page ]) + self.callable = get_view_function(self.module, self.function, self.app, fallback_template) + else: + self.module = None + self.callable = None + self.view_type = self.callable.view_type if self.callable is not None else None + + # parse the urlparams + # note that I'm not using unquote_plus because the + switches to a space *after* the question mark (in the regular parameters) + # in the normal url, spaces should be quoted with %20. Thanks Rosie for the tip. + if isinstance(urlparams, (list, tuple)): + self.urlparams = URLParamList(urlparams) + elif urlparams: + self.urlparams = URLParamList(( unquote(s) for s in urlparams.split('/') )) + else: + self.urlparams = URLParamList() + + + def __repr__(self): + return ''.format( + self.app, + self.page, + self.module, + self.function, + self.view_type, + self.urlparams, + ) + + + def _debug(self): + return 'django_mako_plus RoutingData:' + \ + ''.join(('\n\t{: <16}{}'.format(k, v) for k, v in ( + ( 'app', self.app ), + ( 'page', self.page ), + ( 'module', self.module ), + ( 'function', self.function ), + ( 'callable', self.callable ), + ( 'view_type', self.view_type ), + ( 'urlparams', self.urlparams ), + ))) + + + def render(self, template, context=None, def_name=None, subdir='templates', content_type=None, status=None, charset=None): + '''App-specific render function that renders templates in the *current app*, attached to the request for convenience''' + template_adapter = self.get_template_loader(subdir).get_template(template) + return getattr(template_adapter, 'render_to_response')(context=context, request=self.request, def_name=def_name, content_type=content_type, status=status, charset=charset) + + + def render_to_string(self, template, context=None, def_name=None, subdir='templates'): + '''App-specific render function that renders templates in the *current app*, attached to the request for convenience''' + template_adapter = self.get_template_loader(subdir).get_template(template) + return getattr(template_adapter, 'render')(context=context, request=self.request, def_name=def_name) + + + def get_template(self, template, subdir='templates'): + '''App-specific function to get a template from the current app''' + return self.get_template_loader(subdir).get_template(template) + + + def get_template_loader(self, subdir='templates'): + '''App-specific function to get the current app's template loader''' + if self.request is None: + raise ValueError("this method can only be called after the view middleware is run. Check that `django_mako_plus.middleware` is in MIDDLEWARE.") + dmp = apps.get_app_config('django_mako_plus') + return dmp.engine.get_template_loader(self.app, subdir) diff --git a/build/lib/django_mako_plus/router/decorators.py b/build/lib/django_mako_plus/router/decorators.py new file mode 100644 index 00000000..63b4ef98 --- /dev/null +++ b/build/lib/django_mako_plus/router/decorators.py @@ -0,0 +1,152 @@ +from django.apps import apps +from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist +from django.http import HttpResponse, StreamingHttpResponse, Http404, HttpResponseServerError + +from ..decorators import BaseDecorator +from ..signals import dmp_signal_post_process_request, dmp_signal_pre_process_request, dmp_signal_internal_redirect_exception, dmp_signal_redirect_exception +from ..util import import_qualified, log +from ..exceptions import InternalRedirectException, RedirectException + +import inspect +import sys +import functools + +# key we use to attach the converter to the view function (also see discover.py) +CONVERTER_ATTRIBUTE_NAME = 'parameter_converter' + + +########################################## +### View-function decorator + +class view_function(BaseDecorator): + ''' + A decorator to signify which view functions are "callable" by web browsers. + and to convert parameters using type hints, if provided. + + All endpoint functions, such as process_request, must be decorated as: + + @view_function + function process_request(request): + ... + + Or: + + @view_function(...) + function process_request(request): + ... + ''' + # singleton set of decorated functions + DECORATED_FUNCTIONS = set() + + + def __init__(self, decorator_function, *args, **kwargs): + '''Create a new wrapper around the decorated function''' + super().__init__(decorator_function, *args, **kwargs) + real_func = inspect.unwrap(decorator_function) + + # flag the function as an endpoint. doing it on the actual function because + # we don't know the order of decorators on the function. order only matters if + # the other decorators don't use @wraps correctly .in that case, @view_function + # will put DECORATED_KEY on the decorator function rather than the real function. + # but even that is fine *as long as @view_function is listed first*. + self.DECORATED_FUNCTIONS.add(real_func) + + + @classmethod + def is_decorated(cls, f): + '''Returns True if the given function is decorated with @view_function''' + real_func = inspect.unwrap(f) + return real_func in cls.DECORATED_FUNCTIONS + + + +############################################# +### Per-request wrapper for view functions + +class RequestViewWrapper(object): + ''' + A wrapper for the view function, created for each request. This is different than + @view_function above because @view_function wraps the function one time -- like + a normal decorator. This decorator is used like a normal function (no @ syntax) + and is placed *each* time a request comes through. It must be created per request + because we need to store the RoutingInfo object in it (which is different each request). + + Back story: Django creates its own ResolverMatch object during url resolution -- replacing + the one we create in resolver.py. This makes it difficult to send the RoutingData object, + through to the rest of the request. Since the only thing Django keeps from our ResolverMatch + is the function + args + kwargs, we need to stash the object in one of those. + + This per-request decorator also does parameter conversion, triggers signals, + and loop RedirectExceptions. + ''' + # FYI, not using BaseDecorator super because its metaclass + # expects the function in the constructor arguments. + + def __init__(self, routing_data): + self.routing_data = routing_data + # take name and attributes of the view function + functools.update_wrapper(self, self.routing_data.callable) + + + def __call__(self, request, *args, **kwargs): + log.info('%s', self.routing_data) + dmp = apps.get_app_config('django_mako_plus') + + # the middleware attaches the routing data to request.dmp, but the + # middleware is optional. let's attach it here again for those not + # using the middleware + request.dmp = self.routing_data + + # an outer try that catches the redirect exceptions + try: + + # convert the parameters (the converter is placed on the func in discover.py) + converter = getattr(self.routing_data.callable, CONVERTER_ATTRIBUTE_NAME, None) + if converter is not None: + args, kwargs = converter.convert_parameters(request, *args, **kwargs) + + # send the pre-signal + if dmp.options['SIGNALS']: + for receiver, ret_response in dmp_signal_pre_process_request.send(sender=sys.modules[__name__], request=request, view_args=args, view_kwargs=kwargs): + if isinstance(ret_response, (HttpResponse, StreamingHttpResponse)): + return ret_response + + # call the view function + response = self.routing_data.callable(request, *args, **kwargs) + if not isinstance(response, (HttpResponse, StreamingHttpResponse)): + log.info('%s failed to return an HttpResponse (or the post-signal overwrote it). Returning 500 error.', self.routing_data.callable) + return HttpResponseServerError('Invalid response received from server.') + + # send the post-signal + if dmp.options['SIGNALS']: + for receiver, ret_response in dmp_signal_post_process_request.send(sender=sys.modules[__name__], request=request, response=response, view_args=args, view_kwargs=kwargs): + if ret_response is not None: + response = ret_response # sets it to the last non-None in the signal receiver chain + + return response + + except InternalRedirectException as ivr: + # send the signal + if dmp.options['SIGNALS']: + dmp_signal_internal_redirect_exception.send(sender=sys.modules[__name__], request=request, exc=ivr) + # update the RoutingData object + request.dmp.module = ivr.redirect_module + request.dmp.function = ivr.redirect_function + try: + request.dmp.callable = getattr(import_qualified(request.dmp.module), request.dmp.function) + except (ImportError, AttributeError): + log.info('could not fulfill InternalViewRedirect because %s.%s does not exist.', request.dmp.module, request.dmp.function) + raise Http404() + # recurse with this routing data + log.info('received an InternalViewRedirect to %s.%s', request.dmp.module, request.dmp.function) + return RequestViewWrapper(self.routing_data)(request, *args, **kwargs) + + except RedirectException as e: # redirect to another page + log.info('view %s.%s redirected processing to %s', request.dmp.module, request.dmp.function, e.redirect_to) + # send the signal + if dmp.options['SIGNALS']: + dmp_signal_redirect_exception.send(sender=sys.modules[__name__], request=request, exc=e) + # send the browser the redirect command + return e.get_response(request) + + # the code should never get here diff --git a/build/lib/django_mako_plus/router/discover.py b/build/lib/django_mako_plus/router/discover.py new file mode 100644 index 00000000..69a0b726 --- /dev/null +++ b/build/lib/django_mako_plus/router/discover.py @@ -0,0 +1,116 @@ +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist +from django.template import TemplateDoesNotExist +from django.views.generic import View + +from .decorators import view_function, CONVERTER_ATTRIBUTE_NAME +from ..util import import_qualified, log + +import inspect +import threading +from importlib import import_module +from importlib.util import find_spec + + + +######################################################## +### Cached routers + +CACHED_VIEW_FUNCTIONS = {} +rlock = threading.RLock() + + +def get_view_function(module_name, function_name, fallback_app=None, fallback_template=None, verify_decorator=True): + ''' + Retrieves a view function from the cache, finding it if the first time. + Raises ViewDoesNotExist if not found. This is called by resolver.py. + ''' + # first check the cache (without doing locks) + key = ( module_name, function_name ) + try: + return CACHED_VIEW_FUNCTIONS[key] + except KeyError: + with rlock: + # try again now that we're locked + try: + return CACHED_VIEW_FUNCTIONS[key] + except KeyError: + # if we get here, we need to load the view function + func = find_view_function(module_name, function_name, fallback_app, fallback_template, verify_decorator) + # cache in production mode + if not settings.DEBUG: + CACHED_VIEW_FUNCTIONS[key] = func + return func + + # the code should never be able to get here + raise Exception("Django-Mako-Plus error: get_view_function() should not have been able to get to this point. Please notify the owner of the DMP project. Thanks.") + + +def find_view_function(module_name, function_name, fallback_app=None, fallback_template=None, verify_decorator=True): + ''' + Finds a view function, class-based view, or template view. + Raises ViewDoesNotExist if not found. + ''' + dmp = apps.get_app_config('django_mako_plus') + + # I'm first calling find_spec first here beacuse I don't want import_module in + # a try/except -- there are lots of reasons that importing can fail, and I just want to + # know whether the file actually exists. find_spec raises AttributeError if not found. + try: + spec = find_spec(module_name) + except ValueError: + spec = None + if spec is None: + # no view module, so create a view function that directly renders the template + try: + return create_view_for_template(fallback_app, fallback_template) + except TemplateDoesNotExist as e: + raise ViewDoesNotExist('view module {} not found, and fallback template {} could not be loaded ({})'.format(module_name, fallback_template, e)) + + # load the module and function + try: + module = import_module(module_name) + func = getattr(module, function_name) + func.view_type = 'function' + except ImportError as e: + raise ViewDoesNotExist('module "{}" could not be imported: {}'.format(module_name, e)) + except AttributeError as e: + raise ViewDoesNotExist('module "{}" found successfully, but "{}" was not found: {}'.format(module_name, function_name, e)) + + # if class-based view, call as_view() to get a view function to it + if inspect.isclass(func) and issubclass(func, View): + func = func.as_view() + func.view_type = 'class' + + # if regular view function, check the decorator + elif verify_decorator and not view_function.is_decorated(func): + raise ViewDoesNotExist("view {}.{} was found successfully, but it must be decorated with @view_function or be a subclass of django.views.generic.View.".format(module_name, function_name)) + + # attach a converter to the view function + if dmp.options['PARAMETER_CONVERTER'] is not None: + try: + converter = import_qualified(dmp.options['PARAMETER_CONVERTER'])(func) + setattr(func, CONVERTER_ATTRIBUTE_NAME, converter) + except ImportError as e: + raise ImproperlyConfigured('Cannot find PARAMETER_CONVERTER: {}'.format(str(e))) + + # return the function/class + return func + + +def create_view_for_template(app_name, template_name): + ''' + Creates a view function for templates (used whe a view.py file doesn't exist but the .html does) + Raises TemplateDoesNotExist if the template doesn't exist. + ''' + # ensure the template exists + apps.get_app_config('django_mako_plus').engine.get_template_loader(app_name).get_template(template_name) + # create the view function + def template_view(request, *args, **kwargs): + # not caching the template object (getting it each time) because Mako has its own cache + dmp = apps.get_app_config('django_mako_plus') + template = dmp.engine.get_template_loader(app_name).get_template(template_name) + return template.render_to_response(request=request, context=kwargs) + template_view.view_type = 'template' + return template_view diff --git a/build/lib/django_mako_plus/router/resolver.py b/build/lib/django_mako_plus/router/resolver.py new file mode 100644 index 00000000..54b9f617 --- /dev/null +++ b/build/lib/django_mako_plus/router/resolver.py @@ -0,0 +1,232 @@ +from django import VERSION +from django.apps import apps +from django.core.exceptions import ViewDoesNotExist +from django.http import Http404 +from django.urls import ResolverMatch +from django.urls.exceptions import Resolver404 +try: + from django.urls import re_path # Django 2.x + from django.urls import URLPattern + from django.urls import include + from django.urls.resolvers import RegexPattern +except ImportError: + from django.conf.urls import url as re_path # Django 1.x + from django.urls import RegexURLPattern as URLPattern + RegexPattern = None + +from ..util import merge_dicts, log +from .data import RoutingData +from .decorators import RequestViewWrapper + +from collections import namedtuple + + + +################################################## +### DMP-style resolver for an app + + +def app_resolver(app_name=None, pattern_kwargs=None, name=None): + ''' + Registers the given app_name with DMP and adds convention-based + url patterns for it. + + This function is meant to be called in a project's urls.py. + ''' + urlconf = URLConf(app_name, pattern_kwargs) + resolver = re_path( + '^{}/?'.format(app_name) if app_name is not None else '', + include(urlconf), + name=urlconf.app_name, + ) + # this next line is a workaround for Django's URLResolver class not having + # a `name` attribute, which is expected in Django's technical_404.html. + resolver.name = getattr(resolver, 'name', name or app_name) + return resolver + + +class URLConf(object): + ''' + Mimics a urls.py module for an app by holding a list of url patterns. + This is run through Django's include() function to create a resolver. + + Call `app_resolver()` to instantiate this class. Do not create it directly. + ''' + def __init__(self, app_name=None, pattern_kwargs=None): + self._app_name = app_name + self.app_name = self._app_name or '' + self.name = self.app_name + dmp = apps.get_app_config('django_mako_plus') + dmp.register_app(self._app_name) + self.urlpatterns = dmp_paths_for_app(self._app_name, pattern_kwargs, self.app_name) + + +def dmp_paths_for_app(app_name, pattern_kwargs=None, pretty_app_name=None): + '''Utility function that creates the default patterns for an app''' + dmp = apps.get_app_config('django_mako_plus') + # Because these patterns are subpatterns within the app's resolver, + # we don't include the /app/ in the pattern -- it's already been + # handled by the app's resolver. + # + # Also note how the each pattern below defines the four kwargs-- + # either as 1) a regex named group or 2) in kwargs. + return [ + # page.function/urlparams + dmp_path( + r'^(?P[_a-zA-Z0-9\-]+)\.(?P[_a-zA-Z0-9\.\-]+)/(?P.+?)/?$', + merge_dicts({ + 'dmp_app': app_name or dmp.options['DEFAULT_APP'], + }, pattern_kwargs), + 'DMP /{}/page.function/urlparams'.format(pretty_app_name), + app_name, + ), + + # page.function + dmp_path( + r'^(?P[_a-zA-Z0-9\-]+)\.(?P[_a-zA-Z0-9\.\-]+)/?$', + merge_dicts({ + 'dmp_app': app_name or dmp.options['DEFAULT_APP'], + 'dmp_urlparams': '', + }, pattern_kwargs), + 'DMP /{}/page.function'.format(pretty_app_name), + app_name, + ), + + # page/urlparams + dmp_path( + r'^(?P[_a-zA-Z0-9\-]+)/(?P.+?)/?$', + merge_dicts({ + 'dmp_app': app_name or dmp.options['DEFAULT_APP'], + 'dmp_function': 'process_request', + }, pattern_kwargs), + 'DMP /{}/page/urlparams'.format(pretty_app_name), + app_name, + ), + + # page + dmp_path( + r'^(?P[_a-zA-Z0-9\-]+)/?$', + merge_dicts({ + 'dmp_app': app_name or dmp.options['DEFAULT_APP'], + 'dmp_function': 'process_request', + 'dmp_urlparams': '', + }, pattern_kwargs), + 'DMP /{}/page'.format(pretty_app_name), + app_name, + ), + + # empty + dmp_path( + r'^$', + merge_dicts({ + 'dmp_app': app_name or dmp.options['DEFAULT_APP'], + 'dmp_function': 'process_request', + 'dmp_urlparams': '', + 'dmp_page': dmp.options['DEFAULT_PAGE'], + }, pattern_kwargs), + 'DMP /{}'.format(pretty_app_name), + app_name, + ), + ] + + + + +############################################# +### DMP-style pattern + + +def dmp_path(regex, kwargs=None, name=None, app_name=None): + ''' + Creates a DMP-style, convention-based pattern that resolves + to various view functions based on the 'dmp_page' value. + + The following should exist as 1) regex named groups or + 2) items in the kwargs dict: + dmp_app Should resolve to a name in INSTALLED_APPS. + If missing, defaults to DEFAULT_APP. + dmp_page The page name, which should resolve to a module: + project_dir/{dmp_app}/views/{dmp_page}.py + If missing, defaults to DEFAULT_PAGE. + dmp_function The function name (or View class name) within the module. + If missing, defaults to 'process_request' + dmp_urlparams The urlparams string to parse. + If missing, defaults to ''. + + The reason for this convenience function is to be similar to + Django functions like url(), re_path(), and path(). + ''' + return PagePattern(regex, kwargs, name, app_name) + + +class PagePattern(URLPattern): + ''' + Creates a DMP-style, convention-based pattern that resolves + to various view functions based on the 'dmp_page' value. + ''' + def __init__(self, regex, default_args=None, name=None, app_name=None): + self.dmp = apps.get_app_config('django_mako_plus') + if app_name or self.dmp.options['DEFAULT_APP']: + self.dmp.register_app(app_name) + default_args = merge_dicts({ 'dmp_app': app_name or self.dmp.options['DEFAULT_APP'] }, default_args) + if isinstance(regex, str) and RegexPattern is not None: + regex = RegexPattern(regex, name=name, is_endpoint=True) + # this is a bit of a hack, but the super constructor needs + # a view function. Our resolve() function ignores this view function + # so we'll just creeate a no-op placeholder so super is happy. + def no_op_view(request): + raise Http404() + super().__init__(regex, no_op_view, default_args, name=name) + + + def resolve(self, path): + ''' + Different from Django, this method matches by /app/page/ convention + using its pattern. The pattern should create keyword arguments for + dmp_app, dmp_page. + ''' + match = super().resolve(path) + if match: + try: + routing_data = RoutingData( + match.kwargs.pop('dmp_app', None) or self.dmp.options['DEFAULT_APP'], + match.kwargs.pop('dmp_page', None) or self.dmp.options['DEFAULT_PAGE'], + match.kwargs.pop('dmp_function', None) or 'process_request', + match.kwargs.pop('dmp_urlparams', '').strip(), + ) + if VERSION < (2, 2): + return ResolverMatch( + RequestViewWrapper(routing_data), + match.args, + match.kwargs, + url_name=match.url_name, + app_names=routing_data.app, + ) + else: + return ResolverMatch( + RequestViewWrapper(routing_data), + match.args, + match.kwargs, + url_name=match.url_name, + app_names=routing_data.app, + route=str(self.pattern), + #django 4.1 change + extra_kwargs={}, + ) + except ViewDoesNotExist as vdne: + # we had a pattern match, but we couldn't get a callable using kwargs from the pattern + # create a "pattern" so the programmer can see what happened + # this is a hack, but the resolver error page doesn't give other options. + # the sad face is to catch the dev's attention in Django's printout + msg = "◉︵◉ Pattern matched, but discovery failed: {}".format(vdne) + log.debug("%s %s", match.url_name, msg) + raise Resolver404({ + # this is a bit convoluted, but it makes the PatternStub work with Django 1.x and 2.x + 'tried': [[ PatternStub(match.url_name, msg, PatternStub(match.url_name, msg, None)) ]], + 'path': path, + }) + raise Resolver404({'path': path}) + + +from collections import namedtuple +PatternStub = namedtuple('PatternStub', [ 'name', 'pattern', 'regex' ]) diff --git a/build/lib/django_mako_plus/router/urlparams.py b/build/lib/django_mako_plus/router/urlparams.py new file mode 100644 index 00000000..29d95a78 --- /dev/null +++ b/build/lib/django_mako_plus/router/urlparams.py @@ -0,0 +1,22 @@ + +################################################################ +### Special type of list used for url params + +class URLParamList(list): + ''' + A simple extension to Python's list that returns '' for indices that don't exist. + For example, if the object is ['a', 'b'] and you call obj[5], it will return '' + rather than throwing an IndexError. This makes dealing with url parameters + simpler since you don't have to check the length of the list. + ''' + def __getitem__(self, idx): + '''Returns the element at idx, or '' if idx is beyond the length of the list''' + return self.get(idx, '') + + def get(self, idx, default=''): + '''Returns the element at idx, or default if idx is beyond the length of the list''' + # if the index is beyond the length of the list, return '' + if isinstance(idx, int) and (idx >= len(self) or idx < -1 * len(self)): + return default + # else do the regular list function (for int, slice types, etc.) + return super().__getitem__(idx) diff --git a/build/lib/django_mako_plus/signals.py b/build/lib/django_mako_plus/signals.py new file mode 100644 index 00000000..380b870f --- /dev/null +++ b/build/lib/django_mako_plus/signals.py @@ -0,0 +1,76 @@ +from django.dispatch import Signal + + +############################################################# +### Signals that we send from DMP +### +### See the standard documentation on Django regarding +### signals. Also see the DMP documentation on these +### specific signals. + +# Triggered just before DMP calls a view's process_request() method. +# If the method returns an HttpResponse object, processing stops and the object is returned to the browser. +# +# request :: The request object, which has several attributes that might be of interest: +# request.dmp.app :: The app name, based on the current url. +# request.dmp.page :: The page name (the views/.py file or templates/.html file), based on the current url. +# request.dmp.urlparams :: Any extra url parameters, based on the current url. +# request.dmp.module :: The module where the view function is located. +# request.dmp.function :: The specific view function within the module to call. +# view_args :: The list of positional arguments to be sent to the view function. +# view_kwargs :: The dictionary of keyword arguments to be sent to the view function. +# +dmp_signal_pre_process_request = Signal() + +# Triggered just after a view's process_request() method returns. +# If the method returns an HttpResponse object, the normal response is replaced with that object. +# +# request :: The request object +# response :: The return from the process_request method, normally an HttpResponse object. +# view_args :: The list of positional arguments that was to the view. +# view_kwargs :: The dictionary of keyword arguments that was sent to the view function. +# +dmp_signal_post_process_request = Signal() + +# Triggered just before DMP renders a Mako template. +# If the method returns a different Template object than the one passed into it, the returned on is used. +# In other words, this signal lets you override any template just before DMP renders it. +# +# request :: the request object +# context :: the dict of variables being sent to the template. +# template :: the Mako template object that will render. +dmp_signal_pre_render_template = Signal() + +# Triggered just after DMP renders a Mako template. +# If the method returns a value, the template-generated content is replaced with that value. While +# the template still rendered, its content is discarded and replaced with this return. +# +# request :: the request object +# context :: the dict of variables being sent to the template. +# template :: the Mako template object that will render. +# content :: the rendered content from the template. +dmp_signal_post_render_template = Signal() + +# Triggered when a RedirectException is encountered in the DMP controller. +# This signal lets you adjust the values of the exception, such as where it is redirecting to. +# +# request :: the request object +# exc :: The exception object, including: +# exc.redirect_to (new url the router will process with) +# exc.permanent (whether the browser should be told it is a permanent redirect or not) +dmp_signal_redirect_exception = Signal() + +# Triggered when an InternalRedirectException is encountered in the DMP controller. +# This signal lets you adjust the values of the exception, such as where it is redirecting to. +# +# request :: the request object +# exc :: The exception object, including exc.redirect_to (new url the router will process with). +dmp_signal_internal_redirect_exception = Signal() + + +# Triggered just after the DMP template engine registers an app +# as a DMP app. This happens once per app on a given server run. +# The `sender` argument is the DMP template engine instance. +# +# app_config :: the AppConfig object for the app +dmp_signal_register_app = Signal() diff --git a/build/lib/django_mako_plus/tags.py b/build/lib/django_mako_plus/tags.py new file mode 100644 index 00000000..f6721c60 --- /dev/null +++ b/build/lib/django_mako_plus/tags.py @@ -0,0 +1,91 @@ +from django.template import engines +from django.template import TemplateDoesNotExist +from mako.runtime import supports_caller + +### +### Mako-style tags that DMP provides +### + + +############################################################### +### Include Django templates +### + +def django_include(context, template_name, **kwargs): + ''' + Mako tag to include a Django template withing the current DMP (Mako) template. + Since this is a Django template, it is search for using the Django search + algorithm (instead of the DMP app-based concept). + See https://docs.djangoproject.com/en/2.1/topics/templates/. + + The current context is sent to the included template, which makes all context + variables available to the Django template. Any additional kwargs are added + to the context. + ''' + try: + djengine = engines['django'] + except KeyError as e: + raise TemplateDoesNotExist("Django template engine not configured in settings, so template cannot be found: {}".format(template_name)) from e + djtemplate = djengine.get_template(template_name) + djcontext = {} + djcontext.update(context) + djcontext.update(kwargs) + return djtemplate.render(djcontext, context['request']) + + + +######################################################### +### Template autoescaping on/off + +# attaching to `caller_stack` because it's the same object +# throughout rendering of a template inheritance +AUTOESCAPE_KEY = '__dmp_autoescape' + +def is_autoescape(context): + return bool(getattr(context.caller_stack, AUTOESCAPE_KEY, True)) + + +def _toggle_autoescape(context, escape_on=True): + ''' + Internal method to toggle autoescaping on or off. This function + needs access to the caller, so the calling method must be + decorated with @supports_caller. + ''' + previous = is_autoescape(context) + setattr(context.caller_stack, AUTOESCAPE_KEY, escape_on) + try: + context['caller'].body() + finally: + setattr(context.caller_stack, AUTOESCAPE_KEY, previous) + + +@supports_caller +def autoescape_on(context): + ''' + Mako tag to enable autoescaping for a given block within a template, + (individual filters can still override with ${ somevar | n }). + + Example: + <%namespace name="dmp" module="django_mako_plus.tags"/> + <%dmp:autoescape_on> + ${ somevar } will be autoescaped. + + ''' + _toggle_autoescape(context, True) + return '' + + +@supports_caller +def autoescape_off(context): + ''' + Mako tag to disable autoescaping for a given block within a template, + (individual filters can still override with ${ somevar | h }). + + Example: + <%namespace name="dmp" module="django_mako_plus.tags"/> + <%dmp:autoescape> + ${ somevar } will not be autoescaped. + + ''' + _toggle_autoescape(context, False) + return '' diff --git a/build/lib/django_mako_plus/template/__init__.py b/build/lib/django_mako_plus/template/__init__.py new file mode 100644 index 00000000..850ab2b5 --- /dev/null +++ b/build/lib/django_mako_plus/template/__init__.py @@ -0,0 +1,4 @@ +from .adapter import MakoTemplateAdapter +from .loader import MakoTemplateLoader +from .lexer import ExpressionPostProcessor +from .util import template_inheritance, create_mako_context diff --git a/build/lib/django_mako_plus/template/adapter.py b/build/lib/django_mako_plus/template/adapter.py new file mode 100644 index 00000000..a878aa38 --- /dev/null +++ b/build/lib/django_mako_plus/template/adapter.py @@ -0,0 +1,184 @@ +from django.apps import apps +from django.conf import settings +from django.http import HttpResponse +from django.utils.html import mark_safe +from django.template import Context, RequestContext + +from ..exceptions import RedirectException +from ..signals import dmp_signal_pre_render_template, dmp_signal_post_render_template, dmp_signal_redirect_exception +from ..util import log +from .util import get_template_debug + +import logging +import mimetypes +import os +import os.path +import sys + + + +class MakoTemplateAdapter(object): + '''A thin wrapper for a Mako template object that provides the Django API methods.''' + def __init__(self, mako_template, def_name=None): + ''' + Creates an adapter that corresponds to the Django API. + + If def_name is provided, template rendering will be limited to the named def/block (see Mako docs). + This can also be provided in the call to render(). + ''' + self.mako_template = mako_template + self.def_name = def_name + + @property + def engine(self): + '''Returns the DMP engine (method required by Django specs)''' + dmp = apps.get_app_config('django_mako_plus') + return dmp.engine + + @property + def name(self): + '''Returns the name of this template (if created from a file) or "string" if not''' + if self.mako_template.filename: + return os.path.basename(self.mako_template.filename) + return 'string' + + def has_def(self, name): + '''Convenience passthrough to the Mako template''' + return self.mako_template.has_def(name) + + def get_def(self, name): + '''Convenience passthrough to the Mako template''' + return self.mako_template.get_def(name) + + def list_defs(self): + '''Convenience passthrough to the Mako template''' + return self.mako_template.list_defs() + + def render(self, context=None, request=None, def_name=None): + ''' + Renders a template using the Mako system. This method signature conforms to + the Django template API, which specifies that template.render() returns a string. + + @context A dictionary of name=value variables to send to the template page. This can be a real dictionary + or a Django Context object. + @request The request context from Django. If this is None, any TEMPLATE_CONTEXT_PROCESSORS defined in your settings + file will be ignored but the template will otherwise render fine. + @def_name Limits output to a specific top-level Mako <%block> or <%def> section within the template. + If the section is a <%def>, any parameters must be in the context dictionary. For example, + def_name="foo" will call <%block name="foo"> or <%def name="foo()"> within + the template. + + Returns the rendered template as a unicode string. + + The method triggers two signals: + 1. dmp_signal_pre_render_template: you can (optionally) return a new Mako Template object from a receiver to replace + the normal template object that is used for the render operation. + 2. dmp_signal_post_render_template: you can (optionally) return a string to replace the string from the normal + template object render. + ''' + dmp = apps.get_app_config('django_mako_plus') + # set up the context dictionary, which is the variables available throughout the template + context_dict = {} + # if request is None, add some default items because the context processors won't happen + if request is None: + context_dict['settings'] = settings + context_dict['STATIC_URL'] = settings.STATIC_URL + # let the context_processors add variables to the context. + if not isinstance(context, Context): + context = Context(context) if request is None else RequestContext(request, context) + with context.bind_template(self): + for d in context: + context_dict.update(d) + context_dict.pop('self', None) # some contexts have self in them, and it messes up render_unicode below because we get two selfs + + # send the pre-render signal + if dmp.options['SIGNALS'] and request is not None: + for receiver, ret_template_obj in dmp_signal_pre_render_template.send(sender=self, request=request, context=context, template=self.mako_template): + if ret_template_obj is not None: + if isinstance(ret_template_obj, MakoTemplateAdapter): + self.mako_template = ret_template_obj.mako_template # if the signal function sends a MakoTemplateAdapter back, use the real mako template inside of it + else: + self.mako_template = ret_template_obj # if something else, we assume it is a mako.template.Template, so use it as the template + + # do we need to limit down to a specific def? + # this only finds within the exact template (won't go up the inheritance tree) + render_obj = self.mako_template + if def_name is None: + def_name = self.def_name + if def_name: # do we need to limit to just a def? + render_obj = self.mako_template.get_def(def_name) + + # PRIMARY FUNCTION: render the template + if log.isEnabledFor(logging.INFO): + log.info('rendering template %s%s%s', self.name, ('::' if def_name else ''), def_name or '') + if settings.DEBUG: + try: + content = render_obj.render_unicode(**context_dict) + except Exception as e: + log.exception('exception raised during template rendering: %s', e) # to the console + e.template_debug = get_template_debug('%s%s%s' % (self.name, ('::' if def_name else ''), def_name or ''), e) + raise + else: # this is outside the above "try" loop because in non-DEBUG mode, we want to let the exception throw out of here (without having to re-raise it) + content = render_obj.render_unicode(**context_dict) + + # send the post-render signal + if dmp.options['SIGNALS'] and request is not None: + for receiver, ret_content in dmp_signal_post_render_template.send(sender=self, request=request, context=context, template=self.mako_template, content=content): + if ret_content is not None: + content = ret_content # sets it to the last non-None return in the signal receiver chain + + # return + return mark_safe(content) + + + def render_to_response(self, context=None, request=None, def_name=None, content_type=None, status=None, charset=None): + ''' + Renders the template and returns an HttpRequest object containing its content. + + This method returns a django.http.Http404 exception if the template is not found. + If the template raises a django_mako_plus.RedirectException, the browser is redirected to + the given page, and a new request from the browser restarts the entire DMP routing process. + If the template raises a django_mako_plus.InternalRedirectException, the entire DMP + routing process is restarted internally (the browser doesn't see the redirect). + + @request The request context from Django. If this is None, any TEMPLATE_CONTEXT_PROCESSORS defined in your settings + file will be ignored but the template will otherwise render fine. + @template The template file path to render. This is relative to the app_path/controller_TEMPLATES_DIR/ directory. + For example, to render app_path/templates/page1, set template="page1.html", assuming you have + set up the variables as described in the documentation above. + @context A dictionary of name=value variables to send to the template page. This can be a real dictionary + or a Django Context object. + @def_name Limits output to a specific top-level Mako <%block> or <%def> section within the template. + For example, def_name="foo" will call <%block name="foo"> or <%def name="foo()"> within the template. + @content_type The MIME type of the response. Defaults to settings.DEFAULT_CONTENT_TYPE (usually 'text/html'). + @status The HTTP response status code. Defaults to 200 (OK). + @charset The charset to encode the processed template string (the output) with. Defaults to settings.DEFAULT_CHARSET (usually 'utf-8'). + + The method triggers two signals: + 1. dmp_signal_pre_render_template: you can (optionally) return a new Mako Template object from a receiver to replace + the normal template object that is used for the render operation. + 2. dmp_signal_post_render_template: you can (optionally) return a string to replace the string from the normal + template object render. + ''' + try: + if content_type is None: + content_type = mimetypes.types_map.get(os.path.splitext(self.mako_template.filename)[1].lower(), settings.DEFAULT_CONTENT_TYPE) + if charset is None: + charset = settings.DEFAULT_CHARSET + if status is None: + status = 200 + content = self.render(context=context, request=request, def_name=def_name) + return HttpResponse(content.encode(charset), content_type='%s; charset=%s' % (content_type, charset), status=status) + + except RedirectException: # redirect to another page + e = sys.exc_info()[1] + if request is None: + log.info('a template redirected processing to %s', e.redirect_to) + else: + log.info('view function %s.%s redirected processing to %s', request.dmp.module, request.dmp.function, e.redirect_to) + # send the signal + dmp = apps.get_app_config('django_mako_plus') + if dmp.options['SIGNALS']: + dmp_signal_redirect_exception.send(sender=sys.modules[__name__], request=request, exc=e) + # send the browser the redirect command + return e.get_response(request) diff --git a/build/lib/django_mako_plus/template/lexer.py b/build/lib/django_mako_plus/template/lexer.py new file mode 100644 index 00000000..ff1776c0 --- /dev/null +++ b/build/lib/django_mako_plus/template/lexer.py @@ -0,0 +1,102 @@ +from django.apps import apps +from django.utils.html import conditional_escape +from django.utils.encoding import force_str + +from mako.lexer import Lexer +from mako import parsetree, ast + +from ..util import log +from ..tags import is_autoescape + + +########################################################### +### DMP hook for expression filters. This allows DMP +### to filter expressions ${...} after all other filters +### have been run. +### +### Currently, the use of this hook is HTML autoescaping. +### Django autoescapes by default, while Mako does not. +### DMP injects autoescaping to be consistent with Django. +### + +MAKO_ESCAPE_REPLACEMENTS = { + 'h': 'django.utils.html.escape', # uses Django's escape rather than Mako's, which works better with marks +} + +class DMPLexer(Lexer): + ''' + Subclass of Mako's Lexer, which is used during compilation of + templates. This subclass injects ExpressionPostProcessor + as the final filter on every expression. Overriding append_node() + is a hack, but it's the only way I can find to hook into Mako's + compile process without modifying Mako directly. + ''' + def append_node(self, nodecls, *args, **kwargs): + # fyi, this method runs on template compilation (not on template render) + if nodecls == parsetree.Expression: + # when an Expression, args[1] is a comma-separated string of filters + # parse the filters and make any DMP replacements for them + try: + # this is Mako's ast, not the python one + filters = [ MAKO_ESCAPE_REPLACEMENTS.get(f, f) for f in ast.ArgumentList(args[1]).args ] + except Exception as e: + log.warning('An error occurred when compiling the filters on an expression; allowing through so Mako can handle it (%s)', e) + filters = [] + extra = {} # extra info sent to the expression processor + + # if we have the 'n' filter, send that to the expression processor + if 'n' in filters: + extra['n_filter_on'] = True + + # add the expression processor as the last filter to be run + # then recreate the args tuple + filters.append('django_mako_plus.ExpressionPostProcessor(self' + \ + (", extra={}".format(extra) if len(extra) > 0 else '') + \ + ')') + args = args[:1] + (','.join(filters),) + args[2:] + return super().append_node(nodecls, *args, **kwargs) + + +# this is used read-only, so it can be in __init__ signature +EMPTY_DICT = {} + +class ExpressionPostProcessor(object): + ''' + Object that is called as the final filter on every template + expression ${...}. + + Right now this object does autoescaping. However, it is placed + on *every* expression so we have a hook for future post-processing + of expressions. + + See the creation of this object in DMPLexer above for more info. + ''' + def __init__(self, tself, extra=EMPTY_DICT): + # check whether it's on for this block + self.html_escape = is_autoescape(tself.context) + # the 'n' filter turns off our normal html escaping + if extra.get('n_filter_on', False): + self.html_escape = False + # mark_safe() is handled in Django's conditional_escape(), so no need to deal with it + # check the global setting + dmp = apps.get_app_config('django_mako_plus') + if not dmp.options['AUTOESCAPE']: + self.html_escape = False + + def __call__(self, text): + ''' + Mako calls this after evaluating the expression and applying + all other filters. + + Right now this html escapes the expression, unless autoescape + is toggled off. + ''' + # we apply this across the board, even if the `n` filter is present because + # DMP always creates unicode (see adapter.py where render_unicode() is used) + text = force_str(text) + + # html encoding + if self.html_escape: + text = conditional_escape(text) # internally, this honors mark_safe() + + return text diff --git a/build/lib/django_mako_plus/template/loader.py b/build/lib/django_mako_plus/template/loader.py new file mode 100644 index 00000000..f3e79f90 --- /dev/null +++ b/build/lib/django_mako_plus/template/loader.py @@ -0,0 +1,119 @@ +from django.apps import apps +from django.conf import settings +from django.template import TemplateDoesNotExist, TemplateSyntaxError + +from mako.exceptions import CompileException, SyntaxException, TemplateLookupException, TopLevelLookupException +from mako.lookup import TemplateLookup +from mako.template import Template + +from .util import get_template_debug +from .lexer import DMPLexer +from .adapter import MakoTemplateAdapter + +import os +import os.path + + +class DMPTemplateLookup(TemplateLookup): + '''Small extension to Mako's template lookup to provide a link back to the MakoTemplateLoader''' + def __init__(self, template_loader, *args, **kwargs): + super(DMPTemplateLookup, self).__init__(*args, **kwargs) + self.template_loader = template_loader + + +class MakoTemplateLoader(object): + '''Finds Mako templates for a Django app.''' + def __init__(self, app_path, template_subdir='templates'): + ''' + The loader looks in the app_path/templates directory unless + the template_subdir parameter overrides this default. + + You should not normally create this object because it bypasses + the DMP cache. Instead, call get_template_loader() or + get_template_loader_for_path(). + ''' + self.app_path = app_path + dmp = apps.get_app_config('django_mako_plus') + + # calculate the template directory and check that it exists + if template_subdir is None: # None skips adding the template_subdir + self.template_dir = os.path.abspath(app_path) + else: + self.template_dir = os.path.abspath(os.path.join(app_path, template_subdir)) + + # I used to check for the existence of the template dir here, but it caused error + # checking at engine load time (too soon). I now wait until get_template() is called, + # which fails with a TemplateDoesNotExist exception if the template_dir doesn't exist. + + # calculate the cache root and template search directories + self.cache_root = os.path.join(self.template_dir, dmp.options['TEMPLATES_CACHE_DIR']) + self.template_search_dirs = [ self.template_dir ] + self.template_search_dirs.extend(dmp.options['TEMPLATES_DIRS']) + # Mako doesn't allow parent directory inheritance, such as <%inherit file="../../otherapp/templates/base.html"/> + # including the project base directory allows this through "absolute" like <%inherit file="/otherapp/templates/base.html"/> + # (note the leading slash, which means BASE_DIR) + self.template_search_dirs.append(settings.BASE_DIR) + + # create the actual Mako TemplateLookup, which does the actual work + self.tlookup = DMPTemplateLookup( + template_loader=self, + directories=self.template_search_dirs, + imports=dmp.template_imports, + module_directory=self.cache_root, + collection_size=2000, + filesystem_checks=settings.DEBUG, + input_encoding=dmp.options['DEFAULT_TEMPLATE_ENCODING'], + default_filters=[], # shouldn't be None because that causes Mako to add an html filter and override DMP's html_filter + lexer_cls=DMPLexer, + ) + + + def get_template(self, template, def_name=None): + '''Retrieve a *Django* API template object for the given template name, using the app_path and template_subdir + settings in this object. This method still uses the corresponding Mako template and engine, but it + gives a Django API wrapper around it so you can use it the same as any Django template. + + If def_name is provided, template rendering will be limited to the named def/block (see Mako docs). + + This method corresponds to the Django templating system API. + A Django exception is raised if the template is not found or cannot compile. + ''' + try: + # wrap the mako template in an adapter that gives the Django template API + return MakoTemplateAdapter(self.get_mako_template(template), def_name) + + except (TopLevelLookupException, TemplateLookupException) as e: # Mako exception raised + tdne = TemplateDoesNotExist('Template "%s" not found in search path: %s.' % (template, self.template_search_dirs)) + if settings.DEBUG: + tdne.template_debug = get_template_debug(template, e) + raise tdne from e + + except (CompileException, SyntaxException) as e: # Mako exception raised + tse = TemplateSyntaxError('Template "%s" raised an error: %s' % (template, e)) + if settings.DEBUG: + tse.template_debug = get_template_debug(template, e) + raise tse from e + + + def get_mako_template(self, template, force=False): + '''Retrieve the real *Mako* template object for the given template name without any wrapper, + using the app_path and template_subdir settings in this object. + + This method is an alternative to get_template(). Use it when you need the actual Mako template object. + This method raises a Mako exception if the template is not found or cannot compile. + + If force is True, an empty Mako template will be created when the file does not exist. + This option is used by the providers part of DMP and normally be left False. + ''' + if template is None: + raise TemplateLookupException('Template "%s" not found in search path: %s.' % (template, self.template_search_dirs)) + # get the template + try: + template_obj = self.tlookup.get_template(template) + except TemplateLookupException: + if not force: + raise + template_obj = Template('', filename=os.path.join(self.template_dir, template)) + + # get the template + return template_obj diff --git a/build/lib/django_mako_plus/template/util.py b/build/lib/django_mako_plus/template/util.py new file mode 100644 index 00000000..36f0874f --- /dev/null +++ b/build/lib/django_mako_plus/template/util.py @@ -0,0 +1,170 @@ +from django.apps import apps +from django.utils.html import mark_safe +from mako.exceptions import RichTraceback +from mako.template import Template as MakoTemplate +from mako.runtime import Context as MakoContext, _populate_self_namespace + +import io +import os, os.path + + +def template_inheritance(obj): + ''' + Generator that iterates the template and its ancestors. + The order is from most specialized (furthest descendant) to + most general (furthest ancestor). + + obj can be either: + 1. Mako Template object + 2. Mako `self` object (available within a rendering template) + ''' + if isinstance(obj, MakoTemplate): + obj = create_mako_context(obj)['self'] + elif isinstance(obj, MakoContext): + obj = obj['self'] + while obj is not None: + yield obj.template + obj = obj.inherits + + +def create_mako_context(template_obj, **kwargs): + # I'm hacking into private Mako methods here, but I can't see another + # way to do this. Hopefully this can be rectified at some point. + kwargs.pop('self', None) # some contexts have self in them, and it messes up render_unicode below because we get two selfs + runtime_context = MakoContext(io.StringIO(), **kwargs) + runtime_context._set_with_template(template_obj) + _, mako_context = _populate_self_namespace(runtime_context, template_obj) + return mako_context + + +def get_template_debug(template_name, error): + ''' + This structure is what Django wants when errors occur in templates. + It gives the user a nice stack trace in the error page during debug. + ''' + # This is taken from mako.exceptions.html_error_template(), which has an issue + # in Py3 where files get loaded as bytes but `lines = src.split('\n')` below + # splits with a string. Not sure if this is a bug or if I'm missing something, + # but doing a custom debugging template allows a workaround as well as a custom + # DMP look. + # I used to have a file in the templates directory for this, but too many users + # reported TemplateNotFound errors. This function is a bit of a hack, but it only + # happens during development (and mako.exceptions does this same thing). + # /justification + stacktrace_template = MakoTemplate(r""" +<%! from mako.exceptions import syntax_highlight, pygments_html_formatter %> + +<% + + src = tback.source + line = tback.lineno + if isinstance(src, bytes): + src = src.decode() + if src: + lines = src.split('\n') + else: + lines = None +%> +

${tback.errorname}: ${tback.message}

+ +% if lines: +
+
+ % for index in range(max(0, line-4),min(len(lines), line+5)): + <% + if pygments_html_formatter: + pygments_html_formatter.linenostart = index + 1 + %> + % if index + 1 == line: + <% + if pygments_html_formatter: + old_cssclass = pygments_html_formatter.cssclass + pygments_html_formatter.cssclass = 'error ' + old_cssclass + %> + ${lines[index] | n,syntax_highlight(language='mako')} + <% + if pygments_html_formatter: + pygments_html_formatter.cssclass = old_cssclass + %> + % else: + ${lines[index] | n,syntax_highlight(language='mako')} + % endif + % endfor +
+
+% endif + +
+% for (filename, lineno, function, line) in tback.reverse_traceback: +
${filename}, line ${lineno}:
+
+ <% + if pygments_html_formatter: + pygments_html_formatter.linenostart = lineno + %> +
${line | n,syntax_highlight(filename)}
+
+% endfor +
+""") + tback = RichTraceback(error, error.__traceback__) + lines = stacktrace_template.render_unicode(tback=tback) + return { + 'message': '', + 'source_lines': [ + ( '', mark_safe(lines) ), + ], + 'before': '', + 'during': '', + 'after': '', + 'top': 0, + 'bottom': 0, + 'total': 0, + 'line': tback.lineno or 0, + 'name': template_name, + 'start': 0, + 'end': 0, + } diff --git a/build/lib/django_mako_plus/templatetags/__init__.py b/build/lib/django_mako_plus/templatetags/__init__.py new file mode 100644 index 00000000..a629111d --- /dev/null +++ b/build/lib/django_mako_plus/templatetags/__init__.py @@ -0,0 +1,2 @@ +# This package contains DJANGO tags in the normal Django style +# Don't put regular DMP or Mako things in it. diff --git a/build/lib/django_mako_plus/templatetags/django_mako_plus.py b/build/lib/django_mako_plus/templatetags/django_mako_plus.py new file mode 100644 index 00000000..a86c4366 --- /dev/null +++ b/build/lib/django_mako_plus/templatetags/django_mako_plus.py @@ -0,0 +1,39 @@ +from django import template +from django.apps import apps +from django.utils.safestring import mark_safe + + +############################################################### +### DJANGO template tag to include a Mako template +### +### This file is called "django_mako_plus.py" because it's +### the convention for creating Django template tags. +### +### See also django_mako_plus/filters.py + +register = template.Library() + +@register.simple_tag(takes_context=True) +def dmp_include(context, template_name, def_name=None, **kwargs): + ''' + Includes a DMP (Mako) template into a normal django template. + + context: automatically provided + template_name: specified as "app/template" + def_name: optional block to render within the template + + Example: + {% load django_mako_plus %} + {% dmp_include "homepage/bsnav_dj.html" %} + or + {% dmp_include "homepage/bsnav_dj.html" "blockname" %} + ''' + dmp = apps.get_app_config('django_mako_plus') + template = dmp.engine.get_template(template_name) + dmpcontext = context.flatten() + dmpcontext.update(kwargs) + return mark_safe(template.render( + context=dmpcontext, + request=context.get('request'), + def_name=def_name + )) diff --git a/build/lib/django_mako_plus/urls.py b/build/lib/django_mako_plus/urls.py new file mode 100644 index 00000000..e261197c --- /dev/null +++ b/build/lib/django_mako_plus/urls.py @@ -0,0 +1,44 @@ +from django.apps import apps +from django.conf import settings +try: + from django.urls import re_path # Django 2.x +except ImportError: + from django.conf.urls import url as re_path # Django 1.x +from django.views.static import serve +from .router import app_resolver +import os, os.path + + +######################################################### +### The default DMP url patterns +### +### FYI, even though the valid python identifier is [_A-Za-z][_a-zA-Z0-9]*, +### I'm simplifying it to [_a-zA-Z0-9]+ because it works for our purposes + +app_name = 'django_mako_plus' +dmp = apps.get_app_config('django_mako_plus') +urlpatterns = [] + +# start with the DMP web files - for development time +# at production, serve this directly with Nginx/IIS/etc. instead +# there is no "if debug mode" statement here because the web server will serve the file at production before urls.py happens, +# but if this deployment step isn't done right, it will still work through this link +urlpatterns.append(re_path( + r'^django_mako_plus/(?P[^/]+)', + serve, + { 'document_root': os.path.join(apps.get_app_config('django_mako_plus').path, 'webroot') }, + name='DMP webroot (for devel)', +)) + +# add a DMP-style resolver for each app in the project directory +for config in apps.get_app_configs(): + if os.path.samefile(os.path.dirname(config.path), settings.BASE_DIR): + urlpatterns.append(app_resolver(config.name)) + +# add a DMP-style resolver for the default app +if dmp.options['DEFAULT_APP']: + try: + apps.get_app_config(dmp.options['DEFAULT_APP']) + urlpatterns.append(app_resolver()) + except LookupError: + pass # the default app in dmp's TEMPLATES entry isn't an installed app, so skip it diff --git a/build/lib/django_mako_plus/util/__init__.py b/build/lib/django_mako_plus/util/__init__.py new file mode 100644 index 00000000..918a740d --- /dev/null +++ b/build/lib/django_mako_plus/util/__init__.py @@ -0,0 +1,9 @@ +# set up the logger +import logging +log = logging.getLogger('django_mako_plus') + + +# public functions +from .base58 import b58enc, b58dec +from .datastruct import merge_dicts, flatten, crc32 +from .reflect import qualified_name, import_qualified diff --git a/build/lib/django_mako_plus/util/base58.py b/build/lib/django_mako_plus/util/base58.py new file mode 100644 index 00000000..40530350 --- /dev/null +++ b/build/lib/django_mako_plus/util/base58.py @@ -0,0 +1,36 @@ +########################################################################################### +### Converter of Base10 (decimal) to Base58 +### Ambiguous chars not used: 0, O, I, and l +### This uses the same alphabet as bitcoin. + +BASE58CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +BASE58INDEX = { ch: i for i, ch in enumerate(BASE58CHARS) } + +def b58enc(uid): + '''Encodes a UID to an 11-length string, encoded using base58 url-safe alphabet''' + # note: i tested a buffer array too, but string concat was 2x faster + if not isinstance(uid, int): + raise ValueError('Invalid integer: {}'.format(uid)) + if uid == 0: + return BASE58CHARS[0] + enc_uid = "" + while uid: + uid, r = divmod(uid, 58) + enc_uid = BASE58CHARS[r] + enc_uid + return enc_uid + +def b58dec(enc_uid): + '''Decodes a UID from base58, url-safe alphabet back to int.''' + if isinstance(enc_uid, str): + pass + elif isinstance(enc_uid, bytes): + enc_uid = enc_uid.decode('utf8') + else: + raise ValueError('Cannot decode this type: {}'.format(enc_uid)) + uid = 0 + try: + for i, ch in enumerate(enc_uid): + uid = (uid * 58) + BASE58INDEX[ch] + except KeyError: + raise ValueError('Invalid character: "{}" ("{}", index 5)'.format(ch, enc_uid, i)) + return uid diff --git a/build/lib/django_mako_plus/util/datastruct.py b/build/lib/django_mako_plus/util/datastruct.py new file mode 100644 index 00000000..39ff87d8 --- /dev/null +++ b/build/lib/django_mako_plus/util/datastruct.py @@ -0,0 +1,44 @@ +import collections +import zlib + + +def merge_dicts(*dicts): + ''' + Shallow merges an arbitrary number of dicts, starting + with the first argument and updating through the + last argument (last dict wins on conflicting keys). + ''' + merged = {} + for d in dicts: + if d: + merged.update(d) + return merged + + +def flatten(*args): + '''Generator that recursively flattens embedded lists, tuples, etc.''' + for arg in args: + if isinstance(arg, collections.Iterable) and not isinstance(arg, (str, bytes)): + yield from flatten(*arg) + else: + yield arg + + + +def crc32(filename): + ''' + Calculates the CRC checksum for a file. + Using CRC32 because security isn't the issue and don't need perfect noncollisions. + We just need to know if a file has changed. + + On my machine, crc32 was 20 times faster than any hashlib algorithm, + including blake and md5 algorithms. + ''' + result = 0 + with open(filename, 'rb') as fin: + while True: + chunk = fin.read(48) + if len(chunk) == 0: + break + result = zlib.crc32(chunk, result) + return result diff --git a/build/lib/django_mako_plus/util/reflect.py b/build/lib/django_mako_plus/util/reflect.py new file mode 100644 index 00000000..bf02227d --- /dev/null +++ b/build/lib/django_mako_plus/util/reflect.py @@ -0,0 +1,28 @@ +from importlib import import_module + + +def qualified_name(obj): + '''Returns the fully-qualified name of the given object''' + if not hasattr(obj, '__module__'): + obj = obj.__class__ + module = obj.__module__ + if module is None or module == str.__class__.__module__: + return obj.__qualname__ + return '{}.{}'.format(module, obj.__qualname__) + + +def import_qualified(name): + ''' + Imports a fully-qualified name from a module: + + cls = import_qualified('homepage.views.index.MyForm') + + Raises an ImportError if it can't be ipmorted. + ''' + parts = name.rsplit('.', 1) + if len(parts) != 2: + raise ImportError('Invalid fully-qualified name: {}'.format(name)) + try: + return getattr(import_module(parts[0]), parts[1]) + except AttributeError: + raise ImportError('{} not found in module {}'.format(parts[1], parts[0])) diff --git a/build/lib/django_mako_plus/version.py b/build/lib/django_mako_plus/version.py new file mode 100644 index 00000000..8aa644c6 --- /dev/null +++ b/build/lib/django_mako_plus/version.py @@ -0,0 +1,5 @@ +# This file should have NO imports and be entirely standalone. +# This allows it to import into the runtime DMP as well as +# setup.py during installation. + +__version__ = '5.11.2' diff --git a/build/lib/django_mako_plus/webroot/dmp-common.js b/build/lib/django_mako_plus/webroot/dmp-common.js new file mode 100644 index 00000000..0fa9df93 --- /dev/null +++ b/build/lib/django_mako_plus/webroot/dmp-common.js @@ -0,0 +1,1000 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ({ + +/***/ "./dmp-common.src.js": +/*!***************************!*\ + !*** ./dmp-common.src.js ***! + \***************************/ +/*! exports provided: default */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var core_js_modules_es6_promise__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! core-js/modules/es6.promise */ \"./node_modules/core-js/modules/es6.promise.js\");\n/* harmony import */ var core_js_modules_es6_promise__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es6_promise__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var core_js_modules_es6_string_iterator__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! core-js/modules/es6.string.iterator */ \"./node_modules/core-js/modules/es6.string.iterator.js\");\n/* harmony import */ var core_js_modules_es6_string_iterator__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es6_string_iterator__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var core_js_modules_es6_object_keys__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! core-js/modules/es6.object.keys */ \"./node_modules/core-js/modules/es6.object.keys.js\");\n/* harmony import */ var core_js_modules_es6_object_keys__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es6_object_keys__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var core_js_modules_es6_array_iterator__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! core-js/modules/es6.array.iterator */ \"./node_modules/core-js/modules/es6.array.iterator.js\");\n/* harmony import */ var core_js_modules_es6_array_iterator__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es6_array_iterator__WEBPACK_IMPORTED_MODULE_3__);\n/* harmony import */ var core_js_modules_es7_symbol_async_iterator__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! core-js/modules/es7.symbol.async-iterator */ \"./node_modules/core-js/modules/es7.symbol.async-iterator.js\");\n/* harmony import */ var core_js_modules_es7_symbol_async_iterator__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es7_symbol_async_iterator__WEBPACK_IMPORTED_MODULE_4__);\n/* harmony import */ var core_js_modules_es6_symbol__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! core-js/modules/es6.symbol */ \"./node_modules/core-js/modules/es6.symbol.js\");\n/* harmony import */ var core_js_modules_es6_symbol__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es6_symbol__WEBPACK_IMPORTED_MODULE_5__);\n/* harmony import */ var core_js_modules_web_dom_iterable__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! core-js/modules/web.dom.iterable */ \"./node_modules/core-js/modules/web.dom.iterable.js\");\n/* harmony import */ var core_js_modules_web_dom_iterable__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_web_dom_iterable__WEBPACK_IMPORTED_MODULE_6__);\n\n\n\n\n\n\n\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nif (!window[\"DMP_CONTEXT\"]) {\n /** Main DMP class - a single instance of this class is set as window.DMP_CONTEXT */\n var DMP =\n /*#__PURE__*/\n function () {\n function DMP() {\n _classCallCheck(this, DMP);\n\n this.__version__ = '5.11.2'; // DMP version to check for mismatches\n\n this.contexts = {}; // id -> context1\n\n this.templates = {}; // app/template -> info\n\n this.lastContext = null; // last inserted context (see getAll() below)\n\n this.logEnabled = null; // whether the log is DEBUG in settings\n }\n /*\n Sets a context in the main object.\n This is called by the DMP context provider.\n */\n\n\n _createClass(DMP, [{\n key: \"set\",\n value: function set(context) {\n this.logEnabled = context.log || this.logEnabled;\n\n if (this.__version__ != context.version) {\n this.log(['server version', context.version, 'is different from dmp-common.js', DMP_CONTEXT.__version__, '- unexpected behavior may occur']);\n }\n\n this.log(['creating context for', context.templates[context.templates.length - 1]], context, context);\n this.contexts[context.id] = context;\n this.lastContext = context;\n var _iteratorNormalCompletion = true;\n var _didIteratorError = false;\n var _iteratorError = undefined;\n\n try {\n for (var _iterator = context.templates[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {\n var tname = _step.value;\n this.getTemplate(tname).addContext(context);\n }\n } catch (err) {\n _didIteratorError = true;\n _iteratorError = err;\n } finally {\n try {\n if (!_iteratorNormalCompletion && _iterator.return != null) {\n _iterator.return();\n }\n } finally {\n if (_didIteratorError) {\n throw _iteratorError;\n }\n }\n }\n }\n /* Gets the template object for the given name, creating if needed */\n\n }, {\n key: \"getTemplate\",\n value: function getTemplate(tname) {\n var template = this.templates[tname];\n\n if (!template) {\n template = new Template(this, tname);\n this.templates[tname] = template;\n }\n\n return template;\n }\n /* Ensures a context: gets the context by the given id, or returns the context if not a string */\n\n }, {\n key: \"getContextById\",\n value: function getContextById(context) {\n if (typeof context === 'string' || context instanceof String) {\n return this.contexts[context];\n }\n\n return context; // might already be a context object\n }\n /*\n Convenience method to retrieve context values. If multiple script contexts are found,\n such as when ajax retrieves the same template snippet multiple times, the first \"previously unreturned\"\n one is returned. This method does not return the full context objects but rather the values dictionary.\n DMP_CONTEXT.get() // values for the currently-executing script\n DMP_CONTEXT.get('myapp/mytemplate') // values for the app/template\n DMP_CONTEXT.get(document.querySelector('some selector')) // values for the specified