refactor: organize code in context & extensions

This commit is contained in:
Corentin 2024-05-21 21:13:55 +02:00
parent 8591ae53fa
commit 2729af8e39
38 changed files with 406 additions and 274 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
**/__pycache__ **/__pycache__
.coverage .coverage
jean_website.egg-info jean_website.egg-info
tests/**/*.mo

View File

@ -2,7 +2,7 @@ from pathlib import Path
from click import group from click import group
from jwebsite.site import Site from jwebsite.context import Context
@group @group
@ -13,4 +13,4 @@ def main() -> None: ...
def build() -> None: def build() -> None:
cwd = Path.cwd() cwd = Path.cwd()
with open(cwd / "site.py", encoding="utf-8") as site_config: with open(cwd / "site.py", encoding="utf-8") as site_config:
exec(site_config.read(), {"site": Site(cwd)}) exec(site_config.read(), {"site": Context(cwd)})

View File

@ -1,114 +1,36 @@
from functools import cache
from pathlib import Path from pathlib import Path
from typing import Any, Iterable, Iterator from typing import Any, Iterator
from markdown import Markdown
from yaml import Loader, load
class Content: class Content:
current_language: str | None = None def __init__(self, path: Path, data: Any, language: str | None = None) -> None:
def __init__(self, path: Path) -> None:
self.__path = path self.__path = path
self.__data = data
self.__language = language
@property @property
def path(self) -> Path: def path(self) -> Path:
return self.__path return self.__path
@property
def language(self) -> str | None:
return self.__language
class ContentDirectory(Content): @property
def load(self, subpath: str | Path) -> Content: def data(self) -> Any:
subpath = Path(subpath) return self.__data
current: Content = self
for part in subpath.parts:
if not isinstance(current, ContentDirectory):
raise NotADirectoryError(self.path)
current = current.__load_children(part)
return current
def glob(self, pattern: str) -> Iterable[Content]:
for item in self.path.glob(pattern):
yield self.load(str(item.relative_to(self.path)))
def __load_children(self, name: str) -> Content:
child_path = self.__get_localized_path(self.path / name)
return self.__load_path(child_path)
@cache # noqa: B019
def __load_path(self, child_path: Path) -> Content:
if not child_path.exists():
raise FileNotFoundError(child_path)
if child_path.is_dir():
return ContentDirectory(child_path)
if child_path.is_file():
if child_path.suffix in [".yml", ".yaml", ".json"]:
return DataFile(child_path)
if child_path.suffix == ".md":
return MarkdownFile(child_path)
raise NotImplementedError()
@staticmethod
def __get_localized_path(path: Path) -> Path:
if Content.current_language is None:
return path
localized_path = path.with_name(f"{path.stem}-{Content.current_language}{path.suffix}")
print(localized_path)
if not localized_path.exists():
return path
return localized_path
class DataField:
def __init__(self, file_path: Path, value: Any) -> None:
self.__file_path = file_path
self.__value = value
def as_path(self) -> Path:
return self.__file_path.parent / str(self.__value)
def __str__(self) -> str: def __str__(self) -> str:
return str(self.__value) return str(self.__data)
class ContentField(Content):
def __getitem__(self, key: Any) -> Any: def __getitem__(self, key: Any) -> Any:
return DataField(self.__file_path, self.__value.get(key)) return ContentField(self.path, self.data.get(key), self.language)
def __iter__(self) -> Iterator[Any]: def __iter__(self) -> Iterator[Any]:
for it in self.__value: for it in self.data:
yield DataField(self.__file_path, it) yield ContentField(self.path, it, self.language)
def __str__(self) -> str:
class DataFile(Content): return str(self.data)
def __init__(self, path: Path) -> None:
super().__init__(path)
with path.open("r", encoding="utf-8") as data_file:
self.__data = load(data_file, Loader)
def __getitem__(self, key: Any) -> Any:
return DataField(self.path, self.__data.get(key))
def __iter__(self) -> Iterator[Any]:
for it in self.__data:
yield DataField(self.path, it)
class MarkdownFile(Content):
def __init__(self, path: Path) -> None:
super().__init__(path)
with path.open("r", encoding="utf-8") as markdown_file:
self.__markdown = Markdown(extensions=["full_yaml_metadata"])
self.__html = self.__markdown.convert(markdown_file.read())
@property
def html(self) -> str:
return self.__html
@property
def meta(self) -> Any:
return DataField(self.path, self.__markdown.Meta) # type: ignore

