diff --git a/.copier-answers.yml b/.copier-answers.yml index 1b85c204..022b9006 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: 0.1.10 +_commit: 0.2.1 _src_path: gh:pawamoy/copier-poetry author_email: pawamoy@pm.me author_fullname: "Timoth\xE9e Mazzucotelli" diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index a9c1cf98..c65012f1 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -10,4 +10,4 @@ assignees: '' **Add detailed information, like** - project folder structure (`tree -L 2`) - `mkdocs.yml` configuration file contents -- `mkdocstrings` version: [e.g. 0.10.2] +- *mkdocstrings* version: [e.g. 0.10.2] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ccbc9f..4aa606c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,14 @@ jobs: - name: Set up the project run: | pip install poetry - poetry install -vvv || { rm -rf .venv; poetry install -vvv; } + poetry install -v || { rm -rf .venv; poetry install -v; } poetry update - name: Check if the documentation builds correctly - run: poetry run duty check-docs + run: | + mkdir -p build/coverage + touch build/coverage/index.html + poetry run duty check-docs - name: Check the code quality run: poetry run duty check-code-quality @@ -85,7 +88,7 @@ jobs: - name: Set up the project run: | pip install poetry - poetry install -vvv || { rm -rf .venv; poetry install -vvv; } + poetry install -v || { rm -rf .venv; poetry install -v; } poetry update - name: Run the test suite diff --git a/CHANGELOG.md b/CHANGELOG.md index a960b050..b60b3fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,47 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [0.15.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.15.0) - 2021-02-28 + +[Compare with 0.14.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.14.0...0.15.0) + +### Breaking Changes + +The following two items are *possible* breaking changes: + +- Cross-linking to arbitrary headings now requires to opt-in to the *autorefs* plugin, + which is installed as a dependency of *mkdocstrings*. + See [Cross-references to any Markdown heading](https://mkdocstrings.github.io/usage/#cross-references-to-any-markdown-heading). +- *mkdocstrings* now respects your code highlighting configured method, + so if you are using CodeHilite, the `highlight` CSS classes in the rendered HTML will be replaced by `codehilite`. + In that case make sure to replace `.highlight` by `.codehilite` in any extra CSS rule of yours. + See [Syntax highlighting](https://mkdocstrings.github.io/theming/#syntax-highlighting). + +### Features +- Nicer-looking error outputs - no tracebacks from mkdocstrings ([6baf720](https://github.com/mkdocstrings/mkdocstrings/commit/6baf720850d359ddb55713553a757fe7b2283e10) by Oleh Prypin). [PR #230](https://github.com/mkdocstrings/mkdocstrings/pull/230) +- Let handlers add CSS to the pages, do so for Python handler ([05c7a3f](https://github.com/mkdocstrings/mkdocstrings/commit/05c7a3fc83b67d3244ea3bfe97dab19aa53f2d38) by Oleh Prypin). [Issue #189](https://github.com/mkdocstrings/mkdocstrings/issues/189), [PR #218](https://github.com/mkdocstrings/mkdocstrings/pull/218) +- Allow linking to an object heading not only by its canonical identifier, but also by its possible aliases ([4789950](https://github.com/mkdocstrings/mkdocstrings/commit/4789950ff43c354d47afbed5c89d5abb917ffee6) by Oleh Prypin). [PR #217](https://github.com/mkdocstrings/mkdocstrings/pull/217) + +### Bug Fixes +- Propagate the CSS class to inline highlighting as well ([c7d80e6](https://github.com/mkdocstrings/mkdocstrings/commit/c7d80e63a042913b7511c38a788967796dd10997) by Oleh Prypin). [PR #245](https://github.com/mkdocstrings/mkdocstrings/pull/245) +- Don't double-escape characters in highlighted headings ([6357144](https://github.com/mkdocstrings/mkdocstrings/commit/6357144b100be6a2e7e6140e035c289c225cec22) by Oleh Prypin). [Issue #228](https://github.com/mkdocstrings/mkdocstrings/issues/228), [PR #241](https://github.com/mkdocstrings/mkdocstrings/pull/241) + +### Code Refactoring +- Use the autorefs plugin from its new external location ([e2d74ef](https://github.com/mkdocstrings/mkdocstrings/commit/e2d74efb0d59f9a1aa45e42525ceb1d4b7638426) by Oleh Prypin). [PR #235](https://github.com/mkdocstrings/mkdocstrings/pull/235) +- Split out Markdown extensions from `handlers` to `handlers.rendering` ([7533852](https://github.com/mkdocstrings/mkdocstrings/commit/7533852e3ac0a378b70a380cef1100421b7d5763) by Oleh Prypin). [PR #233](https://github.com/mkdocstrings/mkdocstrings/pull/233) +- Theme-agnostic code highlighting, respecting configs ([f9ea009](https://github.com/mkdocstrings/mkdocstrings/commit/f9ea00979545e39983ba377f1930d73ae94165ea) by Oleh Prypin). [PR #202](https://github.com/mkdocstrings/mkdocstrings/pull/202) +- Split out autorefs plugin, make it optional ([fc67656](https://github.com/mkdocstrings/mkdocstrings/commit/fc676564f9b11269b3e0b0482703ac924069a3fa) by Oleh Prypin). [PR #220](https://github.com/mkdocstrings/mkdocstrings/pull/220) +- Remove the extra wrapper div from the final doc ([7fe438c](https://github.com/mkdocstrings/mkdocstrings/commit/7fe438c4040a2124b00c39e582ef4c38be7c55c9) by Oleh Prypin). [PR #209](https://github.com/mkdocstrings/mkdocstrings/pull/209) +- Don't re-parse the whole subdoc, expose only headings ([15f84f9](https://github.com/mkdocstrings/mkdocstrings/commit/15f84f981982c8e2b15498f5c869ac207f3ce5d7) by Oleh Prypin). [PR #209](https://github.com/mkdocstrings/mkdocstrings/pull/209) +- Actually exclude hidden headings from the doc ([0fdb082](https://github.com/mkdocstrings/mkdocstrings/commit/0fdb0821867eb0e14a972a603c22301aafecf4f4) by Oleh Prypin). [PR #209](https://github.com/mkdocstrings/mkdocstrings/pull/209) + + ## [0.14.0](https://github.com/pawamoy/mkdocstrings/releases/tag/0.14.0) - 2021-01-06 [Compare with 0.13.6](https://github.com/pawamoy/mkdocstrings/compare/0.13.6...0.14.0) Special thanks to Oleh [@oprypin](https://github.com/oprypin) Prypin who did an amazing job (this is a euphemism) -at improving MkDocstrings, fixing hard-to-fix bugs with clever solutions, implementing great new features +at improving *mkdocstrings*, fixing hard-to-fix bugs with clever solutions, implementing great new features and refactoring the code for better performance and readability! Thanks Oleh! ### Bug Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 897d976f..75609fd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ Run `make help` to see all the available actions! ## Tasks -This project uses [Duty](https://github.com/pawamoy/duty) to run tasks. +This project uses [duty](https://github.com/pawamoy/duty) to run tasks. A Makefile is also provided. The Makefile will try to run certain tasks on multiple Python versions. If for some reason you don't want to run the task on multiple Python versions, you can do one of the following: diff --git a/CREDITS.md b/CREDITS.md index 55579d1d..8357b62e 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -12,25 +12,31 @@ These projects were used to build `mkdocstrings`. **Thank you!** ### Direct dependencies [`autoflake`](https://github.com/myint/autoflake) | [`black`](https://github.com/psf/black) | +[`darglint`](None) | [`duty`](https://github.com/pawamoy/duty) | +[`flake8-bandit`](https://github.com/tylerwince/flake8-bandit) | [`flake8-black`](https://github.com/peterjc/flake8-black) | +[`flake8-bugbear`](https://github.com/PyCQA/flake8-bugbear) | [`flake8-builtins`](https://github.com/gforcada/flake8-builtins) | +[`flake8-comprehensions`](https://github.com/adamchainz/flake8-comprehensions) | +[`flake8-docstrings`](https://gitlab.com/pycqa/flake8-docstrings) | [`flake8-pytest-style`](https://pypi.org/project/flake8-pytest-style) | +[`flake8-string-format`](https://github.com/xZise/flake8-string-format) | [`flake8-tidy-imports`](https://github.com/adamchainz/flake8-tidy-imports) | [`flake8-variables-names`](https://github.com/best-doctor/flake8-variables-names) | [`flakehell`](None) | [`git-changelog`](https://github.com/pawamoy/git-changelog) | [`httpx`](https://github.com/encode/httpx) | [`ipython`](https://ipython.org) | -[`isort`](https://github.com/timothycrosley/isort) | +[`isort`](https://pycqa.github.io/isort/) | [`Jinja2`](https://palletsprojects.com/p/jinja/) | [`jinja2-cli`](https://github.com/mattrobenolt/jinja2-cli) | [`Markdown`](https://Python-Markdown.github.io/) | -[`markdown-include`](https://github.com/cmacmackin/markdown-include/) | [`MarkupSafe`](https://palletsprojects.com/p/markupsafe/) | [`mkdocs`](https://www.mkdocs.org) | [`mkdocs-material`](https://squidfunk.github.io/mkdocs-material/) | [`mypy`](http://www.mypy-lang.org/) | +[`pep8-naming`](https://github.com/PyCQA/pep8-naming) | [`pymdown-extensions`](https://github.com/facelessuser/pymdown-extensions) | [`pytest`](https://docs.pytest.org/en/latest/) | [`pytest-cov`](https://github.com/pytest-dev/pytest-cov) | @@ -38,15 +44,13 @@ These projects were used to build `mkdocstrings`. **Thank you!** [`pytest-sugar`](http://pivotfinland.com/pytest-sugar/) | [`pytest-xdist`](https://github.com/pytest-dev/pytest-xdist) | [`pytkdocs`](https://github.com/pawamoy/pytkdocs) | -[`toml`](https://github.com/uiri/toml) | -[`wemake-python-styleguide`](https://wemake-python-stylegui.de) +[`toml`](https://github.com/uiri/toml) ### Indirect dependencies [`ansimarkup`](https://github.com/gvalkov/python-ansimarkup) | [`apipkg`](https://github.com/pytest-dev/apipkg) | [`appdirs`](http://github.com/ActiveState/appdirs) | [`appnope`](http://github.com/minrk/appnope) | -[`astor`](https://github.com/berkerpeksag/astor) | [`astroid`](https://github.com/PyCQA/astroid) | [`atomicwrites`](https://github.com/untitaker/python-atomicwrites) | [`attrs`](https://www.attrs.org/) | @@ -58,29 +62,14 @@ These projects were used to build `mkdocstrings`. **Thank you!** [`colorama`](https://github.com/tartley/colorama) | [`contextvars`](http://github.com/MagicStack/contextvars) | [`coverage`](https://github.com/nedbat/coveragepy) | -[`darglint`](None) | [`dataclasses`](https://github.com/ericvsmith/dataclasses) | [`decorator`](https://github.com/micheles/decorator) | -[`docutils`](http://docutils.sourceforge.net/) | [`entrypoints`](https://github.com/takluyver/entrypoints) | -[`eradicate`](https://github.com/myint/eradicate) | [`execnet`](https://execnet.readthedocs.io/en/latest/) | [`failprint`](https://github.com/pawamoy/failprint) | [`flake8`](https://gitlab.com/pycqa/flake8) | -[`flake8-bandit`](https://github.com/tylerwince/flake8-bandit) | -[`flake8-broken-line`](https://github.com/sobolevn/flake8-broken-line) | -[`flake8-bugbear`](https://github.com/PyCQA/flake8-bugbear) | -[`flake8-commas`](https://github.com/PyCQA/flake8-commas/) | -[`flake8-comprehensions`](https://github.com/adamchainz/flake8-comprehensions) | -[`flake8-debugger`](https://github.com/jbkahn/flake8-debugger) | -[`flake8-docstrings`](https://gitlab.com/pycqa/flake8-docstrings) | -[`flake8-eradicate`](https://github.com/sobolevn/flake8-eradicate) | -[`flake8-isort`](https://github.com/gforcada/flake8-isort) | [`flake8-plugin-utils`](https://pypi.org/project/flake8-plugin-utils) | [`flake8-polyfill`](https://gitlab.com/pycqa/flake8-polyfill) | -[`flake8-quotes`](http://github.com/zheller/flake8-quotes/) | -[`flake8-rst-docstrings`](https://github.com/peterjc/flake8-rst-docstrings) | -[`flake8-string-format`](https://github.com/xZise/flake8-string-format) | [`future`](https://python-future.org) | [`gitdb`](https://github.com/gitpython-developers/gitdb) | [`GitPython`](https://github.com/gitpython-developers/GitPython) | @@ -104,7 +93,6 @@ These projects were used to build `mkdocstrings`. **Thank you!** [`parso`](https://github.com/davidhalter/parso) | [`pathspec`](https://github.com/cpburnz/python-path-specification) | [`pbr`](https://docs.openstack.org/pbr/latest/) | -[`pep8-naming`](https://github.com/PyCQA/pep8-naming) | [`pexpect`](https://pexpect.readthedocs.io/) | [`pickleshare`](https://github.com/pickleshare/pickleshare) | [`pluggy`](https://github.com/pytest-dev/pluggy) | @@ -118,9 +106,8 @@ These projects were used to build `mkdocstrings`. **Thank you!** [`pylint`](https://github.com/PyCQA/pylint) | [`pyparsing`](https://github.com/pyparsing/pyparsing/) | [`pytest-forked`](https://github.com/pytest-dev/pytest-forked) | -[`PyYAML`](https://github.com/yaml/pyyaml) | +[`PyYAML`](https://pyyaml.org/) | [`regex`](https://bitbucket.org/mrabarnett/mrab-regex) | -[`restructuredtext-lint`](https://github.com/twolfson/restructuredtext-lint) | [`rfc3986`](http://rfc3986.readthedocs.io) | [`six`](https://github.com/benjaminp/six) | [`smmap`](https://github.com/gitpython-developers/smmap) | @@ -128,7 +115,6 @@ These projects were used to build `mkdocstrings`. **Thank you!** [`snowballstemmer`](https://github.com/snowballstem/snowball) | [`stevedore`](https://docs.openstack.org/stevedore/latest/) | [`termcolor`](http://pypi.python.org/pypi/termcolor) | -[`testfixtures`](https://github.com/Simplistix/testfixtures) | [`tornado`](http://www.tornadoweb.org/) | [`tqdm`](https://github.com/tqdm/tqdm) | [`traitlets`](http://ipython.org) | diff --git a/Makefile b/Makefile index 56c7b5f0..28074e01 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ args = $(foreach a,$($(subst -,_,$1)_args),$(if $(value $a),$a="$($a)")) check_code_quality_args = files docs_serve_args = host port release_args = version -test_args = match +test_args = cleancov match BASIC_DUTIES = \ changelog \ diff --git a/README.md b/README.md index e3deacea..67ea0f29 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # mkdocstrings -[![ci](https://github.com/pawamoy/mkdocstrings/workflows/ci/badge.svg)](https://github.com/pawamoy/mkdocstrings/actions?query=workflow%3Aci) -[![documentation](https://img.shields.io/badge/docs-mkdocs%20material-blue.svg?style=flat)](https://pawamoy.github.io/mkdocstrings/) +[![ci](https://github.com/mkdocstrings/mkdocstrings/workflows/ci/badge.svg)](https://github.com/mkdocstrings/mkdocstrings/actions?query=workflow%3Aci) +[![documentation](https://img.shields.io/badge/docs-mkdocs%20material-blue.svg?style=flat)](https://mkdocstrings.github.io/) [![pypi version](https://img.shields.io/pypi/v/mkdocstrings.svg)](https://pypi.org/project/mkdocstrings/) [![conda version](https://img.shields.io/conda/vn/conda-forge/mkdocstrings)](https://anaconda.org/conda-forge/mkdocstrings) [![gitter](https://badges.gitter.im/join%20chat.svg)](https://gitter.im/mkdocstrings/community) -Automatic documentation from sources, for MkDocs. +Automatic documentation from sources, for [MkDocs](https://mkdocs.org/). --- @@ -22,33 +22,50 @@ Automatic documentation from sources, for MkDocs. ## Features -- **Language agnostic:** just like `mkdocs`, `mkdocstrings` is written in Python but is language-agnostic. - It means you can use it for any language, as long as you implement a - [`handler`](https://pawamoy.github.io/mkdocstrings/reference/handlers/__init__/) for it. - Currently, we only have a [Python handler](https://pawamoy.github.io/mkdocstrings/reference/handlers/python/). - Maybe you'd like to contribute another one :wink:? -- **Multiple themes support:** each handler can offer multiple themes. Currently, we offer the +- [**Language-agnostic:**](https://mkdocstrings.github.io/handlers/overview/) + just like *MkDocs*, *mkdocstrings* is written in Python but is language-agnostic. + It means you can use it with any programming language, as long as there is a + [**handler**](https://mkdocstrings.github.io/reference/handlers/base/) for it. + The [Python handler](https://mkdocstrings.github.io/handlers/python/) is built-in. + [Others](https://mkdocstrings.github.io/handlers/overview/) are external. + Maybe you'd like to add another one to the list? :wink: + +- [**Multiple themes support:**](https://mkdocstrings.github.io/theming/) + each handler can offer multiple themes. Currently, we offer the :star: [Material theme](https://squidfunk.github.io/mkdocs-material/) :star: as well as basic support for the ReadTheDocs theme for the Python handler. -- **Cross-references to other objects:** `mkdocstrings` makes it possible to reference other headings from your - Markdown files with the classic Markdown syntax: `[identifier][]` or `[title][identifier]`. This feature is language - agnostic as well: you can cross-reference any heading that appear in your Markdown pages. - If the handler for a particular language renders headings for documented objects, you'll be able to reference them! -- **Inline injection in Markdown:** instead of generating Markdown files, `mkdocstrings` allows you to inject + +- [**Cross-links across pages:**](https://mkdocstrings.github.io/usage/#cross-references) + *mkdocstrings* makes it possible to reference headings in other Markdown files with the classic Markdown linking + syntax: `[identifier][]` or `[title][identifier]` -- and you don't need to remember which exact page this object was + on. This works for any heading that's produced by a *mkdocstrings* language handler, and you can opt to include + *any* Markdown heading into the global referencing scheme. + + **Note**: in versions prior to 0.15 *all* Markdown headers were included, but now you need to + [opt in](https://mkdocstrings.github.io/usage/#cross-references). + +- [**Inline injection in Markdown:**](https://mkdocstrings.github.io/usage/) + instead of generating Markdown files, *mkdocstrings* allows you to inject documentation anywhere in your Markdown contents. The syntax is simple: `::: identifier` followed by a 4-spaces indented YAML block. The identifier and YAML configuration will be passed to the appropriate handler to collect and render documentation. -- **Global and local configuration:** each handler can be configured globally in `mkdocs.yml`, and locally for each + +- [**Global and local configuration:**](https://mkdocstrings.github.io/usage/#global-options) + each handler can be configured globally in `mkdocs.yml`, and locally for each "autodoc" instruction. -- **Watch source code directories:** you can tell `mkdocstrings` to add directories to be watched by `mkdocs` when + +- [**Watch source code directories:**](https://mkdocstrings.github.io/usage/#watch-directories) + you can tell *mkdocstrings* to add directories to be watched by *MkDocs* when serving the documentation, for auto-reload. -- **Sane defaults:** you should be able to just drop the plugin in your configuration and enjoy your auto-generated docs. + +- **Reasonable defaults:** + you should be able to just drop the plugin in your configuration and enjoy your auto-generated docs. ### Python handler features - **Data collection from source code**: collection of the object-tree and the docstrings is done by [`pytkdocs`](https://github.com/pawamoy/pytkdocs). The following features are possible thanks to it: - - **Support for type annotations:** `pytkdocs` collects your type annotations and `mkdocstrings` uses them + - **Support for type annotations:** `pytkdocs` collects your type annotations and *mkdocstrings* uses them to display parameters types or return types. - **Recursive documentation of Python objects:** just use the module dotted-path as identifier, and you get the full module docs. You don't need to inject documentation for each class, function, etc. @@ -56,36 +73,37 @@ Automatic documentation from sources, for MkDocs. be recognized by `pytkdocs` in modules, classes and even in `__init__` methods. - **Support for objects properties:** `pytkdocs` detects if a method is a `staticmethod`, a `classmethod`, etc., it also detects if a property is read-only or writable, and more! These properties will be displayed - next to the object signature by `mkdocstrings`. + next to the object signature by *mkdocstrings*. - **Google-style sections support in docstrings:** `pytkdocs` understands `Arguments:`, `Raises:` - and `Returns:` sections, and returns structured data for `mkdocstrings` to render them. + and `Returns:` sections, and returns structured data for *mkdocstrings* to render them. - **reStructuredText-style sections support in docstrings:** `pytkdocs` understands all the [reStructuredText fields](https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html?highlight=python%20domain#info-field-lists), - and returns structured data for `mkdocstrings` to render them. + and returns structured data for *mkdocstrings* to render them. + *Note: only RST **style** is supported, not the whole markup.* - **Admonition support in docstrings:** blocks like `Note: ` or `Warning: ` will be transformed to their [admonition](https://squidfunk.github.io/mkdocs-material/extensions/admonition/) equivalent. *We do not support nested admonitions in docstrings!* - **Support for reStructuredText in docstrings:** `pytkdocs` can parse simple RST. -- **Every object has a TOC entry:** we render a heading for each object, meaning `mkdocs` picks them into the Table - of Contents, which is nicely display by the Material theme. Thanks to `mkdocstrings` cross-reference ability, +- **Every object has a TOC entry:** we render a heading for each object, meaning *MkDocs* picks them into the Table + of Contents, which is nicely display by the Material theme. Thanks to *mkdocstrings* cross-reference ability, you can even reference other objects within your docstrings, with the classic Markdown syntax: `[this object][package.module.object]` or directly with `[package.module.object][]` -- **Source code display:** `mkdocstrings` can add a collapsible div containing the highlighted source code +- **Source code display:** *mkdocstrings* can add a collapsible div containing the highlighted source code of the Python object. -To get an example of what is possible, check `mkdocstrings`' -own [documentation](https://pawamoy.github.io/mkdocstrings), auto-generated from sources by itself of course, +To get an example of what is possible, check *mkdocstrings*' +own [documentation](https://mkdocstrings.github.io/), auto-generated from sources by itself of course, and the following GIF: ![mkdocstrings_gif2](https://user-images.githubusercontent.com/3999221/77157838-7184db80-6aa2-11ea-9f9a-fe77405202de.gif) ## Roadmap -See the [Feature Roadmap issue](https://github.com/pawamoy/mkdocstrings/issues/183) on the bugtracker. +See the [Feature Roadmap issue](https://github.com/mkdocstrings/mkdocstrings/issues/183) on the bugtracker. ## Requirements -`mkdocstrings` requires Python 3.6 or above. +*mkdocstrings* requires Python 3.6 or above.
To install Python 3.6, I recommend using pyenv. @@ -140,10 +158,10 @@ plugins: In one of your markdown files: -```yaml +```markdown # Reference ::: my_library.my_module.my_class ``` -See the [Usage](https://pawamoy.github.io/mkdocstrings/usage) section of the docs for more examples! +See the [Usage](https://mkdocstrings.github.io/usage) section of the docs for more examples! diff --git a/config/coverage.ini b/config/coverage.ini index 544ae164..27b21edf 100644 --- a/config/coverage.ini +++ b/config/coverage.ini @@ -6,7 +6,7 @@ source = [coverage:run] branch = true source = - mkdocstrings + src/mkdocstrings tests parallel = true diff --git a/config/flake8.ini b/config/flake8.ini new file mode 100644 index 00000000..3e559fd2 --- /dev/null +++ b/config/flake8.ini @@ -0,0 +1,50 @@ +[flake8] +exclude = fixtures,docs,site +max-line-length = 132 +strictness = long +docstring-convention = google +ignore = + # we write docstrings in markdown, not rst + RST*, + # redundant with W0622 (builtin override), which is more precise about line number + A001, + # missing docstring in magic method + D105, + # whitespace before β€˜:’ (incompatible with Black) + E203, + # redundant with E0602 (undefined variable) + F821, + # black already deals with quoting + Q000, + # use of assert + S101, + # we are not parsing XML + S405, + # line break before binary operator (incompatible with Black) + W503, + # two-lowercase-letters variable DO conform to snake_case naming style + C0103, + # redunant with D102 (missing docstring) + C0116, + # line too long + C0301, + # too many instance attributes + R0902, + # too few public methods + R0903, + # too many public methods + R0904, + # too many branches + R0912, + # too many methods + R0913, + # too many local variables + R0914, + # too many statements + R0915, + # redundant with F401 (unused import) + W0611, + # lazy formatting for logging calls + W1203, + # short name + VNE001 diff --git a/docs/changelog.md b/docs/changelog.md index dcdfe2a7..786b75d5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1 +1 @@ -{!CHANGELOG.md!} \ No newline at end of file +--8<-- "CHANGELOG.md" diff --git a/docs/code_of_conduct.md b/docs/code_of_conduct.md index b5d5f94b..01f2ea20 100644 --- a/docs/code_of_conduct.md +++ b/docs/code_of_conduct.md @@ -1 +1 @@ -{!CODE_OF_CONDUCT.md!} \ No newline at end of file +--8<-- "CODE_OF_CONDUCT.md" diff --git a/docs/contributing.md b/docs/contributing.md index 0d1453a3..ea38c9bf 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1 +1 @@ -{!CONTRIBUTING.md!} \ No newline at end of file +--8<-- "CONTRIBUTING.md" diff --git a/docs/credits.md b/docs/credits.md deleted file mode 100644 index 343d66d3..00000000 --- a/docs/credits.md +++ /dev/null @@ -1 +0,0 @@ -{!CREDITS.md!} \ No newline at end of file diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css deleted file mode 100644 index 54950fb2..00000000 --- a/docs/css/mkdocstrings.css +++ /dev/null @@ -1,33 +0,0 @@ -/* Indentation. */ -div.doc-contents:not(.first) { - padding-left: 25px; - border-left: 4px solid rgba(230, 230, 230); - margin-bottom: 80px; -} - -/* Don't capitalize names. */ -h5.doc-heading { - text-transform: none !important; -} - -/* Don't use vertical space on hidden ToC entries. */ -.hidden-toc::before { - margin-top: 0 !important; - padding-top: 0 !important; -} - -/* Don't show permalink of hidden ToC entries. */ -.hidden-toc a.headerlink { - display: none; -} - -/* Avoid breaking parameters name, etc. in table cells. */ -td code { - word-break: normal !important; -} - -/* For pieces of Markdown rendered in table cells. */ -td p { - margin-top: 0 !important; - margin-bottom: 0 !important; -} \ No newline at end of file diff --git a/docs/css/style.css b/docs/css/style.css new file mode 100644 index 00000000..27265bb0 --- /dev/null +++ b/docs/css/style.css @@ -0,0 +1,17 @@ +/* Indentation for mkdocstrings items. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: 4px solid rgba(230, 230, 230); + margin-bottom: 80px; +} + +/* Mark external links as such (also in nav) */ +a.external:hover::after, a.md-nav__link[href^="https:"]:hover::after { + /* https://primer.style/octicons/link-external-16 */ + background-image: url('data:image/svg+xml,'); + height: 0.8em; + width: 0.8em; + margin-left: 0.2em; + content: ' '; + display: inline-block; +} diff --git a/docs/gen_credits.py b/docs/gen_credits.py new file mode 100644 index 00000000..d626e220 --- /dev/null +++ b/docs/gen_credits.py @@ -0,0 +1,67 @@ +import functools +from itertools import chain +from pathlib import Path + +import httpx +import mkdocs_gen_files +import toml +from jinja2 import StrictUndefined +from jinja2.sandbox import SandboxedEnvironment +from pip._internal.commands.show import search_packages_info # noqa: WPS436 (no other way?) + + +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 + + # all packages might not be credited, + # like the ones that are now part of the standard library + # or the ones that are only used on other operating systems, + # and therefore are not installed, + # but it's not that important + + return { + "project_name": project_name, + "direct_dependencies": sorted(direct_dependencies), + "indirect_dependencies": sorted(indirect_dependencies), + "package_info": packages, + } + + +@functools.lru_cache(maxsize=None) +def get_credits(): + """Return credits as Markdown. + + Returns: + The credits page Markdown. + """ + jinja_env = SandboxedEnvironment(undefined=StrictUndefined) + commit = "166758a98d5e544aaa94fda698128e00733497f4" + template_url = f"https://raw.githubusercontent.com/pawamoy/jinja-templates/{commit}/credits.md" + template_data = get_credits_data() + template_text = httpx.get(template_url).text + return jinja_env.from_string(template_text).render(**template_data) + + +with mkdocs_gen_files.open("credits.md", "w") as f: + f.write(get_credits()) +mkdocs_gen_files.set_edit_path("credits.md", "gen_credits.py") diff --git a/docs/gen_doc_stubs.py b/docs/gen_doc_stubs.py new file mode 100644 index 00000000..ccbbdc86 --- /dev/null +++ b/docs/gen_doc_stubs.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +from pathlib import Path + +import mkdocs_gen_files + +for path in Path("src", "mkdocstrings").glob("**/*.py"): + doc_path = Path("reference", path.relative_to("src", "mkdocstrings")).with_suffix(".md") + + with mkdocs_gen_files.open(doc_path, "w") as f: + ident = ".".join(path.relative_to("src").with_suffix("").parts) + print("::: " + ident, file=f) + + mkdocs_gen_files.set_edit_path(doc_path, Path("..", path)) diff --git a/docs/handlers/overview.md b/docs/handlers/overview.md index c1c2c4d4..351a356e 100644 --- a/docs/handlers/overview.md +++ b/docs/handlers/overview.md @@ -4,7 +4,8 @@ A handler is what makes it possible to collect and render documentation for a pa ## Available handlers -- [Python](../python) +- [Python](python.md) +- Crystal ## Custom handlers @@ -14,23 +15,13 @@ thanks to namespace packages. For more information about namespace packages, ### Packaging -For MkDocstrings, a custom handler package would have the following structure: +For *mkdocstrings*, a custom handler package would have the following structure: ``` πŸ“ your_repository -└── πŸ“ mkdocstrings - Β Β  └── πŸ“ handlers - └── πŸ“„ custom_handler.py -``` - -Or with a `src` layout: - -``` -πŸ“ your_repository -└── πŸ“ src - └── πŸ“ mkdocstrings - Β Β  └── πŸ“ handlers - └── πŸ“„ custom_handler.py +β””β”€β•΄πŸ“ mkdocstrings + Β  β””β”€β•΄πŸ“ handlers + β””β”€β•΄πŸ“„ custom_handler.py ``` **Note the absence of `__init__.py` modules!** @@ -61,24 +52,49 @@ arguments, and you can add any other keyword argument you'd like. The global configuration items (other than `selection` and `rendering`) will be passed to this function when getting your handler. +### Templates + +You renderer's implementation should normally be backed by templates, which go +to the directory `mkdocstrings/handlers/custom_handler/some_theme`. +(`custom_handler` here should be replaced with the actual name of your handler, +and `some_theme` should be the name of an actual MkDocs theme that you support, +e.g. `material`). + +With that structure, you can use `self.env.get_template("foo.html")` inside +your `render` implementation. This already chooses the subdirectory based on +the current MkDocs theme. + +If you wish to support *any* MkDocs theme, rather than a few specifically +selected ones, you can pick one theme's subdirectory to be the fallback for +when an unknown theme is encountered. Then you just need to set the +`fallback_theme` variable on your renderer subclass. The fallback directory can +be used even for themes you explicitly support: you can omit some template from +one of the other theme directories in case they're exactly the same as in the +fallback theme. + +If your theme's HTML requires CSS to go along with it, put it into a file named +`mkdocstrings/handlers/custom_handler/some_theme/style.css`, then this will be +included into the final site automatically if this handler is ever used. +Alternatively, you can put the CSS as a string into the `extra_css` variable of +your renderer. + ### Usage -When a custom handler is installed, -it is then available to MkDocstrings. +When a custom handler is installed, it is then available to *mkdocstrings*. You can configure it as usual: -```yaml -# mkdocs.yml -plugins: -- mkdocstrings: - handlers: - custom_handler: - selection: - some_config_option: "a" - rendering: - other_config_option: 0 - handler_config_option: yes -``` +!!! example "mkdocs.yml" + ```yaml + plugins: + - mkdocstrings: + handlers: + custom_handler: + selection: + some_config_option: "a" + rendering: + other_config_option: 0 + handler_config_option: yes + ``` ...and use it in your autodoc instructions: diff --git a/docs/handlers/python.md b/docs/handlers/python.md index b3561314..4ad1077b 100644 --- a/docs/handlers/python.md +++ b/docs/handlers/python.md @@ -107,7 +107,7 @@ It stands for *(Python) Take Docs*, and is supposed to be a pun on MkDocs (*Make ### Supported docstrings styles -Right now, `pytkdocs` supports the Google-style and reStrcuturedText-style docstring formats. +Right now, `pytkdocs` supports the Google-style and reStructuredText-style docstring formats. #### Google-style @@ -116,7 +116,7 @@ in [Napoleon's documentation](https://sphinxcontrib-napoleon.readthedocs.io/en/l ##### Sections -Docstrings sections are parsed by `pytkdocs` and rendered by MkDocstrings. +Docstrings sections are parsed by `pytkdocs` and rendered by *mkdocstrings*. Supported sections are: - `Arguments` (or `Args`, `Parameters`, `Params`) @@ -208,12 +208,16 @@ Type annotations are read both in the code and in the docstrings. #### reStructuredText-style +!!! warning "Partial support" + Only RST-**style** is supported, not the whole RST markup specification. + Docstrings will still be converted as Markdown. + You can see examples of reStructuredText-style docstrings in [Sphinx's documentation](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html). ##### Sections -Docstrings directives are parsed by `pytkdocs` and rendered by MkDocstrings. +Docstrings directives are parsed by `pytkdocs` and rendered by *mkdocstrings*. Supported directives are: - `param` (or `parameter`, `arg`, `argument`, `key`, `keyword`) @@ -297,21 +301,21 @@ You may want to to generate documentation for a package while its dependencies a The Python handler provides itself no builtin way to mock libraries, but you can use the `setup_commands` to mock them manually: -```yaml -# mkdocs.yml -plugins: - - mkdocstrings: - handlers: - python: - setup_commands: - - import sys - - from unittest.mock import MagicMock as mock - - sys.modules["lib1"] = mock() - - sys.modules["lib2"] = mock() - - sys.modules["lib2.module1"] = mock() - - sys.modules["lib2.module1.moduleB"] = mock() - # etc -``` +!!! example "mkdocs.yml" + ```yaml + plugins: + - mkdocstrings: + handlers: + python: + setup_commands: + - import sys + - from unittest.mock import MagicMock as mock + - sys.modules["lib1"] = mock() + - sys.modules["lib2"] = mock() + - sys.modules["lib2.module1"] = mock() + - sys.modules["lib2.module1.moduleB"] = mock() + # etc + ``` ## Recommended style (Material) @@ -325,33 +329,6 @@ div.doc-contents:not(.first) { border-left: 4px solid rgba(230, 230, 230); margin-bottom: 80px; } - -/* Don't capitalize names. */ -h5.doc-heading { - text-transform: none !important; -} - -/* Don't use vertical space on hidden ToC entries. */ -.hidden-toc::before { - margin-top: 0 !important; - padding-top: 0 !important; -} - -/* Don't show permalink of hidden ToC entries. */ -.hidden-toc a.headerlink { - display: none; -} - -/* Avoid breaking parameters name, etc. in table cells. */ -td code { - word-break: normal !important; -} - -/* For pieces of Markdown rendered in table cells. */ -td p { - margin-top: 0 !important; - margin-bottom: 0 !important; -} ``` ## Recommended style (ReadTheDocs) @@ -365,43 +342,4 @@ div.doc-contents:not(.first) { border-left: 4px solid rgba(230, 230, 230); margin-bottom: 60px; } - -/* Don't use vertical space on hidden ToC entries. */ -.hidden-toc::before { - margin-top: 0 !important; - padding-top: 0 !important; -} - -/* Don't show permalink of hidden ToC entries. */ -.hidden-toc a.headerlink { - display: none; -} - -/* Avoid breaking parameters name, etc. in table cells. */ -td code { - word-break: normal !important; -} - -/* For pieces of Markdown rendered in table cells. */ -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. */ -.field-name { - min-width: 100px; -} -.field-name, .field-body { - border: none !important; - padding: 0 !important; -} -.field-list { - margin: 0 !important; -} ``` diff --git a/docs/index.md b/docs/index.md index 10882f6c..612c7a5e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1 @@ -{!README.md!} \ No newline at end of file +--8<-- "README.md" diff --git a/docs/license.md b/docs/license.md index 02853682..cdacdfef 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,17 +1,3 @@ ``` -ISC License - -Copyright (c) 2019, TimothΓ©e Mazzucotelli - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +--8<-- "LICENSE" ``` diff --git a/docs/reference/autorefs/plugin.md b/docs/reference/autorefs/plugin.md new file mode 100644 index 00000000..fed901e4 --- /dev/null +++ b/docs/reference/autorefs/plugin.md @@ -0,0 +1 @@ +::: mkdocs_autorefs.plugin diff --git a/docs/reference/autorefs/references.md b/docs/reference/autorefs/references.md new file mode 100644 index 00000000..2a23e10e --- /dev/null +++ b/docs/reference/autorefs/references.md @@ -0,0 +1 @@ +::: mkdocs_autorefs.references diff --git a/docs/reference/extension.md b/docs/reference/extension.md deleted file mode 100644 index 93d3efb8..00000000 --- a/docs/reference/extension.md +++ /dev/null @@ -1 +0,0 @@ -::: mkdocstrings.extension diff --git a/docs/reference/handlers/base.md b/docs/reference/handlers/base.md deleted file mode 100644 index 375d1396..00000000 --- a/docs/reference/handlers/base.md +++ /dev/null @@ -1,3 +0,0 @@ -::: mkdocstrings.handlers.base - rendering: - show_root_heading: false diff --git a/docs/reference/handlers/python.md b/docs/reference/handlers/python.md deleted file mode 100644 index b4935a99..00000000 --- a/docs/reference/handlers/python.md +++ /dev/null @@ -1 +0,0 @@ -::: mkdocstrings.handlers.python diff --git a/docs/reference/plugin.md b/docs/reference/plugin.md deleted file mode 100644 index be612e11..00000000 --- a/docs/reference/plugin.md +++ /dev/null @@ -1 +0,0 @@ -::: mkdocstrings.plugin diff --git a/docs/reference/references.md b/docs/reference/references.md deleted file mode 100644 index 11be1ca9..00000000 --- a/docs/reference/references.md +++ /dev/null @@ -1 +0,0 @@ -::: mkdocstrings.references \ No newline at end of file diff --git a/docs/snippets/function_annotations_google.py b/docs/snippets/function_annotations_google.py index 73b00219..f3b20897 100644 --- a/docs/snippets/function_annotations_google.py +++ b/docs/snippets/function_annotations_google.py @@ -2,8 +2,7 @@ def my_function(param1: int, param2: Optional[str] = None) -> str: - """ - A short description of this function. + """A short description of this function. Arguments: param1: An integer? diff --git a/docs/snippets/function_annotations_rst.py b/docs/snippets/function_annotations_rst.py index 8fd79d62..6a802f74 100644 --- a/docs/snippets/function_annotations_rst.py +++ b/docs/snippets/function_annotations_rst.py @@ -2,8 +2,7 @@ def my_function(param1: int, param2: Optional[str] = None) -> str: - """ - A short description of this function. + """A short description of this function. Complex markup is supported in the main description section. diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 00000000..8a1d969c --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,141 @@ +# Themes + +*mkdocstrings* can support multiple MkDocs themes. +It currently supports supports the +*[Material for MkDocs](https://squidfunk.github.io/mkdocs-material/)* +theme and, partially, the built-in ReadTheDocs theme. + +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. + +## Customization + +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. + +### Templates + +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 + plugins: + - mkdocstrings: + custom_templates: templates + ``` + +You directory structure must be identical to the provided templates one: + +``` +templates +β”œβ”€β•΄ +β”‚ β”œβ”€β”€ +β”‚ └── +└── + β”œβ”€β”€ + └── +``` + +(*[Check out the template tree on GitHub](https://github.com/mkdocstrings/mkdocstrings/tree/master/src/mkdocstrings/templates/)*) + +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: + +``` +templates +└── python + └── material + β”œβ”€β”€ parameters.html + └── exceptions.html +``` + +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.") }} +``` + +### 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; } + ``` + + === "Result" + +

