diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..ba1d7a7 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,181 @@ +# Configuration + +This page lists the available configuration options and what they achieve. + +[](){#option-extra} +## `extra` + +- **:octicons-package-24: Type [`dict`][] :material-equal: `{}`{ title="default value" }** + +The `extra` option lets you inject additional variables into the Jinja context used when rendering templates. You can then use this extra context in your [overridden templates](https://mkdocstrings.github.io/usage/theming/#templates). + +Local `extra` options will be merged into the global `extra` option: + +```yaml title="in mkdocs.yml (global configuration)" +plugins: +- mkdocstrings: + handlers: + shell: + options: + extra: + hello: world +``` + +```md title="in docs/some_page.md (local configuration)" +::: your_package.your_module.your_func + handler: shell + options: + extra: + foo: bar +``` + +...will inject both `hello` and `foo` into the Jinja context when rendering `your_package.your_module.your_func`. + +[](){#option-heading_level} +## `heading_level` + +- **:octicons-package-24: Type [`int`][] :material-equal: `2`{ title="default value" }** + +The initial heading level to use. + +When injecting documentation for an object, +the object itself and its members are rendered. +For each layer of objects, we increase the heading level by 1. + +The initial heading level will be used for the first layer. +If you set it to 3, then headings will start with `

`. + +If the [heading for the root object][show_root_heading] is not shown, +then the initial heading level is used for its members. + +```yaml title="in mkdocs.yml (global configuration)" +plugins: +- mkdocstrings: + handlers: + shell: + options: + heading_level: 2 +``` + +```md title="or in docs/some_page.md (local configuration)" +::: path.to.module + handler: shell + options: + heading_level: 3 +``` + +/// admonition | Preview + type: preview + +//// tab | With level 3 and root heading +

module (3)

+

Docstring of the module.

+

ClassA (4)

+

Docstring of class A.

+

ClassB (4)

+

Docstring of class B.

+
method_1 (5)
+

Docstring of the method.

+//// + +//// tab | With level 3, without root heading +

Docstring of the module.

+

ClassA (3)

+

Docstring of class A.

+

ClassB (3)

+

Docstring of class B.

+

method_1 (4)

+

Docstring of the method.

+//// +/// + +[](){#option-show_root_heading} +## `show_root_heading` + +- **:octicons-package-24: Type [`bool`][] :material-equal: `False`{ title="default value" }** + +Show the heading of the object at the root of the documentation tree +(i.e. the object referenced by the identifier after `:::`). + +While this option defaults to false for backwards compatibility, we recommend setting it to true. Note that the heading of the root object can be a level 1 heading (the first on the page): + +```md +# ::: path.to.object +``` + +```yaml title="in mkdocs.yml (global configuration)" +plugins: +- mkdocstrings: + handlers: + shell: + options: + show_root_heading: false +``` + +```md title="or in docs/some_page.md (local configuration)" +::: path.to.Class + handler: shell + options: + show_root_heading: true +``` + +[](){#option-show_root_toc_entry} +## `show_root_toc_entry` + +- **:octicons-package-24: Type [`bool`][] :material-equal: `True`{ title="default value" }** + + +If the root heading is not shown, at least add a ToC entry for it. + +If you inject documentation for an object in the middle of a page, +after long paragraphs, and without showing the [root heading][show_root_heading], +then you will not be able to link to this particular object +as it won't have a permalink and will be "lost" in the middle of text. +In that case, it is useful to add a hidden anchor to the document, +which will also appear in the table of contents. + +In other cases, you might want to disable the entry to avoid polluting the ToC. +It is not possible to show the root heading *and* hide the ToC entry. + +```yaml title="in mkdocs.yml (global configuration)" +plugins: +- mkdocstrings: + handlers: + shell: + options: + show_root_heading: false + show_root_toc_entry: true +``` + +```md title="or in docs/some_page.md (local configuration)" +## Some heading + +Lots of text. + +::: path.to.object + handler: shell + options: + show_root_heading: false + show_root_toc_entry: false + +## Other heading. + +More text. +``` + +/// admonition | Preview + type: preview + +//// tab | With ToC entry +**Table of contents**
+[Some heading](#permalink-to-some-heading){ title="#permalink-to-some-heading" }
+[`object`](#permalink-to-object){ title="#permalink-to-object" }
+[Other heading](#permalink-to-other-heading){ title="#permalink-to-other-heading" } +//// + +//// tab | Without ToC entry +**Table of contents**
+[Some heading](#permalink-to-some-heading){ title="#permalink-to-some-heading" }
+[Other heading](#permalink-to-other-heading){ title="#permalink-to-other-heading" } +//// +/// diff --git a/docs/css/material.css b/docs/css/material.css index 9e8c14a..235ef94 100644 --- a/docs/css/material.css +++ b/docs/css/material.css @@ -2,3 +2,25 @@ .md-main__inner { margin-bottom: 1.5rem; } + +/* Custom admonition: preview */ +:root { + --md-admonition-icon--preview: url('data:image/svg+xml;charset=utf-8,'); +} + +.md-typeset .admonition.preview, +.md-typeset details.preview { + border-color: rgb(220, 139, 240); +} + +.md-typeset .preview>.admonition-title, +.md-typeset .preview>summary { + background-color: rgba(142, 43, 155, 0.1); +} + +.md-typeset .preview>.admonition-title::before, +.md-typeset .preview>summary::before { + background-color: rgb(220, 139, 240); + -webkit-mask-image: var(--md-admonition-icon--preview); + mask-image: var(--md-admonition-icon--preview); +} diff --git a/duties.py b/duties.py index 3978f44..765ab46 100644 --- a/duties.py +++ b/duties.py @@ -89,6 +89,8 @@ def check_types(ctx: Context) -> None: ctx.run( tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), title=pyprefix("Type-checking"), + # TODO: Update when Pydantic supports 3.14. + nofail=sys.version_info >= (3, 14), ) diff --git a/mkdocs.yml b/mkdocs.yml index fa23198..a24c12d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,7 @@ validation: nav: - Home: - Overview: index.md + - Configuration: configuration.md - Changelog: changelog.md - Credits: credits.md - License: license.md @@ -87,6 +88,9 @@ markdown_extensions: - admonition - callouts - footnotes +- pymdownx.blocks.admonition +- pymdownx.blocks.tab: + alternate_style: true - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg @@ -107,6 +111,7 @@ markdown_extensions: plugins: - search +- autorefs - markdown-exec - gen-files: scripts: diff --git a/pyproject.toml b/pyproject.toml index f37cb1a..58e201d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "mkdocstrings>=0.18", + "mkdocstrings>=0.28", "shellman>=1.0.0", ] @@ -106,4 +106,5 @@ dev = [ "mkdocstrings[python]>=0.25", # YORE: EOL 3.10: Remove line. "tomli>=2.0; python_version < '3.11'", + "pydantic>=2.10", ] \ No newline at end of file diff --git a/src/mkdocstrings_handlers/shell/config.py b/src/mkdocstrings_handlers/shell/config.py new file mode 100644 index 0000000..9e9e546 --- /dev/null +++ b/src/mkdocstrings_handlers/shell/config.py @@ -0,0 +1,133 @@ +"""Configuration and options dataclasses.""" + +from __future__ import annotations + +import sys +from dataclasses import field +from typing import TYPE_CHECKING, Annotated, Any + +# YORE: EOL 3.10: Replace block with line 2. +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +try: + # When Pydantic is available, use it to validate options (done automatically). + # Users can therefore opt into validation by installing Pydantic in development/CI. + # When building the docs to deploy them, Pydantic is not required anymore. + + # When building our own docs, Pydantic is always installed (see `docs` group in `pyproject.toml`) + # to allow automatic generation of a JSON Schema. The JSON Schema is then referenced by mkdocstrings, + # which is itself referenced by mkdocs-material's schema system. For example in VSCode: + # + # "yaml.schemas": { + # "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" + # } + from inspect import cleandoc + + from pydantic import Field as BaseField + from pydantic.dataclasses import dataclass + + _base_url = "https://mkdocstrings.github.io/shell/configuration" + + def Field( # noqa: N802, D103 + *args: Any, + description: str, + parent: str | None = None, + **kwargs: Any, + ) -> None: + def _add_markdown_description(schema: dict[str, Any]) -> None: + url = f"{_base_url}/#{parent or schema['title']}" + schema["markdownDescription"] = f"[DOCUMENTATION]({url})\n\n{schema['description']}" + + return BaseField( + *args, + description=cleandoc(description), + field_title_generator=lambda name, _: name, + json_schema_extra=_add_markdown_description, + **kwargs, + ) +except ImportError: + from dataclasses import dataclass # type: ignore[no-redef] + + def Field(*args: Any, **kwargs: Any) -> None: # type: ignore[misc] # noqa: D103, N802 + pass + + +if TYPE_CHECKING: + from collections.abc import MutableMapping + + +# YORE: EOL 3.9: Remove block. +_dataclass_options = {"frozen": True} +if sys.version_info >= (3, 10): + _dataclass_options["kw_only"] = True + + +# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line. +@dataclass(**_dataclass_options) # type: ignore[call-overload] +class ShellInputOptions: + """Accepted input options.""" + + extra: Annotated[ + dict[str, Any], + Field(description="Extra options."), + ] = field(default_factory=dict) + + heading_level: Annotated[ + int, + Field(description="The initial heading level to use."), + ] = 2 + + show_root_heading: Annotated[ + bool, + Field( + description="""Show the heading of the object at the root of the documentation tree. + + The root object is the object referenced by the identifier after `:::`. + """, + ), + ] = False + + show_root_toc_entry: Annotated[ + bool, + Field( + description="If the root heading is not shown, at least add a ToC entry for it.", + ), + ] = True + + @classmethod + def coerce(cls, **data: Any) -> MutableMapping[str, Any]: + """Coerce data.""" + return data + + @classmethod + def from_data(cls, **data: Any) -> Self: + """Create an instance from a dictionary.""" + return cls(**cls.coerce(**data)) + + +# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line. +@dataclass(**_dataclass_options) # type: ignore[call-overload] +class ShellOptions(ShellInputOptions): # type: ignore[override,unused-ignore] + """Final options passed as template context.""" + + +# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line. +@dataclass(**_dataclass_options) # type: ignore[call-overload] +class ShellInputConfig: + """Python handler configuration.""" + + options: Annotated[ + ShellInputOptions, + Field(description="Configuration options for collecting and rendering objects."), + ] = field(default_factory=ShellInputOptions) + + +# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line. +@dataclass(**_dataclass_options) # type: ignore[call-overload] +class ShellConfig(ShellInputConfig): # type: ignore[override,unused-ignore] + """Shell handler configuration.""" + + options: dict[str, Any] = field(default_factory=dict) # type: ignore[assignment] diff --git a/src/mkdocstrings_handlers/shell/handler.py b/src/mkdocstrings_handlers/shell/handler.py index ea83726..1a29992 100644 --- a/src/mkdocstrings_handlers/shell/handler.py +++ b/src/mkdocstrings_handlers/shell/handler.py @@ -5,15 +5,19 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar +from mkdocs.exceptions import PluginError from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem from mkdocstrings.loggers import get_logger from shellman import DocFile from shellman.templates.filters import FILTERS +from mkdocstrings_handlers.shell.config import ShellConfig, ShellOptions + if TYPE_CHECKING: from collections.abc import Mapping, MutableMapping - from markdown import Markdown + from mkdocs.config.defaults import MkDocsConfig + from mkdocstrings.handlers.base import HandlerOptions logger = get_logger(__name__) @@ -22,100 +26,52 @@ class ShellHandler(BaseHandler): """The Shell handler class.""" - name: str = "shell" + name: ClassVar[str] = "shell" """The handler's name.""" - domain: str = "shell" + domain: ClassVar[str] = "shell" """The cross-documentation domain/language for this handler.""" - enable_inventory: bool = False + enable_inventory: ClassVar[bool] = False """Whether this handler is interested in enabling the creation of the `objects.inv` Sphinx inventory file.""" - fallback_theme = "material" + fallback_theme: ClassVar[str] = "material" """The theme to fallback to.""" - fallback_config: ClassVar[dict] = {"fallback": True} - """The configuration used to collect item during autorefs fallback.""" - - default_config: ClassVar[dict] = { - "show_root_heading": False, - "show_root_toc_entry": True, - "heading_level": 2, - } - """The default configuration options. - - Option | Type | Description | Default - ------ | ---- | ----------- | ------- - **`show_root_heading`** | `bool` | Show the heading of the object at the root of the documentation tree. | `False` - **`show_root_toc_entry`** | `bool` | If the root heading is not shown, at least add a ToC entry for it. | `True` - **`heading_level`** | `int` | The initial heading level to use. | `2` - """ + def __init__(self, config: ShellConfig, base_dir: Path, **kwargs: Any) -> None: # noqa: D107 + super().__init__(**kwargs) + self.config = config + self.base_dir = base_dir + self.global_options = config.options + + def get_options(self, local_options: Mapping[str, Any]) -> HandlerOptions: # noqa: D102 + extra = {**self.global_options.get("extra", {}), **local_options.get("extra", {})} + options = {**self.global_options, **local_options, "extra": extra} + try: + return ShellOptions.from_data(**options) + except Exception as error: + raise PluginError(f"Invalid options: {error}") from error - def __init__( # noqa: D107 - self, - handler: str, - theme: str, - custom_templates: str | None = None, - config_file_path: str | None = None, - ) -> None: - super().__init__(handler, theme, custom_templates) - if config_file_path: - self.base_dir = Path(config_file_path).parent - else: - self.base_dir = Path(".") - - def collect(self, identifier: str, config: MutableMapping[str, Any]) -> CollectorItem: # noqa: ARG002 - """Collect data given an identifier and selection configuration. - - In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into - a Python dictionary for example, though the implementation is completely free. - - Parameters: - identifier: An identifier that was found in a markdown document for which to collect data. For example, - in Python, it would be 'mkdocstrings.handlers' to collect documentation about the handlers module. - It can be anything that you can feed to the tool of your choice. - config: All configuration options for this handler either defined globally in `mkdocs.yml` or - locally overridden in an identifier block by the user. - - Returns: - Anything you want, as long as you can feed it to the `render` method. - """ + def collect(self, identifier: str, options: ShellOptions) -> CollectorItem: # noqa: ARG002 + """Collect data from a shell script/library.""" script_path = self.base_dir / identifier try: return DocFile(str(script_path)) except FileNotFoundError as error: raise CollectionError(f"Could not find script '{script_path}'") from error - def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str: - """Render a template using provided data and configuration options. - - Parameters: - data: The data to render that was collected above in `collect()`. - config: All configuration options for this handler either defined globally in `mkdocs.yml` or - locally overridden in an identifier block by the user. - - Returns: - The rendered template as HTML. - """ - final_config = {**self.default_config, **config} - heading_level = final_config["heading_level"] + def render(self, data: CollectorItem, options: ShellOptions) -> str: + """Render the collected data.""" + heading_level = options.heading_level template = self.env.get_template("script.html.jinja") return template.render( - config=final_config, + config=options, filename=data.filename, script=data.sections, heading_level=heading_level, ) - def update_env(self, md: Markdown, config: dict) -> None: - """Update the Jinja environment with any custom settings/filters/options for this handler. - - Parameters: - md: The Markdown instance. Useful to add functions able to convert Markdown into the environment filters. - config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code - of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. - """ - super().update_env(md, config) # Add some mkdocstrings default filters such as highlight and convert_markdown + def update_env(self, config: MkDocsConfig) -> None: # noqa: ARG002, D102 self.env.trim_blocks = True self.env.lstrip_blocks = True self.env.keep_trailing_newline = False @@ -123,25 +79,20 @@ def update_env(self, md: Markdown, config: dict) -> None: def get_handler( - theme: str, - custom_templates: str | None = None, - config_file_path: str | None = None, - **config: Any, # noqa: ARG001 + *, + handler_config: MutableMapping[str, Any], + tool_config: MkDocsConfig, + **kwargs: Any, ) -> ShellHandler: """Simply return an instance of `ShellHandler`. Parameters: - theme: The theme to use when rendering contents. - custom_templates: Directory containing custom templates. - config_file_path: The MkDocs configuration file path. - **config: Configuration passed to the handler. + handler_config: The handler configuration. + tool_config: The tool (SSG) configuration. + **kwargs: Keyword arguments for the base handler constructor. Returns: An instance of the handler. """ - return ShellHandler( - handler="shell", - theme=theme, - custom_templates=custom_templates, - config_file_path=config_file_path, - ) + base_dir = Path(tool_config.config_file_path or "./mkdocs.yml").parent + return ShellHandler(config=ShellConfig(**handler_config), base_dir=base_dir, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 51a124e..a8cdc66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,5 +94,5 @@ def fixture_handler(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> Shell A handler instance. """ handler = plugin.handlers.get_handler("shell") - handler._update_env(ext_markdown, plugin.handlers._config) + handler._update_env(ext_markdown, config=plugin.handlers._tool_config) return handler # type: ignore[return-value] diff --git a/tests/test_themes.py b/tests/test_themes.py index a11f713..6a0db32 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -35,6 +35,6 @@ def test_render_themes_templates_python(identifier: str, plugin: MkdocstringsPlu ext_markdown: Pytest fixture (see conftest.py). """ handler = plugin.handlers.get_handler("shell") - handler._update_env(ext_markdown, plugin.handlers._config) + handler._update_env(ext_markdown, config=plugin.handlers._tool_config) data = handler.collect(identifier, {}) handler.render(data, {})