diff --git a/.copier-answers.yml b/.copier-answers.yml index 7dd4a454..6a772352 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,20 +1,23 @@ # Changes here will be overwritten by Copier -_commit: 0.9.6 -_src_path: gh:pawamoy/copier-pdm.git -author_email: pawamoy@pm.me +_commit: 1.5.2 +_src_path: gh:pawamoy/copier-uv +author_email: dev@pawamoy.fr author_fullname: Timothée Mazzucotelli author_username: pawamoy copyright_date: '2019' copyright_holder: Timothée Mazzucotelli -copyright_holder_email: pawamoy@pm.me +copyright_holder_email: dev@pawamoy.fr copyright_license: ISC License +insiders: true +insiders_email: insiders@pawamoy.fr +insiders_repository_name: mkdocstrings project_description: Automatic documentation from sources, for MkDocs. project_name: mkdocstrings -python_package_command_line_name: mkdocstrings +public_release: true +python_package_command_line_name: '' python_package_distribution_name: mkdocstrings python_package_import_name: mkdocstrings repository_name: mkdocstrings repository_namespace: mkdocstrings repository_provider: github.com -use_precommit: false diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..f9d77ee3 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +PATH_add scripts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c71a8d4e..a502284a 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,7 +1,5 @@ -github: - - pawamoy +github: pawamoy ko_fi: pawamoy -liberapay: pawamoy -patreon: pawamoy +polar: pawamoy custom: - - https://www.paypal.me/pawamoy +- https://www.paypal.me/pawamoy diff --git a/.github/ISSUE_TEMPLATE/1-bug.md b/.github/ISSUE_TEMPLATE/1-bug.md new file mode 100644 index 00000000..e775cc1f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug.md @@ -0,0 +1,61 @@ +--- +name: Bug report +about: Create a bug report to help us improve. +title: "bug: " +labels: unconfirmed +assignees: [pawamoy] +--- + +### Description of the bug + + +### To Reproduce + + +``` +WRITE MRE / INSTRUCTIONS HERE +``` + +### Full traceback + + +
Full traceback + +```python +PASTE TRACEBACK HERE +``` + +
+ +### Expected behavior + + +### Environment information + + +```bash +python -m mkdocstrings.debug # | xclip -selection clipboard +``` + +PASTE MARKDOWN OUTPUT HERE + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/2-feature.md b/.github/ISSUE_TEMPLATE/2-feature.md new file mode 100644 index 00000000..2df98fbc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project. +title: "feature: " +labels: feature +assignees: pawamoy +--- + +### Is your feature request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/3-docs.md b/.github/ISSUE_TEMPLATE/3-docs.md new file mode 100644 index 00000000..92ac8ec5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-docs.md @@ -0,0 +1,16 @@ +--- +name: Documentation update +about: Point at unclear, missing or outdated documentation. +title: "docs: " +labels: docs +assignees: pawamoy +--- + +### Is something unclear, missing or outdated in our documentation? + + +### Relevant code snippets + + +### Link to the relevant documentation section + diff --git a/.github/ISSUE_TEMPLATE/4-change.md b/.github/ISSUE_TEMPLATE/4-change.md new file mode 100644 index 00000000..dc9a8f17 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-change.md @@ -0,0 +1,18 @@ +--- +name: Change request +about: Suggest any other kind of change for this project. +title: "change: " +assignees: pawamoy +--- + +### Is your change request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 275afae3..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: unconfirmed -assignees: '' - ---- - -**Please open an issue on [pytkdocs](https://github.com/pawamoy/pytkdocs/issues) instead -if this is related to Python docstrings parsing or the collection of Python objects!** - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Information (please complete the following information):** -- OS: [e.g. iOS] -- Browser: [e.g. chrome, safari] -- `mkdocstrings` version: [e.g. 0.10.2] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..23000298 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: +- name: I have a question / I need help + url: https://github.com/mkdocstrings/mkdocstrings/discussions/new?category=q-a + about: Ask and answer questions in the Discussions tab. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 4fe86d5e..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: feature -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index c65012f1..00000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: Question -about: Ask a question about mkdocstrings usage -title: '' -labels: question -assignees: '' - ---- - -**Add detailed information, like** -- project folder structure (`tree -L 2`) -- `mkdocs.yml` configuration file contents -- *mkdocstrings* version: [e.g. 0.10.2] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9133186..d95b7809 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: pull_request: branches: - - master + - main defaults: run: @@ -15,6 +15,7 @@ env: LC_ALL: en_US.utf-8 PYTHONIOENCODING: UTF-8 PYTHONPATH: docs + PYTHON_VERSIONS: "" jobs: @@ -24,47 +25,64 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 - - - name: Set up PDM - uses: pdm-project/setup-pdm@v2.6 + uses: actions/checkout@v4 with: - python-version: "3.8" - - - 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)" + fetch-depth: 0 + fetch-tags: true - - name: Set up cache - uses: actions/cache@v2 + - name: Setup Python + uses: actions/setup-python@v5 with: - path: | - ${{ steps.set_variables.outputs.PIP_CACHE }} - ${{ steps.set_variables.outputs.PDM_CACHE }} - key: checks-cache + python-version: "3.12" - - name: Resolving dependencies - run: pdm lock + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml - name: Install dependencies - run: pdm install -G duty -G docs -G quality -G typing -G security + run: make setup - name: Check if the documentation builds correctly - run: pdm run duty check-docs + run: make check-docs - name: Check the code quality - run: pdm run duty check-quality + run: make check-quality - name: Check if the code is correctly typed - run: pdm run duty check-types + run: make check-types + + - name: Check for breaking changes in the API + run: make check-api - - name: Check for vulnerabilities in dependencies - run: pdm run duty check-dependencies + exclude-test-jobs: + runs-on: ubuntu-latest + outputs: + jobs: ${{ steps.exclude-jobs.outputs.jobs }} + steps: + - id: exclude-jobs + run: | + if ${{ github.repository_owner == 'pawamoy-insiders' }}; then + echo 'jobs=[ + {"os": "macos-latest"}, + {"os": "windows-latest"}, + {"python-version": "3.10"}, + {"python-version": "3.11"}, + {"python-version": "3.12"}, + {"python-version": "3.13"}, + {"python-version": "3.14"} + ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT + else + echo 'jobs=[ + {"os": "macos-latest", "resolution": "lowest-direct"}, + {"os": "windows-latest", "resolution": "lowest-direct"} + ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT + fi tests: + needs: exclude-test-jobs strategy: matrix: os: @@ -72,39 +90,43 @@ jobs: - macos-latest - windows-latest python-version: - - "3.7" - - "3.8" - "3.9" - "3.10" - - "3.11-dev" - + - "3.11" + - "3.12" + - "3.13" + - "3.14" + resolution: + - highest + - lowest-direct + exclude: ${{ fromJSON(needs.exclude-test-jobs.outputs.jobs) }} runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.python-version == '3.14' }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - - name: Set up PDM - uses: pdm-project/setup-pdm@v2.6 + - name: Setup Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - - 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 + - name: Setup uv + uses: astral-sh/setup-uv@v3 with: - path: | - ${{ steps.set_variables.outputs.PIP_CACHE }} - ${{ steps.set_variables.outputs.PDM_CACHE }} - key: tests-cache-${{ runner.os }}-${{ matrix.python-version }} + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: py${{ matrix.python-version }} - name: Install dependencies - run: pdm install --no-editable -G duty -G tests -G docs + env: + UV_RESOLUTION: ${{ matrix.resolution }} + run: make setup - name: Run the test suite - run: pdm run duty test + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..db7a223e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: release + +on: push +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Setup uv + uses: astral-sh/setup-uv@v3 + - name: Build dists + if: github.repository_owner == 'pawamoy-insiders' + run: uv tool run --from build pyproject-build + - name: Upload dists artifact + uses: actions/upload-artifact@v4 + if: github.repository_owner == 'pawamoy-insiders' + with: + name: mkdocstrings-insiders + path: ./dist/* + - name: Prepare release notes + if: github.repository_owner != 'pawamoy-insiders' + run: uv tool run git-changelog --release-notes > release-notes.md + - name: Create release with assets + uses: softprops/action-gh-release@v2 + if: github.repository_owner == 'pawamoy-insiders' + with: + files: ./dist/* + - name: Create release + uses: softprops/action-gh-release@v2 + if: github.repository_owner != 'pawamoy-insiders' + with: + body_path: release-notes.md diff --git a/.gitignore b/.gitignore index f6a13b06..9fea0472 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,25 @@ +# editors .idea/ -__pycache__/ -*.py[cod] -dist/ +.vscode/ + +# python *.egg-info/ -build/ -htmlcov/ +*.py[cod] +.venv/ +.venvs/ +/build/ +/dist/ + +# tools .coverage* -pip-wheel-metadata/ +/.pdm-build/ +/htmlcov/ +/site/ +uv.lock + +# cache +.cache/ .pytest_cache/ -.python-version -site/ -pdm.lock -.pdm.toml -__pypackages__/ .mypy_cache/ -.venv/ +.ruff_cache/ +__pycache__/ diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile deleted file mode 100644 index 33f285c2..00000000 --- a/.gitpod.dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM gitpod/workspace-full -USER gitpod -ENV PIP_USER=no -ENV PYTHON_VERSIONS= -RUN pip3 install pipx; \ - pipx install pdm; \ - pipx ensurepath diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 23a3c2b7..00000000 --- a/.gitpod.yml +++ /dev/null @@ -1,13 +0,0 @@ -vscode: - extensions: - - ms-python.python - -image: - file: .gitpod.dockerfile - -ports: -- port: 8000 - onOpen: notify - -tasks: -- init: make setup diff --git a/CHANGELOG.md b/CHANGELOG.md index 5892de7c..a062c06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,220 @@ # Changelog + All notable changes to this project will be documented in this file. 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.27.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.27.0) - 2024-11-08 + +[Compare with 0.26.2](https://github.com/mkdocstrings/mkdocstrings/compare/0.26.2...0.27.0) + +### Features + +- Add support for authentication in inventory file URLs ([1c23c1b](https://github.com/mkdocstrings/mkdocstrings/commit/1c23c1b0fc4a9bdec5e0eb43c8647beab66fec55) by Stefan Mejlgaard). [Issue-707](https://github.com/mkdocstrings/mkdocstrings/issues/707), [PR-710](https://github.com/mkdocstrings/mkdocstrings/pull/710) + +### Performance Improvements + +- Reduce footprint of template debug messages ([5648e5a](https://github.com/mkdocstrings/mkdocstrings/commit/5648e5aca80a5d8ba9e5456efb36b517b9f3cdeb) by Timothée Mazzucotelli). + +### Code Refactoring + +- Use %-formatting for logging messages ([0bbb8ca](https://github.com/mkdocstrings/mkdocstrings/commit/0bbb8caddf34b0a4faa0ed6f26e33102dc892fc8) by Timothée Mazzucotelli). + +## [0.26.2](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.26.2) - 2024-10-12 + +[Compare with 0.26.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.26.1...0.26.2) + +### Build + +- Drop support for Python 3.8 ([f26edeb](https://github.com/mkdocstrings/mkdocstrings/commit/f26edebe01337caa802a98c13240acdd8332a5fa) by Timothée Mazzucotelli). + +## [0.26.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.26.1) - 2024-09-06 + +[Compare with 0.26.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.26.0...0.26.1) + +### Bug Fixes + +- Instantiate config of the autorefs plugin when it is not enabled by the user ([db2ab34](https://github.com/mkdocstrings/mkdocstrings/commit/db2ab3403a95034987d574a517ddc426a4b4e1bd) by Timothée Mazzucotelli). [Issue-autorefs#57](https://github.com/mkdocstrings/autorefs/issues/57) + +## [0.26.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.26.0) - 2024-09-02 + +[Compare with 0.25.2](https://github.com/mkdocstrings/mkdocstrings/compare/0.25.2...0.26.0) + +### Build + +- Upgrade Python-Markdown lower bound to 3.6 ([28565f9](https://github.com/mkdocstrings/mkdocstrings/commit/28565f97f21bf81b2bc554679c641fba3f639882) by Timothée Mazzucotelli). + +### Dependencies + +- Depend on mkdocs-autorefs v1 ([33aa573](https://github.com/mkdocstrings/mkdocstrings/commit/33aa573efb17b13e7b9da77e29aeccb3fbddd8e8) by Timothée Mazzucotelli). + +### Features + +- Allow hooking into autorefs when converting Markdown docstrings ([b63e726](https://github.com/mkdocstrings/mkdocstrings/commit/b63e72691a8f92dd59b56304125de4a19e0d028c) by Timothée Mazzucotelli). [Based-on-PR-autorefs#46](https://github.com/mkdocstrings/autorefs/pull/46) + +## [0.25.2](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.25.2) - 2024-07-25 + +[Compare with 0.25.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.25.1...0.25.2) + +### Code Refactoring + +- Give precedence to Markdown heading level (`##`) ([2e5f89e](https://github.com/mkdocstrings/mkdocstrings/commit/2e5f89e8cef11e6447425d3700c29558cd6d241b) by Timothée Mazzucotelli). + +## [0.25.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.25.1) - 2024-05-05 + +[Compare with 0.25.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.25.0...0.25.1) + +### Bug Fixes + +- Always descend into sub-headings when re-applying their label ([cb86e08](https://github.com/mkdocstrings/mkdocstrings/commit/cb86e08bbc5e8057393aa1cd7ca29bc2b40ab5eb) by Timothée Mazzucotelli). [Issue-mkdocstrings/python-158](https://github.com/mkdocstrings/python/issues/158) + +## [0.25.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.25.0) - 2024-04-27 + +[Compare with 0.24.3](https://github.com/mkdocstrings/mkdocstrings/compare/0.24.3...0.25.0) + +### Features + +- Support `once` parameter in logging methods, allowing to log a message only once with a given logger ([1532b59](https://github.com/mkdocstrings/mkdocstrings/commit/1532b59a6efd99fed846cf7edfd0b26525700d3f) by Timothée Mazzucotelli). +- Support blank line between `::: path` and YAML options ([d799d2f](https://github.com/mkdocstrings/mkdocstrings/commit/d799d2f3903bce44fb751f8cf3fb8078d25549da) by Timothée Mazzucotelli). [Issue-450](https://github.com/mkdocstrings/mkdocstrings/issues/450) + +### Code Refactoring + +- Allow specifying name of template loggers ([c5b5f69](https://github.com/mkdocstrings/mkdocstrings/commit/c5b5f697c83271d961c7ac795412d6b4964ba2b7) by Timothée Mazzucotelli). + +## [0.24.3](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.24.3) - 2024-04-05 + +[Compare with 0.24.2](https://github.com/mkdocstrings/mkdocstrings/compare/0.24.2...0.24.3) + +### Bug Fixes + +- Support HTML toc labels with Python-Markdown 3.6+ (uncomment code...) ([7fe3e5f](https://github.com/mkdocstrings/mkdocstrings/commit/7fe3e5f28239c08094fb656728369998f52630ea) by Timothée Mazzucotelli). + +## [0.24.2](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.24.2) - 2024-04-02 + +[Compare with 0.24.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.24.1...0.24.2) + +### Bug Fixes + +- Support HTML toc labels with Python-Markdown 3.6+ ([c0d0090](https://github.com/mkdocstrings/mkdocstrings/commit/c0d009000678a2ccbfb0c8adfeff3dc83845ee41) by Timothée Mazzucotelli). [Issue-mkdocstrings/python-143](https://github.com/mkdocstrings/python/issues/143) + +## [0.24.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.24.1) - 2024-02-27 + +[Compare with 0.24.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.24.0...0.24.1) + +### Code Refactoring + +- Support new pymdownx-highlight options ([a7a2907](https://github.com/mkdocstrings/mkdocstrings/commit/a7a29079aebcd79be84ac38ce275797920e4c75e) by Timothée Mazzucotelli). +- Backup anchors with id and no href, for compatibility with autorefs' Markdown anchors ([b5236b4](https://github.com/mkdocstrings/mkdocstrings/commit/b5236b4333ebde9648c84f6e4b0f4c2b10f3ecd4) by Timothée Mazzucotelli). [PR-#651](https://github.com/mkdocstrings/mkdocstrings/pull/651), [Related-to-mkdocs-autorefs#39](https://github.com/mkdocstrings/autorefs/pull/39), Co-authored-by: Oleh Prypin + +## [0.24.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.24.0) - 2023-11-14 + +[Compare with 0.23.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.23.0...0.24.0) + +### Features + +- Cache downloaded inventories as local file ([ce84dd5](https://github.com/mkdocstrings/mkdocstrings/commit/ce84dd57dc5cd3bf3f4be9623ddaa73e1c1868f0) by Oleh Prypin). [PR #632](https://github.com/mkdocstrings/mkdocstrings/pull/632) + +### Bug Fixes + +- Make `custom_templates` relative to the config file ([370a61d](https://github.com/mkdocstrings/mkdocstrings/commit/370a61d12b33f3fb61f6bddb3939eb8ff6018620) by Waylan Limberg). [Issue #477](https://github.com/mkdocstrings/mkdocstrings/issues/477), [PR #627](https://github.com/mkdocstrings/mkdocstrings/pull/627) +- Remove duplicated headings for docstrings nested in tabs/admonitions ([e2123a9](https://github.com/mkdocstrings/mkdocstrings/commit/e2123a935edea0abdc1b439e2c2b76e002c76e2b) by Perceval Wajsburt, [f4a94f7](https://github.com/mkdocstrings/mkdocstrings/commit/f4a94f7d8b8eb1ac01d65bb7237f0077e320ddac) by Oleh Prypin). [Issue #609](https://github.com/mkdocstrings/mkdocstrings/issues/609), [PR #610](https://github.com/mkdocstrings/mkdocstrings/pull/610), [PR #613](https://github.com/mkdocstrings/mkdocstrings/pull/613) + +### Code Refactoring + +- Drop support for MkDocs < 1.4, modernize usages ([b61d4d1](https://github.com/mkdocstrings/mkdocstrings/commit/b61d4d15258c66b14266aa04b456f191f101b2c6) by Oleh Prypin). [PR #629](https://github.com/mkdocstrings/mkdocstrings/pull/629) + +## [0.23.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.23.0) - 2023-08-28 + +[Compare with 0.22.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.22.0...0.23.0) + +### Breaking Changes + +- Removed `BaseCollector` and `BaseRenderer` classes: they were merged into the `BaseHandler` class. +- Removed the watch feature, as MkDocs now provides it natively. +- Removed support for `selection` and `rendering` keys in YAML blocks: use `options` instead. +- Removed support for loading handlers from the `mkdocstrings.handler` namespace. + Handlers must now be packaged under the `mkdocstrings_handlers` namespace. + +### Features + +- Register all anchors for each object in the inventory ([228fb73](https://github.com/mkdocstrings/mkdocstrings/commit/228fb737caca4e20e600053bf59cbfa3e9c73906) by Timothée Mazzucotelli). + +### Bug Fixes + +- Don't add `codehilite` CSS class to inline code ([7690d41](https://github.com/mkdocstrings/mkdocstrings/commit/7690d41e2871997464367e673023585c4fb05e26) by Timothée Mazzucotelli). +- Support cross-references for API docs rendered in top-level index page ([b194452](https://github.com/mkdocstrings/mkdocstrings/commit/b194452be93aee33b3c28a468762b4d96c501f4f) by Timothée Mazzucotelli). + +### Code Refactoring + +- Sort inventories before writing them to disk ([9371e9f](https://github.com/mkdocstrings/mkdocstrings/commit/9371e9fc7dd68506b73aa1580a12c5f5cd779aba) by Timothée Mazzucotelli). +- Stop accepting sets as return value of `get_anchors` (only tuples), to preserve order ([2e10374](https://github.com/mkdocstrings/mkdocstrings/commit/2e10374be258e9713b26f73dd06d0c2520ec07a5) by Timothée Mazzucotelli). +- Remove deprecated parts ([0a90a47](https://github.com/mkdocstrings/mkdocstrings/commit/0a90a474c8dcbd95821700d7dab63f03e392c40f) by Timothée Mazzucotelli). +- Use proper parameters in `Inventory.register` method ([433c6e0](https://github.com/mkdocstrings/mkdocstrings/commit/433c6e01aab9333589f755e483f124db0836f143) by Timothée Mazzucotelli). + +## [0.22.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.22.0) - 2023-05-25 + +[Compare with 0.21.2](https://github.com/mkdocstrings/mkdocstrings/compare/0.21.2...0.22.0) + +### Features + +- Allow extensions to add templates ([cf0af05](https://github.com/mkdocstrings/mkdocstrings/commit/cf0af059eb89240eba0437de417c124389e2f20e) by Timothée Mazzucotelli). [PR #569](https://github.com/mkdocstrings/mkdocstrings/pull/569) + +### Code Refactoring + +- Report inventory loading errors ([2c05d78](https://github.com/mkdocstrings/mkdocstrings/commit/2c05d7854b87251e26c1a2e1810b85702ff110f3) by Timothée Mazzucotelli). Co-authored-by: Oleh Prypin + +## [0.21.2](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.21.2) - 2023-04-06 + +[Compare with 0.21.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.21.1...0.21.2) + +### Bug Fixes + +- Fix regression with LRU cached method ([85efbd2](https://github.com/mkdocstrings/mkdocstrings/commit/85efbd285d4c8977755bda1c36220b241a9e1502) by Timothée Mazzucotelli). [Issue #549](https://github.com/mkdocstrings/mkdocstrings/issues/549) + +## [0.21.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.21.1) - 2023-04-05 + +[Compare with 0.21.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.21.0...0.21.1) + +### Bug Fixes + +- Fix missing typing-extensions dependency on Python less than 3.10 ([bff760b](https://github.com/mkdocstrings/mkdocstrings/commit/bff760b77c1eedfa770e852aa994a69ff51b0a32) by Timothée Mazzucotelli). [Issue #548](https://github.com/mkdocstrings/mkdocstrings/issues/548) + +## [0.21.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.21.0) - 2023-04-05 + +[Compare with 0.20.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.20.0...0.21.0) + +### Features + +- Expose the full config to handlers ([15dacf6](https://github.com/mkdocstrings/mkdocstrings/commit/15dacf62f8479a05e9604383155ffa6fade0522d) by David Patterson). [Issue #501](https://github.com/mkdocstrings/mkdocstrings/issues/501), [PR #509](https://github.com/mkdocstrings/mkdocstrings/pull/509) + +## [0.20.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.20.0) - 2023-01-19 + +[Compare with 0.19.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.19.1...0.20.0) + +### Features +- Add `enabled` configuration option ([8cf117d](https://github.com/mkdocstrings/mkdocstrings/commit/8cf117daeefb4fc522145cc567b40eb4256c0a94) by StefanBRas). [Issue #478](https://github.com/mkdocstrings/mkdocstrings/issues/478), [PR #504](https://github.com/mkdocstrings/mkdocstrings/pull/504) + +### Bug Fixes +- Handle updating Jinja environment of multiple handlers ([a6ea80c](https://github.com/mkdocstrings/mkdocstrings/commit/a6ea80c992f2a200d8cee3c9ff3b651ddd049a3d) by David Patterson). [Related PR #201](https://github.com/mkdocstrings/mkdocstrings/pull/201), [Issue #502](https://github.com/mkdocstrings/mkdocstrings/issues/502), [PR #507](https://github.com/mkdocstrings/mkdocstrings/pull/507) + +### Code Refactoring +- Make `_load_inventory` accept lists as arguments ([105ed82](https://github.com/mkdocstrings/mkdocstrings/commit/105ed8210d4665f6b52f2cc04d56df2d35cd3caf) by Sorin Sbarnea). [Needed by PR mkdocstrings/python#49](https://github.com/mkdocstrings/python/issues/49), [PR #511](https://github.com/mkdocstrings/mkdocstrings/pull/511) +- Remove support for MkDocs < 1.2 (we already depended on MkDocs >= 1.2) ([ac963c8](https://github.com/mkdocstrings/mkdocstrings/commit/ac963c88c793e640d2a7a31392aff1fc2d15ba52) by Timothée Mazzucotelli). + +## [0.19.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.19.1) - 2022-12-13 + +[Compare with 0.19.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.19.0...0.19.1) + +### Bug Fixes +- Fix regular expression for Sphinx inventory parsing ([348bdd5](https://github.com/mkdocstrings/mkdocstrings/commit/348bdd5e930f3cf7a8e27835189794ec940ae1b7) by Luis Michaelis). [Issue #496](https://github.com/mkdocstrings/mkdocstrings/issues/496), [PR #497](https://github.com/mkdocstrings/mkdocstrings/issues/497) + +### Code Refactoring +- Small fixes to type annotations ([9214b74](https://github.com/mkdocstrings/mkdocstrings/commit/9214b74367da1f9c808eacc8ceecc4134d5c9d3c) by Oleh Prypin). [PR #470](https://github.com/mkdocstrings/mkdocstrings/issues/470) +- Report usage-based warnings as user-facing messages ([03dd7a6](https://github.com/mkdocstrings/mkdocstrings/commit/03dd7a6e4fefa44889bda9899d9b698bcfd07990) by Oleh Prypin). [PR #464](https://github.com/mkdocstrings/mkdocstrings/issues/464) + + ## [0.19.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.19.0) - 2022-05-28 [Compare with 0.18.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.18.1...0.19.0) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 35f1f538..255e0eed 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,73 +2,132 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or -advances -* Trolling, insulting/derogatory comments, and personal or political attacks +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission +* Publishing others' private information, such as a physical or email address, + without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at pawamoy@pm.me. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. +reported to the community leaders responsible for enforcement at +dev@pawamoy.fr. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f80c98a..ab0c308b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,27 +7,36 @@ Every little bit helps, and credit will always be given. Nothing easier! -Fork and clone the repository, then: +Fork and clone the repository. The project uses dynamic versioning, so to get the correct package version when building, make sure to pull Git tags: ```bash cd mkdocstrings + +# Assuming you authenticate with SSH. +git remote add upstream git@github.com:mkdocstrings/mkdocstrings +git pull upstream --tags +``` + +Then: + +```bash make setup ``` -> NOTE: If it fails for some reason, +> NOTE: +> If it fails for some reason, > you'll need to install -> [PDM](https://github.com/pdm-project/pdm) +> [uv](https://github.com/astral-sh/uv) > manually. -> +> > You can install it with: -> +> > ```bash -> python3 -m pip install --user pipx -> pipx install pdm +> curl -LsSf https://astral.sh/uv/install.sh | sh > ``` -> +> > Now you can try running `make setup` again, -> or simply `pdm install`. +> or simply `uv sync`. You now have the dependencies installed. @@ -35,23 +44,21 @@ Run `make help` to see all the available actions! ## Tasks -This project uses [duty](https://github.com/pawamoy/duty) to run tasks. -A Makefile is also provided. The Makefile will try to run certain tasks -on multiple Python versions. If for some reason you don't want to run the task -on multiple Python versions, you can do one of the following: +The entry-point to run commands and tasks is the `make` Python script, +located in the `scripts` directory. Try running `make` to show the available commands and tasks. +The *commands* do not need the Python dependencies to be installed, +while the *tasks* do. +The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). -1. `export PYTHON_VERSIONS= `: this will run the task - with only the current Python version -2. run the task directly with `pdm run duty TASK` - -The Makefile detects if a virtual environment is activated, -so `make` will work the same with the virtualenv activated or not. +If you work in VSCode, we provide +[an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) +for the project. ## Development As usual: -1. create a new branch: `git checkout -b feature-or-bugfix-name` +1. create a new branch: `git switch -c feature-or-bugfix-name` 1. edit the code and/or the documentation **Before committing:** @@ -60,20 +67,21 @@ As usual: 1. run `make check` to check everything (fix any warning) 1. run `make test` to run the tests (fix any issue) 1. if you updated the documentation or the project dependencies: - 1. run `make docs-serve` + 1. run `make docs` 1. go to http://localhost:8000 and check that everything looks good 1. follow our [commit message convention](#commit-message-convention) If you are unsure about how to fix or ignore a warning, just let the continuous integration fail, -and we will help you during review. +and we will help you during the review. Don't bother updating the changelog, we will take care of this. ## Commit message convention -Commits messages must follow the -[Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message): +Commit messages must follow our convention based on the +[Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) +or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): ``` [(scope)]: Subject @@ -81,34 +89,52 @@ Commits messages must follow the [Body] ``` +**Subject and body must be valid Markdown.** +Subject must have proper casing (uppercase for first letter +if it makes sense), but no dot at the end, and no punctuation +in general. + Scope and body are optional. Type can be: - `build`: About packaging, building wheels, etc. - `chore`: About packaging or repo/files management. - `ci`: About Continuous Integration. +- `deps`: Dependencies update. - `docs`: About documentation. - `feat`: New feature. - `fix`: Bug fix. - `perf`: About performance. -- `refactor`: Changes which are not features nor bug fixes. +- `refactor`: Changes that are not features or bug fixes. - `style`: A change in code style/format. - `tests`: About tests. -**Subject (and body) must be valid Markdown.** -If you write a body, please add issues references at the end: +If you write a body, please add trailers at the end +(for example issues and PR references, or co-authors), +without relying on GitHub's flavored Markdown: ``` Body. -References: #10, #11. -Fixes #15. +Issue #10: https://github.com/namespace/project/issues/10 +Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 ``` +These "trailers" must appear at the end of the body, +without any blank lines between them. The trailer title +can contain any character except colons `:`. +We expect a full URI for each trailer, not just GitHub autolinks +(for example, full GitHub URLs for commits and issues, +not the hash or the #issue-number). + +We do not enforce a line length on commit messages summary and body, +but please avoid very long summaries, and very long lines in the body, +unless they are part of code blocks that must not be wrapped. + ## Pull requests guidelines Link to any related issue in the Pull Request message. -During review, we recommend using fixups: +During the review, we recommend using fixups: ```bash # SHA is the SHA of the commit you want to fix @@ -118,7 +144,7 @@ git commit --fixup=SHA Once all the changes are approved, you can squash your commits: ```bash -git rebase -i --autosquash master +git rebase -i --autosquash main ``` And force-push: diff --git a/Makefile b/Makefile index 58291575..5e88121d 100644 --- a/Makefile +++ b/Makefile @@ -1,53 +1,28 @@ -.DEFAULT_GOAL := help -SHELL := bash +# If you have `direnv` loaded in your shell, and allow it in the repository, +# the `make` command will point at the `scripts/make` shell script. +# This Makefile is just here to allow auto-completion in the terminal. -DUTY = $(shell [ -n "${VIRTUAL_ENV}" ] || echo pdm run) duty - -args = $(foreach a,$($(subst -,_,$1)_args),$(if $(value $a),$a="$($a)")) -check_quality_args = files -docs_serve_args = host port -release_args = version -test_args = match - -BASIC_DUTIES = \ +actions = \ + allrun \ changelog \ - check-dependencies \ + check \ + check-api \ + check-docs \ + check-quality \ + check-types \ clean \ coverage \ docs \ docs-deploy \ - docs-regen \ - docs-serve \ format \ - release - -QUALITY_DUTIES = \ - check-quality \ - check-docs \ - check-types \ - test - -.PHONY: help -help: - @$(DUTY) --list - -.PHONY: lock -lock: - @pdm lock - -.PHONY: setup -setup: - @bash scripts/setup.sh - -.PHONY: check -check: - @bash scripts/multirun.sh duty check-quality check-types check-docs - @$(DUTY) check-dependencies - -.PHONY: $(BASIC_DUTIES) -$(BASIC_DUTIES): - @$(DUTY) $@ $(call args,$@) - -.PHONY: $(QUALITY_DUTIES) -$(QUALITY_DUTIES): - @bash scripts/multirun.sh duty $@ $(call args,$@) + help \ + multirun \ + release \ + run \ + setup \ + test \ + vscode + +.PHONY: $(actions) +$(actions): + @python scripts/make "$@" diff --git a/README.md b/README.md index 534c64bf..e9f9fbb2 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,17 @@ # mkdocstrings [![ci](https://github.com/mkdocstrings/mkdocstrings/workflows/ci/badge.svg)](https://github.com/mkdocstrings/mkdocstrings/actions?query=workflow%3Aci) -[![documentation](https://img.shields.io/badge/docs-mkdocs%20material-blue.svg?style=flat)](https://mkdocstrings.github.io/) +[![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://mkdocstrings.github.io/) [![pypi version](https://img.shields.io/pypi/v/mkdocstrings.svg)](https://pypi.org/project/mkdocstrings/) [![conda version](https://img.shields.io/conda/vn/conda-forge/mkdocstrings)](https://anaconda.org/conda-forge/mkdocstrings) -[![gitpod](https://img.shields.io/badge/gitpod-workspace-blue.svg?style=flat)](https://gitpod.io/#https://github.com/mkdocstrings/mkdocstrings) -[![gitter](https://badges.gitter.im/join%20chat.svg)](https://gitter.im/mkdocstrings/community) +[![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#mkdocstrings:gitter.im) -Automatic documentation from sources, for [MkDocs](https://mkdocs.org/). +Automatic documentation from sources, for [MkDocs](https://www.mkdocs.org/). Come have a chat or ask questions on our [Gitter channel](https://gitter.im/mkdocstrings/community). --- -**[Features](#features)** - **[Requirements](#requirements)** - **[Installation](#installation)** - **[Quick usage](#quick-usage)** +**[Features](#features)** - **[Installation](#installation)** - **[Quick usage](#quick-usage)** ![mkdocstrings_gif1](https://user-images.githubusercontent.com/3999221/77157604-fb807480-6aa1-11ea-99e0-d092371d4de0.gif) @@ -22,8 +21,13 @@ Come have a chat or ask questions on our [Gitter channel](https://gitter.im/mkdo just like *MkDocs*, *mkdocstrings* is written in Python but is language-agnostic. It means you can use it with any programming language, as long as there is a [**handler**](https://mkdocstrings.github.io/reference/handlers/base/) for it. - We currently have [handlers](https://mkdocstrings.github.io/handlers/overview/) - for the [Crystal](https://mkdocstrings.github.io/crystal/) and [Python](https://mkdocstrings.github.io/python/) languages. + We currently have [handlers](https://mkdocstrings.github.io/handlers/overview/) for the + [C](https://mkdocstrings.github.io/c/), + [Crystal](https://mkdocstrings.github.io/crystal/), + [Python](https://mkdocstrings.github.io/python/), + [TypeScript](https://mkdocstrings.github.io/typescript/), and + [VBA](https://pypi.org/project/mkdocstrings-vba/) languages, + as well as for [shell scripts/libraries](https://mkdocstrings.github.io/shell/). Maybe you'd like to add another one to the list? :wink: - [**Multiple themes support:**](https://mkdocstrings.github.io/theming/) @@ -55,37 +59,58 @@ Come have a chat or ask questions on our [Gitter channel](https://gitter.im/mkdo each handler can be configured globally in `mkdocs.yml`, and locally for each "autodoc" instruction. -- [**Watch source code directories:**](https://mkdocstrings.github.io/usage/#watch-directories) - you can tell *mkdocstrings* to add directories to be watched by *MkDocs* when - serving the documentation, for auto-reload. - - **Reasonable defaults:** you should be able to just drop the plugin in your configuration and enjoy your auto-generated docs. +## Used by + +*mkdocstrings* is used by well-known companies, projects and scientific teams: +[Ansible](https://molecule.readthedocs.io/configuration/), +[Apache](https://streampipes.apache.org/docs/docs/python/latest/reference/client/client/), +[FastAPI](https://fastapi.tiangolo.com/reference/fastapi/), +[Google](https://docs.kidger.site/jaxtyping/api/runtime-type-checking/), +[Jitsi](https://jitsi.github.io/jiwer/reference/alignment/), +[Microsoft](https://microsoft.github.io/presidio/api/analyzer_python/), +[Prefect](https://docs.prefect.io/2.10.12/api-ref/prefect/agent/), +[Pydantic](https://docs.pydantic.dev/dev-v2/api/main/), +[and more...](https://github.com/mkdocstrings/mkdocstrings/network/dependents) + ## Installation -With `pip`: +The `mkdocstrings` package doesn't provide support for any language: it's just a common base for language handlers. +It means you likely want to install it with one or more official handlers, using [extras](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#extras). +For example, to install it with Python support: + ```bash -pip install mkdocstrings +pip install 'mkdocstrings[python]' ``` -You can install support for specific languages using extras, for example: +Alternatively, you can directly install the language handlers themselves, +which depend on `mkdocstrings` anyway: ```bash -pip install mkdocstrings[crystal,python] +pip install mkdocstrings-python ``` -See the [available language handlers](https://mkdocstrings.github.io/handlers/overview/). +This will give you more control over the accepted range of versions for the handlers themselves. + +See the [official language handlers](https://mkdocstrings.github.io/handlers/overview/). + +--- With `conda`: + ```bash -conda install -c conda-forge mkdocstrings +conda install -c conda-forge mkdocstrings mkdocstrings-python ``` ## Quick usage +In `mkdocs.yml`: + ```yaml -# mkdocs.yml +site_name: "My Library" + theme: name: "material" diff --git a/config/coverage.ini b/config/coverage.ini index bb43c37b..18365bd2 100644 --- a/config/coverage.ini +++ b/config/coverage.ini @@ -8,7 +8,8 @@ source = [coverage:paths] equivalent = src/ - __pypackages__/ + .venv/lib/*/site-packages/ + .venvs/*/lib/*/site-packages/ [coverage:report] ignore_errors = True @@ -17,6 +18,10 @@ omit = src/*/__init__.py src/*/__main__.py tests/__init__.py + tests/fixtures/*.py +exclude_lines = + pragma: no cover + if TYPE_CHECKING [coverage:json] output = htmlcov/coverage.json diff --git a/config/flake8.ini b/config/flake8.ini deleted file mode 100644 index e0a4cfbd..00000000 --- a/config/flake8.ini +++ /dev/null @@ -1,133 +0,0 @@ -[flake8] -exclude = fixtures,site,snippets -max-line-length = 132 -strictness = long -docstring-convention = google -ban-relative-imports = true -ignore = - # redundant with W0622 (builtin override), which is more precise about line number - A001 - # missing docstring in magic method - D105 - # multi-line docstring summary should start at the first line - D212 - # does not support Parameters sections - D417 - # whitespace before ':' (incompatible with Black) - E203 - # redundant with E0602 (undefined variable) - F821 - # error suffix foe exception - N818 - # black already deals with quoting - Q000 - # use of assert - S101 - # we are not parsing XML - S405 - # line break before binary operator (incompatible with Black) - W503 - # two-lowercase-letters variable DO conform to snake_case naming style - C0103 - # redundant with D102 (missing docstring) - C0116 - # line too long - C0301 - # too many instance attributes - R0902 - # too few public methods - R0903 - # too many public methods - R0904 - # too many branches - R0912 - # too many methods - R0913 - # too many local variables - R0914 - # too many statements - R0915 - # protected attribute - W0212 - # redundant with F401 (unused import) - W0611 - # lazy formatting for logging calls - 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/git-changelog.toml b/config/git-changelog.toml new file mode 100644 index 00000000..e6bb5b91 --- /dev/null +++ b/config/git-changelog.toml @@ -0,0 +1,9 @@ +bump = "auto" +convention = "angular" +in-place = true +output = "CHANGELOG.md" +parse-refs = false +parse-trailers = true +sections = ["build", "deps", "feat", "fix", "perf", "refactor"] +template = "keepachangelog" +versioning = "pep440" diff --git a/config/pytest.ini b/config/pytest.ini index ad72bbe6..1a0d99c6 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -1,16 +1,19 @@ [pytest] -norecursedirs = - .git - .tox - .env - dist - build python_files = test_*.py - *_test.py - tests.py addopts = --cov --cov-config config/coverage.ini testpaths = tests + +# action:message_regex:warning_class:module_regex:line +filterwarnings = + error + # TODO: remove once pytest-xdist 4 is released + ignore:.*rsyncdir:DeprecationWarning:xdist + # TODO: remove once griffe and mkdocstrings-python release new versions + ignore:.*`get_logger`:DeprecationWarning:_griffe + ignore:.*`name`:DeprecationWarning:_griffe + ignore:.*Importing from `griffe:DeprecationWarning:mkdocstrings_handlers + ignore:.*`patch_loggers`:DeprecationWarning:_griffe diff --git a/config/ruff.toml b/config/ruff.toml new file mode 100644 index 00000000..fbe31d5b --- /dev/null +++ b/config/ruff.toml @@ -0,0 +1,84 @@ +target-version = "py39" +line-length = 120 + +[lint] +exclude = [ + "tests/fixtures/*.py", +] +select = [ + "A", "ANN", "ARG", + "B", "BLE", + "C", "C4", + "COM", + "D", "DTZ", + "E", "ERA", "EXE", + "F", "FBT", + "G", + "I", "ICN", "INP", "ISC", + "N", + "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", + "Q", + "RUF", "RSE", "RET", + "S", "SIM", "SLF", + "T", "T10", "T20", "TCH", "TID", "TRY", + "UP", + "W", + "YTT", +] +ignore = [ + "A001", # Variable is shadowing a Python builtin + "ANN101", # Missing type annotation for self + "ANN102", # Missing type annotation for cls + "ANN204", # Missing return type annotation for special method __str__ + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "ARG005", # Unused lambda argument + "C901", # Too complex + "D105", # Missing docstring in magic method + "D417", # Missing argument description in the docstring + "E501", # Line too long + "ERA001", # Commented out code + "G004", # Logging statement uses f-string + "PLR0911", # Too many return statements + "PLR0912", # Too many branches + "PLR0913", # Too many arguments to function call + "PLR0915", # Too many statements + "SLF001", # Private member accessed + "TRY003", # Avoid specifying long messages outside the exception class +] + +[lint.per-file-ignores] +"src/*/cli.py" = [ + "T201", # Print statement +] +"src/*/debug.py" = [ + "T201", # Print statement +] +"scripts/*.py" = [ + "INP001", # File is part of an implicit namespace package + "T201", # Print statement +] +"tests/*.py" = [ + "ARG005", # Unused lambda argument + "FBT001", # Boolean positional arg in function definition + "PLR2004", # Magic value used in comparison + "S101", # Use of assert detected +] + +[lint.flake8-quotes] +docstring-quotes = "double" + +[lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[lint.isort] +known-first-party = ["mkdocstrings"] + +[lint.pydocstyle] +convention = "google" + +[format] +exclude = [ + "tests/fixtures/*.py", +] +docstring-code-format = true +docstring-code-line-length = 80 diff --git a/config/vscode/launch.json b/config/vscode/launch.json new file mode 100644 index 00000000..e3288388 --- /dev/null +++ b/config/vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "python (current file)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "docs", + "type": "debugpy", + "request": "launch", + "module": "mkdocs", + "justMyCode": false, + "args": [ + "serve", + "-v" + ] + }, + { + "name": "test", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "justMyCode": false, + "args": [ + "-c=config/pytest.ini", + "-vvv", + "--no-cov", + "--dist=no", + "tests", + "-k=${input:tests_selection}" + ] + } + ], + "inputs": [ + { + "id": "tests_selection", + "type": "promptString", + "description": "Tests selection", + "default": "" + } + ] +} \ No newline at end of file diff --git a/config/vscode/settings.json b/config/vscode/settings.json new file mode 100644 index 00000000..949856d1 --- /dev/null +++ b/config/vscode/settings.json @@ -0,0 +1,33 @@ +{ + "files.watcherExclude": { + "**/.venv*/**": true, + "**/.venvs*/**": true, + "**/venv*/**": true + }, + "mypy-type-checker.args": [ + "--config-file=config/mypy.ini" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "--config-file=config/pytest.ini" + ], + "ruff.enable": true, + "ruff.format.args": [ + "--config=config/ruff.toml" + ], + "ruff.lint.args": [ + "--config=config/ruff.toml" + ], + "yaml.schemas": { + "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" + }, + "yaml.customTags": [ + "!ENV scalar", + "!ENV sequence", + "!relative scalar", + "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", + "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", + "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" + ] +} \ No newline at end of file diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json new file mode 100644 index 00000000..73145eec --- /dev/null +++ b/config/vscode/tasks.json @@ -0,0 +1,97 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "changelog", + "type": "process", + "command": "scripts/make", + "args": ["changelog"] + }, + { + "label": "check", + "type": "process", + "command": "scripts/make", + "args": ["check"] + }, + { + "label": "check-quality", + "type": "process", + "command": "scripts/make", + "args": ["check-quality"] + }, + { + "label": "check-types", + "type": "process", + "command": "scripts/make", + "args": ["check-types"] + }, + { + "label": "check-docs", + "type": "process", + "command": "scripts/make", + "args": ["check-docs"] + }, + { + "label": "check-api", + "type": "process", + "command": "scripts/make", + "args": ["check-api"] + }, + { + "label": "clean", + "type": "process", + "command": "scripts/make", + "args": ["clean"] + }, + { + "label": "docs", + "type": "process", + "command": "scripts/make", + "args": ["docs"] + }, + { + "label": "docs-deploy", + "type": "process", + "command": "scripts/make", + "args": ["docs-deploy"] + }, + { + "label": "format", + "type": "process", + "command": "scripts/make", + "args": ["format"] + }, + { + "label": "release", + "type": "process", + "command": "scripts/make", + "args": ["release", "${input:version}"] + }, + { + "label": "setup", + "type": "process", + "command": "scripts/make", + "args": ["setup"] + }, + { + "label": "test", + "type": "process", + "command": "scripts/make", + "args": ["test", "coverage"], + "group": "test" + }, + { + "label": "vscode", + "type": "process", + "command": "scripts/make", + "args": ["vscode"] + } + ], + "inputs": [ + { + "id": "version", + "type": "promptString", + "description": "Version" + } + ] +} \ No newline at end of file diff --git a/docs/.overrides/main.html b/docs/.overrides/main.html new file mode 100644 index 00000000..1e956857 --- /dev/null +++ b/docs/.overrides/main.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block announce %} + + Fund this project through + sponsorship + + {% include ".icons/octicons/heart-fill-16.svg" %} + — + + Follow + @pawamoy on + + + {% include ".icons/fontawesome/brands/mastodon.svg" %} + + Fosstodon + + for updates +{% endblock %} diff --git a/docs/.overrides/partials/comments.html b/docs/.overrides/partials/comments.html new file mode 100644 index 00000000..3976b0d6 --- /dev/null +++ b/docs/.overrides/partials/comments.html @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/docs/credits.md b/docs/credits.md new file mode 100644 index 00000000..f758db87 --- /dev/null +++ b/docs/credits.md @@ -0,0 +1,10 @@ +--- +hide: +- toc +--- + + +```python exec="yes" +--8<-- "scripts/gen_credits.py" +``` + diff --git a/docs/css/insiders.css b/docs/css/insiders.css new file mode 100644 index 00000000..e7b9c74f --- /dev/null +++ b/docs/css/insiders.css @@ -0,0 +1,124 @@ +@keyframes heart { + + 0%, + 40%, + 80%, + 100% { + transform: scale(1); + } + + 20%, + 60% { + transform: scale(1.15); + } +} + +@keyframes vibrate { + 0%, 2%, 4%, 6%, 8%, 10%, 12%, 14%, 16%, 18% { + -webkit-transform: translate3d(-2px, 0, 0); + transform: translate3d(-2px, 0, 0); + } + 1%, 3%, 5%, 7%, 9%, 11%, 13%, 15%, 17%, 19% { + -webkit-transform: translate3d(2px, 0, 0); + transform: translate3d(2px, 0, 0); + } + 20%, 100% { + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} + +.heart { + color: #e91e63; +} + +.pulse { + animation: heart 1000ms infinite; +} + +.vibrate { + animation: vibrate 2000ms infinite; +} + +.new-feature svg { + fill: var(--md-accent-fg-color) !important; +} + +a.insiders { + color: #e91e63; +} + +.sponsorship-list { + width: 100%; +} + +.sponsorship-item { + border-radius: 100%; + display: inline-block; + height: 1.6rem; + margin: 0.1rem; + overflow: hidden; + width: 1.6rem; +} + +.sponsorship-item:focus, .sponsorship-item:hover { + transform: scale(1.1); +} + +.sponsorship-item img { + filter: grayscale(100%) opacity(75%); + height: auto; + width: 100%; +} + +.sponsorship-item:focus img, .sponsorship-item:hover img { + filter: grayscale(0); +} + +.sponsorship-item.private { + background: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + font-size: .6rem; + font-weight: 700; + line-height: 1.6rem; + text-align: center; +} + +.mastodon { + color: #897ff8; + border-radius: 100%; + box-shadow: inset 0 0 0 .05rem currentcolor; + display: inline-block; + height: 1.2rem !important; + padding: .25rem; + transition: all .25s; + vertical-align: bottom !important; + width: 1.2rem; +} + +.premium-sponsors { + text-align: center; +} + +#silver-sponsors img { + height: 140px; +} + +#bronze-sponsors img { + height: 140px; +} + +#bronze-sponsors p { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +#bronze-sponsors a { + display: block; + flex-shrink: 0; +} + +.sponsors-total { + font-weight: bold; +} \ No newline at end of file diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css index a83172e5..05f1088b 100644 --- a/docs/css/mkdocstrings.css +++ b/docs/css/mkdocstrings.css @@ -1,11 +1,42 @@ /* Indentation. */ div.doc-contents:not(.first) { padding-left: 25px; - border-left: 4px solid rgba(230, 230, 230); - margin-bottom: 80px; + border-left: .05rem solid var(--md-typeset-table-color); +} + +/* Mark external links as such. */ +a.external::after, +a.autorefs-external::after { + /* https://primer.style/octicons/arrow-up-right-24 */ + mask-image: url('data:image/svg+xml,'); + -webkit-mask-image: url('data:image/svg+xml,'); + content: ' '; + + display: inline-block; + vertical-align: middle; + position: relative; + + height: 1em; + width: 1em; + background-color: currentColor; +} + +a.external:hover::after, +a.autorefs-external:hover::after { + background-color: var(--md-accent-fg-color); } /* Avoid breaking parameters name, etc. in table cells. */ td code { word-break: normal !important; } + +[data-md-color-scheme="default"] { + --doc-symbol-parameter-fg-color: #d3a81b; + --doc-symbol-parameter-bg-color: #d3a81b1a; +} + +[data-md-color-scheme="slate"] { + --doc-symbol-parameter-fg-color: #dfbe50; + --doc-symbol-parameter-bg-color: #dfbe501a; +} diff --git a/docs/gen_credits.py b/docs/gen_credits.py deleted file mode 100644 index 370d2e7d..00000000 --- a/docs/gen_credits.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Generate the credits page.""" - -import functools -import re -from itertools import chain -from pathlib import Path -from urllib.request import urlopen - -import mkdocs_gen_files -import toml -from jinja2 import StrictUndefined -from jinja2.sandbox import SandboxedEnvironment - - -def get_credits_data() -> dict: - """Return data used to generate the credits file. - - Returns: - Data required to render the credits template. - """ - project_dir = Path(__file__).parent.parent - metadata = toml.load(project_dir / "pyproject.toml")["project"] - metadata_pdm = toml.load(project_dir / "pyproject.toml")["tool"]["pdm"] - lock_data = toml.load(project_dir / "pdm.lock") - project_name = metadata["name"] - - 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 - - return { - "project_name": project_name, - "direct_dependencies": sorted(direct_dependencies), - "indirect_dependencies": sorted(indirect_dependencies), - "more_credits": "http://pawamoy.github.io/credits/", - } - - -@functools.lru_cache(maxsize=None) -def get_credits(): - """Return credits as Markdown. - - Returns: - The credits page Markdown. - """ - jinja_env = SandboxedEnvironment(undefined=StrictUndefined) - commit = "c78c29caa345b6ace19494a98b1544253cbaf8c1" - template_url = f"https://raw.githubusercontent.com/pawamoy/jinja-templates/{commit}/credits.md" - template_data = get_credits_data() - 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 fd: - fd.write(get_credits()) -mkdocs_gen_files.set_edit_path("credits.md", "gen_credits.py") diff --git a/docs/gen_redirects.py b/docs/gen_redirects.py deleted file mode 100644 index f35cce9c..00000000 --- a/docs/gen_redirects.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Generate redirection pages for autorefs reference.""" - -import mkdocs_gen_files - -redirect_map = { - "reference/autorefs/references.md": "https://mkdocstrings.github.io/autorefs/reference/mkdocs_autorefs/references/", - "reference/autorefs/plugin.md": "https://mkdocstrings.github.io/autorefs/reference/mkdocs_autorefs/plugin/", -} - -redirect_template = """ - -Redirecting... -""" - -for page, link in redirect_map.items(): - with mkdocs_gen_files.open(page, "w") as fd: - print(redirect_template.format(link=link), file=fd) diff --git a/docs/gen_ref_nav.py b/docs/gen_ref_nav.py deleted file mode 100644 index d8e80aa4..00000000 --- a/docs/gen_ref_nav.py +++ /dev/null @@ -1,30 +0,0 @@ -"""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) - if parts[-1] == "__init__": - parts = parts[:-1] - doc_path = doc_path.with_name("index.md") - full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1] == "__main__": - continue - nav[parts] = doc_path - - with mkdocs_gen_files.open(full_doc_path, "w") as fd: - ident = ".".join(parts) - print("::: " + ident, file=fd) - - mkdocs_gen_files.set_edit_path(full_doc_path, path) - -with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: - nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/index.md b/docs/index.md index 612c7a5e..8e6f2fb4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1,6 @@ +--- +hide: +- feedback +--- + --8<-- "README.md" diff --git a/docs/insiders/changelog.md b/docs/insiders/changelog.md new file mode 100644 index 00000000..0f438566 --- /dev/null +++ b/docs/insiders/changelog.md @@ -0,0 +1,3 @@ +# Changelog + +## mkdocstrings Insiders diff --git a/docs/insiders/goals.yml b/docs/insiders/goals.yml new file mode 100644 index 00000000..0e27b997 --- /dev/null +++ b/docs/insiders/goals.yml @@ -0,0 +1,13 @@ +goals: + 500: + name: PlasmaVac User Guide + features: [] + 1000: + name: GraviFridge Fluid Renewal + features: [] + 1500: + name: HyperLamp Navigation Tips + features: [] + 2000: + name: FusionDrive Ejection Configuration + features: [] diff --git a/docs/insiders/index.md b/docs/insiders/index.md new file mode 100644 index 00000000..ccbca99a --- /dev/null +++ b/docs/insiders/index.md @@ -0,0 +1,247 @@ +# Insiders + +*mkdocstrings* follows the **sponsorware** release strategy, which means +that new features are first exclusively released to sponsors as part of +[Insiders][insiders]. Read on to learn [what sponsorships achieve][sponsorship], +[how to become a sponsor][sponsors] to get access to Insiders, +and [what's in it for you][features]! + +## What is Insiders? + +*mkdocstrings Insiders* is a private fork of *mkdocstrings*, hosted as +a private GitHub repository. Almost[^1] [all new features][features] +are developed as part of this fork, which means that they are immediately +available to all eligible sponsors, as they are made collaborators of this +repository. + + [^1]: + In general, every new feature is first exclusively released to sponsors, but + sometimes upstream dependencies enhance + existing features that must be supported by *mkdocstrings*. + +Every feature is tied to a [funding goal][funding] in monthly subscriptions. When a +funding goal is hit, the features that are tied to it are merged back into +*mkdocstrings* and released for general availability, making them available +to all users. Bugfixes are always released in tandem. + +Sponsorships start as low as [**$10 a month**][sponsors].[^2] + + [^2]: + Note that $10 a month is the minimum amount to become eligible for + Insiders. While GitHub Sponsors also allows to sponsor lower amounts or + one-time amounts, those can't be granted access to Insiders due to + technical reasons. Such contributions are still very much welcome as + they help ensuring the project's sustainability. + + +## What sponsorships achieve + +Sponsorships make this project sustainable, as they buy the maintainers of this +project time – a very scarce resource – which is spent on the development of new +features, bug fixing, stability improvement, issue triage and general support. +The biggest bottleneck in Open Source is time.[^3] + + [^3]: + Making an Open Source project sustainable is exceptionally hard: maintainers + burn out, projects are abandoned. That's not great and very unpredictable. + The sponsorware model ensures that if you decide to use *mkdocstrings*, + you can be sure that bugs are fixed quickly and new features are added + regularly. + + + +## What's in it for me? + +```python exec="1" session="insiders" +data_source = [ + "docs/insiders/goals.yml", + ("griffe-pydantic", "https://mkdocstrings.github.io/griffe-pydantic/", "insiders/goals.yml"), + ("griffe-typedoc", "https://mkdocstrings.github.io/griffe-typedoc/", "insiders/goals.yml"), + ("griffe-warnings-deprecated", "https://mkdocstrings.github.io/griffe-warnings-deprecated/", "insiders/goals.yml"), + ("mkdocstrings-c", "https://mkdocstrings.github.io/c/", "insiders/goals.yml"), + ("mkdocstrings-python", "https://mkdocstrings.github.io/python/", "insiders/goals.yml"), + ("mkdocstrings-shell", "https://mkdocstrings.github.io/shell/", "insiders/goals.yml"), + ("mkdocstrings-typescript", "https://mkdocstrings.github.io/typescript/", "insiders/goals.yml"), +] +``` + + +```python exec="1" session="insiders" idprefix="" +--8<-- "scripts/insiders.py" + +if unreleased_features: + print( + "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get **immediate " + f"access to {len(unreleased_features)} additional features** that you can start using right away, and " + "which are currently exclusively available to sponsors:\n" + ) + + for feature in unreleased_features: + feature.render(badge=True) + + print( + "\n\nThese are just the features related to this project. " + "[See the complete feature list on the author's main Insiders page](https://pawamoy.github.io/insiders/#whats-in-it-for-me)." + ) +else: + print( + "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get immediate " + "access to all released features that you can start using right away, and " + "which are exclusively available to sponsors. At this moment, there are no " + "Insiders features for this project, but checkout the [next funding goals](#goals) " + "to see what's coming, as well as **[the feature list for all Insiders projects](https://pawamoy.github.io/insiders/#whats-in-it-for-me).**" + ) +``` + + +## How to become a sponsor + +Thanks for your interest in sponsoring! In order to become an eligible sponsor +with your GitHub account, visit [pawamoy's sponsor profile][github sponsor profile], +and complete a sponsorship of **$10 a month or more**. +You can use your individual or organization GitHub account for sponsoring. + +Sponsorships lower than $10 a month are also very much appreciated, and useful. +They won't grant you access to Insiders, but they will be counted towards reaching sponsorship goals. +*Every* sponsorship helps us implementing new features and releasing them to the public. + +**Important**: If you're sponsoring **[@pawamoy][github sponsor profile]** +through a GitHub organization, please send a short email +to insiders@pawamoy.fr with the name of your +organization and the GitHub account of the individual +that should be added as a collaborator.[^4] + +You can cancel your sponsorship anytime.[^5] + + [^4]: + It's currently not possible to grant access to each member of an + organization, as GitHub only allows for adding users. Thus, after + sponsoring, please send an email to insiders@pawamoy.fr, stating which + account should become a collaborator of the Insiders repository. We're + working on a solution which will make access to organizations much simpler. + To ensure that access is not tied to a particular individual GitHub account, + create a bot account (i.e. a GitHub account that is not tied to a specific + individual), and use this account for the sponsoring. After being added to + the list of collaborators, the bot account can create a private fork of the + private Insiders GitHub repository, and grant access to all members of the + organizations. + + [^5]: + If you cancel your sponsorship, GitHub schedules a cancellation request + which will become effective at the end of the billing cycle. This means + that even though you cancel your sponsorship, you will keep your access to + Insiders as long as your cancellation isn't effective. All charges are + processed by GitHub through Stripe. As we don't receive any information + regarding your payment, and GitHub doesn't offer refunds, sponsorships are + non-refundable. + +[:octicons-heart-fill-24:{ .pulse }   Join our awesome sponsors](https://github.com/sponsors/pawamoy){ .md-button .md-button--primary } + +
+
+
+
+
+
+
+ +
+ + + If you sponsor publicly, you're automatically added here with a link to + your profile and avatar to show your support for *mkdocstrings*. + Alternatively, if you wish to keep your sponsorship private, you'll be a + silent +1. You can select visibility during checkout and change it + afterwards. + + +## Funding + +### Goals + +The following section lists all funding goals. Each goal contains a list of +features prefixed with a checkmark symbol, denoting whether a feature is +:octicons-check-circle-fill-24:{ style="color: #00e676" } already available or +:octicons-check-circle-fill-24:{ style="color: var(--md-default-fg-color--lightest)" } planned, +but not yet implemented. When the funding goal is hit, +the features are released for general availability. + +```python exec="1" session="insiders" idprefix="" +for goal in goals.values(): + if not goal.complete: + goal.render() +``` + +### Goals completed + +This section lists all funding goals that were previously completed, which means +that those features were part of Insiders, but are now generally available and +can be used by all users. + +```python exec="1" session="insiders" idprefix="" +for goal in goals.values(): + if goal.complete: + goal.render() +``` + +## Frequently asked questions + +### Compatibility + +> We're building an open source project and want to allow outside collaborators +to use *mkdocstrings* locally without having access to Insiders. +Is this still possible? + +Yes. Insiders is compatible with *mkdocstrings*. Almost all new features +and configuration options are either backward-compatible or implemented behind +feature flags. Most Insiders features enhance the overall experience, +though while these features add value for the users of your project, they +shouldn't be necessary for previewing when making changes to content. + +### Payment + +> We don't want to pay for sponsorship every month. Are there any other options? + +Yes. You can sponsor on a yearly basis by [switching your GitHub account to a +yearly billing cycle][billing cycle]. If for some reason you cannot do that, you +could also create a dedicated GitHub account with a yearly billing cycle, which +you only use for sponsoring (some sponsors already do that). + +If you have any problems or further questions, please reach out to insiders@pawamoy.fr. + +### Terms + +> Are we allowed to use Insiders under the same terms and conditions as +*mkdocstrings*? + +Yes. Whether you're an individual or a company, you may use *mkdocstrings +Insiders* precisely under the same terms as *mkdocstrings*, which are given +by the [ISC License][license]. However, we kindly ask you to respect our +**fair use policy**: + +- Please **don't distribute the source code** of Insiders. You may freely use + it for public, private or commercial projects, privately fork or mirror it, + but please don't make the source code public, as it would counteract the + sponsorware strategy. + +- If you cancel your subscription, you're automatically removed as a + collaborator and will miss out on all future updates of Insiders. However, you + may **use the latest version** that's available to you **as long as you like**. + Just remember that [GitHub deletes private forks][private forks]. + +[insiders]: #what-is-insiders +[sponsorship]: #what-sponsorships-achieve +[sponsors]: #how-to-become-a-sponsor +[features]: #whats-in-it-for-me +[funding]: #funding +[goals completed]: #goals-completed +[github sponsor profile]: https://github.com/sponsors/pawamoy +[billing cycle]: https://docs.github.com/en/github/setting-up-and-managing-billing-and-payments-on-github/changing-the-duration-of-your-billing-cycle +[license]: ../license.md +[private forks]: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/removing-a-collaborator-from-a-personal-repository + + + diff --git a/docs/insiders/installation.md b/docs/insiders/installation.md new file mode 100644 index 00000000..5671f0da --- /dev/null +++ b/docs/insiders/installation.md @@ -0,0 +1,88 @@ +--- +title: Getting started with Insiders +--- + +# Getting started with Insiders + +*mkdocstrings Insiders* is a compatible drop-in replacement for *mkdocstrings*, +and can be installed similarly using `pip` or `git`. +Note that in order to access the Insiders repository, +you need to [become an eligible sponsor] of @pawamoy on GitHub. + + [become an eligible sponsor]: index.md#how-to-become-a-sponsor + +## Installation + +### with PyPI Insiders + +[PyPI Insiders](https://pawamoy.github.io/pypi-insiders/) +is a tool that helps you keep up-to-date versions +of Insiders projects in the PyPI index of your choice +(self-hosted, Google registry, Artifactory, etc.). + +See [how to install it](https://pawamoy.github.io/pypi-insiders/#installation) +and [how to use it](https://pawamoy.github.io/pypi-insiders/#usage). + +**We kindly ask that you do not upload the distributions to public registries, +as it is against our [Terms of use](index.md#terms).** + +### with pip (ssh/https) + +*mkdocstrings Insiders* can be installed with `pip` [using SSH][using ssh]: + +```bash +pip install git+ssh://git@github.com/pawamoy-insiders/mkdocstrings.git +``` + + [using ssh]: https://docs.github.com/en/authentication/connecting-to-github-with-ssh + +Or using HTTPS: + +```bash +pip install git+https://${GH_TOKEN}@github.com/pawamoy-insiders/mkdocstrings.git +``` + +>? NOTE: **How to get a GitHub personal access token** +> The `GH_TOKEN` environment variable is a GitHub token. +> It can be obtained by creating a [personal access token] for +> your GitHub account. It will give you access to the Insiders repository, +> programmatically, from the command line or GitHub Actions workflows: +> +> 1. Go to https://github.com/settings/tokens +> 2. Click on [Generate a new token] +> 3. Enter a name and select the [`repo`][scopes] scope +> 4. Generate the token and store it in a safe place +> +> [personal access token]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token +> [Generate a new token]: https://github.com/settings/tokens/new +> [scopes]: https://docs.github.com/en/developers/apps/scopes-for-oauth-apps#available-scopes +> +> Note that the personal access +> token must be kept secret at all times, as it allows the owner to access your +> private repositories. + +### with Git + +Of course, you can use *mkdocstrings Insiders* directly using Git: + +``` +git clone git@github.com:pawamoy-insiders/mkdocstrings +``` + +When cloning with Git, the package must be installed: + +``` +pip install -e mkdocstrings +``` + +## Upgrading + +When upgrading Insiders, you should always check the version of *mkdocstrings* +which makes up the first part of the version qualifier. For example, a version like +`8.x.x.4.x.x` means that Insiders `4.x.x` is currently based on `8.x.x`. + +If the major version increased, it's a good idea to consult the [changelog] +and go through the steps to ensure your configuration is up to date and +all necessary changes have been made. + + [changelog]: ./changelog.md diff --git a/docs/js/feedback.js b/docs/js/feedback.js new file mode 100644 index 00000000..f97321a5 --- /dev/null +++ b/docs/js/feedback.js @@ -0,0 +1,14 @@ +const feedback = document.forms.feedback; +feedback.hidden = false; + +feedback.addEventListener("submit", function(ev) { + ev.preventDefault(); + const commentElement = document.getElementById("feedback"); + commentElement.style.display = "block"; + feedback.firstElementChild.disabled = true; + const data = ev.submitter.getAttribute("data-md-value"); + const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); + if (note) { + note.hidden = false; + } +}) diff --git a/docs/js/insiders.js b/docs/js/insiders.js new file mode 100644 index 00000000..8bb68485 --- /dev/null +++ b/docs/js/insiders.js @@ -0,0 +1,74 @@ +function humanReadableAmount(amount) { + const strAmount = String(amount); + if (strAmount.length >= 4) { + return `${strAmount.slice(0, strAmount.length - 3)},${strAmount.slice(-3)}`; + } + return strAmount; +} + +function getJSON(url, callback) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'json'; + xhr.onload = function () { + var status = xhr.status; + if (status === 200) { + callback(null, xhr.response); + } else { + callback(status, xhr.response); + } + }; + xhr.send(); +} + +function updatePremiumSponsors(dataURL, rank) { + let capRank = rank.charAt(0).toUpperCase() + rank.slice(1); + getJSON(dataURL + `/sponsors${capRank}.json`, function (err, sponsors) { + const sponsorsDiv = document.getElementById(`${rank}-sponsors`); + if (sponsors.length > 0) { + let html = ''; + html += `${capRank} sponsors