97
jwebsite/context.py Normal file
View File

@ -0,0 +1,97 @@
import gettext
from contextlib import contextmanager
from gettext import GNUTranslations, NullTranslations
from importlib import import_module
from pathlib import Path
from typing import Any, Iterator
from jinja2 import pass_context
from jinja2.environment import Environment
from jinja2.loaders import FileSystemLoader
from jinja2.runtime import Context as JinjaContext
from jwebsite.content import Content
_DEFAULT_LANGUAGE = "en"
class Context:
def __init__(self, root_directory: Path) -> None:
self.__root_directory = root_directory
self.__output_directory = root_directory / "build"
self.__content_directory = root_directory / "content"
self.__environment = Environment(
loader=FileSystemLoader(searchpath=root_directory / "src"),
)
self.__translations: dict[str, GNUTranslations | NullTranslations] = {}
self.__current_language: str | None = None
self.add_filters(load=self.__load, write=self.__write)
@property
def current_language(self) -> str | None:
return self.__current_language
def add_filters(self, **filters: Any) -> None:
self.__environment.filters.update(**filters)
def load_extensions(self, *extensions: str) -> None:
for extension in extensions:
module = import_module(extension)
module.load_extension(self)
def load_translations(self, domain: str, locale_dir: str | Path, *languages: str) -> None:
locale_dir = str(locale_dir)
self.__environment.add_extension("jinja2.ext.i18n")
self.__translations[_DEFAULT_LANGUAGE] = NullTranslations()
for language in languages:
self.__translations[language] = gettext.translation(
domain, localedir=str(locale_dir), languages=[language]
)
@contextmanager
def set_language(self, language: str) -> Iterator[None]:
translation = self.__translations[language]
old_language = self.__current_language
self.__environment.install_gettext_translations(translation, newstyle=True) # type: ignore
self.__current_language = language
yield
self.__current_language = old_language
self.__environment.uninstall_gettext_translations(translation) # type: ignore
def render(self, source: str, output: str | Path, **context: Any) -> None:
if self.__translations:
for language in self.__translations:
with self.set_language(language):
self.__render(source, self.__output_directory / language / output, **context)
else:
self.__render(source, self.__output_directory / output, **context)
@pass_context
def __load(self, context: JinjaContext, path: str | Path) -> Content:
current_language = self.current_language
if current_language:
localized_path = self.__content_directory / current_language / path
if not localized_path.exists():
localized_path = self.__content_directory / _DEFAULT_LANGUAGE / path
else:
localized_path = self.__content_directory / path
with localized_path.open("r", encoding="utf-8") as content_file:
return Content(localized_path, content_file.read(), current_language)
def __write(self, content: Content) -> Path:
relative_path = content.path.relative_to(self.__content_directory)
output_path = self.__output_directory / relative_path
with output_path.open("w") as output_file:
output_file.write(content.data)
return Path(f"/{relative_path}")
def __render(self, source: str, output_path: Path, **context: Any) -> None:
output_path.parent.mkdir(exist_ok=True, parents=True)
template = self.__environment.get_template(source)
content = template.render(**context)
with open(output_path, "w") as output_file:
output_file.write(content)

View File

@ -0,0 +1,31 @@
from datetime import datetime
from subprocess import check_output
from jwebsite.content import Content
from jwebsite.context import Context
def load_extension(context: Context) -> None:
context.add_filters(git_creation_date=_git_creation_date)
def _git_creation_date(content: Content) -> datetime:
git_dir = check_output(["git", "rev-parse", "--show-toplevel"], encoding="utf-8")
git_dir = git_dir.strip()
log = check_output(
[
"git",
"log",
"--pretty=format:%ad",
"--date=iso-strict",
"--diff-filter=A",
"--",
str(content.path.relative_to(git_dir)),
],
encoding="utf-8",
)
log_lines = log.splitlines()
if len(log_lines) == 0:
return datetime.now()
return datetime.fromisoformat(log_lines[0])

View File

@ -0,0 +1,34 @@
from typing import Any
from markdown import Markdown
from jwebsite.content import Content, ContentField
from jwebsite.context import Context
def load_extension(context: Context) -> None:
context.add_filters(markdown=_MarkdownDocument)
class _MarkdownDocument:
def __init__(self, content: Content) -> None:
if not isinstance(content, Content) or not isinstance(content.data, (str, bytes)):
raise ValueError("markdown filter can only accept byte or string content")
data = content.data
if isinstance(data, bytes):
data = data.decode("utf-8")
assert isinstance(data, str)
self.__content = content
self.__markdown = Markdown(extensions=["full_yaml_metadata"])
self.__html = self.__markdown.convert(data)
@property
def html(self) -> str:
return self.__html
@property
def meta(self) -> Any:
return ContentField(self.__content.path, self.__markdown.Meta, self.__content.language) # type: ignore

