diff --git a/.copier-answers.yml b/.copier-answers.yml index 022b9006..f844b711 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,10 +1,10 @@ # Changes here will be overwritten by Copier -_commit: 0.2.1 -_src_path: gh:pawamoy/copier-poetry +_commit: 0.4.3 +_src_path: gh:pawamoy/copier-pdm.git author_email: pawamoy@pm.me author_fullname: "Timoth\xE9e Mazzucotelli" author_username: pawamoy -copyright_date: '2020' +copyright_date: '2019' copyright_holder: "Timoth\xE9e Mazzucotelli" copyright_holder_email: pawamoy@pm.me copyright_license: ISC License @@ -14,5 +14,6 @@ python_package_command_line_name: mkdocstrings python_package_distribution_name: mkdocstrings python_package_import_name: mkdocstrings repository_name: mkdocstrings -repository_namespace: pawamoy +repository_namespace: mkdocstrings repository_provider: github.com +use_precommit: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aa606c8..b5750cd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,9 @@ defaults: shell: bash env: - LANG: "en_US.utf-8" - LC_ALL: "en_US.utf-8" - POETRY_VIRTUALENVS_IN_PROJECT: "true" - PYTHONIOENCODING: "UTF-8" + LANG: en_US.utf-8 + LC_ALL: en_US.utf-8 + PYTHONIOENCODING: UTF-8 PYTHONPATH: docs jobs: @@ -27,39 +26,44 @@ jobs: - name: Checkout uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + - name: Set up PDM + uses: pdm-project/setup-pdm@v2 with: python-version: 3.8 - - name: Set up the cache - uses: actions/cache@v1 + - name: Set cache variables + id: set_variables + run: | + echo "::set-output name=PIP_CACHE::$(pip cache dir)" + echo "::set-output name=PDM_CACHE::$(pdm config cache_dir)" + + - name: Set up cache + uses: actions/cache@v2 with: - path: .venv - key: quality-venv-cache-2 + path: | + ${{ steps.set_variables.outputs.PIP_CACHE }} + ${{ steps.set_variables.outputs.PDM_CACHE }} + key: checks-cache + + - name: Resolving dependencies + run: pdm lock - - name: Set up the project + - name: Install dependencies run: | - pip install poetry - poetry install -v || { rm -rf .venv; poetry install -v; } - poetry update + pdm install -G duty -G docs -G quality -G typing + pip install safety - name: Check if the documentation builds correctly - run: | - mkdir -p build/coverage - touch build/coverage/index.html - poetry run duty check-docs + run: pdm run duty check-docs - name: Check the code quality - run: poetry run duty check-code-quality + run: pdm run duty check-code-quality - name: Check if the code is correctly typed - run: poetry run duty check-types + run: pdm run duty check-types - name: Check for vulnerabilities in dependencies - run: | - pip install safety - poetry run duty check-dependencies + run: pdm run duty check-dependencies tests: @@ -74,22 +78,27 @@ jobs: - name: Checkout uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - name: Set up PDM + uses: pdm-project/setup-pdm@v2 with: python-version: ${{ matrix.python-version }} - - name: Set up the cache - uses: actions/cache@v1 + - name: Set cache variables + id: set_variables + run: | + echo "::set-output name=PIP_CACHE::$(pip cache dir)" + echo "::set-output name=PDM_CACHE::$(pdm config cache_dir)" + + - name: Set up cache + uses: actions/cache@v2 with: - path: .venv - key: tests-venv-cache-${{ matrix.os }}-py${{ matrix.python-version }} + path: | + ${{ steps.set_variables.outputs.PIP_CACHE }} + ${{ steps.set_variables.outputs.PDM_CACHE }} + key: tests-cache-${{ runner.os }}-${{ matrix.python-version }} - - name: Set up the project - run: | - pip install poetry - poetry install -v || { rm -rf .venv; poetry install -v; } - poetry update + - name: Install dependencies + run: pdm install -G duty -G tests -G docs - name: Run the test suite - run: poetry run duty test + run: pdm run duty test diff --git a/.gitignore b/.gitignore index 25973fb9..f6a13b06 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,14 @@ __pycache__/ dist/ *.egg-info/ build/ +htmlcov/ .coverage* pip-wheel-metadata/ .pytest_cache/ .python-version site/ -poetry.lock +pdm.lock +.pdm.toml +__pypackages__/ .mypy_cache/ .venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b60b3fcc..ef6d4eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,21 +5,64 @@ 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.16.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.16.0) - 2021-09-20 + +[Compare with 0.15.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.15.0...0.16.0) + +### Features +- Add a rendering option to change the sorting of members ([b1fff8b](https://github.com/mkdocstrings/mkdocstrings/commit/b1fff8b8ef4d6d77417fc43ed8be4b578d6437e4) by Joe Rickerby). [Issue #114](https://github.com/mkdocstrings/mkdocstrings/issues/114), [PR #274](https://github.com/mkdocstrings/mkdocstrings/pull/274) +- Add option to show Python base classes ([436f550](https://github.com/mkdocstrings/mkdocstrings/commit/436f5504ad72ab6d1f5b4303e6b68bc84562c32b) by Brian Koropoff). [Issue #269](https://github.com/mkdocstrings/mkdocstrings/issues/269), [PR #297](https://github.com/mkdocstrings/mkdocstrings/pull/297) +- Support loading external Python inventories in Sphinx format ([a8418cb](https://github.com/mkdocstrings/mkdocstrings/commit/a8418cb4c6193d35cdc72508b118a712cf0334e1) by Oleh Prypin). [PR #287](https://github.com/mkdocstrings/mkdocstrings/pull/287) +- Support loading external inventories and linking to them ([8b675f4](https://github.com/mkdocstrings/mkdocstrings/commit/8b675f4671f8bbfd2f337ed043e3682b0a0ad0f6) by Oleh Prypin). [PR #277](https://github.com/mkdocstrings/mkdocstrings/pull/277) +- Very basic support for MkDocs theme ([974ca90](https://github.com/mkdocstrings/mkdocstrings/commit/974ca9010efca1b8279767faf8efcd2470a8371d) by Oleh Prypin). [PR #272](https://github.com/mkdocstrings/mkdocstrings/pull/272) +- Generate objects inventory ([14ed959](https://github.com/mkdocstrings/mkdocstrings/commit/14ed959860a784a835cd71f911081f2026d66c81) and [bbd85a9](https://github.com/mkdocstrings/mkdocstrings/commit/bbd85a92fa70bddfe10a907a4d63b8daf0810cb2) by Timothée Mazzucotelli). [Issue #251](https://github.com/mkdocstrings/mkdocstrings/issues/251), [PR #253](https://github.com/mkdocstrings/mkdocstrings/pull/253) + +### Bug Fixes +- Don't render empty code blocks for missing type annotations ([d2e9e1e](https://github.com/mkdocstrings/mkdocstrings/commit/d2e9e1ef3cf304081b07f763843a9722bf9b117e) by Oleh Prypin). +- Fix custom handler not being used ([6dcf342](https://github.com/mkdocstrings/mkdocstrings/commit/6dcf342fb83b19e385d56d37235f2b23e8c8c767) by Timothée Mazzucotelli). [Issue #259](https://github.com/mkdocstrings/mkdocstrings/issues/259), [PR #263](https://github.com/mkdocstrings/mkdocstrings/pull/263) +- Don't hide `setup_commands` errors ([92418c4](https://github.com/mkdocstrings/mkdocstrings/commit/92418c4b3e80b67d5116efa73931fc113daa60e9) by Gabriel Vîjială). [PR #258](https://github.com/mkdocstrings/mkdocstrings/pull/258) + +### Code Refactoring +- Move writing extra files to an earlier stage in the build ([3890ab5](https://github.com/mkdocstrings/mkdocstrings/commit/3890ab597692e56d7ece576c166373b66ff4e615) by Oleh Prypin). [PR #275](https://github.com/mkdocstrings/mkdocstrings/pull/275) + + +## [0.15.2](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.15.2) - 2021-06-09 + +[Compare with 0.15.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.15.1...0.15.2) + +### Packaging +- MkDocs default schema needs to be obtained differently now ([b3e122b](https://github.com/mkdocstrings/mkdocstrings/commit/b3e122b36d586632738ddedaed7d3df8d5dead44) by Oleh Prypin). [PR #273](https://github.com/mkdocstrings/mkdocstrings/pull/273) +- Compatibility with MkDocs 1.2: livereload isn't guaranteed now ([36e8024](https://github.com/mkdocstrings/mkdocstrings/commit/36e80248d2ab9e61975f6c83ae517115c9410fc1) by Oleh Prypin). [PR #294](https://github.com/mkdocstrings/mkdocstrings/pull/294) + + +## [0.15.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.15.1) - 2021-05-16 + +[Compare with 0.15.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.15.0...0.15.1) + +### Bug Fixes +- Prevent error during parallel installations ([fac2c71](https://github.com/mkdocstrings/mkdocstrings/commit/fac2c711351f7b62bf5308f19cfc612a3944588a) by Timothée Mazzucotelli). + +### Packaging +- Support the upcoming major Jinja and MarkupSafe releases ([bb4f9de](https://github.com/mkdocstrings/mkdocstrings/commit/bb4f9de08a77bef85e550d70deb0db13e6aa0c96) by Oleh Prypin). [PR #283](https://github.com/mkdocstrings/mkdocstrings/pull/283) +- Accept a higher version of mkdocs-autorefs ([c8de08e](https://github.com/mkdocstrings/mkdocstrings/commit/c8de08e177f78290d3baaca2716d1ec64c9059b6) by Oleh Prypin). [PR #282](https://github.com/mkdocstrings/mkdocstrings/pull/282) + + ## [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: +The following 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. +- *mkdocstrings* now respects your configured code highlighting method, + so if you are using the CodeHilite extension, the `.highlight` CSS class in the rendered HTML will become `.codehilite`. + So make sure to adapt your extra CSS accordingly. Or just switch to using [pymdownx.highlight](https://facelessuser.github.io/pymdown-extensions/extensions/highlight/), it's better supported by *mkdocstrings* anyway. See [Syntax highlighting](https://mkdocstrings.github.io/theming/#syntax-highlighting). +- Most of the [CSS rules that *mkdocstrings* used to recommend](https://mkdocstrings.github.io/handlers/python/#recommended-style-material) for manual addition, now become mandatory (auto-injected into the site). This shouldn't *break* any of your styles, but you are welcome to remove the now-redundant lines that you had copied into `extra_css`, [similarly to this diff](https://github.com/mkdocstrings/mkdocstrings/pull/218/files#diff-7889a1924c66ff9318f1d81c4a3b75658d09bebf0db3b2e4023ba3e40294eb73). ### 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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75609fd4..31a02435 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,18 +17,18 @@ make setup !!! note If it fails for some reason, you'll need to install - [Poetry](https://github.com/python-poetry/poetry) + [PDM](https://github.com/pdm-project/pdm) manually. You can install it with: ```bash python3 -m pip install --user pipx - pipx install poetry + pipx install pdm ``` Now you can try running `make setup` again, - or simply `poetry install`. + or simply `pdm install`. You now have the dependencies installed. @@ -43,11 +43,9 @@ on multiple Python versions, you can do one of the following: 1. `export PYTHON_VERSIONS= `: this will run the task with only the current Python version -2. run the task directly with `poetry run duty TASK`, - or `duty TASK` if the environment was already activated - through `poetry shell` +2. run the task directly with `pdm run duty TASK` -The Makefile detects if the Poetry environment is activated, +The Makefile detects if a virtual environment is activated, so `make` will work the same with the virtualenv activated or not. ## Development diff --git a/Makefile b/Makefile index 28074e01..97aa6931 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ .DEFAULT_GOAL := help SHELL := bash -DUTY = $(shell [ -n "${VIRTUAL_ENV}" ] || echo poetry run) duty +DUTY = $(shell [ -n "${VIRTUAL_ENV}" ] || echo pdm run) duty 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 = cleancov match +test_args = match BASIC_DUTIES = \ changelog \ diff --git a/README.md b/README.md index 67ea0f29..cb207ff0 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,9 @@ Automatic documentation from sources, for [MkDocs](https://mkdocs.org/). --- -![mkdocstrings_gif1](https://user-images.githubusercontent.com/3999221/77157604-fb807480-6aa1-11ea-99e0-d092371d4de0.gif) - ---- +**[Features](#features)** - **[Python handler](#python-handler)** - **[Requirements](#requirements)** - **[Installation](#installation)** - **[Quick usage](#quick-usage)** -- [Features](#features) - - [Python handler features](#python-handler-features) -- [Requirements](#requirements) -- [Installation](#installation) -- [Quick usage](#quick-usage) +![mkdocstrings_gif1](https://user-images.githubusercontent.com/3999221/77157604-fb807480-6aa1-11ea-99e0-d092371d4de0.gif) ## Features @@ -42,7 +36,7 @@ Automatic documentation from sources, for [MkDocs](https://mkdocs.org/). *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). + [opt in](https://mkdocstrings.github.io/usage/#cross-references-to-any-markdown-heading). - [**Inline injection in Markdown:**](https://mkdocstrings.github.io/usage/) instead of generating Markdown files, *mkdocstrings* allows you to inject @@ -61,42 +55,42 @@ Automatic documentation from sources, for [MkDocs](https://mkdocs.org/). - **Reasonable defaults:** you should be able to just drop the plugin in your configuration and enjoy your auto-generated docs. -### Python handler features +### Python handler + +![mkdocstrings_gif2](https://user-images.githubusercontent.com/3999221/77157838-7184db80-6aa2-11ea-9f9a-fe77405202de.gif) - **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 - 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. - - **Support for documented attribute:** attributes (variables) followed by a docstring (triple-quoted string) will - 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*. - - **Google-style sections support in docstrings:** `pytkdocs` understands `Arguments:`, `Raises:` - 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. - *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. + [`pytkdocs`](https://github.com/pawamoy/pytkdocs). + +- **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. + +- **Support for documented attributes:** attributes (variables) followed by a docstring (triple-quoted string) will + 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*. + +- **Multiple docstring-styles support:** almost complete support for Google-style, Numpy-style, + and reStructuredText-style docstrings. *Notes: only RST **style** is supported, not the whole markup. + Numpy-style requires an extra dependency from `pytkdocs`: `pytkdocs[numpy-style]`.* + +- **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!* + - **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: + you can 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 of the Python object. -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/mkdocstrings/mkdocstrings/issues/183) on the bugtracker. @@ -136,7 +130,7 @@ pip install mkdocs-material With `pip`: ```bash -python3.6 -m pip install mkdocstrings +pip install mkdocstrings ``` With `conda`: @@ -144,6 +138,12 @@ With `conda`: conda install -c conda-forge mkdocstrings ``` +Note for Python: you'll need an extra dependency to parse Numpy-style docstrings: + +``` +pip install pytkdocs[numpy-style] +``` + ## Quick usage ```yaml diff --git a/config/coverage.ini b/config/coverage.ini index 27b21edf..bb43c37b 100644 --- a/config/coverage.ini +++ b/config/coverage.ini @@ -1,23 +1,22 @@ -[coverage:paths] -source = - src/mkdocstrings - */site-packages/mkdocstrings - [coverage:run] branch = true -source = - src/mkdocstrings - tests parallel = true +source = + src/ + tests/ + +[coverage:paths] +equivalent = + src/ + __pypackages__/ [coverage:report] ignore_errors = True precision = 2 omit = - tests/* - -[coverage:html] -directory = build/coverage + src/*/__init__.py + src/*/__main__.py + tests/__init__.py [coverage:json] -output = build/coverage.json +output = htmlcov/coverage.json diff --git a/config/flake8.ini b/config/flake8.ini index 3e559fd2..2b50d854 100644 --- a/config/flake8.ini +++ b/config/flake8.ini @@ -1,50 +1,129 @@ [flake8] -exclude = fixtures,docs,site +exclude = fixtures,site,snippets max-line-length = 132 strictness = long docstring-convention = google +ban-relative-imports = true ignore = - # we write docstrings in markdown, not rst - RST*, # redundant with W0622 (builtin override), which is more precise about line number - A001, + A001 # missing docstring in magic method - D105, - # whitespace before ‘:’ (incompatible with Black) - E203, + D105 + # whitespace before ':' (incompatible with Black) + E203 # redundant with E0602 (undefined variable) - F821, + F821 + # error suffix foe exception + N818 # black already deals with quoting - Q000, + Q000 # use of assert - S101, + S101 # we are not parsing XML - S405, + S405 # line break before binary operator (incompatible with Black) - W503, + W503 # two-lowercase-letters variable DO conform to snake_case naming style - C0103, - # redunant with D102 (missing docstring) - C0116, + C0103 + # redundant with D102 (missing docstring) + C0116 # line too long - C0301, + C0301 # too many instance attributes - R0902, + R0902 # too few public methods - R0903, + R0903 # too many public methods - R0904, + R0904 # too many branches - R0912, + R0912 # too many methods - R0913, + R0913 # too many local variables - R0914, + R0914 # too many statements - R0915, + R0915 + # protected attribute + W0212 # redundant with F401 (unused import) - W0611, + W0611 # lazy formatting for logging calls - W1203, + W1203 # short name VNE001 + # f-strings + WPS305 + # common variable names (too annoying) + WPS110 + # redundant with W0622 (builtin override), which is more precise about line number + WPS125 + # too many imports + WPS201 + # too many module members + WPS202 + # overused expression + WPS204 + # too many local variables + WPS210 + # too many arguments + WPS211 + # too many expressions + WPS213 + # too many methods + WPS214 + # too deep nesting + WPS220 + # high Jones complexity + WPS221 + # too many elif branches + WPS223 + # string over-use: can't disable it per file? + WPS226 + # too many public instance attributes + WPS230 + # too complex function + WPS231 + # too many variables unpacked + WPS236 + # too complex f-string + WPS237 + # too cumbersome, asks to write class A(object) + WPS306 + # multi-line parameters (incompatible with Black) + WPS317 + # multi-line strings (incompatible with attributes docstrings) + WPS322 + # implicit string concatenation + WPS326 + # explicit string concatenation + WPS336 + # line starts with dot (incompatible with Black) + WPS348 + # blank line before bracket (incompatible with Black) + WPS355 + # raw string + WPS360 + # noqa overuse + WPS402 + # __init__ modules with logic + WPS412 + # del/pass + WPS420 + # print statements + WPS421 + # statement with no effect (not compatible with attribute docstrings) + WPS428 + # magic numbers + WPS432 + # redundant with C0415 (not top-level import) + WPS433 + # multiline usage (variable docstring) + WPS462 + # try finally without except + WPS501 + # implicit dict.get usage (generally false-positive) + WPS529 + # subclassing builtin + WPS600 + # getter/stter (false positives) + WPS615 diff --git a/config/mypy.ini b/config/mypy.ini index 3d1cd433..e88e9042 100644 --- a/config/mypy.ini +++ b/config/mypy.ini @@ -1,2 +1,15 @@ [mypy] ignore_missing_imports = true +exclude = tests/fixtures/ + +[mypy-docutils.*] +ignore_missing_imports = true + +[mypy-markdown.*] +ignore_missing_imports = true + +[mypy-toml.*] +ignore_missing_imports = true + +[mypy-yaml.*] +ignore_missing_imports = true diff --git a/config/pytest.ini b/config/pytest.ini index 08aef81e..ad72bbe6 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -11,5 +11,6 @@ python_files = tests.py addopts = --cov - --cov-append --cov-config config/coverage.ini +testpaths = + tests diff --git a/docs/css/material.css b/docs/css/material.css new file mode 100644 index 00000000..9e8c14a6 --- /dev/null +++ b/docs/css/material.css @@ -0,0 +1,4 @@ +/* More space at the bottom of the page. */ +.md-main__inner { + margin-bottom: 1.5rem; +} diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css new file mode 100644 index 00000000..a83172e5 --- /dev/null +++ b/docs/css/mkdocstrings.css @@ -0,0 +1,11 @@ +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: 4px solid rgba(230, 230, 230); + margin-bottom: 80px; +} + +/* Avoid breaking parameters name, etc. in table cells. */ +td code { + word-break: normal !important; +} diff --git a/docs/css/style.css b/docs/css/style.css index 27265bb0..abd97598 100644 --- a/docs/css/style.css +++ b/docs/css/style.css @@ -1,10 +1,3 @@ -/* 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 */ @@ -15,3 +8,8 @@ a.external:hover::after, a.md-nav__link[href^="https:"]:hover::after { content: ' '; display: inline-block; } + +/* More space at the bottom of the page */ +.md-main__inner { + margin-bottom: 1.5rem; +} diff --git a/docs/gen_credits.py b/docs/gen_credits.py index d626e220..370d2e7d 100644 --- a/docs/gen_credits.py +++ b/docs/gen_credits.py @@ -1,13 +1,15 @@ +"""Generate the credits page.""" + import functools +import re from itertools import chain from pathlib import Path +from urllib.request import urlopen -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: @@ -17,33 +19,26 @@ def get_credits_data() -> dict: 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") + metadata = toml.load(project_dir / "pyproject.toml")["project"] + metadata_pdm = toml.load(project_dir / "pyproject.toml")["tool"]["pdm"] + lock_data = toml.load(project_dir / "pdm.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") + all_dependencies = chain( + metadata.get("dependencies", []), + chain(*metadata.get("optional-dependencies", {}).values()), + chain(*metadata_pdm.get("dev-dependencies", {}).values()), + ) + direct_dependencies = {re.sub(r"[^\w-].*$", "", dep) for dep in all_dependencies} + direct_dependencies = {dep.lower() for dep in direct_dependencies} 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, + "more_credits": "http://pawamoy.github.io/credits/", } @@ -55,13 +50,13 @@ def get_credits(): The credits page Markdown. """ jinja_env = SandboxedEnvironment(undefined=StrictUndefined) - commit = "166758a98d5e544aaa94fda698128e00733497f4" + commit = "c78c29caa345b6ace19494a98b1544253cbaf8c1" 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 + template_text = urlopen(template_url).read().decode("utf8") # noqa: S310 return jinja_env.from_string(template_text).render(**template_data) -with mkdocs_gen_files.open("credits.md", "w") as f: - f.write(get_credits()) +with mkdocs_gen_files.open("credits.md", "w") as fd: + fd.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 deleted file mode 100644 index ccbbdc86..00000000 --- a/docs/gen_doc_stubs.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/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/gen_ref_nav.py b/docs/gen_ref_nav.py new file mode 100644 index 00000000..1411abdb --- /dev/null +++ b/docs/gen_ref_nav.py @@ -0,0 +1,28 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() + +for path in sorted(Path("src").glob("**/*.py")): + module_path = path.relative_to("src").with_suffix("") + doc_path = path.relative_to("src", "mkdocstrings").with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + parts = list(module_path.parts) + parts[-1] = f"{parts[-1]}.py" + nav[parts] = doc_path + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(module_path.parts) + print("::: " + ident, file=fd) + + mkdocs_gen_files.set_edit_path(full_doc_path, path) + +nav["mkdocs_autorefs", "references.py"] = "autorefs/references.md" +nav["mkdocs_autorefs", "plugin.py"] = "autorefs/plugin.md" + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/handlers/python.md b/docs/handlers/python.md index 4ad1077b..967bbd58 100644 --- a/docs/handlers/python.md +++ b/docs/handlers/python.md @@ -44,7 +44,7 @@ Option | Type | Description | Default **`filters`** | `list of str` | List of filtering regular expressions. Prefix with `!` to exclude objects whose name match. The default means *exclude private members*. | `["!^_[^_]"]` **`members`** | `bool`, or `list of str` | Explicitly select members. True means *all*, false means *none*. | `True` **`inherited_members`** | `bool` | Also select members inherited from parent classes. | `False` -**`docstring_style`** | `str` | Docstring style to parse. `pytkdocs` supports `google` and `restructured-text`. | `"google"` +**`docstring_style`** | `str` | Docstring style to parse. `pytkdocs` supports `google`, `numpy` and `restructured-text`. *Note: Numpy-style requires the `numpy-style` extra of `pytkdocs`.* | `"google"` **`docstring_options`** | `dict` | Options to pass to the docstring parser. See [Collector: pytkdocs](#collector-pytkdocs) | `{}` **`new_path_syntax`** | `bool` | Whether to use the new "colon" path syntax when importing objects. | `False` @@ -107,7 +107,11 @@ 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 reStructuredText-style docstring formats. +Right now, `pytkdocs` supports the Google-style, Numpy-style and reStructuredText-style docstring formats. +The style used by default is the Google-style. +You can configure what style you want to use with +the `docstring_style` and `docstring_options` [selection options](#selection), +both globally or per autodoc instruction. #### Google-style @@ -206,6 +210,21 @@ Type annotations are read both in the code and in the docstrings. show_root_heading: no show_root_toc_entry: no +#### Numpy-style + +!!! important "Extra dependency required" + You'll need an extra dependency to parse Numpy-style docstrings: + + ``` + pdm add -d --group docs 'pytkdocs[numpy-style]' + poetry add -D 'pytkdocs[numpy-style]' + pip install 'pytkdocs[numpy-style]' + # etc. + ``` + +You can see examples of Numpy-style docstrings +in [numpydoc's documentation](https://numpydoc.readthedocs.io/en/latest/format.html). + #### reStructuredText-style !!! warning "Partial support" @@ -240,7 +259,7 @@ Type annotations are read both in the code and in the docstrings. ::: snippets.function_annotations_rst:my_function selection: - docstring_style: "restructured-text" + docstring_style: "restructured-text" rendering: show_root_heading: no show_root_toc_entry: no diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 20e88195..7bfa2163 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -135,29 +135,6 @@ Make sure the referenced object was both collected and rendered: verify your sel For false-positives, you can wrap the text in backticks (\`) to prevent `mkdocstrings` from trying to process it. -## WindowsPath object is not iterable - -If you get a traceback like this one: - -``` -... -File "c:\users\me\appdata\local\continuum\anaconda3\lib\site-packages\mkdocstrings\handlers\python.py", line 244, in get_handler - return PythonHandler(collector=PythonCollector(), renderer=PythonRenderer("python", theme)) -File "c:\users\me\appdata\local\continuum\anaconda3\lib\site-packages\mkdocstrings\handlers\__init__.py", line 124, in __init__ - self.env = Environment(autoescape=True, loader=FileSystemLoader(theme_dir)) -File "c:\users\me\appdata\local\continuum\anaconda3\lib\site-packages\jinja2\loaders.py", line 163, in __init__ - self.searchpath = list(searchpath) -TypeError: 'WindowsPath' object is not iterable -``` - -Try upgrading your installed version of Jinja2: - -``` -pip install -U jinja2 -``` - -Version 2.11.1 seems to be working fine. - --- ## Python specifics diff --git a/docs/usage.md b/docs/usage.md index ce8c0bef..703dcab7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -112,6 +112,8 @@ The above is equivalent to: The path is relative to the docs directory. See [Theming](theming.md). - `handlers`: the handlers global configuration. +- `enable_inventory`: whether to enable inventory file generation. + See [Cross-references to other projects / inventories](#cross-references-to-other-projects-inventories) Example: @@ -163,7 +165,7 @@ Any item that was inserted using the [autodoc syntax](#autodoc-syntax) cross-reference syntax (`[example][full.path.object1]`). But the cross-references are also applicable to the items' children that get pulled in. -#### Finding out the anchor +### Finding out the anchor 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. @@ -250,6 +252,85 @@ The above tip about [Finding out the anchor](#finding-out-the-anchor) also appli 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. +### Cross-references to other projects / inventories + +!!! tip "New in version 0.16." + +Python developers coming from Sphinx might know about its `intersphinx` extension, +that allows to cross-reference items between several projects. +*mkdocstrings* has a similar feature. + +To reference an item from another project, you must first tell *mkdocstrings* +to load the inventory it provides. Each handler will be responsible of loading +inventories specific to its language. For example, the Python handler +can load Sphinx-generated inventories (`objects.inv`). + +In the following snippet, we load the inventory provided by `requests`: + +```yaml +plugins: +- mkdocstrings: + handlers: + python: + import: + - https://docs.python-requests.org/en/master/objects.inv +``` + +Now it is possible to cross-reference `requests`' items! For example: + +=== "Markdown" + ```md + See [requests.request][] to know what parameters you can pass. + ``` + +=== "Result (HTML)" + ```html +