` + sponsors.forEach(function (sponsor) { + html += ` + + ${sponsor.name} + + ` + }); + html += '

' + sponsorsDiv.innerHTML = html; + } + }); +} + +function updateInsidersPage(author_username) { + const sponsorURL = `https://github.com/sponsors/${author_username}` + const dataURL = `https://raw.githubusercontent.com/${author_username}/sponsors/main`; + getJSON(dataURL + '/numbers.json', function (err, numbers) { + document.getElementById('sponsors-count').innerHTML = numbers.count; + Array.from(document.getElementsByClassName('sponsors-total')).forEach(function (element) { + element.innerHTML = '$ ' + humanReadableAmount(numbers.total); + }); + getJSON(dataURL + '/sponsors.json', function (err, sponsors) { + const sponsorsElem = document.getElementById('sponsors'); + const privateSponsors = numbers.count - sponsors.length; + sponsors.forEach(function (sponsor) { + sponsorsElem.innerHTML += ` + + + + `; + }); + if (privateSponsors > 0) { + sponsorsElem.innerHTML += ` + + +${privateSponsors} + + `; + } + }); + }); + updatePremiumSponsors(dataURL, "gold"); + updatePremiumSponsors(dataURL, "silver"); + updatePremiumSponsors(dataURL, "bronze"); +} diff --git a/docs/license.md b/docs/license.md index cdacdfef..e81c0edf 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,10 @@ +--- +hide: +- feedback +--- + +# License + ``` --8<-- "LICENSE" ``` diff --git a/docs/recipes.md b/docs/recipes.md index 5f006057..cb2d9eb1 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -17,15 +17,15 @@ Let say you have a project called `project`. This project has a lot of source files, or modules, which live in the `src` folder: -``` -📁 repo -└─╴📁 src - └─╴📁 project - ├─╴📄 lorem - ├─╴📄 ipsum - ├─╴📄 dolor - ├─╴📄 sit - └─╴📄 amet +```tree +repo/ + src/ + project/ + lorem + ipsum + dolor + sit + amet ``` Without an automatic process, you will have to manually @@ -49,63 +49,111 @@ and configure it like so: ```yaml title="mkdocs.yml" plugins: -- search # (1) +- search # (1)! - gen-files: scripts: - - docs/gen_ref_pages.py # (2) -- mkdocstrings: - watch: - - src/project # (3) + - scripts/gen_ref_pages.py # (2)! +- mkdocstrings ``` 1. Don't forget to load the `search` plugin when redefining the `plugins` item. 2. The magic happens here, see below how it works. -3. Useful for the live-reload feature of `mkdocs serve`. mkdocs-gen-files is able to run Python scripts at build time. -The Python script that we will execute lives in the docs folder, +The Python script that we will execute lives in a scripts folder, and is named `gen_ref_pages.py`, like "generate code reference pages". -```python title="docs/gen_ref_pages.py" +```tree +repo/ + docs/ + index.md + scripts/ + gen_ref_pages.py + src/ + project/ + mkdocs.yml +``` + +```python title="scripts/gen_ref_pages.py" """Generate the code reference pages.""" from pathlib import Path import mkdocs_gen_files -for path in sorted(Path("src").rglob("*.py")): # (1) - module_path = path.relative_to("src").with_suffix("") # (2) - doc_path = path.relative_to("src").with_suffix(".md") # (3) - full_doc_path = Path("reference", doc_path) # (4) +root = Path(__file__).parent.parent +src = root / "src" # (1)! - parts = list(module_path.parts) +for path in sorted(src.rglob("*.py")): # (2)! + module_path = path.relative_to(src).with_suffix("") # (3)! + doc_path = path.relative_to(src).with_suffix(".md") # (4)! + full_doc_path = Path("reference", doc_path) # (5)! + + parts = tuple(module_path.parts) - if parts[-1] == "__init__": # (5) + if parts[-1] == "__init__": # (6)! parts = parts[:-1] elif parts[-1] == "__main__": continue - with mkdocs_gen_files.open(full_doc_path, "w") as fd: # (6) - identifier = ".".join(parts) # (7) - print("::: " + identifier, file=fd) # (8) + with mkdocs_gen_files.open(full_doc_path, "w") as fd: # (7)! + identifier = ".".join(parts) # (8)! + print("::: " + identifier, file=fd) # (9)! - mkdocs_gen_files.set_edit_path(full_doc_path, path) # (9) + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) # (10)! ``` -1. Here we recursively list all `.py` files, but you can adapt the code to list +1. It's important to build a path relative to the script itself, + to make it possible to build the docs with MkDocs' + [`-f` option](https://www.mkdocs.org/user-guide/cli/#mkdocs-build). +2. Here we recursively list all `.py` files, but you can adapt the code to list files with other extensions of course, supporting other languages than Python. -2. The module path will look like `project/lorem`. +3. The module path will look like `project/lorem`. It will be used to build the *mkdocstrings* autodoc identifier. -3. This is the relative path to the Markdown page. -4. This is the absolute path to the Markdown page. Here we put all reference pages - into a `reference` folder. -5. This part is only relevant for Python modules. We skip `__main__` modules and +4. This is the partial path of the Markdown page for the module. +5. This is the full path of the Markdown page within the docs. + Here we put all reference pages into a `reference` folder. +6. This part is only relevant for Python modules. We skip `__main__` modules and remove `__init__` from the module parts as it's implicit during imports. -6. Magic! Add the file to MkDocs pages, without actually writing it in the docs folder. -7. Build the autodoc identifier. Here we document Python modules, so the identifier +7. Magic! Add the file to MkDocs pages, without actually writing it in the docs folder. +8. Build the autodoc identifier. Here we document Python modules, so the identifier is a dot-separated path, like `project.lorem`. -8. Actually write to the magic file. -9. We can even set the `edit_uri` on the pages. +9. Actually write to the magic file. +10. We can even set the `edit_uri` on the pages. + +> NOTE: +> It is important to look out for correct edit page behaviour when using generated pages. +> For example, if we have `edit_uri` set to `blob/master/docs/` and the following +> file structure: +> +> ```tree +> repo/ +> mkdocs.yml +> docs/ +> index.md +> scripts/ +> gen_ref_pages.py +> src/ +> project/ +> lorem.py +> ipsum.py +> dolor.py +> sit.py +> amet.py +> ``` +> +> Then we will have to change our `set_edit_path` call to: +> +> ```python +> mkdocs_gen_files.set_edit_path(full_doc_path, Path("../") / path) # (1)! +> ``` +> +> 1. Path can be used to traverse the structure in any way you may need, but +> remember to use relative paths! +> +> ...so that it correctly sets the edit path of (for example) `lorem.py` to +> `/blob/master/src/project/lorem.py` instead of +> `/blob/master/docs/src/project/lorem.py`. With this script, a `reference` folder is automatically created each time we build our docs. This folder contains a Markdown page @@ -148,17 +196,15 @@ plugins: - search - gen-files: scripts: - - docs/gen_ref_pages.py + - scripts/gen_ref_pages.py - literate-nav: nav_file: SUMMARY.md -- mkdocstrings: - watch: - - src/project +- mkdocstrings ``` Then, the previous script is updated like so: -```python title="docs/gen_ref_pages.py" hl_lines="7 21 29 30" +```python title="scripts/gen_ref_pages.py" hl_lines="7 24 32 33" """Generate the code reference pages and navigation.""" from pathlib import Path @@ -167,9 +213,12 @@ import mkdocs_gen_files nav = mkdocs_gen_files.Nav() -for path in sorted(Path("src").rglob("*.py")): - module_path = path.relative_to("src").with_suffix("") - doc_path = path.relative_to("src").with_suffix(".md") +root = Path(__file__).parent.parent +src = root / "src" + +for path in sorted(src.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") full_doc_path = Path("reference", doc_path) parts = tuple(module_path.parts) @@ -179,16 +228,16 @@ for path in sorted(Path("src").rglob("*.py")): elif parts[-1] == "__main__": continue - nav[parts] = doc_path.as_posix() # (1) + nav[parts] = doc_path.as_posix() # (1)! with mkdocs_gen_files.open(full_doc_path, "w") as fd: ident = ".".join(parts) fd.write(f"::: {ident}") - mkdocs_gen_files.set_edit_path(full_doc_path, path) + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) -with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: # (2) - nav_file.writelines(nav.build_literate_nav()) # (3) +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: # (2)! + nav_file.writelines(nav.build_literate_nav()) # (3)! ``` 1. Progressively build the navigation object. @@ -202,7 +251,7 @@ and replace it with a single line! nav: # rest of the navigation... # defer to gen-files + literate-nav -- Code Reference: reference/ # (1) +- Code Reference: reference/ # (1)! # rest of the navigation... ``` @@ -220,7 +269,7 @@ will expand or collapse when you click on them, revealing `__init__` modules under them (or equivalent modules in other languages, if relevant). Since we are documenting a public API, and given users -never explicitely import `__init__` modules, it would be nice +never explicitly import `__init__` modules, it would be nice if we could get rid of them and instead render their documentation inside the section itself. @@ -229,7 +278,7 @@ Well, this is possible thanks to a third plugin: Update the script like this: -```python title="docs/gen_ref_pages.py" hl_lines="18 19" +```python title="scripts/gen_ref_pages.py" hl_lines="21 22" """Generate the code reference pages and navigation.""" from pathlib import Path @@ -238,9 +287,12 @@ import mkdocs_gen_files nav = mkdocs_gen_files.Nav() -for path in sorted(Path("src").rglob("*.py")): - module_path = path.relative_to("src").with_suffix("") - doc_path = path.relative_to("src").with_suffix(".md") +root = Path(__file__).parent.parent +src = root / "src" + +for path in sorted(src.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") full_doc_path = Path("reference", doc_path) parts = tuple(module_path.parts) @@ -258,7 +310,7 @@ for path in sorted(Path("src").rglob("*.py")): ident = ".".join(parts) fd.write(f"::: {ident}") - mkdocs_gen_files.set_edit_path(full_doc_path, path) + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) @@ -271,13 +323,11 @@ plugins: - search - gen-files: scripts: - - docs/gen_ref_pages.py + - scripts/gen_ref_pages.py - literate-nav: nav_file: SUMMARY.md - section-index -- mkdocstrings: - watch: - - src/project +- mkdocstrings ``` With this, `__init__` modules will be documented and bound to the sections @@ -293,6 +343,7 @@ and add global CSS rules to your site using MkDocs `extra_css` option: ```pycon >>> for word in ("Hello", "mkdocstrings!"): ... print(word, end=" ") +... Hello mkdocstrings! ``` ```` diff --git a/docs/schema.json b/docs/schema.json new file mode 100644 index 00000000..a74dabf3 --- /dev/null +++ b/docs/schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Automatic documentation from sources, for MkDocs.", + "oneOf": [ + { + "markdownDescription": "https://mkdocstrings.github.io/", + "enum": [ + "mkdocstrings" + ] + }, + { + "type": "object", + "properties": { + "mkdocstrings": { + "markdownDescription": "https://mkdocstrings.github.io/", + "type": "object", + "properties": { + "custom_templates": { + "title": "The path to a directory containing custom templates. The path is relative to the current working directory.", + "markdownDescription": "https://mkdocstrings.github.io/theming/", + "type": "string", + "default": null, + "format": "path" + }, + "default_handler": { + "title": "The handler used by default when no handler is specified in autodoc instructions.", + "markdownDescription": "https://mkdocstrings.github.io/usage/#global-options", + "type": "string", + "default": "python" + }, + "enable_inventory": { + "title": "Whether to enable inventory file generation.", + "markdownDescription": "https://mkdocstrings.github.io/usage/#cross-references-to-other-projects-inventories", + "type": "boolean", + "default": null + }, + "handlers": { + "title": "The handlers global configuration.", + "markdownDescription": "https://mkdocstrings.github.io/handlers/overview/", + "type": "object", + "default": null, + "items": { + "oneOf": [ + { + "$ref": "https://raw.githubusercontent.com/mkdocstrings/python/master/docs/schema.json" + } + ] + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} \ No newline at end of file diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3f2243fd..bc1da01b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -21,6 +21,10 @@ markdown_extensions: - pymdownx.superfences ``` +For code blocks in docstrings, make sure to escape newlines (`\n` -> `\\n`), +or prefix the entire docstring with 'r' to make it a raw-docstring: `r"""`. +Indeed, docstrings are still strings and therefore subject to how Python parses strings. + ## Footnotes are duplicated or overridden Before version 0.14, footnotes could be duplicated over a page. @@ -48,7 +52,7 @@ when it should be `[Section][pytkdocs.parsers.docstrings.Section]`. ## Some objects are not rendered (they do not appear in the generated docs) - Make sure the configuration options of the handler are correct. - Check the documentation for [Handlers](handlers/overview.md) to see the available options for each handler. + Check the documentation for [Handlers](usage/handlers.md) to see the available options for each handler. - Also make sure your documentation in your source code is formatted correctly. For Python code, check the [supported docstring styles](https://mkdocstrings.github.io/python/usage/#supported-docstrings-styles) page. - Re-run the Mkdocs command with `-v`, and carefully read any traceback. @@ -116,13 +120,13 @@ use this workaround. Please open an ticket on the [bugtracker][bugtracker] with a detailed explanation and screenshots of the bad-looking parts. -Note that you can always [customize the look](theming.md) of *mkdocstrings* blocks -- through both HTML and CSS. +Note that you can always [customize the look](usage/theming.md) of *mkdocstrings* blocks -- through both HTML and CSS. ## Warning: could not find cross-reference target TIP: **New in version 0.15.** Cross-linking used to include any Markdown heading, but now it's only for *mkdocstrings* identifiers by default. -See [Cross-references to any Markdown heading](usage.md#cross-references-to-any-markdown-heading) to opt back in. +See [Cross-references to any Markdown heading](usage/index.md#cross-references-to-any-markdown-heading) to opt back in. Make sure the referenced object is properly rendered: verify your configuration options. @@ -151,7 +155,7 @@ Example: ```python def math_function(x, y): r""" - Look at these formulas: + Look at these formulas: ```math f(x) = \int_{-\infty}^\infty @@ -170,6 +174,7 @@ So instead of: ```python import enum + class MyEnum(enum.Enum): v1 = 1 #: The first choice. v2 = 2 #: The second choice. @@ -180,13 +185,15 @@ You can use: ```python import enum + class MyEnum(enum.Enum): """My enum. - + Attributes: v1: The first choice. v2: The second choice. """ + v1 = 1 v2 = 2 ``` @@ -196,6 +203,7 @@ Or: ```python import enum + class MyEnum(enum.Enum): v1 = 1 """The first choice.""" @@ -211,8 +219,9 @@ Use [`functools.wraps()`](https://docs.python.org/3.6/library/functools.html#fun ```python from functools import wraps + def my_decorator(function): - """The decorator docs.""" + """The decorator docs.""" @wraps(function) def wrapped_function(*args, **kwargs): @@ -222,6 +231,7 @@ def my_decorator(function): return wrapped_function + @my_decorator def my_function(*args, **kwargs): """The function docs.""" diff --git a/docs/handlers/overview.md b/docs/usage/handlers.md similarity index 63% rename from docs/handlers/overview.md rename to docs/usage/handlers.md index fd3e87bc..37da4b67 100644 --- a/docs/handlers/overview.md +++ b/docs/usage/handlers.md @@ -4,9 +4,13 @@ A handler is what makes it possible to collect and render documentation for a pa ## Available handlers -- Crystal -- Python (Legacy) -- Python (Experimental) +- [C](https://mkdocstrings.github.io/c/){ .external } [:octicons-heart-fill-24:{ .heart .pulse title="Sponsors only" }](../insiders/index.md) +- [Crystal](https://mkdocstrings.github.io/crystal/){ .external } +- [Python](https://mkdocstrings.github.io/python/){ .external } +- [Python (Legacy)](https://mkdocstrings.github.io/python-legacy/){ .external } +- [Shell](https://mkdocstrings.github.io/shell/){ .external } +- [TypeScript](https://mkdocstrings.github.io/typescript/){ .external } [:octicons-heart-fill-24:{ .heart .pulse title="Sponsors only" }](../insiders/index.md) +- [VBA](https://pypi.org/project/mkdocstrings-vba/){ .external } ## About the Python handlers @@ -14,11 +18,8 @@ Since version 0.18, a new, experimental Python handler is available. It is based on [Griffe](https://github.com/mkdocstrings/griffe), which is an improved version of [pytkdocs](https://github.com/mkdocstrings/pytkdocs). -Note that the experimental handler does not yet support third-party libraries -like Django, Marshmallow, Pydantic, etc. -It is also not completely ready to handle dynamically built objects, -like classes built with a call to `type(...)`. -For most other cases, the experimental handler will work just fine. +Note that the experimental handler does not yet support all third-party libraries +that the legacy handler supported. If you want to keep using the legacy handler as long as possible, you can depend on `mkdocstrings-python-legacy` directly, @@ -51,18 +52,13 @@ dependencies = [ ] ``` -#### Handler options - -- `setup_commands` is not yet implemented. In most cases, you won't need it, - since by default the new handler does not execute the code. - #### Selection options WARNING: Since *mkdocstrings* 0.19, the YAML `selection` key is merged into the `options` key. - [x] `filters` is implemented, and used as before. - [x] `members` is implemented, and used as before. -- [ ] `inherited_members` is not yet implemented. +- [x] `inherited_members` is implemented. - [x] `docstring_style` is implemented, and used as before, except for the `restructured-text` style which is renamed `sphinx`. Numpy-style is now built-in, so you can stop depending on `pytkdocs[numpy-style]` @@ -83,14 +79,14 @@ WARNING: Since *mkdocstrings* 0.19, the YAML `rendering` key is merged into the Every previous option is supported. Additional options are available: -- `separate_signature`: Render the signature (or attribute value) in a code block below the heading, +- [x] `separate_signature`: Render the signature (or attribute value) in a code block below the heading, instead as inline code. Useful for long signatures. If Black is installed, the signature is formatted. Default: `False`. -- `line_length`: The maximum line length to use when formatting signatures. Default: `60`. -- `show_submodules`: Whether to render submodules of a module when iterating on children. +- [x] `line_length`: The maximum line length to use when formatting signatures. Default: `60`. +- [x] `show_submodules`: Whether to render submodules of a module when iterating on children. Default: `False`. -- `docstring_section_style`: The style to use to render docstring sections such as attributes, - parameters, etc. Available styles: `table` (default), `list` and `spacy`. The SpaCy style +- [x] `docstring_section_style`: The style to use to render docstring sections such as attributes, + parameters, etc. Available styles: `"table"` (default), `"list"` and `"spacy"`. The SpaCy style is a poor implementation of their [table style](https://spacy.io/api/doc/#init). We are open to improvements through PRs! @@ -99,34 +95,8 @@ See [all the handler's options](https://mkdocstrings.github.io/python/usage/). #### Templates Templates are mostly the same as before, but the file layout has changed, -as well as some file names. Here is the new tree: - -``` -📁 theme/ -├── 📄 attribute.html -├── 📄 children.html -├── 📄 class.html -├── 📁 docstring/ -│   ├── 📄 admonition.html -│   ├── 📄 attributes.html -│   ├── 📄 examples.html -│   ├── 📄 other_parameters.html -│   ├── 📄 parameters.html -│   ├── 📄 raises.html -│   ├── 📄 receives.html -│   ├── 📄 returns.html -│   ├── 📄 warns.html -│   └── 📄 yields.html -├── 📄 docstring.html -├── 📄 expression.html -├── 📄 function.html -├── 📄 labels.html -├── 📄 module.html -└── 📄 signature.html -``` - -See them [in the handler repository](https://github.com/mkdocstrings/python/tree/8fc8ea5b112627958968823ef500cfa46b63613e/src/mkdocstrings_handlers/python/templates/material). See the documentation about the Python handler templates: -https://mkdocstrings.github.io/python/customization/#templates. +as well as some file names. +See [the documentation about the Python handler templates](https://mkdocstrings.github.io/python/usage/customization/#templates). ## Custom handlers @@ -178,8 +148,12 @@ This function takes the following parameters: These arguments are all passed as keyword arguments, so you can ignore them by adding `**kwargs` or similar to your signature. You can also accept -additional parameters: the handler's global-only options will be passed -to this function when instantiating your handler. +additional parameters: the handler's global-only options and/or the root +config options. This gives flexibility and access to the mkdocs config, mkdocstring +config etc.. You should never modify the root config but can use it to get +information about the MkDocs instance such as where the current `site_dir` lives. +See the [Mkdocs Configuration](https://www.mkdocs.org/user-guide/configuration/) for +more info about what is accessible from it. Check out how the [Python handler](https://github.com/mkdocstrings/python/blob/master/src/mkdocstrings_handlers/python) @@ -188,7 +162,7 @@ is written for inspiration. ### Templates Your handler's implementation should normally be backed by templates, which go -to the directory `mkdocstrings_handlers/custom_handler/templates/some_theme`. +to the directory `mkdocstrings_handlers/custom_handler/templates/some_theme` (`custom_handler` here should be replaced with the actual name of your handler, and `some_theme` should be the name of an actual MkDocs theme that you support, e.g. `material`). @@ -209,7 +183,7 @@ If your theme's HTML requires CSS to go along with it, put it into a file named `mkdocstrings_handlers/custom_handler/templates/some_theme/style.css`, then this will be included into the final site automatically if this handler is ever used. Alternatively, you can put the CSS as a string into the `extra_css` variable of -your renderer. +your handler. Finally, it's possible to entirely omit templates, and tell *mkdocstrings* to use the templates of another handler. In you handler, override the @@ -221,7 +195,7 @@ from mkdocstrings.handlers.base import BaseHandler class CobraHandler(BaseHandler): - def get_templates_dir(self, handler: str) -> Path: + def get_templates_dir(self, handler: str | None = None) -> Path: # use the python handler templates # (it assumes the python handler is installed) return super().get_templates_dir("python") @@ -254,3 +228,79 @@ plugins: some_config_option: "b" other_config_option: 1 ``` + +## Handler extensions + +*mkdocstrings* provides a way for third-party packages +to extend or alter the behavior of handlers. +For example, an extension of the Python handler +could add specific support for another Python library. + +NOTE: This feature is intended for developers. +If you are a user and want to customize how objects are rendered, +see [Theming / Customization](theming.md#customization). + +Such extensions can register additional template folders +that will be used when rendering collected data. +Extensions are responsible for synchronizing +with the handler itself so that it uses the additional templates. + +An extension is a Python package +that defines an entry-point for a specific handler: + +```toml title="pyproject.toml" +[project.entry-points."mkdocstrings.python.templates"] # (1)! +extension-name = "extension_package:get_templates_path" # (2)! +``` + +1. Replace `python` by the name of the handler you want to add templates to. +1. Replace `extension-name` by any name you want, + and replace `extension_package:get_templates_path` + by the actual module path and function name in your package. + +This entry-point assumes that the extension provides +a `get_templates_path` function directly under the `extension_package` package: + +```tree +pyproject.toml +extension_package/ + __init__.py + templates/ +``` + +```python title="extension_package/__init__.py" +from pathlib import Path + + +def get_templates_path() -> Path: + return Path(__file__).parent / "templates" +``` + +This function doesn't accept any argument +and returns the path ([`pathlib.Path`][] or [`str`][]) +to a directory containing templates. +The directory must contain one subfolder +for each supported theme, even if empty +(see "fallback theme" in [custom handlers templates](#templates_1)). +For example: + +```tree +pyproject.toml +extension_package/ + __init__.py + templates/ + material/ + readthedocs/ + mkdocs/ +``` + +*mkdocstrings* will add the folders corresponding to the user-selected theme, +and to the handler's defined fallback theme, as usual. + +The names of the extension templates +must not overlap with the handler's original templates. + +The extension is then responsible, in collaboration with its target handler, +for mutating the collected data in order to instruct the handler +to use one of the extension template when rendering particular objects. +See each handler's docs to see if they support extensions, and how. diff --git a/docs/usage.md b/docs/usage/index.md similarity index 85% rename from docs/usage.md rename to docs/usage/index.md index 02fc9091..77129362 100644 --- a/docs/usage.md +++ b/docs/usage/index.md @@ -31,8 +31,8 @@ The YAML block is optional, and contains some configuration options: `default_handler` key, or `"python"`. - `options`: a dictionary of options passed to the handler's methods responsible both for collecting and rendering the documentation. These options can be defined - globally (in `mkdocs.yml`, see [Global options](#global-options)), - locally (as described here), or both. + globally (in `mkdocs.yml`, see [Global options](#global-options)), + locally (as described here), or both. !!! example "Example with the Python handler" === "docs/my_page.md" @@ -106,21 +106,22 @@ The above is equivalent to: *mkdocstrings* accepts a few top-level configuration options in `mkdocs.yml`: -- `default_handler`: the handler that is used by default when no handler is specified. -- `custom_templates`: the path to a directory containing custom templates. - The path is relative to the docs directory. +- `default_handler`: The handler that is used by default when no handler is specified. +- `custom_templates`: The path to a directory containing custom templates. + The path is relative to the MkDocs configuration file. See [Theming](theming.md). -- `handlers`: the handlers global configuration. -- `enable_inventory`: whether to enable inventory file generation. +- `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) -- `watch` **(deprecated)**: a list of directories to watch while serving the documentation. - See [Watch directories](#watch-directories). **Deprecated in favor of the now built-in - [`watch` feature of MkDocs](https://www.mkdocs.org/user-guide/configuration/#watch). +- `enabled` **(New in version 0.20)**: Whether to enable the plugin. Defaults to `true`. + Can be used to reduce build times when doing local development. + Especially useful when used with environment variables (see example below). !!! example ```yaml title="mkdocs.yml" plugins: - mkdocstrings: + enabled: !ENV [ENABLE_MKDOCSTRINGS, true] custom_templates: templates default_handler: python handlers: @@ -138,7 +139,7 @@ The above is equivalent to: ``` Some handlers accept additional global configuration. -Check the documentation for your handler of interest in [Handlers](handlers/overview.md). +Check the documentation for your handler of interest in [Handlers](handlers.md). ## Cross-references @@ -318,11 +319,29 @@ plugins: Absolute URLs to cross-referenced items will then be based on `https://docs.example.com/version/` instead of `https://cdn.example.com/version/`. +If you need authentication to access the inventory file, you can provide the credentials in the URL, either as `username:password`: + +```yaml +- url: https://username:password@private.example.com/version/objects.inv +``` + +...or with token authentication: + +```yaml +- url: https://token123@private.example.com/version/objects.inv +``` + +The credentials can also be specified using environment variables in the form `${ENV_VAR}`: + +```yaml +- url: https://${USERNAME}:${PASSWORD}@private.example.com/version/objects.inv +``` + 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 +To explicitly enable or disable the generation of the inventory file, use the global `enable_inventory` option: ```yaml @@ -330,30 +349,3 @@ plugins: - mkdocstrings: enable_inventory: false ``` - -## Watch directories - -DANGER: **Deprecated since version 0.19.** -Instead, use the built-in [`watch` feature of MkDocs](https://www.mkdocs.org/user-guide/configuration/#watch). - -You can add directories to watch with the `watch` key. -It accepts a list of paths. - -```yaml title="mkdocs.yml" -plugins: - - mkdocstrings: - watch: - - src/my_package_1 - - src/my_package_2 -``` - -When serving your documentation -and a change occur in one of the listed path, -MkDocs will rebuild the site and reload the current page. - -NOTE: **The `watch` feature doesn't have special effects.** -Adding directories to the `watch` list doesn't have any other effect than watching for changes. -For example, it will not tell the Python handler to look for packages in these paths -(the paths are not added to the `PYTHONPATH` variable). -If you want to tell Python where to look for packages and modules, -see [Python Handler: Finding modules](https://mkdocstrings.github.io/python/usage/#finding-modules). diff --git a/docs/theming.md b/docs/usage/theming.md similarity index 89% rename from docs/theming.md rename to docs/usage/theming.md index 73b7e0b3..09ee92fd 100644 --- a/docs/theming.md +++ b/docs/usage/theming.md @@ -17,9 +17,9 @@ so you can tweak the look and feel with extra CSS rules. ### Templates -To use custom templates and override the theme ones, -specify the relative path to your templates directory -with the `custom_templates` global configuration option: +To use custom templates and override the theme ones, specify the relative path from your +configuration file to your templates directory with the `custom_templates` global +configuration option: ```yaml title="mkdocs.yml" plugins: @@ -62,7 +62,7 @@ to modify small part of the templates without copy-pasting the whole files. See the documentation about templates for: - the Crystal handler: https://mkdocstrings.github.io/crystal/styling.html -- the Python handler: https://mkdocstrings.github.io/python/customization/#templates +- the Python handler: https://mkdocstrings.github.io/python/usage/customization/#templates #### Debugging @@ -82,7 +82,7 @@ Since each handler provides its own set of templates, with their own CSS classes we cannot list them all here. See the documentation about CSS classes for: - the Crystal handler: https://mkdocstrings.github.io/crystal/styling.html#custom-styles -- the Python handler: https://mkdocstrings.github.io/python/customization/#css-classes +- the Python handler: https://mkdocstrings.github.io/python/usage/customization/#css-classes ### Syntax highlighting diff --git a/duties.py b/duties.py index 04511dd3..0f283217 100644 --- a/duties.py +++ b/duties.py @@ -1,344 +1,236 @@ """Development tasks.""" -import importlib +from __future__ import annotations + import os -import re import sys -import tempfile -from contextlib import suppress -from io import StringIO +from contextlib import contextmanager +from importlib.metadata import version as pkgversion from pathlib import Path -from typing import List, Optional, Pattern -from urllib.request import urlopen +from typing import TYPE_CHECKING + +from duty import duty, tools + +if TYPE_CHECKING: + from collections.abc import Iterator + + from duty.context import Context -from duty import duty -PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "docs")) +PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) 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", ""} WINDOWS = os.name == "nt" PTY = not WINDOWS and not CI +MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" -def _latest(lines: List[str], regex: Pattern) -> Optional[str]: - for line in lines: - match = regex.search(line) - if match: - return match.groupdict()["version"] - return None - - -def _unreleased(versions, last_release): - for index, version in enumerate(versions): - if version.tag == last_release: - return versions[:index] - return versions - +def pyprefix(title: str) -> str: # noqa: D103 + if MULTIRUN: + prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" + return f"{prefix:14}{title}" + return title -def update_changelog( - inplace_file: str, - marker: str, - version_regex: str, - template_url: str, -) -> None: - """Update the given changelog file in place. - Arguments: - inplace_file: The file to update in-place. - marker: The line after which to insert new contents. - version_regex: A regular expression to find currently documented versions in the file. - template_url: The URL to the Jinja template used to render contents. - """ - from git_changelog.build import Changelog - from git_changelog.commit import AngularStyle - from jinja2.sandbox import SandboxedEnvironment - - AngularStyle.DEFAULT_RENDER.insert(0, AngularStyle.TYPES["build"]) - env = SandboxedEnvironment(autoescape=False) - template_text = urlopen(template_url).read().decode("utf8") # noqa: S310 - template = env.from_string(template_text) - changelog = Changelog(".", style="angular") - - if len(changelog.versions_list) == 1: - last_version = changelog.versions_list[0] - if last_version.planned_tag is None: - planned_tag = "0.1.0" - last_version.tag = planned_tag - last_version.url += planned_tag - last_version.compare_url = last_version.compare_url.replace("HEAD", planned_tag) - - 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) - rendered = template.render(changelog=changelog, inplace=True) - lines[lines.index(marker)] = rendered - - with open(inplace_file, "w") as changelog_file: # noqa: WPS440 - changelog_file.write("\n".join(lines).rstrip("\n") + "\n") +@contextmanager +def material_insiders() -> Iterator[bool]: # noqa: D103 + if "+insiders" in pkgversion("mkdocs-material"): + os.environ["MATERIAL_INSIDERS"] = "true" + try: + yield True + finally: + os.environ.pop("MATERIAL_INSIDERS") + else: + yield False @duty -def changelog(ctx): +def changelog(ctx: Context, bump: str = "") -> None: """Update the changelog in-place with latest commits. - Arguments: - ctx: The context instance (passed automatically). + Parameters: + bump: Bump option passed to git-changelog. """ - 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": template_url, - }, - title="Updating changelog", - pty=PTY, - ) + ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") -@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies"]) -def check(ctx): - """Check it all! - - Arguments: - ctx: The context instance (passed automatically). - """ +@duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) +def check(ctx: Context) -> None: + """Check it all!""" @duty -def check_quality(ctx, files=PY_SRC): - """Check the code quality. - - Arguments: - ctx: The context instance (passed automatically). - files: The files to check. - """ - ctx.run(f"flake8 --config=config/flake8.ini {files}", title="Checking code quality", pty=PTY) - - -@duty -def check_dependencies(ctx): - """Check for vulnerabilities in dependencies. - - Arguments: - ctx: The context instance (passed automatically). - """ - # undo possible patching - # see https://github.com/pyupio/safety/issues/348 - for module in sys.modules: # noqa: WPS528 - if module.startswith("safety.") or module == "safety": - del sys.modules[module] # noqa: WPS420 - - importlib.invalidate_caches() - - # reload original, unpatched safety - from safety.formatter import report - from safety.safety import check as safety_check - from safety.util import read_requirements - - # retrieve the list of dependencies - requirements = ctx.run( - ["pdm", "export", "-f", "requirements", "--without-hashes"], - title="Exporting dependencies as requirements", - allow_overrides=False, +def check_quality(ctx: Context) -> None: + """Check the code quality.""" + ctx.run( + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + title=pyprefix("Checking code quality"), ) - # check using safety as a library - def safety(): # noqa: WPS430 - packages = list(read_requirements(StringIO(requirements))) - vulns = safety_check(packages=packages, ignore_ids="", key="", db_mirror="", cached=False, proxy={}) - output_report = report(vulns=vulns, full=True, checked_packages=len(packages)) - if vulns: - print(output_report) - - ctx.run(safety, title="Checking dependencies") - @duty -def check_docs(ctx): - """Check if the documentation builds correctly. - - Arguments: - ctx: The context instance (passed automatically). - """ +def check_docs(ctx: Context) -> None: + """Check if the documentation builds correctly.""" 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) - + with material_insiders(): + ctx.run( + tools.mkdocs.build(strict=True, verbose=True), + title=pyprefix("Building documentation"), + ) -@duty # noqa: WPS231 -def check_types(ctx): # noqa: WPS231 - """Check that the code is correctly typed. - Arguments: - ctx: The context instance (passed automatically). - """ - # NOTE: the following code works around this issue: - # https://github.com/python/mypy/issues/10633 - - # compute packages directory path - py = f"{sys.version_info.major}.{sys.version_info.minor}" - pkgs_dir = Path("__pypackages__", py, "lib").resolve() - - # build the list of available packages - packages = {} - for package in pkgs_dir.glob("*"): - if package.suffix not in {".dist-info", ".pth"} and package.name != "__pycache__": - packages[package.name] = package - - # handle .pth files - for pth in pkgs_dir.glob("*.pth"): - with suppress(OSError): - for package in Path(pth.read_text().splitlines()[0]).glob("*"): # noqa: WPS440 - if package.suffix != ".dist-info": - packages[package.name] = package - - # create a temporary directory to assign to MYPYPATH - with tempfile.TemporaryDirectory() as tmpdir: - - # symlink the stubs - ignore = set() - for stubs in (path for name, path in packages.items() if name.endswith("-stubs")): # noqa: WPS335 - Path(tmpdir, stubs.name).symlink_to(stubs, target_is_directory=True) - # try to symlink the corresponding package - # see https://www.python.org/dev/peps/pep-0561/#stub-only-packages - pkg_name = stubs.name.replace("-stubs", "") - if pkg_name in packages: - ignore.add(pkg_name) - Path(tmpdir, pkg_name).symlink_to(packages[pkg_name], target_is_directory=True) - - # create temporary mypy config to ignore stubbed packages - newconfig = Path("config", "mypy.ini").read_text() - newconfig += "\n" + "\n\n".join(f"[mypy-{pkg}.*]\nignore_errors=true" for pkg in ignore) - tmpconfig = Path(tmpdir, "mypy.ini") - tmpconfig.write_text(newconfig) - - # set MYPYPATH and run mypy - os.environ["MYPYPATH"] = tmpdir - ctx.run(f"mypy --config-file {tmpconfig} {PY_SRC}", title="Type-checking", pty=PTY) - - -@duty(silent=True) -def clean(ctx): - """Delete temporary files. - - Arguments: - ctx: The context instance (passed automatically). - """ - 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") - ctx.run("find . -name '*.rej' -delete") +@duty +def check_types(ctx: Context) -> None: + """Check that the code is correctly typed.""" + os.environ["MYPYPATH"] = "src" + ctx.run( + tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), + title=pyprefix("Type-checking"), + ) @duty -def docs(ctx): - """Build the documentation locally. - - Arguments: - ctx: The context instance (passed automatically). - """ - ctx.run("mkdocs build", title="Building documentation") +def check_api(ctx: Context, *cli_args: str) -> None: + """Check for API breaking changes.""" + ctx.run( + tools.griffe.check("mkdocstrings", search=["src"], color=True).add_args(*cli_args), + title="Checking for API breaking changes", + nofail=True, + ) @duty -def docs_serve(ctx, host="127.0.0.1", port=8000): +def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: """Serve the documentation (localhost:8000). - Arguments: - ctx: The context instance (passed automatically). + Parameters: host: The host to serve the docs from. port: The port to serve the docs on. """ - ctx.run(f"mkdocs serve -a {host}:{port}", title="Serving documentation", capture=False) + with material_insiders(): + ctx.run( + tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), + title="Serving documentation", + capture=False, + ) @duty -def docs_deploy(ctx): - """Deploy the documentation on GitHub pages. +def docs_deploy(ctx: Context, *, force: bool = False) -> None: + """Deploy the documentation to GitHub pages. - Arguments: - ctx: The context instance (passed automatically). + Parameters: + force: Whether to force deployment, even from non-Insiders version. """ - 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") + os.environ["DEPLOY"] = "true" + with material_insiders() as insiders: + if not insiders: + ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") + origin = ctx.run("git config --get remote.origin.url", silent=True, allow_overrides=False) + if "pawamoy-insiders/mkdocstrings" in origin: + ctx.run( + "git remote add org-pages git@github.com:mkdocstrings/mkdocstrings.github.io", + silent=True, + nofail=True, + allow_overrides=False, + ) + ctx.run( + tools.mkdocs.gh_deploy(remote_name="upstream", force=True), + title="Deploying documentation", + ) + elif force: + ctx.run( + tools.mkdocs.gh_deploy(force=True), + title="Deploying documentation", + ) + else: + ctx.run( + lambda: False, + title="Not deploying docs from public repository (do that from insiders instead!)", + nofail=True, + ) @duty -def format(ctx): - """Run formatting tools on the code. +def format(ctx: Context) -> None: + """Run formatting tools on the code.""" + ctx.run( + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + title="Auto-fixing code", + ) + ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") - Arguments: - ctx: The context instance (passed automatically). - """ + +@duty +def build(ctx: Context) -> None: + """Build source and wheel distributions.""" ctx.run( - f"autoflake -ir --exclude tests/fixtures --remove-all-unused-imports {PY_SRC}", - title="Removing unused imports", + tools.build(), + title="Building source and wheel distributions", pty=PTY, ) - ctx.run(f"isort {PY_SRC}", title="Ordering imports", pty=PTY) - ctx.run(f"black {PY_SRC}", title="Formatting code", pty=PTY) @duty -def release(ctx, version): +def publish(ctx: Context) -> None: + """Publish source and wheel distributions to PyPI.""" + if not Path("dist").exists(): + ctx.run("false", title="No distribution files found") + dists = [str(dist) for dist in Path("dist").iterdir()] + ctx.run( + tools.twine.upload(*dists, skip_existing=True), + title="Publishing source and wheel distributions to PyPI", + pty=PTY, + ) + + +@duty(post=["build", "publish", "docs-deploy"]) +def release(ctx: Context, version: str = "") -> None: """Release a new Python package. - Arguments: - ctx: The context instance (passed automatically). + Parameters: version: The new version number to use. """ + origin = ctx.run("git config --get remote.origin.url", silent=True) + if "pawamoy-insiders/mkdocstrings" in origin: + ctx.run( + lambda: False, + title="Not releasing from insiders repository (do that from public repo instead!)", + ) + if not (version := (version or input("> Version to release: ")).strip()): + ctx.run("false", title="A version must be provided") 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("pdm build", title="Building dist/wheel", pty=PTY) - ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY) - docs_deploy.run() + ctx.run("git push", title="Pushing commits", pty=False) + ctx.run("git push --tags", title="Pushing tags", pty=False) -@duty(silent=True) -def coverage(ctx): - """Report coverage as text and HTML. - - 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(silent=True, aliases=["cov"]) +def coverage(ctx: Context) -> None: + """Report coverage as text and HTML.""" + ctx.run(tools.coverage.combine(), nofail=True) + ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) + ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) @duty -def test(ctx, match: str = ""): +def test(ctx: Context, *cli_args: str, match: str = "") -> None: """Run the test suite. - Arguments: - ctx: The context instance (passed automatically). + Parameters: match: A pytest expression to filter selected tests. """ 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", - pty=PTY, - nofail=py_version == "311", + tools.pytest( + "tests", + config_file="config/pytest.ini", + select=match, + color="yes", + ).add_args("-n", "auto", *cli_args), + title=pyprefix("Running tests"), ) diff --git a/mkdocs.yml b/mkdocs.yml index d9c86361..3b4fefb2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,10 +2,16 @@ site_name: "mkdocstrings" site_description: "Automatic documentation from sources, for MkDocs." 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" -watch: [src/mkdocstrings] +watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/mkdocstrings] +copyright: Copyright © 2019 Timothée Mazzucotelli +edit_uri: edit/main/docs/ + +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn nav: - Home: @@ -14,31 +20,59 @@ nav: - Credits: credits.md - License: license.md - Usage: - - usage.md - - Theming: theming.md - - Handlers: - - handlers/overview.md + - usage/index.md + - Theming: usage/theming.md + - Handlers: usage/handlers.md + - All handlers: + - C: https://mkdocstrings.github.io/c/ - Crystal: https://mkdocstrings.github.io/crystal/ + - Python: https://mkdocstrings.github.io/python/ - Python (Legacy): https://mkdocstrings.github.io/python-legacy/ - - Python (Experimental): https://mkdocstrings.github.io/python/ - - Recipes: recipes.md - - Troubleshooting: troubleshooting.md + - Shell: https://mkdocstrings.github.io/shell/ + - TypeScript: https://mkdocstrings.github.io/typescript/ + - VBA: https://pypi.org/project/mkdocstrings-vba + - Guides: + - Recipes: recipes.md + - Troubleshooting: troubleshooting.md # defer to gen-files + literate-nav -- Code Reference: reference/ +- API reference: + - mkdocstrings: reference/ - Development: - Contributing: contributing.md - Code of Conduct: code_of_conduct.md - Coverage report: coverage.md +- Insiders: + - insiders/index.md + - Getting started: + - Installation: insiders/installation.md + - Changelog: insiders/changelog.md - Author's website: https://pawamoy.github.io/ theme: name: material logo: logo.svg + custom_dir: docs/.overrides features: + - announce.dismiss + - content.action.edit + - content.action.view - content.code.annotate + - content.code.copy + - content.tooltips + - navigation.footer + - navigation.indexes + - navigation.sections - navigation.tabs + - navigation.tabs.sticky - navigation.top + - search.highlight + - search.suggest + - toc.follow palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode - media: "(prefers-color-scheme: light)" scheme: default primary: teal @@ -52,39 +86,53 @@ theme: accent: lime toggle: icon: material/weather-night - name: Switch to light mode + name: Switch to system preference extra_css: - css/style.css - css/material.css - css/mkdocstrings.css +- css/insiders.css + +extra_javascript: +- js/feedback.js markdown_extensions: +- attr_list - admonition - callouts +- footnotes - pymdownx.details -- pymdownx.emoji +- pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg +- pymdownx.highlight: + pygments_lang_class: true - pymdownx.magiclink - pymdownx.snippets: + base_path: [!relative $config_dir] check_paths: true - pymdownx.superfences - pymdownx.tabbed: alternate_style: true -- pymdownx.tasklist + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower +- pymdownx.tasklist: + custom_checkbox: true +- pymdownx.tilde - toc: permalink: "¤" plugins: - search +- markdown-exec - gen-files: scripts: - - docs/gen_credits.py - - docs/gen_ref_nav.py - - docs/gen_redirects.py + - scripts/gen_ref_nav.py - literate-nav: nav_file: SUMMARY.md - coverage -- section-index - mkdocstrings: handlers: python: @@ -92,16 +140,63 @@ plugins: - https://docs.python.org/3/objects.inv - https://installer.readthedocs.io/en/stable/objects.inv # demonstration purpose in the docs - https://mkdocstrings.github.io/autorefs/objects.inv + - https://www.mkdocs.org/objects.inv + - https://python-markdown.github.io/objects.inv + paths: [src] options: - docstring_style: google docstring_options: - ignore_init_summary: yes - merge_init_into_class: yes - show_submodules: no + ignore_init_summary: true + docstring_section_style: list + filters: ["!^_"] + heading_level: 1 + inherited_members: true + merge_init_into_class: true + parameter_headings: true + separate_signature: true + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + show_source: false + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true + summary: true +- git-revision-date-localized: + enabled: !ENV [DEPLOY, false] + enable_creation_date: true + type: timeago +- redirects: + redirect_maps: + theming.md: usage/theming.md + handlers/overview.md: usage/handlers.md +- minify: + minify_html: !ENV [DEPLOY, false] +- group: + enabled: !ENV [MATERIAL_INSIDERS, false] + plugins: + - typeset extra: social: - icon: fontawesome/brands/github link: https://github.com/pawamoy + - icon: fontawesome/brands/mastodon + link: https://fosstodon.org/@pawamoy - icon: fontawesome/brands/twitter link: https://twitter.com/pawamoy + - icon: fontawesome/brands/gitter + link: https://gitter.im/mkdocstrings/community + - icon: fontawesome/brands/python + link: https://pypi.org/project/mkdocstrings/ + analytics: + feedback: + title: Was this page helpful? + ratings: + - icon: material/emoticon-happy-outline + name: This page was helpful + data: 1 + note: Thanks for your feedback! + - icon: material/emoticon-sad-outline + name: This page could be improved + data: 0 + note: Let us know how we can improve this page. diff --git a/pyproject.toml b/pyproject.toml index b4cbd1d1..867747f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,28 +1,28 @@ [build-system] -requires = ["pdm-pep517"] -build-backend = "pdm.pep517.api" +requires = ["pdm-backend"] +build-backend = "pdm.backend" [project] name = "mkdocstrings" description = "Automatic documentation from sources, for MkDocs." -authors = [{name = "Timothée Mazzucotelli", email = "pawamoy@pm.me"}] -license = {file = "LICENSE"} +authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] +license = {text = "ISC"} readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.9" keywords = ["mkdocs", "mkdocs-plugin", "docstrings", "autodoc", "documentation"] dynamic = ["version"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: ISC License (ISCL)", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Documentation", "Topic :: Software Development", "Topic :: Software Development :: Documentation", @@ -30,12 +30,16 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "click>=7.0", "Jinja2>=2.11.1", - "Markdown>=3.3", + "Markdown>=3.6", "MarkupSafe>=1.1", - "mkdocs>=1.2", - "mkdocs-autorefs>=0.3.1", + "mkdocs>=1.4", + "mkdocs-autorefs>=1.2", + "platformdirs>=2.2", "pymdown-extensions>=6.3", + "importlib-metadata>=4.6; python_version < '3.10'", + "typing-extensions>=4.1; python_version < '3.10'", ] [project.optional-dependencies] @@ -51,81 +55,69 @@ 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" +Funding = "https://github.com/sponsors/pawamoy" [project.entry-points."mkdocs.plugins"] mkdocstrings = "mkdocstrings.plugin:MkdocstringsPlugin" [tool.pdm] -version = {use_scm = true} +version = {source = "scm"} + +[tool.pdm.build] package-dir = "src" -includes = ["src/mkdocstrings"] editable-backend = "editables" - -[tool.pdm.dev-dependencies] -duty = ["duty>=0.7"] -docs = [ - "markdown-callouts>=0.2.0", - "mkdocs>=1.3", # required for the watch feature - "mkdocs-coverage>=0.2", - "mkdocs-gen-files>=0.3", - "mkdocs-literate-nav>=0.4", - "mkdocs-material>=7.3", - "mkdocs-section-index>=0.3", - "mkdocstrings-python>=0.5.1", - "toml>=0.10", -] -format = [ - "autoflake>=1.4", - "black>=21.10b0", - "isort>=5.10", -] -maintain = [ - "git-changelog>=0.4", -] -quality = [ - "darglint>=1.8", - "flake8-bandit>=2.1", - "flake8-black>=0.2", - "flake8-bugbear>=21.9", - "flake8-builtins>=1.5", - "flake8-comprehensions>=3.7", - "flake8-docstrings>=1.6", - "flake8-pytest-style>=1.5", - "flake8-string-format>=0.3", - "flake8-tidy-imports>=4.5", - "flake8-variables-names>=0.0", - "pep8-naming>=0.12", - "wps-light>=0.15", +excludes = ["**/.pytest_cache"] +source-includes = [ + "config", + "docs", + "scripts", + "share", + "tests", + "duties.py", + "mkdocs.yml", + "*.md", + "LICENSE", ] -tests = [ - "docutils", - "pygments>=2.10", # python 3.6 - "pytest>=6.2", - "pytest-cov>=3.0", - "pytest-randomly>=3.10", - "pytest-xdist>=2.4", - "sphinx", -] -typing = [ - "mypy>=0.910", - "types-docutils", - "types-markdown>=3.3", - "types-pyyaml", - "types-toml>=0.10", + +[tool.pdm.build.wheel-data] +data = [ + {path = "share/**/*", relative-to = "."}, ] -security = ["safety>=1.10"] -[tool.black] -line-length = 120 -exclude = "tests/fixtures" +[tool.uv] +dev-dependencies = [ + # dev + "editables>=0.5", + + # maintenance + "build>=1.2", + "git-changelog>=2.5", + "twine>=5.1", + + # ci + "duty>=1.4", + "ruff>=0.4", + "pytest>=8.2", + "pytest-cov>=5.0", + "pytest-randomly>=3.15", + "pytest-xdist>=3.6", + "mypy>=1.10", + "types-markdown>=3.6", + "types-pyyaml>=6.0", -[tool.isort] -line_length = 120 -not_skip = "__init__.py" -multi_line_output = 3 -force_single_line = false -balanced_wrapping = true -default_section = "THIRDPARTY" -known_first_party = "mkdocstrings" -include_trailing_comma = true + # docs + "black>=24.4", + "markdown-callouts>=0.4", + "markdown-exec>=1.8", + "mkdocs>=1.6", + "mkdocs-coverage>=1.0", + "mkdocs-gen-files>=0.5", + "mkdocs-git-revision-date-localized-plugin>=1.2", + "mkdocs-literate-nav>=0.6", + "mkdocs-material>=9.5", + "mkdocs-minify-plugin>=0.8", + "mkdocs-redirects>=1.2", + "mkdocstrings[python]>=0.25", + # YORE: EOL 3.10: Remove line. + "tomli>=2.0; python_version < '3.11'", +] \ No newline at end of file diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py new file mode 100644 index 00000000..bd2dcbf2 --- /dev/null +++ b/scripts/gen_credits.py @@ -0,0 +1,179 @@ +"""Script to generate the project's credits.""" + +from __future__ import annotations + +import os +import sys +from collections import defaultdict +from collections.abc import Iterable +from importlib.metadata import distributions +from itertools import chain +from pathlib import Path +from textwrap import dedent +from typing import Union + +from jinja2 import StrictUndefined +from jinja2.sandbox import SandboxedEnvironment +from packaging.requirements import Requirement + +# YORE: EOL 3.10: Replace block with line 2. +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", ".")) +with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) +project = pyproject["project"] +project_name = project["name"] +devdeps = [dep for dep in pyproject["tool"]["uv"]["dev-dependencies"] if not dep.startswith("-e")] + +PackageMetadata = dict[str, Union[str, Iterable[str]]] +Metadata = dict[str, PackageMetadata] + + +def _merge_fields(metadata: dict) -> PackageMetadata: + fields = defaultdict(list) + for header, value in metadata.items(): + fields[header.lower()].append(value.strip()) + return { + field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] + for field, value in fields.items() + } + + +def _norm_name(name: str) -> str: + return name.replace("_", "-").replace(".", "-").lower() + + +def _requirements(deps: list[str]) -> dict[str, Requirement]: + return {_norm_name((req := Requirement(dep)).name): req for dep in deps} + + +def _extra_marker(req: Requirement) -> str | None: + if not req.marker: + return None + try: + return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") + except StopIteration: + return None + + +def _get_metadata() -> Metadata: + metadata = {} + for pkg in distributions(): + name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] + metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] + metadata[name]["spec"] = set() + metadata[name]["extras"] = set() + metadata[name].setdefault("summary", "") + _set_license(metadata[name]) + return metadata + + +def _set_license(metadata: PackageMetadata) -> None: + license_field = metadata.get("license-expression", metadata.get("license", "")) + license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) + check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") + if check_classifiers: + license_names = [] + for classifier in metadata["classifier"]: + if classifier.startswith("License ::"): + license_names.append(classifier.rsplit("::", 1)[1].strip()) + license_name = " + ".join(license_names) + metadata["license"] = license_name or "?" + + +def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: + deps = {} + for dep_name, dep_req in base_deps.items(): + if dep_name not in metadata or dep_name == "mkdocstrings": + continue + metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] + metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] + deps[dep_name] = metadata[dep_name] + + again = True + while again: + again = False + for pkg_name in metadata: + if pkg_name in deps: + for pkg_dependency in metadata[pkg_name].get("requires-dist", []): + requirement = Requirement(pkg_dependency) + dep_name = _norm_name(requirement.name) + extra_marker = _extra_marker(requirement) + if ( + dep_name in metadata + and dep_name not in deps + and dep_name != project["name"] + and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) + ): + metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] + deps[dep_name] = metadata[dep_name] + again = True + + return deps + + +def _render_credits() -> str: + metadata = _get_metadata() + dev_dependencies = _get_deps(_requirements(devdeps), metadata) + prod_dependencies = _get_deps( + _requirements( + chain( # type: ignore[arg-type] + project.get("dependencies", []), + chain(*project.get("optional-dependencies", {}).values()), + ), + ), + metadata, + ) + + template_data = { + "project_name": project_name, + "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), + "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), + "more_credits": "http://pawamoy.github.io/credits/", + } + template_text = dedent( + """ + # Credits + + These projects were used to build *{{ project_name }}*. **Thank you!** + + [Python](https://www.python.org/) | + [uv](https://github.com/astral-sh/uv) | + [copier-uv](https://github.com/pawamoy/copier-uv) + + {% macro dep_line(dep) -%} + [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} + {%- endmacro %} + + {% if prod_dependencies -%} + ### Runtime dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in prod_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + {% endif -%} + {% if dev_dependencies -%} + ### Development dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in dev_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + {% endif -%} + {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} + """, + ) + jinja_env = SandboxedEnvironment(undefined=StrictUndefined) + return jinja_env.from_string(template_text).render(**template_data) + + +print(_render_credits()) diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py new file mode 100644 index 00000000..676981b6 --- /dev/null +++ b/scripts/gen_ref_nav.py @@ -0,0 +1,37 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() +mod_symbol = '' + +root = Path(__file__).parent.parent +src = root / "src" + +for path in sorted(src.rglob("*.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 = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav_parts = [f"{mod_symbol} {part}" for part in parts] + nav[tuple(nav_parts)] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"---\ntitle: {ident}\n---\n\n::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path.relative_to(root)) + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/scripts/insiders.py b/scripts/insiders.py new file mode 100644 index 00000000..849c6314 --- /dev/null +++ b/scripts/insiders.py @@ -0,0 +1,206 @@ +"""Functions related to Insiders funding goals.""" + +from __future__ import annotations + +import json +import logging +import os +import posixpath +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from itertools import chain +from pathlib import Path +from typing import TYPE_CHECKING, cast +from urllib.error import HTTPError +from urllib.parse import urljoin +from urllib.request import urlopen + +import yaml + +if TYPE_CHECKING: + from collections.abc import Iterable + +logger = logging.getLogger(f"mkdocs.logs.{__name__}") + + +def human_readable_amount(amount: int) -> str: # noqa: D103 + str_amount = str(amount) + if len(str_amount) >= 4: # noqa: PLR2004 + return f"{str_amount[:len(str_amount)-3]},{str_amount[-3:]}" + return str_amount + + +@dataclass +class Project: + """Class representing an Insiders project.""" + + name: str + url: str + + +@dataclass +class Feature: + """Class representing an Insiders feature.""" + + name: str + ref: str | None + since: date | None + project: Project | None + + def url(self, rel_base: str = "..") -> str | None: # noqa: D102 + if not self.ref: + return None + if self.project: + rel_base = self.project.url + return posixpath.join(rel_base, self.ref.lstrip("/")) + + def render(self, rel_base: str = "..", *, badge: bool = False) -> None: # noqa: D102 + new = "" + if badge: + recent = self.since and date.today() - self.since <= timedelta(days=60) # noqa: DTZ011 + if recent: + ft_date = self.since.strftime("%B %d, %Y") # type: ignore[union-attr] + new = f' :material-alert-decagram:{{ .new-feature .vibrate title="Added on {ft_date}" }}' + project = f"[{self.project.name}]({self.project.url}) — " if self.project else "" + feature = f"[{self.name}]({self.url(rel_base)})" if self.ref else self.name + print(f"- [{'x' if self.since else ' '}] {project}{feature}{new}") + + +@dataclass +class Goal: + """Class representing an Insiders goal.""" + + name: str + amount: int + features: list[Feature] + complete: bool = False + + @property + def human_readable_amount(self) -> str: # noqa: D102 + return human_readable_amount(self.amount) + + def render(self, rel_base: str = "..") -> None: # noqa: D102 + print(f"#### $ {self.human_readable_amount} — {self.name}\n") + if self.features: + for feature in self.features: + feature.render(rel_base) + print("") + else: + print("There are no features in this goal for this project. ") + print( + "[See the features in this goal **for all Insiders projects.**]" + f"(https://pawamoy.github.io/insiders/#{self.amount}-{self.name.lower().replace(' ', '-')})", + ) + + +def load_goals(data: str, funding: int = 0, project: Project | None = None) -> dict[int, Goal]: + """Load goals from JSON data. + + Parameters: + data: The JSON data. + funding: The current total funding, per month. + origin: The origin of the data (URL). + + Returns: + A dictionaries of goals, keys being their target monthly amount. + """ + goals_data = yaml.safe_load(data)["goals"] + return { + amount: Goal( + name=goal_data["name"], + amount=amount, + complete=funding >= amount, + features=[ + Feature( + name=feature_data["name"], + ref=feature_data.get("ref"), + since=feature_data.get("since") and datetime.strptime(feature_data["since"], "%Y/%m/%d").date(), # noqa: DTZ007 + project=project, + ) + for feature_data in goal_data["features"] + ], + ) + for amount, goal_data in goals_data.items() + } + + +def _load_goals_from_disk(path: str, funding: int = 0) -> dict[int, Goal]: + project_dir = os.getenv("MKDOCS_CONFIG_DIR", ".") + try: + data = Path(project_dir, path).read_text() + except OSError as error: + raise RuntimeError(f"Could not load data from disk: {path}") from error + return load_goals(data, funding) + + +def _load_goals_from_url(source_data: tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: + project_name, project_url, data_fragment = source_data + data_url = urljoin(project_url, data_fragment) + try: + with urlopen(data_url) as response: # noqa: S310 + data = response.read() + except HTTPError as error: + raise RuntimeError(f"Could not load data from network: {data_url}") from error + return load_goals(data, funding, project=Project(name=project_name, url=project_url)) + + +def _load_goals(source: str | tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: + if isinstance(source, str): + return _load_goals_from_disk(source, funding) + return _load_goals_from_url(source, funding) + + +def funding_goals(source: str | list[str | tuple[str, str, str]], funding: int = 0) -> dict[int, Goal]: + """Load funding goals from a given data source. + + Parameters: + source: The data source (local file path or URL). + funding: The current total funding, per month. + + Returns: + A dictionaries of goals, keys being their target monthly amount. + """ + if isinstance(source, str): + return _load_goals_from_disk(source, funding) + goals = {} + for src in source: + source_goals = _load_goals(src, funding) + for amount, goal in source_goals.items(): + if amount not in goals: + goals[amount] = goal + else: + goals[amount].features.extend(goal.features) + return {amount: goals[amount] for amount in sorted(goals)} + + +def feature_list(goals: Iterable[Goal]) -> list[Feature]: + """Extract feature list from funding goals. + + Parameters: + goals: A list of funding goals. + + Returns: + A list of features. + """ + return list(chain.from_iterable(goal.features for goal in goals)) + + +def load_json(url: str) -> str | list | dict: # noqa: D103 + with urlopen(url) as response: # noqa: S310 + return json.loads(response.read().decode()) + + +data_source = globals()["data_source"] +sponsor_url = "https://github.com/sponsors/pawamoy" +data_url = "https://raw.githubusercontent.com/pawamoy/sponsors/main" +numbers: dict[str, int] = load_json(f"{data_url}/numbers.json") # type: ignore[assignment] +sponsors: list[dict] = load_json(f"{data_url}/sponsors.json") # type: ignore[assignment] +current_funding = numbers["total"] +sponsors_count = numbers["count"] +goals = funding_goals(data_source, funding=current_funding) +ongoing_goals = [goal for goal in goals.values() if not goal.complete] +unreleased_features = sorted( + (ft for ft in feature_list(ongoing_goals) if ft.since), + key=lambda ft: cast(date, ft.since), + reverse=True, +) diff --git a/scripts/make b/scripts/make new file mode 100755 index 00000000..ac430624 --- /dev/null +++ b/scripts/make @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Management commands.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from textwrap import dedent +from typing import Any, Iterator + +PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split() + + +def shell(cmd: str, capture_output: bool = False, **kwargs: Any) -> str | None: + """Run a shell command.""" + if capture_output: + return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 + subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 + return None + + +@contextmanager +def environ(**kwargs: str) -> Iterator[None]: + """Temporarily set environment variables.""" + original = dict(os.environ) + os.environ.update(kwargs) + try: + yield + finally: + os.environ.clear() + os.environ.update(original) + + +def uv_install(venv: Path) -> None: + """Install dependencies using uv.""" + with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): + if "CI" in os.environ: + shell("uv sync --no-editable") + else: + shell("uv sync") + + +def setup() -> None: + """Setup the project.""" + if not shutil.which("uv"): + raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") + + print("Installing dependencies (default environment)") # noqa: T201 + default_venv = Path(".venv") + if not default_venv.exists(): + shell("uv venv --python python") + uv_install(default_venv) + + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + print(f"\nInstalling dependencies (python{version})") # noqa: T201 + venv_path = Path(f".venvs/{version}") + if not venv_path.exists(): + shell(f"uv venv --python {version} {venv_path}") + with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): + uv_install(venv_path) + + +def run(version: str, cmd: str, *args: str, no_sync: bool = False, **kwargs: Any) -> None: + """Run a command in a virtual environment.""" + kwargs = {"check": True, **kwargs} + uv_run = ["uv", "run"] + if no_sync: + uv_run.append("--no-sync") + if version == "default": + with environ(UV_PROJECT_ENVIRONMENT=".venv"): + subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 + else: + with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"): + subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 + + +def multirun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command for all configured Python versions.""" + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + run(version, cmd, *args, **kwargs) + else: + run("default", cmd, *args, **kwargs) + + +def allrun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in all virtual environments.""" + run("default", cmd, *args, **kwargs) + if PYTHON_VERSIONS: + multirun(cmd, *args, **kwargs) + + +def clean() -> None: + """Delete build artifacts and cache files.""" + paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] + for path in paths_to_clean: + shell(f"rm -rf {path}") + + cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"} + for dirpath in Path(".").rglob("*/"): + if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs: + shutil.rmtree(dirpath, ignore_errors=True) + + +def vscode() -> None: + """Configure VSCode to work on this project.""" + Path(".vscode").mkdir(parents=True, exist_ok=True) + shell("cp -v config/vscode/* .vscode") + + +def main() -> int: + """Main entry point.""" + args = list(sys.argv[1:]) + if not args or args[0] == "help": + if len(args) > 1: + run("default", "duty", "--help", args[1]) + else: + print( + dedent( + """ + Available commands + help Print this help. Add task name to print help. + setup Setup all virtual environments (install dependencies). + run Run a command in the default virtual environment. + multirun Run a command for all configured Python versions. + allrun Run a command in all virtual environments. + 3.x Run a command in the virtual environment for Python 3.x. + clean Delete build artifacts and cache files. + vscode Configure VSCode to work on this project. + """ + ), + flush=True, + ) # noqa: T201 + if os.path.exists(".venv"): + print("\nAvailable tasks", flush=True) # noqa: T201 + run("default", "duty", "--list", no_sync=True) + return 0 + + while args: + cmd = args.pop(0) + + if cmd == "run": + run("default", *args) + return 0 + + if cmd == "multirun": + multirun(*args) + return 0 + + if cmd == "allrun": + allrun(*args) + return 0 + + if cmd.startswith("3."): + run(cmd, *args) + return 0 + + opts = [] + while args and (args[0].startswith("-") or "=" in args[0]): + opts.append(args.pop(0)) + + if cmd == "clean": + clean() + elif cmd == "setup": + setup() + elif cmd == "vscode": + vscode() + elif cmd == "check": + multirun("duty", "check-quality", "check-types", "check-docs") + run("default", "duty", "check-api") + elif cmd in {"check-quality", "check-docs", "check-types", "test"}: + multirun("duty", cmd, *opts) + else: + run("default", "duty", cmd, *opts) + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except subprocess.CalledProcessError as process: + if process.output: + print(process.output, file=sys.stderr) # noqa: T201 + sys.exit(process.returncode) diff --git a/scripts/multirun.sh b/scripts/multirun.sh deleted file mode 100755 index a55d1746..00000000 --- a/scripts/multirun.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -e - -PYTHON_VERSIONS="${PYTHON_VERSIONS-3.7 3.8 3.9 3.10 3.11}" - -restore_previous_python_version() { - if pdm use -f "$1" &>/dev/null; then - echo "> Restored previous Python version: ${1##*/}" - fi -} - -if [ -n "${PYTHON_VERSIONS}" ]; then - old_python_version="$(pdm config python.path)" - echo "> Currently selected Python version: ${old_python_version##*/}" - trap "restore_previous_python_version ${old_python_version}" EXIT - for python_version in ${PYTHON_VERSIONS}; do - if pdm use -f "python${python_version}" &>/dev/null; then - echo "> pdm run $@ (python${python_version})" - pdm run "$@" - else - echo "> pdm use -f python${python_version}: Python interpreter not available?" >&2 - fi - done -else - pdm run "$@" -fi diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100755 index 188eaebc..00000000 --- a/scripts/setup.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -e - -PYTHON_VERSIONS="${PYTHON_VERSIONS-3.7 3.8 3.9 3.10 3.11}" - -install_with_pipx() { - if ! command -v "$1" &>/dev/null; then - if ! command -v pipx &>/dev/null; then - python3 -m pip install --user pipx - fi - pipx install "$1" - fi -} - -install_with_pipx pdm - -restore_previous_python_version() { - if pdm use -f "$1" &>/dev/null; then - echo "> Restored previous Python version: ${1##*/}" - fi -} - -if [ -n "${PYTHON_VERSIONS}" ]; then - if old_python_version="$(pdm config python.path 2>/dev/null)"; then - echo "> Currently selected Python version: ${old_python_version##*/}" - trap "restore_previous_python_version ${old_python_version}" EXIT - fi - for python_version in ${PYTHON_VERSIONS}; do - if pdm use -f "python${python_version}" &>/dev/null; then - echo "> Using Python ${python_version} interpreter" - pdm install - else - echo "> pdm use -f python${python_version}: Python interpreter not available?" >&2 - fi - done -else - pdm install -fi diff --git a/src/mkdocstrings/__init__.py b/src/mkdocstrings/__init__.py new file mode 100644 index 00000000..03550f9b --- /dev/null +++ b/src/mkdocstrings/__init__.py @@ -0,0 +1,4 @@ +"""mkdocstrings package. + +Automatic documentation from sources, for MkDocs. +""" diff --git a/src/mkdocstrings/_cache.py b/src/mkdocstrings/_cache.py new file mode 100644 index 00000000..0bd0d90e --- /dev/null +++ b/src/mkdocstrings/_cache.py @@ -0,0 +1,132 @@ +import base64 +import datetime +import gzip +import hashlib +import os +import re +import urllib.parse +import urllib.request +from collections.abc import Mapping +from typing import BinaryIO, Callable, Optional + +import click +import platformdirs + +from mkdocstrings.loggers import get_logger + +log = get_logger(__name__) + +# Regex pattern for an environment variable in the form ${ENV_VAR}. +ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +def download_url_with_gz(url: str) -> bytes: + url, auth_header = _extract_auth_from_url(url) + + req = urllib.request.Request( # noqa: S310 + url, + headers={"Accept-Encoding": "gzip", "User-Agent": "mkdocstrings/0.15.0", **auth_header}, + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + content: BinaryIO = resp + if "gzip" in resp.headers.get("content-encoding", ""): + content = gzip.GzipFile(fileobj=resp) # type: ignore[assignment] + return content.read() + + +def _expand_env_vars(credential: str, url: str, env: Optional[Mapping[str, str]] = None) -> str: + """A safe implementation of environment variable substitution. + + It only supports the following forms: `${ENV_VAR}`. + Neither `$ENV_VAR` or `%ENV_VAR` are supported. + """ + if env is None: + env = os.environ + + def replace_func(match: re.Match) -> str: + try: + return env[match.group(1)] + except KeyError: + log.warning("Environment variable '%s' is not set, but is used in inventory URL %s", match.group(1), url) + return match.group(0) + + return re.sub(ENV_VAR_PATTERN, replace_func, credential) + + +# Implementation adapted from PDM: https://github.com/pdm-project/pdm. +def _extract_auth_from_url(url: str) -> tuple[str, dict[str, str]]: + """Extract credentials from the URL if present, and return the URL and the appropriate auth header for the credentials.""" + if "@" not in url: + return url, {} + + scheme, netloc, *rest = urllib.parse.urlparse(url) + auth, host = netloc.split("@", 1) + auth = _expand_env_vars(credential=auth, url=url) + auth_header = _create_auth_header(credential=auth, url=url) + + url = urllib.parse.urlunparse((scheme, host, *rest)) + return url, auth_header + + +def _create_auth_header(credential: str, url: str) -> dict[str, str]: + """Create the Authorization header for basic or bearer authentication, depending on credential.""" + if ":" not in credential: + # We assume that the user is using a token. + log.debug("Using bearer token authentication for %s", url) + return {"Authorization": f"Bearer {credential}"} + + # Else, we assume that the user is using user:password. + user, pwd = credential.split(":", 1) + log.debug("Using basic authentication for %s", url) + credentials = base64.encodebytes(f"{user}:{pwd}".encode()).decode().strip() + return {"Authorization": f"Basic {credentials}"} + + +# This is mostly a copy of https://github.com/mkdocs/mkdocs/blob/master/mkdocs/utils/cache.py +# In the future maybe they can be deduplicated. + + +def download_and_cache_url( + url: str, + download: Callable[[str], bytes], + cache_duration: datetime.timedelta, + comment: bytes = b"# ", +) -> bytes: + """Downloads a file from the URL, stores it under ~/.cache/, and returns its content. + + For tracking the age of the content, a prefix is inserted into the stored file, rather than relying on mtime. + + Args: + url: URL to use. + download: Callback that will accept the URL and actually perform the download. + cache_duration: How long to consider the URL content cached. + comment: The appropriate comment prefix for this file format. + """ + directory = os.path.join(platformdirs.user_cache_dir("mkdocs"), "mkdocstrings_url_cache") + name_hash = hashlib.sha256(url.encode()).hexdigest()[:32] + path = os.path.join(directory, name_hash + os.path.splitext(url)[1]) + + now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + prefix = b"%s%s downloaded at timestamp " % (comment, url.encode()) + # Check for cached file and try to return it + if os.path.isfile(path): + try: + with open(path, "rb") as f: + line = f.readline() + if line.startswith(prefix): + line = line[len(prefix) :] + timestamp = int(line) + if datetime.timedelta(seconds=(now - timestamp)) <= cache_duration: + log.debug("Using cached '%s' for '%s'", path, url) + return f.read() + except (OSError, ValueError) as e: + log.debug("%s: %s", type(e).__name__, e) + + # Download and cache the file + log.debug("Downloading '%s' to '%s'", url, path) + content = download(url) + os.makedirs(directory, exist_ok=True) + with click.open_file(path, "wb", atomic=True) as f: + f.write(b"%s%d\n" % (prefix, now)) + f.write(content) + return content diff --git a/src/mkdocstrings/debug.py b/src/mkdocstrings/debug.py new file mode 100644 index 00000000..b5da78f2 --- /dev/null +++ b/src/mkdocstrings/debug.py @@ -0,0 +1,109 @@ +"""Debugging utilities.""" + +from __future__ import annotations + +import os +import platform +import sys +from dataclasses import dataclass +from importlib import metadata + + +@dataclass +class Variable: + """Dataclass describing an environment variable.""" + + name: str + """Variable name.""" + value: str + """Variable value.""" + + +@dataclass +class Package: + """Dataclass describing a Python package.""" + + name: str + """Package name.""" + version: str + """Package version.""" + + +@dataclass +class Environment: + """Dataclass to store environment information.""" + + interpreter_name: str + """Python interpreter name.""" + interpreter_version: str + """Python interpreter version.""" + interpreter_path: str + """Path to Python executable.""" + platform: str + """Operating System.""" + packages: list[Package] + """Installed packages.""" + variables: list[Variable] + """Environment variables.""" + + +def _interpreter_name_version() -> tuple[str, str]: + if hasattr(sys, "implementation"): + impl = sys.implementation.version + version = f"{impl.major}.{impl.minor}.{impl.micro}" + kind = impl.releaselevel + if kind != "final": + version += kind[0] + str(impl.serial) + return sys.implementation.name, version + return "", "0.0.0" + + +def get_version(dist: str = "mkdocstrings") -> str: + """Get version of the given distribution. + + Parameters: + dist: A distribution name. + + Returns: + A version number. + """ + try: + return metadata.version(dist) + except metadata.PackageNotFoundError: + return "0.0.0" + + +def get_debug_info() -> Environment: + """Get debug/environment information. + + Returns: + Environment information. + """ + py_name, py_version = _interpreter_name_version() + packages = ["mkdocstrings"] + variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("MKDOCSTRINGS")]] + return Environment( + interpreter_name=py_name, + interpreter_version=py_version, + interpreter_path=sys.executable, + platform=platform.platform(), + variables=[Variable(var, val) for var in variables if (val := os.getenv(var))], + packages=[Package(pkg, get_version(pkg)) for pkg in packages], + ) + + +def print_debug_info() -> None: + """Print debug/environment information.""" + info = get_debug_info() + print(f"- __System__: {info.platform}") + print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") + print("- __Environment variables__:") + for var in info.variables: + print(f" - `{var.name}`: `{var.value}`") + print("- __Installed packages__:") + for pkg in info.packages: + print(f" - `{pkg.name}` v{pkg.version}") + + +if __name__ == "__main__": + print_debug_info() diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py index be0c48bb..266d642f 100644 --- a/src/mkdocstrings/extension.py +++ b/src/mkdocstrings/extension.py @@ -1,7 +1,7 @@ """This module holds the code of the Markdown extension responsible for matching "autodoc" instructions. The extension is composed of a Markdown [block processor](https://python-markdown.github.io/extensions/api/#blockparser) -that matches indented blocks starting with a line like '::: identifier'. +that matches indented blocks starting with a line like `::: identifier`. For each of these blocks, it uses a [handler][mkdocstrings.handlers.base.BaseHandler] to collect documentation about the given identifier and render it with Jinja templates. @@ -12,37 +12,38 @@ ```yaml ::: some.identifier handler: python - selection: + options: option1: value1 option2: - - value2a - - value2b - rendering: + - value2a + - value2b option_x: etc ``` """ + +from __future__ import annotations + import re from collections import ChainMap -from typing import Any, MutableSequence, Tuple -from warnings import warn +from typing import TYPE_CHECKING, Any from xml.etree.ElementTree import Element import yaml from jinja2.exceptions import TemplateNotFound -from markdown import Markdown -from markdown.blockparser import BlockParser from markdown.blockprocessors import BlockProcessor from markdown.extensions import Extension from markdown.treeprocessors import Treeprocessor -from mkdocs_autorefs.plugin import AutorefsPlugin +from mkdocs.exceptions import PluginError 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 # noqa: WPS440 +if TYPE_CHECKING: + from collections.abc import MutableSequence + + from markdown import Markdown + from markdown.blockparser import BlockParser + from mkdocs_autorefs.plugin import AutorefsPlugin log = get_logger(__name__) @@ -61,15 +62,19 @@ class AutoDocProcessor(BlockProcessor): regex = re.compile(r"^(?P#{1,6} *|)::: ?(?P.+?) *$", flags=re.MULTILINE) def __init__( - self, parser: BlockParser, md: Markdown, config: dict, handlers: Handlers, autorefs: AutorefsPlugin + self, + parser: BlockParser, + md: Markdown, + config: dict, + handlers: Handlers, + autorefs: AutorefsPlugin, ) -> None: """Initialize the object. Arguments: parser: A `markdown.blockparser.BlockParser` instance. md: A `markdown.Markdown` instance. - config: The [configuration][mkdocstrings.plugin.MkdocstringsPlugin.config_scheme] - of the `mkdocstrings` plugin. + config: The [configuration][mkdocstrings.plugin.PluginConfig] of the `mkdocstrings` plugin. handlers: The handlers container. autorefs: The autorefs plugin instance. """ @@ -78,9 +83,9 @@ def __init__( self._config = config self._handlers = handlers self._autorefs = autorefs - self._updated_env = False + self._updated_envs: set = set() - def test(self, parent: Element, block: str) -> bool: + def test(self, parent: Element, block: str) -> bool: # noqa: ARG002 """Match our autodoc instructions. Arguments: @@ -113,31 +118,57 @@ def run(self, parent: Element, blocks: MutableSequence[str]) -> None: block, the_rest = self.detab(block) + if not block and blocks and blocks[0].startswith((" handler:", " options:")): + # YAML options were separated from the `:::` line by a blank line. + block = blocks.pop(0) + if match: identifier = match["name"] heading_level = match["heading"].count("#") - log.debug(f"Matched '::: {identifier}'") + log.debug("Matched '::: %s'", identifier) html, handler, data = 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. + # We need to duplicate the headings directly, just so 'toc' can pick them up, + # otherwise they wouldn't appear in the final table of contents. + # These headings are generated by the `BaseHandler.do_heading` method (Jinja filter), + # which runs in the inner Markdown conversion layer, and not in the outer one where we are now. headings = handler.get_headings() el.extend(headings) + # These duplicated headings will later be removed by our `_HeadingsPostProcessor` processor, + # which runs right after 'toc' (see `MkdocstringsExtension.extendMarkdown`). page = self._autorefs.current_page - for heading in headings: - anchor = heading.attrib["id"] # noqa: WPS440 - self._autorefs.register_anchor(page, anchor) # noqa: WPS441 - - if "data-role" in heading.attrib: - self._handlers.inventory.register( - name=anchor, # noqa: WPS441 - domain=handler.domain, - role=heading.attrib["data-role"], - uri=f"{page}#{anchor}", # noqa: WPS441 - ) + if page is not None: + for heading in headings: + rendered_anchor = heading.attrib["id"] + self._autorefs.register_anchor(page, rendered_anchor) + + if "data-role" in heading.attrib: + self._handlers.inventory.register( + name=rendered_anchor, + domain=handler.domain, + role=heading.attrib["data-role"], + priority=1, # register with standard priority + uri=f"{page}#{rendered_anchor}", + ) + + # also register other anchors for this object in the inventory + try: + data_object = handler.collect(rendered_anchor, handler.fallback_config) + except CollectionError: + continue + for anchor in handler.get_anchors(data_object): + if anchor not in self._handlers.inventory: + self._handlers.inventory.register( + name=anchor, + domain=handler.domain, + role=heading.attrib["data-role"], + priority=2, # register with lower priority + uri=f"{page}#{rendered_anchor}", + ) parent.append(el) @@ -152,13 +183,13 @@ def _process_block( identifier: str, yaml_block: str, heading_level: int = 0, - ) -> Tuple[str, BaseHandler, CollectorItem]: + ) -> tuple[str, BaseHandler, CollectorItem]: """Process an autodoc block. Arguments: identifier: The identifier of the object to collect and render. yaml_block: The YAML configuration. - heading_level: Suggested level of the the heading to insert (0 to ignore). + heading_level: Suggested level of the heading to insert (0 to ignore). Raises: PluginError: When something wrong happened during collection. @@ -170,66 +201,76 @@ def _process_block( config = yaml.safe_load(yaml_block) or {} handler_name = self._handlers.get_handler_name(config) - log.debug(f"Using handler '{handler_name}'") + log.debug("Using handler '%s'", handler_name) handler_config = self._handlers.get_handler_config(handler_name) handler = self._handlers.get_handler(handler_name, handler_config) global_options = handler_config.get("options", {}) local_options = config.get("options", {}) - deprecated_global_options = ChainMap(handler_config.get("selection", {}), handler_config.get("rendering", {})) - deprecated_local_options = ChainMap(config.get("selection", {}), config.get("rendering", {})) - - options = ChainMap(local_options, deprecated_local_options, global_options, deprecated_global_options) - - if deprecated_global_options or deprecated_local_options: - warn( - "'selection' and 'rendering' are deprecated and merged into a single 'options' YAML key", - DeprecationWarning, - ) + options = ChainMap(local_options, global_options) if heading_level: - options = ChainMap(options, {"heading_level": heading_level}) # like setdefault + # Heading level obtained from Markdown (`##`) takes precedence. + options = ChainMap({"heading_level": heading_level}, options) log.debug("Collecting data") try: data: CollectorItem = handler.collect(identifier, options) except CollectionError as exception: - log.error(str(exception)) - if PluginError is SystemExit: # When MkDocs 1.2 is sufficiently common, this can be dropped. - log.error(f"Error reading page '{self._autorefs.current_page}':") + log.error("%s", exception) # noqa: TRY400 raise PluginError(f"Could not collect '{identifier}'") from exception - if not self._updated_env: - log.debug("Updating renderer's env") - handler._update_env(self.md, self._config) # noqa: WPS437 (protected member OK) - self._updated_env = True + if handler_name not in self._updated_envs: # We haven't seen this handler before on this document. + log.debug("Updating handler's rendering env") + handler._update_env(self.md, self._config) + self._updated_envs.add(handler_name) log.debug("Rendering templates") try: rendered = handler.render(data, options) except TemplateNotFound as exc: theme_name = self._config["theme_name"] - log.error( - f"Template '{exc.name}' not found for '{handler_name}' handler and theme '{theme_name}'.", + log.error( # noqa: TRY400 + "Template '%s' not found for '%s' handler and theme '%s'.", + exc.name, + handler_name, + theme_name, ) raise return rendered, handler, data -class _PostProcessor(Treeprocessor): - def run(self, root: Element): +class _HeadingsPostProcessor(Treeprocessor): + def run(self, root: Element) -> None: + self._remove_duplicated_headings(root) + + def _remove_duplicated_headings(self, parent: Element) -> None: carry_text = "" - for el in reversed(root): # Reversed mainly for the ability to mutate during iteration. + for el in reversed(parent): # Reversed mainly for the ability to mutate during iteration. if el.tag == "div" and el.get("class") == "mkdocstrings": # Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML). carry_text = (el.text or "") + carry_text - root.remove(el) - elif carry_text: - el.tail = (el.tail or "") + carry_text - carry_text = "" + parent.remove(el) + else: + if carry_text: + el.tail = (el.tail or "") + carry_text + carry_text = "" + self._remove_duplicated_headings(el) + if carry_text: - root.text = (root.text or "") + carry_text + parent.text = (parent.text or "") + carry_text + + +class _TocLabelsTreeProcessor(Treeprocessor): + def run(self, root: Element) -> None: # noqa: ARG002 + self._override_toc_labels(self.md.toc_tokens) # type: ignore[attr-defined] + + def _override_toc_labels(self, tokens: list[dict[str, Any]]) -> None: + for token in tokens: + if (label := token.get("data-toc-label")) and token["name"] != label: + token["name"] = label + self._override_toc_labels(token["children"]) class MkdocstringsExtension(Extension): @@ -267,7 +308,12 @@ def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent me priority=75, # Right before markdown.blockprocessors.HashHeaderProcessor ) md.treeprocessors.register( - _PostProcessor(md.parser), - "mkdocstrings_post", + _HeadingsPostProcessor(md), + "mkdocstrings_post_headings", + priority=4, # Right after 'toc'. + ) + md.treeprocessors.register( + _TocLabelsTreeProcessor(md), + "mkdocstrings_post_toc_labels", priority=4, # Right after 'toc'. ) diff --git a/src/mkdocstrings/handlers/__init__.py b/src/mkdocstrings/handlers/__init__.py new file mode 100644 index 00000000..b9e2a29c --- /dev/null +++ b/src/mkdocstrings/handlers/__init__.py @@ -0,0 +1 @@ +"""Handlers module.""" diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py index a8de29ac..d0b9456a 100644 --- a/src/mkdocstrings/handlers/base.py +++ b/src/mkdocstrings/handlers/base.py @@ -1,25 +1,21 @@ """Base module for handlers. -This module contains the base classes for implementing collectors, renderers, and the combination of the two: handlers. - -It also provides two methods: - -- `get_handler`, that will cache handlers into the `HANDLERS_CACHE` dictionary. -- `teardown`, that will teardown all the cached handlers, and then clear the cache. +This module contains the base classes for implementing handlers. """ from __future__ import annotations import importlib -import warnings -from contextlib import suppress +import sys from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Sequence +from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar, cast from xml.etree.ElementTree import Element, tostring from jinja2 import Environment, FileSystemLoader from markdown import Markdown +from markdown.extensions.toc import TocTreeprocessor from markupsafe import Markup +from mkdocs_autorefs.references import AutorefsInlineProcessor from mkdocstrings.handlers.rendering import ( HeadingShiftingTreeprocessor, @@ -31,6 +27,17 @@ from mkdocstrings.inventory import Inventory from mkdocstrings.loggers import get_template_logger +# TODO: remove once support for Python 3.9 is dropped +if sys.version_info < (3, 10): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Mapping, MutableMapping, Sequence + + from mkdocs_autorefs.references import AutorefsHookInterface + CollectorItem = Any @@ -38,11 +45,11 @@ class CollectionError(Exception): """An exception raised when some collection of data failed.""" -class ThemeNotSupported(Exception): +class ThemeNotSupported(Exception): # noqa: N818 """An exception raised to tell a theme is not supported.""" -def do_any(seq: Sequence, attribute: str = None) -> bool: +def do_any(seq: Sequence, attribute: str | None = None) -> bool: """Check if at least one of the item in the sequence evaluates to true. The `any` builtin as a filter for Jinja templates. @@ -59,23 +66,35 @@ def do_any(seq: Sequence, attribute: str = None) -> bool: return any(_[attribute] for _ in seq) -class BaseRenderer: - """The base renderer class. +class BaseHandler: + """The base handler class. - Inherit from this class to implement a renderer. + Inherit from this class to implement a handler. - You will have to implement the `render` method. - You can also override the `update_env` method, to add more filters to the Jinja environment, + You will have to implement the `collect` and `render` methods. + You can also implement the `teardown` method, + and override the `update_env` method, to add more filters to the Jinja environment, making them available in your Jinja templates. To define a fallback theme, add a `fallback_theme` class-variable. To add custom CSS, add an `extra_css` variable or create an 'style.css' file beside the templates. """ + # TODO: Make name mandatory? + name: str = "" + """The handler's name, for example "python".""" + domain: str = "default" + """The handler's domain, used to register objects in the inventory, for example "py".""" + enable_inventory: bool = False + """Whether the inventory creation is enabled.""" + fallback_config: ClassVar[dict] = {} + """Fallback configuration when searching anchors for identifiers.""" fallback_theme: str = "" + """Fallback theme to use when a template isn't found in the configured theme.""" extra_css = "" + """Extra CSS.""" - def __init__(self, handler: str, theme: str, custom_templates: Optional[str] = None) -> None: + def __init__(self, handler: str, theme: str, custom_templates: str | None = None) -> None: """Initialize the object. If the given theme is not supported (it does not exist), it will look for a `fallback_theme` attribute @@ -88,21 +107,27 @@ def __init__(self, handler: str, theme: str, custom_templates: Optional[str] = N """ paths = [] - # TODO: remove once BaseRenderer is merged into BaseHandler - self._handler = handler - self._theme = theme - self._custom_templates = custom_templates - + # add selected theme templates themes_dir = self.get_templates_dir(handler) paths.append(themes_dir / theme) + # add extended theme templates + extended_templates_dirs = self.get_extended_templates_dirs(handler) + for templates_dir in extended_templates_dirs: + paths.append(templates_dir / theme) + + # add fallback theme templates if self.fallback_theme and self.fallback_theme != theme: paths.append(themes_dir / self.fallback_theme) + # add fallback theme of extended templates + for templates_dir in extended_templates_dirs: + paths.append(templates_dir / self.fallback_theme) + for path in paths: css_path = path / "style.css" if css_path.is_file(): - self.extra_css += "\n" + css_path.read_text(encoding="utf-8") # noqa: WPS601 + self.extra_css += "\n" + css_path.read_text(encoding="utf-8") break if custom_templates is not None: @@ -114,23 +139,69 @@ def __init__(self, handler: str, theme: str, custom_templates: Optional[str] = N auto_reload=False, # Editing a template in the middle of a build is not useful. ) self.env.filters["any"] = do_any - self.env.globals["log"] = get_template_logger() + self.env.globals["log"] = get_template_logger(self.name) - self._headings: List[Element] = [] - self._md: Markdown = None # type: ignore # To be populated in `update_env`. + self._headings: list[Element] = [] + self._md: Markdown = None # type: ignore[assignment] # To be populated in `update_env`. - def render(self, data: CollectorItem, config: dict) -> str: + @classmethod + def load_inventory( + cls, + in_file: BinaryIO, # noqa: ARG003 + url: str, # noqa: ARG003 + base_url: str | None = None, # noqa: ARG003 + **kwargs: Any, # noqa: ARG003 + ) -> Iterator[tuple[str, str]]: + """Yield items and their URLs from an inventory file streamed from `in_file`. + + 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). + """ + yield from () + + def collect(self, identifier: str, config: MutableMapping[str, Any]) -> CollectorItem: + """Collect data given an identifier and user configuration. + + In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into + a Python dictionary for example, though the implementation is completely free. + + Arguments: + identifier: An identifier for which to collect data. For example, in Python, + it would be 'mkdocstrings.handlers' to collect documentation about the handlers module. + It can be anything that you can feed to the tool of your choice. + config: The handler's configuration options. + + Returns: + Anything you want, as long as you can feed it to the handler's `render` method. + """ + raise NotImplementedError + + def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str: """Render a template using provided data and configuration options. Arguments: data: The collected data to render. - config: The rendering options. + config: The handler's configuration options. Returns: The rendered template as HTML. - """ # noqa: DAR202 (excess return section) + """ + raise NotImplementedError - def get_templates_dir(self, handler: str) -> Path: + def teardown(self) -> None: + """Teardown the handler. + + This method should be implemented to, for example, terminate a subprocess + that was started when creating the handler instance. + """ + + def get_templates_dir(self, handler: str | None = None) -> Path: """Return the path to the handler's templates directory. Override to customize how the templates directory is found. @@ -139,45 +210,38 @@ def get_templates_dir(self, handler: str) -> Path: handler: The name of the handler to get the templates directory of. Raises: + ModuleNotFoundError: When no such handler is installed. FileNotFoundError: When the templates directory cannot be found. Returns: The templates directory path. """ - # Templates can be found in 2 different logical locations: - # - in mkdocstrings_handlers/HANDLER/templates: our new migration target - # - in mkdocstrings/templates/HANDLER: current situation, this should be avoided - # These two other locations are forbidden: - # - in mkdocstrings_handlers/templates/HANDLER: sub-namespace packages are too annoying to deal with - # - in mkdocstrings/handlers/HANDLER/templates: not currently supported, - # and mkdocstrings will stop being a namespace - - with suppress(ModuleNotFoundError): # TODO: catch at some point to warn about missing handlers + handler = handler or self.name + try: import mkdocstrings_handlers + except ModuleNotFoundError as error: + raise ModuleNotFoundError(f"Handler '{handler}' not found, is it installed?") from error - for path in mkdocstrings_handlers.__path__: # noqa: WPS609 - theme_path = Path(path, handler, "templates") - if theme_path.exists(): - return theme_path - - # TODO: remove import and loop at some point, - # as mkdocstrings will stop being a namespace package - import mkdocstrings - - for path in mkdocstrings.__path__: # noqa: WPS609,WPS440 - theme_path = Path(path, "templates", handler) + for path in mkdocstrings_handlers.__path__: + theme_path = Path(path, handler, "templates") if theme_path.exists(): - if handler != "python": - warnings.warn( - "Exposing templates in the mkdocstrings.templates namespace is deprecated. " - "Put them in a templates folder inside your handler package instead.", - DeprecationWarning, - ) return theme_path raise FileNotFoundError(f"Can't find 'templates' folder for handler '{handler}'") - def get_anchors(self, data: CollectorItem) -> Sequence[str]: + def get_extended_templates_dirs(self, handler: str) -> list[Path]: + """Load template extensions for the given handler, return their templates directories. + + Arguments: + handler: The name of the handler to get the extended templates directory of. + + Returns: + The extensions templates directories. + """ + discovered_extensions = entry_points(group=f"mkdocstrings.{handler}.templates") + return [extension.load()() for extension in discovered_extensions] + + def get_anchors(self, data: CollectorItem) -> tuple[str, ...]: # noqa: ARG002 """Return the possible identifiers (HTML anchors) for a collected item. Arguments: @@ -186,14 +250,16 @@ def get_anchors(self, data: CollectorItem) -> Sequence[str]: Returns: The HTML anchors (without '#'), or an empty tuple if this item doesn't have an anchor. """ - # TODO: remove this at some point - try: - return (self.get_anchor(data),) # type: ignore - except AttributeError: - return () + return () def do_convert_markdown( - self, text: str, heading_level: int, html_id: str = "", *, strip_paragraph: bool = False + self, + text: str, + heading_level: int, + html_id: str = "", + *, + strip_paragraph: bool = False, + autoref_hook: AutorefsHookInterface | None = None, ) -> Markup: """Render Markdown text; for use inside templates. @@ -207,25 +273,30 @@ def do_convert_markdown( An HTML string. """ treeprocessors = self._md.treeprocessors - treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = heading_level - treeprocessors[IdPrependingTreeprocessor.name].id_prefix = html_id and html_id + "--" - treeprocessors[ParagraphStrippingTreeprocessor.name].strip = strip_paragraph + treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = heading_level # type: ignore[attr-defined] + treeprocessors[IdPrependingTreeprocessor.name].id_prefix = html_id and html_id + "--" # type: ignore[attr-defined] + treeprocessors[ParagraphStrippingTreeprocessor.name].strip = strip_paragraph # type: ignore[attr-defined] + + if autoref_hook: + self._md.inlinePatterns[AutorefsInlineProcessor.name].hook = autoref_hook # type: ignore[attr-defined] + try: return Markup(self._md.convert(text)) finally: - treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0 - treeprocessors[IdPrependingTreeprocessor.name].id_prefix = "" - treeprocessors[ParagraphStrippingTreeprocessor.name].strip = False + treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0 # type: ignore[attr-defined] + treeprocessors[IdPrependingTreeprocessor.name].id_prefix = "" # type: ignore[attr-defined] + treeprocessors[ParagraphStrippingTreeprocessor.name].strip = False # type: ignore[attr-defined] + self._md.inlinePatterns[AutorefsInlineProcessor.name].hook = None # type: ignore[attr-defined] self._md.reset() def do_heading( self, - content: str, + content: Markup, heading_level: int, *, - role: Optional[str] = None, + role: str | None = None, hidden: bool = False, - toc_label: Optional[str] = None, + toc_label: str | None = None, **attributes: str, ) -> Markup: """Render an HTML heading and register it for the table of contents. For use inside templates. @@ -241,10 +312,19 @@ def do_heading( Returns: An HTML string. """ - # First, produce the "fake" heading, for ToC only. + # Produce a heading element that will be used later, in `AutoDocProcessor.run`, to: + # - register it in the ToC: right now we're in the inner Markdown conversion layer, + # so we have to bubble up the information to the outer Markdown conversion layer, + # for the ToC extension to pick it up. + # - register it in autorefs: right now we don't know what page is being rendered, + # so we bubble up the information again to where autorefs knows the page, + # and can correctly register the heading anchor (id) to its full URL. + # - register it in the objects inventory: same as for autorefs, + # we don't know the page here, or the handler (and its domain), + # so we bubble up the information to where the mkdocstrings extension knows that. el = Element(f"h{heading_level}", attributes) if toc_label is None: - toc_label = content.unescape() if isinstance(el, Markup) else content # type: ignore + toc_label = content.unescape() if isinstance(content, Markup) else content el.set("data-toc-label", toc_label) if role: el.set("data-role", role) @@ -257,8 +337,8 @@ def do_heading( # Start with a heading that has just attributes (no text), and add a placeholder into it. el = Element(f"h{heading_level}", attributes) el.append(Element("mkdocstrings-placeholder")) - # Tell the 'toc' extension to make its additions if configured so. - toc = self._md.treeprocessors["toc"] + # Tell the inner 'toc' extension to make its additions if configured so. + toc = cast(TocTreeprocessor, self._md.treeprocessors["toc"]) if toc.use_anchors: toc.add_anchor(el, attributes["id"]) if toc.use_permalinks: @@ -268,7 +348,7 @@ def do_heading( # of the heading with a placeholder that can never occur (text can't directly contain angle brackets). # Now this HTML wrapper can be "filled" by replacing the placeholder. html_with_placeholder = tostring(el, encoding="unicode") - assert ( + assert ( # noqa: S101 html_with_placeholder.count("") == 1 ), f"Bug in mkdocstrings: failed to replace in {html_with_placeholder!r}" html = html_with_placeholder.replace("", content) @@ -284,7 +364,7 @@ def get_headings(self) -> Sequence[Element]: self._headings.clear() return result - def update_env(self, md: Markdown, config: dict) -> None: # noqa: W0613 (unused argument 'config') + def update_env(self, md: Markdown, config: dict) -> None: # noqa: ARG002 """Update the Jinja environment. Arguments: @@ -297,7 +377,8 @@ def update_env(self, md: Markdown, config: dict) -> None: # noqa: W0613 (unused self.env.filters["convert_markdown"] = self.do_convert_markdown self.env.filters["heading"] = self.do_heading - def _update_env(self, md: Markdown, config: dict): + def _update_env(self, md: Markdown, config: dict) -> None: + """Update our handler to point to our configured Markdown instance, grabbing some of the config from `md`.""" extensions = config["mdx"] + [MkdocstringsInnerExtension(self._headings)] new_md = Markdown(extensions=extensions, extension_configs=config["mdx_configs"]) @@ -308,173 +389,6 @@ def _update_env(self, md: Markdown, config: dict): self.update_env(new_md, config) -class BaseCollector: - """The base collector class. - - Inherit from this class to implement a collector. - - You will have to implement the `collect` method. - You can also implement the `teardown` method. - """ - - def collect(self, identifier: str, config: dict) -> CollectorItem: - """Collect data given an identifier and selection configuration. - - In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into - a Python dictionary for example, though the implementation is completely free. - - Arguments: - identifier: An identifier for which to collect data. For example, in Python, - it would be 'mkdocstrings.handlers' to collect documentation about the handlers module. - It can be anything that you can feed to the tool of your choice. - config: Configuration options for the tool you use to collect data. Typically called "selection" because - these options modify how the objects or documentation are "selected" in the source code. - - Returns: - Anything you want, as long as you can feed it to the renderer's `render` method. - """ # noqa: DAR202 (excess return section) - - def teardown(self) -> None: - """Teardown the collector. - - This method should be implemented to, for example, terminate a subprocess - that was started when creating the collector instance. - """ - - -class BaseHandler(BaseCollector, BaseRenderer): - """The base handler class. - - 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. - fallback_config: The configuration used to collect item during autorefs fallback. - """ - - domain: str = "default" - enable_inventory: bool = False - fallback_config: dict = {} - - # TODO: once the BaseCollector and BaseRenderer classes are removed, - # stop accepting the 'handler' parameter, and instead set a 'name' attribute on the Handler class. - # Then make the 'handler' parameter in 'get_templates_dir' optional, and use the class 'name' by default. - def __init__(self, *args: str | BaseCollector | BaseRenderer, **kwargs: str | BaseCollector | BaseRenderer) -> None: - """Initialize the object. - - Arguments: - *args: Collector and renderer, or handler name, theme and custom_templates. - **kwargs: Same thing, but with keyword arguments. - - Raises: - ValueError: When the givin parameters are invalid. - """ - # The method accepts *args and **kwargs temporarily, - # to support the transition period where the BaseCollector - # and BaseRenderer are deprecated, and the BaseHandler - # can be instantiated with both instances of collector/renderer, - # or renderer parameters, as positional parameters. - # Supported: - # handler = Handler(collector, renderer) - # handler = Handler(collector=collector, renderer=renderer) - # handler = Handler("python", "material") - # handler = Handler("python", "material", "templates") - # handler = Handler(handler="python", theme="material") - # handler = Handler(handler="python", theme="material", custom_templates="templates") - # Invalid: - # handler = Handler("python", "material", collector, renderer) - # handler = Handler("python", theme="material", collector=collector) - # handler = Handler(collector, renderer, "material") - # handler = Handler(collector, renderer, theme="material") - # handler = Handler(collector) - # handler = Handler(renderer) - # etc. - - collector = None - renderer = None - - # parsing positional arguments - str_args = [] - for arg in args: - if isinstance(arg, BaseCollector): - collector = arg - elif isinstance(arg, BaseRenderer): - renderer = arg - elif isinstance(arg, str): - str_args.append(arg) - - while len(str_args) != 3: - str_args.append(None) # type: ignore[arg-type] - - handler, theme, custom_templates = str_args - - # fetching values from keyword arguments - if "collector" in kwargs: - collector = kwargs.pop("collector") # type: ignore[assignment] - if "renderer" in kwargs: - renderer = kwargs.pop("renderer") # type: ignore[assignment] - if "handler" in kwargs: - handler = kwargs.pop("handler") # type: ignore[assignment] - if "theme" in kwargs: - theme = kwargs.pop("theme") # type: ignore[assignment] - if "custom_templates" in kwargs: - custom_templates = kwargs.pop("custom_templates") # type: ignore[assignment] - - if collector is None and renderer is not None or collector is not None and renderer is None: - raise ValueError("both 'collector' and 'renderer' must be provided") - - if collector is not None: - warnings.warn( - DeprecationWarning( - "The BaseCollector class is deprecated, and passing an instance of it " - "to your handler is deprecated as well. Instead, define the `collect` and `teardown` " - "methods directly on your handler class." - ) - ) - self.collector = collector - self.collect = collector.collect # type: ignore[assignment] - self.teardown = collector.teardown # type: ignore[assignment] - - if renderer is not None: - if {handler, theme, custom_templates} != {None}: - raise ValueError( - "'handler', 'theme' and 'custom_templates' must all be None when providing a renderer instance" - ) - warnings.warn( - DeprecationWarning( - "The BaseRenderer class is deprecated, and passing an instance of it " - "to your handler is deprecated as well. Instead, define the `render` method " - "directly on your handler class (as well as other methods and attributes like " - "`get_templates_dir`, `get_anchors`, `update_env` and `fallback_theme`, `extra_css`)." - ) - ) - self.renderer = renderer - self.render = renderer.render # type: ignore[assignment] - self.get_templates_dir = renderer.get_templates_dir # type: ignore[assignment] - self.get_anchors = renderer.get_anchors # type: ignore[assignment] - self.do_convert_markdown = renderer.do_convert_markdown # type: ignore[assignment] - self.do_heading = renderer.do_heading # type: ignore[assignment] - self.get_headings = renderer.get_headings # type: ignore[assignment] - self.update_env = renderer.update_env # type: ignore[assignment] - self._update_env = renderer._update_env # type: ignore[assignment] # noqa: WPS437 - self.fallback_theme = renderer.fallback_theme - self.extra_css = renderer.extra_css - renderer.__class__.__init__( # noqa: WPS609 - self, - renderer._handler, # noqa: WPS437 - renderer._theme, # noqa: WPS437 - renderer._custom_templates, # noqa: WPS437 - ) - else: - if handler is None or theme is None: - raise ValueError("'handler' and 'theme' cannot be None") - BaseRenderer.__init__(self, handler, theme, custom_templates) # noqa: WPS609 - - class Handlers: """A collection of handlers. @@ -490,14 +404,14 @@ def __init__(self, config: dict) -> None: of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. """ self._config = config - self._handlers: Dict[str, BaseHandler] = {} - self.inventory: Inventory = Inventory(project=self._config["site_name"]) + self._handlers: dict[str, BaseHandler] = {} + self.inventory: Inventory = Inventory(project=self._config["mkdocs"]["site_name"]) - def get_anchors(self, identifier: str) -> Sequence[str]: + def get_anchors(self, identifier: str) -> tuple[str, ...]: """Return the canonical HTML anchor for the identifier, if any of the seen handlers can collect it. Arguments: - identifier: The identifier (one that [collect][mkdocstrings.handlers.base.BaseCollector.collect] can accept). + identifier: The identifier (one that [collect][mkdocstrings.handlers.base.BaseHandler.collect] can accept). Returns: A tuple of strings - anchors without '#', or an empty tuple if there isn't any identifier familiar with it. @@ -540,7 +454,7 @@ def get_handler_config(self, name: str) -> dict: return handlers.get(name, {}) return {} - def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseHandler: + def get_handler(self, name: str, handler_config: dict | None = None) -> BaseHandler: """Get a handler thanks to its name. This function dynamically imports a module named "mkdocstrings.handlers.NAME", calls its @@ -554,26 +468,17 @@ def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseH Returns: An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler], - as instantiated by the `get_handler` method of the handler's module. + as instantiated by the `get_handler` method of the handler's module. """ if name not in self._handlers: if handler_config is None: handler_config = self.get_handler_config(name) - try: - module = importlib.import_module(f"mkdocstrings_handlers.{name}") - except ModuleNotFoundError: - module = importlib.import_module(f"mkdocstrings.handlers.{name}") - if name != "python": - warnings.warn( - DeprecationWarning( - "Using the mkdocstrings.handlers namespace is deprecated. " - "Handlers must now use the mkdocstrings_handlers namespace." - ) - ) + handler_config.update(self._config) + module = importlib.import_module(f"mkdocstrings_handlers.{name}") self._handlers[name] = module.get_handler( theme=self._config["theme_name"], custom_templates=self._config["mkdocstrings"]["custom_templates"], - config_file_path=self._config["config_file_path"], + config_file_path=self._config["mkdocs"]["config_file_path"], **handler_config, ) return self._handlers[name] diff --git a/src/mkdocstrings/handlers/rendering.py b/src/mkdocstrings/handlers/rendering.py index 24ee6268..1db3c8f1 100644 --- a/src/mkdocstrings/handlers/rendering.py +++ b/src/mkdocstrings/handlers/rendering.py @@ -1,18 +1,23 @@ """This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers.""" +from __future__ import annotations + import copy import re import textwrap -from typing import Any, Dict, List, Optional -from xml.etree.ElementTree import Element +from typing import TYPE_CHECKING, Any -from markdown import Markdown from markdown.extensions import Extension from markdown.extensions.codehilite import CodeHiliteExtension from markdown.treeprocessors import Treeprocessor from markupsafe import Markup from pymdownx.highlight import Highlight, HighlightExtension +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from markdown import Markdown + class Highlighter(Highlight): """Code highlighter that tries to match the Markdown configuration. @@ -38,6 +43,7 @@ class Highlighter(Highlight): ( "css_class", "guess_lang", + "default_lang", "pygments_style", "noclasses", "use_pygments", @@ -53,7 +59,9 @@ class Highlighter(Highlight): "line_spans", "anchor_linenums", "line_anchors", - ) + "pygments_lang_class", + "stripnl", + ), ) def __init__(self, md: Markdown): @@ -62,25 +70,28 @@ def __init__(self, md: Markdown): Arguments: md: The Markdown instance to read configs from. """ - config: Dict[str, Any] = {} + config: dict[str, Any] = {} + self._highlighter: str | None = None for ext in md.registeredExtensions: if isinstance(ext, HighlightExtension) and (ext.enabled or not config): + self._highlighter = "highlight" config = ext.getConfigs() break # This one takes priority, no need to continue looking if isinstance(ext, CodeHiliteExtension) and not config: + self._highlighter = "codehilite" config = ext.getConfigs() config["language_prefix"] = config["lang_prefix"] self._css_class = config.pop("css_class", "highlight") 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) + def highlight( self, src: str, - language: Optional[str] = None, + language: str | None = None, *, inline: bool = False, dedent: bool = True, - linenums: Optional[bool] = None, + linenums: bool | None = None, **kwargs: Any, ) -> str: """Highlight a code-snippet. @@ -102,7 +113,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 # type: ignore + old_linenums = self.linenums # type: ignore[has-type] if linenums is not None: self.linenums = linenums try: @@ -111,7 +122,11 @@ def highlight( # noqa: W0221 (intentionally different params, we're extending t self.linenums = old_linenums if inline: - return Markup(f'{result.text}') + # From the maintainer of codehilite, the codehilite CSS class, as defined by the user, + # should never be added to inline code, because codehilite does not support inline code. + # See https://github.com/Python-Markdown/markdown/issues/1220#issuecomment-1692160297. + css_class = "" if self._highlighter == "codehilite" else kwargs["css_class"] + return Markup(f'{result.text}') return Markup(result) @@ -133,20 +148,36 @@ def __init__(self, md: Markdown, id_prefix: str): super().__init__(md) self.id_prefix = id_prefix - def run(self, root: Element): # noqa: D102 (ignore missing docstring) - if not self.id_prefix: - return - for el in root.iter(): - id_attr = el.get("id") - if id_attr: - el.set("id", self.id_prefix + id_attr) + def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring) + if self.id_prefix: + self._prefix_ids(root) + def _prefix_ids(self, root: Element) -> None: + index = len(root) + for el in reversed(root): # Reversed mainly for the ability to mutate during iteration. + index -= 1 + + self._prefix_ids(el) href_attr = el.get("href") + + if id_attr := el.get("id"): + if el.tag == "a" and not href_attr: + # An anchor with id and no href is used by autorefs: + # leave it untouched and insert a copy with updated id after it. + new_el = copy.deepcopy(el) + new_el.set("id", self.id_prefix + id_attr) + root.insert(index + 1, new_el) + else: + # Anchors with id and href are not used by autorefs: + # update in place. + el.set("id", self.id_prefix + id_attr) + + # Always update hrefs, names and labels-for: + # there will always be a corresponding id. if href_attr and href_attr.startswith("#"): el.set("href", "#" + self.id_prefix + href_attr[1:]) - name_attr = el.get("name") - if name_attr: + if name_attr := el.get("name"): el.set("name", self.id_prefix + name_attr) if el.tag == "label": @@ -174,7 +205,7 @@ def __init__(self, md: Markdown, shift_by: int): super().__init__(md) self.shift_by = shift_by - def run(self, root: Element): # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring) if not self.shift_by: return for el in root.iter(): @@ -191,20 +222,21 @@ class _HeadingReportingTreeprocessor(Treeprocessor): name = "mkdocstrings_headings_list" regex = re.compile(r"[Hh][1-6]") - headings: List[Element] + headings: list[Element] """The list (the one passed in the initializer) that is used to record the heading elements (by appending to it).""" - def __init__(self, md: Markdown, headings: List[Element]): + def __init__(self, md: Markdown, headings: list[Element]): super().__init__(md) self.headings = headings - def run(self, root: Element): + def run(self, root: Element) -> None: + permalink_class = self.md.treeprocessors["toc"].permalink_class # type: ignore[attr-defined] for el in root.iter(): if self.regex.fullmatch(el.tag): - el = copy.copy(el) + el = copy.copy(el) # noqa: PLW2901 # '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: # noqa: WPS507 + if len(el) > 0 and el[-1].get("class") == permalink_class: del el[-1] self.headings.append(el) @@ -215,17 +247,18 @@ class ParagraphStrippingTreeprocessor(Treeprocessor): name = "mkdocstrings_strip_paragraph" strip = False - def run(self, root: Element): # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> Element | None: # noqa: D102 (ignore missing docstring) if self.strip and len(root) == 1 and root[0].tag == "p": # Turn the single