+ special + private + property + read-only +

+ + As you can see, CSS is not my field of predilection... + +### Syntax highlighting + +Code blocks that occur in the docstring of an item inserted with *mkdocstrings*, as well as code blocks (such as *Source code*) that *mkdocstrings* inserts itself, are syntax-highlighted according to the same rules as other normal code blocks in your document. See more details in [mkdocstrings.handlers.rendering.Highlighter][]. + +As for the CSS class used for code blocks -- it will also match the "normal" config, so the default (`.codehilite` or `.highlight`) will match your chosen Markdown extension for highlighting. + +!!! important "Changed in version 0.15" + The CSS class used to always be `.highlight`, but now it depends on the configuration. + +Long story short, you probably should add `pymdownx.highlight` to your `markdown_extensions`, and then use `.doc-contents .highlight` as the CSS selector in case you want to change something about *mkdocstrings'* code blocks specifically. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 97de5c8b..20e88195 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,7 +29,7 @@ Please upgrade to version 0.14 or higher. See also: -- [Issue #186](https://github.com/pawamoy/mkdocstrings/issues/186) +- [Issue #186](https://github.com/mkdocstrings/mkdocstrings/issues/186) - [Tabs in docstrings (from `pymdownx.tabbed`) are not working properly](#tabs-in-docstrings-from-pymdownxtabbed-are-not-working-properly). ## MkDocs warns me about links to unfound documentation files @@ -46,80 +46,72 @@ It shows that it's probably a cross-reference, not a direct link. It's probably written like `[Section](pytkdocs.parsers.docstrings.Section)` in the docs, when it should be `[Section][pytkdocs.parsers.docstrings.Section]`. -## Nothing is rendered at all - -Python? - -- "No": we only support Python right now. -- "Yes": is your package available in the Python path? - See [Python handler: Finding module](../handlers/python/#finding-modules). - ## Some objects are not rendered (they do not appear in the generated docs) - Make sure the configuration options of the handler for both selection and rendering are correct. - Check the documentation for [Handlers](../handlers/overview) to see the available options for each handler. + Check the documentation for [Handlers](handlers/overview.md) to see the available options for each handler. - Also make sure your documentation in your source code is formatted correctly. - For Python code, check the [supported docstring styles](../handlers/python/#supported-docstrings-styles) page. + For Python code, check the [supported docstring styles](handlers/python.md#supported-docstrings-styles) page. - Re-run the Mkdocs command with `-v`, and carefully read any traceback. ## Tabs in docstrings (from `pymdownx.tabbed`) are not working properly -Before version 0.14, multiple tabs blocks injected on the same page +Before version 0.14, multiple tab blocks injected on the same page would result in broken links: clicking on a tab would bring the user to the wrong one. Please upgrade to version 0.14 or higher. See also: -- [Issue #193](https://github.com/pawamoy/mkdocstrings/issues/193) +- [Issue #193](https://github.com/mkdocstrings/mkdocstrings/issues/193) - [Footnotes are duplicated or overridden](#footnotes-are-duplicated-or-overridden). -**JavaScript workaround:** - -If you are stuck on version lower than 0.14, -and want to use multiple tabs blocks in one page, +If you are stuck on a version before 0.14, +and want to use multiple tab blocks in one page, use this workaround. -Put the following code in a .js file, -and list it in MkDocs' `extra_javascript`: - -```javascript -// Credits to Nikolaos Zioulis (@zuru on GitHub) -function setID(){ - var tabs = document.getElementsByClassName("tabbed-set"); - for (var i = 0; i < tabs.length; i++) { - children = tabs[i].children; - var counter = 0; - var iscontent = 0; - for(var j = 0; j < children.length;j++){ - if(typeof children[j].htmlFor === 'undefined'){ - if((iscontent + 1) % 2 == 0){ - // check if it is content - if(iscontent == 1){ - btn = children[j].childNodes[1].getElementsByTagName("button"); +??? example "JavaScript workaround" + + Put the following code in a .js file, + and list it in MkDocs' `extra_javascript`: + + ```javascript + // Credits to Nikolaos Zioulis (@zuru on GitHub) + function setID(){ + var tabs = document.getElementsByClassName("tabbed-set"); + for (var i = 0; i < tabs.length; i++) { + children = tabs[i].children; + var counter = 0; + var iscontent = 0; + for(var j = 0; j < children.length;j++){ + if(typeof children[j].htmlFor === 'undefined'){ + if((iscontent + 1) % 2 == 0){ + // check if it is content + if(iscontent == 1){ + btn = children[j].childNodes[1].getElementsByTagName("button"); + } } + else{ + // if not change the id + children[j].id = "__tabbed_" + String(i + 1) + "_" + String(counter + 1); + children[j].name = "__tabbed_" + String(i + 1); + // make default tab open + if(j == 0) + children[j].click(); + } + iscontent++; } else{ - // if not change the id - children[j].id = "__tabbed_" + String(i + 1) + "_" + String(counter + 1); - children[j].name = "__tabbed_" + String(i + 1); - // make default tab open - if(j == 0) - children[j].click(); + // link to the correct tab + children[j].htmlFor = "__tabbed_" + String(i+1) + "_" + String(counter + 1); + counter ++; } - iscontent++; - } - else{ - // link to the correct tab - children[j].htmlFor = "__tabbed_" + String(i+1) + "_" + String(counter + 1); - counter ++; } } } -} -setID(); -``` + setID(); + ``` -This code will correctly reset the IDs for tabs on a same page. + This code will correctly reset the IDs for tabs on a same page. ## The generated documentation does not look good @@ -130,12 +122,16 @@ Are you using the Material theme? asking to support your theme. If you find one, vote with a thumbs up. If not, you can open a ticket. - "Yes": Please open an ticket on the [bugtracker][bugtracker] with a detailed explanation and screenshots of the bad-looking parts. - + +Note that you can always [customize the look](theming.md) of *mkdocstrings* blocks -- through both HTML and CSS. + ## Warning: could not find cross-reference target -- Make sure you have defined `site_url` in `mkdocs.yml`, as it is required for cross-references when building the site - (the error does not happen when serving because then `site_url` is auto-populated by `mkdocs`). -- Make sure the referenced object was both collected and rendered: verify your selection and rendering options. +!!! important "New in version 0.15" + Cross-linking used to include any Markdown heading, but now it's only for *mkdocstrings* identifiers by default. + See [Cross-references to any Markdown heading](usage.md#cross-references-to-any-markdown-heading) to opt back in. + +Make sure the referenced object was both collected and rendered: verify your selection and rendering options. For false-positives, you can wrap the text in backticks (\`) to prevent `mkdocstrings` from trying to process it. @@ -166,6 +162,12 @@ Version 2.11.1 seems to be working fine. ## Python specifics +### Nothing is rendered at all + +Is your package available in the Python path? + +See [Python handler: Finding modules](handlers/python.md#finding-modules). + ### LaTeX in docstrings is not rendered correctly If you are using a Markdown extension like @@ -212,8 +214,7 @@ You can use: import enum class MyEnum(enum.Enum): - """ - My enum. + """My enum. Attributes: v1: The first choice. @@ -260,7 +261,7 @@ def my_function(*args, **kwargs): print(*args, **kwargs) ``` -[bugtracker]: https://github.com/pawamoy/mkdocstrings +[bugtracker]: https://github.com/mkdocstrings/mkdocstrings [pytkdocs]: https://github.com/pawamoy/pytkdocs [inspect]: https://docs.python.org/3/library/inspect.html [ast]: https://docs.python.org/3/library/ast.html diff --git a/docs/usage.md b/docs/usage.md index 3f95d894..ce8c0bef 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,13 +1,15 @@ # Usage -MkDocstrings works by processing special expressions in your Markdown files. +## Autodoc syntax -The syntax is as follow: +*mkdocstrings* works by processing special expressions in your Markdown files. + +The syntax is as follows: ```md ::: identifier YAML block -``` +``` The `identifier` is a string identifying the object you want to document. The format of an identifier can vary from one handler to another. @@ -28,13 +30,13 @@ The YAML block is optional, and contains some configuration options: Every handler accepts at least these two keys, `selection` and `rendering`, and some handlers accept additional keys. -Check the documentation for your handler of interest in [Handlers](../handlers/overview). +Check the documentation for your handler of interest in [Handlers](handlers/overview.md). !!! example "Example with the Python handler" === "docs/my_page.md" ```md # Documentation for `MyClass` - + ::: my_package.my_module.MyClass handler: python selection: @@ -45,31 +47,31 @@ Check the documentation for your handler of interest in [Handlers](../handlers/o show_root_heading: false show_source: false ``` - + === "mkdocs.yml" ```yaml nav: - "My page": my_page.md ``` - + === "src/my_package/my_module.py" ```python class MyClass: """Print print print!""" - + def method_a(self): """Print A!""" print("A!") - + def method_b(self): """Print B!""" print("B!") - + def method_c(self): """Print C!""" print("C!") ``` - + === "Result"

