From 7a5a1c1345f3a217d17c671d76a38b98d977d46b Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 30 Jan 2024 15:43:28 +0000 Subject: [PATCH] feat: package resource loader --- CHANGES.md | 1 + docs/loaders.md | 3 + liquid/__init__.py | 2 + liquid/builtin/loaders/__init__.py | 3 + liquid/builtin/loaders/choice_loader.py | 2 + liquid/builtin/loaders/package_loader.py | 108 ++++++++++++++++++ liquid/loaders.py | 2 + pyproject.toml | 2 +- tests/mock_package/__init__.py | 1 + tests/mock_package/other.liquid | 1 + .../templates/more_templates/thing.liquid | 1 + tests/mock_package/templates/some.liquid | 1 + tests/secret.liquid | 1 + tests/test_load_template.py | 76 ++++++++++++ 14 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 liquid/builtin/loaders/package_loader.py create mode 100644 tests/mock_package/__init__.py create mode 100644 tests/mock_package/other.liquid create mode 100644 tests/mock_package/templates/more_templates/thing.liquid create mode 100644 tests/mock_package/templates/some.liquid create mode 100644 tests/secret.liquid diff --git a/CHANGES.md b/CHANGES.md index c005b21..14631fb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/docs/loaders.md b/docs/loaders.md index 9a14680..0413555 100644 --- a/docs/loaders.md +++ b/docs/loaders.md @@ -18,6 +18,9 @@ ::: liquid.loaders.CachingChoiceLoader handler: python +::: liquid.loaders.PackageLoader + handler: python + ::: liquid.loaders.BaseLoader handler: python diff --git a/liquid/__init__.py b/liquid/__init__.py index 3df0c45..f254bfc 100644 --- a/liquid/__init__.py +++ b/liquid/__init__.py @@ -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 @@ -68,6 +69,7 @@ "is_undefined", "Markup", "Mode", + "PackageLoader", "soft_str", "StrictDefaultUndefined", "StrictUndefined", diff --git a/liquid/builtin/loaders/__init__.py b/liquid/builtin/loaders/__init__.py index 2041a6d..92ba5ae 100644 --- a/liquid/builtin/loaders/__init__.py +++ b/liquid/builtin/loaders/__init__.py @@ -12,6 +12,8 @@ from .caching_file_system_loader import CachingFileSystemLoader +from .package_loader import PackageLoader + __all__ = ( "BaseLoader", "CachingChoiceLoader", @@ -20,6 +22,7 @@ "DictLoader", "FileExtensionLoader", "FileSystemLoader", + "PackageLoader", "TemplateNamespace", "TemplateSource", "UpToDate", diff --git a/liquid/builtin/loaders/choice_loader.py b/liquid/builtin/loaders/choice_loader.py index 5ba007f..8781a78 100644 --- a/liquid/builtin/loaders/choice_loader.py +++ b/liquid/builtin/loaders/choice_loader.py @@ -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__( diff --git a/liquid/builtin/loaders/package_loader.py b/liquid/builtin/loaders/package_loader.py new file mode 100644 index 0000000..8ca33b9 --- /dev/null +++ b/liquid/builtin/loaders/package_loader.py @@ -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 + ) diff --git a/liquid/loaders.py b/liquid/loaders.py index 6a988b0..0b360ac 100644 --- a/liquid/loaders.py +++ b/liquid/loaders.py @@ -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 @@ -18,6 +19,7 @@ "DictLoader", "FileExtensionLoader", "FileSystemLoader", + "PackageLoader", "TemplateNamespace", "TemplateSource", "UpToDate", diff --git a/pyproject.toml b/pyproject.toml index 865338a..a72e7b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/mock_package/__init__.py b/tests/mock_package/__init__.py new file mode 100644 index 0000000..a2a90df --- /dev/null +++ b/tests/mock_package/__init__.py @@ -0,0 +1 @@ +# Make this folder a package. diff --git a/tests/mock_package/other.liquid b/tests/mock_package/other.liquid new file mode 100644 index 0000000..dad068f --- /dev/null +++ b/tests/mock_package/other.liquid @@ -0,0 +1 @@ +g'day, {{ you }}! diff --git a/tests/mock_package/templates/more_templates/thing.liquid b/tests/mock_package/templates/more_templates/thing.liquid new file mode 100644 index 0000000..404fbaa --- /dev/null +++ b/tests/mock_package/templates/more_templates/thing.liquid @@ -0,0 +1 @@ +Goodbye, {{ you }}! diff --git a/tests/mock_package/templates/some.liquid b/tests/mock_package/templates/some.liquid new file mode 100644 index 0000000..331fa53 --- /dev/null +++ b/tests/mock_package/templates/some.liquid @@ -0,0 +1 @@ +Hello, {{ you }}! diff --git a/tests/secret.liquid b/tests/secret.liquid new file mode 100644 index 0000000..d7aae2c --- /dev/null +++ b/tests/secret.liquid @@ -0,0 +1 @@ +This template should not be accessible with a package loader pointing to some_package \ No newline at end of file diff --git a/tests/test_load_template.py b/tests/test_load_template.py index 51530dc..92b4e1a 100644 --- a/tests/test_load_template.py +++ b/tests/test_load_template.py @@ -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 @@ -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 @@ -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")