Skip to content

Commit

Permalink
feat: package resource loader
Browse files Browse the repository at this point in the history
  • Loading branch information
jg-rp committed Jan 30, 2024
1 parent 4d155ec commit 7a5a1c1
Show file tree
Hide file tree
Showing 14 changed files with 203 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
**Features**

- Added `CachingChoiceLoader`, a template loader that chooses between a list of template loaders and caches parsed templates in memory.
- Added `PackageLoader`, a template loader that reads templates from Python packages.

## Version 1.10.2

Expand Down
3 changes: 3 additions & 0 deletions docs/loaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
::: liquid.loaders.CachingChoiceLoader
handler: python

::: liquid.loaders.PackageLoader
handler: python

::: liquid.loaders.BaseLoader
handler: python

Expand Down
2 changes: 2 additions & 0 deletions liquid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .loaders import DictLoader
from .loaders import FileExtensionLoader
from .loaders import FileSystemLoader
from .loaders import PackageLoader

from .context import Context
from .context import DebugUndefined
Expand Down Expand Up @@ -68,6 +69,7 @@
"is_undefined",
"Markup",
"Mode",
"PackageLoader",
"soft_str",
"StrictDefaultUndefined",
"StrictUndefined",
Expand Down
3 changes: 3 additions & 0 deletions liquid/builtin/loaders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from .caching_file_system_loader import CachingFileSystemLoader

from .package_loader import PackageLoader

