diff --git a/.copier-answers.yml b/.copier-answers.yml index 0e8a2418..b709d322 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ -# Changes here will be overwritten by Copier -_commit: 1.1.0 +# Changes here will be overwritten by Copier. +_commit: 1.11.15 _src_path: gh:pawamoy/copier-uv author_email: dev@pawamoy.fr author_fullname: Timothée Mazzucotelli @@ -7,13 +7,9 @@ author_username: pawamoy copyright_date: '2019' copyright_holder: Timothée Mazzucotelli copyright_holder_email: dev@pawamoy.fr -copyright_license: ISC License -insiders: true -insiders_email: insiders@pawamoy.fr -insiders_repository_name: mkdocstrings +copyright_license: ISC project_description: Automatic documentation from sources, for MkDocs. project_name: mkdocstrings -public_release: true python_package_command_line_name: '' python_package_distribution_name: mkdocstrings python_package_import_name: mkdocstrings diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a502284a..812789e6 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,5 +1,2 @@ github: pawamoy -ko_fi: pawamoy polar: pawamoy -custom: -- https://www.paypal.me/pawamoy diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/1-bug.md similarity index 93% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/1-bug.md index 6ed84b16..5677ad2a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/1-bug.md @@ -50,10 +50,10 @@ PASTE TRACEBACK HERE redacting sensitive information. --> ```bash -python -m mkdocstrings.debug # | xclip -selection clipboard +python -m mkdocstrings._internal.debug # | xclip -selection clipboard ``` -PASTE OUTPUT HERE +PASTE MARKDOWN OUTPUT HERE ### Additional context + +### 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/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/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..6f0f2faf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +### For reviewers + + +- [ ] I did not use AI +- [ ] I used AI and thoroughly reviewed every code/docs change + +### Description of the change + + +### Relevant resources + + +- +- +- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3248b1a9..a8d0a185 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,17 @@ name: ci on: push: + branches: + - main + - test-me-* pull_request: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + defaults: run: shell: bash @@ -15,28 +22,51 @@ env: LC_ALL: en_US.utf-8 PYTHONIOENCODING: UTF-8 PYTHONPATH: docs + PYTHONWARNDEFAULTENCODING: "1" PYTHON_VERSIONS: "" jobs: quality: + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + python-version: + - "3.10" + - "3.14" + include: + - os: ubuntu-latest + python-version: "3.11" + - os: ubuntu-latest + python-version: "3.12" + - os: ubuntu-latest + python-version: "3.13" + - os: ubuntu-latest + python-version: "3.15-dev" - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - - name: Fetch all tags - run: git fetch --depth=1 --tags - - - name: Set up Python - uses: actions/setup-python@v5 + - name: Setup Python + uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: ${{ matrix.python-version }} - - name: Install uv - run: pip install uv + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml - name: Install dependencies run: make setup @@ -50,35 +80,20 @@ jobs: - name: Check if the code is correctly typed run: make check-types - - name: Check for vulnerabilities in dependencies - run: make check-dependencies - - name: Check for breaking changes in the API run: make check-api - 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.9"}, - {"python-version": "3.10"}, - {"python-version": "3.11"}, - {"python-version": "3.12"} - ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT - else - echo 'jobs=[]' >> $GITHUB_OUTPUT - fi + - name: Store objects inventory for tests + uses: actions/upload-artifact@v4 + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' }} + with: + name: objects.inv + path: site/objects.inv tests: - needs: exclude-test-jobs + needs: + - quality strategy: matrix: os: @@ -86,30 +101,54 @@ jobs: - macos-latest - windows-latest python-version: - - "3.8" - - "3.9" - "3.10" - "3.11" - "3.12" - exclude: ${{ fromJSON(needs.exclude-test-jobs.outputs.jobs) }} + - "3.13" + - "3.14" + - "3.15-dev" + resolution: + - highest + - lowest-direct + exclude: + - os: macos-latest + resolution: lowest-direct + - os: windows-latest + resolution: lowest-direct + runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.python-version == '3.12' }} + continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - - name: Set up Python - uses: actions/setup-python@v5 + - name: Setup Python + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - - name: Install uv - run: pip install uv + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: ${{ matrix.resolution }} - name: Install dependencies + env: + UV_RESOLUTION: ${{ matrix.resolution }} run: make setup + - name: Download objects inventory + uses: actions/download-artifact@v4 + with: + name: objects.inv + path: site/ + - name: Run the test suite run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 769e7f71..1c7cda36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,35 +11,19 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Fetch all tags - run: git fetch --depth=1 --tags + with: + fetch-depth: 0 + fetch-tags: true - name: Setup Python - uses: actions/setup-python@v4 - - name: Install build - if: github.repository_owner == 'pawamoy-insiders' - run: python -m pip install build - - name: Build dists - if: github.repository_owner == 'pawamoy-insiders' - run: python -m build - - name: Upload dists artifact - uses: actions/upload-artifact@v4 - if: github.repository_owner == 'pawamoy-insiders' + uses: actions/setup-python@v6 with: - name: mkdocstrings-insiders - path: ./dist/* - - name: Install git-changelog - if: github.repository_owner != 'pawamoy-insiders' - run: pip install git-changelog + python-version: "3.13" + - name: Setup uv + uses: astral-sh/setup-uv@v5 - name: Prepare release notes - if: github.repository_owner != 'pawamoy-insiders' - run: git-changelog --release-notes > release-notes.md - - name: Create release with assets - uses: softprops/action-gh-release@v1 - if: github.repository_owner == 'pawamoy-insiders' - with: - files: ./dist/* + run: uv tool run git-changelog --release-notes > release-notes.md - name: Create release - uses: softprops/action-gh-release@v1 - if: github.repository_owner != 'pawamoy-insiders' + uses: softprops/action-gh-release@v2 with: body_path: release-notes.md + diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml new file mode 100644 index 00000000..8dd9150f --- /dev/null +++ b/.github/workflows/sponsors.yml @@ -0,0 +1,26 @@ +name: Update sponsors + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-readme: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Update README and create PR + uses: pawamoy/readme-insert@main + with: + markup-url: https://pawamoy.github.io/sponsors.txt + start-marker: '' + end-marker: '' + commit-message: 'chore: Update sponsors section in README' + pr-title: 'chore: Update sponsors section in README' + pr-body: 'This PR updates the sponsors section in the README file.' diff --git a/.gitignore b/.gitignore index 41fee62d..faeb06ae 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,10 @@ /.pdm-build/ /htmlcov/ /site/ +uv.lock # cache .cache/ .pytest_cache/ -.mypy_cache/ .ruff_cache/ __pycache__/ diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile deleted file mode 100644 index 1590b415..00000000 --- a/.gitpod.dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM gitpod/workspace-full -USER gitpod -ENV PIP_USER=no -RUN pip3 install pipx; \ - pipx install uv; \ - 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 723eb24c..4e6fb131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,436 @@ # 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). +## [1.0.4](https://github.com/mkdocstrings/mkdocstrings/releases/tag/1.0.4) - 2026-04-15 + +[Compare with 1.0.3](https://github.com/mkdocstrings/mkdocstrings/compare/1.0.3...1.0.4) + +### Bug Fixes + +- Add timeout when downloading inventories (10 seconds) ([3d1969a](https://github.com/mkdocstrings/mkdocstrings/commit/3d1969a279ea396792c682810d029503e48d8fcd) by Simon Lloyd). [Issue-819](https://github.com/mkdocstrings/mkdocstrings/issues/819) + +## [1.0.3](https://github.com/mkdocstrings/mkdocstrings/releases/tag/1.0.3) - 2026-02-07 + +[Compare with 1.0.2](https://github.com/mkdocstrings/mkdocstrings/compare/1.0.2...1.0.3) + +### Bug Fixes + +- Forward extension instances directly passed from Zensical ([65b27ec](https://github.com/mkdocstrings/mkdocstrings/commit/65b27ec8d1d671eddf021e48b0114cc3f8aca14a) by Timothée Mazzucotelli). +- Propagate Zensical's `zrelpath` processor ([dbf263d](https://github.com/mkdocstrings/mkdocstrings/commit/dbf263dfdd2fdd769d66fa62bdd388e05988bc78) by Timothée Mazzucotelli). + +## [1.0.2](https://github.com/mkdocstrings/mkdocstrings/releases/tag/1.0.2) - 2026-01-24 + +[Compare with 1.0.1](https://github.com/mkdocstrings/mkdocstrings/compare/1.0.1...1.0.2) + +### Code Refactoring + +- Use global instances for handlers and autorefs ([9f79141](https://github.com/mkdocstrings/mkdocstrings/commit/9f79141d7eb35aba0c89a43795df0ee22a25a61e) by Timothée Mazzucotelli). + +## [1.0.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/1.0.1) - 2026-01-19 + +[Compare with 1.0.0](https://github.com/mkdocstrings/mkdocstrings/compare/1.0.0...1.0.1) + +### Code Refactoring + +- Support manual cross-references in Zensical too ([d37d907](https://github.com/mkdocstrings/mkdocstrings/commit/d37d9079e5381350b2e3ffc5f698e28a5b572d36) by Timothée Mazzucotelli). +- Support cross-references in Zensical ([f43f1ee](https://github.com/mkdocstrings/mkdocstrings/commit/f43f1ee2cd38a0dba64fc7d0db3c5ffb037bf7f7) by Timothée Mazzucotelli). [PR-812](https://github.com/mkdocstrings/mkdocstrings/pull/812) + +## [1.0.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/1.0.0) - 2025-11-27 + +[Compare with 0.30.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.30.1...1.0.0) + +### Breaking Changes + +- `BaseHandler.name`: *Attribute value was changed*: `''` -> unset +- `BaseHandler.domain`: *Attribute value was changed*: `''` -> unset +- `BaseHandler.fallback_config`: *Public object was removed* +- `BaseHandler.__init__(args)`: *Parameter was removed* +- `BaseHandler.__init__(kwargs)`: *Parameter was removed* +- `BaseHandler.__init__(theme)`: *Parameter was added as required* +- `BaseHandler.__init__(custom_templates)`: *Parameter was added as required* +- `BaseHandler.__init__(mdx)`: *Parameter was added as required* +- `BaseHandler.__init__(mdx_config)`: *Parameter was added as required* +- `BaseHandler.update_env(args)`: *Parameter was removed* +- `BaseHandler.update_env(kwargs)`: *Parameter was removed* +- `BaseHandler.update_env(config)`: *Parameter was added as required* +- `Handlers.get_anchors`: *Public object was removed* (import from `mkdocstrings` directly) +- `mkdocstrings.plugin`: *Public module was removed* (import from `mkdocstrings` directly) +- `mkdocstrings.loggers`: *Public module was removed* (import from `mkdocstrings` directly) +- `mkdocstrings.inventory`: *Public module was removed* (import from `mkdocstrings` directly) +- `mkdocstrings.extension`: *Public module was removed* (import from `mkdocstrings` directly) +- `mkdocstrings.handlers`: *Public module was removed* (import from `mkdocstrings` directly) + +### Code Refactoring + +- Remove deprecated code before v1 ([de34044](https://github.com/mkdocstrings/mkdocstrings/commit/de34044a02b45250e215af0f969dca581dfb82c5) by Timothée Mazzucotelli). +- Expect Zensical to pass extension configuration instead of loading it again from YAML ([6b73d5a](https://github.com/mkdocstrings/mkdocstrings/commit/6b73d5a2f455062ab6c68376c85adce6adc037a3) by Timothée Mazzucotelli). +- Expose the Markdown extension, to make mkdocstrings compatible with Zensical ([6de2667](https://github.com/mkdocstrings/mkdocstrings/commit/6de266759b79eb72cddd300e6a0a8576085fae40) by Timothée Mazzucotelli). + +## [0.30.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.30.1) - 2025-09-19 + +[Compare with 0.30.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.30.0...0.30.1) + +### Bug Fixes + +- Create default SSL context in main thread before downloading inventories ([eec7fb4](https://github.com/mkdocstrings/mkdocstrings/commit/eec7fb4bab948ef6db594fc1d1688be0554c5780) by Çağlar Kutlu). [Issue-796](https://github.com/mkdocstrings/mkdocstrings/issue/796), [PR-797](https://github.com/mkdocstrings/mkdocstrings/pull/797) + +## [0.30.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.30.0) - 2025-07-23 + +[Compare with 0.29.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.29.1...0.30.0) + +### Features + +- Add `data-skip-inventory` boolean attribute for elements to skip registration in local inventory ([f856160](https://github.com/mkdocstrings/mkdocstrings/commit/f856160b03b2c27e1d75fdf4f315c273cb9d9247) by Bartosz Sławecki). [Issue-671](https://github.com/mkdocstrings/mkdocstrings/issues/671), [PR-774](https://github.com/mkdocstrings/mkdocstrings/pull/774) +- Add I18N support (translations) ([2b4ed54](https://github.com/mkdocstrings/mkdocstrings/commit/2b4ed541bc707e55d959092d950ebeecc4fbd136) by Nyuan Zhang). [PR-645](https://github.com/mkdocstrings/mkdocstrings/pull/645), Co-authored-by: Timothée Mazzucotelli + +## [0.29.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.29.1) - 2025-03-31 + +[Compare with 0.29.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.29.0...0.29.1) + +### Dependencies + +- Remove unused typing-extensions dependency ([ba98661](https://github.com/mkdocstrings/mkdocstrings/commit/ba98661b50e2cde19d8696d6c8ceecdbb49ce83f) by Timothée Mazzucotelli). + +### Bug Fixes + +- Ignore invalid inventory lines ([81caff5](https://github.com/mkdocstrings/mkdocstrings/commit/81caff5ff76f1a6606da9d2980e81ae9d2e02246) by Josh Mitchell). [PR-748](https://github.com/mkdocstrings/mkdocstrings/pull/748) + +### Code Refactoring + +- Rename loggers to "mkdocstrings" ([1a98040](https://github.com/mkdocstrings/mkdocstrings/commit/1a980402c39728ce265d8998b396c34bf76a113d) by Timothée Mazzucotelli). + +## [0.29.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.29.0) - 2025-03-10 + +[Compare with 0.28.3](https://github.com/mkdocstrings/mkdocstrings/compare/0.28.3...0.29.0) + +**This is the last version before v1!** + +### Build + +- Depend on MkDocs 1.6 ([11bc400](https://github.com/mkdocstrings/mkdocstrings/commit/11bc400ab7089a47755f24a790c08f2f904c570b) by Timothée Mazzucotelli). + +### Features + +- Support rendering backlinks through handlers ([d4c7b9c](https://github.com/mkdocstrings/mkdocstrings/commit/d4c7b9c42f2de5df234c1ffefae0405a120e383c) by Timothée Mazzucotelli). [Issue-723](https://github.com/mkdocstrings/mkdocstrings/issues/723), [Issue-mkdocstrings-python-153](https://github.com/mkdocstrings/python/issues/153), [PR-739](https://github.com/mkdocstrings/mkdocstrings/pull/739) + +### Code Refactoring + +- Save and forward titles to autorefs ([f49fb29](https://github.com/mkdocstrings/mkdocstrings/commit/f49fb29582714795ca03febf1ee243aa2992917e) by Timothée Mazzucotelli). +- Use a combined event (each split with a different priority) for `on_env` ([8d1dd75](https://github.com/mkdocstrings/mkdocstrings/commit/8d1dd754b4babd3c4f9e6c1d8856be57fe4ba9ea) by Timothée Mazzucotelli). + +## [0.28.3](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.28.3) - 2025-03-08 + +[Compare with 0.28.2](https://github.com/mkdocstrings/mkdocstrings/compare/0.28.2...0.28.3) + +### Deprecations + +All public objects must now be imported from the top-level `mkdocstrings` module. Importing from submodules is deprecated, and will raise errors starting with v1. This should be the last deprecation before v1. + +### Build + +- Make `python` extra depend on latest mkdocstrings-python (1.16.2) ([ba9003e](https://github.com/mkdocstrings/mkdocstrings/commit/ba9003e96c8e5e01900743d5c464cbd228d732f4) by Timothée Mazzucotelli). + +### Code Refactoring + +- Finish exposing/hiding public/internal objects ([0723fc2](https://github.com/mkdocstrings/mkdocstrings/commit/0723fc25fdf5d45bc3b949f370712a706b85fbab) by Timothée Mazzucotelli). +- Re-expose public API in the top-level `mkdocstrings` module ([e66e080](https://github.com/mkdocstrings/mkdocstrings/commit/e66e08096d45f6790492d9a0b767d512e42f67a9) by Timothée Mazzucotelli). +- Move modules to internal folder ([23fe23f](https://github.com/mkdocstrings/mkdocstrings/commit/23fe23f11011d0470a6342ca85e060e5ac2b6bd6) by Timothée Mazzucotelli). + +## [0.28.2](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.28.2) - 2025-02-24 + +[Compare with 0.28.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.28.1...0.28.2) + +### Build + +- Depend on mkdocs-autorefs >= 1.4 ([2c22bdc](https://github.com/mkdocstrings/mkdocstrings/commit/2c22bdc49f6bf5600aefd5ec711747686fda96a8) by Timothée Mazzucotelli). + +## [0.28.1](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.28.1) - 2025-02-14 + +[Compare with 0.28.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.28.0...0.28.1) + +### Bug Fixes + +- Renew MkDocs' `relpath` processor instead of using same instance ([4ab180d](https://github.com/mkdocstrings/mkdocstrings/commit/4ab180d01964c3ef8005cd72c8d91ba3fd241e27) by Timothée Mazzucotelli). [Issue-mkdocs-3919](https://github.com/mkdocs/mkdocs/issues/3919) + +## [0.28.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.28.0) - 2025-02-03 + +[Compare with 0.27.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.27.0...0.28.0) + +### Breaking Changes + +Although the following changes are "breaking" in terms of public API, we didn't find any public use of these classes and methods on GitHub. + +- `mkdocstrings.extension.AutoDocProcessor.__init__(parser)`: *Parameter was removed* +- `mkdocstrings.extension.AutoDocProcessor.__init__(md)`: *Positional parameter was moved* +- `mkdocstrings.extension.AutoDocProcessor.__init__(config)`: *Parameter was removed* +- `mkdocstrings.extension.AutoDocProcessor.__init__(handlers)`: *Parameter kind was changed*: `positional or keyword` -> `keyword-only` +- `mkdocstrings.extension.AutoDocProcessor.__init__(autorefs)`: *Parameter kind was changed*: `positional or keyword` -> `keyword-only` +- `mkdocstrings.extension.MkdocstringsExtension.__init__(config)`: *Parameter was removed* +- `mkdocstrings.extension.MkdocstringsExtension.__init__(handlers)`: *Positional parameter was moved* +- `mkdocstrings.extension.MkdocstringsExtension.__init__(autorefs)`: *Positional parameter was moved* +- `mkdocstrings.handlers.base.Handlers.__init__(config)`: *Parameter was removed* +- `mkdocstrings.handlers.base.Handlers.__init__(theme)`: *Parameter was added as required* +- `mkdocstrings.handlers.base.Handlers.__init__(default)`: *Parameter was added as required* +- `mkdocstrings.handlers.base.Handlers.__init__(inventory_project)`: *Parameter was added as required* +- `mkdocstrings.handlers.base.Handlers.__init__(tool_config)`: *Parameter was added as required* + +Similarly, the following parameters were renamed, but the methods are only called from our own code, using positional arguments. + +- `mkdocstrings.handlers.base.BaseHandler.collect(config)`: *Parameter was renamed `options`* +- `mkdocstrings.handlers.base.BaseHandler.render(config)`: *Parameter was renamed `options`* + +Finally, the following method was removed, but this is again taken into account in our own code: + +- `mkdocstrings.handlers.base.BaseHandler.get_anchors`: *Public object was removed* + +For these reasons, and because we're still in v0, we do not bump to v1 yet. See following deprecations. + +### Deprecations + +*mkdocstrings* 0.28 will start emitting these deprecations warnings: + +> The `handler` argument is deprecated. The handler name must be specified as a class attribute. + +Previously, the `get_handler` function would pass a `handler` (name) argument to the handler constructor. This name must now be set on the handler's class directly. + +```python +class MyHandler: + name = "myhandler" +``` + +> The `domain` attribute must be specified as a class attribute. + +The `domain` class attribute on handlers is now mandatory and cannot be an empty string. + +```python +class MyHandler: + domain = "mh" +``` + +> The `theme` argument must be passed as a keyword argument. + +This argument could previously be passed as a positional argument (from the `get_handler` function), and must now be passed as a keyword argument. + +> The `custom_templates` argument must be passed as a keyword argument. + +Same as for `theme`, but with `custom_templates`. + +> The `mdx` argument must be provided (as a keyword argument). + +The `get_handler` function now receives a `mdx` argument, which it must forward to the handler constructor and then to the base handler, either explicitly or through `**kwargs`: + +=== "Explicitly" + + ```python + def get_handler(..., mdx, ...): + return MyHandler(..., mdx=mdx, ...) + + + class MyHandler: + def __init__(self, ..., mdx, ...): + super().__init__(..., mdx=mdx, ...) + ``` + +=== "Through `**kwargs`" + + ```python + def get_handler(..., **kwargs): + return MyHandler(..., **kwargs) + + + class MyHandler: + def __init__(self, ..., **kwargs): + super().__init__(**kwargs) + ``` + +In the meantime we still retrieve this `mdx` value at a different moment, by reading it from the MkDocs configuration. + +> The `mdx_config` argument must be provided (as a keyword argument). + +Same as for `mdx`, but with `mdx_config`. + +> mkdocstrings v1 will stop handling 'import' in handlers configuration. Instead your handler must define a `get_inventory_urls` method that returns a list of URLs to download. + +Previously, mkdocstrings would pop the `import` key from a handler's configuration to download each item (URLs). Items could be strings, or dictionaries with a `url` key. Now mkdocstrings gives back control to handlers, which must store this inventory configuration within them, and expose it again through a `get_inventory_urls` method. This method returns a list of tuples: an URL, and a dictionary of options that will be passed again to their `load_inventory` method. Handlers have now full control over the "inventory" setting. + +```python +from copy import deepcopy + + +def get_handler(..., handler_config, ...): + return MyHandler(..., config=handler_config, ...) + + +class MyHandler: + def __init__(self, ..., config, ...): + self.config = config + + def get_inventory_urls(self): + config = deepcopy(self.config["import"]) + return [(inv, {}) if isinstance(inv, str) else (inv.pop("url"), inv) for inv in config] +``` + +Changing the name of the key (for example from `import` to `inventories`) involves a change in user configuration, and both keys will have to be supported by your handler for some time. + +```python +def get_handler(..., handler_config, ...): + if "inventories" not in handler_config and "import" in handler_config: + warn("The 'import' key is renamed 'inventories'", FutureWarning) + handler_config["inventories"] = handler_config.pop("import") + return MyHandler(..., config=handler_config, ...) +``` + +> Setting a fallback anchor function is deprecated and will be removed in a future release. + +This comes from mkdocstrings and mkdocs-autorefs, and will disappear with mkdocstrings v0.28. + +> mkdocstrings v1 will start using your handler's `get_options` method to build options instead of merging the global and local options (dictionaries). + +Handlers must now store their own global options (in an instance attribute), and implement a `get_options` method that receives `local_options` (a dict) and returns combined options (dict or custom object). These combined options are then passed to `collect` and `render`, so that these methods can use them right away. + +```python +def get_handler(..., handler_config, ...): + return MyHandler(..., config=handler_config, ...) + + +class MyHandler: + def __init__(self, ..., config, ...): + self.config = config + + def get_options(local_options): + return {**self.default_options, **self.config["options"], **local_options} +``` + +> The `update_env(md)` parameter is deprecated. Use `self.md` instead. + +Handlers can remove the `md` parameter from their `update_env` method implementation, and use `self.md` instead, if they need it. + +> No need to call `super().update_env()` anymore. + +Handlers don't have to call the parent `update_env` method from their own implementation anymore, and can just drop the call. + +> The `get_anchors` method is deprecated. Declare a `get_aliases` method instead, accepting a string (identifier) instead of a collected object. + +Previously, handlers would implement a `get_anchors` method that received a data object (typed `CollectorItem`) to return aliases for this object. This forced mkdocstrings to collect this object through the handler's `collect` method, which then required some logic with "fallback config" as to prevent unwanted collection. mkdocstrings gives back control to handlers and now calls `get_aliases` instead, which accepts an `identifier` (string) and lets the handler decide how to return aliases for this identifier. For example, it can replicate previous behavior by calling its own `collect` method with its own "fallback config", or do something different (cache lookup, etc.). + +```python +class MyHandler: + def get_aliases(identifier): + try: + obj = self.collect(identifier, self.fallback_config) + # or obj = self._objects_cache[identifier] + except CollectionError: # or KeyError + return () + return ... # previous logic in `get_anchors` +``` + +> The `config_file_path` argument in `get_handler` functions is deprecated. Use `tool_config.get('config_file_path')` instead. + +The `config_file_path` argument is now deprecated and only passed to `get_handler` functions if they accept it. If you used it to compute a "base directory", you can now use the `tool_config` argument instead, which is the configuration of the SSG tool in use (here MkDocs): + +```python +base_dir = Path(tool_config.config_file_path or "./mkdocs.yml").parent +``` + +**Most of these warnings will disappear with the next version of mkdocstrings-python.** + +### Bug Fixes + +- Update handlers in JSON schema to be an object instead of an array ([3cf7d51](https://github.com/mkdocstrings/mkdocstrings/commit/3cf7d51704378adc50d4ea50080aacae39e0e731) by Matthew Messinger). [Issue-733](https://github.com/mkdocstrings/mkdocstrings/issues/733), [PR-734](https://github.com/mkdocstrings/mkdocstrings/pull/734) +- Fix broken table of contents when nesting autodoc instructions ([12c8f82](https://github.com/mkdocstrings/mkdocstrings/commit/12c8f82e9a959ce32cada09f0d2b5c651a705fdb) by Timothée Mazzucotelli). [Issue-348](https://github.com/mkdocstrings/mkdocstrings/issues/348) + +### Code Refactoring + +- Pass `config_file_path` to `get_handler` if it expects it ([8c476ee](https://github.com/mkdocstrings/mkdocstrings/commit/8c476ee0b82c09a5b20d7a773ecaf4be17b9e4d1) by Timothée Mazzucotelli). +- Give back inventory control to handlers ([b84653f](https://github.com/mkdocstrings/mkdocstrings/commit/b84653f2b175824c73bd0291fafff8343ba80125) by Timothée Mazzucotelli). [Related-to-issue-719](https://github.com/mkdocstrings/mkdocstrings/issues/719) +- Give back control to handlers on how they want to handle global/local options ([c00de7a](https://github.com/mkdocstrings/mkdocstrings/commit/c00de7a42b9072cbaa47ecbf18e3e15a6d5ab634) by Timothée Mazzucotelli). [Issue-719](https://github.com/mkdocstrings/mkdocstrings/issues/719) +- Deprecate base handler's `get_anchors` method in favor of `get_aliases` method ([7a668f0](https://github.com/mkdocstrings/mkdocstrings/commit/7a668f0f731401b07123bd02aafbbfc55cd24c0d) by Timothée Mazzucotelli). +- Register all identifiers of rendered objects into autorefs ([434d8c7](https://github.com/mkdocstrings/mkdocstrings/commit/434d8c7cd1e3edbdb9d4c45a9b44b290b19d88f1) by Timothée Mazzucotelli). +- Use mkdocs-get-deps' download utility to remove duplicated code ([bb87cd8](https://github.com/mkdocstrings/mkdocstrings/commit/bb87cd833f2333e77cb2c2926aa24a434c97391f) by Timothée Mazzucotelli). +- Clean up data passed down from plugin to extension and handlers ([b8e8703](https://github.com/mkdocstrings/mkdocstrings/commit/b8e87036e0e1ec5c181b4a2ec5931f1a60636a32) by Timothée Mazzucotelli). [PR-726](https://github.com/mkdocstrings/mkdocstrings/pull/726) + +## [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) @@ -560,8 +986,6 @@ See issue [#74](https://github.com/pawamoy/mkdocstrings/issues/74). ### Features - Prepare for new `pytkdocs` version ([336421a](https://github.com/pawamoy/mkdocstrings/commit/336421af95d752671276c2e88c5c173bff4093cc)). Add options `filters` and `members` to the Python collector to reflect the new `pytkdocs` options. - See [the default configuration of the Python collector](https://pawamoy.github.io/mkdocstrings/reference/handlers/python/#mkdocstrings.handlers.python.PythonCollector.DEFAULT_CONFIG). - ## [0.9.1](https://github.com/pawamoy/mkdocstrings/releases/tag/0.9.1) - 2020-03-21 @@ -587,8 +1011,7 @@ No identified breaking changes for end-users. - **Better cross-references:** cross-references now not only work between documented objects (between all languages, given the objects' identifiers are unique), but also for every heading of your Markdown pages. - **Configuration options:** the rendering of Python documentation can now be configured, - (globally and locally thanks to the handlers system), - [check the docs!](https://pawamoy.github.io/mkdocstrings/reference/handlers/python/#mkdocstrings.handlers.python.PythonRenderer.DEFAULT_CONFIG) + (globally and locally thanks to the handlers system). Also see the [recommended CSS](https://pawamoy.github.io/mkdocstrings/handlers/python/#recommended-style). - **Proper logging messages:** `mkdocstrings` now logs debug, warning and error messages, useful when troubleshooting. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 255e0eed..2d46305a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,128 +2,79 @@ ## Our Pledge -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 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. +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 a positive environment for our -community include: +Examples of behavior that contributes to a positive environment for our community include: * 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 +* 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 include: -* The use of sexualized language or imagery, and sexual attention or advances of - any kind +* 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 email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +* 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 ## Enforcement Responsibilities -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. +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. -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. +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 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. +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 to the community leaders responsible for enforcement at -dev@pawamoy.fr. -All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be 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. +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: +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. +**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. +**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. +**Community Impact**: A violation through a single incident or series of actions. -**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. +**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. +**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. +**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. +**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. +**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 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. +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]. +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]. +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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f86ff10..82526a81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,34 +1,38 @@ # Contributing -Contributions are welcome, and they are greatly appreciated! -Every little bit helps, and credit will always be given. +Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. + +**Please always create an issue before working on a new feature or a bug fix, so that we can discuss the implementation and make sure that your work will be merged.** ## Environment setup 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, -> you'll need to install -> [uv](https://github.com/astral-sh/uv) -> manually. +> NOTE: If it fails for some reason, you'll need to install [uv](https://github.com/astral-sh/uv) manually. > > You can install it with: > > ```bash -> python3 -m pip install --user pipx -> pipx install uv +> curl -LsSf https://astral.sh/uv/install.sh | sh > ``` > -> Now you can try running `make setup` again, -> or simply `uv install`. +> Now you can try running `make setup` again, or simply `uv sync`. You now have the dependencies installed. @@ -36,17 +40,9 @@ 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 run the task directly with `make run duty TASK`. - -The Makefile detects if a virtual environment is activated, -so `make` will work the same with the virtualenv activated or not. +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). -If you work in VSCode, we provide -[an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) -for the project. +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 @@ -65,17 +61,13 @@ As usual: 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 the review. +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. Don't bother updating the changelog, we will take care of this. ## Commit message convention -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): +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 @@ -83,10 +75,7 @@ or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg. [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. +**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: @@ -102,9 +91,7 @@ Scope and body are optional. Type can be: - `style`: A change in code style/format. - `tests`: About tests. -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: +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. @@ -113,16 +100,9 @@ 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). +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. +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 @@ -147,5 +127,4 @@ And force-push: git push -f ``` -If this seems all too complicated, you can push or force-push each new commit, -and we will squash them ourselves if needed, before merging. +If this seems all too complicated, you can push or force-push each new commit, and we will squash them ourselves if needed, before merging. diff --git a/LICENSE b/LICENSE index aa2449ff..6270ba3e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ ISC License -Copyright (c) 2019, Timothée Mazzucotelli +Copyright (c) 2019, Timothée Mazzucotelli and contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/Makefile b/Makefile index 771b333c..1b3391da 100644 --- a/Makefile +++ b/Makefile @@ -2,11 +2,18 @@ # the `make` command will point at the `scripts/make` shell script. # This Makefile is just here to allow auto-completion in the terminal. +default: help + @echo + @echo 'Enable direnv in your shell to use the `make` command: `direnv allow`' + @echo 'Or use `python scripts/make ARGS` to run the commands/tasks directly.' + +.DEFAULT_GOAL: default + actions = \ + allrun \ changelog \ check \ check-api \ - check-dependencies \ check-docs \ check-quality \ check-types \ @@ -16,6 +23,7 @@ actions = \ docs-deploy \ format \ help \ + multirun \ release \ run \ setup \ @@ -24,4 +32,4 @@ actions = \ .PHONY: $(actions) $(actions): - @bash scripts/make "$@" + @python scripts/make "$@" diff --git a/README.md b/README.md index 44158d4a..8ddfc16b 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-zensical-FF9100.svg?style=flat)](https://mkdocstrings.github.io/mkdocstrings/) [![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://app.gitter.im/#/room/#mkdocstrings:gitter.im) +[![gitter](https://img.shields.io/badge/matrix-chat-4DB798.svg?style=flat)](https://app.gitter.im/#/room/#mkdocstrings:gitter.im) 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) @@ -21,10 +20,15 @@ Come have a chat or ask questions on our [Gitter channel](https://gitter.im/mkdo - [**Language-agnostic:**](https://mkdocstrings.github.io/handlers/overview/) just like *MkDocs*, *mkdocstrings* is written in Python but is language-agnostic. It means you can use it with any programming language, as long as there is a - [**handler**](https://mkdocstrings.github.io/reference/handlers/base/) for it. - We currently have [handlers](https://mkdocstrings.github.io/handlers/overview/) - for the [Crystal](https://mkdocstrings.github.io/crystal/), [Python](https://mkdocstrings.github.io/python/), - and [VBA](https://pypi.org/project/mkdocstrings-vba/) languages, + [**handler**](https://mkdocstrings.github.io/reference/api/#mkdocstrings.BaseHandler) for it. + We currently have [handlers](https://mkdocstrings.github.io/handlers/overview/) for the + [C](https://mkdocstrings.github.io/c/), + [Crystal](https://mkdocstrings.github.io/crystal/), + [GitHub Actions](https://watermarkhu.nl/mkdocstrings-github/), + [Python](https://mkdocstrings.github.io/python/), + [MATLAB](https://watermarkhu.nl/mkdocstrings-matlab/), + [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: @@ -65,33 +69,44 @@ Come have a chat or ask questions on our [Gitter channel](https://gitter.im/mkdo *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/), +[IBM](https://ds4sd.github.io/docling/api_reference/document_converter/), [Jitsi](https://jitsi.github.io/jiwer/reference/alignment/), [Microsoft](https://microsoft.github.io/presidio/api/analyzer_python/), +[NVIDIA](https://nvidia.github.io/bionemo-framework/main/references/API_reference/bionemo/core/api/), [Prefect](https://docs.prefect.io/2.10.12/api-ref/prefect/agent/), [Pydantic](https://docs.pydantic.dev/dev-v2/api/main/), +[Textual](https://textual.textualize.io/api/app/), [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 @@ -118,3 +133,60 @@ In one of your markdown files: ``` See the [Usage](https://mkdocstrings.github.io/usage) section of the docs for more examples! + +## Sponsors + + + +
+ +
Silver sponsors