See requests.request + to know what parameters you can pass.

+ ``` + +=== "Result (displayed)" + See [requests.request][] to know what parameters you can pass. + +You can of course select another version of the inventory, for example: + +```yaml +plugins: +- mkdocstrings: + handlers: + python: + import: + - https://docs.python-requests.org/en/v3.0.0/objects.inv +``` + +In case the inventory file is not served under the base documentation URL, +you can explicitly specify both URLs: + +```yaml +plugins: +- mkdocstrings: + handlers: + python: + import: + - url: https://cdn.example.com/version/objects.inv + base_url: https://docs.example.com/version +``` + +Absolute URLs to cross-referenced items will then be based +on `https://docs.example.com/version/` instead of `https://cdn.example.com/version/`. + +Reciprocally, *mkdocstrings* also allows to *generate* an inventory file in the Sphinx format. +It will be enabled by default if the Python handler is used, and generated as `objects.inv` in the final site directory. +Other projects will be able to cross-reference items from your project! + +To explicitely enable or disable the generation of the inventory file, use the global +`enable_inventory` option: + +```yaml +plugins: +- mkdocstrings: + enable_inventory: false +``` ## Watch directories diff --git a/duties.py b/duties.py index be0f8d90..284c97ce 100644 --- a/duties.py +++ b/duties.py @@ -3,15 +3,16 @@ import os import re import sys +from functools import wraps +from pathlib import Path from shutil import which from typing import List, Optional, Pattern +from urllib.request import urlopen -import httpx from duty import duty -from git_changelog.build import Changelog, Version -from jinja2.sandbox import SandboxedEnvironment -PY_SRC_LIST = ("src/mkdocstrings", "tests", "duties.py", "docs") +PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "docs")) +PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) 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", ""} @@ -19,16 +20,7 @@ PTY = not WINDOWS and not CI -def latest(lines: List[str], regex: Pattern) -> Optional[str]: - """Return the last released version. - - Arguments: - lines: Lines of the changelog file. - regex: A compiled regex to find version numbers. - - Returns: - The last version. - """ +def _latest(lines: List[str], regex: Pattern) -> Optional[str]: for line in lines: match = regex.search(line) if match: @@ -36,46 +28,13 @@ def latest(lines: List[str], regex: Pattern) -> Optional[str]: return None -def unreleased(versions: List[Version], last_release: str) -> List[Version]: - """Return the most recent versions down to latest release. - - Arguments: - versions: All the versions (released and unreleased). - last_release: The latest release. - - Returns: - A list of versions. - """ +def _unreleased(versions, last_release): for index, version in enumerate(versions): if version.tag == last_release: return versions[:index] return versions -def read_changelog(filepath: str) -> List[str]: - """Read the changelog file. - - Arguments: - filepath: The path to the changelog file. - - Returns: - The changelog lines. - """ - with open(filepath, "r") as changelog_file: - return changelog_file.read().splitlines() - - -def write_changelog(filepath: str, lines: List[str]) -> None: - """Write the changelog file. - - Arguments: - filepath: The path to the changelog file. - lines: The lines to write to the file. - """ - with open(filepath, "w") as changelog_file: - changelog_file.write("\n".join(lines).rstrip("\n") + "\n") - - def update_changelog( inplace_file: str, marker: str, @@ -92,9 +51,13 @@ def update_changelog( template_url: The URL to the Jinja template used to render contents. commit_style: The style of commit messages to parse. """ + from git_changelog.build import Changelog + from jinja2.sandbox import SandboxedEnvironment + env = SandboxedEnvironment(autoescape=False) - template = env.from_string(httpx.get(template_url).text) - changelog = Changelog(".", style=commit_style) # noqa: W0621 (shadowing changelog) + template_text = urlopen(template_url).read().decode("utf8") # noqa: S310 + template = env.from_string(template_text) + changelog = Changelog(".", style=commit_style) if len(changelog.versions_list) == 1: last_version = changelog.versions_list[0] @@ -104,13 +67,17 @@ def update_changelog( last_version.url += planned_tag last_version.compare_url = last_version.compare_url.replace("HEAD", planned_tag) - lines = read_changelog(inplace_file) - last_released = latest(lines, re.compile(version_regex)) + with open(inplace_file, "r") as changelog_file: + lines = changelog_file.read().splitlines() + + last_released = _latest(lines, re.compile(version_regex)) if last_released: - changelog.versions_list = unreleased(changelog.versions_list, last_released) + changelog.versions_list = _unreleased(changelog.versions_list, last_released) rendered = template.render(changelog=changelog, inplace=True) lines[lines.index(marker)] = rendered - write_changelog(inplace_file, lines) + + with open(inplace_file, "w") as changelog_file: # noqa: WPS440 + changelog_file.write("\n".join(lines).rstrip("\n") + "\n") @duty @@ -120,13 +87,15 @@ def changelog(ctx): Arguments: ctx: The context instance (passed automatically). """ + commit = "166758a98d5e544aaa94fda698128e00733497f4" + template_url = f"https://raw.githubusercontent.com/pawamoy/jinja-templates/{commit}/keepachangelog.md" ctx.run( update_changelog, kwargs={ "inplace_file": "CHANGELOG.md", "marker": "", "version_regex": r"^## \[v?(?P[^\]]+)", - "template_url": "https://raw.githubusercontent.com/pawamoy/jinja-templates/master/keepachangelog.md", + "template_url": template_url, "commit_style": "angular", }, title="Updating changelog", @@ -135,12 +104,12 @@ 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) +def check(ctx): """Check it all! Arguments: ctx: The context instance (passed automatically). - """ # noqa: D400 (exclamation mark is funnier) + """ @duty @@ -170,30 +139,48 @@ 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( - "poetry export -f requirements.txt --without-hashes | " - f"{safety} check --stdin --full-report -i {ignored_cves}", + f"pdm export -f requirements --without-hashes | {safety} check --stdin --full-report", title="Checking dependencies", pty=PTY, nofail=nofail, ) +def no_docs_py36(nofail=True): + """Decorate a duty that builds docs to warn that it's not possible on Python 3.6. + + Arguments: + nofail: Whether to fail or not. + + Returns: + The decorated function. + """ + + def decorator(func): + @wraps(func) + def wrapper(ctx): + if sys.version_info <= (3, 7, 0): + ctx.run(["false"], title="Docs can't be built on Python 3.6", nofail=nofail, quiet=True) + else: + func(ctx) + + return wrapper + + return decorator + + @duty +@no_docs_py36() def check_docs(ctx): """Check if the documentation builds correctly. Arguments: ctx: The context instance (passed automatically). """ - # 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) + Path("htmlcov").mkdir(parents=True, exist_ok=True) + Path("htmlcov/index.html").touch(exist_ok=True) + ctx.run("mkdocs build -s", title="Building documentation", pty=PTY) @duty @@ -216,8 +203,10 @@ def clean(ctx): ctx.run("rm -rf .coverage*") ctx.run("rm -rf .mypy_cache") ctx.run("rm -rf .pytest_cache") + ctx.run("rm -rf tests/.pytest_cache") ctx.run("rm -rf build") ctx.run("rm -rf dist") + ctx.run("rm -rf htmlcov") ctx.run("rm -rf pip-wheel-metadata") ctx.run("rm -rf site") ctx.run("find . -type d -name __pycache__ | xargs rm -rf") @@ -225,6 +214,7 @@ def clean(ctx): @duty +@no_docs_py36(nofail=False) def docs(ctx): """Build the documentation locally. @@ -235,6 +225,7 @@ def docs(ctx): @duty +@no_docs_py36(nofail=False) def docs_serve(ctx, host="127.0.0.1", port=8000): """Serve the documentation (localhost:8000). @@ -247,18 +238,19 @@ def docs_serve(ctx, host="127.0.0.1", port=8000): @duty +@no_docs_py36(nofail=False) def docs_deploy(ctx): """Deploy the documentation on GitHub pages. Arguments: ctx: The context instance (passed automatically). """ - ctx.run("git remote set-url org-pages git@github.com:mkdocstrings/mkdocstrings.github.io", silent=True) + ctx.run("git remote add org-pages git@github.com:mkdocstrings/mkdocstrings.github.io", silent=True, nofail=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) +def format(ctx): """Run formatting tools on the code. Arguments: @@ -281,16 +273,15 @@ def release(ctx, version): ctx: The context instance (passed automatically). version: The new version number to use. """ - ctx.run(f"poetry version {version}", title=f"Bumping version in pyproject.toml to {version}", pty=PTY) ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) if not TESTING: ctx.run("git push", title="Pushing commits", pty=False) 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) - docs_deploy.run() + ctx.run("pdm build", title="Building dist/wheel", pty=PTY) + ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY) + docs_deploy.run() # type: ignore @duty(silent=True) @@ -300,21 +291,31 @@ def coverage(ctx): Arguments: ctx: The context instance (passed automatically). """ + ctx.run("coverage combine", nofail=True) ctx.run("coverage report --rcfile=config/coverage.ini", capture=False) ctx.run("coverage html --rcfile=config/coverage.ini") @duty -def test(ctx, cleancov: bool = True, match: str = ""): +def test(ctx, 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) + try: # noqa: WPS229 + import sphinx # isort:skip # noqa: F401 + import docutils # isort:skip # noqa: F401 + except ImportError: + py = f"{sys.version_info.major}.{sys.version_info.minor}" + ctx.run( + f"pip install sphinx docutils --no-deps -t __pypackages__/{py}/lib", + title="Installing additional test dependencies", + ) + + py_version = f"{sys.version_info.major}{sys.version_info.minor}" + os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" ctx.run( ["pytest", "-c", "config/pytest.ini", "-n", "auto", "-k", match, "tests"], title="Running tests", diff --git a/mkdocs.yml b/mkdocs.yml index 80dbc607..96c2e575 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,9 +4,14 @@ site_url: "https://mkdocstrings.github.io/" repo_url: "https://github.com/mkdocstrings/mkdocstrings" edit_uri: "blob/master/docs/" repo_name: "mkdocstrings/mkdocstrings" +site_dir: "site" nav: -- Overview: index.md +- Home: + - Overview: index.md + - Changelog: changelog.md + - Credits: credits.md + - License: license.md - Usage: - usage.md - Theming: theming.md @@ -15,38 +20,45 @@ nav: - Python: handlers/python.md - Crystal: https://mkdocstrings.github.io/crystal/ - Troubleshooting: troubleshooting.md -- Code Reference: - - 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 +# defer to gen-files + literate-nav +- Code Reference: reference/ +- Development: + - Contributing: contributing.md - Code of Conduct: code_of_conduct.md - Coverage report: coverage.md -- Changelog: changelog.md -- Credits: credits.md -- License: license.md +- Author's website: https://pawamoy.github.io/ theme: name: material + icon: + logo: material/currency-sign + features: + - navigation.tabs + - navigation.top palette: - scheme: slate + - media: "(prefers-color-scheme: light)" + scheme: default primary: teal accent: purple + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: lime + toggle: + icon: material/weather-night + name: Switch to light mode extra_css: - css/style.css +- css/material.css +- css/mkdocstrings.css markdown_extensions: - admonition +- pymdownx.details - pymdownx.emoji - pymdownx.magiclink - pymdownx.snippets: @@ -62,10 +74,11 @@ plugins: - gen-files: scripts: - docs/gen_credits.py - - docs/gen_doc_stubs.py + - docs/gen_ref_nav.py +- literate-nav: + nav_file: SUMMARY.md - section-index -- coverage: - html_report_dir: build/coverage +- coverage - mkdocstrings: handlers: python: @@ -74,5 +87,14 @@ plugins: - sys.path.append("docs") selection: new_path_syntax: yes + import: # demonstration purpose in the docs + - https://docs.python-requests.org/en/master/objects.inv watch: - src/mkdocstrings + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/pawamoy + - icon: fontawesome/brands/twitter + link: https://twitter.com/pawamoy diff --git a/pyproject.toml b/pyproject.toml index cf5c7286..c517aa90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,69 +1,95 @@ [build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires = ["pdm-pep517"] +build-backend = "pdm.pep517.api" -[tool.poetry] +[project] name = "mkdocstrings" -version = "0.15.0" +version = {use_scm = true} description = "Automatic documentation from sources, for MkDocs." -authors = ["Timothée Mazzucotelli "] -license = "ISC License" +authors = [{name = "Timothée Mazzucotelli", email = "pawamoy@pm.me"}] +license = {file = "LICENSE"} readme = "README.md" -repository = "https://github.com/mkdocstrings/mkdocstrings" -homepage = "https://github.com/mkdocstrings/mkdocstrings" +requires-python = ">=3.6" keywords = ["mkdocs", "mkdocs-plugin", "docstrings", "autodoc", "documentation"] -packages = [ { include = "mkdocstrings", from = "src" } ] -include = [ - "README.md", - "pyproject.toml" +dynamic = ["version", "classifiers"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: ISC License (ISCL)", + "Typing :: Typed", +] +dependencies = [ + "Jinja2>=2.11.1,<4.0", + "Markdown~=3.3", + "MarkupSafe>=1.1,<3.0", + "mkdocs~=1.2", + "mkdocs-autorefs>=0.1,<0.4", + "pymdown-extensions>=6.3,<9.0", + "pytkdocs>=0.2.0,<0.13.0", ] -[tool.poetry.dependencies] -python = "^3.6" -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.12.0" - -[tool.poetry.dev-dependencies] -autoflake = "^1.4" -black = "^20.8b1" -duty = "^0.6.0" -flakehell = "^0.9.0" -flake8-black = "^0.2.1" -flake8-builtins = "^1.5.3" -flake8-tidy-imports = "^4.2.1" -flake8-variables-names = "^0.0.4" -flake8-pytest-style = "^1.3.0" -git-changelog = "^0.4.2" -httpx = "^0.16.1" -isort = {version = "^5.7.0", extras = ["pyproject"]} -jinja2-cli = "^0.7.0" -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.2.2" -pytest-cov = "^2.11.1" -pytest-randomly = "^3.5.0" -pytest-sugar = "^0.9.4" -pytest-xdist = "^2.2.0" -toml = "^0.10.2" -darglint = "^1.5.8" -flake8-bandit = "^2.1.2" -flake8-bugbear = "^20.11.1" -flake8-comprehensions = "^3.3.1" -flake8-docstrings = "^1.5.0" -flake8-string-format = "^0.3.0" -pep8-naming = "^0.11.1" +[project.urls] +Homepage = "https://mkdocstrings.github.io" +Documentation = "https://mkdocstrings.github.io" +Changelog = "https://mkdocstrings.github.io/changelog" +Repository = "https://github.com/mkdocstrings/mkdocstrings" +Issues = "https://github.com/mkdocstrings/mkdocstrings/issues" +Discussions = "https://github.com/mkdocstrings/mkdocstrings/discussions" +Gitter = "https://gitter.im/mkdocstrings/community" +Funding = "https://github.com/sponsors/mkdocstrings" -[tool.poetry.plugins."mkdocs.plugins"] +[project.entry-points."mkdocs.plugins"] mkdocstrings = "mkdocstrings.plugin:MkdocstringsPlugin" + +[project.optional-dependencies] +[tool.pdm] +package-dir = "src" +includes = ["src/mkdocstrings"] + +[tool.pdm.dev-dependencies] +duty = ["duty~=0.6"] +docs = [ + "mkdocs-coverage~=0.2; python_version >= '3.7'", + "mkdocs-gen-files~=0.3; python_version >= '3.7'", + "mkdocs-literate-nav~=0.4; python_version >= '3.7'", + "mkdocs-material~=7.1; python_version >= '3.7'", + "mkdocs-section-index~=0.3; python_version >= '3.7'", + "toml~=0.10; python_version >= '3.7'", +] +format = [ + "autoflake~=1.4", + "black~=20.8b1", + "isort~=5.8", +] +maintain = [ + # TODO: remove this section when git-changelog is more powerful + "git-changelog~=0.4", +] +quality = [ + "darglint~=1.7", + "flake8-bandit~=2.1", + "flake8-black~=0.2", + "flake8-bugbear~=21.3", + "flake8-builtins~=1.5", + "flake8-comprehensions~=3.4", + "flake8-docstrings~=1.6", + "flake8-pytest-style~=1.4", + "flake8-string-format~=0.3", + "flake8-tidy-imports~=4.2", + "flake8-variables-names~=0.0", + "pep8-naming~=0.11", + "wps-light~=0.15", +] +tests = [ + "pygments~=2.10", # python 3.6 + "pytest~=6.2", + "pytest-cov~=2.11", + "pytest-randomly~=3.6", + "pytest-sugar~=0.9", + "pytest-xdist~=2.2", +] +typing = ["mypy~=0.812"] + [tool.black] line-length = 120 exclude = "tests/fixtures" diff --git a/scripts/multirun.sh b/scripts/multirun.sh index b483defa..4ca6e2ce 100755 --- a/scripts/multirun.sh +++ b/scripts/multirun.sh @@ -1,22 +1,17 @@ #!/usr/bin/env bash set -e -PYTHON_VERSIONS="${PYTHON_VERSIONS:-3.6 3.7 3.8 3.9}" +PYTHON_VERSIONS="${PYTHON_VERSIONS-3.6 3.7 3.8 3.9}" if [ -n "${PYTHON_VERSIONS}" ]; then for python_version in ${PYTHON_VERSIONS}; do - if output=$(poetry env use "${python_version}" 2>&1); then - if echo "${output}" | grep -q ^Creating; then - echo "> Environment for Python ${python_version} not created, skipping" >&2 - poetry env remove "${python_version}" &>/dev/null || true - else - echo "> poetry run $@ (Python ${python_version})" - poetry run "$@" - fi + if pdm use -f "${python_version}" &>/dev/null; then + echo "> pdm run $@ (Python ${python_version})" + pdm run "$@" else - echo "> poetry env use ${python_version}: Python version not available?" >&2 + echo "> pdm use -f ${python_version}: Python version not available?" >&2 fi done else - poetry run "$@" + pdm run "$@" fi diff --git a/scripts/setup.sh b/scripts/setup.sh index a626b257..cfddbac7 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -PYTHON_VERSIONS="${PYTHON_VERSIONS:-3.6 3.7 3.8 3.9}" +PYTHON_VERSIONS="${PYTHON_VERSIONS-3.6 3.7 3.8 3.9}" install_with_pipx() { if ! command -v "$1" &>/dev/null; then @@ -12,21 +12,17 @@ install_with_pipx() { fi } -install_with_pipx poetry +install_with_pipx pdm if [ -n "${PYTHON_VERSIONS}" ]; then for python_version in ${PYTHON_VERSIONS}; do - if output=$(poetry env use "${python_version}" 2>&1); then - if echo "${output}" | grep -q ^Creating; then - echo "> Created environment for Python ${python_version}" - else - echo "> Using Python ${python_version} environment" - fi - poetry install + if pdm use -f "${python_version}" &>/dev/null; then + echo "> Using Python ${python_version} environment" + pdm install else - echo "> poetry env use ${python_version}: Python version not available?" >&2 + echo "> pdm use -f ${python_version}: Python version not available?" >&2 fi done else - poetry install -fi \ No newline at end of file + pdm install +fi diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py index c9f42e6d..afe59708 100644 --- a/src/mkdocstrings/extension.py +++ b/src/mkdocstrings/extension.py @@ -23,7 +23,7 @@ """ import re from collections import ChainMap -from typing import Mapping, MutableSequence, Sequence, Tuple +from typing import Mapping, MutableSequence, Tuple from xml.etree.ElementTree import Element import yaml @@ -35,13 +35,13 @@ from markdown.treeprocessors import Treeprocessor from mkdocs_autorefs.plugin import AutorefsPlugin -from mkdocstrings.handlers.base import CollectionError, CollectorItem, Handlers +from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers from mkdocstrings.loggers import get_logger try: from mkdocs.exceptions import PluginError # New in MkDocs 1.2 except ImportError: - PluginError = SystemExit + PluginError = SystemExit # noqa: WPS440 log = get_logger(__name__) @@ -79,7 +79,7 @@ def __init__( self._autorefs = autorefs self._updated_env = False - def test(self, parent: Element, block: str) -> bool: + def test(self, parent: Element, block: str) -> bool: # type: ignore """Match our autodoc instructions. Arguments: @@ -117,15 +117,26 @@ def run(self, parent: Element, blocks: MutableSequence[str]) -> None: heading_level = match["heading"].count("#") log.debug(f"Matched '::: {identifier}'") - html, headings = self._process_block(identifier, block, heading_level) + html, handler = 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. + headings = handler.renderer.get_headings() el.extend(headings) for heading in headings: - self._autorefs.register_anchor(self._autorefs.current_page, heading.attrib["id"]) + page = self._autorefs.current_page + anchor = heading.attrib["id"] + self._autorefs.register_anchor(page, anchor) + + if "data-role" in heading.attrib: + self._handlers.inventory.register( + name=anchor, + domain=handler.domain, + role=heading.attrib["data-role"], + uri=f"{page}#{anchor}", + ) parent.append(el) @@ -135,7 +146,7 @@ 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) -> Tuple[str, Sequence[Element]]: + def _process_block(self, identifier: str, yaml_block: str, heading_level: int = 0) -> Tuple[str, BaseHandler]: """Process an autodoc block. Arguments: @@ -148,7 +159,7 @@ def _process_block(self, identifier: str, yaml_block: str, heading_level: int = TemplateNotFound: When a template used for rendering could not be found. Returns: - Rendered HTML and the list of heading elements encoutered. + Rendered HTML and the handler that was used. """ config = yaml.safe_load(yaml_block) or {} handler_name = self._handlers.get_handler_name(config) @@ -172,7 +183,7 @@ def _process_block(self, identifier: str, yaml_block: str, heading_level: int = if not self._updated_env: log.debug("Updating renderer's env") - handler.renderer._update_env(self.md, self._config) # noqa: W0212 (protected member OK) + handler.renderer._update_env(self.md, self._config) # noqa: WPS437 (protected member OK) self._updated_env = True log.debug("Rendering templates") @@ -185,7 +196,7 @@ def _process_block(self, identifier: str, yaml_block: str, heading_level: int = ) raise - return (rendered, handler.renderer.get_headings()) + return (rendered, handler) def get_item_configs(handler_config: dict, config: dict) -> Tuple[Mapping, Mapping]: diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py index 2a95e775..f496e7ed 100644 --- a/src/mkdocstrings/handlers/base.py +++ b/src/mkdocstrings/handlers/base.py @@ -11,7 +11,7 @@ import importlib from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, Dict, Iterable, Optional, Sequence +from typing import Any, Dict, Iterable, List, Optional, Sequence from xml.etree.ElementTree import Element, tostring from jinja2 import Environment, FileSystemLoader @@ -24,6 +24,7 @@ IdPrependingTreeprocessor, MkdocstringsInnerExtension, ) +from mkdocstrings.inventory import Inventory from mkdocstrings.loggers import get_template_logger CollectorItem = Any @@ -95,7 +96,7 @@ def __init__(self, directory: str, theme: str, custom_templates: Optional[str] = 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") + self.extra_css += "\n" + css_path.read_text(encoding="utf-8") # noqa: WPS601 break if custom_templates is not None: @@ -103,14 +104,14 @@ def __init__(self, directory: str, theme: str, custom_templates: Optional[str] = self.env = Environment( autoescape=True, - loader=FileSystemLoader(paths), + loader=FileSystemLoader(paths), # type: ignore auto_reload=False, # Editing a template in the middle of a build is not useful. ) # type: ignore self.env.filters["any"] = do_any self.env.globals["log"] = get_template_logger() - self._headings = [] - self._md = None # To be populated in `update_env`. + self._headings: List[Element] = [] + self._md: Markdown = None # type: ignore # To be populated in `update_env`. @abstractmethod def render(self, data: CollectorItem, config: dict) -> str: @@ -163,6 +164,7 @@ def do_heading( content: str, heading_level: int, *, + role: Optional[str] = None, hidden: bool = False, toc_label: Optional[str] = None, **attributes: str, @@ -172,6 +174,7 @@ def do_heading( Arguments: content: The HTML within the heading. heading_level: The level of heading (e.g. 3 -> `h3`). + role: An optional role for the object bound to this heading. 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. @@ -182,8 +185,10 @@ def do_heading( # 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 + toc_label = content.unescape() if isinstance(el, Markup) else content # type: ignore el.set("data-toc-label", toc_label) + if role: + el.set("data-role", role) self._headings.append(el) if hidden: @@ -285,8 +290,16 @@ class BaseHandler: Inherit from this class to implement a handler. It's usually just a combination of a collector and a renderer, but you can make it as complex as you need. + + Attributes: + domain: The cross-documentation domain/language for this handler. + enable_inventory: Whether this handler is interested in enabling the creation + of the `objects.inv` Sphinx inventory file. """ + domain: str = "default" + enable_inventory: bool = False + def __init__(self, collector: BaseCollector, renderer: BaseRenderer) -> None: """Initialize the object. @@ -314,6 +327,7 @@ def __init__(self, config: dict) -> None: """ self._config = config self._handlers: Dict[str, BaseHandler] = {} + self.inventory: Inventory = Inventory(project=self._config["site_name"]) 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. @@ -329,9 +343,8 @@ def get_anchor(self, identifier: str) -> Optional[str]: anchor = handler.renderer.get_anchor(handler.collector.collect(identifier, {})) except CollectionError: continue - else: - if anchor is not None: - return anchor + if anchor is not None: + return anchor return None def get_handler_name(self, config: dict) -> str: @@ -343,10 +356,10 @@ def get_handler_name(self, config: dict) -> str: Returns: The name of the handler to use. """ - config = self._config["mkdocstrings"] + global_config = self._config["mkdocstrings"] if "handler" in config: return config["handler"] - return config["default_handler"] + return global_config["default_handler"] def get_handler_config(self, name: str) -> dict: """Return the global configuration of the given handler. @@ -382,7 +395,7 @@ def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseH if handler_config is None: handler_config = self.get_handler_config(name) module = importlib.import_module(f"mkdocstrings.handlers.{name}") - self._handlers[name] = module.get_handler( + self._handlers[name] = module.get_handler( # type: ignore self._config["theme_name"], self._config["mkdocstrings"]["custom_templates"], **handler_config, diff --git a/src/mkdocstrings/handlers/python.py b/src/mkdocstrings/handlers/python.py index a9e63786..0aab09ac 100644 --- a/src/mkdocstrings/handlers/python.py +++ b/src/mkdocstrings/handlers/python.py @@ -5,15 +5,19 @@ import json import os +import posixpath 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 typing import Any, BinaryIO, Callable, Iterator, List, Optional, Tuple from markdown import Markdown +from markupsafe import Markup +from mkdocstrings.extension import PluginError from mkdocstrings.handlers.base import BaseCollector, BaseHandler, BaseRenderer, CollectionError, CollectorItem +from mkdocstrings.inventory import Inventory from mkdocstrings.loggers import get_logger log = get_logger(__name__) @@ -43,8 +47,10 @@ class PythonRenderer(BaseRenderer): "show_if_no_docstring": False, "show_signature_annotations": False, "show_source": True, + "show_bases": True, "group_by_category": True, "heading_level": 2, + "members_order": "alphabetical", } """The default rendering options. @@ -59,8 +65,10 @@ class PythonRenderer(BaseRenderer): **`show_if_no_docstring`** | `bool` | Show the object heading even if it has no docstring or children with docstrings. | `False` **`show_signature_annotations`** | `bool` | Show the type annotations in methods and functions signatures. | `False` **`show_source`** | `bool` | Show the source code of this object. | `True` + **`show_bases`** | `bool` | Show the base classes of a class. | `True` **`group_by_category`** | `bool` | Group the object's children by categories: attributes, classes, functions, methods, and modules. | `True` **`heading_level`** | `int` | The initial heading level to use. | `2` + **`members_order`** | `str` | The members ordering to use. Options: `alphabetical` - order by the members names, `source` - order members as they appear in the source file. | `alphabetical` """ # noqa: E501 def render(self, data: CollectorItem, config: dict) -> str: # noqa: D102 (ignore missing docstring) @@ -72,6 +80,16 @@ def render(self, data: CollectorItem, config: dict) -> str: # noqa: D102 (ignor # of the rendering recursion. Therefore, it's easier to use it as a plain value # than as an item in a dictionary. heading_level = final_config["heading_level"] + members_order = final_config["members_order"] + + if members_order == "alphabetical": + sort_function = _sort_key_alphabetical + elif members_order == "source": + sort_function = _sort_key_source + else: + raise PluginError(f"Unknown members_order '{members_order}', choose between 'alphabetical' and 'source'.") + + sort_object(data, sort_function=sort_function) return template.render( **{"config": final_config, data["category"]: data, "heading_level": heading_level, "root": True}, @@ -85,6 +103,12 @@ def update_env(self, md: Markdown, config: dict) -> None: # noqa: D102 (ignore self.env.trim_blocks = True self.env.lstrip_blocks = True self.env.keep_trailing_newline = False + self.env.filters["brief_xref"] = self.do_brief_xref + + def do_brief_xref(self, path: str) -> Markup: + """Filter to create cross-reference with brief text and full identifier as hover text.""" + brief = path.split(".")[-1] + return Markup("{brief}").format(path=path, brief=brief) class PythonCollector(BaseCollector): @@ -152,7 +176,6 @@ def __init__(self, setup_commands: Optional[List[str]] = None) -> None: self.process = Popen( # noqa: S603,S607 (we trust the input, and we don't want to use the absolute path) cmd, universal_newlines=True, - stderr=PIPE, stdout=PIPE, stdin=PIPE, bufsize=-1, @@ -236,7 +259,39 @@ def teardown(self) -> None: class PythonHandler(BaseHandler): - """The Python handler class, nothing specific here.""" + """The Python handler class. + + Attributes: + domain: The cross-documentation domain/language for this handler. + enable_inventory: Whether this handler is interested in enabling the creation + of the `objects.inv` Sphinx inventory file. + """ + + domain: str = "py" # to match Sphinx's default domain + enable_inventory: bool = True + + @classmethod + def load_inventory( + cls, in_file: BinaryIO, url: str, base_url: Optional[str] = None, **kwargs + ) -> Iterator[Tuple[str, str]]: + """Yield items and their URLs from an inventory file streamed from `in_file`. + + This implements mkdocstrings' `load_inventory` "protocol" (see plugin.py). + + Arguments: + in_file: The binary file-like object to read the inventory from. + url: The URL that this file is being streamed from (used to guess `base_url`). + base_url: The URL that this inventory's sub-paths are relative to. + **kwargs: Ignore additional arguments passed from the config. + + Yields: + Tuples of (item identifier, item URL). + """ + if base_url is None: + base_url = posixpath.dirname(url) + + for item in Inventory.parse_sphinx(in_file, domain_filter=("py",)).values(): # noqa: WPS526 + yield item.name, posixpath.join(base_url, item.uri) def get_handler( @@ -282,3 +337,36 @@ def rebuild_category_lists(obj: dict) -> None: obj["children"] = [child for _, child in obj["children"].items()] for child in obj["children"]: rebuild_category_lists(child) + + +def sort_object(obj: CollectorItem, sort_function: Callable[[CollectorItem], Any]) -> None: + """Sort the collected object's children. + + Sorts the object's children list, then each category separately, and then recurses into each. + + Arguments: + obj: The collected object, as a dict. Note that this argument is mutated. + sort_function: The sort key function used to determine the order of elements. + """ + obj["children"].sort(key=sort_function) + + for category in ("attributes", "classes", "functions", "methods", "modules"): + obj[category].sort(key=sort_function) + + for child in obj["children"]: + sort_object(child, sort_function=sort_function) + + +def _sort_key_alphabetical(item: CollectorItem) -> Any: + """Return a sort key for 'alphabetical' sorting of CollectorItems.""" + # chr(sys.maxunicode) is a string that contains the final unicode + # character, so if 'name' isn't found on the object, the item will go to + # the end of the list. + return item.get("name", chr(sys.maxunicode)) + + +def _sort_key_source(item: CollectorItem) -> Any: + """Return a sort key for 'source' sorting of CollectorItems.""" + # if 'line_start' isn't found on the object, the item will go to + # the start of the list. + return item.get("source", {}).get("line_start", -1) diff --git a/src/mkdocstrings/handlers/rendering.py b/src/mkdocstrings/handlers/rendering.py index ed9049ba..e8383cf2 100644 --- a/src/mkdocstrings/handlers/rendering.py +++ b/src/mkdocstrings/handlers/rendering.py @@ -3,7 +3,7 @@ import copy import re import textwrap -from typing import List, Optional +from typing import Any, Dict, List, Optional from xml.etree.ElementTree import Element from markdown import Markdown @@ -43,8 +43,8 @@ def __init__(self, md: Markdown): Arguments: md: The Markdown instance to read configs from. """ - config = {} - for ext in md.registeredExtensions: + config: Dict[str, Any] = {} + for ext in md.registeredExtensions: # type: ignore if isinstance(ext, HighlightExtension) and (ext.enabled or not config): config = ext.getConfigs() break # This one takes priority, no need to continue looking @@ -52,7 +52,7 @@ def __init__(self, md: Markdown): 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}) + super().__init__(**{name: opt for name, opt in config.items() if name in self._highlight_config_keys}) def highlight( # noqa: W0221 (intentionally different params, we're extending the functionality) self, @@ -83,7 +83,7 @@ def highlight( # noqa: W0221 (intentionally different params, we're extending t src = textwrap.dedent(src) kwargs.setdefault("css_class", self._css_class) - old_linenums = self.linenums + old_linenums = self.linenums # type: ignore if linenums is not None: self.linenums = linenums try: @@ -185,7 +185,7 @@ def run(self, root: Element): 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: + if len(el) > 0 and el[-1].get("class") == self.md.treeprocessors["toc"].permalink_class: # noqa: WPS507 del el[-1] self.headings.append(el) diff --git a/src/mkdocstrings/inventory.py b/src/mkdocstrings/inventory.py new file mode 100644 index 00000000..9a59d2b4 --- /dev/null +++ b/src/mkdocstrings/inventory.py @@ -0,0 +1,132 @@ +"""Module responsible for the objects inventory.""" + +# Credits to Brian Skinn and the sphobjinv project: +# https://github.com/bskinn/sphobjinv + +import re +import zlib +from textwrap import dedent +from typing import BinaryIO, Collection, List, Optional + + +class InventoryItem: + """Inventory item.""" + + def __init__( + self, name: str, domain: str, role: str, uri: str, priority: str = "1", dispname: Optional[str] = None + ): + """Initialize the object. + + Arguments: + name: The item name. + domain: The item domain, like 'python' or 'crystal'. + role: The item role, like 'class' or 'method'. + uri: The item URI. + priority: The item priority. It can help for inventory suggestions. + dispname: The item display name. + """ + self.name: str = name + self.domain: str = domain + self.role: str = role + self.uri: str = uri + self.priority: str = priority + self.dispname: str = dispname or name + + def format_sphinx(self) -> str: + """Format this item as a Sphinx inventory line. + + Returns: + A line formatted for an `objects.inv` file. + """ + dispname = self.dispname + if dispname == self.name: + dispname = "-" + uri = self.uri + if uri.endswith(self.name): + uri = uri[: -len(self.name)] + "$" + return f"{self.name} {self.domain}:{self.role} {self.priority} {uri} {dispname}" + + sphinx_item_regex = re.compile(r"^(.+?)\s+(\S+):(\S+)\s+(-?\d+)\s+(\S+)\s+(.*)$") + + @classmethod + def parse_sphinx(cls, line: str) -> "InventoryItem": + """Parse a line from a Sphinx v2 inventory file and return an `InventoryItem` from it.""" + match = cls.sphinx_item_regex.search(line) + if not match: + raise ValueError(line) + name, domain, role, priority, uri, dispname = match.groups() + if uri.endswith("$"): + uri = uri[:-1] + name + if dispname == "-": + dispname = name + return cls(name, domain, role, uri, priority, dispname) + + +class Inventory(dict): + """Inventory of collected and rendered objects.""" + + def __init__(self, items: Optional[List[InventoryItem]] = None, project: str = "project", version: str = "0.0.0"): + """Initialize the object. + + Arguments: + items: A list of items. + project: The project name. + version: The project version. + """ + super().__init__() + items = items or [] + for item in items: + self[item.name] = item + self.project = project + self.version = version + + def register(self, *args, **kwargs): + """Create and register an item. + + Arguments: + *args: Arguments passed to [InventoryItem][mkdocstrings.inventory.InventoryItem]. + **kwargs: Keyword arguments passed to [InventoryItem][mkdocstrings.inventory.InventoryItem]. + """ + item = InventoryItem(*args, **kwargs) + self[item.name] = item + + def format_sphinx(self) -> bytes: + """Format this inventory as a Sphinx `objects.inv` file. + + Returns: + The inventory as bytes. + """ + header = ( + dedent( + f""" + # Sphinx inventory version 2 + # Project: {self.project} + # Version: {self.version} + # The remainder of this file is compressed using zlib. + """ + ) + .lstrip() + .encode("utf8") + ) + + lines = [item.format_sphinx().encode("utf8") for item in self.values()] + return header + zlib.compress(b"\n".join(lines) + b"\n", 9) + + @classmethod + def parse_sphinx(cls, in_file: BinaryIO, *, domain_filter: Collection[str] = ()) -> "Inventory": + """Parse a Sphinx v2 inventory file and return an `Inventory` from it. + + Arguments: + in_file: The binary file-like object to read from. + domain_filter: A collection of domain values to allow (and filter out all other ones). + + Returns: + An `Inventory` containing the collected `InventoryItem`s. + """ + for _ in range(4): + in_file.readline() + lines = zlib.decompress(in_file.read()).splitlines() + items = [InventoryItem.parse_sphinx(line.decode("utf8")) for line in lines] + if domain_filter: + items = [item for item in items if item.domain in domain_filter] + return cls(items) diff --git a/src/mkdocstrings/plugin.py b/src/mkdocstrings/plugin.py index 054cd2e0..4e693473 100644 --- a/src/mkdocstrings/plugin.py +++ b/src/mkdocstrings/plugin.py @@ -12,10 +12,14 @@ during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve). """ +import collections +import functools +import gzip import os -from typing import Callable, Optional, Tuple +from concurrent import futures +from typing import Any, BinaryIO, Callable, Iterable, List, Mapping, Optional, Tuple +from urllib import request -from livereload import Server from mkdocs.config import Config from mkdocs.config.config_options import Type as MkType from mkdocs.plugins import BasePlugin @@ -33,6 +37,9 @@ RENDERING_OPTS_KEY: str = "rendering" """The name of the rendering parameter in YAML configuration blocks.""" +InventoryImportType = List[Tuple[str, Mapping[str, Any]]] +InventoryLoaderType = Callable[..., Iterable[Tuple[str, str]]] + class MkdocstringsPlugin(BasePlugin): """An `mkdocs` plugin. @@ -40,6 +47,7 @@ class MkdocstringsPlugin(BasePlugin): This plugin defines the following event hooks: - `on_config` + - `on_env` - `on_post_build` - `on_serve` @@ -52,6 +60,7 @@ class MkdocstringsPlugin(BasePlugin): ("handlers", MkType(dict, default={})), ("default_handler", MkType(str, default="python")), ("custom_templates", MkType(str, default=None)), + ("enable_inventory", MkType(bool, default=None)), ) """ The configuration options of `mkdocstrings`, written in `mkdocs.yml`. @@ -105,7 +114,7 @@ def handlers(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) + def on_serve(self, server, builder: Callable, **kwargs): # noqa: W0613 (unused arguments) """Watch directories. Hook for the [`on_serve` event](https://www.mkdocs.org/user-guide/plugins/#on_serve). @@ -117,18 +126,10 @@ def on_serve(self, server: Server, builder: Callable = None, **kwargs) -> Server server: The `livereload` server instance. builder: The function to build the site. kwargs: Additional arguments passed by MkDocs. - - Returns: - The server instance. """ - if builder is None: - # The builder parameter was added in mkdocs v1.1.1. - # See issue https://github.com/mkdocs/mkdocs/issues/1952. - builder = list(server.watcher._tasks.values())[0]["func"] # noqa: W0212 (protected member) for element in self.config["watch"]: log.debug(f"Adding directory '{element}' to watcher") server.watch(element, builder) - return server def on_config(self, config: Config, **kwargs) -> Config: # noqa: W0613 (unused arguments) """Instantiate our Markdown extension. @@ -155,7 +156,15 @@ def on_config(self, config: Config, **kwargs) -> Config: # noqa: W0613 (unused else: theme_name = config["theme"].name + to_import: InventoryImportType = [] + for handler_name, conf in self.config["handlers"].items(): + for import_item in conf.pop("import", ()): + if isinstance(import_item, str): + import_item = {"url": import_item} + to_import.append((handler_name, import_item)) + extension_config = { + "site_name": config["site_name"], "theme_name": theme_name, "mdx": config["markdown_extensions"], "mdx_configs": config["mdx_configs"], @@ -163,7 +172,7 @@ def on_config(self, config: Config, **kwargs) -> Config: # noqa: W0613 (unused } self._handlers = Handlers(extension_config) - try: + try: # noqa: WPS229 # If autorefs plugin is explicitly enabled, just use it. autorefs = config["plugins"]["autorefs"] log.debug(f"Picked up existing autorefs instance {autorefs!r}") @@ -174,15 +183,61 @@ def on_config(self, config: Config, **kwargs) -> Config: # noqa: W0613 (unused 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 + autorefs.get_fallback_anchor = self.handlers.get_anchor - mkdocstrings_extension = MkdocstringsExtension(extension_config, self._handlers, autorefs) + mkdocstrings_extension = MkdocstringsExtension(extension_config, self.handlers, autorefs) config["markdown_extensions"].append(mkdocstrings_extension) config["extra_css"].insert(0, self.css_filename) # So that it has lower priority than user files. + self._inv_futures = [] + if to_import: + inv_loader = futures.ThreadPoolExecutor(4) + for handler_name, import_item in to_import: # noqa: WPS440 + future = inv_loader.submit( + self._load_inventory, self.get_handler(handler_name).load_inventory, **import_item + ) + self._inv_futures.append(future) + inv_loader.shutdown(wait=False) + return config + @property + def inventory_enabled(self) -> bool: + """Tell if the inventory is enabled or not. + + Returns: + Whether the inventory is enabled. + """ + inventory_enabled = self.config["enable_inventory"] + if inventory_enabled is None: + inventory_enabled = any(handler.enable_inventory for handler in self.handlers.seen_handlers) + return inventory_enabled + + def on_env(self, env, config: Config, **kwargs): + """Extra actions that need to happen after all Markdown rendering and before HTML rendering. + + Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env). + + - Write mkdocstrings' extra files into the site dir. + - Gather results from background inventory download tasks. + """ + 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)) + + if self.inventory_enabled: + log.debug("Creating inventory file objects.inv") + inv_contents = self.handlers.inventory.format_sphinx() + write_file(inv_contents, os.path.join(config["site_dir"], "objects.inv")) + + if self._inv_futures: + log.debug(f"Waiting for {len(self._inv_futures)} inventory download(s)") + futures.wait(self._inv_futures, timeout=30) + for page, identifier in collections.ChainMap(*(fut.result() for fut in self._inv_futures)).items(): + config["plugins"]["autorefs"].register_url(page, identifier) + self._inv_futures = [] + def on_post_build(self, config: Config, **kwargs) -> None: # noqa: W0613,R0201 (unused arguments, cannot be static) """Teardown the handlers. @@ -199,12 +254,12 @@ def on_post_build(self, config: Config, **kwargs) -> None: # noqa: W0613,R0201 config: The MkDocs config object. kwargs: Additional arguments passed by MkDocs. """ - 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)) + for future in self._inv_futures: + future.cancel() + if self._handlers: 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][]. @@ -216,3 +271,26 @@ def get_handler(self, handler_name: str) -> BaseHandler: An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler]. """ return self.handlers.get_handler(handler_name) + + @classmethod + @functools.lru_cache(maxsize=None) + def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs) -> Mapping[str, str]: + """Download and process inventory files using a handler. + + Arguments: + loader: A function returning a sequence of pairs (identifier, url). + url: The URL to download and process. + kwargs: Extra arguments to pass to the loader. + + Returns: + A mapping from identifier to absolute URL. + """ + log.debug(f"Downloading inventory from {url!r}") + req = request.Request(url, headers={"Accept-Encoding": "gzip", "User-Agent": "mkdocstrings/0.15.0"}) + with request.urlopen(req) as resp: # noqa: S310 (URL audit OK: comes from a checked-in config) + content: BinaryIO = resp + if "gzip" in resp.headers.get("content-encoding", ""): + content = gzip.GzipFile(fileobj=resp) # type: ignore[assignment] + result = dict(loader(content, url=url, **kwargs)) + log.debug(f"Loaded inventory from {url!r}: {len(result)} items") + return result diff --git a/src/mkdocstrings/py.typed b/src/mkdocstrings/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/mkdocstrings/templates/python/material/attribute.html b/src/mkdocstrings/templates/python/material/attribute.html index 4b742509..b40518a9 100644 --- a/src/mkdocstrings/templates/python/material/attribute.html +++ b/src/mkdocstrings/templates/python/material/attribute.html @@ -17,6 +17,7 @@ {% endif %} {% filter heading(heading_level, + role="data" if obj == module else "attr", id=html_id, class="doc doc-heading", toc_label=attribute.name) %} @@ -35,6 +36,7 @@ {% else %} {% if config.show_root_toc_entry %} {% filter heading(heading_level, + role="data" if obj == module else "attr", id=html_id, toc_label=attribute.path, hidden=True) %} diff --git a/src/mkdocstrings/templates/python/material/attributes.html b/src/mkdocstrings/templates/python/material/attributes.html index 0c935472..02a2935b 100644 --- a/src/mkdocstrings/templates/python/material/attributes.html +++ b/src/mkdocstrings/templates/python/material/attributes.html @@ -12,7 +12,7 @@ {% for attribute in attributes %} {{ attribute.name }} - {{ attribute.annotation }} + {% if attribute.annotation %}{{ attribute.annotation }}{% endif %} {{ attribute.description|convert_markdown(heading_level, html_id) }} {% endfor %} diff --git a/src/mkdocstrings/templates/python/material/children.html b/src/mkdocstrings/templates/python/material/children.html index 967ad493..7bc56c2d 100644 --- a/src/mkdocstrings/templates/python/material/children.html +++ b/src/mkdocstrings/templates/python/material/children.html @@ -17,7 +17,7 @@ {% 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") %} + {% for attribute in obj.attributes %} {% include "attribute.html" with context %} {% endfor %} {% endwith %} @@ -26,7 +26,7 @@ {% 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") %} + {% for class in obj.classes %} {% include "class.html" with context %} {% endfor %} {% endwith %} @@ -35,7 +35,7 @@ {% 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") %} + {% for function in obj.functions %} {% include "function.html" with context %} {% endfor %} {% endwith %} @@ -44,7 +44,7 @@ {% 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") %} + {% for method in obj.methods %} {% include "method.html" with context %} {% endfor %} {% endwith %} @@ -53,7 +53,7 @@ {% 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") %} + {% for module in obj.modules %} {% include "module.html" with context %} {% endfor %} {% endwith %} @@ -62,7 +62,7 @@ {% else %} - {% for child in obj.children|sort(attribute="name") %} + {% for child in obj.children %} {% if child.category == "attribute" %} {% with attribute = child %} {% include "attribute.html" with context %} diff --git a/src/mkdocstrings/templates/python/material/class.html b/src/mkdocstrings/templates/python/material/class.html index b3e33f84..62a70f57 100644 --- a/src/mkdocstrings/templates/python/material/class.html +++ b/src/mkdocstrings/templates/python/material/class.html @@ -17,11 +17,19 @@ {% endif %} {% filter heading(heading_level, + role="class", id=html_id, class="doc doc-heading", toc_label=class.name) %} - {% if show_full_path %}{{ class.path }}{% else %}{{ class.name }}{% endif %} + + {% if show_full_path %}{{ class.path }}{% else %}{{ class.name }}{% endif %} + {% if config.show_bases and class.bases and class.bases != ['object'] %} + ({% for base in class.bases -%} + {{ base|brief_xref() }}{% if not loop.last %}, {% endif %} + {% endfor %}) + {% endif %} + {% with properties = class.properties %} {% include "properties.html" with context %} @@ -32,6 +40,7 @@ {% else %} {% if config.show_root_toc_entry %} {% filter heading(heading_level, + role="class", id=html_id, toc_label=class.path, hidden=True) %} diff --git a/src/mkdocstrings/templates/python/material/function.html b/src/mkdocstrings/templates/python/material/function.html index 5ac592b9..6f8e6c77 100644 --- a/src/mkdocstrings/templates/python/material/function.html +++ b/src/mkdocstrings/templates/python/material/function.html @@ -17,6 +17,7 @@ {% endif %} {% filter heading(heading_level, + role="function", id=html_id, class="doc doc-heading", toc_label=function.name ~ "()") %} @@ -35,6 +36,7 @@ {% else %} {% if config.show_root_toc_entry %} {% filter heading(heading_level, + role="function", id=html_id, toc_label=function.path, hidden=True) %} diff --git a/src/mkdocstrings/templates/python/material/method.html b/src/mkdocstrings/templates/python/material/method.html index 19e9a530..807009e5 100644 --- a/src/mkdocstrings/templates/python/material/method.html +++ b/src/mkdocstrings/templates/python/material/method.html @@ -17,6 +17,7 @@ {% endif %} {% filter heading(heading_level, + role="method", id=html_id, class="doc doc-heading", toc_label=method.name ~ "()") %} @@ -35,6 +36,7 @@ {% else %} {% if config.show_root_toc_entry %} {% filter heading(heading_level, + role="method", id=html_id, toc_label=method.path, hidden=True) %} diff --git a/src/mkdocstrings/templates/python/material/module.html b/src/mkdocstrings/templates/python/material/module.html index bff6fdcf..ba8f4eac 100644 --- a/src/mkdocstrings/templates/python/material/module.html +++ b/src/mkdocstrings/templates/python/material/module.html @@ -17,6 +17,7 @@ {% endif %} {% filter heading(heading_level, + role="module", id=html_id, class="doc doc-heading", toc_label=module.name) %} @@ -32,6 +33,7 @@ {% else %} {% if config.show_root_toc_entry %} {% filter heading(heading_level, + role="module", id=html_id, toc_label=module.path, hidden=True) %} diff --git a/src/mkdocstrings/templates/python/material/parameters.html b/src/mkdocstrings/templates/python/material/parameters.html index 8ff69147..321318e0 100644 --- a/src/mkdocstrings/templates/python/material/parameters.html +++ b/src/mkdocstrings/templates/python/material/parameters.html @@ -13,7 +13,7 @@ {% for parameter in parameters %} {{ parameter.name }} - {{ parameter.annotation }} + {% if parameter.annotation %}{{ parameter.annotation }}{% endif %} {{ parameter.description|convert_markdown(heading_level, html_id) }} {% if parameter.default %}{{ parameter.default }}{% else %}required{% endif %} diff --git a/src/mkdocstrings/templates/python/material/return.html b/src/mkdocstrings/templates/python/material/return.html index cb108a1b..f4282491 100644 --- a/src/mkdocstrings/templates/python/material/return.html +++ b/src/mkdocstrings/templates/python/material/return.html @@ -9,7 +9,7 @@ - {{ return.annotation }} + {% if return.annotation %}{{ return.annotation }}{% endif %} {{ return.description|convert_markdown(heading_level, html_id) }} diff --git a/src/mkdocstrings/templates/python/mkdocs/exceptions.html b/src/mkdocstrings/templates/python/mkdocs/exceptions.html new file mode 100644 index 00000000..f5b592f5 --- /dev/null +++ b/src/mkdocstrings/templates/python/mkdocs/exceptions.html @@ -0,0 +1,7 @@ +{{ log.debug() }} +
+
Exceptions: + {% for exception in exceptions %} +
{{ ("`" + exception.annotation + "`: " + exception.description)|convert_markdown(heading_level, html_id) }}
+ {% endfor %} +
diff --git a/src/mkdocstrings/templates/python/mkdocs/parameters.html b/src/mkdocstrings/templates/python/mkdocs/parameters.html new file mode 100644 index 00000000..39db7ea3 --- /dev/null +++ b/src/mkdocstrings/templates/python/mkdocs/parameters.html @@ -0,0 +1,7 @@ +{{ log.debug() }} +
+
Parameters: + {% for parameter in parameters %} +
{{ ("**" + parameter.name + ":** " + ("`" + parameter.annotation + "` – " if parameter.annotation else "") + parameter.description)|convert_markdown(heading_level, html_id) }}
+ {% endfor %} +
diff --git a/src/mkdocstrings/templates/python/mkdocs/return.html b/src/mkdocstrings/templates/python/mkdocs/return.html new file mode 100644 index 00000000..270823c4 --- /dev/null +++ b/src/mkdocstrings/templates/python/mkdocs/return.html @@ -0,0 +1,5 @@ +{{ log.debug() }} +
+
Returns: +
{{ (("`" + return.annotation + "` – " if return.annotation else "") + return.description)|convert_markdown(heading_level, html_id) }}
+
diff --git a/src/mkdocstrings/templates/python/mkdocs/style.css b/src/mkdocstrings/templates/python/mkdocs/style.css new file mode 100644 index 00000000..9db45032 --- /dev/null +++ b/src/mkdocstrings/templates/python/mkdocs/style.css @@ -0,0 +1,11 @@ +.doc-contents { + padding-left: 20px; +} + +.doc-contents dd>p { + margin-bottom: 0.5rem; +} + +.doc-contents dl+dl { + margin-top: -0.5rem; +} diff --git a/src/mkdocstrings/templates/python/readthedocs/parameters.html b/src/mkdocstrings/templates/python/readthedocs/parameters.html index 5ae18219..197a411e 100644 --- a/src/mkdocstrings/templates/python/readthedocs/parameters.html +++ b/src/mkdocstrings/templates/python/readthedocs/parameters.html @@ -10,7 +10,7 @@
    {% for parameter in parameters %} -
  • {{ ("**" + parameter.name + "** (`" + parameter.annotation + "`) – " + parameter.description)|convert_markdown(heading_level, html_id) }}
  • +
  • {{ ("**" + parameter.name + "**" + (" (`" + parameter.annotation + "`)" if parameter.annotation else "") + " – " + parameter.description)|convert_markdown(heading_level, html_id) }}
  • {% endfor %}