__all__ = (
"BaseLoader",
"CachingChoiceLoader",
Expand All @@ -20,6 +22,7 @@
"DictLoader",
"FileExtensionLoader",
"FileSystemLoader",
"PackageLoader",
"TemplateNamespace",
"TemplateSource",
"UpToDate",
Expand Down
2 changes: 2 additions & 0 deletions liquid/builtin/loaders/choice_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ class CachingChoiceLoader(CachingLoaderMixin, ChoiceLoader):
argument that resolves to the current loader "namespace" or "scope".
cache_size: The maximum number of templates to hold in the cache before removing
the least recently used template.
_New in version 1.10.3._
"""

def __init__(
Expand Down
108 changes: 108 additions & 0 deletions liquid/builtin/loaders/package_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""A template loader that reads templates from Python packages."""
from __future__ import annotations

import asyncio
import os
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Iterable
from typing import Union

from importlib_resources import files

from liquid.exceptions import TemplateNotFound

from .base_loader import BaseLoader
from .base_loader import TemplateSource

if TYPE_CHECKING:
from types import ModuleType

from importlib_resources.abc import Traversable

from liquid import Environment


class PackageLoader(BaseLoader):
"""A template loader that reads templates from Python packages.
Args:
package: Import name of a package containing Liquid templates.
package_path: One or more directories in the package containing Liquid
templates.
encoding: Encoding of template files.
ext: A default file extension to use if one is not provided. Should
include a leading period.
_New in version 1.10.3._
"""

def __init__(
self,
package: Union[str, ModuleType],
*,
package_path: Union[str, Iterable[str]] = "templates",
encoding: str = "utf-8",
ext: str = ".liquid",
) -> None:
if isinstance(package_path, str):
self.paths = [files(package).joinpath(package_path)]
else:
_package = files(package)
self.paths = [_package.joinpath(path) for path in package_path]

self.encoding = encoding
self.ext = ext

def _resolve_path(self, template_name: str) -> Traversable:
template_path = Path(template_name)

# Don't build a path that escapes package/package_path.
# Does ".." appear in template_name?
if os.path.pardir in template_path.parts:
raise TemplateNotFound(template_name)

# Add suffix self.ext if template name does not have a suffix.
if not template_path.suffix:
template_path = template_path.with_suffix(self.ext)

for path in self.paths:
source_path = path.joinpath(template_path)
if source_path.is_file():
# MyPy seems to think source_path has `Any` type :(
return source_path # type: ignore

raise TemplateNotFound(template_name)

def get_source( # noqa: D102
self,
_: Environment,
template_name: str,
) -> TemplateSource:
source_path = self._resolve_path(template_name)
return TemplateSource(
source=source_path.read_text(self.encoding),
filename=str(source_path),
uptodate=None,
)

async def get_source_async( # noqa: D102
self, _: Environment, template_name: str
) -> TemplateSource:
loop = asyncio.get_running_loop()

source_path = await loop.run_in_executor(
None,
self._resolve_path,
template_name,
)

source_text = await loop.run_in_executor(
None,
source_path.read_text,
self.encoding,
)

return TemplateSource(
source=source_text, filename=str(source_path), uptodate=None
)
2 changes: 2 additions & 0 deletions liquid/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .builtin.loaders import DictLoader
from .builtin.loaders import FileExtensionLoader
from .builtin.loaders import FileSystemLoader
from .builtin.loaders import PackageLoader
from .builtin.loaders import TemplateNamespace
from .builtin.loaders import TemplateSource
from .builtin.loaders import UpToDate
Expand All @@ -18,6 +19,7 @@
"DictLoader",
"FileExtensionLoader",
"FileSystemLoader",
"PackageLoader",
"TemplateNamespace",
"TemplateSource",
"UpToDate",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = ["python-dateutil>=2.8.1", "typing-extensions>=4.2.0"]
dependencies = ["python-dateutil>=2.8.1", "typing-extensions>=4.2.0", "importlib-resources>=6.1.0"]
description = "A Python engine for the Liquid template language."
dynamic = ["version"]
license = "MIT"
Expand Down
1 change: 1 addition & 0 deletions tests/mock_package/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Make this folder a package.
1 change: 1 addition & 0 deletions tests/mock_package/other.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
g'day, {{ you }}!
1 change: 1 addition & 0 deletions tests/mock_package/templates/more_templates/thing.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Goodbye, {{ you }}!
1 change: 1 addition & 0 deletions tests/mock_package/templates/some.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello, {{ you }}!
1 change: 1 addition & 0 deletions tests/secret.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This template should not be accessible with a package loader pointing to some_package
76 changes: 76 additions & 0 deletions tests/test_load_template.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""Template loader test cases."""
import asyncio
import pickle
import sys
import tempfile
import time
import unittest
from pathlib import Path
from typing import Dict

from mock import patch

from liquid import Context
from liquid import Environment
from liquid.exceptions import TemplateNotFound
Expand All @@ -15,6 +18,7 @@
from liquid.loaders import DictLoader
from liquid.loaders import FileExtensionLoader
from liquid.loaders import FileSystemLoader
from liquid.loaders import PackageLoader
from liquid.loaders import TemplateSource
from liquid.template import AwareBoundTemplate
from liquid.template import BoundTemplate
Expand Down Expand Up @@ -722,3 +726,75 @@ async def coro():
self.assertEqual(context_loader.kwargs["tag"], "include")
self.assertIn("uid", context_loader.kwargs)
self.assertEqual(context_loader.kwargs["uid"], 1234)


class PackageLoaderTestCase(unittest.TestCase):
"""Test loading templates from Python packages."""

def test_no_such_package(self) -> None:
"""Test that we get an exception at construction time if the
package doesn't exist."""
with self.assertRaises(ModuleNotFoundError):
Environment(loader=PackageLoader("nosuchthing"))

def test_package_root(self) -> None:
"""Test that we can load templates from a package's root."""
with patch.object(sys, "path", [str(Path(__file__).parent)] + sys.path):
loader = PackageLoader("mock_package", package_path="")

env = Environment(loader=loader)

with self.assertRaises(TemplateNotFound):
env.get_template("some")

template = env.get_template("other")
self.assertEqual(template.render(you="World"), "g'day, World!\n")

def test_package_directory(self) -> None:
"""Test that we can load templates from a package directory."""
with patch.object(sys, "path", [str(Path(__file__).parent)] + sys.path):
loader = PackageLoader("mock_package", package_path="templates")

env = Environment(loader=loader)
template = env.get_template("some")
self.assertEqual(template.render(you="World"), "Hello, World!\n")

def test_package_with_list_of_paths(self) -> None:
"""Test that we can load templates from multiple paths in a package."""
with patch.object(sys, "path", [str(Path(__file__).parent)] + sys.path):
loader = PackageLoader(
"mock_package", package_path=["templates", "templates/more_templates"]
)

env = Environment(loader=loader)
template = env.get_template("some.liquid")
self.assertEqual(template.render(you="World"), "Hello, World!\n")

template = env.get_template("more_templates/thing.liquid")
self.assertEqual(template.render(you="World"), "Goodbye, World!\n")

template = env.get_template("thing.liquid")
self.assertEqual(template.render(you="World"), "Goodbye, World!\n")

def test_package_root_async(self) -> None:
"""Test that we can load templates from a package's root asynchronously."""
with patch.object(sys, "path", [str(Path(__file__).parent)] + sys.path):
loader = PackageLoader("mock_package", package_path="")

env = Environment(loader=loader)

async def coro():
return await env.get_template_async("other")

template = asyncio.run(coro())
self.assertEqual(template.render(you="World"), "g'day, World!\n")

def test_escape_package_root(self) -> None:
"""Test that we can't escape the package's package's root."""
with patch.object(sys, "path", [str(Path(__file__).parent)] + sys.path):
loader = PackageLoader("mock_package", package_path="")

env = Environment(loader=loader)

with self.assertRaises(TemplateNotFound):
env.get_template("../secret.liquid")

0 comments on commit 7a5a1c1

Please sign in to comment.