+FastAPI
+

+ +
Bronze sponsors

+Nixtla
+

+
+ +--- + +

+ofek +samuelcolvin +tlambert03 +ssbarnea +femtomc +cmarqu +kolenaIO +ramnes +machow +BenHammersley +trevorWieland +MarcoGorelli +analog-cbarber +OdinManiac +rstudio-sponsorship +schlich +butterlyn +livingbio +NemetschekAllplan +EricJayHartman +15r10nk +activeloopai +roboflow +cmclaughlin +blaisep +RapidataAI +rodolphebarbanneau +theSymbolSyndicate +blakeNaccarato +ChargeStorm +Alphadelta14 +Cusp-AI +

+ + +*And 7 more private sponsor(s).* + + diff --git a/config/git-changelog.toml b/config/git-changelog.toml index 57114e0c..e6bb5b91 100644 --- a/config/git-changelog.toml +++ b/config/git-changelog.toml @@ -4,6 +4,6 @@ in-place = true output = "CHANGELOG.md" parse-refs = false parse-trailers = true -sections = ["build", "deps", "feat", "fix", "refactor"] +sections = ["build", "deps", "feat", "fix", "perf", "refactor"] template = "keepachangelog" versioning = "pep440" diff --git a/config/mypy.ini b/config/mypy.ini deleted file mode 100644 index 814e2ac8..00000000 --- a/config/mypy.ini +++ /dev/null @@ -1,5 +0,0 @@ -[mypy] -ignore_missing_imports = true -exclude = tests/fixtures/ -warn_unused_ignores = true -show_error_codes = true diff --git a/config/pytest.ini b/config/pytest.ini index 5b5bd2e7..f65bd620 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -1,8 +1,6 @@ [pytest] python_files = test_*.py - *_test.py - tests.py addopts = --cov --cov-config config/coverage.ini @@ -12,9 +10,9 @@ testpaths = # action:message_regex:warning_class:module_regex:line filterwarnings = error - # TODO: remove once pytest-xdist 4 is released + default::EncodingWarning + error::EncodingWarning:mkdocstrings + ignore:.*`get_anchors` method:DeprecationWarning:mkdocstrings + ignore:.*Importing from:DeprecationWarning:mkdocstrings_handlers + # TODO: Remove once pytest-xdist 4 is released. ignore:.*rsyncdir:DeprecationWarning:xdist - # TODO: https://github.com/Python-Markdown/markdown/issues/1355 - ignore:.*Testing:DeprecationWarning:markdown - # TODO: https://github.com/facelessuser/pymdown-extensions/issues/2113 - ignore:.*Testing:DeprecationWarning:pymdownx diff --git a/config/ruff.toml b/config/ruff.toml index a8b26e1e..6cb05666 100644 --- a/config/ruff.toml +++ b/config/ruff.toml @@ -1,34 +1,14 @@ -target-version = "py38" +target-version = "py310" line-length = 120 +output-format = "concise" [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", -] +select = ["ALL"] 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 @@ -36,28 +16,41 @@ ignore = [ "D105", # Missing docstring in magic method "D417", # Missing argument description in the docstring "E501", # Line too long + "EM101", # String literal when raising exception + "EM102", # f-string when raising exception "ERA001", # Commented out code + "FIX", # TODO, FIXME, etc. "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 + "S704", # Unsafe use of `markupsafe.Markup` + "TD002", # Missing author in TODO + "TD003", # Missing issue link for TODO "TRY003", # Avoid specifying long messages outside the exception class ] [lint.per-file-ignores] -"src/*/cli.py" = [ +"src/**/cli.py" = [ "T201", # Print statement ] "src/*/debug.py" = [ "T201", # Print statement ] +"!src/*/*.py" = [ + "D100", # Missing docstring in public module +] +"!src/**.py" = [ + "D101", # Missing docstring in public class + "D103", # Missing docstring in public function +] "scripts/*.py" = [ "INP001", # File is part of an implicit namespace package "T201", # Print statement ] -"tests/*.py" = [ +"tests/**.py" = [ "ARG005", # Unused lambda argument "FBT001", # Boolean positional arg in function definition "PLR2004", # Magic value used in comparison diff --git a/config/ty.toml b/config/ty.toml new file mode 100644 index 00000000..97724fa8 --- /dev/null +++ b/config/ty.toml @@ -0,0 +1,6 @@ +[src] +exclude = ["tests/fixtures"] + +[terminal] +error-on-warning = true +output-format = "concise" diff --git a/config/vscode/launch.json b/config/vscode/launch.json index e3288388..6571bd99 100644 --- a/config/vscode/launch.json +++ b/config/vscode/launch.json @@ -7,17 +7,26 @@ "request": "launch", "program": "${file}", "console": "integratedTerminal", - "justMyCode": false + "justMyCode": false, + "args": "${command:pickArgs}" + }, + { + "name": "run", + "type": "debugpy", + "request": "launch", + "module": "mkdocstrings", + "console": "integratedTerminal", + "justMyCode": false, + "args": "${command:pickArgs}" }, { "name": "docs", "type": "debugpy", "request": "launch", - "module": "mkdocs", + "module": "zensical", "justMyCode": false, "args": [ "serve", - "-v" ] }, { diff --git a/config/vscode/settings.json b/config/vscode/settings.json index 949856d1..51587578 100644 --- a/config/vscode/settings.json +++ b/config/vscode/settings.json @@ -4,9 +4,6 @@ "**/.venvs*/**": true, "**/venv*/**": true }, - "mypy-type-checker.args": [ - "--config-file=config/mypy.ini" - ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.testing.pytestArgs": [ @@ -19,6 +16,7 @@ "ruff.lint.args": [ "--config=config/ruff.toml" ], + "ty.configurationFile": "config/ty.toml", "yaml.schemas": { "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" }, diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json index 30008cf2..73145eec 100644 --- a/config/vscode/tasks.json +++ b/config/vscode/tasks.json @@ -31,12 +31,6 @@ "command": "scripts/make", "args": ["check-docs"] }, - { - "label": "check-dependencies", - "type": "process", - "command": "scripts/make", - "args": ["check-dependencies"] - }, { "label": "check-api", "type": "process", diff --git a/devdeps.txt b/devdeps.txt deleted file mode 100644 index 58700f78..00000000 --- a/devdeps.txt +++ /dev/null @@ -1,28 +0,0 @@ -build>=1.0 -duty>=0.10 -black>=23.9 -markdown-callouts>=0.3 -markdown-exec>=1.7 -mkdocs>=1.5 -mkdocs-coverage>=1.0 -mkdocs-gen-files>=0.5 -mkdocs-git-committers-plugin-2>=1.2 -mkdocs-literate-nav>=0.6 -mkdocs-material>=9.4 -mkdocs-minify-plugin>=0.7 -mkdocs-redirects>=1.2 -mkdocstrings[python]>=0.23 -tomli>=2.0; python_version < '3.11' -black>=23.9 -blacken-docs>=1.16 -git-changelog>=2.3 -ruff>=0.0 -pytest>=7.4 -pytest-cov>=4.1 -pytest-randomly>=3.15 -pytest-xdist>=3.3 -mypy>=1.5 -types-markdown>=3.5 -types-pyyaml>=6.0 -safety>=2.3 -twine>=5.0 diff --git a/docs/.overrides/main.html b/docs/.overrides/main.html index cf8adeb7..3bfd4775 100644 --- a/docs/.overrides/main.html +++ b/docs/.overrides/main.html @@ -1,18 +1,7 @@ {% extends "base.html" %} {% block announce %} - - Sponsorship - is now available! - - {% include ".icons/octicons/heart-fill-16.svg" %} - — - - For updates follow @pawamoy on - - - {% include ".icons/fontawesome/brands/mastodon.svg" %} - - Fosstodon - + ⚠️ mkdocstrings is in maintenance mode! + blog post + {% endblock %} diff --git a/docs/.overrides/partials/comments.html b/docs/.overrides/partials/comments.html new file mode 100644 index 00000000..793b075c --- /dev/null +++ b/docs/.overrides/partials/comments.html @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index 786b75d5..0536cbbe 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1 +1,5 @@ +--- +title: Changelog +--- + --8<-- "CHANGELOG.md" diff --git a/docs/code_of_conduct.md b/docs/code_of_conduct.md index 01f2ea20..002b2a04 100644 --- a/docs/code_of_conduct.md +++ b/docs/code_of_conduct.md @@ -1 +1,5 @@ +--- +title: Code of Conduct +--- + --8<-- "CODE_OF_CONDUCT.md" diff --git a/docs/contributing.md b/docs/contributing.md index ea38c9bf..61935e5d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1 +1,5 @@ +--- +title: Contributing +--- + --8<-- "CONTRIBUTING.md" diff --git a/docs/credits.md b/docs/credits.md index f758db87..f6ab1aa2 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -1,10 +1,9 @@ --- +title: Credits hide: - toc --- - ```python exec="yes" --8<-- "scripts/gen_credits.py" ``` - diff --git a/docs/css/mkdocstrings.css b/docs/css/apidocs.css similarity index 76% rename from docs/css/mkdocstrings.css rename to docs/css/apidocs.css index 3960e49e..35ec8f19 100644 --- a/docs/css/mkdocstrings.css +++ b/docs/css/apidocs.css @@ -1,9 +1,3 @@ -/* Indentation. */ -div.doc-contents:not(.first) { - padding-left: 25px; - border-left: .05rem solid var(--md-typeset-table-color); -} - /* Mark external links as such. */ a.external::after, a.autorefs-external::after { @@ -18,15 +12,10 @@ a.autorefs-external::after { height: 1em; width: 1em; - background-color: var(--md-typeset-a-color); + 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; -} \ No newline at end of file diff --git a/docs/css/insiders.css b/docs/css/insiders.css deleted file mode 100644 index e7b9c74f..00000000 --- a/docs/css/insiders.css +++ /dev/null @@ -1,124 +0,0 @@ -@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/material.css b/docs/css/material.css deleted file mode 100644 index 9e8c14a6..00000000 --- a/docs/css/material.css +++ /dev/null @@ -1,4 +0,0 @@ -/* More space at the bottom of the page. */ -.md-main__inner { - margin-bottom: 1.5rem; -} diff --git a/docs/index.md b/docs/index.md index 612c7a5e..82377e21 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1,7 @@ +--- +title: Overview +hide: +- feedback +--- + --8<-- "README.md" diff --git a/docs/insiders/changelog.md b/docs/insiders/changelog.md deleted file mode 100644 index 0f438566..00000000 --- a/docs/insiders/changelog.md +++ /dev/null @@ -1,3 +0,0 @@ -# Changelog - -## mkdocstrings Insiders diff --git a/docs/insiders/goals.yml b/docs/insiders/goals.yml deleted file mode 100644 index 0e27b997..00000000 --- a/docs/insiders/goals.yml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index d88e8e7c..00000000 --- a/docs/insiders/index.md +++ /dev/null @@ -1,241 +0,0 @@ -# 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-warnings-deprecated", "https://mkdocstrings.github.io/griffe-warnings-deprecated/", "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. - -**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 deleted file mode 100644 index b7af7d2e..00000000 --- a/docs/insiders/installation.md +++ /dev/null @@ -1,200 +0,0 @@ ---- -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). - -### 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 pip (self-hosted) - -Self-hosting the Insiders package makes it possible to depend on *mkdocstrings* normally, -while transparently downloading and installing the Insiders version locally. -It means that you can specify your dependencies normally, and your contributors without access -to Insiders will get the public version, while you get the Insiders version on your machine. - -WARNING: **Limitation** -With this method, there is no way to force the installation of an Insiders version -rather than a public version. If there is a public version that is more recent -than your self-hosted Insiders version, the public version will take precedence. -Remember to regularly update your self-hosted versions by uploading latest distributions. - -You can build the distributions for Insiders yourself, by cloning the repository -and using [build] to build the distributions, -or you can download them from our [GitHub Releases]. -You can upload these distributions to a private PyPI-like registry -([Artifactory], [Google Cloud], [pypiserver], etc.) -with [Twine]: - - [build]: https://pypi.org/project/build/ - [Artifactory]: https://jfrog.com/help/r/jfrog-artifactory-documentation/pypi-repositories - [Google Cloud]: https://cloud.google.com/artifact-registry/docs/python - [pypiserver]: https://pypi.org/project/pypiserver/ - [Github Releases]: https://github.com/pawamoy-insiders/mkdocstrings/releases - [Twine]: https://pypi.org/project/twine/ - -```bash -# download distributions in ~/dists, then upload with: -twine upload --repository-url https://your-private-index.com ~/dists/* -``` - -You might also need to provide a username and password/token to authenticate against the registry. -Please check [Twine's documentation][twine docs]. - - [twine docs]: https://twine.readthedocs.io/en/stable/ - -You can then configure pip (or other tools) to look for packages into your package index. -For example, with pip: - -```bash -pip config set global.extra-index-url https://your-private-index.com/simple -``` - -Note that the URL might differ depending on whether your are uploading a package (with Twine) -or installing a package (with pip), and depending on the registry you are using (Artifactory, Google Cloud, etc.). -Please check the documentation of your registry to learn how to configure your environment. - -**We kindly ask that you do not upload the distributions to public registries, -as it is against our [Terms of use](index.md#terms).** - ->? TIP: **Full example with `pypiserver`** -> In this example we use [pypiserver] to serve a local PyPI index. -> -> ```bash -> pip install --user pypiserver -> # or pipx install pypiserver -> -> # create a packages directory -> mkdir -p ~/.local/pypiserver/packages -> -> # run the pypi server without authentication -> pypi-server run -p 8080 -a . -P . ~/.local/pypiserver/packages & -> ``` -> -> We can configure the credentials to access the server in [`~/.pypirc`][pypirc]: -> -> [pypirc]: https://packaging.python.org/en/latest/specifications/pypirc/ -> -> ```ini title=".pypirc" -> [distutils] -> index-servers = -> local -> -> [local] -> repository: http://localhost:8080 -> username: -> password: -> ``` -> -> We then clone the Insiders repository, build distributions and upload them to our local server: -> -> ```bash -> # clone the repository -> git clone git@github.com:pawamoy-insiders/mkdocstrings -> cd mkdocstrings -> -> # install build -> pip install --user build -> # or pipx install build -> -> # checkout latest tag -> git checkout $(git describe --tags --abbrev=0) -> -> # build the distributions -> pyproject-build -> -> # upload them to our local server -> twine upload -r local dist/* --skip-existing -> ``` -> -> Finally, we configure pip, and for example [PDM][pdm], to use our local index to find packages: -> -> ```bash -> pip config set global.extra-index-url http://localhost:8080/simple -> pdm config pypi.extra.url http://localhost:8080/simple -> ``` -> -> [pdm]: https://pdm.fming.dev/latest/ -> -> Now when running `pip install mkdocstrings`, -> or resolving dependencies with PDM, -> both tools will look into our local index and find the Insiders version. -> **Remember to update your local index regularly!** - -### with git - -Of course, you can use *mkdocstrings Insiders* directly from `git`: - -``` -git clone git@github.com:pawamoy-insiders/mkdocstrings -``` - -When cloning from `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 deleted file mode 100644 index 8bb68485..00000000 --- a/docs/js/insiders.js +++ /dev/null @@ -1,74 +0,0 @@ -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 a873d2b5..5b25a00f 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,9 @@ +--- +title: License +hide: +- feedback +--- + # License ``` diff --git a/docs/recipes.md b/docs/recipes.md index 953f3879..a52347bd 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -3,6 +3,8 @@ for *mkdocstrings* and more generally Markdown documentation. ## Automatic code reference pages +TIP: **[mkdocs-autoapi](https://github.com/jcayers20/mkdocs-autoapi) and [mkdocs-api-autonav](https://github.com/tlambert03/mkdocs-api-autonav) are MkDocs plugins that automatically generate API documentation from your project's source code. They were inspired by the recipe below.** + *mkdocstrings* allows to inject documentation for any object into Markdown pages. But as the project grows, it quickly becomes quite tedious to keep the autodoc instructions, or even the dedicated @@ -204,7 +206,7 @@ plugins: Then, the previous script is updated like so: -```python title="scripts/gen_ref_pages.py" hl_lines="7 23 31 32" +```python title="scripts/gen_ref_pages.py" hl_lines="7 24 32 33" """Generate the code reference pages and navigation.""" from pathlib import Path @@ -278,7 +280,7 @@ Well, this is possible thanks to a third plugin: Update the script like this: -```python title="scripts/gen_ref_pages.py" hl_lines="20 21" +```python title="scripts/gen_ref_pages.py" hl_lines="21 22" """Generate the code reference pages and navigation.""" from pathlib import Path @@ -364,16 +366,16 @@ extra_css: > To target `pycon` code blocks more specifically, you can configure the > `pymdownx.highlight` extension to use Pygments and set language classes > on code blocks: -> +> > ```yaml title="mkdocs.yml" > markdown_extensions: > - pymdownx.highlight: > use_pygments: true > pygments_lang_class: true > ``` -> +> > Then you can update the CSS selector like this: -> +> > ```css title="docs/css/code_select.css" > .language-pycon .gp, .language-pycon .go { /* Generic.Prompt, Generic.Output */ > user-select: none; @@ -405,3 +407,50 @@ Try to select the following code block's text: ... print(word, end=" ") Hello mkdocstrings! ``` + +## Hide documentation strings from source code blocks + +Since documentation strings are rendered by handlers, it can sometimes feel redundant to show these same documentation strings in source code blocks (when handlers render those). + +There is a general workaround to hide these docstrings from source blocks using CSS: + +```css +/* These CSS classes depend on the handler. */ +.doc-contents details .highlight code { + line-height: 0; +} +.doc-contents details .highlight code > * { + line-height: initial; +} +.doc-contents details .highlight code > .sd { /* Literal.String.Doc */ + display: none; +} +``` + +Note that this is considered a workaround and not a proper solution, because it has side-effects like also removing blank lines. + +## Automatic highlighting for indented code blocks in docstrings + +Depending on the language used in your code base and the mkdocstrings handler used to document it, you might want to set a default syntax for code blocks added to your docstrings. For example, to default to the Python syntax: + +```yaml title="mkdocs.yml" +markdown_extensions: +- pymdownx.highlight: + default_lang: python +``` + +Then in your docstrings, indented code blocks will be highlighted as Python code: + +```python +def my_function(): + """This is my function. + + The following code will be highlighted as Python: + + result = my_function() + print(result) + + End of the docstring. + """ + pass +``` diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 00000000..15af3021 --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,9 @@ +--- +title: API reference +hide: +- navigation +--- + +# ::: mkdocstrings + options: + show_submodules: true diff --git a/docs/schema.json b/docs/schema.json index a74dabf3..66197827 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -28,6 +28,12 @@ "type": "string", "default": "python" }, + "locale": { + "title": "The locale to use for translations.", + "markdownDescription": "https://mkdocstrings.github.io/usage/#global-options", + "type": "string", + "default": null + }, "enable_inventory": { "title": "Whether to enable inventory file generation.", "markdownDescription": "https://mkdocstrings.github.io/usage/#cross-references-to-other-projects-inventories", @@ -37,15 +43,11 @@ "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" - } - ] - } + "anyOf": [ + { + "$ref": "https://mkdocstrings.github.io/python/schema.json" + } + ] } }, "additionalProperties": false diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bc1da01b..5e5386e3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -145,7 +145,7 @@ See [Python handler: Finding modules](https://mkdocstrings.github.io/python/usag ### LaTeX in docstrings is not rendered correctly If you are using a Markdown extension like -[Arithmatex Mathjax](https://squidfunk.github.io/mkdocs-material/extensions/pymdown/#arithmatex-mathjax) +[Arithmatex Mathjax](https://squidfunk.github.io/mkdocs-material/setup/extensions/python-markdown-extensions/#arithmatex) or [`markdown-katex`][markdown-katex] to render LaTeX, add `r` in front of your docstring to make sure nothing is escaped. You'll still maybe have to play with escaping to get things right. @@ -167,9 +167,9 @@ def math_function(x, y): ### My docstrings in comments (`#:`) are not picked up -It's because we do not support type annotations in comments. +We only support docstrings in comments through the [griffe-sphinx](https://mkdocstrings.github.io/griffe-sphinx) extension. -So instead of: +Alternatively, instead of: ```python import enum @@ -187,15 +187,11 @@ import enum class MyEnum(enum.Enum): - """My enum. - - Attributes: - v1: The first choice. - v2: The second choice. - """ - v1 = 1 + """The first choice.""" + v2 = 2 + """The second choice.""" ``` Or: @@ -205,11 +201,15 @@ import enum class MyEnum(enum.Enum): - v1 = 1 - """The first choice.""" + """My enum. + + Attributes: + v1: The first choice. + v2: The second choice. + """ + v1 = 1 v2 = 2 - """The second choice.""" ``` ### My wrapped function shows documentation/code for its wrapper instead of its own @@ -238,5 +238,69 @@ def my_function(*args, **kwargs): print(*args, **kwargs) ``` +### Footnotes do not render + +The library that parses docstrings, [Griffe](https://mkdocstrings.github.io/griffe/), splits docstrings in several "sections" (example: [Google-style sections syntax](https://mkdocstrings.github.io/griffe/reference/docstrings/#google-syntax)). If a footnote is used in a section, while referenced in another, mkdocstrings won't be able to render it correctly. The footnote and its reference must appear in the same section. + +```python +def my_function(): + """Summary. + + This is the first section[^1]. + + Note: + This is the second section[^2]. + + Note: + This is the third section[^3]. + + References at the end are part of yet another section (fourth here)[^4]. + + [^1]: Some text. + [^2]: Some text. + [^3]: Some text. + [^4]: Some text. + """ +``` + +Here only the fourth footnote will work, because it is the only one that appear in the same section as its reference. To fix this, make sure all footnotes appear in the same section as their references: + +```python +def my_function(): + """Summary. + + This is the first section[^1]. + + [^1]: Some text. + + Note: + This is the second section[^2]. + + [^2]: Some text. + + Note: + This is the third section[^3]. + + [^3]: Some text. + + References at the end are part of yet another section (fourth here)[^4]. + + [^4]: Some text. + """ +``` + +### Submodules are not rendered + +In previous versions of mkdocstrings-python, submodules were rendered by default. This was changed and you now need to set the following option: + +```yaml title="mkdocs.yml" +plugins: +- mkdocstrings: + handlers: + python: + options: + show_submodules: true +``` + [bugtracker]: https://github.com/mkdocstrings/mkdocstrings [markdown-katex]: https://gitlab.com/mbarkhau/markdown-katex diff --git a/docs/usage/handlers.md b/docs/usage/handlers.md index 10b8aac4..6f326431 100644 --- a/docs/usage/handlers.md +++ b/docs/usage/handlers.md @@ -4,20 +4,22 @@ A handler is what makes it possible to collect and render documentation for a pa ## Available handlers -- Crystal -- Python -- Python (Legacy) -- Shell +- [C](https://mkdocstrings.github.io/c/){ .external } +- [Crystal](https://mkdocstrings.github.io/crystal/){ .external } +- [GitHub Actions](https://watermarkhu.nl/mkdocstrings-github/){ .external } +- [Python](https://mkdocstrings.github.io/python/){ .external } +- [Python (Legacy)](https://mkdocstrings.github.io/python-legacy/){ .external } +- [MATLAB](https://watermarkhu.nl/mkdocstrings-matlab/){ .external } +- [Shell](https://mkdocstrings.github.io/shell/){ .external } +- [TypeScript](https://mkdocstrings.github.io/typescript/){ .external } +- [VBA](https://pypi.org/project/mkdocstrings-vba/){ .external } ## About the Python handlers -Since version 0.18, a new, experimental Python handler is available. +Since version 0.18, a new 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 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, or specify the `python-legacy` extra when depending on *mkdocstrings*: @@ -34,9 +36,9 @@ dependencies = [ The legacy handler will continue to "work" for many releases, as long as the new handler does not cover all previous use-cases. -### Migrate to the experimental Python handler +### Migrate to the new Python handler -To use the new, experimental Python handler, +To use the new Python handler, you can depend on `mkdocstrings-python` directly, or specify the `python` extra when depending on *mkdocstrings*: @@ -101,7 +103,7 @@ Since version 0.14, you can create and use custom handlers thanks to namespace packages. For more information about namespace packages, [see their documentation](https://packaging.python.org/guides/packaging-namespace-packages/). -TIP: **TL;DR - Project template for handlers.** +TIP: **TL;DR - Project template for handlers.** *mkdocstrings* provides a [Copier](https://github.com/copier-org/copier) template to kickstart new handlers: https://github.com/mkdocstrings/handler-template. To use it, install Copier (`pipx install copier`), then run `copier gh:mkdocstrings/handler-template my_handler` @@ -128,26 +130,41 @@ NOTE: **Note the absence of `__init__.py` module in `mkdocstrings_handlers`!** ### Code A handler is a subclass of the base handler provided by *mkdocstrings*. - -See the documentation for the [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler]. -Subclasses of the base handler must implement the `collect` and `render` methods at least. -The `collect` method is responsible for collecting and returning data (extracting -documentation from source code, loading introspecting objects in memory, other sources? etc.) -while the `render` method is responsible for actually rendering the data to HTML, -using the Jinja templates provided by your package. - -You must implement a `get_handler` method at the module level. +See the documentation for the [`BaseHandler`][mkdocstrings.BaseHandler]. + +Subclasses of the base handler must declare a `name` and `domain` as class attributes, +as well as implement the following methods: + +- `collect(identifier, options)` (**required**): method responsible for collecting and returning data (extracting + documentation from source code, loading introspecting objects in memory, other sources? etc.) +- `render(identifier, options)` (**required**): method responsible for actually rendering the data to HTML, + using the Jinja templates provided by your package. +- `get_options(local_options)` (**required**): method responsible for combining global options with local ones. +- `get_aliases(identifier)` (**recommended**): method responsible for returning known aliases of object identifiers, + in order to register cross-references in the autorefs plugin. +- `get_inventory_urls()` (optional): method responsible for returning a list of URLs to download (object inventories) + along with configuration options (for loading the inventory with `load_inventory`). +- `load_inventory(in_file, url, **options)` (optional): method responsible for loading an inventory (binary file-handle) + and yielding tuples of identifiers and URLs. +- `update_env(config)` (optional): Gives you a chance to customize the Jinja environment used to render templates, + for examples by adding/removing Jinja filters and global context variables. +- `teardown()` (optional): Clean up / teardown anything that needs it at the end of the build. + +You must implement a `get_handler` method at the module level, +which returns an instance of your handler. This function takes the following parameters: - `theme` (string, theme name) - `custom_templates` (optional string, path to custom templates directory) -- `config_file_path` (optional string, path to the config file) +- `mdx` (list, Markdown extensions) +- `mdx_config` (dict, extensions configuration) +- `handler_config` (dict, handle configuration) +- `tool_config` (dict, the whole MkDocs configuration) 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 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 +by adding `**kwargs` or similar to your signature. + +You should not modify the MkDocs 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. @@ -188,7 +205,7 @@ to use the templates of another handler. In you handler, override the ```python from pathlib import Path -from mkdocstrings.handlers.base import BaseHandler +from mkdocstrings import BaseHandler class CobraHandler(BaseHandler): diff --git a/docs/usage/index.md b/docs/usage/index.md index 1348b9cc..64588fdf 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -16,7 +16,7 @@ The syntax is as follows: > Here are some resources that other users found useful to better > understand YAML's peculiarities. > -> - [YAML idiosyncrasies](https://docs.saltproject.io/en/3000/topics/troubleshooting/yaml_idiosyncrasies.html) +> - [YAML idiosyncrasies](https://salt-zh.readthedocs.io/en/latest/topics/troubleshooting/yaml_idiosyncrasies.html) > - [YAML multiline](https://yaml-multiline.info/) The `identifier` is a string identifying the object you want to document. @@ -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" @@ -113,6 +113,7 @@ The above is equivalent to: - `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) +- `locale`: The locale used for translations. See [Internationalization](#internationalization-i18n). - `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). @@ -124,6 +125,7 @@ The above is equivalent to: enabled: !ENV [ENABLE_MKDOCSTRINGS, true] custom_templates: templates default_handler: python + locale: en handlers: python: options: @@ -141,6 +143,16 @@ The above is equivalent to: Some handlers accept additional global configuration. Check the documentation for your handler of interest in [Handlers](handlers.md). +## Internationalization (I18N) + +Some handlers support multiple languages. + +If the handler supports localization, the locale it uses is determined by the following order of precedence: + +- `locale` in [global options](#global-options) +- `theme.language`: used by the [MkDocs Material theme](https://squidfunk.github.io/mkdocs-material/setup/changing-the-language/) +- `theme.locale` in [MkDocs configuration](https://www.mkdocs.org/user-guide/configuration/#theme) + ## Cross-references Cross-references are written as Markdown *reference-style* links: @@ -176,7 +188,7 @@ is possible to link to with `[example][full.path.object1]`, regardless of the cu ### Cross-references to any Markdown heading -TIP: **Changed in version 0.15.** +TIP: **Changed in version 0.15.** Linking to any Markdown heading used to be the default, but now opt-in is required. If you want to link to *any* Markdown heading, not just *mkdocstrings*-inserted items, please @@ -249,7 +261,7 @@ In the example below you see the identifier to be linked is `foo.bar--tips`, bec The above tip about [Finding out the anchor](#finding-out-the-anchor) also applies the same way here. -You may also notice that such a heading does not get rendered as a `