Documentation for MyClass

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'
\n{src}\n
') - - 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.*?)</span>') -""" -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'<a href="{escape(url)}">{title}</a>' - - 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 %} - <h{{ heading_level }} - id="{{ html_id }}" - class="doc doc-heading" - data-toc-label="{{ attribute.name }}"> + {% 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 %} - </h{{ heading_level }}> + {% endfilter %} {% else %} {% if config.show_root_toc_entry %} - <h{{ heading_level }} class="hidden-toc" - id="{{ html_id }}" - data-toc-label="{{ attribute.path }}" - style="visibility: hidden; position: absolute;"> - </h{{ heading_level }}> + {% 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") %} - <h{{ heading_level }}>Attributes</h{{ heading_level }}> + {% 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") %} - <h{{ heading_level }}>Classes</h{{ heading_level }}> + {% 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") %} - <h{{ heading_level }}>Functions</h{{ heading_level }}> + {% 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") %} - <h{{ heading_level }}>Methods</h{{ heading_level }}> + {% 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") %} - <h{{ heading_level }}>Modules</h{{ heading_level }}> + {% 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 %} - <h{{ heading_level }} - id="{{ html_id }}" - class="doc doc-heading" - data-toc-label="{{ class.name }}"> + {% filter heading(heading_level, + id=html_id, + class="doc doc-heading", + toc_label=class.name) %} <code>{% if show_full_path %}{{ class.path }}{% else %}{{ class.name }}{% endif %}</code> @@ -27,15 +27,15 @@ {% include "properties.html" with context %} {% endwith %} - </h{{ heading_level }}> + {% endfilter %} {% else %} {% if config.show_root_toc_entry %} - <h{{ heading_level }} class="hidden-toc" - id="{{ html_id }}" - data-toc-label="{{ class.path }}" - style="visibility: hidden; position: absolute;"> - </h{{ heading_level }}> + {% 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 %} <details class="quote"> <summary>Source code in <code>{{ class.relative_file_path }}</code></summary> - {{ 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) }} </details> {% 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 %} - <h{{ heading_level }} - id="{{ html_id }}" - class="doc doc-heading" - data-toc-label="{{ function.name }}()"> + {% 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 %} - </h{{ heading_level }}> + {% endfilter %} {% else %} {% if config.show_root_toc_entry %} - <h{{ heading_level }} class="hidden-toc" - id="{{ html_id }}" - data-toc-label="{{ function.path }}" - style="visibility: hidden; position: absolute;"> - </h{{ heading_level }}> + {% 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 %} <details class="quote"> <summary>Source code in <code>{{ function.relative_file_path }}</code></summary> - {{ 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) }} </details> {% endif %} </div> 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 %} - <h{{ heading_level }} - id="{{ html_id }}" - class="doc doc-heading" - data-toc-label="{{ method.name }}()"> + {% 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 %} - </h{{ heading_level }}> + {% endfilter %} {% else %} {% if config.show_root_toc_entry %} - <h{{ heading_level }} class="hidden-toc" - id="{{ html_id }}" - data-toc-label="{{ method.path }}" - style="visibility: hidden; position: absolute;"> - </h{{ heading_level }}> + {% 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 %} <details class="quote"> <summary>Source code in <code>{{ method.relative_file_path }}</code></summary> - {{ 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) }} </details> {% endif %} </div> 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 %} - <h{{ heading_level }} - id="{{ html_id }}" - class="doc doc-heading" - data-toc-label="{{ module.name }}"> + {% filter heading(heading_level, + id=html_id, + class="doc doc-heading", + toc_label=module.name) %} <code>{% if show_full_path %}{{ module.path }}{% else %}{{ module.name }}{% endif %}</code> @@ -27,15 +27,15 @@ {% include "properties.html" with context %} {% endwith %} - </h{{ heading_level }}> + {% endfilter %} {% else %} {% if config.show_root_toc_entry %} - <h{{ heading_level }} class="hidden-toc" - id="{{ html_id }}" - data-toc-label="{{ module.path }}" - style="visibility: hidden; position: absolute;"> - </h{{ heading_level }}> + {% 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 "<h3>Foo</h3>" in output - assert "<h5>Bar</h5>" in output - assert "<h6>Baz</h6>" in output + output = ext_markdown.convert("::: tests.fixtures.headings\n rendering:\n show_root_heading: true") + assert ">Foo</h3>" in output + assert ">Bar</h5>" in output + assert ">Baz</h6>" 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 "<strong>preceding</strong>" in output - assert "<h2>Foo</h2>" in output + assert ">Foo</h2>" 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 <span data-mkdocstrings-identifier="something.Else">something.Else</span>.' 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") + == '<pre class="hiiii"><code class="language-python">import foo</code></pre>' + ) + assert ( + hl.highlight("import foo", language="python", inline=True) + == '<code class="hiiii language-python">import foo</code>' + ) + + +@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='<p>This <a href="foo.html#Foo">Foo</a>.</p>', - ) - - -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='<p>This <a href="foo.html#Foo"><code>Foo</code></a>.</p>', - ) - - -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='<p>This <a href="foo.html#Foo&"bar">Foo&"bar</a>.</p>', - ) - - -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="<p>This [Foo bar][].</p>", - ) - - -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="<p>This <code>[Foo][]</code>.</p>", - ) - - -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="<p>[Foo][]</p>", - 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="<p>[<code>Foo</code>][Foo]</p>", - 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="<p>[Foo][*oh*]</p>", - 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="<p>[<code>Foo</code>][]</p>", - unmapped=[], - )