Print print print!
@@ -99,34 +101,33 @@ The above is equivalent to:
heading_level: 2
```
-
-
## Global options
-MkDocstrings accept a few top-level configuration options in `mkdocs.yml`:
+*mkdocstrings* accepts a few top-level configuration options in `mkdocs.yml`:
- `watch`: a list of directories to watch while serving the documentation.
See [Watch directories](#watch-directories).
- `default_handler`: the handler that is used by default when no handler is specified.
- `custom_templates`: the path to a directory containing custom templates.
The path is relative to the docs directory.
- See [Customization](#customization).
+ See [Theming](theming.md).
- `handlers`: the handlers global configuration.
Example:
-```yaml
-plugins:
-- mkdocstrings:
- default_handler: python
- handlers:
- python:
- rendering:
- show_source: false
- custom_templates: templates
- watch:
- - src/my_package
-```
+!!! example "mkdocs.yml"
+ ```yaml
+ plugins:
+ - mkdocstrings:
+ default_handler: python
+ handlers:
+ python:
+ rendering:
+ show_source: false
+ custom_templates: templates
+ watch:
+ - src/my_package
+ ```
The handlers global configuration can then be overridden by local configurations:
@@ -144,162 +145,126 @@ Cross-references are written as Markdown *reference-style* links:
```md
With a custom title:
[`Object 1`][full.path.object1]
-
+
With the identifier as title:
[full.path.object2][]
```
-
+
=== "HTML Result"
```html
With a custom title:
- Object 1
+ Object 1
With the identifier as title:
- full.path.object2
+
full.path.object2
```
-## Themes
+Any item that was inserted using the [autodoc syntax](#autodoc-syntax)
+(e.g. `::: full.path.object1`) is possible to link to by using the same identifier with the
+cross-reference syntax (`[example][full.path.object1]`).
+But the cross-references are also applicable to the items' children that get pulled in.
-MkDocstrings can support multiple MkDocs theme.
-It currently supports supports the
-*[Material for MkDocs](https://squidfunk.github.io/mkdocs-material/)*
-theme and, partially, the built-in ReadTheDocs theme.
+#### Finding out the anchor
-Each renderer can fallback to a particular theme when the user selected theme is not supported.
-For example, the Python renderer will fallback to the *Material for MkDocs* templates.
+If you're not sure which exact identifier a doc item uses, you can look at its "anchor", which your
+Web browser will show in the URL bar when clicking an item's entry in the table of contents.
+If the URL is `https://example.com/some/page.html#full.path.object1` then you know that this item
+is possible to link to with `[example][full.path.object1]`, regardless of the current page.
-## Customization
+### Cross-references to any Markdown heading
-There is some degree of customization possible in MkDocstrings.
-First, you can write custom templates to override the theme templates.
-Second, the provided templates make use of CSS classes,
-so you can tweak the look and feel with extra CSS rules.
+!!! important "Changed in version 0.15"
+ Linking to any Markdown heading used to be the default, but now opt-in is required.
-### Templates
+If you want to link to *any* Markdown heading, not just *mkdocstrings*-inserted items, please
+enable the [*autorefs* plugin for *MkDocs*](https://github.com/mkdocstrings/autorefs) by adding
+`autorefs` to `plugins`:
-To use custom templates and override the theme ones,
-specify the relative path to your templates directory
-with the `custom_templates` global configuration option:
+!!! example "mkdocs.yml"
+ ```yaml hl_lines="4"
+ plugins:
+ - admonition
+ - search
+ - autorefs
+ - mkdocstrings:
+ [...]
+ ```
-```yaml
-# mkdocs.yml
-plugins:
- - mkdocstrings:
- custom_templates: templates
-```
+Note that you don't need to (`pip`) install anything more; this plugin is guaranteed to be pulled in with *mkdocstrings*.
-You directory structure must be identical to the provided templates one:
-```
-templates
-βββ
-β βββ
-β βββ
-βββ
- βββ
- βββ
-```
+!!! example
+ === "doc1.md"
+ ```md
+ ## Hello, world!
-(*[Check out the template tree on GitHub](https://github.com/pawamoy/mkdocstrings/tree/master/src/mkdocstrings/templates/)*)
+ Testing
+ ```
-You don't have to replicate the whole tree,
-only the handlers, themes or templates you want to override.
-For example, to override some templates of the *Material* theme for Python:
+ === "doc2.md"
+ ```md
+ ## Something else
-```
-templates
-βββ python
- βββ material
- βββ parameters.html
- βββ exceptions.html
-```
+ Please see the [Hello, World!][hello-world] section.
+ ```
-In the HTML files, replace the original contents with your modified version.
-In the future, the templates will use Jinja blocks, so it will be easier
-to modify a small part of the template without copy-pasting the whole file.
-
-The *Material* theme provides the following template structure:
-
-- `children.html`: where the recursion happen, to render all children of an object
- - `attribute.html`: to render attributes (class-attributes, etc.)
- - `class.html`: to render classes
- - `function.html`: to render functions
- - `method.html`: to render methods
- - `module.html`: to render modules
-- `docstring.html`: to render docstrings
- - `attributes.html`: to render attributes sections of docstrings
- - `examples.html`: to render examples sections of docstrings
- - `exceptions.html`: to render exceptions/"raises" sections of docstrings
- - `parameters.html`: to render parameters/arguments sections of docstrings
- - `return.html`: to render "return" sections of docstrings
-- `properties.html`: to render the properties of an object (`staticmethod`, `read-only`, etc.)
-- `signature.html`: to render functions and methods signatures
-
-#### Debugging
-
-Every template has access to a `log` function, allowing to log messages as usual:
-
-```jinja
-{{ log.debug("A DEBUG message.") }}
-{{ log.info("An INFO message.") }}
-{{ log.warning("A WARNING message.") }}
-{{ log.error("An ERROR message.") }}
-{{ log.critical("A CRITICAL message.") }}
-```
+ === "Result HTML for doc2"
+ ```html
+ Please see the Hello, World! section.
+ ```
+
+### Cross-references to a sub-heading in a docstring
+
+!!! important "New in version 0.14"
+
+If you have a Markdown heading *inside* your docstring, you can also link directly to it.
+In the example below you see the identifier to be linked is `foo.bar--tips`, because it's the "Tips" heading that's part of the `foo.bar` object, joined with "`--`".
-### CSS classes
-
-The *Material* theme uses the following CSS classes in the HTML:
-
-- `doc`: on all the following elements
-- `doc-children`: on `div`s containing the children of an object
-- `doc-object`: on `div`s containing an object
- - `doc-attribute`: on `div`s containing an attribute
- - `doc-class`: on `div`s containing a class
- - `doc-function`: on `div`s containing a function
- - `doc-method`: on `div`s containing a method
- - `doc-module`: on `div`s containing a module
-- `doc-heading`: on objects headings
-- `doc-contents`: on `div`s wrapping the docstring then the children (if any)
- - `first`: same, but only on the root object's contents `div`
-- `doc-properties`: on `span`s wrapping the object's properties
- - `doc-property`: on `small` elements containing a property
- - `doc-property-PROPERTY`: same, where `PROPERTY` is replaced by the actual property
-
-!!! example "Example with colorful properties"
- === "CSS"
- ```css
- .doc-property { border-radius: 15px; padding: 0 5px; }
- .doc-property-special { background-color: blue; color: white; }
- .doc-property-private { background-color: red; color: white; }
- .doc-property-property { background-color: green; color: white; }
- .doc-property-read-only { background-color: yellow; color: black; }
+!!! example
+ === "foo.py"
+ ```python
+ def bar():
+ """Hello, world!
+
+ # Tips
+
+ - Stay hydrated.
+ """
```
-
- === "Result"
-
-
- special
- private
- property
- read-only
-
-
- As you can see, CSS is not my field of predilection...
+
+ === "doc1.md"
+ ```md
+ ::: foo.bar
+ ```
+
+ === "doc2.md"
+ ```md
+ Check out the [tips][foo.bar--tips]
+ ```
+
+ === "Result HTML for doc2"
+ ```html
+ Check out the tips
+ ```
+
+The above tip about [Finding out the anchor](#finding-out-the-anchor) also applies the same way here.
+
+You may also notice that such a heading does not get rendered as a `` element directly, but rather the level gets shifted to fit the encompassing document structure. If you're curious about the implementation, check out [mkdocstrings.handlers.rendering.HeadingShiftingTreeprocessor][] and others.
+
## Watch directories
You can add directories to watch with the `watch` key.
It accepts a list of paths.
-```yaml
-plugins:
- - mkdocstrings:
- watch:
- - src/my_package_1
- - src/my_package_2
-```
+!!! example "mkdocs.yml"
+ ```yaml
+ plugins:
+ - mkdocstrings:
+ watch:
+ - src/my_package_1
+ - src/my_package_2
+ ```
+
When serving your documentation
and a change occur in one of the listed path,
MkDocs will rebuild the site and reload the current page.
@@ -309,4 +274,4 @@ MkDocs will rebuild the site and reload the current page.
For example, it will not tell the Python handler to look for packages in these paths
(the paths are not added to the `PYTHONPATH` variable).
If you want to tell Python where to look for packages and modules,
- see [Python Handler: Finding modules](../handlers/python/#finding-modules).
+ see [Python Handler: Finding modules](handlers/python.md#finding-modules).
diff --git a/duties.py b/duties.py
index f21ce190..be0f8d90 100644
--- a/duties.py
+++ b/duties.py
@@ -3,21 +3,15 @@
import os
import re
import sys
-from itertools import chain
-from pathlib import Path
from shutil import which
from typing import List, Optional, Pattern
import httpx
-import toml
from duty import duty
from git_changelog.build import Changelog, Version
-from jinja2 import StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
-from pip._internal.commands.show import search_packages_info
-PY_SRC_PATHS = (Path(_) for _ in ("src/mkdocstrings", "tests", "duties.py"))
-PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS)
+PY_SRC_LIST = ("src/mkdocstrings", "tests", "duties.py", "docs")
PY_SRC = " ".join(PY_SRC_LIST)
TESTING = os.environ.get("TESTING", "0") in {"1", "true"}
CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""}
@@ -26,8 +20,7 @@
def latest(lines: List[str], regex: Pattern) -> Optional[str]:
- """
- Return the last released version.
+ """Return the last released version.
Arguments:
lines: Lines of the changelog file.
@@ -44,8 +37,7 @@ def latest(lines: List[str], regex: Pattern) -> Optional[str]:
def unreleased(versions: List[Version], last_release: str) -> List[Version]:
- """
- Return the most recent versions down to latest release.
+ """Return the most recent versions down to latest release.
Arguments:
versions: All the versions (released and unreleased).
@@ -61,8 +53,7 @@ def unreleased(versions: List[Version], last_release: str) -> List[Version]:
def read_changelog(filepath: str) -> List[str]:
- """
- Read the changelog file.
+ """Read the changelog file.
Arguments:
filepath: The path to the changelog file.
@@ -75,8 +66,7 @@ def read_changelog(filepath: str) -> List[str]:
def write_changelog(filepath: str, lines: List[str]) -> None:
- """
- Write the changelog file.
+ """Write the changelog file.
Arguments:
filepath: The path to the changelog file.
@@ -93,8 +83,7 @@ def update_changelog(
template_url: str,
commit_style: str,
) -> None:
- """
- Update the given changelog file in place.
+ """Update the given changelog file in place.
Arguments:
inplace_file: The file to update in-place.
@@ -103,7 +92,7 @@ def update_changelog(
template_url: The URL to the Jinja template used to render contents.
commit_style: The style of commit messages to parse.
"""
- env = SandboxedEnvironment(autoescape=True)
+ env = SandboxedEnvironment(autoescape=False)
template = env.from_string(httpx.get(template_url).text)
changelog = Changelog(".", style=commit_style) # noqa: W0621 (shadowing changelog)
@@ -126,8 +115,7 @@ def update_changelog(
@duty
def changelog(ctx):
- """
- Update the changelog in-place with latest commits.
+ """Update the changelog in-place with latest commits.
Arguments:
ctx: The context instance (passed automatically).
@@ -148,8 +136,7 @@ def changelog(ctx):
@duty(pre=["check_code_quality", "check_types", "check_docs", "check_dependencies"])
def check(ctx): # noqa: W0613 (no use for the context argument)
- """
- Check it all!
+ """Check it all!
Arguments:
ctx: The context instance (passed automatically).
@@ -158,20 +145,18 @@ def check(ctx): # noqa: W0613 (no use for the context argument)
@duty
def check_code_quality(ctx, files=PY_SRC):
- """
- Check the code quality.
+ """Check the code quality.
Arguments:
ctx: The context instance (passed automatically).
files: The files to check.
"""
- ctx.run(f"flakehell lint {files}", title="Checking code quality", pty=PTY)
+ ctx.run(f"flake8 --config=config/flake8.ini {files}", title="Checking code quality", pty=PTY)
@duty
def check_dependencies(ctx):
- """
- Check for vulnerabilities in dependencies.
+ """Check for vulnerabilities in dependencies.
Arguments:
ctx: The context instance (passed automatically).
@@ -185,8 +170,14 @@ def check_dependencies(ctx):
else:
safety = "safety"
nofail = True
+
+ # Ignore tornado/39462 as there is currently no fix
+ # See https://github.com/tornadoweb/tornado/issues/2981
+ ignored_cves = "39462"
+
ctx.run(
- f"poetry export -f requirements.txt --without-hashes | {safety} check --stdin --full-report",
+ "poetry export -f requirements.txt --without-hashes | "
+ f"{safety} check --stdin --full-report -i {ignored_cves}",
title="Checking dependencies",
pty=PTY,
nofail=nofail,
@@ -195,32 +186,29 @@ def check_dependencies(ctx):
@duty
def check_docs(ctx):
- """
- Check if the documentation builds correctly.
+ """Check if the documentation builds correctly.
Arguments:
ctx: The context instance (passed automatically).
"""
- # pytkdocs fails on Python 3.9 for now
- nofail = sys.version.startswith("3.9")
+ # mkdocs-gen-files works on 3.7+ only
+ nofail = sys.version_info < (3, 7)
ctx.run("mkdocs build -s", title="Building documentation", pty=PTY, nofail=nofail, quiet=nofail)
@duty
def check_types(ctx):
- """
- Check that the code is correctly typed.
+ """Check that the code is correctly typed.
Arguments:
ctx: The context instance (passed automatically).
"""
- ctx.run(f"mypy --config-file config/mypy.ini {PY_SRC}", title="Type-checking", pty=PTY)
+ ctx.run(f"mypy --config-file config/mypy.ini {PY_SRC}", title="Type-checking", pty=PTY, progress=True)
@duty(silent=True)
def clean(ctx):
- """
- Delete temporary files.
+ """Delete temporary files.
Arguments:
ctx: The context instance (passed automatically).
@@ -236,81 +224,9 @@ def clean(ctx):
ctx.run("find . -name '*.rej' -delete")
-def get_credits_data() -> dict:
- """
- Return data used to generate the credits file.
-
- Returns:
- Data required to render the credits template.
- """
- project_dir = Path(__file__).parent.parent
- metadata = toml.load(project_dir / "pyproject.toml")["tool"]["poetry"]
- lock_data = toml.load(project_dir / "poetry.lock")
- project_name = metadata["name"]
-
- poetry_dependencies = chain(metadata["dependencies"].keys(), metadata["dev-dependencies"].keys())
- direct_dependencies = {dep.lower() for dep in poetry_dependencies}
- direct_dependencies.remove("python")
- indirect_dependencies = {pkg["name"].lower() for pkg in lock_data["package"]}
- indirect_dependencies -= direct_dependencies
- dependencies = direct_dependencies | indirect_dependencies
-
- packages = {}
- for pkg in search_packages_info(dependencies):
- pkg = {_: pkg[_] for _ in ("name", "home-page")}
- packages[pkg["name"].lower()] = pkg
-
- for dependency in dependencies:
- if dependency not in packages:
- pkg_data = httpx.get(f"https://pypi.python.org/pypi/{dependency}/json").json()["info"]
- home_page = pkg_data["home_page"] or pkg_data["project_url"] or pkg_data["package_url"]
- pkg_name = pkg_data["name"]
- package = {"name": pkg_name, "home-page": home_page}
- packages.update({pkg_name.lower(): package})
-
- return {
- "project_name": project_name,
- "direct_dependencies": sorted(direct_dependencies),
- "indirect_dependencies": sorted(indirect_dependencies),
- "package_info": packages,
- }
-
-
@duty
-def docs_regen(ctx):
- """
- Regenerate some documentation pages.
-
- Arguments:
- ctx: The context instance (passed automatically).
- """
- url_prefix = "https://raw.githubusercontent.com/pawamoy/jinja-templates/master/"
- regen_list = (("CREDITS.md", get_credits_data, url_prefix + "credits.md"),)
-
- def regen() -> int:
- """
- Regenerate pages listed in global `REGEN` list.
-
- Returns:
- An exit code.
- """
- env = SandboxedEnvironment(undefined=StrictUndefined)
- for target, get_data, template in regen_list:
- print("Regenerating", target)
- template_data = get_data()
- template_text = httpx.get(template).text
- rendered = env.from_string(template_text).render(**template_data)
- with open(target, "w") as stream:
- stream.write(rendered)
- return 0
-
- ctx.run(regen, title="Regenerating docfiles", pty=PTY)
-
-
-@duty(pre=[docs_regen])
def docs(ctx):
- """
- Build the documentation locally.
+ """Build the documentation locally.
Arguments:
ctx: The context instance (passed automatically).
@@ -318,10 +234,9 @@ def docs(ctx):
ctx.run("mkdocs build", title="Building documentation")
-@duty(pre=[docs_regen])
+@duty
def docs_serve(ctx, host="127.0.0.1", port=8000):
- """
- Serve the documentation (localhost:8000).
+ """Serve the documentation (localhost:8000).
Arguments:
ctx: The context instance (passed automatically).
@@ -331,21 +246,20 @@ def docs_serve(ctx, host="127.0.0.1", port=8000):
ctx.run(f"mkdocs serve -a {host}:{port}", title="Serving documentation", capture=False)
-@duty(pre=[docs_regen])
+@duty
def docs_deploy(ctx):
- """
- Deploy the documentation on GitHub pages.
+ """Deploy the documentation on GitHub pages.
Arguments:
ctx: The context instance (passed automatically).
"""
- ctx.run("mkdocs gh-deploy", title="Deploying documentation")
+ ctx.run("git remote set-url org-pages git@github.com:mkdocstrings/mkdocstrings.github.io", silent=True)
+ ctx.run("mkdocs gh-deploy --remote-name org-pages", title="Deploying documentation")
@duty
def format(ctx): # noqa: W0622 (we don't mind shadowing the format builtin)
- """
- Run formatting tools on the code.
+ """Run formatting tools on the code.
Arguments:
ctx: The context instance (passed automatically).
@@ -361,8 +275,7 @@ def format(ctx): # noqa: W0622 (we don't mind shadowing the format builtin)
@duty
def release(ctx, version):
- """
- Release a new Python package.
+ """Release a new Python package.
Arguments:
ctx: The context instance (passed automatically).
@@ -377,13 +290,12 @@ def release(ctx, version):
ctx.run("git push --tags", title="Pushing tags", pty=False)
ctx.run("poetry build", title="Building dist/wheel", pty=PTY)
ctx.run("poetry publish", title="Publishing version", pty=PTY)
- ctx.run("mkdocs gh-deploy", title="Deploying documentation", pty=PTY)
+ docs_deploy.run()
@duty(silent=True)
def coverage(ctx):
- """
- Report coverage as text and HTML.
+ """Report coverage as text and HTML.
Arguments:
ctx: The context instance (passed automatically).
@@ -392,15 +304,17 @@ def coverage(ctx):
ctx.run("coverage html --rcfile=config/coverage.ini")
-@duty(pre=[duty(lambda ctx: ctx.run("rm -f .coverage", silent=True))])
-def test(ctx, match=""):
- """
- Run the test suite.
+@duty
+def test(ctx, cleancov: bool = True, match: str = ""):
+ """Run the test suite.
Arguments:
ctx: The context instance (passed automatically).
+ cleancov: Whether to remove the `.coverage` file before running the tests.
match: A pytest expression to filter selected tests.
"""
+ if cleancov:
+ ctx.run("rm -f .coverage", silent=True)
ctx.run(
["pytest", "-c", "config/pytest.ini", "-n", "auto", "-k", match, "tests"],
title="Running tests",
diff --git a/mkdocs.yml b/mkdocs.yml
index 30f627f8..80dbc607 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,27 +1,36 @@
site_name: "mkdocstrings"
site_description: "Automatic documentation from sources, for MkDocs."
-site_url: "https://pawamoy.github.io/mkdocstrings"
-repo_url: "https://github.com/pawamoy/mkdocstrings"
-repo_name: "pawamoy/mkdocstrings"
+site_url: "https://mkdocstrings.github.io/"
+repo_url: "https://github.com/mkdocstrings/mkdocstrings"
+edit_uri: "blob/master/docs/"
+repo_name: "mkdocstrings/mkdocstrings"
nav:
- Overview: index.md
-- Usage: usage.md
-- Handlers:
- - Overview: handlers/overview.md
- - Python: handlers/python.md
-
+- Usage:
+ - usage.md
+ - Theming: theming.md
+ - Handlers:
+ - handlers/overview.md
+ - Python: handlers/python.md
+ - Crystal: https://mkdocstrings.github.io/crystal/
+ - Troubleshooting: troubleshooting.md
- Code Reference:
- - handlers:
- - base.py: reference/handlers/base.md
- - python.py: reference/handlers/python.md
- - extension.py: reference/extension.md
- - plugin.py: reference/plugin.md
- - references.py: reference/references.md
-
-- Troubleshooting: troubleshooting.md
-- Contributing: contributing.md
-- Code of Conduct: code_of_conduct.md
+ - mkdocstrings:
+ - handlers:
+ - base.py: reference/handlers/base.md
+ - rendering.py: reference/handlers/rendering.md
+ - python.py: reference/handlers/python.md
+ - extension.py: reference/extension.md
+ - plugin.py: reference/plugin.md
+ - loggers.py: reference/loggers.md
+ - mkdocs_autorefs:
+ - references.py: reference/autorefs/references.md
+ - plugin.py: reference/autorefs/plugin.md
+- Contributing:
+ - contributing.md
+ - Code of Conduct: code_of_conduct.md
+ - Coverage report: coverage.md
- Changelog: changelog.md
- Credits: credits.md
- License: license.md
@@ -34,13 +43,14 @@ theme:
accent: purple
extra_css:
-- css/mkdocstrings.css
+- css/style.css
markdown_extensions:
- admonition
-- markdown_include.include
- pymdownx.emoji
- pymdownx.magiclink
+- pymdownx.snippets:
+ check_paths: true
- pymdownx.superfences
- pymdownx.tabbed
- pymdownx.tasklist
@@ -49,6 +59,13 @@ markdown_extensions:
plugins:
- search
+- gen-files:
+ scripts:
+ - docs/gen_credits.py
+ - docs/gen_doc_stubs.py
+- section-index
+- coverage:
+ html_report_dir: build/coverage
- mkdocstrings:
handlers:
python:
diff --git a/pyproject.toml b/pyproject.toml
index 3bb57576..cf5c7286 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,13 +4,13 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "mkdocstrings"
-version = "0.14.0"
+version = "0.15.0"
description = "Automatic documentation from sources, for MkDocs."
authors = ["TimothΓ©e Mazzucotelli "]
license = "ISC License"
readme = "README.md"
-repository = "https://github.com/pawamoy/mkdocstrings"
-homepage = "https://github.com/pawamoy/mkdocstrings"
+repository = "https://github.com/mkdocstrings/mkdocstrings"
+homepage = "https://github.com/mkdocstrings/mkdocstrings"
keywords = ["mkdocs", "mkdocs-plugin", "docstrings", "autodoc", "documentation"]
packages = [ { include = "mkdocstrings", from = "src" } ]
include = [
@@ -24,33 +24,35 @@ Jinja2 = "^2.11"
Markdown = "^3.3"
MarkupSafe = "^1.1"
mkdocs = "^1.1"
+mkdocs-autorefs = "^0.1"
pymdown-extensions = ">=6.3, <9.0"
-pytkdocs = ">=0.2.0, <0.11.0"
+pytkdocs = ">=0.2.0, <0.12.0"
[tool.poetry.dev-dependencies]
autoflake = "^1.4"
black = "^20.8b1"
-duty = "^0.5.0"
-flakehell = "^0.6.0"
+duty = "^0.6.0"
+flakehell = "^0.9.0"
flake8-black = "^0.2.1"
flake8-builtins = "^1.5.3"
-flake8-tidy-imports = "^4.1.0"
-flake8-variables-names = "^0.0.3"
+flake8-tidy-imports = "^4.2.1"
+flake8-variables-names = "^0.0.4"
flake8-pytest-style = "^1.3.0"
-git-changelog = "^0.4.0"
-httpx = "^0.14.3"
-ipython = "^7.2"
-isort = "^5.7.0"
+git-changelog = "^0.4.2"
+httpx = "^0.16.1"
+isort = {version = "^5.7.0", extras = ["pyproject"]}
jinja2-cli = "^0.7.0"
-markdown-include = "^0.6.0"
-mkdocs-material = "^5.5.12"
+mkdocs-coverage = "^0.2.1"
+mkdocs-gen-files = {version = "^0.3.0", markers = "python_version>='3.7'"}
+mkdocs-material = "^6.2.7"
+mkdocs-section-index = "^0.2.3"
mypy = "^0.782"
-pytest = "^6.0.1"
-pytest-cov = "^2.10.1"
-pytest-randomly = "^3.4.1"
+pytest = "^6.2.2"
+pytest-cov = "^2.11.1"
+pytest-randomly = "^3.5.0"
pytest-sugar = "^0.9.4"
-pytest-xdist = "^2.1.0"
-toml = "^0.10.1"
+pytest-xdist = "^2.2.0"
+toml = "^0.10.2"
darglint = "^1.5.8"
flake8-bandit = "^2.1.2"
flake8-bugbear = "^20.11.1"
@@ -75,36 +77,3 @@ balanced_wrapping = true
default_section = "THIRDPARTY"
known_first_party = "mkdocstrings"
include_trailing_comma = true
-
-[tool.flakehell]
-format = "colored"
-max_line_length = 132
-show_source = false
-exclude = ["tests/fixtures"]
-
-[tool.flakehell.plugins]
-"*" = [
- "+*",
- "-RST*", # we write docstrings in markdown, not rst
- "-A001", # redundant with W0622 (builtin override), which is more precise about line number
- "-D105", # missing docstring in magic method
- "-D212", # multi-line docstring summary should start at the first line
- "-E203", # whitespace before β:β (incompatible with Black)
- "-F821", # redundant with E0602 (undefined variable)
- "-Q000", # black already deals with quoting
- "-S101", # use of assert
- "-W503", # line break before binary operator (incompatible with Black)
- "-C0103", # two-lowercase-letters variable DO conform to snake_case naming style
- "-C0116", # redunant with D102 (missing docstring)
- "-C0301", # line too long
- "-R0902", # too many instance attributes
- "-R0903", # too few public methods
- "-R0904", # too many public methods
- "-R0912", # too many branches
- "-R0913", # too many methods
- "-R0914", # too many local variables
- "-R0915", # too many statements
- "-W0611", # redundant with F401 (unused import)
- "-W1203", # lazy formatting for logging calls
- "-VNE001", # short name
-]
diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py
index 50021030..c9f42e6d 100644
--- a/src/mkdocstrings/extension.py
+++ b/src/mkdocstrings/extension.py
@@ -1,5 +1,4 @@
-"""
-This module holds the code of the Markdown extension responsible for matching "autodoc" instructions.
+"""This module holds the code of the Markdown extension responsible for matching "autodoc" instructions.
The extension is composed of a Markdown [block processor](https://python-markdown.github.io/extensions/api/#blockparser)
that matches indented blocks starting with a line like '::: identifier'.
@@ -24,8 +23,8 @@
"""
import re
from collections import ChainMap
-from typing import Any, Mapping, MutableSequence, Tuple
-from xml.etree.ElementTree import XML, Element, ParseError # noqa: S405 (we choose to trust the XML input)
+from typing import Mapping, MutableSequence, Sequence, Tuple
+from xml.etree.ElementTree import Element
import yaml
from jinja2.exceptions import TemplateNotFound
@@ -33,58 +32,23 @@
from markdown.blockparser import BlockParser
from markdown.blockprocessors import BlockProcessor
from markdown.extensions import Extension
-from markdown.util import AtomicString
+from markdown.treeprocessors import Treeprocessor
+from mkdocs_autorefs.plugin import AutorefsPlugin
-from mkdocstrings.handlers.base import CollectionError, Handlers
+from mkdocstrings.handlers.base import CollectionError, CollectorItem, Handlers
from mkdocstrings.loggers import get_logger
-from mkdocstrings.references import AutoRefInlineProcessor
-
-log = get_logger(__name__)
-
-ENTITIES = """
-
-
-
-
-
-
-
-
-
-
-
- ]>
-"""
+try:
+ from mkdocs.exceptions import PluginError # New in MkDocs 1.2
+except ImportError:
+ PluginError = SystemExit
-def atomic_brute_cast(tree: Element) -> Element:
- """
- Cast every node's text into an atomic string to prevent further processing on it.
-
- Since we generate the final HTML with Jinja templates, we do not want other inline or tree processors
- to keep modifying the data, so this function is used to mark the complete tree as "do not touch".
-
- Reference: issue [Python-Markdown/markdown#920](https://github.com/Python-Markdown/markdown/issues/920).
- On a side note: isn't `atomic_brute_cast` such a beautiful function name?
-
- Arguments:
- tree: An XML node, used like the root of an XML tree.
-
- Returns:
- The same node, recursively modified by side-effect. You can skip re-assigning the return value.
- """
- if tree.text:
- tree.text = AtomicString(tree.text)
- for child in tree:
- atomic_brute_cast(child)
- return tree
+log = get_logger(__name__)
class AutoDocProcessor(BlockProcessor):
- """
- Our "autodoc" Markdown block processor.
+ """Our "autodoc" Markdown block processor.
It has a [`test` method][mkdocstrings.extension.AutoDocProcessor.test] that tells if a block matches a criterion,
and a [`run` method][mkdocstrings.extension.AutoDocProcessor.run] that processes it.
@@ -95,9 +59,10 @@ class AutoDocProcessor(BlockProcessor):
regex = re.compile(r"^(?P#{1,6} *|)::: ?(?P.+?) *$", flags=re.MULTILINE)
- def __init__(self, parser: BlockParser, md: Markdown, config: dict, handlers: Handlers) -> None:
- """
- Initialize the object.
+ def __init__(
+ self, parser: BlockParser, md: Markdown, config: dict, handlers: Handlers, autorefs: AutorefsPlugin
+ ) -> None:
+ """Initialize the object.
Arguments:
parser: A `markdown.blockparser.BlockParser` instance.
@@ -105,16 +70,17 @@ def __init__(self, parser: BlockParser, md: Markdown, config: dict, handlers: Ha
config: The [configuration][mkdocstrings.plugin.MkdocstringsPlugin.config_scheme]
of the `mkdocstrings` plugin.
handlers: A [mkdocstrings.handlers.base.Handlers][] instance.
+ autorefs: A [mkdocs_autorefs.plugin.AutorefsPlugin][] instance.
"""
super().__init__(parser=parser)
self.md = md
self._config = config
self._handlers = handlers
+ self._autorefs = autorefs
self._updated_env = False
def test(self, parent: Element, block: str) -> bool:
- """
- Match our autodoc instructions.
+ """Match our autodoc instructions.
Arguments:
parent: The parent element in the XML tree.
@@ -126,8 +92,7 @@ def test(self, parent: Element, block: str) -> bool:
return bool(self.regex.search(block))
def run(self, parent: Element, blocks: MutableSequence[str]) -> None:
- """
- Run code on the matched blocks.
+ """Run code on the matched blocks.
The identifier and configuration lines are retrieved from a matched block
and used to collect and render an object.
@@ -151,8 +116,18 @@ def run(self, parent: Element, blocks: MutableSequence[str]) -> None:
identifier = match["name"]
heading_level = match["heading"].count("#")
log.debug(f"Matched '::: {identifier}'")
- xml_element = self.process_block(identifier, block, heading_level)
- parent.append(xml_element)
+
+ html, headings = self._process_block(identifier, block, heading_level)
+ el = Element("div", {"class": "mkdocstrings"})
+ # The final HTML is inserted as opaque to subsequent processing, and only revealed at the end.
+ el.text = self.md.htmlStash.store(html)
+ # So we need to duplicate the headings directly (and delete later), just so 'toc' can pick them up.
+ el.extend(headings)
+
+ for heading in headings:
+ self._autorefs.register_anchor(self._autorefs.current_page, heading.attrib["id"])
+
+ parent.append(el)
if the_rest:
# This block contained unindented line(s) after the first indented
@@ -160,9 +135,8 @@ def run(self, parent: Element, blocks: MutableSequence[str]) -> None:
# list for future processing.
blocks.insert(0, the_rest)
- def process_block(self, identifier: str, yaml_block: str, heading_level: int = 0) -> Element:
- """
- Process an autodoc block.
+ def _process_block(self, identifier: str, yaml_block: str, heading_level: int = 0) -> Tuple[str, Sequence[Element]]:
+ """Process an autodoc block.
Arguments:
identifier: The identifier of the object to collect and render.
@@ -170,12 +144,11 @@ def process_block(self, identifier: str, yaml_block: str, heading_level: int = 0
heading_level: Suggested level of the the heading to insert (0 to ignore).
Raises:
- CollectionError: When something wrong happened during collection.
- ParseError: When the generated HTML could not be parsed as XML.
+ PluginError: When something wrong happened during collection.
TemplateNotFound: When a template used for rendering could not be found.
Returns:
- A new XML element.
+ Rendered HTML and the list of heading elements encoutered.
"""
config = yaml.safe_load(yaml_block) or {}
handler_name = self._handlers.get_handler_name(config)
@@ -190,14 +163,16 @@ def process_block(self, identifier: str, yaml_block: str, heading_level: int = 0
log.debug("Collecting data")
try:
- data: Any = handler.collector.collect(identifier, selection)
- except CollectionError:
- log.error(f"Could not collect '{identifier}'")
- raise
+ data: CollectorItem = handler.collector.collect(identifier, selection)
+ except CollectionError as exception:
+ log.error(str(exception))
+ if PluginError is SystemExit: # When MkDocs 1.2 is sufficiently common, this can be dropped.
+ log.error(f"Error reading page '{self._autorefs.current_page}':")
+ raise PluginError(f"Could not collect '{identifier}'") from exception
if not self._updated_env:
log.debug("Updating renderer's env")
- handler.renderer.update_env(self.md, self._config)
+ handler.renderer._update_env(self.md, self._config) # noqa: W0212 (protected member OK)
self._updated_env = True
log.debug("Rendering templates")
@@ -210,20 +185,11 @@ def process_block(self, identifier: str, yaml_block: str, heading_level: int = 0
)
raise
- log.debug("Loading HTML back into XML tree")
- rendered = ENTITIES + rendered
- try:
- xml_contents = XML(rendered)
- except ParseError as error:
- log_xml_parse_error(str(error), rendered)
- raise
-
- return atomic_brute_cast(xml_contents) # type: ignore
+ return (rendered, handler.renderer.get_headings())
def get_item_configs(handler_config: dict, config: dict) -> Tuple[Mapping, Mapping]:
- """
- Get the selection and rendering configuration merged into the global configuration of the given handler.
+ """Get the selection and rendering configuration merged into the global configuration of the given handler.
Arguments:
handler_config: The global configuration of a handler. It can be an empty dictionary.
@@ -237,74 +203,57 @@ def get_item_configs(handler_config: dict, config: dict) -> Tuple[Mapping, Mappi
return item_selection_config, item_rendering_config
-def log_xml_parse_error(error: str, xml_text: str) -> None:
- """
- Log an XML parsing error.
-
- If the error is a tag mismatch, augment the log message.
-
- Arguments:
- error: The error message (no traceback).
- xml_text: The XML text that generated the parsing error.
- """
- message = error
- mismatched_tag = "mismatched tag" in error
- undefined_entity = "undefined entity" in error
-
- if mismatched_tag or undefined_entity:
- line_column = error[error.rfind(":") + 1 :]
- line, column = line_column.split(", ")
- lineno = int(line[line.rfind(" ") + 1 :])
- columnno = int(column[column.rfind(" ") + 1 :])
-
- line = xml_text.split("\n")[lineno - 1]
- if mismatched_tag:
- character = line[columnno]
- message += (
- f" (character {character}):\n{line}\n"
- "If your Markdown contains angle brackets < >, try to wrap them between backticks `< >`, "
- "or replace them with < and >"
- )
- elif undefined_entity:
- message += f":\n{line}\n"
- log.error(message)
+class _PostProcessor(Treeprocessor):
+ def run(self, root: Element):
+ carry_text = ""
+ for el in reversed(root): # Reversed mainly for the ability to mutate during iteration.
+ if el.tag == "div" and el.get("class") == "mkdocstrings":
+ # Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML).
+ carry_text = (el.text or "") + carry_text
+ root.remove(el)
+ elif carry_text:
+ el.tail = (el.tail or "") + carry_text
+ carry_text = ""
+ if carry_text:
+ root.text = (root.text or "") + carry_text
class MkdocstringsExtension(Extension):
- """
- Our Markdown extension.
+ """Our Markdown extension.
It cannot work outside of `mkdocstrings`.
"""
- blockprocessor_priority = 75 # Right before markdown.blockprocessors.HashHeaderProcessor
- inlineprocessor_priority = 168 # Right after markdown.inlinepatterns.ReferenceInlineProcessor
-
- def __init__(self, config: dict, handlers: Handlers, **kwargs) -> None:
- """
- Initialize the object.
+ def __init__(self, config: dict, handlers: Handlers, autorefs: AutorefsPlugin, **kwargs) -> None:
+ """Initialize the object.
Arguments:
config: The configuration items from `mkdocs` and `mkdocstrings` that must be passed to the block processor
when instantiated in [`extendMarkdown`][mkdocstrings.extension.MkdocstringsExtension.extendMarkdown].
handlers: A [mkdocstrings.handlers.base.Handlers][] instance.
+ autorefs: A [mkdocs_autorefs.plugin.AutorefsPlugin][] instance.
kwargs: Keyword arguments used by `markdown.extensions.Extension`.
"""
super().__init__(**kwargs)
self._config = config
self._handlers = handlers
+ self._autorefs = autorefs
def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
- """
- Register the extension.
+ """Register the extension.
Add an instance of our [`AutoDocProcessor`][mkdocstrings.extension.AutoDocProcessor] to the Markdown parser.
Arguments:
md: A `markdown.Markdown` instance.
"""
- md.registerExtension(self)
- processor = AutoDocProcessor(md.parser, md, self._config, self._handlers)
- md.parser.blockprocessors.register(processor, "mkdocstrings", self.blockprocessor_priority)
- ref_processor = AutoRefInlineProcessor(md)
- md.inlinePatterns.register(ref_processor, "mkdocstrings", self.inlineprocessor_priority)
+ md.parser.blockprocessors.register(
+ AutoDocProcessor(md.parser, md, self._config, self._handlers, self._autorefs),
+ "mkdocstrings",
+ priority=75, # Right before markdown.blockprocessors.HashHeaderProcessor
+ )
+ md.treeprocessors.register(
+ _PostProcessor(md.parser),
+ "mkdocstrings_post",
+ priority=4, # Right after 'toc'.
+ )
diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py
index f2dac1c3..2a95e775 100644
--- a/src/mkdocstrings/handlers/base.py
+++ b/src/mkdocstrings/handlers/base.py
@@ -1,5 +1,4 @@
-"""
-Base module for handlers.
+"""Base module for handlers.
This module contains the base classes for implementing collectors, renderers, and the combination of the two: handlers.
@@ -9,25 +8,26 @@
- `teardown`, that will teardown all the cached handlers, and then clear the cache.
"""
-import functools
import importlib
-import re
-import textwrap
from abc import ABC, abstractmethod
from pathlib import Path
-from typing import Any, Dict, Optional, Sequence
-from xml.etree.ElementTree import Element # noqa: S405 (we choose to trust the XML input)
+from typing import Any, Dict, Iterable, Optional, Sequence
+from xml.etree.ElementTree import Element, tostring
from jinja2 import Environment, FileSystemLoader
from markdown import Markdown
-from markdown.extensions import Extension
-from markdown.treeprocessors import Treeprocessor
from markupsafe import Markup
-from pymdownx.highlight import Highlight
+from mkdocstrings.handlers.rendering import (
+ HeadingShiftingTreeprocessor,
+ Highlighter,
+ IdPrependingTreeprocessor,
+ MkdocstringsInnerExtension,
+)
from mkdocstrings.loggers import get_template_logger
-handlers_cache: Dict[str, Any] = {}
+CollectorItem = Any
+
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
@@ -39,80 +39,8 @@ class ThemeNotSupported(Exception):
"""An exception raised to tell a theme is not supported."""
-def do_highlight(
- src: str,
- guess_lang: bool = False,
- language: str = None,
- inline: bool = False,
- dedent: bool = True,
- line_nums: bool = False,
- line_start: int = 1,
-) -> str:
- """
- Highlight a code-snippet.
-
- This function is used as a filter in Jinja templates.
-
- Arguments:
- src: The code to highlight.
- guess_lang: Whether to guess the language or not.
- language: Explicitly tell what language to use for highlighting.
- inline: Whether to do inline highlighting.
- dedent: Whether to dedent the code before highlighting it or not.
- line_nums: Whether to add line numbers in the result.
- line_start: The line number to start with.
-
- Returns:
- The highlighted code as HTML text, marked safe (not escaped for HTML).
- """
- if dedent:
- src = textwrap.dedent(src)
-
- highlighter = Highlight(use_pygments=True, guess_lang=guess_lang, linenums=line_nums)
- result = highlighter.highlight(src=src, language=language, linestart=line_start, inline=inline)
-
- if inline:
- return Markup(f'{result.text}')
- return Markup(result)
-
-
-def do_js_highlight(
- src: str,
- guess_lang: bool = False, # noqa: W0613 (we must accept the same parameters as do_highlight)
- language: str = None,
- inline: bool = False,
- dedent: bool = True,
- line_nums: bool = False, # noqa: W0613
- line_start: int = 1, # noqa: W0613
-) -> str:
- """
- Prepare a code-snippet for JS highlighting.
-
- This function is used as a filter in Jinja templates.
-
- Arguments:
- src: The code to highlight.
- guess_lang: Whether to guess the language or not.
- language: Explicitly tell what language to use for highlighting.
- inline: Whether to do inline highlighting.
- dedent: Whether to dedent the code before highlighting it or not.
- line_nums: Whether to add line numbers in the result.
- line_start: The line number to start with.
-
- Returns:
- The code properly wrapped for later highlighting by JavaScript.
- """
- if dedent:
- src = textwrap.dedent(src)
- if inline:
- src = re.sub(r"\n\s*", "", src)
- return Markup(f'{src}')
- return Markup(f'')
-
-
def do_any(seq: Sequence, attribute: str = None) -> bool:
- """
- Check if at least one of the item in the sequence evaluates to true.
+ """Check if at least one of the item in the sequence evaluates to true.
The `any` builtin as a filter for Jinja templates.
@@ -128,32 +56,8 @@ def do_any(seq: Sequence, attribute: str = None) -> bool:
return any(_[attribute] for _ in seq)
-def do_convert_markdown(md: Markdown, text: str, heading_level: int, html_id: str = "") -> Markup:
- """
- Render Markdown text; for use inside templates.
-
- Arguments:
- md: A `markdown.Markdown` instance.
- text: The text to convert.
- heading_level: The base heading level to start all Markdown headings from.
- html_id: The HTML id of the element that's considered the parent of this element.
-
- Returns:
- An HTML string.
- """
- md.treeprocessors["mkdocstrings_headings"].shift_by = heading_level
- md.treeprocessors["mkdocstrings_ids"].id_prefix = html_id and html_id + "--"
- try:
- return Markup(md.convert(text))
- finally:
- md.treeprocessors["mkdocstrings_headings"].shift_by = 0
- md.treeprocessors["mkdocstrings_ids"].id_prefix = ""
- md.reset()
-
-
class BaseRenderer(ABC):
- """
- The base renderer class.
+ """The base renderer class.
Inherit from this class to implement a renderer.
@@ -161,14 +65,15 @@ class BaseRenderer(ABC):
You can also override the `update_env` method, to add more filters to the Jinja environment,
making them available in your Jinja templates.
- To define a fallback theme, add a `FALLBACK_THEME` class-variable.
+ To define a fallback theme, add a `fallback_theme` class-variable.
+ To add custom CSS, add an `extra_css` variable or create an 'style.css' file beside the templates.
"""
fallback_theme: str = ""
+ extra_css = ""
def __init__(self, directory: str, theme: str, custom_templates: Optional[str] = None) -> None:
- """
- Initialize the object.
+ """Initialize the object.
If the given theme is not supported (it does not exist), it will look for a `fallback_theme` attribute
in `self` to use as a fallback theme.
@@ -180,16 +85,22 @@ def __init__(self, directory: str, theme: str, custom_templates: Optional[str] =
"""
paths = []
- if custom_templates is not None:
- paths.append(Path(custom_templates) / directory / theme)
-
themes_dir = TEMPLATES_DIR / directory
paths.append(themes_dir / theme)
- if self.fallback_theme != "":
+ if self.fallback_theme:
paths.append(themes_dir / self.fallback_theme)
+ for path in paths:
+ css_path = path / "style.css"
+ if css_path.is_file():
+ self.extra_css += "\n" + css_path.read_text(encoding="utf-8")
+ break
+
+ if custom_templates is not None:
+ paths.insert(0, Path(custom_templates) / directory / theme)
+
self.env = Environment(
autoescape=True,
loader=FileSystemLoader(paths),
@@ -198,17 +109,12 @@ def __init__(self, directory: str, theme: str, custom_templates: Optional[str] =
self.env.filters["any"] = do_any
self.env.globals["log"] = get_template_logger()
- if theme == "readthedocs":
- highlight_function = do_js_highlight
- else:
- highlight_function = do_highlight
-
- self.env.filters["highlight"] = highlight_function
+ self._headings = []
+ self._md = None # To be populated in `update_env`.
@abstractmethod
- def render(self, data: Any, config: dict) -> str:
- """
- Render a template using provided data and configuration options.
+ def render(self, data: CollectorItem, config: dict) -> str:
+ """Render a template using provided data and configuration options.
Arguments:
data: The collected data to render.
@@ -218,33 +124,128 @@ def render(self, data: Any, config: dict) -> str:
The rendered template as HTML.
""" # noqa: DAR202 (excess return section)
- def update_env(self, md: Markdown, config: dict) -> None:
+ def get_anchor(self, data: CollectorItem) -> Optional[str]:
+ """Return the canonical identifier (HTML anchor) for a collected item.
+
+ This must match what the renderer would've actually rendered,
+ e.g. if rendering the item contains `...` then the return value should be "foo".
+
+ Arguments:
+ data: The collected data.
+
+ Returns:
+ The HTML anchor (without '#') as a string, or None if this item doesn't have an anchor.
+ """ # noqa: DAR202 (excess return section)
+
+ def do_convert_markdown(self, text: str, heading_level: int, html_id: str = "") -> Markup:
+ """Render Markdown text; for use inside templates.
+
+ Arguments:
+ text: The text to convert.
+ heading_level: The base heading level to start all Markdown headings from.
+ html_id: The HTML id of the element that's considered the parent of this element.
+
+ Returns:
+ An HTML string.
"""
- Update the Jinja environment.
+ treeprocessors = self._md.treeprocessors
+ treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = heading_level
+ treeprocessors[IdPrependingTreeprocessor.name].id_prefix = html_id and html_id + "--"
+ try:
+ return Markup(self._md.convert(text))
+ finally:
+ treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0
+ treeprocessors[IdPrependingTreeprocessor.name].id_prefix = ""
+ self._md.reset()
+
+ def do_heading(
+ self,
+ content: str,
+ heading_level: int,
+ *,
+ hidden: bool = False,
+ toc_label: Optional[str] = None,
+ **attributes: str,
+ ) -> Markup:
+ """Render an HTML heading and register it for the table of contents. For use inside templates.
+
+ Arguments:
+ content: The HTML within the heading.
+ heading_level: The level of heading (e.g. 3 -> `h3`).
+ hidden: If True, only register it for the table of contents, don't render anything.
+ toc_label: The title to use in the table of contents ('data-toc-label' attribute).
+ attributes: Any extra HTML attributes of the heading.
+
+ Returns:
+ An HTML string.
+ """
+ # First, produce the "fake" heading, for ToC only.
+ el = Element(f"h{heading_level}", attributes)
+ if toc_label is None:
+ toc_label = content.unescape() if isinstance(el, Markup) else content
+ el.set("data-toc-label", toc_label)
+ self._headings.append(el)
+
+ if hidden:
+ return Markup('').format(attributes["id"])
+
+ # Now produce the actual HTML to be rendered. The goal is to wrap the HTML content into a heading.
+ # Start with a heading that has just attributes (no text), and add a placeholder into it.
+ el = Element(f"h{heading_level}", attributes)
+ el.append(Element("mkdocstrings-placeholder"))
+ # Tell the 'toc' extension to make its additions if configured so.
+ toc = self._md.treeprocessors["toc"]
+ if toc.use_anchors:
+ toc.add_anchor(el, attributes["id"])
+ if toc.use_permalinks:
+ toc.add_permalink(el, attributes["id"])
+
+ # The content we received is HTML, so it can't just be inserted into the tree. We had marked the middle
+ # of the heading with a placeholder that can never occur (text can't directly contain angle brackets).
+ # Now this HTML wrapper can be "filled" by replacing the placeholder.
+ html_with_placeholder = tostring(el, encoding="unicode")
+ assert (
+ html_with_placeholder.count("") == 1
+ ), f"Bug in mkdocstrings: failed to replace in {html_with_placeholder!r}"
+ html = html_with_placeholder.replace("", content)
+ return Markup(html)
+
+ def get_headings(self) -> Sequence[Element]:
+ """Return and clear the headings gathered so far.
+
+ Returns:
+ A list of HTML elements.
+ """
+ result = list(self._headings)
+ self._headings.clear()
+ return result
+
+ def update_env(self, md: Markdown, config: dict) -> None: # noqa: W0613 (unused argument 'config')
+ """Update the Jinja environment.
Arguments:
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.
"""
- extensions = config["mdx"] + [ShiftHeadingsExtension(), PrefixIdsExtension()]
- configs = dict(config["mdx_configs"])
- # Prevent a bug that happens due to treeprocessors running on the same fragment both as the inner doc and as
- # part of the re-integrated doc. Namely, the permalink 'ΒΆ' would be appended twice. This is the only known
- # non-idempotent effect of an extension, so specifically prevent it on the inner doc.
- try:
- configs["toc"] = dict(configs["toc"], permalink=False)
- except KeyError:
- pass
+ self._md = md
+ self.env.filters["highlight"] = Highlighter(md).highlight
+ self.env.filters["convert_markdown"] = self.do_convert_markdown
+ self.env.filters["heading"] = self.do_heading
- md = Markdown(extensions=extensions, extension_configs=configs)
+ def _update_env(self, md: Markdown, config: dict):
+ extensions = config["mdx"] + [MkdocstringsInnerExtension(self._headings)]
- self.env.filters["convert_markdown"] = functools.partial(do_convert_markdown, md)
+ new_md = Markdown(extensions=extensions, extension_configs=config["mdx_configs"])
+ # MkDocs adds its own (required) extension that's not part of the config. Propagate it.
+ if "relpath" in md.treeprocessors:
+ new_md.treeprocessors.register(md.treeprocessors["relpath"], "relpath", priority=0)
+
+ self.update_env(new_md, config)
class BaseCollector(ABC):
- """
- The base collector class.
+ """The base collector class.
Inherit from this class to implement a collector.
@@ -253,9 +254,8 @@ class BaseCollector(ABC):
"""
@abstractmethod
- def collect(self, identifier: str, config: dict) -> Any:
- """
- Collect data given an identifier and selection configuration.
+ def collect(self, identifier: str, config: dict) -> CollectorItem:
+ """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.
@@ -272,8 +272,7 @@ def collect(self, identifier: str, config: dict) -> Any:
""" # noqa: DAR202 (excess return section)
def teardown(self) -> None:
- """
- Teardown the collector.
+ """Teardown the collector.
This method should be implemented to, for example, terminate a subprocess
that was started when creating the collector instance.
@@ -281,8 +280,7 @@ def teardown(self) -> None:
class BaseHandler:
- """
- The base handler class.
+ """The base handler class.
Inherit from this class to implement a handler.
@@ -290,8 +288,7 @@ class BaseHandler:
"""
def __init__(self, collector: BaseCollector, renderer: BaseRenderer) -> None:
- """
- Initialize the object.
+ """Initialize the object.
Arguments:
collector: A collector instance.
@@ -302,16 +299,14 @@ def __init__(self, collector: BaseCollector, renderer: BaseRenderer) -> None:
class Handlers:
- """
- A collection of handlers.
+ """A collection of handlers.
Do not instantiate this directly. [The plugin][mkdocstrings.plugin.MkdocstringsPlugin] will keep one instance of
this for the purpose of caching. Use [mkdocstrings.plugin.MkdocstringsPlugin.get_handler][] for convenient access.
"""
def __init__(self, config: dict) -> None:
- """
- Initialize the object.
+ """Initialize the object.
Arguments:
config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code
@@ -320,9 +315,27 @@ def __init__(self, config: dict) -> None:
self._config = config
self._handlers: Dict[str, BaseHandler] = {}
- def get_handler_name(self, config: dict) -> str:
+ def get_anchor(self, identifier: str) -> Optional[str]:
+ """Return the canonical HTML anchor for the identifier, if any of the seen handlers can collect it.
+
+ Arguments:
+ identifier: The identifier (one that [collect][mkdocstrings.handlers.base.BaseCollector.collect] can accept).
+
+ Returns:
+ A string - anchor without '#', or None if there isn't any identifier familiar with it.
"""
- Return the handler name defined in an "autodoc" instruction YAML configuration, or the global default handler.
+ for handler in self._handlers.values():
+ try:
+ anchor = handler.renderer.get_anchor(handler.collector.collect(identifier, {}))
+ except CollectionError:
+ continue
+ else:
+ if anchor is not None:
+ return anchor
+ return None
+
+ def get_handler_name(self, config: dict) -> str:
+ """Return the handler name defined in an "autodoc" instruction YAML configuration, or the global default handler.
Arguments:
config: A configuration dictionary, obtained from YAML below the "autodoc" instruction.
@@ -336,8 +349,7 @@ def get_handler_name(self, config: dict) -> str:
return config["default_handler"]
def get_handler_config(self, name: str) -> dict:
- """
- Return the global configuration of the given handler.
+ """Return the global configuration of the given handler.
Arguments:
name: The name of the handler to get the global configuration of.
@@ -351,8 +363,7 @@ def get_handler_config(self, name: str) -> dict:
return {}
def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseHandler:
- """
- Get a handler thanks to its name.
+ """Get a handler thanks to its name.
This function dynamically imports a module named "mkdocstrings.handlers.NAME", calls its
`get_handler` method to get an instance of a handler, and caches it in dictionary.
@@ -378,91 +389,18 @@ def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseH
) # type: ignore
return self._handlers[name]
- def teardown(self):
- """Teardown all cached handlers and clear the cache."""
- for handler in self._handlers.values():
- handler.collector.teardown()
- self._handlers.clear()
-
+ @property
+ def seen_handlers(self) -> Iterable[BaseHandler]:
+ """Get the handlers that were encountered so far throughout the build.
-class _IdPrependingTreeprocessor(Treeprocessor):
- def __init__(self, md, id_prefix: str):
- super().__init__(md)
- self.id_prefix = id_prefix
-
- def run(self, root: Element):
- if not self.id_prefix:
- return
- for el in root.iter():
- id_attr = el.get("id")
- if id_attr:
- el.set("id", self.id_prefix + id_attr)
-
- href_attr = el.get("href")
- if href_attr and href_attr.startswith("#"):
- el.set("href", "#" + self.id_prefix + href_attr[1:])
-
- name_attr = el.get("name")
- if name_attr:
- el.set("name", self.id_prefix + name_attr)
-
- if el.tag == "label":
- for_attr = el.get("for")
- if for_attr:
- el.set("for", self.id_prefix + for_attr)
-
-
-class PrefixIdsExtension(Extension):
- """Prepend the configured prefix to IDs of all HTML elements."""
-
- treeprocessor_priority = 4 # Right after 'toc' (needed because that extension adds ids to headers).
-
- def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
- """
- Register the extension, with a treeprocessor under the name 'mkdocstrings_ids'.
-
- Arguments:
- md: A `markdown.Markdown` instance.
- """
- md.registerExtension(self)
- md.treeprocessors.register(
- _IdPrependingTreeprocessor(md, ""),
- "mkdocstrings_ids",
- self.treeprocessor_priority,
- )
-
-
-class _HeadingShiftingTreeprocessor(Treeprocessor):
- def __init__(self, md, shift_by: int):
- super().__init__(md)
- self.shift_by = shift_by
-
- def run(self, root: Element):
- if not self.shift_by:
- return
- for el in root.iter():
- match = re.fullmatch(r"([Hh])([1-6])", el.tag)
- if match:
- level = int(match[2]) + self.shift_by
- level = max(1, min(level, 6))
- el.tag = f"{match[1]}{level}"
-
-
-class ShiftHeadingsExtension(Extension):
- """Shift levels of all Markdown headings according to the configured base level."""
-
- treeprocessor_priority = 12
-
- def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
+ Returns:
+ An iterable of instances of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler]
+ (usable only to loop through it).
"""
- Register the extension, with a treeprocessor under the name 'mkdocstrings_headings'.
+ return self._handlers.values()
- Arguments:
- md: A `markdown.Markdown` instance.
- """
- md.registerExtension(self)
- md.treeprocessors.register(
- _HeadingShiftingTreeprocessor(md, 0),
- "mkdocstrings_headings",
- self.treeprocessor_priority,
- )
+ def teardown(self) -> None:
+ """Teardown all cached handlers and clear the cache."""
+ for handler in self.seen_handlers:
+ handler.collector.teardown()
+ self._handlers.clear()
diff --git a/src/mkdocstrings/handlers/python.py b/src/mkdocstrings/handlers/python.py
index 95ccfc4d..a9e63786 100644
--- a/src/mkdocstrings/handlers/python.py
+++ b/src/mkdocstrings/handlers/python.py
@@ -1,5 +1,4 @@
-"""
-This module implements a handler for the Python language.
+"""This module implements a handler for the Python language.
The handler collects data with [`pytkdocs`](https://github.com/pawamoy/pytkdocs).
"""
@@ -7,21 +6,21 @@
import json
import os
import sys
+import traceback
from collections import ChainMap
from subprocess import PIPE, Popen # noqa: S404 (what other option, more secure that PIPE do we have? sockets?)
from typing import Any, List, Optional
from markdown import Markdown
-from mkdocstrings.handlers.base import BaseCollector, BaseHandler, BaseRenderer, CollectionError
+from mkdocstrings.handlers.base import BaseCollector, BaseHandler, BaseRenderer, CollectionError, CollectorItem
from mkdocstrings.loggers import get_logger
log = get_logger(__name__)
class PythonRenderer(BaseRenderer):
- """
- The class responsible for loading Jinja templates and rendering them.
+ """The class responsible for loading Jinja templates and rendering them.
It defines some configuration options, implements the `render` method,
and overrides the `update_env` method of the [`BaseRenderer` class][mkdocstrings.handlers.base.BaseRenderer].
@@ -47,8 +46,7 @@ class PythonRenderer(BaseRenderer):
"group_by_category": True,
"heading_level": 2,
}
- """
- The default rendering options.
+ """The default rendering options.
Option | Type | Description | Default
------ | ---- | ----------- | -------
@@ -65,7 +63,7 @@ class PythonRenderer(BaseRenderer):
**`heading_level`** | `int` | The initial heading level to use. | `2`
""" # noqa: E501
- def render(self, data: Any, config: dict) -> str: # noqa: D102 (ignore missing docstring)
+ def render(self, data: CollectorItem, config: dict) -> str: # noqa: D102 (ignore missing docstring)
final_config = ChainMap(config, self.default_config)
template = self.env.get_template(f"{data['category']}.html")
@@ -79,6 +77,9 @@ def render(self, data: Any, config: dict) -> str: # noqa: D102 (ignore missing
**{"config": final_config, data["category"]: data, "heading_level": heading_level, "root": True},
)
+ def get_anchor(self, data: CollectorItem) -> str: # noqa: D102 (ignore missing docstring)
+ return data.get("path")
+
def update_env(self, md: Markdown, config: dict) -> None: # noqa: D102 (ignore missing docstring)
super().update_env(md, config)
self.env.trim_blocks = True
@@ -87,16 +88,14 @@ def update_env(self, md: Markdown, config: dict) -> None: # noqa: D102 (ignore
class PythonCollector(BaseCollector):
- """
- The class responsible for loading Jinja templates and rendering them.
+ """The class responsible for loading Jinja templates and rendering them.
It defines some configuration options, implements the `render` method,
and overrides the `update_env` method of the [`BaseRenderer` class][mkdocstrings.handlers.base.BaseRenderer].
"""
default_config: dict = {"filters": ["!^_[^_]"]}
- """
- The default selection options.
+ """The default selection options.
Option | Type | Description | Default
------ | ---- | ----------- | -------
@@ -119,8 +118,7 @@ class PythonCollector(BaseCollector):
"""
def __init__(self, setup_commands: Optional[List[str]] = None) -> None:
- """
- Initialize the object.
+ """Initialize the object.
When instantiating a Python collector, we open a subprocess in the background with `subprocess.Popen`.
It will allow us to feed input to and read output from this subprocess, keeping it alive during
@@ -161,9 +159,8 @@ def __init__(self, setup_commands: Optional[List[str]] = None) -> None:
env=env,
)
- def collect(self, identifier: str, config: dict) -> Any:
- """
- Collect the documentation tree given an identifier and selection options.
+ def collect(self, identifier: str, config: dict) -> CollectorItem:
+ """Collect the documentation tree given an identifier and selection options.
In this method, we feed one line of JSON to the standard input of the subprocess that was opened
during instantiation of the collector. Then we read one line of JSON on its standard output.
@@ -208,15 +205,13 @@ def collect(self, identifier: str, config: dict) -> Any:
try:
result = json.loads(stdout)
except json.decoder.JSONDecodeError as exception:
- log.error(f"Error while loading JSON: {stdout}")
- raise CollectionError(str(exception)) from exception
+ error = "\n".join(("Error while loading JSON:", stdout, traceback.format_exc()))
+ raise CollectionError(error) from exception
error = result.get("error")
if error:
- message = f"Collection failed: {error}"
if "traceback" in result:
- message += f"\n{result['traceback']}"
- log.error(message)
+ error += f"\n{result['traceback']}"
raise CollectionError(error)
for loading_error in result["loading_errors"]:
@@ -250,8 +245,7 @@ def get_handler(
setup_commands: Optional[List[str]] = None,
**config: Any,
) -> PythonHandler:
- """
- Simply return an instance of `PythonHandler`.
+ """Simply return an instance of `PythonHandler`.
Arguments:
theme: The theme to use when rendering contents.
@@ -269,8 +263,7 @@ def get_handler(
def rebuild_category_lists(obj: dict) -> None:
- """
- Recursively rebuild the category lists of a collected object.
+ """Recursively rebuild the category lists of a collected object.
Since `pytkdocs` dumps JSON on standard output, it must serialize the object-tree and flatten it to reduce data
duplication and avoid cycle-references. Indeed, each node of the object-tree has a `children` list, containing
diff --git a/src/mkdocstrings/handlers/rendering.py b/src/mkdocstrings/handlers/rendering.py
new file mode 100644
index 00000000..ed9049ba
--- /dev/null
+++ b/src/mkdocstrings/handlers/rendering.py
@@ -0,0 +1,226 @@
+"""This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers."""
+
+import copy
+import re
+import textwrap
+from typing import List, Optional
+from xml.etree.ElementTree import Element
+
+from markdown import Markdown
+from markdown.extensions import Extension
+from markdown.extensions.codehilite import CodeHiliteExtension
+from markdown.treeprocessors import Treeprocessor
+from markupsafe import Markup
+from pymdownx.highlight import Highlight, HighlightExtension
+
+
+class Highlighter(Highlight):
+ """Code highlighter that tries to match the Markdown configuration.
+
+ Picking up the global config and defaults works only if you use the `codehilite` or
+ `pymdownx.highlight` (recommended) Markdown extension.
+
+ - If you use `pymdownx.highlight`, highlighting settings are picked up from it, and the
+ default CSS class is `.highlight`. This also means the default of `guess_lang: false`.
+
+ - Otherwise, if you use the `codehilite` extension, settings are picked up from it, and the
+ default CSS class is `.codehilite`. Also consider setting `guess_lang: false`.
+
+ - If neither are added to `markdown_extensions`, highlighting is enabled anyway. This is for
+ backwards compatibility. If you really want to disable highlighting even in *mkdocstrings*,
+ add one of these extensions anyway and set `use_pygments: false`.
+
+ The underlying implementation is `pymdownx.highlight` regardless.
+ """
+
+ _highlight_config_keys = frozenset(
+ "use_pygments guess_lang css_class pygments_style noclasses linenums language_prefix".split(),
+ )
+
+ def __init__(self, md: Markdown):
+ """Configure to match a `markdown.Markdown` instance.
+
+ Arguments:
+ md: The Markdown instance to read configs from.
+ """
+ config = {}
+ for ext in md.registeredExtensions:
+ if isinstance(ext, HighlightExtension) and (ext.enabled or not config):
+ config = ext.getConfigs()
+ break # This one takes priority, no need to continue looking
+ if isinstance(ext, CodeHiliteExtension) and not config:
+ config = ext.getConfigs()
+ config["language_prefix"] = config["lang_prefix"]
+ self._css_class = config.pop("css_class", "highlight")
+ super().__init__(**{k: v for k, v in config.items() if k in self._highlight_config_keys})
+
+ def highlight( # noqa: W0221 (intentionally different params, we're extending the functionality)
+ self,
+ src: str,
+ language: Optional[str] = None,
+ *,
+ inline: bool = False,
+ dedent: bool = True,
+ linenums: Optional[bool] = None,
+ **kwargs,
+ ) -> str:
+ """Highlight a code-snippet.
+
+ Arguments:
+ src: The code to highlight.
+ language: Explicitly tell what language to use for highlighting.
+ inline: Whether to highlight as inline.
+ dedent: Whether to dedent the code before highlighting it or not.
+ linenums: Whether to add line numbers in the result.
+ **kwargs: Pass on to `pymdownx.highlight.Highlight.highlight`.
+
+ Returns:
+ The highlighted code as HTML text, marked safe (not escaped for HTML).
+ """
+ if isinstance(src, Markup):
+ src = src.unescape()
+ if dedent:
+ src = textwrap.dedent(src)
+
+ kwargs.setdefault("css_class", self._css_class)
+ old_linenums = self.linenums
+ if linenums is not None:
+ self.linenums = linenums
+ try:
+ result = super().highlight(src, language, inline=inline, **kwargs)
+ finally:
+ self.linenums = old_linenums
+
+ if inline:
+ return Markup(f'{result.text}')
+ return Markup(result)
+
+
+class IdPrependingTreeprocessor(Treeprocessor):
+ """Prepend the configured prefix to IDs of all HTML elements."""
+
+ name = "mkdocstrings_ids"
+
+ id_prefix: str
+ """The prefix to add to every ID. It is prepended without any separator; specify your own separator if needed."""
+
+ def __init__(self, md: Markdown, id_prefix: str):
+ """Initialize the object.
+
+ Arguments:
+ md: A `markdown.Markdown` instance.
+ id_prefix: The prefix to add to every ID. It is prepended without any separator.
+ """
+ super().__init__(md)
+ self.id_prefix = id_prefix
+
+ def run(self, root: Element): # noqa: D102 (ignore missing docstring)
+ if not self.id_prefix:
+ return
+ for el in root.iter():
+ id_attr = el.get("id")
+ if id_attr:
+ el.set("id", self.id_prefix + id_attr)
+
+ href_attr = el.get("href")
+ if href_attr and href_attr.startswith("#"):
+ el.set("href", "#" + self.id_prefix + href_attr[1:])
+
+ name_attr = el.get("name")
+ if name_attr:
+ el.set("name", self.id_prefix + name_attr)
+
+ if el.tag == "label":
+ for_attr = el.get("for")
+ if for_attr:
+ el.set("for", self.id_prefix + for_attr)
+
+
+class HeadingShiftingTreeprocessor(Treeprocessor):
+ """Shift levels of all Markdown headings according to the configured base level."""
+
+ name = "mkdocstrings_headings"
+ regex = re.compile(r"([Hh])([1-6])")
+
+ shift_by: int
+ """The number of heading "levels" to add to every heading. `` with `shift_by = 3` becomes ``."""
+
+ def __init__(self, md: Markdown, shift_by: int):
+ """Initialize the object.
+
+ Arguments:
+ md: A `markdown.Markdown` instance.
+ shift_by: The number of heading "levels" to add to every heading.
+ """
+ super().__init__(md)
+ self.shift_by = shift_by
+
+ def run(self, root: Element): # noqa: D102 (ignore missing docstring)
+ if not self.shift_by:
+ return
+ for el in root.iter():
+ match = self.regex.fullmatch(el.tag)
+ if match:
+ level = int(match[2]) + self.shift_by
+ level = max(1, min(level, 6))
+ el.tag = f"{match[1]}{level}"
+
+
+class _HeadingReportingTreeprocessor(Treeprocessor):
+ """Records the heading elements encountered in the document."""
+
+ name = "mkdocstrings_headings_list"
+ regex = re.compile(r"[Hh][1-6]")
+
+ headings: List[Element]
+ """The list (the one passed in the initializer) that is used to record the heading elements (by appending to it)."""
+
+ def __init__(self, md: Markdown, headings: List[Element]):
+ super().__init__(md)
+ self.headings = headings
+
+ def run(self, root: Element):
+ for el in root.iter():
+ if self.regex.fullmatch(el.tag):
+ el = copy.copy(el)
+ # 'toc' extension's first pass (which we require to build heading stubs/ids) also edits the HTML.
+ # Undo the permalink edit so we can pass this heading to the outer pass of the 'toc' extension.
+ if len(el) > 0 and el[-1].get("class") == self.md.treeprocessors["toc"].permalink_class:
+ del el[-1]
+ self.headings.append(el)
+
+
+class MkdocstringsInnerExtension(Extension):
+ """Extension that should always be added to Markdown sub-documents that handlers request (and *only* them)."""
+
+ def __init__(self, headings: List[Element]):
+ """Initialize the object.
+
+ Arguments:
+ headings: A list that will be populated with all HTML heading elements encountered in the document.
+ """
+ super().__init__()
+ self.headings = headings
+
+ def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
+ """Register the extension.
+
+ Arguments:
+ md: A `markdown.Markdown` instance.
+ """
+ md.registerExtension(self)
+ md.treeprocessors.register(
+ HeadingShiftingTreeprocessor(md, 0),
+ HeadingShiftingTreeprocessor.name,
+ priority=12,
+ )
+ md.treeprocessors.register(
+ IdPrependingTreeprocessor(md, ""),
+ IdPrependingTreeprocessor.name,
+ priority=4, # Right after 'toc' (needed because that extension adds ids to headers).
+ )
+ md.treeprocessors.register(
+ _HeadingReportingTreeprocessor(md, self.headings),
+ _HeadingReportingTreeprocessor.name,
+ priority=1, # Close to the end.
+ )
diff --git a/src/mkdocstrings/loggers.py b/src/mkdocstrings/loggers.py
index 83b7b74e..1b1a5dff 100644
--- a/src/mkdocstrings/loggers.py
+++ b/src/mkdocstrings/loggers.py
@@ -2,21 +2,20 @@
import logging
from pathlib import Path
-from typing import Callable, Optional
+from typing import Any, Callable, Optional, Tuple
from jinja2 import contextfunction
from jinja2.runtime import Context
from mkdocs.utils import warning_filter
-from mkdocstrings.handlers import base
+TEMPLATES_DIR = Path(__file__).parent / "templates"
class LoggerAdapter(logging.LoggerAdapter):
"""A logger adapter to prefix messages."""
- def __init__(self, prefix, logger):
- """
- Initialize the object.
+ def __init__(self, prefix: str, logger):
+ """Initialize the object.
Arguments:
prefix: The string to insert in front of every message.
@@ -25,9 +24,8 @@ def __init__(self, prefix, logger):
super().__init__(logger, {})
self.prefix = prefix
- def process(self, msg, kwargs):
- """
- Process the message.
+ def process(self, msg: str, kwargs) -> Tuple[str, Any]:
+ """Process the message.
Arguments:
msg: The message:
@@ -40,8 +38,7 @@ def process(self, msg, kwargs):
class TemplateLogger:
- """
- A wrapper class to allow logging in templates.
+ """A wrapper class to allow logging in templates.
Attributes:
debug: Function to log a DEBUG message.
@@ -52,8 +49,7 @@ class TemplateLogger:
"""
def __init__(self, logger: LoggerAdapter):
- """
- Initialize the object.
+ """Initialize the object.
Arguments:
logger: A logger adapter.
@@ -66,8 +62,7 @@ def __init__(self, logger: LoggerAdapter):
def get_template_logger_function(logger_func: Callable) -> Callable:
- """
- Create a wrapper function that automatically receives the Jinja template context.
+ """Create a wrapper function that automatically receives the Jinja template context.
Arguments:
logger_func: The logger function to use within the wrapper.
@@ -78,8 +73,7 @@ def get_template_logger_function(logger_func: Callable) -> Callable:
@contextfunction
def wrapper(context: Context, msg: Optional[str] = None) -> str:
- """
- Log a message.
+ """Log a message.
Arguments:
context: The template context, automatically provided by Jinja.
@@ -96,8 +90,7 @@ def wrapper(context: Context, msg: Optional[str] = None) -> str:
def get_template_path(context: Context) -> str:
- """
- Return the path to the template currently using the given context.
+ """Return the path to the template currently using the given context.
Arguments:
context: The template context.
@@ -108,15 +101,14 @@ def get_template_path(context: Context) -> str:
filename = context.environment.get_template(context.name).filename
if filename:
try:
- return str(Path(filename).relative_to(base.TEMPLATES_DIR))
+ return str(Path(filename).relative_to(TEMPLATES_DIR))
except ValueError:
return filename
return context.name
def get_logger(name: str) -> LoggerAdapter:
- """
- Return a pre-configured logger.
+ """Return a pre-configured logger.
Arguments:
name: The name to use with `logging.getLogger`.
@@ -130,8 +122,7 @@ def get_logger(name: str) -> LoggerAdapter:
def get_template_logger() -> TemplateLogger:
- """
- Return a logger usable in templates.
+ """Return a logger usable in templates.
Returns:
A template logger.
diff --git a/src/mkdocstrings/plugin.py b/src/mkdocstrings/plugin.py
index 87f3421c..054cd2e0 100644
--- a/src/mkdocstrings/plugin.py
+++ b/src/mkdocstrings/plugin.py
@@ -1,19 +1,9 @@
-"""
-This module contains the `mkdocs` plugin.
+"""This module contains the "mkdocstrings" plugin for MkDocs.
The plugin instantiates a Markdown extension ([`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]),
and adds it to the list of Markdown extensions used by `mkdocs`
during the [`on_config` event hook](https://www.mkdocs.org/user-guide/plugins/#on_config).
-After each page is processed by the Markdown converter, this plugin stores absolute URLs of every HTML anchors
-it finds to later be able to fix unresolved references.
-It stores them during the [`on_page_contents` event hook](https://www.mkdocs.org/user-guide/plugins/#on_page_contents).
-
-Just before writing the final HTML to the disc, during the
-[`on_post_page` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_page),
-this plugin searches for references of the form `[identifier][]` or `[title][identifier]` that were not resolved,
-and fixes them using the previously stored identifier-URL mapping.
-
Once the documentation is built, the [`on_post_build` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_build)
is triggered and calls the [`handlers.teardown()` method][mkdocstrings.handlers.base.Handlers.teardown]. This method is
used to teardown the handlers that were instantiated during documentation buildup.
@@ -22,21 +12,19 @@
during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve).
"""
-import logging
import os
-from typing import Any, Callable, Dict, Optional, Tuple
+from typing import Callable, Optional, Tuple
from livereload import Server
from mkdocs.config import Config
from mkdocs.config.config_options import Type as MkType
from mkdocs.plugins import BasePlugin
-from mkdocs.structure.pages import Page
-from mkdocs.structure.toc import AnchorLink
+from mkdocs.utils import write_file
+from mkdocs_autorefs.plugin import AutorefsPlugin
from mkdocstrings.extension import MkdocstringsExtension
from mkdocstrings.handlers.base import BaseHandler, Handlers
from mkdocstrings.loggers import get_logger
-from mkdocstrings.references import fix_refs
log = get_logger(__name__)
@@ -47,19 +35,16 @@
class MkdocstringsPlugin(BasePlugin):
- """
- An `mkdocs` plugin.
+ """An `mkdocs` plugin.
This plugin defines the following event hooks:
- `on_config`
- - `on_page_contents`
- - `on_post_page`
- `on_post_build`
- `on_serve`
Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs`
- for more information about its plugin system..
+ for more information about its plugin system.
"""
config_scheme: Tuple[Tuple[str, MkType]] = (
@@ -99,15 +84,29 @@ class MkdocstringsPlugin(BasePlugin):
```
"""
+ css_filename = "assets/_mkdocstrings.css"
+
def __init__(self) -> None:
"""Initialize the object."""
super().__init__()
- self.url_map: Dict[Any, str] = {}
- self.handlers: Optional[Handlers] = None
+ self._handlers: Optional[Handlers] = None
- def on_serve(self, server: Server, builder: Callable = None, **kwargs) -> Server: # noqa: W0613 (unused arguments)
+ @property
+ def handlers(self) -> Handlers:
+ """Get the instance of [mkdocstrings.handlers.base.Handlers][] for this plugin/build.
+
+ Raises:
+ RuntimeError: If the plugin hasn't been initialized with a config.
+
+ Returns:
+ An instance of [mkdocstrings.handlers.base.Handlers][] (the same throughout the build).
"""
- Watch directories.
+ if not self._handlers:
+ raise RuntimeError("The plugin hasn't been initialized with a config yet")
+ return self._handlers
+
+ def on_serve(self, server: Server, builder: Callable = None, **kwargs) -> Server: # noqa: W0613 (unused arguments)
+ """Watch directories.
Hook for the [`on_serve` event](https://www.mkdocs.org/user-guide/plugins/#on_serve).
In this hook, we add the directories specified in the plugin's configuration to the list of directories
@@ -132,8 +131,7 @@ def on_serve(self, server: Server, builder: Callable = None, **kwargs) -> Server
return server
def on_config(self, config: Config, **kwargs) -> Config: # noqa: W0613 (unused arguments)
- """
- Instantiate our Markdown extension.
+ """Instantiate our Markdown extension.
Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
In this hook, we instantiate our [`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]
@@ -163,85 +161,30 @@ def on_config(self, config: Config, **kwargs) -> Config: # noqa: W0613 (unused
"mdx_configs": config["mdx_configs"],
"mkdocstrings": self.config,
}
-
- self.handlers = Handlers(extension_config)
- mkdocstrings_extension = MkdocstringsExtension(extension_config, self.handlers)
+ self._handlers = Handlers(extension_config)
+
+ try:
+ # If autorefs plugin is explicitly enabled, just use it.
+ autorefs = config["plugins"]["autorefs"]
+ log.debug(f"Picked up existing autorefs instance {autorefs!r}")
+ except KeyError:
+ # Otherwise, add a limited instance of it that acts only on what's added through `register_anchor`.
+ autorefs = AutorefsPlugin()
+ autorefs.scan_toc = False
+ config["plugins"]["autorefs"] = autorefs
+ log.debug(f"Added a subdued autorefs instance {autorefs!r}")
+ # Add collector-based fallback in either case.
+ autorefs.get_fallback_anchor = self._handlers.get_anchor
+
+ mkdocstrings_extension = MkdocstringsExtension(extension_config, self._handlers, autorefs)
config["markdown_extensions"].append(mkdocstrings_extension)
- return config
-
- def on_page_content(self, html: str, page: Page, **kwargs) -> str: # noqa: W0613 (unused arguments)
- """
- Map anchors to URLs.
-
- Hook for the [`on_page_contents` event](https://www.mkdocs.org/user-guide/plugins/#on_page_contents).
- In this hook, we map the IDs of every anchor found in the table of contents to the anchors absolute URLs.
- This mapping will be used later to fix unresolved reference of the form `[title][identifier]` or
- `[identifier][]`.
-
- Arguments:
- html: HTML converted from Markdown.
- page: The related MkDocs page instance.
- kwargs: Additional arguments passed by MkDocs.
-
- Returns:
- The same HTML. We only use this hook to map anchors to URLs.
- """
- log.debug(f"Mapping identifiers to URLs for page {page.file.src_path}")
- for item in page.toc.items:
- self.map_urls(page.url, item)
- return html
- def map_urls(self, base_url: str, anchor: AnchorLink) -> None:
- """
- Recurse on every anchor to map its ID to its absolute URL.
-
- This method populates `self.url_map` by side-effect.
-
- Arguments:
- base_url: The base URL to use as a prefix for each anchor's relative URL.
- anchor: The anchor to process and to recurse on.
- """
- self.url_map[anchor.id] = base_url + anchor.url
- for child in anchor.children:
- self.map_urls(base_url, child)
-
- def on_post_page(self, output: str, page: Page, **kwargs) -> str: # noqa: W0613 (unused arguments)
- """
- Fix cross-references.
-
- Hook for the [`on_post_page` event](https://www.mkdocs.org/user-guide/plugins/#on_post_page).
- In this hook, we try to fix unresolved references of the form `[title][identifier]` or `[identifier][]`.
- Doing that allows the user of `mkdocstrings` to cross-reference objects in their documentation strings.
- It uses the native Markdown syntax so it's easy to remember and use.
-
- We log a warning for each reference that we couldn't map to an URL, but try to be smart and ignore identifiers
- that do not look legitimate (sometimes documentation can contain strings matching
- our [`AUTO_REF_RE`][mkdocstrings.references.AUTO_REF_RE] regular expression that did not intend to reference anything).
- We currently ignore references when their identifier contains a space or a slash.
-
- Arguments:
- output: HTML converted from Markdown.
- page: The related MkDocs page instance.
- kwargs: Additional arguments passed by MkDocs.
-
- Returns:
- Modified HTML.
- """
- log.debug(f"Fixing references in page {page.file.src_path}")
-
- fixed_output, unmapped = fix_refs(output, page.url, self.url_map)
-
- if unmapped and log.isEnabledFor(logging.WARNING):
- for ref in unmapped:
- log.warning(
- f"{page.file.src_path}: Could not find cross-reference target '[{ref}]'",
- )
+ config["extra_css"].insert(0, self.css_filename) # So that it has lower priority than user files.
- return fixed_output
+ return config
- def on_post_build(self, **kwargs) -> None: # noqa: W0613,R0201 (unused arguments, cannot be static)
- """
- Teardown the handlers.
+ def on_post_build(self, config: Config, **kwargs) -> None: # noqa: W0613,R0201 (unused arguments, cannot be static)
+ """Teardown the handlers.
Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build).
This hook is used to teardown all the handlers that were instantiated and cached during documentation buildup.
@@ -253,25 +196,23 @@ def on_post_build(self, **kwargs) -> None: # noqa: W0613,R0201 (unused argument
this hook.
Arguments:
+ config: The MkDocs config object.
kwargs: Additional arguments passed by MkDocs.
"""
- if self.handlers:
+ if self._handlers:
+ css_content = "\n".join(handler.renderer.extra_css for handler in self.handlers.seen_handlers)
+ write_file(css_content.encode("utf-8"), os.path.join(config["site_dir"], self.css_filename))
+
log.debug("Tearing handlers down")
- self.handlers.teardown()
+ self._handlers.teardown()
def get_handler(self, handler_name: str) -> BaseHandler:
- """
- Get a handler by its name. See [mkdocstrings.handlers.base.Handlers.get_handler][].
+ """Get a handler by its name. See [mkdocstrings.handlers.base.Handlers.get_handler][].
Arguments:
handler_name: The name of the handler.
- Raises:
- RuntimeError: If the plugin hasn't been initialized with a config.
-
Returns:
An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler].
"""
- if not self.handlers:
- raise RuntimeError("The plugin hasn't been initialized with a config yet")
return self.handlers.get_handler(handler_name)
diff --git a/src/mkdocstrings/references.py b/src/mkdocstrings/references.py
deleted file mode 100644
index 38957677..00000000
--- a/src/mkdocstrings/references.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""Cross-references module."""
-
-import re
-from html import escape, unescape
-from typing import Any, Callable, Dict, List, Match, Tuple, Union
-from xml.etree.ElementTree import Element # noqa: S405 (input is our own, and Markdown coming from code)
-
-from markdown.inlinepatterns import REFERENCE_RE, ReferenceInlineProcessor
-
-AUTO_REF_RE = re.compile(r'[^"<>]*)\1>(?P.*?)')
-"""
-A regular expression to match mkdocstrings' special reference markers
-in the [`on_post_page` hook][mkdocstrings.plugin.MkdocstringsPlugin.on_post_page].
-"""
-
-EvalIDType = Tuple[Any, Any, Any]
-
-
-class AutoRefInlineProcessor(ReferenceInlineProcessor):
- """A Markdown extension."""
-
- def __init__(self, *args, **kwargs): # noqa: D107
- super().__init__(REFERENCE_RE, *args, **kwargs)
-
- # Code based on
- # https://github.com/Python-Markdown/markdown/blob/8e7528fa5c98bf4652deb13206d6e6241d61630b/markdown/inlinepatterns.py#L780
-
- def handleMatch(self, m, data) -> Union[Element, EvalIDType]: # noqa: N802 (parent's casing)
- """
- Handle an element that matched.
-
- Arguments:
- m: The match object.
- data: The matched data.
-
- Returns:
- A new element or a tuple.
- """
- text, index, handled = self.getText(data, m.end(0))
- if not handled:
- return None, None, None
-
- identifier, end, handled = self.evalId(data, index, text)
- if not handled:
- return None, None, None
-
- if re.search(r"[/ \x00-\x1f]", identifier): # type: ignore
- # Do nothing if the matched reference contains:
- # - a space, slash or control character (considered unintended);
- # - specifically \x01 is used by Python-Markdown HTML stash when there's inline formatting,
- # but references with Markdown formatting are not possible anyway.
- return None, m.start(0), end
-
- return self.makeTag(identifier, text), m.start(0), end
-
- def evalId(self, data: str, index: int, text: str) -> EvalIDType: # noqa: N802 (parent's casing)
- """
- Evaluate the id portion of `[ref][id]`.
-
- If `[ref][]` use `[ref]`.
-
- Arguments:
- data: The data to evaluate.
- index: The starting position.
- text: The text to use when no identifier.
-
- Returns:
- A tuple containing the identifier, its end position, and whether it matched.
- """
- m = self.RE_LINK.match(data, pos=index)
- if not m:
- return None, index, False
- identifier = m.group(1) or text
- end = m.end(0)
- return identifier, end, True
-
- def makeTag(self, identifier: str, text: str) -> Element: # noqa: N802,W0221 (parent's casing, different params)
- """
- Create a tag that can be matched by `AUTO_REF_RE`.
-
- Arguments:
- identifier: The identifier to use in the HTML property.
- text: The text to use in the HTML tag.
-
- Returns:
- A new element.
- """
- el = Element("span")
- el.set("data-mkdocstrings-identifier", identifier)
- el.text = text
- return el
-
-
-def relative_url(url_a: str, url_b: str) -> str:
- """
- Compute the relative path from URL A to URL B.
-
- Arguments:
- url_a: URL A.
- url_b: URL B.
-
- Returns:
- The relative URL to go from A to B.
- """
- parts_a = url_a.split("/")
- url_b, anchor = url_b.split("#", 1)
- parts_b = url_b.split("/")
-
- # remove common left parts
- while parts_a and parts_b and parts_a[0] == parts_b[0]:
- parts_a.pop(0)
- parts_b.pop(0)
-
- # go up as many times as remaining a parts' depth
- levels = len(parts_a) - 1
- parts_relative = [".."] * levels + parts_b
- relative = "/".join(parts_relative)
- return f"{relative}#{anchor}"
-
-
-def fix_ref(url_map: Dict[str, str], from_url: str, unmapped: List[str]) -> Callable:
- """
- Return a `repl` function for [`re.sub`](https://docs.python.org/3/library/re.html#re.sub).
-
- In our context, we match Markdown references and replace them with HTML links.
-
- When the matched reference's identifier was not mapped to an URL, we append the identifier to the outer
- `unmapped` list. It generally means the user is trying to cross-reference an object that was not collected
- and rendered, making it impossible to link to it. We catch this exception in the caller to issue a warning.
-
- Arguments:
- url_map: The mapping of objects and their URLs.
- from_url: The URL of the base page, from which we link towards the targeted pages.
- unmapped: A list to store unmapped identifiers.
-
- Returns:
- The actual function accepting a [`Match` object](https://docs.python.org/3/library/re.html#match-objects)
- and returning the replacement strings.
- """
-
- def inner(match: Match):
- identifier = match["identifier"]
- title = match["title"]
-
- try:
- url = relative_url(from_url, url_map[unescape(identifier)])
- except KeyError:
- unmapped.append(identifier)
- if title == identifier:
- return f"[{identifier}][]"
- return f"[{title}][{identifier}]"
-
- return f'{title}'
-
- return inner
-
-
-def fix_refs(
- html: str,
- from_url: str,
- url_map: Dict[str, str],
-) -> Tuple[str, List[str]]:
- """
- Fix all references in the given HTML text.
-
- Arguments:
- html: The text to fix.
- from_url: The URL at which this HTML is served.
- url_map: The mapping of objects and their URLs.
-
- Returns:
- The fixed HTML.
- """
- unmapped = [] # type: ignore
- html = AUTO_REF_RE.sub(fix_ref(url_map, from_url, unmapped), html)
- return html, unmapped
diff --git a/src/mkdocstrings/templates/python/material/attribute.html b/src/mkdocstrings/templates/python/material/attribute.html
index 3711698b..4b742509 100644
--- a/src/mkdocstrings/templates/python/material/attribute.html
+++ b/src/mkdocstrings/templates/python/material/attribute.html
@@ -16,10 +16,10 @@
{% set show_full_path = config.show_object_full_path %}
{% endif %}
-
+ {% filter heading(heading_level,
+ id=html_id,
+ class="doc doc-heading",
+ toc_label=attribute.name) %}
{% filter highlight(language="python", inline=True) %}
{% if show_full_path %}{{ attribute.path }}{% else %}{{ attribute.name }}{% endif %}
@@ -30,15 +30,15 @@
{% include "properties.html" with context %}
{% endwith %}
-
+ {% endfilter %}
{% else %}
{% if config.show_root_toc_entry %}
-
-
+ {% filter heading(heading_level,
+ id=html_id,
+ toc_label=attribute.path,
+ hidden=True) %}
+ {% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
diff --git a/src/mkdocstrings/templates/python/material/children.html b/src/mkdocstrings/templates/python/material/children.html
index cb6f072a..967ad493 100644
--- a/src/mkdocstrings/templates/python/material/children.html
+++ b/src/mkdocstrings/templates/python/material/children.html
@@ -14,7 +14,7 @@
{% endif %}
{% if config.show_category_heading and obj.attributes|any("has_contents") %}
- Attributes
+ {% filter heading(heading_level, id=html_id ~ "-attributes") %}Attributes{% endfilter %}
{% endif %}
{% with heading_level = heading_level + extra_level %}
{% for attribute in obj.attributes|sort(attribute="name") %}
@@ -23,7 +23,7 @@
{% endwith %}
{% if config.show_category_heading and obj.classes|any("has_contents") %}
- Classes
+ {% filter heading(heading_level, id=html_id ~ "-classes") %}Classes{% endfilter %}
{% endif %}
{% with heading_level = heading_level + extra_level %}
{% for class in obj.classes|sort(attribute="name") %}
@@ -32,7 +32,7 @@
{% endwith %}
{% if config.show_category_heading and obj.functions|any("has_contents") %}
- Functions
+ {% filter heading(heading_level, id=html_id ~ "-functions") %}Functions{% endfilter %}
{% endif %}
{% with heading_level = heading_level + extra_level %}
{% for function in obj.functions|sort(attribute="name") %}
@@ -41,7 +41,7 @@
{% endwith %}
{% if config.show_category_heading and obj.methods|any("has_contents") %}
- Methods
+ {% filter heading(heading_level, id=html_id ~ "-methods") %}Methods{% endfilter %}
{% endif %}
{% with heading_level = heading_level + extra_level %}
{% for method in obj.methods|sort(attribute="name") %}
@@ -50,7 +50,7 @@
{% endwith %}
{% if config.show_category_heading and obj.modules|any("has_contents") %}
- Modules
+ {% filter heading(heading_level, id=html_id ~ "-modules") %}Modules{% endfilter %}
{% endif %}
{% with heading_level = heading_level + extra_level %}
{% for module in obj.modules|sort(attribute="name") %}
diff --git a/src/mkdocstrings/templates/python/material/class.html b/src/mkdocstrings/templates/python/material/class.html
index 76489056..b3e33f84 100644
--- a/src/mkdocstrings/templates/python/material/class.html
+++ b/src/mkdocstrings/templates/python/material/class.html
@@ -16,10 +16,10 @@
{% set show_full_path = config.show_object_full_path %}
{% endif %}
-
+ {% filter heading(heading_level,
+ id=html_id,
+ class="doc doc-heading",
+ toc_label=class.name) %}
{% if show_full_path %}{{ class.path }}{% else %}{{ class.name }}{% endif %}
@@ -27,15 +27,15 @@
{% include "properties.html" with context %}
{% endwith %}
-
+ {% endfilter %}
{% else %}
{% if config.show_root_toc_entry %}
-
-
+ {% filter heading(heading_level,
+ id=html_id,
+ toc_label=class.path,
+ hidden=True) %}
+ {% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
@@ -48,7 +48,7 @@
{% if config.show_source and class.source %}
Source code in {{ class.relative_file_path }}
- {{ class.source.code|highlight(language="python", line_start=class.source.line_start) }}
+ {{ class.source.code|highlight(language="python", linestart=class.source.line_start, linenums=False) }}
{% endif %}
diff --git a/src/mkdocstrings/templates/python/material/examples.html b/src/mkdocstrings/templates/python/material/examples.html
index edc210b2..63b6f430 100644
--- a/src/mkdocstrings/templates/python/material/examples.html
+++ b/src/mkdocstrings/templates/python/material/examples.html
@@ -4,6 +4,6 @@
{% if section_type == "markdown" %}
{{ sub_section|convert_markdown(heading_level, html_id) }}
{% elif section_type == "examples" %}
- {{ sub_section|highlight(language="python", line_nums=False) }}
+ {{ sub_section|highlight(language="python", linenums=False) }}
{% endif %}
{% endfor %}
diff --git a/src/mkdocstrings/templates/python/material/function.html b/src/mkdocstrings/templates/python/material/function.html
index 7b995de8..5ac592b9 100644
--- a/src/mkdocstrings/templates/python/material/function.html
+++ b/src/mkdocstrings/templates/python/material/function.html
@@ -16,10 +16,10 @@
{% set show_full_path = config.show_object_full_path %}
{% endif %}
-
+ {% filter heading(heading_level,
+ id=html_id,
+ class="doc doc-heading",
+ toc_label=function.name ~ "()") %}
{% filter highlight(language="python", inline=True) %}
{% if show_full_path %}{{ function.path }}{% else %}{{ function.name }}{% endif %}
@@ -30,15 +30,15 @@
{% include "properties.html" with context %}
{% endwith %}
-
+ {% endfilter %}
{% else %}
{% if config.show_root_toc_entry %}
-
-
+ {% filter heading(heading_level,
+ id=html_id,
+ toc_label=function.path,
+ hidden=True) %}
+ {% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
@@ -51,7 +51,7 @@
{% if config.show_source and function.source %}
Source code in {{ function.relative_file_path }}
- {{ function.source.code|highlight(language="python", line_start=function.source.line_start) }}
+ {{ function.source.code|highlight(language="python", linestart=function.source.line_start, linenums=False) }}
{% endif %}
diff --git a/src/mkdocstrings/templates/python/material/method.html b/src/mkdocstrings/templates/python/material/method.html
index 9473d059..19e9a530 100644
--- a/src/mkdocstrings/templates/python/material/method.html
+++ b/src/mkdocstrings/templates/python/material/method.html
@@ -16,10 +16,10 @@
{% set show_full_path = config.show_object_full_path %}
{% endif %}
-
+ {% filter heading(heading_level,
+ id=html_id,
+ class="doc doc-heading",
+ toc_label=method.name ~ "()") %}
{% filter highlight(language="python", inline=True) %}
{% if show_full_path %}{{ method.path }}{% else %}{{ method.name }}{% endif %}
@@ -30,15 +30,15 @@
{% include "properties.html" with context %}
{% endwith %}
-
+ {% endfilter %}
{% else %}
{% if config.show_root_toc_entry %}
-
-
+ {% filter heading(heading_level,
+ id=html_id,
+ toc_label=method.path,
+ hidden=True) %}
+ {% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
@@ -51,7 +51,7 @@
{% if config.show_source and method.source %}
Source code in {{ method.relative_file_path }}
- {{ method.source.code|highlight(language="python", line_start=method.source.line_start) }}
+ {{ method.source.code|highlight(language="python", linestart=method.source.line_start, linenums=False) }}
{% endif %}
diff --git a/src/mkdocstrings/templates/python/material/module.html b/src/mkdocstrings/templates/python/material/module.html
index b874eabf..bff6fdcf 100644
--- a/src/mkdocstrings/templates/python/material/module.html
+++ b/src/mkdocstrings/templates/python/material/module.html
@@ -16,10 +16,10 @@
{% set show_full_path = config.show_object_full_path %}
{% endif %}
-
+ {% filter heading(heading_level,
+ id=html_id,
+ class="doc doc-heading",
+ toc_label=module.name) %}
{% if show_full_path %}{{ module.path }}{% else %}{{ module.name }}{% endif %}
@@ -27,15 +27,15 @@
{% include "properties.html" with context %}
{% endwith %}
-
+ {% endfilter %}
{% else %}
{% if config.show_root_toc_entry %}
-
-
+ {% filter heading(heading_level,
+ id=html_id,
+ toc_label=module.path,
+ hidden=True) %}
+ {% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
diff --git a/src/mkdocstrings/templates/python/material/signature.html b/src/mkdocstrings/templates/python/material/signature.html
index 1e1a8b27..e1f815da 100644
--- a/src/mkdocstrings/templates/python/material/signature.html
+++ b/src/mkdocstrings/templates/python/material/signature.html
@@ -1,31 +1,31 @@
{{ log.debug() }}
-{% if signature %}
- {% with %}
- {% set ns = namespace(render_pos_only_separator=True, render_kw_only_separator=True, equal="=") %}
+{%- if signature -%}
+ {%- with -%}
+ {%- set ns = namespace(render_pos_only_separator=True, render_kw_only_separator=True, equal="=") -%}
- {% if config.show_signature_annotations %}
- {% set ns.equal = " = " %}
- {% endif %}
+ {%- if config.show_signature_annotations -%}
+ {%- set ns.equal = " = " -%}
+ {%- endif -%}
- ({% for parameter in signature.parameters %}{% if parameter.kind == "POSITIONAL_ONLY" %}
- {% if ns.render_pos_only_separator %}
- {% set ns.render_pos_only_separator = False %}/, {% endif %}
- {% elif parameter.kind == "KEYWORD_ONLY" %}
- {% if ns.render_kw_only_separator %}
- {% set ns.render_kw_only_separator = False %}*, {% endif %}
- {% endif %}
- {% if config.show_signature_annotations and "annotation" in parameter %}
- {% set annotation = ": " + parameter.annotation|safe %}
- {% endif %}
- {% if "default" in parameter %}
- {% set default = ns.equal + parameter.default|safe %}
- {% endif %}
- {% if parameter.kind == "VAR_POSITIONAL" %}*
- {% set render_kw_only_separator = False %}
- {% elif parameter.kind == "VAR_KEYWORD" %}**
- {% endif %}{{ parameter.name }}{{ annotation }}{{ default }}{% if not loop.last %}, {% endif %}
- {% endfor %}){% if config.show_signature_annotations and "return_annotation" in signature %} -> {{ signature.return_annotation }}
- {% endif %}
+ ({%- for parameter in signature.parameters %}{% if parameter.kind == "POSITIONAL_ONLY" -%}
+ {%- if ns.render_pos_only_separator -%}
+ {%- set ns.render_pos_only_separator = False %}/, {% endif -%}
+ {%- elif parameter.kind == "KEYWORD_ONLY" -%}
+ {%- if ns.render_kw_only_separator -%}
+ {%- set ns.render_kw_only_separator = False %}*, {% endif -%}
+ {%- endif -%}
+ {%- if config.show_signature_annotations and "annotation" in parameter -%}
+ {%- set annotation = ": " + parameter.annotation|safe -%}
+ {%- endif -%}
+ {%- if "default" in parameter -%}
+ {%- set default = ns.equal + parameter.default|safe -%}
+ {%- endif -%}
+ {%- if parameter.kind == "VAR_POSITIONAL" %}*
+ {%- set render_kw_only_separator = False -%}
+ {%- elif parameter.kind == "VAR_KEYWORD" %}**
+ {%- endif %}{{ parameter.name }}{{ annotation }}{{ default }}{% if not loop.last %}, {% endif -%}
+ {%- endfor %}){% if config.show_signature_annotations and "return_annotation" in signature %} -> {{ signature.return_annotation }}
+ {%- endif -%}
- {% endwith %}
-{% endif %}
+ {%- endwith -%}
+{%- endif -%}
diff --git a/src/mkdocstrings/templates/python/material/style.css b/src/mkdocstrings/templates/python/material/style.css
new file mode 100644
index 00000000..7d6a9961
--- /dev/null
+++ b/src/mkdocstrings/templates/python/material/style.css
@@ -0,0 +1,15 @@
+/* Don't capitalize names. */
+h5.doc-heading {
+ text-transform: none !important;
+}
+
+/* Avoid breaking parameters name, etc. in table cells. */
+.doc-contents td code {
+ word-break: normal !important;
+}
+
+/* For pieces of Markdown rendered in table cells. */
+.doc-contents td p {
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+}
diff --git a/src/mkdocstrings/templates/python/readthedocs/style.css b/src/mkdocstrings/templates/python/readthedocs/style.css
new file mode 100644
index 00000000..2cafca81
--- /dev/null
+++ b/src/mkdocstrings/templates/python/readthedocs/style.css
@@ -0,0 +1,27 @@
+/* Avoid breaking parameters name, etc. in table cells. */
+.doc-contents td code {
+ word-break: normal !important;
+}
+
+/* For pieces of Markdown rendered in table cells. */
+.doc-contents td p {
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+}
+
+/* Avoid breaking code headings. */
+.doc-heading code {
+ white-space: normal;
+}
+
+/* Improve rendering of parameters, returns and exceptions. */
+.doc-contents .field-name {
+ min-width: 100px;
+}
+.doc-contents .field-name, .field-body {
+ border: none !important;
+ padding: 0 !important;
+}
+.doc-contents .field-list {
+ margin: 0 !important;
+}
diff --git a/tests/fixtures/builtin.py b/tests/fixtures/builtin.py
new file mode 100644
index 00000000..cab198e3
--- /dev/null
+++ b/tests/fixtures/builtin.py
@@ -0,0 +1,2 @@
+def func(foo=print):
+ """test"""
diff --git a/tests/fixtures/string_annotation.py b/tests/fixtures/string_annotation.py
new file mode 100644
index 00000000..cc0f09f3
--- /dev/null
+++ b/tests/fixtures/string_annotation.py
@@ -0,0 +1,8 @@
+from typing import Literal
+
+
+class Foo:
+ @property
+ def foo() -> Literal["hi"]:
+ "hi"
+ return "hi"
diff --git a/tests/test_extension.py b/tests/test_extension.py
index 2cb5a818..e2e92903 100644
--- a/tests/test_extension.py
+++ b/tests/test_extension.py
@@ -1,127 +1,154 @@
"""Tests for the extension module."""
-import copy
-from contextlib import contextmanager
+import sys
+from collections import ChainMap
from textwrap import dedent
import pytest
from markdown import Markdown
+from mkdocs import config
-from mkdocstrings.extension import MkdocstringsExtension
-from mkdocstrings.handlers.base import Handlers
+@pytest.fixture(name="ext_markdown")
+def fixture_ext_markdown(request, tmp_path):
+ """Yield a Markdown instance with MkdocstringsExtension, with config adjustments."""
+ conf = config.Config(schema=config.DEFAULT_SCHEMA)
-@contextmanager
-def ext_markdown(**kwargs):
- """Yield a Markdown instance with MkdocstringsExtension, with config adjustments from **kwargs.
-
- Arguments:
- **kwargs: Changes to apply to the config, on top of the default config.
-
- Yields:
- A `markdown.Markdown` instance.
- """
- config = {
- "theme_name": "material",
- "mdx": [],
- "mdx_configs": {},
- "mkdocstrings": {"default_handler": "python", "custom_templates": None, "watch": [], "handlers": {}},
+ conf_dict = {
+ "site_name": "foo",
+ "site_dir": str(tmp_path),
+ "plugins": [{"mkdocstrings": {"default_handler": "python"}}],
+ **getattr(request, "param", {}),
}
- config.update(kwargs)
- original_config = copy.deepcopy(config)
+ # Re-create it manually as a workaround for https://github.com/mkdocs/mkdocs/issues/2289
+ mdx_configs = dict(ChainMap(*conf_dict.get("markdown_extensions", [])))
- handlers = Handlers(config)
- extension = MkdocstringsExtension(config, handlers)
- config["mdx"].append(extension)
- original_config["mdx"].append(extension)
+ conf.load_dict(conf_dict)
+ assert conf.validate() == ([], [])
- yield Markdown(extensions=config["mdx"], extension_configs=config["mdx_configs"])
- handlers.teardown()
+ conf["mdx_configs"] = mdx_configs
+ conf["markdown_extensions"].insert(0, "toc") # Guaranteed to be added by MkDocs.
- assert config == original_config # Inadvertent mutations would propagate to the outer doc!
+ conf = conf["plugins"]["mkdocstrings"].on_config(conf)
+ conf = conf["plugins"]["autorefs"].on_config(conf)
+ md = Markdown(extensions=conf["markdown_extensions"], extension_configs=conf["mdx_configs"])
+ yield md
+ conf["plugins"]["mkdocstrings"].on_post_build(conf)
-def test_render_html_escaped_sequences():
+def test_render_html_escaped_sequences(ext_markdown):
"""Assert HTML-escaped sequences are correctly parsed as XML."""
- with ext_markdown() as md:
- md.convert("::: tests.fixtures.html_escaped_sequences")
+ ext_markdown.convert("::: tests.fixtures.html_escaped_sequences")
-def test_multiple_footnotes():
+@pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"footnotes": {}}]}], indirect=["ext_markdown"])
+def test_multiple_footnotes(ext_markdown):
"""Assert footnotes don't get added to subsequent docstrings."""
- with ext_markdown(mdx=["footnotes"]) as md:
- output = md.convert(
- dedent(
- """
- Top.[^aaa]
+ output = ext_markdown.convert(
+ dedent(
+ """
+ Top.[^aaa]
- ::: tests.fixtures.footnotes.func_a
+ ::: tests.fixtures.footnotes.func_a
- ::: tests.fixtures.footnotes.func_b
+ ::: tests.fixtures.footnotes.func_b
- ::: tests.fixtures.footnotes.func_c
+ ::: tests.fixtures.footnotes.func_c
- [^aaa]: Top footnote
- """,
- ),
- )
+ [^aaa]: Top footnote
+ """,
+ ),
+ )
assert output.count("Footnote A") == 1
assert output.count("Footnote B") == 1
assert output.count("Top footnote") == 1
-def test_markdown_heading_level():
+def test_markdown_heading_level(ext_markdown):
"""Assert that Markdown headings' level doesn't exceed heading_level."""
- with ext_markdown() as md:
- output = md.convert("::: tests.fixtures.headings\n rendering:\n show_root_heading: true")
- assert "
Foo
" in output
- assert "
Bar
" in output
- assert "
Baz
" in output
+ output = ext_markdown.convert("::: tests.fixtures.headings\n rendering:\n show_root_heading: true")
+ assert ">Foo" in output
+ assert ">Bar" in output
+ assert ">Baz" in output
-def test_keeps_preceding_text():
+def test_keeps_preceding_text(ext_markdown):
"""Assert that autodoc is recognized in the middle of a block and preceding text is kept."""
- with ext_markdown() as md:
- output = md.convert("**preceding**\n::: tests.fixtures.headings")
+ output = ext_markdown.convert("**preceding**\n::: tests.fixtures.headings")
assert "
preceding" in output
- assert "
Foo
" in output
+ assert ">Foo" in output
assert ":::" not in output
-def test_reference_inside_autodoc():
+def test_reference_inside_autodoc(ext_markdown):
"""Assert cross-reference Markdown extension works correctly."""
- with ext_markdown() as md:
- output = md.convert("::: tests.fixtures.cross_reference")
+ output = ext_markdown.convert("::: tests.fixtures.cross_reference")
snippet = 'Link to
something.Else.'
assert snippet in output
+@pytest.mark.skipif(sys.version_info < (3, 8), reason="typing.Literal requires Python 3.8")
+def test_quote_inside_annotation(ext_markdown):
+ """Assert that inline highlighting doesn't double-escape HTML."""
+ output = ext_markdown.convert("::: tests.fixtures.string_annotation.Foo")
+ assert ";hi&" in output
+ assert "&" not in output
+
+
+def test_html_inside_heading(ext_markdown):
+ """Assert that headings don't double-escape HTML."""
+ output = ext_markdown.convert("::: tests.fixtures.builtin")
+ assert "=<" in output
+ assert "&" not in output
+
+
@pytest.mark.parametrize(
- ("permalink_setting", "expect_permalink"),
+ ("ext_markdown", "expect_permalink"),
[
- ("@@@", "@@@"),
- (True, "¶"),
+ ({"markdown_extensions": [{"toc": {"permalink": "@@@"}}]}, "@@@"),
+ ({"markdown_extensions": [{"toc": {"permalink": "TeSt"}}]}, "TeSt"),
+ ({"markdown_extensions": [{"toc": {"permalink": True}}]}, "¶"),
],
+ indirect=["ext_markdown"],
)
-def test_no_double_toc(permalink_setting, expect_permalink):
- """
- Assert that the 'toc' extension doesn't apply its modification twice.
-
- Arguments:
- permalink_setting: The 'permalink' setting of 'toc' extension.
- expect_permalink: Text of the permalink to search for in the output.
- """
- with ext_markdown(mdx=["toc"], mdx_configs={"toc": {"permalink": permalink_setting}}) as md:
- output = md.convert(
- dedent(
- """
- # aa
-
- ::: tests.fixtures.headings
- rendering:
- show_root_toc_entry: false
-
- # bb
- """
- )
+def test_no_double_toc(ext_markdown, expect_permalink):
+ """Assert that the 'toc' extension doesn't apply its modification twice."""
+ output = ext_markdown.convert(
+ dedent(
+ """
+ # aa
+
+ ::: tests.fixtures.headings
+ rendering:
+ show_root_toc_entry: false
+
+ # bb
+ """
)
+ )
assert output.count(expect_permalink) == 5
+ assert 'id="tests.fixtures.headings--foo"' in output
+ assert ext_markdown.toc_tokens == [ # noqa: E1101 (the member gets populated only with 'toc' extension)
+ {
+ "level": 1,
+ "id": "aa",
+ "name": "aa",
+ "children": [
+ {
+ "level": 2,
+ "id": "tests.fixtures.headings--foo",
+ "name": "Foo",
+ "children": [
+ {
+ "level": 4,
+ "id": "tests.fixtures.headings--bar",
+ "name": "Bar",
+ "children": [
+ {"level": 6, "id": "tests.fixtures.headings--baz", "name": "Baz", "children": []}
+ ],
+ }
+ ],
+ }
+ ],
+ },
+ {"level": 1, "id": "bb", "name": "bb", "children": []},
+ ]
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
new file mode 100644
index 00000000..cfe04cd8
--- /dev/null
+++ b/tests/test_handlers.py
@@ -0,0 +1,46 @@
+"""Tests for the handlers.base module."""
+
+import pytest
+from markdown import Markdown
+
+from mkdocstrings.handlers.base import Highlighter
+
+
+@pytest.mark.parametrize("extension_name", ["codehilite", "pymdownx.highlight"])
+def test_highlighter_without_pygments(extension_name):
+ """Assert that it's possible to disable Pygments highlighting.
+
+ Arguments:
+ extension_name: The "user-chosen" Markdown extension for syntax highlighting.
+ """
+ configs = {extension_name: {"use_pygments": False, "css_class": "hiiii"}}
+ md = Markdown(extensions=configs, extension_configs=configs)
+ hl = Highlighter(md)
+ assert (
+ hl.highlight("import foo", language="python")
+ == '
import foo
'
+ )
+ assert (
+ hl.highlight("import foo", language="python", inline=True)
+ == '
import foo'
+ )
+
+
+@pytest.mark.parametrize("extension_name", [None, "codehilite", "pymdownx.highlight"])
+@pytest.mark.parametrize("inline", [False, True])
+def test_highlighter_basic(extension_name, inline):
+ """Assert that Pygments syntax highlighting works.
+
+ Arguments:
+ extension_name: The "user-chosen" Markdown extension for syntax highlighting.
+ inline: Whether the highlighting was inline.
+ """
+ configs = {}
+ if extension_name:
+ configs[extension_name] = {}
+ md = Markdown(extensions=configs, extension_configs=configs)
+ hl = Highlighter(md)
+
+ actual = hl.highlight("import foo", language="python", inline=inline)
+ assert "import" in actual
+ assert "import foo" not in actual # Highlighting has split it up.
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 61080301..3bdad73c 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -7,7 +7,10 @@
from mkdocs.config.base import load_config
-@pytest.mark.xfail(sys.version.startswith("3.9"), reason="pytkdocs is failing on Python 3.9")
-def test_plugin():
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="using plugins that require Python 3.7")
+@pytest.mark.xfail(sys.version_info >= (3, 9), reason="pytkdocs is failing on Python 3.9")
+def test_plugin(tmp_path):
"""Build our own documentation."""
- build(load_config())
+ config = load_config()
+ config["site_dir"] = tmp_path
+ build(config)
diff --git a/tests/test_references.py b/tests/test_references.py
deleted file mode 100644
index bee270e6..00000000
--- a/tests/test_references.py
+++ /dev/null
@@ -1,149 +0,0 @@
-"""Tests for the references module."""
-import markdown
-import pytest
-
-from mkdocstrings.extension import MkdocstringsExtension
-from mkdocstrings.handlers.base import Handlers
-from mkdocstrings.references import fix_refs, relative_url
-
-
-@pytest.mark.parametrize(
- ("current_url", "to_url", "href_url"),
- [
- ("a/", "a#b", "#b"),
- ("a/", "a/b#c", "b#c"),
- ("a/b/", "a/b#c", "#c"),
- ("a/b/", "a/c#d", "../c#d"),
- ("a/b/", "a#c", "..#c"),
- ("a/b/c/", "d#e", "../../../d#e"),
- ("a/b/", "c/d/#e", "../../c/d/#e"),
- ("a/index.html", "a/index.html#b", "#b"),
- ("a/index.html", "a/b.html#c", "b.html#c"),
- ("a/b.html", "a/b.html#c", "#c"),
- ("a/b.html", "a/c.html#d", "c.html#d"),
- ("a/b.html", "a/index.html#c", "index.html#c"),
- ("a/b/c.html", "d.html#e", "../../d.html#e"),
- ("a/b.html", "c/d.html#e", "../c/d.html#e"),
- ("a/b/index.html", "a/b/c/d.html#e", "c/d.html#e"),
- ("", "#x", "#x"),
- ("a/", "#x", "../#x"),
- ("a/b.html", "#x", "../#x"),
- ("", "a/#x", "a/#x"),
- ("", "a/b.html#x", "a/b.html#x"),
- ],
-)
-def test_relative_url(current_url, to_url, href_url):
- """
- Compute relative URLs correctly.
-
- Arguments:
- current_url: The URL of the source page.
- to_url: The URL of the target page.
- href_url: The relative URL to put in the `href` HTML field.
- """
- assert relative_url(current_url, to_url) == href_url
-
-
-def run_references_test(url_map, source, output, unmapped=None, from_url="page.html"):
- """
- Help running tests about references.
-
- Arguments:
- url_map: The URL mapping.
- source: The source text.
- output: The expected output.
- unmapped: The expected unmapped list.
- from_url: The source page URL.
- """
- config = {}
- ext = MkdocstringsExtension(config, Handlers(config))
- md = markdown.Markdown(extensions=[ext])
- content = md.convert(source)
- actual_output, actual_unmapped = fix_refs(content, from_url, url_map)
- assert actual_output == output
- assert actual_unmapped == (unmapped or [])
-
-
-def test_reference_implicit():
- """Check implicit references (identifier only)."""
- run_references_test(
- url_map={"Foo": "foo.html#Foo"},
- source="This [Foo][].",
- output='
This Foo.
',
- )
-
-
-def test_reference_explicit_with_markdown_text():
- """Check explicit references with Markdown formatting."""
- run_references_test(
- url_map={"Foo": "foo.html#Foo"},
- source="This [`Foo`][Foo].",
- output='
This Foo.
',
- )
-
-
-def test_reference_with_punctuation():
- """Check references with punctuation."""
- run_references_test(
- url_map={'Foo&"bar': 'foo.html#Foo&"bar'},
- source='This [Foo&"bar][].',
- output='
This Foo&"bar.
',
- )
-
-
-def test_no_reference_with_space():
- """Check that references with spaces are not fixed."""
- run_references_test(
- url_map={"Foo bar": "foo.html#Foo bar"},
- source="This [Foo bar][].",
- output="
This [Foo bar][].
",
- )
-
-
-def test_no_reference_inside_markdown():
- """Check that references inside code are not fixed."""
- run_references_test(
- url_map={"Foo": "foo.html#Foo"},
- source="This `[Foo][]`.",
- output="
This [Foo][].
",
- )
-
-
-def test_missing_reference():
- """Check that implicit references are correctly seen as unmapped."""
- run_references_test(
- url_map={"NotFoo": "foo.html#NotFoo"},
- source="[Foo][]",
- output="
[Foo][]
",
- unmapped=["Foo"],
- )
-
-
-def test_missing_reference_with_markdown_text():
- """Check unmapped explicit references."""
- run_references_test(
- url_map={"NotFoo": "foo.html#NotFoo"},
- source="[`Foo`][Foo]",
- output="
[Foo][Foo]
",
- unmapped=["Foo"],
- )
-
-
-def test_missing_reference_with_markdown_id():
- """Check unmapped explicit references with Markdown in the identifier."""
- run_references_test(
- url_map={"NotFoo": "foo.html#NotFoo"},
- source="[Foo][*oh*]",
- output="
[Foo][*oh*]
",
- unmapped=["*oh*"],
- )
-
-
-def test_missing_reference_with_markdown_implicit():
- """Check that implicit references are not fixed when the identifier is not the exact one."""
- run_references_test(
- url_map={"Foo": "foo.html#Foo"},
- source="[`Foo`][]",
- output="
[Foo][]
",
- unmapped=[],
- )