mirror of
https://github.com/zensical/zensical.git
synced 2026-09-26 00:05:50 +00:00
fix: handle Windows extended-length paths when including templates with macros (#914)
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
+121
-1
@@ -23,12 +23,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from io import StringIO
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pandas
|
||||
import pytest
|
||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
||||
from jinja2.exceptions import (
|
||||
TemplateNotFound,
|
||||
TemplateSyntaxError,
|
||||
UndefinedError,
|
||||
)
|
||||
|
||||
from tests.unit.extensions.conftest import soup
|
||||
from zensical.extensions.context import ContextPreprocessor
|
||||
@@ -52,6 +57,18 @@ if TYPE_CHECKING:
|
||||
from pandas import DataFrame
|
||||
|
||||
|
||||
_INCLUDE_CONFIG = {
|
||||
"config": {
|
||||
"markdown_extensions": {
|
||||
"zensical.extensions.macros": {
|
||||
"include_dir": "snippets",
|
||||
"on_error_fail": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filters
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -354,6 +371,47 @@ class TestPreprocessor:
|
||||
assert "Hello Ada!" in text
|
||||
assert "world" in text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"md",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"config": {
|
||||
"markdown_extensions": {
|
||||
"zensical.extensions.macros": {
|
||||
"include_dir": "snippets",
|
||||
"on_error_fail": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
id="nested_include",
|
||||
),
|
||||
],
|
||||
indirect=["md"],
|
||||
)
|
||||
def test_renders_nested_include(
|
||||
self,
|
||||
md: Markdown,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
include_dir = tmp_path / "snippets" / "sub"
|
||||
include_dir.mkdir(parents=True)
|
||||
include_dir.joinpath("fragment.md").write_text(
|
||||
"Included fragment text.", encoding="utf-8"
|
||||
)
|
||||
|
||||
context = ContextPreprocessor.from_markdown(md)
|
||||
assert context is not None
|
||||
if os.name == "nt":
|
||||
# Rust canonicalizes the configuration path before parsing it.
|
||||
root = str(tmp_path)
|
||||
if not root.startswith("\\\\?\\"):
|
||||
context.config["root_dir"] = "\\\\?\\" + root
|
||||
|
||||
html = soup(md.convert('{% include "sub/fragment.md" %}'))
|
||||
assert html.get_text() == "Included fragment text."
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"md",
|
||||
[
|
||||
@@ -494,6 +552,68 @@ class TestPreprocessor:
|
||||
assert code.select("span")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"md",
|
||||
[pytest.param(_INCLUDE_CONFIG, id="include_dir")],
|
||||
indirect=["md"],
|
||||
)
|
||||
class TestIncludeLoader:
|
||||
def test_renders_nested_include_and_refreshes_changed_source(
|
||||
self,
|
||||
md: Markdown,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
include_dir = tmp_path / "snippets" / "sub"
|
||||
include_dir.mkdir(parents=True)
|
||||
fragment = include_dir / "fragment.md"
|
||||
fragment.write_text("First fragment.", encoding="utf-8")
|
||||
|
||||
template = '{% include "sub/fragment.md" %}'
|
||||
assert soup(md.convert(template)).get_text() == "First fragment."
|
||||
|
||||
fragment.write_text("Updated fragment.", encoding="utf-8")
|
||||
assert soup(md.convert(template)).get_text() == "Updated fragment."
|
||||
|
||||
def test_missing_include_raises_template_not_found(
|
||||
self,
|
||||
md: Markdown,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "snippets").mkdir()
|
||||
|
||||
with pytest.raises(TemplateNotFound) as error:
|
||||
md.convert('{% include "missing.md" %}')
|
||||
|
||||
assert error.value.name == "missing.md"
|
||||
|
||||
def test_parent_traversal_is_rejected(
|
||||
self,
|
||||
md: Markdown,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "snippets").mkdir()
|
||||
(tmp_path / "outside.md").write_text("Outside.", encoding="utf-8")
|
||||
|
||||
with pytest.raises(TemplateNotFound):
|
||||
md.convert('{% include "../outside.md" %}')
|
||||
|
||||
def test_template_error_reports_included_source(
|
||||
self,
|
||||
md: Markdown,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
include_dir = tmp_path / "snippets"
|
||||
include_dir.mkdir()
|
||||
fragment = include_dir / "invalid.md"
|
||||
fragment.write_text("{% if %}", encoding="utf-8")
|
||||
|
||||
with pytest.raises(TemplateSyntaxError) as error:
|
||||
md.convert('{% include "invalid.md" %}')
|
||||
|
||||
assert error.value.filename is not None
|
||||
assert os.path.samefile(error.value.filename, fragment)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -41,6 +41,7 @@ from urllib.parse import urlparse
|
||||
import jinja2
|
||||
import yaml
|
||||
from jinja2.exceptions import UndefinedError
|
||||
from jinja2.loaders import split_template_path
|
||||
from markdown import Extension
|
||||
from markdown.preprocessors import Preprocessor
|
||||
|
||||
@@ -300,7 +301,7 @@ class MacrosPreprocessor(Preprocessor):
|
||||
include_dir_path := project_root / self.config.include_dir
|
||||
).exists()
|
||||
):
|
||||
env_kw["loader"] = jinja2.FileSystemLoader(include_dir_path)
|
||||
env_kw["loader"] = _make_include_loader(include_dir_path)
|
||||
|
||||
env = jinja2.Environment(**env_kw) # noqa: S701
|
||||
|
||||
@@ -407,6 +408,38 @@ def makeExtension(**kwargs: Any) -> MacrosExtension:
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_include_loader(root: Path) -> jinja2.FunctionLoader:
|
||||
"""Create a loader that joins template names with native separators.
|
||||
|
||||
Jinja joins filesystem search paths with POSIX separators. Windows
|
||||
extended-length paths require backslashes, so resolve each safe template
|
||||
component with `Path` instead.
|
||||
"""
|
||||
|
||||
def load(
|
||||
template: str,
|
||||
) -> tuple[str, str, Callable[[], bool]] | None:
|
||||
path = root.joinpath(*split_template_path(template))
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def uptodate() -> bool:
|
||||
try:
|
||||
return path.stat().st_mtime == mtime
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return source, str(path), uptodate
|
||||
|
||||
return jinja2.FunctionLoader(load)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
"""Return current datetime (`datetime.now()`)."""
|
||||
return datetime.now() # noqa: DTZ005
|
||||
|
||||
Reference in New Issue
Block a user