element into the root element and inherit its tag name (it's significant!) root[0].tag = root.tag return root[0] + return None class MkdocstringsInnerExtension(Extension): """Extension that should always be added to Markdown sub-documents that handlers request (and *only* them).""" - def __init__(self, headings: List[Element]): + def __init__(self, headings: list[Element]): """Initialize the object. Arguments: diff --git a/src/mkdocstrings/inventory.py b/src/mkdocstrings/inventory.py index 6c1b8558..fb2d0018 100644 --- a/src/mkdocstrings/inventory.py +++ b/src/mkdocstrings/inventory.py @@ -3,17 +3,28 @@ # Credits to Brian Skinn and the sphobjinv project: # https://github.com/bskinn/sphobjinv +from __future__ import annotations + import re import zlib from textwrap import dedent -from typing import BinaryIO, Collection, List, Optional +from typing import TYPE_CHECKING, BinaryIO + +if TYPE_CHECKING: + from collections.abc import Collection class InventoryItem: """Inventory item.""" def __init__( - self, name: str, domain: str, role: str, uri: str, priority: str = "1", dispname: Optional[str] = None + self, + name: str, + domain: str, + role: str, + uri: str, + priority: int = 1, + dispname: str | None = None, ): """Initialize the object. @@ -22,14 +33,14 @@ def __init__( 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. + priority: The item priority. Only used internally by mkdocstrings and Sphinx. 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.priority: int = priority self.dispname: str = dispname or name def format_sphinx(self) -> str: @@ -46,10 +57,10 @@ def format_sphinx(self) -> str: 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+(.*)$") + sphinx_item_regex = re.compile(r"^(.+?)\s+(\S+):(\S+)\s+(-?\d+)\s+(\S+)\s*(.*)$") @classmethod - def parse_sphinx(cls, line: str) -> "InventoryItem": + 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: @@ -59,13 +70,13 @@ def parse_sphinx(cls, line: str) -> "InventoryItem": uri = uri[:-1] + name if dispname == "-": dispname = name - return cls(name, domain, role, uri, priority, dispname) + return cls(name, domain, role, uri, int(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"): + def __init__(self, items: list[InventoryItem] | None = None, project: str = "project", version: str = "0.0.0"): """Initialize the object. Arguments: @@ -80,15 +91,33 @@ def __init__(self, items: Optional[List[InventoryItem]] = None, project: str = " self.project = project self.version = version - def register(self, *args: str, **kwargs: str): + def register( + self, + name: str, + domain: str, + role: str, + uri: str, + priority: int = 1, + dispname: str | None = None, + ) -> None: """Create and register an item. Arguments: - *args: Arguments passed to [InventoryItem][mkdocstrings.inventory.InventoryItem]. - **kwargs: Keyword arguments passed to [InventoryItem][mkdocstrings.inventory.InventoryItem]. + 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. Only used internally by mkdocstrings and Sphinx. + dispname: The item display name. """ - item = InventoryItem(*args, **kwargs) - self[item.name] = item + self[name] = InventoryItem( + name=name, + domain=domain, + role=role, + uri=uri, + priority=priority, + dispname=dispname, + ) def format_sphinx(self) -> bytes: """Format this inventory as a Sphinx `objects.inv` file. @@ -103,17 +132,20 @@ def format_sphinx(self) -> bytes: # 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()] + lines = [ + item.format_sphinx().encode("utf8") + for item in sorted(self.values(), key=lambda item: (item.domain, item.name)) + ] return header + zlib.compress(b"\n".join(lines) + b"\n", 9) @classmethod - def parse_sphinx(cls, in_file: BinaryIO, *, domain_filter: Collection[str] = ()) -> "Inventory": + 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: @@ -121,7 +153,7 @@ def parse_sphinx(cls, in_file: BinaryIO, *, domain_filter: Collection[str] = ()) domain_filter: A collection of domain values to allow (and filter out all other ones). Returns: - An `Inventory` containing the collected `InventoryItem`s. + An inventory containing the collected items. """ for _ in range(4): in_file.readline() diff --git a/src/mkdocstrings/loggers.py b/src/mkdocstrings/loggers.py index d2722616..89f3d7f8 100644 --- a/src/mkdocstrings/loggers.py +++ b/src/mkdocstrings/loggers.py @@ -1,28 +1,51 @@ """Logging functions.""" +from __future__ import annotations + import logging from contextlib import suppress from pathlib import Path -from typing import Any, Callable, MutableMapping, Optional, Sequence, Tuple - -from jinja2.runtime import Context -from mkdocs.utils import warning_filter +from typing import TYPE_CHECKING, Any, Callable try: from jinja2 import pass_context except ImportError: # TODO: remove once Jinja2 < 3.1 is dropped - from jinja2 import contextfunction as pass_context # noqa: WPS440 + from jinja2 import contextfunction as pass_context # type: ignore[attr-defined,no-redef] try: import mkdocstrings_handlers except ImportError: TEMPLATES_DIRS: Sequence[Path] = () else: - TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__) # noqa: WPS609 + TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__) + + +if TYPE_CHECKING: + from collections.abc import MutableMapping, Sequence + + from jinja2.runtime import Context class LoggerAdapter(logging.LoggerAdapter): - """A logger adapter to prefix messages.""" + """A logger adapter to prefix messages. + + This adapter also adds an additional parameter to logging methods + called `once`: if `True`, the message will only be logged once. + + Examples: + In Python code: + + >>> logger = get_logger("myplugin") + >>> logger.debug("This is a debug message.") + >>> logger.info("This is an info message.", once=True) + + In Jinja templates (logger available in context as `log`): + + ```jinja + {{ log.debug("This is a debug message.") }} + {{ log.info("This is an info message.", once=True) }} + ``` + """ def __init__(self, prefix: str, logger: logging.Logger): """Initialize the object. @@ -33,8 +56,9 @@ def __init__(self, prefix: str, logger: logging.Logger): """ super().__init__(logger, {}) self.prefix = prefix + self._logged: set[tuple[LoggerAdapter, str]] = set() - def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> Tuple[str, Any]: + def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, Any]: """Process the message. Arguments: @@ -46,11 +70,32 @@ def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> Tuple[str, Any] """ return f"{self.prefix}: {msg}", kwargs + def log(self, level: int, msg: object, *args: object, **kwargs: object) -> None: + """Log a message. + + Arguments: + level: The logging level. + msg: The message. + *args: Additional arguments passed to parent method. + **kwargs: Additional keyword arguments passed to parent method. + """ + if kwargs.pop("once", False): + if (key := (self, str(msg))) in self._logged: + return + self._logged.add(key) + super().log(level, msg, *args, **kwargs) # type: ignore[arg-type] + class TemplateLogger: """A wrapper class to allow logging in templates. - Attributes: + The logging methods provided by this class all accept + two parameters: + + - `msg`: The message to log. + - `once`: If `True`, the message will only be logged once. + + Methods: debug: Function to log a DEBUG message. info: Function to log an INFO message. warning: Function to log a WARNING message. @@ -71,6 +116,27 @@ def __init__(self, logger: LoggerAdapter): self.critical = get_template_logger_function(logger.critical) +class _Lazy: + unset = object() + + def __init__(self, func: Callable, *args: Any, **kwargs: Any): + self.func = func + self.args = args + self.kwargs = kwargs + self.result = self.unset + + def __call__(self): + if self.result is self.unset: + self.result = self.func(*self.args, **self.kwargs) + return self.result + + def __str__(self) -> str: + return str(self()) + + def __repr__(self) -> str: + return repr(self()) + + def get_template_logger_function(logger_func: Callable) -> Callable: """Create a wrapper function that automatically receives the Jinja template context. @@ -82,18 +148,18 @@ def get_template_logger_function(logger_func: Callable) -> Callable: """ @pass_context - def wrapper(context: Context, msg: Optional[str] = None) -> str: + def wrapper(context: Context, msg: str | None = None, *args: Any, **kwargs: Any) -> str: """Log a message. Arguments: context: The template context, automatically provided by Jinja. msg: The message to log. + **kwargs: Additional arguments passed to the logger function. Returns: An empty string. """ - template_path = get_template_path(context) - logger_func(f"{template_path}: {msg or 'Rendering'}") + logger_func(f"%s: {msg or 'Rendering'}", _Lazy(get_template_path, context), *args, **kwargs) return "" return wrapper @@ -130,14 +196,17 @@ def get_logger(name: str) -> LoggerAdapter: A logger configured to work well in MkDocs. """ logger = logging.getLogger(f"mkdocs.plugins.{name}") - logger.addFilter(warning_filter) return LoggerAdapter(name.split(".", 1)[0], logger) -def get_template_logger() -> TemplateLogger: +def get_template_logger(handler_name: str | None = None) -> TemplateLogger: """Return a logger usable in templates. + Parameters: + handler_name: The name of the handler. + Returns: A template logger. """ - return TemplateLogger(get_logger("mkdocstrings.templates")) + handler_name = handler_name or "base" + return TemplateLogger(get_logger(f"mkdocstrings_handlers.{handler_name}.templates")) diff --git a/src/mkdocstrings/plugin.py b/src/mkdocstrings/plugin.py index 34edcc06..28060b6b 100644 --- a/src/mkdocstrings/plugin.py +++ b/src/mkdocstrings/plugin.py @@ -12,69 +12,67 @@ during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve). """ -import collections +from __future__ import annotations + +import datetime import functools -import gzip import os +import sys +from collections.abc import Iterable, Mapping from concurrent import futures -from typing import Any, BinaryIO, Callable, Iterable, List, Mapping, Optional, Tuple -from urllib import request -from warnings import warn +from io import BytesIO +from typing import TYPE_CHECKING, Any, Callable, TypeVar from mkdocs.config import Config -from mkdocs.config.config_options import Type as MkType -from mkdocs.livereload import LiveReloadServer +from mkdocs.config import config_options as opt from mkdocs.plugins import BasePlugin from mkdocs.utils import write_file -from mkdocs_autorefs.plugin import AutorefsPlugin +from mkdocs_autorefs.plugin import AutorefsConfig, AutorefsPlugin +from mkdocstrings._cache import download_and_cache_url, download_url_with_gz from mkdocstrings.extension import MkdocstringsExtension from mkdocstrings.handlers.base import BaseHandler, Handlers from mkdocstrings.loggers import get_logger +if TYPE_CHECKING: + from jinja2.environment import Environment + from mkdocs.config.defaults import MkDocsConfig + +if sys.version_info < (3, 10): + from typing_extensions import ParamSpec +else: + from typing import ParamSpec + log = get_logger(__name__) -SELECTION_OPTS_KEY: str = "selection" -"""The name of the selection parameter in YAML configuration blocks.""" -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]]] -InventoryImportType = List[Tuple[str, Mapping[str, Any]]] -InventoryLoaderType = Callable[..., Iterable[Tuple[str, str]]] +P = ParamSpec("P") +R = TypeVar("R") -class MkdocstringsPlugin(BasePlugin): - """An `mkdocs` plugin. +def list_to_tuple(function: Callable[P, R]) -> Callable[P, R]: + """Decorater to convert lists to tuples in the arguments.""" - This plugin defines the following event hooks: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + safe_args = [tuple(item) if isinstance(item, list) else item for item in args] + if kwargs: + kwargs = {key: tuple(value) if isinstance(value, list) else value for key, value in kwargs.items()} # type: ignore[assignment] + return function(*safe_args, **kwargs) # type: ignore[arg-type] - - `on_config` - - `on_env` - - `on_post_build` - - `on_serve` + return wrapper - Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs` - for more information about its plugin system. - """ - config_scheme: Tuple[Tuple[str, MkType]] = ( - ("watch", MkType(list, default=[])), # type: ignore - ("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`. +class PluginConfig(Config): + """The configuration options of `mkdocstrings`, written in `mkdocs.yml`.""" - Available options are: + handlers = opt.Type(dict, default={}) + """ + Global configuration of handlers. - - **`watch` (deprecated)**: A list of directories to watch. Only used when serving the documentation with mkdocs. - Whenever a file changes in one of directories, the whole documentation is built again, and the browser refreshed. - Deprecated in favor of the now built-in `watch` feature of MkDocs. - - **`default_handler`**: The default handler to use. The value is the name of the handler module. Default is "python". - - **`handlers`**: Global configuration of handlers. You can set global configuration per handler, applied everywhere, - but overridable in each "autodoc" instruction. Example: + You can set global configuration per handler, applied everywhere, + but overridable in each "autodoc" instruction. Example: ```yaml plugins: @@ -82,20 +80,46 @@ class MkdocstringsPlugin(BasePlugin): handlers: python: options: - selection_opt: true - rendering_opt: "value" + option1: true + option2: "value" rust: options: - selection_opt: 2 + option9: 2 ``` """ + default_handler = opt.Type(str, default="python") + """The default handler to use. The value is the name of the handler module. Default is "python".""" + custom_templates = opt.Optional(opt.Dir(exists=True)) + """Location of custom templates to use when rendering API objects. + + Value should be the path of a directory relative to the MkDocs configuration file. + """ + enable_inventory = opt.Optional(opt.Type(bool)) + """Whether to enable object inventory creation.""" + enabled = opt.Type(bool, default=True) + """Whether to enable the plugin. Default is true. If false, *mkdocstrings* will not collect or render anything.""" + + +class MkdocstringsPlugin(BasePlugin[PluginConfig]): + """An `mkdocs` plugin. + + This plugin defines the following event hooks: + + - `on_config` + - `on_env` + - `on_post_build` + + Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs` + for more information about its plugin system. + """ + css_filename = "assets/_mkdocstrings.css" def __init__(self) -> None: """Initialize the object.""" super().__init__() - self._handlers: Optional[Handlers] = None + self._handlers: Handlers | None = None @property def handlers(self) -> Handlers: @@ -111,31 +135,7 @@ def handlers(self) -> Handlers: raise RuntimeError("The plugin hasn't been initialized with a config yet") return self._handlers - # TODO: remove once watch feature is removed - def on_serve(self, server: LiveReloadServer, builder: Callable, **kwargs: Any): # noqa: W0613 (unused arguments) - """Watch directories. - - Hook for the [`on_serve` event](https://www.mkdocs.org/user-guide/plugins/#on_serve). - In this hook, we add the directories specified in the plugin's configuration to the list of directories - watched by `mkdocs`. Whenever a change occurs in one of these directories, the documentation is built again - and the site reloaded. - - Arguments: - server: The `livereload` server instance. - builder: The function to build the site. - **kwargs: Additional arguments passed by MkDocs. - """ - if self.config["watch"]: - warn( - "mkdocstrings' watch feature is deprecated in favor of MkDocs' watch feature, " - "see https://www.mkdocs.org/user-guide/configuration/#watch.", - DeprecationWarning, - ) - for element in self.config["watch"]: - log.debug(f"Adding directory '{element}' to watcher") - server.watch(element, builder) - - def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: W0613 (unused arguments) + def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None: """Instantiate our Markdown extension. Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config). @@ -147,62 +147,64 @@ def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: W0613 (un Arguments: config: The MkDocs config object. - **kwargs: Additional arguments passed by MkDocs. Returns: The modified config. """ + if not self.plugin_enabled: + log.debug("Plugin is not enabled. Skipping.") + return config log.debug("Adding extension to the list") - theme_name = None - if config["theme"].name is None: - theme_name = os.path.dirname(config["theme"].dirs[0]) - else: - theme_name = config["theme"].name + theme_name = config.theme.name or os.path.dirname(config.theme.dirs[0]) to_import: InventoryImportType = [] - for handler_name, conf in self.config["handlers"].items(): + 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} + import_item = {"url": import_item} # noqa: PLW2901 to_import.append((handler_name, import_item)) extension_config = { - "site_name": config["site_name"], - "config_file_path": config["config_file_path"], "theme_name": theme_name, - "mdx": config["markdown_extensions"], - "mdx_configs": config["mdx_configs"], + "mdx": config.markdown_extensions, + "mdx_configs": config.mdx_configs, "mkdocstrings": self.config, + "mkdocs": config, } self._handlers = Handlers(extension_config) - try: # noqa: WPS229 + autorefs: AutorefsPlugin + try: # If autorefs plugin is explicitly enabled, just use it. - autorefs = config["plugins"]["autorefs"] - log.debug(f"Picked up existing autorefs instance {autorefs!r}") + autorefs = config.plugins["autorefs"] # type: ignore[assignment] + log.debug("Picked up existing autorefs instance %r", autorefs) except KeyError: # Otherwise, add a limited instance of it that acts only on what's added through `register_anchor`. autorefs = AutorefsPlugin() + autorefs.config = AutorefsConfig() autorefs.scan_toc = False - config["plugins"]["autorefs"] = autorefs - log.debug(f"Added a subdued autorefs instance {autorefs!r}") + config.plugins["autorefs"] = autorefs + log.debug("Added a subdued autorefs instance %r", autorefs) # Add collector-based fallback in either case. autorefs.get_fallback_anchor = self.handlers.get_anchors mkdocstrings_extension = MkdocstringsExtension(extension_config, self.handlers, autorefs) - config["markdown_extensions"].append(mkdocstrings_extension) + config.markdown_extensions.append(mkdocstrings_extension) # type: ignore[arg-type] - config["extra_css"].insert(0, self.css_filename) # So that it has lower priority than user files. + config.extra_css.insert(0, self.css_filename) # So that it has lower priority than user files. - self._inv_futures = [] + self._inv_futures = {} if to_import: inv_loader = futures.ThreadPoolExecutor(4) - for handler_name, import_item in to_import: # noqa: WPS440 + for handler_name, import_item in to_import: + loader = self.get_handler(handler_name).load_inventory future = inv_loader.submit( - self._load_inventory, self.get_handler(handler_name).load_inventory, **import_item + self._load_inventory, # type: ignore[misc] + loader, + **import_item, ) - self._inv_futures.append(future) + self._inv_futures[future] = (loader, import_item) inv_loader.shutdown(wait=False) return config @@ -214,12 +216,21 @@ def inventory_enabled(self) -> bool: Returns: Whether the inventory is enabled. """ - inventory_enabled = self.config["enable_inventory"] + 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): + @property + def plugin_enabled(self) -> bool: + """Tell if the plugin is enabled or not. + + Returns: + Whether the plugin is enabled. + """ + return self.config.enabled + + def on_env(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 """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). @@ -227,25 +238,38 @@ def on_env(self, env, config: Config, **kwargs): - Write mkdocstrings' extra files into the site dir. - Gather results from background inventory download tasks. """ + if not self.plugin_enabled: + return if self._handlers: css_content = "\n".join(handler.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)) + 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")) + 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)") + log.debug("Waiting for %s inventory download(s)", len(self._inv_futures)) 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 = [] + results = {} + # Reversed order so that pages from first futures take precedence: + for fut in reversed(list(self._inv_futures)): + try: + results.update(fut.result()) + except Exception as error: # noqa: BLE001 + loader, import_item = self._inv_futures[fut] + loader_name = loader.__func__.__qualname__ + log.error("Couldn't load inventory %s through %s: %s", import_item, loader_name, error) # noqa: TRY400 + for page, identifier in results.items(): + config.plugins["autorefs"].register_url(page, identifier) # type: ignore[attr-defined] + self._inv_futures = {} def on_post_build( - self, config: Config, **kwargs: Any - ) -> None: # noqa: W0613,R0201 (unused arguments, cannot be static) + self, + config: MkDocsConfig, # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> None: """Teardown the handlers. Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build). @@ -253,12 +277,15 @@ def on_post_build( For example, a handler could open a subprocess in the background and keep it open to feed it "autodoc" instructions and get back JSON data. If so, it should then close the subprocess at some point: - the proper place to do this is in the collector's `teardown` method, which is indirectly called by this hook. + the proper place to do this is in the handler's `teardown` method, which is indirectly called by this hook. Arguments: config: The MkDocs config object. **kwargs: Additional arguments passed by MkDocs. """ + if not self.plugin_enabled: + return + for future in self._inv_futures: future.cancel() @@ -278,7 +305,9 @@ def get_handler(self, handler_name: str) -> BaseHandler: return self.handlers.get_handler(handler_name) @classmethod - @functools.lru_cache(maxsize=None) + # lru_cache does not allow mutable arguments such lists, but that is what we load from YAML config. + @list_to_tuple + @functools.cache def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) -> Mapping[str, str]: """Download and process inventory files using a handler. @@ -290,12 +319,8 @@ def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) - 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") + log.debug("Downloading inventory from %s", url) + content = download_and_cache_url(url, download_url_with_gz, datetime.timedelta(days=1)) + result = dict(loader(BytesIO(content), url=url, **kwargs)) + log.debug("Loaded inventory from %s: %s items", url, len(result)) return result diff --git a/tests/conftest.py b/tests/conftest.py index 7025b8fd..74688fe7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,26 +1,29 @@ """Configuration for the pytest test suite.""" +from __future__ import annotations + from collections import ChainMap +from typing import TYPE_CHECKING, Any import pytest from markdown.core import Markdown -from mkdocs import config +from mkdocs.config.defaults import MkDocsConfig + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path -try: - from mkdocs.config.defaults import get_schema -except ImportError: + from mkdocs import config - def get_schema(): # noqa: WPS440 - """Fallback for old versions of MkDocs.""" - return config.DEFAULT_SCHEMA + from mkdocstrings.plugin import MkdocstringsPlugin @pytest.fixture(name="mkdocs_conf") -def fixture_mkdocs_conf(request, tmp_path): +def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[config.Config]: """Yield a MkDocs configuration object.""" - conf = config.Config(schema=get_schema()) - while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"): # noqa: WPS437 - request = request._parent_request # noqa: WPS437 + conf = MkDocsConfig() + while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"): + request = request._parent_request conf_dict = { "site_name": "foo", @@ -30,7 +33,7 @@ def fixture_mkdocs_conf(request, tmp_path): **getattr(request, "param", {}), } # Re-create it manually as a workaround for https://github.com/mkdocs/mkdocs/issues/2289 - mdx_configs = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) + mdx_configs: dict[str, Any] = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) conf.load_dict(conf_dict) assert conf.validate() == ([], []) @@ -45,14 +48,12 @@ def fixture_mkdocs_conf(request, tmp_path): @pytest.fixture(name="plugin") -def fixture_plugin(mkdocs_conf): +def fixture_plugin(mkdocs_conf: config.Config) -> MkdocstringsPlugin: """Return a plugin instance.""" - plugin = mkdocs_conf["plugins"]["mkdocstrings"] - plugin.md = Markdown(extensions=mkdocs_conf["markdown_extensions"], extension_configs=mkdocs_conf["mdx_configs"]) - return plugin + return mkdocs_conf["plugins"]["mkdocstrings"] @pytest.fixture(name="ext_markdown") -def fixture_ext_markdown(plugin): +def fixture_ext_markdown(mkdocs_conf: MkDocsConfig) -> Markdown: """Return a Markdown instance with MkdocstringsExtension.""" - return plugin.md + return Markdown(extensions=mkdocs_conf["markdown_extensions"], extension_configs=mkdocs_conf["mdx_configs"]) diff --git a/tests/fixtures/builtin.py b/tests/fixtures/builtin.py deleted file mode 100644 index cab198e3..00000000 --- a/tests/fixtures/builtin.py +++ /dev/null @@ -1,2 +0,0 @@ -def func(foo=print): - """test""" diff --git a/tests/fixtures/headings_many.py b/tests/fixtures/headings_many.py new file mode 100644 index 00000000..fa643a48 --- /dev/null +++ b/tests/fixtures/headings_many.py @@ -0,0 +1,10 @@ +def heading_1(): + """## Heading one""" + + +def heading_2(): + """### Heading two""" + + +def heading_3(): + """#### Heading three""" diff --git a/tests/fixtures/markdown_anchors.py b/tests/fixtures/markdown_anchors.py new file mode 100644 index 00000000..74cea744 --- /dev/null +++ b/tests/fixtures/markdown_anchors.py @@ -0,0 +1,16 @@ +"""Module docstring. + +[](){#anchor} + +Paragraph. + +[](){#heading-anchor-1} +[](){#heading-anchor-2} +[](){#heading-anchor-3} +## Heading + +[](#has-href1) +[](#has-href2){#with-id} + +Pararaph. +""" \ No newline at end of file diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 00000000..b56e3d3c --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,103 @@ +"""Tests for the internal mkdocstrings _cache module.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from mkdocstrings import _cache + +if TYPE_CHECKING: + from collections.abc import Mapping + + +@pytest.mark.parametrize( + ("credential", "expected", "env"), + [ + ("USER", "USER", {"USER": "testuser"}), + ("$USER", "$USER", {"USER": "testuser"}), + ("${USER", "${USER", {"USER": "testuser"}), + ("$USER}", "$USER}", {"USER": "testuser"}), + ("${TOKEN}", "testtoken", {"TOKEN": "testtoken"}), + ("${USER}:${PASSWORD}", "${USER}:testpass", {"PASSWORD": "testpass"}), + ("${USER}:${PASSWORD}", "testuser:testpass", {"USER": "testuser", "PASSWORD": "testpass"}), + ( + "user_prefix_${USER}_user_$uffix:pwd_prefix_${PASSWORD}_pwd_${uffix", + "user_prefix_testuser_user_$uffix:pwd_prefix_testpass_pwd_${uffix", + {"USER": "testuser", "PASSWORD": "testpass"}, + ), + ], +) +def test_expand_env_vars(credential: str, expected: str, env: Mapping[str, str]) -> None: + """Test expanding environment variables.""" + assert _cache._expand_env_vars(credential, url="https://test.example.com", env=env) == expected + + +def test_expand_env_vars_with_missing_env_var(caplog: pytest.LogCaptureFixture) -> None: + """Test expanding environment variables with a missing environment variable.""" + caplog.set_level(logging.WARNING, logger="mkdocs.plugins.mkdocstrings._cache") + + credential = "${USER}" + env: dict[str, str] = {} + assert _cache._expand_env_vars(credential, url="https://test.example.com", env=env) == "${USER}" + + output = caplog.records[0].getMessage() + assert "'USER' is not set" in output + + +@pytest.mark.parametrize( + ("url", "expected_url"), + [ + ("http://host/path", "http://host/path"), + ("http://token@host/path", "http://host/path"), + ("http://${token}@host/path", "http://host/path"), + ("http://username:password@host/path", "http://host/path"), + ("http://username:${PASSWORD}@host/path", "http://host/path"), + ("http://${USERNAME}:${PASSWORD}@host/path", "http://host/path"), + ("http://prefix${USERNAME}suffix:prefix${PASSWORD}suffix@host/path", "http://host/path"), + ], +) +def test_extract_auth_from_url(monkeypatch: pytest.MonkeyPatch, url: str, expected_url: str) -> None: + """Test extracting the auth part from the URL.""" + monkeypatch.setattr(_cache, "_create_auth_header", lambda *args, **kwargs: {}) + result_url, _result_auth_header = _cache._extract_auth_from_url(url) + assert result_url == expected_url + + +def test_create_auth_header_basic_auth() -> None: + """Test creating the Authorization header for basic authentication.""" + auth_header = _cache._create_auth_header(credential="testuser:testpass", url="https://test.example.com") + assert auth_header == {"Authorization": "Basic dGVzdHVzZXI6dGVzdHBhc3M="} + + +def test_create_auth_header_bearer_auth() -> None: + """Test creating the Authorization header for bearer token authentication.""" + auth_header = _cache._create_auth_header(credential="token123", url="https://test.example.com") + assert auth_header == {"Authorization": "Bearer token123"} + + +@pytest.mark.parametrize( + ("var", "match"), + [ + ("${var}", "var"), + ("${VAR}", "VAR"), + ("${_}", "_"), + ("${_VAR}", "_VAR"), + ("${VAR123}", "VAR123"), + ("${VAR123_}", "VAR123_"), + ("VAR", None), + ("$1VAR", None), + ("${1VAR}", None), + ("${}", None), + ("${ }", None), + ], +) +def test_env_var_pattern(var: str, match: str | None) -> None: + """Test the environment variable regex pattern.""" + _match = _cache.ENV_VAR_PATTERN.match(var) + if _match is None: + assert match is _match + else: + assert _match.group(1) == match diff --git a/tests/test_extension.py b/tests/test_extension.py index df388723..976f376c 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,13 +1,22 @@ """Tests for the extension module.""" + +from __future__ import annotations + import re import sys from textwrap import dedent +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from markdown import Markdown + + from mkdocstrings.plugin import MkdocstringsPlugin + @pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"footnotes": {}}]}], indirect=["ext_markdown"]) -def test_multiple_footnotes(ext_markdown): +def test_multiple_footnotes(ext_markdown: Markdown) -> None: """Assert footnotes don't get added to subsequent docstrings.""" output = ext_markdown.convert( dedent( @@ -29,15 +38,15 @@ def test_multiple_footnotes(ext_markdown): assert output.count("Top footnote") == 1 -def test_markdown_heading_level(ext_markdown): +def test_markdown_heading_level(ext_markdown: Markdown) -> None: """Assert that Markdown headings' level doesn't exceed heading_level.""" - output = ext_markdown.convert("::: tests.fixtures.headings\n rendering:\n show_root_heading: true") + output = ext_markdown.convert("::: tests.fixtures.headings\n options:\n show_root_heading: true") assert ">Foo" in output assert ">Bar" in output assert ">Baz" in output -def test_keeps_preceding_text(ext_markdown): +def test_keeps_preceding_text(ext_markdown: Markdown) -> None: """Assert that autodoc is recognized in the middle of a block and preceding text is kept.""" output = ext_markdown.convert("**preceding**\n::: tests.fixtures.headings") assert "preceding" in output @@ -45,21 +54,21 @@ def test_keeps_preceding_text(ext_markdown): assert ":::" not in output -def test_reference_inside_autodoc(ext_markdown): +def test_reference_inside_autodoc(ext_markdown: Markdown) -> None: """Assert cross-reference Markdown extension works correctly.""" output = ext_markdown.convert("::: tests.fixtures.cross_reference") 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") -def test_quote_inside_annotation(ext_markdown): +def test_quote_inside_annotation(ext_markdown: Markdown) -> None: """Assert that inline highlighting doesn't double-escape HTML.""" output = ext_markdown.convert("::: tests.fixtures.string_annotation.Foo") assert ";hi&" in output assert "&" not in output -def test_html_inside_heading(ext_markdown): +def test_html_inside_heading(ext_markdown: Markdown) -> None: """Assert that headings don't double-escape HTML.""" output = ext_markdown.convert("::: tests.fixtures.html_tokens") assert "'<" in output @@ -75,7 +84,7 @@ def test_html_inside_heading(ext_markdown): ], indirect=["ext_markdown"], ) -def test_no_double_toc(ext_markdown, expect_permalink): +def test_no_double_toc(ext_markdown: Markdown, expect_permalink: str) -> None: """Assert that the 'toc' extension doesn't apply its modification twice.""" output = ext_markdown.convert( dedent( @@ -83,67 +92,155 @@ def test_no_double_toc(ext_markdown, expect_permalink): # aa ::: tests.fixtures.headings - rendering: + options: show_root_toc_entry: false # bb - """ - ) + """, + ), ) assert output.count(expect_permalink) == 5 assert 'id="tests.fixtures.headings--foo"' in output - assert ext_markdown.toc_tokens == [ # noqa: E1101 (the member gets populated only with 'toc' extension) + assert ext_markdown.toc_tokens == [ # type: ignore[attr-defined] # the member gets populated only with 'toc' extension { "level": 1, "id": "aa", + "html": "aa", "name": "aa", + "data-toc-label": "", "children": [ { "level": 2, "id": "tests.fixtures.headings--foo", + "html": "Foo", "name": "Foo", + "data-toc-label": "", "children": [ { "level": 4, "id": "tests.fixtures.headings--bar", + "html": "Bar", "name": "Bar", + "data-toc-label": "", "children": [ - {"level": 6, "id": "tests.fixtures.headings--baz", "name": "Baz", "children": []} + { + "level": 6, + "id": "tests.fixtures.headings--baz", + "html": "Baz", + "name": "Baz", + "data-toc-label": "", + "children": [], + }, ], - } + }, ], - } + }, ], }, - {"level": 1, "id": "bb", "name": "bb", "children": []}, + { + "level": 1, + "id": "bb", + "html": "bb", + "name": "bb", + "data-toc-label": "", + "children": [], + }, ] -def test_use_custom_handler(ext_markdown): +def test_use_custom_handler(ext_markdown: Markdown) -> None: """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") -def test_dont_register_every_identifier_as_anchor(plugin): +def test_dont_register_every_identifier_as_anchor(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> None: """Assert that we don't preemptively register all identifiers of a rendered object.""" - handler = plugin._handlers.get_handler("python") # noqa: WPS437 - ids = {"id1", "id2", "id3"} - handler.get_anchors = lambda _: ids - plugin.md.convert("::: tests.fixtures.headings") - autorefs = plugin.md.parser.blockprocessors["mkdocstrings"]._autorefs # noqa: WPS219,WPS437 + handler = plugin._handlers.get_handler("python") # type: ignore[union-attr] + ids = ("id1", "id2", "id3") + handler.get_anchors = lambda _: ids # type: ignore[method-assign] + ext_markdown.convert("::: tests.fixtures.headings") + autorefs = ext_markdown.parser.blockprocessors["mkdocstrings"]._autorefs # type: ignore[attr-defined] for identifier in ids: - assert identifier not in autorefs._url_map # noqa: WPS437 - assert identifier not in autorefs._abs_url_map # noqa: WPS437 - - -def test_use_deprecated_yaml_keys(ext_markdown): - """Check that using the deprecated 'selection' and 'rendering' YAML keys emits a deprecation warning.""" - with pytest.warns(DeprecationWarning, match="single 'options' YAML key"): - assert "h1" not in ext_markdown.convert("::: tests.fixtures.headings\n rendering:\n heading_level: 2") + assert identifier not in autorefs._url_map + assert identifier not in autorefs._abs_url_map -def test_use_new_options_yaml_key(ext_markdown): - """Check that using the new 'options' YAML key works as expected.""" +def test_use_options_yaml_key(ext_markdown: Markdown) -> None: + """Check that using the 'options' YAML key works as expected.""" assert "h1" in ext_markdown.convert("::: tests.fixtures.headings\n options:\n heading_level: 1") assert "h1" not in ext_markdown.convert("::: tests.fixtures.headings\n options:\n heading_level: 2") + + +def test_use_yaml_options_after_blank_line(ext_markdown: Markdown) -> None: + """Check that YAML options are detected even after a blank line.""" + assert "h1" not in ext_markdown.convert("::: tests.fixtures.headings\n\n options:\n heading_level: 2") + + +@pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"admonition": {}}]}], indirect=["ext_markdown"]) +def test_removing_duplicated_headings(ext_markdown: Markdown) -> None: + """Assert duplicated headings are removed from the output.""" + output = ext_markdown.convert( + dedent( + """ + ::: tests.fixtures.headings_many.heading_1 + + !!! note + + ::: tests.fixtures.headings_many.heading_2 + + ::: tests.fixtures.headings_many.heading_3 + """, + ), + ) + assert output.count(">Heading one<") == 1 + assert output.count(">Heading two<") == 1 + assert output.count(">Heading three<") == 1 + assert output.count('class="mkdocstrings') == 0 + + +def _assert_contains_in_order(items: list[str], string: str) -> None: + index = 0 + for item in items: + assert item in string[index:] + index = string.index(item, index) + len(item) + + +@pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"attr_list": {}}]}], indirect=["ext_markdown"]) +def test_backup_of_anchors(ext_markdown: Markdown) -> None: + """Anchors with empty `href` are backed up.""" + output = ext_markdown.convert("::: tests.fixtures.markdown_anchors") + + # Anchors with id and no href have been backed up and updated. + _assert_contains_in_order( + [ + 'id="anchor"', + 'id="tests.fixtures.markdown_anchors--anchor"', + 'id="heading-anchor-1"', + 'id="tests.fixtures.markdown_anchors--heading-anchor-1"', + 'id="heading-anchor-2"', + 'id="tests.fixtures.markdown_anchors--heading-anchor-2"', + 'id="heading-anchor-3"', + 'id="tests.fixtures.markdown_anchors--heading-anchor-3"', + ], + output, + ) + + # Anchors with href and with or without id have been updated but not backed up. + _assert_contains_in_order( + [ + 'id="tests.fixtures.markdown_anchors--with-id"', + ], + output, + ) + assert 'id="with-id"' not in output + + _assert_contains_in_order( + [ + 'href="#tests.fixtures.markdown_anchors--has-href1"', + 'href="#tests.fixtures.markdown_anchors--has-href2"', + ], + output, + ) + assert 'href="#has-href1"' not in output + assert 'href="#has-href2"' not in output diff --git a/tests/test_handlers.py b/tests/test_handlers.py index cfe04cd8..4a07e98b 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,20 +1,30 @@ """Tests for the handlers.base module.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest +from jinja2.exceptions import TemplateNotFound from markdown import Markdown from mkdocstrings.handlers.base import Highlighter +if TYPE_CHECKING: + from pathlib import Path + + from mkdocstrings.plugin import MkdocstringsPlugin + @pytest.mark.parametrize("extension_name", ["codehilite", "pymdownx.highlight"]) -def test_highlighter_without_pygments(extension_name): +def test_highlighter_without_pygments(extension_name: str) -> None: """Assert that it's possible to disable Pygments highlighting. Arguments: extension_name: The "user-chosen" Markdown extension for syntax highlighting. """ configs = {extension_name: {"use_pygments": False, "css_class": "hiiii"}} - md = Markdown(extensions=configs, extension_configs=configs) + md = Markdown(extensions=[extension_name], extension_configs=configs) hl = Highlighter(md) assert ( hl.highlight("import foo", language="python") @@ -22,25 +32,65 @@ def test_highlighter_without_pygments(extension_name): ) assert ( hl.highlight("import foo", language="python", inline=True) - == 'import foo' + == f'import foo' ) @pytest.mark.parametrize("extension_name", [None, "codehilite", "pymdownx.highlight"]) @pytest.mark.parametrize("inline", [False, True]) -def test_highlighter_basic(extension_name, inline): +def test_highlighter_basic(extension_name: str | None, inline: bool) -> None: """Assert that Pygments syntax highlighting works. Arguments: extension_name: The "user-chosen" Markdown extension for syntax highlighting. inline: Whether the highlighting was inline. """ - configs = {} - if extension_name: - configs[extension_name] = {} - md = Markdown(extensions=configs, extension_configs=configs) + md = Markdown(extensions=[extension_name], extension_configs={extension_name: {}}) if extension_name else Markdown() hl = Highlighter(md) actual = hl.highlight("import foo", language="python", inline=inline) assert "import" in actual assert "import foo" not in actual # Highlighting has split it up. + + +def test_extended_templates(tmp_path: Path, plugin: MkdocstringsPlugin) -> None: + """Test the extended templates functionality. + + Parameters: + tmp_path: Temporary folder. + plugin: Instance of our plugin. + """ + handler = plugin._handlers.get_handler("python") # type: ignore[union-attr] + + # monkeypatch Jinja env search path + search_paths = [ + base_theme := tmp_path / "base_theme", + base_fallback_theme := tmp_path / "base_fallback_theme", + extended_theme := tmp_path / "extended_theme", + extended_fallback_theme := tmp_path / "extended_fallback_theme", + ] + handler.env.loader.searchpath = search_paths # type: ignore[union-attr] + + # assert "new" template is not found + with pytest.raises(expected_exception=TemplateNotFound): + handler.env.get_template("new.html") + + # check precedence: base theme, base fallback theme, extended theme, extended fallback theme + # start with last one and go back up + handler.env.cache = None + + extended_fallback_theme.mkdir() + extended_fallback_theme.joinpath("new.html").write_text("extended fallback new") + assert handler.env.get_template("new.html").render() == "extended fallback new" + + extended_theme.mkdir() + extended_theme.joinpath("new.html").write_text("extended new") + assert handler.env.get_template("new.html").render() == "extended new" + + base_fallback_theme.mkdir() + base_fallback_theme.joinpath("new.html").write_text("base fallback new") + assert handler.env.get_template("new.html").render() == "base fallback new" + + base_theme.mkdir() + base_theme.joinpath("new.html").write_text("base new") + assert handler.env.get_template("new.html").render() == "base new" diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 471ed941..ce707296 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,8 +1,11 @@ """Tests for the inventory module.""" +from __future__ import annotations + import sys from io import BytesIO from os.path import join +from typing import TYPE_CHECKING import pytest from mkdocs.commands.build import build @@ -10,6 +13,8 @@ from mkdocstrings.inventory import Inventory, InventoryItem +if TYPE_CHECKING: + from mkdocstrings.plugin import MkdocstringsPlugin sphinx = pytest.importorskip("sphinx.util.inventory", reason="Sphinx is not installed") @@ -22,7 +27,7 @@ Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#other_anchor")]), ], ) -def test_sphinx_load_inventory_file(our_inv): +def test_sphinx_load_inventory_file(our_inv: Inventory) -> None: """Perform the 'live' inventory load test.""" buffer = BytesIO(our_inv.format_sphinx()) sphinx_inv = sphinx.InventoryFile.load(buffer, "", join) @@ -35,10 +40,14 @@ def test_sphinx_load_inventory_file(our_inv): @pytest.mark.skipif(sys.version_info < (3, 7), reason="using plugins that require Python 3.7") -def test_sphinx_load_mkdocstrings_inventory_file(): +def test_sphinx_load_mkdocstrings_inventory_file() -> None: """Perform the 'live' inventory load test on mkdocstrings own inventory.""" mkdocs_config = load_config() - build(mkdocs_config) + mkdocs_config["plugins"].run_event("startup", command="build", dirty=False) + try: + build(mkdocs_config) + finally: + mkdocs_config["plugins"].run_event("shutdown") own_inv = mkdocs_config["plugins"]["mkdocstrings"].handlers.inventory with open("site/objects.inv", "rb") as fp: @@ -49,3 +58,12 @@ def test_sphinx_load_mkdocstrings_inventory_file(): for item in own_inv.values(): assert item.name in sphinx_inv[f"{item.domain}:{item.role}"] + + +def test_load_inventory(plugin: MkdocstringsPlugin) -> None: + """Test the plugin inventory loading method. + + Parameters: + plugin: A mkdocstrings plugin instance. + """ + plugin._load_inventory(loader=lambda *args, **kwargs: (), url="https://example.com", domains=["a", "b"]) # type: ignore[misc,arg-type] diff --git a/tests/test_loggers.py b/tests/test_loggers.py new file mode 100644 index 00000000..1644c0f0 --- /dev/null +++ b/tests/test_loggers.py @@ -0,0 +1,64 @@ +"""Tests for the loggers module.""" + +from unittest.mock import MagicMock + +import pytest + +from mkdocstrings.loggers import get_logger, get_template_logger + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"once": False}, + {"once": True}, + ], +) +def test_logger(kwargs: dict, caplog: pytest.LogCaptureFixture) -> None: + """Test logger methods. + + Parameters: + kwargs: Keyword arguments passed to the logger methods. + """ + logger = get_logger("mkdocstrings.test") + caplog.set_level(0) + for _ in range(2): + logger.debug("Debug message", **kwargs) + logger.info("Info message", **kwargs) + logger.warning("Warning message", **kwargs) + logger.error("Error message", **kwargs) + logger.critical("Critical message", **kwargs) + if kwargs.get("once", False): + assert len(caplog.records) == 5 + else: + assert len(caplog.records) == 10 + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"once": False}, + {"once": True}, + ], +) +def test_template_logger(kwargs: dict, caplog: pytest.LogCaptureFixture) -> None: + """Test template logger methods. + + Parameters: + kwargs: Keyword arguments passed to the template logger methods. + """ + logger = get_template_logger() + mock = MagicMock() + caplog.set_level(0) + for _ in range(2): + logger.debug(mock, "Debug message", **kwargs) + logger.info(mock, "Info message", **kwargs) + logger.warning(mock, "Warning message", **kwargs) + logger.error(mock, "Error message", **kwargs) + logger.critical(mock, "Critical message", **kwargs) + if kwargs.get("once", False): + assert len(caplog.records) == 5 + else: + assert len(caplog.records) == 10 diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 00000000..3342e2aa --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,71 @@ +"""Tests for the mkdocstrings plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mkdocs.commands.build import build +from mkdocs.config import load_config + +from mkdocstrings.plugin import MkdocstringsPlugin + +if TYPE_CHECKING: + from pathlib import Path + + +def test_disabling_plugin(tmp_path: Path) -> None: + """Test disabling plugin.""" + docs_dir = tmp_path / "docs" + site_dir = tmp_path / "site" + docs_dir.mkdir() + site_dir.mkdir() + docs_dir.joinpath("index.md").write_text("::: mkdocstrings") + + mkdocs_config = load_config() + mkdocs_config["docs_dir"] = str(docs_dir) + mkdocs_config["site_dir"] = str(site_dir) + mkdocs_config["plugins"]["mkdocstrings"].config["enabled"] = False + mkdocs_config["plugins"].run_event("startup", command="build", dirty=False) + try: + build(mkdocs_config) + finally: + mkdocs_config["plugins"].run_event("shutdown") + + # make sure the instruction was not processed + assert "::: mkdocstrings" in site_dir.joinpath("index.html").read_text() + + +def test_plugin_default_config(tmp_path: Path) -> None: + """Test default config options are set for Plugin.""" + config_file_path = tmp_path / "mkdocs.yml" + plugin = MkdocstringsPlugin() + errors, warnings = plugin.load_config({}, config_file_path=str(config_file_path)) + assert errors == [] + assert warnings == [] + assert plugin.config == { + "handlers": {}, + "default_handler": "python", + "custom_templates": None, + "enable_inventory": None, + "enabled": True, + } + + +def test_plugin_config_custom_templates(tmp_path: Path) -> None: + """Test custom_templates option is relative to config file.""" + config_file_path = tmp_path / "mkdocs.yml" + options = {"custom_templates": "docs/templates"} + template_dir = tmp_path / options["custom_templates"] + # Path must exist or config validation will fail. + template_dir.mkdir(parents=True) + plugin = MkdocstringsPlugin() + errors, warnings = plugin.load_config(options, config_file_path=str(config_file_path)) + assert errors == [] + assert warnings == [] + assert plugin.config == { + "handlers": {}, + "default_handler": "python", + "custom_templates": str(template_dir), + "enable_inventory": None, + "enabled": True, + }