diff --git a/src/mkdocstrings/templates/python/readthedocs/return.html b/src/mkdocstrings/templates/python/readthedocs/return.html index f30b9a25..7e45ecaf 100644 --- a/src/mkdocstrings/templates/python/readthedocs/return.html +++ b/src/mkdocstrings/templates/python/readthedocs/return.html @@ -9,7 +9,7 @@ Returns:
    -
  • {{ ("`" + return.annotation + "` – " + return.description)|convert_markdown(heading_level, html_id) }}
  • +
  • {{ (("`" + return.annotation + "` – ") if return.annotation else "") + return.description)|convert_markdown(heading_level, html_id) }}
diff --git a/tests/test_extension.py b/tests/test_extension.py index e2e92903..3d4a5294 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,4 +1,5 @@ """Tests for the extension module.""" +import re import sys from collections import ChainMap from textwrap import dedent @@ -7,14 +8,23 @@ from markdown import Markdown from mkdocs import config +try: + from mkdocs.config.defaults import get_schema +except ImportError: + + def get_schema(): # noqa: WPS440 + """Fallback for old versions of MkDocs.""" + return config.DEFAULT_SCHEMA + @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) + conf = config.Config(schema=get_schema()) conf_dict = { "site_name": "foo", + "site_url": "https://example.org/", "site_dir": str(tmp_path), "plugins": [{"mkdocstrings": {"default_handler": "python"}}], **getattr(request, "param", {}), @@ -82,8 +92,7 @@ def test_keeps_preceding_text(ext_markdown): def test_reference_inside_autodoc(ext_markdown): """Assert cross-reference Markdown extension works correctly.""" output = ext_markdown.convert("::: tests.fixtures.cross_reference") - snippet = 'Link to something.Else.' - assert snippet in output + assert re.search(r"Link to <.*something\.Else.*>something\.Else<.*>\.", output) @pytest.mark.skipif(sys.version_info < (3, 8), reason="typing.Literal requires Python 3.8") @@ -152,3 +161,9 @@ def test_no_double_toc(ext_markdown, expect_permalink): }, {"level": 1, "id": "bb", "name": "bb", "children": []}, ] + + +def test_use_custom_handler(ext_markdown): + """Assert that we use the custom handler declared in an individual autodoc instruction.""" + with pytest.raises(ModuleNotFoundError): + ext_markdown.convert("::: tests.fixtures.headings\n handler: not_here") diff --git a/tests/test_inventory.py b/tests/test_inventory.py new file mode 100644 index 00000000..471ed941 --- /dev/null +++ b/tests/test_inventory.py @@ -0,0 +1,51 @@ +"""Tests for the inventory module.""" + +import sys +from io import BytesIO +from os.path import join + +import pytest +from mkdocs.commands.build import build +from mkdocs.config import load_config + +from mkdocstrings.inventory import Inventory, InventoryItem + +sphinx = pytest.importorskip("sphinx.util.inventory", reason="Sphinx is not installed") + + +@pytest.mark.parametrize( + "our_inv", + [ + Inventory(), + Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url")]), + Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#object_path")]), + Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#other_anchor")]), + ], +) +def test_sphinx_load_inventory_file(our_inv): + """Perform the 'live' inventory load test.""" + buffer = BytesIO(our_inv.format_sphinx()) + sphinx_inv = sphinx.InventoryFile.load(buffer, "", join) + + sphinx_inv_length = sum(len(sphinx_inv[key]) for key in sphinx_inv) + assert sphinx_inv_length == len(our_inv.values()) + + for item in our_inv.values(): + assert item.name in sphinx_inv[f"{item.domain}:{item.role}"] + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="using plugins that require Python 3.7") +def test_sphinx_load_mkdocstrings_inventory_file(): + """Perform the 'live' inventory load test on mkdocstrings own inventory.""" + mkdocs_config = load_config() + build(mkdocs_config) + own_inv = mkdocs_config["plugins"]["mkdocstrings"].handlers.inventory + + with open("site/objects.inv", "rb") as fp: + sphinx_inv = sphinx.InventoryFile.load(fp, "", join) + + sphinx_inv_length = sum(len(sphinx_inv[key]) for key in sphinx_inv) + assert sphinx_inv_length == len(own_inv.values()) + + for item in own_inv.values(): + assert item.name in sphinx_inv[f"{item.domain}:{item.role}"] diff --git a/tests/test_plugin.py b/tests/test_plugin.py deleted file mode 100644 index 3bdad73c..00000000 --- a/tests/test_plugin.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Tests for the plugin module.""" - -import sys - -import pytest -from mkdocs.commands.build import build -from mkdocs.config.base import load_config - - -@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.""" - config = load_config() - config["site_dir"] = tmp_path - build(config) diff --git a/tests/test_python_handler.py b/tests/test_python_handler.py new file mode 100644 index 00000000..315b5760 --- /dev/null +++ b/tests/test_python_handler.py @@ -0,0 +1,86 @@ +"""Tests for the handlers.python module.""" + +from copy import deepcopy + +from mkdocstrings.handlers.python import ( # noqa: WPS450 + _sort_key_alphabetical, + _sort_key_source, + rebuild_category_lists, + sort_object, +) + + +def test_members_order(): + """Assert that members sorting functions work correctly.""" + subcategories = {key: [] for key in ("attributes", "classes", "functions", "methods", "modules")} + categories = {"children": {}, **subcategories} + collected = { + "name": "root", + "children": { + "b": {"name": "b", "source": {"line_start": 0}, **categories}, + "a": {"name": "a", **categories}, + "z": {"name": "z", "source": {"line_start": 100}, **categories}, + "no_name": {"source": {"line_start": 10}, **categories}, + "c": { + "name": "c", + "source": {"line_start": 30}, + "children": { + "z": {"name": "z", "source": {"line_start": 200}, **categories}, + "a": {"name": "a", "source": {"line_start": 20}, **categories}, + }, + **subcategories, + }, + }, + "attributes": ["b", "c", "no_name", "z", "a"], + "classes": [], + "functions": [], + "methods": [], + "modules": [], + } + rebuild_category_lists(collected) + alphebetical = deepcopy(collected) + sort_object(alphebetical, _sort_key_alphabetical) + + rebuilt_categories = {"children": [], **subcategories} + assert ( + alphebetical["children"] + == alphebetical["attributes"] + == [ + {"name": "a", **rebuilt_categories}, + {"name": "b", "source": {"line_start": 0}, **rebuilt_categories}, + { + "name": "c", + "source": {"line_start": 30}, + "children": [ + {"name": "a", "source": {"line_start": 20}, **rebuilt_categories}, + {"name": "z", "source": {"line_start": 200}, **rebuilt_categories}, + ], + **subcategories, + }, + {"name": "z", "source": {"line_start": 100}, **rebuilt_categories}, + {"source": {"line_start": 10}, **rebuilt_categories}, + ] + ) + + source = deepcopy(collected) + sort_object(source, _sort_key_source) + + assert ( + source["children"] + == source["attributes"] + == [ + {"name": "a", **rebuilt_categories}, + {"name": "b", "source": {"line_start": 0}, **rebuilt_categories}, + {"source": {"line_start": 10}, **rebuilt_categories}, + { + "name": "c", + "source": {"line_start": 30}, + "children": [ + {"name": "a", "source": {"line_start": 20}, **rebuilt_categories}, + {"name": "z", "source": {"line_start": 200}, **rebuilt_categories}, + ], + **subcategories, + }, + {"name": "z", "source": {"line_start": 100}, **rebuilt_categories}, + ] + )