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

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

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

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