View File

@ -0,0 +1,23 @@
from typing import Any
from yaml import Loader, load
from jwebsite.content import Content, ContentField
from jwebsite.context import Context
def load_extension(context: Context) -> None:
context.add_filters(yaml=_load_yaml)
def _load_yaml(content: Any) -> ContentField:
if not isinstance(content, Content) or not isinstance(content.data, (str, bytes)):
raise ValueError("yaml filter can only accept byte or string content")
data = content.data
if isinstance(data, bytes):
data = data.decode("utf-8")
assert isinstance(data, str)
return ContentField(content.path, load(data, Loader), content.language)

View File

@ -1,64 +0,0 @@
import gettext
from gettext import GNUTranslations, NullTranslations
from pathlib import Path
from shutil import copy
from typing import Any
from jinja2.environment import Environment
from jinja2.loaders import FileSystemLoader
from jwebsite.content import Content, ContentDirectory
from jwebsite.git import git_creation_date
class Site:
def __init__(self, root_directory: Path) -> None:
self.__root_directory = root_directory
self.__output_directory = root_directory / "build"
self.__environment = Environment(loader=FileSystemLoader(searchpath=root_directory / "src"))
self.__environment.filters.update({"output": self.__output, "git_creation_date": git_creation_date})
self.__content = ContentDirectory(root_directory / "content")
self.__translations: dict[str, GNUTranslations | NullTranslations] = {}
@property
def content(self) -> ContentDirectory:
return self.__content
def set_translations(self, domain: str, locale_dir: str, languages: list[str]) -> None:
self.__environment.add_extension("jinja2.ext.i18n")
self.__translations["en"] = NullTranslations()
for language in languages:
self.__translations[language] = gettext.translation(
domain, localedir=str(locale_dir), languages=[language]
)
def render(self, source: str, output: str | Path, **context: Any) -> None:
if self.__translations:
for language, translation in self.__translations.items():
Content.current_language = language
self.__environment.install_gettext_translations(translation, newstyle=True) # type: ignore
self.__render(source, self.__output_directory / language / output, **context)
self.__environment.uninstall_gettext_translations(translation) # type: ignore
else:
self.__render(source, self.__output_directory / output, **context)
def __render(self, source: str, output_path: Path, **context: Any) -> None:
output_path.parent.mkdir(exist_ok=True, parents=True)
template = self.__environment.get_template(source)
content = template.render(site=self, **context)
with open(output_path, "w") as output_file:
output_file.write(content)
def __output(self, path: str | Path) -> str:
path = Path(path)
if path.is_absolute():
src_path = path
relative_src_path = src_path.relative_to(self.__content.path)
else:
src_path = self.__content.path / path
relative_src_path = path
dst_path = self.__output_directory / relative_src_path
dst_path.parent.mkdir(parents=True, exist_ok=True)
copy(src_path, dst_path, follow_symlinks=True)
return f"/{relative_src_path}"

View File

@ -1,7 +1,12 @@
"""Nox configuration file.""" """Nox configuration file."""
from pathlib import Path
from tempfile import TemporaryDirectory
from nox import Session, session from nox import Session, session
_LOCALIZED_TESTS = ["tests/test_context"]
@session() @session()
def lint(session: Session) -> None: def lint(session: Session) -> None:
@ -16,10 +21,37 @@ def mypy(session: Session) -> None:
session.run("mypy") session.run("mypy")
@session()
def update_messages(session: Session) -> None:
session.install("babel", "jinja2")
for directory in _LOCALIZED_TESTS:
with TemporaryDirectory() as tmp_dir:
messages_file = Path(tmp_dir) / "messages.po"
session.run(
"pybabel",
"extract",
"--mapping",
f"{directory}/babel.cfg",
f"--output-file={messages_file}",
str(directory),
)
session.run(
"pybabel",
"update",
"--domain=tests",
f"--input-file={messages_file}",
f"--output-dir={directory}/locale",
)
@session(python=["3.10", "3.11"]) @session(python=["3.10", "3.11"])
def unit_tests(session: Session) -> None: def unit_tests(session: Session) -> None:
"""Run unit tests.""" """Run unit tests."""
devenv(session) devenv(session)
session.install("babel", "jinja2")
for directory in _LOCALIZED_TESTS:
session.run("pybabel", "compile", "--domain=tests", f"--directory={directory}/locale", "--use-fuzzy")
session.run("python", "-m", "pytest", "--cov=jwebsite", "--cov-report=html") session.run("python", "-m", "pytest", "--cov=jwebsite", "--cov-report=html")