` element directly, but rather the level gets shifted to fit the encompassing document structure. If you're curious about the implementation, check out [mkdocstrings.handlers.rendering.HeadingShiftingTreeprocessor][] and others. +You may also notice that such a heading does not get rendered as a `

` element directly, but rather the level gets shifted to fit the encompassing document structure. If you're curious about the implementation, check out [mkdocstrings.HeadingShiftingTreeprocessor][] and others. ### Cross-references to other projects / inventories @@ -271,7 +283,7 @@ plugins: - mkdocstrings: handlers: python: - import: + inventories: - https://installer.readthedocs.io/en/stable/objects.inv ``` @@ -298,7 +310,7 @@ plugins: - mkdocstrings: handlers: python: - import: + inventories: # latest instead of stable - https://installer.readthedocs.io/en/latest/objects.inv ``` @@ -311,7 +323,7 @@ plugins: - mkdocstrings: handlers: python: - import: + inventories: - url: https://cdn.example.com/version/objects.inv base_url: https://docs.example.com/version ``` @@ -319,6 +331,24 @@ 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. diff --git a/docs/usage/theming.md b/docs/usage/theming.md index b5d6f7b3..04d053f6 100644 --- a/docs/usage/theming.md +++ b/docs/usage/theming.md @@ -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 @@ -86,11 +86,11 @@ we cannot list them all here. See the documentation about CSS classes for: ### Syntax highlighting -Code blocks that occur in the docstring of an item inserted with *mkdocstrings*, as well as code blocks (such as *Source code*) that *mkdocstrings* inserts itself, are syntax-highlighted according to the same rules as other normal code blocks in your document. See more details in [mkdocstrings.handlers.rendering.Highlighter][]. +Code blocks that occur in the docstring of an item inserted with *mkdocstrings*, as well as code blocks (such as *Source code*) that *mkdocstrings* inserts itself, are syntax-highlighted according to the same rules as other normal code blocks in your document. See more details in [mkdocstrings.Highlighter][]. As for the CSS class used for code blocks -- it will also match the "normal" config, so the default (`.codehilite` or `.highlight`) will match your chosen Markdown extension for highlighting. -IMPORTANT: **Changed in version 0.15.** +IMPORTANT: **Changed in version 0.15.** The CSS class used to always be `.highlight`, but now it depends on the configuration. Long story short, you probably should add `pymdownx.highlight` to your `markdown_extensions`, and then use `.doc-contents .highlight` as the CSS selector in case you want to change something about *mkdocstrings'* code blocks specifically. diff --git a/duties.py b/duties.py index 30bf7c63..dfc81027 100644 --- a/duties.py +++ b/duties.py @@ -3,14 +3,12 @@ from __future__ import annotations import os +import re import sys -from contextlib import contextmanager -from importlib.metadata import version as pkgversion from pathlib import Path -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING -from duty import duty -from duty.callables import coverage, lazy, mkdocs, mypy, pytest, ruff, safety +from duty import duty, tools if TYPE_CHECKING: from duty.context import Context @@ -23,286 +21,186 @@ WINDOWS = os.name == "nt" PTY = not WINDOWS and not CI MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" +PY_VERSION = f"{sys.version_info.major}{sys.version_info.minor}" +PY_DEV = "315" -def pyprefix(title: str) -> str: # noqa: D103 +def pyprefix(title: str) -> str: if MULTIRUN: prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" return f"{prefix:14}{title}" return title -@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 +def _get_changelog_version() -> str: + changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") + with Path(__file__).parent.joinpath("CHANGELOG.md").open("r", encoding="utf8") as file: + return next(filter(bool, map(changelog_version_re.match, file))).group(1) # ty: ignore[invalid-argument-type,unresolved-attribute] @duty -def changelog(ctx: Context) -> None: +def changelog(ctx: Context, bump: str = "") -> None: """Update the changelog in-place with latest commits. Parameters: - ctx: The context instance (passed automatically). + bump: Bump option passed to git-changelog. """ - from git_changelog.cli import main as git_changelog + ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") + ctx.run(tools.yore.check(bump=bump or _get_changelog_version()), title="Checking legacy code") - ctx.run(git_changelog, args=[[]], title="Updating changelog") +@duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) +def check(ctx: Context) -> None: + """Check it all!""" -@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) -def check(ctx: Context) -> None: # noqa: ARG001 - """Check it all! - Parameters: - ctx: The context instance (passed automatically). - """ - - -@duty +@duty(nofail=PY_VERSION == PY_DEV) def check_quality(ctx: Context) -> None: - """Check the code quality. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check the code quality.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", color=True), title=pyprefix("Checking code quality"), - command=f"ruff check --config config/ruff.toml {PY_SRC}", ) -@duty -def check_dependencies(ctx: Context) -> None: - """Check for vulnerabilities in dependencies. - - Parameters: - ctx: The context instance (passed automatically). - """ - # retrieve the list of dependencies - requirements = ctx.run( - ["uv", "pip", "freeze"], - silent=True, - allow_overrides=False, - ) - +@duty(nofail=PY_VERSION == PY_DEV) +def check_docs(ctx: Context) -> None: + """Check if the documentation builds correctly.""" ctx.run( - safety.check(requirements), - title="Checking dependencies", - command="uv pip freeze | safety check --stdin", + tools.zensical.build(strict=True), + title=pyprefix("Building documentation"), ) -@duty -def check_docs(ctx: Context) -> None: - """Check if the documentation builds correctly. - - Parameters: - ctx: The context instance (passed automatically). - """ - Path("htmlcov").mkdir(parents=True, exist_ok=True) - Path("htmlcov/index.html").touch(exist_ok=True) - with material_insiders(): - ctx.run( - mkdocs.build(strict=True, verbose=True), - title=pyprefix("Building documentation"), - command="mkdocs build -vs", - ) - - -@duty +@duty(nofail=PY_VERSION == PY_DEV) def check_types(ctx: Context) -> None: - """Check that the code is correctly typed. - - Parameters: - ctx: The context instance (passed automatically). - """ - os.environ["MYPYPATH"] = "src" + """Check that the code is correctly typed.""" + py = f"{sys.version_info.major}.{sys.version_info.minor}" ctx.run( - mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"), + tools.ty.check( + *PY_SRC_LIST, + config_file="config/ty.toml", + color=True, + error_on_warning=True, + python_version=py, + ), title=pyprefix("Type-checking"), - command=f"mypy --config-file config/mypy.ini {PY_SRC}", ) -@duty -def check_api(ctx: Context) -> None: - """Check for API breaking changes. - - Parameters: - ctx: The context instance (passed automatically). - """ - from griffe.cli import check as g_check - - griffe_check = lazy(g_check, name="griffe.check") +@duty(nofail=PY_VERSION == PY_DEV) +def check_api(ctx: Context, *cli_args: str) -> None: + """Check for API breaking changes.""" ctx.run( - griffe_check("mkdocstrings", search_paths=["src"], color=True), + tools.griffe.check("mkdocstrings", search=["src"], color=True).add_args(*cli_args), title="Checking for API breaking changes", - command="griffe check -ssrc mkdocstrings", nofail=True, ) -@duty(silent=True) -def clean(ctx: Context) -> None: - """Delete temporary files. - - Parameters: - ctx: The context instance (passed automatically). - """ - - def _rm(*targets: str) -> None: - for target in targets: - ctx.run(f"rm -rf {target}") - - def _find_rm(*targets: str) -> None: - for target in targets: - ctx.run(f"find . -type d -name '{target}' | xargs rm -rf") - - _rm("build", "dist", ".coverage*", "htmlcov", "site", ".pdm-build") - _find_rm(".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__") - - @duty -def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: +def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: """Serve the documentation (localhost:8000). Parameters: - ctx: The context instance (passed automatically). host: The host to serve the docs from. port: The port to serve the docs on. """ - with material_insiders(): - ctx.run( - mkdocs.serve(dev_addr=f"{host}:{port}"), - title="Serving documentation", - capture=False, - ) + ctx.run( + tools.zensical.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), + title="Serving documentation", + capture=False, + ) @duty def docs_deploy(ctx: Context) -> None: - """Deploy the documentation on GitHub pages. + """Deploy the documentation to GitHub pages.""" + from ghp_import import ghp_import # noqa: PLC0415 - Parameters: - ctx: The context instance (passed automatically). - """ - 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) - if "pawamoy-insiders/mkdocstrings" in origin: - 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="upstream", force=True), - title="Deploying documentation", - ) - else: - ctx.run( - lambda: False, - title="Not deploying docs from public repository (do that from insiders instead!)", - nofail=True, - ) + ctx.run(tools.zensical.build(), title="Building documentation site") + ctx.run( + ghp_import, + kwargs={ + "srcdir": "site", + "mesg": "chore: Update documentation", + "push": True, + "force": True, + "remote": "org-pages", + }, + title="Deploying site to GitHub Pages", + command="ghp-import site -r org-pages -fpm 'chore: Update documentation'", + pty=PTY, + ) @duty def format(ctx: Context) -> None: - """Run formatting tools on the code. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Run formatting tools on the code.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), title="Auto-fixing code", ) - ctx.run(ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") + ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") -@duty(post=["docs-deploy"]) -def release(ctx: Context, version: str) -> None: +@duty +def build(ctx: Context) -> None: + """Build source and wheel distributions.""" + ctx.run( + ["uv", "build"], + title="Building distributions", + pty=PTY, + ) + + +@duty +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() if dist.suffix in (".gz", ".whl")] + ctx.run( + tools.twine.upload(*dists, skip_existing=True), + title="Publishing distributions to PyPI", + pty=PTY, + ) + + +@duty(post=["build", "publish", "docs-deploy"]) +def release(ctx: Context, version: str = "") -> None: """Release a new Python package. Parameters: - ctx: The context instance (passed automatically). 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) + ctx.run(f"git tag -m '' -a {version}", title="Tagging commit", pty=PTY) ctx.run("git push", title="Pushing commits", pty=False) ctx.run("git push --tags", title="Pushing tags", pty=False) - ctx.run("pyproject-build", title="Building dist/wheel", pty=PTY) - ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY) -@duty(silent=True, aliases=["coverage"]) -def cov(ctx: Context) -> None: - """Report coverage as text and HTML. +@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")) - Parameters: - ctx: The context instance (passed automatically). - """ - ctx.run(coverage.combine, nofail=True) - ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False) - ctx.run(coverage.html(rcfile="config/coverage.ini")) - -@duty -def test(ctx: Context, match: str = "") -> None: - """Run the test suite. - - Parameters: - ctx: The context instance (passed automatically). - 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}" +@duty(nofail=PY_VERSION == PY_DEV) +def test(ctx: Context, *cli_args: str) -> None: + """Run the test suite.""" + os.environ["COVERAGE_FILE"] = f".coverage.{PY_VERSION}" + os.environ["PYTHONWARNDEFAULTENCODING"] = "1" ctx.run( - pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"), + tools.pytest( + "tests", + config_file="config/pytest.ini", + color="yes", + ).add_args("-n", "auto", *cli_args), title=pyprefix("Running tests"), - command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests", ) - - -@duty -def vscode(ctx: Context) -> None: - """Configure VSCode. - - This task will overwrite the following files, - so make sure to back them up: - - - `.vscode/launch.json` - - `.vscode/settings.json` - - `.vscode/tasks.json` - - Parameters: - ctx: The context instance (passed automatically). - """ - - def update_config(filename: str) -> None: - source_file = Path("config", "vscode", filename) - target_file = Path(".vscode", filename) - target_file.parent.mkdir(exist_ok=True) - target_file.write_text(source_file.read_text()) - - for filename in ("launch.json", "settings.json", "tasks.json"): - ctx.run(update_config, args=[filename], title=f"Update .vscode/{filename}") diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 860ce66e..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,182 +0,0 @@ -site_name: "mkdocstrings" -site_description: "Automatic documentation from sources, for MkDocs." -site_url: "https://mkdocstrings.github.io/" -repo_url: "https://github.com/mkdocstrings/mkdocstrings" -repo_name: "mkdocstrings/mkdocstrings" -site_dir: "site" -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: - - Overview: index.md - - Changelog: changelog.md - - Credits: credits.md - - License: license.md -- Usage: - - usage/index.md - - Theming: usage/theming.md - - Handlers: usage/handlers.md - - All handlers: - - Crystal: https://mkdocstrings.github.io/crystal/ - - Python: https://mkdocstrings.github.io/python/ - - Python (Legacy): https://mkdocstrings.github.io/python-legacy/ - - Shell: https://mkdocstrings.github.io/shell/ - - VBA: https://pypi.org/project/mkdocstrings-vba - - Guides: - - Recipes: recipes.md - - Troubleshooting: troubleshooting.md -# defer to gen-files + literate-nav -- 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 - accent: purple - toggle: - icon: material/weather-sunny - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: black - accent: lime - toggle: - icon: material/weather-night - name: Switch to system preference - -extra_css: -- css/style.css -- css/material.css -- css/mkdocstrings.css -- css/insiders.css - -markdown_extensions: -- attr_list -- admonition -- callouts -- footnotes -- pymdownx.details -- 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.inlinehilite: - style_plain_text: python -- pymdownx.magiclink -- pymdownx.snippets: - base_path: [!relative $config_dir] - check_paths: true -- pymdownx.superfences -- pymdownx.tabbed: - alternate_style: true - 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: - - scripts/gen_ref_nav.py -- literate-nav: - nav_file: SUMMARY.md -- coverage -- mkdocstrings: - handlers: - python: - import: - - 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 - paths: [src] - options: - docstring_options: - ignore_init_summary: true - docstring_section_style: list - filters: ["!^_"] - heading_level: 1 - inherited_members: true - merge_init_into_class: true - separate_signature: true - show_root_heading: true - show_root_full_path: false - show_signature_annotations: true - show_symbol_type_heading: true - show_symbol_type_toc: true - signature_crossrefs: true - summary: true -- git-committers: - enabled: !ENV [DEPLOY, false] - repository: mkdocstrings/mkdocstrings -- 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/ diff --git a/pyproject.toml b/pyproject.toml index b35e8dc7..9c722187 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,10 @@ build-backend = "pdm.backend" name = "mkdocstrings" description = "Automatic documentation from sources, for MkDocs." authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] -license = {text = "ISC"} +license = "ISC" +license-files = ["LICENSE"] readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" keywords = ["mkdocs", "mkdocs-plugin", "docstrings", "autodoc", "documentation"] dynamic = ["version"] classifiers = [ @@ -17,11 +18,11 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "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", @@ -29,22 +30,18 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "click>=7.0", - "Jinja2>=2.11.1", - "Markdown>=3.3", + "Jinja2>=3.1", + "Markdown>=3.6", "MarkupSafe>=1.1", - "mkdocs>=1.4", - "mkdocs-autorefs>=0.3.1", - "platformdirs>=2.2.0", + "mkdocs>=1.6", + "mkdocs-autorefs>=1.4", "pymdown-extensions>=6.3", - "importlib-metadata>=4.6; python_version < '3.10'", - "typing-extensions>=4.1; python_version < '3.10'", ] [project.optional-dependencies] crystal = ["mkdocstrings-crystal>=0.3.4"] python-legacy = ["mkdocstrings-python-legacy>=0.2.1"] -python = ["mkdocstrings-python>=0.5.2"] +python = ["mkdocstrings-python>=1.16.2"] [project.urls] Homepage = "https://mkdocstrings.github.io" @@ -57,11 +54,63 @@ Gitter = "https://gitter.im/mkdocstrings/community" Funding = "https://github.com/sponsors/pawamoy" [project.entry-points."mkdocs.plugins"] -mkdocstrings = "mkdocstrings.plugin:MkdocstringsPlugin" +mkdocstrings = "mkdocstrings:MkdocstringsPlugin" -[tool.pdm] -version = {source = "scm"} +[tool.pdm.version] +source = "call" +getter = "scripts.get_version:get_version" [tool.pdm.build] -package-dir = "src" -editable-backend = "editables" +# Include as much as possible in the source distribution, to help redistributors. +excludes = ["**/.pytest_cache"] +source-includes = [ + "config", + "docs", + "scripts", + "share", + "tests", + "duties.py", + "zensical.toml", + "*.md", + "LICENSE", +] + +[tool.pdm.build.wheel-data] +# Manual pages can be included in the wheel. +# Depending on the installation tool, they will be accessible to users. +# pipx supports it, uv does not yet, see https://github.com/astral-sh/uv/issues/4731. +data = [ + {path = "share/**/*", relative-to = "."}, +] + +[dependency-groups] +maintain = [ + "git-changelog>=2.5", + "twine>=5.1", + "yore>=0.3.3", +] +ci = [ + "dirty-equals>=0.9", + "duty>=1.6", + "griffe>=2.0", + "pytest>=8.2", + "pytest-cov>=5.0", + "pytest-randomly>=3.15", + "pytest-xdist>=3.6", + "ruff>=0.4", + "ty>=0.0.14", + "types-markdown>=3.6", + "types-pyyaml>=6.0", +] + docs = [ + "ghp-import>=2.1", + "markdown-callouts>=0.4", + "markdown-exec>=1.8", + "mkdocstrings[python]>=0.29", + # YORE: EOL 3.10: Remove line. + "tomli>=2.0; python_version < '3.11'", + "zensical>=0.0.21", +] + +[tool.uv] +default-groups = ["maintain", "ci", "docs"] diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py index 27f94d67..e8712895 100644 --- a/scripts/gen_credits.py +++ b/scripts/gen_credits.py @@ -1,36 +1,34 @@ -"""Script to generate the project's credits.""" +# 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 Dict, Iterable, Union from jinja2 import StrictUndefined from jinja2.sandbox import SandboxedEnvironment from packaging.requirements import Requirement -# TODO: Remove once support for Python 3.10 is dropped. +# 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", ".")) +project_dir = Path.cwd() with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: pyproject = tomllib.load(pyproject_file) project = pyproject["project"] project_name = project["name"] -with project_dir.joinpath("devdeps.txt").open() as devdeps_file: - devdeps = [line.strip() for line in devdeps_file if not line.startswith("-e")] +devdeps = [dep for group in pyproject["dependency-groups"].values() for dep in group if not dep.startswith("-e")] -PackageMetadata = Dict[str, Union[str, Iterable[str]]] -Metadata = Dict[str, PackageMetadata] +PackageMetadata = dict[str, str | Iterable[str]] +Metadata = dict[str, PackageMetadata] def _merge_fields(metadata: dict) -> PackageMetadata: @@ -47,7 +45,7 @@ def _norm_name(name: str) -> str: return name.replace("_", "-").replace(".", "-").lower() -def _requirements(deps: list[str]) -> dict[str, Requirement]: +def _requirements(deps: Iterable[str]) -> dict[str, Requirement]: return {_norm_name((req := Requirement(dep)).name): req for dep in deps} @@ -63,8 +61,8 @@ def _extra_marker(req: Requirement) -> str | 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] + name = _norm_name(pkg.name) + metadata[name] = _merge_fields(pkg.metadata) # ty: ignore[invalid-argument-type] metadata[name]["spec"] = set() metadata[name]["extras"] = set() metadata[name].setdefault("summary", "") @@ -77,10 +75,11 @@ def _set_license(metadata: PackageMetadata) -> None: 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_names = [ + classifier.rsplit("::", 1)[1].strip() + for classifier in metadata["classifier"] + if classifier.startswith("License ::") + ] license_name = " + ".join(license_names) metadata["license"] = license_name or "?" @@ -88,10 +87,10 @@ def _set_license(metadata: PackageMetadata) -> None: 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: + 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] + metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # ty: ignore[unsupported-operator] + metadata[dep_name]["extras"] |= dep_req.extras # ty: ignore[unsupported-operator] deps[dep_name] = metadata[dep_name] again = True @@ -109,7 +108,7 @@ def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata 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] + metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # ty: ignore[unsupported-operator] deps[dep_name] = metadata[dep_name] again = True @@ -121,7 +120,7 @@ def _render_credits() -> str: dev_dependencies = _get_deps(_requirements(devdeps), metadata) prod_dependencies = _get_deps( _requirements( - chain( # type: ignore[arg-type] + chain( project.get("dependencies", []), chain(*project.get("optional-dependencies", {}).values()), ), @@ -131,8 +130,8 @@ def _render_credits() -> str: template_data = { "project_name": project_name, - "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"])), - "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["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( diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py deleted file mode 100644 index 9565765e..00000000 --- a/scripts/gen_ref_nav.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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"::: {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/get_version.py b/scripts/get_version.py new file mode 100644 index 00000000..d56f5858 --- /dev/null +++ b/scripts/get_version.py @@ -0,0 +1,31 @@ +# Get current project version from Git tags or changelog. + +import re +from contextlib import suppress +from pathlib import Path + +from pdm.backend.hooks.version import ( # ty: ignore[unresolved-import] + SCMVersion, + Version, + default_version_formatter, + get_version_from_scm, +) + +_root = Path(__file__).parent.parent +_changelog = _root / "CHANGELOG.md" +_changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") +_default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003 + + +def get_version() -> str: + scm_version = get_version_from_scm(_root) or _default_scm_version + if scm_version.version <= Version("0.1"): # Missing Git tags? + with suppress(OSError, StopIteration): # noqa: SIM117 + with _changelog.open("r", encoding="utf8") as file: + match = next(filter(None, map(_changelog_version_re.match, file))) # ty: ignore[invalid-argument-type] + scm_version = scm_version._replace(version=Version(match.group(1))) + return default_version_formatter(scm_version) + + +if __name__ == "__main__": + print(get_version()) diff --git a/scripts/insiders.py b/scripts/insiders.py deleted file mode 100644 index 15212486..00000000 --- a/scripts/insiders.py +++ /dev/null @@ -1,203 +0,0 @@ -"""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 Iterable, cast -from urllib.error import HTTPError -from urllib.parse import urljoin -from urllib.request import urlopen - -import yaml - -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 deleted file mode 100755 index f690126e..00000000 --- a/scripts/make +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env bash - -set -e -export PYTHON_VERSIONS=${PYTHON_VERSIONS-3.8 3.9 3.10 3.11 3.12} - -exe="" -prefix="" - - -# Install runtime and development dependencies, -# as well as current project in editable mode. -uv_install() { - uv pip compile pyproject.toml devdeps.txt | uv pip install -r - - if [ -z "${CI}" ]; then - uv pip install -e . - else - uv pip install "mkdocstrings @ ." - fi -} - - -# Setup the development environment by installing dependencies -# in multiple Python virtual environments with uv: -# one venv per Python version in `.venvs/$py`, -# and an additional default venv in `.venv`. -setup() { - if ! command -v uv &>/dev/null; then - echo "make: setup: uv must be installed, see https://github.com/astral-sh/uv" >&2 - return 1 - fi - - if [ -n "${PYTHON_VERSIONS}" ]; then - for version in ${PYTHON_VERSIONS}; do - if [ ! -d ".venvs/${version}" ]; then - uv venv --python "${version}" ".venvs/${version}" - fi - VIRTUAL_ENV="${PWD}/.venvs/${version}" uv_install - done - fi - - if [ ! -d .venv ]; then uv venv --python python; fi - uv_install -} - - -# Activate a Python virtual environments. -# The annoying operating system also requires -# that we set some global variables to help it find commands... -activate() { - local path - if [ -f "$1/bin/activate" ]; then - source "$1/bin/activate" - return 0 - fi - if [ -f "$1/Scripts/activate.bat" ]; then - "$1/Scripts/activate.bat" - exe=".exe" - prefix="$1/Scripts/" - return 0 - fi - echo "run: Cannot activate venv $1" >&2 - return 1 -} - - -# Run a command in all configured Python virtual environments. -# We handle the case when the `PYTHON_VERSIONS` environment variable -# is unset or empty, for robustness. -multirun() { - local cmd="$1" - shift - - if [ -n "${PYTHON_VERSIONS}" ]; then - for version in ${PYTHON_VERSIONS}; do - (activate ".venvs/${version}" && MULTIRUN=1 "${prefix}${cmd}${exe}" "$@") - done - else - (activate .venv && "${prefix}${cmd}${exe}" "$@") - fi -} - - -# Run a command in the default Python virtual environment. -# We rely on `multirun`'s handling of empty `PYTHON_VERSIONS`. -singlerun() { - PYTHON_VERSIONS= multirun "$@" -} - - -# Record options following a command name, -# until a non-option argument is met or there are no more arguments. -# Output each option on a new line, so the parent caller can store them in an array. -# Return the number of times the parent caller must shift arguments. -options() { - local shift_count=0 - for arg in "$@"; do - if [[ "${arg}" =~ ^- || "${arg}" =~ ^.+= ]]; then - echo "${arg}" - ((shift_count++)) - else - break - fi - done - return ${shift_count} -} - - -# Main function. -main() { - local cmd - while [ $# -ne 0 ]; do - cmd="$1" - shift - - # Handle `run` early to simplify `case` below. - if [ "${cmd}" = "run" ]; then - singlerun "$@" - exit $? - fi - - # Handle `multirun` early to simplify `case` below. - if [ "${cmd}" = "multirun" ]; then - multirun "$@" - exit $? - fi - - # All commands except `run` and `multirun` can be chained on a single line. - # Some of them accept options in two formats: `-f`, `--flag` and `param=value`. - # Some of them don't, and will print warnings/errors if options were given. - opts=($(options "$@")) || shift $? - - case "${cmd}" in - # The following commands require special handling. - help|"") - singlerun duty --list ;; - setup) - setup ;; - check) - multirun duty check-quality check-types check-docs - singlerun duty check-dependencies check-api - ;; - - # The following commands run in all venvs. - check-quality|\ - check-docs|\ - check-types|\ - test) - multirun duty "${cmd}" "${opts[@]}" ;; - - # The following commands run in the default venv only. - *) - singlerun duty "${cmd}" "${opts[@]}" ;; - esac - done -} - - -# Execute the main function. -main "$@" diff --git a/scripts/make b/scripts/make new file mode 120000 index 00000000..c2eda0df --- /dev/null +++ b/scripts/make @@ -0,0 +1 @@ +make.py \ No newline at end of file diff --git a/scripts/make.py b/scripts/make.py new file mode 100755 index 00000000..7fa7b56d --- /dev/null +++ b/scripts/make.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +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 TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator + + +PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.10 3.11 3.12 3.13 3.14 3.15").split() +PYTHON_DEV = "3.15" + + +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)") + default_venv = Path(".venv") + if not default_venv.exists(): + shell("uv venv") + uv_install(default_venv) + + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + print(f"\nInstalling dependencies (python{version})") + 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) + + +class _RunError(subprocess.CalledProcessError): + def __init__(self, *args: Any, python_version: str, **kwargs: Any): + super().__init__(*args, **kwargs) + self.python_version = python_version + + +def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in a virtual environment.""" + kwargs = {"check": True, **kwargs} + uv_run = ["uv", "run", "--no-sync"] + try: + 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 + except subprocess.CalledProcessError as process: + raise _RunError( + returncode=process.returncode, + python_version=version, + cmd=process.cmd, + output=process.output, + stderr=process.stderr, + ) from process + + +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: + shutil.rmtree(path, ignore_errors=True) + + cache_dirs = {".cache", ".pytest_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.""" + shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True) + + +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, + ) + if Path(".venv").exists(): + print("\nAvailable tasks", flush=True) + run("default", "duty", "--list") + return 0 + + while args: + cmd = args.pop(0) + + if cmd == "run": + if not args: + print("make: run: missing command", file=sys.stderr) + return 1 + run("default", *args) + return 0 + + if cmd == "multirun": + if not args: + print("make: run: missing command", file=sys.stderr) + return 1 + multirun(*args) + return 0 + + if cmd == "allrun": + if not args: + print("make: run: missing command", file=sys.stderr) + return 1 + allrun(*args) + return 0 + + if cmd.startswith("3."): + if not args: + print("make: run: missing command", file=sys.stderr) + return 1 + 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 _RunError as process: + if process.output: + print(process.output, file=sys.stderr) + if (code := process.returncode) == 139: # noqa: PLR2004 + print( + f"✗ (python{process.python_version}) '{' '.join(process.cmd)}' failed with return code {code} (segfault)", + file=sys.stderr, + ) + if process.python_version == PYTHON_DEV: + code = 0 + sys.exit(code) diff --git a/src/mkdocstrings/__init__.py b/src/mkdocstrings/__init__.py index 03550f9b..137811b1 100644 --- a/src/mkdocstrings/__init__.py +++ b/src/mkdocstrings/__init__.py @@ -2,3 +2,65 @@ Automatic documentation from sources, for MkDocs. """ + +from __future__ import annotations + +from mkdocstrings._internal.extension import AutoDocProcessor, MkdocstringsExtension, makeExtension +from mkdocstrings._internal.handlers.base import ( + BaseHandler, + CollectionError, + CollectorItem, + HandlerConfig, + HandlerOptions, + Handlers, + ThemeNotSupported, + do_any, +) +from mkdocstrings._internal.handlers.rendering import ( + HeadingShiftingTreeprocessor, + Highlighter, + IdPrependingTreeprocessor, + MkdocstringsInnerExtension, + ParagraphStrippingTreeprocessor, +) +from mkdocstrings._internal.inventory import Inventory, InventoryItem +from mkdocstrings._internal.loggers import ( + TEMPLATES_DIRS, + LoggerAdapter, + TemplateLogger, + get_logger, + get_template_logger, + get_template_logger_function, + get_template_path, +) +from mkdocstrings._internal.plugin import MkdocstringsPlugin, PluginConfig + +__all__: list[str] = [ + "TEMPLATES_DIRS", + "AutoDocProcessor", + "BaseHandler", + "CollectionError", + "CollectorItem", + "HandlerConfig", + "HandlerOptions", + "Handlers", + "HeadingShiftingTreeprocessor", + "Highlighter", + "IdPrependingTreeprocessor", + "Inventory", + "InventoryItem", + "LoggerAdapter", + "MkdocstringsExtension", + "MkdocstringsInnerExtension", + "MkdocstringsPlugin", + "ParagraphStrippingTreeprocessor", + "PluginConfig", + "TemplateLogger", + "ThemeNotSupported", + "do_any", + "get_logger", + "get_template_logger", + "get_template_logger_function", + "get_template_path", + "makeExtension", +] diff --git a/src/mkdocstrings/_cache.py b/src/mkdocstrings/_cache.py deleted file mode 100644 index 8737f317..00000000 --- a/src/mkdocstrings/_cache.py +++ /dev/null @@ -1,76 +0,0 @@ -import datetime -import gzip -import hashlib -import os -import urllib.parse -import urllib.request -from typing import BinaryIO, Callable - -import click -import platformdirs - -from mkdocstrings.loggers import get_logger - -log = get_logger(__name__) - - -def download_url_with_gz(url: str) -> bytes: - req = urllib.request.Request( # noqa: S310 - url, - headers={"Accept-Encoding": "gzip", "User-Agent": "mkdocstrings/0.15.0"}, - ) - 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() - - -# 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(f"Using cached '{path}' for '{url}'") - return f.read() - except (OSError, ValueError) as e: - log.debug(f"{type(e).__name__}: {e}") - - # Download and cache the file - log.debug(f"Downloading '{url}' to '{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/_internal/__init__.py b/src/mkdocstrings/_internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mkdocstrings/debug.py b/src/mkdocstrings/_internal/debug.py similarity index 82% rename from src/mkdocstrings/debug.py rename to src/mkdocstrings/_internal/debug.py index b5da78f2..7b56409b 100644 --- a/src/mkdocstrings/debug.py +++ b/src/mkdocstrings/_internal/debug.py @@ -1,5 +1,3 @@ -"""Debugging utilities.""" - from __future__ import annotations import os @@ -10,7 +8,7 @@ @dataclass -class Variable: +class _Variable: """Dataclass describing an environment variable.""" name: str @@ -20,7 +18,7 @@ class Variable: @dataclass -class Package: +class _Package: """Dataclass describing a Python package.""" name: str @@ -30,7 +28,7 @@ class Package: @dataclass -class Environment: +class _Environment: """Dataclass to store environment information.""" interpreter_name: str @@ -41,9 +39,9 @@ class Environment: """Path to Python executable.""" platform: str """Operating System.""" - packages: list[Package] + packages: list[_Package] """Installed packages.""" - variables: list[Variable] + variables: list[_Variable] """Environment variables.""" @@ -58,7 +56,7 @@ def _interpreter_name_version() -> tuple[str, str]: return "", "0.0.0" -def get_version(dist: str = "mkdocstrings") -> str: +def _get_version(dist: str = "mkdocstrings") -> str: """Get version of the given distribution. Parameters: @@ -73,7 +71,7 @@ def get_version(dist: str = "mkdocstrings") -> str: return "0.0.0" -def get_debug_info() -> Environment: +def _get_debug_info() -> _Environment: """Get debug/environment information. Returns: @@ -82,19 +80,19 @@ def get_debug_info() -> Environment: py_name, py_version = _interpreter_name_version() packages = ["mkdocstrings"] variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("MKDOCSTRINGS")]] - return Environment( + 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], + 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: +def _print_debug_info() -> None: """Print debug/environment information.""" - info = get_debug_info() + info = _get_debug_info() print(f"- __System__: {info.platform}") print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") print("- __Environment variables__:") @@ -106,4 +104,4 @@ def print_debug_info() -> None: if __name__ == "__main__": - print_debug_info() + _print_debug_info() diff --git a/src/mkdocstrings/_internal/download.py b/src/mkdocstrings/_internal/download.py new file mode 100644 index 00000000..52bf42f5 --- /dev/null +++ b/src/mkdocstrings/_internal/download.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import base64 +import gzip +import os +import re +import urllib.parse +import urllib.request +from typing import TYPE_CHECKING, BinaryIO + +from mkdocstrings._internal.loggers import get_logger + +if TYPE_CHECKING: + from collections.abc import Mapping + + +_logger = get_logger("mkdocstrings") + +# Regex pattern for an environment variable in the form ${ENV_VAR}. +_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + +# Timeout in seconds for downloading. +_TIMEOUT = 10 + + +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, timeout=_TIMEOUT) as resp: # noqa: S310 + content: BinaryIO = resp + if "gzip" in resp.headers.get("content-encoding", ""): + content = gzip.GzipFile(fileobj=resp) # ty: ignore[invalid-assignment] + return content.read() + + +def _expand_env_vars(credential: str, url: str, env: Mapping[str, str] | None = 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: + _logger.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. + _logger.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) + _logger.debug("Using basic authentication for %s", url) + credentials = base64.encodebytes(f"{user}:{pwd}".encode()).decode().strip() + return {"Authorization": f"Basic {credentials}"} diff --git a/src/mkdocstrings/_internal/extension.py b/src/mkdocstrings/_internal/extension.py new file mode 100644 index 00000000..0722c539 --- /dev/null +++ b/src/mkdocstrings/_internal/extension.py @@ -0,0 +1,493 @@ +# 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`. +# +# For each of these blocks, it uses a [handler][mkdocstrings.BaseHandler] to collect documentation about +# the given identifier and render it with Jinja templates. +# +# Both the collection and rendering process can be configured by adding YAML configuration under the "autodoc" +# instruction: +# +# ```yaml +# ::: some.identifier +# handler: python +# options: +# option1: value1 +# option2: +# - value2a +# - value2b +# option_x: etc +# ``` + +from __future__ import annotations + +import re +from functools import partial +from inspect import signature +from typing import TYPE_CHECKING, Any +from xml.etree.ElementTree import Element + +import yaml +from jinja2.exceptions import TemplateNotFound +from markdown.blockprocessors import BlockProcessor +from markdown.extensions import Extension +from markdown.treeprocessors import Treeprocessor +from mkdocs.exceptions import PluginError +from mkdocs_autorefs import AutorefsConfig, AutorefsExtension, AutorefsPlugin + +from mkdocstrings._internal.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers +from mkdocstrings._internal.loggers import get_logger + +if TYPE_CHECKING: + from collections.abc import MutableSequence + + from markdown import Markdown + + +_logger = get_logger("mkdocstrings") + + +class AutoDocProcessor(BlockProcessor): + """Our "autodoc" Markdown block processor. + + It has a [`test` method][mkdocstrings.AutoDocProcessor.test] that tells if a block matches a criterion, + and a [`run` method][mkdocstrings.AutoDocProcessor.run] that processes it. + + It also has utility methods allowing to get handlers and their configuration easily, useful when processing + a matched block. + """ + + regex = re.compile(r"^(?P#{1,6} *|)::: ?(?P.+?) *$", flags=re.MULTILINE) + """The regular expression to match our autodoc instructions.""" + + def __init__( + self, + md: Markdown, + *, + handlers: Handlers, + autorefs: AutorefsPlugin, + ) -> None: + """Initialize the object. + + Arguments: + md: A `markdown.Markdown` instance. + handlers: The handlers container. + autorefs: The autorefs plugin instance. + """ + super().__init__(parser=md.parser) + self.md = md + """The Markdown instance.""" + self._handlers = handlers + self._autorefs = autorefs + self._updated_envs: set = set() + + def test(self, parent: Element, block: str) -> bool: # noqa: ARG002 + """Match our autodoc instructions. + + Arguments: + parent: The parent element in the XML tree. + block: The block to be tested. + + Returns: + Whether this block should be processed or not. + """ + return bool(self.regex.search(block)) + + def run(self, parent: Element, blocks: MutableSequence[str]) -> None: + """Run code on the matched blocks. + + The identifier and configuration lines are retrieved from a matched block + and used to collect and render an object. + + Arguments: + parent: The parent element in the XML tree. + blocks: The rest of the blocks to be processed. + """ + block = blocks.pop(0) + match = self.regex.search(block) + + if match: + if match.start() > 0: + self.parser.parseBlocks(parent, [block[: match.start()]]) + # removes the first line + block = block[match.end() :] + + 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("#") + _logger.debug("Matched '::: %s'", identifier) + + html, handler, _ = self._process_block(identifier, block, heading_level) + el = Element("div", {"class": "mkdocstrings"}) + # The final HTML is inserted as opaque to subsequent processing, and only revealed at the end. + el.text = self.md.htmlStash.store(html) + + if handler.outer_layer: + self._process_headings(handler, el) + + parent.append(el) + + if the_rest: + # This block contained unindented line(s) after the first indented + # line. Insert these lines as the first block of the master blocks + # list for future processing. + blocks.insert(0, the_rest) + + def _process_block( + self, + identifier: str, + yaml_block: str, + heading_level: int = 0, + ) -> 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 heading to insert (0 to ignore). + + Raises: + PluginError: When something wrong happened during collection. + TemplateNotFound: When a template used for rendering could not be found. + + Returns: + Rendered HTML, the handler that was used, and the collected item. + """ + local_config = yaml.safe_load(yaml_block) or {} + handler_name = self._handlers.get_handler_name(local_config) + + _logger.debug("Using handler '%s'", handler_name) + handler = self._handlers.get_handler(handler_name) + + local_options = local_config.get("options", {}) + if heading_level: + # Heading level obtained from Markdown (`##`) takes precedence. + local_options["heading_level"] = heading_level + + options = handler.get_options(local_options) + + _logger.debug("Collecting data") + try: + data: CollectorItem = handler.collect(identifier, options) + except CollectionError as exception: + _logger.error("%s", exception) # noqa: TRY400 + raise PluginError(f"Could not collect '{identifier}'") from exception + + if handler_name not in self._updated_envs: # We haven't seen this handler before on this document. + _logger.debug("Updating handler's rendering env") + handler._update_env(self.md, config=self._handlers._tool_config) + self._updated_envs.add(handler_name) + + _logger.debug("Rendering templates") + if "locale" in signature(handler.render).parameters: + render = partial(handler.render, locale=self._handlers._locale) + else: + render = handler.render + try: + rendered = render(data, options) + except TemplateNotFound as exc: + _logger.error( # noqa: TRY400 + "Template '%s' not found for '%s' handler and theme '%s'.", + exc.name, + handler_name, + self._handlers._theme, + ) + raise + + return rendered, handler, data + + def _process_headings(self, handler: BaseHandler, element: Element) -> None: + # We're in the outer handler layer, as well as the outer extension layer. + # + # The "handler layer" tracks the nesting of the autodoc blocks, which can appear in docstrings. + # + # - Render ::: Object1 # Outer handler layer + # - Render Object1's docstring # Outer handler layer + # - Docstring renders ::: Object2 # Inner handler layers + # - etc. # Inner handler layers + # + # The "extension layer" tracks whether we're converting an autodoc instruction + # or nested content within it, like docstrings. Markdown conversion within Markdown conversion. + # + # - Render ::: Object1 # Outer extension layer + # - Render Object1's docstring # Inner extension layer + # + # The generated HTML was just stashed, and the `toc` extension won't be able to see headings. + # 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 extension layer, and not in the outer one where we are now. + headings = handler.get_headings() + element.extend(headings) + # These duplicated headings will later be removed by our `_HeadingsPostProcessor` processor, + # which runs right after `toc` (see `MkdocstringsExtension.extendMarkdown`). + # + # If we were in an inner handler layer, we wouldn't do any of this + # and would just let headings bubble up to the outer handler layer. + + if (page := self._autorefs.current_page) is None: + return + + for heading in headings: + rendered_id = heading.attrib["id"] + + skip_inventory = "data-skip-inventory" in heading.attrib + if skip_inventory: + _logger.debug( + "Skipping heading with id %r because data-skip-inventory is present", + rendered_id, + ) + continue + + # The title is registered to be used as tooltip by autorefs. + self._autorefs.register_anchor(page, rendered_id, title=heading.text, primary=True) + + # Register all identifiers for this object + # both in the autorefs plugin and in the inventory. + aliases: tuple[str, ...] + aliases = handler.get_aliases(rendered_id) + + for alias in aliases: + if alias != rendered_id: + self._autorefs.register_anchor(page, alias, rendered_id, primary=False) + + if "data-role" in heading.attrib: + self._handlers.inventory.register( + name=rendered_id, + domain=handler.domain, + role=heading.attrib["data-role"], + priority=1, # Register with standard priority. + uri=f"{page.url}#{rendered_id}", + ) + for alias in aliases: + if alias not in self._handlers.inventory: + self._handlers.inventory.register( + name=alias, + domain=handler.domain, + role=heading.attrib["data-role"], + priority=2, # Register with lower priority. + uri=f"{page.url}#{rendered_id}", + ) + + +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(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 + parent.remove(el) + else: + if carry_text: + el.tail = (el.tail or "") + carry_text + carry_text = "" + self._remove_duplicated_headings(el) + + if 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) # ty: ignore[unresolved-attribute] + + 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): + """Our Markdown extension. + + It cannot work outside of `mkdocstrings`. + """ + + def __init__( + self, + handlers: Handlers, + autorefs: AutorefsPlugin, + *, + autorefs_extension: bool = False, + **kwargs: Any, + ) -> None: + """Initialize the object. + + Arguments: + handlers: The handlers container. + autorefs: The autorefs plugin instance. + autorefs_extension: Whether the autorefs extension must be registered. + **kwargs: Keyword arguments used by `markdown.extensions.Extension`. + """ + super().__init__(**kwargs) + self._handlers = handlers + self._autorefs = autorefs + self._autorefs_extension = autorefs_extension + + def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name) + """Register the extension. + + Add an instance of our [`AutoDocProcessor`][mkdocstrings.AutoDocProcessor] to the Markdown parser. + + Arguments: + md: A `markdown.Markdown` instance. + """ + md.registerExtension(self) + + # Zensical integration: get the current page from the Zensical-specific preprocessor. + if "zensical_current_page" in md.preprocessors: + self._autorefs.current_page = md.preprocessors["zensical_current_page"] + + md.parser.blockprocessors.register( + AutoDocProcessor(md, handlers=self._handlers, autorefs=self._autorefs), + "mkdocstrings", + priority=75, # Right before markdown.blockprocessors.HashHeaderProcessor + ) + md.treeprocessors.register( + _HeadingsPostProcessor(md), + "mkdocstrings_post_headings", + priority=4, # Right after 'toc'. + ) + md.treeprocessors.register( + _TocLabelsTreeProcessor(md), + "mkdocstrings_post_toc_labels", + priority=4, # Right after 'toc'. + ) + + if self._autorefs_extension: + AutorefsExtension(self._autorefs).extendMarkdown(md) + + +# ----------------------------------------------------------------------------- +# The following is only used by Zensical. The goal is to provide temporary +# compatibility for users migrating from MkDocs (and Material for MkDocs) +# to Zensical. When detecting the use of the mkdocstrings plugin in mkdocs.yml, +# Zensical will add the mkdocstrings extension to its Markdown extensions. + +_default_config: dict[str, Any] = { + "default_handler": "python", + "handlers": {}, + "custom_templates": None, + "locale": "en", + "enable_inventory": True, + "enabled": True, +} + + +def _split_configs( + markdown_extensions: list[str | dict[str, dict[str, Any]] | Extension], +) -> tuple[list[str | Extension], dict[str, dict[str, Any]]]: + # Split markdown extensions and their configs from mkdocs.yml + mdx: list[str | Extension] = [] + mdx_config: dict[str, dict[str, Any]] = {} + for item in markdown_extensions: + if isinstance(item, (str, Extension)): + mdx.append(item) + elif isinstance(item, dict): + for key, value in item.items(): + mdx.append(key) + mdx_config[key] = value + break # Only one item per dict + return mdx, mdx_config + + +class _ToolConfig: + def __init__(self, config_file_path: str | None = None) -> None: + self.config_file_path = config_file_path + + +_AUTOREFS = None +_HANDLERS = None + + +def makeExtension( # noqa: N802 + *, + default_handler: str | None = None, + inventory_project: str | None = None, + inventory_version: str | None = None, + handlers: dict[str, dict] | None = None, + custom_templates: str | None = None, + markdown_extensions: list[str | dict | Extension] | None = None, + locale: str | None = None, + config_file_path: str | None = None, +) -> MkdocstringsExtension: + """Create the extension instance. + + We only support this function being used by Zensical. + Consider this function private API. + """ + global _AUTOREFS # noqa: PLW0603 + if _AUTOREFS is None: + _AUTOREFS = AutorefsPlugin() + _AUTOREFS.config = AutorefsConfig() # ty:ignore[invalid-assignment] + _AUTOREFS.config.resolve_closest = True + _AUTOREFS.config.link_titles = "auto" + _AUTOREFS.config.strip_title_tags = "auto" + _AUTOREFS.scan_toc = True + _AUTOREFS._link_titles = "external" + _AUTOREFS._strip_title_tags = False + + global _HANDLERS # noqa: PLW0603 + if _HANDLERS is None: + mdx, mdx_config = _split_configs(markdown_extensions or []) + tool_config = _ToolConfig(config_file_path=config_file_path) + mdx.append(AutorefsExtension(_AUTOREFS)) + _HANDLERS = Handlers( + theme="material", + default=default_handler or _default_config["default_handler"], + inventory_project=inventory_project or "Project", + inventory_version=inventory_version or "0.0.0", + handlers_config=handlers or _default_config["handlers"], + custom_templates=custom_templates or _default_config["custom_templates"], + mdx=mdx, + mdx_config=mdx_config, + locale=locale or _default_config["locale"], + tool_config=tool_config, + ) + + _HANDLERS._download_inventories() + register = _AUTOREFS.register_url + for identifier, url in _HANDLERS._yield_inventory_items(): + register(identifier, url) + + return MkdocstringsExtension( + handlers=_HANDLERS, + autorefs=_AUTOREFS, + autorefs_extension=True, + ) + + +def _reset() -> None: + global _AUTOREFS, _HANDLERS # noqa: PLW0603 + _AUTOREFS = None + _HANDLERS = None + + +def _get_autorefs() -> dict[str, Any]: + if _AUTOREFS: + return { + "primary": _AUTOREFS._primary_url_map, + "secondary": _AUTOREFS._secondary_url_map, + "inventory": _AUTOREFS._abs_url_map, + "titles": _AUTOREFS._title_map, + } + return {} + + +def _get_inventory() -> bytes: + if _HANDLERS: + return _HANDLERS.inventory.format_sphinx() + return b"" diff --git a/src/mkdocstrings/_internal/handlers/__init__.py b/src/mkdocstrings/_internal/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mkdocstrings/_internal/handlers/base.py b/src/mkdocstrings/_internal/handlers/base.py new file mode 100644 index 00000000..799c4499 --- /dev/null +++ b/src/mkdocstrings/_internal/handlers/base.py @@ -0,0 +1,666 @@ +# Base module for handlers. +# +# This module contains the base classes for implementing handlers. + +from __future__ import annotations + +import datetime +import importlib +import ssl +from concurrent import futures +from importlib.metadata import entry_points +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar, cast +from warnings import warn +from xml.etree.ElementTree import Element, tostring + +from jinja2 import Environment, FileSystemLoader +from markdown import Markdown +from markupsafe import Markup +from mkdocs.utils.cache import download_and_cache_url +from mkdocs_autorefs import AutorefsInlineProcessor, BacklinksTreeProcessor + +from mkdocstrings._internal.download import _download_url_with_gz +from mkdocstrings._internal.handlers.rendering import ( + HeadingShiftingTreeprocessor, + Highlighter, + IdPrependingTreeprocessor, + MkdocstringsInnerExtension, + ParagraphStrippingTreeprocessor, +) +from mkdocstrings._internal.inventory import Inventory +from mkdocstrings._internal.loggers import get_logger, get_template_logger + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Mapping, Sequence + + from markdown import Extension + from markdown.extensions.toc import TocTreeprocessor + from mkdocs_autorefs import AutorefsHookInterface, Backlink + +_logger = get_logger("mkdocstrings") + +CollectorItem = Any +"""The type of the item returned by the `collect` method of a handler.""" +HandlerConfig = Any +"""The type of the configuration of a handler.""" +HandlerOptions = Any +"""The type of the options passed to a handler.""" + + +# Autodoc instructions can appear in nested Markdown, +# so we need to keep track of the Markdown conversion layer we're in. +# Since any handler can be called from any Markdown conversion layer, +# we need to keep track of the layer globally. +# This global variable is incremented/decremented in `do_convert_markdown`, +# and used in `outer_layer`. +_markdown_conversion_layer: int = 0 + + +class CollectionError(Exception): + """An exception raised when some collection of data failed.""" + + +class ThemeNotSupported(Exception): # noqa: N818 + """An exception raised to tell a theme is not supported.""" + + +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. + + Arguments: + seq: An iterable object. + attribute: The attribute name to use on each object of the iterable. + + Returns: + A boolean telling if any object of the iterable evaluated to True. + """ + if attribute is None: + return any(seq) + return any(_[attribute] for _ in seq) + + +class BaseHandler: + """The base handler class. + + Inherit from this class to implement a handler. + + 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. + """ + + name: ClassVar[str] + """The handler's name, for example "python".""" + + domain: ClassVar[str] + """The handler's domain, used to register objects in the inventory, for example "py".""" + + enable_inventory: ClassVar[bool] = False + """Whether the inventory creation is enabled.""" + + fallback_theme: ClassVar[str] = "" + """Fallback theme to use when a template isn't found in the configured theme.""" + + extra_css: str = "" + """Extra CSS.""" + + def __init__( + self, + *, + theme: str, + custom_templates: str | None, + mdx: Sequence[str | Extension], + mdx_config: Mapping[str, Any], + ) -> None: + """Initialize the object. + + If the given theme is not supported (it does not exist), it will look for a `fallback_theme` attribute + in `self` to use as a fallback theme. + + Keyword Arguments: + theme (str): The theme to use. + custom_templates (str | None): The path to custom templates. + mdx (list[str | Extension]): A list of Markdown extensions to use. + mdx_config (Mapping[str, Mapping[str, Any]]): Configuration for the Markdown extensions. + """ + self.theme = theme + """The selected theme.""" + self.custom_templates = custom_templates + """The path to custom templates.""" + self.mdx = mdx + """The Markdown extensions to use.""" + self.mdx_config = mdx_config + """The configuration for the Markdown extensions.""" + self._md: Markdown | None = None + self._headings: list[Element] = [] + + paths = [] + + # add selected theme templates + themes_dir = self.get_templates_dir(self.name) + paths.append(themes_dir / self.theme) + + # add extended theme templates + extended_templates_dirs = self.get_extended_templates_dirs(self.name) + paths.extend(templates_dir / self.theme for templates_dir in extended_templates_dirs) + + # add fallback theme templates + if self.fallback_theme and self.fallback_theme != self.theme: + paths.append(themes_dir / self.fallback_theme) + + # add fallback theme of extended templates + paths.extend(templates_dir / self.fallback_theme for templates_dir in extended_templates_dirs) + + for path in paths: + css_path = path / "style.css" + if css_path.is_file(): + self.extra_css += "\n" + css_path.read_text(encoding="utf-8") + break + + if self.custom_templates is not None: + paths.insert(0, Path(self.custom_templates) / self.name / self.theme) + + self.env = Environment( + autoescape=True, + loader=FileSystemLoader(paths), + auto_reload=False, # Editing a template in the middle of a build is not useful. + ) + """The Jinja environment.""" + + self.env.filters["convert_markdown"] = self.do_convert_markdown + self.env.filters["heading"] = self.do_heading + self.env.filters["any"] = do_any + self.env.globals["log"] = get_template_logger(self.name) # ty:ignore[invalid-assignment] + + @property + def md(self) -> Markdown: + """The Markdown instance. + + Raises: + RuntimeError: When the Markdown instance is not set yet. + """ + if self._md is None: + raise RuntimeError("Markdown instance not set yet") + return self._md + + def get_inventory_urls(self) -> list[tuple[str, dict[str, Any]]]: + """Return the URLs (and configuration options) of the inventory files to download.""" + return [] + + @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 get_options(self, local_options: Mapping[str, Any]) -> HandlerOptions: + """Get combined options. + + Override this method to customize how options are combined, + for example by merging the global options with the local options. + By combining options here, you don't have to do it twice in `collect` and `render`. + + Arguments: + local_options: The local options. + + Returns: + The combined options. + """ + return local_options + + def collect(self, identifier: str, options: HandlerOptions) -> 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.BaseHandler' to collect documentation about the BaseHandler class. + It can be anything that you can feed to the tool of your choice. + options: The final 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, options: HandlerOptions, *, locale: str | None = None) -> str: + """Render a template using provided data and configuration options. + + Arguments: + data: The collected data to render. + options: The final configuration options. + locale: The locale to use for translations, if any. + + Returns: + The rendered template as HTML. + """ + raise NotImplementedError + + def render_backlinks(self, backlinks: Mapping[str, Iterable[Backlink]], *, locale: str | None = None) -> str: # noqa: ARG002 + """Render backlinks. + + Parameters: + backlinks: A mapping of identifiers to backlinks. + locale: The locale to use for translations, if any. + + Returns: + The rendered backlinks as HTML. + """ + return "" + + 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. + + Arguments: + 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. + """ + handler = handler or self.name + try: + import mkdocstrings_handlers # noqa: PLC0415 + except ModuleNotFoundError as error: + raise ModuleNotFoundError(f"Handler '{handler}' not found, is it installed?") from error + + for path in mkdocstrings_handlers.__path__: + theme_path = Path(path, handler, "templates") + if theme_path.exists(): + return theme_path + + raise FileNotFoundError(f"Can't find 'templates' folder for handler '{handler}'") + + 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_aliases(self, identifier: str) -> tuple[str, ...]: # noqa: ARG002 + """Return the possible aliases for a given identifier. + + Arguments: + identifier: The identifier to get the aliases of. + + Returns: + A tuple of strings - aliases. + """ + return () + + @property + def outer_layer(self) -> bool: + """Whether we're in the outer Markdown conversion layer.""" + return _markdown_conversion_layer == 0 + + def do_convert_markdown( + 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. + + Arguments: + text: The text to convert. + heading_level: The base heading level to start all Markdown headings from. + html_id: The HTML id of the element that's considered the parent of this element. + strip_paragraph: Whether to exclude the `

