From 033f1fcb7e0d7f44a431a766c19b5a3766eafaeb Mon Sep 17 00:00:00 2001 From: Richard Terry Date: Sat, 7 Dec 2024 14:52:23 +0000 Subject: [PATCH] Improve template support refs #44 --- docs/changelog.rst | 8 +++ docs/templates.rst | 109 ++++++++++++++++++++++++++++++++ examples/hello_template.py | 29 +++++++++ examples/scale/scale.py | 2 +- nanodjango/app.py | 48 ++++++++++++-- nanodjango/app_meta.py | 8 ++- nanodjango/convert/converter.py | 23 +++++++ nanodjango/convert/objects.py | 52 ++++++++++++++- nanodjango/convert/plugin.py | 9 ++- nanodjango/convert/reference.py | 16 +++-- nanodjango/settings.py | 10 ++- 11 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 docs/templates.rst create mode 100644 examples/hello_template.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 5e9058b..61dba6f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,14 @@ Changelog ========= +0.10.0 - TBC +------------ + +Features: + +* Improved template support (#44) + + 0.9.2 - 2024-10-14 ------------------ diff --git a/docs/templates.rst b/docs/templates.rst new file mode 100644 index 0000000..f4d8598 --- /dev/null +++ b/docs/templates.rst @@ -0,0 +1,109 @@ +========= +Templates +========= + +There are two ways to define templates for use with nanodjango apps: + +* put them in a ``templates/`` dir next to your script +* define them on the ``app.templates`` dict + +Whichever approach you take, you can render them using standard techniques, or the +``app.render`` shortcut method. + + +Defining templates +================== + +To define a template in the same script as the rest of your code, assign the templates +to the ``app.templates`` dict, using the filename and relative path as the key, and the +template content as the value. + +It is recommended that templates are defined at the bottom of your script, out of the +way of your code. + +You can either assign by key: + +.. code-block:: python + + app.templates["base.html"] = """ + + + {% block content %}{% endblock %} + " + + """ + app.templates["myview/hello.html"] = "{% block content %}Hello{% endblock %}" + + +or by dict: + +.. code-block:: python + + app.templates = { + "base.html": """ + + + {% block content %}{% endblock %} + " + + """, + "myview/hello.html": """ + {% extends "base.html" %} + {% block content %}Hello{% endblock %} + """, + } + + +This uses Django's ``locmem`` template loader, so these templates can be extended and +included as normal templates, and can work with files in a ``templates`` dir. + +If a template path is defined as both a file and in the ``app.templates`` dict, the +template in the dict will be used. + + +Using templates +=============== + +Nanodjango provides a helper method to quickly render a template: +**app.render(__request, template_name, context=None, content_type=None, status=None, +using=None__)** + +Example usage: + +.. code-block:: python + + @app.route("/") + def index(request): + return app.render(request, "index.html", {"books": Book.objects.all()}) + + app.templates = { + "index.html" : """ + {% extends "base.html" %} + {% block content %} +

There are {{ books.count }} books:

+ {% for book in books %} +

{{ book.title }}

+ {% endfor %} + {% endblock %} + """, + ... + } + +This is a direct convenience wrapper for `django.shortcuts.render +`. When converting +a script which calls ``app.render``, nanodjango will attempt to rewrite it to use the +standard Django shortcut. + + +Converting templates +==================== + +Running the ``nanodjango convert`` command on an app script will put templates in the +app's ``templates`` directory. + +Files which are in a dir will be copied across. + +Templates defined in the ``app.templates`` dict will be written out to files. + +If the same template path is defined in both, the template from the dict will be written +to the file. diff --git a/examples/hello_template.py b/examples/hello_template.py new file mode 100644 index 0000000..f88db1c --- /dev/null +++ b/examples/hello_template.py @@ -0,0 +1,29 @@ +""" +nanodjango - Django models, views and admin in a single file + +Embedded template example + +Usage:: + + nanodjango run hello_template.py + nanodjango convert hello_template.html /path/to/site --name=myproject +""" + +from nanodjango import Django + +app = Django( + # Avoid clashes with other examples + SQLITE_DATABASE="hello_template.sqlite3", + MIGRATIONS_DIR="hello_template_migrations", +) + + +@app.route("/") +def hello_world(request): + return app.render(request, "hello.html", context={"message": "Hello!"}) + + +app.templates = { + "base.html": "

Base title

{% block content %}{% endblock %}", + "hello.html": "{% extends 'base.html' %}{% block content %}

Hello, World!

{% endblock %}", +} diff --git a/examples/scale/scale.py b/examples/scale/scale.py index b65f7fb..273c9ff 100644 --- a/examples/scale/scale.py +++ b/examples/scale/scale.py @@ -9,7 +9,7 @@ Usage:: - nanodjango scale.py convert /path/to/site --name=myproject + nanodjango convert scale.py /path/to/site --name=myproject cd /path/to/site ./manage.py runserver 0:8000 """ diff --git a/nanodjango/app.py b/nanodjango/app.py index e46b805..fc5363f 100644 --- a/nanodjango/app.py +++ b/nanodjango/app.py @@ -6,13 +6,14 @@ import sys from pathlib import Path from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence from django import setup from django import urls as django_urls from django.contrib import admin from django.contrib.auth import get_user_model from django.db.models import Model +from django.shortcuts import render from django.views import View from . import app_meta @@ -23,6 +24,7 @@ if TYPE_CHECKING: from pathlib import Path + from django.http import HttpRequest, HttpResponse from ninja import NinjaAPI @@ -56,6 +58,9 @@ class Django: #: Whether this app has defined an @app.admin has_admin: bool = False + #: In-memory template store - access via app.templates + _templates: dict[str, str] + #: Whether this app has any async views _has_async_view: bool = False @@ -125,6 +130,7 @@ def _config(self, _settings): self.app_name = settings.DF_APP_NAME self.app_module = app_meta.get_app_module() self.app_path = Path(inspect.getfile(self.app_module)) + self._templates = app_meta.get_templates() # Import and apply glue after django.conf has its settings from .django_glue.apps import prepare_apps @@ -159,8 +165,15 @@ def instance_name(self): ) return self._instance_name - - def route(self, pattern: str, *, re=False, include=None, name=None): + def route( + self, + pattern: str, + *, + re: bool = False, + include=None, + name: str | None = None, + template: bool | str | None = None, + ): """ Decorator to add a view to the urls @@ -302,6 +315,31 @@ def api(self): return self._api + @property + def templates(self) -> dict[str, str]: + return self._templates + + @templates.setter + def templates(self, data: dict[str, str]): + # Set or replace the templates dict, maintaining the single object reference + if self._templates: + self._templates.clear() + self._templates.update(data) + + def render( + self, + request: HttpRequest, + template_name: str | Sequence[str], + context: Mapping[str, Any] | None = None, + content_type: str | None = None, + status: int | None = None, + using: str | None = None, + ) -> HttpResponse: + """ + Convenience wrapper for ``django.shortcuts.render`` to save an import + """ + return render(request, template_name, context, content_type, status, using) + def _prepare(self, is_prod=False): """ Perform any final setup for this project after it has been imported: @@ -518,7 +556,6 @@ def _pre_xsgi(self, is_prod=True): settings.DEBUG = False - async def asgi(self, scope, receive, send, is_prod=True): """ ASGI handler @@ -528,8 +565,9 @@ async def asgi(self, scope, receive, send, is_prod=True): Alternatively run with uvicorn script:app.asgi """ from django.core.asgi import get_asgi_application + self._pre_xsgi(is_prod=is_prod) - + application = get_asgi_application() return await application(scope, receive, send) diff --git a/nanodjango/app_meta.py b/nanodjango/app_meta.py index 707c0a5..a3587fb 100644 --- a/nanodjango/app_meta.py +++ b/nanodjango/app_meta.py @@ -7,7 +7,6 @@ from types import ModuleType from typing import Any - #: Reference to app module #: #: Available after the app is imported, before app.run() is called @@ -20,6 +19,9 @@ #: Used to configure settings _app_conf: dict[str, Any] = {} +#: Dict for in-memory template definitions +_locmem_templates: dict[str, str] = {} + def get_app_module() -> ModuleType: global _app_module @@ -30,3 +32,7 @@ def get_app_module() -> ModuleType: def get_app_conf() -> dict[str, Any]: return _app_conf + + +def get_templates() -> dict[str, str]: + return _locmem_templates diff --git a/nanodjango/convert/converter.py b/nanodjango/convert/converter.py index 8708974..80ee022 100644 --- a/nanodjango/convert/converter.py +++ b/nanodjango/convert/converter.py @@ -249,6 +249,7 @@ def build(self) -> None: plugins.build_settings_done(self) self.copy_assets() + self.build_app_templates() self.build_app_models() plugins.build_app_models_done(self) @@ -458,6 +459,21 @@ def copy_assets(self) -> None: plugins.copy_assets(self) + def build_app_templates(self) -> None: + """ + Build ``templates/*html`` from the app.templates dict + + Hooks: + build_templates: After the templates have been built + """ + dest_dir = self.app_path / "templates" + for template_name, template_str in self.app.templates.items(): + path = dest_dir / template_name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(template_str) + + plugins.build_app_templates(self) + def build_app_models(self) -> None: """ Build ``app/models.py`` and collect models in ``self.models`` (a list of @@ -513,8 +529,15 @@ def build_app_views(self) -> None: if not self.views and not extra_src: return + extra_imports = "" + if any([v.has_render for v in self.views]): + # If we're converting an app.render, register that we know render + extra_imports = "from django.shortcuts import render" + resolver.global_refs.remove("render") + self.write_file( self.app_path / "views.py", + extra_imports, resolver.gen_src(), "\n".join([app_view.src for app_view in self.views]), "\n".join(extra_src), diff --git a/nanodjango/convert/objects.py b/nanodjango/convert/objects.py index e13a184..9b39403 100644 --- a/nanodjango/convert/objects.py +++ b/nanodjango/convert/objects.py @@ -6,6 +6,7 @@ from django.http import HttpResponse +from .reference import ReferenceVisitor from .utils import ( applied_ensure_http_response, collect_references, @@ -18,7 +19,6 @@ parse_admin_decorator, ) - if TYPE_CHECKING: from .converter import Converter @@ -53,9 +53,24 @@ def collect_references(self): self.references = collect_references(self.ast) +class AppRenderRewriter(ast.NodeTransformer): + def __init__(self, app_attr_nodes: list[ast.AST]): + self.app_attr_nodes = app_attr_nodes + + def visit_Call(self, node): + if isinstance(node.func, ast.Attribute) and node.func in self.app_attr_nodes: + return ast.Call( + func=ast.Name(id="render", ctx=ast.Load()), + args=node.args, + keywords=node.keywords, + ) + return self.generic_visit(node) + + class AppView(ConverterObject): pattern: str url_config: dict[str, Any] + has_render: bool = False def __init__( self, @@ -73,6 +88,41 @@ def __init__( self.fix_return_value() self.collect_references() + self.rewrite_app_render() + + def collect_references(self): + # Same as standard collect_references, except we persist the visitor + self.visitor = ReferenceVisitor() + self.visitor.visit(self.ast) + self.references = self.visitor.globals_ref + + def rewrite_app_render(self): + app_attr_nodes = [] + # Go over all app references we found + dirty = False + for node in self.visitor.globals_lookup.get(self.converter.app._instance_name): + attr_node = getattr(node, "attribute_parent", None) + + if attr_node and attr_node.attr == "render": + app_attr_nodes.append(attr_node) + else: + dirty = True + + if dirty: + print(f"Unexpected reference to `app` in view {self.name}") + elif app_attr_nodes: + self.visitor.globals_ref.remove(self.converter.app._instance_name) + del self.visitor.globals_lookup[self.converter.app._instance_name] + + if not app_attr_nodes: + return + + # Found one to rewrite + self.has_render = True + rewriter = AppRenderRewriter(app_attr_nodes) + self.ast = rewriter.visit(self.ast) + self.src = ast.unparse(self.ast) + self.references.add("render") def fix_return_value(self): """ diff --git a/nanodjango/convert/plugin.py b/nanodjango/convert/plugin.py index e650ced..e74725a 100644 --- a/nanodjango/convert/plugin.py +++ b/nanodjango/convert/plugin.py @@ -7,7 +7,6 @@ import importlib.metadata from typing import TYPE_CHECKING, Any - if TYPE_CHECKING: import ast @@ -79,6 +78,14 @@ def copy_assets(self, converter: Converter): converter (Converter): The current converter instance. """ + def build_app_templates(self, converter: Converter): + """ + Build templates from the app.templates dict. + + Args: + converter (Converter): The current converter instance. + """ + def build_app_models( self, converter: Converter, resolver: Resolver, extra_src: list[str] ) -> tuple[Resolver, list[str]]: diff --git a/nanodjango/convert/reference.py b/nanodjango/convert/reference.py index 6d44270..83e682f 100644 --- a/nanodjango/convert/reference.py +++ b/nanodjango/convert/reference.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +from collections import defaultdict class ReferenceVisitor(ast.NodeVisitor): @@ -14,9 +15,13 @@ class ReferenceVisitor(ast.NodeVisitor): #: Set of global names referenced globals_ref: set[str] + #: Lookup of which nodes reference each global + globals_lookup: dict[str, list[ast.AST]] + def __init__(self): self.locals_stack = [set()] self.globals_ref = set() + self.globals_lookup = defaultdict(list) def push_scope(self): self.locals_stack.append(self.current_scope.copy()) @@ -32,11 +37,13 @@ def current_scope(self): def local_scopes(self): return set().union(*self.locals_stack) - def found_reference(self, ref): + def found_reference(self, node): + ref = node.id if ref in __builtins__: return if ref not in self.local_scopes: self.globals_ref.add(ref) + self.globals_lookup[ref].append(node) def visit_FunctionDef(self, node): """Function definition, including top level definition if obj is a function""" @@ -88,18 +95,19 @@ def visit_NamedExpr(self, node): def visit_Attribute(self, node): """Accessing an attribute of a variable""" if isinstance(node.value, ast.Name): - self.found_reference(node.value.id) + node.value.attribute_parent = node + self.found_reference(node.value) self.visit(node.value) def visit_Name(self, node): """Accessing something in scope, eg a variable""" if isinstance(node.ctx, ast.Load): - self.found_reference(node.id) + self.found_reference(node) def visit_Global(self, node): """Bringing in a global reference""" for name in node.names: - self.globals_ref.add(name) + self.found_reference(node) def visit_ListComp(self, node: ast.ListComp | ast.SetComp | ast.GeneratorExp): """List and set comprehensions, and generator expressions""" diff --git a/nanodjango/settings.py b/nanodjango/settings.py index 10eeb51..021a986 100644 --- a/nanodjango/settings.py +++ b/nanodjango/settings.py @@ -6,7 +6,7 @@ from pathlib import Path from types import ModuleType -from nanodjango.app_meta import get_app_conf, get_app_module +from nanodjango.app_meta import get_app_conf, get_app_module, get_templates app_conf = get_app_conf() @@ -54,14 +54,18 @@ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [str(BASE_DIR / "templates")], - "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", - ] + ], + "loaders": [ + ("django.template.loaders.locmem.Loader", get_templates()), + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], }, } ]