View File

@ -36,7 +36,7 @@ requires = ["setuptools>=45"]
[tool.mypy] [tool.mypy]
strict = true strict = true
files = "jwebsite/**/*.py,noxfile.py" files = "jwebsite/**/*.py,tests/**/*.py,noxfile.py"
[tool.ruff] [tool.ruff]
line-length = 110 line-length = 110

View File

@ -0,0 +1,38 @@
from pathlib import Path
from pytest import mark, raises
from jwebsite.content import Content
from jwebsite.context import Context
@mark.parametrize("content", ["# Otters", b"# Otters"])
def test_load(datadir: Path, content: bytes | str) -> None:
context = Context(datadir)
context.load_extensions("jwebsite.extensions.markdown")
context.render("test-load.html", "output.html", content=Content(Path("content.yml"), content, None))
with open(datadir / "build" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "<h1>Otters</h1>"
def test_load_type_error(datadir: Path) -> None:
context = Context(datadir)
context.load_extensions("jwebsite.extensions.markdown")
with raises(ValueError):
context.render("test-load.html", "output.html", content=Content(Path("content.yml"), 10, None))
with raises(ValueError):
context.render("test-load.html", "output.html", content=10)
def test_metadata(datadir: Path) -> None:
context = Context(datadir)
context.load_extensions("jwebsite.extensions.markdown")
markdown = "---\n" "title: Steven\n" "---\n" "\n" "Content\n"
context.render("test-metadata.html", "output.html", content=Content(Path("content.yml"), markdown, None))
with open(datadir / "build" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Steven"

View File

@ -0,0 +1,4 @@
{% with document = content | markdown %}
{{- document.html }}
{%- endwith -%}

View File

@ -0,0 +1,3 @@
{% with document = content | markdown %}
{{- document.meta.title }}
{%- endwith -%}

View File

@ -0,0 +1,27 @@
from pathlib import Path
from pytest import mark, raises
from jwebsite.content import Content
from jwebsite.context import Context
@mark.parametrize("content", ["[Peter, Steven]", b"[Peter, Steven]"])
def test_load(datadir: Path, content: bytes | str) -> None:
context = Context(datadir)
context.load_extensions("jwebsite.extensions.yaml")
context.render("test-load.html", "output.html", content=Content(Path("content.yml"), content, None))
with open(datadir / "build" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "PeterSteven"
def test_load_type_error(datadir: Path) -> None:
context = Context(datadir)
context.load_extensions("jwebsite.extensions.yaml")
with raises(ValueError):
context.render("test-load.html", "output.html", content=Content(Path("content.yml"), 10, None))
with raises(ValueError):
context.render("test-load.html", "output.html", content=10)

View File

@ -0,0 +1,3 @@
{% for otter in content | yaml -%}
{{- otter }}
{%- endfor %}

View File

@ -1,56 +0,0 @@
from pathlib import Path
from pytest import raises
from jwebsite.content import Content, ContentDirectory
def test_load_directory(datadir: Path) -> None:
content = ContentDirectory(datadir)
otters = content.load(Path("otters"))
assert isinstance(otters, ContentDirectory)
def test_load_errors(datadir: Path) -> None:
directory = ContentDirectory(datadir)
with raises(FileNotFoundError):
directory.load(Path("otters/i-dont-exist"))
with raises(NotADirectoryError):
directory.load(Path("otters/steven.yml/child"))
def test_load_data(datadir: Path) -> None:
content = ContentDirectory(datadir)
steven = content.load(Path("otters/steven.yml"))
assert str(steven["name"]) == "Steven"
assert str(steven["mood"]) == "Angry"
otter_list = content.load(Path("otters/otter_list.json"))
assert [str(it) for it in otter_list] == ["steven", "peter"]
def test_data_as_path(datadir: Path) -> None:
content = ContentDirectory(datadir)
paths = content.load(Path("otters/paths.yml"))
assert paths["steven"].as_path() == datadir / "otters" / "relative-steven"
def test_load_markdown(datadir: Path) -> None:
content = ContentDirectory(datadir)
page = content.load(Path("page.md"))
assert page.html == "<p>Content</p>"
assert str(page.meta["title"]) == "Title"
def test_localized_content(datadir: Path) -> None:
content = ContentDirectory(datadir)
Content.current_language = "fr"
page = content.load(Path("page.md"))
assert page.html == "<p>Contenu</p>"
assert str(page.meta["title"]) == "Titre"

View File

@ -1 +0,0 @@
["steven", "peter"]

View File

@ -1,2 +0,0 @@
steven: ./relative-steven

View File

@ -1,2 +0,0 @@
name: Steven
mood: Angry

View File

@ -1,6 +0,0 @@
---
title: Titre
---
Contenu

View File

@ -1,5 +0,0 @@
---
title: Title
---
Content

53
tests/test_context.py Normal file
View File

@ -0,0 +1,53 @@
from pathlib import Path
from jwebsite.context import Context
def test_render(datadir: Path) -> None:
context = Context(datadir)
context.render("test-render.html", "output.html", animal="Otters")
with open(datadir / "build" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Otters"
def test_jinja_localization(datadir: Path) -> None:
context = Context(datadir)
context.load_translations("tests", datadir / "locale", "fr")
context.render("test-jinja-localization.html", "output.html")
with open(datadir / "build" / "en" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Otters"
with open(datadir / "build" / "fr" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Loutres"
def test_load(datadir: Path) -> None:
context = Context(datadir)
context.render("test-load.html", "output.html")
with open(datadir / "build" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Otters\n"
def test_load_localized(datadir: Path) -> None:
context = Context(datadir)
context.load_translations("tests", datadir / "locale", "fr")
context.render("test-load-localized.html", "output.html")
with open(datadir / "build" / "en" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Otters\nCaimans\n"
with open(datadir / "build" / "fr" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Loutres\nCaimans\n"
def test_write(datadir: Path) -> None:
context = Context(datadir)
context.render("test-write.html", "output.html")
with open(datadir / "build" / "content.txt", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Otters\n"
with open(datadir / "build" / "output.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "/content.txt"

View File

@ -0,0 +1,3 @@
[jinja2: src/**.html]
encoding = utf-8

View File

@ -0,0 +1 @@
Otters

View File

@ -0,0 +1 @@
Otters

View File

@ -0,0 +1 @@
Caimans

View File

@ -0,0 +1 @@
Loutres

View File

@ -0,0 +1,25 @@
# French translations for PROJECT.
# Copyright (C) 2024 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-05-21 21:13+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
"Language-Team: fr <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.15.0\n"
#: tests/test_context/src/test-jinja-localization.html:1
msgid "Otters"
msgstr "Loutres"

View File

@ -0,0 +1 @@
{{ gettext('Otters') }}

View File

@ -0,0 +1,3 @@
{{ 'content.txt' | load -}}
{{ 'not-localized-content.txt' | load -}}

View File

@ -0,0 +1 @@
{{ 'content.txt' | load }}

View File

@ -0,0 +1 @@
{{animal}}

View File

@ -0,0 +1 @@
{{ 'content.txt' | load | write }}

View File

@ -1,32 +0,0 @@
from pathlib import Path
from pyfakefs.fake_filesystem import FakeFilesystem
from pytest import fixture
from jwebsite.site import Site
@fixture
def site_dir(datadir: Path, fs: FakeFilesystem):
fs.add_real_directory(datadir)
yield fs
def test_render_page(datadir: Path, site_dir: FakeFilesystem):
site = Site(datadir)
site.render("index.j2", "index.html")
with open(datadir / "build" / "index.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "<p>Peter</p><p>Steven</p>"
def test_output_file(datadir: Path, site_dir: FakeFilesystem):
site = Site(datadir)
build_dir = datadir / "build"
site.render("output-file.j2", "output-file.html")
with open(build_dir / "assets/steven-avatar", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "Yipee\n"
with open(build_dir / "output-file.html", encoding="utf-8") as ouput_file:
assert ouput_file.read() == "/assets/steven-avatar\n"

View File

@ -1 +0,0 @@
Yipee

View File

@ -1 +0,0 @@
[Peter, Steven]

View File

@ -1,3 +0,0 @@
{%- for otter in site.content.load('otters.yml') -%}
<p>{{ otter }}</p>
{%- endfor %}

View File

@ -1,2 +0,0 @@
{{ "assets/steven-avatar" | output }}