diff --git a/.copier-answers.yml b/.copier-answers.yml index 3efb287c..e3b6e940 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: 0.10.10 +_commit: 0.11.3 _src_path: gh:pawamoy/copier-pdm.git author_email: pawamoy@pm.me author_fullname: Timothée Mazzucotelli diff --git a/CHANGELOG.md b/CHANGELOG.md index 9086f223..70a67007 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.21.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.21.0) - 2023-04-05 + +[Compare with 0.20.0](https://github.com/mkdocstrings/mkdocstrings/compare/0.20.0...0.21.0) + +### Features + +- Expose the full config to handlers ([15dacf6](https://github.com/mkdocstrings/mkdocstrings/commit/15dacf62f8479a05e9604383155ffa6fade0522d) by David Patterson). [Issue #501](https://github.com/mkdocstrings/mkdocstrings/issues/501), [PR #509](https://github.com/mkdocstrings/mkdocstrings/pull/509) + ## [0.20.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.20.0) - 2023-01-19 [Compare with 0.19.1](https://github.com/mkdocstrings/mkdocstrings/compare/0.19.1...0.20.0) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 35f1f538..fe3eefbf 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,73 +2,132 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or -advances -* Trolling, insulting/derogatory comments, and personal or political attacks +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission +* Publishing others' private information, such as a physical or email address, + without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at pawamoy@pm.me. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. +reported to the community leaders responsible for enforcement at +pawamoy@pm.me. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b796eb8c..d9f8e89f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,8 +73,9 @@ Don't bother updating the changelog, we will take care of this. ## Commit message convention -Commits messages must follow the -[Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message): +Commit messages must follow our convention based on the +[Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) +or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): ``` [(scope)]: Subject @@ -82,11 +83,17 @@ Commits messages must follow the [Body] ``` +**Subject and body must be valid Markdown.** +Subject must have proper casing (uppercase for first letter +if it makes sense), but no dot at the end, and no punctuation +in general. + Scope and body are optional. Type can be: - `build`: About packaging, building wheels, etc. - `chore`: About packaging or repo/files management. - `ci`: About Continuous Integration. +- `deps`: Dependencies update. - `docs`: About documentation. - `feat`: New feature. - `fix`: Bug fix. @@ -95,16 +102,28 @@ Scope and body are optional. Type can be: - `style`: A change in code style/format. - `tests`: About tests. -**Subject (and body) must be valid Markdown.** -If you write a body, please add issues references at the end: +If you write a body, please add trailers at the end +(for example issues and PR references, or co-authors), +without relying on GitHub's flavored Markdown: ``` Body. -References: #10, #11. -Fixes #15. +Issue #10: https://github.com/namespace/project/issues/10 +Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 ``` +These "trailers" must appear at the end of the body, +without any blank lines between them. The trailer title +can contain any character except colons `:`. +We expect a full URI for each trailer, not just GitHub autolinks +(for example, full GitHub URLs for commits and issues, +not the hash or the #issue-number). + +We do not enforce a line length on commit messages summary and body, +but please avoid very long summaries, and very long lines in the body, +unless they are part of code blocks that must not be wrapped. + ## Pull requests guidelines Link to any related issue in the Pull Request message. diff --git a/Makefile b/Makefile index 58291575..b034ffff 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ setup: .PHONY: check check: - @bash scripts/multirun.sh duty check-quality check-types check-docs + @pdm multirun duty check-quality check-types check-docs @$(DUTY) check-dependencies .PHONY: $(BASIC_DUTIES) @@ -50,4 +50,4 @@ $(BASIC_DUTIES): .PHONY: $(QUALITY_DUTIES) $(QUALITY_DUTIES): - @bash scripts/multirun.sh duty $@ $(call args,$@) + @pdm multirun duty $@ $(call args,$@) diff --git a/README.md b/README.md index 534c64bf..52300c96 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ pip install mkdocstrings You can install support for specific languages using extras, for example: ```bash -pip install mkdocstrings[crystal,python] +pip install 'mkdocstrings[crystal,python]' ``` See the [available language handlers](https://mkdocstrings.github.io/handlers/overview/). @@ -84,8 +84,11 @@ conda install -c conda-forge mkdocstrings ## Quick usage +In `mkdocs.yml`: + ```yaml -# mkdocs.yml +site_name: "My Library" + theme: name: "material" diff --git a/config/black.toml b/config/black.toml new file mode 100644 index 00000000..d24affe5 --- /dev/null +++ b/config/black.toml @@ -0,0 +1,3 @@ +[tool.black] +line-length = 120 +exclude = "tests/fixtures" diff --git a/config/coverage.ini b/config/coverage.ini index bb43c37b..fde9d55a 100644 --- a/config/coverage.ini +++ b/config/coverage.ini @@ -17,6 +17,9 @@ omit = src/*/__init__.py src/*/__main__.py tests/__init__.py +exclude_lines = + pragma: no cover + if TYPE_CHECKING [coverage:json] output = htmlcov/coverage.json diff --git a/config/flake8.ini b/config/flake8.ini deleted file mode 100644 index d477956a..00000000 --- a/config/flake8.ini +++ /dev/null @@ -1,137 +0,0 @@ -[flake8] -exclude = fixtures,site,snippets -max-line-length = 132 -strictness = long -docstring-convention = google -ban-relative-imports = true -ignore = - # redundant with W0622 (builtin override), which is more precise about line number - A001 - # missing docstring in magic method - D105 - # multi-line docstring summary should start at the first line - D212 - # does not support Parameters sections - D417 - # whitespace before ':' (incompatible with Black) - E203 - # redundant with E0602 (undefined variable) - F821 - # error suffix foe exception - N818 - # black already deals with quoting - Q000 - # use of assert - S101 - # we are not parsing XML - S405 - # line break before binary operator (incompatible with Black) - W503 - # two-lowercase-letters variable DO conform to snake_case naming style - C0103 - # redundant with D102 (missing docstring) - C0116 - # line too long - C0301 - # too many instance attributes - R0902 - # too few public methods - R0903 - # too many public methods - R0904 - # too many branches - R0912 - # too many methods - R0913 - # too many local variables - R0914 - # too many statements - R0915 - # protected attribute - W0212 - # redundant with F401 (unused import) - W0611 - # lazy formatting for logging calls - W1203 - # short name - VNE001 - # f-strings - WPS305 - # common variable names (too annoying) - WPS110 - # redundant with W0622 (builtin override), which is more precise about line number - WPS125 - # too many imports - WPS201 - # too many module members - WPS202 - # overused expression - WPS204 - # too many local variables - WPS210 - # too many arguments - WPS211 - # too many expressions - WPS213 - # too many methods - WPS214 - # too deep nesting - WPS220 - # high Jones complexity - WPS221 - # too many elif branches - WPS223 - # string over-use: can't disable it per file? - WPS226 - # too many public instance attributes - WPS230 - # too complex function - WPS231 - # too many objects imported from module - WPS235 - # too many variables unpacked - WPS236 - # too complex f-string - WPS237 - # too cumbersome, asks to write class A(object) - WPS306 - # multi-line parameters (incompatible with Black) - WPS317 - # multi-line strings (incompatible with attributes docstrings) - WPS322 - # implicit string concatenation - WPS326 - # explicit string concatenation - WPS336 - # line starts with dot (incompatible with Black) - WPS348 - # yield from () - WPS353 - # blank line before bracket (incompatible with Black) - WPS355 - # raw string - WPS360 - # noqa overuse - WPS402 - # __init__ modules with logic - WPS412 - # del/pass - WPS420 - # print statements - WPS421 - # statement with no effect (not compatible with attribute docstrings) - WPS428 - # magic numbers - WPS432 - # redundant with C0415 (not top-level import) - WPS433 - # multiline usage (variable docstring) - WPS462 - # try finally without except - WPS501 - # implicit dict.get usage (generally false-positive) - WPS529 - # subclassing builtin - WPS600 - # getter/stter (false positives) - WPS615 diff --git a/config/pytest.ini b/config/pytest.ini index ad72bbe6..5a493959 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -14,3 +14,9 @@ addopts = --cov-config config/coverage.ini testpaths = tests + +# action:message_regex:warning_class:module_regex:line +filterwarnings = + error + # TODO: remove once pytest-xdist 4 is released + ignore:.*rsyncdir:DeprecationWarning:xdist diff --git a/config/ruff.toml b/config/ruff.toml new file mode 100644 index 00000000..62f45f64 --- /dev/null +++ b/config/ruff.toml @@ -0,0 +1,101 @@ +target-version = "py37" +line-length = 132 +exclude = [ + "fixtures", + "site", +] +select = [ + "A", + "ANN", + "ARG", + "B", + "BLE", + "C", + "C4", + "COM", + "D", + "DTZ", + "E", + "ERA", + "EXE", + "F", + "FBT", + "G", + "I", + "ICN", + "INP", + "ISC", + "N", + "PGH", + "PIE", + "PL", + "PLC", + "PLE", + "PLR", + "PLW", + "PT", + "PYI", + "Q", + "RUF", + "RSE", + "RET", + "S", + "SIM", + "SLF", + "T", + "T10", + "T20", + "TCH", + "TID", + "TRY", + "UP", + "W", + "YTT", +] +ignore = [ + "A001", # Variable is shadowing a Python builtin + "ANN101", # Missing type annotation for self + "ANN102", # Missing type annotation for cls + "ANN204", # Missing return type annotation for special method __str__ + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "ARG005", # Unused lambda argument + "C901", # Too complex + "D105", # Missing docstring in magic method + "D417", # Missing argument description in the docstring + "E501", # Line too long + "G004", # Logging statement uses f-string + "INP001", # File is part of an implicit namespace package + "PLR0911", # Too many return statements + "PLR0912", # Too many branches + "PLR0913", # Too many arguments to function call + "PLR0915", # Too many statements + "SLF001", # Private member accessed + "TRY003", # Avoid specifying long messages outside the exception class +] + +[per-file-ignores] +"src/*/cli.py" = [ + "T201", # Print statement +] +"scripts/*.py" = [ + "INP001", # File is part of an implicit namespace package + "T201", # Print statement +] +"tests/*.py" = [ + "ARG005", # Unused lambda argument + "FBT001", # Boolean positional arg in function definition + "PLR2004", # Magic value used in comparison + "S101", # Use of assert detected +] + +[flake8-quotes] +docstring-quotes = "double" + +[flake8-tidy-imports] +ban-relative-imports = "all" + +[isort] +known-first-party = ["mkdocstrings"] + +[pydocstyle] +convention = "google" diff --git a/docs/handlers/overview.md b/docs/handlers/overview.md index fd3e87bc..779f7c81 100644 --- a/docs/handlers/overview.md +++ b/docs/handlers/overview.md @@ -178,8 +178,12 @@ This function takes the following parameters: These arguments are all passed as keyword arguments, so you can ignore them by adding `**kwargs` or similar to your signature. You can also accept -additional parameters: the handler's global-only options will be passed -to this function when instantiating your handler. +additional parameters: the handler's global-only options and/or the root +config options. This gives flexibility and access to the mkdocs config, mkdocstring +config etc.. You should never modify the root config but can use it to get +information about the MkDocs instance such as where the current `site_dir` lives. +See the [Mkdocs Configuration](https://www.mkdocs.org/user-guide/configuration/) for +more info about what is accessible from it. Check out how the [Python handler](https://github.com/mkdocstrings/python/blob/master/src/mkdocstrings_handlers/python) diff --git a/docs/recipes.md b/docs/recipes.md index 8a28425f..c33130f0 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -250,7 +250,7 @@ will expand or collapse when you click on them, revealing `__init__` modules under them (or equivalent modules in other languages, if relevant). Since we are documenting a public API, and given users -never explicitely import `__init__` modules, it would be nice +never explicitly import `__init__` modules, it would be nice if we could get rid of them and instead render their documentation inside the section itself. @@ -321,6 +321,7 @@ and add global CSS rules to your site using MkDocs `extra_css` option: ```pycon >>> for word in ("Hello", "mkdocstrings!"): ... print(word, end=" ") +... Hello mkdocstrings! ``` ```` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3f2243fd..1b360ffb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -151,7 +151,7 @@ Example: ```python def math_function(x, y): r""" - Look at these formulas: + Look at these formulas: ```math f(x) = \int_{-\infty}^\infty @@ -170,6 +170,7 @@ So instead of: ```python import enum + class MyEnum(enum.Enum): v1 = 1 #: The first choice. v2 = 2 #: The second choice. @@ -180,13 +181,15 @@ You can use: ```python import enum + class MyEnum(enum.Enum): """My enum. - + Attributes: v1: The first choice. v2: The second choice. """ + v1 = 1 v2 = 2 ``` @@ -196,6 +199,7 @@ Or: ```python import enum + class MyEnum(enum.Enum): v1 = 1 """The first choice.""" @@ -211,8 +215,9 @@ Use [`functools.wraps()`](https://docs.python.org/3.6/library/functools.html#fun ```python from functools import wraps + def my_decorator(function): - """The decorator docs.""" + """The decorator docs.""" @wraps(function) def wrapped_function(*args, **kwargs): @@ -222,6 +227,7 @@ def my_decorator(function): return wrapped_function + @my_decorator def my_function(*args, **kwargs): """The function docs.""" diff --git a/docs/usage.md b/docs/usage.md index 3c7c5707..0969164c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -106,18 +106,18 @@ The above is equivalent to: *mkdocstrings* accepts a few top-level configuration options in `mkdocs.yml`: -- `default_handler`: the handler that is used by default when no handler is specified. -- `custom_templates`: the path to a directory containing custom templates. +- `default_handler`: The handler that is used by default when no handler is specified. +- `custom_templates`: The path to a directory containing custom templates. The path is relative to the current working directory. See [Theming](theming.md). -- `handlers`: the handlers global configuration. -- `enable_inventory`: whether to enable inventory file generation. +- `handlers`: The handlers' global configuration. +- `enable_inventory`: Whether to enable inventory file generation. See [Cross-references to other projects / inventories](#cross-references-to-other-projects-inventories) - `enabled` **(New in version 0.20)**: Whether to enable the plugin. Defaults to `true`. Can be used to reduce build times when doing local development. Especially useful when used with environment variables (see example below). -- `watch` **(deprecated)**: a list of directories to watch while serving the documentation. - See [Watch directories](#watch-directories). **Deprecated in favor of the now built-in +- `watch` **(deprecated)**: A list of directories to watch while serving the documentation. + See [Watch directories](#watch-directories). Deprecated in favor of the now built-in [`watch` feature of MkDocs](https://www.mkdocs.org/user-guide/configuration/#watch). !!! example @@ -326,7 +326,7 @@ Reciprocally, *mkdocstrings* also allows to *generate* an inventory file in the It will be enabled by default if the Python handler is used, and generated as `objects.inv` in the final site directory. Other projects will be able to cross-reference items from your project. -To explicitely enable or disable the generation of the inventory file, use the global +To explicitly enable or disable the generation of the inventory file, use the global `enable_inventory` option: ```yaml @@ -361,4 +361,3 @@ For example, it will not tell the Python handler to look for packages in these p (the paths are not added to the `PYTHONPATH` variable). If you want to tell Python where to look for packages and modules, see [Python Handler: Finding modules](https://mkdocstrings.github.io/python/usage/#finding-modules). - diff --git a/duties.py b/duties.py index e5103cbf..d7ed6115 100644 --- a/duties.py +++ b/duties.py @@ -1,148 +1,90 @@ """Development tasks.""" -import importlib +from __future__ import annotations + import os -import re import sys -from io import StringIO from pathlib import Path -from typing import List, Optional, Pattern -from urllib.request import urlopen +from typing import TYPE_CHECKING from duty import duty +from duty.callables import black, blacken_docs, coverage, lazy, mkdocs, mypy, pytest, ruff, safety + +if TYPE_CHECKING: + from duty.context import Context -PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "docs")) +PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) PY_SRC = " ".join(PY_SRC_LIST) TESTING = os.environ.get("TESTING", "0") in {"1", "true"} CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} WINDOWS = os.name == "nt" PTY = not WINDOWS and not CI +MULTIRUN = os.environ.get("PDM_MULTIRUN", "0") == "1" -def _latest(lines: List[str], regex: Pattern) -> Optional[str]: - for line in lines: - match = regex.search(line) - if match: - return match.groupdict()["version"] - return None - - -def _unreleased(versions, last_release): - for index, version in enumerate(versions): - if version.tag == last_release: - return versions[:index] - return versions - - -def update_changelog( - inplace_file: str, - marker: str, - version_regex: str, - template_url: str, -) -> None: - """Update the given changelog file in place. - - Arguments: - inplace_file: The file to update in-place. - marker: The line after which to insert new contents. - version_regex: A regular expression to find currently documented versions in the file. - template_url: The URL to the Jinja template used to render contents. - """ - from git_changelog.build import Changelog - from git_changelog.commit import AngularStyle - from jinja2.sandbox import SandboxedEnvironment - - AngularStyle.DEFAULT_RENDER.insert(0, AngularStyle.TYPES["build"]) - env = SandboxedEnvironment(autoescape=False) - template_text = urlopen(template_url).read().decode("utf8") # noqa: S310 - template = env.from_string(template_text) - changelog = Changelog(".", style="angular") - - if len(changelog.versions_list) == 1: - last_version = changelog.versions_list[0] - if last_version.planned_tag is None: - planned_tag = "0.1.0" - last_version.tag = planned_tag - last_version.url += planned_tag - last_version.compare_url = last_version.compare_url.replace("HEAD", planned_tag) - - with open(inplace_file, "r") as changelog_file: - lines = changelog_file.read().splitlines() - - last_released = _latest(lines, re.compile(version_regex)) - if last_released: - changelog.versions_list = _unreleased(changelog.versions_list, last_released) - rendered = template.render(changelog=changelog, inplace=True) - lines[lines.index(marker)] = rendered - - with open(inplace_file, "w") as changelog_file: # noqa: WPS440 - changelog_file.write("\n".join(lines).rstrip("\n") + "\n") +def pyprefix(title: str) -> str: # noqa: D103 + if MULTIRUN: + prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" + return f"{prefix:14}{title}" + return title @duty -def changelog(ctx): +def changelog(ctx: Context) -> None: """Update the changelog in-place with latest commits. - Arguments: + Parameters: ctx: The context instance (passed automatically). """ - commit = "166758a98d5e544aaa94fda698128e00733497f4" - template_url = f"https://raw.githubusercontent.com/pawamoy/jinja-templates/{commit}/keepachangelog.md" + from git_changelog.cli import build_and_render + + git_changelog = lazy("git_changelog")(build_and_render) ctx.run( - update_changelog, - kwargs={ - "inplace_file": "CHANGELOG.md", - "marker": "", - "version_regex": r"^## \[v?(?P[^\]]+)", - "template_url": template_url, - }, + git_changelog( + repository=".", + output="CHANGELOG.md", + convention="angular", + template="keepachangelog", + parse_trailers=True, + parse_refs=False, + sections=("build", "deps", "feat", "fix", "refactor"), + bump_latest=True, + in_place=True, + ), title="Updating changelog", - pty=PTY, ) @duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies"]) -def check(ctx): +def check(ctx: Context) -> None: # noqa: ARG001 """Check it all! - Arguments: + Parameters: ctx: The context instance (passed automatically). """ @duty -def check_quality(ctx, files=PY_SRC): +def check_quality(ctx: Context) -> None: """Check the code quality. - Arguments: + Parameters: ctx: The context instance (passed automatically). - files: The files to check. """ - ctx.run(f"flake8 --config=config/flake8.ini {files}", title="Checking code quality", pty=PTY) + ctx.run( + ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + title=pyprefix("Checking code quality"), + ) @duty -def check_dependencies(ctx): +def check_dependencies(ctx: Context) -> None: """Check for vulnerabilities in dependencies. - Arguments: + Parameters: ctx: The context instance (passed automatically). """ - # undo possible patching - # see https://github.com/pyupio/safety/issues/348 - for module in sys.modules: # noqa: WPS528 - if module.startswith("safety.") or module == "safety": - del sys.modules[module] # noqa: WPS420 - - importlib.invalidate_caches() - - # reload original, unpatched safety - from safety.formatter import SafetyFormatter - from safety.safety import calculate_remediations - from safety.safety import check as safety_check - from safety.util import read_requirements - # retrieve the list of dependencies requirements = ctx.run( ["pdm", "export", "-f", "requirements", "--without-hashes"], @@ -150,54 +92,40 @@ def check_dependencies(ctx): allow_overrides=False, ) - # check using safety as a library - def safety(): # noqa: WPS430 - packages = list(read_requirements(StringIO(requirements))) - vulns, db_full = safety_check(packages=packages, ignore_vulns="") - remediations = calculate_remediations(vulns, db_full) - output_report = SafetyFormatter("text").render_vulnerabilities( - announcements=[], - vulnerabilities=vulns, - remediations=remediations, - full=True, - packages=packages, - ) - if vulns: - print(output_report) - return False - return True - - ctx.run(safety, title="Checking dependencies") + ctx.run(safety.check(requirements), title="Checking dependencies") @duty -def check_docs(ctx): +def check_docs(ctx: Context) -> None: """Check if the documentation builds correctly. - Arguments: + Parameters: ctx: The context instance (passed automatically). """ Path("htmlcov").mkdir(parents=True, exist_ok=True) Path("htmlcov/index.html").touch(exist_ok=True) - ctx.run("mkdocs build -s", title="Building documentation", pty=PTY) + ctx.run(mkdocs.build(strict=True), title=pyprefix("Building documentation")) -@duty # noqa: WPS231 -def check_types(ctx): # noqa: WPS231 +@duty +def check_types(ctx: Context) -> None: """Check that the code is correctly typed. - Arguments: + Parameters: ctx: The context instance (passed automatically). """ os.environ["MYPYPATH"] = "src" - ctx.run(f"mypy --config-file config/mypy.ini {PY_SRC}", title="Type-checking", pty=PTY) + ctx.run( + mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"), + title=pyprefix("Type-checking"), + ) @duty(silent=True) -def clean(ctx): +def clean(ctx: Context) -> None: """Delete temporary files. - Arguments: + Parameters: ctx: The context instance (passed automatically). """ ctx.run("rm -rf .coverage*") @@ -214,59 +142,66 @@ def clean(ctx): @duty -def docs(ctx): +def docs(ctx: Context) -> None: """Build the documentation locally. - Arguments: + Parameters: ctx: The context instance (passed automatically). """ - ctx.run("mkdocs build", title="Building documentation") + ctx.run(mkdocs.build, title="Building documentation") @duty -def docs_serve(ctx, host="127.0.0.1", port=8000): +def docs_serve(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: """Serve the documentation (localhost:8000). - Arguments: + Parameters: ctx: The context instance (passed automatically). host: The host to serve the docs from. port: The port to serve the docs on. """ - ctx.run(f"mkdocs serve -a {host}:{port}", title="Serving documentation", capture=False) + ctx.run( + mkdocs.serve(dev_addr=f"{host}:{port}"), + title="Serving documentation", + capture=False, + ) @duty -def docs_deploy(ctx): +def docs_deploy(ctx: Context) -> None: """Deploy the documentation on GitHub pages. - Arguments: + 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", title="Deploying documentation") + ctx.run(mkdocs.gh_deploy(remote_name="org-pages"), title="Deploying documentation") @duty -def format(ctx): +def format(ctx: Context) -> None: """Run formatting tools on the code. - Arguments: + Parameters: ctx: The context instance (passed automatically). """ ctx.run( - f"autoflake -ir --exclude tests/fixtures --remove-all-unused-imports {PY_SRC}", - title="Removing unused imports", - pty=PTY, + ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + title="Auto-fixing code", + ) + ctx.run(black.run(*PY_SRC_LIST, config="config/black.toml"), title="Formatting code") + ctx.run( + blacken_docs.run(*PY_SRC_LIST, "docs", exts=["py", "md"], line_length=120), + title="Formatting docs", + nofail=True, ) - ctx.run(f"isort {PY_SRC}", title="Ordering imports", pty=PTY) - ctx.run(f"black {PY_SRC}", title="Formatting code", pty=PTY) @duty -def release(ctx, version): +def release(ctx: Context, version: str) -> None: """Release a new Python package. - Arguments: + Parameters: ctx: The context instance (passed automatically). version: The new version number to use. """ @@ -281,31 +216,29 @@ def release(ctx, version): docs_deploy.run() -@duty(silent=True) -def coverage(ctx): +@duty(silent=True, aliases=["coverage"]) +def cov(ctx: Context) -> None: """Report coverage as text and HTML. - Arguments: + 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") + ctx.run(coverage.combine, nofail=True) + ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False) + ctx.run(coverage.html(rcfile="config/coverage.ini")) @duty -def test(ctx, match: str = ""): +def test(ctx: Context, match: str = "") -> None: """Run the test suite. - Arguments: + 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", "-c", "config/pytest.ini", "-n", "auto", "-k", match, "tests"], - title="Running tests", - pty=PTY, - nofail=py_version == "311", + pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match), + title=pyprefix("Running tests"), ) diff --git a/mkdocs.yml b/mkdocs.yml index f8037a73..191526d4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,6 +39,8 @@ theme: - navigation.tabs - navigation.tabs.sticky - navigation.top + - search.highlight + - search.suggest palette: - media: "(prefers-color-scheme: light)" scheme: default @@ -80,8 +82,8 @@ plugins: - markdown-exec - gen-files: scripts: - - docs/gen_ref_nav.py - - docs/gen_redirects.py + - scripts/gen_ref_nav.py + - scripts/gen_redirects.py - literate-nav: nav_file: SUMMARY.txt - coverage @@ -94,15 +96,16 @@ plugins: - https://installer.readthedocs.io/en/stable/objects.inv # demonstration purpose in the docs - https://mkdocstrings.github.io/autorefs/objects.inv options: - docstring_style: google + separate_signature: true + merge_init_into_class: true docstring_options: - ignore_init_summary: yes - merge_init_into_class: yes - show_submodules: no + ignore_init_summary: true extra: social: - icon: fontawesome/brands/github link: https://github.com/pawamoy + - icon: fontawesome/brands/mastodon + link: https://fosstodon.org/@pawamoy - icon: fontawesome/brands/twitter link: https://twitter.com/pawamoy diff --git a/pyproject.toml b/pyproject.toml index 35aea778..744f7d57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,8 +64,9 @@ includes = ["src/mkdocstrings"] editable-backend = "editables" [tool.pdm.dev-dependencies] -duty = ["duty>=0.7"] +duty = ["duty>=0.8"] docs = [ + "black>=23.1", "mkdocs>=1.3", "mkdocs-coverage>=0.2", "mkdocs-gen-files>=0.3", @@ -77,32 +78,13 @@ docs = [ "markdown-exec>=0.5", "toml>=0.10", ] -format = [ - "autoflake>=1.4", - "black>=21.10b0", - "isort>=5.10", -] maintain = [ - "git-changelog>=0.4", + "black>=23.1", + "blacken-docs>=1.13", + "git-changelog>=1.0", ] quality = [ - # TODO: remove once importlib-metadata version conflict is resolved - "importlib-metadata<5; python_version < '3.8'", - "flake8>=4; python_version >= '3.8'", - - "darglint>=1.8", - "flake8-bandit>=2.1", - "flake8-black>=0.2", - "flake8-bugbear>=21.9", - "flake8-builtins>=1.5", - "flake8-comprehensions>=3.7", - "flake8-docstrings>=1.6", - "flake8-pytest-style>=1.5", - "flake8-string-format>=0.3", - "flake8-tidy-imports>=4.5", - "flake8-variables-names>=0.0", - "pep8-naming>=0.12", - "wps-light>=0.15", + "ruff>=0.0.246", ] tests = [ "docutils", @@ -114,24 +96,10 @@ tests = [ "sphinx", ] typing = [ - "mypy>=0.910", + "mypy>=0.911", "types-docutils", "types-markdown>=3.3", "types-pyyaml", "types-toml>=0.10", ] security = ["safety>=2"] - -[tool.black] -line-length = 120 -exclude = "tests/fixtures" - -[tool.isort] -line_length = 120 -not_skip = "__init__.py" -multi_line_output = 3 -force_single_line = false -balanced_wrapping = true -default_section = "THIRDPARTY" -known_first_party = "mkdocstrings" -include_trailing_comma = true diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py index e5bdd7a7..7f59f8f9 100644 --- a/scripts/gen_credits.py +++ b/scripts/gen_credits.py @@ -1,16 +1,22 @@ +"""Script to generate the project's credits.""" + +from __future__ import annotations + import re +import sys from itertools import chain from pathlib import Path from textwrap import dedent +from typing import Mapping, cast import toml from jinja2 import StrictUndefined from jinja2.sandbox import SandboxedEnvironment -try: - from importlib.metadata import metadata, PackageNotFoundError -except ImportError: - from importlib_metadata import metadata, PackageNotFoundError +if sys.version_info < (3, 8): + from importlib_metadata import PackageNotFoundError, metadata +else: + from importlib.metadata import PackageNotFoundError, metadata project_dir = Path(".") pyproject = toml.load(project_dir / "pyproject.toml") @@ -21,31 +27,33 @@ project_name = project["name"] regex = re.compile(r"(?P[\w.-]+)(?P.*)$") -def get_license(pkg_name): + +def _get_license(pkg_name: str) -> str: try: data = metadata(pkg_name) except PackageNotFoundError: return "?" - license = data.get("License", "").strip() - multiple_lines = bool(license.count("\n")) + license_name = cast(dict, data).get("License", "").strip() + multiple_lines = bool(license_name.count("\n")) # TODO: remove author logic once all my packages licenses are fixed author = "" - if multiple_lines or not license or license == "UNKNOWN": - for header, value in data.items(): + if multiple_lines or not license_name or license_name == "UNKNOWN": + for header, value in cast(dict, data).items(): if header == "Classifier" and value.startswith("License ::"): - license = value.rsplit("::", 1)[1].strip() + license_name = value.rsplit("::", 1)[1].strip() elif header == "Author-email": author = value - if license == "Other/Proprietary License" and "pawamoy" in author: - license = "ISC" - return license or "?" + if license_name == "Other/Proprietary License" and "pawamoy" in author: + license_name = "ISC" + return license_name or "?" -def get_deps(base_deps): + +def _get_deps(base_deps: Mapping[str, Mapping[str, str]]) -> dict[str, dict[str, str]]: deps = {} for dep in base_deps: - parsed = regex.match(dep).groupdict() + parsed = regex.match(dep).groupdict() # type: ignore[union-attr] dep_name = parsed["dist"].lower() - deps[dep_name] = {"license": get_license(dep_name), **parsed, **lock_pkgs[dep_name]} + deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]} again = True while again: @@ -53,58 +61,63 @@ def get_deps(base_deps): for pkg_name in lock_pkgs: if pkg_name in deps: for pkg_dependency in lock_pkgs[pkg_name].get("dependencies", []): - parsed = regex.match(pkg_dependency).groupdict() + 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"]: - deps[dep_name] = {"license": get_license(dep_name), **parsed, **lock_pkgs[dep_name]} + deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]} again = True return deps -dev_dependencies = get_deps(chain(*pdm.get("dev-dependencies", {}).values())) -prod_dependencies = get_deps( - chain( - project.get("dependencies", []), - chain(*project.get("optional-dependencies", {}).values()), + +def _render_credits() -> str: + dev_dependencies = _get_deps(chain(*pdm.get("dev-dependencies", {}).values())) # type: ignore[arg-type] + prod_dependencies = _get_deps( + chain( # type: ignore[arg-type] + project.get("dependencies", []), + chain(*project.get("optional-dependencies", {}).values()), + ), ) -) - -template_data = { - "project_name": project_name, - "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: dep["name"]), - "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: dep["name"]), - "more_credits": "http://pawamoy.github.io/credits/", -} -template_text = dedent( - """ - These projects were used to build `{{ project_name }}`. **Thank you!** - - [`python`](https://www.python.org/) | - [`pdm`](https://pdm.fming.dev/) | - [`copier-pdm`](https://github.com/pawamoy/copier-pdm) - - {% macro dep_line(dep) -%} - [`{{ dep.name }}`](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} - {%- endmacro %} - - ### Runtime dependencies - - Project | Summary | Version (accepted) | Version (last resolved) | License - ------- | ------- | ------------------ | ----------------------- | ------- - {% for dep in prod_dependencies -%} - {{ dep_line(dep) }} - {% endfor %} - - ### Development dependencies - - Project | Summary | Version (accepted) | Version (last resolved) | License - ------- | ------- | ------------------ | ----------------------- | ------- - {% for dep in dev_dependencies -%} - {{ dep_line(dep) }} - {% endfor %} - - {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} - """ -) -jinja_env = SandboxedEnvironment(undefined=StrictUndefined) -print(jinja_env.from_string(template_text).render(**template_data)) + + template_data = { + "project_name": project_name, + "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: dep["name"]), + "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: dep["name"]), + "more_credits": "http://pawamoy.github.io/credits/", + } + template_text = dedent( + """ + These projects were used to build `{{ project_name }}`. **Thank you!** + + [`python`](https://www.python.org/) | + [`pdm`](https://pdm.fming.dev/) | + [`copier-pdm`](https://github.com/pawamoy/copier-pdm) + + {% macro dep_line(dep) -%} + [`{{ dep.name }}`](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} + {%- endmacro %} + + ### Runtime dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in prod_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + ### Development dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in dev_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} + """, + ) + jinja_env = SandboxedEnvironment(undefined=StrictUndefined) + return jinja_env.from_string(template_text).render(**template_data) + + +print(_render_credits()) diff --git a/docs/gen_redirects.py b/scripts/gen_redirects.py similarity index 100% rename from docs/gen_redirects.py rename to scripts/gen_redirects.py diff --git a/docs/gen_ref_nav.py b/scripts/gen_ref_nav.py similarity index 96% rename from docs/gen_ref_nav.py rename to scripts/gen_ref_nav.py index 71c2dcba..65e70cfe 100644 --- a/docs/gen_ref_nav.py +++ b/scripts/gen_ref_nav.py @@ -17,7 +17,7 @@ parts = parts[:-1] doc_path = doc_path.with_name("index.md") full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1] == "__main__": + elif parts[-1].startswith("_"): continue nav[parts] = doc_path.as_posix() diff --git a/scripts/multirun.sh b/scripts/multirun.sh deleted file mode 100755 index a55d1746..00000000 --- a/scripts/multirun.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -e - -PYTHON_VERSIONS="${PYTHON_VERSIONS-3.7 3.8 3.9 3.10 3.11}" - -restore_previous_python_version() { - if pdm use -f "$1" &>/dev/null; then - echo "> Restored previous Python version: ${1##*/}" - fi -} - -if [ -n "${PYTHON_VERSIONS}" ]; then - old_python_version="$(pdm config python.path)" - echo "> Currently selected Python version: ${old_python_version##*/}" - trap "restore_previous_python_version ${old_python_version}" EXIT - for python_version in ${PYTHON_VERSIONS}; do - if pdm use -f "python${python_version}" &>/dev/null; then - echo "> pdm run $@ (python${python_version})" - pdm run "$@" - else - echo "> pdm use -f python${python_version}: Python interpreter not available?" >&2 - fi - done -else - pdm run "$@" -fi diff --git a/scripts/setup.sh b/scripts/setup.sh index 188eaebc..9e7ab1ff 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -3,36 +3,18 @@ set -e PYTHON_VERSIONS="${PYTHON_VERSIONS-3.7 3.8 3.9 3.10 3.11}" -install_with_pipx() { - if ! command -v "$1" &>/dev/null; then - if ! command -v pipx &>/dev/null; then - python3 -m pip install --user pipx - fi - pipx install "$1" +if ! command -v pdm &>/dev/null; then + if ! command -v pipx &>/dev/null; then + python3 -m pip install --user pipx fi -} - -install_with_pipx pdm - -restore_previous_python_version() { - if pdm use -f "$1" &>/dev/null; then - echo "> Restored previous Python version: ${1##*/}" - fi -} + pipx install pdm +fi +if ! pdm self list 2>/dev/null | grep -q pdm-multirun; then + pipx inject pdm pdm-multirun +fi if [ -n "${PYTHON_VERSIONS}" ]; then - if old_python_version="$(pdm config python.path 2>/dev/null)"; then - echo "> Currently selected Python version: ${old_python_version##*/}" - trap "restore_previous_python_version ${old_python_version}" EXIT - fi - for python_version in ${PYTHON_VERSIONS}; do - if pdm use -f "python${python_version}" &>/dev/null; then - echo "> Using Python ${python_version} interpreter" - pdm install - else - echo "> pdm use -f python${python_version}: Python interpreter not available?" >&2 - fi - done + pdm multirun -vi ${PYTHON_VERSIONS// /,} pdm install else pdm install fi diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py index 8c21eaba..62d837ca 100644 --- a/src/mkdocstrings/extension.py +++ b/src/mkdocstrings/extension.py @@ -21,25 +21,31 @@ option_x: etc ``` """ + +from __future__ import annotations + import functools import re from collections import ChainMap -from typing import Any, MutableSequence, Tuple +from typing import TYPE_CHECKING, Any, MutableSequence from xml.etree.ElementTree import Element import yaml from jinja2.exceptions import TemplateNotFound -from markdown import Markdown -from markdown.blockparser import BlockParser from markdown.blockprocessors import BlockProcessor from markdown.extensions import Extension from markdown.treeprocessors import Treeprocessor from mkdocs.exceptions import PluginError -from mkdocs_autorefs.plugin import AutorefsPlugin from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers from mkdocstrings.loggers import get_logger +if TYPE_CHECKING: + from markdown import Markdown + from markdown.blockparser import BlockParser + from mkdocs_autorefs.plugin import AutorefsPlugin + + log = get_logger(__name__) @@ -56,7 +62,12 @@ class AutoDocProcessor(BlockProcessor): regex = re.compile(r"^(?P#{1,6} *|)::: ?(?P.+?) *$", flags=re.MULTILINE) def __init__( - self, parser: BlockParser, md: Markdown, config: dict, handlers: Handlers, autorefs: AutorefsPlugin + self, + parser: BlockParser, + md: Markdown, + config: dict, + handlers: Handlers, + autorefs: AutorefsPlugin, ) -> None: """Initialize the object. @@ -75,7 +86,7 @@ def __init__( self._autorefs = autorefs self._updated_envs: set = set() - def test(self, parent: Element, block: str) -> bool: + def test(self, parent: Element, block: str) -> bool: # noqa: ARG002 """Match our autodoc instructions. Arguments: @@ -124,15 +135,15 @@ def run(self, parent: Element, blocks: MutableSequence[str]) -> None: page = self._autorefs.current_page if page: for heading in headings: - anchor = heading.attrib["id"] # noqa: WPS440 - self._autorefs.register_anchor(page, anchor) # noqa: WPS441 + anchor = heading.attrib["id"] + self._autorefs.register_anchor(page, anchor) if "data-role" in heading.attrib: self._handlers.inventory.register( - name=anchor, # noqa: WPS441 + name=anchor, domain=handler.domain, role=heading.attrib["data-role"], - uri=f"{page}#{anchor}", # noqa: WPS441 + uri=f"{page}#{anchor}", ) parent.append(el) @@ -148,13 +159,13 @@ def _process_block( identifier: str, yaml_block: str, heading_level: int = 0, - ) -> Tuple[str, BaseHandler, CollectorItem]: + ) -> tuple[str, BaseHandler, CollectorItem]: """Process an autodoc block. Arguments: identifier: The identifier of the object to collect and render. yaml_block: The YAML configuration. - heading_level: Suggested level of the the heading to insert (0 to ignore). + heading_level: Suggested level of the heading to insert (0 to ignore). Raises: PluginError: When something wrong happened during collection. @@ -187,14 +198,14 @@ def _process_block( try: data: CollectorItem = handler.collect(identifier, options) except CollectionError as exception: - log.error(str(exception)) + log.error(str(exception)) # noqa: TRY400 if PluginError is SystemExit: # When MkDocs 1.2 is sufficiently common, this can be dropped. - log.error(f"Error reading page '{self._autorefs.current_page}':") + log.error(f"Error reading page '{self._autorefs.current_page}':") # noqa: TRY400 raise PluginError(f"Could not collect '{identifier}'") from exception if handler_name not in self._updated_envs: # We haven't seen this handler before on this document. log.debug("Updating renderer's env") - handler._update_env(self.md, self._config) # noqa: WPS437 (protected member OK) + handler._update_env(self.md, self._config) self._updated_envs.add(handler_name) log.debug("Rendering templates") @@ -202,7 +213,7 @@ def _process_block( rendered = handler.render(data, options) except TemplateNotFound as exc: theme_name = self._config["theme_name"] - log.error( + log.error( # noqa: TRY400 f"Template '{exc.name}' not found for '{handler_name}' handler and theme '{theme_name}'.", ) raise @@ -211,12 +222,12 @@ def _process_block( @classmethod @functools.lru_cache(maxsize=None) # Warn only once - def _warn_about_options_key(cls): + def _warn_about_options_key(cls) -> None: log.info("DEPRECATION: 'selection' and 'rendering' are deprecated and merged into a single 'options' YAML key") class _PostProcessor(Treeprocessor): - def run(self, root: Element): + def run(self, root: Element) -> None: carry_text = "" for el in reversed(root): # Reversed mainly for the ability to mutate during iteration. if el.tag == "div" and el.get("class") == "mkdocstrings": diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/handlers/base.py index 88e3de42..51efd775 100644 --- a/src/mkdocstrings/handlers/base.py +++ b/src/mkdocstrings/handlers/base.py @@ -14,7 +14,7 @@ import warnings from contextlib import suppress from pathlib import Path -from typing import Any, BinaryIO, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence +from typing import Any, BinaryIO, Iterable, Iterator, Mapping, MutableMapping, Sequence from xml.etree.ElementTree import Element, tostring from jinja2 import Environment, FileSystemLoader @@ -38,7 +38,7 @@ class CollectionError(Exception): """An exception raised when some collection of data failed.""" -class ThemeNotSupported(Exception): +class ThemeNotSupported(Exception): # noqa: N818 """An exception raised to tell a theme is not supported.""" @@ -75,7 +75,7 @@ class BaseRenderer: fallback_theme: str = "" extra_css = "" - def __init__(self, handler: str, theme: str, custom_templates: Optional[str] = None) -> None: + def __init__(self, handler: str, theme: str, custom_templates: str | None = None) -> None: """Initialize the object. If the given theme is not supported (it does not exist), it will look for a `fallback_theme` attribute @@ -102,7 +102,7 @@ def __init__(self, handler: str, theme: str, custom_templates: Optional[str] = N for path in paths: css_path = path / "style.css" if css_path.is_file(): - self.extra_css += "\n" + css_path.read_text(encoding="utf-8") # noqa: WPS601 + self.extra_css += "\n" + css_path.read_text(encoding="utf-8") break if custom_templates is not None: @@ -116,19 +116,19 @@ def __init__(self, handler: str, theme: str, custom_templates: Optional[str] = N self.env.filters["any"] = do_any self.env.globals["log"] = get_template_logger() - self._headings: List[Element] = [] - self._md: Markdown = None # type: ignore # To be populated in `update_env`. + self._headings: list[Element] = [] + self._md: Markdown = None # type: ignore[assignment] # To be populated in `update_env`. def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str: """Render a template using provided data and configuration options. Arguments: data: The collected data to render. - config: The handler's configuraton options. + config: The handler's configuration options. Returns: The rendered template as HTML. - """ # noqa: DAR202,DAR401 + """ raise NotImplementedError def get_templates_dir(self, handler: str) -> Path: @@ -156,7 +156,7 @@ def get_templates_dir(self, handler: str) -> Path: with suppress(ModuleNotFoundError): # TODO: catch at some point to warn about missing handlers import mkdocstrings_handlers - for path in mkdocstrings_handlers.__path__: # noqa: WPS609 + for path in mkdocstrings_handlers.__path__: theme_path = Path(path, handler, "templates") if theme_path.exists(): return theme_path @@ -165,7 +165,7 @@ def get_templates_dir(self, handler: str) -> Path: # as mkdocstrings will stop being a namespace package import mkdocstrings - for path in mkdocstrings.__path__: # noqa: WPS609,WPS440 + for path in mkdocstrings.__path__: theme_path = Path(path, "templates", handler) if theme_path.exists(): if handler != "python": @@ -173,12 +173,13 @@ def get_templates_dir(self, handler: str) -> Path: "Exposing templates in the mkdocstrings.templates namespace is deprecated. " "Put them in a templates folder inside your handler package instead.", DeprecationWarning, + stacklevel=1, ) return theme_path raise FileNotFoundError(f"Can't find 'templates' folder for handler '{handler}'") - def get_anchors(self, data: CollectorItem) -> Sequence[str]: + def get_anchors(self, data: CollectorItem) -> tuple[str, ...] | set[str]: """Return the possible identifiers (HTML anchors) for a collected item. Arguments: @@ -189,12 +190,17 @@ def get_anchors(self, data: CollectorItem) -> Sequence[str]: """ # TODO: remove this at some point try: - return (self.get_anchor(data),) # type: ignore + return (self.get_anchor(data),) # type: ignore[attr-defined] except AttributeError: return () def do_convert_markdown( - self, text: str, heading_level: int, html_id: str = "", *, strip_paragraph: bool = False + self, + text: str, + heading_level: int, + html_id: str = "", + *, + strip_paragraph: bool = False, ) -> Markup: """Render Markdown text; for use inside templates. @@ -221,12 +227,12 @@ def do_convert_markdown( def do_heading( self, - content: str, + content: Markup, heading_level: int, *, - role: Optional[str] = None, + role: str | None = None, hidden: bool = False, - toc_label: Optional[str] = None, + toc_label: str | None = None, **attributes: str, ) -> Markup: """Render an HTML heading and register it for the table of contents. For use inside templates. @@ -245,7 +251,7 @@ def do_heading( # First, produce the "fake" heading, for ToC only. el = Element(f"h{heading_level}", attributes) if toc_label is None: - toc_label = content.unescape() if isinstance(el, Markup) else content # type: ignore + toc_label = content.unescape() if isinstance(content, Markup) else content el.set("data-toc-label", toc_label) if role: el.set("data-role", role) @@ -269,7 +275,7 @@ def do_heading( # of the heading with a placeholder that can never occur (text can't directly contain angle brackets). # Now this HTML wrapper can be "filled" by replacing the placeholder. html_with_placeholder = tostring(el, encoding="unicode") - assert ( + assert ( # noqa: S101 html_with_placeholder.count("") == 1 ), f"Bug in mkdocstrings: failed to replace in {html_with_placeholder!r}" html = html_with_placeholder.replace("", content) @@ -285,7 +291,7 @@ def get_headings(self) -> Sequence[Element]: self._headings.clear() return result - def update_env(self, md: Markdown, config: dict) -> None: # noqa: W0613 (unused argument 'config') + def update_env(self, md: Markdown, config: dict) -> None: # noqa: ARG002 """Update the Jinja environment. Arguments: @@ -298,7 +304,7 @@ def update_env(self, md: Markdown, config: dict) -> None: # noqa: W0613 (unused self.env.filters["convert_markdown"] = self.do_convert_markdown self.env.filters["heading"] = self.do_heading - def _update_env(self, md: Markdown, config: dict): + def _update_env(self, md: Markdown, config: dict) -> None: """Update our handler to point to our configured Markdown instance, grabbing some of the config from `md`.""" extensions = config["mdx"] + [MkdocstringsInnerExtension(self._headings)] @@ -319,7 +325,7 @@ class BaseCollector: You can also implement the `teardown` method. """ - def collect(self, identifier: str, config: Mapping[str, Any]) -> CollectorItem: + def collect(self, identifier: str, config: MutableMapping[str, Any]) -> CollectorItem: """Collect data given an identifier and selection configuration. In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into @@ -329,11 +335,11 @@ def collect(self, identifier: str, config: Mapping[str, Any]) -> CollectorItem: identifier: An identifier for which to collect data. For example, in Python, it would be 'mkdocstrings.handlers' to collect documentation about the handlers module. It can be anything that you can feed to the tool of your choice. - config: The handler's configuraton options. + config: The handler's configuration options. Returns: Anything you want, as long as you can feed it to the renderer's `render` method. - """ # noqa: DAR202,DAR401 + """ raise NotImplementedError def teardown(self) -> None: @@ -373,28 +379,13 @@ def __init__(self, *args: str | BaseCollector | BaseRenderer, **kwargs: str | Ba **kwargs: Same thing, but with keyword arguments. Raises: - ValueError: When the givin parameters are invalid. + ValueError: When the given parameters are invalid. """ # The method accepts *args and **kwargs temporarily, # to support the transition period where the BaseCollector # and BaseRenderer are deprecated, and the BaseHandler # can be instantiated with both instances of collector/renderer, # or renderer parameters, as positional parameters. - # Supported: - # handler = Handler(collector, renderer) - # handler = Handler(collector=collector, renderer=renderer) - # handler = Handler("python", "material") - # handler = Handler("python", "material", "templates") - # handler = Handler(handler="python", theme="material") - # handler = Handler(handler="python", theme="material", custom_templates="templates") - # Invalid: - # handler = Handler("python", "material", collector, renderer) - # handler = Handler("python", theme="material", collector=collector) - # handler = Handler(collector, renderer, "material") - # handler = Handler(collector, renderer, theme="material") - # handler = Handler(collector) - # handler = Handler(renderer) - # etc. collector = None renderer = None @@ -409,7 +400,7 @@ def __init__(self, *args: str | BaseCollector | BaseRenderer, **kwargs: str | Ba elif isinstance(arg, str): str_args.append(arg) - while len(str_args) != 3: + while len(str_args) != 3: # noqa: PLR2004 str_args.append(None) # type: ignore[arg-type] handler, theme, custom_templates = str_args @@ -434,55 +425,57 @@ def __init__(self, *args: str | BaseCollector | BaseRenderer, **kwargs: str | Ba DeprecationWarning( "The BaseCollector class is deprecated, and passing an instance of it " "to your handler is deprecated as well. Instead, define the `collect` and `teardown` " - "methods directly on your handler class." - ) + "methods directly on your handler class.", + ), + stacklevel=1, ) self.collector = collector - self.collect = collector.collect # type: ignore[assignment] - self.teardown = collector.teardown # type: ignore[assignment] + self.collect = collector.collect # type: ignore[method-assign] + self.teardown = collector.teardown # type: ignore[method-assign] if renderer is not None: if {handler, theme, custom_templates} != {None}: raise ValueError( - "'handler', 'theme' and 'custom_templates' must all be None when providing a renderer instance" + "'handler', 'theme' and 'custom_templates' must all be None when providing a renderer instance", ) warnings.warn( DeprecationWarning( "The BaseRenderer class is deprecated, and passing an instance of it " "to your handler is deprecated as well. Instead, define the `render` method " "directly on your handler class (as well as other methods and attributes like " - "`get_templates_dir`, `get_anchors`, `update_env` and `fallback_theme`, `extra_css`)." - ) + "`get_templates_dir`, `get_anchors`, `update_env` and `fallback_theme`, `extra_css`).", + ), + stacklevel=1, ) self.renderer = renderer - self.render = renderer.render # type: ignore[assignment] - self.get_templates_dir = renderer.get_templates_dir # type: ignore[assignment] - self.get_anchors = renderer.get_anchors # type: ignore[assignment] - self.do_convert_markdown = renderer.do_convert_markdown # type: ignore[assignment] - self.do_heading = renderer.do_heading # type: ignore[assignment] - self.get_headings = renderer.get_headings # type: ignore[assignment] - self.update_env = renderer.update_env # type: ignore[assignment] - self._update_env = renderer._update_env # type: ignore[assignment] # noqa: WPS437 + self.render = renderer.render # type: ignore[method-assign] + self.get_templates_dir = renderer.get_templates_dir # type: ignore[method-assign] + self.get_anchors = renderer.get_anchors # type: ignore[method-assign] + self.do_convert_markdown = renderer.do_convert_markdown # type: ignore[method-assign] + self.do_heading = renderer.do_heading # type: ignore[method-assign] + self.get_headings = renderer.get_headings # type: ignore[method-assign] + self.update_env = renderer.update_env # type: ignore[method-assign] + self._update_env = renderer._update_env # type: ignore[method-assign] self.fallback_theme = renderer.fallback_theme self.extra_css = renderer.extra_css - renderer.__class__.__init__( # noqa: WPS609 + renderer.__class__.__init__( self, - renderer._handler, # noqa: WPS437 - renderer._theme, # noqa: WPS437 - renderer._custom_templates, # noqa: WPS437 + renderer._handler, + renderer._theme, + renderer._custom_templates, ) else: if handler is None or theme is None: raise ValueError("'handler' and 'theme' cannot be None") - BaseRenderer.__init__(self, handler, theme, custom_templates) # noqa: WPS609 + BaseRenderer.__init__(self, handler, theme, custom_templates) @classmethod def load_inventory( cls, - in_file: BinaryIO, - url: str, - base_url: Optional[str] = None, - **kwargs: Any, + in_file: BinaryIO, # noqa: ARG003 + url: str, # noqa: ARG003 + base_url: str | None = None, # noqa: ARG003 + **kwargs: Any, # noqa: ARG003 ) -> Iterator[tuple[str, str]]: """Yield items and their URLs from an inventory file streamed from `in_file`. @@ -513,10 +506,10 @@ def __init__(self, config: dict) -> None: of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. """ self._config = config - self._handlers: Dict[str, BaseHandler] = {} - self.inventory: Inventory = Inventory(project=self._config["site_name"]) + self._handlers: dict[str, BaseHandler] = {} + self.inventory: Inventory = Inventory(project=self._config["mkdocs"]["site_name"]) - def get_anchors(self, identifier: str) -> Sequence[str]: + def get_anchors(self, identifier: str) -> tuple[str, ...] | set[str]: """Return the canonical HTML anchor for the identifier, if any of the seen handlers can collect it. Arguments: @@ -563,7 +556,7 @@ def get_handler_config(self, name: str) -> dict: return handlers.get(name, {}) return {} - def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseHandler: + def get_handler(self, name: str, handler_config: dict | None = None) -> BaseHandler: """Get a handler thanks to its name. This function dynamically imports a module named "mkdocstrings.handlers.NAME", calls its @@ -577,11 +570,12 @@ def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseH Returns: An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler], - as instantiated by the `get_handler` method of the handler's module. + as instantiated by the `get_handler` method of the handler's module. """ if name not in self._handlers: if handler_config is None: handler_config = self.get_handler_config(name) + handler_config.update(self._config) try: module = importlib.import_module(f"mkdocstrings_handlers.{name}") except ModuleNotFoundError: @@ -590,13 +584,14 @@ def get_handler(self, name: str, handler_config: Optional[dict] = None) -> BaseH warnings.warn( DeprecationWarning( "Using the mkdocstrings.handlers namespace is deprecated. " - "Handlers must now use the mkdocstrings_handlers namespace." - ) + "Handlers must now use the mkdocstrings_handlers namespace.", + ), + stacklevel=1, ) self._handlers[name] = module.get_handler( theme=self._config["theme_name"], custom_templates=self._config["mkdocstrings"]["custom_templates"], - config_file_path=self._config["config_file_path"], + config_file_path=self._config["mkdocs"]["config_file_path"], **handler_config, ) return self._handlers[name] diff --git a/src/mkdocstrings/handlers/rendering.py b/src/mkdocstrings/handlers/rendering.py index 24ee6268..c3fb236a 100644 --- a/src/mkdocstrings/handlers/rendering.py +++ b/src/mkdocstrings/handlers/rendering.py @@ -1,18 +1,23 @@ """This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers.""" +from __future__ import annotations + import copy import re import textwrap -from typing import Any, Dict, List, Optional -from xml.etree.ElementTree import Element +from typing import TYPE_CHECKING, Any -from markdown import Markdown from markdown.extensions import Extension from markdown.extensions.codehilite import CodeHiliteExtension from markdown.treeprocessors import Treeprocessor from markupsafe import Markup from pymdownx.highlight import Highlight, HighlightExtension +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from markdown import Markdown + class Highlighter(Highlight): """Code highlighter that tries to match the Markdown configuration. @@ -53,7 +58,7 @@ class Highlighter(Highlight): "line_spans", "anchor_linenums", "line_anchors", - ) + ), ) def __init__(self, md: Markdown): @@ -62,7 +67,7 @@ def __init__(self, md: Markdown): Arguments: md: The Markdown instance to read configs from. """ - config: Dict[str, Any] = {} + config: dict[str, Any] = {} for ext in md.registeredExtensions: if isinstance(ext, HighlightExtension) and (ext.enabled or not config): config = ext.getConfigs() @@ -73,14 +78,14 @@ def __init__(self, md: Markdown): self._css_class = config.pop("css_class", "highlight") super().__init__(**{name: opt for name, opt in config.items() if name in self._highlight_config_keys}) - def highlight( # noqa: W0221 (intentionally different params, we're extending the functionality) + def highlight( self, src: str, - language: Optional[str] = None, + language: str | None = None, *, inline: bool = False, dedent: bool = True, - linenums: Optional[bool] = None, + linenums: bool | None = None, **kwargs: Any, ) -> str: """Highlight a code-snippet. @@ -102,7 +107,7 @@ def highlight( # noqa: W0221 (intentionally different params, we're extending t src = textwrap.dedent(src) kwargs.setdefault("css_class", self._css_class) - old_linenums = self.linenums # type: ignore + old_linenums = self.linenums # type: ignore[has-type] if linenums is not None: self.linenums = linenums try: @@ -133,7 +138,7 @@ def __init__(self, md: Markdown, id_prefix: str): super().__init__(md) self.id_prefix = id_prefix - def run(self, root: Element): # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring) if not self.id_prefix: return for el in root.iter(): @@ -174,7 +179,7 @@ def __init__(self, md: Markdown, shift_by: int): super().__init__(md) self.shift_by = shift_by - def run(self, root: Element): # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring) if not self.shift_by: return for el in root.iter(): @@ -191,20 +196,20 @@ class _HeadingReportingTreeprocessor(Treeprocessor): name = "mkdocstrings_headings_list" regex = re.compile(r"[Hh][1-6]") - headings: List[Element] + headings: list[Element] """The list (the one passed in the initializer) that is used to record the heading elements (by appending to it).""" - def __init__(self, md: Markdown, headings: List[Element]): + def __init__(self, md: Markdown, headings: list[Element]): super().__init__(md) self.headings = headings - def run(self, root: Element): + def run(self, root: Element) -> None: for el in root.iter(): if self.regex.fullmatch(el.tag): - el = copy.copy(el) + el = copy.copy(el) # noqa: PLW2901 # 'toc' extension's first pass (which we require to build heading stubs/ids) also edits the HTML. # Undo the permalink edit so we can pass this heading to the outer pass of the 'toc' extension. - if len(el) > 0 and el[-1].get("class") == self.md.treeprocessors["toc"].permalink_class: # noqa: WPS507 + if len(el) > 0 and el[-1].get("class") == self.md.treeprocessors["toc"].permalink_class: del el[-1] self.headings.append(el) @@ -215,17 +220,18 @@ class ParagraphStrippingTreeprocessor(Treeprocessor): name = "mkdocstrings_strip_paragraph" strip = False - def run(self, root: Element): # noqa: D102 (ignore missing docstring) + def run(self, root: Element) -> Element | None: # noqa: D102 (ignore missing docstring) if self.strip and len(root) == 1 and root[0].tag == "p": # Turn the single

element into the root element and inherit its tag name (it's significant!) root[0].tag = root.tag return root[0] + return None class MkdocstringsInnerExtension(Extension): """Extension that should always be added to Markdown sub-documents that handlers request (and *only* them).""" - def __init__(self, headings: List[Element]): + def __init__(self, headings: list[Element]): """Initialize the object. Arguments: diff --git a/src/mkdocstrings/inventory.py b/src/mkdocstrings/inventory.py index 9108d91d..42e9b3db 100644 --- a/src/mkdocstrings/inventory.py +++ b/src/mkdocstrings/inventory.py @@ -3,17 +3,25 @@ # Credits to Brian Skinn and the sphobjinv project: # https://github.com/bskinn/sphobjinv +from __future__ import annotations + import re import zlib from textwrap import dedent -from typing import BinaryIO, Collection, List, Optional +from typing import BinaryIO, Collection class InventoryItem: """Inventory item.""" def __init__( - self, name: str, domain: str, role: str, uri: str, priority: str = "1", dispname: Optional[str] = None + self, + name: str, + domain: str, + role: str, + uri: str, + priority: str = "1", + dispname: str | None = None, ): """Initialize the object. @@ -49,7 +57,7 @@ def format_sphinx(self) -> str: sphinx_item_regex = re.compile(r"^(.+?)\s+(\S+):(\S+)\s+(-?\d+)\s+(\S+)\s*(.*)$") @classmethod - def parse_sphinx(cls, line: str) -> "InventoryItem": + def parse_sphinx(cls, line: str) -> InventoryItem: """Parse a line from a Sphinx v2 inventory file and return an `InventoryItem` from it.""" match = cls.sphinx_item_regex.search(line) if not match: @@ -65,7 +73,7 @@ def parse_sphinx(cls, line: str) -> "InventoryItem": class Inventory(dict): """Inventory of collected and rendered objects.""" - def __init__(self, items: Optional[List[InventoryItem]] = None, project: str = "project", version: str = "0.0.0"): + def __init__(self, items: list[InventoryItem] | None = None, project: str = "project", version: str = "0.0.0"): """Initialize the object. Arguments: @@ -80,7 +88,7 @@ def __init__(self, items: Optional[List[InventoryItem]] = None, project: str = " self.project = project self.version = version - def register(self, *args: str, **kwargs: str): + def register(self, *args: str, **kwargs: str) -> None: """Create and register an item. Arguments: @@ -103,7 +111,7 @@ def format_sphinx(self) -> bytes: # Project: {self.project} # Version: {self.version} # The remainder of this file is compressed using zlib. - """ + """, ) .lstrip() .encode("utf8") @@ -113,7 +121,7 @@ def format_sphinx(self) -> bytes: return header + zlib.compress(b"\n".join(lines) + b"\n", 9) @classmethod - def parse_sphinx(cls, in_file: BinaryIO, *, domain_filter: Collection[str] = ()) -> "Inventory": + def parse_sphinx(cls, in_file: BinaryIO, *, domain_filter: Collection[str] = ()) -> Inventory: """Parse a Sphinx v2 inventory file and return an `Inventory` from it. Arguments: diff --git a/src/mkdocstrings/loggers.py b/src/mkdocstrings/loggers.py index 24004f8f..5e3f42bb 100644 --- a/src/mkdocstrings/loggers.py +++ b/src/mkdocstrings/loggers.py @@ -1,24 +1,29 @@ """Logging functions.""" +from __future__ import annotations + import logging from contextlib import suppress from pathlib import Path -from typing import Any, Callable, MutableMapping, Optional, Sequence, Tuple +from typing import TYPE_CHECKING, Any, Callable, MutableMapping, Sequence -from jinja2.runtime import Context from mkdocs.utils import warning_filter try: from jinja2 import pass_context except ImportError: # TODO: remove once Jinja2 < 3.1 is dropped - from jinja2 import contextfunction as pass_context # type: ignore # noqa: WPS440 + from jinja2 import contextfunction as pass_context # type: ignore[attr-defined,no-redef] try: import mkdocstrings_handlers except ImportError: TEMPLATES_DIRS: Sequence[Path] = () else: - TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__) # type: ignore[arg-type] # noqa: WPS609 + TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__) # type: ignore[arg-type] + + +if TYPE_CHECKING: + from jinja2.runtime import Context class LoggerAdapter(logging.LoggerAdapter): @@ -34,7 +39,7 @@ def __init__(self, prefix: str, logger: logging.Logger): super().__init__(logger, {}) self.prefix = prefix - def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> Tuple[str, Any]: + def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, Any]: """Process the message. Arguments: @@ -82,7 +87,7 @@ def get_template_logger_function(logger_func: Callable) -> Callable: """ @pass_context - def wrapper(context: Context, msg: Optional[str] = None) -> str: + def wrapper(context: Context, msg: str | None = None) -> str: """Log a message. Arguments: diff --git a/src/mkdocstrings/plugin.py b/src/mkdocstrings/plugin.py index bbb5ee8a..a35309c8 100644 --- a/src/mkdocstrings/plugin.py +++ b/src/mkdocstrings/plugin.py @@ -12,17 +12,18 @@ during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve). """ +from __future__ import annotations + import collections import functools import gzip import os +import sys from concurrent import futures -from typing import Any, BinaryIO, Callable, Iterable, List, Mapping, Optional, Tuple +from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Iterable, List, Mapping, Tuple, TypeVar from urllib import request -from mkdocs.config import Config from mkdocs.config.config_options import Type as MkType -from mkdocs.livereload import LiveReloadServer from mkdocs.plugins import BasePlugin from mkdocs.utils import write_file from mkdocs_autorefs.plugin import AutorefsPlugin @@ -31,25 +32,38 @@ from mkdocstrings.handlers.base import BaseHandler, Handlers from mkdocstrings.loggers import get_logger +if TYPE_CHECKING: + from jinja2.environment import Environment + from mkdocs.config import Config + from mkdocs.livereload import LiveReloadServer + +if sys.version_info < (3, 10): + from typing_extensions import ParamSpec +else: + from typing import ParamSpec + log = get_logger(__name__) SELECTION_OPTS_KEY: str = "selection" -"""The name of the selection parameter in YAML configuration blocks.""" +"""Deprecated. The name of the selection parameter in YAML configuration blocks.""" RENDERING_OPTS_KEY: str = "rendering" -"""The name of the rendering parameter in YAML configuration blocks.""" +"""Deprecated. The name of the rendering parameter in YAML configuration blocks.""" InventoryImportType = List[Tuple[str, Mapping[str, Any]]] InventoryLoaderType = Callable[..., Iterable[Tuple[str, str]]] +P = ParamSpec("P") +R = TypeVar("R") -def list_to_tuple(function: Callable[..., Any]) -> Callable[..., Any]: + +def list_to_tuple(function: Callable[P, R]) -> Callable[P, R]: """Decorater to convert lists to tuples in the arguments.""" - def wrapper(*args: Any, **kwargs: Any): + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: safe_args = [tuple(item) if isinstance(item, list) else item for item in args] if kwargs: - kwargs = {key: tuple(value) if isinstance(value, list) else value for key, value in kwargs.items()} - return function(*safe_args, **kwargs) + kwargs = {key: tuple(value) if isinstance(value, list) else value for key, value in kwargs.items()} # type: ignore[assignment] + return function(*safe_args, **kwargs) # type: ignore[arg-type] return wrapper @@ -68,8 +82,8 @@ class MkdocstringsPlugin(BasePlugin): for more information about its plugin system. """ - config_scheme: Tuple[Tuple[str, MkType]] = ( - ("watch", MkType(list, default=[])), # type: ignore + config_scheme: tuple[tuple[str, MkType]] = ( + ("watch", MkType(list, default=[])), # type: ignore[assignment] ("handlers", MkType(dict, default={})), ("default_handler", MkType(str, default="python")), ("custom_templates", MkType(str, default=None)), @@ -108,7 +122,7 @@ class MkdocstringsPlugin(BasePlugin): def __init__(self) -> None: """Initialize the object.""" super().__init__() - self._handlers: Optional[Handlers] = None + self._handlers: Handlers | None = None @property def handlers(self) -> Handlers: @@ -126,8 +140,13 @@ def handlers(self) -> Handlers: # TODO: remove once watch feature is removed def on_serve( - self, server: LiveReloadServer, config: Config, builder: Callable, *args: Any, **kwargs: Any - ) -> None: # noqa: W0613 (unused arguments) + self, + server: LiveReloadServer, + config: Config, # noqa: ARG002 + builder: Callable, + *args: Any, # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> None: """Watch directories. Hook for the [`on_serve` event](https://www.mkdocs.org/user-guide/plugins/#on_serve). @@ -149,7 +168,7 @@ def on_serve( log.debug(f"Adding directory '{element}' to watcher") server.watch(element, builder) - def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: W0613 (unused arguments) + def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: ARG002 """Instantiate our Markdown extension. Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config). @@ -171,30 +190,25 @@ def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: W0613 (un return config log.debug("Adding extension to the list") - theme_name = None - if config["theme"].name is None: - theme_name = os.path.dirname(config["theme"].dirs[0]) - else: - theme_name = config["theme"].name + theme_name = config["theme"].name or os.path.dirname(config["theme"].dirs[0]) to_import: InventoryImportType = [] for handler_name, conf in self.config["handlers"].items(): for import_item in conf.pop("import", ()): if isinstance(import_item, str): - import_item = {"url": import_item} + import_item = {"url": import_item} # noqa: PLW2901 to_import.append((handler_name, import_item)) extension_config = { - "site_name": config["site_name"], - "config_file_path": config["config_file_path"], "theme_name": theme_name, "mdx": config["markdown_extensions"], "mdx_configs": config["mdx_configs"], "mkdocstrings": self.config, + "mkdocs": config, } self._handlers = Handlers(extension_config) - try: # noqa: WPS229 + try: # If autorefs plugin is explicitly enabled, just use it. autorefs = config["plugins"]["autorefs"] log.debug(f"Picked up existing autorefs instance {autorefs!r}") @@ -215,9 +229,11 @@ def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: W0613 (un self._inv_futures = [] if to_import: inv_loader = futures.ThreadPoolExecutor(4) - for handler_name, import_item in to_import: # noqa: WPS440 + for handler_name, import_item in to_import: future = inv_loader.submit( - self._load_inventory, self.get_handler(handler_name).load_inventory, **import_item + self._load_inventory, + self.get_handler(handler_name).load_inventory, + **import_item, ) self._inv_futures.append(future) inv_loader.shutdown(wait=False) @@ -248,7 +264,7 @@ def plugin_enabled(self) -> bool: """ return self.config["enabled"] - def on_env(self, env, config: Config, *args, **kwargs) -> None: + def on_env(self, env: Environment, config: Config, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 """Extra actions that need to happen after all Markdown rendering and before HTML rendering. Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env). @@ -275,8 +291,10 @@ def on_env(self, env, config: Config, *args, **kwargs) -> None: self._inv_futures = [] def on_post_build( - self, config: Config, **kwargs: Any - ) -> None: # noqa: W0613,R0201 (unused arguments, cannot be static) + self, + config: Config, # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> None: """Teardown the handlers. Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build). @@ -312,9 +330,9 @@ def get_handler(self, handler_name: str) -> BaseHandler: return self.handlers.get_handler(handler_name) @classmethod + @functools.lru_cache(maxsize=None) # lru_cache does not allow mutable arguments such lists, but that is what we load from YAML config. @list_to_tuple - @functools.lru_cache(maxsize=None) def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) -> Mapping[str, str]: """Download and process inventory files using a handler. @@ -338,7 +356,7 @@ def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) - @classmethod @functools.lru_cache(maxsize=None) # Warn only once - def _warn_about_watch_option(cls): + def _warn_about_watch_option(cls) -> None: log.info( "DEPRECATION: mkdocstrings' watch feature is deprecated in favor of MkDocs' watch feature, " "see https://www.mkdocs.org/user-guide/configuration/#watch", diff --git a/tests/conftest.py b/tests/conftest.py index c79b04d0..a2ea6b26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,27 @@ """Configuration for the pytest test suite.""" +from __future__ import annotations + from collections import ChainMap +from typing import TYPE_CHECKING, Any, Iterator import pytest from markdown.core import Markdown from mkdocs import config from mkdocs.config.defaults import get_schema +if TYPE_CHECKING: + from pathlib import Path + + from mkdocstrings.plugin import MkdocstringsPlugin + @pytest.fixture(name="mkdocs_conf") -def fixture_mkdocs_conf(request, tmp_path): +def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[config.Config]: """Yield a MkDocs configuration object.""" - conf = config.Config(schema=get_schema()) - while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"): # noqa: WPS437 - request = request._parent_request # noqa: WPS437 + conf = config.Config(schema=get_schema()) # type: ignore[call-arg] + while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"): + request = request._parent_request conf_dict = { "site_name": "foo", @@ -23,7 +31,7 @@ def fixture_mkdocs_conf(request, tmp_path): **getattr(request, "param", {}), } # Re-create it manually as a workaround for https://github.com/mkdocs/mkdocs/issues/2289 - mdx_configs = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) + mdx_configs: dict[str, Any] = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) # type: ignore[arg-type] conf.load_dict(conf_dict) assert conf.validate() == ([], []) @@ -38,14 +46,12 @@ def fixture_mkdocs_conf(request, tmp_path): @pytest.fixture(name="plugin") -def fixture_plugin(mkdocs_conf): +def fixture_plugin(mkdocs_conf: config.Config) -> MkdocstringsPlugin: """Return a plugin instance.""" - plugin = mkdocs_conf["plugins"]["mkdocstrings"] - plugin.md = Markdown(extensions=mkdocs_conf["markdown_extensions"], extension_configs=mkdocs_conf["mdx_configs"]) - return plugin + return mkdocs_conf["plugins"]["mkdocstrings"] @pytest.fixture(name="ext_markdown") -def fixture_ext_markdown(plugin): +def fixture_ext_markdown(mkdocs_conf: config.Config) -> Markdown: """Return a Markdown instance with MkdocstringsExtension.""" - return plugin.md + return Markdown(extensions=mkdocs_conf["markdown_extensions"], extension_configs=mkdocs_conf["mdx_configs"]) diff --git a/tests/test_extension.py b/tests/test_extension.py index 3c41932c..f7c3cecc 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,14 +1,23 @@ """Tests for the extension module.""" + +from __future__ import annotations + import logging import re import sys from textwrap import dedent +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from markdown import Markdown + + from mkdocstrings.plugin import MkdocstringsPlugin + @pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"footnotes": {}}]}], indirect=["ext_markdown"]) -def test_multiple_footnotes(ext_markdown): +def test_multiple_footnotes(ext_markdown: Markdown) -> None: """Assert footnotes don't get added to subsequent docstrings.""" output = ext_markdown.convert( dedent( @@ -30,7 +39,7 @@ def test_multiple_footnotes(ext_markdown): assert output.count("Top footnote") == 1 -def test_markdown_heading_level(ext_markdown): +def test_markdown_heading_level(ext_markdown: Markdown) -> None: """Assert that Markdown headings' level doesn't exceed heading_level.""" output = ext_markdown.convert("::: tests.fixtures.headings\n options:\n show_root_heading: true") assert ">Foo" in output @@ -38,7 +47,7 @@ def test_markdown_heading_level(ext_markdown): assert ">Baz" in output -def test_keeps_preceding_text(ext_markdown): +def test_keeps_preceding_text(ext_markdown: Markdown) -> None: """Assert that autodoc is recognized in the middle of a block and preceding text is kept.""" output = ext_markdown.convert("**preceding**\n::: tests.fixtures.headings") assert "preceding" in output @@ -46,21 +55,21 @@ def test_keeps_preceding_text(ext_markdown): assert ":::" not in output -def test_reference_inside_autodoc(ext_markdown): +def test_reference_inside_autodoc(ext_markdown: Markdown) -> None: """Assert cross-reference Markdown extension works correctly.""" output = ext_markdown.convert("::: tests.fixtures.cross_reference") assert re.search(r"Link to <.*something\.Else.*>something\.Else<.*>\.", output) @pytest.mark.skipif(sys.version_info < (3, 8), reason="typing.Literal requires Python 3.8") -def test_quote_inside_annotation(ext_markdown): +def test_quote_inside_annotation(ext_markdown: Markdown) -> None: """Assert that inline highlighting doesn't double-escape HTML.""" output = ext_markdown.convert("::: tests.fixtures.string_annotation.Foo") assert ";hi&" in output assert "&" not in output -def test_html_inside_heading(ext_markdown): +def test_html_inside_heading(ext_markdown: Markdown) -> None: """Assert that headings don't double-escape HTML.""" output = ext_markdown.convert("::: tests.fixtures.html_tokens") assert "'<" in output @@ -76,7 +85,7 @@ def test_html_inside_heading(ext_markdown): ], indirect=["ext_markdown"], ) -def test_no_double_toc(ext_markdown, expect_permalink): +def test_no_double_toc(ext_markdown: Markdown, expect_permalink: str) -> None: """Assert that the 'toc' extension doesn't apply its modification twice.""" output = ext_markdown.convert( dedent( @@ -88,12 +97,12 @@ def test_no_double_toc(ext_markdown, expect_permalink): show_root_toc_entry: false # bb - """ - ) + """, + ), ) assert output.count(expect_permalink) == 5 assert 'id="tests.fixtures.headings--foo"' in output - assert ext_markdown.toc_tokens == [ # noqa: E1101 (the member gets populated only with 'toc' extension) + assert ext_markdown.toc_tokens == [ # type: ignore[attr-defined] # the member gets populated only with 'toc' extension { "level": 1, "id": "aa", @@ -109,43 +118,43 @@ def test_no_double_toc(ext_markdown, expect_permalink): "id": "tests.fixtures.headings--bar", "name": "Bar", "children": [ - {"level": 6, "id": "tests.fixtures.headings--baz", "name": "Baz", "children": []} + {"level": 6, "id": "tests.fixtures.headings--baz", "name": "Baz", "children": []}, ], - } + }, ], - } + }, ], }, {"level": 1, "id": "bb", "name": "bb", "children": []}, ] -def test_use_custom_handler(ext_markdown): +def test_use_custom_handler(ext_markdown: Markdown) -> None: """Assert that we use the custom handler declared in an individual autodoc instruction.""" with pytest.raises(ModuleNotFoundError): ext_markdown.convert("::: tests.fixtures.headings\n handler: not_here") -def test_dont_register_every_identifier_as_anchor(plugin): +def test_dont_register_every_identifier_as_anchor(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> None: """Assert that we don't preemptively register all identifiers of a rendered object.""" - handler = plugin._handlers.get_handler("python") # noqa: WPS437 + handler = plugin._handlers.get_handler("python") # type: ignore[union-attr] ids = {"id1", "id2", "id3"} - handler.get_anchors = lambda _: ids - plugin.md.convert("::: tests.fixtures.headings") - autorefs = plugin.md.parser.blockprocessors["mkdocstrings"]._autorefs # noqa: WPS219,WPS437 + handler.get_anchors = lambda _: ids # type: ignore[method-assign] + ext_markdown.convert("::: tests.fixtures.headings") + autorefs = ext_markdown.parser.blockprocessors["mkdocstrings"]._autorefs for identifier in ids: - assert identifier not in autorefs._url_map # noqa: WPS437 - assert identifier not in autorefs._abs_url_map # noqa: WPS437 + assert identifier not in autorefs._url_map + assert identifier not in autorefs._abs_url_map -def test_use_deprecated_yaml_keys(ext_markdown, caplog): +def test_use_deprecated_yaml_keys(ext_markdown: Markdown, caplog: pytest.LogCaptureFixture) -> None: """Check that using the deprecated 'selection' and 'rendering' YAML keys emits a deprecation warning.""" caplog.set_level(logging.INFO) assert "h1" not in ext_markdown.convert("::: tests.fixtures.headings\n rendering:\n heading_level: 2") assert "single 'options' YAML key" in caplog.text -def test_use_new_options_yaml_key(ext_markdown): +def test_use_new_options_yaml_key(ext_markdown: Markdown) -> None: """Check that using the new 'options' YAML key works as expected.""" assert "h1" in ext_markdown.convert("::: tests.fixtures.headings\n options:\n heading_level: 1") assert "h1" not in ext_markdown.convert("::: tests.fixtures.headings\n options:\n heading_level: 2") diff --git a/tests/test_handlers.py b/tests/test_handlers.py index cfe04cd8..a0b3be3e 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,5 +1,7 @@ """Tests for the handlers.base module.""" +from __future__ import annotations + import pytest from markdown import Markdown @@ -7,14 +9,14 @@ @pytest.mark.parametrize("extension_name", ["codehilite", "pymdownx.highlight"]) -def test_highlighter_without_pygments(extension_name): +def test_highlighter_without_pygments(extension_name: str) -> None: """Assert that it's possible to disable Pygments highlighting. Arguments: extension_name: The "user-chosen" Markdown extension for syntax highlighting. """ configs = {extension_name: {"use_pygments": False, "css_class": "hiiii"}} - md = Markdown(extensions=configs, extension_configs=configs) + md = Markdown(extensions=[extension_name], extension_configs=configs) hl = Highlighter(md) assert ( hl.highlight("import foo", language="python") @@ -28,17 +30,14 @@ def test_highlighter_without_pygments(extension_name): @pytest.mark.parametrize("extension_name", [None, "codehilite", "pymdownx.highlight"]) @pytest.mark.parametrize("inline", [False, True]) -def test_highlighter_basic(extension_name, inline): +def test_highlighter_basic(extension_name: str | None, inline: bool) -> None: """Assert that Pygments syntax highlighting works. Arguments: extension_name: The "user-chosen" Markdown extension for syntax highlighting. inline: Whether the highlighting was inline. """ - configs = {} - if extension_name: - configs[extension_name] = {} - md = Markdown(extensions=configs, extension_configs=configs) + md = Markdown(extensions=[extension_name], extension_configs={extension_name: {}}) if extension_name else Markdown() hl = Highlighter(md) actual = hl.highlight("import foo", language="python", inline=inline) diff --git a/tests/test_inventory.py b/tests/test_inventory.py index afbaa9fe..ecbb3cd2 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,5 +1,7 @@ """Tests for the inventory module.""" +from __future__ import annotations + import sys from io import BytesIO from os.path import join @@ -22,7 +24,7 @@ Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#other_anchor")]), ], ) -def test_sphinx_load_inventory_file(our_inv): +def test_sphinx_load_inventory_file(our_inv: Inventory) -> None: """Perform the 'live' inventory load test.""" buffer = BytesIO(our_inv.format_sphinx()) sphinx_inv = sphinx.InventoryFile.load(buffer, "", join) @@ -35,7 +37,7 @@ def test_sphinx_load_inventory_file(our_inv): @pytest.mark.skipif(sys.version_info < (3, 7), reason="using plugins that require Python 3.7") -def test_sphinx_load_mkdocstrings_inventory_file(): +def test_sphinx_load_mkdocstrings_inventory_file() -> None: """Perform the 'live' inventory load test on mkdocstrings own inventory.""" mkdocs_config = load_config() mkdocs_config["plugins"].run_event("startup", command="build", dirty=False) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c8649dc8..b8e8d2a5 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,11 +1,17 @@ """Tests for the mkdocstrings plugin.""" +from __future__ import annotations + +from typing import TYPE_CHECKING from mkdocs.commands.build import build from mkdocs.config import load_config +if TYPE_CHECKING: + from pathlib import Path + -def test_disabling_plugin(tmp_path): +def test_disabling_plugin(tmp_path: Path) -> None: """Test disabling plugin.""" docs_dir = tmp_path / "docs" site_dir = tmp_path / "site"