` tag from around the whole output. + + Returns: + An HTML string. + """ + global _markdown_conversion_layer # noqa: PLW0603 + _markdown_conversion_layer += 1 + 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 + if BacklinksTreeProcessor.name in treeprocessors: + treeprocessors[BacklinksTreeProcessor.name].initial_id = html_id + if autoref_hook and AutorefsInlineProcessor.name in self.md.inlinePatterns: + self.md.inlinePatterns[AutorefsInlineProcessor.name].hook = autoref_hook # ty: ignore[unresolved-attribute] + + try: + return Markup(self.md.convert(text)) + finally: + treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0 + treeprocessors[IdPrependingTreeprocessor.name].id_prefix = "" + treeprocessors[ParagraphStrippingTreeprocessor.name].strip = False + if BacklinksTreeProcessor.name in treeprocessors: + treeprocessors[BacklinksTreeProcessor.name].initial_id = None + if AutorefsInlineProcessor.name in self.md.inlinePatterns: + self.md.inlinePatterns[AutorefsInlineProcessor.name].hook = None + self.md.reset() + _markdown_conversion_layer -= 1 + + def do_heading( + self, + content: Markup, + heading_level: int, + *, + role: str | None = None, + hidden: bool = False, + toc_label: str | None = None, + skip_inventory: bool = False, + **attributes: str, + ) -> Markup: + """Render an HTML heading and register it for the table of contents. For use inside templates. + + Arguments: + content: The HTML within the heading. + heading_level: The level of heading (e.g. 3 -> `h3`). + role: An optional role for the object bound to this heading. + hidden: If True, only register it for the table of contents, don't render anything. + toc_label: The title to use in the table of contents ('data-toc-label' attribute). + skip_inventory: Flag element to not be registered in the inventory (by setting a `data-skip-inventory` attribute). + **attributes: Any extra HTML attributes of the heading. + + Returns: + An HTML string. + """ + # 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(content, Markup) else content + el.set("data-toc-label", toc_label) + if skip_inventory: + el.set("data-skip-inventory", "true") + if role: + el.set("data-role", role) + if content: + el.text = str(content).strip() + self._headings.append(el) + + if hidden: + return Markup('').format(attributes["id"]) + + # Now produce the actual HTML to be rendered. The goal is to wrap the HTML content into a heading. + # Start with a heading that has just attributes (no text), and add a placeholder into it. + el = Element(f"h{heading_level}", attributes) + el.append(Element("mkdocstrings-placeholder")) + # Tell the 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: + toc.add_permalink(el, attributes["id"]) + + # The content we received is HTML, so it can't just be inserted into the tree. We had marked the middle + # of the heading with a placeholder that can never occur (text can't directly contain angle brackets). + # Now this HTML wrapper can be "filled" by replacing the placeholder. + html_with_placeholder = tostring(el, encoding="unicode") + assert ( # 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) + return Markup(html) + + def get_headings(self) -> Sequence[Element]: + """Return and clear the headings gathered so far. + + Returns: + A list of HTML elements. + """ + result = list(self._headings) + self._headings.clear() + return result + + def update_env(self, config: Any) -> None: + """Update the Jinja environment.""" + + def _update_env(self, md: Markdown, *, config: Any | None = None) -> None: + """Update our handler to point to our configured Markdown instance, grabbing some of the config from `md`.""" + extensions: list[str | Extension] = [*self.mdx, MkdocstringsInnerExtension(self._headings)] + + new_md = Markdown(extensions=extensions, extension_configs=self.mdx_config) + + # MkDocs adds its own (required) extension that's not part of the config. Propagate it. + if "relpath" in md.treeprocessors: + relpath = md.treeprocessors["relpath"] + new_relpath = type(relpath)(relpath.file, relpath.files, relpath.config) + new_md.treeprocessors.register(new_relpath, "relpath", priority=0) + elif "zrelpath" in md.treeprocessors: + zrelpath = md.treeprocessors["zrelpath"] + new_zrelpath = type(zrelpath)(new_md, zrelpath.path, zrelpath.use_directory_urls) + new_md.treeprocessors.register(new_zrelpath, "zrelpath", priority=0) + + self._md = new_md + + self.env.filters["highlight"] = Highlighter(new_md).highlight + + self.update_env(config) + + +class Handlers: + """A collection of handlers. + + Do not instantiate this directly. [The plugin][mkdocstrings.MkdocstringsPlugin] will keep one instance of + this for the purpose of caching. Use [mkdocstrings.MkdocstringsPlugin.get_handler][] for convenient access. + """ + + def __init__( + self, + *, + theme: str, + default: str, + inventory_project: str, + inventory_version: str = "0.0.0", + handlers_config: dict[str, HandlerConfig] | None = None, + custom_templates: str | None = None, + mdx: Sequence[str | Extension] | None = None, + mdx_config: Mapping[str, Any] | None = None, + locale: str = "en", + tool_config: Any, + ) -> None: + """Initialize the object. + + Arguments: + theme: The theme to use. + default: The default handler to use. + inventory_project: The project name to use in the inventory. + inventory_version: The project version to use in the inventory. + handlers_config: The handlers configuration. + custom_templates: The path to custom templates. + mdx: A list of Markdown extensions to use. + mdx_config: Configuration for the Markdown extensions. + locale: The locale to use for translations. + tool_config: Tool configuration to pass down to handlers. + """ + self._theme = theme + self._default = default + self._handlers_config = handlers_config or {} + self._custom_templates = custom_templates + self._mdx = mdx or [] + self._mdx_config = mdx_config or {} + self._handlers: dict[str, BaseHandler] = {} + self._locale = locale + self._tool_config = tool_config + + self.inventory: Inventory = Inventory(project=inventory_project, version=inventory_version) + """The objects inventory.""" + + self._inv_futures: dict[futures.Future, tuple[BaseHandler, str, Any]] = {} + + def get_handler_name(self, config: dict) -> str: + """Return the handler name defined in an "autodoc" instruction YAML configuration, or the global default handler. + + Arguments: + config: A configuration dictionary, obtained from YAML below the "autodoc" instruction. + + Returns: + The name of the handler to use. + """ + return config.get("handler", self._default) + + def get_handler_config(self, name: str) -> dict: + """Return the global configuration of the given handler. + + Arguments: + name: The name of the handler to get the global configuration of. + + Returns: + The global configuration of the given handler. It can be an empty dictionary. + """ + return self._handlers_config.get(name, None) or {} + + 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 + `get_handler` method to get an instance of a handler, and caches it in dictionary. + It means that during one run (for each reload when serving, or once when building), + a handler is instantiated only once, and reused for each "autodoc" instruction asking for it. + + Arguments: + name: The name of the handler. Really, it's the name of the Python module holding it. + handler_config: Configuration passed to the handler. + + Returns: + An instance of a subclass of [`BaseHandler`][mkdocstrings.BaseHandler], + 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._handlers_config.get(name, {}) + module = importlib.import_module(f"mkdocstrings_handlers.{name}") + + self._handlers[name] = module.get_handler( + theme=self._theme, + custom_templates=self._custom_templates, + mdx=self._mdx, + mdx_config=self._mdx_config, + handler_config=handler_config, + tool_config=self._tool_config, + ) + return self._handlers[name] + + def _download_inventories(self) -> None: + """Download an inventory file from an URL. + + Arguments: + url: The URL of the inventory. + """ + to_download: list[tuple[BaseHandler, str, Any]] = [] + + for handler_name, conf in self._handlers_config.items(): + handler = self.get_handler(handler_name) + + if handler.get_inventory_urls.__func__ is BaseHandler.get_inventory_urls: + if inv_configs := conf.pop("import", ()): + warn( + "mkdocstrings v1 will stop handling 'import' in handlers configuration. " + "Instead your handler must define a `get_inventory_urls` method " + "that returns a list of URLs to download. ", + DeprecationWarning, + stacklevel=1, + ) + inv_configs = [{"url": inv} if isinstance(inv, str) else inv for inv in inv_configs] + inv_configs = [(inv.pop("url"), inv) for inv in inv_configs] + else: + inv_configs = handler.get_inventory_urls() + + to_download.extend((handler, url, conf) for url, conf in inv_configs) + + if to_download: + # YORE: EOL 3.12: Remove block. + # NOTE: Create context in main thread to fix issue + # https://github.com/mkdocstrings/mkdocstrings/issues/796. + _ = ssl.create_default_context() + + thread_pool = futures.ThreadPoolExecutor(4) + for handler, url, conf in to_download: + _logger.debug("Downloading inventory from %s", url) + future = thread_pool.submit( + download_and_cache_url, + url, + datetime.timedelta(days=1), + download=_download_url_with_gz, + ) + self._inv_futures[future] = (handler, url, conf) + thread_pool.shutdown(wait=False) + + def _yield_inventory_items(self) -> Iterator[tuple[str, str]]: + if self._inv_futures: + _logger.debug("Waiting for %s inventory download(s)", len(self._inv_futures)) + futures.wait(self._inv_futures, timeout=30) + # Reversed order so that pages from first futures take precedence: + for fut, (handler, url, conf) in reversed(self._inv_futures.items()): + try: + yield from handler.load_inventory(BytesIO(fut.result()), url, **conf) + except Exception as error: # noqa: BLE001,PERF203 + _logger.error("Couldn't load inventory %s through handler '%s': %s", url, handler.name, error) # noqa: TRY400 + self._inv_futures = {} + + @property + def seen_handlers(self) -> Iterable[BaseHandler]: + """Get the handlers that were encountered so far throughout the build. + + Returns: + An iterable of instances of [`BaseHandler`][mkdocstrings.BaseHandler] + (usable only to loop through it). + """ + return self._handlers.values() + + def teardown(self) -> None: + """Teardown all cached handlers and clear the cache.""" + for future in self._inv_futures: + future.cancel() + for handler in self.seen_handlers: + handler.teardown() + self._handlers.clear() diff --git a/src/mkdocstrings/handlers/rendering.py b/src/mkdocstrings/_internal/handlers/rendering.py similarity index 87% rename from src/mkdocstrings/handlers/rendering.py rename to src/mkdocstrings/_internal/handlers/rendering.py index 1db3c8f1..264a77ef 100644 --- a/src/mkdocstrings/handlers/rendering.py +++ b/src/mkdocstrings/_internal/handlers/rendering.py @@ -1,4 +1,4 @@ -"""This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers.""" +# This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers. from __future__ import annotations @@ -84,7 +84,7 @@ def __init__(self, md: Markdown): 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( + def highlight( # ty: ignore[invalid-method-override] self, src: str, language: str | None = None, @@ -113,7 +113,7 @@ def highlight( src = textwrap.dedent(src) kwargs.setdefault("css_class", self._css_class) - old_linenums = self.linenums # type: ignore[has-type] + old_linenums = self.linenums if linenums is not None: self.linenums = linenums try: @@ -133,7 +133,8 @@ def highlight( class IdPrependingTreeprocessor(Treeprocessor): """Prepend the configured prefix to IDs of all HTML elements.""" - name = "mkdocstrings_ids" + name: str = "mkdocstrings_ids" + """The name of the treeprocessor.""" id_prefix: str """The prefix to add to every ID. It is prepended without any separator; specify your own separator if needed.""" @@ -148,7 +149,8 @@ def __init__(self, md: Markdown, id_prefix: str): super().__init__(md) self.id_prefix = id_prefix - def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> None: + """Prepend the configured prefix to all IDs in the document.""" if self.id_prefix: self._prefix_ids(root) @@ -189,8 +191,11 @@ def _prefix_ids(self, root: Element) -> None: class HeadingShiftingTreeprocessor(Treeprocessor): """Shift levels of all Markdown headings according to the configured base level.""" - name = "mkdocstrings_headings" - regex = re.compile(r"([Hh])([1-6])") + name: str = "mkdocstrings_headings" + """The name of the treeprocessor.""" + + regex: re.Pattern = re.compile(r"([Hh])([1-6])") + """The regex to match heading tags.""" shift_by: int """The number of heading "levels" to add to every heading. `

