diff --git a/.copier-answers.yml b/.copier-answers.yml index 90ce2e79..1dc4ac4d 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: 1.1.4 +_commit: 1.2.0 _src_path: gh:mkdocstrings/handler-template author_email: dev@pawamoy.fr author_fullname: Timothée Mazzucotelli diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e422aeb8..6940069d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,13 +29,16 @@ jobs: - name: Fetch all tags run: git fetch --depth=1 --tags - - name: Set up Python + - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - - name: Install uv - run: pip install uv + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml - name: Install dependencies run: make setup @@ -63,11 +66,11 @@ jobs: echo 'jobs=[ {"os": "macos-latest"}, {"os": "windows-latest"}, - {"python-version": "3.9"}, {"python-version": "3.10"}, {"python-version": "3.11"}, {"python-version": "3.12"}, - {"python-version": "3.13"} + {"python-version": "3.13"}, + {"python-version": "3.14"} ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT else echo 'jobs=[ @@ -87,31 +90,35 @@ jobs: - macos-latest - windows-latest python-version: - - "3.8" - "3.9" - "3.10" - "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.13' }} + continue-on-error: ${{ matrix.python-version == '3.14' }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python + - name: Setup Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - - name: Install uv - run: pip install uv + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: py${{ matrix.python-version }} - name: Install dependencies env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d82736f7..45bcf5a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,32 +14,30 @@ jobs: - name: Fetch all tags run: git fetch --depth=1 --tags - name: Setup Python - uses: actions/setup-python@v4 - - name: Install build - if: github.repository_owner == 'pawamoy-insiders' - run: python -m pip install build + 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: python -m build + 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: python-insiders path: ./dist/* - - name: Install git-changelog - if: github.repository_owner != 'pawamoy-insiders' - run: pip install git-changelog - name: Prepare release notes if: github.repository_owner != 'pawamoy-insiders' - run: git-changelog --release-notes > release-notes.md + run: uv tool run git-changelog --release-notes > release-notes.md - name: Create release with assets - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: github.repository_owner == 'pawamoy-insiders' with: files: ./dist/* - name: Create release - uses: softprops/action-gh-release@v1 + 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 41fee62d..9fea0472 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ /.pdm-build/ /htmlcov/ /site/ +uv.lock # cache .cache/ diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile deleted file mode 100644 index 1590b415..00000000 --- a/.gitpod.dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM gitpod/workspace-full -USER gitpod -ENV PIP_USER=no -RUN pip3 install pipx; \ - pipx install uv; \ - pipx ensurepath diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 23a3c2b7..00000000 --- a/.gitpod.yml +++ /dev/null @@ -1,13 +0,0 @@ -vscode: - extensions: - - ms-python.python - -image: - file: .gitpod.dockerfile - -ports: -- port: 8000 - onOpen: notify - -tasks: -- init: make setup diff --git a/CHANGELOG.md b/CHANGELOG.md index f1668147..9d9f4c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.12.1](https://github.com/mkdocstrings/python/releases/tag/1.12.1) - 2024-10-14 + +[Compare with 1.12.0](https://github.com/mkdocstrings/python/compare/1.12.0...1.12.1) + +### Bug Fixes + +- Don't escape parameter default values ([9dee4d4](https://github.com/mkdocstrings/python/commit/9dee4d4f8e1258e99c19dc7b2b18d3e9090de79b) by Timothée Mazzucotelli). [Issue-191](https://github.com/mkdocstrings/python/issues/191) + +## [1.12.0](https://github.com/mkdocstrings/python/releases/tag/1.12.0) - 2024-10-12 + +[Compare with 1.11.1](https://github.com/mkdocstrings/python/compare/1.11.1...1.12.0) + +### Build + +- Drop support for Python 3.8 ([6615c91](https://github.com/mkdocstrings/python/commit/6615c91cdc035bc0c2fdd12f3952ff84f5e1c04e) by Timothée Mazzucotelli). + +### Features + +- Auto-summary of members ([7f9757d](https://github.com/mkdocstrings/python/commit/7f9757d1584555edebc56f1aefe6cc8242e6c8bb) by Timothée Mazzucotelli). +- Render function overloads ([0f2c25c](https://github.com/mkdocstrings/python/commit/0f2c25c9ed7f6c5c93ff13df214f02edfd3a4cb1) by Timothée Mazzucotelli). +- Parameter headings, more automatic cross-references ([0176b83](https://github.com/mkdocstrings/python/commit/0176b83f21ae02d345489c93cca3baf51f8bc58c) by Timothée Mazzucotelli). + +### Code Refactoring + +- Declare default CSS symbol colors under :host as well ([3b9dba2](https://github.com/mkdocstrings/python/commit/3b9dba2709a8668e379c6ce1536cb1714971b3f4) by James McDonnell). [PR-186](https://github.com/mkdocstrings/python/pull/186) + ## [1.11.1](https://github.com/mkdocstrings/python/releases/tag/1.11.1) - 2024-09-03 [Compare with 1.11.0](https://github.com/mkdocstrings/python/compare/1.11.0...1.11.1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbc08404..3e3dc294 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,12 +23,11 @@ make setup > You can install it with: > > ```bash -> python3 -m pip install --user pipx -> pipx install uv +> curl -LsSf https://astral.sh/uv/install.sh | sh > ``` > > Now you can try running `make setup` again, -> or simply `uv install`. +> or simply `uv sync`. You now have the dependencies installed. diff --git a/config/ruff.toml b/config/ruff.toml index e3c9ec30..4c91b364 100644 --- a/config/ruff.toml +++ b/config/ruff.toml @@ -1,4 +1,4 @@ -target-version = "py38" +target-version = "py39" line-length = 120 [lint] diff --git a/devdeps.txt b/devdeps.txt deleted file mode 100644 index e0afd7e2..00000000 --- a/devdeps.txt +++ /dev/null @@ -1,32 +0,0 @@ -# dev -editables>=0.5 - -# maintenance -build>=1.2 -git-changelog>=2.5 -twine>=5.0; python_version < '3.13' - -# 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 - -# 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-committers-plugin-2>=2.3 -mkdocs-literate-nav>=0.6 -mkdocs-material>=9.5 -mkdocs-minify-plugin>=0.8 -mkdocstrings[python]>=0.25 -tomli>=2.0; python_version < '3.11' diff --git a/docs/usage/configuration/headings.md b/docs/usage/configuration/headings.md index 63950206..467779e4 100644 --- a/docs/usage/configuration/headings.md +++ b/docs/usage/configuration/headings.md @@ -59,7 +59,6 @@ plugins: ## `parameter_headings` -[:octicons-heart-fill-24:{ .pulse } Sponsors only](../../insiders/index.md){ .insiders } — [:octicons-tag-24: Insiders 1.6.0](../../insiders/changelog.md#1.6.0) - **:octicons-package-24: Type [`bool`][] :material-equal: `False`{ title="default value" }** diff --git a/docs/usage/configuration/members.md b/docs/usage/configuration/members.md index 119d8294..220a26fe 100644 --- a/docs/usage/configuration/members.md +++ b/docs/usage/configuration/members.md @@ -552,7 +552,6 @@ package ## `summary` -[:octicons-heart-fill-24:{ .pulse } Sponsors only](../../insiders/index.md){ .insiders } — [:octicons-tag-24: Insiders 1.2.0](../../insiders/changelog.md#1.2.0) - **:octicons-package-24: Type bool | dict[str, bool] :material-equal: `False`{ title="default value" }** diff --git a/duties.py b/duties.py index f1909cc1..3864e74e 100644 --- a/duties.py +++ b/duties.py @@ -7,11 +7,13 @@ from contextlib import contextmanager from importlib.metadata import version as pkgversion from pathlib import Path -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING from duty import duty, tools if TYPE_CHECKING: + from collections.abc import Iterator + from duty.context import Context @@ -53,7 +55,7 @@ def changelog(ctx: Context, bump: str = "") -> None: ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") -@duty(pre=["check_quality", "check_types", "check_docs", "check-api"]) +@duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) def check(ctx: Context) -> None: """Check it all!""" @@ -116,23 +118,33 @@ def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000 @duty -def docs_deploy(ctx: Context) -> None: - """Deploy the documentation to GitHub pages.""" +def docs_deploy(ctx: Context, *, force: bool = False) -> None: + """Deploy the documentation to GitHub pages. + + Parameters: + force: Whether to force deployment, even from non-Insiders version. + """ 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) + origin = ctx.run("git config --get remote.origin.url", silent=True, allow_overrides=False) if "pawamoy-insiders/mkdocstrings-python" in origin: ctx.run( "git remote add upstream git@github.com:mkdocstrings/python", silent=True, nofail=True, + allow_overrides=False, ) ctx.run( tools.mkdocs.gh_deploy(remote_name="upstream", force=True), title="Deploying documentation", ) + elif force: + ctx.run( + tools.mkdocs.gh_deploy(force=True), + title="Deploying documentation", + ) else: ctx.run( lambda: False, diff --git a/mkdocs.yml b/mkdocs.yml index 19aa90d9..2d546126 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -180,9 +180,10 @@ plugins: signature_crossrefs: true summary: true unwrap_annotated: true -- git-committers: +- git-revision-date-localized: enabled: !ENV [DEPLOY, false] - repository: mkdocstrings/python + enable_creation_date: true + type: timeago - minify: minify_html: !ENV [DEPLOY, false] - group: diff --git a/pyproject.toml b/pyproject.toml index 0eccf7fe..636a67fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "A Python handler for mkdocstrings." authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] license = {text = "ISC"} readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" keywords = [] dynamic = ["version"] classifiers = [ @@ -17,14 +17,15 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Documentation", "Topic :: Software Development", + "Topic :: Software Development :: Documentation", "Topic :: Utilities", "Typing :: Typed", ] @@ -58,7 +59,6 @@ source-includes = [ "scripts", "share", "tests", - "devdeps.txt", "duties.py", "mkdocs.yml", "*.md", @@ -69,3 +69,39 @@ source-includes = [ data = [ {path = "share/**/*", relative-to = "."}, ] + +[tool.uv] +dev-dependencies = [ + # dev + "editables>=0.5", + + # maintenance + "build>=1.2", + "git-changelog>=2.5", + "twine>=5.1", + + # ci + "duty>=1.4", + "ruff>=0.4", + "pytest>=8.2", + "pytest-cov>=5.0", + "pytest-randomly>=3.15", + "pytest-xdist>=3.6", + "mypy>=1.10", + "types-markdown>=3.6", + "types-pyyaml>=6.0", + + # 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", + # 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 index b2f6d3e4..51ebe2f3 100644 --- a/scripts/gen_credits.py +++ b/scripts/gen_credits.py @@ -5,17 +5,18 @@ import os import sys from collections import defaultdict +from collections.abc import Iterable from importlib.metadata import distributions from itertools import chain from pathlib import Path from textwrap import dedent -from typing import Dict, Iterable, Union +from typing import Union from jinja2 import StrictUndefined from jinja2.sandbox import SandboxedEnvironment from packaging.requirements import Requirement -# TODO: Remove once support for Python 3.10 is dropped. +# YORE: EOL 3.10: Replace block with line 2. if sys.version_info >= (3, 11): import tomllib else: @@ -26,11 +27,10 @@ pyproject = tomllib.load(pyproject_file) project = pyproject["project"] project_name = project["name"] -with project_dir.joinpath("devdeps.txt").open() as devdeps_file: - devdeps = [line.strip() for line in devdeps_file if line.strip() and not line.strip().startswith(("-e", "#"))] +devdeps = [dep for dep in pyproject["tool"]["uv"]["dev-dependencies"] if not dep.startswith("-e")] -PackageMetadata = Dict[str, Union[str, Iterable[str]]] -Metadata = Dict[str, PackageMetadata] +PackageMetadata = dict[str, Union[str, Iterable[str]]] +Metadata = dict[str, PackageMetadata] def _merge_fields(metadata: dict) -> PackageMetadata: diff --git a/scripts/insiders.py b/scripts/insiders.py index 15212486..849c6314 100644 --- a/scripts/insiders.py +++ b/scripts/insiders.py @@ -10,13 +10,16 @@ from datetime import date, datetime, timedelta from itertools import chain from pathlib import Path -from typing import Iterable, cast +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__}") diff --git a/scripts/make b/scripts/make index d898022e..ac430624 100755 --- a/scripts/make +++ b/scripts/make @@ -9,12 +9,10 @@ import subprocess import sys from contextlib import contextmanager from pathlib import Path +from textwrap import dedent from typing import Any, Iterator -PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.8 3.9 3.10 3.11 3.12 3.13").split() - -exe = "" -prefix = "" +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: @@ -37,17 +35,13 @@ def environ(**kwargs: str) -> Iterator[None]: os.environ.update(original) -def uv_install() -> None: +def uv_install(venv: Path) -> None: """Install dependencies using uv.""" - uv_opts = "" - if "UV_RESOLUTION" in os.environ: - uv_opts = f"--resolution={os.getenv('UV_RESOLUTION')}" - requirements = shell(f"uv pip compile {uv_opts} pyproject.toml devdeps.txt", capture_output=True) - shell("uv pip install -r -", input=requirements, text=True) - if "CI" not in os.environ: - shell("uv pip install --no-deps -e .") - else: - shell("uv pip install --no-deps .") + 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: @@ -59,7 +53,7 @@ def setup() -> None: default_venv = Path(".venv") if not default_venv.exists(): shell("uv venv --python python") - uv_install() + uv_install(default_venv) if PYTHON_VERSIONS: for version in PYTHON_VERSIONS: @@ -67,39 +61,22 @@ def setup() -> None: venv_path = Path(f".venvs/{version}") if not venv_path.exists(): shell(f"uv venv --python {version} {venv_path}") - with environ(VIRTUAL_ENV=str(venv_path.resolve())): - uv_install() - - -def activate(path: str) -> None: - """Activate a virtual environment.""" - global exe, prefix # noqa: PLW0603 - - if (bin := Path(path, "bin")).exists(): - activate_script = bin / "activate_this.py" - elif (scripts := Path(path, "Scripts")).exists(): - activate_script = scripts / "activate_this.py" - exe = ".exe" - prefix = f"{path}/Scripts/" - else: - raise ValueError(f"make: activate: Cannot find activation script in {path}") - - if not activate_script.exists(): - raise ValueError(f"make: activate: Cannot find activation script in {path}") - - exec(activate_script.read_text(), {"__file__": str(activate_script)}) # noqa: S102 + with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): + uv_install(venv_path) -def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: +def run(version: str, cmd: str, *args: str, no_sync: bool = False, **kwargs: Any) -> None: """Run a command in a virtual environment.""" kwargs = {"check": True, **kwargs} + uv_run = ["uv", "run"] + if no_sync: + uv_run.append("--no-sync") if version == "default": - activate(".venv") - subprocess.run([f"{prefix}{cmd}{exe}", *args], **kwargs) # noqa: S603, PLW1510 + with environ(UV_PROJECT_ENVIRONMENT=".venv"): + subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 else: - activate(f".venvs/{version}") - os.environ["MULTIRUN"] = "1" - subprocess.run([f"{prefix}{cmd}{exe}", *args], **kwargs) # noqa: S603, PLW1510 + 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: @@ -124,10 +101,10 @@ def clean() -> None: for path in paths_to_clean: shell(f"rm -rf {path}") - cache_dirs = [".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"] - for dirpath in Path(".").rglob("*"): - if any(dirpath.match(pattern) for pattern in cache_dirs) and not (dirpath.match(".venv") or dirpath.match(".venvs")): - 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: @@ -143,22 +120,25 @@ def main() -> int: if len(args) > 1: run("default", "duty", "--help", args[1]) else: - print("Available commands") # noqa: T201 - print(" help Print this help. Add task name to print help.") # noqa: T201 - print(" setup Setup all virtual environments (install dependencies).") # noqa: T201 - print(" run Run a command in the default virtual environment.") # noqa: T201 - print(" multirun Run a command for all configured Python versions.") # noqa: T201 - print(" allrun Run a command in all virtual environments.") # noqa: T201 - print(" 3.x Run a command in the virtual environment for Python 3.x.") # noqa: T201 - print(" clean Delete build artifacts and cache files.") # noqa: T201 - print(" vscode Configure VSCode to work on this project.") # noqa: T201 - try: - run("default", "python", "-V", capture_output=True) - except (subprocess.CalledProcessError, ValueError): - pass - else: - print("\nAvailable tasks") # noqa: T201 - run("default", "duty", "--list") + print( + dedent( + """ + Available commands + help Print this help. Add task name to print help. + setup Setup all virtual environments (install dependencies). + run Run a command in the default virtual environment. + multirun Run a command for all configured Python versions. + allrun Run a command in all virtual environments. + 3.x Run a command in the virtual environment for Python 3.x. + clean Delete build artifacts and cache files. + vscode Configure VSCode to work on this project. + """ + ), + flush=True, + ) # noqa: T201 + if os.path.exists(".venv"): + print("\nAvailable tasks", flush=True) # noqa: T201 + run("default", "duty", "--list", no_sync=True) return 0 while args: diff --git a/src/mkdocstrings_handlers/python/handler.py b/src/mkdocstrings_handlers/python/handler.py index ef93ee3b..0aac3cdc 100644 --- a/src/mkdocstrings_handlers/python/handler.py +++ b/src/mkdocstrings_handlers/python/handler.py @@ -10,7 +10,7 @@ from collections import ChainMap from contextlib import suppress from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar, Iterator, Mapping, Sequence +from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar from griffe import ( AliasResolutionError, @@ -29,6 +29,8 @@ from mkdocstrings_handlers.python import rendering if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + from markdown import Markdown @@ -102,6 +104,7 @@ class PythonHandler(BaseHandler): "show_docstring_yields": True, "show_source": True, "show_bases": True, + "show_inheritance_diagram": False, "show_submodules": False, "group_by_category": True, "heading_level": 2, @@ -116,6 +119,8 @@ class PythonHandler(BaseHandler): "summary": False, "show_labels": True, "unwrap_annotated": False, + "parameter_headings": False, + "modernize_annotations": False, } """Default handler configuration. @@ -123,6 +128,7 @@ class PythonHandler(BaseHandler): find_stubs_package (bool): Whether to load stubs package (package-stubs) when extracting docstrings. Default `False`. allow_inspection (bool): Whether to allow inspecting modules when visiting them is not possible. Default: `True`. show_bases (bool): Show the base classes of a class. Default: `True`. + show_inheritance_diagram (bool): Show the inheritance diagram of a class using Mermaid. Default: `False`. show_source (bool): Show the source code of this object. Default: `True`. preload_modules (list[str] | None): Pre-load modules that are not specified directly in autodoc instructions (`::: identifier`). @@ -136,6 +142,7 @@ class PythonHandler(BaseHandler): Attributes: Headings options: heading_level (int): The initial heading level to use. Default: `2`. + parameter_headings (bool): Whether to render headings for parameters (therefore showing parameters in the ToC). Default: `False`. show_root_heading (bool): Show the heading of the object at the root of the documentation tree (i.e. the object referenced by the identifier after `:::`). Default: `False`. show_root_toc_entry (bool): If the root heading is not shown, at least add a ToC entry for it. Default: `True`. @@ -196,6 +203,7 @@ class PythonHandler(BaseHandler): separate_signature (bool): Whether to put the whole signature in a code block below the heading. If Black is installed, the signature is also formatted using it. Default: `False`. unwrap_annotated (bool): Whether to unwrap `Annotated` types to show only the type without the annotations. Default: `False`. + modernize_annotations (bool): Whether to modernize annotations, for example `Optional[str]` into `str | None`. Default: `False`. """ def __init__( @@ -270,7 +278,7 @@ def load_inventory( ) -> Iterator[tuple[str, str]]: """Yield items and their URLs from an inventory file streamed from `in_file`. - This implements mkdocstrings' `load_inventory` "protocol" (see [`mkdocstrings.plugin`][mkdocstrings.plugin]). + This implements mkdocstrings' `load_inventory` "protocol" (see [`mkdocstrings.plugin`][]). Arguments: in_file: The binary file-like object to read the inventory from. @@ -424,7 +432,7 @@ def update_env(self, md: Markdown, config: dict) -> None: self.env.filters["format_signature"] = rendering.do_format_signature self.env.filters["format_attribute"] = rendering.do_format_attribute self.env.filters["filter_objects"] = rendering.do_filter_objects - self.env.filters["stash_crossref"] = lambda ref, length: ref + self.env.filters["stash_crossref"] = rendering.do_stash_crossref self.env.filters["get_template"] = rendering.do_get_template self.env.filters["as_attributes_section"] = rendering.do_as_attributes_section self.env.filters["as_functions_section"] = rendering.do_as_functions_section diff --git a/src/mkdocstrings_handlers/python/rendering.py b/src/mkdocstrings_handlers/python/rendering.py index 2c4a4893..a7ea38f7 100644 --- a/src/mkdocstrings_handlers/python/rendering.py +++ b/src/mkdocstrings_handlers/python/rendering.py @@ -8,12 +8,17 @@ import string import sys import warnings -from functools import lru_cache, partial +from functools import lru_cache from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Match, Pattern, Sequence +from re import Match, Pattern +from typing import TYPE_CHECKING, Any, Callable, ClassVar from griffe import ( Alias, + DocstringAttribute, + DocstringClass, + DocstringFunction, + DocstringModule, DocstringSectionAttributes, DocstringSectionClasses, DocstringSectionFunctions, @@ -26,6 +31,8 @@ from mkdocstrings.loggers import get_logger if TYPE_CHECKING: + from collections.abc import Sequence + from griffe import Attribute, Class, Function, Module from jinja2 import Environment, Template from jinja2.runtime import Context @@ -80,24 +87,26 @@ def do_format_code(code: str, line_length: int) -> str: return formatter(code, line_length) -_stash_key_alphabet = string.ascii_letters + string.digits - +class _StashCrossRefFilter: + stash: ClassVar[dict[str, str]] = {} -def _gen_key(length: int) -> str: - return "_" + "".join(random.choice(_stash_key_alphabet) for _ in range(max(1, length - 1))) # noqa: S311 + @staticmethod + def _gen_key(length: int) -> str: + return "_" + "".join(random.choice(string.ascii_letters + string.digits) for _ in range(max(1, length - 1))) # noqa: S311 + def _gen_stash_key(self, length: int) -> str: + key = self._gen_key(length) + while key in self.stash: + key = self._gen_key(length) + return key -def _gen_stash_key(stash: dict[str, str], length: int) -> str: - key = _gen_key(length) - while key in stash: - key = _gen_key(length) - return key + def __call__(self, crossref: str, *, length: int) -> str: + key = self._gen_stash_key(length) + self.stash[key] = crossref + return key -def _stash_crossref(stash: dict[str, str], crossref: str, *, length: int) -> str: - key = _gen_stash_key(stash, length) - stash[key] = crossref - return key +do_stash_crossref = _StashCrossRefFilter() def _format_signature(name: Markup, signature: str, line_length: int) -> str: @@ -126,7 +135,7 @@ def do_format_signature( line_length: int, *, annotations: bool | None = None, - crossrefs: bool = False, + crossrefs: bool = False, # noqa: ARG001 ) -> str: """Format a signature using Black. @@ -144,12 +153,6 @@ def do_format_signature( env = context.environment # TODO: Stop using `do_get_template` when `*.html` templates are removed. template = env.get_template(do_get_template(env, "signature")) - config_annotations = context.parent["config"]["show_signature_annotations"] - old_stash_ref_filter = env.filters["stash_crossref"] - - stash: dict[str, str] = {} - if (annotations or config_annotations) and crossrefs: - env.filters["stash_crossref"] = partial(_stash_crossref, stash) if annotations is None: new_context = context.parent @@ -157,11 +160,8 @@ def do_format_signature( new_context = dict(context.parent) new_context["config"] = dict(new_context["config"]) new_context["config"]["show_signature_annotations"] = annotations - try: - signature = template.render(new_context, function=function, signature=True) - finally: - env.filters["stash_crossref"] = old_stash_ref_filter + signature = template.render(new_context, function=function, signature=True) signature = _format_signature(callable_path, signature, line_length) signature = str( env.filters["highlight"]( @@ -181,9 +181,10 @@ def do_format_signature( if signature.find('class="nf"') == -1: signature = signature.replace('class="n"', 'class="nf"', 1) - if stash: + if stash := env.filters["stash_crossref"].stash: for key, value in stash.items(): signature = re.sub(rf"\b{key}\b", value, signature) + stash.clear() return signature @@ -195,7 +196,7 @@ def do_format_attribute( attribute: Attribute, line_length: int, *, - crossrefs: bool = False, + crossrefs: bool = False, # noqa: ARG001 ) -> str: """Format an attribute using Black. @@ -213,23 +214,14 @@ def do_format_attribute( # TODO: Stop using `do_get_template` when `*.html` templates are removed. template = env.get_template(do_get_template(env, "expression")) annotations = context.parent["config"]["show_signature_annotations"] - separate_signature = context.parent["config"]["separate_signature"] - old_stash_ref_filter = env.filters["stash_crossref"] - stash: dict[str, str] = {} - if separate_signature and crossrefs: - env.filters["stash_crossref"] = partial(_stash_crossref, stash) - - try: - signature = str(attribute_path).strip() - if annotations and attribute.annotation: - annotation = template.render(context.parent, expression=attribute.annotation, signature=True) - signature += f": {annotation}" - if attribute.value: - value = template.render(context.parent, expression=attribute.value, signature=True) - signature += f" = {value}" - finally: - env.filters["stash_crossref"] = old_stash_ref_filter + signature = str(attribute_path).strip() + if annotations and attribute.annotation: + annotation = template.render(context.parent, expression=attribute.annotation, signature=True) + signature += f": {annotation}" + if attribute.value: + value = template.render(context.parent, expression=attribute.value, signature=True) + signature += f" = {value}" signature = do_format_code(signature, line_length) signature = str( @@ -241,9 +233,10 @@ def do_format_attribute( ), ) - if stash: + if stash := env.filters["stash_crossref"].stash: for key, value in stash.items(): signature = re.sub(rf"\b{key}\b", value, signature) + stash.clear() return signature @@ -477,16 +470,7 @@ def do_get_template(env: Environment, obj: str | Object) -> str | Template: template = env.get_template(f"{name}.html") except TemplateNotFound: return f"{name}.html.jinja" - # TODO: Remove once support for Python 3.8 is dropped. - if sys.version_info < (3, 9): - try: - Path(template.filename).relative_to(Path(__file__).parent) # type: ignore[arg-type] - except ValueError: - our_template = False - else: - our_template = True - else: - our_template = Path(template.filename).is_relative_to(Path(__file__).parent) # type: ignore[arg-type] + our_template = Path(template.filename).is_relative_to(Path(__file__).parent) # type: ignore[arg-type] if our_template: return f"{name}.html.jinja" # TODO: Switch to a warning log after some time. @@ -501,9 +485,9 @@ def do_get_template(env: Environment, obj: str | Object) -> str | Template: @pass_context def do_as_attributes_section( context: Context, # noqa: ARG001 - attributes: Sequence[Attribute], # noqa: ARG001 + attributes: Sequence[Attribute], *, - check_public: bool = True, # noqa: ARG001 + check_public: bool = True, ) -> DocstringSectionAttributes: """Build an attributes section from a list of attributes. @@ -514,15 +498,26 @@ def do_as_attributes_section( Returns: An attributes docstring section. """ - return DocstringSectionAttributes([]) + return DocstringSectionAttributes( + [ + DocstringAttribute( + name=attribute.name, + description=attribute.docstring.value.split("\n", 1)[0] if attribute.docstring else "", + annotation=attribute.annotation, + value=attribute.value, # type: ignore[arg-type] + ) + for attribute in attributes + if not check_public or attribute.is_public + ], + ) @pass_context def do_as_functions_section( - context: Context, # noqa: ARG001 - functions: Sequence[Function], # noqa: ARG001 + context: Context, + functions: Sequence[Function], *, - check_public: bool = True, # noqa: ARG001 + check_public: bool = True, ) -> DocstringSectionFunctions: """Build a functions section from a list of functions. @@ -533,15 +528,25 @@ def do_as_functions_section( Returns: A functions docstring section. """ - return DocstringSectionFunctions([]) + keep_init_method = not context.parent["config"]["merge_init_into_class"] + return DocstringSectionFunctions( + [ + DocstringFunction( + name=function.name, + description=function.docstring.value.split("\n", 1)[0] if function.docstring else "", + ) + for function in functions + if (not check_public or function.is_public) and (function.name != "__init__" or keep_init_method) + ], + ) @pass_context def do_as_classes_section( context: Context, # noqa: ARG001 - classes: Sequence[Class], # noqa: ARG001 + classes: Sequence[Class], *, - check_public: bool = True, # noqa: ARG001 + check_public: bool = True, ) -> DocstringSectionClasses: """Build a classes section from a list of classes. @@ -552,15 +557,24 @@ def do_as_classes_section( Returns: A classes docstring section. """ - return DocstringSectionClasses([]) + return DocstringSectionClasses( + [ + DocstringClass( + name=cls.name, + description=cls.docstring.value.split("\n", 1)[0] if cls.docstring else "", + ) + for cls in classes + if not check_public or cls.is_public + ], + ) @pass_context def do_as_modules_section( context: Context, # noqa: ARG001 - modules: Sequence[Module], # noqa: ARG001 + modules: Sequence[Module], *, - check_public: bool = True, # noqa: ARG001 + check_public: bool = True, ) -> DocstringSectionModules: """Build a modules section from a list of modules. @@ -571,7 +585,16 @@ def do_as_modules_section( Returns: A modules docstring section. """ - return DocstringSectionModules([]) + return DocstringSectionModules( + [ + DocstringModule( + name=module.name, + description=module.docstring.value.split("\n", 1)[0] if module.docstring else "", + ) + for module in modules + if not check_public or module + ], + ) class AutorefsHook(AutorefsHookInterface): @@ -589,7 +612,9 @@ def __init__(self, current_object: Object | Alias, config: dict[str, Any]) -> No config: The configuration dictionary. """ self.current_object = current_object + """The current object being rendered.""" self.config = config + """The configuration options.""" def expand_identifier(self, identifier: str) -> str: """Expand an identifier. diff --git a/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja b/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja index fd13661b..24046e8f 100644 --- a/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja +++ b/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja @@ -142,6 +142,14 @@ Context: {% endif %} {% endblock docstring %} + {% block summary scoped %} + {#- Summary block. + + This block renders auto-summaries for classes, methods, and attributes. + -#} + {% include "summary"|get_template with context %} + {% endblock summary %} + {% block source scoped %} {#- Source block. diff --git a/src/mkdocstrings_handlers/python/templates/material/_base/docstring/parameters.html.jinja b/src/mkdocstrings_handlers/python/templates/material/_base/docstring/parameters.html.jinja index 8b0556f3..fef553b1 100644 --- a/src/mkdocstrings_handlers/python/templates/material/_base/docstring/parameters.html.jinja +++ b/src/mkdocstrings_handlers/python/templates/material/_base/docstring/parameters.html.jinja @@ -34,7 +34,21 @@ Context: {% for parameter in section.value %} - {{ parameter.name }} + + {% if config.parameter_headings %} + {% filter heading( + heading_level + 1, + role="param", + id=html_id ~ "(" ~ parameter.name ~ ")", + class="doc doc-heading doc-heading-parameter", + toc_label=(' '|safe if config.show_symbol_type_toc else '') + parameter.name, + ) %} + {{ parameter.name }} + {% endfilter %} + {% else %} + {{ parameter.name }} + {% endif %} + {% if parameter.annotation %} {% with expression = parameter.annotation %} @@ -68,7 +82,19 @@ Context: