Skip to content

Commit

Permalink
Remove file operations from renderers
Browse files Browse the repository at this point in the history
  • Loading branch information
bartfeenstra committed Sep 9, 2024
1 parent a5cf387 commit 754fce7
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 68 deletions.
81 changes: 48 additions & 33 deletions betty/jinja2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@
Mapping,
)
from threading import Lock
from typing import Callable, Any, cast, TYPE_CHECKING, TypeAlias, final, Awaitable, Self
from typing import (
Callable,
Any,
cast,
TYPE_CHECKING,
TypeAlias,
final,
Awaitable,
Self,
)

import aiofiles
from aiofiles import os as aiofiles_os
from jinja2 import (
Environment as Jinja2Environment,
select_autoescape,
Expand All @@ -24,7 +32,7 @@
from jinja2.runtime import StrictUndefined, Context, DebugUndefined
from typing_extensions import override

from betty import model
from betty import model, job
from betty.asyncio import wait_to_thread
from betty.html import CssProvider, JsProvider
from betty.jinja2.filter import FILTERS
Expand All @@ -34,19 +42,20 @@
from betty.locale.localizable import Localizable, plain
from betty.locale.localizer import DEFAULT_LOCALIZER
from betty.locale.localizer import Localizer
from betty.plugin import Plugin
from betty.media_type.media_types import JINJA2_HTML, HTML
from betty.project.factory import ProjectDependentFactory
from betty.render import Renderer
from betty.render import RendererPlugin, MediaTypey
from betty.serde.dump import Dumpable, DumpMapping, VoidableDump, Dump
from betty.typing import Void
from pathlib import Path

if TYPE_CHECKING:
from betty.media_type import MediaType
from betty.machine_name import MachineName
from betty.model import Entity
from betty.project.extension import Extension
from betty.project import ProjectConfiguration, Project
from betty.ancestry import Citation
from pathlib import Path
from collections.abc import (
MutableMapping,
Iterator,
Expand Down Expand Up @@ -396,7 +405,7 @@ def _init_extensions(self) -> None:


@final
class Jinja2Renderer(Renderer, ProjectDependentFactory, Plugin):
class Jinja2Renderer(RendererPlugin, ProjectDependentFactory):
"""
Render content as Jinja2 templates.
"""
Expand All @@ -422,41 +431,47 @@ async def new_for_project(cls, project: Project) -> Self:

@override
@property
def file_extensions(self) -> set[str]:
return {".j2"}
def media_types(self) -> Mapping[MediaType, MediaType]:
return {JINJA2_HTML: HTML}

@override
async def render_file(
async def render(
self,
file_path: Path,
content: str,
media_typey: MediaTypey,
*,
job_context: JobContext | None = None,
job_context: job.Context | None = None,
localizer: Localizer | None = None,
) -> Path:
destination_file_path = file_path.parent / file_path.stem
) -> tuple[str, MediaType]:
to_media_type = self._assert_to_media_type(media_typey)
data: MutableMapping[str, Any] = {}
if job_context is not None:
data["job_context"] = job_context
if localizer is not None:
data["localizer"] = localizer
try:
relative_file_destination_path = destination_file_path.relative_to(
self._configuration.www_directory_path
)
except ValueError:
pass
if isinstance(media_typey, Path):
try:
relative_file_destination_path = media_typey.relative_to(
self._configuration.www_directory_path
)
except ValueError:
pass
else:
resource = "/".join(relative_file_destination_path.parts)
if self._configuration.locales.multilingual:
resource_parts = resource.lstrip("/").split("/")
if resource_parts[0] in (
x.alias for x in self._configuration.locales.values()
):
resource = "/".join(resource_parts[1:])
data["page_resource"] = f"/{resource}"
template_file_name = str(media_typey.expanduser().resolve())

else:
resource = "/".join(relative_file_destination_path.parts)
if self._configuration.locales.multilingual:
resource_parts = resource.lstrip("/").split("/")
if resource_parts[0] in (
x.alias for x in self._configuration.locales.values()
):
resource = "/".join(resource_parts[1:])
data["page_resource"] = f"/{resource}"
template = await self._environment.from_file(file_path)
template_file_name = None
template_code = self._environment.compile(content, filename=template_file_name)
template = self._environment.template_class.from_code(
self._environment, template_code, self._environment.globals
)
rendered = await template.render_async(data)
async with aiofiles.open(destination_file_path, "w", encoding="utf-8") as f:
await f.write(rendered)
await aiofiles_os.remove(file_path)
return destination_file_path
return rendered, to_media_type
30 changes: 29 additions & 1 deletion betty/media_type.py → betty/media_type/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ class InvalidMediaType(ValueError):
pass # pragma: no cover


class UnsupportedMediaType(ValueError):
"""
Raised when a media type is not supported.
"""

pass # pragma: no cover


@final
class MediaType:
"""
Expand All @@ -33,8 +41,11 @@ class MediaType:

_suffix: str | None

def __init__(self, media_type: str):
def __init__(
self, media_type: str, *, file_extensions: Sequence[str] | None = None
):
self._str = media_type
self._file_extensions = file_extensions or ()
message = EmailMessage()
message["Content-Type"] = media_type
type_part = message.get_content_type()
Expand Down Expand Up @@ -107,6 +118,23 @@ def __eq__(self, other: Any) -> bool:
other.parameters,
)

@property
def file_extensions(self) -> Sequence[str]:
"""
The file extensions associated with this media type.
"""
return list(self._file_extensions)

@property
def preferred_file_extension(self) -> str | None:
"""
The preferred extension for files containing content of this media type.
"""
try:
return self.file_extensions[0]
except IndexError:
return None


@final
class MediaTypeSchema(String):
Expand Down
8 changes: 8 additions & 0 deletions betty/media_type/media_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Common media types.
"""

from betty.media_type import MediaType

HTML = MediaType("text/html", file_extensions=[".html"])
JINJA2_HTML = MediaType("text/x.betty.jinja2-html", file_extensions=[".html.j2"])
96 changes: 66 additions & 30 deletions betty/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,92 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import final, TYPE_CHECKING
from pathlib import Path
from typing import final, TYPE_CHECKING, Union, TypeAlias

from typing_extensions import override

from betty.media_type import MediaType, UnsupportedMediaType
from betty.plugin import Plugin
from betty.plugin.entry_point import EntryPointPluginRepository
from betty.typing import internal

if TYPE_CHECKING:
from betty.plugin import PluginRepository, Plugin
from betty.plugin import PluginRepository
from betty.locale.localizer import Localizer
from betty.job import Context
from pathlib import Path
from collections.abc import Sequence
from collections.abc import Sequence, Mapping

MediaTypey: TypeAlias = Union[MediaType, Path]


class Renderer(ABC):
"""
Render content to HTML.
Read more about :doc:`/development/plugin/renderer`.
See also :py:class:`betty.render.RendererPlugin`.
"""

def to_media_type(self, media_typey: MediaTypey) -> MediaType | None:
"""
Get the media type the given media typey can be rendered to.
"""
if isinstance(media_typey, Path):
for from_media_type, to_media_type in self.media_types.items():
for file_extension in from_media_type.file_extensions:
if media_typey.suffix == file_extension:
return to_media_type
else:
for from_media_type, to_media_type in self.media_types.items():
if media_typey == from_media_type:
return to_media_type
return None

def _assert_to_media_type(self, media_typey: MediaTypey) -> MediaType:
media_type = self.to_media_type(media_typey)
if media_type is None:
raise RuntimeError(f'{self} cannot render "{media_typey}"')
return media_type

@property
@abstractmethod
def file_extensions(self) -> set[str]:
def media_types(self) -> Mapping[MediaType, MediaType]:
"""
The extensions of the files this renderer can render.
The media types this renderer can render.
:return: Keys are media types of content this renderer can render, and values are the media types of the
content after rendering.
"""
pass

@abstractmethod
async def render_file(
async def render(
self,
file_path: Path,
content: str,
media_typey: MediaTypey,
*,
job_context: Context | None = None,
localizer: Localizer | None = None,
) -> Path:
) -> tuple[str, MediaType]:
"""
Render a single file.
Render content.
:return: The file's new path, which may have been changed, e.g. a
renderer-specific extension may have been stripped from the end.
:return: The rendered content, and its new media type or path, which may have been changed.
"""
pass


RENDERER_REPOSITORY: PluginRepository[Renderer & Plugin] = EntryPointPluginRepository(
class RendererPlugin(Renderer, Plugin):
"""
A renderer as a plugin.
Read more about :doc:`/development/plugin/renderer`.
"""

pass


RENDERER_REPOSITORY: PluginRepository[RendererPlugin] = EntryPointPluginRepository(
"betty.renderer"
)
"""
Expand All @@ -71,31 +109,29 @@ class SequentialRenderer(Renderer):

def __init__(self, renderers: Sequence[Renderer]):
self._renderers = renderers
self._file_extensions = {
file_extension
self._media_types = {
from_media_type: to_media_type
for renderer in self._renderers
for file_extension in renderer.file_extensions
for from_media_type, to_media_type in renderer.media_types.items()
}

@override
@property
def file_extensions(self) -> set[str]:
return self._file_extensions
def media_types(self) -> Mapping[MediaType, MediaType]:
return self._media_types

@override
async def render_file(
async def render(
self,
file_path: Path,
content: str,
media_typey: MediaTypey,
*,
job_context: Context | None = None,
localizer: Localizer | None = None,
) -> Path:
) -> tuple[str, MediaType]:
for renderer in self._renderers:
for renderer_file_extension in renderer.file_extensions:
if file_path.suffix.endswith(renderer_file_extension):
return await renderer.render_file(
file_path,
job_context=job_context,
localizer=localizer,
)
return file_path
if renderer.to_media_type(media_typey):
return await renderer.render(
content, media_typey, job_context=job_context, localizer=localizer
)
raise UnsupportedMediaType()
7 changes: 3 additions & 4 deletions documentation/development/plugin/renderer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,15 @@ Renderers convert textual content to HTML. A renderer is often built to support
Creating a renderer
-------------------

#. Create a new class that extends both :py:class:`betty.render.Renderer` and :py:class:`betty.plugin.Plugin` and implements the abstract methods,
#. Create a new class that extends :py:class:`betty.render.RendererPlugin` and implements the abstract methods,
for example:

.. code-block:: python
from typing import override
from betty.plugin import Plugin
from betty.render import Renderer
from betty.render import RendererPlugin
class MyRenderer(Renderer, Plugin):
class MyRenderer(RendererPlugin):
@override
@classmethod
def plugin_id(cls) -> str:
Expand Down

0 comments on commit 754fce7

Please sign in to comment.