diff --git a/.copier-answers.yml b/.copier-answers.yml index 570a2b9..98e0704 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,6 +1,6 @@ # Changes here will be overwritten by Copier -_commit: 1.2.1 -_src_path: gh:pawamoy/copier-pdm +_commit: 1.5.7 +_src_path: gh:pawamoy/copier-uv author_email: pawamoy@pm.me author_fullname: Timothée Mazzucotelli author_username: pawamoy diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..f9d77ee --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +PATH_add scripts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 01e293a..a502284 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,5 @@ github: pawamoy ko_fi: pawamoy +polar: pawamoy custom: - https://www.paypal.me/pawamoy diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/1-bug.md similarity index 98% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/1-bug.md index 56913ea..b8157a4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/1-bug.md @@ -53,7 +53,7 @@ PASTE TRACEBACK HERE python -m griffe_typingdoc.debug # | xclip -selection clipboard ``` -PASTE OUTPUT HERE +PASTE MARKDOWN OUTPUT HERE ### Additional context + +### Relevant code snippets + + +### Link to the relevant documentation section + diff --git a/.github/ISSUE_TEMPLATE/4-change.md b/.github/ISSUE_TEMPLATE/4-change.md new file mode 100644 index 0000000..dc9a8f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-change.md @@ -0,0 +1,18 @@ +--- +name: Change request +about: Suggest any other kind of change for this project. +title: "change: " +assignees: pawamoy +--- + +### Is your change request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 633f859..de566ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ env: LANG: en_US.utf-8 LC_ALL: en_US.utf-8 PYTHONIOENCODING: UTF-8 + PYTHON_VERSIONS: "" jobs: @@ -23,70 +24,87 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 - - - name: Fetch all tags - run: git fetch --depth=1 --tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - - name: Set up PDM - uses: pdm-project/setup-pdm@v3 + - name: Setup Python + uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.12" - - name: Resolving dependencies - run: pdm lock -v --no-cross-platform -G ci-quality + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml - name: Install dependencies - run: pdm install -G ci-quality + run: make setup - name: Check if the documentation builds correctly - run: pdm run duty check-docs + run: make check-docs - name: Check the code quality - run: pdm run duty check-quality + run: make check-quality - name: Check if the code is correctly typed - run: pdm run duty check-types - - - name: Check for vulnerabilities in dependencies - run: pdm run duty check-dependencies + run: make check-types - name: Check for breaking changes in the API - run: pdm run duty check-api + run: make check-api tests: strategy: - max-parallel: 4 matrix: os: - ubuntu-latest - macos-latest - windows-latest python-version: - - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" + - "3.13" + - "3.14" + resolution: + - highest + - lowest-direct + exclude: + - os: macos-latest + resolution: lowest-direct + - os: windows-latest + resolution: lowest-direct runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.python-version == '3.12' }} + continue-on-error: ${{ matrix.python-version == '3.14' }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - - name: Set up PDM - uses: pdm-project/setup-pdm@v3 + - name: Setup Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - allow-python-prereleases: true + allow-prereleases: true - - name: Resolving dependencies - run: pdm lock -v --no-cross-platform -G ci-tests + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: py${{ matrix.python-version }} - name: Install dependencies - run: pdm install --no-editable -G ci-tests + env: + UV_RESOLUTION: ${{ matrix.resolution }} + run: make setup - name: Run the test suite - run: pdm run duty test + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1f92ec..d09c514 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,16 +10,19 @@ jobs: if: startsWith(github.ref, 'refs/tags/') steps: - name: Checkout - uses: actions/checkout@v3 - - name: Fetch all tags - run: git fetch --depth=1 --tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - name: Setup Python - uses: actions/setup-python@v4 - - name: Install git-changelog - run: pip install git-changelog + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Setup uv + uses: astral-sh/setup-uv@v3 - name: Prepare release notes - run: git-changelog --release-notes > release-notes.md + run: uv tool run git-changelog --release-notes > release-notes.md - name: Create release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: body_path: release-notes.md diff --git a/.gitignore b/.gitignore index 588e34e..9fea047 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,25 @@ +# editors .idea/ .vscode/ -__pycache__/ -*.py[cod] -dist/ + +# python *.egg-info/ -build/ -htmlcov/ +*.py[cod] +.venv/ +.venvs/ +/build/ +/dist/ + +# tools .coverage* -pip-wheel-metadata/ +/.pdm-build/ +/htmlcov/ +/site/ +uv.lock + +# cache +.cache/ .pytest_cache/ .mypy_cache/ -site/ -pdm.lock -pdm.toml -.pdm-plugins/ -.pdm-python -__pypackages__/ -.venv/ -.cache/ +.ruff_cache/ +__pycache__/ diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile deleted file mode 100644 index 0e6d9d3..0000000 --- a/.gitpod.dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM gitpod/workspace-full -USER gitpod -ENV PIP_USER=no -RUN pip3 install pipx; \ - pipx install pdm; \ - pipx ensurepath diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 23a3c2b..0000000 --- a/.gitpod.yml +++ /dev/null @@ -1,13 +0,0 @@ -vscode: - extensions: - - ms-python.python - -image: - file: .gitpod.dockerfile - -ports: -- port: 8000 - onOpen: notify - -tasks: -- init: make setup diff --git a/CHANGELOG.md b/CHANGELOG.md index 82453de..27cb2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ 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.2.8](https://github.com/mkdocstrings/griffe-typingdoc/releases/tag/0.2.8) - 2025-02-18 + +[Compare with 0.2.7](https://github.com/mkdocstrings/griffe-typingdoc/compare/0.2.7...0.2.8) + +### Code Refactoring + +- Only add docstring sections when elements are annotated with `Doc` ([514467c](https://github.com/mkdocstrings/griffe-typingdoc/commit/514467c06f4381fca9c5f96a860c8abd87c1827b) by Timothée Mazzucotelli). [Issue-13](https://github.com/mkdocstrings/griffe-typingdoc/issues/13) + +## [0.2.7](https://github.com/mkdocstrings/griffe-typingdoc/releases/tag/0.2.7) - 2024-09-10 + +[Compare with 0.2.6](https://github.com/mkdocstrings/griffe-typingdoc/compare/0.2.6...0.2.7) + +### Bug Fixes + +- Resolve names in `Unpack`, instead of naively trying to get them from the parent of the function being handled ([5e06b33](https://github.com/mkdocstrings/griffe-typingdoc/commit/5e06b33651f43b292059d27d3b232e2646a409d5) by Timothée Mazzucotelli). [Issue-11](https://github.com/mkdocstrings/griffe-typingdoc/issues/11) + +## [0.2.6](https://github.com/mkdocstrings/griffe-typingdoc/releases/tag/0.2.6) - 2024-08-14 + +[Compare with 0.2.5](https://github.com/mkdocstrings/griffe-typingdoc/compare/0.2.5...0.2.6) + +### Build + +- Depend on Griffe 0.49 ([b6d7bd9](https://github.com/mkdocstrings/griffe-typingdoc/commit/b6d7bd9ce462a8dbd067464b3d14a9dd25865957) by Timothée Mazzucotelli). + ## [0.2.5](https://github.com/mkdocstrings/griffe-typingdoc/releases/tag/0.2.5) - 2024-02-08 [Compare with 0.2.4](https://github.com/mkdocstrings/griffe-typingdoc/compare/0.2.4...0.2.5) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8ccecd..37283bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,18 +17,17 @@ make setup > NOTE: > If it fails for some reason, > you'll need to install -> [PDM](https://github.com/pdm-project/pdm) +> [uv](https://github.com/astral-sh/uv) > manually. > > You can install it with: > > ```bash -> python3 -m pip install --user pipx -> pipx install pdm +> curl -LsSf https://astral.sh/uv/install.sh | sh > ``` > > Now you can try running `make setup` again, -> or simply `pdm install`. +> or simply `uv sync`. You now have the dependencies installed. @@ -36,16 +35,14 @@ Run `make help` to see all the available actions! ## Tasks -This project uses [duty](https://github.com/pawamoy/duty) to run tasks. -A Makefile is also provided. The Makefile will try to run certain tasks -on multiple Python versions. If for some reason you don't want to run the task -on multiple Python versions, you run the task directly with `pdm run duty TASK`. - -The Makefile detects if a virtual environment is activated, -so `make` will work the same with the virtualenv activated or not. +The entry-point to run commands and tasks is the `make` Python script, +located in the `scripts` directory. Try running `make` to show the available commands and tasks. +The *commands* do not need the Python dependencies to be installed, +while the *tasks* do. +The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). If you work in VSCode, we provide -[an action to configure VSCode](https://pawamoy.github.io/copier-pdm/work/#vscode-setup) +[an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) for the project. ## Development diff --git a/Makefile b/Makefile index 8ad5209..5e88121 100644 --- a/Makefile +++ b/Makefile @@ -1,54 +1,28 @@ -.DEFAULT_GOAL := help -SHELL := bash -DUTY := $(if $(VIRTUAL_ENV),,pdm run) duty -export PDM_MULTIRUN_VERSIONS ?= 3.8 3.9 3.10 3.11 3.12 -export PDM_MULTIRUN_USE_VENVS ?= $(if $(shell pdm config python.use_venv | grep True),1,0) +# If you have `direnv` loaded in your shell, and allow it in the repository, +# the `make` command will point at the `scripts/make` shell script. +# This Makefile is just here to allow auto-completion in the terminal. -args = $(foreach a,$($(subst -,_,$1)_args),$(if $(value $a),$a="$($a)")) -check_quality_args = files -docs_args = host port -release_args = version -test_args = match - -BASIC_DUTIES = \ +actions = \ + allrun \ changelog \ + check \ check-api \ - check-dependencies \ + check-docs \ + check-quality \ + check-types \ clean \ coverage \ docs \ docs-deploy \ format \ + help \ + multirun \ release \ + run \ + setup \ + test \ vscode -QUALITY_DUTIES = \ - check-quality \ - check-docs \ - check-types \ - test - -.PHONY: help -help: - @$(DUTY) --list - -.PHONY: lock -lock: - @pdm lock -G:all - -.PHONY: setup -setup: - @bash scripts/setup.sh - -.PHONY: check -check: - @pdm multirun duty check-quality check-types check-docs - @$(DUTY) check-dependencies check-api - -.PHONY: $(BASIC_DUTIES) -$(BASIC_DUTIES): - @$(DUTY) $@ $(call args,$@) - -.PHONY: $(QUALITY_DUTIES) -$(QUALITY_DUTIES): - @pdm multirun duty $@ $(call args,$@) +.PHONY: $(actions) +$(actions): + @python scripts/make "$@" diff --git a/README.md b/README.md index 937b8c5..5dbbf75 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,18 @@ # Griffe TypingDoc [![ci](https://github.com/mkdocstrings/griffe-typingdoc/workflows/ci/badge.svg)](https://github.com/mkdocstrings/griffe-typingdoc/actions?query=workflow%3Aci) -[![documentation](https://img.shields.io/badge/docs-mkdocs%20material-blue.svg?style=flat)](https://mkdocstrings.github.io/griffe-typingdoc/) +[![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://mkdocstrings.github.io/griffe-typingdoc/) [![pypi version](https://img.shields.io/pypi/v/griffe-typingdoc.svg)](https://pypi.org/project/griffe-typingdoc/) -[![gitpod](https://img.shields.io/badge/gitpod-workspace-blue.svg?style=flat)](https://gitpod.io/#https://github.com/mkdocstrings/griffe-typingdoc) [![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#griffe-typingdoc:gitter.im) Griffe extension for [PEP 727 – Documentation Metadata in Typing](https://peps.python.org/pep-0727/). ## Installation -With `pip`: - ```bash pip install griffe-typingdoc ``` -With [`pipx`](https://github.com/pipxproject/pipx): - -```bash -python3.8 -m pip install --user pipx -pipx install griffe-typingdoc -``` - To use the extension in a MkDocs project, use this configuration: diff --git a/config/black.toml b/config/black.toml deleted file mode 100644 index d24affe..0000000 --- a/config/black.toml +++ /dev/null @@ -1,3 +0,0 @@ -[tool.black] -line-length = 120 -exclude = "tests/fixtures" diff --git a/config/coverage.ini b/config/coverage.ini index 97d3229..b56a286 100644 --- a/config/coverage.ini +++ b/config/coverage.ini @@ -8,7 +8,8 @@ source = [coverage:paths] equivalent = src/ - __pypackages__/ + .venv/lib/*/site-packages/ + .venvs/*/lib/*/site-packages/ [coverage:report] precision = 2 diff --git a/config/git-changelog.toml b/config/git-changelog.toml index 44e2b1f..57114e0 100644 --- a/config/git-changelog.toml +++ b/config/git-changelog.toml @@ -6,3 +6,4 @@ parse-refs = false parse-trailers = true sections = ["build", "deps", "feat", "fix", "refactor"] template = "keepachangelog" +versioning = "pep440" diff --git a/config/pytest.ini b/config/pytest.ini index 5a49395..052a2f1 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -1,14 +1,6 @@ [pytest] -norecursedirs = - .git - .tox - .env - dist - build python_files = test_*.py - *_test.py - tests.py addopts = --cov --cov-config config/coverage.ini diff --git a/config/ruff.toml b/config/ruff.toml index 69097e6..7aed449 100644 --- a/config/ruff.toml +++ b/config/ruff.toml @@ -1,53 +1,26 @@ -target-version = "py38" +target-version = "py39" line-length = 120 + +[lint] exclude = [ - "fixtures", - "site", + "tests/fixtures/*.py", ] select = [ - "A", - "ANN", - "ARG", - "B", - "BLE", - "C", - "C4", + "A", "ANN", "ARG", + "B", "BLE", + "C", "C4", "COM", - "D", - "DTZ", - "E", - "ERA", - "EXE", - "F", - "FBT", + "D", "DTZ", + "E", "ERA", "EXE", + "F", "FBT", "G", - "I", - "ICN", - "INP", - "ISC", + "I", "ICN", "INP", "ISC", "N", - "PGH", - "PIE", - "PL", - "PLC", - "PLE", - "PLR", - "PLW", - "PT", - "PYI", + "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", "Q", - "RUF", - "RSE", - "RET", - "S", - "SIM", - "SLF", - "T", - "T10", - "T20", - "TCH", - "TID", - "TRY", + "RUF", "RSE", "RET", + "S", "SIM", "SLF", + "T", "T10", "T20", "TCH", "TID", "TRY", "UP", "W", "YTT", @@ -73,7 +46,7 @@ ignore = [ "TRY003", # Avoid specifying long messages outside the exception class ] -[per-file-ignores] +[lint.per-file-ignores] "src/*/cli.py" = [ "T201", # Print statement ] @@ -91,18 +64,21 @@ ignore = [ "S101", # Use of assert detected ] -[flake8-quotes] +[lint.flake8-quotes] docstring-quotes = "double" -[flake8-tidy-imports] +[lint.flake8-tidy-imports] ban-relative-imports = "all" -[isort] +[lint.isort] known-first-party = ["griffe_typingdoc"] -[pydocstyle] +[lint.pydocstyle] convention = "google" [format] +exclude = [ + "tests/fixtures/*.py", +] docstring-code-format = true docstring-code-line-length = 80 diff --git a/config/vscode/launch.json b/config/vscode/launch.json index d056cce..e328838 100644 --- a/config/vscode/launch.json +++ b/config/vscode/launch.json @@ -9,6 +9,17 @@ "console": "integratedTerminal", "justMyCode": false }, + { + "name": "docs", + "type": "debugpy", + "request": "launch", + "module": "mkdocs", + "justMyCode": false, + "args": [ + "serve", + "-v" + ] + }, { "name": "test", "type": "debugpy", diff --git a/config/vscode/settings.json b/config/vscode/settings.json index 17beee4..949856d 100644 --- a/config/vscode/settings.json +++ b/config/vscode/settings.json @@ -1,29 +1,9 @@ { "files.watcherExclude": { - "**/__pypackages__/**": true, "**/.venv*/**": true, + "**/.venvs*/**": true, "**/venv*/**": true }, - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" - }, - "python.autoComplete.extraPaths": [ - "__pypackages__/3.8/lib", - "__pypackages__/3.9/lib", - "__pypackages__/3.10/lib", - "__pypackages__/3.11/lib", - "__pypackages__/3.12/lib" - ], - "python.analysis.extraPaths": [ - "__pypackages__/3.8/lib", - "__pypackages__/3.9/lib", - "__pypackages__/3.10/lib", - "__pypackages__/3.11/lib", - "__pypackages__/3.12/lib" - ], - "black-formatter.args": [ - "--config=config/black.toml" - ], "mypy-type-checker.args": [ "--config-file=config/mypy.ini" ], @@ -32,6 +12,7 @@ "python.testing.pytestArgs": [ "--config-file=config/pytest.ini" ], + "ruff.enable": true, "ruff.format.args": [ "--config=config/ruff.toml" ], diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json index 80cd13d..73145ee 100644 --- a/config/vscode/tasks.json +++ b/config/vscode/tasks.json @@ -3,84 +3,88 @@ "tasks": [ { "label": "changelog", - "type": "shell", - "command": "pdm run duty changelog" + "type": "process", + "command": "scripts/make", + "args": ["changelog"] }, { "label": "check", - "type": "shell", - "command": "pdm run duty check" + "type": "process", + "command": "scripts/make", + "args": ["check"] }, { "label": "check-quality", - "type": "shell", - "command": "pdm run duty check-quality" + "type": "process", + "command": "scripts/make", + "args": ["check-quality"] }, { "label": "check-types", - "type": "shell", - "command": "pdm run duty check-types" + "type": "process", + "command": "scripts/make", + "args": ["check-types"] }, { "label": "check-docs", - "type": "shell", - "command": "pdm run duty check-docs" - }, - { - "label": "check-dependencies", - "type": "shell", - "command": "pdm run duty check-dependencies" + "type": "process", + "command": "scripts/make", + "args": ["check-docs"] }, { "label": "check-api", - "type": "shell", - "command": "pdm run duty check-api" + "type": "process", + "command": "scripts/make", + "args": ["check-api"] }, { "label": "clean", - "type": "shell", - "command": "pdm run duty clean" + "type": "process", + "command": "scripts/make", + "args": ["clean"] }, { "label": "docs", - "type": "shell", - "command": "pdm run duty docs" + "type": "process", + "command": "scripts/make", + "args": ["docs"] }, { "label": "docs-deploy", - "type": "shell", - "command": "pdm run duty docs-deploy" + "type": "process", + "command": "scripts/make", + "args": ["docs-deploy"] }, { "label": "format", - "type": "shell", - "command": "pdm run duty format" - }, - { - "label": "lock", - "type": "shell", - "command": "pdm lock -G:all" + "type": "process", + "command": "scripts/make", + "args": ["format"] }, { "label": "release", - "type": "shell", - "command": "pdm run duty release ${input:version}" + "type": "process", + "command": "scripts/make", + "args": ["release", "${input:version}"] }, { "label": "setup", - "type": "shell", - "command": "bash scripts/setup.sh" + "type": "process", + "command": "scripts/make", + "args": ["setup"] }, { "label": "test", - "type": "shell", - "command": "pdm run duty test coverage", + "type": "process", + "command": "scripts/make", + "args": ["test", "coverage"], "group": "test" }, { "label": "vscode", - "type": "shell", - "command": "pdm run duty vscode" + "type": "process", + "command": "scripts/make", + "args": ["vscode"] } ], "inputs": [ diff --git a/docs/.overrides/main.html b/docs/.overrides/main.html index fcad323..75c5edf 100644 --- a/docs/.overrides/main.html +++ b/docs/.overrides/main.html @@ -2,11 +2,13 @@ {% block announce %} - For updates follow @pawamoy on + Follow + @pawamoy on {% include ".icons/fontawesome/brands/mastodon.svg" %} Fosstodon + for updates {% endblock %} diff --git a/docs/.overrides/partials/comments.html b/docs/.overrides/partials/comments.html new file mode 100644 index 0000000..27ffdc7 --- /dev/null +++ b/docs/.overrides/partials/comments.html @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css index 727a614..88c7357 100644 --- a/docs/css/mkdocstrings.css +++ b/docs/css/mkdocstrings.css @@ -18,7 +18,7 @@ a.autorefs-external::after { height: 1em; width: 1em; - background-color: var(--md-typeset-a-color); + background-color: currentColor; } a.external:hover::after, diff --git a/docs/index.md b/docs/index.md index 612c7a5..8e6f2fb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1,6 @@ +--- +hide: +- feedback +--- + --8<-- "README.md" diff --git a/docs/js/feedback.js b/docs/js/feedback.js new file mode 100644 index 0000000..f97321a --- /dev/null +++ b/docs/js/feedback.js @@ -0,0 +1,14 @@ +const feedback = document.forms.feedback; +feedback.hidden = false; + +feedback.addEventListener("submit", function(ev) { + ev.preventDefault(); + const commentElement = document.getElementById("feedback"); + commentElement.style.display = "block"; + feedback.firstElementChild.disabled = true; + const data = ev.submitter.getAttribute("data-md-value"); + const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); + if (note) { + note.hidden = false; + } +}) diff --git a/docs/license.md b/docs/license.md index a873d2b..e81c0ed 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,8 @@ +--- +hide: +- feedback +--- + # License ``` diff --git a/duties.py b/duties.py index 6d66c94..2625f8f 100644 --- a/duties.py +++ b/duties.py @@ -7,12 +7,13 @@ from contextlib import contextmanager from importlib.metadata import version as pkgversion from pathlib import Path -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING -from duty import duty -from duty.callables import coverage, lazy, mkdocs, mypy, pytest, ruff, safety +from duty import duty, tools if TYPE_CHECKING: + from collections.abc import Iterator + from duty.context import Context @@ -22,7 +23,7 @@ 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" +MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" def pyprefix(title: str) -> str: # noqa: D103 @@ -45,142 +46,72 @@ def material_insiders() -> Iterator[bool]: # noqa: D103 @duty -def changelog(ctx: Context) -> None: +def changelog(ctx: Context, bump: str = "") -> None: """Update the changelog in-place with latest commits. Parameters: - ctx: The context instance (passed automatically). + bump: Bump option passed to git-changelog. """ - from git_changelog.cli import main as git_changelog - - ctx.run(git_changelog, args=[[]], title="Updating changelog") + ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") -@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) -def check(ctx: Context) -> None: # noqa: ARG001 - """Check it all! - - Parameters: - ctx: The context instance (passed automatically). - """ +@duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) +def check(ctx: Context) -> None: + """Check it all!""" @duty def check_quality(ctx: Context) -> None: - """Check the code quality. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check the code quality.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), title=pyprefix("Checking code quality"), - command=f"ruff check --config config/ruff.toml {PY_SRC}", - ) - - -@duty -def check_dependencies(ctx: Context) -> None: - """Check for vulnerabilities in dependencies. - - Parameters: - ctx: The context instance (passed automatically). - """ - # retrieve the list of dependencies - requirements = ctx.run( - ["pdm", "export", "-f", "requirements", "--without-hashes"], - title="Exporting dependencies as requirements", - allow_overrides=False, - ) - - ctx.run( - safety.check(requirements), - title="Checking dependencies", - command="pdm export -f requirements --without-hashes | safety check --stdin", ) @duty def check_docs(ctx: Context) -> None: - """Check if the documentation builds correctly. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check if the documentation builds correctly.""" Path("htmlcov").mkdir(parents=True, exist_ok=True) Path("htmlcov/index.html").touch(exist_ok=True) with material_insiders(): ctx.run( - mkdocs.build(strict=True, verbose=True), + tools.mkdocs.build(strict=True, verbose=True), title=pyprefix("Building documentation"), - command="mkdocs build -vs", ) @duty def check_types(ctx: Context) -> None: - """Check that the code is correctly typed. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Check that the code is correctly typed.""" + os.environ["FORCE_COLOR"] = "1" ctx.run( - mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"), + tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), title=pyprefix("Type-checking"), - command=f"mypy --config-file config/mypy.ini {PY_SRC}", ) @duty -def check_api(ctx: Context) -> None: - """Check for API breaking changes. - - Parameters: - ctx: The context instance (passed automatically). - """ - from griffe.cli import check as g_check - - griffe_check = lazy(g_check, name="griffe.check") +def check_api(ctx: Context, *cli_args: str) -> None: + """Check for API breaking changes.""" ctx.run( - griffe_check("griffe_typingdoc", search_paths=["src"], color=True), + tools.griffe.check("griffe_typingdoc", search=["src"], color=True).add_args(*cli_args), title="Checking for API breaking changes", - command="griffe check -ssrc griffe_typingdoc", nofail=True, ) -@duty(silent=True) -def clean(ctx: Context) -> None: - """Delete temporary files. - - Parameters: - ctx: The context instance (passed automatically). - """ - ctx.run("rm -rf .coverage*") - ctx.run("rm -rf .mypy_cache") - ctx.run("rm -rf .pytest_cache") - ctx.run("rm -rf tests/.pytest_cache") - ctx.run("rm -rf build") - ctx.run("rm -rf dist") - ctx.run("rm -rf htmlcov") - ctx.run("rm -rf pip-wheel-metadata") - ctx.run("rm -rf site") - ctx.run("find . -type d -name __pycache__ | xargs rm -rf") - ctx.run("find . -name '*.rej' -delete") - - @duty -def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: +def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: """Serve the documentation (localhost:8000). Parameters: - ctx: The context instance (passed automatically). host: The host to serve the docs from. port: The port to serve the docs on. """ with material_insiders(): ctx.run( - mkdocs.serve(dev_addr=f"{host}:{port}"), + tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), title="Serving documentation", capture=False, ) @@ -188,98 +119,86 @@ def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: @duty def docs_deploy(ctx: Context) -> None: - """Deploy the documentation on GitHub pages. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Deploy the documentation to GitHub pages.""" os.environ["DEPLOY"] = "true" with material_insiders() as insiders: if not insiders: ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") - ctx.run(mkdocs.gh_deploy(), title="Deploying documentation") + ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") @duty def format(ctx: Context) -> None: - """Run formatting tools on the code. - - Parameters: - ctx: The context instance (passed automatically). - """ + """Run formatting tools on the code.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), title="Auto-fixing code", ) - ctx.run(ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") + ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") -@duty(post=["docs-deploy"]) -def release(ctx: Context, version: str) -> None: +@duty +def build(ctx: Context) -> None: + """Build source and wheel distributions.""" + ctx.run( + tools.build(), + title="Building source and wheel distributions", + pty=PTY, + ) + + +@duty +def publish(ctx: Context) -> None: + """Publish source and wheel distributions to PyPI.""" + if not Path("dist").exists(): + ctx.run("false", title="No distribution files found") + dists = [str(dist) for dist in Path("dist").iterdir()] + ctx.run( + tools.twine.upload(*dists, skip_existing=True), + title="Publishing source and wheel distributions to PyPI", + pty=PTY, + ) + + +@duty(post=["build", "publish", "docs-deploy"]) +def release(ctx: Context, version: str = "") -> None: """Release a new Python package. Parameters: - ctx: The context instance (passed automatically). version: The new version number to use. """ + if not (version := (version or input("> Version to release: ")).strip()): + ctx.run("false", title="A version must be provided") ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) ctx.run("git push", title="Pushing commits", pty=False) ctx.run("git push --tags", title="Pushing tags", pty=False) - ctx.run("pdm build", title="Building dist/wheel", pty=PTY) - ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY) -@duty(silent=True, aliases=["coverage"]) -def cov(ctx: Context) -> None: - """Report coverage as text and HTML. - - Parameters: - ctx: The context instance (passed automatically). - """ - ctx.run(coverage.combine, nofail=True) - ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False) - ctx.run(coverage.html(rcfile="config/coverage.ini")) +@duty(silent=True, aliases=["cov"]) +def coverage(ctx: Context) -> None: + """Report coverage as text and HTML.""" + ctx.run(tools.coverage.combine(), nofail=True) + ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) + ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) @duty -def test(ctx: Context, match: str = "") -> None: +def test(ctx: Context, *cli_args: str, match: str = "") -> None: """Run the test suite. Parameters: - ctx: The context instance (passed automatically). match: A pytest expression to filter selected tests. """ py_version = f"{sys.version_info.major}{sys.version_info.minor}" os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" ctx.run( - pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"), + tools.pytest( + "tests", + config_file="config/pytest.ini", + select=match, + color="yes", + ).add_args("-n", "auto", *cli_args), title=pyprefix("Running tests"), - command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests", ) - - -@duty -def vscode(ctx: Context) -> None: - """Configure VSCode. - - This task will overwrite the following files, - so make sure to back them up: - - - `.vscode/launch.json` - - `.vscode/settings.json` - - `.vscode/tasks.json` - - Parameters: - ctx: The context instance (passed automatically). - """ - - def update_config(filename: str) -> None: - source_file = Path("config", "vscode", filename) - target_file = Path(".vscode", filename) - target_file.parent.mkdir(exist_ok=True) - target_file.write_text(source_file.read_text()) - - for filename in ("launch.json", "settings.json", "tasks.json"): - ctx.run(update_config, args=[filename], title=f"Update .vscode/{filename}") diff --git a/mkdocs.yml b/mkdocs.yml index 080d985..6e7d619 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,9 @@ extra_css: - css/material.css - css/mkdocstrings.css +extra_javascript: +- js/feedback.js + markdown_extensions: - attr_list - admonition @@ -128,14 +131,15 @@ plugins: show_root_heading: true show_root_full_path: false show_signature_annotations: true + show_source: true show_symbol_type_heading: true show_symbol_type_toc: true signature_crossrefs: true summary: true - unwrap_annotated: true -- git-committers: +- git-revision-date-localized: enabled: !ENV [DEPLOY, false] - repository: mkdocstrings/griffe-typingdoc + enable_creation_date: true + type: timeago - minify: minify_html: !ENV [DEPLOY, false] - group: @@ -155,3 +159,15 @@ extra: link: https://gitter.im/griffe-typingdoc/community - icon: fontawesome/brands/python link: https://pypi.org/project/griffe-typingdoc/ + analytics: + feedback: + title: Was this page helpful? + ratings: + - icon: material/emoticon-happy-outline + name: This page was helpful + data: 1 + note: Thanks for your feedback! + - icon: material/emoticon-sad-outline + name: This page could be improved + data: 0 + note: Let us know how we can improve this page. diff --git a/pyproject.toml b/pyproject.toml index 6625f8b..88ef5e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Griffe extension for PEP 727 – Documentation Metadata in Typing authors = [{name = "Timothée Mazzucotelli", email = "pawamoy@pm.me"}] license = {text = "ISC"} readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" keywords = [] dynamic = ["version"] classifiers = [ @@ -17,11 +17,12 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Documentation", "Topic :: Software Development", "Topic :: Software Development :: Documentation", @@ -29,7 +30,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "griffe>=0.38", + "griffe>=0.49", "typing-extensions>=4.7", ] @@ -43,54 +44,63 @@ Discussions = "https://github.com/mkdocstrings/griffe-typingdoc/discussions" Gitter = "https://gitter.im/mkdocstrings/griffe-typingdoc" Funding = "https://github.com/sponsors/pawamoy" -[tool.pdm] -version = {source = "scm"} -plugins = [ - "pdm-multirun", -] +[tool.pdm.version] +source = "call" +getter = "scripts.get_version:get_version" [tool.pdm.build] -package-dir = "src" -editable-backend = "editables" +# Include as much as possible in the source distribution, to help redistributors. +excludes = ["**/.pytest_cache"] +source-includes = [ + "config", + "docs", + "scripts", + "share", + "tests", + "duties.py", + "mkdocs.yml", + "*.md", + "LICENSE", +] + +[tool.pdm.build.wheel-data] +# Manual pages can be included in the wheel. +# Depending on the installation tool, they will be accessible to users. +# pipx supports it, uv does not yet, see https://github.com/astral-sh/uv/issues/4731. +data = [ + {path = "share/**/*", relative-to = "."}, +] + +[dependency-groups] +dev = [ + # maintenance + "build>=1.2", + "git-changelog>=2.5", + "twine>=5.1", + + # ci + "duty>=1.4", + "ruff>=0.4", + "pytest>=8.2", + "pytest-cov>=5.0", + "pytest-randomly>=3.15", + "pytest-xdist>=3.6", + "mypy>=1.10", + "types-markdown>=3.6", + "types-pyyaml>=6.0", -[tool.pdm.dev-dependencies] -duty = ["duty>=0.10"] -ci-quality = ["griffe-typingdoc[duty,docs,quality,typing,security]"] -ci-tests = ["griffe-typingdoc[duty,tests]"] -docs = [ - "black>=23.9", - "markdown-callouts>=0.3", - "markdown-exec>=1.7", - "mkdocs>=1.5", + # docs + "black>=24.4", + "markdown-callouts>=0.4", + "markdown-exec>=1.8", + "mkdocs>=1.6", "mkdocs-coverage>=1.0", "mkdocs-gen-files>=0.5", - "mkdocs-git-committers-plugin-2>=1.2", + "mkdocs-git-revision-date-localized-plugin>=1.2", "mkdocs-literate-nav>=0.6", - "mkdocs-material>=9.4", - "mkdocs-minify-plugin>=0.7", - "mkdocstrings[python]>=0.23", + "mkdocs-material>=9.5", + "mkdocs-minify-plugin>=0.8", + "mkdocstrings[python]>=0.25", + # YORE: EOL 3.10: Remove line. "tomli>=2.0; python_version < '3.11'", -] -maintain = [ - "black>=23.9", - "blacken-docs>=1.16", - "git-changelog>=2.3", -] -quality = [ - "ruff>=0.0", -] -tests = [ - "pytest>=7.4", - "pytest-cov>=4.1", - "pytest-randomly>=3.15", - "pytest-xdist>=3.3", - "typing-extensions>=4.8.0rc1", -] -typing = [ - "mypy>=1.5", - "types-markdown>=3.5", - "types-pyyaml>=6.0", -] -security = [ - "safety>=2.3", -] +] \ No newline at end of file diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py index bf35f0d..749e0ae 100644 --- a/scripts/gen_credits.py +++ b/scripts/gen_credits.py @@ -3,18 +3,20 @@ from __future__ import annotations import os -import re import sys -from importlib.metadata import PackageNotFoundError, metadata +from collections import defaultdict +from collections.abc import Iterable +from importlib.metadata import distributions from itertools import chain from pathlib import Path from textwrap import dedent -from typing import Mapping, cast +from typing import Union from jinja2 import StrictUndefined from jinja2.sandbox import SandboxedEnvironment +from packaging.requirements import Requirement -# TODO: Remove once support for Python 3.10 is dropped. +# YORE: EOL 3.10: Replace block with line 2. if sys.version_info >= (3, 11): import tomllib else: @@ -24,71 +26,113 @@ with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: pyproject = tomllib.load(pyproject_file) project = pyproject["project"] -pdm = pyproject["tool"]["pdm"] -with project_dir.joinpath("pdm.lock").open("rb") as lock_file: - lock_data = tomllib.load(lock_file) -lock_pkgs = {pkg["name"].lower(): pkg for pkg in lock_data["package"]} project_name = project["name"] -regex = re.compile(r"(?P[\w.-]+)(?P.*)$") +devdeps = [dep for dep in pyproject["dependency-groups"]["dev"] if not dep.startswith("-e")] +PackageMetadata = dict[str, Union[str, Iterable[str]]] +Metadata = dict[str, PackageMetadata] -def _get_license(pkg_name: str) -> str: + +def _merge_fields(metadata: dict) -> PackageMetadata: + fields = defaultdict(list) + for header, value in metadata.items(): + fields[header.lower()].append(value.strip()) + return { + field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] + for field, value in fields.items() + } + + +def _norm_name(name: str) -> str: + return name.replace("_", "-").replace(".", "-").lower() + + +def _requirements(deps: list[str]) -> dict[str, Requirement]: + return {_norm_name((req := Requirement(dep)).name): req for dep in deps} + + +def _extra_marker(req: Requirement) -> str | None: + if not req.marker: + return None try: - data = metadata(pkg_name) - except PackageNotFoundError: - return "?" - 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_name or license_name == "UNKNOWN": - for header, value in cast(dict, data).items(): - if header == "Classifier" and value.startswith("License ::"): - license_name = value.rsplit("::", 1)[1].strip() - elif header == "Author-email": - author = value - if license_name == "Other/Proprietary License" and "pawamoy" in author: - license_name = "ISC" - return license_name or "?" - - -def _get_deps(base_deps: Mapping[str, Mapping[str, str]]) -> dict[str, dict[str, str]]: + return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") + except StopIteration: + return None + + +def _get_metadata() -> Metadata: + metadata = {} + for pkg in distributions(): + name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] + metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] + metadata[name]["spec"] = set() + metadata[name]["extras"] = set() + metadata[name].setdefault("summary", "") + _set_license(metadata[name]) + return metadata + + +def _set_license(metadata: PackageMetadata) -> None: + license_field = metadata.get("license-expression", metadata.get("license", "")) + license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) + check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") + if check_classifiers: + license_names = [] + for classifier in metadata["classifier"]: + if classifier.startswith("License ::"): + license_names.append(classifier.rsplit("::", 1)[1].strip()) + license_name = " + ".join(license_names) + metadata["license"] = license_name or "?" + + +def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: deps = {} - for dep in base_deps: - parsed = regex.match(dep).groupdict() # type: ignore[union-attr] - dep_name = parsed["dist"].lower() - if dep_name not in lock_pkgs: + for dep_name, dep_req in base_deps.items(): + if dep_name not in metadata or dep_name == "griffe-typingdoc": continue - deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]} + metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] + metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] + deps[dep_name] = metadata[dep_name] again = True while again: again = False - for pkg_name in lock_pkgs: + for pkg_name in metadata: if pkg_name in deps: - for pkg_dependency in lock_pkgs[pkg_name].get("dependencies", []): - parsed = regex.match(pkg_dependency).groupdict() # type: ignore[union-attr] - dep_name = parsed["dist"].lower() - if dep_name in lock_pkgs and dep_name not in deps and dep_name != project["name"]: - deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]} + for pkg_dependency in metadata[pkg_name].get("requires-dist", []): + requirement = Requirement(pkg_dependency) + dep_name = _norm_name(requirement.name) + extra_marker = _extra_marker(requirement) + if ( + dep_name in metadata + and dep_name not in deps + and dep_name != project["name"] + and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) + ): + metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] + deps[dep_name] = metadata[dep_name] again = True return deps def _render_credits() -> str: - dev_dependencies = _get_deps(chain(*pdm.get("dev-dependencies", {}).values())) # type: ignore[arg-type] + metadata = _get_metadata() + dev_dependencies = _get_deps(_requirements(devdeps), metadata) prod_dependencies = _get_deps( - chain( # type: ignore[arg-type] - project.get("dependencies", []), - chain(*project.get("optional-dependencies", {}).values()), + _requirements( + chain( # type: ignore[arg-type] + project.get("dependencies", []), + chain(*project.get("optional-dependencies", {}).values()), + ), ), + metadata, ) template_data = { "project_name": project_name, - "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: dep["name"]), - "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: dep["name"]), + "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), + "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), "more_credits": "http://pawamoy.github.io/credits/", } template_text = dedent( @@ -97,14 +141,15 @@ def _render_credits() -> str: 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) + [Python](https://www.python.org/) | + [uv](https://github.com/astral-sh/uv) | + [copier-uv](https://github.com/pawamoy/copier-uv) {% macro dep_line(dep) -%} - [`{{ dep.name }}`](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} + [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} {%- endmacro %} + {% if prod_dependencies -%} ### Runtime dependencies Project | Summary | Version (accepted) | Version (last resolved) | License @@ -113,6 +158,8 @@ def _render_credits() -> str: {{ dep_line(dep) }} {% endfor %} + {% endif -%} + {% if dev_dependencies -%} ### Development dependencies Project | Summary | Version (accepted) | Version (last resolved) | License @@ -121,6 +168,7 @@ def _render_credits() -> str: {{ dep_line(dep) }} {% endfor %} + {% endif -%} {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} """, ) diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py index 7285ac1..6939e86 100644 --- a/scripts/gen_ref_nav.py +++ b/scripts/gen_ref_nav.py @@ -7,7 +7,8 @@ nav = mkdocs_gen_files.Nav() mod_symbol = '' -src = Path(__file__).parent.parent / "src" +root = Path(__file__).parent.parent +src = root / "src" for path in sorted(src.rglob("*.py")): module_path = path.relative_to(src).with_suffix("") @@ -28,9 +29,9 @@ with mkdocs_gen_files.open(full_doc_path, "w") as fd: ident = ".".join(parts) - fd.write(f"::: {ident}") + fd.write(f"---\ntitle: {ident}\n---\n\n::: {ident}") - mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path) + mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path.relative_to(root)) with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) diff --git a/scripts/get_version.py b/scripts/get_version.py new file mode 100644 index 0000000..f4a30a8 --- /dev/null +++ b/scripts/get_version.py @@ -0,0 +1,27 @@ +"""Get current project version from Git tags or changelog.""" + +import re +from contextlib import suppress +from pathlib import Path + +from pdm.backend.hooks.version import SCMVersion, Version, default_version_formatter, get_version_from_scm + +_root = Path(__file__).parent.parent +_changelog = _root / "CHANGELOG.md" +_changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") +_default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003 + + +def get_version() -> str: + """Get current project version from Git tags or changelog.""" + scm_version = get_version_from_scm(_root) or _default_scm_version + if scm_version.version <= Version("0.1"): # Missing Git tags? + with suppress(OSError, StopIteration): # noqa: SIM117 + with _changelog.open("r", encoding="utf8") as file: + match = next(filter(None, map(_changelog_version_re.match, file))) + scm_version = scm_version._replace(version=Version(match.group(1))) + return default_version_formatter(scm_version) + + +if __name__ == "__main__": + print(get_version()) diff --git a/scripts/make b/scripts/make new file mode 120000 index 0000000..c2eda0d --- /dev/null +++ b/scripts/make @@ -0,0 +1 @@ +make.py \ No newline at end of file diff --git a/scripts/make.py b/scripts/make.py new file mode 100755 index 0000000..3d42729 --- /dev/null +++ b/scripts/make.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Management commands.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator + + +PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split() + + +def shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None: + """Run a shell command.""" + if capture_output: + return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 + subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 + return None + + +@contextmanager +def environ(**kwargs: str) -> Iterator[None]: + """Temporarily set environment variables.""" + original = dict(os.environ) + os.environ.update(kwargs) + try: + yield + finally: + os.environ.clear() + os.environ.update(original) + + +def uv_install(venv: Path) -> None: + """Install dependencies using uv.""" + with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): + if "CI" in os.environ: + shell("uv sync --no-editable") + else: + shell("uv sync") + + +def setup() -> None: + """Setup the project.""" + if not shutil.which("uv"): + raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") + + print("Installing dependencies (default environment)") + default_venv = Path(".venv") + if not default_venv.exists(): + shell("uv venv") + uv_install(default_venv) + + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + print(f"\nInstalling dependencies (python{version})") + venv_path = Path(f".venvs/{version}") + if not venv_path.exists(): + shell(f"uv venv --python {version} {venv_path}") + with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): + uv_install(venv_path) + + +def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in a virtual environment.""" + kwargs = {"check": True, **kwargs} + uv_run = ["uv", "run", "--no-sync"] + if version == "default": + with environ(UV_PROJECT_ENVIRONMENT=".venv"): + subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 + else: + with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"): + subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 + + +def multirun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command for all configured Python versions.""" + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + run(version, cmd, *args, **kwargs) + else: + run("default", cmd, *args, **kwargs) + + +def allrun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in all virtual environments.""" + run("default", cmd, *args, **kwargs) + if PYTHON_VERSIONS: + multirun(cmd, *args, **kwargs) + + +def clean() -> None: + """Delete build artifacts and cache files.""" + paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] + for path in paths_to_clean: + shutil.rmtree(path, ignore_errors=True) + + cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"} + for dirpath in Path(".").rglob("*/"): + if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs: + shutil.rmtree(dirpath, ignore_errors=True) + + +def vscode() -> None: + """Configure VSCode to work on this project.""" + shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True) + + +def main() -> int: + """Main entry point.""" + args = list(sys.argv[1:]) + if not args or args[0] == "help": + if len(args) > 1: + run("default", "duty", "--help", args[1]) + else: + print( + dedent( + """ + Available commands + help Print this help. Add task name to print help. + setup Setup all virtual environments (install dependencies). + run Run a command in the default virtual environment. + multirun Run a command for all configured Python versions. + allrun Run a command in all virtual environments. + 3.x Run a command in the virtual environment for Python 3.x. + clean Delete build artifacts and cache files. + vscode Configure VSCode to work on this project. + """, + ), + flush=True, + ) + if os.path.exists(".venv"): + print("\nAvailable tasks", flush=True) + run("default", "duty", "--list") + return 0 + + while args: + cmd = args.pop(0) + + if cmd == "run": + run("default", *args) + return 0 + + if cmd == "multirun": + multirun(*args) + return 0 + + if cmd == "allrun": + allrun(*args) + return 0 + + if cmd.startswith("3."): + run(cmd, *args) + return 0 + + opts = [] + while args and (args[0].startswith("-") or "=" in args[0]): + opts.append(args.pop(0)) + + if cmd == "clean": + clean() + elif cmd == "setup": + setup() + elif cmd == "vscode": + vscode() + elif cmd == "check": + multirun("duty", "check-quality", "check-types", "check-docs") + run("default", "duty", "check-api") + elif cmd in {"check-quality", "check-docs", "check-types", "test"}: + multirun("duty", cmd, *opts) + else: + run("default", "duty", cmd, *opts) + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except subprocess.CalledProcessError as process: + if process.output: + print(process.output, file=sys.stderr) + sys.exit(process.returncode) diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100755 index eef3843..0000000 --- a/scripts/setup.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -e - -if ! command -v pdm &>/dev/null; then - if ! command -v pipx &>/dev/null; then - python3 -m pip install --user pipx - fi - pipx install pdm -fi -if ! pdm self list 2>/dev/null | grep -q pdm-multirun; then - pdm install --plugins -fi - -if [ -n "${PDM_MULTIRUN_VERSIONS}" ]; then - if [ "${PDM_MULTIRUN_USE_VENVS}" -eq "1" ]; then - for version in ${PDM_MULTIRUN_VERSIONS}; do - if ! pdm venv --path "${version}" &>/dev/null; then - pdm venv create --name "${version}" "${version}" - fi - done - fi - pdm multirun -v pdm install -G:all -else - pdm install -G:all -fi diff --git a/src/griffe_typingdoc/_docstrings.py b/src/griffe_typingdoc/_docstrings.py index ed6be39..df4d0d8 100644 --- a/src/griffe_typingdoc/_docstrings.py +++ b/src/griffe_typingdoc/_docstrings.py @@ -2,9 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any -from griffe.docstrings.dataclasses import ( +from griffe import ( DocstringParameter, DocstringRaise, DocstringReceive, @@ -22,8 +22,9 @@ ) if TYPE_CHECKING: - from griffe import Function - from griffe.dataclasses import Parameter + from collections.abc import Iterable + + from griffe import Function, Parameter def _no_self_params(func: Function) -> list[Parameter]: @@ -59,7 +60,7 @@ def _to_other_parameters_section(params_dict: dict[str, dict[str, Any]]) -> Docs ) -def _to_yields_section(yield_data: Iterator[dict[str, Any]]) -> DocstringSectionYields: +def _to_yields_section(yield_data: Iterable[dict[str, Any]]) -> DocstringSectionYields: return DocstringSectionYields( [ DocstringYield( @@ -72,7 +73,7 @@ def _to_yields_section(yield_data: Iterator[dict[str, Any]]) -> DocstringSection ) -def _to_receives_section(receive_data: Iterator[dict[str, Any]]) -> DocstringSectionReceives: +def _to_receives_section(receive_data: Iterable[dict[str, Any]]) -> DocstringSectionReceives: return DocstringSectionReceives( [ DocstringReceive( @@ -85,7 +86,7 @@ def _to_receives_section(receive_data: Iterator[dict[str, Any]]) -> DocstringSec ) -def _to_returns_section(return_data: Iterator[dict[str, Any]]) -> DocstringSectionReturns: +def _to_returns_section(return_data: Iterable[dict[str, Any]]) -> DocstringSectionReturns: return DocstringSectionReturns( [ DocstringReturn( @@ -98,7 +99,7 @@ def _to_returns_section(return_data: Iterator[dict[str, Any]]) -> DocstringSecti ) -def _to_warns_section(warn_data: Iterator[dict[str, Any]]) -> DocstringSectionWarns: +def _to_warns_section(warn_data: Iterable[dict[str, Any]]) -> DocstringSectionWarns: return DocstringSectionWarns( [ DocstringWarn( @@ -110,7 +111,7 @@ def _to_warns_section(warn_data: Iterator[dict[str, Any]]) -> DocstringSectionWa ) -def _to_raises_section(raise_data: Iterator[dict[str, Any]]) -> DocstringSectionRaises: +def _to_raises_section(raise_data: Iterable[dict[str, Any]]) -> DocstringSectionRaises: return DocstringSectionRaises( [ DocstringRaise( diff --git a/src/griffe_typingdoc/_dynamic.py b/src/griffe_typingdoc/_dynamic.py index 2e91248..6c7a049 100644 --- a/src/griffe_typingdoc/_dynamic.py +++ b/src/griffe_typingdoc/_dynamic.py @@ -2,9 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any - -from typing_extensions import get_type_hints +from typing import TYPE_CHECKING, Any, get_type_hints from griffe_typingdoc._docstrings import _to_parameters_section diff --git a/src/griffe_typingdoc/_extension.py b/src/griffe_typingdoc/_extension.py index bfd5d1b..105d378 100644 --- a/src/griffe_typingdoc/_extension.py +++ b/src/griffe_typingdoc/_extension.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from griffe import Docstring, Extension, Function, ObjectNode @@ -10,9 +10,10 @@ if TYPE_CHECKING: import ast + from typing import Annotated from griffe.dataclasses import Attribute, Module, Object - from typing_extensions import Annotated, Doc + from typing_extensions import Doc class TypingDocExtension(Extension): @@ -107,11 +108,11 @@ def _handle_object(self, obj: Object) -> None: return if obj.is_module or obj.is_class: for member in obj.members.values(): - self._handle_object(member) # type: ignore[arg-type] + self._handle_object(member) elif obj.is_function: - self._handle_function(obj) # type: ignore[arg-type] + self._handle_function(obj) elif obj.is_attribute: - self._handle_attribute(obj) # type: ignore[arg-type] + self._handle_attribute(obj) def on_package_loaded( self, @@ -120,6 +121,7 @@ def on_package_loaded( Module, Doc("The top-level module representing a package."), ], + **kwargs: Any, # noqa: ARG002 ) -> None: """Post-process Griffe packages recursively (non-yet handled objects only).""" self._handle_object(pkg) @@ -135,6 +137,7 @@ def on_function_instance( Function, Doc("""The Griffe function just instantiated."""), ], + **kwargs: Any, # noqa: ARG002 ) -> None: """Post-process Griffe functions to add a parameters section. @@ -154,6 +157,7 @@ def on_attribute_instance( Attribute, Doc("The Griffe attribute just instantiated."), ], + **kwargs: Any, # noqa: ARG002 ) -> None: """Post-process Griffe attributes to create their docstring. diff --git a/src/griffe_typingdoc/_static.py b/src/griffe_typingdoc/_static.py index 652b6dd..778219a 100644 --- a/src/griffe_typingdoc/_static.py +++ b/src/griffe_typingdoc/_static.py @@ -5,10 +5,9 @@ import inspect from ast import literal_eval from collections import defaultdict -from typing import TYPE_CHECKING, Any, Sequence +from typing import TYPE_CHECKING, Any -from griffe.enumerations import ParameterKind -from griffe.expressions import Expr, ExprCall, ExprSubscript, ExprTuple +from griffe import Expr, ExprCall, ExprSubscript, ExprTuple, ParameterKind from griffe_typingdoc._docstrings import ( _no_self_params, @@ -23,6 +22,8 @@ ) if TYPE_CHECKING: + from collections.abc import Sequence + from griffe import Function from griffe.dataclasses import Attribute from griffe.docstrings.dataclasses import ( @@ -102,16 +103,17 @@ def _attribute_docs(attr: Attribute, **kwargs: Any) -> str: # noqa: ARG001 def _parameters_docs(func: Function, **kwargs: Any) -> DocstringSectionParameters | None: # noqa: ARG001 - params_doc: dict[str, dict[str, Any]] = defaultdict(dict) + params_data: dict[str, dict[str, Any]] = defaultdict(dict) for parameter in _no_self_params(func): stars = {ParameterKind.var_positional: "*", ParameterKind.var_keyword: "**"}.get(parameter.kind, "") # type: ignore[arg-type] param_name = f"{stars}{parameter.name}" metadata = _metadata(parameter.annotation) - description = f'{metadata.get("deprecated", "")} {metadata.get("doc", "")}'.lstrip() - params_doc[param_name]["annotation"] = parameter.annotation - params_doc[param_name]["description"] = description - if params_doc: - return _to_parameters_section(params_doc, func) + if "deprecated" in metadata or "doc" in metadata: + description = f"{metadata.get('deprecated', '')} {metadata.get('doc', '')}".lstrip() + params_data[param_name]["description"] = description + params_data[param_name]["annotation"] = parameter.annotation + if params_data: + return _to_parameters_section(params_data, func) return None @@ -123,79 +125,85 @@ def _other_parameters_docs(func: Function, **kwargs: Any) -> DocstringSectionPar "typing.Annotated", "typing_extensions.Annotated", }: - annotation = annotation.slice.elements[0] # type: ignore[attr-defined] + annotation = annotation.slice.elements[0] # type: ignore[union-attr] if isinstance(annotation, ExprSubscript) and annotation.canonical_path in { "typing.Unpack", "typing_extensions.Unpack", }: - typed_dict = annotation.slice.parent.get_member(annotation.slice.name) # type: ignore[attr-defined] - params_doc = { - attr.name: {"annotation": attr.annotation, "description": _metadata(attr.annotation).get("doc", "")} + slice_path = annotation.slice.canonical_path # type: ignore[union-attr] + typed_dict = func.modules_collection[slice_path] + params_data = { + attr.name: {"annotation": attr.annotation, "description": description} for attr in typed_dict.members.values() + if (description := _metadata(attr.annotation).get("doc")) is not None } - if params_doc: - return _to_other_parameters_section(params_doc) + if params_data: + return _to_other_parameters_section(params_data) break return None def _yields_docs(func: Function, **kwargs: Any) -> DocstringSectionYields | None: # noqa: ARG001 - yields_section = None yield_annotation = None - annotation = func.returns if isinstance(annotation, ExprSubscript): if annotation.canonical_path in {"typing.Generator", "typing_extensions.Generator"}: - yield_annotation = annotation.slice.elements[0] # type: ignore[attr-defined] + yield_annotation = annotation.slice.elements[0] # type: ignore[union-attr] elif annotation.canonical_path in {"typing.Iterator", "typing_extensions.Iterator"}: yield_annotation = annotation.slice if yield_annotation: if isinstance(yield_annotation, ExprSubscript) and yield_annotation.is_tuple: - yield_elements = yield_annotation.slice.elements # type: ignore[attr-defined] + yield_elements = yield_annotation.slice.elements # type: ignore[union-attr] else: yield_elements = [yield_annotation] - yields_section = _to_yields_section({"annotation": element, **_metadata(element)} for element in yield_elements) + yield_data = [ + {"annotation": element, **metadata} + for element in yield_elements + if "doc" in (metadata := _metadata(element)) + ] + if yield_data: + return _to_yields_section(yield_data) - return yields_section + return None def _receives_docs(func: Function, **kwargs: Any) -> DocstringSectionReceives | None: # noqa: ARG001 - receives_section = None receive_annotation = None - annotation = func.returns if isinstance(annotation, ExprSubscript) and annotation.canonical_path in { "typing.Generator", "typing_extensions.Generator", }: - receive_annotation = annotation.slice.elements[1] # type: ignore[attr-defined] + receive_annotation = annotation.slice.elements[1] # type: ignore[union-attr] if receive_annotation: if isinstance(receive_annotation, ExprSubscript) and receive_annotation.is_tuple: - receive_elements = receive_annotation.slice.elements # type: ignore[attr-defined] + receive_elements = receive_annotation.slice.elements # type: ignore[union-attr] else: receive_elements = [receive_annotation] - receives_section = _to_receives_section( - {"annotation": element, **_metadata(element)} for element in receive_elements - ) + receive_data = [ + {"annotation": element, **metadata} + for element in receive_elements + if "doc" in (metadata := _metadata(element)) + ] + if receive_data: + return _to_receives_section(receive_data) - return receives_section + return None def _returns_docs(func: Function, **kwargs: Any) -> DocstringSectionReturns | None: # noqa: ARG001 - returns_section = None return_annotation = None - annotation = func.returns if isinstance(annotation, ExprSubscript) and annotation.canonical_path in { "typing.Generator", "typing_extensions.Generator", }: - return_annotation = annotation.slice.elements[2] # type: ignore[attr-defined] + return_annotation = annotation.slice.elements[2] # type: ignore[union-attr] elif isinstance(annotation, ExprSubscript) and annotation.canonical_path in { "typing.Annotated", "typing_extensions.Annotated", @@ -204,21 +212,25 @@ def _returns_docs(func: Function, **kwargs: Any) -> DocstringSectionReturns | No if return_annotation: if isinstance(return_annotation, ExprSubscript) and return_annotation.is_tuple: - return_elements = return_annotation.slice.elements # type: ignore[attr-defined] + return_elements = return_annotation.slice.elements # type: ignore[union-attr] else: return_elements = [return_annotation] - returns_section = _to_returns_section( - {"annotation": element, **_metadata(element)} for element in return_elements - ) + return_data = [ + {"annotation": element, **metadata} + for element in return_elements + if "doc" in (metadata := _metadata(element)) + ] + if return_data: + return _to_returns_section(return_data) - return returns_section + return None def _warns_docs(attr_or_func: Attribute | Function, **kwargs: Any) -> DocstringSectionWarns | None: # noqa: ARG001 if attr_or_func.is_attribute: annotation = attr_or_func.annotation elif attr_or_func.is_function: - annotation = attr_or_func.returns # type: ignore[union-attr] + annotation = attr_or_func.returns metadata = _metadata(annotation) if metadata["warns"]: return _to_warns_section({"annotation": warned[0], "description": warned[1]} for warned in metadata["warns"]) @@ -229,7 +241,7 @@ def _raises_docs(attr_or_func: Attribute | Function, **kwargs: Any) -> Docstring if attr_or_func.is_attribute: annotation = attr_or_func.annotation elif attr_or_func.is_function: - annotation = attr_or_func.returns # type: ignore[union-attr] + annotation = attr_or_func.returns metadata = _metadata(annotation) if metadata["raises"]: return _to_raises_section({"annotation": raised[0], "description": raised[1]} for raised in metadata["raises"]) @@ -243,7 +255,7 @@ def _deprecated_docs( if attr_or_func.is_attribute: annotation = attr_or_func.annotation elif attr_or_func.is_function: - annotation = attr_or_func.returns # type: ignore[union-attr] + annotation = attr_or_func.returns metadata = _metadata(annotation) if "deprecated" in metadata: return _to_deprecated_section({"description": metadata["deprecated"]}) diff --git a/src/griffe_typingdoc/debug.py b/src/griffe_typingdoc/debug.py index acfc1cd..35335a9 100644 --- a/src/griffe_typingdoc/debug.py +++ b/src/griffe_typingdoc/debug.py @@ -37,6 +37,8 @@ class Environment: """Python interpreter name.""" interpreter_version: str """Python interpreter version.""" + interpreter_path: str + """Path to Python executable.""" platform: str """Operating System.""" packages: list[Package] @@ -83,6 +85,7 @@ def get_debug_info() -> Environment: return Environment( interpreter_name=py_name, interpreter_version=py_version, + interpreter_path=sys.executable, platform=platform.platform(), variables=[Variable(var, val) for var in variables if (val := os.getenv(var))], packages=[Package(pkg, get_version(pkg)) for pkg in packages], @@ -93,7 +96,7 @@ def print_debug_info() -> None: """Print debug/environment information.""" info = get_debug_info() print(f"- __System__: {info.platform}") - print(f"- __Python__: {info.interpreter_name} {info.interpreter_version}") + print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") print("- __Environment variables__:") for var in info.variables: print(f" - `{var.name}`: `{var.value}`") diff --git a/tests/test_extension.py b/tests/test_extension.py index a0ffc47..4688390 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,9 +1,7 @@ """Tests for the Griffe extension.""" -from griffe.docstrings.dataclasses import DocstringSectionKind -from griffe.extensions import Extensions -from griffe.loader import GriffeLoader -from griffe.tests import temporary_visited_package +import pytest +from griffe import DocstringSectionKind, Extensions, GriffeLoader, temporary_visited_package from griffe_typingdoc import TypingDocExtension @@ -150,3 +148,153 @@ def test_return_doc() -> None: extensions=Extensions(TypingDocExtension()), ) as package: assert package["f"].docstring.parsed[1].value[0].description == "Hello." + + +def test_unpacking_typed_dict() -> None: + """Unpack typed dicts, resolving them to their right location.""" + with temporary_visited_package( + "package", + { + "__init__.py": """ + from typing import TypedDict + from typing_extensions import Annotated, Doc, Unpack + + from package import module + + class Options(TypedDict): + foo: Annotated[int, Doc("Foo's description.")] + + class A: + def __init__(self, **kwargs: Unpack[Options]) -> None: + '''Init.''' + self.options = kwargs + + class B: + def __init__(self, **kwargs: Unpack[module.Options]) -> None: + '''Init.''' + self.options = kwargs + """, + "module.py": """ + from typing import TypedDict + from typing_extensions import Annotated, Doc + + class Options(TypedDict): + bar: Annotated[str, Doc("Bar's description.")] + """, + }, + extensions=Extensions(TypingDocExtension()), + ) as package: + sections = package["A.__init__"].docstring.parsed + assert len(sections) == 2 + assert sections[0].kind is DocstringSectionKind.text + assert sections[1].kind is DocstringSectionKind.other_parameters + foo = sections[1].value[0] + assert foo.name == "foo" + assert foo.description == "Foo's description." + assert str(foo.annotation).startswith("Annotated[int") + + sections = package["B.__init__"].docstring.parsed + assert len(sections) == 2 + assert sections[0].kind is DocstringSectionKind.text + assert sections[1].kind is DocstringSectionKind.other_parameters + bar = sections[1].value[0] + assert bar.name == "bar" + assert bar.description == "Bar's description." + assert str(bar.annotation).startswith("Annotated[str") + + +@pytest.mark.parametrize( + "annotation", + ["int", "Annotated[int, '']"], +) +def test_ignore_unannotated_params(annotation: str) -> None: + """Ignore parameters that are not annotated with `Doc`.""" + with temporary_visited_package( + "package", + { + "__init__.py": f"{typing_imports}\ndef f(a: {annotation}):\n '''Docstring.'''", + }, + extensions=Extensions(TypingDocExtension()), + ) as package: + sections = package["f"].docstring.parsed + assert len(sections) == 1 + assert sections[0].kind is DocstringSectionKind.text + + +@pytest.mark.parametrize( + "annotation", + ["int", "Annotated[int, '']"], +) +def test_ignore_unannotated_other_params(annotation: str) -> None: + """Ignore other parameters that are not annotated with `Doc`.""" + with temporary_visited_package( + "package", + { + "__init__.py": f""" + {typing_imports} + from typing import TypedDict + class Kwargs(TypedDict): + a: {annotation} + def f(**kwargs: Unpack[Kwargs]): + '''Docstring.''' + """, + }, + extensions=Extensions(TypingDocExtension()), + ) as package: + sections = package["f"].docstring.parsed + assert len(sections) == 1 + assert sections[0].kind is DocstringSectionKind.text + + +@pytest.mark.parametrize( + "annotation", + ["int", "Annotated[int, '']"], +) +def test_ignore_unannotated_returns(annotation: str) -> None: + """Ignore return values that are not annotated with `Doc`.""" + with temporary_visited_package( + "package", + { + "__init__.py": f"{typing_imports}\ndef f() -> {annotation}:\n '''Docstring.'''", + }, + extensions=Extensions(TypingDocExtension()), + ) as package: + sections = package["f"].docstring.parsed + assert len(sections) == 1 + assert sections[0].kind is DocstringSectionKind.text + + +@pytest.mark.parametrize( + "annotation", + ["int", "Annotated[int, '']"], +) +def test_ignore_unannotated_yields(annotation: str) -> None: + """Ignore yields that are not annotated with `Doc`.""" + with temporary_visited_package( + "package", + { + "__init__.py": f"{typing_imports}\ndef f() -> Iterator[{annotation}]:\n '''Docstring.'''", + }, + extensions=Extensions(TypingDocExtension()), + ) as package: + sections = package["f"].docstring.parsed + assert len(sections) == 1 + assert sections[0].kind is DocstringSectionKind.text + + +@pytest.mark.parametrize( + "annotation", + ["int", "Annotated[int, '']"], +) +def test_ignore_unannotated_receives(annotation: str) -> None: + """Ignore receives that are not annotated with `Doc`.""" + with temporary_visited_package( + "package", + { + "__init__.py": f"{typing_imports}\ndef f() -> Generator[int, {annotation}, None]:\n '''Docstring.'''", + }, + extensions=Extensions(TypingDocExtension()), + ) as package: + sections = package["f"].docstring.parsed + assert len(sections) == 1 + assert sections[0].kind is DocstringSectionKind.text