diff --git a/.copier-answers.yml b/.copier-answers.yml index e3b6e940..62b9e3c8 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: 0.11.3 +_commit: 0.15.7 _src_path: gh:pawamoy/copier-pdm.git author_email: pawamoy@pm.me author_fullname: Timothée Mazzucotelli @@ -8,13 +8,13 @@ copyright_date: '2019' copyright_holder: Timothée Mazzucotelli copyright_holder_email: pawamoy@pm.me copyright_license: ISC License +insiders: true project_description: Automatic documentation from sources, for MkDocs. project_name: mkdocstrings -python_package_command_line_name: mkdocstrings +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/.github/FUNDING.yml b/.github/FUNDING.yml index cf5764f4..01e293ac 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ -github: -- pawamoy +github: pawamoy +ko_fi: pawamoy custom: - https://www.paypal.me/pawamoy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef882e3c..2b5ed158 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,10 +32,10 @@ jobs: python-version: "3.8" - name: Resolving dependencies - run: pdm lock -v + run: pdm lock -v --no-cross-platform -G ci-quality - name: Install dependencies - run: pdm install -G duty -G docs -G quality -G typing -G security + run: pdm install -G ci-quality - name: Check if the documentation builds correctly run: pdm run duty check-docs @@ -49,9 +49,13 @@ jobs: - name: Check for vulnerabilities in dependencies run: pdm run duty check-dependencies + - name: Check for breaking changes in the API + run: pdm run duty check-api + tests: strategy: + max-parallel: 4 matrix: os: - ubuntu-latest @@ -76,10 +80,10 @@ jobs: python-version: ${{ matrix.python-version }} - name: Resolving dependencies - run: pdm lock -v + run: pdm lock -v --no-cross-platform -G ci-tests - name: Install dependencies - run: pdm install --no-editable -G duty -G tests -G docs + run: pdm install --no-editable -G ci-tests - name: Run the test suite run: pdm run duty test diff --git a/.github/workflows/dists.yml b/.github/workflows/dists.yml new file mode 100644 index 00000000..41833b63 --- /dev/null +++ b/.github/workflows/dists.yml @@ -0,0 +1,30 @@ +name: dists + +on: push +permissions: + contents: write + +jobs: + build: + name: Build dists + runs-on: ubuntu-latest + if: github.repository_owner == 'pawamoy-insiders' + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v3 + - name: Install build + run: python -m pip install build + - name: Build dists + run: python -m build + - name: Upload dists artifact + uses: actions/upload-artifact@v3 + with: + name: mkdocstrings-insiders + path: ./dist/* + - name: Create release and upload assets + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: ./dist/* diff --git a/.gitignore b/.gitignore index f6a13b06..97dc958b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,10 @@ pip-wheel-metadata/ .python-version site/ pdm.lock -.pdm.toml +pdm.toml +.pdm-plugins/ +.pdm-python __pypackages__/ .mypy_cache/ .venv/ +.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea7668c..bbeb8ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9f8e89f..4ecc0fa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ 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) diff --git a/Makefile b/Makefile index b034ffff..b71d86ce 100644 --- a/Makefile +++ b/Makefile @@ -5,19 +5,18 @@ 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 +docs_args = host port release_args = version test_args = match BASIC_DUTIES = \ changelog \ + check-api \ check-dependencies \ clean \ coverage \ docs \ docs-deploy \ - docs-regen \ - docs-serve \ format \ release @@ -33,7 +32,7 @@ help: .PHONY: lock lock: - @pdm lock + @pdm lock --dev .PHONY: setup setup: @@ -42,7 +41,7 @@ setup: .PHONY: check check: @pdm multirun duty check-quality check-types check-docs - @$(DUTY) check-dependencies + @$(DUTY) check-dependencies check-api .PHONY: $(BASIC_DUTIES) $(BASIC_DUTIES): diff --git a/config/coverage.ini b/config/coverage.ini index fde9d55a..1bcf0935 100644 --- a/config/coverage.ini +++ b/config/coverage.ini @@ -17,6 +17,7 @@ omit = src/*/__init__.py src/*/__main__.py tests/__init__.py + tests/fixtures/*.py exclude_lines = pragma: no cover if TYPE_CHECKING diff --git a/config/ruff.toml b/config/ruff.toml index 62f45f64..53824875 100644 --- a/config/ruff.toml +++ b/config/ruff.toml @@ -63,6 +63,7 @@ ignore = [ "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 "INP001", # File is part of an implicit namespace package "PLR0911", # Too many return statements diff --git a/docs/.overrides/main.html b/docs/.overrides/main.html new file mode 100644 index 00000000..cf8adeb7 --- /dev/null +++ b/docs/.overrides/main.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block announce %} + + Sponsorship + is now available! + + {% include ".icons/octicons/heart-fill-16.svg" %} + — + + For updates follow @pawamoy on + + + {% include ".icons/fontawesome/brands/mastodon.svg" %} + + Fosstodon + +{% endblock %} diff --git a/docs/css/insiders.css b/docs/css/insiders.css new file mode 100644 index 00000000..81dbd756 --- /dev/null +++ b/docs/css/insiders.css @@ -0,0 +1,98 @@ +@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 { + float: left; + border-radius: 100%; + display: block; + height: 1.6rem; + margin: .2rem; + 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; +} \ No newline at end of file diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css index f269d975..af758331 100644 --- a/docs/css/mkdocstrings.css +++ b/docs/css/mkdocstrings.css @@ -4,7 +4,30 @@ div.doc-contents:not(.first) { border-left: .05rem solid var(--md-typeset-table-color); } +/* Mark external links as such. */ +a.autorefs-external::after { + /* https://primer.style/octicons/arrow-up-right-24 */ + background-image: url('data:image/svg+xml,'); + content: ' '; + + display: inline-block; + vertical-align: middle; + position: relative; + bottom: 0.1em; + margin-left: 0.2em; + margin-right: 0.1em; + + height: 0.7em; + width: 0.7em; + border-radius: 100%; + background-color: var(--md-typeset-a-color); +} + +a.autorefs-external:hover::after { + background-color: var(--md-accent-fg-color); +} + /* Avoid breaking parameters name, etc. in table cells. */ td code { word-break: normal !important; -} +} \ No newline at end of file diff --git a/docs/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..896b9240 --- /dev/null +++ b/docs/insiders/goals.yml @@ -0,0 +1 @@ +goals: {} diff --git a/docs/insiders/index.md b/docs/insiders/index.md new file mode 100644 index 00000000..0f5b0de9 --- /dev/null +++ b/docs/insiders/index.md @@ -0,0 +1,222 @@ +# 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", + ("mkdocstrings-python", "https://mkdocstrings.github.io/python/", "insiders/goals.yml"), +] +``` + +```python exec="1" session="insiders" +--8<-- "scripts/insiders.py" +``` + +```python exec="1" session="insiders" +print(f"""The moment you become a sponsor, you'll get **immediate +access to {len(completed_features)} additional features** that you can start using right away, and +which are currently exclusively available to sponsors:\n""") + +for feature in completed_features: + feature.render(badge=True) +``` + +## How to become a sponsor + +Thanks for your interest in sponsoring! In order to become an eligible sponsor +with your GitHub account, visit [pawamoy's sponsor profile][github sponsor profile], +and complete a sponsorship of **$10 a month or more**. +You can use your individual or organization GitHub account for sponsoring. + +**Important**: If you're sponsoring **[@pawamoy][github sponsor profile]** +through a GitHub organization, please send a short email +to pawamoy@pm.me 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 pawamoy@pm.me, 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. + +```python exec="1" session="insiders" +print_join_sponsors_button() +``` + +
+ +```python exec="1" session="insiders" +print_sponsors() +``` + +

