diff --git a/.copier-answers.yml b/.copier-answers.yml index 0e8a2418..47271588 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: 1.1.0 +_commit: 1.4.0 _src_path: gh:pawamoy/copier-uv author_email: dev@pawamoy.fr author_fullname: Timothée Mazzucotelli diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/1-bug.md similarity index 98% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/1-bug.md index 6ed84b16..e775cc1f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/1-bug.md @@ -53,7 +53,7 @@ PASTE TRACEBACK HERE python -m mkdocstrings.debug # | xclip -selection clipboard ``` -PASTE OUTPUT HERE +PASTE MARKDOWN OUTPUT HERE ### Additional context + +### Relevant code snippets + + +### Link to the relevant documentation section + diff --git a/.github/ISSUE_TEMPLATE/4-change.md b/.github/ISSUE_TEMPLATE/4-change.md new file mode 100644 index 00000000..dc9a8f17 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-change.md @@ -0,0 +1,18 @@ +--- +name: Change request +about: Suggest any other kind of change for this project. +title: "change: " +assignees: pawamoy +--- + +### Is your change request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3248b1a9..8469f091 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,6 @@ jobs: - name: Check if the code is correctly typed run: make check-types - - name: Check for vulnerabilities in dependencies - run: make check-dependencies - - name: Check for breaking changes in the API run: make check-api @@ -70,10 +67,14 @@ jobs: {"python-version": "3.9"}, {"python-version": "3.10"}, {"python-version": "3.11"}, - {"python-version": "3.12"} + {"python-version": "3.12"}, + {"python-version": "3.13"} ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT else - echo 'jobs=[]' >> $GITHUB_OUTPUT + echo 'jobs=[ + {"os": "macos-latest", "resolution": "lowest-direct"}, + {"os": "windows-latest", "resolution": "lowest-direct"} + ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT fi tests: @@ -91,9 +92,13 @@ jobs: - "3.10" - "3.11" - "3.12" + - "3.13" + resolution: + - highest + - lowest-direct exclude: ${{ fromJSON(needs.exclude-test-jobs.outputs.jobs) }} runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.python-version == '3.12' }} + continue-on-error: ${{ matrix.python-version == '3.13' }} steps: - name: Checkout @@ -109,6 +114,8 @@ jobs: run: pip install uv - name: Install dependencies + env: + UV_RESOLUTION: ${{ matrix.resolution }} run: make setup - name: Run the test suite diff --git a/CHANGELOG.md b/CHANGELOG.md index 105df260..c092ed42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f86ff10..b04a64fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,13 +36,11 @@ Run `make help` to see all the available actions! ## Tasks -This project uses [duty](https://github.com/pawamoy/duty) to run tasks. -A Makefile is also provided. The Makefile will try to run certain tasks -on multiple Python versions. If for some reason you don't want to run the task -on multiple Python versions, you run the task directly with `make run duty TASK`. - -The Makefile detects if a virtual environment is activated, -so `make` will work the same with the virtualenv activated or not. +The entry-point to run commands and tasks is the `make` Python script, +located in the `scripts` directory. Try running `make` to show the available commands and tasks. +The *commands* do not need the Python dependencies to be installed, +while the *tasks* do. +The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). If you work in VSCode, we provide [an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) diff --git a/Makefile b/Makefile index 771b333c..5e88121d 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,10 @@ # This Makefile is just here to allow auto-completion in the terminal. actions = \ + allrun \ changelog \ check \ check-api \ - check-dependencies \ check-docs \ check-quality \ check-types \ @@ -16,6 +16,7 @@ actions = \ docs-deploy \ format \ help \ + multirun \ release \ run \ setup \ @@ -24,4 +25,4 @@ actions = \ .PHONY: $(actions) $(actions): - @bash scripts/make "$@" + @python scripts/make "$@" diff --git a/README.md b/README.md index 10b3b8ff..80822c52 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # mkdocstrings [](https://github.com/mkdocstrings/mkdocstrings/actions?query=workflow%3Aci) -[](https://mkdocstrings.github.io/) +[](https://mkdocstrings.github.io/) [](https://pypi.org/project/mkdocstrings/) [](https://anaconda.org/conda-forge/mkdocstrings) -[](https://gitpod.io/#https://github.com/mkdocstrings/mkdocstrings) +[](https://gitpod.io/#https://github.com/mkdocstrings/mkdocstrings) [](https://app.gitter.im/#/room/#mkdocstrings:gitter.im) Automatic documentation from sources, for [MkDocs](https://www.mkdocs.org/). diff --git a/config/pytest.ini b/config/pytest.ini index 5b5bd2e7..1a0d99c6 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -1,8 +1,6 @@ [pytest] python_files = test_*.py - *_test.py - tests.py addopts = --cov --cov-config config/coverage.ini @@ -14,7 +12,8 @@ filterwarnings = error # TODO: remove once pytest-xdist 4 is released ignore:.*rsyncdir:DeprecationWarning:xdist - # TODO: https://github.com/Python-Markdown/markdown/issues/1355 - ignore:.*Testing:DeprecationWarning:markdown - # TODO: https://github.com/facelessuser/pymdown-extensions/issues/2113 - ignore:.*Testing:DeprecationWarning:pymdownx + # TODO: remove once griffe and mkdocstrings-python release new versions + ignore:.*`get_logger`:DeprecationWarning:_griffe + ignore:.*`name`:DeprecationWarning:_griffe + ignore:.*Importing from `griffe:DeprecationWarning:mkdocstrings_handlers + ignore:.*`patch_loggers`:DeprecationWarning:_griffe diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json index 30008cf2..73145eec 100644 --- a/config/vscode/tasks.json +++ b/config/vscode/tasks.json @@ -31,12 +31,6 @@ "command": "scripts/make", "args": ["check-docs"] }, - { - "label": "check-dependencies", - "type": "process", - "command": "scripts/make", - "args": ["check-dependencies"] - }, { "label": "check-api", "type": "process", diff --git a/devdeps.txt b/devdeps.txt index 58700f78..2a987e77 100644 --- a/devdeps.txt +++ b/devdeps.txt @@ -1,28 +1,33 @@ -build>=1.0 -duty>=0.10 -black>=23.9 -markdown-callouts>=0.3 -markdown-exec>=1.7 -mkdocs>=1.5 +# 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>=1.2 +mkdocs-git-committers-plugin-2>=2.3 mkdocs-literate-nav>=0.6 -mkdocs-material>=9.4 -mkdocs-minify-plugin>=0.7 +mkdocs-material>=9.5 +mkdocs-minify-plugin>=0.8 mkdocs-redirects>=1.2 -mkdocstrings[python]>=0.23 +mkdocstrings[python]>=0.25 tomli>=2.0; python_version < '3.11' -black>=23.9 -blacken-docs>=1.16 -git-changelog>=2.3 -ruff>=0.0 -pytest>=7.4 -pytest-cov>=4.1 -pytest-randomly>=3.15 -pytest-xdist>=3.3 -mypy>=1.5 -types-markdown>=3.5 -types-pyyaml>=6.0 -safety>=2.3 -twine>=5.0 diff --git a/docs/.overrides/main.html b/docs/.overrides/main.html index cf8adeb7..1e956857 100644 --- a/docs/.overrides/main.html +++ b/docs/.overrides/main.html @@ -2,17 +2,19 @@ {% block announce %} - Sponsorship - is now available! + Fund this project through + sponsorship {% include ".icons/octicons/heart-fill-16.svg" %} — - For updates follow @pawamoy on + 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/css/mkdocstrings.css b/docs/css/mkdocstrings.css index 9e17377f..05f1088b 100644 --- a/docs/css/mkdocstrings.css +++ b/docs/css/mkdocstrings.css @@ -18,7 +18,7 @@ a.autorefs-external::after { height: 1em; width: 1em; - background-color: var(--md-typeset-a-color); + background-color: currentColor; } a.external:hover::after, 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/index.md b/docs/insiders/index.md index d88e8e7c..f164974c 100644 --- a/docs/insiders/index.md +++ b/docs/insiders/index.md @@ -102,6 +102,10 @@ with your GitHub account, visit [pawamoy's sponsor profile][github sponsor profi 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 diff --git a/docs/insiders/installation.md b/docs/insiders/installation.md index b7af7d2e..5671f0da 100644 --- a/docs/insiders/installation.md +++ b/docs/insiders/installation.md @@ -23,6 +23,9 @@ of Insiders projects in the PyPI index of your choice 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]: @@ -58,130 +61,15 @@ pip install git+https://${GH_TOKEN}@github.com/pawamoy-insiders/mkdocstrings.git > token must be kept secret at all times, as it allows the owner to access your > private repositories. -### with pip (self-hosted) - -Self-hosting the Insiders package makes it possible to depend on *mkdocstrings* normally, -while transparently downloading and installing the Insiders version locally. -It means that you can specify your dependencies normally, and your contributors without access -to Insiders will get the public version, while you get the Insiders version on your machine. - -WARNING: **Limitation** -With this method, there is no way to force the installation of an Insiders version -rather than a public version. If there is a public version that is more recent -than your self-hosted Insiders version, the public version will take precedence. -Remember to regularly update your self-hosted versions by uploading latest distributions. - -You can build the distributions for Insiders yourself, by cloning the repository -and using [build] to build the distributions, -or you can download them from our [GitHub Releases]. -You can upload these distributions to a private PyPI-like registry -([Artifactory], [Google Cloud], [pypiserver], etc.) -with [Twine]: - - [build]: https://pypi.org/project/build/ - [Artifactory]: https://jfrog.com/help/r/jfrog-artifactory-documentation/pypi-repositories - [Google Cloud]: https://cloud.google.com/artifact-registry/docs/python - [pypiserver]: https://pypi.org/project/pypiserver/ - [Github Releases]: https://github.com/pawamoy-insiders/mkdocstrings/releases - [Twine]: https://pypi.org/project/twine/ - -```bash -# download distributions in ~/dists, then upload with: -twine upload --repository-url https://your-private-index.com ~/dists/* -``` - -You might also need to provide a username and password/token to authenticate against the registry. -Please check [Twine's documentation][twine docs]. - - [twine docs]: https://twine.readthedocs.io/en/stable/ - -You can then configure pip (or other tools) to look for packages into your package index. -For example, with pip: - -```bash -pip config set global.extra-index-url https://your-private-index.com/simple -``` - -Note that the URL might differ depending on whether your are uploading a package (with Twine) -or installing a package (with pip), and depending on the registry you are using (Artifactory, Google Cloud, etc.). -Please check the documentation of your registry to learn how to configure your environment. - -**We kindly ask that you do not upload the distributions to public registries, -as it is against our [Terms of use](index.md#terms).** +### with Git ->? TIP: **Full example with `pypiserver`** -> In this example we use [pypiserver] to serve a local PyPI index. -> -> ```bash -> pip install --user pypiserver -> # or pipx install pypiserver -> -> # create a packages directory -> mkdir -p ~/.local/pypiserver/packages -> -> # run the pypi server without authentication -> pypi-server run -p 8080 -a . -P . ~/.local/pypiserver/packages & -> ``` -> -> We can configure the credentials to access the server in [`~/.pypirc`][pypirc]: -> -> [pypirc]: https://packaging.python.org/en/latest/specifications/pypirc/ -> -> ```ini title=".pypirc" -> [distutils] -> index-servers = -> local -> -> [local] -> repository: http://localhost:8080 -> username: -> password: -> ``` -> -> We then clone the Insiders repository, build distributions and upload them to our local server: -> -> ```bash -> # clone the repository -> git clone git@github.com:pawamoy-insiders/mkdocstrings -> cd mkdocstrings -> -> # install build -> pip install --user build -> # or pipx install build -> -> # checkout latest tag -> git checkout $(git describe --tags --abbrev=0) -> -> # build the distributions -> pyproject-build -> -> # upload them to our local server -> twine upload -r local dist/* --skip-existing -> ``` -> -> Finally, we configure pip, and for example [PDM][pdm], to use our local index to find packages: -> -> ```bash -> pip config set global.extra-index-url http://localhost:8080/simple -> pdm config pypi.extra.url http://localhost:8080/simple -> ``` -> -> [pdm]: https://pdm.fming.dev/latest/ -> -> Now when running `pip install mkdocstrings`, -> or resolving dependencies with PDM, -> both tools will look into our local index and find the Insiders version. -> **Remember to update your local index regularly!** - -### with git - -Of course, you can use *mkdocstrings Insiders* directly from `git`: +Of course, you can use *mkdocstrings Insiders* directly using Git: ``` git clone git@github.com:pawamoy-insiders/mkdocstrings ``` -When cloning from `git`, the package must be installed: +When cloning with Git, the package must be installed: ``` pip install -e mkdocstrings 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/license.md b/docs/license.md index a873d2b5..e81c0edf 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,8 @@ +--- +hide: +- feedback +--- + # License ``` diff --git a/duties.py b/duties.py index 30bf7c63..655cfdfc 100644 --- a/duties.py +++ b/duties.py @@ -9,8 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Iterator -from duty import duty -from duty.callables import coverage, lazy, mkdocs, mypy, pytest, ruff, safety +from duty import duty, tools if TYPE_CHECKING: from duty.context import Context @@ -45,143 +44,72 @@ def material_insiders() -> Iterator[bool]: # noqa: D103 @duty -def changelog(ctx: Context) -> None: +def changelog(ctx: Context, bump: str = "") -> None: """Update the changelog in-place with latest commits. Parameters: - ctx: The context instance (passed automatically). + bump: Bump option passed to git-changelog. """ - from git_changelog.cli import main as git_changelog - - ctx.run(git_changelog, args=[[]], title="Updating changelog") + ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") @duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) def check(ctx: Context) -> None: # noqa: ARG001 - """Check it all! - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check it all!""" @duty def check_quality(ctx: Context) -> None: - """Check the code quality. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check the code quality.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), title=pyprefix("Checking code quality"), - command=f"ruff check --config config/ruff.toml {PY_SRC}", - ) - - -@duty -def check_dependencies(ctx: Context) -> None: - """Check for vulnerabilities in dependencies. - - Parameters: - ctx: The context instance (passed automatically). - """ - # retrieve the list of dependencies - requirements = ctx.run( - ["uv", "pip", "freeze"], - silent=True, - allow_overrides=False, - ) - - ctx.run( - safety.check(requirements), - title="Checking dependencies", - command="uv pip freeze | safety check --stdin", ) @duty def check_docs(ctx: Context) -> None: - """Check if the documentation builds correctly. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check if the documentation builds correctly.""" Path("htmlcov").mkdir(parents=True, exist_ok=True) Path("htmlcov/index.html").touch(exist_ok=True) with material_insiders(): ctx.run( - mkdocs.build(strict=True, verbose=True), + tools.mkdocs.build(strict=True, verbose=True), title=pyprefix("Building documentation"), - command="mkdocs build -vs", ) @duty def check_types(ctx: Context) -> None: - """Check that the code is correctly typed. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check that the code is correctly typed.""" os.environ["MYPYPATH"] = "src" ctx.run( - mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"), + tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), title=pyprefix("Type-checking"), - command=f"mypy --config-file config/mypy.ini {PY_SRC}", ) @duty -def check_api(ctx: Context) -> None: - """Check for API breaking changes. - - Parameters: - ctx: The context instance (passed automatically). - """ - from griffe.cli import check as g_check - - griffe_check = lazy(g_check, name="griffe.check") +def check_api(ctx: Context, *cli_args: str) -> None: + """Check for API breaking changes.""" ctx.run( - griffe_check("mkdocstrings", search_paths=["src"], color=True), + tools.griffe.check("mkdocstrings", search=["src"], color=True).add_args(*cli_args), title="Checking for API breaking changes", - command="griffe check -ssrc mkdocstrings", nofail=True, ) -@duty(silent=True) -def clean(ctx: Context) -> None: - """Delete temporary files. - - Parameters: - ctx: The context instance (passed automatically). - """ - - def _rm(*targets: str) -> None: - for target in targets: - ctx.run(f"rm -rf {target}") - - def _find_rm(*targets: str) -> None: - for target in targets: - ctx.run(f"find . -type d -name '{target}' | xargs rm -rf") - - _rm("build", "dist", ".coverage*", "htmlcov", "site", ".pdm-build") - _find_rm(".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__") - - @duty -def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: +def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: """Serve the documentation (localhost:8000). Parameters: - ctx: The context instance (passed automatically). host: The host to serve the docs from. port: The port to serve the docs on. """ with material_insiders(): ctx.run( - mkdocs.serve(dev_addr=f"{host}:{port}"), + tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), title="Serving documentation", capture=False, ) @@ -189,11 +117,7 @@ def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: @duty def docs_deploy(ctx: Context) -> None: - """Deploy the documentation on GitHub pages. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Deploy the documentation to GitHub pages.""" os.environ["DEPLOY"] = "true" with material_insiders() as insiders: if not insiders: @@ -206,7 +130,7 @@ def docs_deploy(ctx: Context) -> None: nofail=True, ) ctx.run( - mkdocs.gh_deploy(remote_name="upstream", force=True), + tools.mkdocs.gh_deploy(remote_name="upstream", force=True), title="Deploying documentation", ) else: @@ -219,24 +143,42 @@ def docs_deploy(ctx: Context) -> None: @duty def format(ctx: Context) -> None: - """Run formatting tools on the code. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Run formatting tools on the code.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), title="Auto-fixing code", ) - ctx.run(ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") + ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") + + +@duty +def build(ctx: Context) -> None: + """Build source and wheel distributions.""" + ctx.run( + tools.build(), + title="Building source and wheel distributions", + pty=PTY, + ) + + +@duty +def publish(ctx: Context) -> None: + """Publish source and wheel distributions to PyPI.""" + if not Path("dist").exists(): + ctx.run("false", title="No distribution files found") + dists = [str(dist) for dist in Path("dist").iterdir()] + ctx.run( + tools.twine.upload(*dists, skip_existing=True), + title="Publishing source and wheel distributions to PyPI", + pty=PTY, + ) -@duty(post=["docs-deploy"]) -def release(ctx: Context, version: str) -> None: +@duty(post=["build", "publish", "docs-deploy"]) +def release(ctx: Context, version: str = "") -> None: """Release a new Python package. Parameters: - ctx: The context instance (passed automatically). version: The new version number to use. """ origin = ctx.run("git config --get remote.origin.url", silent=True) @@ -245,64 +187,38 @@ def release(ctx: Context, version: str) -> None: lambda: False, title="Not releasing from insiders repository (do that from public repo instead!)", ) + if not (version := (version or input("> Version to release: ")).strip()): + ctx.run("false", title="A version must be provided") ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) ctx.run("git push", title="Pushing commits", pty=False) ctx.run("git push --tags", title="Pushing tags", pty=False) - ctx.run("pyproject-build", title="Building dist/wheel", pty=PTY) - ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY) - -@duty(silent=True, aliases=["coverage"]) -def cov(ctx: Context) -> None: - """Report coverage as text and HTML. - Parameters: - ctx: The context instance (passed automatically). - """ - ctx.run(coverage.combine, nofail=True) - ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False) - ctx.run(coverage.html(rcfile="config/coverage.ini")) +@duty(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: Context, match: str = "") -> None: +def test(ctx: Context, *cli_args: str, match: str = "") -> None: """Run the test suite. Parameters: - ctx: The context instance (passed automatically). match: A pytest expression to filter selected tests. """ py_version = f"{sys.version_info.major}{sys.version_info.minor}" os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" ctx.run( - pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"), + tools.pytest( + "tests", + config_file="config/pytest.ini", + select=match, + color="yes", + ).add_args("-n", "auto", *cli_args), title=pyprefix("Running tests"), - command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests", ) - - -@duty -def vscode(ctx: Context) -> None: - """Configure VSCode. - - This task will overwrite the following files, - so make sure to back them up: - - - `.vscode/launch.json` - - `.vscode/settings.json` - - `.vscode/tasks.json` - - Parameters: - ctx: The context instance (passed automatically). - """ - - def update_config(filename: str) -> None: - source_file = Path("config", "vscode", filename) - target_file = Path(".vscode", filename) - target_file.parent.mkdir(exist_ok=True) - target_file.write_text(source_file.read_text()) - - for filename in ("launch.json", "settings.json", "tasks.json"): - ctx.run(update_config, args=[filename], title=f"Update .vscode/{filename}") diff --git a/mkdocs.yml b/mkdocs.yml index 30afc977..3522d7db 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -92,6 +92,9 @@ extra_css: - css/mkdocstrings.css - css/insiders.css +extra_javascript: +- js/feedback.js + markdown_extensions: - attr_list - admonition @@ -183,3 +186,15 @@ extra: 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 b35e8dc7..e7c02d17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Documentation", "Topic :: Software Development", "Topic :: Software Development :: Documentation", @@ -65,3 +66,21 @@ version = {source = "scm"} [tool.pdm.build] package-dir = "src" editable-backend = "editables" +excludes = ["**/.pytest_cache"] +source-includes = [ + "config", + "docs", + "scripts", + "share", + "tests", + "devdeps.txt", + "duties.py", + "mkdocs.yml", + "*.md", + "LICENSE", +] + +[tool.pdm.build.wheel-data] +data = [ + {path = "share/**/*", relative-to = "."}, +] diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py index 27f94d67..eaa1c7f4 100644 --- a/scripts/gen_credits.py +++ b/scripts/gen_credits.py @@ -27,7 +27,7 @@ project = pyproject["project"] project_name = project["name"] with project_dir.joinpath("devdeps.txt").open() as devdeps_file: - devdeps = [line.strip() for line in devdeps_file if not line.startswith("-e")] + devdeps = [line.strip() for line in devdeps_file if line.strip() and not line.strip().startswith(("-e", "#"))] PackageMetadata = Dict[str, Union[str, Iterable[str]]] Metadata = Dict[str, PackageMetadata] @@ -88,7 +88,7 @@ def _set_license(metadata: PackageMetadata) -> None: def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: deps = {} for dep_name, dep_req in base_deps.items(): - if dep_name not in metadata: + if dep_name not in metadata or dep_name == "mkdocstrings": continue metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] @@ -131,8 +131,8 @@ def _render_credits() -> str: template_data = { "project_name": project_name, - "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"])), - "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"])), + "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), + "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), "more_credits": "http://pawamoy.github.io/credits/", } template_text = dedent( diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py index 9565765e..676981b6 100644 --- a/scripts/gen_ref_nav.py +++ b/scripts/gen_ref_nav.py @@ -29,7 +29,7 @@ with mkdocs_gen_files.open(full_doc_path, "w") as fd: ident = ".".join(parts) - fd.write(f"::: {ident}") + fd.write(f"---\ntitle: {ident}\n---\n\n::: {ident}") mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path.relative_to(root)) diff --git a/scripts/make b/scripts/make index f690126e..d898022e 100755 --- a/scripts/make +++ b/scripts/make @@ -1,159 +1,210 @@ -#!/usr/bin/env bash - -set -e -export PYTHON_VERSIONS=${PYTHON_VERSIONS-3.8 3.9 3.10 3.11 3.12} - -exe="" -prefix="" - - -# Install runtime and development dependencies, -# as well as current project in editable mode. -uv_install() { - uv pip compile pyproject.toml devdeps.txt | uv pip install -r - - if [ -z "${CI}" ]; then - uv pip install -e . - else - uv pip install "mkdocstrings @ ." - fi -} - - -# Setup the development environment by installing dependencies -# in multiple Python virtual environments with uv: -# one venv per Python version in `.venvs/$py`, -# and an additional default venv in `.venv`. -setup() { - if ! command -v uv &>/dev/null; then - echo "make: setup: uv must be installed, see https://github.com/astral-sh/uv" >&2 - return 1 - fi - - if [ -n "${PYTHON_VERSIONS}" ]; then - for version in ${PYTHON_VERSIONS}; do - if [ ! -d ".venvs/${version}" ]; then - uv venv --python "${version}" ".venvs/${version}" - fi - VIRTUAL_ENV="${PWD}/.venvs/${version}" uv_install - done - fi - - if [ ! -d .venv ]; then uv venv --python python; fi - uv_install -} - - -# Activate a Python virtual environments. -# The annoying operating system also requires -# that we set some global variables to help it find commands... -activate() { - local path - if [ -f "$1/bin/activate" ]; then - source "$1/bin/activate" +#!/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 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 = "" + + +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() -> 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 .") + + +def setup() -> None: + """Setup the project.""" + if not shutil.which("uv"): + raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") + + print("Installing dependencies (default environment)") # noqa: T201 + default_venv = Path(".venv") + if not default_venv.exists(): + shell("uv venv --python python") + uv_install() + + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + print(f"\nInstalling dependencies (python{version})") # noqa: T201 + venv_path = Path(f".venvs/{version}") + if not venv_path.exists(): + shell(f"uv venv --python {version} {venv_path}") + with environ(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 + + +def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in a virtual environment.""" + kwargs = {"check": True, **kwargs} + if version == "default": + activate(".venv") + subprocess.run([f"{prefix}{cmd}{exe}", *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 + + +def multirun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command for all configured Python versions.""" + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + run(version, cmd, *args, **kwargs) + else: + run("default", cmd, *args, **kwargs) + + +def allrun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in all virtual environments.""" + run("default", cmd, *args, **kwargs) + if PYTHON_VERSIONS: + multirun(cmd, *args, **kwargs) + + +def clean() -> None: + """Delete build artifacts and cache files.""" + paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] + for path in paths_to_clean: + shell(f"rm -rf {path}") + + cache_dirs = [".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"] + for dirpath in Path(".").rglob("*"): + if any(dirpath.match(pattern) for pattern in cache_dirs) and not (dirpath.match(".venv") or dirpath.match(".venvs")): + shutil.rmtree(path, ignore_errors=True) + + +def vscode() -> None: + """Configure VSCode to work on this project.""" + Path(".vscode").mkdir(parents=True, exist_ok=True) + shell("cp -v config/vscode/* .vscode") + + +def main() -> int: + """Main entry point.""" + args = list(sys.argv[1:]) + if not args or args[0] == "help": + if len(args) > 1: + run("default", "duty", "--help", args[1]) + else: + print("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") return 0 - fi - if [ -f "$1/Scripts/activate.bat" ]; then - "$1/Scripts/activate.bat" - exe=".exe" - prefix="$1/Scripts/" - return 0 - fi - echo "run: Cannot activate venv $1" >&2 - return 1 -} - - -# Run a command in all configured Python virtual environments. -# We handle the case when the `PYTHON_VERSIONS` environment variable -# is unset or empty, for robustness. -multirun() { - local cmd="$1" - shift - - if [ -n "${PYTHON_VERSIONS}" ]; then - for version in ${PYTHON_VERSIONS}; do - (activate ".venvs/${version}" && MULTIRUN=1 "${prefix}${cmd}${exe}" "$@") - done - else - (activate .venv && "${prefix}${cmd}${exe}" "$@") - fi -} - - -# Run a command in the default Python virtual environment. -# We rely on `multirun`'s handling of empty `PYTHON_VERSIONS`. -singlerun() { - PYTHON_VERSIONS= multirun "$@" -} - - -# Record options following a command name, -# until a non-option argument is met or there are no more arguments. -# Output each option on a new line, so the parent caller can store them in an array. -# Return the number of times the parent caller must shift arguments. -options() { - local shift_count=0 - for arg in "$@"; do - if [[ "${arg}" =~ ^- || "${arg}" =~ ^.+= ]]; then - echo "${arg}" - ((shift_count++)) - else - break - fi - done - return ${shift_count} -} - - -# Main function. -main() { - local cmd - while [ $# -ne 0 ]; do - cmd="$1" - shift - - # Handle `run` early to simplify `case` below. - if [ "${cmd}" = "run" ]; then - singlerun "$@" - exit $? - fi - - # Handle `multirun` early to simplify `case` below. - if [ "${cmd}" = "multirun" ]; then - multirun "$@" - exit $? - fi - - # All commands except `run` and `multirun` can be chained on a single line. - # Some of them accept options in two formats: `-f`, `--flag` and `param=value`. - # Some of them don't, and will print warnings/errors if options were given. - opts=($(options "$@")) || shift $? - - case "${cmd}" in - # The following commands require special handling. - help|"") - singlerun duty --list ;; - setup) - setup ;; - check) - multirun duty check-quality check-types check-docs - singlerun duty check-dependencies check-api - ;; - - # The following commands run in all venvs. - check-quality|\ - check-docs|\ - check-types|\ - test) - multirun duty "${cmd}" "${opts[@]}" ;; - - # The following commands run in the default venv only. - *) - singlerun duty "${cmd}" "${opts[@]}" ;; - esac - done -} - - -# Execute the main function. -main "$@" + + while args: + cmd = args.pop(0) + + if cmd == "run": + run("default", *args) + return 0 + + if cmd == "multirun": + multirun(*args) + return 0 + + if cmd == "allrun": + allrun(*args) + return 0 + + if cmd.startswith("3."): + run(cmd, *args) + return 0 + + opts = [] + while args and (args[0].startswith("-") or "=" in args[0]): + opts.append(args.pop(0)) + + if cmd == "clean": + clean() + elif cmd == "setup": + setup() + elif cmd == "vscode": + vscode() + elif cmd == "check": + multirun("duty", "check-quality", "check-types", "check-docs") + run("default", "duty", "check-api") + elif cmd in {"check-quality", "check-docs", "check-types", "test"}: + multirun("duty", cmd, *opts) + else: + run("default", "duty", cmd, *opts) + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except subprocess.CalledProcessError as process: + if process.output: + print(process.output, file=sys.stderr) # noqa: T201 + sys.exit(process.returncode) diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py index 9ecffebc..19326720 100644 --- a/src/mkdocstrings/extension.py +++ b/src/mkdocstrings/extension.py @@ -129,9 +129,14 @@ def run(self, parent: Element, blocks: MutableSequence[str]) -> None: el = Element("div", {"class": "mkdocstrings"}) # The final HTML is inserted as opaque to subsequent processing, and only revealed at the end. el.text = self.md.htmlStash.store(html) - # So we need to duplicate the headings directly (and delete later), just so 'toc' can pick them up. + # We need to duplicate the headings directly, just so 'toc' can pick them up, + # otherwise they wouldn't appear in the final table of contents. + # These headings are generated by the `BaseHandler.do_heading` method (Jinja filter), + # which runs in the inner Markdown conversion layer, and not in the outer one where we are now. headings = handler.get_headings() el.extend(headings) + # These duplicated headings will later be removed by our `_HeadingsPostProcessor` processor, + # which runs right after 'toc' (see `MkdocstringsExtension.extendMarkdown`). page = self._autorefs.current_page if page is not None: @@ -203,15 +208,14 @@ def _process_block( options = ChainMap(local_options, global_options) if heading_level: - options = ChainMap(options, {"heading_level": heading_level}) # like setdefault + # Heading level obtained from Markdown (`##`) takes precedence. + options = ChainMap({"heading_level": heading_level}, options) log.debug("Collecting data") try: data: CollectorItem = handler.collect(identifier, options) except CollectionError as exception: log.error(str(exception)) # noqa: TRY400 - if PluginError is SystemExit: # TODO: when MkDocs 1.2 is sufficiently common, this can be dropped. - log.error(f"Error reading page '{self._autorefs.current_page}':") # noqa: TRY400 raise PluginError(f"Could not collect '{identifier}'") from exception if handler_name not in self._updated_envs: # We haven't seen this handler before on this document. diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py index 27c22db1..d86e9df1 100644 --- a/src/mkdocstrings/handlers/base.py +++ b/src/mkdocstrings/handlers/base.py @@ -235,7 +235,7 @@ def get_extended_templates_dirs(self, handler: str) -> list[Path]: discovered_extensions = entry_points(group=f"mkdocstrings.{handler}.templates") return [extension.load()() for extension in discovered_extensions] - def get_anchors(self, data: CollectorItem) -> tuple[str, ...]: + def get_anchors(self, data: CollectorItem) -> tuple[str, ...]: # noqa: ARG002 """Return the possible identifiers (HTML anchors) for a collected item. Arguments: @@ -244,11 +244,7 @@ def get_anchors(self, data: CollectorItem) -> tuple[str, ...]: Returns: The HTML anchors (without '#'), or an empty tuple if this item doesn't have an anchor. """ - # TODO: remove this when https://github.com/mkdocstrings/crystal/pull/6 is merged and released - try: - return (self.get_anchor(data),) # type: ignore[attr-defined] - except AttributeError: - return () + return () def do_convert_markdown( self, @@ -304,7 +300,16 @@ 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(content, Markup) else content @@ -320,7 +325,7 @@ 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. + # 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"])