├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc.cjs ├── .readthedocs.yml ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.rst ├── codecov.yml ├── docs ├── _templates │ └── autosummary │ │ └── module.rst ├── conf.py └── index.rst ├── pyproject.toml ├── src └── scanpydoc │ ├── __init__.py │ ├── _types.py │ ├── autosummary_generate_imported.py │ ├── definition_list_typed_field.py │ ├── elegant_typehints │ ├── __init__.py │ ├── _autodoc_patch.py │ ├── _formatting.py │ ├── _return_tuple.py │ ├── _role_mapping.py │ └── example.py │ ├── py.typed │ ├── release_notes.py │ ├── rtd_github_links │ ├── __init__.py │ ├── _linkcode.py │ └── _testdata.py │ ├── testing.py │ └── theme │ ├── __init__.py │ ├── layout.html │ ├── static │ ├── scripts │ │ └── rtd-sphinx-search.js │ └── styles │ │ └── scanpy.css │ └── theme.conf └── tests ├── conftest.py ├── test_base.py ├── test_definition_list_typed_field.py ├── test_elegant_typehints.py ├── test_release_notes.py └── test_rtd_github_links.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | max_line_length = 88 9 | indent_size = 4 10 | indent_style = space 11 | 12 | [*.{yml,yaml}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 2 | 3 | name: Python tests 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: ["*"] 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | env: 16 | PIP_ROOT_USER_ACTION: ignore 17 | COLUMNS: "200" 18 | FORCE_COLOR: "1" 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | python-version: ["3.10", "3.11", "3.12"] 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | filter: blob:none 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - uses: hynek/setup-cached-uv@v2 35 | with: 36 | cache-dependency-path: pyproject.toml 37 | - name: dependencies 38 | # TODO: remove typer constraint after fixing https://github.com/WaylonWalker/coverage-rich/issues/4 39 | run: uv pip install --system .[test,typehints,myst] coverage-rich 'typer <0.14' 'anyconfig[toml] >=0.14' 40 | - name: tests 41 | run: coverage run -m pytest --verbose --color=yes 42 | - name: show coverage 43 | run: coverage-rich report 44 | - name: upload coverage 45 | uses: codecov/codecov-action@v5 46 | with: 47 | fail_ci_if_error: true 48 | token: c66a2830-d3c7-4ae7-a230-21aef89dcf65 49 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | environment: publish 11 | permissions: 12 | id-token: write # to authenticate as Trusted Publisher to pypi.org 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.x" 18 | cache: "pip" 19 | - run: pip install build 20 | - run: python -m build 21 | - uses: pypa/gh-action-pypi-publish@release/v1 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | _version.py 4 | /build/ 5 | /dist/ 6 | /.python-version 7 | 8 | # Testing 9 | /.coverage* 10 | /coverage.* 11 | /.*cache/ 12 | 13 | # Docs 14 | /docs/_build/ 15 | /docs/scanpydoc.*.rst 16 | 17 | # IDEs 18 | /.idea/ 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - repo: https://github.com/astral-sh/ruff-pre-commit 7 | rev: v0.11.12 8 | hooks: 9 | - id: ruff 10 | args: [--fix, --exit-non-zero-on-fix] 11 | - id: ruff-format 12 | - repo: https://github.com/pre-commit/mirrors-prettier 13 | rev: v4.0.0-alpha.8 14 | hooks: 15 | - id: prettier 16 | additional_dependencies: 17 | - prettier@3.0.2 18 | - prettier-plugin-jinja-template@1.0.0 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v1.16.0 21 | hooks: 22 | - id: mypy 23 | additional_dependencies: 24 | - sphinx 25 | - pytest 26 | - types-docutils 27 | - legacy-api-wrap 28 | - myst-parser 29 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | // TODO: switch back to YAML once https://github.com/prettier/prettier/issues/15141 (I assume) is fixed 2 | // or at least switch to mjs once https://github.com/prettier/prettier-vscode/issues/3066 is fixed 3 | 4 | /** @type {import("prettier").Config} */ 5 | module.exports = { 6 | plugins: [require.resolve("prettier-plugin-jinja-template")], 7 | semi: false, 8 | overrides: [ 9 | { 10 | files: [".vscode/*.json"], 11 | options: { 12 | parser: "json5", 13 | quoteProps: "preserve", 14 | singleQuote: false, 15 | trailingComma: "all", 16 | }, 17 | }, 18 | { 19 | files: ["*.html"], 20 | options: { 21 | parser: "jinja-template", 22 | }, 23 | }, 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-24.04 4 | tools: 5 | python: "3.12" 6 | python: 7 | install: 8 | - method: pip 9 | path: . 10 | extra_requirements: 11 | - doc 12 | sphinx: 13 | configuration: docs/conf.py 14 | fail_on_warning: true 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Verwendet IntelliSense zum Ermitteln möglicher Attribute. 3 | // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. 4 | // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug current File", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "${file}", 12 | "pythonArgs": ["-Xfrozen_modules=off"], 13 | "console": "internalConsole", 14 | "justMyCode": false, 15 | }, 16 | { 17 | "name": "Build Documentation", 18 | "type": "debugpy", 19 | "request": "launch", 20 | "module": "sphinx", 21 | "args": ["-M", "html", ".", "_build"], 22 | "cwd": "${workspaceFolder}/docs", 23 | "console": "internalConsole", 24 | "justMyCode": false, 25 | }, 26 | { 27 | "name": "Debug test", 28 | "type": "debugpy", 29 | "request": "launch", 30 | "program": "${file}", 31 | "pythonArgs": ["-Xfrozen_modules=off"], 32 | "console": "internalConsole", 33 | "justMyCode": false, 34 | "purpose": ["debug-test"], 35 | "presentation": { 36 | "hidden": true, 37 | }, 38 | }, 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "strict", 3 | "python.testing.pytestArgs": ["-vv", "--color=yes"], 4 | "python.testing.pytestEnabled": true, 5 | "[python]": { 6 | "editor.defaultFormatter": "charliermarsh.ruff", 7 | "editor.formatOnSave": true, 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll": "explicit", 10 | "source.organizeImports": "explicit", 11 | }, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | scanpydoc |pypi| |docs| |tests| |checks| |cov| 2 | ============================================== 3 | 4 | A collection of Sphinx extensions similar to (but more flexible than) numpydoc. 5 | 6 | Check the self-documenting documentation at https://icb-scanpydoc.readthedocs-hosted.com 7 | 8 | .. |pypi| image:: https://img.shields.io/pypi/v/scanpydoc.svg 9 | :target: https://pypi.org/project/scanpydoc/ 10 | :alt: PiPI version 11 | .. |docs| image:: https://readthedocs.com/projects/icb-scanpydoc/badge/ 12 | :target: https://icb-scanpydoc.readthedocs-hosted.com/ 13 | :alt: doc build status 14 | .. |tests| image:: https://github.com/theislab/scanpydoc/actions/workflows/ci.yml/badge.svg 15 | :target: https://github.com/theislab/scanpydoc/actions/workflows/ci.yml 16 | :alt: python test status 17 | .. |checks| image:: https://results.pre-commit.ci/badge/github/theislab/scanpydoc/main.svg 18 | :target: https://results.pre-commit.ci/latest/github/theislab/scanpydoc/main 19 | :alt: pre-commit.ci status 20 | .. |cov| image:: https://codecov.io/gh/theislab/scanpydoc/branch/main/graph/badge.svg 21 | :target: https://codecov.io/gh/theislab/scanpydoc 22 | :alt: coverage 23 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 100% 6 | if_not_found: failure 7 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | {# https://raw.githubusercontent.com/sphinx-doc/sphinx/master/sphinx/ext/autosummary/templates/autosummary/module.rst #} 2 | {% extends "!autosummary/module.rst" %} 3 | 4 | {% block classes %} 5 | {% if classes -%} 6 | Classes 7 | ------- 8 | 9 | {%- for class in classes -%} 10 | {%- if not class.startswith('_') %} 11 | .. autoclass:: {{ class }} 12 | :members: 13 | {%- endif -%} 14 | {%- endfor -%} 15 | {%- endif %} 16 | {% endblock %} 17 | 18 | {% block functions %} 19 | {% if functions -%} 20 | Functions 21 | --------- 22 | 23 | {%- for function in functions -%} 24 | {%- if not function.startswith('_') and not function.startswith('example_') %} 25 | .. autofunction:: {{ function }} 26 | {%- endif -%} 27 | {%- endfor -%} 28 | {%- if functions | select('search', '^example_') | first %} 29 | 30 | Examples 31 | -------- 32 | 33 | {%- for function in functions -%} 34 | {%- if function.startswith('example_') %} 35 | .. autofunction:: {{ function }} 36 | {%- endif -%} 37 | {%- endfor -%} 38 | {%- endif -%} 39 | {%- endif %} 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from typing import TYPE_CHECKING 7 | from pathlib import PurePosixPath 8 | from datetime import datetime 9 | from datetime import timezone as tz 10 | from importlib.metadata import metadata 11 | 12 | from jinja2.tests import TESTS 13 | 14 | 15 | if TYPE_CHECKING: 16 | from sphinx.application import Sphinx 17 | 18 | 19 | extensions = [ 20 | "sphinx.ext.intersphinx", 21 | "sphinx.ext.napoleon", 22 | "sphinx_autodoc_typehints", # needs to be after napoleon 23 | "sphinx.ext.autodoc", 24 | "sphinx.ext.autosummary", 25 | "scanpydoc", 26 | "sphinx.ext.linkcode", # needs to be after scanpydoc 27 | ] 28 | 29 | intersphinx_mapping = dict( 30 | python=("https://docs.python.org/3", None), 31 | jinja=("https://jinja.palletsprojects.com/en/2.10.x/", None), 32 | sphinx=("https://www.sphinx-doc.org/en/master/", None), 33 | sphinx_book_theme=("https://sphinx-book-theme.readthedocs.io/en/stable/", None), 34 | # examples 35 | numpy=("https://numpy.org/doc/stable/", None), 36 | anndata=("https://anndata.readthedocs.io/en/latest/", None), 37 | pandas=("https://pandas.pydata.org/pandas-docs/stable/", None), 38 | scipy=("https://docs.scipy.org/doc/scipy/", None), 39 | ) 40 | 41 | # general information 42 | meta = metadata("scanpydoc") 43 | project = meta["name"] 44 | author = meta["author-email"].split(" <")[0] 45 | copyright = f"{datetime.now(tz=tz.utc):%Y}, {author}." # noqa: A001 46 | version = release = meta["version"] 47 | 48 | master_doc = "index" 49 | templates_path = ["_templates"] 50 | 51 | # Generate .rst stubs for modules using autosummary 52 | autosummary_generate = True 53 | autosummary_ignore_module_all = False 54 | # Don’t add module paths to documented functions’ names 55 | add_module_names = False 56 | 57 | napoleon_google_docstring = False 58 | napoleon_numpy_docstring = True 59 | 60 | 61 | def test_search(value: str, pattern: str) -> bool: 62 | """Tests if `pattern` can be found in `value`.""" 63 | return bool(re.search(pattern, value)) 64 | 65 | 66 | # IDK if there’s a good way to do this without modifying the global list 67 | TESTS["search"] = test_search 68 | 69 | html_theme = "scanpydoc" 70 | html_theme_options = dict( 71 | repository_url="https://github.com/theislab/scanpydoc", 72 | repository_branch="main", 73 | use_repository_button=True, 74 | ) 75 | 76 | rtd_links_prefix = PurePosixPath("src") 77 | 78 | 79 | def setup(app: Sphinx) -> None: 80 | """Set up custom Sphinx extension.""" 81 | app.add_object_type( 82 | "confval", 83 | "confval", 84 | objname="configuration value", 85 | indextemplate="pair: %s; configuration value", 86 | ) 87 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Scanpydoc 2 | ========= 3 | 4 | .. automodule:: scanpydoc 5 | :members: 6 | 7 | Included extensions 8 | ------------------- 9 | 10 | .. autosummary:: 11 | :toctree: . 12 | 13 | definition_list_typed_field 14 | elegant_typehints 15 | rtd_github_links 16 | release_notes 17 | theme 18 | 19 | .. hidden deprecated extension(s): 20 | 21 | .. toctree:: 22 | :hidden: 23 | 24 | scanpydoc.autosummary_generate_imported 25 | 26 | .. 27 | .. autosummary:: 28 | :toctree: . 29 | 30 | autosummary_generate_imported 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'scanpydoc' 3 | dynamic = ['version'] 4 | description = 'A series of Sphinx extensions to get maintainable numpydoc style documentation.' 5 | authors = [ 6 | { name = 'Philipp Angerer', email = 'phil.angerer@gmail.com' }, 7 | ] 8 | urls.Source = 'https://github.com/theislab/scanpydoc/' 9 | urls.Documentation = 'https://icb-scanpydoc.readthedocs-hosted.com/' 10 | readme = 'README.rst' 11 | license = 'GPL-3.0-or-later' 12 | classifiers = [ 13 | 'Intended Audience :: Developers', 14 | 'Programming Language :: Python :: 3', 15 | 'Topic :: Documentation :: Sphinx', 16 | 'Topic :: Software Development :: Libraries :: Python Modules', 17 | 'Framework :: Sphinx :: Extension', 18 | 'Typing :: Typed', 19 | ] 20 | requires-python = '>=3.10' 21 | dependencies = [ 22 | 'sphinx>=7.0', 23 | ] 24 | 25 | [project.optional-dependencies] 26 | dev = ['pre-commit'] 27 | test = [ 28 | 'pytest', 29 | 'coverage', 30 | 'legacy-api-wrap', 31 | 'defusedxml', # sphinx[test] would also pull in cython 32 | 'sphinx>=8.1.0' # https://github.com/sphinx-doc/sphinx/pull/12743 33 | ] 34 | doc = [ 35 | 'scanpydoc[typehints,myst,theme]', 36 | 'sphinx', 37 | ] 38 | typehints = ['sphinx-autodoc-typehints>=1.15.2'] 39 | theme = ['sphinx-book-theme>=1.1.0'] 40 | myst = ['myst-parser'] 41 | 42 | [project.entry-points.'sphinx.html_themes'] 43 | scanpydoc = 'scanpydoc.theme' 44 | 45 | [tool.ruff.lint] 46 | select = ['ALL'] 47 | allowed-confusables = ['’', '×', 'l'] 48 | ignore = [ 49 | 'E741', # Ambiguous variable name 50 | 'C408', # `dict` calls are useful 51 | 'D203', # No blank lines before class body 52 | 'D213', # Docstring summary on first line 53 | 'D407', # We’re not using Numpydoc style 54 | 'FIX002', # TODOs are OK 55 | 'PD', # False positives 56 | 'COM812', # Conflicts with formatting 57 | 'ISC001', # Conflicts with formatting 58 | ] 59 | [tool.ruff.lint.per-file-ignores] 60 | 'example.py' = ['ALL'] 61 | 'docs/conf.py' = [ 62 | 'INP001', # `docs` is not a namespace package 63 | ] 64 | 'tests/**/*.py' = [ 65 | 'INP001', # test directories are not namespace packages 66 | 'D103', # Test functions don’t need docstrings 67 | 'S101', # Pytest tests use `assert` 68 | 'RUF018', # Assignment expressions in assert are fine here 69 | 'PLR0913', # Tests should be able to use as many fixtures as they want 70 | ] 71 | [tool.ruff.lint.flake8-type-checking] 72 | strict = true 73 | exempt-modules = [] 74 | [tool.ruff.lint.isort] 75 | length-sort = true 76 | lines-after-imports = 2 77 | known-first-party = ['scanpydoc'] 78 | required-imports = ['from __future__ import annotations'] 79 | [tool.ruff.lint.pydocstyle] 80 | convention = 'numpy' 81 | 82 | [tool.mypy] 83 | strict = true 84 | explicit_package_bases = true 85 | disallow_untyped_defs = false # handled by Ruff 86 | mypy_path = ['$MYPY_CONFIG_FILE_DIR/src'] 87 | 88 | [tool.hatch.version] 89 | source = 'vcs' 90 | [tool.hatch.build.hooks.vcs] 91 | version-file = 'src/scanpydoc/_version.py' 92 | 93 | [tool.hatch.envs.default] 94 | dependencies = ['types-docutils'] 95 | [tool.hatch.envs.docs] 96 | python = '3.11' 97 | features = ['doc'] 98 | [tool.hatch.envs.docs.scripts] 99 | build = 'sphinx-build -M html docs docs/_build' 100 | clean = 'git clean -fdX docs' 101 | 102 | [tool.hatch.envs.hatch-test] 103 | features = ['test', 'typehints', 'myst'] 104 | 105 | [tool.pytest.ini_options] 106 | addopts = [ 107 | '--import-mode=importlib', 108 | '-psphinx.testing.fixtures', 109 | ] 110 | filterwarnings = [ 111 | 'error', 112 | 'ignore:The frontend.Option:DeprecationWarning', 113 | ] 114 | 115 | [tool.coverage.run] 116 | source_pkgs = ['scanpydoc'] 117 | [tool.coverage.paths] 118 | scanpydoc = ['src/scanpydoc'] 119 | [tool.coverage.report] 120 | exclude_lines = [ 121 | 'no cov', 122 | 'if __name__ == .__main__.:', 123 | 'if TYPE_CHECKING:', 124 | ] 125 | 126 | [build-system] 127 | requires = ['hatchling', 'hatch-vcs'] 128 | build-backend = 'hatchling.build' 129 | -------------------------------------------------------------------------------- /src/scanpydoc/__init__.py: -------------------------------------------------------------------------------- 1 | """A series of Sphinx extensions to get maintainable numpydoc style documentation. 2 | 3 | This module is also an extension itself which simply sets up all included extensions. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from typing import TYPE_CHECKING, Any, TypeVar 9 | from textwrap import indent 10 | from collections.abc import Callable 11 | 12 | from ._version import __version__ 13 | 14 | 15 | if TYPE_CHECKING: 16 | from sphinx.application import Sphinx 17 | 18 | 19 | metadata = dict(version=__version__, env_version=1, parallel_read_safe=True) 20 | 21 | # Can’t seem to be able to do this in numpydoc style: 22 | # https://github.com/sphinx-doc/sphinx/issues/5887 23 | setup_sig_str = """\ 24 | Arguments 25 | --------- 26 | app 27 | Sphinx app to set this :term:`sphinx:extension` up for 28 | 29 | Returns 30 | ------- 31 | :ref:`Metadata ` for this extension. 32 | """ 33 | 34 | C = TypeVar("C", bound=Callable[..., Any]) 35 | 36 | 37 | def _setup_sig(fn: C) -> C: 38 | fn.__doc__ = f"{fn.__doc__ or ''}\n\n{indent(setup_sig_str, ' ' * 4)}" 39 | return fn 40 | 41 | 42 | @_setup_sig 43 | def setup(app: Sphinx) -> dict[str, Any]: 44 | """Set up all included extensions!""" # noqa: D400 45 | app.setup_extension("scanpydoc.definition_list_typed_field") 46 | app.setup_extension("scanpydoc.elegant_typehints") 47 | app.setup_extension("scanpydoc.rtd_github_links") 48 | app.setup_extension("scanpydoc.theme") 49 | app.setup_extension("scanpydoc.release_notes") 50 | return metadata 51 | -------------------------------------------------------------------------------- /src/scanpydoc/_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Generic, TypeVar 4 | 5 | 6 | _GenericAlias: type = type(Generic[TypeVar("_")]) 7 | -------------------------------------------------------------------------------- /src/scanpydoc/autosummary_generate_imported.py: -------------------------------------------------------------------------------- 1 | """Generate autosummary for imported members. 2 | 3 | .. deprecated:: 0.11.0 4 | 5 | Use ``autosummary_imported_members = True`` instead. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from typing import TYPE_CHECKING 11 | 12 | from . import _setup_sig 13 | 14 | 15 | if TYPE_CHECKING: 16 | import sys 17 | 18 | if sys.version_info >= (3, 11): 19 | from typing import Never 20 | else: # pragma: no cover 21 | from typing import NoReturn as Never 22 | 23 | from sphinx.application import Sphinx 24 | 25 | 26 | @_setup_sig 27 | def setup(_app: Sphinx) -> Never: # pragma: no cover 28 | """Throws an :exc:`ImportError`.""" 29 | msg = ( 30 | "Please use `autosummary_imported_members = True` " 31 | f"instead of the {__name__} Sphinx extension." 32 | ) 33 | raise ImportError(msg) 34 | -------------------------------------------------------------------------------- /src/scanpydoc/definition_list_typed_field.py: -------------------------------------------------------------------------------- 1 | """Prettier function parameter documentation. 2 | 3 | This extension replaces the default :class:`~sphinx.domains.python.PyTypedField` 4 | with a derivative :class:`DLTypedField`, which renders item items 5 | (e.g. function parameters) as definition lists instead of simple paragraphs. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from typing import TYPE_CHECKING 11 | 12 | from sphinx import addnodes 13 | from docutils import nodes 14 | from sphinx.domains.python import ( # type: ignore[attr-defined,unused-ignore] 15 | PyObject, 16 | PyTypedField, 17 | ) 18 | 19 | from . import metadata, _setup_sig 20 | 21 | 22 | if TYPE_CHECKING: 23 | from typing import Any, TypeAlias 24 | from collections.abc import Iterable 25 | 26 | from sphinx.application import Sphinx 27 | from sphinx.environment import BuildEnvironment 28 | 29 | TextLikeNode: TypeAlias = nodes.Text | nodes.TextElement 30 | 31 | 32 | class DLTypedField(PyTypedField): 33 | """A reStructuredText field-list renderer that creates definition lists. 34 | 35 | This style is more readable than cramming name, type, and description in one line. 36 | 37 | The class is not intended to be used directly, but by using the extension, 38 | it will be used instead of the default :class:`~sphinx.domains.python.PyTypedField`. 39 | """ 40 | 41 | #: Override the list type 42 | list_type: type[nodes.definition_list] = nodes.definition_list # type: ignore[assignment] 43 | 44 | def make_field( # type: ignore[override] 45 | self, 46 | types: dict[str, list[nodes.Node]], 47 | domain: str, 48 | items: Iterable[tuple[str, list[nodes.inline]]], 49 | env: BuildEnvironment | None = None, 50 | **kw: Any, # noqa: ANN401 51 | ) -> nodes.field: 52 | """Render a field to a documenttree node representing a definition list item.""" 53 | 54 | def make_refs( 55 | role_name: str, name: str, node: type[TextLikeNode] 56 | ) -> list[nodes.Node]: 57 | return self.make_xrefs(role_name, domain, name, node, env=env, **kw) 58 | 59 | def handle_item( 60 | fieldarg: str, content: list[nodes.inline] 61 | ) -> nodes.definition_list_item: 62 | term = nodes.term() 63 | term += make_refs(self.rolename, fieldarg, addnodes.literal_strong) 64 | 65 | field_type = types.pop(fieldarg, None) 66 | if field_type is not None: 67 | # convert `param : SomeClass` into reference 68 | if len(field_type) == 1 and isinstance(field_type[0], nodes.Text): 69 | classifier_content = make_refs( 70 | self.typerolename, 71 | field_type[0].astext(), 72 | addnodes.literal_emphasis, 73 | ) 74 | else: 75 | classifier_content = field_type 76 | term += [ 77 | # https://github.com/sphinx-doc/sphinx/issues/10815 78 | nodes.Text(" "), 79 | # Sphinx tries to fixup classifiers without rawsource, 80 | # but for this expects attributes we don’t have. Thus “×”. 81 | nodes.classifier("×", "", *classifier_content), 82 | ] 83 | 84 | def_content = nodes.paragraph("", "", *content) 85 | definition = nodes.definition("", def_content) 86 | 87 | return nodes.definition_list_item("", term, definition) 88 | 89 | field_name = nodes.field_name("", self.label) 90 | assert not self.can_collapse # noqa: S101 91 | body_node = self.list_type(classes=["simple"]) 92 | for field_arg, content in items: 93 | body_node += handle_item(field_arg, content) 94 | field_body = nodes.field_body("", body_node) 95 | return nodes.field("", field_name, field_body) 96 | 97 | 98 | @_setup_sig 99 | def setup(app: Sphinx) -> dict[str, Any]: 100 | """Replace :class:`~sphinx.domains.python.PyTypedField` with ours.""" 101 | napoleon_requested = "sphinx.ext.napoleon" in app.config.extensions 102 | napoleon_loaded = next( 103 | (True for ft in PyObject.doc_field_types if ft.name == "keyword"), 104 | False, 105 | ) 106 | if napoleon_requested and not napoleon_loaded: 107 | msg = f"Please load sphinx.ext.napoleon before {__name__}" 108 | raise RuntimeError(msg) 109 | 110 | PyObject.doc_field_types = [ 111 | DLTypedField( 112 | ft.name, 113 | names=ft.names, 114 | typenames=ft.typenames, 115 | label=ft.label, 116 | rolename=ft.rolename, 117 | typerolename=ft.typerolename, 118 | # Definition lists can’t collapse. 119 | can_collapse=False, 120 | ) 121 | if isinstance(ft, PyTypedField) 122 | else ft 123 | for ft in PyObject.doc_field_types 124 | ] 125 | 126 | return metadata 127 | -------------------------------------------------------------------------------- /src/scanpydoc/elegant_typehints/__init__.py: -------------------------------------------------------------------------------- 1 | """Format typehints elegantly and and fix automatically created links. 2 | 3 | The Sphinx extension :mod:`sphinx_autodoc_typehints` adds type annotations to functions. 4 | This extension modifies the created type annotations in four ways: 5 | 6 | #. It formats the annotations more simply and in line with e.g. :mod:`numpy`. 7 | #. It defines a configuration value ``qualname_overrides`` for ``conf.py`` 8 | that overrides automatically created links. It is used like this:: 9 | 10 | qualname_overrides = { 11 | "pandas.core.frame.DataFrame": "pandas.DataFrame", # fix qualname 12 | "numpy.int64": ("py:data", "numpy.int64"), # fix role 13 | ..., 14 | } 15 | 16 | The defaults include :class:`anndata.AnnData`, :class:`pandas.DataFrame`, 17 | :class:`scipy.sparse.spmatrix` and other classes in :mod:`scipy.sparse`. 18 | 19 | It is necessary since :attr:`~definition.__qualname__` does not necessarily match 20 | the documented location of the function/class. 21 | 22 | Once either `sphinx issue 4826`_ or `sphinx-autodoc-typehints issue 38`_ are fixed, 23 | this part of the functionality will no longer be necessary. 24 | #. The config value ``annotate_defaults`` (default: :data:`True`) controls if rST code 25 | like ``(default: `42`)`` is added after the type. 26 | It sets sphinx-autodoc-typehints’s option ``typehints_defaults`` to ``'braces'`` 27 | #. Type annotations for :class:`tuple` return types are added:: 28 | 29 | def x() -> Tuple[int, float]: 30 | \""" 31 | Returns: 32 | a: An integer 33 | b: A floating point number 34 | \""" 35 | 36 | will render as: 37 | 38 | :Returns: a : :class:`int` 39 | An integer 40 | b : :class:`float` 41 | A floating point number 42 | 43 | 44 | .. _sphinx issue 4826: https://github.com/sphinx-doc/sphinx/issues/4826 45 | .. _sphinx-autodoc-typehints issue 38: https://github.com/tox-dev/sphinx-autodoc-typehints/issues/38 46 | 47 | """ # noqa: D300 48 | 49 | from __future__ import annotations 50 | 51 | from typing import TYPE_CHECKING, cast 52 | from pathlib import Path 53 | from collections import ChainMap 54 | from dataclasses import dataclass 55 | 56 | from sphinx.ext.autodoc import ClassDocumenter 57 | 58 | from scanpydoc import metadata, _setup_sig 59 | from scanpydoc.elegant_typehints._role_mapping import RoleMapping 60 | 61 | from .example import ( 62 | example_func_prose, 63 | example_func_tuple, 64 | example_func_anonymous_tuple, 65 | ) 66 | 67 | 68 | if TYPE_CHECKING: 69 | from typing import Any 70 | from collections.abc import Callable 71 | 72 | from sphinx.config import Config 73 | from docutils.nodes import TextElement, reference 74 | from sphinx.addnodes import pending_xref 75 | from sphinx.application import Sphinx 76 | from sphinx.environment import BuildEnvironment 77 | 78 | 79 | __all__ = [ 80 | "example_func_anonymous_tuple", 81 | "example_func_prose", 82 | "example_func_tuple", 83 | "setup", 84 | ] 85 | 86 | 87 | HERE = Path(__file__).parent.resolve() 88 | 89 | qualname_overrides_default = { 90 | "anndata.base.AnnData": "anndata.AnnData", 91 | "anndata.core.anndata.AnnData": "anndata.AnnData", 92 | "anndata._core.anndata.AnnData": "anndata.AnnData", 93 | "matplotlib.axes._axes.Axes": "matplotlib.axes.Axes", 94 | "pandas.core.frame.DataFrame": "pandas.DataFrame", 95 | "pandas.core.indexes.base.Index": "pandas.Index", 96 | "scipy.sparse.base.spmatrix": "scipy.sparse.spmatrix", 97 | "scipy.sparse.csr.csr_matrix": "scipy.sparse.csr_matrix", 98 | "scipy.sparse.csc.csc_matrix": "scipy.sparse.csc_matrix", 99 | } 100 | qualname_overrides = ChainMap( 101 | RoleMapping(), 102 | RoleMapping.from_user(qualname_overrides_default), # type: ignore[arg-type] 103 | ) 104 | 105 | 106 | def _init_vars(_app: Sphinx, config: Config) -> None: 107 | cast("RoleMapping", qualname_overrides.maps[0]).update_user( 108 | config.qualname_overrides 109 | ) 110 | if ( 111 | "sphinx_autodoc_typehints" in config.extensions 112 | and config.typehints_defaults is None 113 | and config.annotate_defaults 114 | ): 115 | # override default for “typehints_defaults” 116 | config.typehints_defaults = "braces" 117 | 118 | 119 | @dataclass 120 | class PickleableCallable: 121 | func: Callable[..., Any] 122 | 123 | __call__ = property(lambda self: self.func) 124 | 125 | 126 | # https://www.sphinx-doc.org/en/master/extdev/event_callbacks.html#event-missing-reference 127 | def _last_resolve( 128 | app: Sphinx, 129 | env: BuildEnvironment, 130 | node: pending_xref, 131 | contnode: TextElement, 132 | ) -> reference | None: 133 | if "sphinx.ext.intersphinx" not in app.extensions: 134 | return None 135 | 136 | from sphinx.ext.intersphinx import resolve_reference_detect_inventory 137 | 138 | if ( 139 | ref := qualname_overrides.get( 140 | (f"{node['refdomain']}:{node['reftype']}", node["reftarget"]) 141 | ) 142 | ) is None: 143 | return None 144 | role, node["reftarget"] = ref 145 | if role is not None: 146 | node["refdomain"], node["reftype"] = role.split(":", 1) 147 | return resolve_reference_detect_inventory(env, node, contnode) 148 | 149 | 150 | @_setup_sig 151 | def setup(app: Sphinx) -> dict[str, Any]: 152 | """Patches :mod:`sphinx_autodoc_typehints` for a more elegant display.""" 153 | if "sphinx.ext.autodoc" not in app.extensions: 154 | msg = "`scanpydoc.elegant_typehints` requires `sphinx.ext.autodoc`." 155 | raise RuntimeError(msg) 156 | 157 | app.add_config_value("qualname_overrides", default={}, rebuild="html") 158 | app.add_config_value("annotate_defaults", default=True, rebuild="html") 159 | app.connect("config-inited", _init_vars) 160 | # Add 1 to priority to run after sphinx.ext.intersphinx 161 | app.connect("missing-reference", _last_resolve, priority=501) 162 | 163 | from ._formatting import typehints_formatter 164 | 165 | app.config["typehints_formatter"] = PickleableCallable(typehints_formatter) 166 | 167 | from ._autodoc_patch import dir_head_adder 168 | 169 | ClassDocumenter.add_directive_header = dir_head_adder( # type: ignore[method-assign,assignment] 170 | qualname_overrides, 171 | ClassDocumenter.add_directive_header, 172 | ) 173 | 174 | from . import _return_tuple 175 | 176 | _return_tuple.setup(app) 177 | 178 | return metadata 179 | -------------------------------------------------------------------------------- /src/scanpydoc/elegant_typehints/_autodoc_patch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from functools import wraps 5 | 6 | 7 | if TYPE_CHECKING: 8 | from collections.abc import Mapping, Callable 9 | 10 | from sphinx.ext.autodoc import ClassDocumenter 11 | from docutils.statemachine import StringList 12 | 13 | 14 | def dir_head_adder( 15 | qualname_overrides: Mapping[tuple[str | None, str], tuple[str | None, str]], 16 | orig: Callable[[ClassDocumenter, str], None], 17 | ) -> Callable[[ClassDocumenter, str], None]: 18 | @wraps(orig) 19 | def add_directive_header(self: ClassDocumenter, sig: str) -> None: 20 | orig(self, sig) 21 | lines = self.directive.result 22 | inferred_role, direc = ( 23 | ("py:exc", "py:exception") 24 | if isinstance(self.object, type) and issubclass(self.object, BaseException) 25 | else ("py:class", "py:class") 26 | ) 27 | for (old_role, old_name), (new_role, new_name) in qualname_overrides.items(): 28 | role = inferred_role if new_role is None else new_role 29 | # Currently, autodoc doesn’t link to bases using :exc: 30 | lines.replace( 31 | f":{old_role or 'py:class'}:`{old_name}`", f":{role}:`{new_name}`" 32 | ) 33 | # But maybe in the future it will 34 | lines.replace(f":{role}:`{old_name}`", f":{role}:`{new_name}`") 35 | if any("." not in name for name in (old_name, new_name)): 36 | continue # pragma: no cover 37 | old_mod, old_cls = old_name.rsplit(".", 1) 38 | new_mod, new_cls = new_name.rsplit(".", 1) 39 | replace_multi_suffix( 40 | lines, 41 | (f".. {direc}:: {old_cls}", f" :module: {old_mod}"), 42 | (f".. {direc}:: {new_cls}", f" :module: {new_mod}"), 43 | ) 44 | 45 | return add_directive_header 46 | 47 | 48 | def replace_multi_suffix( 49 | lines: StringList, old: tuple[str, str], new: tuple[str, str] 50 | ) -> None: 51 | if len(old) != len(new) != 2: # noqa: PLR2004 # pragma: no cover 52 | msg = "Only supports replacing 2 lines" 53 | raise NotImplementedError(msg) 54 | for l, line in enumerate(lines): 55 | start = line.find(old[0]) 56 | if start == -1: 57 | continue 58 | prefix = line[:start] 59 | suffix = line[start + len(old[0]) :] 60 | if lines[l + 1].startswith(prefix + old[1]): 61 | break 62 | else: 63 | return 64 | lines[l + 0] = prefix + new[0] + suffix 65 | lines[l + 1] = prefix + new[1] 66 | -------------------------------------------------------------------------------- /src/scanpydoc/elegant_typehints/_formatting.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from types import GenericAlias 5 | from typing import TYPE_CHECKING, cast, get_args, get_origin 6 | 7 | from sphinx_autodoc_typehints import format_annotation 8 | 9 | from scanpydoc import elegant_typehints 10 | from scanpydoc._types import _GenericAlias 11 | 12 | 13 | if TYPE_CHECKING: 14 | from typing import Any 15 | 16 | from sphinx.config import Config 17 | 18 | 19 | def typehints_formatter(annotation: type[Any], config: Config) -> str | None: 20 | """Generate reStructuredText containing links to the types. 21 | 22 | Can be used as ``typehints_formatter`` for :mod:`sphinx_autodoc_typehints`, 23 | to respect the ``qualname_overrides`` option. 24 | 25 | Arguments 26 | --------- 27 | annotation 28 | A type or class used as type annotation. 29 | config 30 | Sphinx config containing ``sphinx-autodoc-typehints``’s options. 31 | 32 | Returns 33 | ------- 34 | reStructuredText describing the type 35 | """ 36 | if inspect.isclass(annotation) and annotation.__module__ == "builtins": 37 | return None 38 | 39 | tilde = "" if config.typehints_fully_qualified else "~" 40 | 41 | if isinstance(annotation, GenericAlias | _GenericAlias): 42 | args = get_args(annotation) 43 | annotation = cast("type[Any]", get_origin(annotation)) 44 | else: 45 | args = None 46 | annotation_cls = annotation if inspect.isclass(annotation) else type(annotation) 47 | if annotation_cls.__module__ in {"typing", "types"}: 48 | return None 49 | 50 | # Only if this is a real class we override sphinx_autodoc_typehints 51 | if inspect.isclass(annotation): 52 | full_name = f"{annotation.__module__}.{annotation.__qualname__}" 53 | override = elegant_typehints.qualname_overrides.get((None, full_name)) 54 | if override is not None: 55 | if args is None: 56 | formatted_args = "" 57 | else: 58 | formatted_args = ", ".join( 59 | format_annotation(arg, config) for arg in args 60 | ) 61 | formatted_args = rf"\ \[{formatted_args}]" 62 | role, qualname = override 63 | if role is None: 64 | role = ( 65 | "py:exc" 66 | if issubclass(annotation_cls, BaseException) 67 | else "py:class" 68 | ) 69 | return f":{role}:`{tilde}{qualname}`{formatted_args}" 70 | 71 | return None 72 | -------------------------------------------------------------------------------- /src/scanpydoc/elegant_typehints/_return_tuple.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import inspect 5 | from types import UnionType 6 | from typing import TYPE_CHECKING, Union, get_args, get_origin, get_type_hints 7 | from typing import Tuple as t_Tuple # noqa: UP035 8 | from logging import getLogger 9 | 10 | from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined] 11 | from sphinx_autodoc_typehints import format_annotation 12 | 13 | 14 | if TYPE_CHECKING: 15 | from typing import Any 16 | from collections.abc import Sequence 17 | 18 | from sphinx.application import Sphinx 19 | from sphinx.ext.autodoc import Options 20 | 21 | 22 | UNION_TYPES = {Union, UnionType} 23 | 24 | 25 | __all__ = ["_parse_returns_section", "process_docstring", "setup"] 26 | 27 | logger = getLogger(__name__) 28 | re_ret = re.compile("^:returns?: ") 29 | 30 | 31 | def get_tuple_annot(annotation: type | None) -> tuple[type, ...] | None: 32 | if annotation is None: 33 | return None 34 | origin = get_origin(annotation) 35 | if not origin: 36 | return None 37 | if origin in UNION_TYPES: 38 | for annot in get_args(annotation): 39 | origin = get_origin(annot) 40 | if origin in (tuple, t_Tuple): # noqa: UP006 41 | annotation = annot 42 | break 43 | else: 44 | return None 45 | return get_args(annotation) 46 | 47 | 48 | def process_docstring( # noqa: PLR0913 49 | app: Sphinx, 50 | what: str, 51 | name: str, # noqa: ARG001 52 | obj: Any, # noqa: ANN401 53 | options: Options | None, # noqa: ARG001 54 | lines: list[str], 55 | ) -> None: 56 | # Handle complex objects 57 | if isinstance(obj, property): 58 | obj = obj.fget 59 | if not callable(obj): 60 | return 61 | if what in ("class", "exception"): 62 | obj = obj.__init__ 63 | obj = inspect.unwrap(obj) 64 | try: 65 | hints = get_type_hints(obj) 66 | except (AttributeError, NameError, TypeError): # pragma: no cover 67 | # Introspecting a slot wrapper can raise TypeError 68 | return 69 | ret_types = get_tuple_annot(hints.get("return")) 70 | if ret_types is None: 71 | return 72 | 73 | idxs_ret_names = _get_idxs_ret_names(lines) 74 | if len(idxs_ret_names) == len(ret_types): 75 | for l, rt in zip(idxs_ret_names, ret_types, strict=False): 76 | typ = format_annotation(rt, app.config) 77 | if (line := lines[l]).lstrip() in {":returns: :", ":return: :", ":"}: 78 | transformed = f"{line[:-1]}{typ}" 79 | else: 80 | transformed = f"{line} : {typ}" 81 | lines[l : l + 1] = [transformed] 82 | 83 | 84 | def _get_idxs_ret_names(lines: Sequence[str]) -> list[int]: 85 | # Get return section 86 | i_prefix = None 87 | l_start = 0 88 | for l, line in enumerate(lines): 89 | if i_prefix is None: 90 | m = re_ret.match(line) 91 | if m: 92 | i_prefix = m.span()[1] 93 | l_start = l 94 | elif len(line[:i_prefix].strip()) > 0: 95 | l_end = l - 1 96 | break 97 | else: 98 | l_end = len(lines) - 1 99 | if i_prefix is None: 100 | return [] 101 | 102 | # Meat 103 | idxs_ret_names = [] 104 | for l, line in enumerate([l[i_prefix:] for l in lines[l_start : l_end + 1]]): 105 | if (line == ":" or line.isidentifier()) and ( 106 | lines[l + l_start + 1].startswith(" ") 107 | ): 108 | idxs_ret_names.append(l + l_start) 109 | return idxs_ret_names 110 | 111 | 112 | def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]: # noqa: ARG001 113 | """Parse return section as prose instead of tuple by default.""" 114 | lines_raw = list(self._dedent(self._consume_to_next_section())) 115 | lines = self._format_block(":returns: ", lines_raw) 116 | if lines and lines[-1]: 117 | lines.append("") 118 | return lines 119 | 120 | 121 | def _delete_sphinx_autodoc_typehints_docstring_processor(app: Sphinx) -> None: 122 | for listener in app.events.listeners["autodoc-process-docstring"].copy(): 123 | handler_name = getattr(listener.handler, "__name__", None) 124 | # https://github.com/tox-dev/sphinx-autodoc-typehints/blob/a5c091f725da8374347802d54c16c3d38833d41c/src/sphinx_autodoc_typehints/patches.py#L69 125 | if handler_name == "napoleon_numpy_docstring_return_type_processor": 126 | app.disconnect(listener.id) 127 | 128 | 129 | def setup(app: Sphinx) -> None: 130 | """Patches the Sphinx app and :mod:`sphinx.ext.napoleon` in some ways. 131 | 132 | 1. Replaces the return section parser of napoleon’s NumpyDocstring 133 | with one that just adds a prose section. 134 | 2. Removes sphinx-autodoc-typehints’s docstring processor that expects 135 | NumpyDocstring’s old behavior. 136 | 2. Adds our own docstring processor that adds tuple return types 137 | If the docstring contains a definition list of appropriate length. 138 | """ 139 | NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[method-assign,assignment] # noqa: SLF001 140 | _delete_sphinx_autodoc_typehints_docstring_processor(app) 141 | app.connect("autodoc-process-docstring", process_docstring, 1000) 142 | -------------------------------------------------------------------------------- /src/scanpydoc/elegant_typehints/_role_mapping.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from itertools import chain 5 | from collections.abc import MutableMapping 6 | 7 | 8 | if TYPE_CHECKING: 9 | from typing import Self 10 | from collections.abc import Mapping, Iterator 11 | 12 | 13 | class RoleMapping(MutableMapping[tuple[str | None, str], tuple[str | None, str]]): 14 | data: dict[tuple[str | None, str], tuple[str | None, str]] 15 | 16 | def __init__( 17 | self, 18 | mapping: Mapping[tuple[str | None, str], str | tuple[str | None, str]] = {}, 19 | /, 20 | ) -> None: 21 | self.data = dict(mapping) # type: ignore[arg-type] 22 | 23 | @classmethod 24 | def from_user( 25 | cls, mapping: Mapping[str | tuple[str, str], str | tuple[str, str]] 26 | ) -> Self: 27 | rm = cls({}) 28 | rm.update_user(mapping) 29 | return rm 30 | 31 | def update_user( 32 | self, mapping: Mapping[str | tuple[str, str], str | tuple[str, str]] 33 | ) -> None: 34 | for k, v in mapping.items(): 35 | self[k if isinstance(k, tuple) else (None, k)] = ( 36 | v if isinstance(v, tuple) else (None, v) 37 | ) 38 | 39 | def __setitem__( 40 | self, key: tuple[str | None, str], value: tuple[str | None, str] 41 | ) -> None: 42 | self.data[key] = value 43 | 44 | def __getitem__(self, key: tuple[str | None, str]) -> tuple[str | None, str]: 45 | if key[0] is not None: 46 | try: 47 | return self.data[key] 48 | except KeyError: 49 | return self.data[None, key[1]] 50 | for known_role in chain([None], {r for r, _ in self}): 51 | try: 52 | return self.data[known_role, key[1]] 53 | except KeyError: # noqa: PERF203 54 | pass 55 | raise KeyError(key) 56 | 57 | def __contains__(self, key: object) -> bool: 58 | if not isinstance(key, tuple): # pragma: no cover 59 | raise TypeError 60 | try: 61 | self[key] 62 | except KeyError: 63 | return False 64 | return True 65 | 66 | def __delitem__(self, key: tuple[str | None, str]) -> None: 67 | del self.data[key] 68 | 69 | def __iter__(self) -> Iterator[tuple[str | None, str]]: 70 | return self.data.__iter__() 71 | 72 | def __len__(self) -> int: # pragma: no cover 73 | return len(self.data) 74 | -------------------------------------------------------------------------------- /src/scanpydoc/elegant_typehints/example.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def example_func_prose( 5 | a: str | None, b: str | int | None = None 6 | ) -> dict[str, int]: # pragma: no cover 7 | """Example function with a paragraph return section. 8 | 9 | Parameters 10 | ---------- 11 | a 12 | An example parameter 13 | b 14 | Another, with a default 15 | 16 | Returns 17 | ------- 18 | An example dict 19 | """ 20 | return {} 21 | 22 | 23 | def example_func_tuple() -> tuple[int, str]: # pragma: no cover 24 | """Example function with return tuple. 25 | 26 | Returns 27 | ------- 28 | x 29 | An example int 30 | y 31 | An example str 32 | """ 33 | return (1, "foo") 34 | 35 | 36 | def example_func_anonymous_tuple() -> tuple[int, str]: # pragma: no cover 37 | """Example function with anonymous return tuple. 38 | 39 | Returns 40 | ------- 41 | : 42 | An example int 43 | : 44 | An example str 45 | """ 46 | return (1, "foo") 47 | -------------------------------------------------------------------------------- /src/scanpydoc/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theislab/scanpydoc/b2e0288ccd2ef92d8b4ab0859d0947fc901ee1be/src/scanpydoc/py.typed -------------------------------------------------------------------------------- /src/scanpydoc/release_notes.py: -------------------------------------------------------------------------------- 1 | """A release notes directive. 2 | 3 | Given a list of version files matching :attr:`FULL_VERSION_RE`, 4 | render them using the following (where ``.`` is the directory they are in): 5 | 6 | .. code:: restructuredtext 7 | 8 | .. release-notes:: . 9 | 10 | With e.g. the files :file:`1.2.0.md`, :file:`1.2.1.md`, and :file:`1.3.0.rst`, 11 | this will render like the following: 12 | 13 | .. code:: restructuredtext 14 | 15 | _v1.3: 16 | 17 | Version 1.3 18 | =========== 19 | 20 | .. include:: 1.3.0.rst 21 | 22 | 23 | _v1.2: 24 | 25 | Version 1.2 26 | =========== 27 | 28 | .. include:: 1.2.1.md 29 | .. include:: 1.2.0.md 30 | """ 31 | 32 | from __future__ import annotations 33 | 34 | import re 35 | import itertools 36 | from typing import TYPE_CHECKING 37 | from pathlib import Path 38 | from dataclasses import dataclass 39 | 40 | from docutils import nodes 41 | from packaging.version import Version 42 | from sphinx.util.parsing import nested_parse_to_nodes 43 | from sphinx.util.docutils import SphinxDirective 44 | 45 | from . import metadata, _setup_sig 46 | 47 | 48 | if TYPE_CHECKING: 49 | from typing import Any, ClassVar 50 | from collections.abc import Iterable, Sequence 51 | 52 | from sphinx.application import Sphinx 53 | from myst_parser.mdit_to_docutils.base import DocutilsRenderer 54 | 55 | 56 | FULL_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:.*)?$") 57 | """Regex matching a full version number including patch part, maybe with more after.""" 58 | 59 | 60 | @dataclass 61 | class _Backend: 62 | dir: Path 63 | instance: SphinxDirective 64 | 65 | def run(self) -> Sequence[nodes.Node]: 66 | versions = sorted( 67 | ( 68 | (Version(f.stem), f) 69 | for f in self.dir.iterdir() 70 | if FULL_VERSION_RE.match(f.stem) 71 | ), 72 | reverse=True, # descending 73 | ) 74 | version_groups = itertools.groupby( 75 | versions, key=lambda vf: (vf[0].major, vf[0].minor) 76 | ) 77 | return [ 78 | node 79 | for (major, minor), versions in version_groups 80 | for node in self.render_version_group(major, minor, versions) 81 | ] 82 | 83 | def render_version_group( 84 | self, 85 | major: int, 86 | minor: int, 87 | versions: Iterable[tuple[Version, Path]] = (), 88 | ) -> tuple[nodes.target, nodes.section]: 89 | target = nodes.target( 90 | ids=[f"v{major}-{minor}"], 91 | names=[f"v{major}.{minor}"], 92 | ) 93 | section = nodes.section( 94 | "", 95 | nodes.title("", f"Version {major}.{minor}"), 96 | ids=[], 97 | names=[f"version {major}.{minor}"], 98 | ) 99 | 100 | self.instance.state.document.note_implicit_target(section) 101 | self.instance.state.document.note_explicit_target(target) 102 | 103 | for _, p in versions: 104 | section += self.render_include(p) 105 | return target, section 106 | 107 | def render_include(self, path: Path) -> Sequence[nodes.Node]: 108 | return nested_parse_to_nodes( 109 | self.instance.state, 110 | path.read_text(), 111 | source=str(path), 112 | offset=self.instance.content_offset, 113 | ) 114 | 115 | 116 | # TODO(flying-sheep): Remove once MyST-Parser bug is fixed 117 | # https://github.com/executablebooks/MyST-Parser/issues/967 118 | class _BackendMyst(_Backend): 119 | def run(self) -> Sequence[nodes.Node]: 120 | super().run() 121 | return [] 122 | 123 | def render_version_group( 124 | self, major: int, minor: int, versions: Iterable[tuple[Version, Path]] = () 125 | ) -> tuple[nodes.target, nodes.section]: 126 | target, section = super().render_version_group(major, minor) 127 | # append target and section to parent 128 | self._myst_renderer.current_node.append(target) 129 | self._myst_renderer.update_section_level_state(section, 2) 130 | # append children to section 131 | for _, p in versions: 132 | with self._myst_renderer.current_node_context(section): 133 | self.render_include(p) 134 | return target, section # ignored, just to not change the types 135 | 136 | def render_include(self, path: Path) -> Sequence[nodes.Node]: 137 | from myst_parser.mocking import MockIncludeDirective 138 | from docutils.parsers.rst.directives.misc import Include 139 | 140 | srcfile, lineno = self.instance.get_source_info() 141 | parent_dir = Path(srcfile).parent 142 | 143 | d = MockIncludeDirective( 144 | renderer=self._myst_renderer, 145 | name=type(self).__name__, 146 | klass=Include, 147 | arguments=[str(path.relative_to(parent_dir))], 148 | options={}, 149 | body=[], 150 | lineno=lineno, 151 | ) 152 | return d.run() 153 | 154 | @property 155 | def _myst_renderer(self) -> DocutilsRenderer: 156 | rv: DocutilsRenderer = self.instance.state._renderer # type: ignore[attr-defined] # noqa: SLF001 157 | return rv 158 | 159 | 160 | class ReleaseNotes(SphinxDirective): 161 | """Directive rendering release notes, grouping them by minor versions.""" 162 | 163 | required_arguments: ClassVar = 1 164 | 165 | def run(self) -> Sequence[nodes.Node]: 166 | """Read the release notes and render them.""" 167 | dir_ = Path(self.arguments[0]) 168 | # resolve relative dir 169 | if not dir_.is_absolute(): 170 | src_file = Path(self.get_source_info()[0]) 171 | if not src_file.is_file(): 172 | msg = f"Cannot find relative path to: {src_file}" 173 | raise self.error(msg) 174 | dir_ = src_file.parent / self.arguments[0] 175 | if not dir_.is_dir(): 176 | msg = f"Not a directory: {dir_}" 177 | raise self.error(msg) 178 | 179 | cls = _BackendMyst if hasattr(self.state, "_renderer") else _Backend 180 | return cls(dir_, self).run() 181 | 182 | 183 | @_setup_sig 184 | def setup(app: Sphinx) -> dict[str, Any]: 185 | """Add the ``release-notes`` directive.""" 186 | app.add_directive("release-notes", ReleaseNotes) 187 | return metadata 188 | -------------------------------------------------------------------------------- /src/scanpydoc/rtd_github_links/__init__.py: -------------------------------------------------------------------------------- 1 | """GitHub URLs for class and method pages. 2 | 3 | This extension does two things: 4 | 5 | #. It registers a :ref:`Jinja filter ` called :func:`github_url` 6 | that you can use to convert a module path into a GitHub URL. 7 | #. It configures :mod:`sphinx.ext.linkcode` for you if loaded after it in ``conf.py``: 8 | 9 | .. code:: python 10 | 11 | extensions = [ 12 | "scanpydoc", 13 | "sphinx.ext.linkcode", 14 | ] 15 | 16 | # no need to define `linkcode_resolve` 17 | 18 | Configuration 19 | ------------- 20 | 21 | Uses the following config values in ``conf.py``:: 22 | 23 | rtd_links_prefix: os.PathLike | str = ... # default: '.' 24 | 25 | # sphinx book theme style 26 | html_theme_options = dict( 27 | repository_url=..., 28 | repository_branch=..., 29 | ) 30 | # or RTD theme style: 31 | html_context = dict( 32 | github_user=..., 33 | github_repo=..., 34 | github_version=..., 35 | ) 36 | 37 | The ``rtd_links_prefix`` is for figuring out the .py file path relative to the git root, 38 | that is to construct the path in the GitHub URL. 39 | 40 | Which html configuration style you want to use depends on your theme, e.g. 41 | :doc:`Sphinx Book Theme `. 42 | 43 | ``:github_url:`` usage 44 | ---------------------- 45 | 46 | You can use the filter e.g. in `autosummary templates`_. 47 | To configure the :doc:`Sphinx Book Theme `, 48 | override the ``autosummary/base.rst`` template like this: 49 | 50 | .. code:: restructuredtext 51 | 52 | :github_url: {{ fullname | github_url }} 53 | 54 | {% extends "!autosummary/base.rst" %} 55 | 56 | .. _autosummary templates: \ 57 | http://www.sphinx-doc.org/en/master/usage/extensions/autosummary.html#customizing-templates 58 | """ 59 | 60 | from __future__ import annotations 61 | 62 | import sys 63 | import inspect 64 | from types import ModuleType, GenericAlias 65 | from typing import TYPE_CHECKING 66 | from pathlib import Path, PurePosixPath 67 | from importlib import import_module 68 | from dataclasses import fields, is_dataclass 69 | 70 | from jinja2.defaults import DEFAULT_FILTERS # type: ignore[attr-defined] 71 | 72 | from scanpydoc import metadata, _setup_sig 73 | from scanpydoc._types import _GenericAlias 74 | 75 | 76 | if TYPE_CHECKING: 77 | from types import CodeType, FrameType, MethodType, FunctionType, TracebackType 78 | from typing import Any, TypeAlias 79 | from collections.abc import Callable 80 | 81 | _SourceObjectType: TypeAlias = ( 82 | ModuleType 83 | | type[Any] 84 | | MethodType 85 | | FunctionType 86 | | TracebackType 87 | | FrameType 88 | | CodeType 89 | | Callable[..., Any] 90 | ) 91 | 92 | from sphinx.config import Config 93 | from sphinx.application import Sphinx 94 | 95 | 96 | rtd_links_prefix: PurePosixPath | None = None 97 | github_base_url: str | None = None 98 | 99 | 100 | def _init_vars(_app: Sphinx, config: Config) -> None: # pragma: no cover 101 | """Run hook when ``conf.py`` has been loaded.""" 102 | global github_base_url, rtd_links_prefix 103 | github_base_url, rtd_links_prefix = _infer_vars(config) 104 | 105 | 106 | def _infer_vars(config: Config) -> tuple[str, PurePosixPath]: 107 | _check_html_config(config) 108 | try: 109 | github_base_url = "https://github.com/{github_user}/{github_repo}/tree/{github_version}".format_map( 110 | config.html_context, 111 | ) 112 | except (AttributeError, KeyError): 113 | github_base_url = "{repository_url}/tree/{repository_branch}".format_map( 114 | config.html_theme_options, 115 | ) 116 | rtd_links_prefix = PurePosixPath(config.rtd_links_prefix) 117 | return github_base_url, rtd_links_prefix 118 | 119 | 120 | def _get_annotations(obj: _SourceObjectType) -> dict[str, Any]: 121 | from inspect import get_annotations 122 | 123 | try: 124 | return get_annotations(obj) # type: ignore[no-any-return,arg-type,unused-ignore] 125 | except TypeError: # pragma: no cover 126 | return {} 127 | 128 | 129 | def _get_obj_module(qualname: str) -> tuple[Any, ModuleType]: 130 | """Get a module/class/attribute and its original module by qualname. 131 | 132 | Returns `None` as `obj` if it’s an annotated field without value. 133 | """ 134 | modname = qualname 135 | attr_path: list[str] = [] 136 | while "." in modname and modname not in sys.modules: 137 | modname, leaf = modname.rsplit(".", 1) 138 | attr_path.insert(0, leaf) 139 | 140 | # retrieve object and find original module name 141 | mod = sys.modules[modname] 142 | obj: Any = None if attr_path else mod 143 | del modname 144 | for attr_name in attr_path: 145 | try: 146 | thing = getattr(mod if obj is None else obj, attr_name) 147 | except AttributeError as e: 148 | if is_dataclass(obj): 149 | thing = next(f for f in fields(obj) if f.name == attr_name) 150 | elif obj is not None and attr_name in _get_annotations(obj): 151 | thing = None 152 | else: # pragma: no cover 153 | try: 154 | thing = import_module(f"{mod.__name__}.{attr_name}") 155 | except ImportError: 156 | raise e from None 157 | if isinstance(thing, ModuleType): # pragma: no cover 158 | mod = thing 159 | elif is_dataclass(obj) or isinstance(thing, GenericAlias | _GenericAlias): 160 | obj = thing 161 | else: 162 | obj = thing 163 | mod_orig = getattr(obj, "__module__", None) 164 | if mod_orig is not None: 165 | mod = sys.modules[mod_orig] 166 | 167 | return obj, mod 168 | 169 | 170 | def _get_linenos(obj: _SourceObjectType) -> tuple[int, int] | tuple[None, None]: 171 | """Get an object’s line numbers.""" 172 | try: 173 | lines, start = inspect.getsourcelines(obj) 174 | # https://docs.python.org/3/library/inspect.html#inspect.getsourcelines 175 | # means an OSError is raised if the source is not found, 176 | # as is the case with collections.abc.Mapping. 177 | # A TypeError indicates a builtin class. 178 | except (TypeError, OSError): 179 | return None, None 180 | else: 181 | return start, start + len(lines) - 1 182 | 183 | 184 | def _module_path(obj: _SourceObjectType, module: ModuleType) -> PurePosixPath: 185 | """Relative module path to parent directory of toplevel module.""" 186 | while hasattr(obj, "__wrapped__"): 187 | obj = obj.__wrapped__ 188 | try: 189 | file = Path(inspect.getabsfile(obj)) 190 | except TypeError: 191 | # Some don’t have the attribute, some have it set to None 192 | file = Path(getattr(module, "__file__", None) or "") 193 | offset = -1 if file.name == "__init__.py" else 0 194 | parts = module.__name__.split(".") 195 | return PurePosixPath(*file.parts[offset - len(parts) :]) 196 | 197 | 198 | def github_url(qualname: str) -> str: 199 | """Get the full GitHub URL for some object’s qualname. 200 | 201 | Parameters 202 | ---------- 203 | qualname 204 | The full qualified name of a function, class, method or module 205 | 206 | Returns 207 | ------- 208 | A GitHub URL derived from the :confval:`html_context` 209 | or the :confval:`html_theme_options`. 210 | """ 211 | try: 212 | obj, module = _get_obj_module(qualname) 213 | except Exception as e: 214 | if sys.version_info >= (3, 11): 215 | e.add_note(f"Qualname: {qualname!r}") 216 | raise 217 | assert rtd_links_prefix is not None # noqa: S101 218 | path = rtd_links_prefix / _module_path(obj, module) 219 | start, end = _get_linenos(obj) 220 | fragment = f"#L{start}-L{end}" if start and end else "" 221 | return f"{github_base_url}/{path}{fragment}" 222 | 223 | 224 | def _check_html_config(config: Config) -> None: 225 | options = dict( 226 | html_context={"github_user", "github_repo", "github_version"}, 227 | html_theme_options={"repository_url", "repository_branch"}, 228 | ) 229 | if not any(name in config for name in options): 230 | msg = ( 231 | f"Extension {__name__} needs “html_context” " 232 | "or “html_theme_options” to be defined in conf.py" 233 | ) 234 | raise ValueError(msg) 235 | missing_value_sets = { 236 | name: opts - getattr(config, name, {}).keys() for name, opts in options.items() 237 | } 238 | if not all(missing_value_sets.values()): 239 | return # a valid configuration was found 240 | 241 | mvs = " or ".join( 242 | f"{name} {', '.join(repr(mv) for mv in mvs)}" 243 | for name, mvs in missing_value_sets.items() 244 | ) 245 | msg = f"Extension {__name__} needs {mvs} to be defined in conf.py." 246 | for name in options: 247 | if name in config: 248 | msg += f"\n{name} = {config[name]!r}" 249 | raise ValueError(msg) 250 | 251 | 252 | @_setup_sig 253 | def setup(app: Sphinx) -> dict[str, Any]: 254 | """Register the :func:`github_url` :ref:`Jinja filter `.""" 255 | app.add_config_value("rtd_links_prefix", PurePosixPath("."), "") 256 | app.connect("config-inited", _init_vars) 257 | 258 | # if linkcode config not set 259 | if "linkcode_resolve" not in app.config or app.config["linkcode_resolve"] is None: 260 | from ._linkcode import linkcode_resolve 261 | 262 | app.config["linkcode_resolve"] = linkcode_resolve 263 | 264 | # html_context doesn’t apply to autosummary templates ☹ 265 | # and there’s no way to insert filters into those templates 266 | # so we have to modify the default filters 267 | DEFAULT_FILTERS["github_url"] = github_url 268 | 269 | return metadata 270 | -------------------------------------------------------------------------------- /src/scanpydoc/rtd_github_links/_linkcode.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TypedDict, cast, overload 4 | 5 | 6 | class PyInfo(TypedDict): 7 | module: str 8 | fullname: str 9 | 10 | 11 | class CInfo(TypedDict): 12 | """C / C++ info.""" 13 | 14 | names: list[str] 15 | 16 | 17 | class JSInfo(TypedDict): 18 | object: str 19 | fullname: str 20 | 21 | 22 | if TYPE_CHECKING: 23 | from typing import Literal, TypeAlias 24 | 25 | Domain = Literal["py", "c", "cpp", "javascript"] 26 | DomainInfo: TypeAlias = PyInfo | CInfo | JSInfo 27 | 28 | 29 | @overload 30 | def linkcode_resolve( 31 | domain: Literal["py"], info: PyInfo 32 | ) -> str | None: # pragma: no cover 33 | ... 34 | 35 | 36 | @overload 37 | def linkcode_resolve( 38 | domain: Literal["c", "cpp"], info: CInfo 39 | ) -> str | None: # pragma: no cover 40 | ... 41 | 42 | 43 | @overload 44 | def linkcode_resolve( 45 | domain: Literal["javascript"], info: JSInfo 46 | ) -> str | None: # pragma: no cover 47 | ... 48 | 49 | 50 | def linkcode_resolve(domain: Domain, info: DomainInfo) -> str | None: 51 | from . import github_url 52 | 53 | if domain != "py": 54 | return None 55 | info = cast("PyInfo", info) 56 | if not info["module"]: 57 | return None 58 | return github_url(f"{info['module']}.{info['fullname']}") 59 | -------------------------------------------------------------------------------- /src/scanpydoc/rtd_github_links/_testdata.py: -------------------------------------------------------------------------------- 1 | """This module exists just for rtd_github_links tests.""" # noqa: D404 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Generic, TypeVar 6 | from dataclasses import field, dataclass 7 | 8 | from legacy_api_wrap import legacy_api 9 | 10 | 11 | if TYPE_CHECKING: 12 | from typing import TypeAlias 13 | 14 | 15 | _T = TypeVar("_T") 16 | 17 | 18 | class _G(Generic[_T]): 19 | pass 20 | 21 | 22 | # make sure that TestGenericClass keeps its __module__ 23 | _G.__module__ = "somewhere_else" 24 | 25 | 26 | TestGenericBuiltin: TypeAlias = list[str] 27 | TestGenericClass: TypeAlias = _G[int] 28 | 29 | 30 | @dataclass 31 | class TestDataCls: 32 | test_attr: dict[str, str] = field(default_factory=dict) 33 | 34 | 35 | class TestCls: 36 | test_anno: int 37 | 38 | 39 | def test_func() -> None: # pragma: no cover 40 | pass 41 | 42 | 43 | @legacy_api() 44 | def test_func_wrap() -> None: # pragma: no cover 45 | pass 46 | -------------------------------------------------------------------------------- /src/scanpydoc/testing.py: -------------------------------------------------------------------------------- 1 | """Testing utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Protocol 6 | 7 | 8 | if TYPE_CHECKING: 9 | from typing import Any 10 | 11 | from sphinx.testing.util import SphinxTestApp 12 | 13 | 14 | class MakeApp(Protocol): 15 | """Create a SphinxTestApp instance.""" 16 | 17 | def __call__( # noqa: D102 18 | self, 19 | builder: str = "html", 20 | /, 21 | *, 22 | exception_on_warning: bool = False, 23 | **conf: Any, # noqa: ANN401 24 | ) -> SphinxTestApp: ... 25 | -------------------------------------------------------------------------------- /src/scanpydoc/theme/__init__.py: -------------------------------------------------------------------------------- 1 | """A widescreen extension for :doc:`Sphinx Book Theme `. 2 | 3 | Add to ``conf.py``: 4 | 5 | .. code:: python 6 | 7 | html_theme = 'scanpydoc' 8 | 9 | Theme options 10 | ============= 11 | 12 | This theme adds the following configuration option, 13 | and the ones under `docsearch options`_: 14 | 15 | .. confval:: accent_color 16 | 17 | :type: str 18 | :default: ``#f07e44`` 19 | 20 | The CSS color used for the mobile header background and the project name text. 21 | 22 | See ``sphinx_book_theme``’s :doc:`sphinx_book_theme:reference`, e.g.: 23 | 24 | .. code:: python 25 | 26 | html_theme_options = dict( 27 | repository_url="https://github.com/theislab/scanpydoc", 28 | repository_branch="main", 29 | use_repository_button=True, 30 | ) 31 | 32 | Docsearch options 33 | ----------------- 34 | 35 | These two configuration values are required to use docsearch_: 36 | 37 | .. _docsearch: https://docsearch.algolia.com/ 38 | 39 | .. confval:: docsearch_key 40 | 41 | :type: str 42 | 43 | The API key provided by docsearch. 44 | 45 | .. confval:: docsearch_index 46 | 47 | :type: str 48 | 49 | The index name used by docsearch. 50 | 51 | The following configuration values are optional: 52 | 53 | .. confval:: docsearch_doc_version 54 | 55 | :type: str 56 | :default: ``'latest'`` or ``'stable'`` 57 | 58 | The documentation version searched. 59 | The default is ``'stable'`` if ``READTHEDOCS_VERSION=stable`` is set, 60 | and ``'latest'`` otherwise. 61 | 62 | .. confval:: docsearch_js_version 63 | 64 | :type: str 65 | :default: ``'2.6'`` 66 | 67 | The docsearch library version used. 68 | 69 | """ 70 | 71 | from __future__ import annotations 72 | 73 | from typing import TYPE_CHECKING 74 | from pathlib import Path 75 | 76 | from scanpydoc import _setup_sig 77 | 78 | 79 | if TYPE_CHECKING: 80 | from sphinx.application import Sphinx 81 | 82 | 83 | HERE = Path(__file__).parent.resolve() 84 | 85 | 86 | @_setup_sig 87 | def setup(app: Sphinx) -> dict[str, bool]: # pragma: no cover 88 | """Set up theme (like an extension).""" 89 | app.add_html_theme("scanpydoc", str(HERE)) 90 | 91 | # if we’re on ReadTheDocs, hide the pydata-sphinx-theme search popup 92 | app.add_js_file("scripts/rtd-sphinx-search.js", loading_method="defer") 93 | 94 | return dict(parallel_read_safe=True, parallel_write_safe=True) 95 | -------------------------------------------------------------------------------- /src/scanpydoc/theme/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "sphinx_book_theme/layout.html" %} 2 | 3 | {%- block css -%} 4 | {{ super() }} 5 | {% if theme_docsearch_key and theme_docsearch_index %} 6 | 10 | {% endif %} 11 | {%- endblock -%} 12 | 13 | 14 | {%- block extrahead -%} 15 | {{ super() }} 16 | 17 | {% if theme_accent_color %} 18 | 19 | 22 | 23 | {% endif %} 24 | {% endblock %} 25 | 26 | {% set safe_version = version if version in ["latest", "stable"] else "latest" %} 27 | 28 | {% block scripts %} 29 | {{ super() }} 30 | {% if theme_docsearch_key and theme_docsearch_index %} 31 | 32 | 33 | 45 | 46 | {% endif %} 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /src/scanpydoc/theme/static/scripts/rtd-sphinx-search.js: -------------------------------------------------------------------------------- 1 | /** 2 | * See https://docs.readthedocs.com/platform/stable/addons.html#integrate-with-search-as-you-type 3 | */ 4 | 5 | document.addEventListener("readthedocs-addons-data-ready", (event) => { 6 | const { addons } = event.detail.data() 7 | if (!addons.search?.enabled) { 8 | return 9 | } 10 | 11 | // wire up the search key combination 12 | addEventListener( 13 | "keydown", 14 | ({ key, metaKey, ctrlKey }) => { 15 | if (key === "k" && (metaKey || ctrlKey)) { 16 | const here = document.querySelector("readthedocs-search")?.show 17 | const event = new CustomEvent( 18 | `readthedocs-search-${here ? "hide" : "show"}`, 19 | ) 20 | document.dispatchEvent(event) 21 | } 22 | }, 23 | { passive: true }, 24 | ) 25 | 26 | // start attempting to override the search popup and to wire up the search button 27 | setTimeout(overrideSearch, 0) 28 | 29 | function overrideSearch() { 30 | /** @type {HTMLDivElement} */ 31 | const theme_popup = document.querySelector(".search-button__wrapper") 32 | /** @type {HTMLButtonElement} */ 33 | const search_button = document.querySelector("button[aria-label='Search']") 34 | if (!theme_popup || !search_button) { 35 | // try again later 36 | setTimeout(overrideSearch, 500) 37 | return 38 | } 39 | // Hide the pydata theme’s search popup. 40 | theme_popup.style.display = "none" 41 | // wire up the search button 42 | search_button.addEventListener("click", () => { 43 | const event = new CustomEvent("readthedocs-search-show") 44 | document.dispatchEvent(event) 45 | }) 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /src/scanpydoc/theme/static/styles/scanpy.css: -------------------------------------------------------------------------------- 1 | @import "sphinx-book-theme.css"; 2 | 3 | /* for the sphinx design cards */ 4 | body { 5 | --sd-color-shadow: dimgrey; 6 | } 7 | 8 | dl.citation > dt { 9 | float: left; 10 | margin-right: 15px; 11 | font-weight: bold; 12 | } 13 | 14 | /* for custom small role */ 15 | .small { 16 | font-size: 40% !important; 17 | } 18 | 19 | .smaller, 20 | .pr { 21 | font-size: 70% !important; 22 | } 23 | 24 | /* Fix pydata theme search button changing size */ 25 | .search-button-field { 26 | border: 0; 27 | outline: 1px solid var(--pst-color-border); 28 | } 29 | .search-button-field:hover { 30 | border: 0; 31 | outline: 2px solid var(--pst-color-link-hover); 32 | } 33 | 34 | /* Make the readthedocs search popup adapt to the theme. 35 | * Might break again soon: https://github.com/readthedocs/addons/issues/570 36 | */ 37 | :root { 38 | --readthedocs-search-backdrop-color: var(--pst-color-shadow); 39 | --readthedocs-search-color: var(--pst-color-text-base); 40 | --readthedocs-search-content-background-color: var(--pst-color-background); 41 | --readthedocs-search-content-border-color: var(--pst-color-border); 42 | --readthedocs-search-filters-border-color: var(--pst-color-border); 43 | --readthedocs-search-font-family: var(--pst-font-family-base); 44 | --readthedocs-search-font-size: var(--pst-font-size-base); 45 | --readthedocs-search-footer-background-color: var(--pst-color-on-background); 46 | --readthedocs-search-footer-code-background-color: var(--pst-color-surface); 47 | --readthedocs-search-footer-code-border-color: var(--pst-color-border); 48 | --readthedocs-search-input-background-color: var(--pst-color-surface); 49 | --readthedocs-search-result-section-border-color: var(--pst-color-border); 50 | --readthedocs-search-result-section-color: var(--pst-color-link); 51 | --readthedocs-search-result-section-highlight-color: var(--pst-color-accent); 52 | --readthedocs-search-result-section-subheading-color: var(--pst-color-text-muted); 53 | } 54 | -------------------------------------------------------------------------------- /src/scanpydoc/theme/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = sphinx_book_theme 3 | pygments_style = tango 4 | sidebars = navbar-logo.html, icon-links.html, search-button-field.html, sbt-sidebar-nav.html 5 | stylesheet = styles/scanpy.css 6 | 7 | [options] 8 | # inherits from both of these: 9 | # https://github.com/pydata/pydata-sphinx-theme/blob/v0.13.3/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/theme.conf 10 | # https://github.com/executablebooks/sphinx-book-theme/blob/v1.0.1/src/sphinx_book_theme/theme/sphinx_book_theme/theme.conf 11 | 12 | accent_color = 13 | docsearch_key = 14 | docsearch_index = 15 | docsearch_doc_version = 16 | docsearch_js_version = 2.6 17 | docsearch_debug = false 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Custom pytest fixtures and setup.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | import linecache 7 | import importlib.util 8 | from uuid import uuid4 9 | from typing import TYPE_CHECKING 10 | from textwrap import dedent 11 | 12 | import pytest 13 | 14 | 15 | if TYPE_CHECKING: 16 | from types import ModuleType 17 | from typing import Any 18 | from pathlib import Path 19 | from collections.abc import Callable, Generator 20 | 21 | from sphinx.testing.util import SphinxTestApp 22 | 23 | from scanpydoc.testing import MakeApp 24 | 25 | 26 | @pytest.fixture 27 | def make_app_setup(make_app: type[SphinxTestApp], tmp_path: Path) -> MakeApp: 28 | def make_app_setup( 29 | builder: str = "html", 30 | /, 31 | *, 32 | exception_on_warning: bool = False, 33 | **conf: Any, # noqa: ANN401 34 | ) -> SphinxTestApp: 35 | (tmp_path / "conf.py").write_text("") 36 | conf.setdefault("suppress_warnings", []).append("app.add_node") 37 | return make_app( 38 | buildername=builder, 39 | srcdir=tmp_path, 40 | confoverrides=conf, 41 | warningiserror=exception_on_warning, 42 | exception_on_warning=exception_on_warning, 43 | ) 44 | 45 | return make_app_setup 46 | 47 | 48 | @pytest.fixture 49 | def make_module( 50 | tmp_path: Path, 51 | ) -> Generator[Callable[[str, str], ModuleType], None, None]: 52 | added_modules = [] 53 | 54 | def make_module(name: str, code: str) -> ModuleType: 55 | code = dedent(code) 56 | assert name not in sys.modules 57 | spec = importlib.util.spec_from_loader(name, loader=None) 58 | assert spec is not None 59 | mod = sys.modules[name] = importlib.util.module_from_spec(spec) 60 | path = tmp_path / f"{name}_{str(uuid4()).replace('-', '_')}.py" 61 | path.write_text(code) 62 | mod.__file__ = str(path) 63 | exec(code, mod.__dict__) # noqa: S102 64 | linecache.updatecache(str(path), mod.__dict__) 65 | added_modules.append(name) 66 | return mod 67 | 68 | yield make_module 69 | 70 | for name in added_modules: 71 | del sys.modules[name] 72 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | """Test things that aren’t sub-extension specific.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pkgutil 6 | from typing import TYPE_CHECKING 7 | from functools import partial 8 | from importlib import import_module 9 | 10 | import scanpydoc 11 | 12 | 13 | if TYPE_CHECKING: 14 | import pytest 15 | from sphinx.application import Sphinx 16 | 17 | from scanpydoc.testing import MakeApp 18 | 19 | 20 | DEPRECATED = frozenset({"scanpydoc.autosummary_generate_imported"}) 21 | 22 | 23 | def test_all_get_installed( 24 | monkeypatch: pytest.MonkeyPatch, make_app_setup: MakeApp 25 | ) -> None: 26 | setups_seen: set[str] = set() 27 | setups_called: dict[str, Sphinx] = {} 28 | for _finder, mod_name, _ in pkgutil.walk_packages( 29 | scanpydoc.__path__, f"{scanpydoc.__name__}." 30 | ): 31 | mod = import_module(mod_name) 32 | if ( 33 | mod_name in DEPRECATED 34 | or any(m.startswith("_") for m in mod_name.split(".")) 35 | or not hasattr(mod, "setup") 36 | ): 37 | continue 38 | setups_seen.add(mod_name) 39 | monkeypatch.setattr(mod, "setup", partial(setups_called.__setitem__, mod_name)) 40 | 41 | app = make_app_setup() 42 | app.setup_extension("scanpydoc") 43 | 44 | assert set(setups_called) == setups_seen 45 | for app2 in setups_called.values(): 46 | assert app is app2 47 | -------------------------------------------------------------------------------- /tests/test_definition_list_typed_field.py: -------------------------------------------------------------------------------- 1 | """Test definition_list_typed_field subextension.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | from sphinx import addnodes 9 | from docutils import nodes 10 | from sphinx.testing.restructuredtext import parse 11 | 12 | 13 | if TYPE_CHECKING: 14 | from sphinx.application import Sphinx 15 | 16 | from scanpydoc.testing import MakeApp 17 | 18 | 19 | @pytest.fixture 20 | def app(make_app_setup: MakeApp) -> Sphinx: 21 | app = make_app_setup() 22 | app.setup_extension("scanpydoc.definition_list_typed_field") 23 | return app 24 | 25 | 26 | # Avoid :class: to not get pending_xref. TODO: fix 27 | params_code_single = """\ 28 | .. py:function:: test(a) 29 | 30 | :param a: Only parameter 31 | :type a: ``str`` 32 | """ 33 | 34 | params_code = """\ 35 | .. py:function:: test(a, b=None) 36 | 37 | :param a: First parameter 38 | :type a: str 39 | :param b: Second parameter 40 | :type b: ``~typing.Optional``\\[``str``] 41 | """ 42 | 43 | 44 | def test_apps_separate(app: Sphinx, make_app_setup: MakeApp) -> None: 45 | app_no_setup = make_app_setup() 46 | assert app is not app_no_setup 47 | assert "scanpydoc.definition_list_typed_field" in app.extensions 48 | assert "scanpydoc.definition_list_typed_field" not in app_no_setup.extensions 49 | 50 | 51 | @pytest.mark.parametrize( 52 | ("code", "n", "i", "conv_types"), 53 | [ 54 | pytest.param(params_code_single, 1, 0, [nodes.literal], id="1-simple"), 55 | pytest.param(params_code, 2, 0, [addnodes.pending_xref], id="2-refconv"), 56 | pytest.param( 57 | params_code, 58 | 2, 59 | 1, 60 | [nodes.literal, nodes.Text, nodes.literal, nodes.Text], 61 | id="2-multi", 62 | ), 63 | ], 64 | ) 65 | def test_convert_params( 66 | app: Sphinx, code: str, n: int, i: int, conv_types: list[type[nodes.Node]] 67 | ) -> None: 68 | # the directive class is PyModuleLevel → PyObject → ObjectDescription 69 | # ObjectDescription.run uses a DocFieldTransformer to transform members 70 | # the signature of each Directive( 71 | # name, arguments, options, content, lineno, 72 | # content_offset, block_text, state, state_machine, 73 | 74 | doc = parse(app, code) 75 | assert isinstance(desc := doc[1], addnodes.desc) 76 | assert desc["desctype"] == "function" 77 | assert isinstance(desc_content := desc[1], addnodes.desc_content) 78 | assert isinstance(field_list := desc_content[0], nodes.field_list) 79 | assert isinstance(field := field_list[0], nodes.field) 80 | assert isinstance(field_name := field[0], nodes.field_name) 81 | assert isinstance(field_name_text := field_name[0], nodes.Text) 82 | assert field_name_text.astext() == "Parameters" 83 | assert isinstance(field_body := field[1], nodes.field_body) 84 | assert isinstance(dl := field_body[0], nodes.definition_list) 85 | 86 | # each parameter is a dl item that contains a term and a definition 87 | assert len(dl) == n, dl.children 88 | assert isinstance(dli := dl[i], nodes.definition_list_item) 89 | assert isinstance(term := dli[0], nodes.term) 90 | assert isinstance(dli[1], nodes.definition) 91 | 92 | # the dl term contains the parameter name and type 93 | assert len(term) == 3, term.children # noqa: PLR2004 94 | assert isinstance(term[0], addnodes.literal_strong) 95 | assert isinstance(term[1], nodes.Text) 96 | assert term[1].astext() == " " # spacer 97 | assert isinstance(cyr := term[2], nodes.classifier) 98 | assert len(cyr) == len(conv_types), cyr.children 99 | assert all( 100 | isinstance(cyr_part, conv_type) 101 | for cyr_part, conv_type in zip(cyr, conv_types, strict=True) 102 | ) 103 | 104 | 105 | def test_load_error(make_app_setup: MakeApp) -> None: 106 | with pytest.raises(RuntimeError, match=r"Please load sphinx\.ext\.napoleon before"): 107 | make_app_setup( 108 | extensions=["scanpydoc.definition_list_typed_field", "sphinx.ext.napoleon"] 109 | ) 110 | -------------------------------------------------------------------------------- /tests/test_elegant_typehints.py: -------------------------------------------------------------------------------- 1 | """Test elegant_typehints subextension.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | import inspect 7 | from typing import TYPE_CHECKING, Any, AnyStr, NoReturn, NamedTuple, cast, get_origin 8 | from pathlib import Path 9 | from operator import attrgetter 10 | from collections.abc import Mapping, Callable 11 | from importlib.metadata import version 12 | 13 | import pytest 14 | from packaging.version import Version 15 | 16 | 17 | if TYPE_CHECKING or Version(version("sphinx")) >= Version("8.2"): 18 | from sphinx.util.inventory import _InventoryItem 19 | else: 20 | 21 | class _InventoryItem(NamedTuple): 22 | project_name: str 23 | project_version: str 24 | uri: str 25 | display_name: str 26 | 27 | 28 | from scanpydoc.elegant_typehints import _last_resolve, qualname_overrides 29 | from scanpydoc.elegant_typehints._formatting import typehints_formatter 30 | 31 | 32 | if TYPE_CHECKING: 33 | from io import StringIO 34 | from types import ModuleType 35 | from typing import Protocol 36 | from collections.abc import Generator 37 | 38 | from sphinx.application import Sphinx 39 | 40 | from scanpydoc.testing import MakeApp 41 | 42 | class ProcessDoc(Protocol): # noqa: D101 43 | def __call__( # noqa: D102 44 | self, fn: Callable[..., Any], *, run_napoleon: bool = False 45 | ) -> list[str]: ... 46 | 47 | 48 | NONE_RTYPE = ":rtype: :sphinx_autodoc_typehints_type:`\\:py\\:obj\\:\\`None\\``" 49 | 50 | 51 | @pytest.fixture(autouse=True) 52 | def _reset_qualname_overrides() -> Generator[None, None, None]: 53 | yield 54 | qualname_overrides.clear() 55 | 56 | 57 | @pytest.fixture 58 | def testmod(make_module: Callable[[str, str], ModuleType]) -> ModuleType: 59 | return make_module( 60 | "testmod", 61 | """\ 62 | from __future__ import annotations 63 | from typing import Generic, TypeVar 64 | 65 | class Class: pass 66 | class SubCl(Class): pass 67 | class Excep(RuntimeError): pass 68 | class Excep2(Excep): pass 69 | 70 | T = TypeVar('T') 71 | class Gen(Generic[T]): pass 72 | """, 73 | ) 74 | 75 | 76 | @pytest.fixture 77 | def app(make_app_setup: MakeApp) -> Sphinx: 78 | return make_app_setup( 79 | master_doc="index", 80 | extensions=[ 81 | "sphinx.ext.autodoc", 82 | "sphinx.ext.napoleon", 83 | "sphinx_autodoc_typehints", 84 | "scanpydoc.elegant_typehints", 85 | ], 86 | qualname_overrides={ 87 | "testmod.Class": "test.Class", 88 | "testmod.SubCl": "test.SubCl", 89 | "testmod.Excep": "test.Excep", 90 | "testmod.Excep2": ("py:exc", "test.Excep2"), 91 | "testmod.Gen": "test.Gen", 92 | }, 93 | ) 94 | 95 | 96 | @pytest.fixture 97 | def process_doc(app: Sphinx) -> ProcessDoc: 98 | listeners = sorted( 99 | (l for l in app.events.listeners["autodoc-process-docstring"]), 100 | key=attrgetter("priority"), 101 | ) 102 | assert [f"{l.handler.__module__}.{l.handler.__qualname__}" for l in listeners] == [ 103 | "sphinx.ext.napoleon._process_docstring", 104 | "sphinx_autodoc_typehints.process_docstring", 105 | "scanpydoc.elegant_typehints._return_tuple.process_docstring", 106 | ] 107 | 108 | def process(fn: Callable[..., Any], *, run_napoleon: bool = False) -> list[str]: 109 | app.env.prepare_settings(getattr(fn, "__name__", str(fn))) 110 | lines = (inspect.getdoc(fn) or "").split("\n") 111 | if isinstance(fn, property): 112 | name = fn.fget.__name__ 113 | elif hasattr(fn, "__name__"): 114 | name = fn.__name__ 115 | else: 116 | name = "???" 117 | for listener in listeners: 118 | if ( 119 | not run_napoleon 120 | and listener.handler.__module__ == "sphinx.ext.napoleon" 121 | ): 122 | continue 123 | listener.handler(app, "function", name, fn, None, lines) 124 | return lines 125 | 126 | return process 127 | 128 | 129 | def test_app(app: Sphinx) -> None: 130 | assert "qualname_overrides" in app.config.values 131 | assert "testmod.Class" in app.config.qualname_overrides 132 | 133 | 134 | def test_default(app: Sphinx) -> None: 135 | assert typehints_formatter(str, app.config) is None 136 | 137 | 138 | def _escape_sat(rst: str) -> str: 139 | rst = ( 140 | rst.replace("\\", r"\\") 141 | .replace("`", r"\`") 142 | .replace(":", r"\:") 143 | .replace("~", r"\~") 144 | .replace(",", r"\,") 145 | .replace("[", r"\[") 146 | .replace("]", r"\]") 147 | ) 148 | return f":sphinx_autodoc_typehints_type:`{rst}`" 149 | 150 | 151 | @pytest.mark.parametrize( 152 | ("kind", "add_rtype"), 153 | [ 154 | pytest.param(lambda f: f, True, id="function"), 155 | pytest.param(property, False, id="property"), 156 | ], 157 | ) 158 | def test_kinds( 159 | *, 160 | process_doc: ProcessDoc, 161 | kind: Callable[[Callable[..., Any]], Callable[..., Any]], 162 | add_rtype: bool, 163 | ) -> None: 164 | def fn_test(s: str) -> None: # pragma: no cover 165 | """:param s: Test""" 166 | del s 167 | 168 | assert process_doc(kind(fn_test)) == [ 169 | f":type s: {_escape_sat(':py:class:`str`')}", 170 | ":param s: Test", 171 | *([NONE_RTYPE] if add_rtype else []), 172 | ] 173 | 174 | 175 | class CustomCls: # noqa: D101 176 | __slots__ = ["foo"] 177 | 178 | def meth(self): # pragma: no cover # noqa: ANN201 179 | """No return section and no return annotation.""" 180 | 181 | 182 | @pytest.mark.parametrize( 183 | "obj", 184 | [ 185 | pytest.param(None, id="none"), 186 | pytest.param(CustomCls.foo, id="slotwrapper"), # type: ignore[attr-defined] 187 | pytest.param(lambda: None, id="lambda"), 188 | pytest.param(CustomCls.meth, id="func_nodoc"), 189 | pytest.param(CustomCls().meth, id="meth_nodoc"), 190 | ], 191 | ) 192 | def test_skip(process_doc: ProcessDoc, obj: Callable[..., Any]) -> None: 193 | doc = inspect.getdoc(obj) 194 | assert process_doc(obj) == [doc or ""] 195 | 196 | 197 | def test_defaults_simple(process_doc: ProcessDoc) -> None: 198 | def fn_test( 199 | s: str = "foo", n: None = None, i_: int = 1 200 | ) -> None: # pragma: no cover 201 | r""":param s: Test S 202 | :param n: Test N 203 | :param i\_: Test I 204 | """ # noqa: D205 205 | del s, n, i_ 206 | 207 | assert process_doc(fn_test) == [ 208 | f":type s: {_escape_sat(':py:class:`str`')} (default: ``'foo'``)", 209 | ":param s: Test S", 210 | f":type n: {_escape_sat(':py:obj:`None`')} (default: ``None``)", 211 | ":param n: Test N", 212 | rf":type i\_: {_escape_sat(':py:class:`int`')} (default: ``1``)", 213 | r":param i\_: Test I", 214 | NONE_RTYPE, 215 | ] 216 | 217 | 218 | def test_defaults_complex(process_doc: ProcessDoc) -> None: 219 | def fn_test(m: Mapping[str, int] = {}) -> None: # pragma: no cover 220 | """:param m: Test M""" 221 | del m 222 | 223 | expected = ( 224 | r":py:class:`~collections.abc.Mapping`\ \[:py:class:`str`, :py:class:`int`]" 225 | ) 226 | assert process_doc(fn_test) == [ 227 | f":type m: {_escape_sat(expected)} (default: ``{{}}``)", 228 | ":param m: Test M", 229 | NONE_RTYPE, 230 | ] 231 | 232 | 233 | @pytest.mark.parametrize( 234 | ("get", "expected"), 235 | [ 236 | pytest.param(lambda m: m.Class, ":py:class:`~test.Class`", id="class"), 237 | pytest.param(lambda m: m.Excep, ":py:exc:`~test.Excep`", id="exc"), 238 | pytest.param( 239 | lambda m: m.Gen[m.Class], 240 | r":py:class:`~test.Gen`\ \[:py:class:`~test.Class`]", 241 | id="generic", 242 | ), 243 | ], 244 | ) 245 | def test_qualname_overrides( 246 | process_doc: ProcessDoc, 247 | testmod: ModuleType, 248 | get: Callable[[ModuleType], object], 249 | expected: str, 250 | ) -> None: 251 | def fn_test(m: object) -> None: # pragma: no cover 252 | """:param m: Test M""" 253 | del m 254 | 255 | fn_test.__annotations__["m"] = get(testmod) 256 | assert fn_test.__annotations__["m"].__module__ == "testmod" 257 | 258 | assert process_doc(fn_test) == [ 259 | f":type m: {_escape_sat(expected)}", 260 | ":param m: Test M", 261 | NONE_RTYPE, 262 | ] 263 | 264 | 265 | @pytest.mark.parametrize( 266 | ("qualname", "docname"), 267 | [("testmod.Class", "test.Class"), ("testmod.Excep2", "test.Excep2")], 268 | ) 269 | def test_resolve(app: Sphinx, qualname: str, docname: str) -> None: 270 | """Test that qualname_overrides affects _last_resolve as expected.""" 271 | from docutils.nodes import TextElement, reference 272 | from sphinx.addnodes import pending_xref 273 | from sphinx.ext.intersphinx import InventoryAdapter 274 | 275 | app.setup_extension("sphinx.ext.intersphinx") 276 | 277 | # Inventory contains documented name 278 | InventoryAdapter(app.env).main_inventory["py:class"] = { 279 | docname: _InventoryItem( 280 | project_name="TestProj", 281 | project_version="1", 282 | uri="https://x.com", 283 | display_name=docname.split(".")[-1], 284 | ), 285 | } 286 | # Node contains name from code 287 | node = pending_xref(refdomain="py", reftarget=qualname, reftype="class") 288 | 289 | resolved = _last_resolve(app, app.env, node, TextElement()) 290 | assert isinstance(resolved, reference) 291 | assert resolved["refuri"] == "https://x.com" 292 | assert resolved["reftitle"] == "(in TestProj v1)" 293 | 294 | 295 | @pytest.mark.parametrize("qualname", ["testmod.Class", "nonexistent.Class"]) 296 | def test_resolve_failure(app: Sphinx, qualname: str) -> None: 297 | from docutils.nodes import TextElement 298 | from sphinx.addnodes import pending_xref 299 | 300 | app.setup_extension("sphinx.ext.intersphinx") 301 | node = pending_xref(refdomain="py", reftarget=qualname, reftype="class") 302 | 303 | resolved = _last_resolve(app, app.env, node, TextElement()) 304 | assert resolved is None 305 | type_ex, target_ex = qualname_overrides.get( 306 | ("py:class", qualname), (None, qualname) 307 | ) 308 | if type_ex is not None: 309 | assert node["refdomain"], node["reftype"] == type_ex.split(":", 1) 310 | assert node["reftarget"] == target_ex 311 | 312 | 313 | # These guys aren’t listed as classes in Python’s intersphinx index: 314 | @pytest.mark.parametrize( 315 | "annotation", 316 | [ 317 | Any, 318 | AnyStr, 319 | NoReturn, 320 | Callable[[int], None], 321 | int | str, 322 | int | str | None, 323 | ], 324 | ids=lambda p: str(p).replace("typing.", ""), 325 | ) 326 | def test_typing_classes(app: Sphinx, annotation: type) -> None: 327 | app.config.typehints_fully_qualified = True 328 | name = ( 329 | getattr(annotation, "_name", None) 330 | or getattr(annotation, "__name__", None) 331 | or getattr(get_origin(annotation), "_name", None) 332 | # 3.6 _Any and _Union 333 | or annotation.__class__.__name__[1:] 334 | ) 335 | output = typehints_formatter(annotation, app.config) 336 | assert output is None or output.startswith(f":py:data:`typing.{name}") 337 | 338 | 339 | def test_union_type(app: Sphinx) -> None: 340 | union = eval("int | str") # noqa: S307 341 | assert typehints_formatter(union, app.config) is None 342 | 343 | 344 | @pytest.mark.parametrize( 345 | ("direc", "base", "sub"), 346 | [ 347 | ("autoclass", "Class", "SubCl"), 348 | ("autoexception", "Excep", "Excep2"), 349 | ], 350 | ) 351 | def test_autodoc( 352 | app: Sphinx, 353 | testmod: ModuleType, # noqa: ARG001 354 | direc: str, 355 | base: str, 356 | sub: str, 357 | ) -> None: 358 | Path(app.srcdir, "index.rst").write_text( 359 | f"""\ 360 | .. {direc}:: testmod.{sub} 361 | :show-inheritance: 362 | """, 363 | ) 364 | app.build() 365 | out = Path(app.outdir, "index.html").read_text() 366 | assert not (ws := cast("StringIO", app._warning).getvalue()), ws # noqa: SLF001 367 | assert re.search( 368 | r'<(code|span)?[^>]*>test\.' 369 | f'<(code|span)?[^>]*>{sub}', 370 | out, 371 | ), out 372 | assert f']*>]*>(?:test\.)?{base}", out), out 374 | 375 | 376 | def test_fwd_ref(app: Sphinx, make_module: Callable[[str, str], ModuleType]) -> None: 377 | make_module( 378 | "fwd_mod", 379 | """\ 380 | from dataclasses import dataclass 381 | 382 | @dataclass 383 | class A: 384 | b: 'B' 385 | 386 | @dataclass 387 | class B: 388 | a: A 389 | """, 390 | ) 391 | Path(app.srcdir, "index.rst").write_text( 392 | """\ 393 | .. autosummary:: 394 | 395 | fwd_mod.A 396 | fwd_mod.B 397 | """, 398 | ) 399 | app.setup_extension("sphinx.ext.autosummary") 400 | 401 | app.build() 402 | 403 | out = Path(app.outdir, "index.html").read_text() 404 | buf = cast("StringIO", app._warning) # noqa: SLF001 405 | warnings = [ 406 | w 407 | for w in buf.getvalue().splitlines() 408 | if "Cannot treat a function defined as a local function" not in w 409 | ] 410 | assert not warnings, warnings 411 | # TODO(flying-sheep): actually reproduce 412 | # https://github.com/theislab/scanpydoc/issues/14 413 | assert "fwd_mod.A" in out, out 414 | 415 | 416 | @pytest.mark.parametrize( 417 | "docstring", 418 | [ 419 | """ 420 | :param x: An x 421 | :returns: foo 422 | A foo! 423 | bar 424 | A bar! 425 | """, 426 | """ 427 | :returns: foo 428 | A foo! 429 | bar 430 | A bar! 431 | :param x: An x 432 | """, 433 | """ 434 | Parameters 435 | ---------- 436 | x 437 | An x 438 | 439 | Returns 440 | ------- 441 | foo 442 | A foo! 443 | bar 444 | A bar! 445 | """, 446 | ], 447 | ids=["last", "first", "numpydoc"], 448 | ) 449 | @pytest.mark.parametrize( 450 | ("return_ann", "foo_rendered"), 451 | [ 452 | pytest.param(tuple[str, int], ":py:class:`str`", id="tuple"), 453 | pytest.param(tuple[str, int] | None, ":py:class:`str`", id="tuple | None"), 454 | pytest.param( 455 | tuple[Mapping[str, float], int], 456 | r":py:class:`~collections.abc.Mapping`\ \[" 457 | ":py:class:`str`, :py:class:`float`" 458 | "]", 459 | id="complex", 460 | ), 461 | pytest.param(int | None, None, id="int | None"), 462 | ], 463 | ) 464 | def test_return_tuple( 465 | process_doc: ProcessDoc, 466 | docstring: str, 467 | return_ann: type, 468 | foo_rendered: str | None, 469 | ) -> None: 470 | is_numpydoc = "-----" in docstring 471 | 472 | def fn_test() -> None: # pragma: no cover 473 | pass 474 | 475 | fn_test.__doc__ = docstring 476 | fn_test.__annotations__["return"] = return_ann 477 | lines = [ 478 | l 479 | for l in process_doc(fn_test, run_napoleon=is_numpydoc) 480 | if l 481 | if not re.match(r"^:(rtype|param)( \w+)?:", l) 482 | ] 483 | if foo_rendered is None: 484 | assert lines == [ 485 | ":returns: foo", 486 | " A foo!", 487 | " bar", 488 | " A bar!", 489 | ] 490 | else: 491 | assert lines == [ 492 | f":returns: foo : {foo_rendered}", 493 | " A foo!", 494 | " bar : :py:class:`int`", 495 | " A bar!", 496 | ] 497 | 498 | 499 | def test_return_tuple_anonymous(process_doc: ProcessDoc) -> None: 500 | def fn_test() -> tuple[int, str]: # pragma: no cover 501 | """ 502 | Returns 503 | ------- 504 | : 505 | An int! 506 | : 507 | A str! 508 | """ # noqa: D401, D205 509 | return (1, "foo") 510 | 511 | lines = [ 512 | l 513 | for l in process_doc(fn_test, run_napoleon=True) 514 | if l 515 | if not re.match(r"^:(rtype|param)( \w+)?:", l) 516 | ] 517 | assert lines == [ 518 | ":returns: :py:class:`int`", 519 | " An int!", 520 | " :py:class:`str`", 521 | " A str!", 522 | ] 523 | 524 | 525 | def test_return_nodoc(process_doc: ProcessDoc) -> None: 526 | def fn() -> tuple[int, str]: # pragma: no cover 527 | """No return section.""" 528 | return 1, "" 529 | 530 | res = process_doc(fn) 531 | assert len(res) == 3 # noqa: PLR2004 532 | assert res[0:2] == [inspect.getdoc(fn), ""] 533 | assert res[2].startswith(":rtype: :sphinx_autodoc_typehints_type:") 534 | 535 | 536 | def test_load_without_sat(make_app_setup: MakeApp) -> None: 537 | make_app_setup( 538 | master_doc="index", 539 | extensions=["sphinx.ext.autodoc", "scanpydoc.elegant_typehints"], 540 | ) 541 | 542 | 543 | def test_load_error(make_app_setup: MakeApp) -> None: 544 | with pytest.raises( 545 | RuntimeError, 546 | match=r"`scanpydoc.elegant_typehints` requires `sphinx.ext.autodoc`", 547 | ): 548 | make_app_setup( 549 | master_doc="index", 550 | extensions=["scanpydoc.elegant_typehints"], 551 | ) 552 | -------------------------------------------------------------------------------- /tests/test_release_notes.py: -------------------------------------------------------------------------------- 1 | """Test release_notes subextension.""" 2 | 3 | from __future__ import annotations 4 | 5 | from types import MappingProxyType 6 | from typing import TYPE_CHECKING 7 | from textwrap import dedent 8 | from functools import partial 9 | 10 | import pytest 11 | from sphinx.errors import SphinxWarning 12 | from docutils.utils import new_document 13 | from docutils.languages import get_language 14 | from docutils.parsers.rst import directives 15 | 16 | 17 | if TYPE_CHECKING: 18 | from typing import Literal, TypeAlias 19 | from pathlib import Path 20 | from collections.abc import Mapping 21 | 22 | from sphinx.application import Sphinx 23 | from sphinx.testing.util import SphinxTestApp 24 | 25 | from scanpydoc.testing import MakeApp 26 | 27 | Tree: TypeAlias = Mapping[str | Path, "Tree | str"] 28 | 29 | 30 | def mkfiles(root: Path, tree: Tree = MappingProxyType({})) -> None: 31 | root.mkdir(parents=True, exist_ok=True) 32 | for path, sub in tree.items(): 33 | if isinstance(sub, str): 34 | (root / path).write_text(sub) 35 | else: 36 | mkfiles(root / path, sub) 37 | 38 | 39 | @pytest.fixture(params=["rst", "myst"]) 40 | def file_format(request: pytest.FixtureRequest) -> Literal["rst", "myst"]: 41 | return request.param # type: ignore[no-any-return] 42 | 43 | 44 | @pytest.fixture 45 | def make_app_relnotes( 46 | make_app_setup: MakeApp, file_format: Literal["rst", "myst"] 47 | ) -> MakeApp: 48 | return partial( 49 | make_app_setup, 50 | "pseudoxml", 51 | extensions=[ 52 | *(["myst_parser"] if file_format == "myst" else []), 53 | "scanpydoc.release_notes", 54 | ], 55 | exclude_patterns=["[!i]*.md"], 56 | ) 57 | 58 | 59 | @pytest.fixture 60 | def app(make_app_relnotes: MakeApp) -> SphinxTestApp: 61 | return make_app_relnotes() 62 | 63 | 64 | @pytest.fixture 65 | def index_filename(file_format: Literal["rst", "myst"]) -> str: 66 | return "index.md" if file_format == "myst" else "index.rst" 67 | 68 | 69 | @pytest.fixture 70 | def index_template(file_format: Literal["rst", "myst"]) -> str: 71 | return ( 72 | "```{{release-notes}} {}\n```" 73 | if file_format == "myst" 74 | else ".. release-notes:: {}" 75 | ) 76 | 77 | 78 | @pytest.fixture 79 | def files( 80 | file_format: Literal["rst", "myst"], index_filename: str, index_template: str 81 | ) -> Tree: 82 | files: Tree 83 | if file_format == "myst": 84 | files = { 85 | index_filename: index_template.format("."), 86 | "1.2.0.md": "(v1.2.0)=\n### 1.2.0", 87 | "1.2.1.md": "(v1.2.1)=\n### 1.2.1", 88 | "1.3.0rc1.md": "(v1.3.0rc1)=\n### 1.3.0rc1", 89 | } 90 | else: 91 | files = { 92 | index_filename: index_template.format("."), 93 | "1.2.0.rst": ".. _v1.2.0:\n1.2.0\n=====", 94 | "1.2.1.rst": ".. _v1.2.1:\n1.2.1\n=====", 95 | "1.3.0rc1.rst": ".. _v1.3.0rc1:\n1.3.0rc1\n========", 96 | } 97 | return files 98 | 99 | 100 | expected = """\ 101 | 102 |
103 | 104 | Version 1.3 105 | <target refid="v1-3-0rc1"> 106 | <section ids="rc1 v1-3-0rc1" names="1.3.0rc1 v1.3.0rc1"> 107 | <title> 108 | 1.3.0rc1 109 | <target refid="v1-2"> 110 | <section ids="version-1-2 v1-2" names="version\\ 1.2 v1.2"> 111 | <title> 112 | Version 1.2 113 | <target refid="v1-2-1"> 114 | <section ids="v1-2-1 id1" names="1.2.1 v1.2.1"> 115 | <title> 116 | 1.2.1 117 | <target refid="v1-2-0"> 118 | <section ids="v1-2-0 id2" names="1.2.0 v1.2.0"> 119 | <title> 120 | 1.2.0 121 | """ 122 | 123 | 124 | def test_release_notes(tmp_path: Path, app: Sphinx, files: Tree) -> None: 125 | mkfiles(tmp_path, files) 126 | app.build() 127 | index_out = (tmp_path / "_build/pseudoxml/index.pseudoxml").read_text() 128 | assert ( 129 | "\n".join(l[4:] for l in dedent(index_out).splitlines()[1:]) == expected.strip() 130 | ) 131 | 132 | 133 | @pytest.mark.parametrize( 134 | ("root", "files", "pattern"), 135 | [ 136 | pytest.param( 137 | "doesnt-exist.txt", {}, r"Not a directory:.*doesnt-exist.txt", id="nothing" 138 | ), 139 | pytest.param( 140 | "file.txt", {"file.txt": "cont"}, r"Not a directory:.*file.txt", id="file" 141 | ), 142 | ], 143 | ) 144 | def test_error_wrong_file( 145 | tmp_path: Path, 146 | make_app_relnotes: MakeApp, 147 | index_filename: str, 148 | index_template: str, 149 | root: str, 150 | files: Tree, 151 | pattern: str, 152 | ) -> None: 153 | app = make_app_relnotes(exception_on_warning=True) 154 | mkfiles(tmp_path, {index_filename: index_template.format(root), **files}) 155 | with pytest.raises(SphinxWarning, match=pattern): 156 | app.build() 157 | 158 | 159 | def test_error_no_src( 160 | monkeypatch: pytest.MonkeyPatch, 161 | tmp_path: Path, 162 | make_app_relnotes: MakeApp, 163 | files: Tree, 164 | ) -> None: 165 | app = make_app_relnotes(exception_on_warning=True) 166 | if "myst_parser" not in app.extensions: 167 | pytest.skip("rst parser doesn’t need this") 168 | rn, _ = directives.directive("release-notes", get_language("en"), new_document("")) 169 | monkeypatch.setattr(rn, "get_source_info", lambda *_, **__: ("<string>", 0)) 170 | 171 | mkfiles(tmp_path, files) 172 | with pytest.raises(SphinxWarning, match=r"Cannot find relative path to: <string>"): 173 | app.build() 174 | -------------------------------------------------------------------------------- /tests/test_rtd_github_links.py: -------------------------------------------------------------------------------- 1 | """Test rtd_github_links subextension.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | import sys 7 | import textwrap 8 | from typing import TYPE_CHECKING 9 | from pathlib import Path, PurePosixPath 10 | from importlib import import_module 11 | from collections.abc import Mapping 12 | 13 | import pytest 14 | from sphinx.config import Config 15 | 16 | from scanpydoc.rtd_github_links import ( 17 | _testdata, 18 | github_url, 19 | _infer_vars, 20 | _get_linenos, 21 | _module_path, 22 | _get_obj_module, 23 | ) 24 | from scanpydoc.rtd_github_links._linkcode import CInfo, PyInfo, linkcode_resolve 25 | 26 | 27 | if TYPE_CHECKING: 28 | from types import ModuleType 29 | from typing import Literal 30 | from collections.abc import Callable 31 | 32 | from _pytest.monkeypatch import MonkeyPatch 33 | 34 | from scanpydoc.testing import MakeApp 35 | from scanpydoc.rtd_github_links._linkcode import Domain, DomainInfo 36 | 37 | 38 | HERE = Path(__file__).parent 39 | 40 | 41 | @pytest.fixture 42 | def config() -> Config: 43 | config = Config() 44 | config.add( 45 | "rtd_links_prefix", PurePosixPath("."), rebuild="", types=[PurePosixPath] 46 | ) 47 | return config 48 | 49 | 50 | @pytest.fixture 51 | def _env(monkeypatch: MonkeyPatch) -> None: 52 | monkeypatch.setattr("scanpydoc.rtd_github_links.github_base_url", "x") 53 | 54 | 55 | @pytest.fixture(params=[".", "src"]) 56 | def prefix( 57 | monkeypatch: MonkeyPatch, _env: None, request: pytest.FixtureRequest 58 | ) -> PurePosixPath: 59 | pfx = PurePosixPath(request.param) 60 | monkeypatch.setattr("scanpydoc.rtd_github_links.rtd_links_prefix", pfx) 61 | return "x" / pfx / "scanpydoc" 62 | 63 | 64 | @pytest.mark.parametrize( 65 | ("opt_name", "opt"), 66 | [ 67 | pytest.param( 68 | "html_context", 69 | dict( 70 | github_user="scverse", 71 | github_repo="scanpydoc", 72 | github_version="test-branch", 73 | ), 74 | id="html_context", 75 | ), 76 | pytest.param( 77 | "html_theme_options", 78 | dict( 79 | repository_url="https://github.com/scverse/scanpydoc", 80 | repository_branch="test-branch", 81 | ), 82 | id="html_theme_options", 83 | ), 84 | ], 85 | ) 86 | def test_infer_vars(config: Config, opt_name: str, opt: dict[str, str]) -> None: 87 | config.add(opt_name, {}, rebuild="", types=[dict]) 88 | config[opt_name] = opt 89 | gbu, rlp = _infer_vars(config) 90 | assert gbu == "https://github.com/scverse/scanpydoc/tree/test-branch" 91 | assert rlp 92 | 93 | 94 | PAT_VAR = r"needs html_context.*'github_user'.*or html_theme_options.*'repository_url'" 95 | 96 | 97 | @pytest.mark.parametrize( 98 | ("setup", "pattern"), 99 | [ 100 | pytest.param( 101 | [], r"needs “html_context” or “html_theme_options” to be defined", id="none" 102 | ), 103 | pytest.param(["html_context"], PAT_VAR, id="html_context"), 104 | pytest.param(["html_theme_options"], PAT_VAR, id="html_theme_options"), 105 | pytest.param(["html_context", "html_theme_options"], PAT_VAR, id="both"), 106 | ], 107 | ) 108 | def test_infer_vars_error(config: Config, setup: list[str], pattern: str) -> None: 109 | for opt_name in setup: 110 | config.add(opt_name, {}, rebuild="", types=[dict]) 111 | with pytest.raises(ValueError, match=pattern): 112 | _infer_vars(config) 113 | 114 | 115 | def test_app(monkeypatch: MonkeyPatch, make_app_setup: MakeApp) -> None: 116 | filters: dict[str, Callable[..., object]] = {} 117 | monkeypatch.setattr("scanpydoc.rtd_github_links._init_vars", lambda *_: None) 118 | monkeypatch.setattr("scanpydoc.rtd_github_links.DEFAULT_FILTERS", filters) 119 | app = make_app_setup( 120 | extensions=["sphinx.ext.linkcode", "scanpydoc.rtd_github_links"], 121 | html_context=dict( 122 | github_user="scverse", github_repo="scanpydoc", github_version="test-branch" 123 | ), 124 | ) 125 | assert app.config["linkcode_resolve"] is linkcode_resolve 126 | assert filters == dict(github_url=github_url) 127 | 128 | 129 | @pytest.mark.parametrize( 130 | ("module", "name", "obj_path"), 131 | [ 132 | pytest.param( 133 | *("rtd_github_links", "github_url", "rtd_github_links/__init__.py"), 134 | id="basic", 135 | ), 136 | pytest.param( 137 | "elegant_typehints", 138 | "example_func_prose", 139 | "elegant_typehints/example.py", 140 | id="reexport", 141 | ), 142 | ], 143 | ) 144 | def test_as_function( 145 | prefix: PurePosixPath, module: str, name: str, obj_path: str 146 | ) -> None: 147 | assert github_url(f"scanpydoc.{module}") == str(prefix / module / "__init__.py") 148 | obj = getattr(import_module(f"scanpydoc.{module}"), name) 149 | s, e = _get_linenos(obj) 150 | assert github_url(f"scanpydoc.{module}.{name}") == f"{prefix}/{obj_path}#L{s}-L{e}" 151 | 152 | 153 | @pytest.mark.parametrize("cls", [dict, Mapping]) 154 | def test_no_line_nos_for_unavailable_source(cls: type) -> None: 155 | start, end = _get_linenos(cls) 156 | assert start is end is None 157 | 158 | 159 | def test_get_github_url_only_annotation(prefix: PurePosixPath) -> None: 160 | """Doesn’t really work but shouldn’t crash either.""" 161 | url = github_url(f"{_testdata.__name__}.TestCls.test_anno") 162 | path = prefix.parent / Path(*_testdata.__name__.split(".")) 163 | assert url == str(path.with_suffix(".py")) 164 | 165 | 166 | def test_get_github_url_error() -> None: 167 | with pytest.raises(KeyError) as exc_info: 168 | github_url("test.nonexistant.Thingamajig") 169 | if sys.version_info >= (3, 11): 170 | assert exc_info.value.__notes__[0] == "Qualname: 'test.nonexistant.Thingamajig'" 171 | 172 | 173 | @pytest.mark.parametrize( 174 | ("obj_path", "obj", "mod", "path_expected"), 175 | [ 176 | pytest.param( 177 | "scanpydoc.indent", textwrap.indent, textwrap, "textwrap.py", id="reexport" 178 | ), 179 | pytest.param( 180 | "scanpydoc.rtd_github_links._testdata.test_func", 181 | _testdata.test_func, 182 | _testdata, 183 | "scanpydoc/rtd_github_links/_testdata.py", 184 | id="func", 185 | ), 186 | pytest.param( 187 | "scanpydoc.rtd_github_links._testdata.test_func_wrap", 188 | _testdata.test_func_wrap, 189 | _testdata, 190 | "scanpydoc/rtd_github_links/_testdata.py", 191 | id="wrapper", 192 | ), 193 | pytest.param( 194 | "scanpydoc.rtd_github_links._testdata", 195 | _testdata, 196 | _testdata, 197 | "scanpydoc/rtd_github_links/_testdata.py", 198 | id="mod", 199 | ), 200 | pytest.param( 201 | "scanpydoc.rtd_github_links._testdata.TestDataCls.test_attr", 202 | _testdata.TestDataCls.__dataclass_fields__["test_attr"], 203 | _testdata, 204 | "scanpydoc/rtd_github_links/_testdata.py", 205 | id="dataclass_field", 206 | ), 207 | pytest.param( 208 | "scanpydoc.rtd_github_links._testdata.TestCls.test_anno", 209 | None, 210 | _testdata, 211 | "scanpydoc/rtd_github_links/_testdata.py", 212 | id="anno", 213 | ), 214 | pytest.param( 215 | "scanpydoc.rtd_github_links._testdata.TestGenericBuiltin", 216 | _testdata.TestGenericBuiltin, 217 | _testdata, 218 | "scanpydoc/rtd_github_links/_testdata.py", 219 | id="generic_builtin", 220 | ), 221 | pytest.param( 222 | "scanpydoc.rtd_github_links._testdata.TestGenericClass", 223 | _testdata.TestGenericClass, 224 | _testdata, 225 | "scanpydoc/rtd_github_links/_testdata.py", 226 | id="generic_class", 227 | ), 228 | ], 229 | ) 230 | def test_get_obj_module_path( 231 | obj_path: str, obj: object, mod: ModuleType, path_expected: PurePosixPath 232 | ) -> None: 233 | obj_rcv, mod_rcv = _get_obj_module(obj_path) 234 | assert obj_rcv is obj 235 | assert mod_rcv is mod 236 | path = _module_path(obj_rcv, mod_rcv) 237 | assert path == PurePosixPath(path_expected) 238 | 239 | 240 | def test_linkdoc(prefix: PurePosixPath) -> None: 241 | link = linkcode_resolve( 242 | "py", PyInfo(module="scanpydoc.rtd_github_links", fullname="setup") 243 | ) 244 | assert link is not None 245 | url, hash_ = link.split("#") 246 | assert url == f"{prefix}/rtd_github_links/__init__.py" 247 | assert re.fullmatch(r"L\d+-L\d+", hash_) 248 | 249 | 250 | @pytest.mark.parametrize( 251 | ("domain", "info"), 252 | [("py", PyInfo(fullname="foo", module="")), ("c", CInfo(names=[]))], 253 | ) 254 | def test_linkcode_skip(domain: Literal[Domain], info: DomainInfo) -> None: 255 | assert linkcode_resolve(domain, info) is None # type: ignore[arg-type] 256 | --------------------------------------------------------------------------------