mirror of
https://github.com/zensical/zensical.git
synced 2026-10-10 22:51:26 +00:00
1838 lines
60 KiB
Python
Vendored
1838 lines
60 KiB
Python
Vendored
# Copyright (c) 2025-2026 Zensical and contributors
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# All contributions are certified under the DCO
|
|
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to
|
|
# deal in the Software without restriction, including without limitation the
|
|
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
# sell copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
# IN THE SOFTWARE.
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from io import StringIO
|
|
from textwrap import dedent
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pandas
|
|
import pytest
|
|
from jinja2.exceptions import (
|
|
TemplateNotFound,
|
|
TemplateSyntaxError,
|
|
UndefinedError,
|
|
)
|
|
from markdown import Markdown
|
|
|
|
from tests.unit.extensions.conftest import soup
|
|
from zensical.extensions.context import (
|
|
ContextExtension,
|
|
ContextPreprocessor,
|
|
Page,
|
|
)
|
|
from zensical.extensions.macros import (
|
|
MacroEnv,
|
|
MacrosExtension,
|
|
_fix_url,
|
|
_load_module,
|
|
_load_one_yaml,
|
|
_merge_include_yaml,
|
|
_pretty,
|
|
)
|
|
from zensical.extensions.table_reader import (
|
|
_add_indentation,
|
|
_convert_to_md_table,
|
|
_get_fake_table_readers,
|
|
_get_table_readers,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from pandas import DataFrame
|
|
|
|
|
|
_INCLUDE_CONFIG = {
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"include_dir": "snippets",
|
|
"on_error_fail": True,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFilters:
|
|
@pytest.mark.parametrize(
|
|
("url", "expected"),
|
|
[
|
|
pytest.param("page.html", "../page.html", id="relative_html"),
|
|
pytest.param(
|
|
"assets/image.png", "../assets/image.png", id="relative_asset"
|
|
),
|
|
pytest.param(
|
|
"https://example.org",
|
|
"https://example.org",
|
|
id="absolute_https",
|
|
),
|
|
pytest.param(
|
|
"mailto:test@example.org",
|
|
"mailto:test@example.org",
|
|
id="mailto",
|
|
),
|
|
],
|
|
)
|
|
def test_fix_url(self, url: str, expected: str) -> None:
|
|
assert _fix_url(url) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "expected"),
|
|
[
|
|
pytest.param(
|
|
[("alpha", "str", "hello")],
|
|
"**alpha** | *str* | hello",
|
|
id="single_row",
|
|
),
|
|
pytest.param([], "", id="empty"),
|
|
],
|
|
)
|
|
def test_pretty(
|
|
self, payload: list[tuple[str, str, str]], expected: str
|
|
) -> None:
|
|
output = _pretty(payload)
|
|
if expected:
|
|
assert expected in output
|
|
else:
|
|
assert output == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defining environments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMacroEnv:
|
|
@pytest.mark.parametrize("verbose", [False, True])
|
|
def test_chatter_respects_verbose(
|
|
self, caplog: pytest.LogCaptureFixture, verbose: bool
|
|
) -> None:
|
|
env = MacroEnv(verbose=verbose)
|
|
chatter = env.start_chatting("Example", color="cyan")
|
|
with caplog.at_level(logging.INFO, logger="zensical.extensions.macros"):
|
|
chatter("Count:", 2)
|
|
assert caplog.messages == (
|
|
["[macros - Example] - Count: 2"] if verbose else []
|
|
)
|
|
|
|
def test_conf_is_stored(self) -> None:
|
|
conf = {"site_name": "My Site", "docs_dir": "/docs"}
|
|
env = MacroEnv(conf=conf)
|
|
assert env.conf is conf
|
|
|
|
def test_conf_defaults_to_empty_dict(self) -> None:
|
|
env = MacroEnv()
|
|
assert env.conf == {}
|
|
|
|
def test_variables_support_nested_dot_access_after_updates(self) -> None:
|
|
env = MacroEnv()
|
|
source = {"nested": {"entries": [{"label": "first"}]}}
|
|
|
|
# Configured data is converted when the variable dictionary is updated.
|
|
env.variables.update(source)
|
|
entries = env.variables.nested.entries
|
|
entries.extend([{"label": "second"}])
|
|
combined = entries + [{"label": "third"}] # noqa: RUF005
|
|
|
|
assert [entry.label for entry in combined] == [
|
|
"first",
|
|
"second",
|
|
"third",
|
|
]
|
|
assert source == {"nested": {"entries": [{"label": "first"}]}}
|
|
|
|
def test_registers_macros_and_filters(self) -> None:
|
|
env = MacroEnv(conf={})
|
|
|
|
@env.macro
|
|
def twice(value: int) -> int:
|
|
return value * 2
|
|
|
|
@env.filter(name="rev")
|
|
def reverse(value: str) -> str:
|
|
return value[::-1]
|
|
|
|
assert env.macros["twice"](4) == 8
|
|
assert env.filters["rev"]("abc") == "cba"
|
|
|
|
|
|
@pytest.mark.parametrize("on_error_fail", [False, True])
|
|
@pytest.mark.parametrize("navigation_title", [None, "Short"])
|
|
def test_title_errors_respect_on_error_fail(
|
|
on_error_fail: bool, navigation_title: str | None
|
|
) -> None:
|
|
page = Page(
|
|
url="/",
|
|
path="index.md",
|
|
title=navigation_title,
|
|
meta={"title": "{{ missing_variable }}"},
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(page=page, config={}),
|
|
MacrosExtension(
|
|
module_name="",
|
|
on_undefined="strict",
|
|
on_error_fail=on_error_fail,
|
|
),
|
|
]
|
|
)
|
|
|
|
# The body is valid; the error is raised only when the title is rendered.
|
|
if on_error_fail:
|
|
with pytest.raises(UndefinedError):
|
|
md.convert("Body")
|
|
else:
|
|
assert md.convert("Body") == "<p>Body</p>"
|
|
assert page.rendered_metadata_title is not None
|
|
assert "Macro Rendering Error" in page.rendered_metadata_title
|
|
assert "UndefinedError" in page.rendered_metadata_title
|
|
assert "index.md" in page.rendered_metadata_title
|
|
assert page.title == (navigation_title or page.rendered_metadata_title)
|
|
|
|
assert page.meta["title"] == "{{ missing_variable }}"
|
|
|
|
|
|
@pytest.mark.parametrize("title", ["{# omitted #}", "{{ '' }}"])
|
|
@pytest.mark.parametrize("navigation_title", [None, "Short"])
|
|
def test_titles_can_render_to_an_empty_string(
|
|
title: str, navigation_title: str | None
|
|
) -> None:
|
|
page = Page(
|
|
url="/", path="index.md", title=navigation_title, meta={"title": title}
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(page=page, config={}),
|
|
MacrosExtension(module_name="", on_error_fail=True),
|
|
]
|
|
)
|
|
|
|
md.convert("# Heading")
|
|
|
|
assert page.title == (navigation_title or "")
|
|
assert page.meta["title"] == title
|
|
assert page.rendered_metadata_title == ""
|
|
|
|
|
|
def test_metadata_title_uses_configured_delimiters() -> None:
|
|
page = Page(
|
|
url="/",
|
|
path="index.md",
|
|
title="Short",
|
|
meta={"title": "Metadata [[ 2 + 3 ]]"},
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(page=page, config={}),
|
|
MacrosExtension(
|
|
module_name="",
|
|
j2_variable_start_string="[[",
|
|
j2_variable_end_string="]]",
|
|
on_error_fail=True,
|
|
),
|
|
]
|
|
)
|
|
|
|
md.convert("Body")
|
|
|
|
# The configured delimiters are also applied
|
|
# to metadata with a separate navigation label.
|
|
assert page.title == "Short"
|
|
assert page.meta["title"] == "Metadata [[ 2 + 3 ]]"
|
|
assert page.rendered_metadata_title == "Metadata 5"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
{
|
|
"config": {
|
|
"extra": {"shared": "extra", "nested": {"value": "original"}},
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"include_yaml": ["vars.yaml"],
|
|
"on_error_fail": True,
|
|
},
|
|
},
|
|
},
|
|
"page": {"title": "Current page", "meta": {"shared": "metadata"}},
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
class TestMacroContext:
|
|
def test_context_is_available_during_registration_and_rendering(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
# Configuration and YAML values are read while the module is registered.
|
|
(tmp_path / "vars.yaml").write_text(
|
|
"included: yaml\n", encoding="utf-8"
|
|
)
|
|
(tmp_path / "main.py").write_text(
|
|
"from pathlib import Path\n"
|
|
"def define_env(env):\n"
|
|
" env.variables.registered = env.variables.shared + '/' "
|
|
"+ env.variables.included\n"
|
|
" env.variables.shared = 'module'\n"
|
|
" @env.macro\n"
|
|
" def describe():\n"
|
|
" assert Path(env.project_dir)"
|
|
".samefile(env.conf['root_dir'])\n"
|
|
" assert env.config['on_error_fail'] is True\n"
|
|
" assert env.variables.config['site_name'] == "
|
|
"env.conf['site_name']\n"
|
|
" assert env.variables.page.title == env.page.title\n"
|
|
" return env.page.title + '/' + env.variables.shared\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
html = soup(
|
|
md.convert("{{ registered }}\n\n{{ describe() }}\n\n{{ shared }}")
|
|
)
|
|
|
|
# Metadata overrides are applied to templates,
|
|
# while module variables are retained.
|
|
assert [p.get_text() for p in html.select("p")] == [
|
|
"extra/yaml",
|
|
"Current page/module",
|
|
"metadata",
|
|
]
|
|
|
|
def test_nested_variable_changes_are_local_to_the_page(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text("{}\n", encoding="utf-8")
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" env.variables.previous = env.variables['nested']['value']\n"
|
|
" env.variables.nested['value'] = 'changed'\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# A new environment is created for each conversion,
|
|
# without changing config.extra.
|
|
first = soup(
|
|
md.convert("{{ previous }} / {{ nested.value }}")
|
|
).get_text()
|
|
second = soup(
|
|
md.convert("{{ previous }} / {{ nested.value }}")
|
|
).get_text()
|
|
|
|
assert first == "original / changed"
|
|
assert second == "original / changed"
|
|
|
|
def test_configured_nested_variables_are_available_through_attributes(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text(
|
|
"included: yaml\n", encoding="utf-8"
|
|
)
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" @env.macro\n"
|
|
" def describe():\n"
|
|
" return env.variables.nested.value + '/' "
|
|
"+ env.variables.included\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
html = soup(md.convert("{{ describe() }}"))
|
|
|
|
assert html.get_text() == "original/yaml"
|
|
|
|
def test_template_page_is_a_shallow_copy(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text("{}\n", encoding="utf-8")
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" @env.macro\n"
|
|
" def update_page():\n"
|
|
" env.page.title = 'Macro title'\n"
|
|
" env.page.meta['note'] = 'shared'\n"
|
|
" return env.page.title\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
html = soup(
|
|
md.convert(
|
|
"{{ update_page() }} / {{ page.title }} / {{ page.meta.note }}"
|
|
)
|
|
)
|
|
|
|
# A direct attribute change is kept local,
|
|
# while the metadata dictionary is shared.
|
|
assert html.get_text() == "Macro title / Current page / shared"
|
|
|
|
def test_title_pass_respects_render_macros_changes(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text("{}\n", encoding="utf-8")
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" @env.macro\n"
|
|
" def stop_rendering():\n"
|
|
" env.page.meta['render_macros'] = False\n"
|
|
" return 'Body'\n",
|
|
encoding="utf-8",
|
|
)
|
|
context = ContextPreprocessor.from_markdown(md)
|
|
assert context is not None
|
|
context.page.meta = context.page.meta.copy()
|
|
context.page.title = "{{ missing_variable }}"
|
|
context.page.meta["title"] = "Metadata {{ missing_variable }}"
|
|
|
|
html = soup(md.convert("{{ stop_rendering() }}"))
|
|
|
|
# Rendering is disabled by the body before the title pass is reached.
|
|
assert html.get_text() == "Body"
|
|
assert context.page.title == "{{ missing_variable }}"
|
|
assert (
|
|
context.page.rendered_metadata_title
|
|
== "Metadata {{ missing_variable }}"
|
|
)
|
|
|
|
def test_nested_mutations_remain_visible_to_templates(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text("{}\n", encoding="utf-8")
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" state = {'value': 'initial', 'alias': 'initial'}\n"
|
|
" env.variables.state = state\n"
|
|
" @env.macro\n"
|
|
" def update():\n"
|
|
" env.variables.state['value'] = 'changed'\n"
|
|
" state['alias'] = 'changed'\n"
|
|
" return ''\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Both references are changed after the Jinja context has been created.
|
|
html = soup(
|
|
md.convert("{{ update() }}{{ state.value }} / {{ state.alias }}")
|
|
)
|
|
|
|
assert html.get_text() == "changed / changed"
|
|
|
|
def test_template_config_changes_do_not_mutate_project_config(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text("{}\n", encoding="utf-8")
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" env.variables.config['site_name'] = 'Template name'\n"
|
|
" @env.macro\n"
|
|
" def site_name():\n"
|
|
" return env.conf['site_name']\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
html = soup(md.convert("{{ config.site_name }} / {{ site_name() }}"))
|
|
|
|
assert html.get_text() == "Template name / Demo"
|
|
|
|
def test_page_context_includes_the_effective_title(
|
|
self, md: Markdown
|
|
) -> None:
|
|
# The computed property is included in the built-in context diagnostic.
|
|
html = md.convert("{{ context(page) | pretty }}")
|
|
|
|
assert "Current page" in html
|
|
assert "<strong>title</strong>" in html
|
|
assert "_fallback_title" not in html
|
|
assert "_title" not in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loading YAML
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoadYAML:
|
|
@pytest.mark.parametrize(
|
|
("relative_path", "content", "expected"),
|
|
[
|
|
pytest.param(
|
|
"ok.yaml", "a: 1\nb: x\n", {"a": 1, "b": "x"}, id="valid_dict"
|
|
),
|
|
pytest.param(
|
|
"not_dict.yaml", "- 1\n- 2\n", None, id="non_dict_returns_none"
|
|
),
|
|
],
|
|
)
|
|
def test_with_relative_paths(
|
|
self,
|
|
tmp_path: Path,
|
|
relative_path: str,
|
|
content: str,
|
|
expected: dict | None,
|
|
) -> None:
|
|
(tmp_path / relative_path).write_text(content, encoding="utf-8")
|
|
loaded = _load_one_yaml(relative_path, tmp_path)
|
|
assert loaded == expected
|
|
|
|
def test_blocks_outside_project_root(self, tmp_path: Path) -> None:
|
|
outside = tmp_path.parent / "outside.yaml"
|
|
outside.write_text("x: 1\n", encoding="utf-8")
|
|
loaded = _load_one_yaml(str(outside), tmp_path)
|
|
assert loaded is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"include_yaml",
|
|
[
|
|
pytest.param(["a.yaml", "b.yaml"], id="list"),
|
|
pytest.param({"left": "a.yaml", "right": "b.yaml"}, id="dict"),
|
|
],
|
|
)
|
|
def test_merge_include(
|
|
self,
|
|
tmp_path: Path,
|
|
include_yaml: list[str] | dict[str, str],
|
|
) -> None:
|
|
(tmp_path / "a.yaml").write_text("x: 1\n", encoding="utf-8")
|
|
(tmp_path / "b.yaml").write_text("y: 2\n", encoding="utf-8")
|
|
variables: dict = {}
|
|
_merge_include_yaml(include_yaml, tmp_path, variables)
|
|
if isinstance(include_yaml, list):
|
|
assert variables == {"x": 1, "y": 2}
|
|
else:
|
|
assert variables == {"left": {"x": 1}, "right": {"y": 2}}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loading modules / pluglets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoadModule:
|
|
def test_from_local_file(self, tmp_path: Path) -> None:
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" env.variables['site_name'] = 'Demo'\n"
|
|
" @env.macro\n"
|
|
" def twice(x):\n"
|
|
" return x * 2\n"
|
|
" @env.filter\n"
|
|
" def shout(s):\n"
|
|
" return s.upper()\n",
|
|
encoding="utf-8",
|
|
)
|
|
env = MacroEnv(conf={})
|
|
_load_module(env, "main", tmp_path)
|
|
assert env.variables["site_name"] == "Demo"
|
|
assert env.macros["twice"](3) == 6
|
|
assert env.filters["shout"]("hi") == "HI"
|
|
|
|
@pytest.mark.parametrize(
|
|
("module_name", "file_rel_path"),
|
|
[
|
|
pytest.param("../evil", "../evil.py", id="path_traversal"),
|
|
pytest.param("foo/bar", "foo/bar.py", id="forward_slash"),
|
|
pytest.param("foo\\\\bar", "foo\\\\bar.py", id="backslash"),
|
|
],
|
|
)
|
|
def test_rejects_invalid_names(
|
|
self,
|
|
module_name: str,
|
|
file_rel_path: str,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
define_env_source = (
|
|
"def define_env(env):\n"
|
|
" env.variables['pwned'] = True\n"
|
|
" env.macros['evil'] = lambda: None\n"
|
|
" env.filters['bad'] = lambda x: x\n"
|
|
)
|
|
target = tmp_path / file_rel_path
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(define_env_source, encoding="utf-8")
|
|
monkeypatch.chdir(tmp_path)
|
|
env = MacroEnv(conf={})
|
|
_load_module(env, module_name)
|
|
assert env.variables == {}
|
|
assert env.macros == {}
|
|
assert env.filters == {}
|
|
|
|
|
|
@pytest.mark.parametrize("module_name", ["", "main"])
|
|
def test_installed_modules_are_loaded_in_order_before_the_local_module(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, module_name: str
|
|
) -> None:
|
|
monkeypatch.syspath_prepend(str(tmp_path))
|
|
modules = [f"macros_first_{module_name}", f"macros_second_{module_name}"]
|
|
|
|
# The same variable, macro, and filter are registered by all three modules.
|
|
for name, label in [
|
|
*zip(modules, ["first", "second"], strict=True),
|
|
("main", "local"),
|
|
]:
|
|
(tmp_path / f"{name}.py").write_text(
|
|
dedent(f"""\
|
|
def define_env(env):
|
|
env.variables.setdefault('load_order', []).append('{label}')
|
|
env.variables.winner = '{label}'
|
|
env.macro(lambda: '{label}', 'winner_macro')
|
|
env.filter(lambda value: '{label}', 'winner_filter')
|
|
"""),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path)},
|
|
),
|
|
MacrosExtension(
|
|
modules=modules, module_name=module_name, on_error_fail=True
|
|
),
|
|
]
|
|
)
|
|
html = soup(
|
|
md.convert(
|
|
'{{ load_order | join(",") }} / {{ winner }} / '
|
|
'{{ winner_macro() }} / {{ "" | winner_filter }}'
|
|
)
|
|
)
|
|
|
|
if module_name:
|
|
assert html.get_text() == "first,second,local / local / local / local"
|
|
else:
|
|
assert html.get_text() == "first,second / second / second / second"
|
|
|
|
|
|
@pytest.mark.parametrize("on_error_fail", [False, True])
|
|
@pytest.mark.parametrize(
|
|
("module_source", "error_type", "message"),
|
|
[
|
|
(None, ModuleNotFoundError, "unavailable_macros_pluglet"),
|
|
(
|
|
"import unavailable_macros_dependency\n",
|
|
ModuleNotFoundError,
|
|
"unavailable_macros_dependency",
|
|
),
|
|
(
|
|
"raise ImportError('broken macros import')\n",
|
|
ImportError,
|
|
"broken macros import",
|
|
),
|
|
],
|
|
)
|
|
def test_installed_module_import_errors_are_reported(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
module_source: str | None,
|
|
error_type: type[Exception],
|
|
message: str,
|
|
on_error_fail: bool,
|
|
) -> None:
|
|
monkeypatch.syspath_prepend(str(tmp_path))
|
|
if module_source is not None:
|
|
(tmp_path / "unavailable_macros_pluglet.py").write_text(
|
|
module_source, encoding="utf-8"
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
MacrosExtension(
|
|
module_name="",
|
|
modules=["unavailable_macros_pluglet"],
|
|
on_error_fail=on_error_fail,
|
|
)
|
|
]
|
|
)
|
|
|
|
# Module imports are configuration errors,
|
|
# regardless of the rendering policy.
|
|
with pytest.raises(error_type, match=message):
|
|
md.convert("Body")
|
|
|
|
|
|
def test_missing_default_local_module_is_optional(tmp_path: Path) -> None:
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path)},
|
|
),
|
|
MacrosExtension(on_error_fail=True),
|
|
]
|
|
)
|
|
|
|
assert md.convert("{{ 2 + 3 }}") == "<p>5</p>"
|
|
|
|
|
|
def test_default_local_module_dependency_errors_are_reported(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
(tmp_path / "main.py").write_text(
|
|
"import unavailable_local_macros_dependency\n", encoding="utf-8"
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path)},
|
|
),
|
|
MacrosExtension(),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(
|
|
ModuleNotFoundError, match="unavailable_local_macros_dependency"
|
|
):
|
|
md.convert("Body")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"config",
|
|
"environment",
|
|
"files",
|
|
"filters",
|
|
"filters_builtin",
|
|
"git",
|
|
"macros",
|
|
"navigation",
|
|
"page",
|
|
"plugin",
|
|
"context",
|
|
"now",
|
|
"macros_info",
|
|
"read_csv",
|
|
"range",
|
|
"custom_macro",
|
|
],
|
|
)
|
|
def test_metadata_overrides_variables_and_macros_in_body_and_title(
|
|
tmp_path: Path, name: str
|
|
) -> None:
|
|
(tmp_path / "main.py").write_text(
|
|
f"def define_env(env):\n env.variables[{name!r}] = 'module'\n "
|
|
f"env.macro(lambda: 'macro', {name!r})\n",
|
|
encoding="utf-8",
|
|
)
|
|
template = "{{ " + name + " }}"
|
|
page = Page(
|
|
url="/", path="index.md", meta={name: "metadata", "title": template}
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=page,
|
|
config={"root_dir": str(tmp_path), "extra": {name: "extra"}},
|
|
),
|
|
MacrosExtension(on_error_fail=True),
|
|
]
|
|
)
|
|
|
|
html = md.convert(template)
|
|
|
|
# Metadata overrides are applied during both rendering passes.
|
|
assert html == "<p>metadata</p>"
|
|
assert page.title == "metadata"
|
|
assert page.rendered_metadata_title == "metadata"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
["context", "now", "macros_info", "read_csv", "range", "custom_macro"],
|
|
)
|
|
@pytest.mark.parametrize("source", ["yaml", "extra", "module"])
|
|
def test_variables_override_registered_macros(
|
|
tmp_path: Path, name: str, source: str
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text(f"{name}: yaml\n", encoding="utf-8")
|
|
module_source = (
|
|
f"def define_env(env):\n env.macro(lambda: 'macro', {name!r})\n"
|
|
)
|
|
if source == "module":
|
|
module_source += f" env.variables[{name!r}] = 'module'\n"
|
|
(tmp_path / "main.py").write_text(module_source, encoding="utf-8")
|
|
extra = {name: "extra"} if source in {"extra", "module"} else {}
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path), "extra": extra},
|
|
),
|
|
MacrosExtension(include_yaml=["vars.yaml"], on_error_fail=True),
|
|
]
|
|
)
|
|
|
|
html = md.convert("{{ " + name + " }}")
|
|
|
|
# YAML, extra, and module variables are each given precedence
|
|
# over macro globals.
|
|
assert html == f"<p>{source}</p>"
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["config", "environment", "git", "plugin"])
|
|
@pytest.mark.parametrize("source", ["extra", "module"])
|
|
def test_variables_override_standard_context(
|
|
tmp_path: Path, name: str, source: str
|
|
) -> None:
|
|
(tmp_path / "main.py").write_text(
|
|
f"def define_env(env):\n env.variables[{name!r}] = 'module'\n",
|
|
encoding="utf-8",
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path), "extra": {name: "extra"}},
|
|
),
|
|
MacrosExtension(
|
|
module_name="main" if source == "module" else "",
|
|
on_error_fail=True,
|
|
),
|
|
]
|
|
)
|
|
|
|
html = md.convert("{{ " + name + " }}")
|
|
|
|
assert html == f"<p>{source}</p>"
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["context", "now", "macros_info", "read_csv"])
|
|
def test_registered_macros_override_standard_macros(
|
|
tmp_path: Path, name: str
|
|
) -> None:
|
|
(tmp_path / "main.py").write_text(
|
|
f"def define_env(env):\n env.macro(lambda: 'custom', {name!r})\n",
|
|
encoding="utf-8",
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path)},
|
|
),
|
|
MacrosExtension(on_error_fail=True),
|
|
]
|
|
)
|
|
|
|
html = md.convert("{{ " + name + "() }}")
|
|
|
|
assert html == "<p>custom</p>"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown preprocessor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPreprocessor:
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": False,
|
|
"force_render_paths": (
|
|
"# Selected pages\n\n"
|
|
"guides/\n!guides/drafts/\nguides/drafts/keep.md\n"
|
|
"/only.md\nrender_*.md\n\\#literal.md\n\\!literal.md"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("path", "override", "rendered"),
|
|
[
|
|
("guides/index.md", None, True),
|
|
(os.path.join("guides", "nested", "page.md"), None, True),
|
|
("guides/café.md", None, True),
|
|
("guides/drafts/page.md", None, False),
|
|
("guides/drafts/keep.md", None, True),
|
|
("guides/disabled.md", False, False),
|
|
("guides/drafts/keep.md", False, False),
|
|
("outside.md", True, True),
|
|
("outside.md", None, False),
|
|
("guides.md", None, False),
|
|
("only.md", None, True),
|
|
("nested/only.md", None, False),
|
|
("nested/render_example.md", None, True),
|
|
("#literal.md", None, True),
|
|
("!literal.md", None, True),
|
|
],
|
|
)
|
|
def test_force_render_paths_respects_page_overrides(
|
|
self, md: Markdown, path: str, override: bool | None, rendered: bool
|
|
) -> None:
|
|
context = ContextPreprocessor.from_markdown(md)
|
|
assert context is not None
|
|
context.page.path = path
|
|
if override is not None:
|
|
context.page.meta["render_macros"] = override
|
|
|
|
html = soup(md.convert("Value: {{ 1 + 1 }}"))
|
|
assert html.get_text() == (
|
|
"Value: 2" if rendered else "Value: {{ 1 + 1 }}"
|
|
)
|
|
|
|
def test_force_render_paths_does_not_require_page_context(self) -> None:
|
|
md = Markdown(
|
|
extensions=[
|
|
MacrosExtension(
|
|
render_by_default=False, force_render_paths="**"
|
|
)
|
|
]
|
|
)
|
|
assert md.convert("Value: {{ 1 + 1 }}") == "<p>Value: {{ 1 + 1 }}</p>"
|
|
|
|
@pytest.mark.parametrize("verbose", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {"on_error_fail": True}
|
|
}
|
|
}
|
|
}
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_verbose_traces_module_loading_and_rendering(
|
|
self,
|
|
md: Markdown,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
verbose: bool,
|
|
) -> None:
|
|
md.preprocessors["macros"].config.verbose = verbose
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
' chatter = env.start_chatting("Example")\n'
|
|
' chatter("Registered macros")\n'
|
|
" @env.macro\n"
|
|
" def greet(name):\n"
|
|
' chatter("Greeting:", name)\n'
|
|
' return "Hello " + name\n',
|
|
encoding="utf-8",
|
|
)
|
|
with caplog.at_level(logging.INFO, logger="zensical.extensions.macros"):
|
|
html = md.convert('{{ greet("World") }}')
|
|
assert "Hello World" in html
|
|
if verbose:
|
|
assert (
|
|
"[macros - render] - Rendering page: index.md"
|
|
in caplog.messages
|
|
)
|
|
assert any(
|
|
"Loading local module:" in line for line in caplog.messages
|
|
)
|
|
assert "[macros - Example] - Registered macros" in caplog.messages
|
|
assert "[macros - Example] - Greeting: World" in caplog.messages
|
|
else:
|
|
assert caplog.messages == []
|
|
|
|
@pytest.mark.parametrize(
|
|
("md", "expected_text"),
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": False
|
|
},
|
|
},
|
|
}
|
|
},
|
|
"Value: {{ 1 + 1 }}",
|
|
id="disabled_keeps_template",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True,
|
|
"force_render_paths": "!**",
|
|
},
|
|
},
|
|
}
|
|
},
|
|
"Value: 2",
|
|
id="enabled_renders",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_respects_render_by_default(
|
|
self, md: Markdown, expected_text: str
|
|
) -> None:
|
|
html = soup(md.convert("Value: {{ 1 + 1 }}"))
|
|
p = html.select_one("p")
|
|
assert p is not None
|
|
assert p.get_text() == expected_text
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": False
|
|
},
|
|
},
|
|
},
|
|
"page": {"meta": {"render_macros": True}},
|
|
},
|
|
id="page_opt_in",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_renders_when_opted_in_by_page_meta(self, md: Markdown) -> None:
|
|
html = soup(md.convert("Value: {{ 1 + 1 }}"))
|
|
p = html.select_one("p")
|
|
assert p is not None
|
|
assert p.get_text() == "Value: 2"
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True,
|
|
"include_yaml": ["vars.yaml"],
|
|
"module_name": "main",
|
|
},
|
|
},
|
|
"extra/who": "world",
|
|
},
|
|
"page": {"meta": {"render_macros": True}},
|
|
},
|
|
id="include_yaml_and_module",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_renders_with_include_yaml_and_module(
|
|
self,
|
|
md: Markdown,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
(tmp_path / "vars.yaml").write_text("name: Ada\n", encoding="utf-8")
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" @env.macro\n"
|
|
" def greet(name):\n"
|
|
" return f'Hello {name}!'\n",
|
|
encoding="utf-8",
|
|
)
|
|
html = soup(md.convert("{{ greet(name) }}\n\n{{ who }}"))
|
|
text = html.get_text()
|
|
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",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True
|
|
},
|
|
},
|
|
}
|
|
},
|
|
id="render_by_default",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_syntax_errors_are_displayed_by_default(self, md: Markdown) -> None:
|
|
html = soup(md.convert("Before\n\n{{ not_closed"))
|
|
|
|
assert html.h1 is not None
|
|
assert "Macro Syntax Error" in html.h1.get_text()
|
|
assert "index.md" in html.get_text()
|
|
assert "Line 3" in html.get_text()
|
|
assert "unexpected end of template" in html.get_text()
|
|
code = html.select_one("pre")
|
|
assert code is not None
|
|
assert code.get_text().strip() == "{{ not_closed"
|
|
assert "Before" not in html.get_text()
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True,
|
|
"on_error_fail": True,
|
|
},
|
|
},
|
|
}
|
|
},
|
|
id="on_error_fail",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_error_handling_raises_when_enabled(self, md: Markdown) -> None:
|
|
with pytest.raises(TemplateSyntaxError):
|
|
md.convert("{{ not_closed")
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True
|
|
},
|
|
},
|
|
},
|
|
"page": {
|
|
"meta": {
|
|
"render_macros": True,
|
|
"title": "Doc {{ 2 + 3 }}",
|
|
},
|
|
},
|
|
},
|
|
id="jinja_in_title",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_renders_title_after_body_without_changing_metadata(
|
|
self, md: Markdown
|
|
) -> None:
|
|
html = soup(md.convert("# {{ title }}"))
|
|
context = ContextPreprocessor.from_markdown(md)
|
|
assert context is not None
|
|
|
|
assert html.h1 is not None
|
|
assert html.h1.get_text().startswith("Doc {{ 2 + 3 }}")
|
|
assert context.page.title == "Doc 5"
|
|
assert context.page.meta["title"] == "Doc {{ 2 + 3 }}"
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True,
|
|
"on_undefined": "strict",
|
|
"on_error_fail": True,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
id="strict_on_error_fail",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_strict_undefined_raises(self, md: Markdown) -> None:
|
|
with pytest.raises(UndefinedError):
|
|
md.convert("{{ missing_variable }}")
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True,
|
|
"module_name": "main",
|
|
},
|
|
"pymdownx.superfences": {},
|
|
},
|
|
},
|
|
},
|
|
id="superfences",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_fenced_code_block_processed_by_superfences(
|
|
self,
|
|
md: Markdown,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
(tmp_path / "main.py").write_text(
|
|
"def define_env(env):\n"
|
|
" @env.macro\n"
|
|
" def code_snippet(lang, code):\n"
|
|
" return f'```{lang}\\n{code}\\n```\\n'\n",
|
|
encoding="utf-8",
|
|
)
|
|
html = soup(md.convert("{{ code_snippet('python', 'x = 1 + 1') }}"))
|
|
code = html.select_one("code")
|
|
assert code is not None
|
|
# Language in class attr, not text
|
|
assert "python" not in code.get_text()
|
|
# Source broken into syntax-highlighted spans
|
|
assert code.select("span")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mode", "expected"),
|
|
[
|
|
("keep", "Before {{ missing }} after"),
|
|
("silent", "Before after"),
|
|
("strict", None),
|
|
("lax", "Before after"),
|
|
],
|
|
)
|
|
def test_undefined_variable_modes(mode: str, expected: str | None) -> None:
|
|
md = Markdown(
|
|
extensions=[
|
|
MacrosExtension(
|
|
module_name="", on_undefined=mode, on_error_fail=True
|
|
)
|
|
]
|
|
)
|
|
|
|
if expected is None:
|
|
with pytest.raises(UndefinedError, match="missing"):
|
|
md.convert("Before {{ missing }} after")
|
|
else:
|
|
assert md.convert("Before {{ missing }} after") == f"<p>{expected}</p>"
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["keep", "silent", "strict", "lax"])
|
|
@pytest.mark.parametrize(
|
|
"expression",
|
|
["missing.attribute", "missing['item']", "missing()", "missing + 1"],
|
|
)
|
|
def test_undefined_operations_follow_mkdocs_macros_behavior(
|
|
mode: str, expression: str
|
|
) -> None:
|
|
md = Markdown(
|
|
extensions=[
|
|
MacrosExtension(
|
|
module_name="", on_undefined=mode, on_error_fail=True
|
|
)
|
|
]
|
|
)
|
|
template = "Before {{ " + expression + " }} after"
|
|
|
|
# Attribute access is tolerated in lax mode; calls, indexing,
|
|
# and arithmetic still fail.
|
|
if mode == "lax" and expression == "missing.attribute":
|
|
assert md.convert(template) == "<p>Before after</p>"
|
|
else:
|
|
with pytest.raises(UndefinedError, match="missing"):
|
|
md.convert(template)
|
|
|
|
|
|
def test_undefined_variables_are_kept_by_default() -> None:
|
|
md = Markdown(
|
|
extensions=[MacrosExtension(module_name="", on_error_fail=True)]
|
|
)
|
|
|
|
assert md.convert("{{ missing }}") == "<p>{{ missing }}</p>"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "error_type", "message"),
|
|
[
|
|
(
|
|
"Before {{ missing }} after",
|
|
"UndefinedError",
|
|
"'missing' is undefined",
|
|
),
|
|
("Before {{ 1 / 0 }} after", "ZeroDivisionError", "division by zero"),
|
|
],
|
|
)
|
|
def test_rendering_errors_are_displayed_with_a_traceback(
|
|
source: str, error_type: str, message: str, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/guide/", path="guide.md"), config={}
|
|
),
|
|
MacrosExtension(module_name="", on_undefined="strict"),
|
|
]
|
|
)
|
|
|
|
html = soup(md.convert(source))
|
|
|
|
assert html.h1 is not None
|
|
assert html.h1.get_text() == "Macro Rendering Error"
|
|
assert "guide.md" in html.get_text()
|
|
assert error_type in html.get_text()
|
|
assert message in html.get_text()
|
|
assert "Before" not in html.get_text()
|
|
assert message in caplog.text
|
|
|
|
traceback_code = html.select_one("pre code")
|
|
assert traceback_code is not None
|
|
assert "Traceback (most recent call last)" in traceback_code.get_text()
|
|
|
|
|
|
def test_macro_exceptions_are_displayed_as_text(tmp_path: Path) -> None:
|
|
(tmp_path / "main.py").write_text(
|
|
dedent("""\
|
|
def define_env(env):
|
|
@env.macro
|
|
def fail():
|
|
raise ValueError('<script>broken macro</script>')
|
|
"""),
|
|
encoding="utf-8",
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path)},
|
|
),
|
|
MacrosExtension(),
|
|
]
|
|
)
|
|
|
|
html = soup(md.convert("{{ fail() }}"))
|
|
|
|
assert "Macro Rendering Error" in html.get_text()
|
|
assert "ValueError" in html.get_text()
|
|
assert "<script>broken macro</script>" in html.get_text()
|
|
assert html.select("script") == []
|
|
|
|
|
|
@pytest.mark.parametrize("docs_path", ["default", "relative", "absolute"])
|
|
@pytest.mark.parametrize("include_dir", ["", "snippets"])
|
|
def test_includes_and_imports_default_to_docs_dir(
|
|
tmp_path: Path, docs_path: str, include_dir: str
|
|
) -> None:
|
|
docs_dir = tmp_path / ("docs" if docs_path == "default" else "content")
|
|
docs_dir.mkdir()
|
|
include_root = tmp_path / include_dir if include_dir else docs_dir
|
|
(include_root / "nested").mkdir(parents=True)
|
|
(include_root / "nested" / "fragment.md").write_text(
|
|
"Included {{ who }}", encoding="utf-8"
|
|
)
|
|
(include_root / "helpers.j2").write_text(
|
|
"{% macro greet(name) %}Hello {{ name }}{% endmacro %}",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# A conflicting template in `docs_dir` is ignored
|
|
# when `include_dir` is configured.
|
|
if include_dir:
|
|
(docs_dir / "helpers.j2").write_text("Wrong template", encoding="utf-8")
|
|
config = {"root_dir": str(tmp_path), "extra": {"who": "Ada"}}
|
|
if docs_path != "default":
|
|
config["docs_dir"] = (
|
|
str(docs_dir) if docs_path == "absolute" else "content"
|
|
)
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"), config=config
|
|
),
|
|
MacrosExtension(
|
|
module_name="", include_dir=include_dir, on_error_fail=True
|
|
),
|
|
]
|
|
)
|
|
|
|
html = soup(
|
|
md.convert(
|
|
'{% include "nested/fragment.md" %}\n\n'
|
|
'{% import "helpers.j2" as helpers %}{{ helpers.greet(who) }}'
|
|
)
|
|
)
|
|
|
|
assert [p.get_text() for p in html.select("p")] == [
|
|
"Included Ada",
|
|
"Hello Ada",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("include_dir", ["", "missing"])
|
|
def test_missing_include_directory_reports_template_not_found(
|
|
tmp_path: Path, include_dir: str
|
|
) -> None:
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path)},
|
|
),
|
|
MacrosExtension(
|
|
module_name="", include_dir=include_dir, on_error_fail=True
|
|
),
|
|
]
|
|
)
|
|
|
|
with pytest.raises(TemplateNotFound, match=r"missing\.md"):
|
|
md.convert('{% include "missing.md" %}')
|
|
|
|
|
|
def test_syntax_errors_in_included_templates_report_the_correct_source(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
(tmp_path / "docs").mkdir()
|
|
fragment = tmp_path / "docs" / "invalid.md"
|
|
fragment.write_text("First\nSecond\nThird\n{% if %}", encoding="utf-8")
|
|
md = Markdown(
|
|
extensions=[
|
|
ContextExtension(
|
|
page=Page(url="/", path="index.md"),
|
|
config={"root_dir": str(tmp_path)},
|
|
),
|
|
MacrosExtension(module_name=""),
|
|
]
|
|
)
|
|
|
|
# The error line is beyond the length of the page
|
|
# that includes this template.
|
|
html = soup(md.convert('{% include "invalid.md" %}'))
|
|
|
|
assert "Macro Syntax Error" in html.get_text()
|
|
assert str(fragment) in html.get_text()
|
|
assert "Line 4" in html.get_text()
|
|
code = html.select_one("pre code")
|
|
assert code is not None
|
|
assert code.get_text().strip() == "{% if %}"
|
|
|
|
|
|
@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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Unit tests for the table conversion helper functions.
|
|
class TestTableHelpers:
|
|
def test_add_indentation_spaces(self) -> None:
|
|
result = _add_indentation("line1\nline2", spaces=4)
|
|
assert result == " line1\n line2"
|
|
|
|
def test_add_indentation_tabs(self) -> None:
|
|
result = _add_indentation("line1\nline2", tabs=2)
|
|
assert result == "\t\tline1\n\t\tline2"
|
|
|
|
def test_add_indentation_none_returns_unchanged(self) -> None:
|
|
assert _add_indentation("hello") == "hello"
|
|
|
|
def test_add_indentation_raises_when_both_specified(self) -> None:
|
|
with pytest.raises(ValueError, match="spaces or tabs"):
|
|
_add_indentation("x", spaces=2, tabs=1)
|
|
|
|
def test_convert_to_md_table_basic(self) -> None:
|
|
df: DataFrame = pandas.DataFrame(
|
|
{"Name": ["Alice", "Bob"], "Age": [30, 25]}
|
|
)
|
|
result = _convert_to_md_table(df)
|
|
assert "|" in result
|
|
assert "Name" in result
|
|
assert "Age" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_convert_to_md_table_escapes_pipes_in_cells(self) -> None:
|
|
df: DataFrame = pandas.DataFrame({"Col": ["a|b", "c"]})
|
|
result = _convert_to_md_table(df)
|
|
assert r"a\|b" in result
|
|
|
|
def test_convert_to_md_table_escapes_pipes_in_column_names(self) -> None:
|
|
df: DataFrame = pandas.DataFrame({"Na|me": ["Alice"]})
|
|
result = _convert_to_md_table(df)
|
|
assert r"Na\|me" in result
|
|
|
|
def test_convert_to_md_table_omits_index_by_default(self) -> None:
|
|
# Custom index values must not leak into the output.
|
|
# Verifies that the `index=False` default is applied.
|
|
df: DataFrame = pandas.DataFrame(
|
|
{"X": [1, 2]}, index=pandas.Index([100, 200])
|
|
)
|
|
result = _convert_to_md_table(df)
|
|
assert "100" not in result
|
|
assert "200" not in result
|
|
|
|
def test_convert_to_md_table_requires_string_output(self) -> None:
|
|
df: DataFrame = pandas.DataFrame({"X": [1, 2]})
|
|
with pytest.raises(ValueError, match="produced no output"):
|
|
_convert_to_md_table(df, buf=StringIO())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Table readers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTableReaders:
|
|
def test_fake_readers_raise_runtime_error_when_pandas_missing(
|
|
self,
|
|
) -> None:
|
|
readers = _get_fake_table_readers()
|
|
for reader in readers.values():
|
|
with pytest.raises(RuntimeError, match="table reading requires"):
|
|
reader("irrelevant.csv")
|
|
|
|
# CSV
|
|
def test_read_csv(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.csv").write_text(
|
|
"Name,Age\nAlice,30\nBob,25\n", encoding="utf-8"
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
result = readers["read_csv"]("data.csv")
|
|
assert "Name" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_pd_read_csv_returns_dataframe(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.csv").write_text("X,Y\n1,2\n3,4\n", encoding="utf-8")
|
|
readers = _get_table_readers(tmp_path)
|
|
df: DataFrame = readers["pd_read_csv"]("data.csv")
|
|
assert list(df.columns) == ["X", "Y"]
|
|
assert len(df) == 2
|
|
|
|
# JSON
|
|
def test_read_json(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.json").write_text(
|
|
'[{"Name": "Alice", "Age": 30}, {"Name": "Bob", "Age": 25}]',
|
|
encoding="utf-8",
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
result = readers["read_json"]("data.json")
|
|
assert "Name" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_pd_read_json_returns_dataframe(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.json").write_text(
|
|
'[{"X": 1, "Y": 2}, {"X": 3, "Y": 4}]', encoding="utf-8"
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
df: DataFrame = readers["pd_read_json"]("data.json")
|
|
assert list(df.columns) == ["X", "Y"]
|
|
assert len(df) == 2
|
|
|
|
# YAML
|
|
def test_read_yaml(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.yaml").write_text(
|
|
"- Name: Alice\n Age: 30\n- Name: Bob\n Age: 25\n",
|
|
encoding="utf-8",
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
result = readers["read_yaml"]("data.yaml")
|
|
assert "Name" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_pd_read_yaml_returns_dataframe(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.yaml").write_text(
|
|
"- X: 1\n Y: 2\n- X: 3\n Y: 4\n", encoding="utf-8"
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
df: DataFrame = readers["pd_read_yaml"]("data.yaml")
|
|
assert list(df.columns) == ["X", "Y"]
|
|
assert len(df) == 2
|
|
|
|
# Table (tab-separated)
|
|
def test_read_table(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.tsv").write_text(
|
|
"Name\tAge\nAlice\t30\nBob\t25\n", encoding="utf-8"
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
result = readers["read_table"]("data.tsv")
|
|
assert "Name" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_pd_read_table_returns_dataframe(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.tsv").write_text(
|
|
"X\tY\n1\t2\n3\t4\n", encoding="utf-8"
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
df: DataFrame = readers["pd_read_table"]("data.tsv")
|
|
assert list(df.columns) == ["X", "Y"]
|
|
assert len(df) == 2
|
|
|
|
# FWF (fixed-width format)
|
|
def test_read_fwf(self, tmp_path: Path) -> None:
|
|
content = "Name Age\nAlice 30\nBob 25\n"
|
|
(tmp_path / "data.fwf").write_text(content, encoding="utf-8")
|
|
readers = _get_table_readers(tmp_path)
|
|
result = readers["read_fwf"]("data.fwf")
|
|
assert "Name" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_pd_read_fwf_returns_dataframe(self, tmp_path: Path) -> None:
|
|
(tmp_path / "data.fwf").write_text(
|
|
"X Y\n1 2\n3 4\n", encoding="utf-8"
|
|
)
|
|
readers = _get_table_readers(tmp_path)
|
|
df: DataFrame = readers["pd_read_fwf"]("data.fwf")
|
|
assert list(df.columns) == ["X", "Y"]
|
|
assert len(df) == 2
|
|
|
|
# Excel (.xlsx)
|
|
def test_read_excel(self, tmp_path: Path) -> None:
|
|
pytest.importorskip("openpyxl")
|
|
df: DataFrame = pandas.DataFrame(
|
|
{"Name": ["Alice", "Bob"], "Age": [30, 25]}
|
|
)
|
|
df.to_excel(tmp_path / "data.xlsx", index=False)
|
|
readers = _get_table_readers(tmp_path)
|
|
result = readers["read_excel"]("data.xlsx")
|
|
assert "Name" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_pd_read_excel_returns_dataframe(self, tmp_path: Path) -> None:
|
|
pytest.importorskip("openpyxl")
|
|
df_in: DataFrame = pandas.DataFrame({"X": [1, 3], "Y": [2, 4]})
|
|
df_in.to_excel(tmp_path / "data.xlsx", index=False)
|
|
readers = _get_table_readers(tmp_path)
|
|
df: DataFrame = readers["pd_read_excel"]("data.xlsx")
|
|
assert list(df.columns) == ["X", "Y"]
|
|
assert len(df) == 2
|
|
|
|
# Feather
|
|
def test_read_feather(self, tmp_path: Path) -> None:
|
|
pytest.importorskip("pyarrow")
|
|
df: DataFrame = pandas.DataFrame(
|
|
{"Name": ["Alice", "Bob"], "Age": [30, 25]}
|
|
)
|
|
df.to_feather(tmp_path / "data.feather")
|
|
readers = _get_table_readers(tmp_path)
|
|
result = readers["read_feather"]("data.feather")
|
|
assert "Name" in result
|
|
assert "Alice" in result
|
|
assert "Bob" in result
|
|
|
|
def test_pd_read_feather_returns_dataframe(self, tmp_path: Path) -> None:
|
|
pytest.importorskip("pyarrow")
|
|
df_in: DataFrame = pandas.DataFrame({"X": [1, 3], "Y": [2, 4]})
|
|
df_in.to_feather(tmp_path / "data.feather")
|
|
readers = _get_table_readers(tmp_path)
|
|
df: DataFrame = readers["pd_read_feather"]("data.feather")
|
|
assert list(df.columns) == ["X", "Y"]
|
|
assert len(df) == 2
|
|
|
|
# End-to-end: CSV rendered through the Jinja2 / Markdown pipeline
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True,
|
|
},
|
|
},
|
|
}
|
|
},
|
|
id="end_to_end_csv",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_end_to_end_csv_via_template(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "scores.csv").write_text(
|
|
"Player,Score\nAlice,100\nBob,80\n", encoding="utf-8"
|
|
)
|
|
html = soup(md.convert("{{ read_csv('scores.csv') }}"))
|
|
table = html.select_one("table")
|
|
assert table is not None
|
|
headers = [th.get_text(strip=True) for th in table.select("th")]
|
|
assert "Player" in headers
|
|
assert "Score" in headers
|
|
cells = [td.get_text(strip=True) for td in table.select("td")]
|
|
assert "Alice" in cells
|
|
|
|
@pytest.mark.parametrize(
|
|
"md",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"config": {
|
|
"markdown_extensions": {
|
|
"zensical.extensions.macros": {
|
|
"render_by_default": True,
|
|
},
|
|
},
|
|
}
|
|
},
|
|
id="end_to_end_pd_filter",
|
|
),
|
|
],
|
|
indirect=["md"],
|
|
)
|
|
def test_end_to_end_pd_read_csv_with_convert_filter(
|
|
self, md: Markdown, tmp_path: Path
|
|
) -> None:
|
|
(tmp_path / "nums.csv").write_text("X,Y\n1,2\n3,4\n", encoding="utf-8")
|
|
html = soup(
|
|
md.convert("{{ pd_read_csv('nums.csv') | convert_to_md_table }}")
|
|
)
|
|
table = html.select_one("table")
|
|
assert table is not None
|
|
headers = [th.get_text(strip=True) for th in table.select("th")]
|
|
assert "X" in headers
|
|
assert "Y" in headers
|