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 "" - # Don't render if not enabled by default and no page-level override - if ( + # Page metadata takes precedence over the default and path selection. + render_macros = page.meta.get("render_macros") if page else None + if render_macros is False or ( not self.config.render_by_default - and (not page or (page and not page.meta.get("render_macros"))) - ) or (page and page.meta.get("render_macros") is False): + and not render_macros + and not (page and self._render_paths.match_file(page.path)) + ): + chatter("Skipping page:", page_path) return lines + chatter("Rendering page:", page_path) text = "\n".join(lines) variables = {} @@ -582,6 +620,7 @@ def _load_module( env: MacroEnv, module_name: str, project_root: Path | None = None ) -> None: """Load a module by name (e.g. 'main').""" + chatter = env.start_chatting("module") if project_root: for candidate in [ project_root / f"{module_name}.py", @@ -595,6 +634,7 @@ def _load_module( module_name, candidate ) if spec and spec.loader: + chatter("Loading local module:", candidate) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) if hasattr(mod, "define_env"): @@ -605,10 +645,11 @@ def _load_module( # Only try import for package-like names (no path separators or ".."). if "/" in module_name or "\\" in module_name or ".." in module_name: return + chatter("Importing module:", module_name) try: mod = importlib.import_module(module_name) except ImportError: - pass + chatter("Module unavailable:", module_name) else: if hasattr(mod, "define_env"): mod.define_env(env) diff --git a/uv.lock b/uv.lock index bd4adc9..ad514a7 100644 --- a/uv.lock +++ b/uv.lock @@ -616,6 +616,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -924,6 +933,7 @@ dependencies = [ { name = "deepmerge" }, { name = "jinja2" }, { name = "markdown" }, + { name = "pathspec" }, { name = "pygments" }, { name = "pymdown-extensions" }, { name = "pyyaml" }, @@ -951,6 +961,7 @@ requires-dist = [ { name = "deepmerge", specifier = ">=2.0" }, { name = "jinja2", specifier = ">=3.1" }, { name = "markdown", specifier = ">=3.7" }, + { name = "pathspec", specifier = ">=1.0.4" }, { name = "pygments", specifier = ">=2.20" }, { name = "pymdown-extensions", specifier = ">=11.0" }, { name = "pyyaml", specifier = ">=6.0.2" },