diff --git a/CHANGELOG.md b/CHANGELOG.md index 723eb24c..7066ff44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [0.25.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.25.0) - 2024-04-27 + +[Compare with 0.24.3](https://github.com/mkdocstrings/mkdocstrings/compare/0.24.3...0.25.0) + +### Features + +- Support `once` parameter in logging methods, allowing to log a message only once with a given logger ([1532b59](https://github.com/mkdocstrings/mkdocstrings/commit/1532b59a6efd99fed846cf7edfd0b26525700d3f) by Timothée Mazzucotelli). +- Support blank line between `::: path` and YAML options ([d799d2f](https://github.com/mkdocstrings/mkdocstrings/commit/d799d2f3903bce44fb751f8cf3fb8078d25549da) by Timothée Mazzucotelli). [Issue-450](https://github.com/mkdocstrings/mkdocstrings/issues/450) + +### Code Refactoring + +- Allow specifying name of template loggers ([c5b5f69](https://github.com/mkdocstrings/mkdocstrings/commit/c5b5f697c83271d961c7ac795412d6b4964ba2b7) by Timothée Mazzucotelli). + ## [0.24.3](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.24.3) - 2024-04-05 [Compare with 0.24.2](https://github.com/mkdocstrings/mkdocstrings/compare/0.24.2...0.24.3) diff --git a/mkdocs.yml b/mkdocs.yml index 860ce66e..30afc977 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -137,6 +137,8 @@ plugins: - https://docs.python.org/3/objects.inv - https://installer.readthedocs.io/en/stable/objects.inv # demonstration purpose in the docs - https://mkdocstrings.github.io/autorefs/objects.inv + - https://www.mkdocs.org/objects.inv + - https://python-markdown.github.io/objects.inv paths: [src] options: docstring_options: @@ -146,6 +148,7 @@ plugins: heading_level: 1 inherited_members: true merge_init_into_class: true + parameter_headings: true separate_signature: true show_root_heading: true show_root_full_path: false diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py index bef8c799..23e90cff 100644 --- a/src/mkdocstrings/extension.py +++ b/src/mkdocstrings/extension.py @@ -116,6 +116,10 @@ def run(self, parent: Element, blocks: MutableSequence[str]) -> None: block, the_rest = self.detab(block) + if not block and blocks and blocks[0].startswith((" handler:", " options:")): + # YAML options were separated from the `:::` line by a blank line. + block = blocks.pop(0) + if match: identifier = match["name"] heading_level = match["heading"].count("#") diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py index f52e17dc..27c22db1 100644 --- a/src/mkdocstrings/handlers/base.py +++ b/src/mkdocstrings/handlers/base.py @@ -74,6 +74,7 @@ class BaseHandler: To add custom CSS, add an `extra_css` variable or create an 'style.css' file beside the templates. """ + # TODO: Make name mandatory? name: str = "" """The handler's name, for example "python".""" domain: str = "default" @@ -132,7 +133,7 @@ def __init__(self, handler: str, theme: str, custom_templates: str | None = None auto_reload=False, # Editing a template in the middle of a build is not useful. ) self.env.filters["any"] = do_any - self.env.globals["log"] = get_template_logger() + self.env.globals["log"] = get_template_logger(self.name) self._headings: list[Element] = [] self._md: Markdown = None # type: ignore[assignment] # To be populated in `update_env`. diff --git a/src/mkdocstrings/loggers.py b/src/mkdocstrings/loggers.py index 63502474..240e1808 100644 --- a/src/mkdocstrings/loggers.py +++ b/src/mkdocstrings/loggers.py @@ -17,7 +17,7 @@ except ImportError: TEMPLATES_DIRS: Sequence[Path] = () else: - TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__) # type: ignore[arg-type] + TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__) if TYPE_CHECKING: @@ -25,7 +25,25 @@ class LoggerAdapter(logging.LoggerAdapter): - """A logger adapter to prefix messages.""" + """A logger adapter to prefix messages. + + This adapter also adds an additional parameter to logging methods + called `once`: if `True`, the message will only be logged once. + + Examples: + In Python code: + + >>> logger = get_logger("myplugin") + >>> logger.debug("This is a debug message.") + >>> logger.info("This is an info message.", once=True) + + In Jinja templates (logger available in context as `log`): + + ```jinja + {{ log.debug("This is a debug message.") }} + {{ log.info("This is an info message.", once=True) }} + ``` + """ def __init__(self, prefix: str, logger: logging.Logger): """Initialize the object. @@ -36,6 +54,7 @@ def __init__(self, prefix: str, logger: logging.Logger): """ super().__init__(logger, {}) self.prefix = prefix + self._logged: set[tuple[LoggerAdapter, str]] = set() def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, Any]: """Process the message. @@ -49,11 +68,32 @@ def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, Any] """ return f"{self.prefix}: {msg}", kwargs + def log(self, level: int, msg: object, *args: object, **kwargs: object) -> None: + """Log a message. + + Arguments: + level: The logging level. + msg: The message. + *args: Additional arguments passed to parent method. + **kwargs: Additional keyword arguments passed to parent method. + """ + if kwargs.pop("once", False): + if (key := (self, str(msg))) in self._logged: + return + self._logged.add(key) + super().log(level, msg, *args, **kwargs) # type: ignore[arg-type] + class TemplateLogger: """A wrapper class to allow logging in templates. - Attributes: + The logging methods provided by this class all accept + two parameters: + + - `msg`: The message to log. + - `once`: If `True`, the message will only be logged once. + + Methods: debug: Function to log a DEBUG message. info: Function to log an INFO message. warning: Function to log a WARNING message. @@ -85,18 +125,19 @@ def get_template_logger_function(logger_func: Callable) -> Callable: """ @pass_context - def wrapper(context: Context, msg: str | None = None) -> str: + def wrapper(context: Context, msg: str | None = None, **kwargs: Any) -> str: """Log a message. Arguments: context: The template context, automatically provided by Jinja. msg: The message to log. + **kwargs: Additional arguments passed to the logger function. Returns: An empty string. """ template_path = get_template_path(context) - logger_func(f"{template_path}: {msg or 'Rendering'}") + logger_func(f"{template_path}: {msg or 'Rendering'}", **kwargs) return "" return wrapper @@ -136,10 +177,14 @@ def get_logger(name: str) -> LoggerAdapter: return LoggerAdapter(name.split(".", 1)[0], logger) -def get_template_logger() -> TemplateLogger: +def get_template_logger(handler_name: str | None = None) -> TemplateLogger: """Return a logger usable in templates. + Parameters: + handler_name: The name of the handler. + Returns: A template logger. """ - return TemplateLogger(get_logger("mkdocstrings.templates")) + handler_name = handler_name or "base" + return TemplateLogger(get_logger(f"mkdocstrings_handlers.{handler_name}.templates")) diff --git a/tests/test_extension.py b/tests/test_extension.py index affd6c6a..976f376c 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -172,6 +172,11 @@ def test_use_options_yaml_key(ext_markdown: Markdown) -> None: assert "h1" not in ext_markdown.convert("::: tests.fixtures.headings\n options:\n heading_level: 2") +def test_use_yaml_options_after_blank_line(ext_markdown: Markdown) -> None: + """Check that YAML options are detected even after a blank line.""" + assert "h1" not in ext_markdown.convert("::: tests.fixtures.headings\n\n options:\n heading_level: 2") + + @pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"admonition": {}}]}], indirect=["ext_markdown"]) def test_removing_duplicated_headings(ext_markdown: Markdown) -> None: """Assert duplicated headings are removed from the output.""" diff --git a/tests/test_loggers.py b/tests/test_loggers.py new file mode 100644 index 00000000..1644c0f0 --- /dev/null +++ b/tests/test_loggers.py @@ -0,0 +1,64 @@ +"""Tests for the loggers module.""" + +from unittest.mock import MagicMock + +import pytest + +from mkdocstrings.loggers import get_logger, get_template_logger + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"once": False}, + {"once": True}, + ], +) +def test_logger(kwargs: dict, caplog: pytest.LogCaptureFixture) -> None: + """Test logger methods. + + Parameters: + kwargs: Keyword arguments passed to the logger methods. + """ + logger = get_logger("mkdocstrings.test") + caplog.set_level(0) + for _ in range(2): + logger.debug("Debug message", **kwargs) + logger.info("Info message", **kwargs) + logger.warning("Warning message", **kwargs) + logger.error("Error message", **kwargs) + logger.critical("Critical message", **kwargs) + if kwargs.get("once", False): + assert len(caplog.records) == 5 + else: + assert len(caplog.records) == 10 + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"once": False}, + {"once": True}, + ], +) +def test_template_logger(kwargs: dict, caplog: pytest.LogCaptureFixture) -> None: + """Test template logger methods. + + Parameters: + kwargs: Keyword arguments passed to the template logger methods. + """ + logger = get_template_logger() + mock = MagicMock() + caplog.set_level(0) + for _ in range(2): + logger.debug(mock, "Debug message", **kwargs) + logger.info(mock, "Info message", **kwargs) + logger.warning(mock, "Warning message", **kwargs) + logger.error(mock, "Error message", **kwargs) + logger.critical(mock, "Critical message", **kwargs) + if kwargs.get("once", False): + assert len(caplog.records) == 5 + else: + assert len(caplog.records) == 10