diff --git a/pyproject.toml b/pyproject.toml index c2c1e89..d3c699a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "deepmerge>=2.0", "jinja2>=3.1", "markdown>=3.7", + "pathspec>=1.0.4", "pygments>=2.20", "pymdown-extensions>=11.0", "pyyaml>=6.0.2", diff --git a/python/tests/integration/test_macros.py b/python/tests/integration/test_macros.py new file mode 100644 index 0000000..28ce6ec --- /dev/null +++ b/python/tests/integration/test_macros.py @@ -0,0 +1,176 @@ +# 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. + +"""Integration coverage for macros page selection and verbose output.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from typing import TYPE_CHECKING + +import pytest +import yaml + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + +def _write_config( + root: Path, options: dict[str, str | bool], *, toml: bool = False +) -> Path: + (root / "content").mkdir(exist_ok=True) + overrides = root / "overrides" + overrides.mkdir(exist_ok=True) + (overrides / "main.html").write_text("{{ page.content }}", encoding="utf-8") + if toml: + config = root / "zensical.toml" + content = ( + '[project]\nsite_name = "Macros"\ndocs_dir = "content"\n' + '[project.theme]\ncustom_dir = "overrides"\n' + "[project.plugins.macros]\n" + ) + content += "\n".join( + f"{name} = {json.dumps(value)}" for name, value in options.items() + ) + else: + config = root / "mkdocs.yml" + content = yaml.safe_dump( + { + "site_name": "Macros", + "docs_dir": "content", + "theme": {"custom_dir": "overrides"}, + "plugins": {"macros": options}, + } + ) + config.write_text(content, encoding="utf-8") + return config + + +@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) +def test_force_render_paths_uses_docs_paths_and_page_metadata( + tmp_path: Path, toml: bool +) -> None: + config = _write_config( + tmp_path, + { + "render_by_default": False, + "force_render_paths": ( + "/guides/\n!guides/drafts/\nguides/drafts/keep.md" + ), + }, + toml=toml, + ) + pages = { + "index.md": (None, False), + "guides/index.md": (None, True), + "guides/disabled.md": (False, False), + "guides/drafts/page.md": (None, False), + "guides/drafts/keep.md": (None, True), + "outside.md": (True, True), + "nested/guides/page.md": (None, False), + } + for path, (override, _) in pages.items(): + page = tmp_path / "content" / path + page.parent.mkdir(parents=True, exist_ok=True) + header = ( + "" + if override is None + else f"---\nrender_macros: {str(override).lower()}\n---\n" + ) + page.write_text(header + "Value: {{ 1 + 1 }}\n", encoding="utf-8") + + zensical.build(str(config), {"clean": True, "strict": False}) + + for path, (_, rendered) in pages.items(): + output = tmp_path / "site" / path.removesuffix(".md") + output = ( + output.with_suffix(".html") + if output.name == "index" + else output / "index.html" + ) + html = output.read_text(encoding="utf-8") + assert ("Value: 2" if rendered else "Value: {{ 1 + 1 }}") in html, path + + +def test_force_render_paths_changes_between_builds(tmp_path: Path) -> None: + for index, (pattern, rendered) in enumerate( + [("", False), ("/index.md", True), ("!index.md", False)] + ): + config = _write_config( + tmp_path, + { + "render_by_default": False, + "force_render_paths": pattern, + }, + ) + if index == 0: + (tmp_path / "content" / "index.md").write_text( + "Value: {{ 1 + 1 }}\n", encoding="utf-8" + ) + zensical.build(str(config), {"clean": index == 0, "strict": False}) + html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") + assert ("Value: 2" if rendered else "Value: {{ 1 + 1 }}") in html + + +def test_verbose_chatter_reaches_cli_and_can_be_disabled( + tmp_path: Path, +) -> None: + for index, verbose in enumerate([False, True, False]): + config = _write_config( + tmp_path, {"verbose": verbose, "on_error_fail": True} + ) + if index == 0: + (tmp_path / "content" / "index.md").write_text( + '{{ greet("World") }}\n', encoding="utf-8" + ) + (tmp_path / "main.py").write_text( + "def define_env(env):\n" + ' chatter = env.start_chatting("Example", color="cyan")\n' + ' chatter("Registered macros")\n' + " @env.macro\n" + " def greet(name):\n" + ' chatter("Greeting:", name)\n' + ' return "Hello " + name\n', + encoding="utf-8", + ) + result = subprocess.run( # noqa: S603 + [sys.executable, "-m", "zensical.main", "build", "-f", str(config)], + capture_output=True, + text=True, + check=True, + ) + html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") + assert "Hello World" in html + if verbose: + assert "[macros - Example] - Registered macros" in result.stderr + assert "[macros - Example] - Greeting: World" in result.stderr + assert "Rendering page: index.md" in result.stderr + assert "Loading local module:" in result.stderr + else: + assert "[macros -" not in result.stderr + assert "[macros -" not in result.stdout + assert "[macros -" not in html diff --git a/python/tests/unit/extensions/test_macros.py b/python/tests/unit/extensions/test_macros.py index 19c010c..cc8b196 100644 --- a/python/tests/unit/extensions/test_macros.py +++ b/python/tests/unit/extensions/test_macros.py @@ -23,6 +23,7 @@ from __future__ import annotations +import logging import os from io import StringIO from typing import TYPE_CHECKING @@ -34,11 +35,13 @@ from jinja2.exceptions import ( TemplateSyntaxError, UndefinedError, ) +from markdown import Markdown from tests.unit.extensions.conftest import soup from zensical.extensions.context import ContextPreprocessor from zensical.extensions.macros import ( MacroEnv, + MacrosExtension, _fix_url, _load_module, _load_one_yaml, @@ -55,7 +58,6 @@ from zensical.extensions.table_reader import ( if TYPE_CHECKING: from pathlib import Path - from markdown import Markdown from pandas import DataFrame @@ -126,6 +128,18 @@ class TestFilters: 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) @@ -268,6 +282,118 @@ class TestLoadModule: 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 }}") == "
Value: {{ 1 + 1 }}
" + + @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"), [ @@ -289,7 +415,8 @@ class TestPreprocessor: "config": { "markdown_extensions": { "zensical.extensions.macros": { - "render_by_default": True + "render_by_default": True, + "force_render_paths": "!**", }, }, } diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 3c0a975..3f701a5 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -281,7 +281,6 @@ class TestPluginShimming: plugins: dict[str, dict[str, Any]] = { "callouts": {}, "glightbox": {"auto": False}, - "macros": {"render_by_default": False}, "mike": {"version_selector": False}, "mkdocstrings": {"enabled": False}, "search": {"separator": r"\s+"}, @@ -291,7 +290,6 @@ class TestPluginShimming: for name, options in { "callouts": {"aliases": False, "breakless_lists": False}, "glightbox": {"slide_effect": "fade"}, - "macros": {"force_render_paths": "guides/**"}, "mike": {"javascript_dir": "scripts"}, "mkdocstrings": {"enable_inventory": False, "watch": ["src"]}, "search": {"lang": ["en", "fr"]}, @@ -634,6 +632,23 @@ class TestPluginShimming: config = self._parse_yaml(tmp_path, plugins={"macros": {}}) assert MacrosExtension.name in config["markdown_extensions"] + @pytest.mark.parametrize( + ("option", "value"), + [("force_render_paths", "guides/\n!guides/drafts/"), ("verbose", True)], + ) + def test_macros_settings_forwarded_and_hashed( + self, tmp_path: Path, option: str, value: str | bool + ) -> None: + baseline = self._parse_yaml(tmp_path, plugins={"macros": {}}) + config_file = tmp_path / "mkdocs.yml" + config_file.write_text( + _minimal_yaml(plugins={"macros": {option: value}}) + ) + configured = parse_config(str(config_file)) + + assert configured["mdx_configs"][MacrosExtension.name][option] == value + assert configured["plugins_hash"] != baseline["plugins_hash"] + def test_table_reader_plugin_shimmed(self, tmp_path: Path) -> None: config = self._parse_yaml(tmp_path, plugins={"table-reader": {}}) assert TableReaderExtension.name in config["markdown_extensions"] diff --git a/python/tests/unit/test_plugin_config.py b/python/tests/unit/test_plugin_config.py index 197a9ee..48b6754 100644 --- a/python/tests/unit/test_plugin_config.py +++ b/python/tests/unit/test_plugin_config.py @@ -287,8 +287,6 @@ def test_rejects_invalid_blog_configuration(name: str, data: Any) -> None: ("glightbox", "draggable"), ("glightbox", "background"), ("glightbox", "shadow"), - ("macros", "force_render_paths"), - ("macros", "verbose"), ("mike", "css_dir"), ("mike", "javascript_dir"), ("mkdocstrings", "enable_inventory"), @@ -449,6 +447,8 @@ def test_normalizes_null_shim_configuration(name: str) -> None: "include_yaml": {"data": "data.yml"}, "include_dir": "includes", "render_by_default": False, + "force_render_paths": "guides/\n!guides/drafts/", + "verbose": True, "on_error_fail": True, "on_undefined": "strict", "j2_block_start_string": "<%", @@ -480,6 +480,44 @@ def test_accepts_supported_shim_options( assert plugins[name]["config"] == config +@pytest.mark.parametrize("plugin", ["macros", "material/macros"]) +@pytest.mark.parametrize( + ("option", "value"), + [ + ("force_render_paths", ""), + ("force_render_paths", "# Pages to render\nguides/\n!guides/drafts/"), + ("verbose", True), + ("verbose", False), + ], +) +def test_preserves_macros_settings( + plugin: str, option: str, value: Any +) -> None: + data = {option: value} + plugins = _convert_plugins({plugin: data}) + assert plugins["macros"]["config"] == data + assert data == {option: value} + + +@pytest.mark.parametrize( + ("option", "value"), + [ + ("force_render_paths", True), + ("force_render_paths", ["guides/"]), + ("force_render_paths", {}), + ("force_render_paths", 1), + ("force_render_paths", None), + ("verbose", "true"), + ("verbose", 1), + ("verbose", []), + ("verbose", None), + ], +) +def test_rejects_invalid_macros_settings(option: str, value: Any) -> None: + with pytest.raises(ConfigurationError, match=f"macros {option} must be"): + _convert_plugins({"macros": {option: value}}) + + @pytest.mark.parametrize("plugin", ["autorefs", "material/autorefs"]) @pytest.mark.parametrize( ("option", "value"), diff --git a/python/zensical/config.py b/python/zensical/config.py index d3c84ea..1be5276 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -136,12 +136,7 @@ _PLUGIN_UNSUPPORTED_OPTIONS = { "shadow", ), "literate-nav": (), - "macros": ( - # TODO: Match page paths before deciding whether to render macros. - "force_render_paths", - # TODO: Add diagnostics for module loading and macro rendering. - "verbose", - ), + "macros": (), "markdown-exec": (), "meta": (), "mike": ( @@ -2135,6 +2130,7 @@ def _convert_plugins(value: Any, config: dict) -> dict: string_options = { "module_name", "include_dir", + "force_render_paths", "j2_block_start_string", "j2_block_end_string", "j2_variable_start_string", @@ -2147,6 +2143,7 @@ def _convert_plugins(value: Any, config: dict) -> dict: "enabled", "render_by_default", "on_error_fail", + "verbose", } _reject_unknown_options( "macros", diff --git a/python/zensical/extensions/macros.py b/python/zensical/extensions/macros.py index 9728d5f..ae4d322 100644 --- a/python/zensical/extensions/macros.py +++ b/python/zensical/extensions/macros.py @@ -24,6 +24,7 @@ from __future__ import annotations import importlib.util +import logging import platform import subprocess import traceback @@ -38,10 +39,13 @@ from urllib.parse import urlparse import jinja2 import yaml +from click import echo, style from jinja2.exceptions import UndefinedError from jinja2.loaders import split_template_path from markdown import Extension from markdown.preprocessors import Preprocessor +from pathspec import PathSpec +from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern from zensical.extensions.context import ContextPreprocessor from zensical.extensions.table_reader import ( @@ -60,6 +64,8 @@ if TYPE_CHECKING: # ----------------------------------------------------------------------------- +_LOGGER = logging.getLogger(__name__) + VariablesType: TypeAlias = dict[str, Any] MacrosType: TypeAlias = dict[str, Callable[..., Any]] FiltersType: TypeAlias = dict[str, Callable[..., Any]] @@ -151,12 +157,33 @@ See also the [Jinja2 documentation on builtin filters](https://jinja.palletsproj class MacroEnv: """Minimal env object for compatibility with MkDocs Macros.""" - def __init__(self, conf: dict[str, Any] | None = None) -> None: + def __init__( + self, conf: dict[str, Any] | None = None, *, verbose: bool = False + ) -> None: self.conf = conf if conf is not None else {} + self._verbose = verbose self.variables: VariablesType = {} self.macros: MacrosType = {} self.filters: FiltersType = {} + def start_chatting( + self, prefix: str, color: str = "yellow" + ) -> Callable[..., None]: + """Create a module logger enabled by the plugin's verbose setting.""" + + def chatter(*args: Any) -> None: + if not self._verbose: + return + label = f"[macros - {prefix}] -" + message = " ".join(str(arg) for arg in args) + if _LOGGER.hasHandlers(): + _LOGGER.info("%s %s", label, message) + else: + # The native CLI does not configure Python logging handlers. + echo(f"{style(label, fg=color)} {message}", err=True) + + return chatter + def macro( self, fn: Callable[..., Any] | None = None, name: str | None = None ) -> Any: @@ -191,6 +218,7 @@ class MacrosConfig: include_yaml: list[str] | dict[str, str] = field(default_factory=list) include_dir: str = "" render_by_default: bool = True + force_render_paths: str = "" on_error_fail: bool = False on_undefined: Literal["keep", "strict"] = "keep" verbose: bool = False @@ -215,6 +243,10 @@ class MacrosPreprocessor(Preprocessor): ) -> None: super().__init__(md) self.config = config + # Use the pattern class behind upstream's deprecated gitwildmatch name. + self._render_paths = PathSpec.from_lines( + GitIgnoreSpecPattern, config.force_render_paths.splitlines() + ) def run(self, lines: list[str]) -> list[str]: """Render body as Jinja2 template with built context.""" @@ -223,14 +255,20 @@ class MacrosPreprocessor(Preprocessor): page = context.page if context else None project_config = context.config if context else {} project_root = Path(project_config.get("root_dir", ".")).resolve() - macros_env = MacroEnv(conf=project_config) + macros_env = MacroEnv(conf=project_config, verbose=self.config.verbose) + chatter = macros_env.start_chatting("render") + page_path = page.path if page else "