+ + + 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 + +```python exec="1" session="insiders" +print(f"Current funding is at **$ {human_readable_amount(current_funding)} a month**.") +``` + +### 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() +``` + + + +## 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 pawamoy@pm.me. + +### 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/ +[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..9852182b --- /dev/null +++ b/docs/insiders/installation.md @@ -0,0 +1,190 @@ +--- +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 pip (ssh/https) + +*mkdocstrings Insiders* can be installed with `pip` [using SSH][using ssh]: + +```bash +pip install git+ssh://git@github.com/pawamoy-insiders/mkdocstrings.git +``` + + [using ssh]: https://docs.github.com/en/authentication/connecting-to-github-with-ssh + +Or using HTTPS: + +```bash +pip install git+https://${GH_TOKEN}@github.com/pawamoy-insiders/mkdocstrings.git +``` + +>? NOTE: **How to get a GitHub personal access token** +> The `GH_TOKEN` environment variable is a GitHub token. +> It can be obtained by creating a [personal access token] for +> your GitHub account. It will give you access to the Insiders repository, +> programmatically, from the command line or GitHub Actions workflows: +> +> 1. Go to https://github.com/settings/tokens +> 2. Click on [Generate a new token] +> 3. Enter a name and select the [`repo`][scopes] scope +> 4. Generate the token and store it in a safe place +> +> [personal access token]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token +> [Generate a new token]: https://github.com/settings/tokens/new +> [scopes]: https://docs.github.com/en/developers/apps/scopes-for-oauth-apps#available-scopes +> +> Note that the personal access +> token must be kept secret at all times, as it allows the owner to access your +> private repositories. + +### with pip (self-hosted) + +Self-hosting the Insiders package makes it possible to depend on *mkdocstrings* normally, +while transparently downloading and installing the Insiders version locally. +It means that you can specify your dependencies normally, and your contributors without access +to Insiders will get the public version, while you get the Insiders version on your machine. + +WARNING: **Limitation** +With this method, there is no way to force the installation of an Insiders version +rather than a public version. If there is a public version that is more recent +than your self-hosted Insiders version, the public version will take precedence. +Remember to regularly update your self-hosted versions by uploading latest distributions. + +You can build the distributions for Insiders yourself, by cloning the repository +and using [build] to build the distributions, +or you can download them from our [GitHub Releases]. +You can upload these distributions to a private PyPI-like registry +([Artifactory], [Google Cloud], [pypiserver], etc.) +with [Twine]: + + [build]: https://pypi.org/project/build/ + [Artifactory]: https://jfrog.com/help/r/jfrog-artifactory-documentation/pypi-repositories + [Google Cloud]: https://cloud.google.com/artifact-registry/docs/python + [pypiserver]: https://pypi.org/project/pypiserver/ + [Github Releases]: https://github.com/pawamoy-insiders/mkdocstrings/releases + [Twine]: https://pypi.org/project/twine/ + +```bash +# download distributions in ~/dists, then upload with: +twine upload --repository-url https://your-private-index.com ~/dists/* +``` + +You might also need to provide a username and password/token to authenticate against the registry. +Please check [Twine's documentation][twine docs]. + + [twine docs]: https://twine.readthedocs.io/en/stable/ + +You can then configure pip (or other tools) to look for packages into your package index. +For example, with pip: + +```bash +pip config set global.extra-index-url https://your-private-index.com/simple +``` + +Note that the URL might differ depending on whether your are uploading a package (with Twine) +or installing a package (with pip), and depending on the registry you are using (Artifactory, Google Cloud, etc.). +Please check the documentation of your registry to learn how to configure your environment. + +**We kindly ask that you do not upload the distributions to public registries, +as it is against our [Terms of use](../#terms).** + +>? TIP: **Full example with `pypiserver`** +> In this example we use [pypiserver] to serve a local PyPI index. +> +> ```bash +> pip install --user pypiserver +> # or pipx install pypiserver +> +> # create a packages directory +> mkdir -p ~/.local/pypiserver/packages +> +> # run the pypi server without authentication +> pypi-server run -p 8080 -a . -P . ~/.local/pypiserver/packages & +> ``` +> +> We can configure the credentials to access the server in [`~/.pypirc`][pypirc]: +> +> [pypirc]: https://packaging.python.org/en/latest/specifications/pypirc/ +> +> ```ini title=".pypirc" +> [distutils] +> index-servers = +> local +> +> [local] +> repository: http://localhost:8080 +> username: +> password: +> ``` +> +> We then clone the Insiders repository, build distributions and upload them to our local server: +> +> ```bash +> # clone the repository +> git clone git@github.com:pawamoy-insiders/mkdocstrings +> cd mkdocstrings +> +> # install build +> pip install --user build +> # or pipx install build +> +> # checkout latest tag +> git checkout $(git describe --tags --abbrev=0) +> +> # build the distributions +> pyproject-build +> +> # upload them to our local server +> twine upload -r local dist/* --skip-existing +> ``` +> +> Finally, we configure pip, and for example [PDM][pdm], to use our local index to find packages: +> +> ```bash +> pip config set global.extra-index-url http://localhost:8080/simple +> pdm config pypi.extra.url http://localhost:8080/simple +> ``` +> +> [pdm]: https://pdm.fming.dev/latest/ +> +> Now when running `pip install mkdocstrings`, +> or resolving dependencies with PDM, +> both tools will look into our local index and find the Insiders version. +> **Remember to update your local index regularly!** + +### with git + +Of course, you can use *mkdocstrings Insiders* directly from `git`: + +``` +git clone git@github.com:pawamoy-insiders/mkdocstrings +``` + +When cloning from `git`, the package must be installed: + +``` +pip install -e mkdocstrings +``` + +## Upgrading + +When upgrading Insiders, you should always check the version of *mkdocstrings* +which makes up the first part of the version qualifier. For example, a version like +`8.x.x.4.x.x` means that Insiders `4.x.x` is currently based on `8.x.x`. + +If the major version increased, it's a good idea to consult the [changelog] +and go through the steps to ensure your configuration is up to date and +all necessary changes have been made. + + [changelog]: ./changelog.md diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1b360ffb..4d2b074e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -48,7 +48,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 +116,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. diff --git a/docs/handlers/overview.md b/docs/usage/handlers.md similarity index 80% rename from docs/handlers/overview.md rename to docs/usage/handlers.md index 779f7c81..e381090e 100644 --- a/docs/handlers/overview.md +++ b/docs/usage/handlers.md @@ -5,8 +5,8 @@ A handler is what makes it possible to collect and render documentation for a pa ## Available handlers - Crystal +- Python - Python (Legacy) -- Python (Experimental) ## About the Python handlers @@ -192,7 +192,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`). @@ -258,3 +258,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/#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 99% rename from docs/usage.md rename to docs/usage/index.md index 0969164c..3318c053 100644 --- a/docs/usage.md +++ b/docs/usage/index.md @@ -142,7 +142,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 diff --git a/docs/theming.md b/docs/usage/theming.md similarity index 100% rename from docs/theming.md rename to docs/usage/theming.md diff --git a/duties.py b/duties.py index 01d632ae..77410349 100644 --- a/duties.py +++ b/duties.py @@ -5,18 +5,23 @@ import os import sys from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from duty import duty from duty.callables import black, blacken_docs, coverage, lazy, mkdocs, mypy, pytest, ruff, safety +if sys.version_info < (3, 8): + from importlib_metadata import version as pkgversion +else: + from importlib.metadata import version as pkgversion + + if TYPE_CHECKING: from duty.context import Context 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 @@ -30,6 +35,34 @@ def pyprefix(title: str) -> str: # noqa: D103 return title +def merge(d1: Any, d2: Any) -> Any: # noqa: D103 + basic_types = (int, float, str, bool, complex) + if isinstance(d1, dict) and isinstance(d2, dict): + for key, value in d2.items(): + if key in d1: + if isinstance(d1[key], basic_types): + d1[key] = value + else: + d1[key] = merge(d1[key], value) + else: + d1[key] = value + return d1 + if isinstance(d1, list) and isinstance(d2, list): + return d1 + d2 + return d2 + + +def mkdocs_config() -> str: # noqa: D103 + from mkdocs import utils + + # patch YAML loader to merge arrays + utils.merge = merge + + if "+insiders" in pkgversion("mkdocs-material"): + return "mkdocs.insiders.yml" + return "mkdocs.yml" + + @duty def changelog(ctx: Context) -> None: """Update the changelog in-place with latest commits. @@ -39,7 +72,7 @@ def changelog(ctx: Context) -> None: """ from git_changelog.cli import build_and_render - git_changelog = lazy("git_changelog")(build_and_render) + git_changelog = lazy(build_and_render, name="git_changelog") ctx.run( git_changelog( repository=".", @@ -48,7 +81,7 @@ def changelog(ctx: Context) -> None: template="keepachangelog", parse_trailers=True, parse_refs=False, - sections=("build", "deps", "feat", "fix", "refactor"), + sections=["build", "deps", "feat", "fix", "refactor"], bump_latest=True, in_place=True, ), @@ -56,7 +89,7 @@ def changelog(ctx: Context) -> None: ) -@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies"]) +@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) def check(ctx: Context) -> None: # noqa: ARG001 """Check it all! @@ -104,7 +137,7 @@ def check_docs(ctx: Context) -> None: """ Path("htmlcov").mkdir(parents=True, exist_ok=True) Path("htmlcov/index.html").touch(exist_ok=True) - ctx.run(mkdocs.build(strict=True), title=pyprefix("Building documentation")) + ctx.run(mkdocs.build(strict=True, config_file=mkdocs_config()), title=pyprefix("Building documentation")) @duty @@ -121,6 +154,23 @@ def check_types(ctx: Context) -> None: ) +@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") + ctx.run( + griffe_check("mkdocstrings", search_paths=["src"]), + title="Checking for API breaking changes", + nofail=True, + ) + + @duty(silent=True) def clean(ctx: Context) -> None: """Delete temporary files. @@ -142,17 +192,7 @@ def clean(ctx: Context) -> None: @duty -def docs(ctx: Context) -> None: - """Build the documentation locally. - - Parameters: - ctx: The context instance (passed automatically). - """ - ctx.run(mkdocs.build, title="Building documentation") - - -@duty -def docs_serve(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: +def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: """Serve the documentation (localhost:8000). Parameters: @@ -161,7 +201,7 @@ def docs_serve(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: port: The port to serve the docs on. """ ctx.run( - mkdocs.serve(dev_addr=f"{host}:{port}"), + mkdocs.serve(dev_addr=f"{host}:{port}", config_file=mkdocs_config()), title="Serving documentation", capture=False, ) @@ -174,8 +214,23 @@ def docs_deploy(ctx: Context) -> None: Parameters: ctx: The context instance (passed automatically). """ - 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", force=True), title="Deploying documentation") + os.environ["DEPLOY"] = "true" + config_file = mkdocs_config() + if config_file == "mkdocs.yml": + ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") + origin = ctx.run("git config --get remote.origin.url", silent=True) + if "pawamoy-insiders/mkdocstrings" in origin: + ctx.run("git remote add org-pages git@github.com:mkdocstrings/mkdocstrings.github.io", silent=True, nofail=True) + ctx.run( + mkdocs.gh_deploy(config_file=config_file, 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 @@ -197,7 +252,7 @@ def format(ctx: Context) -> None: ) -@duty +@duty(post=["docs-deploy"]) def release(ctx: Context, version: str) -> None: """Release a new Python package. @@ -205,15 +260,19 @@ def release(ctx: Context, version: str) -> None: ctx: The context instance (passed automatically). version: The new version number to use. """ + origin = ctx.run("git config --get remote.origin.url", silent=True) + if "pawamoy-insiders/mkdocstrings" in origin: + ctx.run( + lambda: False, + title="Not releasing from insiders repository (do that from public repo instead!)", + ) 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) + ctx.run("pdm build", title="Building dist/wheel", pty=PTY) + ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY) @duty(silent=True, aliases=["coverage"]) diff --git a/mkdocs.insiders.yml b/mkdocs.insiders.yml new file mode 100644 index 00000000..a93edcc3 --- /dev/null +++ b/mkdocs.insiders.yml @@ -0,0 +1,5 @@ +INHERIT: mkdocs.yml + +# waiting for https://github.com/squidfunk/mkdocs-material/issues/5446 +# plugins: +# - typeset diff --git a/mkdocs.yml b/mkdocs.yml index 191526d4..f544387c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,7 +5,8 @@ repo_url: "https://github.com/mkdocstrings/mkdocstrings" edit_uri: "blob/master/docs/" repo_name: "mkdocstrings/mkdocstrings" site_dir: "site" -watch: [README.md, CONTRIBUTING.md, CHANGELOG.md, src/mkdocstrings] +watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/mkdocstrings] +copyright: Copyright © 2019 Timothée Mazzucotelli nav: - Home: @@ -14,34 +15,54 @@ 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: - 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 + - Guides: + - Recipes: recipes.md + - Troubleshooting: troubleshooting.md # defer to gen-files + literate-nav - Code Reference: 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 @@ -55,25 +76,34 @@ 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 markdown_extensions: +- attr_list - admonition - callouts +- footnotes +- pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg - pymdownx.details -- pymdownx.emoji - pymdownx.magiclink - pymdownx.snippets: 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 - toc: permalink: "¤" @@ -83,11 +113,9 @@ plugins: - gen-files: scripts: - scripts/gen_ref_nav.py - - scripts/gen_redirects.py - literate-nav: nav_file: SUMMARY.txt - coverage -- section-index - mkdocstrings: handlers: python: @@ -100,6 +128,15 @@ plugins: merge_init_into_class: true docstring_options: ignore_init_summary: true +- git-committers: + enabled: !ENV [DEPLOY, false] + repository: mkdocstrings/mkdocstrings +- redirects: + redirect_maps: + theming.md: usage/theming.md + handlers/overview.md: usage/handlers.md +- minify: + minify_html: !ENV [DEPLOY, false] extra: social: @@ -109,3 +146,7 @@ extra: link: https://fosstodon.org/@pawamoy - icon: fontawesome/brands/twitter link: https://twitter.com/pawamoy + - icon: fontawesome/brands/gitter + link: https://gitter.im/mkdocstrings/community + - icon: fontawesome/brands/python + link: https://pypi.org/project/mkdocstrings/ diff --git a/pyproject.toml b/pyproject.toml index 07665246..a36f6d25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [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 = "ISC" +license = {text = "ISC"} readme = "README.md" requires-python = ">=3.7" keywords = ["mkdocs", "mkdocs-plugin", "docstrings", "autodoc", "documentation"] @@ -35,6 +35,7 @@ dependencies = [ "mkdocs>=1.2", "mkdocs-autorefs>=0.3.1", "pymdown-extensions>=6.3", + "importlib-metadata>=4.6; python_version < '3.10'", "typing-extensions>=4.1; python_version < '3.10'", ] @@ -51,13 +52,16 @@ 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 = {source = "scm"} +plugins = [ + "pdm-multirun", +] [tool.pdm.build] package-dir = "src" @@ -65,18 +69,22 @@ includes = ["src/mkdocstrings"] editable-backend = "editables" [tool.pdm.dev-dependencies] -duty = ["duty>=0.8"] +duty = ["duty>=0.10"] +ci-quality = ["mkdocstrings[duty,docs,quality,typing,security]"] +ci-tests = ["mkdocstrings[duty,docs,tests]"] docs = [ "black>=23.1", + "markdown-callouts>=0.2", + "markdown-exec>=0.5", "mkdocs>=1.3", "mkdocs-coverage>=0.2", "mkdocs-gen-files>=0.3", + "mkdocs-git-committers-plugin-2>=1.1", "mkdocs-literate-nav>=0.4", "mkdocs-material>=7.3", - "mkdocs-section-index>=0.3", + "mkdocs-minify-plugin>=0.6.4", + "mkdocs-redirects>=1.2.0", "mkdocstrings-python>=0.5.1", - "markdown-callouts>=0.2", - "markdown-exec>=0.5", "toml>=0.10", ] maintain = [ @@ -100,7 +108,9 @@ typing = [ "mypy>=0.911", "types-docutils", "types-markdown>=3.3", - "types-pyyaml", + "types-pyyaml>=6.0", "types-toml>=0.10", ] -security = ["safety>=2"] +security = [ + "safety>=2", +] diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py index 7f59f8f9..85ac9041 100644 --- a/scripts/gen_credits.py +++ b/scripts/gen_credits.py @@ -53,6 +53,8 @@ def _get_deps(base_deps: Mapping[str, Mapping[str, str]]) -> dict[str, dict[str, for dep in base_deps: parsed = regex.match(dep).groupdict() # type: ignore[union-attr] dep_name = parsed["dist"].lower() + if dep_name not in lock_pkgs: + continue deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]} again = True @@ -63,7 +65,7 @@ def _get_deps(base_deps: Mapping[str, Mapping[str, str]]) -> dict[str, dict[str, for pkg_dependency in lock_pkgs[pkg_name].get("dependencies", []): parsed = regex.match(pkg_dependency).groupdict() # type: ignore[union-attr] dep_name = parsed["dist"].lower() - if dep_name not in deps and dep_name != project["name"]: + if dep_name in lock_pkgs and dep_name not in deps and dep_name != project["name"]: deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]} again = True @@ -87,7 +89,7 @@ def _render_credits() -> str: } template_text = dedent( """ - These projects were used to build `{{ project_name }}`. **Thank you!** + These projects were used to build *{{ project_name }}*. **Thank you!** [`python`](https://www.python.org/) | [`pdm`](https://pdm.fming.dev/) | diff --git a/scripts/gen_redirects.py b/scripts/gen_redirects.py deleted file mode 100644 index f35cce9c..00000000 --- a/scripts/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/scripts/insiders.py b/scripts/insiders.py new file mode 100644 index 00000000..0d23a45a --- /dev/null +++ b/scripts/insiders.py @@ -0,0 +1,216 @@ +"""Functions related to Insiders funding goals.""" + +from __future__ import annotations + +import json +import logging +import posixpath +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from itertools import chain +from pathlib import Path +from textwrap import dedent +from typing import Iterable, cast +from urllib.error import HTTPError +from urllib.parse import urljoin +from urllib.request import urlopen + +import yaml + +logger = logging.getLogger(f"mkdocs.logs.{__name__}") + + +def human_readable_amount(amount: int) -> str: # noqa: D103 + str_amount = str(amount) + if len(str_amount) >= 4: # noqa: PLR2004 + return f"{str_amount[:len(str_amount)-3]},{str_amount[-3:]}" + return str_amount + + +@dataclass +class Project: + """Class representing an Insiders project.""" + + name: str + url: str + + +@dataclass +class Feature: + """Class representing an Insiders feature.""" + + name: str + ref: str + since: date | None + project: Project | None + + def url(self, rel_base: str = "..") -> str: # noqa: D102 + 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 "" + print(f"- [{'x' if self.since else ' '}] {project}[{self.name}]({self.url(rel_base)}){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") + for feature in self.features: + feature.render(rel_base) + print("") + + +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["ref"], + since=feature_data["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]: + try: + data = Path(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) + for amount, goal in source_goals.items(): + if amount not in goals: + goals[amount] = goal + else: + goals[amount].features.extend(goal.features) + return 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) +all_features = feature_list(goals.values()) +completed_features = sorted( + (ft for ft in all_features if ft.since), + key=lambda ft: cast(date, ft.since), + reverse=True, +) + + +def print_join_sponsors_button() -> None: # noqa: D103 + btn_classes = "{ .md-button .md-button--primary }" + print( + dedent( + f""" + [:octicons-heart-fill-24:{{ .pulse }} +   Join our {sponsors_count} awesome sponsors]({sponsor_url}){btn_classes} + """, + ), + ) + + +def print_sponsors() -> None: # noqa: D103 + private_sponsors_count = sponsors_count - len(sponsors) + for sponsor in sponsors: + print( + f"""""" + f"""""", + ) + if private_sponsors_count: + print(f"""+{private_sponsors_count}""") diff --git a/scripts/setup.sh b/scripts/setup.sh index 9e7ab1ff..f6c90de5 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -10,11 +10,11 @@ if ! command -v pdm &>/dev/null; then pipx install pdm fi if ! pdm self list 2>/dev/null | grep -q pdm-multirun; then - pipx inject pdm pdm-multirun + pdm install --plugins fi if [ -n "${PYTHON_VERSIONS}" ]; then - pdm multirun -vi ${PYTHON_VERSIONS// /,} pdm install + pdm multirun -vi ${PYTHON_VERSIONS// /,} pdm install --dev else - pdm install + pdm install --dev fi diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py index 51efd775..b6ecd4fa 100644 --- a/src/mkdocstrings/handlers/base.py +++ b/src/mkdocstrings/handlers/base.py @@ -11,6 +11,7 @@ from __future__ import annotations import importlib +import sys import warnings from contextlib import suppress from pathlib import Path @@ -31,6 +32,12 @@ from mkdocstrings.inventory import Inventory from mkdocstrings.loggers import get_template_logger +# TODO: remove once support for Python 3.9 is dropped +if sys.version_info < (3, 10): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points + CollectorItem = Any @@ -93,12 +100,23 @@ def __init__(self, handler: str, theme: str, custom_templates: str | None = None self._theme = theme self._custom_templates = custom_templates + # add selected theme templates themes_dir = self.get_templates_dir(handler) paths.append(themes_dir / theme) + # add extended theme templates + extended_templates_dirs = self.get_extended_templates_dirs(handler) + for templates_dir in extended_templates_dirs: + paths.append(templates_dir / theme) + + # add fallback theme templates if self.fallback_theme and self.fallback_theme != theme: paths.append(themes_dir / self.fallback_theme) + # add fallback theme of extended templates + for templates_dir in extended_templates_dirs: + paths.append(templates_dir / self.fallback_theme) + for path in paths: css_path = path / "style.css" if css_path.is_file(): @@ -179,6 +197,18 @@ def get_templates_dir(self, handler: str) -> Path: raise FileNotFoundError(f"Can't find 'templates' folder for handler '{handler}'") + def get_extended_templates_dirs(self, handler: str) -> list[Path]: + """Load template extensions for the given handler, return their templates directories. + + Arguments: + handler: The name of the handler to get the extended templates directory of. + + Returns: + The extensions templates directories. + """ + discovered_extensions = entry_points(group=f"mkdocstrings.{handler}.templates") + return [extension.load()() for extension in discovered_extensions] + def get_anchors(self, data: CollectorItem) -> tuple[str, ...] | set[str]: """Return the possible identifiers (HTML anchors) for a collected item. diff --git a/src/mkdocstrings/loggers.py b/src/mkdocstrings/loggers.py index 5e3f42bb..63502474 100644 --- a/src/mkdocstrings/loggers.py +++ b/src/mkdocstrings/loggers.py @@ -7,8 +7,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, MutableMapping, Sequence -from mkdocs.utils import warning_filter - try: from jinja2 import pass_context except ImportError: # TODO: remove once Jinja2 < 3.1 is dropped @@ -135,7 +133,6 @@ 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) diff --git a/src/mkdocstrings/plugin.py b/src/mkdocstrings/plugin.py index 5233cf4f..4c902935 100644 --- a/src/mkdocstrings/plugin.py +++ b/src/mkdocstrings/plugin.py @@ -14,7 +14,6 @@ from __future__ import annotations -import collections import functools import gzip import os @@ -226,16 +225,17 @@ def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: ARG002 config["extra_css"].insert(0, self.css_filename) # So that it has lower priority than user files. - self._inv_futures = [] + self._inv_futures = {} if to_import: inv_loader = futures.ThreadPoolExecutor(4) for handler_name, import_item in to_import: + loader = self.get_handler(handler_name).load_inventory future = inv_loader.submit( self._load_inventory, # type: ignore[misc] - self.get_handler(handler_name).load_inventory, + loader, **import_item, ) - self._inv_futures.append(future) + self._inv_futures[future] = (loader, import_item) inv_loader.shutdown(wait=False) if self.config["watch"]: @@ -286,9 +286,18 @@ def on_env(self, env: Environment, config: Config, *args: Any, **kwargs: Any) -> 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(): + results = {} + # Reversed order so that pages from first futures take precedence: + for fut in reversed(list(self._inv_futures)): + try: + results.update(fut.result()) + except Exception as error: # noqa: BLE001 + loader, import_item = self._inv_futures[fut] + loader_name = loader.__func__.__qualname__ + log.error(f"Couldn't load inventory {import_item} through {loader_name}: {error}") # noqa: TRY400 + for page, identifier in results.items(): config["plugins"]["autorefs"].register_url(page, identifier) - self._inv_futures = [] + self._inv_futures = {} def on_post_build( self, diff --git a/tests/test_handlers.py b/tests/test_handlers.py index a0b3be3e..777173e8 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -2,10 +2,18 @@ from __future__ import annotations +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING + import pytest +from jinja2.exceptions import TemplateNotFound from markdown import Markdown -from mkdocstrings.handlers.base import Highlighter +from mkdocstrings.handlers.base import BaseRenderer, Highlighter + +if TYPE_CHECKING: + from mkdocstrings.plugin import MkdocstringsPlugin @pytest.mark.parametrize("extension_name", ["codehilite", "pymdownx.highlight"]) @@ -43,3 +51,53 @@ def test_highlighter_basic(extension_name: str | None, inline: bool) -> None: actual = hl.highlight("import foo", language="python", inline=inline) assert "import" in actual assert "import foo" not in actual # Highlighting has split it up. + + +@pytest.fixture(name="extended_templates") +def fixture_extended_templates(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: # noqa: D103 + monkeypatch.setattr(BaseRenderer, "get_extended_templates_dirs", lambda self, handler: [tmp_path]) + return tmp_path + + +def test_extended_templates(extended_templates: Path, plugin: MkdocstringsPlugin) -> None: + """Test the extended templates functionality. + + Parameters: + extended_templates: Temporary folder. + plugin: Instance of our plugin. + """ + handler = plugin._handlers.get_handler("python") # type: ignore[union-attr] + + # assert mocked method added temp path to loader + search_paths = handler.env.loader.searchpath # type: ignore[union-attr] + assert any(str(extended_templates) in path for path in search_paths) + + # assert "new" template is not found + for path in search_paths: + # TODO: use missing_ok=True once support for Python 3.7 is dropped + with suppress(FileNotFoundError): + Path(path).joinpath("new.html").unlink() + 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 = extended_templates.joinpath(handler.fallback_theme) + 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 = extended_templates.joinpath("mkdocs") + 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 = Path(search_paths[1]) + base_fallback_theme.joinpath("new.html").write_text("base fallback new") + assert handler.env.get_template("new.html").render() == "base fallback new" + + base_theme = Path(search_paths[0]) + base_theme.joinpath("new.html").write_text("base new") + assert handler.env.get_template("new.html").render() == "base new"