` with `shift_by = 3` becomes `

`.""" @@ -205,7 +210,8 @@ def __init__(self, md: Markdown, shift_by: int): super().__init__(md) self.shift_by = shift_by - def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> None: + """Shift the levels of all headings in the document.""" if not self.shift_by: return for el in root.iter(): @@ -219,8 +225,11 @@ def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring) class _HeadingReportingTreeprocessor(Treeprocessor): """Records the heading elements encountered in the document.""" - name = "mkdocstrings_headings_list" - regex = re.compile(r"[Hh][1-6]") + name: str = "mkdocstrings_headings_list" + """The name of the treeprocessor.""" + + regex: re.Pattern = re.compile(r"[Hh][1-6]") + """The regex to match heading tags.""" headings: list[Element] """The list (the one passed in the initializer) that is used to record the heading elements (by appending to it).""" @@ -230,7 +239,8 @@ def __init__(self, md: Markdown, headings: list[Element]): self.headings = headings def run(self, root: Element) -> None: - permalink_class = self.md.treeprocessors["toc"].permalink_class # type: ignore[attr-defined] + """Record all heading elements encountered in the document.""" + permalink_class = self.md.treeprocessors["toc"].permalink_class for el in root.iter(): if self.regex.fullmatch(el.tag): el = copy.copy(el) # noqa: PLW2901 @@ -242,14 +252,18 @@ def run(self, root: Element) -> None: class ParagraphStrippingTreeprocessor(Treeprocessor): - """Unwraps the

element around the whole output.""" + """Unwraps the `

` element around the whole output.""" + + name: str = "mkdocstrings_strip_paragraph" + """The name of the treeprocessor.""" - name = "mkdocstrings_strip_paragraph" - strip = False + strip: bool = False + """Whether to strip `

` elements or not.""" - def run(self, root: Element) -> Element | None: # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> Element | None: + """Unwrap the root element if it's a single `

` element.""" 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!) + # 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 @@ -266,6 +280,7 @@ def __init__(self, headings: list[Element]): """ super().__init__() self.headings = headings + """The list that will be populated with all HTML heading elements encountered in the document.""" def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name) """Register the extension. diff --git a/src/mkdocstrings/inventory.py b/src/mkdocstrings/_internal/inventory.py similarity index 81% rename from src/mkdocstrings/inventory.py rename to src/mkdocstrings/_internal/inventory.py index f1c8962a..241bbb12 100644 --- a/src/mkdocstrings/inventory.py +++ b/src/mkdocstrings/_internal/inventory.py @@ -1,14 +1,17 @@ -"""Module responsible for the objects inventory.""" - +# Module responsible for the objects inventory. +# # Credits to Brian Skinn and the sphobjinv project: -# https://github.com/bskinn/sphobjinv +# https://github.com/bskinn/sphobjinv. from __future__ import annotations import re import zlib from textwrap import dedent -from typing import BinaryIO, Collection +from typing import TYPE_CHECKING, BinaryIO, Literal, overload + +if TYPE_CHECKING: + from collections.abc import Collection class InventoryItem: @@ -34,11 +37,17 @@ def __init__( dispname: The item display name. """ self.name: str = name + """The item name.""" self.domain: str = domain + """The item domain.""" self.role: str = role + """The item role.""" self.uri: str = uri + """The item URI.""" self.priority: int = priority + """The item priority.""" self.dispname: str = dispname or name + """The item display name.""" def format_sphinx(self) -> str: """Format this item as a Sphinx inventory line. @@ -55,12 +64,23 @@ def format_sphinx(self) -> str: 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*(.*)$") + """Regex to parse a Sphinx v2 inventory line.""" + + @overload + @classmethod + def parse_sphinx(cls, line: str, *, return_none: Literal[False]) -> InventoryItem: ... + @overload @classmethod - def parse_sphinx(cls, line: str) -> InventoryItem: + def parse_sphinx(cls, line: str, *, return_none: Literal[True]) -> InventoryItem | None: ... + + @classmethod + def parse_sphinx(cls, line: str, *, return_none: bool = False) -> InventoryItem | None: """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: + if return_none: + return None raise ValueError(line) name, domain, role, priority, uri, dispname = match.groups() if uri.endswith("$"): @@ -86,7 +106,9 @@ def __init__(self, items: list[InventoryItem] | None = None, project: str = "pro for item in items: self[item.name] = item self.project = project + """The project name.""" self.version = version + """The project version.""" def register( self, @@ -155,7 +177,9 @@ def parse_sphinx(cls, in_file: BinaryIO, *, domain_filter: Collection[str] = ()) for _ in range(4): in_file.readline() lines = zlib.decompress(in_file.read()).splitlines() - items = [InventoryItem.parse_sphinx(line.decode("utf8")) for line in lines] + items: list[InventoryItem] = [ + item for line in lines if (item := InventoryItem.parse_sphinx(line.decode("utf8"), return_none=True)) + ] if domain_filter: items = [item for item in items if item.domain in domain_filter] return cls(items) diff --git a/src/mkdocstrings/loggers.py b/src/mkdocstrings/_internal/loggers.py similarity index 53% rename from src/mkdocstrings/loggers.py rename to src/mkdocstrings/_internal/loggers.py index 63502474..6c8817ac 100644 --- a/src/mkdocstrings/loggers.py +++ b/src/mkdocstrings/_internal/loggers.py @@ -1,31 +1,49 @@ -"""Logging functions.""" +# Logging functions. from __future__ import annotations import logging from contextlib import suppress from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, MutableMapping, Sequence +from typing import TYPE_CHECKING, Any + +from jinja2 import pass_context + +if TYPE_CHECKING: + from collections.abc import Callable, MutableMapping, Sequence + + from jinja2.runtime import Context -try: - from jinja2 import pass_context -except ImportError: # TODO: remove once Jinja2 < 3.1 is dropped - 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__) # type: ignore[arg-type] + TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__) + """The directories where the handler templates are located.""" -if TYPE_CHECKING: - from jinja2.runtime import Context +class LoggerAdapter(logging.LoggerAdapter): + """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. -class LoggerAdapter(logging.LoggerAdapter): - """A logger adapter to prefix messages.""" + 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. @@ -36,6 +54,8 @@ def __init__(self, prefix: str, logger: logging.Logger): """ super().__init__(logger, {}) self.prefix = prefix + """The prefix to insert in front of every message.""" + self._logged: set[tuple[LoggerAdapter, str]] = set() def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, Any]: """Process the message. @@ -49,11 +69,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) # ty: ignore[invalid-argument-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. @@ -68,10 +109,36 @@ def __init__(self, logger: LoggerAdapter): logger: A logger adapter. """ self.debug = get_template_logger_function(logger.debug) + """Log a DEBUG message.""" self.info = get_template_logger_function(logger.info) + """Log an INFO message.""" self.warning = get_template_logger_function(logger.warning) + """Log a WARNING message.""" self.error = get_template_logger_function(logger.error) + """Log an ERROR message.""" self.critical = get_template_logger_function(logger.critical) + """Log a CRITICAL message.""" + + +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: @@ -85,18 +152,18 @@ def get_template_logger_function(logger_func: Callable) -> Callable: """ @pass_context - def wrapper(context: Context, msg: str | None = 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 @@ -136,10 +203,14 @@ def get_logger(name: str) -> LoggerAdapter: 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/_internal/plugin.py b/src/mkdocstrings/_internal/plugin.py new file mode 100644 index 00000000..4f9bd29d --- /dev/null +++ b/src/mkdocstrings/_internal/plugin.py @@ -0,0 +1,298 @@ +# This module contains the "mkdocstrings" plugin for MkDocs. +# +# The plugin instantiates a Markdown extension ([`MkdocstringsExtension`][mkdocstrings.MkdocstringsExtension]), +# and adds it to the list of Markdown extensions used by `mkdocs` +# during the [`on_config` event hook](https://www.mkdocs.org/user-guide/plugins/#on_config). +# +# Once the documentation is built, the [`on_post_build` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_build) +# is triggered and calls the [`handlers.teardown()` method][mkdocstrings.Handlers.teardown]. This method is +# used to teardown the handlers that were instantiated during documentation buildup. +# +# Finally, when serving the documentation, it can add directories to watch +# during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve). + +from __future__ import annotations + +import os +import re +from functools import partial +from inspect import signature +from re import Match +from typing import TYPE_CHECKING, Any + +from mkdocs.config import Config +from mkdocs.config import config_options as opt +from mkdocs.plugins import BasePlugin, CombinedEvent, event_priority +from mkdocs.utils import write_file +from mkdocs_autorefs import AutorefsConfig, AutorefsPlugin + +from mkdocstrings._internal.extension import MkdocstringsExtension +from mkdocstrings._internal.handlers.base import BaseHandler, Handlers +from mkdocstrings._internal.loggers import get_logger + +if TYPE_CHECKING: + from jinja2.environment import Environment + from mkdocs.config.defaults import MkDocsConfig + from mkdocs.structure.files import Files + + +_logger = get_logger("mkdocstrings") + + +class PluginConfig(Config): + """The configuration options of `mkdocstrings`, written in `mkdocs.yml`.""" + + handlers = opt.Type(dict, default={}) + """ + Global configuration of handlers. + + You can set global configuration per handler, applied everywhere, + but overridable in each "autodoc" instruction. Example: + + ```yaml + plugins: + - mkdocstrings: + handlers: + python: + options: + option1: true + option2: "value" + rust: + options: + 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.""" + locale = opt.Optional(opt.Type(str)) + """The locale to use for translations.""" + + +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: str = "assets/_mkdocstrings.css" + """The path of the CSS file to write in the site directory.""" + + def __init__(self) -> None: + """Initialize the object.""" + super().__init__() + self._handlers: Handlers | None = None + + @property + def handlers(self) -> Handlers: + """Get the instance of [mkdocstrings.Handlers][] for this plugin/build. + + Raises: + RuntimeError: If the plugin hasn't been initialized with a config. + + Returns: + An instance of [mkdocstrings.Handlers][] (the same throughout the build). + """ + if not self._handlers: + raise RuntimeError("The plugin hasn't been initialized with a config yet") + return self._handlers + + 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). + In this hook, we instantiate our [`MkdocstringsExtension`][mkdocstrings.MkdocstringsExtension] + and add it to the list of Markdown extensions used by `mkdocs`. + + We pass this plugin's configuration dictionary to the extension when instantiating it (it will need it + later when processing markdown to get handlers and their global configurations). + + Arguments: + config: The MkDocs config object. + + Returns: + The modified config. + """ + if not self.plugin_enabled: + _logger.debug("Plugin is not enabled. Skipping.") + return config + _logger.debug("Adding extension to the list") + + locale = self.config.locale or config.theme.get("language") or config.theme.get("locale") or "en" + locale = str(locale).replace("_", "-") + + handlers = Handlers( + default=self.config.default_handler, + handlers_config=self.config.handlers, + theme=config.theme.name or os.path.dirname(config.theme.dirs[0]), # noqa: PTH120 + custom_templates=self.config.custom_templates, + mdx=config.markdown_extensions, + mdx_config=config.mdx_configs, + inventory_project=config.site_name, + inventory_version="0.0.0", # TODO: Find a way to get actual version. + locale=locale, + tool_config=config, + ) + + handlers._download_inventories() + + AutorefsPlugin.record_backlinks = True + autorefs: AutorefsPlugin + try: + # If autorefs plugin is explicitly enabled, just use it. + autorefs = config.plugins["autorefs"] # ty: ignore[invalid-assignment] + _logger.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() # ty:ignore[invalid-assignment] + autorefs.scan_toc = False + config.plugins["autorefs"] = autorefs + _logger.debug("Added a subdued autorefs instance %r", autorefs) + + mkdocstrings_extension = MkdocstringsExtension(handlers, autorefs) + config.markdown_extensions.append(mkdocstrings_extension) # ty: ignore[invalid-argument-type] + + config.extra_css.insert(0, self.css_filename) # So that it has lower priority than user files. + + self._autorefs = autorefs + self._handlers = handlers + return config + + @property + def inventory_enabled(self) -> bool: + """Tell if the inventory is enabled or not. + + Returns: + Whether the inventory is enabled. + """ + inventory_enabled = self.config.enable_inventory + if inventory_enabled is None: + inventory_enabled = any(handler.enable_inventory for handler in self.handlers.seen_handlers) + return inventory_enabled + + @property + def plugin_enabled(self) -> bool: + """Tell if the plugin is enabled or not. + + Returns: + Whether the plugin is enabled. + """ + return self.config.enabled + + @event_priority(50) # Early, before autorefs' starts applying cross-refs and collecting backlinks. + def _on_env_load_inventories(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 + if self.plugin_enabled and self._handlers: + register = config.plugins["autorefs"].register_url # ty: ignore[unresolved-attribute] + for identifier, url in self._handlers._yield_inventory_items(): + register(identifier, url) + + @event_priority(-20) # Late, not important. + def _on_env_add_css(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 + if self.plugin_enabled and 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)) # noqa: PTH118 + + @event_priority(-20) # Late, not important. + def _on_env_write_inventory(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 + if self.plugin_enabled and self._handlers and self.inventory_enabled: + _logger.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")) # noqa: PTH118 + + @event_priority(-100) # Last, after autorefs has finished applying cross-refs and collecting backlinks. + def _on_env_apply_backlinks(self, env: Environment, /, *, config: MkDocsConfig, files: Files) -> Environment: # noqa: ARG002 + regex = re.compile(r"") + + def repl(match: Match) -> str: + handler_name = match.group(2) + handler = self.handlers.get_handler(handler_name) + + # The handler doesn't implement backlinks, + # return early to avoid computing them. + if handler.render_backlinks.__func__ is BaseHandler.render_backlinks: + return "" + + identifier = match.group(1) + aliases = handler.get_aliases(identifier) + backlinks = self._autorefs.get_backlinks(identifier, *aliases, from_url=file.page.url) + + # No backlinks, avoid calling the handler's method. + if not backlinks: + return "" + + if "locale" in signature(handler.render_backlinks).parameters: + render_backlinks = partial(handler.render_backlinks, locale=self.handlers._locale) + else: + render_backlinks = handler.render_backlinks + + return render_backlinks(backlinks) + + for file in files: + if file.page and file.page.content: + _logger.debug("Applying backlinks in page %s", file.page.file.src_path) + file.page.content = regex.sub(repl, file.page.content) + + return env + + on_env = CombinedEvent(_on_env_load_inventories, _on_env_add_css, _on_env_write_inventory, _on_env_apply_backlinks) + """Extra actions that need to happen after all Markdown-to-HTML page rendering. + + Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env). + + - Gather results from background inventory download tasks. + - Write mkdocstrings' extra files (CSS, inventory) into the site directory. + - Apply backlinks to the HTML output of each page. + """ + + def on_post_build( + 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). + This hook is used to teardown all the handlers that were instantiated and cached during documentation buildup. + + 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 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 + + if self._handlers: + _logger.debug("Tearing handlers down") + self.handlers.teardown() + + def get_handler(self, handler_name: str) -> BaseHandler: + """Get a handler by its name. See [mkdocstrings.Handlers.get_handler][]. + + Arguments: + handler_name: The name of the handler. + + Returns: + An instance of a subclass of [`BaseHandler`][mkdocstrings.BaseHandler]. + """ + return self.handlers.get_handler(handler_name) diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py deleted file mode 100644 index bef8c799..00000000 --- a/src/mkdocstrings/extension.py +++ /dev/null @@ -1,306 +0,0 @@ -"""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`. - -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. - -Both the collection and rendering process can be configured by adding YAML configuration under the "autodoc" -instruction: - -```yaml -::: some.identifier - handler: python - options: - option1: value1 - option2: - - value2a - - value2b - option_x: etc -``` -""" - -from __future__ import annotations - -import re -from collections import ChainMap -from typing import TYPE_CHECKING, Any, MutableSequence -from xml.etree.ElementTree import Element - -import yaml -from jinja2.exceptions import TemplateNotFound -from markdown.blockprocessors import BlockProcessor -from markdown.extensions import Extension -from markdown.treeprocessors import Treeprocessor -from mkdocs.exceptions import PluginError - -from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers -from mkdocstrings.loggers import get_logger - -if TYPE_CHECKING: - from markdown import Markdown - from markdown.blockparser import BlockParser - from mkdocs_autorefs.plugin import AutorefsPlugin - - -log = get_logger(__name__) - - -class AutoDocProcessor(BlockProcessor): - """Our "autodoc" Markdown block processor. - - It has a [`test` method][mkdocstrings.extension.AutoDocProcessor.test] that tells if a block matches a criterion, - and a [`run` method][mkdocstrings.extension.AutoDocProcessor.run] that processes it. - - It also has utility methods allowing to get handlers and their configuration easily, useful when processing - a matched block. - """ - - regex = re.compile(r"^(?P#{1,6} *|)::: ?(?P.+?) *$", flags=re.MULTILINE) - - def __init__( - 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.PluginConfig] of the `mkdocstrings` plugin. - handlers: The handlers container. - autorefs: The autorefs plugin instance. - """ - super().__init__(parser=parser) - self.md = md - self._config = config - self._handlers = handlers - self._autorefs = autorefs - self._updated_envs: set = set() - - def test(self, parent: Element, block: str) -> bool: # noqa: ARG002 - """Match our autodoc instructions. - - Arguments: - parent: The parent element in the XML tree. - block: The block to be tested. - - Returns: - Whether this block should be processed or not. - """ - return bool(self.regex.search(block)) - - def run(self, parent: Element, blocks: MutableSequence[str]) -> None: - """Run code on the matched blocks. - - The identifier and configuration lines are retrieved from a matched block - and used to collect and render an object. - - Arguments: - parent: The parent element in the XML tree. - blocks: The rest of the blocks to be processed. - """ - block = blocks.pop(0) - match = self.regex.search(block) - - if match: - if match.start() > 0: - self.parser.parseBlocks(parent, [block[: match.start()]]) - # removes the first line - block = block[match.end() :] - - block, the_rest = self.detab(block) - - if match: - identifier = match["name"] - heading_level = match["heading"].count("#") - log.debug(f"Matched '::: {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. - headings = handler.get_headings() - el.extend(headings) - - page = self._autorefs.current_page - 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) - - if the_rest: - # This block contained unindented line(s) after the first indented - # line. Insert these lines as the first block of the master blocks - # list for future processing. - blocks.insert(0, the_rest) - - def _process_block( - self, - identifier: str, - yaml_block: str, - heading_level: int = 0, - ) -> 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 heading to insert (0 to ignore). - - Raises: - PluginError: When something wrong happened during collection. - TemplateNotFound: When a template used for rendering could not be found. - - Returns: - Rendered HTML, the handler that was used, and the collected item. - """ - config = yaml.safe_load(yaml_block) or {} - handler_name = self._handlers.get_handler_name(config) - - log.debug(f"Using handler '{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", {}) - options = ChainMap(local_options, global_options) - - if heading_level: - options = ChainMap(options, {"heading_level": heading_level}) # like setdefault - - log.debug("Collecting data") - try: - data: CollectorItem = handler.collect(identifier, options) - except CollectionError as exception: - log.error(str(exception)) # noqa: TRY400 - if PluginError is SystemExit: # TODO: when MkDocs 1.2 is sufficiently common, this can be dropped. - log.error(f"Error reading page '{self._autorefs.current_page}':") # noqa: TRY400 - raise PluginError(f"Could not collect '{identifier}'") from exception - - 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( # noqa: TRY400 - f"Template '{exc.name}' not found for '{handler_name}' handler and theme '{theme_name}'.", - ) - raise - - return rendered, handler, data - - -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(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 - parent.remove(el) - else: - if carry_text: - el.tail = (el.tail or "") + carry_text - carry_text = "" - self._remove_duplicated_headings(el) - - if 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): - """Our Markdown extension. - - It cannot work outside of `mkdocstrings`. - """ - - def __init__(self, config: dict, handlers: Handlers, autorefs: AutorefsPlugin, **kwargs: Any) -> None: - """Initialize the object. - - Arguments: - config: The configuration items from `mkdocs` and `mkdocstrings` that must be passed to the block processor - when instantiated in [`extendMarkdown`][mkdocstrings.extension.MkdocstringsExtension.extendMarkdown]. - handlers: The handlers container. - autorefs: The autorefs plugin instance. - **kwargs: Keyword arguments used by `markdown.extensions.Extension`. - """ - super().__init__(**kwargs) - self._config = config - self._handlers = handlers - self._autorefs = autorefs - - def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name) - """Register the extension. - - Add an instance of our [`AutoDocProcessor`][mkdocstrings.extension.AutoDocProcessor] to the Markdown parser. - - Arguments: - md: A `markdown.Markdown` instance. - """ - md.parser.blockprocessors.register( - AutoDocProcessor(md.parser, md, self._config, self._handlers, self._autorefs), - "mkdocstrings", - priority=75, # Right before markdown.blockprocessors.HashHeaderProcessor - ) - md.treeprocessors.register( - _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 deleted file mode 100644 index b9e2a29c..00000000 --- a/src/mkdocstrings/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Handlers module.""" diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py deleted file mode 100644 index f52e17dc..00000000 --- a/src/mkdocstrings/handlers/base.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Base module for handlers. - -This module contains the base classes for implementing handlers. -""" - -from __future__ import annotations - -import importlib -import sys -from pathlib import Path -from typing import Any, BinaryIO, ClassVar, Iterable, Iterator, Mapping, MutableMapping, Sequence, 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 mkdocstrings.handlers.rendering import ( - HeadingShiftingTreeprocessor, - Highlighter, - IdPrependingTreeprocessor, - MkdocstringsInnerExtension, - ParagraphStrippingTreeprocessor, -) -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 - -CollectorItem = Any - - -class CollectionError(Exception): - """An exception raised when some collection of data failed.""" - - -class ThemeNotSupported(Exception): # noqa: N818 - """An exception raised to tell a theme is not supported.""" - - -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. - - Arguments: - seq: An iterable object. - attribute: The attribute name to use on each object of the iterable. - - Returns: - A boolean telling if any object of the iterable evaluated to True. - """ - if attribute is None: - return any(seq) - return any(_[attribute] for _ in seq) - - -class BaseHandler: - """The base handler class. - - Inherit from this class to implement a handler. - - 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. - """ - - 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: 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 - in `self` to use as a fallback theme. - - Arguments: - handler: The name of the handler. - theme: The name of theme to use. - custom_templates: Directory containing custom templates. - """ - paths = [] - - # 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") - break - - if custom_templates is not None: - paths.insert(0, Path(custom_templates) / handler / theme) - - self.env = Environment( - autoescape=True, - loader=FileSystemLoader(paths), - 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._headings: list[Element] = [] - self._md: Markdown = None # type: ignore[assignment] # To be populated in `update_env`. - - @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 handler's configuration options. - - Returns: - The rendered template as HTML. - """ - raise NotImplementedError - - 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. - - Arguments: - 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. - """ - 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__: - theme_path = Path(path, handler, "templates") - if theme_path.exists(): - return theme_path - - raise FileNotFoundError(f"Can't find 'templates' folder for handler '{handler}'") - - 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, ...]: - """Return the possible identifiers (HTML anchors) for a collected item. - - Arguments: - data: The collected data. - - Returns: - The HTML anchors (without '#'), or an empty tuple if this item doesn't have an anchor. - """ - # TODO: remove this when https://github.com/mkdocstrings/crystal/pull/6 is merged and released - try: - return (self.get_anchor(data),) # type: ignore[attr-defined] - except AttributeError: - return () - - def do_convert_markdown( - self, - text: str, - heading_level: int, - html_id: str = "", - *, - strip_paragraph: bool = False, - ) -> Markup: - """Render Markdown text; for use inside templates. - - Arguments: - text: The text to convert. - heading_level: The base heading level to start all Markdown headings from. - html_id: The HTML id of the element that's considered the parent of this element. - strip_paragraph: Whether to exclude the

tag from around the whole output. - - Returns: - An HTML string. - """ - treeprocessors = self._md.treeprocessors - 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] - try: - return Markup(self._md.convert(text)) - finally: - 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.reset() - - def do_heading( - self, - content: Markup, - heading_level: int, - *, - role: str | None = None, - hidden: bool = False, - toc_label: str | None = None, - **attributes: str, - ) -> Markup: - """Render an HTML heading and register it for the table of contents. For use inside templates. - - Arguments: - content: The HTML within the heading. - heading_level: The level of heading (e.g. 3 -> `h3`). - role: An optional role for the object bound to this heading. - hidden: If True, only register it for the table of contents, don't render anything. - toc_label: The title to use in the table of contents ('data-toc-label' attribute). - **attributes: Any extra HTML attributes of the heading. - - Returns: - An HTML string. - """ - # First, produce the "fake" heading, for ToC only. - el = Element(f"h{heading_level}", attributes) - if toc_label is None: - toc_label = content.unescape() if isinstance(content, Markup) else content - el.set("data-toc-label", toc_label) - if role: - el.set("data-role", role) - self._headings.append(el) - - if hidden: - return Markup('').format(attributes["id"]) - - # Now produce the actual HTML to be rendered. The goal is to wrap the HTML content into a heading. - # Start with a heading that has just attributes (no text), and add a placeholder into it. - el = Element(f"h{heading_level}", attributes) - el.append(Element("mkdocstrings-placeholder")) - # Tell the 'toc' extension to make its additions if configured so. - toc = cast(TocTreeprocessor, self._md.treeprocessors["toc"]) - if toc.use_anchors: - toc.add_anchor(el, attributes["id"]) - if toc.use_permalinks: - toc.add_permalink(el, attributes["id"]) - - # The content we received is HTML, so it can't just be inserted into the tree. We had marked the middle - # of the heading with a placeholder that can never occur (text can't directly contain angle brackets). - # Now this HTML wrapper can be "filled" by replacing the placeholder. - html_with_placeholder = tostring(el, encoding="unicode") - assert ( # 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) - return Markup(html) - - def get_headings(self) -> Sequence[Element]: - """Return and clear the headings gathered so far. - - Returns: - A list of HTML elements. - """ - result = list(self._headings) - self._headings.clear() - return result - - def update_env(self, md: Markdown, config: dict) -> None: # noqa: ARG002 - """Update the Jinja environment. - - Arguments: - md: The Markdown instance. Useful to add functions able to convert Markdown into the environment filters. - config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code - of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. - """ - self._md = md - self.env.filters["highlight"] = Highlighter(md).highlight - self.env.filters["convert_markdown"] = self.do_convert_markdown - self.env.filters["heading"] = self.do_heading - - 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"]) - # MkDocs adds its own (required) extension that's not part of the config. Propagate it. - if "relpath" in md.treeprocessors: - new_md.treeprocessors.register(md.treeprocessors["relpath"], "relpath", priority=0) - - self.update_env(new_md, config) - - -class Handlers: - """A collection of handlers. - - Do not instantiate this directly. [The plugin][mkdocstrings.plugin.MkdocstringsPlugin] will keep one instance of - this for the purpose of caching. Use [mkdocstrings.plugin.MkdocstringsPlugin.get_handler][] for convenient access. - """ - - def __init__(self, config: dict) -> None: - """Initialize the object. - - Arguments: - config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code - of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. - """ - self._config = config - self._handlers: dict[str, BaseHandler] = {} - self.inventory: Inventory = Inventory(project=self._config["mkdocs"]["site_name"]) - - 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.BaseHandler.collect] can accept). - - Returns: - A tuple of strings - anchors without '#', or an empty tuple if there isn't any identifier familiar with it. - """ - for handler in self._handlers.values(): - fallback_config = getattr(handler, "fallback_config", {}) - try: - anchors = handler.get_anchors(handler.collect(identifier, fallback_config)) - except CollectionError: - continue - if anchors: - return anchors - return () - - def get_handler_name(self, config: dict) -> str: - """Return the handler name defined in an "autodoc" instruction YAML configuration, or the global default handler. - - Arguments: - config: A configuration dictionary, obtained from YAML below the "autodoc" instruction. - - Returns: - The name of the handler to use. - """ - global_config = self._config["mkdocstrings"] - if "handler" in config: - return config["handler"] - return global_config["default_handler"] - - def get_handler_config(self, name: str) -> dict: - """Return the global configuration of the given handler. - - Arguments: - name: The name of the handler to get the global configuration of. - - Returns: - The global configuration of the given handler. It can be an empty dictionary. - """ - handlers = self._config["mkdocstrings"].get("handlers", {}) - if handlers: - return handlers.get(name, {}) - return {} - - 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 - `get_handler` method to get an instance of a handler, and caches it in dictionary. - It means that during one run (for each reload when serving, or once when building), - a handler is instantiated only once, and reused for each "autodoc" instruction asking for it. - - Arguments: - name: The name of the handler. Really, it's the name of the Python module holding it. - handler_config: Configuration passed to the handler. - - Returns: - An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler], - 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) - 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["mkdocs"]["config_file_path"], - **handler_config, - ) - return self._handlers[name] - - @property - def seen_handlers(self) -> Iterable[BaseHandler]: - """Get the handlers that were encountered so far throughout the build. - - Returns: - An iterable of instances of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler] - (usable only to loop through it). - """ - return self._handlers.values() - - def teardown(self) -> None: - """Teardown all cached handlers and clear the cache.""" - for handler in self.seen_handlers: - handler.teardown() - self._handlers.clear() diff --git a/src/mkdocstrings/plugin.py b/src/mkdocstrings/plugin.py deleted file mode 100644 index 48a7d1ab..00000000 --- a/src/mkdocstrings/plugin.py +++ /dev/null @@ -1,324 +0,0 @@ -"""This module contains the "mkdocstrings" plugin for MkDocs. - -The plugin instantiates a Markdown extension ([`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]), -and adds it to the list of Markdown extensions used by `mkdocs` -during the [`on_config` event hook](https://www.mkdocs.org/user-guide/plugins/#on_config). - -Once the documentation is built, the [`on_post_build` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_build) -is triggered and calls the [`handlers.teardown()` method][mkdocstrings.handlers.base.Handlers.teardown]. This method is -used to teardown the handlers that were instantiated during documentation buildup. - -Finally, when serving the documentation, it can add directories to watch -during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve). -""" - -from __future__ import annotations - -import datetime -import functools -import os -import sys -from concurrent import futures -from io import BytesIO -from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Mapping, Tuple, TypeVar - -from mkdocs.config import Config -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 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__) - -InventoryImportType = List[Tuple[str, Mapping[str, Any]]] -InventoryLoaderType = Callable[..., Iterable[Tuple[str, str]]] - -P = ParamSpec("P") -R = TypeVar("R") - - -def list_to_tuple(function: Callable[P, R]) -> Callable[P, R]: - """Decorater to convert lists to tuples in the arguments.""" - - 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] - - return wrapper - - -class PluginConfig(Config): - """The configuration options of `mkdocstrings`, written in `mkdocs.yml`.""" - - handlers = opt.Type(dict, default={}) - """ - Global configuration of handlers. - - You can set global configuration per handler, applied everywhere, - but overridable in each "autodoc" instruction. Example: - - ```yaml - plugins: - - mkdocstrings: - handlers: - python: - options: - option1: true - option2: "value" - rust: - options: - 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: Handlers | None = None - - @property - def handlers(self) -> Handlers: - """Get the instance of [mkdocstrings.handlers.base.Handlers][] for this plugin/build. - - Raises: - RuntimeError: If the plugin hasn't been initialized with a config. - - Returns: - An instance of [mkdocstrings.handlers.base.Handlers][] (the same throughout the build). - """ - if not self._handlers: - raise RuntimeError("The plugin hasn't been initialized with a config yet") - return self._handlers - - 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). - In this hook, we instantiate our [`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension] - and add it to the list of Markdown extensions used by `mkdocs`. - - We pass this plugin's configuration dictionary to the extension when instantiating it (it will need it - later when processing markdown to get handlers and their global configurations). - - Arguments: - config: The MkDocs config object. - - 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 = config.theme.name or os.path.dirname(config.theme.dirs[0]) - - to_import: InventoryImportType = [] - for handler_name, conf in self.config.handlers.items(): - for import_item in conf.pop("import", ()): - if isinstance(import_item, str): - import_item = {"url": import_item} # noqa: PLW2901 - to_import.append((handler_name, import_item)) - - extension_config = { - "theme_name": theme_name, - "mdx": config.markdown_extensions, - "mdx_configs": config.mdx_configs, - "mkdocstrings": self.config, - "mkdocs": config, - } - self._handlers = Handlers(extension_config) - - autorefs: AutorefsPlugin - try: - # If autorefs plugin is explicitly enabled, just use it. - autorefs = config.plugins["autorefs"] # type: ignore[assignment] - log.debug(f"Picked up existing autorefs instance {autorefs!r}") - except KeyError: - # Otherwise, add a limited instance of it that acts only on what's added through `register_anchor`. - autorefs = AutorefsPlugin() - autorefs.scan_toc = False - config.plugins["autorefs"] = autorefs - log.debug(f"Added a subdued autorefs instance {autorefs!r}") - # Add collector-based fallback in either case. - autorefs.get_fallback_anchor = self.handlers.get_anchors - - mkdocstrings_extension = MkdocstringsExtension(extension_config, self.handlers, autorefs) - 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. - - self._inv_futures = {} - if to_import: - inv_loader = futures.ThreadPoolExecutor(4) - for handler_name, import_item in to_import: - loader = self.get_handler(handler_name).load_inventory - future = inv_loader.submit( - self._load_inventory, # type: ignore[misc] - loader, - **import_item, - ) - self._inv_futures[future] = (loader, import_item) - inv_loader.shutdown(wait=False) - - return config - - @property - def inventory_enabled(self) -> bool: - """Tell if the inventory is enabled or not. - - Returns: - Whether the inventory is enabled. - """ - inventory_enabled = self.config.enable_inventory - if inventory_enabled is None: - inventory_enabled = any(handler.enable_inventory for handler in self.handlers.seen_handlers) - return inventory_enabled - - @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). - - - 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)) - - if self.inventory_enabled: - log.debug("Creating inventory file objects.inv") - inv_contents = self.handlers.inventory.format_sphinx() - write_file(inv_contents, os.path.join(config.site_dir, "objects.inv")) - - if self._inv_futures: - log.debug(f"Waiting for {len(self._inv_futures)} inventory download(s)") - futures.wait(self._inv_futures, timeout=30) - 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(f"Couldn't load inventory {import_item} through {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: 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). - This hook is used to teardown all the handlers that were instantiated and cached during documentation buildup. - - 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 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() - - if self._handlers: - log.debug("Tearing handlers down") - self.handlers.teardown() - - def get_handler(self, handler_name: str) -> BaseHandler: - """Get a handler by its name. See [mkdocstrings.handlers.base.Handlers.get_handler][]. - - Arguments: - handler_name: The name of the handler. - - Returns: - An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler]. - """ - return self.handlers.get_handler(handler_name) - - @classmethod - # lru_cache does not allow mutable arguments such lists, but that is what we load from YAML config. - @list_to_tuple - @functools.lru_cache(maxsize=None) - def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) -> Mapping[str, str]: - """Download and process inventory files using a handler. - - Arguments: - loader: A function returning a sequence of pairs (identifier, url). - url: The URL to download and process. - **kwargs: Extra arguments to pass to the loader. - - Returns: - A mapping from identifier to absolute URL. - """ - log.debug(f"Downloading inventory from {url!r}") - content = download_and_cache_url(url, download_url_with_gz, datetime.timedelta(days=1)) - result = dict(loader(BytesIO(content), url=url, **kwargs)) - log.debug(f"Loaded inventory from {url!r}: {len(result)} items") - return result diff --git a/tests/conftest.py b/tests/conftest.py index 9bb09368..a2a40652 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,18 +3,19 @@ from __future__ import annotations from collections import ChainMap -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any import pytest from markdown.core import Markdown from mkdocs.config.defaults import MkDocsConfig if TYPE_CHECKING: + from collections.abc import Iterator from pathlib import Path from mkdocs import config - from mkdocstrings.plugin import MkdocstringsPlugin + from mkdocstrings._internal.plugin import MkdocstringsPlugin @pytest.fixture(name="mkdocs_conf") @@ -22,7 +23,7 @@ def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> Itera """Yield a MkDocs configuration object.""" conf = MkDocsConfig() while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"): - request = request._parent_request + request = request._parent_request # ty: ignore[invalid-assignment] conf_dict = { "site_name": "foo", @@ -32,7 +33,7 @@ def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> Itera **getattr(request, "param", {}), } # Re-create it manually as a workaround for https://github.com/mkdocs/mkdocs/issues/2289 - mdx_configs: dict[str, Any] = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) + mdx_configs: dict[str, Any] = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) # ty: ignore[invalid-argument-type,invalid-assignment] conf.load_dict(conf_dict) assert conf.validate() == ([], []) diff --git a/tests/fixtures/nesting.py b/tests/fixtures/nesting.py new file mode 100644 index 00000000..92f7a9ee --- /dev/null +++ b/tests/fixtures/nesting.py @@ -0,0 +1,10 @@ +class Class: + """A class. + + ## ::: tests.fixtures.nesting.Class.method + options: + show_root_heading: true + """ + + def method(self) -> None: + """A method.""" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..7ae732cb --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,184 @@ +"""Tests for our own API exposition.""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +from typing import TYPE_CHECKING + +import griffe +import pytest + +import mkdocstrings +from mkdocstrings import Inventory + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture(name="loader", scope="module") +def _fixture_loader() -> griffe.GriffeLoader: + loader = griffe.GriffeLoader() + loader.load("mkdocstrings") + loader.resolve_aliases() + return loader + + +@pytest.fixture(name="internal_api", scope="module") +def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: + return loader.modules_collection["mkdocstrings._internal"] + + +@pytest.fixture(name="public_api", scope="module") +def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: + return loader.modules_collection["mkdocstrings"] + + +def _yield_public_objects( + obj: griffe.Module | griffe.Class, + *, + modules: bool = False, + modulelevel: bool = True, + inherited: bool = False, + special: bool = False, +) -> Iterator[griffe.Object | griffe.Alias]: + for member in obj.all_members.values() if inherited else obj.members.values(): + try: + if member.is_module: + if member.is_alias or not member.is_public: + continue + if modules: + yield member + yield from _yield_public_objects( + member, # ty: ignore[invalid-argument-type] + modules=modules, + modulelevel=modulelevel, + inherited=inherited, + special=special, + ) + elif member.is_public and (special or not member.is_special): + yield member + else: + continue + if member.is_class and not modulelevel: + yield from _yield_public_objects( + member, # ty: ignore[invalid-argument-type] + modules=modules, + modulelevel=False, + inherited=inherited, + special=special, + ) + except (griffe.AliasResolutionError, griffe.CyclicAliasError): + continue + + +@pytest.fixture(name="modulelevel_internal_objects", scope="module") +def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: + return list(_yield_public_objects(internal_api, modulelevel=True)) + + +@pytest.fixture(name="internal_objects", scope="module") +def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: + return list(_yield_public_objects(internal_api, modulelevel=False, special=True)) + + +@pytest.fixture(name="public_objects", scope="module") +def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: + return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True)) + + +@pytest.fixture(name="inventory", scope="module") +def _fixture_inventory() -> Inventory: + inventory_file = Path(__file__).parent.parent / "site" / "objects.inv" + if not inventory_file.exists(): + pytest.skip("The objects inventory is not available.") + with inventory_file.open("rb") as file: + return Inventory.parse_sphinx(file) + + +def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: + """All public objects in the internal API are exposed under `mkdocstrings`.""" + not_exposed = [ + obj.path + for obj in modulelevel_internal_objects + if obj.name not in mkdocstrings.__all__ or not hasattr(mkdocstrings, obj.name) + ] + assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed)) + + +def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: + """All internal objects have unique names.""" + names_to_paths = defaultdict(list) + for obj in modulelevel_internal_objects: + names_to_paths[obj.name].append(obj.path) + non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1] + assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) + + +def test_single_locations(public_api: griffe.Module) -> None: + """All objects have a single public location.""" + + def _public_path(obj: griffe.Object | griffe.Alias) -> bool: + return obj.is_public and (obj.parent is None or _public_path(obj.parent)) + + multiple_locations = {} + for obj_name in mkdocstrings.__all__: + obj = public_api[obj_name] + if obj.aliases and ( + public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)] + ): + multiple_locations[obj.path] = public_aliases + assert not multiple_locations, "Multiple public locations:\n" + "\n".join( + f"{path}: {aliases}" for path, aliases in multiple_locations.items() + ) + + +def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None: + """All public objects are added to the inventory.""" + ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"} + not_in_inventory = [ + f"{obj.relative_filepath}:{obj.lineno}: {obj.path}" + for obj in public_objects + if obj.name not in ignore_names and obj.path not in inventory + ] + msg = "Objects not in the inventory (try running `make run zensical build --clean`):\n{paths}" + assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory))) + + +def test_inventory_matches_api( + inventory: Inventory, + public_objects: list[griffe.Object | griffe.Alias], + loader: griffe.GriffeLoader, +) -> None: + """The inventory doesn't contain any additional Python object.""" + not_in_api = [] + public_api_paths = {obj.path for obj in public_objects} + public_api_paths.add("mkdocstrings") + for item in inventory.values(): + if ( + item.domain == "py" + and "(" not in item.name + and (item.name == "mkdocstrings" or item.name.startswith("mkdocstrings.")) + ): + obj = loader.modules_collection[item.name] + + if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): + not_in_api.append(item.name) + msg = "Inventory objects not in public API (try running `make run zensical build --clean`):\n{paths}" + assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api))) + + +def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None: + """No module docstrings should be written in our internal API. + + The reasoning is that docstrings are addressed to users of the public API, + but internal modules are not exposed to users, so they should not have docstrings. + """ + + def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: + for member in obj.modules.values(): + yield member + yield from _modules(member) + + for obj in _modules(internal_api): + assert not obj.docstring diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 00000000..4aa19fd7 --- /dev/null +++ b/tests/test_download.py @@ -0,0 +1,103 @@ +"""Tests for the internal mkdocstrings _download module.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from mkdocstrings._internal import download + +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 download._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._download") + + credential = "${USER}" + env: dict[str, str] = {} + assert download._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(download, "_create_auth_header", lambda *args, **kwargs: {}) + result_url, _result_auth_header = download._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 = download._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 = download._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 = download._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 affd6c6a..5b031842 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -import sys from textwrap import dedent from typing import TYPE_CHECKING @@ -12,7 +11,7 @@ if TYPE_CHECKING: from markdown import Markdown - from mkdocstrings.plugin import MkdocstringsPlugin + from mkdocstrings import MkdocstringsPlugin @pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"footnotes": {}}]}], indirect=["ext_markdown"]) @@ -60,7 +59,6 @@ def test_reference_inside_autodoc(ext_markdown: Markdown) -> None: 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: Markdown) -> None: """Assert that inline highlighting doesn't double-escape HTML.""" output = ext_markdown.convert("::: tests.fixtures.string_annotation.Foo") @@ -101,7 +99,7 @@ def test_no_double_toc(ext_markdown: Markdown, expect_permalink: str) -> None: ) assert output.count(expect_permalink) == 5 assert 'id="tests.fixtures.headings--foo"' in output - assert ext_markdown.toc_tokens == [ # type: ignore[attr-defined] # the member gets populated only with 'toc' extension + assert ext_markdown.toc_tokens == [ # ty: ignore[unresolved-attribute] { "level": 1, "id": "aa", @@ -154,16 +152,20 @@ def test_use_custom_handler(ext_markdown: Markdown) -> None: ext_markdown.convert("::: tests.fixtures.headings\n handler: not_here") -def test_dont_register_every_identifier_as_anchor(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> None: +def test_register_every_identifier_alias(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") # type: ignore[union-attr] + handler = plugin._handlers.get_handler("python") # ty: ignore[unresolved-attribute] ids = ("id1", "id2", "id3") - handler.get_anchors = lambda _: ids # type: ignore[method-assign] + handler.get_aliases = lambda _: ids # ty: ignore[invalid-assignment] + autorefs = ext_markdown.parser.blockprocessors["mkdocstrings"]._autorefs + + class Page: + url = "foo" + + autorefs.current_page = Page() 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 - assert identifier not in autorefs._abs_url_map + assert identifier in autorefs._secondary_url_map def test_use_options_yaml_key(ext_markdown: Markdown) -> None: @@ -172,6 +174,11 @@ def test_use_options_yaml_key(ext_markdown: Markdown) -> None: 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.""" @@ -191,7 +198,6 @@ def test_removing_duplicated_headings(ext_markdown: Markdown) -> None: 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: diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 4a07e98b..a1ef4ee6 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -2,18 +2,20 @@ from __future__ import annotations +from textwrap import dedent from typing import TYPE_CHECKING import pytest +from dirty_equals import IsStr from jinja2.exceptions import TemplateNotFound from markdown import Markdown -from mkdocstrings.handlers.base import Highlighter +from mkdocstrings import Highlighter if TYPE_CHECKING: from pathlib import Path - from mkdocstrings.plugin import MkdocstringsPlugin + from mkdocstrings import MkdocstringsPlugin @pytest.mark.parametrize("extension_name", ["codehilite", "pymdownx.highlight"]) @@ -60,7 +62,7 @@ def test_extended_templates(tmp_path: Path, plugin: MkdocstringsPlugin) -> None: tmp_path: Temporary folder. plugin: Instance of our plugin. """ - handler = plugin._handlers.get_handler("python") # type: ignore[union-attr] + handler = plugin._handlers.get_handler("python") # ty: ignore[unresolved-attribute] # monkeypatch Jinja env search path search_paths = [ @@ -69,7 +71,7 @@ def test_extended_templates(tmp_path: Path, plugin: MkdocstringsPlugin) -> None: extended_theme := tmp_path / "extended_theme", extended_fallback_theme := tmp_path / "extended_fallback_theme", ] - handler.env.loader.searchpath = search_paths # type: ignore[union-attr] + handler.env.loader.searchpath = search_paths # ty: ignore[invalid-assignment] # assert "new" template is not found with pytest.raises(expected_exception=TemplateNotFound): @@ -94,3 +96,43 @@ def test_extended_templates(tmp_path: Path, plugin: MkdocstringsPlugin) -> None: base_theme.mkdir() base_theme.joinpath("new.html").write_text("base new") assert handler.env.get_template("new.html").render() == "base new" + + +@pytest.mark.parametrize( + "ext_markdown", + [{"markdown_extensions": [{"toc": {"permalink": True}}]}], + indirect=["ext_markdown"], +) +def test_nested_autodoc(ext_markdown: Markdown) -> None: + """Assert that nested autodocs render well and do not mess up the TOC.""" + output = ext_markdown.convert( + dedent( + """ + # ::: tests.fixtures.nesting.Class + options: + members: false + show_root_heading: true + """, + ), + ) + assert 'id="tests.fixtures.nesting.Class"' in output + assert 'id="tests.fixtures.nesting.Class.method"' in output + assert ext_markdown.toc_tokens == [ # ty: ignore[unresolved-attribute] + { + "level": 1, + "id": "tests.fixtures.nesting.Class", + "html": IsStr(), + "name": "Class", + "data-toc-label": "Class", + "children": [ + { + "level": 2, + "id": "tests.fixtures.nesting.Class.method", + "html": IsStr(), + "name": "method", + "data-toc-label": "method", + "children": [], + }, + ], + }, + ] diff --git a/tests/test_inventory.py b/tests/test_inventory.py index ce707296..858ac340 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -2,20 +2,15 @@ from __future__ import annotations -import sys from io import BytesIO from os.path import join -from typing import TYPE_CHECKING +from pathlib import Path import pytest from mkdocs.commands.build import build from mkdocs.config import load_config -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") +from mkdocstrings import Inventory, InventoryItem @pytest.mark.parametrize( @@ -25,10 +20,13 @@ Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url")]), Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#object_path")]), Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#other_anchor")]), + Inventory([InventoryItem(name="o", domain="py", role="obj", uri="u#o", dispname="first line\nsecond line")]), ], ) def test_sphinx_load_inventory_file(our_inv: Inventory) -> None: """Perform the 'live' inventory load test.""" + sphinx = pytest.importorskip("sphinx.util.inventory", reason="Sphinx is not installed") + buffer = BytesIO(our_inv.format_sphinx()) sphinx_inv = sphinx.InventoryFile.load(buffer, "", join) @@ -39,9 +37,10 @@ def test_sphinx_load_inventory_file(our_inv: Inventory) -> None: assert item.name in sphinx_inv[f"{item.domain}:{item.role}"] -@pytest.mark.skipif(sys.version_info < (3, 7), reason="using plugins that require Python 3.7") def test_sphinx_load_mkdocstrings_inventory_file() -> None: """Perform the 'live' inventory load test on mkdocstrings own inventory.""" + sphinx = pytest.importorskip("sphinx.util.inventory", reason="Sphinx is not installed") + mkdocs_config = load_config() mkdocs_config["plugins"].run_event("startup", command="build", dirty=False) try: @@ -50,7 +49,7 @@ def test_sphinx_load_mkdocstrings_inventory_file() -> None: mkdocs_config["plugins"].run_event("shutdown") own_inv = mkdocs_config["plugins"]["mkdocstrings"].handlers.inventory - with open("site/objects.inv", "rb") as fp: + with Path("site/objects.inv").open("rb") as fp: sphinx_inv = sphinx.InventoryFile.load(fp, "", join) sphinx_inv_length = sum(len(sphinx_inv[key]) for key in sphinx_inv) @@ -60,10 +59,27 @@ def test_sphinx_load_mkdocstrings_inventory_file() -> None: 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] +@pytest.mark.parametrize( + "our_inv", + [ + Inventory(), + Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url")]), + Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#object_path")]), + Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#other_anchor")]), + Inventory([InventoryItem(name="o", domain="py", role="obj", uri="u#o", dispname="first line\nsecond line")]), + ], +) +def test_mkdocstrings_roundtrip_inventory_file(our_inv: Inventory) -> None: + """Save some inventory files, then load them in again.""" + buffer = BytesIO(our_inv.format_sphinx()) + round_tripped = Inventory.parse_sphinx(buffer) + + assert our_inv.keys() == round_tripped.keys() + for key, value in our_inv.items(): + round_tripped_item = round_tripped[key] + assert round_tripped_item.name == value.name + assert round_tripped_item.domain == value.domain + assert round_tripped_item.role == value.role + assert round_tripped_item.uri == value.uri + assert round_tripped_item.priority == value.priority + assert round_tripped_item.dispname == value.dispname.splitlines()[0] diff --git a/tests/test_loggers.py b/tests/test_loggers.py new file mode 100644 index 00000000..35e4dc86 --- /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 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 index 3342e2aa..833de692 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -7,7 +7,7 @@ from mkdocs.commands.build import build from mkdocs.config import load_config -from mkdocstrings.plugin import MkdocstringsPlugin +from mkdocstrings import MkdocstringsPlugin if TYPE_CHECKING: from pathlib import Path @@ -20,11 +20,20 @@ def test_disabling_plugin(tmp_path: Path) -> None: docs_dir.mkdir() site_dir.mkdir() docs_dir.joinpath("index.md").write_text("::: mkdocstrings") + config_file = tmp_path / "mkdocs.yml" + config_file.write_text( + """ + site_name: Test + theme: mkdocs + plugins: + - mkdocstrings: + enabled: false + """, + ) - mkdocs_config = load_config() + mkdocs_config = load_config(str(config_file)) 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) @@ -32,7 +41,7 @@ def test_disabling_plugin(tmp_path: Path) -> None: mkdocs_config["plugins"].run_event("shutdown") # make sure the instruction was not processed - assert "::: mkdocstrings" in site_dir.joinpath("index.html").read_text() + assert "::: mkdocstrings" in site_dir.joinpath("index.html").read_text(encoding="utf8") def test_plugin_default_config(tmp_path: Path) -> None: @@ -48,6 +57,7 @@ def test_plugin_default_config(tmp_path: Path) -> None: "custom_templates": None, "enable_inventory": None, "enabled": True, + "locale": None, } @@ -68,4 +78,5 @@ def test_plugin_config_custom_templates(tmp_path: Path) -> None: "custom_templates": str(template_dir), "enable_inventory": None, "enabled": True, + "locale": None, } diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 00000000..d04f4896 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,214 @@ +[project] +site_name = "mkdocstrings" +site_description = "Automatic documentation from sources, for MkDocs." +site_author = "Timothée Mazzucotelli" +site_url = "https://mkdocstrings.github.io/mkdocstrings" +repo_url = "https://github.com/mkdocstrings/mkdocstrings" +repo_name = "mkdocstrings/mkdocstrings" +copyright = "Copyright © 2019 Timothée Mazzucotelli" +extra_css = ["css/apidocs.css"] +extra_javascript = ["js/feedback.js"] +nav = [ + { "Home" = [ + { "Overview" = "index.md" }, + { "Changelog" = "changelog.md" }, + { "Credits" = "credits.md" }, + { "License" = "license.md" }, + ] }, + { "Usage" = [ + "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/" }, + { "GitHub Actions" = "https://watermarkhu.nl/mkdocstrings-github/" }, + { "Python" = "https://mkdocstrings.github.io/python/" }, + { "Python (Legacy)" = "https://mkdocstrings.github.io/python-legacy/" }, + { "MATLAB" = "https://watermarkhu.nl/mkdocstrings-matlab/" }, + { "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" }, + ] }, + ] }, + { "API reference" = "reference/api.md" }, + { "Development" = [ + { "Contributing" = "contributing.md" }, + { "Code of Conduct" = "code_of_conduct.md" }, + ] }, + { "Author's website" = "https://pawamoy.github.io" }, +] + +# ---------------------------------------------------------------------------- +# Theme configuration +# ---------------------------------------------------------------------------- +[project.theme] +logo = "logo.svg" +custom_dir = "docs/.overrides" +language = "en" +features = [ + "announce.dismiss", + "content.action.edit", + "content.action.view", + "content.code.annotate", + "content.code.copy", + "content.code.select", + "content.footnote.tooltips", + "content.tabs.link", + "content.tooltips", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.path", + "navigation.sections", + "navigation.tabs", + "navigation.tabs.sticky", + "navigation.top", + "search.highlight", + "toc.follow", +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "material/brightness-auto" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "teal" +accent = "purple" +toggle.icon = "material/weather-sunny" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "black" +accent = "lime" +toggle.icon = "material/weather-night" +toggle.name = "Switch to system preference" + +[project.theme.icon] +logo = "material/currency-sign" + +# ---------------------------------------------------------------------------- +# Social configuration +# ---------------------------------------------------------------------------- +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/pawamoy" + +[[project.extra.social]] +icon = "fontawesome/brands/mastodon" +link = "https://fosstodon.org/@pawamoy" + +[[project.extra.social]] +icon = "fontawesome/brands/twitter" +link = "https://twitter.com/pawamoy" + +[[project.extra.social]] +icon = "fontawesome/brands/gitter" +link = "https://gitter.im/mkdocstrings/community" + +[[project.extra.social]] +icon = "fontawesome/brands/python" +link = "https://pypi.org/project/mkdocstrings/" + +[project.extra.analytics.feedback] +title = "Was this page helpful?" + +[[project.extra.analytics.feedback.ratings]] +icon = "material/emoticon-happy-outline" +name = "This page was helpful" +data = 1 +note = "Thank you for your feedback!" + +[[project.extra.analytics.feedback.ratings]] +icon = "material/emoticon-sad-outline" +name = "This page could be improved" +data = 0 +note = "Let us know how we can improve this page." + +# ---------------------------------------------------------------------------- +# Markdown extensions configuration +# ---------------------------------------------------------------------------- +[project.markdown_extensions.abbr] +[project.markdown_extensions.admonition] +[project.markdown_extensions.attr_list] +[project.markdown_extensions.callouts] +[project.markdown_extensions.def_list] +[project.markdown_extensions.footnotes] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.toc] +permalink = "¤" +[project.markdown_extensions.pymdownx.arithmatex] +generic = true +[project.markdown_extensions.pymdownx.betterem] +smart_enable = "all" +[project.markdown_extensions.pymdownx.caret] +[project.markdown_extensions.pymdownx.details] +[project.markdown_extensions.pymdownx.emoji] +emoji_generator = "zensical.extensions.emoji.to_svg" +emoji_index = "zensical.extensions.emoji.twemoji" +[project.markdown_extensions.pymdownx.highlight] +[project.markdown_extensions.pymdownx.inlinehilite] +[project.markdown_extensions.pymdownx.keys] +[project.markdown_extensions.pymdownx.magiclink] +[project.markdown_extensions.pymdownx.mark] +[project.markdown_extensions.pymdownx.smartsymbols] +[project.markdown_extensions.pymdownx.snippets] +check_paths = true +[project.markdown_extensions.pymdownx.superfences] +[[project.markdown_extensions.pymdownx.superfences.custom_fences]] +name = "python" +class = "python" +validator = "markdown_exec.validator" +format = "markdown_exec.formatter" +[project.markdown_extensions.pymdownx.tabbed] +alternate_style = true +[project.markdown_extensions.pymdownx.tasklist] +custom_checkbox = true +[project.markdown_extensions.pymdownx.tilde] +[project.markdown_extensions.zensical.extensions.preview] +configurations = [{targets.include = ["reference/api.md"]}] + +# ---------------------------------------------------------------------------- +# Plugins configuration +# ---------------------------------------------------------------------------- +[project.plugins.mkdocstrings.handlers.python] +paths = ["src"] +inventories = [ + "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", + "https://jinja.palletsprojects.com/en/stable/objects.inv", + "https://markupsafe.palletsprojects.com/en/stable/objects.inv", +] + +[project.plugins.mkdocstrings.handlers.python.options] +backlinks = "tree" +docstring_options = { "ignore_init_summary" = true } +docstring_section_style = "list" +filters = "public" +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 = true +show_symbol_type_heading = true +show_symbol_type_toc = true +signature_crossrefs = true +summary = true +unwrap_annotated = true