pax_global_header00006660000000000000000000000064147366514420014526gustar00rootroot0000000000000052 comment=7b2f9b02462af49754990a4b37c69e783882e8b4 pydoctor-24.11.2/000077500000000000000000000000001473665144200135405ustar00rootroot00000000000000pydoctor-24.11.2/.codecov.yml000066400000000000000000000002021473665144200157550ustar00rootroot00000000000000coverage: status: project: default: informational: true patch: default: informational: true pydoctor-24.11.2/.coveragerc000066400000000000000000000010221473665144200156540ustar00rootroot00000000000000[run] branch = True omit = pydoctor/sphinx_ext/* pydoctor/test/* pydoctor/epydoc/sre_parse36.py pydoctor/epydoc/sre_constants36.py source = pydoctor [report] exclude_lines = # Manually marked: pragma: no cover # Intended to be unreachable: raise NotImplementedError$ raise NotImplementedError\( raise AssertionError$ raise AssertionError\( assert False$ assert False, # Debug-only code: def __repr__\( # Exclusive to mypy: if TYPE_CHECKING:$ \.\.\.$ pydoctor-24.11.2/.github/000077500000000000000000000000001473665144200151005ustar00rootroot00000000000000pydoctor-24.11.2/.github/pull_request_template.md000066400000000000000000000002221473665144200220350ustar00rootroot00000000000000 pydoctor-24.11.2/.github/workflows/000077500000000000000000000000001473665144200171355ustar00rootroot00000000000000pydoctor-24.11.2/.github/workflows/pydoctor_primer.yaml000066400000000000000000000075701473665144200232530ustar00rootroot00000000000000name: Run pydoctor_primer on: # Only run on PR, since we diff against master pull_request: paths-ignore: - 'pydoctor/test/**' - 'docs/**' - '*.rst' - '*.txt' - '*.in' - '*.md' - '.*' - 'setup.py' jobs: pydoctor_primer: name: Run pydoctor_primer runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v4 with: path: pydoctor_to_test fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install pydoctor_primer run: | python -m pip install -U pip pip install git+https://github.com/twisted/pydoctor_primer.git - name: Run pydoctor_primer shell: bash run: | cd pydoctor_to_test echo "new commit" git rev-list --format=%s --max-count=1 $GITHUB_SHA MERGE_BASE=$(git merge-base $GITHUB_SHA origin/$GITHUB_BASE_REF) git checkout -b base_commit $MERGE_BASE echo "base commit" git rev-list --format=%s --max-count=1 base_commit echo '' cd .. # fail action if exit code isn't zero or one ( pydoctor_primer \ --repo pydoctor_to_test \ --new $GITHUB_SHA --old base_commit \ --debug \ --output concise \ -j 8 \ | tee diff.txt ) || [ $? -eq 1 ] - name: Post comment id: post-comment uses: actions/github-script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const MAX_CHARACTERS = 30000 const MAX_CHARACTERS_PER_PROJECT = MAX_CHARACTERS / 3 const fs = require('fs') let data = fs.readFileSync('diff.txt', { encoding: 'utf8' }) function truncateIfNeeded(original, maxLength) { if (original.length <= maxLength) { return original } let truncated = original.substring(0, maxLength) // further, remove last line that might be truncated truncated = truncated.substring(0, truncated.lastIndexOf('\n')) let lines_truncated = original.split('\n').length - truncated.split('\n').length return `${truncated}\n\n... (truncated ${lines_truncated} lines) ...` } const projects = data.split('\n\n') // don't let one project dominate data = projects.map(project => truncateIfNeeded(project, MAX_CHARACTERS_PER_PROJECT)).join('\n\n') // posting comment fails if too long, so truncate data = truncateIfNeeded(data, MAX_CHARACTERS) console.log("Diff from pydoctor_primer:") console.log(data) let body if (data.trim()) { body = 'Diff from [pydoctor_primer](https://github.com/tristanlatr/pydoctor_primer), showing the effect of this PR on open source code:\n```diff\n' + data + '```' } else { body = "According to [pydoctor_primer](https://github.com/tristanlatr/pydoctor_primer), this change doesn't affect pydoctor warnings on a corpus of open source code. ✅" } const ev = JSON.parse( fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8') ) const prNumber = ev.pull_request.number await github.rest.issues.createComment({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, body }) return prNumber - name: Hide old comments uses: kanga333/comment-hider@v0.4.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} leave_visible: 1 issue_number: ${{ steps.post-comment.outputs.result }}pydoctor-24.11.2/.github/workflows/static.yaml000066400000000000000000000017741473665144200213210ustar00rootroot00000000000000name: Static code checks on: push: branches: [ master ] pull_request: branches: [ master ] jobs: static_checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install tox run: | python -m pip install --upgrade pip tox - name: Log system information run: | test -r /etc/os-release && sh -c '. /etc/os-release && echo "OS: $PRETTY_NAME"' python --version python -c "print('\nENVIRONMENT VARIABLES\n=====================\n')" python -c "import os; [print(f'{k}={v}') for k, v in os.environ.items()]" - name: Run mypy run: | tox -e mypy - name: Run pyflakes run: | tox -e pyflakes - name: Run pydoctor on its own source and fail on docstring errors run: | tox -e apidocs - name: Run docs and check extensions run: | tox -e testdocs pydoctor-24.11.2/.github/workflows/system.yaml000066400000000000000000000017141473665144200213500ustar00rootroot00000000000000name: System tests on: push: branches: [ master ] pull_request: branches: [ master ] jobs: system_tests: runs-on: ubuntu-latest strategy: matrix: tox_target: [twisted-apidoc, cpython-summary, python-igraph-apidocs, cpython-apidocs, numpy-apidocs, git-buildpackage-apidocs, pytype-apidocs] steps: - uses: actions/checkout@v4 - name: Set up CPython uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install tox run: | python -m pip install --upgrade pip tox - name: Log system information run: | test -r /etc/os-release && sh -c '. /etc/os-release && echo "OS: $PRETTY_NAME"' python --version python -c "print('\nENVIRONMENT VARIABLES\n=====================\n')" python -c "import os; [print(f'{k}={v}') for k, v in os.environ.items()]" - name: Generate API docs run: | tox -e ${{ matrix.tox_target }} pydoctor-24.11.2/.github/workflows/unit.yaml000066400000000000000000000071041473665144200210020ustar00rootroot00000000000000name: Unit tests and release on: push: branches: [ master ] tags: - '*' pull_request: branches: [ master ] permissions: contents: read jobs: unit_tests: runs-on: ${{ matrix.os }} strategy: matrix: python-version: ['pypy-3.8', 'pypy-3.9', 'pypy-3.10', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install tox run: | python -m pip install --upgrade pip tox - name: Log system information run: | test -r /etc/os-release && sh -c '. /etc/os-release && echo "OS: $PRETTY_NAME"' python --version python -c "print('\nENVIRONMENT VARIABLES\n=====================\n')" python -c "import os; [print(f'{k}={v}') for k, v in os.environ.items()]" - name: Run unit tests and coverage reports run: | tox -e test-cov - name: Run unit tests with latest Twisted version run: | tox -e test-latest-twisted - name: Publish code coverage uses: codecov/codecov-action@v4 with: fail_ci_if_error: true files: ./coverage.xml name: unit-${{ matrix.os }}-${{matrix.python-version}} # Check the secret defined in GHA here # https://github.com/twisted/pydoctor/settings/secrets/actions # And get it from Codecov.io here # https://app.codecov.io/gh/twisted/pydoctor/settings token: ${{ secrets.CODECOV_TOKEN }} verbose: true release: needs: [unit_tests] runs-on: ubuntu-latest permissions: contents: read # The `id-token` permission is mandatory for trusted publishing # See https://github.com/pypa/gh-action-pypi-publish id-token: write steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: 3.12 - name: Log system information run: | test -r /etc/os-release && sh -c '. /etc/os-release && echo "OS: $PRETTY_NAME"' python --version python -c "print('\nENVIRONMENT VARIABLES\n=====================\n')" python -c "import os; [print(f'{k}={v}') for k, v in os.environ.items()]" - name: Install deps run: | python -m pip install --upgrade pip setuptools wheel - name: Build pydoctor run: | python setup.py --quiet build check sdist bdist_wheel ls -alh ./dist/ - name: Publish to PyPI - on tag if: startsWith(github.ref, 'refs/tags/') uses: pypa/gh-action-pypi-publish@release/v1 # This is a meta-job to simplify PR CI enforcement configuration in GitHub. # Inside the GitHub config UI you only configure this job as required. # All the extra requirements are defined "as code" as part of the `needs` # list for this job. gha-required: name: GHA Required runs-on: ubuntu-latest # The `if` condition is very important. # If not set, the job will be skipped on failing dependencies. if: ${{ !cancelled() }} needs: # This is the list of CI job that we are interested to pass before # a merge. # pypi-publish is skipped since this is only executed for a tag. - unit_tests steps: - name: Require all successes uses: re-actors/alls-green@3a2de129f0713010a71314c74e33c0e3ef90e696 with: jobs: ${{ toJSON(needs) }} pydoctor-24.11.2/.gitignore000066400000000000000000000001361473665144200155300ustar00rootroot00000000000000build MANIFEST dist *.pyc .tox/ .coverage .DS_Store *~ _trial_temp/ apidocs/ *.egg-info .eggs pydoctor-24.11.2/CONTRIBUTING.rst000066400000000000000000000000411473665144200161740ustar00rootroot00000000000000See ``_ pydoctor-24.11.2/LICENSE.txt000066400000000000000000000124041473665144200153640ustar00rootroot00000000000000 Copyright 2006-2008 Michael Hudson Copyright 2006-2020 Various contributors (see Git history) All Rights Reserved Permission to use, copy, modify, and distribute this software and its documentation for any purpose is hereby granted without fee, provided that the above copyright notice appear in all copies and that both that copyright notice and this permission notice appear in supporting documentation. THE AUTHOR MICHAEL HUDSON DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. Support for epytext and reStructuredText was imported from epydoc. This code can be found under pydoctor/epydoc/ and is licensed as follows: Copyright 2001-2009 Edward Loper Permission is hereby granted, free of charge, to any person obtaining a copy of this software and any associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. The software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software. 'classic' theme uses Bootstrap CSS v3.3.4 (http://getbootstrap.com). This code can be found at pydoctor/themes/classic/bootstrap.min.css and is licensed as follows: The MIT License (MIT) Copyright 2011-2015 Twitter, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 'readthedocs' theme is largely adapted from ReadTheDocs Inc. Sphinx theme. This code can be found under pydoctor/themes/readthedocs and is licensed as follows: The MIT License (MIT) Copyright (c) 2013-2018 Dave Snider, Read the Docs, Inc. & contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 'readthedocs' theme includes Roboto Slab Font. Font files can be found under folder pydoctor/themes/readthedocs/fonts/ and are licensed as follows: Copyright Christian Robertson Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. pydoctor-24.11.2/MANIFEST.in000066400000000000000000000001751473665144200153010ustar00rootroot00000000000000include setup.cfg graft pydoctor/themes graft docs include *.txt *.tac include MANIFEST.in include *.cfg graft pydoctor/test pydoctor-24.11.2/README.rst000066400000000000000000000563721473665144200152440ustar00rootroot00000000000000pydoctor -------- .. image:: https://img.shields.io/pypi/pyversions/pydoctor.svg :target: https://pypi.python.org/pypi/pydoctor .. image:: https://github.com/twisted/pydoctor/actions/workflows/unit.yaml/badge.svg :target: https://github.com/twisted/pydoctor/actions/workflows/unit.yaml .. image:: https://codecov.io/gh/twisted/pydoctor/branch/master/graph/badge.svg :target: https://codecov.io/gh/twisted/pydoctor .. image:: https://img.shields.io/badge/-documentation-blue :target: https://pydoctor.readthedocs.io/ This is *pydoctor*, a standalone API documentation generator that works by static analysis. It was written primarily to replace ``epydoc`` for the purposes of the Twisted project as ``epydoc`` has difficulties with ``zope.interface``. If you are looking for a successor to ``epydoc`` after moving to Python 3, ``pydoctor`` might be the right tool for your project as well. ``pydoctor`` puts a fair bit of effort into resolving imports and computing inheritance hierarchies and, as it aims at documenting Twisted, knows about ``zope.interface``'s declaration API and can present information about which classes implement which interface, and vice versa. Simple Usage ~~~~~~~~~~~~ You can run pydoctor on your project like this:: $ pydoctor --html-output=docs/api src/mylib For more info, `Read The Docs `_. Markup ~~~~~~ pydoctor currently supports the following markup languages in docstrings: `epytext`__ (default) The markup language of epydoc. Simple and compact. `restructuredtext`__ The markup language used by Sphinx. More expressive than epytext, but also slightly more complex and verbose. `google`__ Docstrings formatted as specified by the Google Python Style Guide. (compatible with reStructuredText markup) `numpy`__ Docstrings formatted as specified by the Numpy Docstring Standard. (compatible with reStructuredText markup) ``plaintext`` Text without any markup. __ http://epydoc.sourceforge.net/manual-epytext.html __ https://docutils.sourceforge.io/rst.html __ https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings __ https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard You can select a different format using the ``--docformat`` option or the ``__docformat__`` module variable. What's New? ~~~~~~~~~~~ pydoctor 24.11.2 ^^^^^^^^^^^^^^^^ * Replace deprecated usage of ``datetime.datetime.utcfromtimestamp()`` pydoctor 24.11.1 ^^^^^^^^^^^^^^^^ * Fix a bug that would cause a variable marked as `Final` not being considered as a constant if it was declared under a control-flow block. * Fix a bug in google and numpy "Attributes" section in module docstring: the module attributes now shows as "Variables" instead of "Instance Variables". pydoctor 24.11.0 ^^^^^^^^^^^^^^^^ * Drop Python 3.7 and support Python 3.13. * Implement canonical HTML element (````) to help search engines reduce outdated content. Enable this feature by passing the base URL of the API documentation with option ``--html-base-url``. * Improve collection of objects: - Document objects declared in the ``else`` block of 'if' statements (previously they were ignored). - Document objects declared in ``finalbody`` and ``else`` block of 'try' statements (previously they were ignored). - Objects declared in the ``else`` block of if statements and in the ``handlers`` of 'try' statements are ignored if a concurrent object is declared before (`more infos on branch priorities `_). * Trigger a warning when several docstrings are detected for the same object. * Improve typing of docutils related code. * Run unit tests on all supported combinations of Python versions and platforms, including PyPy for Windows. Previously, tests where ran on all supported Python version for Linux, but not for MacOS and Windows. * Replace the deprecated dependency appdirs with platformdirs. * Fix WinError caused by the failure of the symlink creation process. Pydoctor should now run on windows without the need to be administrator. * Adjust the sphinx extension to support Sphinx 8.1. The entries dynamically added to the intersphinx config from the ``pydoctor_url_path`` config option now includes a project name which defaults to 'main' (instead of putting None), use mapping instead of a list to define your own project name. * Improve the themes so the adds injected by ReadTheDocs are rendered with the correct width and do not overlap too much with the main content. * Fix an issue in the readthedocs theme that prevented to use the search bar from the summary pages (like the class hierarchy). * The generated documentation now includes a help page under the path ``/apidocs-help.html``. This page is accessible by clicking on the information icon in the navbar (``ℹ``). * Improve the javascript searching code to better understand terms that contains a dot (``.``). pydoctor 24.3.3 ^^^^^^^^^^^^^^^ * Fix release pipeline. pydoctor 24.3.0 ^^^^^^^^^^^^^^^ This is the last major release to support Python 3.7. * Drop support for Python 3.6. * Add support for Python 3.12. * Astor is no longer a requirement starting at Python 3.9. * `ExtRegistrar.register_post_processor()` now supports a `priority` argument that is an int. Highest priority callables will be called first during post-processing. * Fix too noisy ``--verbose`` mode (suppres some ambiguous annotations warnings). * Fix type processing inside restructuredtext consolidated fields. * Add options ``--cls-member-order`` and ``--mod-member-order`` to customize the presentation order of class members and module/package members, the supported values are "alphabetical" or "source". The default behavior is to sort all members alphabetically. * Make sure the line number coming from ast analysis has precedence over the line of a ``ivar`` field. * Ensure that all docutils generated css classes have the ``rst-`` prefix, the base theme have been updated accordingly. * Fix compatibility issue with docutils 0.21.x * Transform annotations to use python 3.10 style: ``typing.Union[x, y]`` -> ``x | y``; ``typing.Optional[x]`` -> ``x | None``; ``typing.List[x]`` -> ``list[x]``. * Do not output useless parenthesis when colourizing subscripts. pydoctor 23.9.1 ^^^^^^^^^^^^^^^ * Fix regression in link not found warnings' line numbers. pydoctor 23.9.0 ^^^^^^^^^^^^^^^ This is the last major release to support Python 3.6. * Do not show `**kwargs` when keywords are specifically documented with the `keyword` field and no specific documentation is given for the `**kwargs` entry. * Fix annotation resolution edge cases: names are resolved in the context of the module scope when possible, when impossible, the theoretical runtime scopes are used. A warning can be reported when an annotation name is ambiguous (can be resolved to different names depending on the scope context) with option ``-v``. * Ensure that explicit annotation are honored when there are multiple declarations of the same name. * Use stricter verification before marking an attribute as constant: - instance variables are never marked as constant - a variable that has several definitions will not be marked as constant - a variable declaration under any kind of control flow block will not be marked as constant * Do not trigger warnings when pydoctor cannot make sense of a potential constant attribute (pydoctor is not a static checker). * Fix presentation of type aliases in string form. * Improve the AST colorizer to output less parenthesis when it's not required. * Fix colorization of dictionary unpacking. * Improve the class hierarchy such that it links top level names with intersphinx when possible. * Add highlighting when clicking on "View In Hierarchy" link from class page. * Recognize variadic generics type variables (PEP 646). * Fix support for introspection of cython3 generated modules. * Instance variables are marked as such across subclasses. pydoctor 23.4.1 ^^^^^^^^^^^^^^^ * Pin ``urllib3`` version to keep compatibility with ``cachecontrol`` and python3.6. pydoctor 23.4.0 ^^^^^^^^^^^^^^^ * Add support for Python 3.11 * Add support for the ``@overload`` decorator. * Show type annotations in function's signatures. * If none of a function's parameters have documentation, do not render the parameter table. * Themes have been adjusted to render annotations more concisely. * Fix a rare crash in the type inference. Invalid python code like a set of lists would raise a uncaught TypeError in the evaluation. * Support when source path lies outside base directory (``--project-base-dir``). Since pydoctor support generating docs for multiple packages, it is not certain that all of the source is even viewable below a single URL. We now allow to add arbitrary paths to the system, but only the objects inside a module wich path is relative to the base directory can have a source control link generated. * Cache the default docutils settings on docutils>=0.19 to improve performance. * Improve the search bar user experience by automatically appending wildcard to each query terms when no terms already contain a wildcard. * Link recognized constructors in class page. * An invalid epytext docstring will be rederered as plaintext, just like invalid restructuredtext docstrings (finally). pydoctor 22.9.1 ^^^^^^^^^^^^^^^ * ``pydoctor --help`` works again. pydoctor 22.9.0 ^^^^^^^^^^^^^^^ * Add a special kind for exceptions (before, they were treated just like any other class). * The ZopeInterface features now renders again. A regression was introduced in pydoctor 22.7.0. * Python syntax errors are now logged as violations. * Fixed rare crash in the rendering of parsed elements (i.e. docstrings and ASTs). This is because XHTML entities like non-breaking spaces are not supported by Twisted's ``XMLString`` at the moment. * Show the value of type aliases and type variables. * The ``--prepend-package`` now work as documented. A regression was introduced in pydoctor 22.7.0 and it was not nesting new packages under the "fake" package. * `self` parameter is now removed only when the target is a method. In the previous version, it was always removed in any context. * `cls` parameter is now removed only when the target is a class method. In the previous version, it was always removed in any context. * Add anchors aside attributes and functions to ease the process of sharing links to these API docs. * Fix a bug in the return clause of google-style docstrings where the return type would be treated as the description when there is no explicit description. * Trigger warnings for unknown config options. * Fix minor UX issues in the search bar. * Fix deprecation in Docutils 0.19 frontend pydoctor 22.7.0 ^^^^^^^^^^^^^^^ * Add support for generics in class hierarchies. * Fix long standing bugs in ``Class`` method resolution order. * Improve the extensibility of pydoctor (`more infos on extensions `_) * Fix line numbers in reStructuredText xref warnings. * Add support for `twisted.python.deprecated` (this was originally part of Twisted's customizations). * Add support for re-exporting it names imported from a wildcard import. pydoctor 22.5.1 ^^^^^^^^^^^^^^^ * ``docutils>=0.17`` is now the minimum supported version. This was done to fix crashing with ``AttributeError`` when processing type fields. pydoctor 22.5.0 ^^^^^^^^^^^^^^^ * Add Read The Docs theme, enable it with option ``--theme=readthedocs``. * Add a sidebar. Configure it with options ``--sidebar-expand-depth`` and ``--sidebar-toc-depth``. Disable with ``--no-sidebar``. * Highlight the active function or attribute. * Packages and modules are now listed together. * Docstring summaries are now generated from docutils nodes: - fixes a bug in restructuredtext references in summary. - still display summary when the first paragraph is long instead of "No summary". * The module index now uses a more compact presentation for modules with more than 50 submodules and no subsubmodules. * Fix source links for code hosted on Bitbucket or SourceForge. * The ``--html-viewsource-template`` option was added to allow for custom URL scheme when linking to the source code pages and lines. pydoctor 22.4.0 ^^^^^^^^^^^^^^^ * Add option ``--privacy`` to set the privacy of specific objects when default rules doesn't fit the use case. * Option ``--docformat=plaintext`` overrides any assignments to ``__docformat__`` module variable in order to focus on potential python code parsing errors. * Switch to ``configargparse`` to handle argument and configuration file parsing (`more infos `_). * Improved performances with caching of docstring summaries. pydoctor 22.3.0 ^^^^^^^^^^^^^^^ * Add client side search system based on lunr.js. * Fix broken links in docstring summaries. * Add cache for the xref linker, reduces the number of identical warnings. * Fix crash when reparenting objects with duplicate names. pydoctor 22.2.2 ^^^^^^^^^^^^^^^ * Fix resolving names re-exported in ``__all__`` variable. pydoctor 22.2.1 ^^^^^^^^^^^^^^^ * Fix crash of pydoctor when processing a reparented module. pydoctor 22.2.0 ^^^^^^^^^^^^^^^ * Improve the name resolving algo such that it checks in super classes for inherited attributes. * C-modules wins over regular modules when there is a name clash. * Packages wins over modules when there is a name clash. * Fixed that modules were processed in a random order leading to several hard to reproduce bugs. * Intersphinx links have now dedicated markup. With the default theme, this allows to have the external intershinx links blue while the internal links are red. * Smarter line wrapping in summary and parameters tables. * Any code inside of ``if __name__ == '__main__'`` is now excluded from the documentation. * Fix variables named like the current module not being documented. * The Module Index now only shows module names instead of their full name. You can hover over a module link to see the full name. * If there is only a single root module, `index.html` now documents that module (previously it only linked the module page). * Fix introspection of functions comming from C-extensions. * Fix that the colorizer might make Twisted's flatten function crash with surrogates unicode strings. pydoctor 21.12.1 ^^^^^^^^^^^^^^^^ * Include module ``sre_parse36.py`` within ``pydoctor.epydoc`` to avoid an extra PyPi dependency. pydoctor 21.12.0 ^^^^^^^^^^^^^^^^ * Add support for reStructuredText directives ``.. deprecated::``, ``.. versionchanged::`` and ``.. versionadded::``. * Add syntax highlight for constant values, decorators and parameter defaults. * Embedded documentation links inside the value of constants, decorators and parameter defaults. * Provide option ``--pyval-repr-maxlines`` and ``--pyval-repr-linelen`` to control the size of a constant value representation. * Provide option ``--process-types`` to automatically link types in docstring fields (`more info `_). * Forked Napoleon Sphinx extension to provide google-style and numpy-style docstring parsing. * Introduced fields ``warns``, ``yields`` and ``yieldtype``. * Following google style guide, ``*args`` and ``**kwargs`` are now rendered with asterisks in the parameters table. * Mark variables as constants when their names is all caps or if using `Final` annotation. pydoctor 21.9.2 ^^^^^^^^^^^^^^^ * Fix ``AttributeError`` raised when parsing reStructuredText consolidated fields, caused by a change in ``docutils`` 0.18. * Fix ``DeprecationWarning``, use newer APIs of ``importlib_resources`` module. pydoctor 21.9.1 ^^^^^^^^^^^^^^^ * Fix deprecation warning and officially support Python 3.10. * Fix the literals style (use same style as before). pydoctor 21.9.0 ^^^^^^^^^^^^^^^ * Add support for multiple themes, selectable with ``--theme`` option. * Support selecting a different docstring format for a module using the ``__docformat__`` variable. * HTML templates are now customizable with ``--template-dir`` option. * Change the fields layout to display the arguments type right after their name. Same goes for variables. pydoctor 21.2.2 ^^^^^^^^^^^^^^^ * Fix positioning of anchors, such that following a link to a member of a module or class will scroll its documentation to a visible spot at the top of the page. pydoctor 21.2.1 ^^^^^^^^^^^^^^^ * Fix presentation of the project name and URL in the navigation bars, such that it works as expected on all generated HTML pages. pydoctor 21.2.0 ^^^^^^^^^^^^^^^ * Removed the ``--html-write-function-pages`` option. As a replacement, you can use the generated Intersphinx inventory (``objects.inv``) for deep-linking your documentation. * Fixed project version in the generated Intersphinx inventory. This used to be hardcoded to 2.0 (we mistook it for a format version), now it is unversioned by default and a version can be specified using the new ``--project-version`` option. * Fixed multiple bugs in Python name resolution, which could lead to for example missing "implemented by" links. * Fixed bug where class docstring fields such as ``cvar`` and ``ivar`` are ignored when they override inherited attribute docstrings. * Property decorators containing one or more dots (such as ``@abc.abstractproperty``) are now recognized by the custom properties support. * Improvements to `attrs`__ support: - Attributes are now marked as instance variables. - Type comments are given precedence over types inferred from ``attr.ib``. - Support positional arguments in ``attr.ib`` definitions. Please use keyword arguments instead though, both for clarity and to be compatible with future ``attrs`` releases. * Improvements in the treatment of the ``__all__`` module variable: - Assigning an empty sequence is interpreted as exporting nothing instead of being ignored. - Better error reporting when the value assigned is either invalid or pydoctor cannot make sense of it. * Added ``except`` field as a synonym of ``raises``, to be compatible with epydoc and to fix handling of the ``:Exceptions:`` consolidated field in reStructuredText. * Exception types and external base classes are hyperlinked to their class documentation. * Formatting of ``def func():`` and ``class Class:`` lines was made consistent with code blocks. * Changes to the "Show/hide Private API" button: - The button was moved to the right hand side of the navigation bar, to avoid overlapping the content on narrow displays. - The show/hide state is now synced with a query argument in the location bar. This way, if you bookmark the page or send a link to someone else, the show/hide state will be preserved. - A deep link to a private API item will now automatically enable "show private API" mode. * Improvements to the ``build_apidocs`` Sphinx extension: - API docs are now built before Sphinx docs, such that the rest of the documentation can link to it via Intersphinx. - New configuration variable ``pydoctor_url_path`` that will automatically update the ``intersphinx_mapping`` variable so that it uses the latest API inventory. - The extension can be configured to build API docs for more than one package. * ``pydoctor.__version__`` is now a plain ``str`` instead of an ``incremental.Version`` object. __ https://www.attrs.org/ pydoctor 20.12.1 ^^^^^^^^^^^^^^^^ * Reject source directories outside the project base directory (if given), instead of crashing. * Fixed bug where source directories containing symbolic links could appear to be outside of the project base directory, leading to a crash. * Bring back source link on package pages. pydoctor 20.12.0 ^^^^^^^^^^^^^^^^ * Python 3.6 or higher is required. * There is now a user manual that can be built with Sphinx or read online on `Read the Docs`__. This is a work in progress and the online version will be updated between releases. * Added support for Python language features: - Type annotations of function parameters and return value are used when the docstring does not document a type. - Functions decorated with ``@property`` or any other decorator with a name ending in "property" are now formatted similar to variables. - Coroutine functions (``async def``) are included in the output. - Keyword-only and position-only parameters are included in the output. * Output improvements: - Type names in annotations are hyperlinked to the corresponding documentation. - Styling changes to make the generated documentation easier to read and navigate. - Private API is now hidden by default on the Module Index, Class Hierarchy and Index of Names pages. - The pydoctor version is included in the "generated by" line in the footer. * All parents of the HTML output directory are now created by pydoctor; previously it would create only the deepest directory. * The ``--add-package`` and ``--add-module`` options have been deprecated; pass the source paths as positional arguments instead. * New option ``-W``/``--warnings-as-errors`` to fail your build on documentation errors. * Linking to the standard library documentation is more accurate now, but does require the use of an Intersphinx inventory (``--intersphinx=https://docs.python.org/3/objects.inv``). * Caching of Intersphinx inventories is now enabled by default. * Added a `Sphinx extension`__ for embedding pydoctor's output in a project's Sphinx documentation. * Added an extra named ``rst`` for the dependencies needed to process reStructuredText (``pip install -U pydoctor[rst]``). * Improved error reporting: - More accurate source locations (file + line number) in error messages. - Warnings were added for common mistakes when documenting parameters. - Clearer error message when a link target is not found. * Increased reliability: - Fixed crash when analyzing ``from package import *``. - Fixed crash when the line number for a docstring error is unknown. - Better unit test coverage, more system tests, started adding type annotations to the code. - Unit tests are also run on Windows. __ https://pydoctor.readthedocs.io/ __ https://pydoctor.readthedocs.io/en/latest/usage.html#building-pydoctor-together-with-sphinx-html-build pydoctor 20.7.2 ^^^^^^^^^^^^^^^ * Fix handling of external links in reStructuredText under Python 3. * Fix reporting of errors in reStructuredText under Python 3. * Restore syntax highlighting of Python code blocks. pydoctor 20.7.1 ^^^^^^^^^^^^^^^ * Fix cross-reference links to builtin types in standard library. * Fix and improve error message printed for unknown fields. pydoctor 20.7.0 ^^^^^^^^^^^^^^^ * Python 3 support. * Type annotations on attributes are supported when running on Python 3. * Type comments on attributes are supported when running on Python 3.8+. * Type annotations on function definitions are not supported yet. * Undocumented attributes are now included in the output. * Attribute docstrings: a module, class or instance variable can be documented by a following it up with a docstring. * Improved error reporting: more errors are reported, error messages include file name and line number. * Dropped support for implicit relative imports. * Explicit relative imports (using ``from``) no longer cause warnings. * Dropped support for index terms in epytext (``X{}``). This was never supported in any meaningful capacity, but now the tag is gone. This was the last major release to support Python 2.7 and 3.5. .. description-end pydoctor-24.11.2/SECURITY.md000066400000000000000000000006001473665144200153250ustar00rootroot00000000000000# Security Policy twisted/pydoctor project uses the same security policy as [twisted/twisted](https://github.com/twisted/twisted). For more details please check the [Twisted security process](https://github.com/twisted/twisted?tab=security-ov-file#readme) You can send a security report via [GitHub Security Advisories](https://github.com/twisted/pydoctor/security/advisories/new) pydoctor-24.11.2/docs/000077500000000000000000000000001473665144200144705ustar00rootroot00000000000000pydoctor-24.11.2/docs/Makefile000066400000000000000000000011761473665144200161350ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) pydoctor-24.11.2/docs/epytext_demo/000077500000000000000000000000001473665144200171765ustar00rootroot00000000000000pydoctor-24.11.2/docs/epytext_demo/__init__.py000066400000000000000000000121111473665144200213030ustar00rootroot00000000000000""" General epytext formating markups are documented here. Epydoc code related formating are demonstrated in the L{demo_epytext_module}. Read the U{the epytext manual } for more documentation. Scope and Purpose ================= Sample package for describing and demonstrating C{pydoctor} HTML API rendering for B{Epytext} based documentation. Many examples are copied from U{the epytext manual }. Try to keep the example as condensed as possible. - Make it easy to review HTML rendering. - Cover all epytext markup. Like the usage of list with various indentation types. - Have it build as part of our continuous integration tests. To ensure we don't introduce regressions. Lists ===== Epytext supports both ordered and unordered lists. A list consists of one or more consecutive list items with the same indentation. Each list item is marked by a bullet. The bullet for unordered list items is a single dash character (C{-}). Bullets for ordered list items consist of a series of numbers followed by periods, such as C{12.} or C{1.2.8.}. Ordered list example: 1. This is an ordered list item. 2. This is a another ordered list item. 3. This is a third list item. Note that the paragraph may be indented more than the bullet. Example of unordered list: - This is an ordered list item. - This is a another ordered list item. Example of complex list: 1. This is a list item. - This is a sublist. - The sublist contains two items. - The second item of the sublist has its own sublist. 2. This list item contains two paragraphs and a doctest block. >>> print 'This is a doctest block' This is a doctest block This is the second paragraph. Literal Blocks ============== Literal blocks are used to represent "preformatted" text. Everything within a literal block should be displayed exactly as it appears in plaintext. - Spaces and newlines are preserved. - Text is shown in a monospaced font. - Inline markup is not detected. Literal blocks are introduced by paragraphs ending in the special sequence C{::}. Literal blocks end at the first line whose indentation is equal to or less than that of the paragraph that introduces them. The following is a literal block:: Literal / / X{Block} Doctest Blocks ============== - contain examples consisting of Python expressions and their output - can be used by the doctest module to test the documented object - begin with the special sequence C{>>>} - are delimited from surrounding blocks by blank lines - may not contain blank lines The following is a doctest block: >>> print (1+3, ... 3+5) (4, 8) >>> 'a-b-c-d-e'.split('-') ['a', 'b', 'c', 'd', 'e'] This is a paragraph following the doctest block. Basic Inline Markup =================== I{B{Inline markup} may be nested; and it may span} multiple lines. Epytext defines four types of inline markup that specify how text should be displayed: - I{Italicized text} - B{Bold-faced text} - C{Source code} - M{Math} Without the capital letter, matching braces are not interpreted as markup: C{my_dict={1:2, 3:4}}. URLs ==== The inline markup construct U{text} is used to create links to external URLs and URIs. 'text' is the text that should be displayed for the link, and 'url' is the target of the link. If you wish to use the URL as the text for the link, you can simply write "U{url}". Whitespace within URL targets is ignored. In particular, URL targets may be split over multiple lines. The following example illustrates how URLs can be used: - U{www.python.org} - U{http://www.python.org} - U{The epydoc homepage} - U{The B{Python} homepage } - U{Edward Loper} Symbols ======= Symbols are used to insert special characters in your documentation. A symbol has the form SE{lb}codeE{rb}, where code is a symbol code that specifies what character should be produced. Symbols can be used in equations: S{sum}S{alpha}/x S{<=} S{beta} S{<-} and S{larr} both give left arrows. Some other arrows are S{rarr}, S{uarr}, and S{darr}. Escaping ======== Escaping is used to write text that would otherwise be interpreted as epytext markup. Escaped text has the form EE{lb}codeE{rb}, where code is an escape code that specifies what character should be produced. If the escape code is a single character (other than '{' or '}'), then that character is produced. For example, to begin a paragraph with a dash (which would normally signal a list item), write 'E{-}'. In addition, two special escape codes are defined: 'E{lb}' produces a left curly brace ('{'); and 'E{rb}' produces a right curly brace ('}'). This paragraph ends with two colons, but does not introduce a literal blockE{:}E{:} E{-} This is not a list item. Escapes can be used to write unmatched curly braces: E{rb}E{lb} """ pydoctor-24.11.2/docs/epytext_demo/constants.py000066400000000000000000000054501473665144200215700ustar00rootroot00000000000000""" Module demonstrating the constant representations. """ import re from .demo_epytext_module import demo_fields_docstring_arguments, _PrivateClass A_DICT = {'1':33, '2':[1,2,3,{7:'oo'*20}], '3': demo_fields_docstring_arguments, '4': _PrivateClass.method_inside_private, '5': re.compile('^<(?P.*) at (?P0x[0-9a-f]+)>$') } """ The value of a constant is rendered with syntax highlighting. Internal and external links are generated to references of classes/functions used inside the constant """ A_STIRNG = "L'humour, c'est l'arme blanche des hommes désarmés; c'est une déclaration de supériorité de l'humain sur ce qui lui arrive 😀. Romain GARY." """ Strings are always rendered in single quotes, and appropriate escaping is added when required. Continuing lines are wrapped with symbol: "↵" after reaching the maximum number of caracters per line (defaults to 80), change this value with option --pyval-repr-linelen. Unicode is supported. """ A_MULTILINE_STRING = "Dieu se rit des hommes qui déplorent les effets dont ils cherrissent les causes.\n\nJacques-Bénigne BOSSUET." """ Multiline strings are always rendered in triple quotes. """ A_LIST = [1,2,[5,6,[(11,22,33),9],10],11]+[99,98,97,96,95] """ Nested objects are colorized. """ FUNCTION_CALL = list(range(100))+[99,98,97,96,95] """ Function calls are colorized. """ OPERATORS = 1 << (10 | 1) << 1 """Operators are colorized and parenthesis are added when syntactically required.""" UNSUPPORTED = lambda x: demo_fields_docstring_arguments(x, 0) // 2 """ A lot of objects can be colorized: function calls, strings, lists, dicts, sets, frozensets, operators, annotations, names, compiled regular expressions, etc. But when dealing with usupported constructs, like lamba functions, it will display the value without colorization. """ RE_STR = re.compile("(foo (?Pbar) | (?Pbaz))") """ Regular expressions have special colorizing that add syntax highlight to the regex components. """ RE_WITH_UNICODE = re.compile("abc 😀") """ Unicode is supported in regular expressions. """ RE_MULTILINE = re.compile(r''' # Source consists of a PS1 line followed by zero or more PS2 lines. (?P (?:^(?P [ ]*) >>> .*) # PS1 line (?:\n [ ]* \.\.\. .*)* # PS2 lines \n?) # Want consists of any non-blank lines that do not start with PS1. (?P (?:(?![ ]*$) # Not a blank line (?![ ]*>>>) # Not a line starting with PS1 .*$\n? # But any other line )*) ''', re.MULTILINE | re.VERBOSE) """ Multiline regex patterns are rendered as string. "..." is added when reaching the maximum number of lines for constant representation (defaults to 7), change this value with option --pyval-repr-maxlines. """ pydoctor-24.11.2/docs/epytext_demo/demo_epytext_module.py000066400000000000000000000124641473665144200236320ustar00rootroot00000000000000""" This is a module demonstrating epydoc code documentation features. Most part of this documentation is using Python type hinting. """ from abc import ABC import math from typing import overload, AnyStr, Dict, Generator, List, Union, Callable, Tuple, TYPE_CHECKING from somelib import SomeInterface import zope.interface import zope.schema from typing import Sequence, Optional from incremental import Version from twisted.python.deprecate import deprecated, deprecatedProperty if TYPE_CHECKING: from typing_extensions import Final Parser = Callable[[str], Tuple[int, bytes, bytes]] """ Type aliases are documented as such and their value is shown just like constants. """ LANG = 'Fr' """ This is a constant. See L{constants} for more examples. """ lang: 'Final[Sequence[str]]' = ['Fr', 'En'] """ This is also a constant, but annotated with typing.Final. """ @deprecated(Version("demo", "NEXT", 0, 0), replacement=math.prod) def demo_product_deprecated(x, y) -> float: # type: ignore return float(x * y) def demo_fields_docstring_arguments(m, b): # type: ignore """ Fields are used to describe specific properties of a documented object. This function can be used in conjunction with L{demo_typing_arguments} to find an arbitrary function's zeros. @type m: number @param m: The slope of the line. @type b: number @param b: The y intercept of the line. @rtype: number @return: the x intercept of the line M{y=m*x+b}. """ return -b/m def demo_typing_arguments(name: str, size: Optional[bytes] = None) -> bool: """ Type documentation can be extracted from standard Python type hints. @param name: The human readable name for something. @param size: How big the name should be. Leave none if you don't care. @return: Always C{True}. """ return True def demo_long_function_and_parameter_names__this_indeed_very_long( this_is_a_very_long_parameter_name_aahh: str, what__another_super_super_long_name__ho_no: Generator[Union[List[AnyStr], Dict[str, AnyStr]], None, None]) -> bool: """ Long names and annotations should display on several lines when they don't fit in a single line. """ return True def demo_cross_reference() -> None: """ The inline markup construct C{LE{lb}textE{rb}} is used to create links to the documentation for other Python objects. 'text' is the text that should be displayed for the link, and 'object' is the name of the Python object that should be linked to. If you wish to use the name of the Python object as the text for the link, you can simply write C{LE{lb}objectE{rb}}. - L{demo_typing_arguments} - L{Custom name } """ @overload def demo_overload(s: str) -> str: ... @overload def demo_overload(s: bytes) -> bytes: ... def demo_overload(s: Union[str, bytes]) -> Union[str, bytes]: """ Overload signatures appear without the main signature and with C{@overload} decorator. @param s: Some string or bytes param. @return: Some string or bytes result. """ raise NotImplementedError def demo_undocumented(s: str) -> str: raise NotImplementedError class _PrivateClass: """ This is the docstring of a private class. """ def method_inside_private(self) -> bool: """ A public method inside a private class. @return: Something. """ return True def _private_inside_private(self) -> bool: """ A private method inside a private class. @return: Something. """ return True class DemoClass(ABC, SomeInterface, _PrivateClass): """ This is the docstring of this class. """ def __init__(self, one: str, two: bytes) -> None: """ Documentation for class initialization. @param one: Docs for first argument. @param two: Docs for second argument. """ @property def read_only(self) -> int: """ This is a read-only property. """ return 1 @deprecatedProperty(Version("demo", 1, 3, 0), replacement=read_only) def read_only_deprecated(self) -> int: """ This is a deprecated read-only property. """ return 1 @property def read_and_write(self) -> int: """ This is a read-write property. """ return 1 @read_and_write.setter def read_and_write(self, value: int) -> None: """ This is a docstring for setter. """ @property def read_and_write_delete(self) -> int: """ This is a read-write-delete property. """ return 1 @read_and_write_delete.setter def read_and_write_delete(self, value: int) -> None: """ This is a docstring for setter. """ @read_and_write_delete.deleter def read_and_write_delete(self) -> None: """ This is a docstring for deleter. """ class IContact(zope.interface.Interface): """ Example of an interface with schemas. Provides access to basic contact information. """ first = zope.schema.TextLine(description="First name") email = zope.schema.TextLine(description="Electronic mail address") address = zope.schema.Text(description="Postal address") def send_email(text: str) -> None: pass pydoctor-24.11.2/docs/google_demo/000077500000000000000000000000001473665144200167505ustar00rootroot00000000000000pydoctor-24.11.2/docs/google_demo/__init__.py000066400000000000000000000221611473665144200210630ustar00rootroot00000000000000""" Pydoctor pre-process Google-style docstrings to convert them to reStructuredText. **All standard reStructuredText formatting will still works as expected**. Please see `restructuredtext_demo <../restructuredtext/restructuredtext_demo.html>`_ for general reStructuredText formmating exemple. Example Google style docstrings. This module demonstrates documentation as specified by the `Google Python Style Guide`_. Docstrings may extend over multiple lines. Sections are created with a section header and a colon followed by a block of indented text. Example: Examples can be given using either the ``Example`` or ``Examples`` sections. Sections support any reStructuredText formatting, including literal blocks:: $ python example_google.py Section breaks are created by resuming unindented text. Section breaks are also implicitly created anytime a new section starts. Attributes: module_level_variable1 (int): Module level variables may be documented in either the ``Attributes`` section of the module docstring, or in an inline docstring immediately following the variable. Either form is acceptable, but the two should not be mixed. Choose one convention to document module level variables and be consistent with it. .. _Google Python Style Guide: https://google.github.io/styleguide/pyguide.html """ from typing import List, Union # NOQA module_level_variable1 = 12345 module_level_variable2 = 98765 """int: Module level variable documented inline. The docstring may span multiple lines. The type may optionally be specified on the first line, separated by a colon. """ def function_with_types_in_docstring(param1, param2): """Example function with types documented in the docstring. `PEP 484`_ type annotations are supported. If attribute, parameter, and return types are annotated according to `PEP 484`_, they do not need to be included in the docstring: Args: param1 (int): The first parameter. param2 (str): The second parameter. Returns: bool: The return value. True for success, False otherwise. .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ """ def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: """Example function with PEP 484 type annotations. Args: param1: The first parameter. param2: The second parameter. Returns: The return value. True for success, False otherwise. """ def module_level_function(param1, param2=None, *args, **kwargs): """This is an example of a module level function. Function parameters should be documented in the ``Args`` section. The name of each parameter is required. The type and description of each parameter is optional, but should be included if not obvious. If ``*args`` or ``**kwargs`` are accepted, they should be listed as ``*args`` and ``**kwargs``. The format for a parameter is:: name (type): description The description may span multiple lines. Following lines should be indented. The "(type)" is optional. Multiple paragraphs are supported in parameter descriptions. Args: param1 (int): The first parameter. param2 (`str`, optional): The second parameter. Defaults to None. Second line of description should be indented. *args: Variable length argument list. **kwargs: Arbitrary keyword arguments. Returns: bool: True if successful, False otherwise. The return type is optional and may be specified at the beginning of the ``Returns`` section followed by a colon. The ``Returns`` section may span multiple lines and paragraphs. Following lines should be indented to match the first line. The ``Returns`` section supports any reStructuredText formatting, including literal blocks:: { 'param1': param1, 'param2': param2 } Raises: AttributeError: The ``Raises`` section is a list of all exceptions that are relevant to the interface. ValueError: If ``param2`` is equal to ``param1``. """ if param1 == param2: raise ValueError('param1 may not be equal to param2') return True def example_generator(n): """Generators have a ``Yields`` section instead of a ``Returns`` section. Args: n (int): The upper limit of the range to generate, from 0 to ``n`` - 1. Yields: int: The next number in the range of 0 to ``n`` - 1. Examples: Examples should be written in doctest format, and should illustrate how to use the function. >>> print([i for i in example_generator(4)]) [0, 1, 2, 3] """ for i in range(n): yield i class ExampleError(Exception): """Exceptions are documented in the same way as classes. The __init__ method should be documented as a docstring on the __init__ method. Note: Do not include the ``self`` parameter in the ``Args`` section. Args: msg (str): Human readable string describing the exception. code (int, optional): Error code. Attributes: msg (str): Human readable string describing the exception. code (int): Exception error code. """ def __init__(self, msg, code): self.msg = msg self.code = code class ExampleClass: """The summary line for a class docstring should fit on one line. If the class has public attributes, they may be documented here in an ``Attributes`` section and follow the same formatting as a function's ``Args`` section. Alternatively, attributes may be documented inline with the attribute's declaration (see __init__ method below). Attributes: attr1 (str): Description of `attr1`. attr2 (List[Union[str, bytes, int]], optional): Description of `attr2`. Methods: example_method: Quick example __special__: Dunder methods are considered public __special_without_docstring__: *Undocumented* text will appear. Note: The "Methods" section is supported only as a "best effort" basis. See: Google style "See Also" section is just like any admonition. """ def __init__(self, param1, param2, param3): """Example of docstring on the __init__ method. The __init__ method should be documented as a docstring on the __init__ method. Note: Do not include the ``self`` parameter in the ``Args`` section. Args: param1 (str): Description of ``param1``. param2 (`int`, optional): Description of ``param2``. Multiple lines are supported. param3 (list(str)): Description of ``param3``. """ self.attr1 = param1 self.attr2 = param2 self.attr3 = param3 #: Doc comment *inline* with attribute #: list(str): Doc comment *before* attribute, with type specified self.attr4 = ['attr4'] self.attr5 = None """str: Docstring *after* attribute, with type specified.""" @property def readonly_property(self): """str: Properties should be documented in their getter method.""" return 'readonly_property' @property def readwrite_property(self): """list(str): Properties with both a getter and setter should only be documented in their getter method. If the setter method contains notable behavior, it should be mentioned here. """ return ['readwrite_property'] @readwrite_property.setter def readwrite_property(self, value): value def example_method(self, param1, param2): """Class methods are similar to regular functions. Note: Do not include the ``self`` parameter in the ``Args`` section. Args: param1: The first parameter. param2: The second parameter. Returns: tuple(str, str, int, tuple(str, str)): A complicated result. """ return tuple('string', 'foo', -1, tuple('cool', 'right')) def __special__(self): """Dunder methods are considered public and will be included in the output. """ pass def __special_without_docstring__(self): pass def _private(self): """ Private members are any methods or attributes that start with an underscore and are *not* special. By default they are hidden, they can be displayed with the "Show Private API" button. """ pass def _private_without_docstring(self): pass class ExamplePEP526Class: """The summary line for a class docstring should fit on one line. If the class has public attributes, they may be documented here in an ``Attributes`` section and follow the same formatting as a function's ``Args`` section. If ``napoleon_attr_annotations`` is True, types can be specified in the class body using ``PEP 526`` annotations. Attributes: attr1: Description of `attr1`. attr2: Description of `attr2`. """ attr1: str attr2: int pydoctor-24.11.2/docs/make.bat000066400000000000000000000014371473665144200161020ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd pydoctor-24.11.2/docs/numpy_demo/000077500000000000000000000000001473665144200166445ustar00rootroot00000000000000pydoctor-24.11.2/docs/numpy_demo/__init__.py000066400000000000000000000224541473665144200207640ustar00rootroot00000000000000""" Pydoctor pre-process Numpy-style docstrings to convert them to reStructuredText. **All standard reStructuredText formatting will still works as expected**. Please see `restructuredtext_demo <../restructuredtext/restructuredtext_demo.html>`_ for general reStructuredText formmating exemple. Example NumPy style docstrings. This module demonstrates documentation as specified by the `NumPy Documentation HOWTO`_. Docstrings may extend over multiple lines. Sections are created with a section header followed by an underline of equal length. Example ------- Examples can be given using either the ``Example`` or ``Examples`` sections. Sections support any reStructuredText formatting, including literal blocks:: $ python example_numpy.py Section breaks are created with two blank lines. Section breaks are also implicitly created anytime a new section starts. Section bodies *may* be indented: Notes ----- This is an example of an indented section. It's like any other section, but the body is indented to help it stand out from surrounding text. If a section is indented, then a section break is created by resuming unindented text. Attributes ---------- module_level_variable1 : int Module level variables may be documented in either the ``Attributes`` section of the module docstring, or in an inline docstring immediately following the variable. Either form is acceptable, but the two should not be mixed. Choose one convention to document module level variables and be consistent with it. .. _NumPy Documentation HOWTO: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard """ from typing import List, Union __docformat__ = 'numpy' module_level_variable1 = 12345 module_level_variable2 = 98765 """int: Module level variable documented inline. The docstring may span multiple lines. The type may optionally be specified on the first line, separated by a colon. """ def function_with_types_in_docstring(param1, param2): """Example function with types documented in the docstring. `PEP 484`_ type annotations are supported. If attribute, parameter, and return types are annotated according to `PEP 484`_, they do not need to be included in the docstring: Parameters ---------- param1 : int The first parameter. param2 : str The second parameter. Returns ------- bool True if successful, False otherwise. .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ """ def function_with_pep484_type_annotations(param1: int, param2: str) -> bool: """Example function with PEP 484 type annotations. The return type must be duplicated in the docstring to comply with the NumPy docstring style. Parameters ---------- param1 The first parameter. param2 The second parameter. Returns ------- bool True if successful, False otherwise. """ def module_level_function(param1, param2=None, *args, **kwargs): """This is an example of a module level function. Function parameters should be documented in the ``Parameters`` section. The name of each parameter is required. The type and description of each parameter is optional, but should be included if not obvious. If ``*args`` or ``**kwargs`` are accepted, they should be listed as ``*args`` and ``**kwargs``. The format for a parameter is:: name : type description The description may span multiple lines. Following lines should be indented to match the first line of the description. The ": type" is optional. Multiple paragraphs are supported in parameter descriptions. Parameters ---------- param1 : int The first parameter. param2 : `str`, optional The second parameter. *args Variable length argument list. **kwargs Arbitrary keyword arguments. Returns ------- bool True if successful, False otherwise. The return type is not optional. The ``Returns`` section may span multiple lines and paragraphs. Following lines should be indented to match the first line of the description. The ``Returns`` section supports any reStructuredText formatting, including literal blocks:: { 'param1': param1, 'param2': param2 } Raises ------ AttributeError The ``Raises`` section is a list of all exceptions that are relevant to the interface. ValueError If ``param2`` is equal to ``param1``. """ if param1 == param2: raise ValueError('param1 may not be equal to param2') return True def example_generator(n): """Generators have a ``Yields`` section instead of a ``Returns`` section. Parameters ---------- n : int The upper limit of the range to generate, from 0 to ``n`` - 1. Yields ------ int The next number in the range of 0 to ``n`` - 1. Examples -------- Examples should be written in doctest format, and should illustrate how to use the function. >>> print([i for i in example_generator(4)]) [0, 1, 2, 3] """ for i in range(n): yield i class ExampleError(Exception): """Exceptions are documented in the same way as classes. The __init__ method should be documented as a docstring on the __init__ method. Note ---- Do not include the ``self`` parameter in the ``Parameters`` section. Parameters ---------- msg : str Human readable string describing the exception. code : `int`, optional Numeric error code. Attributes ---------- msg : str Human readable string describing the exception. code : int Numeric error code. """ def __init__(self, msg, code): self.msg = msg self.code = code class ExampleClass: """The summary line for a class docstring should fit on one line. If the class has public attributes, they may be documented here in an ``Attributes`` section and follow the same formatting as a function's ``Args`` section. Alternatively, attributes may be documented inline with the attribute's declaration (see __init__ method below). Attributes ---------- attr1 : str Description of `attr1`. attr2 : List[Union[str, bytes, int]], optional Description of `attr2`. Methods ------- example_method Quick example __special__ Dunder methods are considered public __special_without_docstring__ *Undocumented* text will appear. Note ---- The "Methods" section is supported only as a "best effort" basis. See Also -------- ``example_google`` The same document but for google-style. """ def __init__(self, param1, param2, param3): """Example of docstring on the __init__ method. The __init__ method should be documented as a docstring on the __init__ method. Note ---- Do not include the ``self`` parameter in the ``Parameters`` section. Parameters ---------- param1 : str Description of ``param1``. param2 : list(str) Description of ``param2``. Multiple lines are supported. param3 : `int`, optional Description of ``param3``. """ self.attr1 = param1 self.attr2 = param2 self.attr3 = param3 #: Doc comment *inline* with attribute #: list(str): Doc comment *before* attribute, with type specified self.attr4 = ["attr4"] self.attr5 = None """str: Docstring *after* attribute, with type specified.""" @property def readonly_property(self): """str: Properties should be documented in their getter method.""" return "readonly_property" @property def readwrite_property(self): """list(str): Properties with both a getter and setter should only be documented in their getter method. If the setter method contains notable behavior, it should be mentioned here. """ return ["readwrite_property"] @readwrite_property.setter def readwrite_property(self, value): value def example_method(self, param1, param2): """Class methods are similar to regular functions. Note ---- Do not include the ``self`` parameter in the ``Parameters`` section. Parameters ---------- param1 The first parameter. param2 The second parameter. Returns ------- tuple(str, str, int, tuple(str, str)) A complicated result. """ return tuple('string', 'foo', -1, tuple('cool', 'right')) def __special__(self): """Dunder methods are considered public and will be included in the output. """ pass def __special_without_docstring__(self): pass def _private(self): """ Private members are any methods or attributes that start with an underscore and are *not* special. By default they are hidden, they can be displayed with the "Show Private API" button. """ pass def _private_without_docstring(self): pass pydoctor-24.11.2/docs/restructuredtext_demo/000077500000000000000000000000001473665144200211345ustar00rootroot00000000000000pydoctor-24.11.2/docs/restructuredtext_demo/__init__.py000066400000000000000000000152701473665144200232520ustar00rootroot00000000000000r""" Few general reStructuredText formating markups are documented here. reStructuredText code related formating are demonstrated in the `demo_restructuredtext_module`. Many examples are copied from `the docutils quickref `_. Scope and Purpose ================= Sample package for describing and demonstrating ``pydoctor`` HTML API rendering for **reStructuredText** based documentation. Try to keep the example as condensed as possible. - Make it easy to review HTML rendering. - Cover all most common reStructuredText markup. Like the usage of list with various indentation types. - Have it build as part of our continuous integration tests. To ensure we don't introduce regressions. .. note:: Even if most of the structural (i.e. not inline) reST markup appears to ressemble Epytext markup, blank lines are often needed where Epytext allowed no blank line after parent element. Indentation is also much more important, lists content and child items must be correctly indented. Titles ====== Level 2 ------- Level 3 ~~~~~~~ Level 4 ^^^^^^^ Level 5 !!!!!!! Lists ===== reStructuredText supports both ordered and unordered lists. A list consists of one or more consecutive list items with the same indentation. Each list item is marked by a bullet. The bullet for unordered list items is a single dash character (``-``). Bullets for ordered list items consist of a series of numbers followed by periods, such as ``12.`` or ``1.2.8.``. Ordered list example: 1. This is an ordered list item. 2. This is a another ordered list item. 3. This is a third list item. Note that the paragraph may be indented more than the bullet. Example of unordered list: - This is an ordered list item. - This is a another ordered list item. Example of complex list: 1. This is a list item. - This is a sublist. - The sublist contains two items. - The second item of the sublist has its own sublist. 2. This list item contains two paragraphs and a doctest block. >>> print 'This is a doctest block' This is a doctest block This is the second paragraph. Literal Blocks ============== Literal blocks are used to represent "preformatted" text. Everything within a literal block should be displayed exactly as it appears in plaintext. - Spaces and newlines are preserved. - Text is shown in a monospaced font. - Inline markup is not detected. Literal blocks are introduced by paragraphs ending in the special sequence ``::``. Literal blocks end at the first line whose indentation is equal to or less than that of the paragraph that introduces them. The following is a literal block:: Literal / / **Block** Doctest Blocks ============== - contain examples consisting of Python expressions and their output - can be used by the doctest module to test the documented object - begin with the special sequence ``>>>`` - are delimited from surrounding blocks by blank lines - may not contain blank lines The following is a doctest block inside a block quote (automatically added because of indentation): >>> print (1+3, ... 3+5) (4, 8) >>> 'a-b-c-d-e'.split('-') ['a', 'b', 'c', 'd', 'e'] This is a paragraph following the doctest block. Python code Blocks ================== Using reStructuredText markup it is possible to specify Python code snippets in a ``.. python::`` directive . If the Python prompt gets in your way when you try to copy and paste and you are not interested in self-testing docstrings, the This will let you obtain a simple block of colorized text: .. python:: def fib(n): '''Print a Fibonacci series.''' a, b = 0, 1 while b < n: print b, a, b = b, a+b Inline Markup ============= reStructuredText defines a lot of inline markup, here's a few of the most common: - *Italicized text* - **Bold-faced text** - ``Source code`` - `subprocess.Popen` (Interpreted text: used for cross-referencing python objects) .. note:: Inline markup cannot be nested. A workaround is to use the ``.. replace::`` directive: I recommend you try |Python|_. .. |Python| replace:: **Python**, *the* best language around URLs ==== The inline markup construct ```text `_`` is used to create links to external URLs and URIs. 'text' is the text that should be displayed for the link, and 'url' is the target of the link. If you wish to use the URL as the text for the link, you can simply write the URL as is. The following example illustrates how URLs can be used: - http://www.python.org (A standalone hyperlink.) - `docutils quickref `_ - External hyperlinks with substitution, like Python_. .. _Python: http://www.python.org/ Admonitions =========== .. note:: This is just a info. .. tip:: This is good for you. .. hint:: This too. .. important:: Important information here. .. warning:: This should be taken seriouly. .. attention:: Beware. .. caution:: This should be taken very seriouly. .. danger:: This function is a security whole! .. error:: This is not right. .. raw:: html .. admonition:: Purple This needs additionnal CSS for the new "rst-admonition-purple" class. Include additional CSS by defining a raw block:: .. raw:: html .. note:: The ``! important`` is required to overrride ``apidocs.css``. Symbols ======= Any symbol can be rendered with the ``.. unicode::`` directive. Copyright |copy| 2021, |MojoInc (TM)| |---| all rights reserved. .. |copy| unicode:: 0xA9 .. copyright sign .. |MojoInc (TM)| unicode:: MojoInc U+2122 .. with trademark sign .. |---| unicode:: U+02014 .. em dash :trim: Comments ======== This is a commented warning:: .. .. warning:: This should not be used! .. .. warning:: This should not be used! Escaping ======== Escaping is used to write text that would otherwise be interpreted as reStructuredText markup. ReStructuredText handles escaping with the backslash character. thisis\ *one*\ word. .. note:: The docstring must be declared as a raw docstring: with the ``r`` prefix to prevent Python to interpret the backslashes. See more on escaping on `docutils documentation page `_ """ pydoctor-24.11.2/docs/restructuredtext_demo/constants.py000066400000000000000000000054611473665144200235300ustar00rootroot00000000000000""" Module demonstrating the constant representations. """ import re from .demo_restructuredtext_module import demo_fields_docstring_arguments, _PrivateClass A_DICT = {'1':33, '2':[1,2,3,{7:'oo'*20}], '3': demo_fields_docstring_arguments, '4': _PrivateClass.method_inside_private, '5': re.compile('^<(?P.*) at (?P0x[0-9a-f]+)>$') } """ The value of a constant is rendered with syntax highlighting. Internal and external links are generated to references of classes/functions used inside the constant """ A_STIRNG = "L'humour, c'est l'arme blanche des hommes désarmés; c'est une déclaration de supériorité de l'humain sur ce qui lui arrive 😀. Romain GARY." """ Strings are always rendered in single quotes, and appropriate escaping is added when required. Continuing lines are wrapped with symbol: "↵" after reaching the maximum number of caracters per line (defaults to 80), change this value with option --pyval-repr-linelen. Unicode is supported. """ A_MULTILINE_STRING = "Dieu se rit des hommes qui déplorent les effets dont ils cherrissent les causes.\n\nJacques-Bénigne BOSSUET." """ Multiline strings are always rendered in triple quotes. """ A_LIST = [1,2,[5,6,[(11,22,33),9],10],11]+[99,98,97,96,95] """ Nested objects are colorized. """ FUNCTION_CALL = list(range(100))+[99,98,97,96,95] """ Function calls are colorized. """ OPERATORS = 1 << (10 | 1) << 1 """Operators are colorized and parenthesis are added when syntactically required.""" UNSUPPORTED = lambda x: demo_fields_docstring_arguments(x, 0) // 2 """ A lot of objects can be colorized: function calls, strings, lists, dicts, sets, frozensets, operators, annotations, names, compiled regular expressions, etc. But when dealing with usupported constructs, like lamba functions, it will display the value without colorization. """ RE_STR = re.compile("(foo (?Pbar) | (?Pbaz))") """ Regular expressions have special colorizing that add syntax highlight to the regex components. """ RE_WITH_UNICODE = re.compile("abc 😀") """ Unicode is supported in regular expressions. """ RE_MULTILINE = re.compile(r''' # Source consists of a PS1 line followed by zero or more PS2 lines. (?P (?:^(?P [ ]*) >>> .*) # PS1 line (?:\n [ ]* \.\.\. .*)* # PS2 lines \n?) # Want consists of any non-blank lines that do not start with PS1. (?P (?:(?![ ]*$) # Not a blank line (?![ ]*>>>) # Not a line starting with PS1 .*$\n? # But any other line )*) ''', re.MULTILINE | re.VERBOSE) """ Multiline regex patterns are rendered as string. "..." is added when reaching the maximum number of lines for constant representation (defaults to 7), change this value with option --pyval-repr-maxlines. """ pydoctor-24.11.2/docs/restructuredtext_demo/demo_restructuredtext_module.py000066400000000000000000000133431473665144200275230ustar00rootroot00000000000000""" This is a module demonstrating reST code documentation features. Most part of this documentation is using Python type hinting. """ from abc import ABC from ast import Tuple import math import zope.interface import zope.schema from typing import overload, Callable, Sequence, Optional, AnyStr, Generator, Union, List, Dict, TYPE_CHECKING from incremental import Version from twisted.python.deprecate import deprecated, deprecatedProperty if TYPE_CHECKING: from typing_extensions import Final Parser = Callable[[str], Tuple[int, bytes, bytes]] """ Type aliases are documented as such and their value is shown just like constants. """ LANG = 'Fr' """ This is a constant. See `constants` for more examples. """ lang: 'Final[Sequence[str]]' = ['Fr', 'En'] """ This is also a constant, but annotated with typing.Final. """ @deprecated(Version("demo", "NEXT", 0, 0), replacement=math.prod) def demo_product_deprecated(x, y) -> float: # type: ignore return float(x * y) def demo_fields_docstring_arguments(m, b = 0): # type: ignore """ Fields are used to describe specific properties of a documented object. This function's ":type:" tags are taking advantage of the --process-types. :type m: numbers.Number :param m: The slope of the line. :type b: numbers.Number, optional :param b: The y intercept of the line. :rtype: numbers.Number :return: the x intercept of the line M{y=m*x+b}. """ return -b/m def demo_consolidated_fields(a:float, b): # type: ignore """ Fields can be condensed into one "consolidated" field. Looks better in plain text. :Parameters: - `a`: The size of the fox (in meters) - `b`: The weight of the fox (in stones) :rtype: str :return: The number of foxes """ return -b/a def demo_typing_arguments(name: str, size: Optional[bytes] = None) -> bool: """ Type documentation can be extracted from standard Python type hints. :param name: The human readable name for something. :param size: How big the name should be. Leave none if you don't care. :return: Always `True`. """ return True def demo_long_function_and_parameter_names__this_indeed_very_long( this_is_a_very_long_parameter_name_aahh: str, what__another_super_super_long_name__ho_no: Generator[Union[List[AnyStr], Dict[str, AnyStr]], None, None]) -> bool: """ Long names and annotations should display on several lines when they don't fit in a single line. """ return True def demo_cross_reference() -> None: r""" The inline markup construct ```object``` is used to create links to the documentation for other Python objects. 'text' is the text that should be displayed for the link, and 'object' is the name of the Python object that should be linked to. If you wish to use the name of the Python object as the text for the link, you can simply write ```object``` -> `object`. - `demo_typing_arguments` """ @overload def demo_overload(s: str) -> str: ... @overload def demo_overload(s: bytes) -> bytes: ... def demo_overload(s: Union[str, bytes]) -> Union[str, bytes]: """ Overload signatures appear without the main signature and with ``@overload`` decorator. :param s: Some string or bytes param. :return: Some string or bytes result. """ raise NotImplementedError def demo_undocumented(s: str) -> str: raise NotImplementedError class _PrivateClass: """ This is the docstring of a private class. """ def method_inside_private(self) -> bool: """ A public method inside a private class. :return: Something. """ return True def _private_inside_private(self) -> List[str]: """ Returns something. :rtype: `list` """ return [] class DemoClass(ABC, _PrivateClass): """ This is the docstring of this class. .. versionchanged:: 1.1 This class now inherits from `_PrivateClass` and demonstrate the ``.. versionchanged::`` directive support. .. versionchanged:: 1.2 Add `read_and_write_delete` property. """ def __init__(self, one: str, two: bytes) -> None: """ Documentation for class initialization. :param one: Docs for first argument. :param two: Docs for second argument. """ @property def read_only(self) -> int: """ This is a read-only property. """ return 1 @deprecatedProperty(Version("demo", 1, 3, 0), replacement=read_only) def read_only_deprecated(self) -> int: """ This is a deprecated read-only property. """ return 1 @property def read_and_write(self) -> int: """ This is a read-write property. """ return 1 @read_and_write.setter def read_and_write(self, value: int) -> None: """ This is a docstring for setter. """ @property def read_and_write_delete(self) -> int: """ This is a read-write-delete property. """ return 1 @read_and_write_delete.setter def read_and_write_delete(self, value: int) -> None: """ This is a docstring for setter. """ @read_and_write_delete.deleter def read_and_write_delete(self) -> None: """ This is a docstring for deleter. """ class IContact(zope.interface.Interface): """ Example of an interface with schemas. Provides access to basic contact information. """ first = zope.schema.TextLine(description="First name") email = zope.schema.TextLine(description="Electronic mail address") address = zope.schema.Text(description="Postal address") def send_email(text: str) -> None: pass pydoctor-24.11.2/docs/sample_template/000077500000000000000000000000001473665144200176445ustar00rootroot00000000000000pydoctor-24.11.2/docs/sample_template/extra.css000066400000000000000000000002501473665144200214760ustar00rootroot00000000000000#banner { background: rgb(150, 150, 150); background-image: linear-gradient(bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, .3)); width: 100%; padding: 10px; } pydoctor-24.11.2/docs/sample_template/header.html000066400000000000000000000013311473665144200217600ustar00rootroot00000000000000 pydoctor-24.11.2/docs/source/000077500000000000000000000000001473665144200157705ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/api/000077500000000000000000000000001473665144200165415ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/api/index.rst000066400000000000000000000003461473665144200204050ustar00rootroot00000000000000API Reference ============= This file will be overwritten by the pydoctor build triggered at the end of the Sphinx build. It's a hack to be able to reference the API index page from inside Sphinx and have it as part of the TOC. pydoctor-24.11.2/docs/source/codedoc.rst000066400000000000000000000373371473665144200201370ustar00rootroot00000000000000How to Document Your Code ========================= Docstrings ---------- In Python, a string at the top of a module, class or function is called a *docstring*. For example:: """This docstring describes the purpose of this module.""" class C: """This docstring describes the purpose of this class.""" def m(self): """This docstring describes the purpose of this method.""" Pydoctor also supports *attribute docstrings*:: CONST = 123 """This docstring describes a module level constant.""" class C: cvar = None """This docstring describes a class variable.""" def __init__(self): self.ivar = [] """This docstring describes an instance variable.""" Attribute docstrings are not part of the Python language itself (`PEP 224 `_ was rejected), so these docstrings are not available at runtime. For long docstrings, start with a short summary, followed by an empty line:: def f(): """This line is used as the summary. More detail about the workings of this function can be added here. They will be displayed in the documentation of the function itself but omitted from the summary table. """ Since docstrings are Python strings, escape sequences such as ``\n`` will be parsed as if the corresponding character---for example a newline---occurred at the position of the escape sequence in the source code. To have the text ``\n`` in a docstring at runtime and in the generated documentation, you either have escape it twice in the source: ``\\n`` or use the ``r`` prefix for a raw string literal. The following example shows the raw string approach:: def iter_lines(stream): r"""Iterate through the lines in the given text stream, with newline characters (\n) removed. """ for line in stream: yield line.rstrip('\n') Further reading: - `Python Tutorial: Documentation Strings `_ - `PEP 257 -- Docstring Conventions `_ - `Python Language Reference: String and Bytes literals `_ Docstring assignments --------------------- Simple assignments to the ``__doc__`` attribute of a class or function are recognized by pydoctor:: class CustomException(Exception): __doc__ = MESSAGE = "Oops!" Non-trivial assignments to ``__doc__`` are not supported. A warning will be logged by pydoctor as a reminder that the assignment will not be part of the generated API documentation:: if LOUD_DOCS: f.__doc__ = f.__doc__.upper() Assignments to ``__doc__`` inside functions are ignored by pydoctor. This can be used to avoid warnings when you want to modify runtime docstrings without affecting the generated API documentation:: def mark_unavailable(func): func.__doc__ = func.__doc__ + '\n\nUnavailable on this system.' if not is_supported('thing'): mark_unavailable(do_the_thing) Augmented assignments like ``+=`` are currently ignored as well, but that is an implementation limitation rather than a design decision, so this might change in the future. Constants --------- The value of a constant is rendered with syntax highlighting. See `module `_ demonstrating the constant values rendering. Following `PEP8 `_, any variable defined with all upper case name will be considered as a constant. Additionally, starting with Python 3.8, one can use the `typing.Final `_ qualifier to declare a constant. For instance, these variables will be recognized as constants:: from typing import Final X = 3.14 y: Final = ['a', 'b'] In Python 3.6 and 3.7, you can use the qualifier present in the `typing_extensions` instead of `typing.Final`:: from typing_extensions import Final z: Final = 'relative/path' .. _codedoc-fields: Fields ------ Pydoctor supports most of the common fields usable in Sphinx, and some others. Epytext fields are written with arobase, like ``@field:`` or ``@field arg:``. ReStructuredText fields are written with colons, like ``:field:`` or ``:field arg:``. Here are the supported fields (written with ReStructuredText format, but same fields are supported with Epytext): - ``:cvar foo:``, document a class variable named ``foo``. Applicable in the context of the docstring of a class. - ``:ivar foo:``, document a instance variable named ``foo``. Applicable in the context of the docstring of a class. - ``:var foo:``, document a variable named ``foo``. Applicable in the context of the docstring of a module or class. If used in the context of a class, behaves just like ``@ivar:``. - ``:note:``, add a note section. - ``:param bar:`` (synonym: ``@arg bar:``), document a function's (or method's) parameter named ``bar``. Applicable in the context of the docstring of a function of method. - ``:keyword:``, document a function's (or method's) keyword parameter (``**kwargs``). - ``:type bar: C{list}``, document the type of an argument/keyword or variable (``bar`` in this example), depending on the context. - ``:return:`` (synonym: ``@returns:``), document the return type of a function (or method). - ``:rtype:`` (synonym: ``@returntype:``), document the type of the return value of a function (or method). - ``:yield:`` (synonym: ``@yields:``), document the values yielded by a generator function (or method). - ``:ytype:`` (synonym: ``@yieldtype:``), document the type of the values yielded by a generator function (or method). - ``:raise ValueError:`` (synonym: ``@raises ValueError:``), document the potential exception a function (or method) can raise. - ``:warn RuntimeWarning:`` (synonym: ``@warns ValueError:``), document the potential warning a function (or method) can trigger. - ``:see:`` (synonym: ``@seealso:``), add a see also section. - ``:since:``, document the date and/or version since a component is present in the API. - ``:author:``, document the author of a component, generally a module. .. note:: Currently, any other fields will be considered "unknown" and will be flagged as such. See `"fields" issues `_ for discussions and improvements. .. note:: Unlike Sphinx, ``vartype`` and ``kwtype`` are not recognized as valid fields, we simply use ``type`` everywhere. Type fields ~~~~~~~~~~~ Type fields, namely ``type``, ``rtype`` and ``ytype``, can be interpreted, such that, instead of being just a regular text field, types can be linked automatically. For reStructuredText and Epytext documentation format, enable this behaviour with the option:: --process-types The type auto-linking is always enabled for Numpy and Google style documentation formats. Like in Sphinx, regular types and container types such as lists and dictionaries can be linked automatically:: :type priority: int :type priorities: list[int] :type mapping: dict(str, int) :type point: tuple[float, float] Natural language types can be linked automatically if separated by the words “or”, "and", "to", "of" or the comma:: :rtype: float or str :returntype: list of str or list[int] :ytype: tuple of str, int and float :yieldtype: mapping of str to int Additionally, it's still possible to include regular text description inside a type specification:: :rtype: a result that needs a longer text description or str :rtype: tuple of a result that needs a longer text description and str Some special keywords will be recognized: "optional" and "default":: :type value: list[float], optional :type value: int, default: -1 :type value: dict(str, int), default: same as default_dict .. note:: Literals caracters - numbers and strings within quotes - will be automatically rendered like docutils literals. .. note:: It's not currently possible to combine parameter type and description inside the same ``param`` field, see issue `#267 `_. Type annotations ---------------- Type annotations in your source code will be included in the API documentation that pydoctor generates. For example:: colors: dict[str, int] = { 'red': 0xFF0000, 'green': 0x00FF00, 'blue': 0x0000FF } def inverse(name: str) -> int: return colors[name] ^ 0xFFFFFF If your project still supports Python versions prior to 3.6, you can also use type comments:: from typing import Optional favorite_color = None # type: Optional[str] However, the ability to extract type comments only exists in the parser of Python 3.8 and later, so make sure you run pydoctor using a recent Python version, or the type comments will be ignored. There is basic type inference support for variables/constants that are assigned literal values. Unlike for example mypy, pydoctor cannot infer the type for computed values:: FIBONACCI = [1, 1, 2, 3, 5, 8, 13] # pydoctor will automatically determine the type: list[int] SQUARES = [n ** 2 for n in range(10)] # pydoctor needs an annotation to document this type Type variables and type aliases will be recognized as such and their value will be colorized in HTML:: from typing import Callable, Tuple, TypeAlias, TypeVar T = TypeVar('T') # a type variable Parser = Callable[[str], Tuple[int, bytes, bytes]] # a type alias .. note:: About name resolving in annotations: ``pydoctor`` checks for top-level names first before checking for other names, this is true only for annotations. This behaviour matches pyright's when PEP-563 is enabled (module starts with ``from __future__ import annotations``). When there is an ambiguous annotation, a warning can be printed if option ``-v`` is supplied. Further reading: - `Python Standard Library: typing -- Support for type hints `_ - `PEP 483 -- The Theory of Type Hints `_ - `PEP 563 -- Postponed Evaluation of Annotations `_ Properties ---------- A method with a decoration ending in ``property`` or ``Property`` will be included in the generated API documentation as an attribute rather than a method:: class Knight: @property def name(self): return self._name @abc.abstractproperty def age(self): raise NotImplementedError @customProperty def quest(self): return f'Find the {self._object}' All you have to do for pydoctor to recognize your custom properties is stick to this naming convention. Using ``attrs`` --------------- If you use the ``attrs`` library to define attributes on your classes, you can use inline docstrings combined with type annotations to provide pydoctor with all the information it needs to document those attributes:: import attr @attr.s(auto_attribs=True) class SomeClass: a_number: int = 42 """One number.""" list_of_numbers: list[int] """Multiple numbers.""" If you are using explicit ``attr.ib`` definitions instead of ``auto_attribs``, pydoctor will try to infer the type of the attribute from the default value, but will need help in the form of type annotations or comments for collections and custom types:: from typing import List import attr @attr.s class SomeClass: a_number = attr.ib(default=42) """One number.""" list_of_numbers = attr.ib(factory=list) # type: List[int] """Multiple numbers.""" Private API ----------- Modules, classes and functions of which the name starts with an underscore are considered *private*. These will not be shown by default, but there is a button in the generated documentation to reveal them. An exception to this rule is *dunders*: names that start and end with double underscores, like ``__str__`` and ``__eq__``, which are always considered public:: class _Private: """This class won't be shown unless explicitly revealed.""" class Public: """This class is public, but some of its methods are private.""" def public(self): """This is a public method.""" def _private(self): """For internal use only.""" def __eq__(self, other): """Is this object equal to 'other'? This method is public. """ .. note:: Pydoctor actually supports 3 types of privacy: public, private and hidden. See :ref:`Override objects privacy ` for more informations. Re-exporting ------------ If your project is a library or framework of significant size, you might want to split the implementation over multiple private modules while keeping the public API importable from a single module. This is supported using pydoctor's re-export feature. A documented element which is defined in one (typically private) module can be imported into another module and re-exported by naming it in the ``__all__`` special variable. Doing so will move its documentation to the module from where it was re-exported, which is where users of your project will be importing it from. In the following example, the documentation of ``MyClass`` is written in the ``my_project.core._impl`` module, which is imported into the top-level ``__init__.py`` and then re-exported by including ``"MyClass"`` in the value of ``__all__``. As a result, the documentation for ``MyClass`` can be read in the documentation of the top-level ``my_project`` package:: ├── README.rst ├── my_project │ ├── __init__.py <-- Re-exports my_project.core._impl.MyClass │ ├── core as my_project.MyClass │ │ ├── __init__.py │ │ ├── _impl.py <-- Defines and documents MyClass The content of ``my_project/__init__.py`` includes:: from .core._impl import MyClass __all__ = ("MyClass",) Branch priorities ----------------- When pydoctor deals with try/except/else or if/else block, it makes sure that the names defined in the main flow has precedence over the definitions in ``except`` handlers or ``else`` blocks. Meaning that in the context of the code below, ``ssl`` would resolve to ``twisted.internet.ssl``: .. code:: python try: # main flow from twisted.internet import ssl as _ssl except ImportError: # exceptional flow ssl = None # ignored since 'ssl' is defined in the main flow below. var = True # not ignored since 'var' is not defined anywhere else. else: # main flow ssl = _ssl Similarly, in the context of the code below, the ``CapSys`` protocol under the ``TYPE_CHECKING`` block will be documented and the runtime version will be ignored. .. code:: python from typing import TYPE_CHECKING if TYPE_CHECKING: # main flow from typing import Protocol class CapSys(Protocol): def readouterr() -> Any: ... else: # secondary flow class CapSys(object): # ignored since 'CapSys' is defined in the main flow above. ... But sometimes pydoctor can be better off analysing the ``TYPE_CHECKING`` blocks and should stick to the runtime version of the code instead. For these case, you might want to inverse the condition of if statement: .. code:: python if not TYPE_CHECKING: # main flow from ._implementation import Thing else: # secondary flow from ._typing import Thing # ignored since 'Thing' is defined in the main flow above. pydoctor-24.11.2/docs/source/conf.py000066400000000000000000000150641473665144200172750ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) import os import subprocess import pathlib # -- Project information ----------------------------------------------------- project = 'pydoctor' copyright = '2020, Michael Hudson-Doyle and various contributors (see Git history)' author = 'Michael Hudson-Doyle and various contributors (see Git history)' from pydoctor import __version__ as version # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx_rtd_theme", "sphinx.ext.intersphinx", "pydoctor.sphinx_ext.build_apidocs", "sphinxcontrib.spelling", "sphinxarg.ext", ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # Definitions that will be made available to every document. rst_epilog = """ .. include:: """ # Configure spell checker. spelling_word_list_filename = 'spelling_wordlist.txt' # Configure intersphinx magic intersphinx_mapping = { # FIXME: use the official Twisted's docs when they update 'twisted': ('https://tristanlatr.github.io/apidocs/twisted/', None), 'configargparse': ('https://bw2.github.io/ConfigArgParse/', None), 'std': ('https://docs.python.org/3/', None), } # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = [] # Try to find URL fragment for the GitHub source page based on current # branch or tag. _git_reference = subprocess.getoutput('git rev-parse --abbrev-ref HEAD') if _git_reference == 'HEAD': # It looks like the branch has no name. # Fallback to commit ID. _git_reference = subprocess.getoutput('git rev-parse HEAD') if os.environ.get('READTHEDOCS', '') == 'True': rtd_version = os.environ.get('READTHEDOCS_VERSION', '') if '.' in rtd_version: # It looks like we have a tag build. _git_reference = rtd_version _pydoctor_root = pathlib.Path(__file__).parent.parent.parent _common_args = [ f'--html-viewsource-base=https://github.com/twisted/pydoctor/tree/{_git_reference}', f'--project-base-dir={_pydoctor_root}', f'--config={_pydoctor_root}/setup.cfg', ] pydoctor_args = { 'main': [ '--html-output={outdir}/api/', # Make sure to have a trailing delimiter for better usage coverage. '--html-base-url=https://pydoctor.readthedocs.io/en/latest/api', '--project-name=pydoctor', f'--project-version={version}', '--docformat=epytext', '--privacy=HIDDEN:pydoctor.test', '--project-url=../index.html', f'{_pydoctor_root}/pydoctor', ] + _common_args, 'custom_template_demo': [ '--html-output={outdir}/custom_template_demo/', '--html-base-url=https://pydoctor.readthedocs.io/en/latest/custom_template_demo', f'--project-version={version}', f'--template-dir={_pydoctor_root}/docs/sample_template', f'{_pydoctor_root}/pydoctor', ] + _common_args + [f'--config={_pydoctor_root}/docs/source/custom_template_demo/pyproject.toml', '-qqq' ], # we don't want to hear any warnings from this custom template demo. 'epydoc_demo': [ '--html-output={outdir}/docformat/epytext_demo', '--html-base-url=https://pydoctor.readthedocs.io/en/latest/docformat/epytext_demo', '--project-name=pydoctor-epytext-demo', '--project-version=1.3.0', '--docformat=epytext', '--sidebar-toc-depth=3', '--project-url=../epytext.html', '--theme=readthedocs', f'{_pydoctor_root}/docs/epytext_demo', ] + _common_args, 'restructuredtext_demo': [ '--html-output={outdir}/docformat/restructuredtext_demo', '--html-base-url=https://pydoctor.readthedocs.io/en/latest/docformat/restructuredtext_demo', '--project-name=pydoctor-restructuredtext-demo', '--project-version=1.0.0', '--docformat=restructuredtext', '--sidebar-toc-depth=3', '--project-url=../restructuredtext.html', '--process-types', f'{_pydoctor_root}/docs/restructuredtext_demo', ] + _common_args, 'numpy_demo': [ # no need to pass --docformat here, we use __docformat__ '--html-output={outdir}/docformat/numpy_demo', '--html-base-url=https://pydoctor.readthedocs.io/en/latest/docformat/numpy_demo', '--project-name=pydoctor-numpy-style-demo', '--project-version=1.0.0', '--project-url=../google-numpy.html', '--theme=readthedocs', f'{_pydoctor_root}/docs/numpy_demo', f'{_pydoctor_root}/pydoctor/napoleon' ] + _common_args, 'google_demo': [ '--html-output={outdir}/docformat/google_demo', '--html-base-url=https://pydoctor.readthedocs.io/en/latest/docformat/google_demo', '--project-name=pydoctor-google-style-demo', '--project-version=1.0.0', '--docformat=google', '--project-url=../google-numpy.html', '--theme=readthedocs', f'{_pydoctor_root}/docs/google_demo', ] + _common_args, } pydoctor_url_path = { 'main': '/en/{rtd_version}/api', 'epydoc_demo': '/en/{rtd_version}/docformat/epytext/', 'restructuredtext_demo': '/en/{rtd_version}/docformat/restructuredtext/', 'numpy_demo': '/en/{rtd_version}/docformat/numpy/', 'google_demo': '/en/{rtd_version}/docformat/google/', } pydoctor-24.11.2/docs/source/contrib.rst000066400000000000000000000255151473665144200201720ustar00rootroot00000000000000Contribute ========== What can you do --------------- If you like the project and think you could help with making it better, there are many ways you can do it: - Create a new issue for new feature proposal or a bug - Triage old issues that needs a refresh - Implement existing issues (there are quite some of them, choose whatever you like) - Help with improving the documentation (We still have work to do!) - Spread a word about the project to your colleagues, friends, blogs or any other channels - Any other things you could imagine Any contribution would be of great help and I will highly appreciate it! If you have any questions, please create a new issue. Development process ------------------- Create a fork of the git repository and checkout a new branch from ``master`` branch. The branch name may start with an associated issue number so that we can easily cross-reference them. For example, use ``1234-some-brach-name`` as the name of the branch working to fix issue ``1234``. Once you're ready to run a full batterie of tests to your changes, open a pull request. Don't forget to sync your fork once in while to work from the latest revision. Pre-commit checks ----------------- Make sure all the unit tests pass and the code pass the coding standard checks. We use `tox `_ for running our checks, but you can roughly do the same thing from your python environment. .. list-table:: Pre-commit checks :widths: 10 45 45 :header-rows: 1 * - \ - Using `tox` - Using your environment * - Run unit tests - ``tox -e test`` - ``pip install '.[test]' && pytest pydoctor`` * - Run pyflakes - ``tox -e pyflakes`` - ``pip install pyflakes && find pydoctor/ -name \*.py ! -path '*/testpackages/*' ! -path '*/sre_parse36.py' ! -path '*/sre_constants36.py' | xargs pyflakes`` * - Run mypy - ``tox -e mypy`` - ``pip install '.[mypy]' && mypy pydoctor`` * - Run pydoctor on it's own source - ``tox -e apidocs`` - ``pip install . && pydoctor --privacy "HIDDEN:pydoctor.test" -q -W pydoctor`` These should be the minimum check to run on your local system. A pull request will trigger more tests and most probably there is a tox environment dedicated to that extra test. Other things hapenning when a PR is open ---------------------------------------- - System tests: these tests checks if pydoctor can generate the documentation for a few specific packages that have been considered as problematic in the past. - Pydoctor primer: this is to pydoctor what ``mypy_primer`` is to ``mypy``. It runs pydoctor on a corpus of open source code and compares the output of the application before and after a modification in the code. Then it reports in comments the result for a PR. The source code of this tool is here: https://github.com/twisted/pydoctor_primer. - Readthedocs build: For every PR, the sphinx documentation is built and available at ``https://pydoctor--{pr-number}.org.readthedocs.build/en/``. Review process and requirements ------------------------------- - Code changes and code added should have tests: untested code is buggy code. Except special cases, overall test coverage should be increased. - If your pull request is a work in progress, please mark it as draft such that reviewers do not loose time on a PR that is not ready yet. - There is no strict coding style standard. Since pydoctor is more than 20 years old and we have vendored some code from other packages as well (namely epydoc and sre_parse), so we can’t really enforce the same style everywhere. It's up to the reviewers to request refactors when the code is too ugly. - All code changes must be reviewed by at least one person who is not an author of the code being added. This helps prevent bugs from slipping through the net and gives another source for improvements. If the author of the PR is one of the core developers of pydoctor* and no one has reviewed their PR after 9 calendar days, they can review the code changes themselves and proceed with next steps. - When one is done with the review, always say what the next step should be: for example, if the author is a core developer, can they merge the PR after making a few minor fixes? If your review feedback is more substantial, should they ask for another review? \* A core developer is anyone with a write access to the repository that have an intimate knowledge of pydoctor internals, or, alternatively the specific aspect in which they are contributing to (i.e. Sphinx docs, setup, pytest, etc.). Read more about reviewing: - `How to be a good reviewer `_. - `Leave well enough alone `_. Releasing and publishing a new package -------------------------------------- Publishing to PyPI is done via a GitHub Actions workflow. The publish to PyPI is triggered when a tag is pushed. Version is configured in the ``setup.cfg``. The `pydoctor PyPI project `_ is configured to accept trusted publishing via GitHub actions from the `unit.yaml` workflow. If the release is done from another workflow file, the PyPI project management page needs to be updated. The following process ensures correct version management: - Create a branch: name it by the name of the new major ``pydoctor`` version, i.e. ``21.9.x``, re-use that same branch for bug-fixes. - On the branch, update the version and release notes. - Update the HTML templates version (meta tag ``pydoctor-template-version``) when there is a change from a version to another. For instance, check the diff of the HTML templates since version ``21.9.1`` with the following git command:: git diff 21.9.1 pydoctor/themes/*/*.html .. note:: The HTML template version can also be updated in the PR in which the actual HTML template change is done. - Create a PR for that branch, wait for tests to pass and get an approval. - Create a tag based on the ``HEAD`` of the release branch, name it by the full version number of the ``pydoctor`` version, i.e. ``21.9.1``, this will trigger the release. For instance:: git tag 21.9.1 git push --tags - Update the version on the branch and append ``.dev0`` to the current version number. In this way, stable versions only exist for a brief period of time (if someone tries to do a ``pip install`` from the git source, they will get a ``.dev0`` version instead of a misleading stable version number. - Update the README file and add an empty placeholder for unreleased changes. - Merge the branch Updating pydoctor for Linux distributions ----------------------------------------- The information below covers Debian and its derivative distributions. The same principles should be applied for Fedora, Arch, Alpine or any other Linux distribution. There shouldn't be any additional steps needed to get pydoctor updated in Debian (and its downstream distributions like Ubuntu). As pydoctor is a Python based package the `Debian Python Team `_ is usually taking care about updating pydoctor in Debian. The DPT is available through the team mailing list (``Debian Python List ``) there everyone can get in contact by email. If you just want to ask something quickly please use this option. Debian uses a separate, non GitHub, BTS (Bug Tracking System) to keep track of issues. The package maintainers like to use this system in case of more specific requests or problems. The preferred and suggested way to open up new issues within the Debian BTS is to use the tool `reportbug `_ that will do some additional magic while collecting the data for the bug report like collecting installed packages and there versions. ``reportbug`` should be used if you are working on a Debian based system. But you can also use any email client to open up bug reports on the Debian BTS by simply writing an email to the address ``submit@bugs.debian.org``. If you want to help to keep the pydoctor package up to date in Debian the DPT is happy to take your help! Helping out can be done in various ways. * Keep an eye on `reported issues `_ for the pydoctor package and forward them upstream if needed. * Have also a look at cross connected packages and possible build issues there regarding the build dependency onpydoctor. These packages are mostly `twisted `_ or `git-buildpackage `_. * Ideally taking over some maintainer responsibilities for pydoctor in Debian. pydoctor and new depending packages ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ It might happen that pydoctor is requiring new additional Python libraries due to new wanted features or to enhance the internal test suite. Such new packages shouldn't get vendored. They need to be packaged in Debian. Best is to get in contact with the DPT to talk about about new requirements and the best way to get things done. Profiling pydoctor with austin and speedscope --------------------------------------------- 1. Install austin (https://github.com/P403n1x87/austin) 2. Install austin-python (https://pypi.org/project/austin-python/) 3. Run program under austin .. code:: $ sudo austin -i 1ms -C -o pydoctor.austin pydoctor 4. Convert .austin to .speedscope (austin2speedscope comes from austin-python) .. code:: $ austin2speedscope pydoctor.austin pydoctor.speedscope 5. Open https://speedscope.app and load pydoctor.speedscope into it. Note on sampling interval ~~~~~~~~~~~~~~~~~~~~~~~~~ On our large repo I turn down the sampling interval from 100us to 1ms to make the resulting ``.speedscope`` file a manageable size (15MB instead of 158MB which is too large to put into a gist.) Author Design Notes ------------------- I guess I've always been interested in more-or-less static analysis of Python code and have over time developed some fairly strong opinions on the Right Way\ |trade| to do it. The first of these is that pydoctor works on an entire *system* of packages and modules, not just a ``.py`` file at a time. The second, and this only struck me with full force as I have written pydoctor, is that it's much the best approach to proceed incrementally, and outside-in. First, you scan the directory structure to and compute the package/module structure, then parse each module, then do some analysis on what you've found, then generate html. Finally, pydoctor should never crash, no matter what code you feed it (this seems a basic idea for a documentation generator, but it's not that universally applied, it seems). Missing information is OK, crashing out is not. This probably isn't as true as it should be at the moment. pydoctor-24.11.2/docs/source/custom_template_demo/000077500000000000000000000000001473665144200222015ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/custom_template_demo/index.rst000066400000000000000000000004321473665144200240410ustar00rootroot00000000000000:orphan: API Reference with a Twisted theme ================================== This file will be overwritten by the pydoctor build triggered at the end of the Sphinx build. It's a hack to be able to reference the API index page from inside Sphinx and have it as part of the TOC. pydoctor-24.11.2/docs/source/custom_template_demo/pyproject.toml000066400000000000000000000006241473665144200251170ustar00rootroot00000000000000[tool.pydoctor] project-name = 'pydoctor with a twisted theme' docformat = 'epytext' privacy = 'HIDDEN:pydoctor.test' project-url = '../customize.html' theme = base intersphinx = ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv",] # Yes, it's missing a lot of intersphinx links, but that's ok, this is just an example.pydoctor-24.11.2/docs/source/customize.rst000066400000000000000000000221041473665144200205430ustar00rootroot00000000000000Theming and other customizations ================================ Configure sidebar expanding/collapsing -------------------------------------- By default, the sidebar only lists one level of objects (always expanded), to allow objects to expand/collapse and show first nested content, use the following option:: --sidebar-expand-depth=2 This value describe how many nested modules and classes should be expandable. .. note:: Careful, a value higher than ``1`` (which is the default) can make your HTML files significantly larger if you have many modules or classes. To disable completely the sidebar, use option ``--no-sidebar`` Theming ------- Currently, there are 2 main themes packaged with pydoctor: ``classic`` and ``readthedocs``. Choose your theme with option:: --theme .. note:: Additionnaly, the ``base`` theme can be used as a base for customizations. Tweak HTML templates -------------------- They are 3 special files designed to be included in specific places of each pages. - ``header.html``: at the very beginning of the body - ``subheader.html``: after the main header, before the page title - ``extra.css``: extra CSS sheet for layout customization To include a file, write your custom HTML or CSS files to a directory and use the following option:: --template-dir=./pydoctor_templates If you want more customization, you can override the default templates in `pydoctor/themes/base `_ with the same method. HTML templates have their own versioning system and warnings will be triggered when an outdated custom template is used. .. admonition:: Demo theme example There is a demo template inspired by Twisted web page for which the source code is `here `_. You can try the result by checking `this page `_. .. note:: This example is using the ``base`` theme. .. _customize-privacy: Override objects privacy (show/hide) ------------------------------------ Pydoctor supports 3 types of privacy. Below is the description of each type and the default association: - ``PRIVATE``: By default for objects whose name starts with an underscore and are not a dunder method. Rendered in HTML, but hidden via CSS by default. - ``PUBLIC``: By default everything else that is not private. Always rendered and visible in HTML. - ``HIDDEN``: Nothing is hidden by default. Not rendered at all and no links can be created to hidden objects. Not present in the search index nor the intersphinx inventory. Basically excluded from API documentation. If a module/package/class is hidden, then all it's members are hidden as well. When the default rules regarding privacy doesn't fit your use case, use the ``--privacy`` command line option. It can be used multiple times to define multiple privacy rules:: --privacy=: where ```` can be one of ``PUBLIC``, ``PRIVATE`` or ``HIDDEN`` (case insensitive), and ```` is fnmatch-like pattern matching objects fullName. Privacy tweak examples ^^^^^^^^^^^^^^^^^^^^^^ - ``--privacy="PUBLIC:**"`` Makes everything public. - ``--privacy="HIDDEN:twisted.test.*" --privacy="PUBLIC:twisted.test.proto_helpers"`` Makes everything under ``twisted.test`` hidden except ``twisted.test.proto_helpers``, which will be public. - ``--privacy="PRIVATE:**.__*__" --privacy="PUBLIC:**.__init__"`` Makes all dunder methods private except ``__init__``. .. important:: The order of arguments matters. Pattern added last have priority over a pattern added before, but an exact match wins over a fnmatch. .. note:: See :py:mod:`pydoctor.qnmatch` for more informations regarding the pattern syntax. .. note:: Quotation marks should be added around each rule to avoid shell expansions. Unless the arguments are passed directly to pydoctor, like in Sphinx's ``conf.py``, in this case you must not quote the privacy rules. Use a custom system class ------------------------- You can subclass the :py:class:`pydoctor.model.System` and pass your custom class dotted name with the following argument:: --system-class=mylib._pydoctor.CustomSystem System class allows you to customize certain aspect of the system and configure the enabled extensions. If what you want to achieve has something to do with the state of some objects in the Documentable tree, it's very likely that you can do it without the need to override any system method, by using the extension mechanism described below. Brief on pydoctor extensions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The AST builder can now be customized with extension modules. This is how we handle Zope Interfaces declarations and :py:mod:`twisted.python.deprecate` warnings. Each pydocotor extension is a Python module with at least a ``setup_pydoctor_extension()`` function. This function is called at initialization of the system with one argument, the :py:class:`pydoctor.extensions.ExtRegistrar` object representing the system. An extension can register multiple kind of components: - AST builder visitors - Mixin classes for :py:class:`pydoctor.model.Documentable` - Post processors Take a look at built-in extensions :py:mod:`pydoctor.extensions.zopeinterface` and :py:mod:`pydoctor.extensions.deprecate`. Navigate to the source code for a better overview. A concrete example ^^^^^^^^^^^^^^^^^^ Let's say you want to write a extension for simple pydantic classes like this one: .. code:: python from typing import ClassVar from pydantic import BaseModel class Model(BaseModel): a: int b: int = Field(...) name:str = 'Jane Doe' kind:ClassVar = 'person' First, we need to create a new module that will hold our extension code: ``mylib._pydoctor``. This module will contain visitor code that visits ``ast.AnnAssign`` nodes after the main visitor. It will check if the current context object is a class derived from ``pydantic.BaseModel`` and transform each class variable into instance variables accordingly. .. code:: python # Module mylib._pydoctor import ast from pydoctor import astutils, extensions, model class PydanticModVisitor(extensions.ModuleVisitorExt): def depart_AnnAssign(self, node: ast.AnnAssign) -> None: """ Called after an annotated assignment definition is visited. """ ctx = self.visitor.builder.current if not isinstance(ctx, model.Class): # check if the current context object is a class return if not any(ctx.expandName(b) == 'pydantic.BaseModel' for b in ctx.bases): # check if the current context object if a class derived from ``pydantic.BaseModel`` return dottedname = astutils.node2dottedname(node.target) if not dottedname or len(dottedname)!=1: # check if the assignment is a simple name, otherwise ignore it return # Get the attribute from current context attr = ctx.contents[dottedname[0]] assert isinstance(attr, model.Attribute) # All class variables that are not annotated with ClassVar will be transformed to instance variables. if astutils.is_using_typing_classvar(attr.annotation, attr): return if attr.kind == model.DocumentableKind.CLASS_VARIABLE: attr.kind = model.DocumentableKind.INSTANCE_VARIABLE def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: r.register_astbuilder_visitor(PydanticModVisitor) class PydanticSystem(model.System): # Declare that this system should load this additional extension custom_extensions = ['mylib._pydoctor'] Then, we would pass our custom class dotted name with the argument ``--system-class``:: --system-class=mylib._pydoctor.PydanticSystem Et voilà. If this extension mechanism doesn't support the tweak you want, you can consider overriding some :py:class:`pydoctor.model.System` methods. For instance, overriding :py:meth:`pydoctor.model.System.__init__` method could be useful, if some want to write a custom :py:class:`pydoctor.sphinx.SphinxInventory`. .. important:: If you feel like other users of the community might benefit from your extension as well, please don't hesitate to open a pull request adding your extension module to the package :py:mod:`pydoctor.extensions`. Use a custom writer class ------------------------- You can subclass the :py:class:`pydoctor.templatewriter.TemplateWriter` (or the abstract super class :py:class:`pydoctor.templatewriter.IWriter`) and pass your custom class dotted name with the following argument:: --html-writer=mylib._pydoctor.CustomTemplateWriter The option is actually badly named because, theorically one could write a subclass of :py:class:`pydoctor.templatewriter.IWriter` (to be used alongside option ``--template-dir``) that would output Markdown, reStructuredText or JSON. .. warning:: Pydoctor does not have a stable API yet. Code customization is prone to break in future versions. pydoctor-24.11.2/docs/source/docformat/000077500000000000000000000000001473665144200177465ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/docformat/epytext.rst000066400000000000000000000013301473665144200221770ustar00rootroot00000000000000Epytext ======= .. toctree:: :maxdepth: 1 epytext_demo/index Read the `the epytext manual `_ for full documentation. Pydoctor has extended ``epydoc``'s parser and uses it as a library to parse epytext formatted docstrings. All markup should work except the indexed terms ``X{}`` tag, which has been removed. Fields ------ See :ref:`fields section `. .. note:: Not everything from the `epydoc fields manual `_ is applicable. Some fields might still display as unknown. .. note:: In any case, *plaintext* docstring format will be used if docstrings can't be parsed with *epytext* parser. pydoctor-24.11.2/docs/source/docformat/epytext_demo/000077500000000000000000000000001473665144200224545ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/docformat/epytext_demo/index.rst000066400000000000000000000000641473665144200243150ustar00rootroot00000000000000Epytext demo package ==================== :orphan: pydoctor-24.11.2/docs/source/docformat/google-numpy.rst000066400000000000000000000040111473665144200231160ustar00rootroot00000000000000Google and Numpy ================ .. toctree:: :maxdepth: 1 google_demo/index numpy_demo/index Pydoctor now supports numpydoc and google style docstrings! Docstrings will be first converted to reStructuredText and then parsed with ``docutils``. Any supported `reST markup `_ can be use to supplement google-style or numpy-style markup. The main difference between the two styles is that Google uses indentation to separate sections, whereas NumPy uses underlines. This means that 2 blank lines are needed to end a NumPy section that is followed by a regular paragraph (i.e. not another section header) .. note:: We have forked and enhanced the `napoleon Sphinx extension `_. For more information, refer to :py:mod:`pydoctor.napoleon` documentation. For complete markup details, refer to the `Google style `_ or `NumpyDoc style `_ reference documentation. Sections -------- List of supported sections: - ``Args``, ``Arguments``, ``Parameters`` - ``Keyword Args``, ``Keyword Arguments`` - ``Return(s)``, ``Yield(s)`` (if you use type annotations a ``Returns`` section will always be present) - ``Raise(s)``, ``Warn(s)`` - ``See Also``, ``See`` - ``Example(s)`` - ``Note(s)``, ``Warning(s)`` and other admonitions - ``Attributes`` (Items will be translated into ``ivar`` fields.) Sections supported on a "best effort" basis: - ``Methods``: Items will be included into a generic "Methods" section. - ``References``: Rendered as a generic section. - ``Other Parameters``, ``Receive(s)``: Parameters described in those sections will be merged with regular parameters. .. ReST syntax violations might be reported with a slightly incorrect line number because of this pre-processing. (uncommented this when pydoctor/issues/237 is solved) pydoctor-24.11.2/docs/source/docformat/google_demo/000077500000000000000000000000001473665144200222265ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/docformat/google_demo/index.rst000066400000000000000000000000641473665144200240670ustar00rootroot00000000000000Google-style demo package ========================= pydoctor-24.11.2/docs/source/docformat/index.rst000066400000000000000000000022231473665144200216060ustar00rootroot00000000000000Documentation Formats ===================== The following sections roughly documents the supported docstrings formatting. As an additional reference, small python packages demonstrates how docstrings are rendered. .. toctree:: :maxdepth: 1 epytext restructuredtext google-numpy Choose your docstring format with the option:: --docformat= The following format keywords are recognized: - ``epytext`` - ``restructuredtext`` - ``google`` - ``numpy`` - ``plaintext`` To override the default markup language for a module, define a module-level string variable ``__docformat__``, containing the name of the module's markup language:: __docformat__ = "reStructuredText" __docformat__ = "Epytext" .. note:: Language code can be added. It is currently ignored, though it might be used it the future to generate ``lang`` attribute in HTML or as configuration for a spell checker:: __docformat__ = "reStructuredText en" Parser name and language code are **case insensitve**. If a package defines ``__docformat__`` in its ``__init__.py`` file, all modules (including subpackages) in that package will inherit its value. pydoctor-24.11.2/docs/source/docformat/list-restructuredtext-support.rst000066400000000000000000000162101473665144200266230ustar00rootroot00000000000000:orphan: List of ReST directives ======================= .. list-table:: List of ReST directives and status whether they are supported or unsupported by PyDoctor :header-rows: 1 * - Directive - Defined by - Supported * - ``.. include::`` - `docutils `__ - Yes * - ``.. contents::`` - `docutils `__ - Yes * - ``.. image::`` - `docutils `__ - Yes * - ``.. |time| date:: %H:%M`` - `docutils `__ - Yes * - ``.. figure::`` - `docutils `__ - Yes * - ``.. |T| replace:: term`` - `docutils `__ - Yes * - ``.. unicode::`` - `docutils `__ - Yes * - ``.. raw::`` - `docutils `__ - Yes * - ``.. class::`` - `docutils `__ - No * - ``.. role::`` - `docutils `__ - Yes * - ``.. default-role::`` - `docutils `__ - Should not be changed. * - ``.. line-block::`` - `docutils `__ - No * - ``.. code::`` - `docutils `__ - Yes (No options supported) * - ``.. python::`` - pydoctor - Yes * - ``.. math::`` - `docutils `__ - Yes * - ``.. highlights::`` - `docutils `__ - No * - ``.. pull-quote::`` - `docutils `__ - No * - ``.. container::`` - `docutils `__ - Yes * - ``.. table::`` - `docutils `__ - Yes * - ``.. csv-table::`` - `docutils `__ - Yes * - ``.. list-table::`` - `docutils `__ - Yes * - ``.. warning::`` and other admonitions - `docutils `__ - Yes. This includes: attention, caution, danger, error, hint, important, note, tip, warning and the generic admonitions. * - ``.. versionadded::`` - `Sphinx `__ - Yes * - ``.. versionchanged::`` - `Sphinx `__ - Yes * - ``.. deprecated::`` - `Sphinx `__ - Yes * - ``.. centered::`` - `Sphinx `__ - No * - ``.. digraph::`` - `epydoc `__ - No * - ``.. classtree::`` - `epydoc `__ - No * - ``.. packagetree::`` - `epydoc `__ - No * - ``.. importgraph::`` - `epydoc `__ - No * - ``.. callgraph::`` - `epydoc `__ - No * - ``.. hlist::`` - `Sphinx `__ - No * - ``.. highlight::`` - `Sphinx `__ - No * - ``.. code-block::`` - `Sphinx `__ - Yes (No options supported) * - ``.. literalinclude::`` - `Sphinx `__ - No * - ``.. glossary::`` - `Sphinx `__ - No * - ``.. index::`` - `Sphinx `__ - No * - ``.. sectionauthor::`` - `Sphinx `__ - No * - ``.. codeauthor::`` - `Sphinx `__ - No * - ``.. topic::`` - `docutils `__ - No * - ``.. sidebar::`` - `docutils `__ - No * - ``.. rubric::`` - `docutils `__ - No * - ``.. epigraph::`` - `docutils `__ - No * - ``.. compound::`` - `docutils `__ - No * - ``.. sectnum::`` - `docutils `__ - No * - ``.. header::`` - `docutils `__ - No * - ``.. footer::`` - `docutils `__ - No * - ``.. meta::`` - `docutils `__ - No * - ``.. title::`` - `docutils `__ - No *This list is not exhaustive* pydoctor-24.11.2/docs/source/docformat/numpy_demo/000077500000000000000000000000001473665144200221225ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/docformat/numpy_demo/index.rst000066400000000000000000000000621473665144200237610ustar00rootroot00000000000000Numpy-style demo package ======================== pydoctor-24.11.2/docs/source/docformat/restructuredtext.rst000066400000000000000000000114421473665144200241420ustar00rootroot00000000000000reStructuredText ================ .. toctree:: :maxdepth: 1 restructuredtext_demo/index For the language syntax documentation, read the `ReST docutils syntax reference `_. Fields ------ See :ref:`fields section `. In addition to the standard set of fields, the reStructuredText parser also supports **consolidated fields**, which combine the documentation for several objects into a single field. These consolidated fields may be written using either a `bulleted list `_ or a `definition list `_. - If a consolidated field is written as a bulleted list, then each list item must begin with the field's argument, marked as `interpreted text `_, and followed by a colon or dash. - If a consolidated field is written as a definition list, then each definition item's term should contain the field's argument, (it is not mandatory for it being marked as interpreted text). The following example shows the use of a definition list to define the ``Parameters`` consolidated field with type definition. Note that *docutils* requires a space before and after the ``:`` used to mark classifiers. .. code:: python def fox_speed(size, weight, age): """ :Parameters: size The size of the fox (in meters) weight : float The weight of the fox (in stones) age : int The age of the fox (in years) """ Using a bulleted list. .. code:: python def fox_speed(size:float, weight:float, age:int): """ :Parameters: - `size`: The size of the fox (in meters) - `weight`: The weight of the fox (in stones) - `age`: The age of the fox (in years) """ The following consolidated fields are currently supported by PyDoctor: .. table:: Consolidated Fields ============================== ============================== Consolidated Field Tag Corresponding Base Field Tag ============================== ============================== ``:Parameters:`` ``:param:`` ``:Keywords:`` ``:keyword:`` ``:Exceptions:`` ``:except:`` ``:Variables:`` ``:var:`` ``:IVariables:`` ``:ivar:`` ``:CVariables:`` ``:cvar:`` ``:Types:`` ``:type:`` ============================== ============================== Fields are case *insensitive*. Cross-references ---------------- PyDoctor replaces the Docutils' default `interpreted text role `_ with the creation of `documentation cross-reference links `_. If you want to create a cross-reference link to the ``module.Example`` class, simply put backticks around it, typing:: `module.Example` .. note:: Sphinx interpreted text roles for code references like ``:obj:`` or ``:meth:`` are not required and will be ignored. Directives ---------- Here is a list of the supported ReST directives by package of origin: - `docutils`: ``.. include::``, ``.. contents::``, ``.. image::``, ``.. figure::``, ``.. unicode::``, ``.. raw::``, ``.. math::``, ``.. role::``, ``.. table::``, ``.. code::``, ``.. warning::``, ``.. note::`` and other admonitions, and a few others. - `epydoc`: None yet. - `Sphinx`: ``.. deprecated::``, ``.. versionchanged::``, ``.. versionadded::``, ``.. code-block::`` - `pydoctor`: ``.. python::`` `Full list of supported and unsupported directives `_ Colorized snippets directive ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Using reStructuredText markup it is possible to specify Python snippets in a `doctest block `_. If the Python prompt gets in your way when you try to copy and paste and you are not interested in self-testing docstrings, the python directive will let you obtain a simple block of colorized text. Directives ``.. code::`` and ``.. code-block::`` acts exactly the same. :: .. python:: def fib(n): """Print a Fibonacci series.""" a, b = 0, 1 while b < n: print b, a, b = b, a+b .. note:: HTML element's classes generated by our custom ``HTMLTranslator`` have a ``"rst-"`` prefix .. note:: In any case, *plaintext* docstring format will be used if docstrings can't be parsed with *restructuredtext* parser. pydoctor-24.11.2/docs/source/docformat/restructuredtext_demo/000077500000000000000000000000001473665144200244125ustar00rootroot00000000000000pydoctor-24.11.2/docs/source/docformat/restructuredtext_demo/index.rst000066400000000000000000000001061473665144200262500ustar00rootroot00000000000000reStructuredText demo package ============================= :orphan: pydoctor-24.11.2/docs/source/faq.rst000066400000000000000000000056141473665144200172770ustar00rootroot00000000000000Frequently Asked Questions ========================== Why? ---- ``pydoctor`` was written to be used by the `Twisted project `_ which was using `epydoc `_ but was becoming increasingly unhappy with it for various reasons. In addition, development on Epydoc seemed to have halted. The needs of the Twisted project are still the main driving force for ``pydoctor``'s development, but it is getting to the point where there's some chance that it is useful for your project too. Who wrote ``pydoctor``? ------------------------ Michael "mwhudson" Hudson, PyPy, Launchpad and sometimes Twisted hacker, with help from Christopher "radix" Armstrong and Jonathan "jml" Lange and advice and ideas from many people who hang out in #twisted on freenode. More recently, Maarten ter Huurne ("mth"), took the lead. Always backed with `numerous contributors `_. Why would I use it? ------------------- ``pydoctor`` is probably best suited to documenting a library that have some degree of internal subclassing. It also has support for `zope.interface `_, and can recognize interfaces and classes which implement such interfaces. How is it different from ``sphinx-autodoc`` ------------------------------------------- ``sphinx-autodoc`` operates semi-automatic rather than fully automatically. It can not generate documentation solely from Python source files; it always requires a reStructuredText file as well. It can also be complex and the output is sometimes overwhelming, ``pydoctor`` will generate one page per class, module and package, it tries to keeps it simple and present information in a efficient way with tables. Sphinx narrative documentation can seamlessly link to API documentation formatted by pydoctor. Please refer to the `Sphinx Integration `_ section for details. What does the output look like? ------------------------------- It looks `like this `_, which is the Twisted API documentation. The output is reasonably simple. Who is using ``pydoctor``? -------------------------- Here are some projects using ``pydoctor``: - `Twisted `_ - `Incremental `_ - `OTFBot `_ - `python-igraph `_ - `Wokkel `_ - `msiempy `_ - `git-buildpackage `_ - `pycma `_ - `cocopp `_ - `python-Wappalyzer `_ - and others How do I use it? ---------------- Please review the `Quick Start `_ section. pydoctor-24.11.2/docs/source/help.rst000066400000000000000000000075231473665144200174610ustar00rootroot00000000000000CLI Options and Config File =========================== Command line options -------------------- .. argparse:: :ref: pydoctor.options.get_parser :prog: pydoctor :nodefault: Configuration file ------------------ All arguments can also be set in a config file. Repeatable arguments must be defined as list. Positional arguments can be set with option ``add-package``. By convention, the config file resides on the root of your repository. Pydoctor automatically integrates with common project files ``./pyproject.toml`` or ``./setup.cfg`` and loads file ``./pydoctor.ini`` if if exists. The configuration parser supports `TOML `_ and INI formats. .. note:: No path processing is done to determine the project root directory, pydoctor only looks at the current working directory. You can set a different config file path with option ``--config``, this is necessary to load project configuration files from Sphinx's ``conf.py``. ``pydoctor.ini`` ^^^^^^^^^^^^^^^^ Declaring section ``[pydoctor]`` is required. :: [pydoctor] add-package = src/mylib intersphinx = https://docs.python.org/3/objects.inv https://twistedmatrix.com/documents/current/api/objects.inv docformat = restructuredtext verbose = 1 warnings-as-errors = true privacy = HIDDEN:pydoctor.test PUBLIC:pydoctor._configparser ``pyproject.toml`` ^^^^^^^^^^^^^^^^^^ ``pyproject.toml`` are considered for configuration when they contain a ``[tool.pydoctor]`` table. It must use TOML format. :: [tool.pydoctor] add-package = ["src/mylib"] intersphinx = ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv"] docformat = "restructuredtext" verbose = 1 warnings-as-errors = true privacy = ["HIDDEN:pydoctor.test", "PUBLIC:pydoctor._configparser",] Note that the config file fragment above is also valid INI format and could be parsed from a ``setup.cfg`` file successfully. ``setup.cfg`` ^^^^^^^^^^^^^ ``setup.cfg`` can also be used to hold pydoctor configuration if they have a ``[tool:pydoctor]`` section. It must use ``INI`` format. :: [tool:pydoctor] add-package = src/mylib intersphinx = https://docs.python.org/3/objects.inv https://twistedmatrix.com/documents/current/api/objects.inv docformat = restructuredtext verbose = 1 warnings-as-errors = true privacy = HIDDEN:pydoctor.test PUBLIC:pydoctor._configparser .. Note:: If an argument is specified in more than one place, then command line values override config file values which override defaults. If more than one config file exists, ``pydoctor.ini`` overrides values from ``pyproject.toml`` which overrrides ``setup.cfg``. Repeatable options are not merged together, there are overriden as well. .. Note:: The INI parser behaves like :py:class:`configargparse:configargparse.ConfigparserConfigFileParser` in addition that it converts plain multiline values to list, each non-empty line will be converted to a list item. If for some reason you need newlines in a string value, just tripple quote your string like you would do in python. Allowed syntax is that for a :py:class:`std:configparser.ConfigParser` with the default options. .. Note:: Last note: pydoctor has always supported a ``--config`` option, but before 2022, the format was undocumentd and rather fragile. This new configuration format breaks compatibility with older config file in three main ways: - Options names are now the same as argument without the leading ``--`` (e.g ``project-name`` and not ``projectname``). - Define repeatable options with multiline strings or list literals instead of commas separated string. pydoctor-24.11.2/docs/source/index.rst000066400000000000000000000006341473665144200176340ustar00rootroot00000000000000Introduction ============ Welcome to ``pydoctor``'s documentation! .. toctree:: :maxdepth: 4 :caption: Contents: quickstart codedoc docformat/index sphinx-integration customize help faq transition contrib readme .. toctree:: :maxdepth: 1 :caption: Quick links api/index GitHub PyPI pydoctor-24.11.2/docs/source/publish-github-action.rst000066400000000000000000000042251473665144200227260ustar00rootroot00000000000000:orphan: Simple GitHub Action to publish API docs ---------------------------------------- Here is an example of a simple GitHub Action to automatically generate your documentation with Pydoctor and publish it to your default GitHub Pages website when there is a push on the ``main`` branch. Just substitute `(projectname)` and `(packagedirectory)` with the appropriate information. :: name: apidocs on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Set up Python 3.12 uses: actions/setup-python@v2 with: python-version: 3.12 - name: Install requirements for documentation generation run: | python -m pip install --upgrade pip setuptools wheel python -m pip install pydoctor - name: Generate API documentation with pydoctor run: | # Run pydoctor build pydoctor \ --project-name=(projectname) \ --project-url=https://github.com/$GITHUB_REPOSITORY \ --html-viewsource-base=https://github.com/$GITHUB_REPOSITORY/tree/$GITHUB_SHA \ --html-base-url=https://$GITHUB_REPOSITORY_OWNER.github.io/${GITHUB_REPOSITORY#*/} \ --html-output=./apidocs \ --docformat=restructuredtext \ --intersphinx=https://docs.python.org/3/objects.inv \ ./(packagedirectory) - name: Push API documentation to Github Pages uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./apidocs commit_message: "Generate API documentation" .. note:: As mentioned in the ``actions-gh-pages`` `documentation`__, the first workflow run won't actually publish the documentation to GitHub Pages. GitHub Pages needs to be enabled afterwards in the repository settings, select ``gh-pages`` branch, then re-run your workflow. The website will be located at `https://(user).github.io/(repo)/`. __ https://github.com/peaceiris/actions-gh-pages pydoctor-24.11.2/docs/source/publish-readthedocs.rst000066400000000000000000000040171473665144200224550ustar00rootroot00000000000000:orphan: Simple ReadTheDocs config to publish API docs --------------------------------------------- Here is an example of a simple ReadTheDocs integration to automatically generate your documentation with Pydoctor. .. note:: This kind of integration should not be confused with `Sphinx support `_ that can also be used to run pydoctor inside ReadTheDocs as part of the standard Sphinx build process. This page, on the other hand, documents **how to simply run pydoctor and publish on ReadTheDocs** by using build customizations features. This example only includes a configuration file (``.readthedocs.yaml``), but the repository must also have been integrated to ReadTheDocs (by linking your Github account and importing your project for instance or by `manual webhook configuration `_). The config file below assume you're cloning your repository with http(s) protocol and that repository is a GitHub instance (the value of ``--html-viewsource-base`` could vary depending on your git server). Though, a similar process can be applied to Gitea, GitLab, Bitbucket ot others git servers. Just substitute `(projectname)` and `(packagedirectory)` with the appropriate information. .. code:: yaml version: 2 build: os: "ubuntu-22.04" tools: python: "3.10" commands: - pip install pydoctor - | pydoctor \ --project-name=(projectname) \ --project-version=${READTHEDOCS_GIT_IDENTIFIER} \ --project-url=${READTHEDOCS_GIT_CLONE_URL%*.git} \ --html-viewsource-base=${READTHEDOCS_GIT_CLONE_URL%*.git}/tree/${READTHEDOCS_GIT_COMMIT_HASH} \ --html-base-url=${READTHEDOCS_CANONICAL_URL} \ --html-output $READTHEDOCS_OUTPUT/html/ \ --docformat=restructuredtext \ --intersphinx=https://docs.python.org/3/objects.inv \ ./(packagedirectory) `More on ReadTheDocs build customizations `_. pydoctor-24.11.2/docs/source/quickstart.rst000066400000000000000000000051471473665144200207230ustar00rootroot00000000000000Quick Start =========== Installation ------------ Pydoctor can be installed from PyPI:: $ pip install -U pydoctor For Debian and derivatives, pydoctor can be installed with ``apt``:: $ sudo apt install pydoctor Example ------- The following example uses most common options to generate pydoctor's own API docs under the ``docs/api`` folder. It will add a link to the project website in the header of each page, show a link to its source code beside every documented object and resolve links to Python standard library objects. The result looks like `this `_. :: pydoctor \ --project-name=pydoctor \ --project-version=20.7.2 \ --project-url=https://github.com/twisted/pydoctor/ \ --html-viewsource-base=https://github.com/twisted/pydoctor/tree/20.7.2 \ --html-base-url=https://pydoctor.readthedocs.io/en/latest/api \ --html-output=docs/api \ --docformat=epytext \ --intersphinx=https://docs.python.org/3/objects.inv \ ./pydoctor .. note:: This example assume that you have cloned and installed ``pydoctor`` and you are running the ``pydoctor`` build from Unix and the current directory is the root folder of the Python project. .. tip:: First run pydoctor with ``--docformat=plaintext`` to focus on eventual python code parsing errors. Then, enable docstring parsing by selecting another `docformat `_. .. warning:: The ``--html-viewsource-base`` argument should point to a tag or a commit SHA rather than a branch since line numbers are not going to match otherwise when commits are added to the branch after the documentation has been published. Publish your documentation -------------------------- Output files are static HTML pages which require no extra server-side support. Here is a `GitHub Action example `_ to automatically publish your API documentation to your default GitHub Pages website. Here is a `ReadTheDocs configuration `_ to automatically publish your API documentation to ReadTheDocs Return codes ------------ Pydoctor is a pretty verbose tool by default. It’s quite unlikely that you get a zero exit code on the first run. But don’t worry, pydoctor should have produced useful HTML pages no matter your project design or docstrings. Exit codes includes: - ``0``: All docstrings are well formatted (warnings may be printed). - ``1``: Pydoctor crashed with traceback (default Python behaviour). - ``2``: Some docstrings are mal formatted. - ``3``: Pydoctor detects some warnings and ``--warnings-as-errors`` is enabled. pydoctor-24.11.2/docs/source/readme.rst000066400000000000000000000001121473665144200177510ustar00rootroot00000000000000Readme ====== This is the README.rst file .. include:: ../../README.rst pydoctor-24.11.2/docs/source/spelling_wordlist.txt000066400000000000000000000003521473665144200222750ustar00rootroot00000000000000backticks coroutine docstring docstrings Docutils epydoc epytext freenode Intersphinx jml Lange monkeypatch msiempy mth mwhudson mypy pre pydoctor readme reStructuredText subclassing tox Wokkel runtime Numpy numpy reST py customizablepydoctor-24.11.2/docs/source/sphinx-integration.rst000066400000000000000000000106521473665144200223600ustar00rootroot00000000000000 Sphinx Integration ================== Sphinx object inventories can be used to create links in both ways between documentation generated by pydoctor and by Sphinx. Linking from pydoctor to external API docs ------------------------------------------ It can link to external API documentation using a Sphinx objects inventory with the following cumulative configuration option:: --intersphinx=https://docs.python.org/3/objects.inv .. note:: The URL must point to the the ``objects.inv``. Then, your interpreted text, with backticks (`````) using `restructuredtext` and with ``L{}`` tag using `epytext`, will be linked to the Python element. Example:: `datetime.datetime` L{datetime.datetime} Simple as that! Linking from Sphinx to your pydoctor API docs --------------------------------------------- pydoctor's HTML generator will also generate a Sphinx objects inventory that can be used with the following mapping: * packages, modules -> ``:py:mod:`` * classes -> ``:py:class:`` * functions -> ``:py:func:`` * methods -> ``:py:meth:`` * attributes -> ``:py:attr:`` You can use this mapping in Sphinx via the `Intersphinx extension`__. __ https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html For an up to date lists of API links, run pydoctor before building the Sphinx documentation. You can use the ``--make-intersphinx`` option to only generate the object inventory file. You will then reference this file inside the Sphinx `intersphinx_mapping`. Note that relative paths are relative to the Sphinx source directory. You might need to exit the source and reference the build directory:: intersphinx_mapping = { 'twisted': ('https://twistedmatrix.com/documents/current/api/', '../../build/apidocs/objects.inv'), } Link to elements :py:func:`with custom text ` with:: :py:func:`with custom text ` Link to elements with default label :py:class:`twisted:twisted.web.client.HTTPDownloader` with:: :py:class:`twisted:twisted.web.client.HTTPDownloader` Building pydoctor together with Sphinx HTML build ------------------------------------------------- When running pydoctor with HTML generation it will generate a set of static HTML files that can be used any HTTP server. Under some circumstances (ex Read The Docs) you might want to trigger the pydoctor API docs build together with the Sphinx build. This can be done by using the :py:mod:`pydoctor.sphinx_ext.build_apidocs` extension. Inside your Sphinx ``conf.py`` file enable and configure the extension in this way:: extensions.append("pydoctor.sphinx_ext.build_apidocs") pydoctor_args = [ '--project-name=YOUR-PROJECT-NAME', '--project-version=YOUR-PUBLIC-VERSION', '--project-url=YOUR-PROJECT-HOME-URL', '--docformat=epytext', '--intersphinx=https://docs.python.org/3/objects.inv', '--html-viewsource-base=https://github.com/ORG/REPO/tree/default', '--html-output={outdir}/api', '--project-base-dir=path/to/source/code', 'path/to/source/code/package1' ] pydoctor_url_path = '/en/{rtd_version}/api/' You can pass almost any argument to ``pydoctor_args`` in the same way you call ``pydoctor`` from the command line. You don't need to pass the ``--make-html``, ``--make-intersphinx`` or ``--quiet`` arguments. The extension will add them automatically. The ``pydoctor_url_path`` is an URL path, relative to your public API documentation site. ``{rtd_version}`` will be replaced with the Read The Docs version (``stable`` , ``latest``, tag name). You only need to define this argument if you need to have Intersphinx links from your Sphinx narrative documentation to your pydoctor API documentation. As a hack to integrate the pydoctor API docs ``index.html`` with the Sphinx TOC and document reference, you can create an ``index.rst`` at the location where the pydoctor ``index.html`` is hosted. The Sphinx ``index.html`` will be generated during the Sphinx build process and later overwritten by the pydoctor build process. It is possible to call pydoctor multiple times (with different arguments) as part of the same build process. For this you need to define ``pydoctor_args`` as a dict. The key is the human readable build name and the value for each dict member is the list of arguments. See pydoctor's own `conf.py `_ for usage example. pydoctor-24.11.2/docs/source/transition.rst000066400000000000000000000015151473665144200207160ustar00rootroot00000000000000Transition to ``pydoctor`` ========================== From ``epydoc`` --------------- If you are looking for a successor to ``epydoc`` after moving to Python 3, ``pydoctor`` is the right tool for your project! - ``pydoctor`` dropped support for the ``X{}`` tag. All other epytext markup syntax should be fully supported. From ``pdoc3`` -------------- - ``pydoctor`` do not support Markdown docstrings. The easiest is to use *restructuredtext* docstring format as they are sharing numerous markup syntax. - ``pydoctor`` can only generate HTML, if you are using Markdown output, consider using ``pdocs``. - All references to ``__pdoc__`` module variable should be deleted as they are not supported. If you dynamically generated documentation, you should create a separate script and include it's output with an ``.. include::`` directive. pydoctor-24.11.2/docs/tests/000077500000000000000000000000001473665144200156325ustar00rootroot00000000000000pydoctor-24.11.2/docs/tests/__init__.py000066400000000000000000000004731473665144200177470ustar00rootroot00000000000000from pathlib import Path import os def get_toxworkdir_subdir(subdir:str) -> Path: dir = Path(os.environ['TOX_WORK_DIR']).joinpath(subdir) \ if os.environ.get('TOX_WORK_DIR') else Path(os.getcwd()).joinpath(f'./.tox/{subdir}') assert dir.exists(), f"Looks like {dir} not not exist!" return dir pydoctor-24.11.2/docs/tests/test-search.html000066400000000000000000000071631473665144200207510ustar00rootroot00000000000000


    
    
    
    
    
    
    
pydoctor-24.11.2/docs/tests/test.py000066400000000000000000000237731473665144200171770ustar00rootroot00000000000000#
# Run tests after the documentation is executed.
#
# These tests are designed to be executed inside tox, after sphinx-build.
#
import os
import pathlib
from typing import List
import xml.etree.ElementTree as ET
import json

from lunr.index import Index

from sphinx.ext.intersphinx import inspect_main

from pydoctor import __version__


BASE_DIR = pathlib.Path(os.environ.get('TOX_INI_DIR', os.getcwd())) / 'build' / 'docs'


def test_rtd_pydoctor_call():
    """
    With the pydoctor Sphinx extension, the pydoctor API HTML files are
    generated.
    """
    # The pydoctor index is generated and overwrites the Sphinx files.
    with open(BASE_DIR / 'api' / 'index.html', 'r') as stream:
        page = stream.read()
        assert 'moduleIndex.html' in page, page


def test_rtd_pydoctor_multiple_call():
    """
    With the pydoctor Sphinx extension can call pydoctor for more than one
    API doc source.
    """
    with open(BASE_DIR / 'docformat' / 'epytext_demo' / 'index.html', 'r') as stream:
        page = stream.read()
        assert 'pydoctor-epytext-demo' in page, page


def test_rtd_extension_inventory():
    """
    The Sphinx inventory is available during normal sphinx-build.
    """
    with open(BASE_DIR / 'sphinx-integration.html', 'r') as stream:
        page = stream.read()
        assert 'href="/en/latest/api/pydoctor.sphinx_ext.build_apidocs.html"' in page, page


def test_sphinx_object_inventory_version(capsys):
    """
    The Sphinx inventory is generated with the project version in the header.
    """
    # The pydoctor own inventory.
    apidocs_inv = BASE_DIR / 'api' / 'objects.inv'
    with open(apidocs_inv, 'rb') as stream:
        page = stream.read()
        assert page.startswith(
            b'# Sphinx inventory version 2\n'
            b'# Project: pydoctor\n'
            b'# Version: ' + __version__.encode() + b'\n'
            ), page

    # Check that inventory can be parsed by Sphinx own extension.
    inspect_main([str(apidocs_inv)])
    out, err = capsys.readouterr()

    assert '' == err
    assert 'pydoctor.driver.main' in out, out


def test_sphinx_object_inventory_version_epytext_demo():
    """
    The Sphinx inventory for demo/showcase code has a fixed version and name,
    passed via docs/source/conf.py.
    """
    with open(BASE_DIR / 'docformat' / 'epytext_demo' / 'objects.inv', 'rb') as stream:
        page = stream.read()
        assert page.startswith(
            b'# Sphinx inventory version 2\n'
            b'# Project: pydoctor-epytext-demo\n'
            b'# Version: 1.3.0\n'
            ), page


def test_page_contains_infos():
    """
    Test if pydoctor.driver.html contains the following informations:

        - meta generator tag
        - nav and links to modules, classes, names
        - js script source
        - pydoctor github link in the footer
        - canonical link
    """

    infos = (f'',
              'pydoctor',
              'pydoctor',
              'Twisted',
              '',
              ' None:
    """
    Run some searches on the lunr index to test it's validity. 
    """

    with (BASE_DIR / 'api' / 'searchindex.json').open() as fobj:
        index_data = json.load(fobj)
        index = Index.load(index_data)

        def test_search(query:str, expected:List[str], order_is_important:bool=True) -> None:
            if order_is_important:
                assert [r["ref"] for r in index.search(query)] == expected
            else:
                assert sorted([r["ref"] for r in index.search(query)]) == sorted(expected)

        test_search('+qname:pydoctor', ['pydoctor'])
        test_search('+qname:pydoctor.epydoc2stan', ['pydoctor.epydoc2stan'])
        test_search('_colorize_re_pattern', ['pydoctor.epydoc.markup._pyval_repr.PyvalColorizer._colorize_re_pattern'])
        
        test_search('+name:Class', 
            ['pydoctor.model.Class', 
             'pydoctor.factory.Factory.Class',
             'pydoctor.model.DocumentableKind.CLASS',
             'pydoctor.model.System.Class', 
             ])
        
        to_stan_results = [
                    'pydoctor.epydoc.markup.ParsedDocstring.to_stan', 
                    'pydoctor.epydoc.markup.plaintext.ParsedPlaintextDocstring.to_stan',
                    'pydoctor.epydoc.markup._types.ParsedTypeDocstring.to_stan',
                    'pydoctor.epydoc.markup._pyval_repr.ColorizedPyvalRepr.to_stan',
                    'pydoctor.epydoc2stan.ParsedStanOnly.to_stan',
                ]
        test_search('to_stan*', to_stan_results, order_is_important=False)
        test_search('to_stan', to_stan_results, order_is_important=False)

        to_node_results = [
                    'pydoctor.epydoc.markup.ParsedDocstring.to_node', 
                    'pydoctor.epydoc.markup.plaintext.ParsedPlaintextDocstring.to_node',
                    'pydoctor.epydoc.markup._types.ParsedTypeDocstring.to_node',
                    'pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring.to_node',
                    'pydoctor.epydoc.markup.epytext.ParsedEpytextDocstring.to_node',
                    'pydoctor.epydoc2stan.ParsedStanOnly.to_node',
                ]
        test_search('to_node*', to_node_results, order_is_important=False)
        test_search('to_node', to_node_results, order_is_important=False)
        
        test_search('qname:pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring', 
                ['pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring'])
        test_search('pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring', 
                ['pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring'])

def test_pydoctor_test_is_hidden():
    """
    Test that option --privacy=HIDDEN:pydoctor.test makes everything under pydoctor.test HIDDEN.
    """

    def getText(node: ET.Element) -> str:
        return ''.join(node.itertext()).strip()

    with open(BASE_DIR / 'api' / 'all-documents.html', 'r', encoding='utf-8') as stream:
        document = ET.fromstring(stream.read())
        for liobj in document.findall('body/div/ul/li[@id]'):
            
            if not str(liobj.get("id")).startswith("pydoctor"):
                continue # not a all-documents list item, maybe in the menu or whatever.
            
            # figure obj name
            fullName = getText(liobj.findall('./div[@class=\'fullName\']')[0])
            
            if fullName.startswith("pydoctor.test"):
                # figure obj privacy
                privacy = getText(liobj.findall('./div[@class=\'privacy\']')[0])
                # check that it's indeed private
                assert privacy == 'HIDDEN'

def test_missing_subclasses():
    """
    Test for missing subclasses of ParsedDocstring, issue https://github.com/twisted/pydoctor/issues/528.
    """

    infos = ('pydoctor.epydoc.markup._types.ParsedTypeDocstring', 
        'pydoctor.epydoc.markup.epytext.ParsedEpytextDocstring', 
        'pydoctor.epydoc.markup.plaintext.ParsedPlaintextDocstring', 
        'pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring', 
        'pydoctor.epydoc2stan.ParsedStanOnly', )

    with open(BASE_DIR / 'api' / 'pydoctor.epydoc.markup.ParsedDocstring.html', 'r', encoding='utf-8') as stream:
        page = stream.read()
        for i in infos:
            assert i in page, page
pydoctor-24.11.2/docs/tests/test_python_igraph_docs.py000066400000000000000000000017331473665144200231320ustar00rootroot00000000000000#
# Run tests after python-igraph's documentation is executed.
#
# These tests are designed to be executed inside tox, after pydoctor is run.
# Alternatively this can be excuted manually from the project root folder like:
#   pytest docs/tests/test_python_igraph_docs.py

from . import get_toxworkdir_subdir

BASE_DIR = get_toxworkdir_subdir('python-igraph-output')

def test_python_igraph_docs() -> None:
    """
    Test for https://github.com/twisted/pydoctor/issues/287
    """

    with open(BASE_DIR / 'igraph.html') as stream:
        page = stream.read()
        assert all(impl in page for impl in ['href="igraph._igraph.html"']), page

    with open(BASE_DIR / 'igraph.Graph.html') as stream:
        page = stream.read()
        assert all(impl in page for impl in ['href="igraph.GraphBase.html"']), page

    with open(BASE_DIR / 'igraph.GraphBase.html') as stream:
        page = stream.read()
        assert all(impl in page for impl in ['href="igraph.Graph.html"']), page
pydoctor-24.11.2/docs/tests/test_standard_library_docs.py000066400000000000000000000023551473665144200236040ustar00rootroot00000000000000#
# Run tests after Python standard library's documentation is executed.
#
# These tests are designed to be executed inside tox, after pydoctor is run.
# Alternatively this can be excuted manually from the project root folder like:
#   pytest docs/tests/test_standard_library_docs.py

from . import get_toxworkdir_subdir

PYTHON_DIR = get_toxworkdir_subdir('cpython')
BASE_DIR = get_toxworkdir_subdir('cpython-output')

def test_std_lib_docs() -> None:
    """
    For each top-level module in python standard library, check if there is an associated documentation page.
    """
    for entry in PYTHON_DIR.joinpath('Lib').iterdir():
        if entry.is_file() and entry.suffix=='.py': # Module
            name = entry.name[0:-3]
            if name == "__init__": continue
            assert BASE_DIR.joinpath('Lib.'+name+'.html').exists()
        
        elif entry.is_dir() and entry.joinpath('__init__.py').exists(): # Package
            assert BASE_DIR.joinpath('Lib.'+entry.name+'.html').exists()

def test_std_lib_logs() -> None:
    """
    'Cannot parse file' do not appear too much.
    This test expect a run.log file in cpython-output directory
    """
    log = (BASE_DIR / 'run.log').read_text()
    assert log.count('cannot parse file') == 4

pydoctor-24.11.2/docs/tests/test_twisted_docs.py000066400000000000000000000037721473665144200217470ustar00rootroot00000000000000#
# Run tests after Twisted's the documentation is executed.
#
# These tests are designed to be executed inside tox, after bin/admin/build-apidocs.
# Alternatively this can be excuted manually from the project root folder like:
#   pytest docs/tests/test_twisted_docs.py

from . import get_toxworkdir_subdir

BASE_DIR = get_toxworkdir_subdir('twisted-apidocs-build')

# Test for https://github.com/twisted/pydoctor/issues/428
def test_IPAddress_implementations() -> None:
    """
    This test ensures all important subclasses of IAddress show up in the IAddress class page documentation.
    """

    show_up = ['twisted.internet.address.IPv4Address', 
        'twisted.internet.address.IPv6Address', 
        'twisted.internet.address.HostnameAddress', 
        'twisted.internet.address.UNIXAddress']

    with open(BASE_DIR / 'twisted.internet.interfaces.IAddress.html') as stream:
        page = stream.read()
        assert all(impl in page for impl in show_up), page

# Test for https://github.com/twisted/pydoctor/issues/505
def test_web_template_api() -> None:
    """
    This test ensures all important members of the twisted.web.template 
    module are documented at the right place
    """

    exists = ['twisted.web.template.Tag.html', 
        'twisted.web.template.slot.html', 
        'twisted.web.template.Comment.html', 
        'twisted.web.template.CDATA.html',
        'twisted.web.template.CharRef.html',
        'twisted.web.template.TagLoader.html',
        'twisted.web.template.XMLString.html',
        'twisted.web.template.XMLFile.html',
        'twisted.web.template.Element.html',]
    for e in exists:
        assert (BASE_DIR / e).exists(), f"{e} not found"
    
    show_up = [
        'twisted.web.template.renderer',
        'twisted.web.template.flatten',
        'twisted.web.template.flattenString', 
        'twisted.web.template.renderElement']

    with open(BASE_DIR / 'twisted.web.template.html') as stream:
        page = stream.read()
        assert all(impl in page for impl in show_up), page
pydoctor-24.11.2/mypy.ini000066400000000000000000000026561473665144200152500ustar00rootroot00000000000000[mypy]
disallow_any_generics=True
disallow_incomplete_defs=True
disallow_untyped_defs=True
namespace_packages=True
no_implicit_optional=True
show_error_codes=True
warn_no_return=True
warn_redundant_casts=True
warn_return_any=True
warn_unreachable=True
warn_unused_configs=True
warn_unused_ignores=True

plugins=mypy_zope:plugin

# The following modules are currently only partially annotated:

[mypy-pydoctor.test.test_napoleon_docstring]
disallow_untyped_defs=False

[mypy-pydoctor.test.test_napoleon_iterators]
disallow_untyped_defs=False

# The following external libraries don't support annotations (yet):

[mypy-appdirs.*]
ignore_missing_imports=True

[mypy-astor.*]
ignore_missing_imports=True

[mypy-bs4.*]
ignore_missing_imports=True

[mypy-cachecontrol.*]
ignore_missing_imports=True

[mypy-configargparse.*]
ignore_missing_imports=True

[mypy-cython_test_exception_raiser.*]
ignore_missing_imports=True

[mypy-incremental.*]
ignore_missing_imports=True

[mypy-lunr.*]
ignore_missing_imports=True

[mypy-urllib3.*]
ignore_missing_imports=True

[mypy-pydoctor.epydoc.sre_parse36]
ignore_errors=True

[mypy-pydoctor.epydoc.sre_constants36]
ignore_errors=True

# Don't check the C3 lineartization code
[mypy-pydoctor.mro.*]
ignore_errors=True

# Don't check our test data:

[mypy-pydoctor.test.testpackages.*]
ignore_errors=True

# The following external library doesn't exist (it's used in the demo):

[mypy-somelib.*]
ignore_missing_imports=True
pydoctor-24.11.2/pydoctor/000077500000000000000000000000001473665144200154035ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/__init__.py000066400000000000000000000005311473665144200175130ustar00rootroot00000000000000"""PyDoctor, an API documentation generator for Python libraries.

Warning: PyDoctor's API isn't stable YET, custom builds are prone to break!

"""
# On Python 3.8+, use importlib.metadata from the standard library.
import importlib.metadata as importlib_metadata

__version__ = importlib_metadata.version('pydoctor')

__all__ = ["__version__"]
pydoctor-24.11.2/pydoctor/__main__.py000066400000000000000000000001511473665144200174720ustar00rootroot00000000000000import sys
from pydoctor.driver import main

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
pydoctor-24.11.2/pydoctor/_configparser.py000066400000000000000000000460051473665144200206030ustar00rootroot00000000000000"""
Useful extension to L{configargparse} config file parsers.

Provides L{configargparse.ConfigFileParser} classes to parse C{TOML} and C{INI} files with **mandatory** support for sections.
Useful to integrate configuration into project files like C{pyproject.toml} or C{setup.cfg}.

L{TomlConfigParser} usage: 

>>> TomlParser = TomlConfigParser(['tool.my_super_tool']) # Simple TOML parser.
>>> parser = ArgumentParser(..., default_config_files=['./pyproject.toml'], config_file_parser_class=TomlParser)

L{IniConfigParser} works the same way (also it optionaly convert multiline strings to list with argument C{split_ml_text_to_list}).

L{CompositeConfigParser} usage:

>>> MY_CONFIG_SECTIONS = ['tool.my_super_tool', 'tool:my_super_tool', 'my_super_tool']
>>> TomlParser =  TomlConfigParser(MY_CONFIG_SECTIONS)
>>> IniParser = IniConfigParser(MY_CONFIG_SECTIONS, split_ml_text_to_list=True)
>>> MixedParser = CompositeConfigParser([TomlParser, IniParser]) # This parser supports both TOML and INI formats.
>>> parser = ArgumentParser(..., default_config_files=['./pyproject.toml', 'setup.cfg', 'my_super_tool.ini'], config_file_parser_class=MixedParser)

"""
from __future__ import annotations

import argparse
from collections import OrderedDict
import re
import sys
from typing import Any, Callable, Dict, List, Optional, Tuple, TextIO, Union
import csv
import functools
import configparser
from ast import literal_eval
import warnings

from configargparse import ConfigFileParserException, ConfigFileParser, ArgumentParser

if sys.version_info >= (3, 11):
    from tomllib import load as _toml_load
    import io
    # The tomllib module from the standard library 
    # expect a binary IO and will fail if receives otherwise. 
    # So we hack a compat function that will work with TextIO and assume the utf-8 encoding.
    def toml_load(stream: TextIO) -> Any:
        return _toml_load(io.BytesIO(stream.read().encode()))
else:
    from toml import load as toml_load

# I did not invented these regex, just put together some stuff from:
# - https://stackoverflow.com/questions/11859442/how-to-match-string-in-quotes-using-regex
# - and https://stackoverflow.com/a/41005190

_QUOTED_STR_REGEX = re.compile(r'(^\"(?:\\.|[^\"\\])*\"$)|'
                               r'(^\'(?:\\.|[^\'\\])*\'$)')

_TRIPLE_QUOTED_STR_REGEX = re.compile(r'(^\"\"\"(\s+)?(([^\"]|\"([^\"]|\"[^\"]))*(\"\"?)?)?(\s+)?(?:\\.|[^\"\\])\"\"\"$)|'
                                                                                                 # Unescaped quotes at the end of a string generates 
                                                                                                 # "SyntaxError: EOL while scanning string literal", 
                                                                                                 # so we don't account for those kind of strings as quoted.
                                      r'(^\'\'\'(\s+)?(([^\']|\'([^\']|\'[^\']))*(\'\'?)?)?(\s+)?(?:\\.|[^\'\\])\'\'\'$)', flags=re.DOTALL)

@functools.lru_cache(maxsize=256, typed=True)
def is_quoted(text:str, triple:bool=True) -> bool:
    """
    Detect whether a string is a quoted representation. 

    @param triple: Also match tripple quoted strings.
    """
    return bool(_QUOTED_STR_REGEX.match(text)) or \
        (triple and bool(_TRIPLE_QUOTED_STR_REGEX.match(text)))

def unquote_str(text:str, triple:bool=True) -> str:
    """
    Unquote a maybe quoted string representation. 
    If the string is not detected as being a quoted representation, it returns the same string as passed.
    It supports all kinds of python quotes: C{\"\"\"}, C{'''}, C{"} and C{'}.

    @param triple: Also unquote tripple quoted strings.
    @raises ValueError: If the string is detected as beeing quoted but literal_eval() fails to evaluate it as string.
        This would be a bug in the regex. 
    """
    if is_quoted(text, triple=triple):
        try:
            s = literal_eval(text)
            assert isinstance(s, str)
        except Exception as e:
            raise ValueError(f"Error trying to unquote the quoted string: {text}: {e}") from e
        return s
    return text

def parse_toml_section_name(section_name:str) -> Tuple[str, ...]:
    """
    Parse a TOML section name to a sequence of strings.

    The following names are all valid::

        "a.b.c"            # this is best practice -> returns ("a", "b", "c")
        " d.e.f "          # same as [d.e.f] -> returns ("d", "e", "f")
        " g .  h  . i "    # same as [g.h.i] -> returns ("g", "h", "i")
        ' j . "ʞ" . "l" '  # same as [j."ʞ"."l"], double or simple quotes here are supported. -> returns ("j", "ʞ", "l")
    """
    section = []
    for row in csv.reader([section_name], delimiter='.'):
        for a in row:
            section.append(unquote_str(a.strip(), triple=False))
    return tuple(section)

def get_toml_section(data:Dict[str, Any], section:Union[Tuple[str, ...], str]) -> Optional[Dict[str, Any]]:
    """
    Given some TOML data (as loaded with C{toml.load()}), returns the requested section of the data.
    Returns C{None} if the section is not found.
    """
    sections = parse_toml_section_name(section) if isinstance(section, str) else section
    itemdata = data.get(sections[0])
    if not itemdata:
        return None
    sections = sections[1:]
    if sections:
        return get_toml_section(itemdata, sections)
    else:
        if not isinstance(itemdata, dict):
            return None
        return itemdata

class TomlConfigParser(ConfigFileParser):
    """
    U{TOML } parser with support for sections.

    This config parser can be used to integrate with C{pyproject.toml} files.

    Example::

        # this is a comment
        # this is TOML section table:
        [tool.my-software] 
        # how to specify a key-value pair (strings must be quoted):
        format-string = "restructuredtext"
        # how to set an arg which has action="store_true":
        warnings-as-errors = true
        # how to set an arg which has action="count" or type=int:
        verbosity = 1
        # how to specify a list arg (eg. arg which has action="append"):
        repeatable-option = ["https://docs.python.org/3/objects.inv",
                        "https://twistedmatrix.com/documents/current/api/objects.inv"]
        # how to specify a multiline text:
        multi-line-text = '''
            Lorem ipsum dolor sit amet, consectetur adipiscing elit. 
            Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. 
            Maecenas quis dapibus leo, a pellentesque leo. 
            '''
        # how to specify a empty text:
        empty-text = ''
        # how to specify a empty list:
        empty-list = []

    Usage:

    >>> import configargparse
    >>> parser = configargparse.ArgParser(
    ...             default_config_files=['pyproject.toml', 'my_super_tool.toml'],
    ...             config_file_parser_class=configargparse.TomlConfigParser(['tool.my_super_tool']),
    ...          )

    """

    def __init__(self, sections: List[str]) -> None:
        super().__init__()
        self.sections = sections
    
    def __call__(self) -> ConfigFileParser:
        return self

    def parse(self, stream:TextIO) -> Dict[str, Any]:
        """Parses the keys and values from a TOML config file."""
        # parse with configparser to allow multi-line values
        try:
            config = toml_load(stream)
        except Exception as e:
            raise ConfigFileParserException("Couldn't parse TOML file: %s" % e)

        # convert to dict and filter based on section names
        result: Dict[str, Any] = OrderedDict()

        for section in self.sections:
            data = get_toml_section(config, section)
            if data:
                # Seems a little weird, but anything that is not a list is converted to string, 
                # It will be converted back to boolean, int or whatever after.
                # Because config values are still passed to argparser for computation.
                for key, value in data.items():
                    if isinstance(value, list):
                        result[key] = [str(i) for i in value]
                    elif value is None:
                        pass
                    else:
                        result[key] = str(value)
                break
        
        return result

    def get_syntax_description(self) -> str:
        return ("Config file syntax is Tom's Obvious, Minimal Language. "
                "See https://github.com/toml-lang/toml/blob/v0.5.0/README.md for details.")

class IniConfigParser(ConfigFileParser):
    """
    INI parser with support for sections.
    
    This parser somewhat ressembles L{configargparse.ConfigparserConfigFileParser}. 
    It uses L{configparser} and evaluate values written with python list syntax. 

    With the following changes: 
        - Must be created with argument to bind the parser to a list of sections.
        - Does not convert multiline strings to single line.
        - Optional support for converting multiline strings to list (if ``split_ml_text_to_list=True``). 
        - Optional support for quoting strings in config file 
            (useful when text must not be converted to list or when text 
            should contain trailing whitespaces).
        - Comments may only appear on their own in an otherwise empty line (like in configparser).

    This config parser can be used to integrate with ``setup.cfg`` files.

    Example::

        # this is a comment
        ; also a comment
        [my_super_tool]
        # how to specify a key-value pair:
        format-string: restructuredtext 
        # white space are ignored, so name = value same as name=value
        # this is why you can quote strings (double quotes works just as well)
        quoted-string = '\thello\tmom...  '
        # how to set an arg which has action="store_true"
        warnings-as-errors = true
        # how to set an arg which has action="count" or type=int
        verbosity = 1
        # how to specify a list arg (eg. arg which has action="append")
        repeatable-option = ["https://docs.python.org/3/objects.inv",
                        "https://twistedmatrix.com/documents/current/api/objects.inv"]
        # how to specify a multiline text:
        multi-line-text = 
            Lorem ipsum dolor sit amet, consectetur adipiscing elit. 
            Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. 
            Maecenas quis dapibus leo, a pellentesque leo. 
        # how to specify a empty text:
        empty-text = 
        # this also works:
        empty-text = ''
        # how to specify a empty list:
        empty-list = []

    If you use L{IniConfigParser(sections, split_ml_text_to_list=True)}, 
    the same rules are applicable with the following changes::

        [my-software]
        # to specify a list arg (eg. arg which has action="append"), 
        # just enter one value per line (the list literal format can still be used):
        repeatable-option =
            https://docs.python.org/3/objects.inv
            https://twistedmatrix.com/documents/current/api/objects.inv
        # to specify a multiline text, you have to quote it:
        multi-line-text = '''
            Lorem ipsum dolor sit amet, consectetur adipiscing elit. 
            Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. 
            Maecenas quis dapibus leo, a pellentesque leo. 
            '''
        # how to specify a empty text:
        empty-text = ''
        # how to specify a empty list:
        empty-list = []
        # the following empty value would be simply ignored because we can't 
        # differenciate between simple value and list value without any data:
        totally-ignored-field = 

    Usage:

    >>> import configargparse
    >>> parser = configargparse.ArgParser(
    ...             default_config_files=['setup.cfg', 'my_super_tool.ini'],
    ...             config_file_parser_class=configargparse.IniConfigParser(['tool:my_super_tool', 'my_super_tool']),
    ...          )

    """

    def __init__(self, sections:List[str], split_ml_text_to_list:bool) -> None:
        super().__init__()
        self.sections = sections
        self.split_ml_text_to_list = split_ml_text_to_list

    def __call__(self) -> ConfigFileParser:
        return self

    def parse(self, stream:TextIO) -> Dict[str, Any]:
        """Parses the keys and values from an INI config file."""
        # parse with configparser to allow multi-line values
        config = configparser.ConfigParser()
        try:
            config.read_string(stream.read())
        except Exception as e:
            raise ConfigFileParserException("Couldn't parse INI file: %s" % e)

        # convert to dict and filter based on INI section names
        result: Dict[str, Union[str, List[str]]] = OrderedDict()
        for section in config.sections() + [configparser.DEFAULTSECT]:
            if section not in self.sections:
                continue
            for k,value in config[section].items():
                # value is already strip by configparser
                if not value and self.split_ml_text_to_list:
                    # ignores empty values when split_ml_text_to_list is True
                    # because we can't differenciate empty list and empty string.
                    continue
                # evaluate lists
                if value.startswith('[') and value.endswith(']'):
                    try:
                        l = literal_eval(value)
                        assert isinstance(l, list)
                        # Ensure all list values are strings.
                        result[k] = [str(i) for i in l]
                    except Exception as e:
                        # error evaluating object
                        _tripple = 'tripple ' if '\n' in value else ''
                        raise ConfigFileParserException("Error evaluating list: " + str(e) + f". Put {_tripple}quotes around your text if it's meant to be a string.") from e
                else:
                    if is_quoted(value):
                        # evaluate quoted string
                        try:
                            result[k] = unquote_str(value)
                        except ValueError as e:
                            # error unquoting string
                            raise ConfigFileParserException(str(e)) from e
                    # split multi-line text into list of strings if split_ml_text_to_list is enabled.
                    elif self.split_ml_text_to_list and '\n' in value.rstrip('\n'):
                        result[k] = [i for i in value.split('\n') if i]
                    else:
                        result[k] = value
        return result

    def get_syntax_description(self) -> str:
        msg = ("Uses configparser module to parse an INI file which allows multi-line values. "
                "See https://docs.python.org/3/library/configparser.html for details. "
                "This parser includes support for quoting strings literal as well as python list syntax evaluation. ")
        if self.split_ml_text_to_list:
            msg += ("Alternatively lists can be constructed with a plain multiline string, "
                "each non-empty line will be converted to a list item.")
        return msg

class CompositeConfigParser(ConfigFileParser):
    """
    A config parser that understands multiple formats.

    This parser will successively try to parse the file with each compisite parser, until it succeeds, 
    else it fails showing all encountered error messages.

    The following code will make configargparse understand both TOML and INI formats. 
    Making it easy to integrate in both C{pyproject.toml} and C{setup.cfg}.

    >>> import configargparse
    >>> my_tool_sections = ['tool.my_super_tool', 'tool:my_super_tool', 'my_super_tool']
    ...                     # pyproject.toml like section, setup.cfg like section, custom section
    >>> parser = configargparse.ArgParser(
    ...             default_config_files=['setup.cfg', 'my_super_tool.ini'],
    ...             config_file_parser_class=configargparse.CompositeConfigParser(
    ...             [configargparse.TomlConfigParser(my_tool_sections), 
    ...                 configargparse.IniConfigParser(my_tool_sections, split_ml_text_to_list=True)]
    ...             ),
    ...          )

    """

    def __init__(self, config_parser_types: List[Callable[[], ConfigFileParser]]) -> None:
        super().__init__()
        self.parsers = [p() for p in config_parser_types]

    def __call__(self) -> ConfigFileParser:
        return self

    def parse(self, stream:TextIO) -> Dict[str, Any]:
        errors = []
        for p in self.parsers:
            try:
                return p.parse(stream) # type: ignore[no-any-return]
            except Exception as e:
                stream.seek(0)
                errors.append(e)
        raise ConfigFileParserException(
                f"Error parsing config: {', '.join(repr(str(e)) for e in errors)}")
    
    def get_syntax_description(self) -> str:
        msg = "Uses multiple config parser settings (in order): \n"
        for i, parser in enumerate(self.parsers): 
            msg += f"[{i+1}] {parser.__class__.__name__}: {parser.get_syntax_description()} \n"
        return msg

class ValidatorParser(ConfigFileParser):
    """
    A parser that warns when unknown options are used. 
    It must be created with a reference to the ArgumentParser object, so like::

        parser = ArgumentParser(
            prog='mysoft',
            config_file_parser_class=ConfigParser,)
    
        # Add the validator to the config file parser, this is arguably a hack.
        parser._config_file_parser = ValidatorParser(parser._config_file_parser, parser)
    
    @note: Using this parser implies acting 
        like L{ArgumentParser}'s option C{ignore_unknown_config_file_keys=True}.
        So no need to explicitely mention it.
    """

    def __init__(self, config_parser: ConfigFileParser, argument_parser: ArgumentParser) -> None:
        super().__init__()
        self.config_parser = config_parser
        self.argument_parser = argument_parser
    
    def get_syntax_description(self) -> str:
        return self.config_parser.get_syntax_description() #type:ignore[no-any-return]

    def parse(self, stream:TextIO) -> Dict[str, Any]:
        data: Dict[str, Any] = self.config_parser.parse(stream)

        # Prepare for checking config file.
        # This code maps all supported config keys to their 
        # argparse action counterpart, it will allow more checks to be done down the road.
        known_config_keys: Dict[str, argparse.Action] = {config_key: action for action in self.argument_parser._actions
            for config_key in self.argument_parser.get_possible_config_keys(action)}

        # Trigger warning
        new_data = {}
        for key, value in data.items():
            action = known_config_keys.get(key)
            if not action:
                # Warn "no such config option"
                warnings.warn(f"No such config option: {key!r}")
                # Remove option
            else:
                new_data[key] = value
        
        return new_data
pydoctor-24.11.2/pydoctor/astbuilder.py000066400000000000000000001630501473665144200201200ustar00rootroot00000000000000"""Convert ASTs into L{pydoctor.model.Documentable} instances."""
from __future__ import annotations

import ast
import contextlib
import sys

from functools import partial
from inspect import Parameter, Signature
from pathlib import Path
from typing import (
    Any, Callable, Collection, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple,
    Type, TypeVar, Union, Set, cast
)

from pydoctor import epydoc2stan, model, node2stan, extensions, linker
from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval
from pydoctor.astutils import (is_none_literal, is_typing_annotation, is_using_annotations, is_using_typing_final, node2dottedname, node2fullname, 
                               is__name__equals__main__, unstring_annotation, upgrade_annotation, iterassign, extract_docstring_linenum, infer_type, get_parents,
                               get_docstring_node, get_assign_docstring_node, unparse, NodeVisitor, Parentage, Str)


def parseFile(path: Path) -> ast.Module:
    """Parse the contents of a Python source file."""
    with open(path, 'rb') as f:
        src = f.read() + b'\n'
    return _parse(src, filename=str(path))

if sys.version_info >= (3,8):
    _parse = partial(ast.parse, type_comments=True)
else:
    _parse = ast.parse

def _maybeAttribute(cls: model.Class, name: str) -> bool:
    """Check whether a name is a potential attribute of the given class.
    This is used to prevent an assignment that wraps a method from
    creating an attribute that would overwrite or shadow that method.

    @return: L{True} if the name does not exist or is an existing (possibly
        inherited) attribute, L{False} if this name defines something else than an L{Attribute}. 
    """
    obj = cls.find(name)
    return obj is None or isinstance(obj, model.Attribute)

class IgnoreAssignment(Exception):
    """
    A control flow exception meaning that the assignment should not be further proccessed.
    """

def _handleAliasing(
        ctx: model.CanContainImportsDocumentable,
        target: str,
        expr: Optional[ast.expr]
        ) -> bool:
    """If the given expression is a name assigned to a target that is not yet
    in use, create an alias.
    @return: L{True} iff an alias was created.
    """
    if target in ctx.contents:
        return False
    full_name = node2fullname(expr, ctx)
    if full_name is None:
        return False
    ctx._localNameToFullName_map[target] = full_name
    return True


_CONTROL_FLOW_BLOCKS:Tuple[Type[ast.stmt],...] = (ast.If, ast.While, ast.For, ast.Try, 
                                            ast.AsyncFor, ast.With, ast.AsyncWith)
"""
AST types that introduces a new control flow block, potentially conditionnal.
"""
if sys.version_info >= (3, 10):
    _CONTROL_FLOW_BLOCKS += (ast.Match,)
if sys.version_info >= (3, 11):
    _CONTROL_FLOW_BLOCKS += (ast.TryStar,)

def is_constant(obj: model.Attribute, 
                annotation:Optional[ast.expr], 
                value:Optional[ast.expr]) -> bool:
    """
    Detect if the given assignment is a constant. 

    For an assignment to be detected as constant, it should: 
        - have all-caps variable name or using L{typing.Final} annotation
        - not be overriden
        - not be defined in a conditionnal block or any other kind of control flow blocks
    
    @note: Must be called after setting obj.annotation to detect variables using Final.
    """
    if is_using_typing_final(annotation, obj):
        return True
    if not is_attribute_overridden(obj, value) and value:
        if not any(isinstance(n, _CONTROL_FLOW_BLOCKS) for n in get_parents(value)):
            return obj.name.isupper()
    return False

class TypeAliasVisitorExt(extensions.ModuleVisitorExt):
    """
    This visitor implements the handling of type aliases and type variables.
    """
    def _isTypeVariable(self, ob: model.Attribute) -> bool:
        if ob.value is not None:
            if isinstance(ob.value, ast.Call) and \
                node2fullname(ob.value.func, ob) in ('typing.TypeVar', 
                                                     'typing_extensions.TypeVar',
                                                     'typing.TypeVarTuple', 
                                                     'typing_extensions.TypeVarTuple'):
                return True
        return False
    
    def _isTypeAlias(self, ob: model.Attribute) -> bool:
        """
        Return C{True} if the Attribute is a type alias.
        """
        if ob.value is not None:
            if is_using_annotations(ob.annotation, ('typing.TypeAlias', 
                                                    'typing_extensions.TypeAlias'), ob):
                return True
            if is_typing_annotation(ob.value, ob.parent):
                return True
        return False

    def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None:
        current = self.visitor.builder.current
        for dottedname in iterassign(node): 
            if dottedname and len(dottedname)==1:
                attr = current.contents.get(dottedname[0])
                if attr is None:
                    return
                if not isinstance(attr, model.Attribute):
                    return
                if self._isTypeAlias(attr) is True:
                    attr.kind = model.DocumentableKind.TYPE_ALIAS
                    # unstring type aliases
                    attr.value = upgrade_annotation(unstring_annotation(
                        # this cast() is safe because _isTypeAlias() return True only if value is not None
                        cast(ast.expr, attr.value), attr, section='type alias'), attr, section='type alias')
                elif self._isTypeVariable(attr) is True:
                    # TODO: unstring bound argument of type variables
                    attr.kind = model.DocumentableKind.TYPE_VARIABLE
    
    visit_AnnAssign = visit_Assign

def is_attribute_overridden(obj: model.Attribute, new_value: Optional[ast.expr]) -> bool:
    """
    Detect if the optional C{new_value} expression override the one already stored in the L{Attribute.value} attribute.
    """
    return obj.value is not None and new_value is not None

def _extract_annotation_subscript(annotation: ast.Subscript) -> ast.AST:
    """
    Extract the "str, bytes" part from annotations like  "Union[str, bytes]".
    """
    ann_slice = annotation.slice
    if sys.version_info < (3,9) and isinstance(ann_slice, ast.Index):
        return ann_slice.value
    else:
        return ann_slice

def extract_final_subscript(annotation: ast.Subscript) -> ast.expr:
    """
    Extract the "str" part from annotations like  "Final[str]".

    @raises ValueError: If the "Final" annotation is not valid.
    """ 
    ann_slice = _extract_annotation_subscript(annotation)
    if isinstance(ann_slice, (ast.ExtSlice, ast.Slice, ast.Tuple)):
        raise ValueError("Annotation is invalid, it should not contain slices.")
    else:
        assert isinstance(ann_slice, ast.expr)
        return ann_slice

class ModuleVistor(NodeVisitor):

    def __init__(self, builder: 'ASTBuilder', module: model.Module):
        super().__init__()
        self.builder = builder
        self.system = builder.system
        self.module = module
        self._override_guard_state: Tuple[Optional[model.Documentable], Set[str]] = (None, set())
    
    @contextlib.contextmanager
    def override_guard(self) -> Iterator[None]:
        """
        Returns a context manager that will make the builder ignore any new 
        assigments to existing names within the same context.  Currently used to visit C{If.orelse} and C{Try.handlers}.
        
        @note: The list of existing names is generated at the moment of
            calling the function, such that new names defined inside these blocks follows the usual override rules.
        """
        ctx = self.builder.current
        while not isinstance(ctx, model.CanContainImportsDocumentable):
            assert ctx.parent
            ctx = ctx.parent
        ignore_override_init = self._override_guard_state
        # we list names only once to ignore new names added inside the block,
        # they should be overriden as usual.
        self._override_guard_state = (ctx, set(ctx.localNames()))
        yield
        self._override_guard_state = ignore_override_init
    
    def _ignore_name(self, ob: model.Documentable, name:str) -> bool:
        """
        Should this C{name} be ignored because it matches 
        the override guard in the context of C{ob}?
        """
        ctx, names = self._override_guard_state
        return ctx is ob and name in names

    def _infer_attr_annotations(self, scope: model.Documentable) -> None:
        # Infer annotation when leaving scope so explicit
        # annotations take precedence.
        for attrib in scope.contents.values():
            if not isinstance(attrib, model.Attribute):
                continue
            # If this attribute has not explicit annotation, 
            # infer its type from it's ast expression.
            if attrib.annotation is None and attrib.value is not None:
                # do not override explicit annotation
                attrib.annotation = infer_type(attrib.value)
    
    def _tweak_constants_annotations(self, scope: model.Documentable) -> None:
        # tweak constants annotations when we leave the scope so we can still
        # check whether the annotation uses Final while we're visiting other nodes.
        for attrib in scope.contents.values():
            if not isinstance(attrib, model.Attribute) or attrib.kind is not model.DocumentableKind.CONSTANT :
                continue
            self._tweak_constant_annotation(attrib)

    def visit_If(self, node: ast.If) -> None:
        if isinstance(node.test, ast.Compare):
            if is__name__equals__main__(node.test):
                # skip if __name__ == '__main__': blocks since
                # whatever is declared in them cannot be imported
                # and thus is not part of the API
                raise self.SkipChildren()
    
    def depart_If(self, node: ast.If) -> None:
        # At this point the body of the If node has already been visited
        # Visit the 'orelse' block of the If node, with override guard
        with self.override_guard():
            for n in node.orelse:
                self.walkabout(n)
    
    def depart_Try(self, node: ast.Try) -> None:
        # At this point the body of the Try node has already been visited
        # Visit the 'orelse' and 'finalbody' blocks of the Try node.
        
        for n in node.orelse:
            self.walkabout(n)
        for n in node.finalbody:
            self.walkabout(n)
        
        # Visit the handlers with override guard 
        with self.override_guard():
            for h in node.handlers:
                for n in h.body:
                    self.walkabout(n)

    def visit_Module(self, node: ast.Module) -> None:
        assert self.module.docstring is None
        Parentage().visit(node)

        self.builder.push(self.module, 0)
        doc_node = get_docstring_node(node)
        if doc_node is not None:
            self.module.setDocstring(doc_node)
            epydoc2stan.extract_fields(self.module)

    def depart_Module(self, node: ast.Module) -> None:
        self._tweak_constants_annotations(self.builder.current)
        self._infer_attr_annotations(self.builder.current)
        self.builder.pop(self.module)

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        # Ignore classes within functions.
        parent = self.builder.current
        if isinstance(parent, model.Function):
            raise self.SkipNode()
        # Ignore in override guard
        if self._ignore_name(parent, node.name):
            raise self.IgnoreNode()

        rawbases = []
        initialbases = []
        initialbaseobjects = []

        for base_node in node.bases:
            # This handles generics in MRO, by extracting the first
            # subscript value::
            #   class Visitor(MyGeneric[T]):...
            # 'MyGeneric' will be added to rawbases instead 
            # of 'MyGeneric[T]' which cannot resolve to anything.
            name_node = base_node
            if isinstance(base_node, ast.Subscript):
                name_node = base_node.value
            
            str_base = '.'.join(node2dottedname(name_node) or \
                # Fallback on unparse() if the expression is unknown by node2dottedname().
                [unparse(base_node).strip()]) 
                
            # Store the base as string and as ast.expr in rawbases list.
            rawbases += [(str_base, base_node)]
            
            # Try to resolve the base, put None if could not resolve it,
            # if we can't resolve it now, it most likely mean that there are
            # import cycles (maybe in TYPE_CHECKING blocks). 
            # None bases will be re-resolved in post-processing.
            expandbase = parent.expandName(str_base)
            baseobj = self.system.objForFullName(expandbase)
            
            if not isinstance(baseobj, model.Class):
                baseobj = None
                
            initialbases.append(expandbase)
            initialbaseobjects.append(baseobj)

        lineno = node.lineno
        if node.decorator_list:
            lineno = node.decorator_list[0].lineno

        cls: model.Class = self.builder.pushClass(node.name, lineno)
        cls.decorators = []
        cls.rawbases = rawbases
        cls._initialbaseobjects = initialbaseobjects
        cls._initialbases = initialbases

        doc_node = get_docstring_node(node)
        if doc_node is not None:
            cls.setDocstring(doc_node)
            epydoc2stan.extract_fields(cls)

        if node.decorator_list:
            
            cls.raw_decorators = node.decorator_list
        
            for decnode in node.decorator_list:
                args: Optional[Sequence[ast.expr]]
                if isinstance(decnode, ast.Call):
                    base = node2fullname(decnode.func, parent)
                    args = decnode.args
                else:
                    base = node2fullname(decnode, parent)
                    args = None
                if base is None:  # pragma: no cover
                    # There are expressions for which node2data() returns None,
                    # but I cannot find any that don't lead to a SyntaxError
                    # when used in a decorator.
                    cls.report("cannot make sense of class decorator")
                else:
                    cls.decorators.append((base, args))

            
        # We're not resolving the subclasses at this point yet because all 
        # modules might not have been processed, and since subclasses are only used in the presentation,
        # it's better to resolve them in the post-processing instead.


    def depart_ClassDef(self, node: ast.ClassDef) -> None:
        self._tweak_constants_annotations(self.builder.current)
        self._infer_attr_annotations(self.builder.current)
        self.builder.popClass()


    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        ctx = self.builder.current
        if not isinstance(ctx, model.CanContainImportsDocumentable):
            # processing import statement in odd context
            return

        modname = node.module
        level = node.level
        if level:
            # Relative import.
            parent: Optional[model.Documentable] = ctx.parentMod
            if isinstance(ctx.module, model.Package):
                level -= 1
            for _ in range(level):
                if parent is None:
                    break
                parent = parent.parent
            if parent is None:
                assert ctx.parentMod is not None
                ctx.parentMod.report(
                    "relative import level (%d) too high" % node.level,
                    lineno_offset=node.lineno
                    )
                return
            if modname is None:
                modname = parent.fullName()
            else:
                modname = f'{parent.fullName()}.{modname}'
        else:
            # The module name can only be omitted on relative imports.
            assert modname is not None

        if node.names[0].name == '*':
            self._importAll(modname)
        else:
            self._importNames(modname, node.names)

    def _importAll(self, modname: str) -> None:
        """Handle a C{from  import *} statement."""

        current = self.builder.current

        mod = self.system.getProcessedModule(modname)
        if mod is None:
            # We don't have any information about the module, so we don't know
            # what names to import.
            current.report(f"import * from unknown {modname}", thresh=1)
            return

        current.report(f"import * from {modname}", thresh=1)

        # Get names to import: use __all__ if available, otherwise take all
        # names that are not private.
        names = mod.all
        if names is None:
            names = [ name for name in mod.localNames() 
                     if not name.startswith('_') ]

        # Fetch names to export.
        exports = self._getCurrentModuleExports()

        # Add imported names to our module namespace.
        assert isinstance(current, model.CanContainImportsDocumentable)
        _localNameToFullName = current._localNameToFullName_map
        expandName = mod.expandName
        for name in names:

            # # Ignore in override guard
            if self._ignore_name(current, name):
                continue

            if self._handleReExport(exports, name, name, mod) is True:
                continue

            _localNameToFullName[name] = expandName(name)

    def _getCurrentModuleExports(self) -> Collection[str]:
        # Fetch names to export.
        current = self.builder.current
        if isinstance(current, model.Module):
            exports = current.all
            if exports is None:
                exports = []
        else:
            # Don't export names imported inside classes or functions.
            exports = []
        return exports

    def _handleReExport(self, curr_mod_exports:Collection[str], 
                        origin_name:str, as_name:str,
                        origin_module:model.Module) -> bool:
        """
        Move re-exported objects into current module.

        @returns: True if the imported name has been sucessfully re-exported.
        """
        # Move re-exported objects into current module.
        current = self.builder.current
        modname = origin_module.fullName()
        if as_name in curr_mod_exports:
            # In case of duplicates names, we can't rely on resolveName,
            # So we use content.get first to resolve non-alias names. 
            ob = origin_module.contents.get(origin_name) or origin_module.resolveName(origin_name)
            if ob is None:
                current.report("cannot resolve re-exported name :"
                                        f'{modname}.{origin_name}', thresh=1)
            else:
                if origin_module.all is None or origin_name not in origin_module.all:
                    self.system.msg(
                        "astbuilder",
                        "moving %r into %r" % (ob.fullName(), current.fullName())
                        )
                    # Must be a Module since the exports is set to an empty list if it's not.
                    assert isinstance(current, model.Module)
                    ob.reparent(current, as_name)
                    return True
        return False

    def _importNames(self, modname: str, names: Iterable[ast.alias]) -> None:
        """Handle a C{from  import } statement."""

        # Process the module we're importing from.
        mod = self.system.getProcessedModule(modname)

        # Fetch names to export.
        exports = self._getCurrentModuleExports()

        current = self.builder.current
        assert isinstance(current, model.CanContainImportsDocumentable)
        _localNameToFullName = current._localNameToFullName_map
        for al in names:
            orgname, asname = al.name, al.asname
            if asname is None:
                asname = orgname
            
            # Ignore in override guard
            if self._ignore_name(current, asname):
                continue
            
            # If we're importing from a package, make sure imported modules
            # are processed (getProcessedModule() ignores non-modules).
            if isinstance(mod, model.Package):
                self.system.getProcessedModule(f'{modname}.{orgname}')
            if mod is not None and self._handleReExport(exports, orgname, asname, mod) is True:
                continue

            _localNameToFullName[asname] = f'{modname}.{orgname}'

    def visit_Import(self, node: ast.Import) -> None:
        """Process an import statement.

        The grammar for the statement is roughly:

        mod_as := DOTTEDNAME ['as' NAME]
        import_stmt := 'import' mod_as (',' mod_as)*

        and this is translated into a node which is an instance of Import wih
        an attribute 'names', which is in turn a list of 2-tuples
        (dotted_name, as_name) where as_name is None if there was no 'as foo'
        part of the statement.
        """
        current = self.builder.current
        if not isinstance(current, model.CanContainImportsDocumentable):
            # processing import statement in odd context
            return
        _localNameToFullName = current._localNameToFullName_map
        
        for al in node.names:
            targetname, asname = al.name, al.asname
            if asname is None:
                # we're keeping track of all defined names
                asname = targetname = targetname.split('.')[0]
            # Ignore in override guard
            if self._ignore_name(current, asname):
                continue
            _localNameToFullName[asname] = targetname

    def _handleOldSchoolMethodDecoration(self, target: str, expr: Optional[ast.expr]) -> bool:
        if not isinstance(expr, ast.Call):
            return False
        func = expr.func
        if not isinstance(func, ast.Name):
            return False
        func_name = func.id
        args = expr.args
        if len(args) != 1:
            return False
        arg, = args
        if not isinstance(arg, ast.Name):
            return False
        if target == arg.id and func_name in ['staticmethod', 'classmethod']:
            target_obj = self.builder.current.contents.get(target)
            if isinstance(target_obj, model.Function):

                # _handleOldSchoolMethodDecoration must only be called in a class scope.
                assert target_obj.kind is model.DocumentableKind.METHOD

                if func_name == 'staticmethod':
                    target_obj.kind = model.DocumentableKind.STATIC_METHOD
                elif func_name == 'classmethod':
                    target_obj.kind = model.DocumentableKind.CLASS_METHOD
                return True
        return False

    @classmethod
    def _handleConstant(cls, obj:model.Attribute, 
                             annotation:Optional[ast.expr], 
                             value:Optional[ast.expr],
                             lineno:int, 
                             defaultKind:model.DocumentableKind) -> None:
        if is_constant(obj, annotation=annotation, value=value):
            obj.kind = model.DocumentableKind.CONSTANT
            # do not call tweak annotation just yet...
        elif obj.kind is model.DocumentableKind.CONSTANT:
            # reset to the default kind only for attributes that were heuristically
            # declared as constants
            if not is_using_typing_final(obj.annotation, obj):
                obj.kind = defaultKind
    
    @staticmethod
    def _tweak_constant_annotation(obj: model.Attribute) -> None:
        # Display variables annotated with Final with the real type instead.
        annotation = obj.annotation
        if is_using_typing_final(annotation, obj):
            if isinstance(annotation, ast.Subscript):
                try:
                    annotation = extract_final_subscript(annotation)
                except ValueError as e:
                    obj.report(str(e), section='ast', lineno_offset=annotation.lineno-obj.linenumber)
                    obj.annotation = infer_type(obj.value) if obj.value else None
                else:
                    # Will not display as "Final[str]" but rather only "str"
                    obj.annotation = annotation
            else:
                # Just plain "Final" annotation.
                # Simply ignore it because it's duplication of information.
                obj.annotation = infer_type(obj.value) if obj.value else None

    @staticmethod
    def _setAttributeAnnotation(obj: model.Attribute, 
                                annotation: Optional[ast.expr],) -> None:
        if annotation is not None:
            # TODO: What to do when an attribute has several explicit annotations?
            # (mypy reports a warning in these kind of cases)
            obj.annotation = annotation

    @staticmethod
    def _storeAttrValue(obj:model.Attribute, new_value:Optional[ast.expr], 
                        augassign:Optional[ast.operator]=None) -> None:
        if new_value:
            if augassign: 
                if obj.value:
                    # We're storing the value of augmented assignemnt value as binop for the sake 
                    # of correctness, but we're not doing anything special with it at the
                    # moment, nonethless this could be useful for future developments.
                    # We don't bother reporting warnings, pydoctor is not a checker.
                    obj.value = ast.BinOp(left=obj.value, op=augassign, right=new_value)
            else:
                obj.value = new_value
    

    def _handleModuleVar(self,
            target: str,
            annotation: Optional[ast.expr],
            expr: Optional[ast.expr],
            lineno: int,
            augassign:Optional[ast.operator],
            ) -> None:
        if target in MODULE_VARIABLES_META_PARSERS:
            # This is metadata, not a variable that needs to be documented,
            # and therefore doesn't need an Attribute instance.
            raise IgnoreAssignment()
        parent = self.builder.current
        obj = parent.contents.get(target)
        if obj is None:
            if augassign:
                return
            obj = self.builder.addAttribute(name=target, 
                                            kind=model.DocumentableKind.VARIABLE, 
                                            parent=parent, 
                                            lineno=lineno)
        
        # If it's not an attribute it means that the name is already denifed as function/class 
        # probably meaning that this attribute is a bound callable. 
        #
        #   def func(value, stock) -> int:...
        #   var = 2
        #   func = partial(func, value=var)
        #
        # We don't know how to handle this,
        # so we ignore it to document the original object. This means that we might document arguments 
        # that are in reality not existing because they have values in a partial() call for instance.

        if not isinstance(obj, model.Attribute):
            raise IgnoreAssignment()
        
        self._setAttributeAnnotation(obj, annotation)
        
        obj.setLineNumber(lineno)
        
        self._handleConstant(obj, annotation, expr, lineno, 
                                  model.DocumentableKind.VARIABLE)
        self._storeAttrValue(obj, expr, augassign)

    def _handleAssignmentInModule(self,
            target: str,
            annotation: Optional[ast.expr],
            expr: Optional[ast.expr],
            lineno: int,
            augassign:Optional[ast.operator],
            ) -> None:
        module = self.builder.current
        assert isinstance(module, model.Module)
        if not _handleAliasing(module, target, expr):
            self._handleModuleVar(target, annotation, expr, lineno, augassign=augassign)
        else:
            raise IgnoreAssignment()

    def _handleClassVar(self,
            name: str,
            annotation: Optional[ast.expr],
            expr: Optional[ast.expr],
            lineno: int,
            augassign:Optional[ast.operator],
            ) -> None:
        
        cls = self.builder.current
        assert isinstance(cls, model.Class)
        if not _maybeAttribute(cls, name):
            raise IgnoreAssignment()

        # Class variables can only be Attribute, so it's OK to cast
        obj = cast(Optional[model.Attribute], cls.contents.get(name))

        if obj is None:
            if augassign:
                return
            obj = self.builder.addAttribute(name=name, kind=None, parent=cls, lineno=lineno)

        if obj.kind is None:
            obj.kind = model.DocumentableKind.CLASS_VARIABLE

        self._setAttributeAnnotation(obj, annotation)
        
        obj.setLineNumber(lineno)

        self._handleConstant(obj, annotation, expr, lineno, 
                                  model.DocumentableKind.CLASS_VARIABLE)
        self._storeAttrValue(obj, expr, augassign)

       
    def _handleInstanceVar(self,
            name: str,
            annotation: Optional[ast.expr],
            expr: Optional[ast.expr],
            lineno: int
            ) -> None:
        if not (cls:=self._getClassFromMethodContext()):
            raise IgnoreAssignment()
        if not _maybeAttribute(cls, name):
            raise IgnoreAssignment()
        if self._ignore_name(cls, name):
            raise IgnoreAssignment()

        # Class variables can only be Attribute, so it's OK to cast because we used _maybeAttribute() above.
        obj = cast(Optional[model.Attribute], cls.contents.get(name))
        if obj is None:
            obj = self.builder.addAttribute(name=name, kind=None, parent=cls, lineno=lineno)

        self._setAttributeAnnotation(obj, annotation)

        obj.setLineNumber(lineno)
        # undonditionnaly set the kind to ivar
        obj.kind = model.DocumentableKind.INSTANCE_VARIABLE
        self._storeAttrValue(obj, expr)

    def _handleAssignmentInClass(self,
            target: str,
            annotation: Optional[ast.expr],
            expr: Optional[ast.expr],
            lineno: int,
            augassign:Optional[ast.operator],
            ) -> None:
        cls = self.builder.current
        assert isinstance(cls, model.Class)
        if not _handleAliasing(cls, target, expr):
            self._handleClassVar(target, annotation, expr, lineno, augassign=augassign)
        else:
            raise IgnoreAssignment()

    def _handleDocstringUpdate(self,
            targetNode: ast.expr,
            expr: Optional[ast.expr],
            lineno: int
            ) -> None:
        def warn(msg: str) -> None:
            module = self.builder.currentMod
            assert module is not None
            module.report(msg, section='ast', lineno_offset=lineno)

        # Ignore docstring updates in functions.
        scope = self.builder.current
        if isinstance(scope, model.Function):
            return

        # Figure out target object.
        full_name = node2fullname(targetNode, scope)
        if full_name is None:
            warn("Unable to figure out target for __doc__ assignment")
            # Don't return yet: we might have to warn about the value too.
            obj = None
        else:
            obj = self.system.objForFullName(full_name)
            if obj is None:
                warn("Unable to figure out target for __doc__ assignment: "
                     "computed full name not found: " + full_name)

        # Determine docstring value.
        try:
            if expr is None:
                # The expr is None for detupling assignments, which can
                # be described as "too complex".
                raise ValueError()
            docstring: object = ast.literal_eval(expr)
        except ValueError:
            warn("Unable to figure out value for __doc__ assignment, "
                 "maybe too complex")
            return
        if not isinstance(docstring, str):
            warn("Ignoring value assigned to __doc__: not a string")
            return

        if obj is not None:
            obj._setDocstringValue(docstring, expr.lineno)
            # TODO: It might be better to not perform docstring parsing until
            #       we have the final docstrings for all objects.
            obj.parsed_docstring = None

    def _handleAssignment(self,
            targetNode: ast.expr,
            annotation: Optional[ast.expr],
            expr: Optional[ast.expr],
            lineno: int,
            augassign:Optional[ast.operator]=None,
            ) -> None:
        """
        @raises IgnoreAssignment: If the assignemnt should not be further processed.
        """
        if isinstance(targetNode, ast.Name):
            target = targetNode.id
            scope = self.builder.current
            if self._ignore_name(scope, target):
                raise IgnoreAssignment()
            if isinstance(scope, model.Module):
                self._handleAssignmentInModule(target, annotation, expr, lineno, augassign=augassign)
            elif isinstance(scope, model.Class):
                if augassign or not self._handleOldSchoolMethodDecoration(target, expr):
                    self._handleAssignmentInClass(target, annotation, expr, lineno, augassign=augassign)
        elif isinstance(targetNode, ast.Attribute) and not augassign:
            value = targetNode.value
            if targetNode.attr == '__doc__':
                self._handleDocstringUpdate(value, expr, lineno)
                raise IgnoreAssignment()
            elif isinstance(value, ast.Name) and value.id == 'self':
                self._handleInstanceVar(targetNode.attr, annotation, expr, lineno)
        else:
            raise IgnoreAssignment()

    def visit_Assign(self, node: ast.Assign) -> None:
        lineno = node.lineno
        expr = node.value

        type_comment: Optional[str] = getattr(node, 'type_comment', None)
        if type_comment is None:
            annotation = None
        else:
            annotation = upgrade_annotation(unstring_annotation(
                ast.Constant(type_comment, lineno=lineno), self.builder.current), self.builder.current)

        for target in node.targets:
            try:
                if isTupleAssignment:=isinstance(target, ast.Tuple):
                    # TODO: Only one level of nested tuple is taken into account...
                    # ideally we would extract al the names declared in the lhs, not
                    # only the first level ones.
                    for elem in target.elts:
                        # Note: We skip type and aliasing analysis for this case,
                        #       but we do record line numbers.
                        self._handleAssignment(elem, None, None, lineno)
                else:
                    self._handleAssignment(target, annotation, expr, lineno)
            except IgnoreAssignment:
                continue
            else:
                if not isTupleAssignment:
                    self._handleInlineDocstrings(node, target)
                else:
                    for elem in cast(ast.Tuple, target).elts: # mypy is not as smart as pyright yet.
                        self._handleInlineDocstrings(node, elem)

    def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
        annotation = upgrade_annotation(unstring_annotation(
            node.annotation, self.builder.current), self.builder.current)
        try:
            self._handleAssignment(node.target, annotation, node.value, node.lineno)
        except IgnoreAssignment:
            return
        else:
            self._handleInlineDocstrings(node, node.target)

    def _getClassFromMethodContext(self) -> Optional[model.Class]:
        func = self.builder.current
        if not isinstance(func, model.Function):
            return None
        cls = func.parent
        if not isinstance(cls, model.Class):
            return None
        return cls
    
    def _contextualizeTarget(self, target:ast.expr) -> Tuple[model.Documentable, str]:
        """
        Find out the documentatble wich is the parent of the assignment's target as well as it's name. 

        @returns: Tuple C{parent, name}. 
        @raises ValueError: if the target does not bind a new variable.
        """
        dottedname = node2dottedname(target)
        if not dottedname or len(dottedname) > 2:
            raise ValueError('does not bind a new variable')
        parent: model.Documentable
        if len(dottedname) == 2 and dottedname[0] == 'self':
            # an instance variable.
            # TODO: This currently only works if the first argument of methods
            # is named 'self'.
            if (maybe_cls:=self._getClassFromMethodContext()) is None:
                raise ValueError('using self in unsupported context')
            dottedname = dottedname[1:]
            parent = maybe_cls
        elif len(dottedname) != 1:
            raise ValueError('does not bind a new variable')
        else:
            parent = self.builder.current
        return parent, dottedname[0]

    def _handleInlineDocstrings(self, assign:Union[ast.Assign, ast.AnnAssign], target:ast.expr) -> None:
        # Process the inline docstrings
        try:
            parent, name = self._contextualizeTarget(target)
        except ValueError:
            return
        
        docstring_node = get_assign_docstring_node(assign)
        if docstring_node:
            # fetch the target of the inline docstring
            attr = parent.contents.get(name)
            if attr:
                attr.setDocstring(docstring_node)
    
    def visit_AugAssign(self, node:ast.AugAssign) -> None:
        try:
            self._handleAssignment(node.target, None, node.value, 
                                node.lineno, augassign=node.op)
        except IgnoreAssignment:
            pass

    
    def visit_Expr(self, node: ast.Expr) -> None:
        # Visit's ast.Expr.value with the visitor, used by extensions to visit top-level calls.
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._handleFunctionDef(node, is_async=True)

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._handleFunctionDef(node, is_async=False)

    def _handleFunctionDef(self,
            node: Union[ast.AsyncFunctionDef, ast.FunctionDef],
            is_async: bool
            ) -> None:
        # Ignore inner functions.
        parent = self.builder.current
        if isinstance(parent, model.Function):
            raise self.SkipNode()
        # Ignore in override guard
        if self._ignore_name(parent, node.name):
            raise self.IgnoreNode()

        lineno = node.lineno

        # setting linenumber from the start of the decorations
        if node.decorator_list:
            lineno = node.decorator_list[0].lineno

        # extracting docstring
        doc_node = get_docstring_node(node)
        func_name = node.name

        # determine the function's kind
        is_property = False
        is_classmethod = False
        is_staticmethod = False
        is_overload_func = False
        if node.decorator_list:
            for d in node.decorator_list:
                if isinstance(d, ast.Call):
                    deco_name = node2dottedname(d.func)
                else:
                    deco_name = node2dottedname(d)
                if deco_name is None:
                    continue
                if isinstance(parent, model.Class):
                    if deco_name[-1].endswith('property') or deco_name[-1].endswith('Property'):
                        is_property = True
                    elif deco_name == ['classmethod']:
                        is_classmethod = True
                    elif deco_name == ['staticmethod']:
                        is_staticmethod = True
                    elif len(deco_name) >= 2 and deco_name[-1] in ('setter', 'deleter'):
                        # Rename the setter/deleter, so it doesn't replace
                        # the property object.
                        func_name = '.'.join(deco_name[-2:])
                # Determine if the function is decorated with overload
                if parent.expandName('.'.join(deco_name)) in ('typing.overload', 'typing_extensions.overload'):
                    is_overload_func = True

        if is_property:
            # handle property and skip child nodes.
            attr = self._handlePropertyDef(node, doc_node, lineno)
            if is_classmethod:
                attr.report(f'{attr.fullName()} is both property and classmethod')
            if is_staticmethod:
                attr.report(f'{attr.fullName()} is both property and staticmethod')
            raise self.SkipNode() # visitor extensions will still be called.

        # Check if it's a new func or exists with an overload
        existing_func = parent.contents.get(func_name)
        if isinstance(existing_func, model.Function) and existing_func.overloads:
            # If the existing function has a signature and this function is an
            # overload, then the overload came _after_ the primary function
            # which we do not allow. This also ensures that func will have
            # properties set for the primary function and not overloads.
            if existing_func.signature and is_overload_func:
                existing_func.report(f'{existing_func.fullName()} overload appeared after primary function', lineno_offset=lineno-existing_func.linenumber)
                raise self.IgnoreNode()
            # Do not recreate function object, just re-push it
            self.builder.push(existing_func, lineno)
            func = existing_func
        else:
            func = self.builder.pushFunction(func_name, lineno)

        func.is_async = is_async
        if doc_node is not None:
            # Docstring not allowed on overload
            if is_overload_func:
                docline = extract_docstring_linenum(doc_node)
                func.report(f'{func.fullName()} overload has docstring, unsupported', lineno_offset=docline-func.linenumber)
            else:
                func.setDocstring(doc_node)
        func.decorators = node.decorator_list
        if is_staticmethod:
            if is_classmethod:
                func.report(f'{func.fullName()} is both classmethod and staticmethod')
            else:
                func.kind = model.DocumentableKind.STATIC_METHOD
        elif is_classmethod:
            func.kind = model.DocumentableKind.CLASS_METHOD

        # Position-only arguments were introduced in Python 3.8.
        posonlyargs: Sequence[ast.arg] = getattr(node.args, 'posonlyargs', ())

        num_pos_args = len(posonlyargs) + len(node.args.args)
        defaults = node.args.defaults
        default_offset = num_pos_args - len(defaults)
        annotations = self._annotations_from_function(node)

        def get_default(index: int) -> Optional[ast.expr]:
            assert 0 <= index < num_pos_args, index
            index -= default_offset
            return None if index < 0 else defaults[index]

        parameters: List[Parameter] = []
        def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None:
            default_val = Parameter.empty if default is None else _ValueFormatter(default, ctx=func)
                                                                               # this cast() is safe since we're checking if annotations.get(name) is None first
            annotation = Parameter.empty if annotations.get(name) is None else _AnnotationValueFormatter(cast(ast.expr, annotations[name]), ctx=func)
            parameters.append(Parameter(name, kind, default=default_val, annotation=annotation))

        for index, arg in enumerate(posonlyargs):
            add_arg(arg.arg, Parameter.POSITIONAL_ONLY, get_default(index))

        for index, arg in enumerate(node.args.args, start=len(posonlyargs)):
            add_arg(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, get_default(index))

        vararg = node.args.vararg
        if vararg is not None:
            add_arg(vararg.arg, Parameter.VAR_POSITIONAL, None)

        assert len(node.args.kwonlyargs) == len(node.args.kw_defaults)
        for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults):
            add_arg(arg.arg, Parameter.KEYWORD_ONLY, default)

        kwarg = node.args.kwarg
        if kwarg is not None:
            add_arg(kwarg.arg, Parameter.VAR_KEYWORD, None)

        return_type = annotations.get('return')
        return_annotation = Parameter.empty if return_type is None or is_none_literal(return_type) else _AnnotationValueFormatter(return_type, ctx=func)
        try:
            signature = Signature(parameters, return_annotation=return_annotation)
        except ValueError as ex:
            func.report(f'{func.fullName()} has invalid parameters: {ex}')
            signature = Signature()

        func.annotations = annotations

        # Only set main function signature if it is a non-overload
        if is_overload_func:
            func.overloads.append(model.FunctionOverload(primary=func, signature=signature, decorators=node.decorator_list))
        else:
            func.signature = signature

    def depart_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self.builder.popFunction()

    def depart_FunctionDef(self, node: ast.FunctionDef) -> None:
        self.builder.popFunction()

    def _handlePropertyDef(self,
            node: Union[ast.AsyncFunctionDef, ast.FunctionDef],
            doc_node: Optional[Str],
            lineno: int
            ) -> model.Attribute:

        attr = self.builder.addAttribute(name=node.name, 
                                         kind=model.DocumentableKind.PROPERTY, 
                                         parent=self.builder.current, 
                                         lineno=lineno)
        attr.setLineNumber(lineno)

        if doc_node is not None:
            attr.setDocstring(doc_node)
            assert attr.docstring is not None
            pdoc = epydoc2stan.parse_docstring(attr, attr.docstring, attr)
            other_fields = []
            for field in pdoc.fields:
                tag = field.tag()
                if tag == 'return':
                    if not pdoc.has_body:
                        pdoc = field.body()

                elif tag == 'rtype':
                    attr.parsed_type = field.body()
                else:
                    other_fields.append(field)
            pdoc.fields = other_fields
            attr.parsed_docstring = pdoc

        if node.returns is not None:
            attr.annotation = upgrade_annotation(unstring_annotation(node.returns, attr), attr)
        attr.decorators = node.decorator_list

        return attr

    def _annotations_from_function(
            self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef]
            ) -> Mapping[str, Optional[ast.expr]]:
        """Get annotations from a function definition.
        @param func: The function definition's AST.
        @return: Mapping from argument name to annotation.
            The name C{return} is used for the return type.
            Unannotated arguments are omitted.
        """
        def _get_all_args() -> Iterator[ast.arg]:
            base_args = func.args
            # New on Python 3.8 -- handle absence gracefully
            try:
                yield from base_args.posonlyargs
            except AttributeError:
                pass
            yield from base_args.args
            varargs = base_args.vararg
            if varargs:
                varargs.arg = epydoc2stan.VariableArgument(varargs.arg)
                yield varargs
            yield from base_args.kwonlyargs
            kwargs = base_args.kwarg
            if kwargs:
                kwargs.arg = epydoc2stan.KeywordArgument(kwargs.arg)
                yield kwargs
        def _get_all_ast_annotations() -> Iterator[Tuple[str, Optional[ast.expr]]]:
            for arg in _get_all_args():
                yield arg.arg, arg.annotation
            returns = func.returns
            if returns:
                yield 'return', returns
        return {
            # Include parameter names even if they're not annotated, so that
            # we can use the key set to know which parameters exist and warn
            # when non-existing parameters are documented.
            name: None if value is None else upgrade_annotation(unstring_annotation(
                value, self.builder.current), self.builder.current)
            for name, value in _get_all_ast_annotations()
            }
    
class _ValueFormatter:
    """
    Class to encapsulate a python value and translate it to HTML when calling L{repr()} on the L{_ValueFormatter}.
    Used for presenting default values of parameters.
    """

    def __init__(self, value: ast.expr, ctx: model.Documentable):
        self._colorized = colorize_inline_pyval(value)
        """
        The colorized value as L{ParsedDocstring}.
        """

        self._linker = ctx.docstring_linker
        """
        Linker.
        """

    def __repr__(self) -> str:
        """
        Present the python value as HTML. 
        Without the englobing  tags.
        """
        # Using node2stan.node2html instead of flatten(to_stan()). 
        # This avoids calling flatten() twice, 
        # but potential XML parser errors caused by XMLString needs to be handled later.
        return ''.join(node2stan.node2html(self._colorized.to_node(), self._linker))

class _AnnotationValueFormatter(_ValueFormatter):
    """
    Special L{_ValueFormatter} for function annotations.
    """
    def __init__(self, value: ast.expr, ctx: model.Function):
        super().__init__(value, ctx)
        self._linker = linker._AnnotationLinker(ctx)
    
    def __repr__(self) -> str:
        """
        Present the annotation wrapped inside  tags.
        """
        return '%s' % super().__repr__()

DocumentableT = TypeVar('DocumentableT', bound=model.Documentable)

class ASTBuilder:
    """
    Keeps tracks of the state of the AST build, creates documentable and adds objects to the system.
    """
    ModuleVistor = ModuleVistor

    def __init__(self, system: model.System):
        self.system = system
        
        self.current = cast(model.Documentable, None) # current visited object.
        self.currentMod: Optional[model.Module] = None # current module, set when visiting ast.Module.
        
        self._stack: List[model.Documentable] = []
        self.ast_cache: Dict[Path, Optional[ast.Module]] = {}

    def _push(self, 
              cls: Type[DocumentableT], 
              name: str, 
              lineno: int, 
              parent:Optional[model.Documentable]=None) -> DocumentableT:
        """
        Create and enter a new object of the given type and add it to the system.

        @param parent: Parent of the new documentable instance, it will use self.current if unspecified.
            Used for attributes declared in methods, typically ``__init__``.
        """
        obj = cls(self.system, name, parent or self.current)
        self.push(obj, lineno) 
        # make sure push() is called before addObject() since addObject() can trigger a warning for duplicates
        # and this relies on the correct parentMod attribute, which is set in push().
        self.system.addObject(obj)
        return obj

    def _pop(self, cls: Type[model.Documentable]) -> None:
        assert isinstance(self.current, cls)
        self.pop(self.current)

    def push(self, obj: model.Documentable, lineno: int) -> None:
        """
        Enter a documentable.
        """
        self._stack.append(self.current)
        self.current = obj
        if isinstance(obj, model.Module):
            assert self.currentMod is None
            obj.parentMod = self.currentMod = obj
        elif self.currentMod is not None:
            if obj.parentMod is not None:
                assert obj.parentMod is self.currentMod
            else:
                obj.parentMod = self.currentMod
        else:
            assert obj.parentMod is None
        if lineno:
            obj.setLineNumber(lineno)

    def pop(self, obj: model.Documentable) -> None:
        """
        Leave a documentable.
        """
        assert self.current is obj, f"{self.current!r} is not {obj!r}"
        self.current = self._stack.pop()
        if isinstance(obj, model.Module):
            self.currentMod = None

    def pushClass(self, name: str, lineno: int) -> model.Class:
        """
        Create and a new class in the system.
        """
        return self._push(self.system.Class, name, lineno)

    def popClass(self) -> None:
        """
        Leave a class.
        """
        self._pop(self.system.Class)

    def pushFunction(self, name: str, lineno: int) -> model.Function:
        """
        Create and enter a new function in the system.
        """
        return self._push(self.system.Function, name, lineno)

    def popFunction(self) -> None:
        """
        Leave a function.
        """
        self._pop(self.system.Function)

    def addAttribute(self,
            name: str, 
            kind: Optional[model.DocumentableKind], 
            parent: model.Documentable, 
            lineno: int
            ) -> model.Attribute:
        """
        Add a new attribute to the system.
        """
        attr = self._push(self.system.Attribute, name, lineno, parent=parent)
        self._pop(self.system.Attribute)
        attr.kind = kind
        return attr


    def processModuleAST(self, mod_ast: ast.Module, mod: model.Module) -> None:

        for name, node in findModuleLevelAssign(mod_ast):
            try:
                module_var_parser = MODULE_VARIABLES_META_PARSERS[name]
            except KeyError:
                continue
            else:
                module_var_parser(node, mod)

        vis = self.ModuleVistor(self, mod)
        vis.extensions.add(*self.system._astbuilder_visitors)
        vis.extensions.attach_visitor(vis)
        vis.walkabout(mod_ast)

    def parseFile(self, path: Path, ctx: model.Module) -> Optional[ast.Module]:
        try:
            return self.ast_cache[path]
        except KeyError:
            mod: Optional[ast.Module] = None
            try:
                mod = parseFile(path)
            except (SyntaxError, ValueError) as e:
                ctx.report(f"cannot parse file, {e}")

            self.ast_cache[path] = mod
            return mod
    
    def parseString(self, py_string:str, ctx: model.Module) -> Optional[ast.Module]:
        mod = None
        try:
            mod = _parse(py_string)
        except (SyntaxError, ValueError):
            ctx.report("cannot parse string")
        return mod

model.System.defaultBuilder = ASTBuilder

def findModuleLevelAssign(mod_ast: ast.Module) -> Iterator[Tuple[str, ast.Assign]]:
    """
    Find module level Assign. 
    Yields tuples containing the assigment name and the Assign node.
    """
    for node in mod_ast.body:
        if isinstance(node, ast.Assign) and \
            len(node.targets) == 1 and \
            isinstance(node.targets[0], ast.Name):
                yield (node.targets[0].id, node)

def parseAll(node: ast.Assign, mod: model.Module) -> None:
    """Find and attempt to parse into a list of names the 
    C{__all__} variable of a module's AST and set L{Module.all} accordingly."""

    if not isinstance(node.value, (ast.List, ast.Tuple)):
        mod.report(
            'Cannot parse value assigned to "__all__"',
            section='all', lineno_offset=node.lineno)
        return

    names = []
    for idx, item in enumerate(node.value.elts):
        try:
            name: object = ast.literal_eval(item)
        except ValueError:
            mod.report(
                f'Cannot parse element {idx} of "__all__"',
                section='all', lineno_offset=node.lineno)
        else:
            if isinstance(name, str):
                names.append(name)
            else:
                mod.report(
                    f'Element {idx} of "__all__" has '
                    f'type "{type(name).__name__}", expected "str"',
                    section='all', lineno_offset=node.lineno)

    if mod.all is not None:
        mod.report(
            'Assignment to "__all__" overrides previous assignment',
            section='all', lineno_offset=node.lineno)
    mod.all = names

def parseDocformat(node: ast.Assign, mod: model.Module) -> None:
    """
    Find C{__docformat__} variable of this 
    module's AST and set L{Module.docformat} accordingly.
        
    This is all valid::

        __docformat__ = "reStructuredText en"
        __docformat__ = "epytext"
        __docformat__ = "restructuredtext"
    """

    try:
        value = ast.literal_eval(node.value)
    except ValueError:
        mod.report(
            'Cannot parse value assigned to "__docformat__": not a string',
            section='docformat', lineno_offset=node.lineno)
        return
    
    if not isinstance(value, str):
        mod.report(
            'Cannot parse value assigned to "__docformat__": not a string',
            section='docformat', lineno_offset=node.lineno)
        return
        
    if not value.strip():
        mod.report(
            'Cannot parse value assigned to "__docformat__": empty value',
            section='docformat', lineno_offset=node.lineno)
        return
    
    # Language is ignored and parser name is lowercased.
    value = value.split(" ", 1)[0].lower()

    if mod._docformat is not None:
        mod.report(
            'Assignment to "__docformat__" overrides previous assignment',
            section='docformat', lineno_offset=node.lineno)

    mod.docformat = value

MODULE_VARIABLES_META_PARSERS: Mapping[str, Callable[[ast.Assign, model.Module], None]] = {
    '__all__': parseAll,
    '__docformat__': parseDocformat
}


def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None:
    r.register_astbuilder_visitor(TypeAliasVisitorExt)
    r.register_post_processor(model.defaultPostProcess, priority=200)
pydoctor-24.11.2/pydoctor/astutils.py000066400000000000000000000715701473665144200176370ustar00rootroot00000000000000"""
Various bits of reusable code related to L{ast.AST} node processing.
"""
from __future__ import annotations

import inspect
import platform
import sys
from numbers import Number
from typing import Any, Callable, Collection, Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, cast
from inspect import BoundArguments, Signature
import ast

if sys.version_info >= (3, 9):
    from ast import unparse as _unparse
else:
    from astor import to_source as _unparse

from pydoctor import visitor

if TYPE_CHECKING:
    from pydoctor import model

def unparse(node:ast.AST) -> str:
    """
    This function convert a node tree back into python sourcecode.

    Uses L{ast.unparse} or C{astor.to_source} for python versions before 3.9.
    """
    return _unparse(node)
    
# AST visitors

def iter_values(node: ast.AST) -> Iterator[ast.AST]:
    for _, value in ast.iter_fields(node):
        if isinstance(value, list):
            for item in value:
                if isinstance(item, ast.AST):
                    yield item
        elif isinstance(value, ast.AST):
            yield value

class NodeVisitor(visitor.PartialVisitor[ast.AST]):
    """
    Generic AST node visitor. This class does not work like L{ast.NodeVisitor}, 
    it only visits statements directly within a C{B{body}}. Also, visitor methods can't return anything.

    :See: L{visitor} for more informations.
    """
    def generic_visit(self, node: ast.AST) -> None:
        """
        Helper method to visit a node by calling C{visit()} on each child of the node. 
        This is useful because this vistitor only visits statements inside C{.body} attribute. 
        
        So if one wants to visit L{ast.Expr} children with their visitor, they should include::

            def visit_Expr(self, node:ast.Expr):
                self.generic_visit(node)
        """
        for v in iter_values(node):
            self.visit(v)
    
    @classmethod
    def get_children(cls, node: ast.AST) -> Iterable[ast.AST]:
        """
        Returns the nested nodes in the body of a node.
        """
        body: Optional[Sequence[ast.AST]] = getattr(node, 'body', None)
        if body is not None:
            for child in body:
                yield child

class NodeVisitorExt(visitor.VisitorExt[ast.AST]):
    ...

_AssingT = Union[ast.Assign, ast.AnnAssign]
def iterassign(node:_AssingT) -> Iterator[Optional[List[str]]]:
    """
    Utility function to iterate assignments targets. 

    Useful for all the following AST assignments:

    >>> var:int=2
    >>> self.var = target = node.astext()
    >>> lol = ['extensions']

    NOT Useful for the following AST assignments:

    >>> x, y = [1,2]

    Example:

    >>> from pydoctor.astutils import iterassign
    >>> from ast import parse
    >>> node = parse('self.var = target = thing[0] = node.astext()').body[0]
    >>> list(iterassign(node))
    
    """
    for target in node.targets if isinstance(node, ast.Assign) else [node.target]:
        dottedname = node2dottedname(target) 
        yield dottedname

def node2dottedname(node: Optional[ast.AST]) -> Optional[List[str]]:
    """
    Resove expression composed by L{ast.Attribute} and L{ast.Name} nodes to a list of names. 
    """
    parts = []
    while isinstance(node, ast.Attribute):
        parts.append(node.attr)
        node = node.value
    if isinstance(node, ast.Name):
        parts.append(node.id)
    else:
        return None
    parts.reverse()
    return parts

def node2fullname(expr: Optional[ast.AST], 
                  ctx: model.Documentable | None = None, 
                  *,
                  expandName:Callable[[str], str] | None = None) -> Optional[str]:
    if expandName is None:
        if ctx is None:
            raise TypeError('this function takes exactly two arguments')
        expandName = ctx.expandName
    elif ctx is not None:
        raise TypeError('this function takes exactly two arguments')

    dottedname = node2dottedname(expr)
    if dottedname is None:
        return None
    return expandName('.'.join(dottedname))

def bind_args(sig: Signature, call: ast.Call) -> BoundArguments:
    """
    Binds the arguments of a function call to that function's signature.
    @raise TypeError: If the arguments do not match the signature.
    """
    kwargs = {
        kw.arg: kw.value
        for kw in call.keywords
        # When keywords are passed using '**kwargs', the 'arg' field will
        # be None. We don't currently support keywords passed that way.
        if kw.arg is not None
        }
    return sig.bind(*call.args, **kwargs)



if sys.version_info[:2] >= (3, 8):
    # Since Python 3.8 "foo" is parsed as ast.Constant.
    def get_str_value(expr:ast.expr) -> Optional[str]:
        if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
            return expr.value
        return None
    def get_num_value(expr:ast.expr) -> Optional[Number]:
        if isinstance(expr, ast.Constant) and isinstance(expr.value, Number):
            return expr.value
        return None
    def _is_str_constant(expr: ast.expr, s: str) -> bool:
        return isinstance(expr, ast.Constant) and expr.value == s
else:
    # Before Python 3.8 "foo" was parsed as ast.Str.
    # TODO: remove me when python3.7 is not supported anymore
    def get_str_value(expr:ast.expr) -> Optional[str]:
        if isinstance(expr, ast.Str):
            return expr.s
        return None
    def get_num_value(expr:ast.expr) -> Optional[Number]:
        if isinstance(expr, ast.Num):
            return expr.n
        return None
    def _is_str_constant(expr: ast.expr, s: str) -> bool:
        return isinstance(expr, ast.Str) and expr.s == s

def get_int_value(expr: ast.expr) -> Optional[int]:
    num = get_num_value(expr)
    if isinstance(num, int):
        return num # type:ignore[unreachable]
    return None

def is__name__equals__main__(cmp: ast.Compare) -> bool:
    """
    Returns whether or not the given L{ast.Compare} is equal to C{__name__ == '__main__'}.
    """
    return isinstance(cmp.left, ast.Name) \
    and cmp.left.id == '__name__' \
    and len(cmp.ops) == 1 \
    and isinstance(cmp.ops[0], ast.Eq) \
    and len(cmp.comparators) == 1 \
    and _is_str_constant(cmp.comparators[0], '__main__')

def is_using_typing_final(expr: Optional[ast.AST], 
                    ctx:'model.Documentable') -> bool:
    return is_using_annotations(expr, ("typing.Final", "typing_extensions.Final"), ctx)

def is_using_typing_classvar(expr: Optional[ast.AST], 
                    ctx:'model.Documentable') -> bool:
    return is_using_annotations(expr, ('typing.ClassVar', "typing_extensions.ClassVar"), ctx)

def is_using_annotations(expr: Optional[ast.AST], 
                            annotations:Sequence[str], 
                            ctx:'model.Documentable') -> bool:
    """
    Detect if this expr is firstly composed by one of the specified annotation(s)' full name.
    """
    full_name = node2fullname(expr, ctx)
    if full_name in annotations:
        return True
    if isinstance(expr, ast.Subscript):
        # Final[...] or typing.Final[...] expressions
        if isinstance(expr.value, (ast.Name, ast.Attribute)):
            value = expr.value
            full_name = node2fullname(value, ctx)
            if full_name in annotations:
                return True
    return False

def get_node_block(node: ast.AST) -> tuple[ast.AST, str]:
    """
    Tell in wich block the given node lives in. 
    
    A block is defined by a tuple: (parent node, fieldname)
    """
    try:
        parent = next(get_parents(node))
    except StopIteration:
        raise ValueError(f'node has no parents: {node}')
    for fieldname, value in ast.iter_fields(parent):
        if value is node or (isinstance(value, (list, tuple)) and node in value):
            break
    else:
        raise ValueError(f"node {node} not found in {parent}")
    return parent, fieldname

def get_assign_docstring_node(assign:ast.Assign | ast.AnnAssign) -> Str | None:
    """
    Get the docstring for a L{ast.Assign} or L{ast.AnnAssign} node.

    This helper function relies on the non-standard C{.parent} attribute on AST nodes
    to navigate upward in the tree and determine this node direct siblings.
    """
    # if this call raises an ValueError it means that we're doing something nasty with the ast...
    parent_node, fieldname = get_node_block(assign)
    statements = getattr(parent_node, fieldname, None)
    
    if isinstance(statements, Sequence):
        # it must be a sequence if it's not None since an assignment 
        # can only be a part of a compound statement.
        assign_index = statements.index(assign)
        try:
            right_sibling = statements[assign_index+1]
        except IndexError:
            return None
        if isinstance(right_sibling, ast.Expr) and \
           get_str_value(right_sibling.value) is not None:
            return cast(Str, right_sibling.value)
    return None

def is_none_literal(node: ast.expr) -> bool:
    """Does this AST node represent the literal constant None?"""
    if sys.version_info >= (3,8):
        return isinstance(node, ast.Constant) and node.value is None
    else:
        # TODO: remove me when python3.7 is not supported anymore
        return isinstance(node, (ast.Constant, ast.NameConstant)) and node.value is None
    
def unstring_annotation(node: ast.expr, ctx:'model.Documentable', section:str='annotation') -> ast.expr:
    """Replace all strings in the given expression by parsed versions.
    @return: The unstringed node. If parsing fails, an error is logged
        and the original node is returned.
    """
    try:
        expr = _AnnotationStringParser().visit(node)
    except SyntaxError as ex:
        module = ctx.module
        assert module is not None
        module.report(f'syntax error in {section}: {ex}', lineno_offset=node.lineno, section=section)
        return node
    else:
        assert isinstance(expr, ast.expr), expr
        return expr

class _AnnotationStringParser(ast.NodeTransformer):
    """Implementation of L{unstring_annotation()}.

    When given an expression, the node returned by L{ast.NodeVisitor.visit()}
    will also be an expression.
    If any string literal contained in the original expression is either
    invalid Python or not a singular expression, L{SyntaxError} is raised.
    """

    def _parse_string(self, value: str) -> ast.expr:
        statements = ast.parse(value).body
        if len(statements) != 1:
            raise SyntaxError("expected expression, found multiple statements")
        stmt, = statements
        if isinstance(stmt, ast.Expr):
            # Expression wrapped in an Expr statement.
            expr = self.visit(stmt.value)
            assert isinstance(expr, ast.expr), expr
            return expr
        else:
            raise SyntaxError("expected expression, found statement")

    def visit_Subscript(self, node: ast.Subscript) -> ast.Subscript:
        value = self.visit(node.value)
        if isinstance(value, ast.Name) and value.id == 'Literal':
            # Literal[...] expression; don't unstring the arguments.
            slice = node.slice
        elif isinstance(value, ast.Attribute) and value.attr == 'Literal':
            # typing.Literal[...] expression; don't unstring the arguments.
            slice = node.slice
        else:
            # Other subscript; unstring the slice.
            slice = self.visit(node.slice)
        return ast.copy_location(ast.Subscript(value=value, slice=slice, ctx=node.ctx), node)

    def visit_fast(self, node: ast.expr) -> ast.expr:
        return node
    
    visit_Attribute = visit_Name = visit_fast

    # For Python >= 3.8:

    def visit_Constant(self, node: ast.Constant) -> ast.expr:
        value = node.value
        if isinstance(value, str):
            return ast.copy_location(self._parse_string(value), node)
        else:
            const = self.generic_visit(node)
            assert isinstance(const, ast.Constant), const
            return const

    # For Python < 3.8:
    if sys.version_info < (3,8):
        # TODO: remove me when python3.7 is not supported anymore
        def visit_Str(self, node: ast.Str) -> ast.expr:
            return ast.copy_location(self._parse_string(node.s), node)

def upgrade_annotation(node: ast.expr, ctx: model.Documentable, section:str='annotation') -> ast.expr:
    """
    Transform the annotation to use python 3.10+ syntax. 
    """
    return _UpgradeDeprecatedAnnotations(ctx).visit(node)

class _UpgradeDeprecatedAnnotations(ast.NodeTransformer):
    if TYPE_CHECKING:
        def visit(self, node:ast.AST) -> ast.expr:...

    def __init__(self, ctx: model.Documentable) -> None:
        def _node2fullname(node:ast.expr) -> str | None:
            return node2fullname(node, expandName=ctx.expandAnnotationName)
        self.node2fullname = _node2fullname

    def _union_args_to_bitor(self, args: list[ast.expr], ctxnode:ast.AST) -> ast.BinOp:
        assert len(args) > 1
        *others, right = args
        if len(others) == 1:
            rnode = ast.BinOp(left=others[0], right=right, op=ast.BitOr())
        else:
            rnode = ast.BinOp(left=self._union_args_to_bitor(others, ctxnode), right=right, op=ast.BitOr())
    
        return ast.fix_missing_locations(ast.copy_location(rnode, ctxnode))

    def visit_Name(self, node: ast.Name | ast.Attribute) -> Any:
        fullName = self.node2fullname(node)
        if fullName in DEPRECATED_TYPING_ALIAS_BUILTINS:
            return ast.Name(id=DEPRECATED_TYPING_ALIAS_BUILTINS[fullName], ctx=ast.Load())
        # TODO: Support all deprecated aliases including the ones in the collections.abc module.
        # In order to support that we need to generate the parsed docstring directly and include 
        # custom refmap or transform the ast such that missing imports are added.
        return node

    visit_Attribute = visit_Name

    def visit_Subscript(self, node: ast.Subscript) -> ast.expr:
        node.value = self.visit(node.value)
        node.slice = self.visit(node.slice)
        fullName = self.node2fullname(node.value)
        
        if fullName == 'typing.Union':
            # typing.Union can be used with a single type or a 
            # tuple of types, includea single element tuple, which is the same
            # as the directly using the type: Union[x] == Union[(x,)] == x
            slice_ = node.slice
            if sys.version_info <= (3,9) and isinstance(slice_, ast.Index): # Compat
                slice_ = slice_.value
            if isinstance(slice_, ast.Tuple):
                args = slice_.elts
                if len(args) > 1:
                    return self._union_args_to_bitor(args, node)
                elif len(args) == 1:
                    return args[0]
            elif isinstance(slice_, (ast.Attribute, ast.Name, ast.Subscript, ast.BinOp)):
                return slice_
        
        elif fullName == 'typing.Optional':
            # typing.Optional requires a single type, so we don't process when slice is a tuple.
            slice_ = node.slice
            if sys.version_info <= (3,9) and isinstance(slice_, ast.Index): # Compat
                slice_ = slice_.value
            if isinstance(slice_, (ast.Attribute, ast.Name, ast.Subscript, ast.BinOp)):
                return self._union_args_to_bitor([slice_, ast.Constant(value=None)], node)

        return node
    
DEPRECATED_TYPING_ALIAS_BUILTINS = {
        "typing.Text": 'str',
        "typing.Dict": 'dict',
        "typing.Tuple": 'tuple',
        "typing.Type": 'type',
        "typing.List": 'list',
        "typing.Set": 'set',
        "typing.FrozenSet": 'frozenset',
}

# These do not belong in the deprecated builtins aliases, so we make sure it doesn't happen.
assert 'typing.Union' not in DEPRECATED_TYPING_ALIAS_BUILTINS
assert 'typing.Optional' not in DEPRECATED_TYPING_ALIAS_BUILTINS

TYPING_ALIAS = (
        "typing.Hashable",
        "typing.Awaitable",
        "typing.Coroutine",
        "typing.AsyncIterable",
        "typing.AsyncIterator",
        "typing.Iterable",
        "typing.Iterator",
        "typing.Reversible",
        "typing.Sized",
        "typing.Container",
        "typing.Collection",
        "typing.Callable",
        "typing.AbstractSet",
        "typing.MutableSet",
        "typing.Mapping",
        "typing.MutableMapping",
        "typing.Sequence",
        "typing.MutableSequence",
        "typing.ByteString",
        "typing.Deque",
        "typing.MappingView",
        "typing.KeysView",
        "typing.ItemsView",
        "typing.ValuesView",
        "typing.ContextManager",
        "typing.AsyncContextManager",
        "typing.DefaultDict",
        "typing.OrderedDict",
        "typing.Counter",
        "typing.ChainMap",
        "typing.Generator",
        "typing.AsyncGenerator",
        "typing.Pattern",
        "typing.Match",
        # Special forms
        "typing.Union",
        "typing.Literal",
        "typing.Optional",
        *DEPRECATED_TYPING_ALIAS_BUILTINS, 
    )

SUBSCRIPTABLE_CLASSES_PEP585 = (
        "tuple",
        "list",
        "dict",
        "set",
        "frozenset",
        "type",
        "builtins.tuple",
        "builtins.list",
        "builtins.dict",
        "builtins.set",
        "builtins.frozenset",
        "builtins.type",
        "collections.deque",
        "collections.defaultdict",
        "collections.OrderedDict",
        "collections.Counter",
        "collections.ChainMap",
        "collections.abc.Awaitable",
        "collections.abc.Coroutine",
        "collections.abc.AsyncIterable",
        "collections.abc.AsyncIterator",
        "collections.abc.AsyncGenerator",
        "collections.abc.Iterable",
        "collections.abc.Iterator",
        "collections.abc.Generator",
        "collections.abc.Reversible",
        "collections.abc.Container",
        "collections.abc.Collection",
        "collections.abc.Callable",
        "collections.abc.Set",
        "collections.abc.MutableSet",
        "collections.abc.Mapping",
        "collections.abc.MutableMapping",
        "collections.abc.Sequence",
        "collections.abc.MutableSequence",
        "collections.abc.ByteString",
        "collections.abc.MappingView",
        "collections.abc.KeysView",
        "collections.abc.ItemsView",
        "collections.abc.ValuesView",
        "contextlib.AbstractContextManager",
        "contextlib.AbstractAsyncContextManager",
        "re.Pattern",
        "re.Match",
    )

def is_typing_annotation(node: ast.AST, ctx: 'model.Documentable') -> bool:
    """
    Whether this annotation node refers to a typing alias.
    """
    return is_using_annotations(node, TYPING_ALIAS, ctx) or \
            is_using_annotations(node, SUBSCRIPTABLE_CLASSES_PEP585, ctx)

def get_docstring_node(node: ast.AST) -> Str | None:
    """
    Return the docstring node for the given class, function or module
    or None if no docstring can be found.
    """
    if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module)) or not node.body:
        return None
    node = node.body[0]
    if isinstance(node, ast.Expr):
        if isinstance(node.value, Str):
            return node.value
    return None

_string_lineno_is_end = sys.version_info < (3,8) \
                    and platform.python_implementation() != 'PyPy'
"""True iff the 'lineno' attribute of an AST string node points to the last
line in the string, rather than the first line.
"""


class _StrMeta(type):
    if sys.version_info >= (3,8):
        def __instancecheck__(self, instance: object) -> bool:
            if isinstance(instance, ast.expr):
                return get_str_value(instance) is not None
            return False
    else:
        # TODO: remove me when python3.7 is not supported
        def __instancecheck__(self, instance: object) -> bool:
            return isinstance(instance, ast.Str)

class Str(ast.expr, metaclass=_StrMeta):
    """
    Wraps ast.Constant/ast.Str for `isinstance` checks and annotations. 
    Ensures that the value is actually a string.
    Do not try to instanciate this class.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        raise TypeError(f'{Str.__qualname__} cannot be instanciated')

    if sys.version_info >= (3,8):
        value: str
    else:
        # TODO: remove me when python3.7 is not supported
        s: str

def extract_docstring_linenum(node: Str) -> int:
    r"""
    In older CPython versions, the AST only tells us the end line
    number and we must approximate the start line number.
    This approximation is correct if the docstring does not contain
    explicit newlines ('\n') or joined lines ('\' at end of line).

    Leading blank lines are stripped by cleandoc(), so we must
    return the line number of the first non-blank line.
    """
    if sys.version_info >= (3,8):
        doc = node.value
    else:
        # TODO: remove me when python3.7 is not supported
        doc = node.s
    lineno = node.lineno
    if _string_lineno_is_end:
        # In older CPython versions, the AST only tells us the end line
        # number and we must approximate the start line number.
        # This approximation is correct if the docstring does not contain
        # explicit newlines ('\n') or joined lines ('\' at end of line).
        lineno -= doc.count('\n')

    # Leading blank lines are stripped by cleandoc(), so we must
    # return the line number of the first non-blank line.
    for ch in doc:
        if ch == '\n':
            lineno += 1
        elif not ch.isspace():
            break
    
    return lineno

def extract_docstring(node: Str) -> Tuple[int, str]:
    """
    Extract docstring information from an ast node that represents the docstring.

    @returns: 
        - The line number of the first non-blank line of the docsring. See L{extract_docstring_linenum}.
        - The docstring to be parsed, cleaned by L{inspect.cleandoc}.
    """
    if sys.version_info >= (3,8):
        value = node.value
    else:
        # TODO: remove me when python3.7 is not supported
        value = node.s
    lineno = extract_docstring_linenum(node)
    return lineno, inspect.cleandoc(value)


def infer_type(expr: ast.expr) -> Optional[ast.expr]:
    """Infer a literal expression's type.
    @param expr: The expression's AST.
    @return: A type annotation, or None if the expression has no obvious type.
    """
    try:
        value: object = ast.literal_eval(expr)
    except (ValueError, TypeError):
        return None
    else:
        ann = _annotation_for_value(value)
        if ann is None:
            return None
        else:
            return ast.fix_missing_locations(ast.copy_location(ann, expr))

def _annotation_for_value(value: object) -> Optional[ast.expr]:
    if value is None:
        return None
    name = type(value).__name__
    if isinstance(value, (dict, list, set, tuple)):
        ann_elem = _annotation_for_elements(value)
        if isinstance(value, dict):
            ann_value = _annotation_for_elements(value.values())
            if ann_value is None:
                ann_elem = None
            elif ann_elem is not None:
                ann_elem = ast.Tuple(elts=[ann_elem, ann_value], ctx=ast.Load())
        if ann_elem is not None:
            if name == 'tuple':
                ann_elem = ast.Tuple(elts=[ann_elem, ast.Constant(value=...)], ctx=ast.Load())
            return ast.Subscript(value=ast.Name(id=name, ctx=ast.Load()),
                                 slice=ann_elem,
                                 ctx=ast.Load())
    return ast.Name(id=name, ctx=ast.Load())

def _annotation_for_elements(sequence: Iterable[object]) -> Optional[ast.expr]:
    names = set()
    for elem in sequence:
        ann = _annotation_for_value(elem)
        if isinstance(ann, ast.Name):
            names.add(ann.id)
        else:
            # Nested sequences are too complex.
            return None
    if len(names) == 1:
        name = names.pop()
        return ast.Name(id=name, ctx=ast.Load())
    else:
        # Empty sequence or no uniform type.
        return None

      
class Parentage(ast.NodeVisitor):
    """
    Add C{parent} attribute to ast nodes instances.
    """
    def __init__(self) -> None:
        self.current: ast.AST | None = None

    def generic_visit(self, node: ast.AST) -> None:
        current = self.current
        setattr(node, 'parent', current)
        self.current = node
        for child in ast.iter_child_nodes(node):
            self.generic_visit(child)
        self.current = current

def get_parents(node:ast.AST) -> Iterator[ast.AST]:
    """
    Once nodes have the C{.parent} attribute with {Parentage}, use this function
    to get a iterator on all parents of the given node up to the root module.
    """
    def _yield_parents(n:Optional[ast.AST]) -> Iterator[ast.AST]:
        if n:
            yield n
            p = cast(ast.AST, getattr(n, 'parent', None))
            yield from _yield_parents(p)
    yield from _yield_parents(getattr(node, 'parent', None))

#Part of the astor library for Python AST manipulation.
#License: 3-clause BSD
#Copyright (c) 2015 Patrick Maupin
_op_data = """
    GeneratorExp                1

          Assign                1
       AnnAssign                1
       AugAssign                0
            Expr                0
           Yield                1
       YieldFrom                0
              If                1
             For                0
        AsyncFor                0
           While                0
          Return                1

           Slice                1
       Subscript                0
           Index                1
        ExtSlice                1
    comprehension_target        1
           Tuple                0
  FormattedValue                0

           Comma                1
       NamedExpr                1
          Assert                0
           Raise                0
    call_one_arg                1

          Lambda                1
           IfExp                0

   comprehension                1
              Or   or           1
             And   and          1
             Not   not          1

              Eq   ==           1
              Gt   >            0
             GtE   >=           0
              In   in           0
              Is   is           0
           NotEq   !=           0
              Lt   <            0
             LtE   <=           0
           NotIn   not in       0
           IsNot   is not       0

           BitOr   |            1
          BitXor   ^            1
          BitAnd   &            1
          LShift   <<           1
          RShift   >>           0
             Add   +            1
             Sub   -            0
            Mult   *            1
             Div   /            0
             Mod   %            0
        FloorDiv   //           0
         MatMult   @            0
          PowRHS                1
          Invert   ~            1
            UAdd   +            0
            USub   -            0
             Pow   **           1
           Await                1
             Num                1
        Constant                1
"""

_op_data = [x.split() for x in _op_data.splitlines()] # type:ignore
_op_data = [[x[0], ' '.join(x[1:-1]), int(x[-1])] for x in _op_data if x] # type:ignore
for _index in range(1, len(_op_data)):
    _op_data[_index][2] *= 2 # type:ignore
    _op_data[_index][2] += _op_data[_index - 1][2] # type:ignore

_deprecated: Collection[str] = ()
if sys.version_info >= (3, 12):
    _deprecated = ('Num', 'Str', 'Bytes', 'Ellipsis', 'NameConstant')
_precedence_data = dict((getattr(ast, x, None), z) for x, y, z in _op_data if x not in _deprecated) # type:ignore
_symbol_data = dict((getattr(ast, x, None), y) for x, y, z in _op_data if x not in _deprecated) # type:ignore

class op_util:
    """
    This class provides data and functions for mapping
    AST nodes to symbols and precedences.
    """
    @classmethod
    def get_op_symbol(cls, obj:ast.operator|ast.boolop|ast.cmpop|ast.unaryop,
                      fmt:str='%s', 
                      symbol_data:dict[type[ast.AST]|None, str]=_symbol_data, 
                      type:Callable[[object], type[Any]]=type) -> str:
        """Given an AST node object, returns a string containing the symbol.
        """
        return fmt % symbol_data[type(obj)]
    @classmethod
    def get_op_precedence(cls, obj:ast.AST, 
                          precedence_data:dict[type[ast.AST]|None, int]=_precedence_data, 
                          type:Callable[[object], type[Any]]=type) -> int:
        """Given an AST node object, returns the precedence.

        @raises KeyError: If the node is not explicitely supported by this function. 
            This is a very legacy piece of code, all calls to L{get_op_precedence} should be
            guarded in a C{try:... except KeyError:...} statement.
        """
        return precedence_data[type(obj)]

    if not TYPE_CHECKING:
        class Precedence(object):
            vars().update((cast(str, x), z) for x, _, z in _op_data)
            highest = max(cast(int, z) for _, _, z in _op_data) + 2
    else:
        Precedence: Any

del _op_data, _index, _precedence_data, _symbol_data, _deprecated
# This was part of the astor library for Python AST manipulation.
pydoctor-24.11.2/pydoctor/driver.py000066400000000000000000000150041473665144200172500ustar00rootroot00000000000000"""The entry point."""
from __future__ import annotations

from typing import  Sequence
import datetime
import os
import sys
from pathlib import Path

from pydoctor.options import Options, BUILDTIME_FORMAT
from pydoctor.utils import error
from pydoctor import model
from pydoctor.templatewriter import IWriter, TemplateLookup, TemplateError
from pydoctor.sphinx import SphinxInventoryWriter, prepareCache

# In newer Python versions, use importlib.resources from the standard library.
# On older versions, a compatibility package must be installed from PyPI.
if sys.version_info < (3, 9):
    import importlib_resources
else:
    import importlib.resources as importlib_resources

def get_system(options: model.Options) -> model.System:
    """
    Get a system with the defined options. Load packages and modules.
    """
    cache = prepareCache(clearCache=options.clear_intersphinx_cache,
                         enableCache=options.enable_intersphinx_cache,
                         cachePath=options.intersphinx_cache_path,
                         maxAge=options.intersphinx_cache_max_age)

    # step 1: make/find the system
    system = options.systemclass(options)
    system.fetchIntersphinxInventories(cache)
    cache.close() # Fixes ResourceWarning: unclosed 

    # TODO: load buildtime with default factory and converter in model.Options
    # Support source date epoch:
    # https://reproducible-builds.org/specs/source-date-epoch/
    try:
        system.buildtime = datetime.datetime.fromtimestamp(
            int(os.environ['SOURCE_DATE_EPOCH']), datetime.UTC)
    except ValueError as e:
        error(str(e))
    except KeyError:
        pass
    # Load custom buildtime
    if options.buildtime:
        try:
            system.buildtime = datetime.datetime.strptime(
                options.buildtime, BUILDTIME_FORMAT)
        except ValueError as e:
            error(str(e))
    
    # step 1.5: create the builder

    builderT = system.systemBuilder

    if options.prependedpackage:
        builderT = model.prepend_package(builderT, package=options.prependedpackage)

    builder = builderT(system)

    # step 2: add any packages and modules to the builder

    try:
        for path in options.sourcepath:
            builder.addModule(path)
    except model.SystemBuildingError as e:
        error(str(e))

    # step 3: move the system to the desired state

    if system.options.projectname is None:
        name = '/'.join(system.root_names)
        system.msg('warning', f"Guessing '{name}' for project name.", thresh=0)
        system.projectname = name
    else:
        system.projectname = system.options.projectname

    builder.buildModules()

    return system

def make(system: model.System) -> None:
    """
    Produce the html/intersphinx output, as configured in the system's options. 
    """
    options = system.options
    # step 4: make html, if desired

    if options.makehtml:
        options.makeintersphinx = True
        
        system.msg('html', 'writing html to %s using %s.%s'%(
            options.htmloutput, options.htmlwriter.__module__,
            options.htmlwriter.__name__))

        writer: IWriter
        
        # Always init the writer with the 'base' set of templates at least.
        template_lookup = TemplateLookup(
                            importlib_resources.files('pydoctor.themes') / 'base')
        
        # Handle theme selection, 'classic' by default.
        if system.options.theme != 'base':
            template_lookup.add_templatedir(
                importlib_resources.files('pydoctor.themes') / system.options.theme)

        # Handle custom HTML templates
        if system.options.templatedir:
            try:
                for t in system.options.templatedir:
                    template_lookup.add_templatedir(Path(t))
            except TemplateError  as e:
                error(str(e))

        build_directory = Path(options.htmloutput)

        writer = options.htmlwriter(build_directory, template_lookup=template_lookup)

        writer.prepOutputDirectory()

        subjects: Sequence[model.Documentable] = ()
        if options.htmlsubjects:
            subjects = [system.allobjects[fn] for fn in options.htmlsubjects]
        else:
            writer.writeSummaryPages(system)
            if not options.htmlsummarypages:
                subjects = system.rootobjects
        writer.writeIndividualFiles(subjects)
        if not options.htmlsubjects:
            writer.writeLinks(system)
        
    if options.makeintersphinx:
        if not options.makehtml:
            subjects = system.rootobjects
        # Generate Sphinx inventory.
        sphinx_inventory = SphinxInventoryWriter(
            logger=system.msg,
            project_name=system.projectname,
            project_version=system.options.projectversion,
            )
        if not os.path.exists(options.htmloutput):
            os.makedirs(options.htmloutput)
        sphinx_inventory.generate(
            subjects=subjects,
            basepath=options.htmloutput,
            )

def main(args: Sequence[str] = sys.argv[1:]) -> int:
    """
    This is the console_scripts entry point for pydoctor CLI.

    @param args: Command line arguments to run the CLI.
    """
    options = Options.from_args(args)

    exitcode = 0

    try:

        # Check that we're actually going to accomplish something here
        if not options.sourcepath:
            error("No source paths given.")

        # Build model
        system = get_system(options)
        
        # Produce output (HMTL, json, ect)
        make(system)

        # Print summary of docstring syntax errors
        docstring_syntax_errors = system.parse_errors['docstring']
        if docstring_syntax_errors:
            exitcode = 2

            def p(msg: str) -> None:
                system.msg('docstring-summary', msg, thresh=-1, topthresh=1)
            p("these %s objects' docstrings contain syntax errors:"
                %(len(docstring_syntax_errors),))
            for fn in sorted(docstring_syntax_errors):
                p('    '+fn)

        # If there is any other kind of parse errors, exit with code 2 as well.
        # This applies to errors generated from colorizing AST.
        elif any(system.parse_errors.values()):
            exitcode = 2

        if system.violations and options.warnings_as_errors:
            # Update exit code if the run has produced warnings.
            exitcode = 3
        
    except:
        if options.pdb:
            import pdb
            pdb.post_mortem(sys.exc_info()[2])
        raise
    
    return exitcode
pydoctor-24.11.2/pydoctor/epydoc/000077500000000000000000000000001473665144200166665ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/epydoc/__init__.py000066400000000000000000000040451473665144200210020ustar00rootroot00000000000000# epydoc
#
# Copyright (C) 2005 Edward Loper
# Author: Edward Loper 
# URL: 
#

"""

epydoc is an automatic Python reference documentation generator.
pydoctor uses parts of the epydoc source as a library.

Package Organization
====================

Docstring markup parsing is handled by the `markup` package.
See the submodule list for more information about the submodules
and subpackages.

:author: `Edward Loper `__

:see: `The epydoc webpage `__
:see: `The epytext markup language manual `__

::

    :license: IBM Open Source License
    :copyright: |copy| 2006 Edward Loper

    :newfield contributor: Contributor, Contributors (Alphabetical Order)
    :contributor: `Glyph Lefkowitz  `__
    :contributor: `Edward Loper  `__
    :contributor: `Bruce Mitchener  `__
    :contributor: `Jeff O'Halloran  `__
    :contributor: `Simon Pamies  `__
    :contributor: `Christian Reis  `__
    :contributor: `Daniele Varrazzo  `__
    :contributor: `Jonathan Guyer `__

    .. |copy| unicode:: 0xA9 .. copyright sign

"""
__docformat__ = 'restructuredtext en'

__version__ = '3.0.1'
"""The version of epydoc"""

__author__ = 'Edward Loper '
"""The primary author of eypdoc"""

__url__ = 'http://epydoc.sourceforge.net'
"""The URL for epydoc's homepage"""

__license__ = 'IBM Open Source License'
"""The license governing the use and distribution of epydoc"""

# Changes needed for docs:
#   - document the method for deciding what's public/private
#   - epytext: fields are defined slightly differently (@group)
#   - new fields
#   - document __extra_epydoc_fields__ and @newfield
#   - Add a faq?
#   - @type a,b,c: ...
#   - new command line option: --command-line-order

pydoctor-24.11.2/pydoctor/epydoc/doctest.py000066400000000000000000000164011473665144200207070ustar00rootroot00000000000000#
# doctest.py: Syntax Highlighting for doctest blocks
# Edward Loper
#
# Created [06/28/03 02:52 AM]
#
"""
Syntax highlighting for blocks of Python code.
"""
from __future__ import annotations

__docformat__ = 'epytext en'

from typing import Iterator, Match, Union
import builtins
import re

from twisted.web.template import Tag, tags

__all__ = ['colorize_codeblock', 'colorize_doctest']

#: A list of the names of all Python keywords.
_KEYWORDS = [
    'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue',
    'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
    'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass',
    'raise', 'return', 'try', 'while', 'with', 'yield'
    ]
# The following are technically keywords since Python 3,
# but we don't want to colorize them as such: 'None', 'True', 'False'.

#: A list of all Python builtins.
_BUILTINS = [_BI for _BI in dir(builtins) if not _BI.startswith('__')]

#: A regexp group that matches keywords.
_KEYWORD_GRP = '|'.join(rf'\b{_KW}\b' for _KW in _KEYWORDS)

#: A regexp group that matches Python builtins.
_BUILTIN_GRP = r'(?>>" prompts.
_PROMPT1_GRP = r'^[ \t]*>>>(?:[ \t]|$)'

#: A regexp group that matches Python "..." prompts.
_PROMPT2_GRP = r'^[ \t]*\.\.\.(?:[ \t]|$)'

#: A regexp group that matches function and class definitions.
_DEFINE_GRP = r'\b(?:def|class)[ \t]+\w+'

#: A regexp that decomposes function definitions.
DEFINE_FUNC_RE = re.compile(r'(?P\w+)(?P\s+)(?P\w+)')

#: A regexp that matches Python prompts
PROMPT_RE = re.compile(f'({_PROMPT1_GRP}|{_PROMPT2_GRP})',
                       re.MULTILINE | re.DOTALL)

#: A regexp that matches Python "..." prompts.
PROMPT2_RE = re.compile(f'({_PROMPT2_GRP})',
                        re.MULTILINE | re.DOTALL)

#: A regexp that matches doctest exception blocks.
EXCEPT_RE = re.compile(r'^[ \t]*Traceback \(most recent call last\):.*',
                       re.DOTALL | re.MULTILINE)

#: A regexp that matches doctest directives.
DOCTEST_DIRECTIVE_RE = re.compile(r'#[ \t]*doctest:.*')

#: A regexp that matches all of the regions of a doctest block
#: that should be colored.
DOCTEST_RE = re.compile(
    '('
        rf'(?P{_STRING_GRP})|(?P{_COMMENT_GRP})|'
        rf'(?P{_DEFINE_GRP})|'
        rf'(?P{_KEYWORD_GRP})|(?P{_BUILTIN_GRP})|'
        rf'(?P{_PROMPT1_GRP})|(?P{_PROMPT2_GRP})|(?P\Z)'
    ')',
    re.MULTILINE | re.DOTALL)

#: This regular expression is used to find doctest examples in a
#: string.  This is copied from the standard Python doctest.py
#: module (after the refactoring in Python 2.4+).
DOCTEST_EXAMPLE_RE = re.compile(r'''
    # Source consists of a PS1 line followed by zero or more PS2 lines.
    (?P
        (?:^(?P [ ]*) >>>    .*)    # PS1 line
        (?:\n           [ ]*  \.\.\. .*)*   # PS2 lines
        \n?)
    # Want consists of any non-blank lines that do not start with PS1.
    (?P (?:(?![ ]*$)    # Not a blank line
                 (?![ ]*>>>)  # Not a line starting with PS1
                 .*$\n?       # But any other line
              )*)
    ''', re.MULTILINE | re.VERBOSE)

def colorize_codeblock(s: str) -> Tag:
    """
    Colorize a string containing only Python code.  This method
    differs from L{colorize_doctest} in that it will not search
    for doctest prompts when deciding how to colorize the string.

    This code consists of a C{
} block with class=py-doctest.
    Syntax highlighting is performed using the following CSS classes:

      - C{py-keyword} -- a Python keyword (for, if, etc.)
      - C{py-builtin} -- a Python builtin name (abs, dir, etc.)
      - C{py-string} -- a string literal
      - C{py-comment} -- a comment
      - C{py-except} -- an exception traceback (up to the next >>>)
      - C{py-output} -- the output from a doctest block.
      - C{py-defname} -- the name of a function or class defined by
        a C{def} or C{class} statement.
    """

    return tags.pre('\n', *colorize_codeblock_body(s), class_='py-doctest')

def colorize_doctest(s: str) -> Tag:
    """
    Perform syntax highlighting on the given doctest string, and
    return the resulting HTML code.

    This code consists of a C{
} block with class=py-doctest.
    Syntax highlighting is performed using the following CSS classes:

      - C{py-prompt} -- the Python PS1 prompt (>>>)
      - C{py-more} -- the Python PS2 prompt (...)
      - the CSS classes output by L{colorize_codeblock}
    """

    return tags.pre('\n', *colorize_doctest_body(s), class_='py-doctest')

def colorize_doctest_body(s: str) -> Iterator[Union[str, Tag]]:
    idx = 0
    for match in DOCTEST_EXAMPLE_RE.finditer(s):
        # Parse the doctest example:
        pysrc, want = match.group('source', 'want')
        # Pre-example text:
        yield s[idx:match.start()]
        # Example source code:
        yield from colorize_codeblock_body(pysrc)
        # Example output:
        if want:
            style = 'py-except' if EXCEPT_RE.match(want) else 'py-output'
            for line in want.rstrip().split('\n'):
                yield tags.span(line, class_=style)
                yield '\n'
        idx = match.end()
    # Add any remaining post-example text.
    yield s[idx:]

def colorize_codeblock_body(s: str) -> Iterator[Union[Tag, str]]:
    idx = 0
    for match in DOCTEST_RE.finditer(s):
        start = match.start()
        if idx < start:
            yield s[idx:start]
        yield from subfunc(match)
        idx = match.end()
    # DOCTEST_RE matches end-of-string.
    assert idx == len(s)

def subfunc(match: Match[str]) -> Iterator[Union[Tag, str]]:
    text = match.group(1)
    if match.group('PROMPT1'):
        yield tags.span(text, class_='py-prompt')
    elif match.group('PROMPT2'):
        yield tags.span(text, class_='py-more')
    elif match.group('KEYWORD'):
        yield tags.span(text, class_='py-keyword')
    elif match.group('BUILTIN'):
        yield tags.span(text, class_='py-builtin')
    elif match.group('COMMENT'):
        yield tags.span(text, class_='py-comment')
    elif match.group('STRING'):
        idx = 0
        while True:
            nxt = text.find('\n', idx)
            line = text[idx:] if nxt == -1 else text[idx:nxt]
            m = PROMPT2_RE.match(line)
            if m:
                prompt_end = m.end()
                yield tags.span(line[:prompt_end], class_='py-more')
                line = line[prompt_end:]
            if line:
                yield tags.span(line, class_='py-string')
            if nxt == -1:
                break
            yield '\n'
            idx = nxt + 1
    elif match.group('DEFINE'):
        m = DEFINE_FUNC_RE.match(text)
        assert m is not None
        yield tags.span(m.group('def'), class_='py-keyword')
        yield m.group('space')
        yield tags.span(m.group('name'), class_='py-defname')
    elif match.group('EOS') is None:
        raise AssertionError('Unexpected match')
pydoctor-24.11.2/pydoctor/epydoc/docutils.py000066400000000000000000000141431473665144200210710ustar00rootroot00000000000000"""
Collection of helper functions and classes related to the creation and processing of L{docutils} nodes.
"""
from __future__ import annotations

from typing import Iterable, Iterator, Optional, TypeVar, cast

import optparse

from docutils import nodes, utils, frontend, __version_info__ as docutils_version_info
from docutils.transforms import parts

__docformat__ = 'epytext en'

_DEFAULT_DOCUTILS_SETTINGS: Optional[optparse.Values] = None

def new_document(source_path: str, settings: Optional[optparse.Values] = None) -> nodes.document:
    """
    Create a new L{nodes.document} using the provided settings or cached default settings.

    @returns: L{nodes.document}
    """
    global _DEFAULT_DOCUTILS_SETTINGS
    # If we have docutils >= 0.19 we use get_default_settings to calculate and cache
    # the default settings. Otherwise we let new_document figure it out.
    if settings is None and docutils_version_info >= (0,19):
        if _DEFAULT_DOCUTILS_SETTINGS is None:
            _DEFAULT_DOCUTILS_SETTINGS = frontend.get_default_settings()

        settings = _DEFAULT_DOCUTILS_SETTINGS

    return utils.new_document(source_path, settings)

def _set_nodes_parent(nodes: Iterable[nodes.Node], parent: nodes.Element) -> Iterator[nodes.Node]:
    """
    Set the L{nodes.Node.parent} attribute of the C{nodes} to the defined C{parent}. 
    
    @returns: An iterator containing the modified nodes.
    """
    for node in nodes:
        node.parent = parent
        yield node

TNode = TypeVar('TNode', bound=nodes.Node)
def set_node_attributes(node: TNode, 
                        document: Optional[nodes.document] = None, 
                        lineno: Optional[int] = None, 
                        children: Optional[Iterable[nodes.Node]] = None) -> TNode:
    """
    Set the attributes of a Node and return the modified node.
    This is required to manually construct a docutils document that is consistent.

    @param node: A node to edit.
    @param document: The L{nodes.Node.document} attribute.
    @param lineno: The L{nodes.Node.line} attribute.
    @param children: The L{nodes.Element.children} attribute. Special care is taken 
        to appropriately set the L{nodes.Node.parent} attribute on the child nodes. 
    """
    if lineno is not None:
        node.line = lineno
    
    if document:
        node.document = document

    if children:
        assert isinstance(node, nodes.Element), (f'Cannot set the children on Text node: "{node.astext()}". '
                                                 f'Children: {children}')
        node.extend(_set_nodes_parent(children, node))

    return node

def build_table_of_content(node: nodes.Element, depth: int, level: int = 0) -> nodes.Element | None:
    """
    Simplified from docutils Contents transform. 

    All section nodes MUST have set attribute 'ids' to a list of strings.
    """

    def _copy_and_filter(node: nodes.Element) -> nodes.Element:
        """Return a copy of a title, with references, images, etc. removed."""
        if (doc:=node.document) is None:
            raise AssertionError(f'missing document attribute on {node}')
        visitor = parts.ContentsFilter(doc)
        node.walkabout(visitor)
        #                                 the stubs are currently imcomplete, 2024.
        return visitor.get_entry_text() # type:ignore

    level += 1
    sections = [sect for sect in node if isinstance(sect, nodes.section)]
    entries = []
    if (doc:=node.document) is None:
        raise AssertionError(f'missing document attribute on {node}')
    
    for section in sections:
        title = cast(nodes.Element, section[0]) # the first element of a section is the header.
        entrytext = _copy_and_filter(title)
        reference = nodes.reference('', '', refid=section['ids'][0],
                                    *entrytext)
        ref_id = doc.set_id(reference, suggested_prefix='toc-entry')
        entry = nodes.paragraph('', '', reference)
        item = nodes.list_item('', entry)
        if title.next_node(nodes.reference) is None:
            title['refid'] = ref_id
        if level < depth:
            subsects = build_table_of_content(section, depth=depth, level=level)
            item += subsects or []
        entries.append(item)
    if entries:
        contents = nodes.bullet_list('', *entries)
        return contents
    else:
        return None

def get_lineno(node: nodes.Element) -> int:
    """
    Get the 0-based line number for a docutils `nodes.title_reference`.

    Walk up the tree hierarchy until we find an element with a line number, then
    counts the number of newlines until the reference element is found.
    """
    # Fixes https://github.com/twisted/pydoctor/issues/237
        
    def get_first_parent_lineno(_node: nodes.Element | None) -> int:
        if _node is None:
            return 0
        
        if _node.line:
            # This line points to the start of the containing node
            # Here we are removing 1 to the result because ParseError class is zero-based
            # while docutils line attribute is 1-based.
            line:int = _node.line-1
            # Let's figure out how many newlines we need to add to this number 
            # to get the right line number.
            parent_rawsource: Optional[str] = _node.rawsource or None
            node_rawsource: Optional[str] = node.rawsource or None

            if parent_rawsource is not None and \
               node_rawsource is not None:
                if node_rawsource in parent_rawsource:
                    node_index = parent_rawsource.index(node_rawsource)
                    # Add the required number of newlines to the result
                    line += parent_rawsource[:node_index].count('\n')
        else:
            line = get_first_parent_lineno(_node.parent)
        return line

    if node.line:
        line = node.line
    else:
        line = get_first_parent_lineno(node.parent)
    
    return line

class wbr(nodes.inline):
    """
    Word break opportunity.
    """
    def __init__(self) -> None:
        super().__init__('', '')

class obj_reference(nodes.title_reference):
    """
    A reference to a documentable object.
    """
pydoctor-24.11.2/pydoctor/epydoc/markup/000077500000000000000000000000001473665144200201655ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/epydoc/markup/__init__.py000066400000000000000000000436721473665144200223120ustar00rootroot00000000000000#
# epydoc package file
#
# A python documentation Module
# Edward Loper
#
"""
Markup language support for docstrings.  Each submodule defines a
parser for a single markup language.  These parsers convert an
object's docstring to a L{ParsedDocstring}, a standard intermediate
representation that can be used to generate output.

A C{ParsedDocstring} is used for output generation
(L{to_stan()}).
It also stores the fields that were extracted from the docstring
during parsing (L{fields}).

The C{parse_docstring()} functions in the format modules take a docstring,
parse it and return a format-specific subclass of C{ParsedDocstring}.
A docstring's fields are separated from the body during parsing.

The C{ParsedDocstring} output generation method
(L{to_stan()}) uses a
L{DocstringLinker} to link the docstring output with the rest of
the documentation that epydoc generates.  C{DocstringLinker}s are
responsible for formatting cross-references
(L{link_xref() }).

Markup errors are represented using L{ParseError}s.  These exception
classes record information about the cause, location, and severity of
each error.
"""
from __future__ import annotations
__docformat__ = 'epytext en'

from typing import Callable, ContextManager, List, Optional, Sequence, Iterator, TYPE_CHECKING
import abc
import sys
import re
from importlib import import_module
from inspect import getmodulename

from docutils import nodes
from twisted.web.template import Tag, tags

from pydoctor import node2stan
from pydoctor.epydoc.docutils import set_node_attributes, build_table_of_content, new_document


# In newer Python versions, use importlib.resources from the standard library.
# On older versions, a compatibility package must be installed from PyPI.
if sys.version_info < (3, 9):
    import importlib_resources
else:
    import importlib.resources as importlib_resources

if TYPE_CHECKING:
    from twisted.web.template import Flattenable
    from pydoctor.model import Documentable
    from typing import Protocol, Literal, TypeAlias
else:
    Protocol = object

##################################################
## Contents
##################################################
#
# 1. ParsedDocstring abstract base class
# 2. Field class
# 3. Docstring Linker
# 4. ParseError exceptions
#

ObjClass: TypeAlias = "Literal['module', 'class', 'function', 'attribute']"
"""
A simpler version of L{DocumentableKind} used for docstring parsing only.
"""

ParserFunction = Callable[[str, List['ParseError']], 'ParsedDocstring']

def get_supported_docformats() -> Iterator[str]:
    """
    Get the list of currently supported docformat.
    """
    for fileName in (path.name for path in importlib_resources.files('pydoctor.epydoc.markup').iterdir()):
        moduleName = getmodulename(fileName)
        if moduleName is None or moduleName.startswith("_"):
            continue
        else:
            yield moduleName

def get_parser_by_name(docformat: str, objclass: ObjClass | None = None) -> ParserFunction:
    """
    Get the C{parse_docstring(str, List[ParseError], bool) -> ParsedDocstring} function based on a parser name. 

    @raises ImportError: If the parser could not be imported, probably meaning that your are missing a dependency
        or it could be that the docformat name do not match any know L{pydoctor.epydoc.markup} submodules.
    """
    mod = import_module(f'pydoctor.epydoc.markup.{docformat}')
    # We can be sure the 'get_parser' function exist and is "correct" 
    # since the docformat is validated beforehand.
    get_parser: Callable[[ObjClass | None], ParserFunction] = mod.get_parser
    return get_parser(objclass)

def processtypes(parse:ParserFunction) -> ParserFunction:
    """
    Wraps a docstring parser function to provide option --process-types.
    """
    
    def _processtypes(doc: 'ParsedDocstring', errs: List['ParseError']) -> None:
        """
        Mutates the type fields of the given parsed docstring to replace 
        their body by parsed version with type auto-linking.
        """
        from pydoctor.epydoc.markup._types import ParsedTypeDocstring
        for field in doc.fields:
            if field.tag() in ParsedTypeDocstring.FIELDS:
                body = ParsedTypeDocstring(field.body().to_node(), lineno=field.lineno)
                append_warnings(body.warnings, errs, lineno=field.lineno+1)
                field.replace_body(body)
    
    def parse_and_processtypes(doc:str, errs:List['ParseError']) -> 'ParsedDocstring':
        parsed_doc = parse(doc, errs)
        _processtypes(parsed_doc, errs)
        return parsed_doc

    return parse_and_processtypes

##################################################
## ParsedDocstring
##################################################
class ParsedDocstring(abc.ABC):
    """
    A standard intermediate representation for parsed docstrings that
    can be used to generate output.  Parsed docstrings are produced by
    markup parsers such as L{pydoctor.epydoc.markup.epytext.parse_docstring()}
    or L{pydoctor.epydoc.markup.restructuredtext.parse_docstring()}.

    Subclasses must implement L{has_body()} and L{to_node()}.
    
    A default implementation for L{to_stan()} method, relying on L{to_node()} is provided.
    But some subclasses override this behaviour.
    
    Implementation of L{get_toc()} also relies on L{to_node()}.
    """

    def __init__(self, fields: Sequence['Field']):
        self.fields = fields
        """
        A list of L{Field}s, each of which encodes a single field.
        The field's bodies are encoded as C{ParsedDocstring}s.
        """

        self._stan: Optional[Tag] = None
        self._summary: Optional['ParsedDocstring'] = None

    @abc.abstractproperty
    def has_body(self) -> bool:
        """
        Does this docstring have a non-empty body?

        The body is the part of the docstring that remains after the fields
        have been split off.
        """
    
    def get_toc(self, depth: int) -> Optional['ParsedDocstring']:
        """
        The table of contents of the docstring if titles are defined or C{None}.
        """
        try:
            document = self.to_node()
        except NotImplementedError:
            return None
        contents = build_table_of_content(document, depth=depth)
        docstring_toc = new_document('toc')
        if contents:
            docstring_toc.extend(contents)
            from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring
            return ParsedRstDocstring(docstring_toc, ())
        else:
            return None

    def to_stan(self, docstring_linker: 'DocstringLinker') -> Tag:
        """
        Translate this docstring to a Stan tree.

        @note: The default implementation relies on functionalities 
            provided by L{node2stan.node2stan} and L{ParsedDocstring.to_node()}.

        @param docstring_linker: An HTML translator for crossreference
            links into and out of the docstring.
        @return: The docstring presented as a stan tree.
        @raises Exception: If something went wrong. Callers should generally catch C{Exception}
            when calling L{to_stan()}.
        """
        if self._stan is not None:
            return self._stan
        self._stan = Tag('', children=node2stan.node2stan(self.to_node(), docstring_linker).children)
        return self._stan
    
    @abc.abstractmethod
    def to_node(self) -> nodes.document:
        """
        Translate this docstring to a L{nodes.document}.

        @return: The docstring presented as a L{nodes.document}.

        @note: Some L{ParsedDocstring} subclasses do not support docutils nodes.
            This method might raise L{NotImplementedError} in such cases. (i.e. L{pydoctor.epydoc.markup._types.ParsedTypeDocstring})
        """
        raise NotImplementedError()
    
    def get_summary(self) -> 'ParsedDocstring':
        """
        Returns the summary of this docstring.
        
        @note: The summary is cached.
        """
        # Avoid rare cyclic import error, see https://github.com/twisted/pydoctor/pull/538#discussion_r845668735
        from pydoctor import epydoc2stan
        if self._summary is not None:
            return self._summary
        try: 
            _document = self.to_node()
            visitor = SummaryExtractor(_document)
            _document.walk(visitor)
        except Exception: 
            self._summary = epydoc2stan.ParsedStanOnly(tags.span(class_='undocumented')("Broken summary"))
        else:
            self._summary = visitor.summary or epydoc2stan.ParsedStanOnly(tags.span(class_='undocumented')("No summary"))
        return self._summary

      
##################################################
## Fields
##################################################
class Field:
    """
    The contents of a docstring's field.  Docstring fields are used
    to describe specific aspects of an object, such as a parameter of
    a function or the author of a module.  Each field consists of a
    tag, an optional argument, and a body:
      - The tag specifies the type of information that the field
        encodes.
      - The argument specifies the object that the field describes.
        The argument may be C{None} or a C{string}.
      - The body contains the field's information.

    Tags are automatically downcased and stripped; and arguments are
    automatically stripped.
    """

    def __init__(self, tag: str, arg: Optional[str], body: ParsedDocstring, lineno: int):
        self._tag = tag.lower().strip()
        self._arg = None if arg is None else arg.strip()
        self._body = body
        self.lineno = lineno

    def tag(self) -> str:
        """
        @return: This field's tag.
        """
        return self._tag

    def arg(self) -> Optional[str]:
        """
        @return: This field's argument, or C{None} if this field has no argument.
        """
        return self._arg

    def body(self) -> ParsedDocstring:
        """
        @return: This field's body.
        """
        return self._body
    
    def replace_body(self, newbody:ParsedDocstring) -> None:
        self._body = newbody

    def __repr__(self) -> str:
        if self._arg is None:
            return f''
        else:
            return f''

##################################################
## Docstring Linker (resolves crossreferences)
##################################################
class DocstringLinker(Protocol):
    """
    A resolver for crossreference links out of a C{ParsedDocstring}.
    C{DocstringLinker} is used by C{ParsedDocstring} to look up the
    target URL for crossreference links.
    """

    def link_to(self, target: str, label: "Flattenable") -> Tag:
        """
        Format a link to a Python identifier.
        This will resolve the identifier like Python itself would.

        @param target: The name of the Python identifier that
            should be linked to.
        @param label: The label to show for the link.
        @return: The link, or just the label if the target was not found.
        """

    def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag:
        """
        Format a cross-reference link to a Python identifier.
        This will resolve the identifier to any reasonable target,
        even if it has to look in places where Python itself would not.

        @param target: The name of the Python identifier that
            should be linked to.
        @param label: The label to show for the link.
        @param lineno: The line number within the docstring at which the
            crossreference is located.
        @return: The link, or just the label if the target was not found.
            In either case, the returned top-level tag will be C{}.
        """

    def switch_context(self, ob:Optional['Documentable']) -> ContextManager[None]:
        """
        Switch the context of the linker, keeping the same underlying lookup rules.

        Useful to resolve links with the right L{Documentable} context but
        create correct - absolute or relative - links to be clicked on from another page 
        rather than the initial page of the context. "Cannot find link target" errors will be reported
        relatively to the new context object.

        Pass C{None} to always generate full URLs (for summaries for example), 
        in this case error will NOT be reported at all.
        """

##################################################
## ParseError exceptions
##################################################

def append_warnings(warns:List[str], errs:List['ParseError'], lineno:int) -> None:
    """
    Utility method to create non fatal L{ParseError}s and append them to the provided list.

    @param warns: The warnings strings.
    @param errs: The list of errors.
    """
    for warn in warns:
        errs.append(ParseError(warn, linenum=lineno, is_fatal=False))

class ParseError(Exception):
    """
    The base class for errors generated while parsing docstrings.
    """

    def __init__(self,
            descr: str,
            linenum: Optional[int] = None,
            is_fatal: bool = True
            ):
        """
        @param descr: A description of the error.
        @param linenum: The line on which the error occured within
            the docstring.  The linenum of the first line is 0.
        @param is_fatal: True if this is a fatal error.
        """
        self._descr = descr
        self._linenum = linenum
        self._fatal = is_fatal

    def is_fatal(self) -> bool:
        """
        @return: true if this is a fatal error.  If an error is fatal,
            then epydoc should ignore the output of the parser, and
            parse the docstring as plaintext.
        """
        return self._fatal

    def linenum(self) -> Optional[int]:
        """
        @return: The line number on which the error occured (including
        any offset).  If the line number is unknown, then return
        C{None}.
        """
        if self._linenum is None: return None
        else: return self._linenum + 1

    def descr(self) -> str:
        """
        @return: A description of the error.
        """
        return self._descr

    def __str__(self) -> str:
        """
        Return a string representation of this C{ParseError}.  This
        multi-line string contains a description of the error, and
        specifies where it occured.

        @return: the informal representation of this C{ParseError}.
        """
        if self._linenum is not None:
            return f'Line {self._linenum + 1:d}: {self.descr()}'
        else:
            return self.descr()

    def __repr__(self) -> str:
        """
        Return the formal representation of this C{ParseError}.
        C{ParseError}s have formal representations of the form::
           

        @return: the formal representation of this C{ParseError}.
        """
        if self._linenum is None:
            return ''
        else:
            return f''

class SummaryExtractor(nodes.NodeVisitor):
    """
    A docutils node visitor that extracts first sentences from
    the first paragraph in a document.
    """
    def __init__(self, document: nodes.document, maxchars:int=200) -> None:
        """
        @param document: The docutils document to extract a summary from.
        @param maxchars: Maximum of characters the summary can span. 
            Sentences are not cut in the middle, so the actual length
            might be longer if your have a large first paragraph.
        """
        super().__init__(document)
        self.summary: Optional['ParsedDocstring'] = None
        self.other_docs: bool = False
        self.maxchars = maxchars

    def visit_document(self, node: nodes.Node) -> None:
        self.summary = None

    _SENTENCE_RE_SPLIT = re.compile(r'( *[\.\?!][\'"\)\]]* *)')

    def visit_paragraph(self, node: nodes.paragraph) -> None:
        if self.summary is not None:
            # found a paragraph after the first one
            self.other_docs = True
            raise nodes.StopTraversal()

        summary_doc = new_document('summary')
        summary_pieces: list[nodes.Node] = []

        # Extract the first sentences from the first paragraph until maximum number 
        # of characters is reach or until the end of the paragraph.
        char_count = 0

        for child in node:

            if char_count > self.maxchars:
                break
            
            if isinstance(child, nodes.Text):
                text = child.astext().replace('\n', ' ')
                sentences = [item for item in self._SENTENCE_RE_SPLIT.split(text) if item] # Not empty values only
                
                for i,s in enumerate(sentences):
                    
                    if char_count > self.maxchars:
                        # Leave final point alone.
                        if not (i == len(sentences)-1 and len(s)==1):
                            break

                    summary_pieces.append(set_node_attributes(nodes.Text(s), document=summary_doc))
                    char_count += len(s)

            else:
                summary_pieces.append(set_node_attributes(child.deepcopy(), document=summary_doc))
                char_count += len(''.join(node2stan.gettext(child)))
            
        if char_count > self.maxchars:
            if not summary_pieces[-1].astext().endswith('.'):
                summary_pieces.append(set_node_attributes(nodes.Text('...'), document=summary_doc))
            self.other_docs = True

        set_node_attributes(summary_doc, children=[
            set_node_attributes(nodes.paragraph('', ''), document=summary_doc, lineno=1, 
            children=summary_pieces)])

        from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring
        self.summary = ParsedRstDocstring(summary_doc, fields=[])

    def visit_field(self, node: nodes.Node) -> None:
        raise nodes.SkipNode()

    def unknown_visit(self, node: nodes.Node) -> None:
        '''Ignore all unknown nodes'''
pydoctor-24.11.2/pydoctor/epydoc/markup/_napoleon.py000066400000000000000000000055471473665144200225240ustar00rootroot00000000000000"""
This module contains a class to wrap shared behaviour between 
L{pydoctor.epydoc.markup.numpy} and L{pydoctor.epydoc.markup.google}. 
"""
from __future__ import annotations

from pydoctor.epydoc.markup import ObjClass, ParsedDocstring, ParseError, processtypes
from pydoctor.epydoc.markup import restructuredtext
from pydoctor.napoleon.docstring import GoogleDocstring, NumpyDocstring


class NapoelonDocstringParser:
    """
    Parse google-style or numpy-style docstrings.

    First wrap the L{pydoctor.napoleon} converter classes, then call
    L{pydoctor.epydoc.markup.restructuredtext.parse_docstring} with the
    converted reStructuredText docstring.

    If the L{Documentable} instance is an L{Attribute}, the docstring
    will be parsed differently.
    """

    def __init__(self, objclass: ObjClass | None = None):
        """
        @param objclass: Class of the documentable object we're parsing the docstring for.
        """
        self.objclass = objclass

    def parse_google_docstring(
        self, docstring: str, errors: list[ParseError]
    ) -> ParsedDocstring:
        """
        Parse the given docstring, which is formatted as Google style docstring.
        Return a L{ParsedDocstring} representation of its contents.

        @param docstring: The docstring to parse
        @param errors: A list where any errors generated during parsing
            will be stored.
        """
        return self._parse_docstring(
            docstring, errors, GoogleDocstring, )

    def parse_numpy_docstring(
        self, docstring: str, errors: list[ParseError]
    ) -> ParsedDocstring:
        """
        Parse the given docstring, which is formatted as NumPy style docstring.
        Return a L{ParsedDocstring} representation of its contents.

        @param docstring: The docstring to parse
        @param errors: A list where any errors generated during parsing
            will be stored.
        """
        return self._parse_docstring(
            docstring, errors, NumpyDocstring, )

    def _parse_docstring(
        self,
        docstring: str,
        errors: list[ParseError],
        docstring_cls: type[GoogleDocstring],
    ) -> ParsedDocstring:

        docstring_obj = docstring_cls(
            docstring, 
            what=self.objclass,
        )

        return self._parse_docstring_obj(docstring_obj, errors)

    @staticmethod
    def _parse_docstring_obj(
        docstring_obj: GoogleDocstring, errors: list[ParseError]
    ) -> ParsedDocstring:
        """
        Helper method to parse L{GoogleDocstring} or L{NumpyDocstring} objects.
        """
        # log any warnings
        for warn, lineno in docstring_obj.warnings:
            errors.append(ParseError(warn, lineno, is_fatal=False))
        # Get the converted reST string and parse it with docutils
        return processtypes(restructuredtext.parse_docstring)(str(docstring_obj), errors)
pydoctor-24.11.2/pydoctor/epydoc/markup/_pyval_repr.py000066400000000000000000001310401473665144200230600ustar00rootroot00000000000000# epydoc -- Marked-up Representations for Python Values
#
# Copyright (C) 2005 Edward Loper
# Author: Edward Loper 
# URL: 
#
"""
Syntax highlighter for Python values.  Currently provides special
colorization support for:

  - lists, tuples, sets, frozensets, dicts
  - numbers
  - strings
  - compiled regexps
  - a variety of AST expressions

The highlighter also takes care of line-wrapping, and automatically
stops generating repr output as soon as it has exceeded the specified
number of lines (which should make it faster than pprint for large
values).  It does I{not} bother to do automatic cycle detection,
because maxlines is typically around 5, so it's really not worth it.

The syntax-highlighted output is encoded using a
L{ParsedDocstring}, which can then be used to generate output in
a variety of formats.

B{Implementation note}: we use exact tests for builtin classes (list, etc)
rather than using isinstance, because subclasses might override
C{__repr__}.

B{Usage}: 
>>> 
"""
from __future__ import annotations

__docformat__ = 'epytext en'

import re
import ast
import functools
import sys
from inspect import signature
from typing import Any, AnyStr, Union, Callable, Dict, Iterable, Sequence, Optional, List, Tuple, cast

import attr
from docutils import nodes
from twisted.web.template import Tag

from pydoctor.epydoc import sre_parse36, sre_constants36 as sre_constants
from pydoctor.epydoc.markup import DocstringLinker
from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring
from pydoctor.epydoc.docutils import set_node_attributes, wbr, obj_reference, new_document
from pydoctor.astutils import node2dottedname, bind_args, Parentage, get_parents, unparse, op_util

def decode_with_backslashreplace(s: bytes) -> str:
    r"""
    Convert the given 8-bit string into unicode, treating any
    character c such that ord(c)<128 as an ascii character, and
    converting any c such that ord(c)>128 into a backslashed escape
    sequence.
        >>> decode_with_backslashreplace(b'abc\xff\xe8')
        'abc\\xff\\xe8'
    """
    # s.encode('string-escape') is not appropriate here, since it
    # also adds backslashes to some ascii chars (eg \ and ').

    return (s
            .decode('latin1')
            .encode('ascii', 'backslashreplace')
            .decode('ascii'))

@attr.s(auto_attribs=True)
class _MarkedColorizerState:
    length: int
    charpos: int
    lineno: int
    linebreakok: bool
    stacklength: int

class _ColorizerState:
    """
    An object uesd to keep track of the current state of the pyval
    colorizer.  The L{mark()}/L{restore()} methods can be used to set
    a backup point, and restore back to that backup point.  This is
    used by several colorization methods that first try colorizing
    their object on a single line (setting linebreakok=False); and
    then fall back on a multi-line output if that fails.  
    """
    def __init__(self) -> None:
        self.result: list[nodes.Node] = []
        self.charpos = 0
        self.lineno = 1
        self.linebreakok = True
        self.warnings: list[str] = []
        self.stack: list[ast.AST] = []

    def mark(self) -> _MarkedColorizerState:
        return _MarkedColorizerState(
                    length=len(self.result), 
                    charpos=self.charpos,
                    lineno=self.lineno, 
                    linebreakok=self.linebreakok,
                    stacklength=len(self.stack))

    def restore(self, mark: _MarkedColorizerState) -> List[nodes.Node]:
        """
        Return what's been trimmed from the result.
        """
        (self.charpos, self.lineno, 
        self.linebreakok) = (mark.charpos, mark.lineno, 
                                        mark.linebreakok)
        trimmed = self.result[mark.length:]
        del self.result[mark.length:]
        del self.stack[mark.stacklength:]
        return trimmed

# TODO: add support for comparators when needed. 
# _OperatorDelimitier is needed for:
# - IfExp (TODO)
# - UnaryOp (DONE)
# - BinOp, needs special handling for power operator (DONE)
# - Compare (TODO)
# - BoolOp (DONE)
# - Lambda (TODO)
class _OperatorDelimiter:
    """
    A context manager that can add enclosing delimiters to nested operators when needed. 
    
    Adapted from C{astor} library, thanks.
    """

    def __init__(self, colorizer: 'PyvalColorizer', state: _ColorizerState, 
                 node: ast.expr,) -> None:

        self.discard = True
        """No parenthesis by default."""

        self.colorizer = colorizer
        self.state = state
        self.marked = state.mark()

        # We use a hack to populate a "parent" attribute on AST nodes.
        # See astutils.Parentage class, applied in PyvalColorizer._colorize_ast()
        try:
            parent_node: ast.AST = next(get_parents(node))
        except StopIteration:
            return
        
        # avoid needless parenthesis, since we now collect parents for every nodes 
        if isinstance(parent_node, (ast.expr, ast.keyword, ast.comprehension)):
            try:
                precedence = op_util.get_op_precedence(getattr(node, 'op', node))
            except KeyError:
                self.discard = False
            else:
                try:
                    parent_precedence = op_util.get_op_precedence(getattr(parent_node, 'op', parent_node))
                    if isinstance(getattr(parent_node, 'op', None), ast.Pow) or isinstance(parent_node, ast.BoolOp):
                        parent_precedence+=1
                except KeyError:
                    parent_precedence = colorizer.explicit_precedence.get(
                        node, op_util.Precedence.highest)
                    
                if precedence < parent_precedence:
                    self.discard = False

    def __enter__(self) -> '_OperatorDelimiter':
        return self

    def __exit__(self, *exc_info: Any) -> None:
        if not self.discard:
            trimmed = self.state.restore(self.marked)
            self.colorizer._output('(', self.colorizer.GROUP_TAG, self.state)
            self.state.result.extend(trimmed)
            self.colorizer._output(')', self.colorizer.GROUP_TAG, self.state)

class _Maxlines(Exception):
    """A control-flow exception that is raised when PyvalColorizer
    exeeds the maximum number of allowed lines."""

class _Linebreak(Exception):
    """A control-flow exception that is raised when PyvalColorizer
    generates a string containing a newline, but the state object's
    linebreakok variable is False."""

class ColorizedPyvalRepr(ParsedRstDocstring):
    """
    @ivar is_complete: True if this colorized repr completely describes
       the object.
    """
    def __init__(self, document: nodes.document, is_complete: bool, warnings: List[str]) -> None:
        super().__init__(document, ())
        self.is_complete = is_complete
        self.warnings = warnings
        """
        List of warnings
        """
    
    def to_stan(self, docstring_linker: DocstringLinker) -> Tag:
        return Tag('code')(super().to_stan(docstring_linker))

def colorize_pyval(pyval: Any, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None) -> ColorizedPyvalRepr:
    """
    Get a L{ColorizedPyvalRepr} instance for this piece of ast. 

    @param refmap: A mapping that maps local names to full names. 
        This can be used to explicitely links some objects by assigning an 
        explicit 'refuri' value on the L{obj_reference} node.
        This can be used for cases the where the linker might be wrong, obviously this is just a workaround.
    @return: A L{ColorizedPyvalRepr} describing the given pyval.
    """
    return PyvalColorizer(linelen=linelen, maxlines=maxlines, linebreakok=linebreakok, refmap=refmap).colorize(pyval)

def colorize_inline_pyval(pyval: Any, refmap:Optional[Dict[str, str]]=None) -> ColorizedPyvalRepr:
    """
    Used to colorize type annotations and parameters default values.
    @returns: C{L{colorize_pyval}(pyval, linelen=None, linebreakok=False)}
    """
    return colorize_pyval(pyval, linelen=None, maxlines=1, linebreakok=False, refmap=refmap)

def _get_str_func(pyval:  AnyStr) -> Callable[[str], AnyStr]:
    func = cast(Callable[[str], AnyStr], str if isinstance(pyval, str) else \
        functools.partial(bytes, encoding='utf-8', errors='replace'))
    return func
def _str_escape(s: str) -> str:
    """
    Encode a string such that it's correctly represented inside simple quotes.
    """
    # displays unicode caracters as is.
    def enc(c: str) -> str:
        if c == "'":
            c = r"\'"
        elif c == '\t': 
            c = r'\t'
        elif c == '\r': 
            c = r'\r'
        elif c == '\n': 
            c = r'\n'
        elif c == '\f': 
            c = r'\f'
        elif c == '\v': 
            c = r'\v'
        elif c == "\\": 
            c = r'\\'
        return c

    # Escape it
    s = ''.join(map(enc, s))

    # Ensures there is no funcky caracters (like surrogate unicode strings)
    try:
        s.encode('utf-8')
    except UnicodeEncodeError:
        # Otherwise replace them with backslashreplace
        s = s.encode('utf-8', 'backslashreplace').decode('utf-8')
    
    return s

def _bytes_escape(b: bytes) -> str:
    return repr(b)[2:-1]

class PyvalColorizer:
    """
    Syntax highlighter for Python values.
    """

    def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None):
        self.linelen: Optional[int] = linelen if linelen!=0 else None
        self.maxlines: Union[int, float] = maxlines if maxlines!=0 else float('inf')
        self.linebreakok = linebreakok
        self.refmap = refmap if refmap is not None else {}
        # some edge cases require to compute the precedence ahead of time and can't be 
        # easily done with access only to the parent node of some operators.
        self.explicit_precedence:Dict[ast.AST, int] = {}

    #////////////////////////////////////////////////////////////
    # Colorization Tags & other constants
    #////////////////////////////////////////////////////////////

    GROUP_TAG = None # was 'variable-group'     # e.g., "[" and "]"
    COMMA_TAG = None # was 'variable-op'        # The "," that separates elements
    COLON_TAG = None # was 'variable-op'        # The ":" in dictionaries
    CONST_TAG = None                 # None, True, False
    NUMBER_TAG = None                # ints, floats, etc
    QUOTE_TAG = 'variable-quote'     # Quotes around strings.
    STRING_TAG = 'variable-string'   # Body of string literals
    LINK_TAG = 'variable-link'       # Links to other documentables, extracted from AST names and attributes.
    ELLIPSIS_TAG = 'variable-ellipsis'
    LINEWRAP_TAG = 'variable-linewrap'
    UNKNOWN_TAG = 'variable-unknown'

    RE_CHAR_TAG = None
    RE_GROUP_TAG = 're-group'
    RE_REF_TAG = 're-ref'
    RE_OP_TAG = 're-op'
    RE_FLAGS_TAG = 're-flags'

    ELLIPSIS = nodes.inline('...', '...', classes=[ELLIPSIS_TAG])
    LINEWRAP = nodes.inline('', chr(8629), classes=[LINEWRAP_TAG])
    UNKNOWN_REPR = nodes.inline('??', '??', classes=[UNKNOWN_TAG])
    WORD_BREAK_OPPORTUNITY = wbr()
    NEWLINE = nodes.Text('\n')

    GENERIC_OBJECT_RE = re.compile(r'^<(?P.*) at (?P0x[0-9a-f]+)>$', re.IGNORECASE)

    RE_COMPILE_SIGNATURE = signature(re.compile)

    def _set_precedence(self, precedence:int, *node:ast.AST) -> None:
        for n in node:
            self.explicit_precedence[n] = precedence

    def colorize(self, pyval: Any) -> ColorizedPyvalRepr:
        """
        Entry Point.
        """
        # Create an object to keep track of the colorization.
        state = _ColorizerState()
        state.linebreakok = self.linebreakok
        # Colorize the value.  If we reach maxlines, then add on an
        # ellipsis marker and call it a day.
        try:
            self._colorize(pyval, state)
        except (_Maxlines, _Linebreak):
            if self.linebreakok:
                state.result.append(self.NEWLINE)
                state.result.append(self.ELLIPSIS)
            else:
                if state.result[-1] is self.LINEWRAP:
                    state.result.pop()
                self._trim_result(state.result, 3)
                state.result.append(self.ELLIPSIS)
            is_complete = False
        else:
            is_complete = True
        
        # Put it all together.
        document = new_document('pyval_repr')
        # This ensure the .parent and .document attributes of the child nodes are set correcly.
        set_node_attributes(document, children=[set_node_attributes(node, document=document) for node in state.result])
        return ColorizedPyvalRepr(document, is_complete, state.warnings)
    
    def _colorize(self, pyval: Any, state: _ColorizerState) -> None:

        pyvaltype = type(pyval)
        
        # Individual "is" checks are required here to be sure we don't consider 0 as True and 1 as False!
        if pyval is False or pyval is True or pyval is None or pyval is NotImplemented:
            # Link built-in constants to the standard library.
            # Ellipsis is not included here, both because its code syntax is
            # different from its constant's name and because its documentation
            # is not relevant to annotations.
            self._output(str(pyval), self.CONST_TAG, state, link=True)
        elif pyvaltype is int or pyvaltype is float or pyvaltype is complex:
            self._output(str(pyval), self.NUMBER_TAG, state)
        elif pyvaltype is str:
            self._colorize_str(pyval, state, '', escape_fcn=_str_escape)
        elif pyvaltype is bytes:
            self._colorize_str(pyval, state, b'b', escape_fcn=_bytes_escape)
        elif pyvaltype is tuple:
            # tuples need an ending comma when they contains only one value.
            self._multiline(self._colorize_iter, pyval, state, prefix='(', 
                            suffix=(',' if len(pyval) <= 1 else '')+')')
        elif pyvaltype is set:
            self._multiline(self._colorize_iter, pyval,
                            state, prefix='set([', suffix='])')
        elif pyvaltype is frozenset:
            self._multiline(self._colorize_iter, pyval,
                            state, prefix='frozenset([', suffix='])')
        elif pyvaltype is list:
            self._multiline(self._colorize_iter, pyval, state, prefix='[', suffix=']')
        elif issubclass(pyvaltype, ast.AST):
            self._colorize_ast(pyval, state)
        else:
            # Unknow live object
            try:
                pyval_repr = repr(pyval)
                if not isinstance(pyval_repr, str):
                    pyval_repr = str(pyval_repr) #type: ignore[unreachable]
            except Exception:
                state.warnings.append(f"Cannot colorize object of type '{pyval.__class__.__name__}', repr() raised an exception.")
                state.result.append(self.UNKNOWN_REPR)
            else:
                match = self.GENERIC_OBJECT_RE.search(pyval_repr)
                if match:
                    self._output(f"<{match.groupdict().get('descr')}>", None, state)
                else:
                    self._output(pyval_repr, None, state)

    def _trim_result(self, result: List[nodes.Node], num_chars: int) -> None:
        while num_chars > 0:
            if not result: 
                return
            if isinstance(r1:=result[-1], nodes.Element):
                if len(r1.children) >= 1:
                    data = r1[-1].astext()
                    trim = min(num_chars, len(data))
                    r1[-1] = nodes.Text(data[:-trim])
                    if not r1[-1].astext(): 
                        if len(r1.children) == 1:
                            result.pop()
                        else:
                            r1.pop()
                else:
                    trim = 0
                    result.pop()
                num_chars -= trim
            else:
                # Must be Text if it's not an Element
                assert isinstance(r1, nodes.Text)
                trim = min(num_chars, len(r1))
                result[-1] = nodes.Text(r1.astext()[:-trim])
                if not result[-1].astext(): 
                    result.pop()
                num_chars -= trim

    #////////////////////////////////////////////////////////////
    # Object Colorization Functions
    #////////////////////////////////////////////////////////////

    def _insert_comma(self, indent: int, state: _ColorizerState) -> None:
        if state.linebreakok:
            self._output(',', self.COMMA_TAG, state)
            self._output('\n'+' '*indent, None, state)
        else:
            self._output(', ', self.COMMA_TAG, state)

    def _multiline(self, func: Callable[..., None], pyval: Iterable[Any], state: _ColorizerState, **kwargs: Any) -> None:
        """
        Helper for container-type colorizers.  First, try calling
        C{func(pyval, state, **kwargs)} with linebreakok set to false;
        and if that fails, then try again with it set to true.
        """
        linebreakok = state.linebreakok
        mark = state.mark()

        try:
            state.linebreakok = False
            func(pyval, state, **kwargs)
            state.linebreakok = linebreakok

        except _Linebreak:
            if not linebreakok:
                raise
            state.restore(mark)
            func(pyval, state, **kwargs)

    def _colorize_iter(self, pyval: Iterable[Any], state: _ColorizerState, 
                       prefix: Optional[AnyStr] = None, 
                       suffix: Optional[AnyStr] = None) -> None:
        if prefix is not None:
            self._output(prefix, self.GROUP_TAG, state)
        indent = state.charpos
        for i, elt in enumerate(pyval):
            if i>=1:
                self._insert_comma(indent, state)
            # word break opportunity for inline values
            state.result.append(self.WORD_BREAK_OPPORTUNITY)
            self._colorize(elt, state)
        if suffix is not None:
            self._output(suffix, self.GROUP_TAG, state)

    def _colorize_ast_dict(self, items: Iterable[Tuple[Optional[ast.AST], ast.AST]], 
                           state: _ColorizerState, prefix: str, suffix: str) -> None:
        self._output(prefix, self.GROUP_TAG, state)
        indent = state.charpos
        for i, (key, val) in enumerate(items):
            if i>=1:
                self._insert_comma(indent, state)
            state.result.append(self.WORD_BREAK_OPPORTUNITY)
            if key:
                self._set_precedence(op_util.Precedence.Comma, val)
                self._colorize(key, state)
                self._output(': ', self.COLON_TAG, state)
            else:
                self._output('**', None, state)
            self._colorize(val, state)
        self._output(suffix, self.GROUP_TAG, state)
    
    def _colorize_str(self, pyval: AnyStr, state: _ColorizerState, prefix: AnyStr, 
                      escape_fcn: Callable[[AnyStr], str]) -> None:
        
        str_func = _get_str_func(pyval)

        #  Decide which quote to use.
        if str_func('\n') in pyval and state.linebreakok:
            quote = str_func("'''")
        else: 
            quote = str_func("'")
        
        # Open quote.
        self._output(prefix, None, state)
        self._output(quote, self.QUOTE_TAG, state)

        # Divide the string into lines.
        if state.linebreakok:
            lines = pyval.split(str_func('\n'))
        else:
            lines = [pyval]
        # Body
        for i, line in enumerate(lines):
            if i>0:
                self._output(str_func('\n'), None, state)

            # It's not redundant when line is bytes
            line = cast(AnyStr, escape_fcn(line)) # type:ignore[redundant-cast]
            
            self._output(line, self.STRING_TAG, state)
        # Close quote.
        self._output(quote, self.QUOTE_TAG, state)

    #////////////////////////////////////////////////////////////
    # Support for AST
    #////////////////////////////////////////////////////////////

    # Nodes not explicitely handled that would be nice to handle.
    #   f-strings, 
    #   comparators, 
    #   generator expressions, 
    #   Slice and ExtSlice

    @staticmethod
    def _is_ast_constant(node: ast.AST) -> bool:
        if sys.version_info[:2] >= (3, 8):
            return isinstance(node, ast.Constant)
        else:
            # TODO: remove me when python3.7 is not supported anymore
            return isinstance(node, (ast.Num, ast.Str, ast.Bytes, 
                    ast.Constant, ast.NameConstant, ast.Ellipsis))
    @staticmethod
    def _get_ast_constant_val(node: ast.AST) -> Any:
        # Deprecated since version 3.8: Replaced by Constant
        if sys.version_info[:2] >= (3, 8):
            if isinstance(node, ast.Constant):
                return node.value
        else:
            # TODO: remove me when python3.7 is not supported anymore
            if isinstance(node, ast.Num): 
                return(node.n)
            if isinstance(node, (ast.Str, ast.Bytes)):
                return(node.s)
            if isinstance(node, (ast.Constant, ast.NameConstant)):
                return(node.value)
            if isinstance(node, ast.Ellipsis):
                return(...)
        raise RuntimeError(f'expected a constant: {ast.dump(node)}')
        
    def _colorize_ast_constant(self, pyval: ast.AST, state: _ColorizerState) -> None:
        val = self._get_ast_constant_val(pyval)
        # Handle elipsis
        if val != ...:
            self._colorize(val, state)
        else:
            self._output('...', self.ELLIPSIS_TAG, state)

    def _colorize_ast(self, pyval: ast.AST, state: _ColorizerState) -> None:
        state.stack.append(pyval)
        # Set nodes parent in order to check theirs precedences and add delimiters when needed.
        try:
            next(get_parents(pyval))
        except StopIteration:
            Parentage().visit(pyval)

        if self._is_ast_constant(pyval): 
            self._colorize_ast_constant(pyval, state)
        elif isinstance(pyval, ast.UnaryOp):
            self._colorize_ast_unary_op(pyval, state)
        elif isinstance(pyval, ast.BinOp):
            self._colorize_ast_binary_op(pyval, state)
        elif isinstance(pyval, ast.BoolOp):
            self._colorize_ast_bool_op(pyval, state)
        elif isinstance(pyval, ast.List):
            self._multiline(self._colorize_iter, pyval.elts, state, prefix='[', suffix=']')
        elif isinstance(pyval, ast.Tuple):
            self._multiline(self._colorize_iter, pyval.elts, state, prefix='(', suffix=')')
        elif isinstance(pyval, ast.Set):
            self._multiline(self._colorize_iter, pyval.elts, state, prefix='set([', suffix='])')
        elif isinstance(pyval, ast.Dict):
            items = list(zip(pyval.keys, pyval.values))
            self._multiline(self._colorize_ast_dict, items, state, prefix='{', suffix='}')
        elif isinstance(pyval, ast.Name):
            self._colorize_ast_name(pyval, state)
        elif isinstance(pyval, ast.Attribute):
            self._colorize_ast_attribute(pyval, state)
        elif isinstance(pyval, ast.Subscript):
            self._colorize_ast_subscript(pyval, state)
        elif isinstance(pyval, ast.Call):
            self._colorize_ast_call(pyval, state)
        elif isinstance(pyval, ast.Starred):
            self._output('*', None, state)
            self._colorize_ast(pyval.value, state)
        elif isinstance(pyval, ast.keyword):
            if pyval.arg is not None:
                self._output(pyval.arg, None, state)
                self._output('=', None, state)
            else:
                self._output('**', None, state)
            self._colorize_ast(pyval.value, state)
        else:
            self._colorize_ast_generic(pyval, state)
        assert state.stack.pop() is pyval
    
    def _colorize_ast_unary_op(self, pyval: ast.UnaryOp, state: _ColorizerState) -> None:
        with _OperatorDelimiter(self, state, pyval):
            if isinstance(pyval.op, ast.USub):
                self._output('-', None, state)
            elif isinstance(pyval.op, ast.UAdd):
                self._output('+', None, state)
            elif isinstance(pyval.op, ast.Not):
                self._output('not ', None, state)
            elif isinstance(pyval.op, ast.Invert):
                self._output('~', None, state)
            else:
                state.warnings.append(f"Unknow unrary operator: {pyval}")
                self._colorize_ast_generic(pyval, state)

            self._colorize(pyval.operand, state)
    
    def _colorize_ast_binary_op(self, pyval: ast.BinOp, state: _ColorizerState) -> None:
        with _OperatorDelimiter(self, state, pyval):
            # Colorize first operand
            mark = state.mark()
            self._colorize(pyval.left, state)
            # Colorize operator
            try:
                self._output(op_util.get_op_symbol(pyval.op, ' %s '), None, state)
            except KeyError:
                state.warnings.append(f"Unknow binary operator: {pyval}")
                state.restore(mark)
                self._colorize_ast_generic(pyval, state)
                return

            # Colorize second operand
            self._colorize(pyval.right, state)
    
    def _colorize_ast_bool_op(self, pyval: ast.BoolOp, state: _ColorizerState) -> None:
        with _OperatorDelimiter(self, state, pyval):
            _maxindex = len(pyval.values)-1

            for index, value in enumerate(pyval.values):
                self._colorize(value, state)

                if index != _maxindex:
                    if isinstance(pyval.op, ast.And):
                        self._output(' and ', None, state)
                    elif isinstance(pyval.op, ast.Or):
                        self._output(' or ', None, state)

    def _colorize_ast_name(self, pyval: ast.Name, state: _ColorizerState) -> None:
        self._output(pyval.id, self.LINK_TAG, state, link=True)

    def _colorize_ast_attribute(self, pyval: ast.Attribute, state: _ColorizerState) -> None:
        parts = []
        curr: ast.expr = pyval
        while isinstance(curr, ast.Attribute):
            parts.append(curr.attr)
            curr = curr.value
        if not isinstance(curr, ast.Name):
            self._colorize_ast_generic(pyval, state)
            return
        parts.append(curr.id)
        parts.reverse()
        self._output('.'.join(parts), self.LINK_TAG, state, link=True)

    def _colorize_ast_subscript(self, node: ast.Subscript, state: _ColorizerState) -> None:

        self._colorize(node.value, state)

        sub: ast.AST = node.slice
        if sys.version_info < (3,9) and isinstance(sub, ast.Index):
            # In Python < 3.9, non-slices are always wrapped in an Index node.
            sub = sub.value
        self._output('[', self.GROUP_TAG, state)
        self._set_precedence(op_util.Precedence.Subscript, node)
        self._set_precedence(op_util.Precedence.Index, sub)
        if isinstance(sub, ast.Tuple):
            self._multiline(self._colorize_iter, sub.elts, state)
        else:
            state.result.append(self.WORD_BREAK_OPPORTUNITY)
            self._colorize(sub, state)
       
        self._output(']', self.GROUP_TAG, state)
    
    def _colorize_ast_call(self, node: ast.Call, state: _ColorizerState) -> None:
        
        if node2dottedname(node.func) == ['re', 'compile']:
            # Colorize regexps from re.compile AST arguments.
            self._colorize_ast_re(node, state)
        else:
            # Colorize other forms of callables.
            self._colorize_ast_call_generic(node, state)

    def _colorize_ast_call_generic(self, node: ast.Call, state: _ColorizerState) -> None:
        self._colorize(node.func, state)
        self._output('(', self.GROUP_TAG, state)
        indent = state.charpos
        self._multiline(self._colorize_iter, node.args, state)
        if len(node.keywords)>0:
            if len(node.args)>0:
                self._insert_comma(indent, state)
            self._multiline(self._colorize_iter, node.keywords, state)
        self._output(')', self.GROUP_TAG, state)

    def _colorize_ast_re(self, node:ast.Call, state: _ColorizerState) -> None:
        
        try:
            # Can raise TypeError
            args = bind_args(self.RE_COMPILE_SIGNATURE, node)
        except TypeError:
            self._colorize_ast_call_generic(node, state)
            return
        
        ast_pattern = args.arguments['pattern']

        # Cannot colorize regex
        if not self._is_ast_constant(ast_pattern):
            self._colorize_ast_call_generic(node, state)
            return

        pat = self._get_ast_constant_val(ast_pattern)
        
        # Just in case regex pattern is not valid type
        if not isinstance(pat, (bytes, str)):
            state.warnings.append("Cannot colorize regular expression: pattern must be bytes or str.")
            self._colorize_ast_call_generic(node, state)
            return

        mark = state.mark()
        
        self._output("re.compile", None, state, link=True)
        self._output('(', self.GROUP_TAG, state)
        indent = state.charpos
        
        try:
            # Can raise ValueError or re.error
            # Value of type variable "AnyStr" cannot be "Union[bytes, str]": Yes it can.
            self._colorize_re_pattern_str(pat, state) #type:ignore[type-var]
        except (ValueError, sre_constants.error) as e:
            # Make sure not to swallow control flow errors.
            # Colorize the ast.Call as any other node if the pattern parsing fails.
            state.restore(mark)
            state.warnings.append(f"Cannot colorize regular expression, error: {str(e)}")
            self._colorize_ast_call_generic(node, state)
            return

        ast_flags = args.arguments.get('flags')
        if ast_flags is not None:
            self._insert_comma(indent, state)
            self._colorize_ast(ast_flags, state)

        self._output(')', self.GROUP_TAG, state)

    def _colorize_ast_generic(self, pyval: ast.AST, state: _ColorizerState) -> None:
        try:
            # Always wrap the expression inside parenthesis because we can't be sure 
            # if there are required since we don;t have support for all operators 
            # See TODO comment in _OperatorDelimiter.
            source = unparse(pyval).strip()
            if sys.version_info > (3,9) and isinstance(pyval, 
                    (ast.IfExp, ast.Compare, ast.Lambda)) and len(state.stack)>1:
                source = f'({source})'
        except Exception: #  No defined handler for node of type 
            state.result.append(self.UNKNOWN_REPR)
        else:
            # TODO: Maybe try to colorize anyway, without links, with epydoc.doctest ?
            self._output(source, None, state)
        
    #////////////////////////////////////////////////////////////
    # Support for Regexes
    #////////////////////////////////////////////////////////////

    def _colorize_re_pattern_str(self, pat: AnyStr, state: _ColorizerState) -> None:
        # Currently, the colorizer do not render multiline regex patterns correctly because we don't
        # recover the flag values from re.compile() arguments (so we don't know when re.VERBOSE is used for instance). 
        # With default flags, newlines are mixed up with literals \n and probably more fun stuff like that.
        # Turns out the sre_parse.parse() function treats caracters "\n" and "\\n" the same way.
        
        # If the pattern string is composed by mutiple lines, simply use the string colorizer instead.
        # It's more informative to have the proper newlines than the fancy regex colors. 

        # Note: Maybe this decision is driven by a misunderstanding of regular expression.

        str_func = _get_str_func(pat)
        if str_func('\n') in pat:
            if isinstance(pat, bytes):
                self._colorize_str(pat, state, b'b', escape_fcn=_bytes_escape)
            else:
                self._colorize_str(pat, state, '', escape_fcn=_str_escape)
        else:
            if isinstance(pat, bytes):
                self._colorize_re_pattern(pat, state, b'rb')
            else:
                self._colorize_re_pattern(pat, state, 'r')
    
    def _colorize_re_pattern(self, pat: AnyStr, state: _ColorizerState, prefix: AnyStr) -> None:

        # Parse the regexp pattern.
        # The regex pattern strings are always parsed with the default flags.
        # Flag values are displayed as regular ast.Call arguments. 

        tree: sre_parse36.SubPattern = sre_parse36.parse(pat, 0)
        # from python 3.8 SubPattern.pattern is named SubPattern.state, but we don't care right now because we use sre_parse36
        pattern = tree.pattern
        groups = dict([(num,name) for (name,num) in
                       pattern.groupdict.items()])
        flags: int = pattern.flags
        
        # Open quote. Never triple quote regex patterns string, anyway parterns that includes an '\n' caracter are displayed as regular strings.
        quote = "'"
        self._output(prefix, None, state)
        self._output(quote, self.QUOTE_TAG, state)
        
        if flags != sre_constants.SRE_FLAG_UNICODE:
            # If developers included flags in the regex string, display them.
            # By default, do not display the '(?u)'
            self._colorize_re_flags(flags, state)
        
        # Colorize it!
        self._colorize_re_tree(tree.data, state, True, groups)

        # Close quote.
        self._output(quote, self.QUOTE_TAG, state)

    def _colorize_re_flags(self, flags: int, state: _ColorizerState) -> None:
        if flags:
            flags_list = [c for (c,n) in sorted(sre_parse36.FLAGS.items())
                        if (n&flags)]
            flags_str = '(?%s)' % ''.join(flags_list)
            self._output(flags_str, self.RE_FLAGS_TAG, state)

    def _colorize_re_tree(self, tree: Sequence[Tuple[sre_constants._NamedIntConstant, Any]],
                          state: _ColorizerState, noparen: bool, groups: Dict[int, str]) -> None:

        if len(tree) > 1 and not noparen:
            self._output('(', self.RE_GROUP_TAG, state)

        for elt in tree:
            op = elt[0]
            args = elt[1]

            if op == sre_constants.LITERAL: #type:ignore[attr-defined]
                c = chr(cast(int, args))
                # Add any appropriate escaping.
                if c in '.^$\\*+?{}[]|()\'': 
                    c = '\\' + c
                elif c == '\t': 
                    c = r'\t'
                elif c == '\r': 
                    c = r'\r'
                elif c == '\n': 
                    c = r'\n'
                elif c == '\f': 
                    c = r'\f'
                elif c == '\v': 
                    c = r'\v'
                # Keep unicode chars as is, so do nothing if ord(c) > 65535
                elif ord(c) > 255 and ord(c) <= 65535: 
                   c = rb'\u%04x' % ord(c) # type:ignore[assignment]
                elif (ord(c)<32 or ord(c)>=127) and ord(c) <= 65535: 
                    c = rb'\x%02x' % ord(c) # type:ignore[assignment]
                self._output(c, self.RE_CHAR_TAG, state)

            elif op == sre_constants.ANY: #type:ignore[attr-defined]
                self._output('.', self.RE_CHAR_TAG, state)

            elif op == sre_constants.BRANCH: #type:ignore[attr-defined]
                if args[0] is not None:
                    raise ValueError('Branch expected None arg but got %s'
                                     % args[0])
                for i, item in enumerate(args[1]):
                    if i > 0:
                        self._output('|', self.RE_OP_TAG, state)
                    self._colorize_re_tree(item, state, True, groups)

            elif op == sre_constants.IN: #type:ignore[attr-defined]
                if (len(args) == 1 and args[0][0] == sre_constants.CATEGORY): #type:ignore[attr-defined]
                    self._colorize_re_tree(args, state, False, groups)
                else:
                    self._output('[', self.RE_GROUP_TAG, state)
                    self._colorize_re_tree(args, state, True, groups)
                    self._output(']', self.RE_GROUP_TAG, state)

            elif op == sre_constants.CATEGORY: #type:ignore[attr-defined]
                if args == sre_constants.CATEGORY_DIGIT: val = r'\d' #type:ignore[attr-defined]
                elif args == sre_constants.CATEGORY_NOT_DIGIT: val = r'\D' #type:ignore[attr-defined]
                elif args == sre_constants.CATEGORY_SPACE: val = r'\s' #type:ignore[attr-defined]
                elif args == sre_constants.CATEGORY_NOT_SPACE: val = r'\S' #type:ignore[attr-defined]
                elif args == sre_constants.CATEGORY_WORD: val = r'\w' #type:ignore[attr-defined]
                elif args == sre_constants.CATEGORY_NOT_WORD: val = r'\W' #type:ignore[attr-defined]
                else: raise ValueError('Unknown category %s' % args)
                self._output(val, self.RE_CHAR_TAG, state)

            elif op == sre_constants.AT: #type:ignore[attr-defined]
                if args == sre_constants.AT_BEGINNING_STRING: val = r'\A' #type:ignore[attr-defined]
                elif args == sre_constants.AT_BEGINNING: val = '^' #type:ignore[attr-defined]
                elif args == sre_constants.AT_END: val = '$' #type:ignore[attr-defined]
                elif args == sre_constants.AT_BOUNDARY: val = r'\b' #type:ignore[attr-defined]
                elif args == sre_constants.AT_NON_BOUNDARY: val = r'\B' #type:ignore[attr-defined]
                elif args == sre_constants.AT_END_STRING: val = r'\Z' #type:ignore[attr-defined]
                else: raise ValueError('Unknown position %s' % args)
                self._output(val, self.RE_CHAR_TAG, state)

            elif op in (sre_constants.MAX_REPEAT, sre_constants.MIN_REPEAT): #type:ignore[attr-defined]
                minrpt = args[0]
                maxrpt = args[1]
                if maxrpt == sre_constants.MAXREPEAT:
                    if minrpt == 0:   val = '*'
                    elif minrpt == 1: val = '+'
                    else: val = '{%d,}' % (minrpt)
                elif minrpt == 0:
                    if maxrpt == 1: val = '?'
                    else: val = '{,%d}' % (maxrpt)
                elif minrpt == maxrpt:
                    val = '{%d}' % (maxrpt)
                else:
                    val = '{%d,%d}' % (minrpt, maxrpt)
                if op == sre_constants.MIN_REPEAT: #type:ignore[attr-defined]
                    val += '?'

                self._colorize_re_tree(args[2], state, False, groups)
                self._output(val, self.RE_OP_TAG, state)

            elif op == sre_constants.SUBPATTERN: #type:ignore[attr-defined]
                if args[0] is None:
                    self._output(r'(?:', self.RE_GROUP_TAG, state)
                elif args[0] in groups:
                    self._output(r'(?P<', self.RE_GROUP_TAG, state)
                    self._output(groups[args[0]], self.RE_REF_TAG, state)
                    self._output('>', self.RE_GROUP_TAG, state)
                elif isinstance(args[0], int):
                    # This is cheating:
                    self._output('(', self.RE_GROUP_TAG, state)
                else:
                    self._output('(?P<', self.RE_GROUP_TAG, state)
                    self._output(args[0], self.RE_REF_TAG, state)
                    self._output('>', self.RE_GROUP_TAG, state)
                self._colorize_re_tree(args[3], state, True, groups)
                self._output(')', self.RE_GROUP_TAG, state)

            elif op == sre_constants.GROUPREF: #type:ignore[attr-defined]
                self._output('\\%d' % args, self.RE_REF_TAG, state)

            elif op == sre_constants.RANGE: #type:ignore[attr-defined]
                self._colorize_re_tree( ((sre_constants.LITERAL, args[0]),), #type:ignore[attr-defined]
                                        state, False, groups )
                self._output('-', self.RE_OP_TAG, state)
                self._colorize_re_tree( ((sre_constants.LITERAL, args[1]),), #type:ignore[attr-defined]
                                        state, False, groups )

            elif op == sre_constants.NEGATE: #type:ignore[attr-defined]
                self._output('^', self.RE_OP_TAG, state)

            elif op == sre_constants.ASSERT: #type:ignore[attr-defined]
                if args[0] > 0:
                    self._output('(?=', self.RE_GROUP_TAG, state)
                else:
                    self._output('(?<=', self.RE_GROUP_TAG, state)
                self._colorize_re_tree(args[1], state, True, groups)
                self._output(')', self.RE_GROUP_TAG, state)

            elif op == sre_constants.ASSERT_NOT: #type:ignore[attr-defined]
                if args[0] > 0:
                    self._output('(?!', self.RE_GROUP_TAG, state)
                else:
                    self._output('(? 1 and not noparen:
            self._output(')', self.RE_GROUP_TAG, state)

    #////////////////////////////////////////////////////////////
    # Output function
    #////////////////////////////////////////////////////////////

    def _output(self, s: AnyStr, css_class: Optional[str], 
                state: _ColorizerState, link: bool = False) -> None:
        """
        Add the string C{s} to the result list, tagging its contents
        with the specified C{css_class}. Any lines that go beyond L{PyvalColorizer.linelen} will
        be line-wrapped.  If the total number of lines exceeds
        L{PyvalColorizer.maxlines}, then raise a L{_Maxlines} exception.
        """
        # Make sure the string is unicode.
        if isinstance(s, bytes):
            s = cast(AnyStr, decode_with_backslashreplace(s))
        assert isinstance(s, str)
        # Split the string into segments.  The first segment is the
        # content to add to the current line, and the remaining
        # segments are new lines.
        segments = s.split('\n')

        for i, segment in enumerate(segments):
            # If this isn't the first segment, then add a newline to
            # split it from the previous segment.
            if i > 0:
                if (state.lineno+1) > self.maxlines:
                    raise _Maxlines()
                if not state.linebreakok:
                    raise _Linebreak()
                state.result.append(self.NEWLINE)
                state.lineno += 1
                state.charpos = 0
            
            segment_len = len(segment) 

            # If the segment fits on the current line, then just call
            # markup to tag it, and store the result.
            # Don't break links into separate segments, neither quotes.
            element: nodes.Node
            if (self.linelen is None or 
                state.charpos + segment_len <= self.linelen 
                or link is True 
                or css_class in ('variable-quote',)):

                state.charpos += segment_len

                if link is True:
                    # Here, we bypass the linker if refmap contains the segment we're linking to. 
                    # The linker can be problematic because it has some design blind spots when the same name is declared in the imports and in the module body.
                    
                    # Note that the argument name is 'refuri', not 'refuid. 
                    element = obj_reference('', segment, refuri=self.refmap.get(segment, segment))
                elif css_class is not None:
                    element = nodes.inline('', segment, classes=[css_class])
                else:
                    element = nodes.Text(segment)

                state.result.append(element)

            # If the segment doesn't fit on the current line, then
            # line-wrap it, and insert the remainder of the line into
            # the segments list that we're iterating over.  (We'll go
            # the beginning of the next line at the start of the
            # next iteration through the loop.)
            else:
                assert isinstance(self.linelen, int)
                split = self.linelen-state.charpos
                segments.insert(i+1, segment[split:])
                segment = segment[:split]

                if css_class is not None:
                    element = nodes.inline('', segment, classes=[css_class])
                else:
                    element = nodes.Text(segment)
                state.result += [element, self.LINEWRAP]
pydoctor-24.11.2/pydoctor/epydoc/markup/_types.py000066400000000000000000000172211473665144200220450ustar00rootroot00000000000000"""
Render types from L{docutils.nodes.document} objects. 

This module provides yet another L{ParsedDocstring} subclass.
"""
from __future__ import annotations

from typing import Any, Callable, Dict, List, Tuple, Union, cast

from pydoctor.epydoc.markup import DocstringLinker, ParseError, ParsedDocstring, get_parser_by_name
from pydoctor.node2stan import node2stan
from pydoctor.napoleon.docstring import TokenType, TypeDocstring

from docutils import nodes
from twisted.web.template import Tag, tags

class ParsedTypeDocstring(TypeDocstring, ParsedDocstring):
    """
    Add L{ParsedDocstring} interface on top of L{TypeDocstring} and 
    allow to parse types from L{nodes.Node} objects, providing the C{--process-types} option.
    """

    FIELDS = ('type', 'rtype', 'ytype', 'returntype', 'yieldtype')
    
    #                                                   yes this overrides the superclass type!
    _tokens: list[tuple[str | nodes.Node, TokenType]] # type: ignore

    def __init__(self, annotation: Union[nodes.document, str],
                 warns_on_unknown_tokens: bool = False, lineno: int = 0) -> None:
        ParsedDocstring.__init__(self, ())
        if isinstance(annotation, nodes.document):
            TypeDocstring.__init__(self, '', warns_on_unknown_tokens)

            _tokens = self._tokenize_node_type_spec(annotation)
            self._tokens = cast('list[tuple[str | nodes.Node, TokenType]]', 
                                self._build_tokens(_tokens))
            self._trigger_warnings()
        else:
            TypeDocstring.__init__(self, annotation, warns_on_unknown_tokens)
        
        
        # We need to store the line number because we need to pass it to DocstringLinker.link_xref
        self._lineno = lineno

    @property
    def has_body(self) -> bool:
        return len(self._tokens)>0

    def to_node(self) -> nodes.document:
        """
        Not implemented.
        """
        raise NotImplementedError()

    def to_stan(self, docstring_linker: DocstringLinker) -> Tag:
        """
        Present the type as a stan tree. 
        """
        return self._convert_type_spec_to_stan(docstring_linker)

    def _tokenize_node_type_spec(self, spec: nodes.document) -> List[Union[str, nodes.Node]]:
        def _warn_not_supported(n:nodes.Node) -> None:
            self.warnings.append(f"Unexpected element in type specification field: element '{n.__class__.__name__}'. "
                                    "This value should only contain text or inline markup.")

        tokens: List[Union[str, nodes.Node]] = []
        # Determine if the content is nested inside a paragraph
        # this is generally the case, except for consolidated fields generate documents.
        if spec.children and isinstance(spec.children[0], nodes.paragraph):
            if len(spec.children)>1:
                _warn_not_supported(spec.children[1])
            children = spec.children[0].children
        else:
            children = spec.children
        
        for child in children:
            if isinstance(child, nodes.Text):
                # Tokenize the Text node with the same method TypeDocstring uses.
                tokens.extend(TypeDocstring._tokenize_type_spec(child.astext()))
            elif isinstance(child, nodes.Inline):
                tokens.append(child)
            else:
                _warn_not_supported(child)
        
        return tokens

    def _convert_obj_tokens_to_stan(self, tokens: List[Tuple[Any, TokenType]], 
                                    docstring_linker: DocstringLinker) -> list[tuple[Any, TokenType]]:
        """
        Convert L{TokenType.OBJ} and PEP 484 like L{TokenType.DELIMITER} type to stan, merge them together. Leave the rest untouched. 

        Exemple:

        >>> tokens = [("list", TokenType.OBJ), ("(", TokenType.DELIMITER), ("int", TokenType.OBJ), (")", TokenType.DELIMITER)]
        >>> ann._convert_obj_tokens_to_stan(tokens, NotFoundLinker())
        ... [(Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ)]
        
        @param tokens: List of tuples: C{(token, type)}
        """

        combined_tokens: list[tuple[Any, TokenType]] = []

        open_parenthesis = 0
        open_square_braces = 0

        for _token, _type in tokens:
            # The actual type of_token is str | Tag | Node. 

            if (_type is TokenType.DELIMITER and _token in ('[', '(', ')', ']')) \
               or _type is TokenType.OBJ: 
                if _token == "[": open_square_braces += 1
                elif _token == "(": open_parenthesis += 1

                if _type is TokenType.OBJ:
                    _token = docstring_linker.link_xref(
                                _token, _token, self._lineno)

                if open_square_braces + open_parenthesis > 0:
                    try: last_processed_token = combined_tokens[-1]
                    except IndexError:
                        combined_tokens.append((_token, _type))
                    else:
                        if last_processed_token[1] is TokenType.OBJ \
                           and isinstance(last_processed_token[0], Tag):
                            # Merge with last Tag
                            if _type is TokenType.OBJ:
                                assert isinstance(_token, Tag)
                                last_processed_token[0](*_token.children)
                            else:
                                last_processed_token[0](_token)
                        else:
                            combined_tokens.append((_token, _type))
                else:
                    combined_tokens.append((_token, _type))
                
                if _token == "]": open_square_braces -= 1
                elif _token == ")": open_parenthesis -= 1

            else:
                # the token will be processed in _convert_type_spec_to_stan() method.
                combined_tokens.append((_token, _type))

        return combined_tokens

    def _convert_type_spec_to_stan(self, docstring_linker: DocstringLinker) -> Tag:
        """
        Convert type to L{Tag} object.
        """

        tokens = self._convert_obj_tokens_to_stan(self._tokens, docstring_linker)

        warnings: List[ParseError] = []

        converters: Dict[TokenType, Callable[[Union[str, Tag]], Union[str, Tag]]] = {
            TokenType.LITERAL:      lambda _token: tags.span(_token, class_="literal"),
            TokenType.CONTROL:      lambda _token: tags.em(_token),
            # We don't use safe_to_stan() here, if these converter functions raise an exception, 
            # the whole type docstring will be rendered as plaintext.
            # it does not crash on invalid xml entities
            TokenType.REFERENCE:    lambda _token: get_parser_by_name('restructuredtext')(_token, warnings).to_stan(docstring_linker) if isinstance(_token, str) else _token, 
            TokenType.UNKNOWN:      lambda _token: get_parser_by_name('restructuredtext')(_token, warnings).to_stan(docstring_linker) if isinstance(_token, str) else _token, 
            TokenType.OBJ:          lambda _token: _token, # These convertions (OBJ and DELIMITER) are done in _convert_obj_tokens_to_stan().
            TokenType.DELIMITER:    lambda _token: _token, 
            TokenType.ANY:          lambda _token: _token, 
        }

        for w in warnings:
            self.warnings.append(w.descr())

        converted = Tag('')

        for token, type_ in tokens:
            assert token is not None
            if isinstance(token, nodes.Node):
                token = node2stan(token, docstring_linker)
            assert isinstance(token, (str, Tag))
            converted_token = converters[type_](token)
            converted(converted_token)

        return converted
pydoctor-24.11.2/pydoctor/epydoc/markup/epytext.py000066400000000000000000001556651473665144200222630ustar00rootroot00000000000000#
# epytext.py: epydoc formatted docstring parsing
# Edward Loper
#
# Created [04/10/01 12:00 AM]
#
"""
Parser for epytext strings.  Epytext is a lightweight markup whose
primary intended application is Python documentation strings.  This
parser converts Epytext strings to a simple DOM-like representation
(encoded as a tree of L{Element} objects and strings).  Epytext
strings can contain the following I{structural blocks}:

    - C{epytext}: The top-level element of the DOM tree.
    - C{para}: A paragraph of text.  Paragraphs contain no newlines,
      and all spaces are soft.
    - C{section}: A section or subsection.
    - C{field}: A tagged field.  These fields provide information
      about specific aspects of a Python object, such as the
      description of a function's parameter, or the author of a
      module.
    - C{literalblock}: A block of literal text.  This text should be
      displayed as it would be displayed in plaintext.  The
      parser removes the appropriate amount of leading whitespace
      from each line in the literal block.
    - C{doctestblock}: A block containing sample python code,
      formatted according to the specifications of the C{doctest}
      module.
    - C{ulist}: An unordered list.
    - C{olist}: An ordered list.
    - C{li}: A list item.  This tag is used both for unordered list
      items and for ordered list items.

Additionally, the following I{inline regions} may be used within
C{para} blocks:

    - C{code}:   Source code and identifiers.
    - C{math}:   Mathematical expressions.
    - C{index}:  A term which should be included in an index, if one
                 is generated.
    - C{italic}: Italicized text.
    - C{bold}:   Bold-faced text.
    - C{uri}:    A Universal Resource Indicator (URI) or Universal
                 Resource Locator (URL)
    - C{link}:   A Python identifier which should be hyperlinked to
                 the named object's documentation, when possible.

The returned DOM tree will conform to the the following Document Type
Description::

   

   

   

   

   
   
   
   

   
   

   
   
   
   
   

   
   
   
   

   
   
   
   
   
   

   
   

@var SYMBOLS: A list of the of escape symbols that are supported by epydoc.  Currently the following symbols are supported ::

    # Arrows
    '<-', '->', '^', 'v',

    # Greek letters
    'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta',
    'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu',
    'nu', 'xi', 'omicron', 'pi', 'rho', 'sigma',
    'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega',
    'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta',
    'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu',
    'Nu', 'Xi', 'Omicron', 'Pi', 'Rho', 'Sigma',
    'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega',

    # HTML character entities
    'larr', 'rarr', 'uarr', 'darr', 'harr', 'crarr',
    'lArr', 'rArr', 'uArr', 'dArr', 'hArr',
    'copy', 'times', 'forall', 'exist', 'part',
    'empty', 'isin', 'notin', 'ni', 'prod', 'sum',
    'prop', 'infin', 'ang', 'and', 'or', 'cap', 'cup',
    'int', 'there4', 'sim', 'cong', 'asymp', 'ne',
    'equiv', 'le', 'ge', 'sub', 'sup', 'nsub',
    'sube', 'supe', 'oplus', 'otimes', 'perp',

    # Alternate (long) names
    'infinity', 'integral', 'product',
    '>=', '<=',

"""
# Note: the symbol list is appended to the docstring automatically,
# below.
from __future__ import annotations

__docformat__ = 'epytext en'

# Code organization..
#   1. parse()
#   2. tokenize()
#   3. colorize()
#   4. helpers
#   5. testing

from typing import Any, Iterable, List, Optional, Sequence, Set, Union, cast
import re
import unicodedata

from docutils import nodes
from twisted.web.template import Tag

from pydoctor.epydoc.markup import Field, ObjClass, ParseError, ParsedDocstring, ParserFunction
from pydoctor.epydoc.docutils import set_node_attributes, new_document

##################################################
## Helper functions
##################################################

def gettext(node: Union[str, 'Element', List[Union[str, 'Element']]]) -> List[str]:
    """Return the text inside the epytext element(s)."""
    filtered: List[str] = []
    if isinstance(node, str):
        filtered.append(node)
    elif isinstance(node, list):
        for child in node:
            filtered.extend(gettext(child))
    elif isinstance(node, Element):
        filtered.extend(gettext(node.children))
    return filtered

def slugify(string:str) -> str:
    # zacharyvoase/slugify is licensed under the The Unlicense
    """
    A generic slugifier utility (currently only for Latin-based scripts).
    Example:
        >>> slugify("Héllo Wörld")
        "hello-world"
    """
    return re.sub(r'[-\s]+', '-', 
                re.sub(rb'[^\w\s-]', b'',
                    unicodedata.normalize('NFKD', string)
                    .encode('ascii', 'ignore'))
                .strip()
                .lower()
                .decode())

##################################################
## DOM-Like Encoding
##################################################

class Element:
    """
    A very simple DOM-like representation for parsed epytext
    documents.  Each epytext document is encoded as a tree whose nodes
    are L{Element} objects, and whose leaves are C{string}s.  Each
    node is marked by a L{tag} and zero or more attributes, L{attribs}.  Each
    attribute is a mapping from a string key to a string value.
    """
    def __init__(self, tag: str, *children: Union[str, 'Element'], **attribs: Any):
        self.tag = tag
        """A string tag indicating the type of this element."""

        self.children = list(children)
        """A list of the children of this element."""

        self.attribs = attribs
        """A dictionary mapping attribute names to attribute values for this element."""

    def __str__(self) -> str:
        """
        Return a string representation of this element, using XML
        notation.
        @note: Doesn't escape '<' or '&' or '>', so the result is only XML-like
            and cannot actually be parsed as XML.
        """
        attribs = ''.join(f' {k}={v!r}' for k, v in self.attribs.items())
        content = ''.join(str(child) for child in self.children)
        return f'<{self.tag}{attribs}>{content}'

    def __repr__(self) -> str:
        attribs = ''.join(f', {k}={v!r}' for k, v in self.attribs.items())
        args = ''.join(f', {c!r}' for c in self.children)
        return f'Element({self.tag}{args}{attribs})'

##################################################
## Constants
##################################################

# The possible heading underline characters, listed in order of
# heading depth.
_HEADING_CHARS = '=-~'

# Escape codes.  These should be needed very rarely.
_ESCAPES = {'lb':'{', 'rb': '}'}

# Symbols.  These can be generated via S{...} escapes.
SYMBOLS = [
    # Arrows
    '<-', '->', '^', 'v',

    # Greek letters
    'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta',
    'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu',
    'nu', 'xi', 'omicron', 'pi', 'rho', 'sigma',
    'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega',
    'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta',
    'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu',
    'Nu', 'Xi', 'Omicron', 'Pi', 'Rho', 'Sigma',
    'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega',

    # HTML character entities
    'larr', 'rarr', 'uarr', 'darr', 'harr', 'crarr',
    'lArr', 'rArr', 'uArr', 'dArr', 'hArr',
    'copy', 'times', 'forall', 'exist', 'part',
    'empty', 'isin', 'notin', 'ni', 'prod', 'sum',
    'prop', 'infin', 'ang', 'and', 'or', 'cap', 'cup',
    'int', 'there4', 'sim', 'cong', 'asymp', 'ne',
    'equiv', 'le', 'ge', 'sub', 'sup', 'nsub',
    'sube', 'supe', 'oplus', 'otimes', 'perp',

    # Alternate (long) names
    'infinity', 'integral', 'product',
    '>=', '<=',
    ]
# Convert to a set, for quick lookup
_SYMBOLS = set(SYMBOLS)

# Add symbols to the docstring.
symblist = '      '
symblist += ';\n      '.join(' - C{E{S}{%s}}=S{%s}' % (symbol, symbol)
                             for symbol in SYMBOLS)
__doc__ = __doc__.replace('<<>>', symblist)
del symblist

# Tags for colorizing text.
_COLORIZING_TAGS = {
    'C': 'code',
    'M': 'math',
    'I': 'italic',
    'B': 'bold',
    'U': 'uri',
    'L': 'link',       # A Python identifier that should be linked to
    'E': 'escape',     # escapes characters or creates symbols
    'S': 'symbol',
    }

# Which tags can use "link syntax" (e.g., U{Python})?
_LINK_COLORIZING_TAGS = ['link', 'uri']

##################################################
## Structuring (Top Level)
##################################################

def parse(text: str, errors: List[ParseError]) -> Optional[Element]:
    """
    Return a DOM tree encoding the contents of an epytext string.  Any
    errors generated during parsing will be stored in C{errors}.

    @param text: The epytext string to parse.
    @param errors: A list where any errors generated during parsing
        will be stored.  If no list is specified, then fatal errors
        will generate exceptions, and non-fatal errors will be
        ignored.
    @return: a DOM tree encoding the contents of an epytext string,
        or C{None} if non-fatal errors were encountered and no C{errors}
        accumulator was provided.
    @raise ParseError: If C{errors} is C{None} and an error is
        encountered while parsing.
    """    
    # Preprocess the string.
    text = re.sub('\015\012', '\012', text)
    text = text.expandtabs()

    # Tokenize the input string.
    tokens = _tokenize(text, errors)

    # Have we encountered a field yet?
    encountered_field = False

    # Create an document to hold the epytext.
    doc = Element('epytext')

    # Maintain two parallel stacks: one contains DOM elements, and
    # gives the ancestors of the current block.  The other contains
    # indentation values, and gives the indentation of the
    # corresponding DOM elements.  An indentation of "None" reflects
    # an unknown indentation.  However, the indentation must be
    # greater than, or greater than or equal to, the indentation of
    # the prior element (depending on what type of DOM element it
    # corresponds to).  No 2 consecutive indent_stack values will be
    # ever be "None."  Use initial dummy elements in the stack, so we
    # don't have to worry about bounds checking.
    stack = [cast(Element, None), doc]
    indent_stack = [-1, None]

    for token in tokens:
        # Uncomment this for debugging:
        #print('%s: %s\n%s: %s\n' %
        #       (''.join('%-11s' % (t and t.tag) for t in stack),
        #        token.tag, ''.join('%-11s' % i for i in indent_stack),
        #        token.indent))

        # Pop any completed blocks off the stack.
        _pop_completed_blocks(token, stack, indent_stack)

        # If Token has type PARA, colorize and add the new paragraph
        if token.tag == Token.PARA:
            _add_para(token, stack, indent_stack, errors)

        # If Token has type HEADING, add the new section
        elif token.tag == Token.HEADING:
            _add_section(token, stack, indent_stack, errors)

        # If Token has type LBLOCK, add the new literal block
        elif token.tag == Token.LBLOCK:
            stack[-1].children.append(token.to_dom())

        # If Token has type DTBLOCK, add the new doctest block
        elif token.tag == Token.DTBLOCK:
            stack[-1].children.append(token.to_dom())

        # If Token has type BULLET, add the new list/list item/field
        elif token.tag == Token.BULLET:
            _add_list(token, stack, indent_stack, errors)
        else:
            raise AssertionError(f"Unknown token type: {token.tag}")

        # Check if the DOM element we just added was a field..
        if stack[-1].tag == 'field':
            encountered_field = True
        elif encountered_field:
            if len(stack) <= 3:
                estr = ("Fields must be the final elements in an "+
                        "epytext string.")
                errors.append(StructuringError(estr, token.startline))

    # If there was an error, then signal it!
    try:
        raise next(e for e in errors if e.is_fatal())
    except StopIteration:
        pass

    # Return the top-level epytext DOM element.
    return doc

def _pop_completed_blocks(
        token: 'Token',
        stack: List[Element],
        indent_stack: List[Optional[int]]
        ) -> None:
    """
    Pop any completed blocks off the stack.  This includes any
    blocks that we have dedented past, as well as any list item
    blocks that we've dedented to.  The top element on the stack
    should only be a list if we're about to start a new list
    item (i.e., if the next token is a bullet).
    """
    indent = token.indent
    if indent is not None:
        while (len(stack) > 2):
            pop = False

            # Dedent past a block
            if indent_stack[-1] is not None and indent < indent_stack[-1]:
                pop = True
            elif indent_stack[-1] is None and indent < cast(int, indent_stack[-2]):
                pop = True

            # Dedent to a list item, if it is follwed by another list
            # item with the same indentation.
            elif (token.tag == 'bullet' and indent==indent_stack[-2] and
                  stack[-1].tag in ('li', 'field')): pop = True

            # End of a list (no more list items available)
            elif (stack[-1].tag in ('ulist', 'olist') and
                  (token.tag != 'bullet' or token.contents[-1] == ':')):
                pop = True

            # Pop the block, if it's complete.  Otherwise, we're done.
            if not pop: return
            stack.pop()
            indent_stack.pop()

def _add_para(
        para_token: 'Token',
        stack: List[Element],
        indent_stack: List[Optional[int]],
        errors: List[ParseError]
        ) -> None:
    """Colorize the given paragraph, and add it to the DOM tree."""
    # Check indentation, and update the parent's indentation
    # when appropriate.
    if indent_stack[-1] is None:
        indent_stack[-1] = para_token.indent
    if para_token.indent == indent_stack[-1]:
        # Colorize the paragraph and add it.
        para = _colorize(para_token, errors)
        stack[-1].children.append(para)
    else:
        estr = "Improper paragraph indentation."
        errors.append(StructuringError(estr, para_token.startline))

def _add_section(
        heading_token: 'Token',
        stack: List[Element],
        indent_stack: List[Optional[int]],
        errors: List[ParseError]
        ) -> None:
    """Add a new section to the DOM tree, with the given heading."""
    if indent_stack[-1] is None:
        indent_stack[-1] = heading_token.indent
    elif indent_stack[-1] != heading_token.indent:
        estr = "Improper heading indentation."
        errors.append(StructuringError(estr, heading_token.startline))

    # Check for errors.
    for tok in stack[2:]:
        if tok.tag != 'section':
            estr = "Headings must occur at the top level."
            errors.append(StructuringError(estr, heading_token.startline))
            break
    index = cast(int, heading_token.level) + 2
    if index > len(stack):
        estr = "Wrong underline character for heading."
        errors.append(StructuringError(estr, heading_token.startline))

    # Pop the appropriate number of headings so we're at the
    # correct level.
    stack[index:] = []
    indent_stack[index:] = []

    # Colorize the heading
    head = _colorize(heading_token, errors, 'heading')

    # Add the section's and heading's DOM elements.
    sec = Element('section')
    stack[-1].children.append(sec)
    stack.append(sec)
    sec.children.append(head)
    indent_stack.append(None)

def _add_list(
        bullet_token: 'Token',
        stack: List[Element],
        indent_stack: List[Optional[int]],
        errors: List[ParseError]
        ) -> None:
    """
    Add a new list item or field to the DOM tree, with the given
    bullet or field tag.  When necessary, create the associated
    list.
    """
    # Determine what type of bullet it is.
    if bullet_token.contents[-1] == '-':
        list_type = 'ulist'
    elif bullet_token.contents[-1] == '.':
        list_type = 'olist'
    elif bullet_token.contents[-1] == ':':
        list_type = 'fieldlist'
    else:
        raise AssertionError(f'Bad Bullet: {bullet_token.contents!r}')

    # Is this a new list?
    newlist = False
    if stack[-1].tag != list_type:
        newlist = True
    elif list_type == 'olist' and stack[-1].tag == 'olist':
        old_listitem = cast(Element, stack[-1].children[-1])
        old_bullet = old_listitem.attribs['bullet'].split('.')[:-1]
        new_bullet = bullet_token.contents.split('.')[:-1]
        if (new_bullet[:-1] != old_bullet[:-1] or
            int(new_bullet[-1]) != int(old_bullet[-1])+1):
            newlist = True

    # Create the new list.
    if newlist:
        if stack[-1].tag == 'fieldlist':
            # The new list item is not a field list item (since this
            # is a new list); but it's indented the same as the field
            # list.  This either means that they forgot to indent the
            # list, or they are trying to put something after the
            # field list.  The first one seems more likely, so we'll
            # just warn about that (to avoid confusion).
            estr = "Lists must be indented."
            errors.append(StructuringError(estr, bullet_token.startline))
        if stack[-1].tag in ('ulist', 'olist', 'fieldlist'):
            stack.pop()
            indent_stack.pop()

        if (list_type != 'fieldlist' and indent_stack[-1] is not None and
            bullet_token.indent == indent_stack[-1]):
            # Ignore this error if there's text on the same line as
            # the comment-opening quote -- epydoc can't reliably
            # determine the indentation for that line.
            if bullet_token.startline != 1 or bullet_token.indent != 0:
                estr = "Lists must be indented."
                errors.append(StructuringError(estr, bullet_token.startline))

        if list_type == 'fieldlist':
            # Fieldlist should be at the top-level.
            for tok in stack[2:]:
                if tok.tag != 'section':
                    estr = "Fields must be at the top level."
                    errors.append(
                        StructuringError(estr, bullet_token.startline))
                    break
            stack[2:] = []
            indent_stack[2:] = []

        # Add the new list.
        lst = Element(list_type)
        stack[-1].children.append(lst)
        stack.append(lst)
        indent_stack.append(bullet_token.indent)
        if list_type == 'olist':
            start = bullet_token.contents.split('.')[:-1]
            if start != '1':
                lst.attribs['start'] = start[-1]

    # Fields are treated somewhat specially: A 'fieldlist'
    # node is created to make the parsing simpler, but fields
    # are adjoined directly into the 'epytext' node, not into
    # the 'fieldlist' node.
    if list_type == 'fieldlist':
        li = Element('field', lineno=str(bullet_token.startline))
        token_words = bullet_token.contents[1:-1].split(None, 1)
        tag_elt = Element('tag')
        tag_elt.children.append(token_words[0])
        li.children.append(tag_elt)

        if len(token_words) > 1:
            arg_elt = Element('arg')
            arg_elt.children.append(token_words[1])
            li.children.append(arg_elt)
    else:
        li = Element('li')
        if list_type == 'olist':
            li.attribs['bullet'] = bullet_token.contents

    # Add the bullet.
    stack[-1].children.append(li)
    stack.append(li)
    indent_stack.append(None)

##################################################
## Tokenization
##################################################

class Token:
    """
    C{Token}s are an intermediate data structure used while
    constructing the structuring DOM tree for a formatted docstring.
    There are five types of C{Token}:

        - Paragraphs
        - Literal blocks
        - Doctest blocks
        - Headings
        - Bullets

    The text contained in each C{Token} is stored in the
    C{contents} variable.  The string in this variable has been
    normalized.  For paragraphs, this means that it has been converted
    into a single line of text, with newline/indentation replaced by
    single spaces.  For literal blocks and doctest blocks, this means
    that the appropriate amount of leading whitespace has been removed
    from each line.

    Each C{Token} has an indentation level associated with it,
    stored in the C{indent} variable.  This indentation level is used
    by the structuring procedure to assemble hierarchical blocks.

    @type tag: C{string}
    @ivar tag: This C{Token}'s type.  Possible values are C{Token.PARA}
        (paragraph), C{Token.LBLOCK} (literal block), C{Token.DTBLOCK}
        (doctest block), C{Token.HEADINGC}, and C{Token.BULLETC}.

    @type startline: C{int}
    @ivar startline: The line on which this C{Token} begins.  This
        line number is only used for issuing errors.

    @type contents: C{string}
    @ivar contents: The normalized text contained in this C{Token}.

    @type indent: C{int} or C{None}
    @ivar indent: The indentation level of this C{Token} (in
        number of leading spaces).  A value of C{None} indicates an
        unknown indentation; this is used for list items and fields
        that begin with one-line paragraphs.

    @type level: C{int} or C{None}
    @ivar level: The heading-level of this C{Token} if it is a
        heading; C{None}, otherwise.  Valid heading levels are 0, 1,
        and 2.

    @type PARA: C{string}
    @cvar PARA: The C{tag} value for paragraph C{Token}s.
    @type LBLOCK: C{string}
    @cvar LBLOCK: The C{tag} value for literal C{Token}s.
    @type DTBLOCK: C{string}
    @cvar DTBLOCK: The C{tag} value for doctest C{Token}s.
    @type HEADING: C{string}
    @cvar HEADING: The C{tag} value for heading C{Token}s.
    @type BULLET: C{string}
    @cvar BULLET: The C{tag} value for bullet C{Token}s.  This C{tag}
        value is also used for field tag C{Token}s, since fields
        function syntactically the same as list items.
    """
    # The possible token types.
    PARA = 'para'
    LBLOCK = 'literalblock'
    DTBLOCK = 'doctestblock'
    HEADING = 'heading'
    BULLET = 'bullet'

    def __init__(self,
            tag: str,
            startline: int,
            contents: str,
            indent: Optional[int],
            level: Optional[int] = None
            ):
        """
        Create a new C{Token}.

        @param tag: The type of the new C{Token}.
        @param startline: The line on which the new C{Token} begins.
        @param contents: The normalized contents of the new C{Token}.
        @param indent: The indentation of the new C{Token} (in number
            of leading spaces).  A value of C{None} indicates an
            unknown indentation.
        @param level: The heading-level of this C{Token} if it is a
            heading; C{None}, otherwise.
        """
        self.tag = tag
        self.startline = startline
        self.contents = contents
        self.indent = indent
        self.level = level

    def __repr__(self) -> str:
        """
        @rtype: C{string}
        @return: the formal representation of this C{Token}.
            C{Token}s have formal representaitons of the form::
                
        """
        return f''

    def to_dom(self) -> Element:
        """
        @return: a DOM representation of this C{Token}.
        """
        e = Element(self.tag)
        e.children.append(self.contents)
        return e

# Construct regular expressions for recognizing bullets.  These are
# global so they don't have to be reconstructed each time we tokenize
# a docstring.
_ULIST_BULLET = r'[-]( +|$)'
_OLIST_BULLET = r'(\d+[.])+( +|$)'
_FIELD_BULLET = r'@\w+( [^{}:\n]+)?:'
_BULLET_RE = re.compile(_ULIST_BULLET + '|' +
                        _OLIST_BULLET + '|' +
                        _FIELD_BULLET)
_LIST_BULLET_RE = re.compile(_ULIST_BULLET + '|' + _OLIST_BULLET)
_FIELD_BULLET_RE = re.compile(_FIELD_BULLET)
del _ULIST_BULLET, _OLIST_BULLET, _FIELD_BULLET

def _tokenize_doctest(
        lines: List[str],
        start: int,
        block_indent: int,
        tokens: List[Token],
        errors: List[ParseError]
        ) -> int:
    """
    Construct a L{Token} containing the doctest block starting at
    C{lines[start]}, and append it to C{tokens}.  C{block_indent}
    should be the indentation of the doctest block.  Any errors
    generated while tokenizing the doctest block will be appended to
    C{errors}.

    @param lines: The list of lines to be tokenized
    @param start: The index into C{lines} of the first line of the
        doctest block to be tokenized.
    @param block_indent: The indentation of C{lines[start]}.  This is
        the indentation of the doctest block.
    @param errors: A list where any errors generated during parsing
        will be stored.  If no list is specified, then errors will
        generate exceptions.
    @return: The line number of the first line following the doctest
        block.
    """
    # If they dedent past block_indent, keep track of the minimum
    # indentation.  This is used when removing leading indentation
    # from the lines of the doctest block.
    min_indent = block_indent

    linenum = start + 1
    while linenum < len(lines):
        # Find the indentation of this line.
        line = lines[linenum]
        indent = len(line) - len(line.lstrip())

        # A blank line ends doctest block.
        if indent == len(line): break

        # A Dedent past block_indent is an error.
        if indent < block_indent:
            min_indent = min(min_indent, indent)
            estr = 'Improper doctest block indentation.'
            errors.append(TokenizationError(estr, linenum))

        # Go on to the next line.
        linenum += 1

    # Add the token, and return the linenum after the token ends.
    contents = '\n'.join(ln[min_indent:] for ln in lines[start:linenum])
    tokens.append(Token(Token.DTBLOCK, start, contents, block_indent))
    return linenum

def _tokenize_literal(
        lines: List[str],
        start: int,
        block_indent: int,
        tokens: List[Token],
        errors: List[ParseError]
        ) -> int:
    """
    Construct a L{Token} containing the literal block starting at
    C{lines[start]}, and append it to C{tokens}.  C{block_indent}
    should be the indentation of the literal block.  Any errors
    generated while tokenizing the literal block will be appended to
    C{errors}.

    @param lines: The list of lines to be tokenized
    @param start: The index into C{lines} of the first line of the
        literal block to be tokenized.
    @param block_indent: The indentation of C{lines[start]}.  This is
        the indentation of the literal block.
    @param errors: A list of the errors generated by parsing.  Any
        new errors generated while will tokenizing this paragraph
        will be appended to this list.
    @return: The line number of the first line following the literal
        block.
    """
    linenum = start + 1
    while linenum < len(lines):
        # Find the indentation of this line.
        line = lines[linenum]
        indent = len(line) - len(line.lstrip())

        # A Dedent to block_indent ends the literal block.
        # (Ignore blank likes, though)
        if len(line) != indent and indent <= block_indent:
            break

        # Go on to the next line.
        linenum += 1

    # Add the token, and return the linenum after the token ends.
    contents = '\n'.join(ln[block_indent:] for ln in lines[start:linenum])
    contents = re.sub(r'(\A[ \n]*\n)|(\n[ \n]*\Z)', '', contents)
    tokens.append(Token(Token.LBLOCK, start, contents, block_indent))
    return linenum

def _tokenize_listart(
        lines: List[str],
        start: int,
        bullet_indent: int,
        tokens: List[Token],
        errors: List[ParseError]
        ) -> int:
    """
    Construct L{Token}s for the bullet and the first paragraph of the
    list item (or field) starting at C{lines[start]}, and append them
    to C{tokens}.  C{bullet_indent} should be the indentation of the
    list item.  Any errors generated while tokenizing will be
    appended to C{errors}.

    @param lines: The list of lines to be tokenized
    @param start: The index into C{lines} of the first line of the
        list item to be tokenized.
    @param bullet_indent: The indentation of C{lines[start]}.  This is
        the indentation of the list item.
    @param errors: A list of the errors generated by parsing.  Any
        new errors generated while will tokenizing this paragraph
        will be appended to this list.
    @return: The line number of the first line following the list
        item's first paragraph.
    """
    linenum = start + 1
    para_indent = None
    doublecolon = lines[start].rstrip()[-2:] == '::'

    # Get the contents of the bullet.
    match = _BULLET_RE.match(lines[start], bullet_indent)
    assert match is not None
    para_start = match.end()
    bcontents = lines[start][bullet_indent : para_start].strip()

    while linenum < len(lines):
        # Find the indentation of this line.
        line = lines[linenum]
        indent = len(line) - len(line.lstrip())

        # "::" markers end paragraphs.
        if doublecolon: break
        if line.rstrip()[-2:] == '::': doublecolon = True

        # A blank line ends the token
        if indent == len(line): break

        # Dedenting past bullet_indent ends the list item.
        if indent < bullet_indent: break

        # A line beginning with a bullet ends the token.
        if _BULLET_RE.match(line, indent): break

        # If this is the second line, set the paragraph indentation, or
        # end the token, as appropriate.
        if para_indent is None: para_indent = indent

        # A change in indentation ends the token
        if indent != para_indent: break

        # Go on to the next line.
        linenum += 1

    # Add the bullet token.
    tokens.append(Token(Token.BULLET, start, bcontents, bullet_indent))

    # Add the paragraph token.
    pcontents = ' '.join(
        [lines[start][para_start:].strip()] +
        [ln.strip() for ln in lines[start+1:linenum]]
        ).strip()
    if pcontents:
        tokens.append(Token(Token.PARA, start, pcontents, para_indent))

    # Return the linenum after the paragraph token ends.
    return linenum

def _tokenize_para(
        lines: List[str],
        start: int,
        para_indent: int,
        tokens: List[Token],
        errors: List[ParseError]
        ) -> int:
    """
    Construct a L{Token} containing the paragraph starting at
    C{lines[start]}, and append it to C{tokens}.  C{para_indent}
    should be the indentation of the paragraph .  Any errors
    generated while tokenizing the paragraph will be appended to
    C{errors}.

    @param lines: The list of lines to be tokenized
    @param start: The index into C{lines} of the first line of the
        paragraph to be tokenized.
    @param para_indent: The indentation of C{lines[start]}.  This is
        the indentation of the paragraph.
    @param errors: A list of the errors generated by parsing.  Any
        new errors generated while will tokenizing this paragraph
        will be appended to this list.
    @return: The line number of the first line following the
        paragraph.
    """
    linenum = start + 1
    doublecolon = False
    while linenum < len(lines):
        # Find the indentation of this line.
        line = lines[linenum]
        indent = len(line) - len(line.lstrip())

        # "::" markers end paragraphs.
        if doublecolon: break
        if line.rstrip()[-2:] == '::': doublecolon = True

        # Blank lines end paragraphs
        if indent == len(line): break

        # Indentation changes end paragraphs
        if indent != para_indent: break

        # List bullets end paragraphs
        if _BULLET_RE.match(line, indent): break

        # Check for mal-formatted field items.
        if line[indent] == '@':
            estr = "Possible mal-formatted field item."
            errors.append(TokenizationError(estr, linenum, is_fatal=False))

        # Go on to the next line.
        linenum += 1

    contents = [ln.strip() for ln in lines[start:linenum]]

    # Does this token look like a heading?
    if ((len(contents) < 2) or
        (contents[1][0] not in _HEADING_CHARS) or
        (abs(len(contents[0])-len(contents[1])) > 5)):
        looks_like_heading = False
    else:
        looks_like_heading = True
        for char in contents[1]:
            if char != contents[1][0]:
                looks_like_heading = False
                break

    if looks_like_heading:
        if len(contents[0]) != len(contents[1]):
            estr = ("Possible heading typo: the number of "+
                    "underline characters must match the "+
                    "number of heading characters.")
            errors.append(TokenizationError(estr, start, is_fatal=False))
        else:
            level = _HEADING_CHARS.index(contents[1][0])
            tokens.append(Token(Token.HEADING, start,
                                contents[0], para_indent, level))
            return start+2

    # Add the paragraph token, and return the linenum after it ends.
    tokens.append(Token(Token.PARA, start, ' '.join(contents), para_indent))
    return linenum

def _tokenize(text: str, errors: List[ParseError]) -> List[Token]:
    """
    Split a given formatted docstring into an ordered list of
    L{Token}s, according to the epytext markup rules.

    @param text: The epytext string
    @param errors: A list where any errors generated during parsing
        will be stored.  If no list is specified, then errors will
        generate exceptions.
    @return: a list of the L{Token}s that make up the given string.
    """
    tokens: List[Token] = []
    lines = text.split('\n')

    # Scan through the lines, determining what @type of token we're
    # dealing with, and tokenizing it, as appropriate.
    linenum = 0
    while linenum < len(lines):
        # Get the current line and its indentation.
        line = lines[linenum]
        indent = len(line)-len(line.lstrip())

        if indent == len(line):
            # Ignore blank lines.
            linenum += 1
            continue
        elif line[indent:indent+4] == '>>> ':
            # blocks starting with ">>> " are doctest block tokens.
            linenum = _tokenize_doctest(lines, linenum, indent,
                                        tokens, errors)
        elif _BULLET_RE.match(line, indent):
            # blocks starting with a bullet are LI start tokens.
            linenum = _tokenize_listart(lines, linenum, indent,
                                        tokens, errors)
            if tokens[-1].indent is not None:
                indent = tokens[-1].indent
        else:
            # Check for mal-formatted field items.
            if line[indent] == '@':
                estr = "Possible mal-formatted field item."
                errors.append(TokenizationError(estr, linenum, is_fatal=False))

            # anything else is either a paragraph or a heading.
            linenum = _tokenize_para(lines, linenum, indent, tokens, errors)

        # Paragraph tokens ending in '::' initiate literal blocks.
        if (tokens[-1].tag == Token.PARA and
            tokens[-1].contents[-2:] == '::'):
            tokens[-1].contents = tokens[-1].contents[:-1]
            linenum = _tokenize_literal(lines, linenum, indent, tokens, errors)

    return tokens


##################################################
## Inline markup ("colorizing")
##################################################

# Assorted regular expressions used for colorizing.
_BRACE_RE = re.compile(r'{|}')
_TARGET_RE = re.compile(r'^(.*?)\s*<(?:URI:|URL:)?([^<>]+)>$')

def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> Element:
    """
    Given a string containing the contents of a paragraph, produce a
    DOM C{Element} encoding that paragraph.  Colorized regions are
    represented using DOM C{Element}s, and text is represented using
    DOM C{Text}s.

    @param errors: A list of errors.  Any newly generated errors will
        be appended to this list.
    @type errors: C{list} of C{string}

    @param tagName: The element tag for the DOM C{Element} that should
        be generated.
    @type tagName: C{string}

    @return: a DOM C{Element} encoding the given paragraph.
    @returntype: C{Element}
    """
    text = token.contents

    # Maintain a stack of DOM elements, containing the ancestors of
    # the text currently being analyzed.  New elements are pushed when
    # "{" is encountered, and old elements are popped when "}" is
    # encountered.
    stack = [Element(tagName)]

    # This is just used to make error-reporting friendlier.  It's a
    # stack parallel to "stack" containing the index of each element's
    # open brace.
    openbrace_stack = [0]

    # Process the string, scanning for '{' and '}'s.  start is the
    # index of the first unprocessed character.  Each time through the
    # loop, we process the text from the first unprocessed character
    # to the next open or close brace.
    start = 0
    while 1:
        match = _BRACE_RE.search(text, start)
        if match is None: break
        end = match.start()

        # Open braces start new colorizing elements.  When preceeded
        # by a capital letter, they specify a colored region, as
        # defined by the _COLORIZING_TAGS dictionary.  Otherwise,
        # use a special "literal braces" element (with tag "litbrace"),
        # and convert them to literal braces once we find the matching
        # close-brace.
        if match.group() == '{':
            if (end>0) and 'A' <= text[end-1] <= 'Z':
                if (end-1) > start:
                    stack[-1].children.append(text[start:end-1])
                if text[end-1] not in _COLORIZING_TAGS:
                    estr = "Unknown inline markup tag."
                    errors.append(ColorizingError(estr, token, end-1))
                    stack.append(Element('unknown'))
                else:
                    tag = _COLORIZING_TAGS[text[end-1]]
                    stack.append(Element(tag))
            else:
                if end > start:
                    stack[-1].children.append(text[start:end])
                stack.append(Element('litbrace'))
            openbrace_stack.append(end)
            stack[-2].children.append(stack[-1])

        # Close braces end colorizing elements.
        elif match.group() == '}':
            # Check for (and ignore) unbalanced braces.
            if len(stack) <= 1:
                estr = "Unbalanced '}'."
                errors.append(ColorizingError(estr, token, end))
                start = end + 1
                continue

            # Add any remaining text.
            if end > start:
                stack[-1].children.append(text[start:end])

            # Special handling for symbols:
            if stack[-1].tag == 'symbol':
                if (len(stack[-1].children) != 1 or
                    not isinstance(stack[-1].children[0], str)):
                    estr = "Invalid symbol code."
                    errors.append(ColorizingError(estr, token, end))
                else:
                    symb = stack[-1].children[0]
                    if symb in _SYMBOLS:
                        # It's a symbol
                        stack[-2].children[-1] = Element('symbol', symb)
                    else:
                        estr = "Invalid symbol code."
                        errors.append(ColorizingError(estr, token, end))

            # Special handling for escape elements:
            if stack[-1].tag == 'escape':
                if (len(stack[-1].children) != 1 or
                    not isinstance(stack[-1].children[0], str)):
                    estr = "Invalid escape code."
                    errors.append(ColorizingError(estr, token, end))
                else:
                    escp = stack[-1].children[0]
                    if escp in _ESCAPES:
                        # It's an escape from _ESCPAES
                        stack[-2].children[-1] = _ESCAPES[escp]
                    elif len(escp) == 1:
                        # It's a single-character escape (eg E{.})
                        stack[-2].children[-1] = escp
                    else:
                        estr = "Invalid escape code."
                        errors.append(ColorizingError(estr, token, end))

            # Special handling for literal braces elements:
            if stack[-1].tag == 'litbrace':
                stack[-2].children[-1:] = ['{'] + cast(List[str], stack[-1].children) + ['}']

            # Special handling for link-type elements:
            if stack[-1].tag in _LINK_COLORIZING_TAGS:
                _colorize_link(stack[-1], token, end, errors)

            # Pop the completed element.
            openbrace_stack.pop()
            stack.pop()

        start = end+1

    # Add any final text.
    if start < len(text):
        stack[-1].children.append(text[start:])

    if len(stack) != 1:
        estr = "Unbalanced '{'."
        errors.append(ColorizingError(estr, token, openbrace_stack[-1]))

    return stack[0]

def _colorize_link(link: Element, token: Token, end: int, errors: List[ParseError]) -> None:
    variables = link.children[:]

    # If the last child isn't text, we know it's bad.
    if len(variables)==0 or not isinstance(variables[-1], str):
        estr = f"Bad {link.tag} target."
        errors.append(ColorizingError(estr, token, end))
        return

    # Did they provide an explicit target?
    match2 = _TARGET_RE.match(variables[-1])
    if match2:
        (text, target) = match2.groups()
        variables[-1] = text
    # Can we extract an implicit target?
    elif len(variables) == 1:
        target = cast(str, variables[0])
    else:
        estr = f"Bad {link.tag} target."
        errors.append(ColorizingError(estr, token, end))
        return

    # Construct the name element.
    name_elt = Element('name', *variables)

    # Clean up the target.  For URIs, assume http or mailto if they
    # don't specify (no relative urls)
    target = re.sub(r'\s', '', target)
    if link.tag=='uri':
        if not re.match(r'\w+:', target):
            if re.match(r'\w+@(\w+)(\.\w+)*', target):
                target = 'mailto:' + target
            else:
                target = 'http://'+target
    elif link.tag=='link':
        # Remove arg lists for functions (e.g., L{_colorize_link()})
        target = re.sub(r'\(.*\)$', '', target)
        if not re.match(r'^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$', target):
            estr = "Bad link target."
            errors.append(ColorizingError(estr, token, end))
            return

    # Construct the target element.
    target_elt = Element('target', target, lineno=str(token.startline))

    # Add them to the link element.
    link.children = [name_elt, target_elt]

##################################################
## Parse Errors
##################################################

class TokenizationError(ParseError):
    """
    An error generated while tokenizing a formatted documentation
    string.
    """

class StructuringError(ParseError):
    """
    An error generated while structuring a formatted documentation
    string.
    """

class ColorizingError(ParseError):
    """
    An error generated while colorizing a paragraph.
    """
    def __init__(self, descr: str, token: Token, charnum: int, is_fatal: bool = True):
        """
        Construct a new colorizing exception.

        @param descr: A short description of the error.
        @param token: The token where the error occured
        @param charnum: The character index of the position in
            C{token} where the error occured.
        """
        ParseError.__init__(self, descr, token.startline, is_fatal)
        self.token = token
        self.charnum = charnum

    CONTEXT_RANGE = 20
    def descr(self) -> str:
        RANGE = self.CONTEXT_RANGE
        if self.charnum <= RANGE:
            left = self.token.contents[0:self.charnum]
        else:
            left = '...'+self.token.contents[self.charnum-RANGE:self.charnum]
        if (len(self.token.contents)-self.charnum) <= RANGE:
            right = self.token.contents[self.charnum:]
        else:
            right = (self.token.contents[self.charnum:self.charnum+RANGE]
                     + '...')
        return f"{self._descr}\n\n{left}{right}\n{' '*len(left)}^"

#################################################################
##                    SUPPORT FOR EPYDOC
#################################################################

def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring:
    """
    Parse the given docstring, which is formatted using epytext; and
    return a L{ParsedDocstring} representation of its contents.

    @param docstring: The docstring to parse
    @param errors: A list where any errors generated during parsing
        will be stored.
    """
    tree = parse(docstring, errors)
    if tree is None:
        return ParsedEpytextDocstring(None, ())

    tree_children = cast(List[Element], tree.children)

    fields = []
    if tree_children and tree_children[-1].tag == 'fieldlist':
        # Take field list out of the document tree.
        field_list = tree_children.pop()
        field_children = cast(List[Element], field_list.children)

        for field in field_children:
            # Get the tag
            tag = cast(str, cast(Element, field.children.pop(0)).children[0]).lower()

            # Get the argument.
            if field.children and cast(Element, field.children[0]).tag == 'arg':
                arg: Optional[str] = \
                    cast(str, cast(Element, field.children.pop(0)).children[0])
            else:
                arg = None

            # Process the field.
            field.tag = 'epytext'
            field_parsed_doc: ParsedDocstring = ParsedEpytextDocstring(field, ())
            lineno = int(field.attribs['lineno'])
            fields.append(Field(tag, arg, field_parsed_doc, lineno))

    # Save the remaining docstring as the description.
    if tree_children and tree_children[0].children:
        return ParsedEpytextDocstring(tree, fields)
    else:
        return ParsedEpytextDocstring(None, fields)

def get_parser(_: ObjClass | None) -> ParserFunction:
    """
    Get the L{parse_docstring} function. 
    """
    return parse_docstring

class ParsedEpytextDocstring(ParsedDocstring):
    SYMBOL_TO_CODEPOINT = {
        # Symbols
        '<-': 8592, '->': 8594, '^': 8593, 'v': 8595,

        # Greek letters
        'alpha': 945, 'beta': 946, 'gamma': 947,
        'delta': 948, 'epsilon': 949, 'zeta': 950,
        'eta': 951, 'theta': 952, 'iota': 953,
        'kappa': 954, 'lambda': 955, 'mu': 956,
        'nu': 957, 'xi': 958, 'omicron': 959,
        'pi': 960, 'rho': 961, 'sigma': 963,
        'tau': 964, 'upsilon': 965, 'phi': 966,
        'chi': 967, 'psi': 968, 'omega': 969,
        'Alpha': 913, 'Beta': 914, 'Gamma': 915,
        'Delta': 916, 'Epsilon': 917, 'Zeta': 918,
        'Eta': 919, 'Theta': 920, 'Iota': 921,
        'Kappa': 922, 'Lambda': 923, 'Mu': 924,
        'Nu': 925, 'Xi': 926, 'Omicron': 927,
        'Pi': 928, 'Rho': 929, 'Sigma': 931,
        'Tau': 932, 'Upsilon': 933, 'Phi': 934,
        'Chi': 935, 'Psi': 936, 'Omega': 937,

        # HTML character entities
        'larr': 8592, 'rarr': 8594, 'uarr': 8593,
        'darr': 8595, 'harr': 8596, 'crarr': 8629,
        'lArr': 8656, 'rArr': 8658, 'uArr': 8657,
        'dArr': 8659, 'hArr': 8660,
        'copy': 169, 'times': 215, 'forall': 8704,
        'exist': 8707, 'part': 8706,
        'empty': 8709, 'isin': 8712, 'notin': 8713,
        'ni': 8715, 'prod': 8719, 'sum': 8721,
        'prop': 8733, 'infin': 8734, 'ang': 8736,
        'and': 8743, 'or': 8744, 'cap': 8745, 'cup': 8746,
        'int': 8747, 'there4': 8756, 'sim': 8764,
        'cong': 8773, 'asymp': 8776, 'ne': 8800,
        'equiv': 8801, 'le': 8804, 'ge': 8805,
        'sub': 8834, 'sup': 8835, 'nsub': 8836,
        'sube': 8838, 'supe': 8839, 'oplus': 8853,
        'otimes': 8855, 'perp': 8869,

        # Alternate (long) names
        'infinity': 8734, 'integral': 8747, 'product': 8719,
        '<=': 8804, '>=': 8805,
        }

    def __init__(self, body: Optional[Element], fields: Sequence['Field']):
        ParsedDocstring.__init__(self, fields)
        self._tree = body
        # Caching:
        self._stan: Optional[Tag] = None
        self._document: Optional[nodes.document] = None
        self._section_slugs: Set[str] = set()

    def __str__(self) -> str:
        return str(self._tree)

    @property
    def has_body(self) -> bool:
        return self._tree is not None

    def _slugify(self, text:str) -> str:
        # Takes special care to ensure we don't generate 
        # twice the same ID for sections.
        s = slugify(text)
        i = 1
        while s in self._section_slugs:
            s = slugify(f"{text}-{i}")
            i+=1
        self._section_slugs.add(s)
        return s

    def to_node(self) -> nodes.document:

        if self._document is not None:
            return self._document

        self._document = new_document('epytext')

        if self._tree is not None:
            node, = self._to_node(self._tree)
            # The contents is encapsulated inside a section node. 
            # Reparent the contents of the second level to the root level. 
            self._document = set_node_attributes(self._document, children=node.children)
        
        return self._document
    
    def _to_node(self, tree: Element) -> Iterable[nodes.Node]:
        
        # Process the children first.
        variables: List[nodes.Node] = []
        for child in tree.children:
            if isinstance(child, str):
                variables.append(set_node_attributes(nodes.Text(child), document=self._document))
            else:
                variables.extend(self._to_node(child))

        # Perform the approriate action for the DOM tree type.
        if tree.tag == 'para':
            # tree.attribs.get('inline') does not exist anymore.
            # the choice to render the 

tags is handled in HTMLTranslator.should_be_compact_paragraph(), not here anymore yield set_node_attributes(nodes.paragraph('', ''), document=self._document, children=variables) elif tree.tag == 'code': yield set_node_attributes(nodes.literal('', ''), document=self._document, children=variables) elif tree.tag == 'uri': label, target = variables yield set_node_attributes(nodes.reference( '', internal=False, refuri=target), document=self._document, children=label.children) elif tree.tag == 'link': label, target = variables assert isinstance(target, nodes.Text) assert isinstance(label, nodes.inline) # Figure the line number to warn on precise lines. # This is needed only for links currently. lineno = int(cast(Element, tree.children[1]).attribs['lineno']) yield set_node_attributes(nodes.title_reference( '', '', refuri=target.astext()), document=self._document, lineno=lineno, children=label.children) elif tree.tag == 'name': # name can contain nested inline markup, so we use nodes.inline instead of nodes.Text yield set_node_attributes(nodes.inline('', ''), document=self._document, children=variables) elif tree.tag == 'target': value, = variables if not isinstance(value, nodes.Text): raise AssertionError("target contents must be a simple text.") yield set_node_attributes(value, document=self._document) elif tree.tag == 'italic': yield set_node_attributes(nodes.emphasis('', ''), document=self._document, children=variables) elif tree.tag == 'math': node = set_node_attributes(nodes.math('', ''), document=self._document, children=variables) node['classes'].append('math') yield node elif tree.tag == 'bold': yield set_node_attributes(nodes.strong('', ''), document=self._document, children=variables) elif tree.tag == 'ulist': yield set_node_attributes(nodes.bullet_list(''), document=self._document, children=variables) elif tree.tag == 'olist': yield set_node_attributes(nodes.enumerated_list(''), document=self._document, children=variables) elif tree.tag == 'li': yield set_node_attributes(nodes.list_item(''), document=self._document, children=variables) elif tree.tag == 'heading': yield set_node_attributes(nodes.title('', ''), document=self._document, children=variables) elif tree.tag == 'literalblock': yield set_node_attributes(nodes.literal_block('', ''), document=self._document, children=variables) elif tree.tag == 'doctestblock': if not isinstance(contents:=tree.children[0], str): raise AssertionError("doctest block contents is not a string") yield set_node_attributes(nodes.doctest_block(contents, contents), document=self._document) elif tree.tag in ('fieldlist', 'tag', 'arg'): raise AssertionError("There should not be any field lists left") elif tree.tag == 'section': assert len(tree.children)>0, f"empty section {tree}" yield set_node_attributes(nodes.section('', ids=[self._slugify(' '.join(gettext(tree.children[0])))]), document=self._document, children=variables) elif tree.tag == 'epytext': yield set_node_attributes(nodes.section(''), document=self._document, children=variables) elif tree.tag == 'symbol': symbol = cast(str, tree.children[0]) char = chr(self.SYMBOL_TO_CODEPOINT[symbol]) yield set_node_attributes(nodes.inline(symbol, char), document=self._document) else: raise AssertionError(f"Unknown epytext DOM element {tree.tag!r}") pydoctor-24.11.2/pydoctor/epydoc/markup/google.py000066400000000000000000000010321473665144200220070ustar00rootroot00000000000000""" Parser for google-style docstrings. @See: L{pydoctor.epydoc.markup.numpy} @See: L{pydoctor.epydoc.markup._napoleon} """ from __future__ import annotations from pydoctor.epydoc.markup import ObjClass, ParserFunction from pydoctor.epydoc.markup._napoleon import NapoelonDocstringParser def get_parser(objclass: ObjClass | None) -> ParserFunction: """ Returns the parser function. Behaviour will depend on the documentable type and system options. """ return NapoelonDocstringParser(objclass).parse_google_docstring pydoctor-24.11.2/pydoctor/epydoc/markup/numpy.py000066400000000000000000000010311473665144200217020ustar00rootroot00000000000000""" Parser for numpy-style docstrings. @See: L{pydoctor.epydoc.markup.google} @See: L{pydoctor.epydoc.markup._napoleon} """ from __future__ import annotations from pydoctor.epydoc.markup import ObjClass, ParserFunction from pydoctor.epydoc.markup._napoleon import NapoelonDocstringParser def get_parser(objclass: ObjClass | None) -> ParserFunction: """ Returns the parser function. Behaviour will depend on the documentable type and system options. """ return NapoelonDocstringParser(objclass).parse_numpy_docstring pydoctor-24.11.2/pydoctor/epydoc/markup/plaintext.py000066400000000000000000000055541473665144200225600ustar00rootroot00000000000000# # plaintext.py: plaintext docstring parsing # Edward Loper # # Created [04/10/01 12:00 AM] # """ Parser for plaintext docstrings. Plaintext docstrings are rendered as verbatim output, preserving all whitespace. """ from __future__ import annotations __docformat__ = 'epytext en' from typing import List, Optional from docutils import nodes from twisted.web.template import Tag, tags from pydoctor.epydoc.markup import DocstringLinker, ObjClass, ParsedDocstring, ParseError, ParserFunction from pydoctor.epydoc.docutils import set_node_attributes, new_document def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring: """ Parse the given docstring, which is formatted as plain text; and return a L{ParsedDocstring} representation of its contents. @param docstring: The docstring to parse @param errors: A list where any errors generated during parsing will be stored. """ return ParsedPlaintextDocstring(docstring) def get_parser(_: ObjClass | None) -> ParserFunction: """ Just return the L{parse_docstring} function. """ return parse_docstring class ParsedPlaintextDocstring(ParsedDocstring): def __init__(self, text: str): ParsedDocstring.__init__(self, ()) self._text = text # Caching: self._document: Optional[nodes.document] = None @property def has_body(self) -> bool: return bool(self._text) # plaintext parser overrides the default to_stan() method for performance and design reasons. # We don't want to use docutils to process the plaintext format because we won't # actually use the document tree ,it does not contains any additionnalt information compared to the raw docstring. # Also, the consolidated fields handling in restructuredtext.py relies on this "pre" class. def to_stan(self, docstring_linker: DocstringLinker) -> Tag: return tags.p(self._text, class_='pre') def to_node(self) -> nodes.document: # This code is mainly used to generate summary of plaintext docstrings. if self._document is not None: return self._document else: # create document _document = new_document('plaintext') # split text into paragraphs paragraphs = [set_node_attributes(nodes.paragraph('',''), children=[ set_node_attributes(nodes.Text(p.strip('\n')), document=_document, lineno=0)], document=_document, lineno=0) for p in self._text.split('\n\n')] # assemble document _document = set_node_attributes(_document, children=paragraphs, document=_document, lineno=0) self._document = _document return self._document pydoctor-24.11.2/pydoctor/epydoc/markup/restructuredtext.py000066400000000000000000000505631473665144200242100ustar00rootroot00000000000000# # restructuredtext.py: ReStructuredText docstring parsing # Edward Loper # # Created [06/28/03 02:52 AM] # """ Epydoc parser for ReStructuredText strings. ReStructuredText is the standard markup language used by the Docutils project. L{parse_docstring()} provides the primary interface to this module; it returns a L{ParsedRstDocstring}, which supports all of the methods defined by L{ParsedDocstring}. L{ParsedRstDocstring} is basically just a L{ParsedDocstring} wrapper for the C{nodes.document} class. B{Creating C{ParsedRstDocstring}s}: C{ParsedRstDocstring}s are created by the L{parse_docstring} function, using the C{docutils.core.publish_string()} method, with the following helpers: - An L{_EpydocReader} is used to capture all error messages as it parses the docstring. - A L{_DocumentPseudoWriter} is used to extract the document itself, without actually writing any output. The document is saved for further processing. The settings for the writer are copied from C{docutils.writers.html4css1.Writer}, since those settings will be used when we actually write the docstring to html. @var CONSOLIDATED_FIELDS: A dictionary encoding the set of 'consolidated fields' that can be used. Each consolidated field is marked by a single tag, and contains a single bulleted list, where each list item starts with an identifier, marked as interpreted text (C{`...`}). This module automatically splits these consolidated fields into individual fields. The keys of C{CONSOLIDATED_FIELDS} are the names of possible consolidated fields; and the values are the names of the field tags that should be used for individual entries in the list. """ from __future__ import annotations __docformat__ = 'epytext en' from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Sequence, Set, cast if TYPE_CHECKING: from typing import TypeAlias import re from docutils import nodes from docutils.core import publish_string from docutils.writers import Writer from docutils.parsers.rst.directives.admonitions import BaseAdmonition from docutils.readers.standalone import Reader as StandaloneReader from docutils.utils import Reporter from docutils.parsers.rst import Directive, directives from docutils.transforms import Transform, frontmatter from pydoctor.epydoc.markup import Field, ObjClass, ParseError, ParsedDocstring, ParserFunction from pydoctor.epydoc.markup.plaintext import ParsedPlaintextDocstring from pydoctor.epydoc.docutils import new_document #: A dictionary whose keys are the "consolidated fields" that are #: recognized by epydoc; and whose values are the corresponding epydoc #: field names that should be used for the individual fields. CONSOLIDATED_FIELDS = { 'parameters': 'param', 'arguments': 'arg', 'exceptions': 'except', 'variables': 'var', 'ivariables': 'ivar', 'cvariables': 'cvar', 'groups': 'group', 'types': 'type', 'keywords': 'keyword', } #: A list of consolidated fields whose bodies may be specified using a #: definition list, rather than a bulleted list. For these fields, the #: 'classifier' for each term in the definition list is translated into #: a @type field. CONSOLIDATED_DEFLIST_FIELDS = ['param', 'arg', 'var', 'ivar', 'cvar', 'keyword'] def parse_docstring(docstring: str, errors: List[ParseError], ) -> ParsedDocstring: """ Parse the given docstring, which is formatted using ReStructuredText; and return a L{ParsedDocstring} representation of its contents. @param docstring: The docstring to parse @param errors: A list where any errors generated during parsing will be stored. """ writer = _DocumentPseudoWriter() reader = _EpydocReader(errors) # Outputs errors to the list. # Credits: mhils - Maximilian Hils from the pdoc repository https://github.com/mitmproxy/pdoc # Strip Sphinx interpreted text roles for code references: :obj:`foo` -> `foo` docstring = re.sub( r"(:py)?:(mod|func|data|const|class|meth|attr|exc|obj):", "", docstring ) publish_string(docstring, writer=writer, reader=reader, settings_overrides={'report_level':10000, 'halt_level':10000, 'warning_stream':None}) document = writer.document visitor = _SplitFieldsTranslator(document, errors) document.walk(visitor) return ParsedRstDocstring(document, visitor.fields) def get_parser(_: ObjClass | None) -> ParserFunction: """ Get the L{parse_docstring} function. """ return parse_docstring class OptimizedReporter(Reporter): """A reporter that ignores all debug messages. This is used to shave a couple seconds off of epydoc's run time, since docutils isn't very fast about processing its own debug messages. """ def debug(self, *args: Any, **kwargs: Any) -> None: # type:ignore[override] pass class ParsedRstDocstring(ParsedDocstring): """ An encoded version of a ReStructuredText docstring. The contents of the docstring are encoded in the L{_document} instance variable. """ def __init__(self, document: nodes.document, fields: Sequence[Field]): self._document = document """A ReStructuredText document, encoding the docstring.""" document.reporter = OptimizedReporter( document.reporter.source, report_level=10000, halt_level=10000, stream='') ParsedDocstring.__init__(self, fields) @property def has_body(self) -> bool: return any( isinstance(child, nodes.Text) or child.children for child in self._document.children ) def to_node(self) -> nodes.document: return self._document def __repr__(self) -> str: return '' class _EpydocReader(StandaloneReader): """ A reader that captures all errors that are generated by parsing, and appends them to a list as L{ParseError}. """ def __init__(self, errors: List[ParseError]): self._errors = errors StandaloneReader.__init__(self) def get_transforms(self) -> List[Transform]: # Remove the DocInfo transform, to ensure that :author: fields # are correctly handled. return [t for t in StandaloneReader.get_transforms(self) if t != frontmatter.DocInfo] def new_document(self) -> nodes.document: document = new_document(self.source.source_path, self.settings) # Capture all warning messages. document.reporter.attach_observer(self.report) # Return the new document. return document def report(self, error: nodes.system_message) -> None: level: int = error['level'] is_fatal = level >= Reporter.ERROR_LEVEL linenum: Optional[int] = error.get('line') msg = ''.join(c.astext() for c in error) self._errors.append(ParseError(msg, linenum, is_fatal)) if TYPE_CHECKING: _StrWriter: TypeAlias = Writer[str] else: _StrWriter = Writer class _DocumentPseudoWriter(_StrWriter): """ A pseudo-writer for the docutils framework, that can be used to access the document itself. The output of C{_DocumentPseudoWriter} is just an empty string; but after it has been used, the most recently processed document is available as the instance variable C{document}. """ document: nodes.document """The most recently processed document.""" def translate(self) -> None: self.output = '' class _SplitFieldsTranslator(nodes.NodeVisitor): """ A docutils translator that removes all fields from a document, and collects them into the instance variable C{fields} @ivar fields: The fields of the most recently walked document. @type fields: C{list} of L{Field} """ ALLOW_UNMARKED_ARG_IN_CONSOLIDATED_FIELD = True """If true, then consolidated fields are not required to mark arguments with C{`backticks`}. (This is currently only implemented for consolidated fields expressed as definition lists; consolidated fields expressed as unordered lists still require backticks for now.""" def __init__(self, document: nodes.document, errors: List[ParseError]): nodes.NodeVisitor.__init__(self, document) self._errors = errors self.fields: List[Field] = [] self._newfields: Set[str] = set() def visit_document(self, node: nodes.document) -> None: self.fields = [] def visit_field(self, node: nodes.field) -> None: # Remove the field from the tree. node.parent.remove(node) # Extract the field name & optional argument # FIXME: https://github.com/twisted/pydoctor/issues/267 # Support combined parameter type and description, if the type is a single word like:: # :param str user_agent: user agent tag = node[0].astext().split(None, 1) tagname = tag[0] if len(tag)>1: arg = tag[1] else: arg = None # Handle special fields: fbody = node[1] assert isinstance(fbody, nodes.Element) if arg is None: for (list_tag, entry_tag) in CONSOLIDATED_FIELDS.items(): if tagname.lower() == list_tag: try: self.handle_consolidated_field(fbody, entry_tag) return except ValueError as e: estr = 'Unable to split consolidated field ' estr += f'"{tagname}" - {e}' self._errors.append(ParseError(estr, node.line, is_fatal=False)) # Use a @newfield to let it be displayed as-is. if tagname.lower() not in self._newfields: newfield = Field('newfield', tagname.lower(), ParsedPlaintextDocstring(tagname), (node.line or 1) - 1) self.fields.append(newfield) self._newfields.add(tagname.lower()) self._add_field(tagname, arg, fbody, node.line) def _add_field(self, tagname: str, arg: Optional[str], fbody: Iterable[nodes.Node], lineno: int | None ) -> None: field_doc = self.document.copy() for child in fbody: field_doc.append(child) field_parsed_doc = ParsedRstDocstring(field_doc, ()) self.fields.append(Field(tagname, arg, field_parsed_doc, (lineno or 1) - 1)) def visit_field_list(self, node: nodes.field_list) -> None: # Remove the field list from the tree. The visitor will still walk # over the node's children. node.parent.remove(node) def handle_consolidated_field(self, body: nodes.Element, tagname: str) -> None: """ Attempt to handle a consolidated section. """ if len(body) != 1: raise ValueError('does not contain a single list.') if not isinstance(b0:=body[0], nodes.Element): # unfornutate assertion required for typing purposes raise ValueError('does not contain a list.') if isinstance(b0, nodes.bullet_list): self.handle_consolidated_bullet_list(b0, tagname) elif (isinstance(b0, nodes.definition_list) and tagname in CONSOLIDATED_DEFLIST_FIELDS): self.handle_consolidated_definition_list(b0, tagname) elif tagname in CONSOLIDATED_DEFLIST_FIELDS: raise ValueError('does not contain a bulleted list or ' 'definition list.') else: raise ValueError('does not contain a bulleted list.') def handle_consolidated_bullet_list(self, items: nodes.bullet_list, tagname: str) -> None: # Check the contents of the list. In particular, each list # item should have the form: # - `arg`: description... n = 0 _BAD_ITEM = ("list item %d is not well formed. Each item must " "consist of a single marked identifier (e.g., `x`), " "optionally followed by a colon or dash and a " "description.") for item in items: n += 1 if not isinstance(item, nodes.list_item) or len(item) == 0: raise ValueError('bad bulleted list (bad child %d).' % n) if not isinstance(i0:=item[0], nodes.paragraph): if isinstance(i0, nodes.definition_list): raise ValueError(('list item %d contains a definition '+ 'list (it\'s probably indented '+ 'wrong).') % n) else: raise ValueError(_BAD_ITEM % n) if len(i0) == 0: raise ValueError(_BAD_ITEM % n) if not isinstance(i0[0], nodes.title_reference): raise ValueError(_BAD_ITEM % n) # Everything looks good; convert to multiple fields. for item in items: assert isinstance(item, nodes.list_item) # for typing # Extract the arg, item[0][0] is safe since we checked eariler for malformated list. arg = item[0][0].astext() # type: ignore # Extract the field body, and remove the arg fbody = cast('list[nodes.Element]', item[:]) fbody[0] = fbody[0].copy() fbody[0][:] = cast(nodes.paragraph, item[0])[1:] # Remove the separating ":", if present if (len(fbody[0]) > 0 and isinstance(fbody[0][0], nodes.Text)): text = fbody[0][0].astext() if text[:1] in ':-': fbody[0][0] = nodes.Text(text[1:].lstrip()) elif text[:2] in (' -', ' :'): fbody[0][0] = nodes.Text(text[2:].lstrip()) # Wrap the field body, and add a new field self._add_field(tagname, arg, fbody, fbody[0].line) def handle_consolidated_definition_list(self, items: nodes.definition_list, tagname: str) -> None: # Check the list contents. n = 0 _BAD_ITEM = ("item %d is not well formed. Each item's term must " "consist of a single marked identifier (e.g., `x`), " "optionally followed by a space, colon, space, and " "a type description.") for item in items: n += 1 if (not isinstance(item, nodes.definition_list_item) or len(item) < 2 or not isinstance(item[-1], nodes.definition) or not isinstance(i0:=item[0], nodes.Element)): raise ValueError('bad definition list (bad child %d).' % n) if len(item) > 3: raise ValueError(_BAD_ITEM % n) if not ((isinstance(i0[0], nodes.title_reference)) or (self.ALLOW_UNMARKED_ARG_IN_CONSOLIDATED_FIELD and isinstance(i0[0], nodes.Text))): raise ValueError(_BAD_ITEM % n) for child in i0[1:]: if child.astext() != '': raise ValueError(_BAD_ITEM % n) # Extract it. for item in items: assert isinstance(item, nodes.definition_list_item) # for typing # The basic field. arg = cast(nodes.Element, item[0])[0].astext() lineno = item[0].line fbody = cast(nodes.definition, item[-1]) self._add_field(tagname, arg, fbody, lineno) # If there's a classifier, treat it as a type. if len(item) == 3: type_descr = cast(nodes.Element, item[1]) self._add_field('type', arg, type_descr, lineno) def unknown_visit(self, node: nodes.Node) -> None: 'Ignore all unknown nodes' versionlabels = { 'versionadded': 'New in version %s', 'versionchanged': 'Changed in version %s', 'deprecated': 'Deprecated since version %s', } versionlabel_classes = { 'versionadded': 'added', 'versionchanged': 'changed', 'deprecated': 'deprecated', } class VersionChange(Directive): """ Directive to describe a change/addition/deprecation in a specific version. """ class versionmodified(nodes.Admonition, nodes.TextElement): """Node for version change entries. Currently used for "versionadded", "versionchanged" and "deprecated" directives. """ has_content = True required_arguments = 1 optional_arguments = 1 final_argument_whitespace = True def run(self) -> List[nodes.Node]: node = self.versionmodified() node.document = self.state.document node['type'] = self.name node['version'] = self.arguments[0] text = versionlabels[self.name] % self.arguments[0] if len(self.arguments) == 2: inodes, messages = self.state.inline_text(self.arguments[1], self.lineno + 1) para = nodes.paragraph(self.arguments[1], '', *inodes) node.append(para) else: messages = [] if self.content: self.state.nested_parse(self.content, self.content_offset, node) classes = ['versionmodified', versionlabel_classes[self.name]] if len(node): if isinstance(node[0], nodes.paragraph) and node[0].rawsource: content = nodes.inline(node[0].rawsource) content.source = node[0].source content.line = node[0].line content += node[0].children node[0].replace_self(nodes.paragraph('', '', content)) para = cast(nodes.paragraph, node[0]) para.insert(0, nodes.inline('', '%s: ' % text, classes=classes)) else: para = nodes.paragraph('', '', nodes.inline('', '%s.' % text, classes=classes), ) node.append(para) ret = [node] # type: List[nodes.Node] ret += messages return ret # Do like Sphinx does for the seealso directive. class SeeAlso(BaseAdmonition): """ An admonition mentioning things to look at as reference. """ class seealso(nodes.Admonition, nodes.Element): """Custom "see also" admonition node.""" node_class = seealso class PythonCodeDirective(Directive): """ A custom restructuredtext directive which can be used to display syntax-highlighted Python code blocks. This directive takes no arguments, and the body should contain only Python code. This directive can be used instead of doctest blocks when it is inconvenient to list prompts on each line, or when you would prefer that the output not contain prompts (e.g., to make copy/paste easier). """ has_content = True def run(self) -> List[nodes.Node]: text = '\n'.join(self.content) node = nodes.doctest_block(text, text, codeblock=True) return [ node ] class DocutilsAndSphinxCodeBlockAdapter(PythonCodeDirective): # Docutils and Sphinx code blocks have both one optional argument, # so we accept it here as well but do nothing with it. required_arguments = 0 optional_arguments = 1 # Listing all options that docutils.parsers.rst.directives.body.CodeBlock provides # And also sphinx.directives.code.CodeBlock. We don't care about their values, # we just don't want to see them in self.content. option_spec = {'class': directives.class_option, 'name': directives.unchanged, 'number-lines': directives.unchanged, # integer or None 'force': directives.flag, 'linenos': directives.flag, 'dedent': directives.unchanged, # integer or None 'lineno-start': int, 'emphasize-lines': directives.unchanged_required, 'caption': directives.unchanged_required, } directives.register_directive('python', PythonCodeDirective) directives.register_directive('code', DocutilsAndSphinxCodeBlockAdapter) directives.register_directive('code-block', DocutilsAndSphinxCodeBlockAdapter) directives.register_directive('versionadded', VersionChange) directives.register_directive('versionchanged', VersionChange) directives.register_directive('deprecated', VersionChange) directives.register_directive('seealso', SeeAlso) pydoctor-24.11.2/pydoctor/epydoc/sre_constants36.py000066400000000000000000000154141473665144200223030ustar00rootroot00000000000000# Code copied from Python 3.6 - Python Software Foundation - GNU General Public License v3.0 # # Secret Labs' Regular Expression Engine # # various symbols used by the regular expression engine. # run this script to update the _sre include files! # # Copyright (c) 1998-2001 by Secret Labs AB. All rights reserved. # # See the sre.py file for information on usage and redistribution. # """Internal support module for sre""" # update when constants are added or removed MAGIC = 20140917 _MAXREPEAT = 4294967295 MAXGROUPS = 2147483647 # SRE standard exception (access as sre.error) # should this really be here? class error(Exception): """Exception raised for invalid regular expressions. Attributes: msg: The unformatted error message pattern: The regular expression pattern pos: The index in the pattern where compilation failed (may be None) lineno: The line corresponding to pos (may be None) colno: The column corresponding to pos (may be None) """ def __init__(self, msg, pattern=None, pos=None): self.msg = msg self.pattern = pattern self.pos = pos if pattern is not None and pos is not None: msg = '%s at position %d' % (msg, pos) if isinstance(pattern, str): newline = '\n' else: newline = b'\n' self.lineno = pattern.count(newline, 0, pos) + 1 self.colno = pos - pattern.rfind(newline, 0, pos) if newline in pattern: msg = '%s (line %d, column %d)' % (msg, self.lineno, self.colno) else: self.lineno = self.colno = None super().__init__(msg) class _NamedIntConstant(int): def __new__(cls, value, name): self = super(_NamedIntConstant, cls).__new__(cls, value) self.name = name return self def __str__(self): return self.name __repr__ = __str__ MAXREPEAT = _NamedIntConstant(_MAXREPEAT, 'MAXREPEAT') def _makecodes(names): names = names.strip().split() items = [_NamedIntConstant(i, name) for i, name in enumerate(names)] globals().update({item.name: item for item in items}) return items # operators # failure=0 success=1 (just because it looks better that way :-) OPCODES = _makecodes(""" FAILURE SUCCESS ANY ANY_ALL ASSERT ASSERT_NOT AT BRANCH CALL CATEGORY CHARSET BIGCHARSET GROUPREF GROUPREF_EXISTS GROUPREF_IGNORE IN IN_IGNORE INFO JUMP LITERAL LITERAL_IGNORE MARK MAX_UNTIL MIN_UNTIL NOT_LITERAL NOT_LITERAL_IGNORE NEGATE RANGE REPEAT REPEAT_ONE SUBPATTERN MIN_REPEAT_ONE RANGE_IGNORE MIN_REPEAT MAX_REPEAT """) del OPCODES[-2:] # remove MIN_REPEAT and MAX_REPEAT # positions ATCODES = _makecodes(""" AT_BEGINNING AT_BEGINNING_LINE AT_BEGINNING_STRING AT_BOUNDARY AT_NON_BOUNDARY AT_END AT_END_LINE AT_END_STRING AT_LOC_BOUNDARY AT_LOC_NON_BOUNDARY AT_UNI_BOUNDARY AT_UNI_NON_BOUNDARY """) # categories CHCODES = _makecodes(""" CATEGORY_DIGIT CATEGORY_NOT_DIGIT CATEGORY_SPACE CATEGORY_NOT_SPACE CATEGORY_WORD CATEGORY_NOT_WORD CATEGORY_LINEBREAK CATEGORY_NOT_LINEBREAK CATEGORY_LOC_WORD CATEGORY_LOC_NOT_WORD CATEGORY_UNI_DIGIT CATEGORY_UNI_NOT_DIGIT CATEGORY_UNI_SPACE CATEGORY_UNI_NOT_SPACE CATEGORY_UNI_WORD CATEGORY_UNI_NOT_WORD CATEGORY_UNI_LINEBREAK CATEGORY_UNI_NOT_LINEBREAK """) # replacement operations for "ignore case" mode OP_IGNORE = { GROUPREF: GROUPREF_IGNORE, IN: IN_IGNORE, LITERAL: LITERAL_IGNORE, NOT_LITERAL: NOT_LITERAL_IGNORE, RANGE: RANGE_IGNORE, } AT_MULTILINE = { AT_BEGINNING: AT_BEGINNING_LINE, AT_END: AT_END_LINE } AT_LOCALE = { AT_BOUNDARY: AT_LOC_BOUNDARY, AT_NON_BOUNDARY: AT_LOC_NON_BOUNDARY } AT_UNICODE = { AT_BOUNDARY: AT_UNI_BOUNDARY, AT_NON_BOUNDARY: AT_UNI_NON_BOUNDARY } CH_LOCALE = { CATEGORY_DIGIT: CATEGORY_DIGIT, CATEGORY_NOT_DIGIT: CATEGORY_NOT_DIGIT, CATEGORY_SPACE: CATEGORY_SPACE, CATEGORY_NOT_SPACE: CATEGORY_NOT_SPACE, CATEGORY_WORD: CATEGORY_LOC_WORD, CATEGORY_NOT_WORD: CATEGORY_LOC_NOT_WORD, CATEGORY_LINEBREAK: CATEGORY_LINEBREAK, CATEGORY_NOT_LINEBREAK: CATEGORY_NOT_LINEBREAK } CH_UNICODE = { CATEGORY_DIGIT: CATEGORY_UNI_DIGIT, CATEGORY_NOT_DIGIT: CATEGORY_UNI_NOT_DIGIT, CATEGORY_SPACE: CATEGORY_UNI_SPACE, CATEGORY_NOT_SPACE: CATEGORY_UNI_NOT_SPACE, CATEGORY_WORD: CATEGORY_UNI_WORD, CATEGORY_NOT_WORD: CATEGORY_UNI_NOT_WORD, CATEGORY_LINEBREAK: CATEGORY_UNI_LINEBREAK, CATEGORY_NOT_LINEBREAK: CATEGORY_UNI_NOT_LINEBREAK } # flags SRE_FLAG_TEMPLATE = 1 # template mode (disable backtracking) SRE_FLAG_IGNORECASE = 2 # case insensitive SRE_FLAG_LOCALE = 4 # honour system locale SRE_FLAG_MULTILINE = 8 # treat target as multiline string SRE_FLAG_DOTALL = 16 # treat target as a single string SRE_FLAG_UNICODE = 32 # use unicode "locale" SRE_FLAG_VERBOSE = 64 # ignore whitespace and comments SRE_FLAG_DEBUG = 128 # debugging SRE_FLAG_ASCII = 256 # use ascii "locale" # flags for INFO primitive SRE_INFO_PREFIX = 1 # has prefix SRE_INFO_LITERAL = 2 # entire pattern is literal (given by prefix) SRE_INFO_CHARSET = 4 # pattern starts with character from given set if __name__ == "__main__": def dump(f, d, prefix): items = sorted(d) for item in items: f.write("#define %s_%s %d\n" % (prefix, item, item)) with open("sre_constants.h", "w") as f: f.write("""\ /* * Secret Labs' Regular Expression Engine * * regular expression matching engine * * NOTE: This file is generated by sre_constants.py. If you need * to change anything in here, edit sre_constants.py and run it. * * Copyright (c) 1997-2001 by Secret Labs AB. All rights reserved. * * See the _sre.c file for information on usage and redistribution. */ """) f.write("#define SRE_MAGIC %d\n" % MAGIC) dump(f, OPCODES, "SRE_OP") dump(f, ATCODES, "SRE") dump(f, CHCODES, "SRE") f.write("#define SRE_FLAG_TEMPLATE %d\n" % SRE_FLAG_TEMPLATE) f.write("#define SRE_FLAG_IGNORECASE %d\n" % SRE_FLAG_IGNORECASE) f.write("#define SRE_FLAG_LOCALE %d\n" % SRE_FLAG_LOCALE) f.write("#define SRE_FLAG_MULTILINE %d\n" % SRE_FLAG_MULTILINE) f.write("#define SRE_FLAG_DOTALL %d\n" % SRE_FLAG_DOTALL) f.write("#define SRE_FLAG_UNICODE %d\n" % SRE_FLAG_UNICODE) f.write("#define SRE_FLAG_VERBOSE %d\n" % SRE_FLAG_VERBOSE) f.write("#define SRE_FLAG_DEBUG %d\n" % SRE_FLAG_DEBUG) f.write("#define SRE_FLAG_ASCII %d\n" % SRE_FLAG_ASCII) f.write("#define SRE_INFO_PREFIX %d\n" % SRE_INFO_PREFIX) f.write("#define SRE_INFO_LITERAL %d\n" % SRE_INFO_LITERAL) f.write("#define SRE_INFO_CHARSET %d\n" % SRE_INFO_CHARSET) print("done") pydoctor-24.11.2/pydoctor/epydoc/sre_parse36.py000066400000000000000000001124111473665144200213740ustar00rootroot00000000000000# Code copied from Python 3.6 - Python Software Foundation - GNU General Public License v3.0 # # The motivation to add the ``sre_parse36`` module is to provide a # colorizer for regular expressions that produce the *same* expression # as initially provided (the way epydoc did it). # It's packaged with pydoctor for the simplicity of not having to install another requirement form PyPi. # # The handling of non-capturing groups changed from Python 3.7, we can't # back reproduce the original regular expression from a ``SubPattern`` # instance anymore. This regression is tracked at https://bugs.python.org/issue45674. # It seems that it won't be fixed. # # The the issue is that in Python 3.7 and beyond, it not possible to # differentiate capturing groups and non-capturing from a ``SubPattern`` # intance. # # Demontration: # ```python # >>> import sre_parse # >>> sre_parse.parse("(?:foo (?:bar) | (?:baz))").dump() # BRANCH # LITERAL 102 # LITERAL 111 # LITERAL 111 # LITERAL 32 # LITERAL 98 # LITERAL 97 # LITERAL 114 # LITERAL 32 # OR # LITERAL 32 # LITERAL 98 # LITERAL 97 # LITERAL 122 # ``` # # Whereas in Python 3.6: # # ```python # >>> import sre_parse # >>> sre_parse.parse("(?:foo (?:bar) | (?:baz))").dump() # SUBPATTERN None 0 0 # BRANCH # LITERAL 102 # LITERAL 111 # LITERAL 111 # LITERAL 32 # SUBPATTERN None 0 0 # LITERAL 98 # LITERAL 97 # LITERAL 114 # LITERAL 32 # OR # LITERAL 32 # SUBPATTERN None 0 0 # LITERAL 98 # LITERAL 97 # LITERAL 122 # ``` # # ------------------------------- # # # Secret Labs' Regular Expression Engine # # convert re-style regular expression to sre pattern # # Copyright (c) 1998-2001 by Secret Labs AB. All rights reserved. # # See the sre.py file for information on usage and redistribution. # """Internal support module for sre""" # XXX: show string offset and offending character for all errors from .sre_constants36 import * SPECIAL_CHARS = ".\\[{()*+?^$|" REPEAT_CHARS = "*+?{" DIGITS = frozenset("0123456789") OCTDIGITS = frozenset("01234567") HEXDIGITS = frozenset("0123456789abcdefABCDEF") ASCIILETTERS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") WHITESPACE = frozenset(" \t\n\r\v\f") _REPEATCODES = frozenset({MIN_REPEAT, MAX_REPEAT}) _UNITCODES = frozenset({ANY, RANGE, IN, LITERAL, NOT_LITERAL, CATEGORY}) ESCAPES = { r"\a": (LITERAL, ord("\a")), r"\b": (LITERAL, ord("\b")), r"\f": (LITERAL, ord("\f")), r"\n": (LITERAL, ord("\n")), r"\r": (LITERAL, ord("\r")), r"\t": (LITERAL, ord("\t")), r"\v": (LITERAL, ord("\v")), r"\\": (LITERAL, ord("\\")) } CATEGORIES = { r"\A": (AT, AT_BEGINNING_STRING), # start of string r"\b": (AT, AT_BOUNDARY), r"\B": (AT, AT_NON_BOUNDARY), r"\d": (IN, [(CATEGORY, CATEGORY_DIGIT)]), r"\D": (IN, [(CATEGORY, CATEGORY_NOT_DIGIT)]), r"\s": (IN, [(CATEGORY, CATEGORY_SPACE)]), r"\S": (IN, [(CATEGORY, CATEGORY_NOT_SPACE)]), r"\w": (IN, [(CATEGORY, CATEGORY_WORD)]), r"\W": (IN, [(CATEGORY, CATEGORY_NOT_WORD)]), r"\Z": (AT, AT_END_STRING), # end of string } FLAGS = { # standard flags "i": SRE_FLAG_IGNORECASE, "L": SRE_FLAG_LOCALE, "m": SRE_FLAG_MULTILINE, "s": SRE_FLAG_DOTALL, "x": SRE_FLAG_VERBOSE, # extensions "a": SRE_FLAG_ASCII, "t": SRE_FLAG_TEMPLATE, "u": SRE_FLAG_UNICODE, } GLOBAL_FLAGS = (SRE_FLAG_ASCII | SRE_FLAG_LOCALE | SRE_FLAG_UNICODE | SRE_FLAG_DEBUG | SRE_FLAG_TEMPLATE) class Verbose(Exception): pass class Pattern: # master pattern object. keeps track of global attributes def __init__(self): self.flags = 0 self.groupdict = {} self.groupwidths = [None] # group 0 self.lookbehindgroups = None @property def groups(self): return len(self.groupwidths) def opengroup(self, name=None): gid = self.groups self.groupwidths.append(None) if self.groups > MAXGROUPS: raise error("too many groups") if name is not None: ogid = self.groupdict.get(name, None) if ogid is not None: raise error("redefinition of group name %r as group %d; " "was group %d" % (name, gid, ogid)) self.groupdict[name] = gid return gid def closegroup(self, gid, p): self.groupwidths[gid] = p.getwidth() def checkgroup(self, gid): return gid < self.groups and self.groupwidths[gid] is not None def checklookbehindgroup(self, gid, source): if self.lookbehindgroups is not None: if not self.checkgroup(gid): raise source.error('cannot refer to an open group') if gid >= self.lookbehindgroups: raise source.error('cannot refer to group defined in the same ' 'lookbehind subpattern') class SubPattern: # a subpattern, in intermediate form def __init__(self, pattern, data=None): self.pattern = pattern if data is None: data = [] self.data = data self.width = None def dump(self, level=0): nl = True seqtypes = (tuple, list) for op, av in self.data: print(level*" " + str(op), end='') if op is IN: # member sublanguage print() for op, a in av: print((level+1)*" " + str(op), a) elif op is BRANCH: print() for i, a in enumerate(av[1]): if i: print(level*" " + "OR") a.dump(level+1) elif op is GROUPREF_EXISTS: condgroup, item_yes, item_no = av print('', condgroup) item_yes.dump(level+1) if item_no: print(level*" " + "ELSE") item_no.dump(level+1) elif isinstance(av, seqtypes): nl = False for a in av: if isinstance(a, SubPattern): if not nl: print() a.dump(level+1) nl = True else: if not nl: print(' ', end='') print(a, end='') nl = False if not nl: print() else: print('', av) def __repr__(self): return repr(self.data) def __len__(self): return len(self.data) def __delitem__(self, index): del self.data[index] def __getitem__(self, index): if isinstance(index, slice): return SubPattern(self.pattern, self.data[index]) return self.data[index] def __setitem__(self, index, code): self.data[index] = code def insert(self, index, code): self.data.insert(index, code) def append(self, code): self.data.append(code) def getwidth(self): # determine the width (min, max) for this subpattern if self.width is not None: return self.width lo = hi = 0 for op, av in self.data: if op is BRANCH: i = MAXREPEAT - 1 j = 0 for av in av[1]: l, h = av.getwidth() i = min(i, l) j = max(j, h) lo = lo + i hi = hi + j elif op is CALL: i, j = av.getwidth() lo = lo + i hi = hi + j elif op is SUBPATTERN: i, j = av[-1].getwidth() lo = lo + i hi = hi + j elif op in _REPEATCODES: i, j = av[2].getwidth() lo = lo + i * av[0] hi = hi + j * av[1] elif op in _UNITCODES: lo = lo + 1 hi = hi + 1 elif op is GROUPREF: i, j = self.pattern.groupwidths[av] lo = lo + i hi = hi + j elif op is GROUPREF_EXISTS: i, j = av[1].getwidth() if av[2] is not None: l, h = av[2].getwidth() i = min(i, l) j = max(j, h) else: i = 0 lo = lo + i hi = hi + j elif op is SUCCESS: break self.width = min(lo, MAXREPEAT - 1), min(hi, MAXREPEAT) return self.width class Tokenizer: def __init__(self, string): self.istext = isinstance(string, str) self.string = string if not self.istext: string = str(string, 'latin1') self.decoded_string = string self.index = 0 self.next = None self.__next() def __next(self): index = self.index try: char = self.decoded_string[index] except IndexError: self.next = None return if char == "\\": index += 1 try: char += self.decoded_string[index] except IndexError: raise error("bad escape (end of pattern)", self.string, len(self.string) - 1) from None self.index = index + 1 self.next = char def match(self, char): if char == self.next: self.__next() return True return False def get(self): this = self.next self.__next() return this def getwhile(self, n, charset): result = '' for _ in range(n): c = self.next if c not in charset: break result += c self.__next() return result def getuntil(self, terminator): result = '' while True: c = self.next self.__next() if c is None: if not result: raise self.error("missing group name") raise self.error("missing %s, unterminated name" % terminator, len(result)) if c == terminator: if not result: raise self.error("missing group name", 1) break result += c return result @property def pos(self): return self.index - len(self.next or '') def tell(self): return self.index - len(self.next or '') def seek(self, index): self.index = index self.__next() def error(self, msg, offset=0): return error(msg, self.string, self.tell() - offset) def _class_escape(source, escape): # handle escape code inside character class code = ESCAPES.get(escape) if code: return code code = CATEGORIES.get(escape) if code and code[0] is IN: return code try: c = escape[1:2] if c == "x": # hexadecimal escape (exactly two digits) escape += source.getwhile(2, HEXDIGITS) if len(escape) != 4: raise source.error("incomplete escape %s" % escape, len(escape)) return LITERAL, int(escape[2:], 16) elif c == "u" and source.istext: # unicode escape (exactly four digits) escape += source.getwhile(4, HEXDIGITS) if len(escape) != 6: raise source.error("incomplete escape %s" % escape, len(escape)) return LITERAL, int(escape[2:], 16) elif c == "U" and source.istext: # unicode escape (exactly eight digits) escape += source.getwhile(8, HEXDIGITS) if len(escape) != 10: raise source.error("incomplete escape %s" % escape, len(escape)) c = int(escape[2:], 16) chr(c) # raise ValueError for invalid code return LITERAL, c elif c in OCTDIGITS: # octal escape (up to three digits) escape += source.getwhile(2, OCTDIGITS) c = int(escape[1:], 8) if c > 0o377: raise source.error('octal escape value %s outside of ' 'range 0-0o377' % escape, len(escape)) return LITERAL, c elif c in DIGITS: raise ValueError if len(escape) == 2: if c in ASCIILETTERS: raise source.error('bad escape %s' % escape, len(escape)) return LITERAL, ord(escape[1]) except ValueError: pass raise source.error("bad escape %s" % escape, len(escape)) def _escape(source, escape, state): # handle escape code in expression code = CATEGORIES.get(escape) if code: return code code = ESCAPES.get(escape) if code: return code try: c = escape[1:2] if c == "x": # hexadecimal escape escape += source.getwhile(2, HEXDIGITS) if len(escape) != 4: raise source.error("incomplete escape %s" % escape, len(escape)) return LITERAL, int(escape[2:], 16) elif c == "u" and source.istext: # unicode escape (exactly four digits) escape += source.getwhile(4, HEXDIGITS) if len(escape) != 6: raise source.error("incomplete escape %s" % escape, len(escape)) return LITERAL, int(escape[2:], 16) elif c == "U" and source.istext: # unicode escape (exactly eight digits) escape += source.getwhile(8, HEXDIGITS) if len(escape) != 10: raise source.error("incomplete escape %s" % escape, len(escape)) c = int(escape[2:], 16) chr(c) # raise ValueError for invalid code return LITERAL, c elif c == "0": # octal escape escape += source.getwhile(2, OCTDIGITS) return LITERAL, int(escape[1:], 8) elif c in DIGITS: # octal escape *or* decimal group reference (sigh) if source.next in DIGITS: escape += source.get() if (escape[1] in OCTDIGITS and escape[2] in OCTDIGITS and source.next in OCTDIGITS): # got three octal digits; this is an octal escape escape += source.get() c = int(escape[1:], 8) if c > 0o377: raise source.error('octal escape value %s outside of ' 'range 0-0o377' % escape, len(escape)) return LITERAL, c # not an octal escape, so this is a group reference group = int(escape[1:]) if group < state.groups: if not state.checkgroup(group): raise source.error("cannot refer to an open group", len(escape)) state.checklookbehindgroup(group, source) return GROUPREF, group raise source.error("invalid group reference %d" % group, len(escape) - 1) if len(escape) == 2: if c in ASCIILETTERS: raise source.error("bad escape %s" % escape, len(escape)) return LITERAL, ord(escape[1]) except ValueError: pass raise source.error("bad escape %s" % escape, len(escape)) def _parse_sub(source, state, verbose, nested): # parse an alternation: a|b|c items = [] itemsappend = items.append sourcematch = source.match start = source.tell() while True: itemsappend(_parse(source, state, verbose, nested + 1, not nested and not items)) if not sourcematch("|"): break if len(items) == 1: return items[0] subpattern = SubPattern(state) subpatternappend = subpattern.append # check if all items share a common prefix while True: prefix = None for item in items: if not item: break if prefix is None: prefix = item[0] elif item[0] != prefix: break else: # all subitems start with a common "prefix". # move it out of the branch for item in items: del item[0] subpatternappend(prefix) continue # check next one break # check if the branch can be replaced by a character set for item in items: if len(item) != 1 or item[0][0] is not LITERAL: break else: # we can store this as a character set instead of a # branch (the compiler may optimize this even more) subpatternappend((IN, [item[0] for item in items])) return subpattern subpattern.append((BRANCH, (None, items))) return subpattern def _parse_sub_cond(source, state, condgroup, verbose, nested): item_yes = _parse(source, state, verbose, nested + 1) if source.match("|"): item_no = _parse(source, state, verbose, nested + 1) if source.next == "|": raise source.error("conditional backref with more than two branches") else: item_no = None subpattern = SubPattern(state) subpattern.append((GROUPREF_EXISTS, (condgroup, item_yes, item_no))) return subpattern def _parse(source, state, verbose, nested, first=False): # parse a simple pattern subpattern = SubPattern(state) # precompute constants into local variables subpatternappend = subpattern.append sourceget = source.get sourcematch = source.match _len = len _ord = ord while True: this = source.next if this is None: break # end of pattern if this in "|)": break # end of subpattern sourceget() if verbose: # skip whitespace and comments if this in WHITESPACE: continue if this == "#": while True: this = sourceget() if this is None or this == "\n": break continue if this[0] == "\\": code = _escape(source, this, state) subpatternappend(code) elif this not in SPECIAL_CHARS: subpatternappend((LITERAL, _ord(this))) elif this == "[": here = source.tell() - 1 # character set set = [] setappend = set.append ## if sourcematch(":"): ## pass # handle character classes if sourcematch("^"): setappend((NEGATE, None)) # check remaining characters start = set[:] while True: this = sourceget() if this is None: raise source.error("unterminated character set", source.tell() - here) if this == "]" and set != start: break elif this[0] == "\\": code1 = _class_escape(source, this) else: code1 = LITERAL, _ord(this) if sourcematch("-"): # potential range that = sourceget() if that is None: raise source.error("unterminated character set", source.tell() - here) if that == "]": if code1[0] is IN: code1 = code1[1][0] setappend(code1) setappend((LITERAL, _ord("-"))) break if that[0] == "\\": code2 = _class_escape(source, that) else: code2 = LITERAL, _ord(that) if code1[0] != LITERAL or code2[0] != LITERAL: msg = "bad character range %s-%s" % (this, that) raise source.error(msg, len(this) + 1 + len(that)) lo = code1[1] hi = code2[1] if hi < lo: msg = "bad character range %s-%s" % (this, that) raise source.error(msg, len(this) + 1 + len(that)) setappend((RANGE, (lo, hi))) else: if code1[0] is IN: code1 = code1[1][0] setappend(code1) # XXX: should move set optimization to compiler! if _len(set)==1 and set[0][0] is LITERAL: subpatternappend(set[0]) # optimization elif _len(set)==2 and set[0][0] is NEGATE and set[1][0] is LITERAL: subpatternappend((NOT_LITERAL, set[1][1])) # optimization else: # XXX: should add charmap optimization here subpatternappend((IN, set)) elif this in REPEAT_CHARS: # repeat previous item here = source.tell() if this == "?": min, max = 0, 1 elif this == "*": min, max = 0, MAXREPEAT elif this == "+": min, max = 1, MAXREPEAT elif this == "{": if source.next == "}": subpatternappend((LITERAL, _ord(this))) continue min, max = 0, MAXREPEAT lo = hi = "" while source.next in DIGITS: lo += sourceget() if sourcematch(","): while source.next in DIGITS: hi += sourceget() else: hi = lo if not sourcematch("}"): subpatternappend((LITERAL, _ord(this))) source.seek(here) continue if lo: min = int(lo) if min >= MAXREPEAT: raise OverflowError("the repetition number is too large") if hi: max = int(hi) if max >= MAXREPEAT: raise OverflowError("the repetition number is too large") if max < min: raise source.error("min repeat greater than max repeat", source.tell() - here) else: raise AssertionError("unsupported quantifier %r" % (char,)) # figure out which item to repeat if subpattern: item = subpattern[-1:] else: item = None if not item or (_len(item) == 1 and item[0][0] is AT): raise source.error("nothing to repeat", source.tell() - here + len(this)) if item[0][0] in _REPEATCODES: raise source.error("multiple repeat", source.tell() - here + len(this)) if sourcematch("?"): subpattern[-1] = (MIN_REPEAT, (min, max, item)) else: subpattern[-1] = (MAX_REPEAT, (min, max, item)) elif this == ".": subpatternappend((ANY, None)) elif this == "(": start = source.tell() - 1 group = True name = None condgroup = None add_flags = 0 del_flags = 0 if sourcematch("?"): # options char = sourceget() if char is None: raise source.error("unexpected end of pattern") if char == "P": # python extensions if sourcematch("<"): # named group: skip forward to end of name name = source.getuntil(">") if not name.isidentifier(): msg = "bad character in group name %r" % name raise source.error(msg, len(name) + 1) elif sourcematch("="): # named backreference name = source.getuntil(")") if not name.isidentifier(): msg = "bad character in group name %r" % name raise source.error(msg, len(name) + 1) gid = state.groupdict.get(name) if gid is None: msg = "unknown group name %r" % name raise source.error(msg, len(name) + 1) if not state.checkgroup(gid): raise source.error("cannot refer to an open group", len(name) + 1) state.checklookbehindgroup(gid, source) subpatternappend((GROUPREF, gid)) continue else: char = sourceget() if char is None: raise source.error("unexpected end of pattern") raise source.error("unknown extension ?P" + char, len(char) + 2) elif char == ":": # non-capturing group group = None elif char == "#": # comment while True: if source.next is None: raise source.error("missing ), unterminated comment", source.tell() - start) if sourceget() == ")": break continue elif char in "=!<": # lookahead assertions dir = 1 if char == "<": char = sourceget() if char is None: raise source.error("unexpected end of pattern") if char not in "=!": raise source.error("unknown extension ?<" + char, len(char) + 2) dir = -1 # lookbehind lookbehindgroups = state.lookbehindgroups if lookbehindgroups is None: state.lookbehindgroups = state.groups p = _parse_sub(source, state, verbose, nested + 1) if dir < 0: if lookbehindgroups is None: state.lookbehindgroups = None if not sourcematch(")"): raise source.error("missing ), unterminated subpattern", source.tell() - start) if char == "=": subpatternappend((ASSERT, (dir, p))) else: subpatternappend((ASSERT_NOT, (dir, p))) continue elif char == "(": # conditional backreference group condname = source.getuntil(")") group = None if condname.isidentifier(): condgroup = state.groupdict.get(condname) if condgroup is None: msg = "unknown group name %r" % condname raise source.error(msg, len(condname) + 1) else: try: condgroup = int(condname) if condgroup < 0: raise ValueError except ValueError: msg = "bad character in group name %r" % condname raise source.error(msg, len(condname) + 1) from None if not condgroup: raise source.error("bad group number", len(condname) + 1) if condgroup >= MAXGROUPS: msg = "invalid group reference %d" % condgroup raise source.error(msg, len(condname) + 1) state.checklookbehindgroup(condgroup, source) elif char in FLAGS or char == "-": # flags flags = _parse_flags(source, state, char) if flags is None: # global flags if not first or subpattern: import warnings warnings.warn( 'Flags not at the start of the expression %r%s' % ( source.string[:20], # truncate long regexes ' (truncated)' if len(source.string) > 20 else '', ), DeprecationWarning, stacklevel=nested + 6 ) if (state.flags & SRE_FLAG_VERBOSE) and not verbose: raise Verbose continue add_flags, del_flags = flags group = None else: raise source.error("unknown extension ?" + char, len(char) + 1) # parse group contents if group is not None: try: group = state.opengroup(name) except error as err: raise source.error(err.msg, len(name) + 1) from None if condgroup: p = _parse_sub_cond(source, state, condgroup, verbose, nested + 1) else: sub_verbose = ((verbose or (add_flags & SRE_FLAG_VERBOSE)) and not (del_flags & SRE_FLAG_VERBOSE)) p = _parse_sub(source, state, sub_verbose, nested + 1) if not source.match(")"): raise source.error("missing ), unterminated subpattern", source.tell() - start) if group is not None: state.closegroup(group, p) subpatternappend((SUBPATTERN, (group, add_flags, del_flags, p))) elif this == "^": subpatternappend((AT, AT_BEGINNING)) elif this == "$": subpattern.append((AT, AT_END)) else: raise AssertionError("unsupported special character %r" % (char,)) return subpattern def _parse_flags(source, state, char): sourceget = source.get add_flags = 0 del_flags = 0 if char != "-": while True: add_flags |= FLAGS[char] char = sourceget() if char is None: raise source.error("missing -, : or )") if char in ")-:": break if char not in FLAGS: msg = "unknown flag" if char.isalpha() else "missing -, : or )" raise source.error(msg, len(char)) if char == ")": state.flags |= add_flags return None if add_flags & GLOBAL_FLAGS: raise source.error("bad inline flags: cannot turn on global flag", 1) if char == "-": char = sourceget() if char is None: raise source.error("missing flag") if char not in FLAGS: msg = "unknown flag" if char.isalpha() else "missing flag" raise source.error(msg, len(char)) while True: del_flags |= FLAGS[char] char = sourceget() if char is None: raise source.error("missing :") if char == ":": break if char not in FLAGS: msg = "unknown flag" if char.isalpha() else "missing :" raise source.error(msg, len(char)) assert char == ":" if del_flags & GLOBAL_FLAGS: raise source.error("bad inline flags: cannot turn off global flag", 1) if add_flags & del_flags: raise source.error("bad inline flags: flag turned on and off", 1) return add_flags, del_flags def fix_flags(src, flags): # Check and fix flags according to the type of pattern (str or bytes) if isinstance(src, str): if flags & SRE_FLAG_LOCALE: raise ValueError("cannot use LOCALE flag with a str pattern") if not flags & SRE_FLAG_ASCII: flags |= SRE_FLAG_UNICODE elif flags & SRE_FLAG_UNICODE: raise ValueError("ASCII and UNICODE flags are incompatible") else: if flags & SRE_FLAG_UNICODE: raise ValueError("cannot use UNICODE flag with a bytes pattern") if flags & SRE_FLAG_LOCALE and flags & SRE_FLAG_ASCII: raise ValueError("ASCII and LOCALE flags are incompatible") return flags def parse(str, flags=0, pattern=None): # parse 're' pattern into list of (opcode, argument) tuples source = Tokenizer(str) if pattern is None: pattern = Pattern() pattern.flags = flags pattern.str = str try: p = _parse_sub(source, pattern, flags & SRE_FLAG_VERBOSE, 0) except Verbose: # the VERBOSE flag was switched on inside the pattern. to be # on the safe side, we'll parse the whole thing again... pattern = Pattern() pattern.flags = flags | SRE_FLAG_VERBOSE pattern.str = str source.seek(0) p = _parse_sub(source, pattern, True, 0) p.pattern.flags = fix_flags(str, p.pattern.flags) if source.next is not None: assert source.next == ")" raise source.error("unbalanced parenthesis") if flags & SRE_FLAG_DEBUG: p.dump() return p def parse_template(source, pattern): # parse 're' replacement string into list of literals and # group references s = Tokenizer(source) sget = s.get groups = [] literals = [] literal = [] lappend = literal.append def addgroup(index, pos): if index > pattern.groups: raise s.error("invalid group reference %d" % index, pos) if literal: literals.append(''.join(literal)) del literal[:] groups.append((len(literals), index)) literals.append(None) groupindex = pattern.groupindex while True: this = sget() if this is None: break # end of replacement string if this[0] == "\\": # group c = this[1] if c == "g": name = "" if not s.match("<"): raise s.error("missing <") name = s.getuntil(">") if name.isidentifier(): try: index = groupindex[name] except KeyError: raise IndexError("unknown group name %r" % name) else: try: index = int(name) if index < 0: raise ValueError except ValueError: raise s.error("bad character in group name %r" % name, len(name) + 1) from None if index >= MAXGROUPS: raise s.error("invalid group reference %d" % index, len(name) + 1) addgroup(index, len(name) + 1) elif c == "0": if s.next in OCTDIGITS: this += sget() if s.next in OCTDIGITS: this += sget() lappend(chr(int(this[1:], 8) & 0xff)) elif c in DIGITS: isoctal = False if s.next in DIGITS: this += sget() if (c in OCTDIGITS and this[2] in OCTDIGITS and s.next in OCTDIGITS): this += sget() isoctal = True c = int(this[1:], 8) if c > 0o377: raise s.error('octal escape value %s outside of ' 'range 0-0o377' % this, len(this)) lappend(chr(c)) if not isoctal: addgroup(int(this[1:]), len(this) - 1) else: try: this = chr(ESCAPES[this][1]) except KeyError: if c in ASCIILETTERS: import warnings warnings.warn('bad escape %s' % this, DeprecationWarning, stacklevel=4) lappend(this) else: lappend(this) if literal: literals.append(''.join(literal)) if not isinstance(source, str): # The tokenizer implicitly decodes bytes objects as latin-1, we must # therefore re-encode the final representation. literals = [None if s is None else s.encode('latin-1') for s in literals] return groups, literals def expand_template(template, match): g = match.group empty = match.string[:0] groups, literals = template literals = literals[:] try: for index, group in groups: literals[index] = g(group) or empty except IndexError: raise error("invalid group reference %d" % index) return empty.join(literals) pydoctor-24.11.2/pydoctor/epydoc2stan.py000066400000000000000000001274171473665144200202240ustar00rootroot00000000000000""" Convert L{pydoctor.epydoc} parsed markup into renderable content. """ from __future__ import annotations from collections import defaultdict import enum from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, Dict, Generator, Iterator, List, Mapping, Optional, Sequence, Tuple, Union, ) import ast import re import attr from docutils import nodes from pydoctor import model, linker, node2stan from pydoctor.astutils import is_none_literal from pydoctor.epydoc.docutils import new_document, set_node_attributes from pydoctor.epydoc.markup import Field as EpydocField, ParseError, get_parser_by_name, processtypes from twisted.web.template import Tag, tags from pydoctor.epydoc.markup import ParsedDocstring, DocstringLinker, ObjClass import pydoctor.epydoc.markup.plaintext from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring from pydoctor.epydoc.markup._pyval_repr import colorize_pyval, colorize_inline_pyval if TYPE_CHECKING: from twisted.web.template import Flattenable taglink = linker.taglink """ Alias to L{pydoctor.linker.taglink()}. """ BROKEN = tags.p(class_="undocumented")('Broken description') def _get_docformat(obj: model.Documentable) -> str: """ Returns the docformat to use to parse the docstring of this object. """ # Use module's __docformat__ if specified, else use system's. # Except if system's docformat is plaintext, in this case, use plaintext. # See https://github.com/twisted/pydoctor/issues/503 for the reason # of this behavior. if obj.system.options.docformat == 'plaintext': return 'plaintext' # the docstring should be parsed using the format of the module it was inherited from docformat = obj.module.docformat or obj.system.options.docformat return docformat @attr.s(auto_attribs=True) class FieldDesc: """ Combines informations from multiple L{Field} objects into one. Example:: :param foo: description of parameter foo :type foo: SomeClass """ _UNDOCUMENTED: ClassVar[Tag] = tags.span(class_='undocumented')("Undocumented") name: Optional[str] = None """Field name, i.e. C{:param :}""" type: Optional[Tag] = None """Formatted type""" body: Optional[Tag] = None def format(self) -> Generator[Tag, None, None]: """ @return: Iterator that yields one or two C{tags.td}. """ formatted = self.body or self._UNDOCUMENTED fieldNameTd: List[Tag] = [] if self.name: # Add the stars to the params names just before generating the field stan, not before. if isinstance(self.name, VariableArgument): prefix = "*" elif isinstance(self.name, KeywordArgument): prefix = "**" else: prefix = "" name = tags.transparent(prefix, insert_break_points(self.name)) stan_name = tags.span(class_="fieldArg")(name) if self.type: stan_name(":") fieldNameTd.append(stan_name) if self.type: fieldNameTd.append(self.type) if fieldNameTd: # : | yield tags.td(class_="fieldArgContainer")(*fieldNameTd) yield tags.td(class_="fieldArgDesc")(formatted) else: # yield tags.td(formatted, colspan="2") @attr.s(auto_attribs=True) class _SignatureDesc(FieldDesc): type_origin: Optional['FieldOrigin'] = None def is_documented(self) -> bool: return bool(self.body or self.type_origin is FieldOrigin.FROM_DOCSTRING) @attr.s(auto_attribs=True) class ReturnDesc(_SignatureDesc):... @attr.s(auto_attribs=True) class ParamDesc(_SignatureDesc):... @attr.s(auto_attribs=True) class KeywordDesc(_SignatureDesc):... class RaisesDesc(FieldDesc): """Description of an exception that can be raised by function/method.""" def format(self) -> Generator[Tag, None, None]: assert self.type is not None # TODO: Why can't it be None? yield tags.td(tags.code(self.type), class_="fieldArgContainer") yield tags.td(self.body or self._UNDOCUMENTED) def format_desc_list(label: str, descs: Sequence[FieldDesc]) -> Iterator[Tag]: """ Format list of L{FieldDesc}. Used for param, returns, raises, etc. Generates a 2-columns layout as follow:: +------------------------------------+ |

tags, start at

# h1 is reserved for the page nodes.title. self.section_level += 1 # Handle interpreted text (crossreferences) def visit_title_reference(self, node: nodes.title_reference) -> None: lineno = get_lineno(node) self._handle_reference(node, link_func=lambda target, label: self._linker.link_xref(target, label, lineno)) # Handle internal references def visit_obj_reference(self, node: obj_reference) -> None: self._handle_reference(node, link_func=self._linker.link_to) def _handle_reference(self, node: nodes.title_reference, link_func: Callable[[str, "Flattenable"], "Flattenable"]) -> None: label: "Flattenable" if 'refuri' in node.attributes: # Epytext parsed or manually constructed nodes. label, target = node2stan(node.children, self._linker), node.attributes['refuri'] else: # RST parsed. m = _TARGET_RE.match(node.astext()) if m: label, target = m.groups() else: label = target = node.astext() # Support linking to functions and methods with () at the end if target.endswith('()'): target = target[:len(target)-2] self.body.append(flatten(link_func(target, label))) raise nodes.SkipNode() def should_be_compact_paragraph(self, node: nodes.Element) -> bool: if self.document.children == [node]: return True else: return super().should_be_compact_paragraph(node) # type: ignore[no-any-return] def visit_document(self, node: nodes.document) -> None: pass def depart_document(self, node: nodes.document) -> None: pass def starttag(self, node: nodes.Node, tagname: str, suffix: str = '\n', **attributes: Any) -> str: """ This modified version of starttag makes a few changes to HTML tags, to prevent them from conflicting with epydoc. In particular: - existing class attributes are prefixed with C{'rst-'} - existing names are prefixed with C{'rst-'} - hrefs starting with C{'#'} are prefixed with C{'rst-'} - hrefs not starting with C{'#'} are given target='_top' - all headings (C{}) are given the css class C{'heading'} """ to_list_names = {'name':'names', 'id':'ids', 'class':'classes'} # Get the list of all attribute dictionaries we need to munge. attr_dicts = [attributes] if isinstance(node, nodes.Element): attr_dicts.append(node.attributes) if isinstance(node, dict): attr_dicts.append(node) # Munge each attribute dictionary. Unfortunately, we need to # iterate through attributes one at a time because some # versions of docutils don't case-normalize attributes. for attr_dict in attr_dicts: # Prefix all CSS classes with "rst-"; and prefix all # names with "rst-" to avoid conflicts. done = set() for key, val in tuple(attr_dict.items()): if key.lower() in ('class', 'id', 'name'): list_key = to_list_names[key.lower()] attr_dict[list_key] = [f'rst-{cls}' if not cls.startswith('rst-') else cls for cls in sorted(chain(val.split(), attr_dict.get(list_key, ())))] del attr_dict[key] done.add(list_key) for key, val in tuple(attr_dict.items()): if key.lower() in ('classes', 'ids', 'names') and key.lower() not in done: attr_dict[key] = [f'rst-{cls}' if not cls.startswith('rst-') else cls for cls in sorted(val)] elif key.lower() == 'href': if attr_dict[key][:1]=='#': href = attr_dict[key][1:] # We check that the class doesn't alrealy start with "rst-" if not href.startswith('rst-'): attr_dict[key] = f'#rst-{href}' else: # If it's an external link, open it in a new # page. attr_dict['target'] = '_top' # For headings, use class="heading" if re.match(r'^h\d+$', tagname): attributes['class'] = ' '.join([attributes.get('class',''), 'heading']).strip() return super().starttag(node, tagname, suffix, **attributes) # type: ignore[no-any-return] def visit_doctest_block(self, node: nodes.doctest_block) -> None: pysrc = node[0].astext() if node.get('codeblock'): self.body.append(flatten(colorize_codeblock(pysrc))) else: self.body.append(flatten(colorize_doctest(pysrc))) raise nodes.SkipNode() # Other ressources on how to extend docutils: # https://docutils.sourceforge.io/docs/user/tools.html # https://docutils.sourceforge.io/docs/dev/hacking.html # https://docutils.sourceforge.io/docs/howto/rst-directives.html # docutils apidocs: # http://code.nabla.net/doc/docutils/api/docutils.html#package-structure # this part of the HTMLTranslator is based on sphinx's HTMLTranslator: # https://github.com/sphinx-doc/sphinx/blob/3.x/sphinx/writers/html.py#L271 def _visit_admonition(self, node: nodes.Element, name: str) -> None: self.body.append(self.starttag( node, 'div', CLASS=('admonition ' + _valid_identifier(name)))) node.insert(0, nodes.title(name, name.title())) self.set_first_last(node) def visit_note(self, node: nodes.Element) -> None: self._visit_admonition(node, 'note') def depart_note(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_warning(self, node: nodes.Element) -> None: self._visit_admonition(node, 'warning') def depart_warning(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_attention(self, node: nodes.Element) -> None: self._visit_admonition(node, 'attention') def depart_attention(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_caution(self, node: nodes.Element) -> None: self._visit_admonition(node, 'caution') def depart_caution(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_danger(self, node: nodes.Element) -> None: self._visit_admonition(node, 'danger') def depart_danger(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_error(self, node: nodes.Element) -> None: self._visit_admonition(node, 'error') def depart_error(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_hint(self, node: nodes.Element) -> None: self._visit_admonition(node, 'hint') def depart_hint(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_important(self, node: nodes.Element) -> None: self._visit_admonition(node, 'important') def depart_important(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_tip(self, node: nodes.Element) -> None: self._visit_admonition(node, 'tip') def depart_tip(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_wbr(self, node: nodes.Node) -> None: self.body.append('') def depart_wbr(self, node: nodes.Node) -> None: pass def visit_seealso(self, node: nodes.Element) -> None: self._visit_admonition(node, 'see also') def depart_seealso(self, node: nodes.Element) -> None: self.depart_admonition(node) def visit_versionmodified(self, node: nodes.Element) -> None: self.body.append(self.starttag(node, 'div', CLASS=node['type'])) def depart_versionmodified(self, node: nodes.Node) -> None: self.body.append('\n') pydoctor-24.11.2/pydoctor/options.py000066400000000000000000000506471473665144200174640ustar00rootroot00000000000000""" The command-line parsing. """ from __future__ import annotations import re from typing import Sequence, List, Optional, Type, Tuple, TYPE_CHECKING import sys import functools from pathlib import Path from argparse import SUPPRESS, Namespace from configargparse import ArgumentParser import attr from pydoctor import __version__ from pydoctor.themes import get_themes from pydoctor.epydoc.markup import get_supported_docformats from pydoctor.sphinx import MAX_AGE_HELP, USER_INTERSPHINX_CACHE from pydoctor.utils import parse_path, findClassFromDottedName, parse_privacy_tuple, error from pydoctor._configparser import CompositeConfigParser, IniConfigParser, TomlConfigParser, ValidatorParser if TYPE_CHECKING: from typing import Literal from pydoctor import model from pydoctor.templatewriter import IWriter BUILDTIME_FORMAT = '%Y-%m-%d %H:%M:%S' BUILDTIME_FORMAT_HELP = 'YYYY-mm-dd HH:MM:SS' DEFAULT_CONFIG_FILES = ['./pyproject.toml', './setup.cfg', './pydoctor.ini'] CONFIG_SECTIONS = ['tool.pydoctor', 'tool:pydoctor', 'pydoctor'] DEFAULT_SYSTEM = 'pydoctor.model.System' __all__ = ("Options", ) # CONFIGURATION PARSING PydoctorConfigParser = CompositeConfigParser( [TomlConfigParser(CONFIG_SECTIONS), IniConfigParser(CONFIG_SECTIONS, split_ml_text_to_list=True)]) # ARGUMENTS PARSING def get_parser() -> ArgumentParser: parser = ArgumentParser( prog='pydoctor', description="API doc generator.", usage="pydoctor [options] SOURCEPATH...", default_config_files=DEFAULT_CONFIG_FILES, config_file_parser_class=PydoctorConfigParser) # Add the validator to the config file parser, this is arguably a hack. parser._config_file_parser = ValidatorParser(parser._config_file_parser, parser) parser.add_argument( '-c', '--config', is_config_file=True, help=("Load config from this file (any command line" "options override settings from the file)."), metavar="PATH",) parser.add_argument( '--project-name', dest='projectname', metavar="PROJECTNAME", help=("The project name, shown at the top of each HTML page.")) parser.add_argument( '--project-version', dest='projectversion', default='', metavar='VERSION', help=( "The version of the project for which the API docs are generated. " "Defaults to empty string." )) parser.add_argument( '--project-url', dest='projecturl', metavar="URL", help=("The project url, appears in the html if given.")) parser.add_argument( '--project-base-dir', dest='projectbasedirectory', help=("Path to the base directory of the project. Source links " "will be computed based on this value."), metavar="PATH", default='.') parser.add_argument( '--testing', dest='testing', action='store_true', help=("Don't complain if the run doesn't have any effects.")) parser.add_argument( '--pdb', dest='pdb', action='store_true', help=("Like py.test's --pdb.")) parser.add_argument( '--make-html', action='store_true', dest='makehtml', default=Options.MAKE_HTML_DEFAULT, help=("Produce html output." " Enabled by default if options '--testing' or '--make-intersphinx' are not specified. ")) parser.add_argument( '--make-intersphinx', action='store_true', dest='makeintersphinx', default=False, help=("Produce (only) the objects.inv intersphinx file.")) # Used to pass sourcepath from config file parser.add_argument( '--add-package', '--add-module', action='append', dest='packages', metavar='MODPATH', default=[], help=SUPPRESS) parser.add_argument( '--prepend-package', action='store', dest='prependedpackage', help=("Pretend that all packages are within this one. " "Can be used to document part of a package."), metavar='PACKAGE') _docformat_choices = list(get_supported_docformats()) parser.add_argument( '--docformat', dest='docformat', action='store', default='epytext', choices=_docformat_choices, help=("Format used for parsing docstrings. " f"Supported values: {', '.join(_docformat_choices)}"), metavar='FORMAT') parser.add_argument('--theme', dest='theme', default='classic', choices=list(get_themes()) , help=("The theme to use when building your API documentation. "), metavar='THEME', ) parser.add_argument( '--template-dir', action='append', dest='templatedir', default=[], help=("Directory containing custom HTML templates. Can be repeated."), metavar='PATH', ) parser.add_argument( '--privacy', action='append', dest='privacy', metavar=':', default=[], help=("Set the privacy of specific objects when default rules doesn't fit the use case. " "Format: ':', where can be one of 'PUBLIC', 'PRIVATE' or " "'HIDDEN' (case insensitive), and is fnmatch-like pattern matching objects fullName. " "Pattern added last have priority over a pattern added before, but an exact match wins over a fnmatch. Can be repeated.")) parser.add_argument( '--html-subject', dest='htmlsubjects', action='append', help=("The fullName of objects to generate API docs for" " (generates everything by default)."), metavar='PACKAGE/MOD/CLASS') parser.add_argument( '--html-summary-pages', dest='htmlsummarypages', action='store_true', default=False, help=("Only generate the summary pages.")) parser.add_argument( '--html-output', dest='htmloutput', default='apidocs', help=("Directory to save HTML files to (default 'apidocs')"), metavar='PATH') parser.add_argument( '--html-writer', dest='htmlwriter', default='pydoctor.templatewriter.TemplateWriter', help=("Dotted name of HTML writer class to use (default 'pydoctor.templatewriter.TemplateWriter')."), metavar='CLASS', ) parser.add_argument( '--html-viewsource-base', dest='htmlsourcebase', help=("This should be the path to the trac browser for the top " "of the svn checkout we are documenting part of."), metavar='URL',) parser.add_argument( '--html-viewsource-template', dest='htmlsourcetemplate', help=("A format string used to generate the source link of documented objects. " "The default behaviour auto detects most common providers like Github, Bitbucket, GitLab or SourceForge. " "But in some cases you might have to override the template string, for instance to make it work with git-web, use: " '--html-viewsource-template="{mod_source_href}#n{lineno}"'), metavar='SOURCETEMPLATE', default=Options.HTML_SOURCE_TEMPLATE_DEFAULT) parser.add_argument( '--html-base-url', dest='htmlbaseurl', help=("A base URL used to include a canonical link in every html page. " "This help search engine to link to the preferred version of " "a web page to prevent duplicated or oudated content. "), default=None, metavar='BASEURL', ) parser.add_argument( '--buildtime', dest='buildtime', help=("Use the specified build time over the current time. " f"Format: {BUILDTIME_FORMAT_HELP}"), metavar='TIME') parser.add_argument( '--process-types', dest='processtypes', action='store_true', help="Process the 'type' and 'rtype' fields, add links and inline markup automatically. " "This settings should not be enabled when using google or numpy docformat because the types are always processed by default.",) parser.add_argument( '--warnings-as-errors', '-W', action='store_true', dest='warnings_as_errors', default=False, help=("Return exit code 3 on warnings.")) parser.add_argument( '--verbose', '-v', action='count', dest='verbosity', default=0, help=("Be noisier. Can be repeated for more noise.")) parser.add_argument( '--quiet', '-q', action='count', dest='quietness', default=0, help=("Be quieter.")) parser.add_argument( '--introspect-c-modules', default=False, action='store_true', help=("Import and introspect any C modules found.")) parser.add_argument( '--intersphinx', action='append', dest='intersphinx', metavar='URL_TO_OBJECTS.INV', default=[], help=( "Use Sphinx objects inventory to generate links to external " "documentation. Can be repeated.")) parser.add_argument( '--enable-intersphinx-cache', dest='enable_intersphinx_cache_deprecated', action='store_true', default=False, help=SUPPRESS ) parser.add_argument( '--disable-intersphinx-cache', dest='enable_intersphinx_cache', action='store_false', default=True, help="Disable Intersphinx cache." ) parser.add_argument( '--intersphinx-cache-path', dest='intersphinx_cache_path', default=USER_INTERSPHINX_CACHE, help="Where to cache intersphinx objects.inv files.", metavar='PATH', ) parser.add_argument( '--clear-intersphinx-cache', dest='clear_intersphinx_cache', action='store_true', default=False, help=("Clear the Intersphinx cache " "specified by --intersphinx-cache-path."), ) parser.add_argument( '--intersphinx-cache-max-age', dest='intersphinx_cache_max_age', default='1d', help=MAX_AGE_HELP, metavar='DURATION', ) parser.add_argument( '--pyval-repr-maxlines', dest='pyvalreprmaxlines', default=7, type=int, metavar='INT', help='Maxinum number of lines for a constant value representation. Use 0 for unlimited.') parser.add_argument( '--pyval-repr-linelen', dest='pyvalreprlinelen', default=80, type=int, metavar='INT', help='Maxinum number of caracters for a constant value representation line. Use 0 for unlimited.') parser.add_argument( '--sidebar-expand-depth', metavar="INT", type=int, default=1, dest='sidebarexpanddepth', help=("How many nested modules and classes should be expandable, " "first level is always expanded, nested levels can expand/collapse. Value should be 1 or greater. (default: 1)")) parser.add_argument( '--sidebar-toc-depth', metavar="INT", type=int, default=6, dest='sidebartocdepth', help=("How many nested titles should be listed in the docstring TOC " "(default: 6)")) parser.add_argument( '--no-sidebar', default=False, action='store_true', dest='nosidebar', help=("Do not generate the sidebar at all.")) parser.add_argument( '--system-class', dest='systemclass', default=DEFAULT_SYSTEM, help=("A dotted name of the class to use to make a system.")) parser.add_argument( '--cls-member-order', dest='cls_member_order', default="alphabetical", choices=["alphabetical", "source"], help=("Presentation order of class members. (default: alphabetical)")) parser.add_argument( '--mod-member-order', dest='mod_member_order', default="alphabetical", choices=["alphabetical", "source"], help=("Presentation order of module/package members. (default: alphabetical)")) parser.add_argument( '--use-hardlinks', default=False, action='store_true', dest='use_hardlinks', help=("Always copy files instead of creating a symlink (hardlinks will be automatically used if the symlink process failed).")) parser.add_argument('-V', '--version', action='version', version=f'%(prog)s {__version__}') parser.add_argument( 'sourcepath', metavar='SOURCEPATH', help=("Path to python modules/packages to document."), nargs="*", default=[], ) return parser def parse_args(args: Sequence[str]) -> Namespace: parser = get_parser() options = parser.parse_args(args) assert isinstance(options, Namespace) options.verbosity -= options.quietness _warn_deprecated_options(options) return options def _warn_deprecated_options(options: Namespace) -> None: """ Check the CLI options and warn on deprecated options. """ if options.enable_intersphinx_cache_deprecated: print("The --enable-intersphinx-cache option is deprecated; " "the cache is now enabled by default.", file=sys.stderr, flush=True) # CONVERTERS def _convert_sourcepath(l: List[str]) -> List[Path]: return list(map(functools.partial(parse_path, opt='SOURCEPATH'), l)) def _convert_templatedir(l: List[str]) -> List[Path]: return list(map(functools.partial(parse_path, opt='--template-dir'), l)) def _convert_projectbasedirectory(s: Optional[str]) -> Optional[Path]: if s: return parse_path(s, opt='--project-base-dir') else: return None def _convert_systemclass(s: str) -> Type['model.System']: try: return findClassFromDottedName(s, '--system-class', base_class='pydoctor.model.System') except ValueError as e: error(str(e)) def _convert_htmlwriter(s: str) -> Type['IWriter']: try: return findClassFromDottedName(s, '--html-writer', base_class='pydoctor.templatewriter.IWriter') except ValueError as e: error(str(e)) def _convert_privacy(l: List[str]) -> List[Tuple['model.PrivacyClass', str]]: return list(map(functools.partial(parse_privacy_tuple, opt='--privacy'), l)) def _convert_htmlbaseurl(url:str | None) -> str | None: if url and not url.endswith('/'): url += '/' return url _RECOGNIZED_SOURCE_HREF = { # Sourceforge '{mod_source_href}#l{lineno}': re.compile(r'(^https?:\/\/sourceforge\.net\/)'), # Bitbucket '{mod_source_href}#lines-{lineno}': re.compile(r'(^https?:\/\/bitbucket\.org\/)'), # Matches all other plaforms: Github, Gitlab, etc. # This match should be kept last in the list. '{mod_source_href}#L{lineno}': re.compile(r'(.*)?') } # Since we can't guess git-web platform form URL, # we have to pass the template string wih option: # --html-viewsource-template="{mod_source_href}#n{lineno}" def _get_viewsource_template(sourcebase: Optional[str]) -> str: """ Recognize several version control providers based on option C{--html-viewsource-base}. """ if not sourcebase: return '{mod_source_href}#L{lineno}' for template, regex in _RECOGNIZED_SOURCE_HREF.items(): if regex.match(sourcebase): return template else: assert False # TYPED OPTIONS CONTAINER @attr.s class Options: """ Container for all possible pydoctor options. See C{pydoctor --help} for more informations. """ MAKE_HTML_DEFAULT = object() # Avoid to define default values for config options here because it's taken care of by argparse. HTML_SOURCE_TEMPLATE_DEFAULT = object() sourcepath: List[Path] = attr.ib(converter=_convert_sourcepath) systemclass: Type['model.System'] = attr.ib(converter=_convert_systemclass) projectname: Optional[str] = attr.ib() projectversion: str = attr.ib() projecturl: Optional[str] = attr.ib() projectbasedirectory: Path = attr.ib(converter=_convert_projectbasedirectory) testing: bool = attr.ib() pdb: bool = attr.ib() # only working via driver.main() makehtml: bool = attr.ib() makeintersphinx: bool = attr.ib() prependedpackage: Optional[str] = attr.ib() docformat: str = attr.ib() theme: str = attr.ib() processtypes: bool = attr.ib() templatedir: List[Path] = attr.ib(converter=_convert_templatedir) privacy: List[Tuple['model.PrivacyClass', str]] = attr.ib(converter=_convert_privacy) htmlsubjects: Optional[List[str]] = attr.ib() htmlsummarypages: bool = attr.ib() htmloutput: str = attr.ib() # TODO: make this a Path object once https://github.com/twisted/pydoctor/pull/389/files is merged htmlwriter: Type['IWriter'] = attr.ib(converter=_convert_htmlwriter) htmlsourcebase: Optional[str] = attr.ib() htmlsourcetemplate: str = attr.ib() htmlbaseurl: str | None = attr.ib(converter=_convert_htmlbaseurl) buildtime: Optional[str] = attr.ib() warnings_as_errors: bool = attr.ib() verbosity: int = attr.ib() quietness: int = attr.ib() introspect_c_modules: bool = attr.ib() intersphinx: List[str] = attr.ib() enable_intersphinx_cache: bool = attr.ib() intersphinx_cache_path: str = attr.ib() clear_intersphinx_cache: bool = attr.ib() intersphinx_cache_max_age: str = attr.ib() pyvalreprlinelen: int = attr.ib() pyvalreprmaxlines: int = attr.ib() sidebarexpanddepth: int = attr.ib() sidebartocdepth: int = attr.ib() nosidebar: int = attr.ib() cls_member_order: 'Literal["alphabetical", "source"]' = attr.ib() mod_member_order: 'Literal["alphabetical", "source"]' = attr.ib() use_hardlinks: bool = attr.ib() def __attrs_post_init__(self) -> None: # do some validations... # check if sidebar related arguments are valid if self.sidebarexpanddepth < 1: error("Invalid --sidebar-expand-depth value." + 'The value of --sidebar-expand-depth option should be greater or equal to 1, ' 'to suppress sidebar generation all together: use --no-sidebar') if self.sidebartocdepth < 0: error("Invalid --sidebar-toc-depth value" + 'The value of --sidebar-toc-depth option should be greater or equal to 0, ' 'to suppress sidebar generation all together: use --no-sidebar') # HIGH LEVEL FACTORY METHODS @classmethod def defaults(cls,) -> 'Options': return cls.from_args([]) @classmethod def from_args(cls, args: Sequence[str]) -> 'Options': return cls.from_namespace(parse_args(args)) @classmethod def from_namespace(cls, args: Namespace) -> 'Options': argsdict = vars(args) # set correct default for --make-html if args.makehtml == cls.MAKE_HTML_DEFAULT: if not args.testing and not args.makeintersphinx: argsdict['makehtml'] = True else: argsdict['makehtml'] = False # auto-detect source link template if the default value is used. if args.htmlsourcetemplate == cls.HTML_SOURCE_TEMPLATE_DEFAULT: argsdict['htmlsourcetemplate'] = _get_viewsource_template(args.htmlsourcebase) # handle deprecated arguments argsdict['sourcepath'].extend(list(map(functools.partial(parse_path, opt='--add-package'), argsdict.pop('packages')))) # remove deprecated arguments argsdict.pop('enable_intersphinx_cache_deprecated') # remove the config argument argsdict.pop('config') return cls(**argsdict) pydoctor-24.11.2/pydoctor/qnmatch.py000066400000000000000000000044761473665144200174230ustar00rootroot00000000000000""" Provides a modified L{fnmatch} function specialized for python objects fully qualified name pattern matching. Special patterns are:: ** matches everything (recursive) * matches everything except "." (one level ony) ? matches any single character [seq] matches any character in seq [!seq] matches any char not in seq """ from __future__ import annotations import functools import re from typing import Any, Callable @functools.lru_cache(maxsize=256, typed=True) def _compile_pattern(pat: str) -> Callable[[str], Any]: res = translate(pat) return re.compile(res).match def qnmatch(name:str, pattern:str) -> bool: """Test whether C{name} matches C{pattern}. """ match = _compile_pattern(pattern) return match(name) is not None # Barely changed from https://github.com/python/cpython/blob/3.8/Lib/fnmatch.py # Not using python3.9+ version because implementation is significantly more complex. def translate(pat:str) -> str: """Translate a shell PATTERN to a regular expression. There is no way to quote meta-characters. """ i, n = 0, len(pat) res = '' while i < n: c = pat[i] i = i+1 if c == '*': # Changes begins: understands '**'. if i < n and pat[i] == '*': res = res + '.*?' i = i + 1 else: res = res + r'[^\.]*?' # Changes ends. elif c == '?': res = res + '.' elif c == '[': j = i if j < n and pat[j] == '!': j = j+1 if j < n and pat[j] == ']': j = j+1 while j < n and pat[j] != ']': j = j+1 if j >= n: res = res + '\\[' else: stuff = pat[i:j] # Changes begins: simplifications handling backslashes and hyphens not required for fully qualified names. stuff = stuff.replace('\\', r'\\') i = j+1 if stuff[0] == '!': stuff = '^' + stuff[1:] elif stuff[0] in ('^', '['): stuff = '\\' + stuff res = '%s[%s]' % (res, stuff) # Changes ends. else: res = res + re.escape(c) return r'(?s:%s)\Z' % res pydoctor-24.11.2/pydoctor/sphinx.py000066400000000000000000000311151473665144200172670ustar00rootroot00000000000000""" Support for Sphinx compatibility. """ from __future__ import annotations import logging import os import shutil import textwrap import zlib from typing import ( TYPE_CHECKING, Callable, ContextManager, Dict, IO, Iterable, Mapping, Optional, Tuple ) import platformdirs import attr import requests from cachecontrol import CacheControl from cachecontrol.caches import FileCache from cachecontrol.heuristics import ExpiresAfter if TYPE_CHECKING: from pydoctor.model import Documentable from typing_extensions import Protocol class CacheT(Protocol): def get(self, url: str) -> Optional[bytes]: ... def close(self) -> None: ... else: Documentable = object CacheT = object logger = logging.getLogger(__name__) class SphinxInventory: """ Sphinx inventory handler. """ def __init__( self, logger: Callable[..., None], project_name: Optional[str] = None ): """ @param project_name: Dummy argument. """ self._links: Dict[str, Tuple[str, str]] = {} self._logger = logger def error(self, where: str, message: str) -> None: self._logger(where, message, thresh=-1) def update(self, cache: CacheT, url: str) -> None: """ Update inventory from URL. """ parts = url.rsplit('/', 1) if len(parts) != 2: self.error( 'sphinx', 'Failed to get remote base url for %s' % (url,)) return base_url = parts[0] data = cache.get(url) if not data: self.error( 'sphinx', 'Failed to get object inventory from %s' % (url, )) return payload = self._getPayload(base_url, data) self._links.update(self._parseInventory(base_url, payload)) def _getPayload(self, base_url: str, data: bytes) -> str: """ Parse inventory and return clear text payload without comments. """ payload = b'' while True: parts = data.split(b'\n', 1) if len(parts) != 2: payload = data break if not parts[0].startswith(b'#'): payload = data break data = parts[1] try: decompressed = zlib.decompress(payload) except zlib.error: self.error( 'sphinx', 'Failed to uncompress inventory from %s' % (base_url,)) return '' try: return decompressed.decode('utf-8') except UnicodeError: self.error( 'sphinx', 'Failed to decode inventory from %s' % (base_url,)) return '' def _parseInventory( self, base_url: str, payload: str ) -> Dict[str, Tuple[str, str]]: """ Parse clear text payload and return a dict with module to link mapping. """ result = {} for line in payload.splitlines(): try: name, typ, prio, location, display = _parseInventoryLine(line) except ValueError: self.error( 'sphinx', 'Failed to parse line "%s" for %s' % (line, base_url), ) continue if not typ.startswith('py:'): # Non-Python references are ignored. continue result[name] = (base_url, location) return result def getLink(self, name: str) -> Optional[str]: """ Return link for `name` or None if no link is found. """ base_url, relative_link = self._links.get(name, (None, None)) if not relative_link: return None # For links ending with $, replace it with full name. if relative_link.endswith('$'): relative_link = relative_link[:-1] + name return f'{base_url}/{relative_link}' def _parseInventoryLine(line: str) -> Tuple[str, str, int, str, str]: """ Parse a single line from a Sphinx inventory. @raise ValueError: If the line does not conform to the syntax. """ parts = line.split(' ') # The format is a bit of a mess: spaces are used as separators, but # there are also columns that can contain spaces. # Use the numeric priority column as a reference point, since that is # what sphinx.util.inventory.InventoryFile.load_v2() does as well. prio_idx = 2 try: while True: try: prio = int(parts[prio_idx]) break except ValueError: prio_idx += 1 except IndexError: raise ValueError("Could not find priority column") name = ' '.join(parts[: prio_idx - 1]) typ = parts[prio_idx - 1] location = parts[prio_idx + 1] display = ' '.join(parts[prio_idx + 2 :]) if not display: raise ValueError("Display name column cannot be empty") return name, typ, prio, location, display class SphinxInventoryWriter: """ Sphinx inventory handler. """ def __init__(self, logger: Callable[..., None], project_name: str, project_version: str): self._project_name = project_name self._project_version = project_version self._logger = logger def info(self, where: str, message: str) -> None: self._logger(where, message) def error(self, where: str, message: str) -> None: self._logger(where, message, thresh=-1) def generate(self, subjects: Iterable[Documentable], basepath: str) -> None: """ Generate Sphinx objects inventory version 2 at `basepath`/objects.inv. """ path = os.path.join(basepath, 'objects.inv') self.info('sphinx', 'Generating objects inventory at %s' % (path,)) with self._openFileForWriting(path) as target: target.write(self._generateHeader()) content = self._generateContent(subjects) target.write(zlib.compress(content)) def _openFileForWriting(self, path: str) -> ContextManager[IO[bytes]]: """ Helper for testing. """ return open(path, 'wb') def _generateHeader(self) -> bytes: """ Return header for project with name. """ return f"""# Sphinx inventory version 2 # Project: {self._project_name} # Version: {self._project_version} # The rest of this file is compressed with zlib. """.encode('utf-8') def _generateContent(self, subjects: Iterable[Documentable]) -> bytes: """ Write inventory for all `subjects`. """ content = [] for obj in subjects: if not obj.isVisible: continue content.append(self._generateLine(obj).encode('utf-8')) content.append(self._generateContent(obj.contents.values())) return b''.join(content) def _generateLine(self, obj: Documentable) -> str: """ Return inventory line for object. name domain_name:type priority URL display_name Domain name is always: py Priority is always: -1 Display name is always: - """ # Avoid circular import. from pydoctor import model full_name = obj.fullName() url = obj.url display = '-' if isinstance(obj, model.Module): domainname = 'module' elif isinstance(obj, model.Class): domainname = 'class' elif isinstance(obj, model.Function): if obj.kind is model.DocumentableKind.FUNCTION: domainname = 'function' else: domainname = 'method' elif isinstance(obj, model.Attribute): domainname = 'attribute' else: domainname = 'obj' self.error( 'sphinx', "Unknown type %r for %s." % (type(obj), full_name,)) return f'{full_name} py:{domainname} -1 {url} {display}\n' USER_INTERSPHINX_CACHE = platformdirs.user_cache_dir("pydoctor") @attr.s(auto_attribs=True) class _Unit: """ A unit of time for maximum age parsing. @see: L{parseMaxAge} """ name: str """The name of the unit.""" minimum: int """The minimum value, inclusive.""" maximum: int """The maximum value, exclusive.""" # timedelta stores seconds and minutes internally as ints. Limit them # to a 32 bit value. Per the documentation, days are limited to # 999999999, and weeks are converted to days by multiplying 7. _maxAgeUnits = { "s": _Unit("seconds", minimum=1, maximum=2 ** 32 - 1), "m": _Unit("minutes", minimum=1, maximum=2 ** 32 - 1), "h": _Unit("hours", minimum=1, maximum=2 ** 32 - 1), "d": _Unit("days", minimum=1, maximum=999999999 + 1), "w": _Unit("weeks", minimum=1, maximum=(999999999 + 1) // 7), } _maxAgeUnitNames = ", ".join( f"{indicator} ({unit.name})" for indicator, unit in _maxAgeUnits.items() ) MAX_AGE_HELP = textwrap.dedent( f""" The maximum age of any entry in the cache. Of the format where is one of {_maxAgeUnitNames}. """ ) MAX_AGE_DEFAULT = '1w' class InvalidMaxAge(Exception): """ Raised when a string cannot be parsed as a maximum age. """ def parseMaxAge(maxAge: str) -> Dict[str, int]: """ Parse a string into a maximum age dictionary. @param maxAge: A string consisting of an integer number followed by a single character unit. @return: A dictionary whose keys match L{datetime.timedelta}'s arguments. @raises InvalidMaxAge: when a string cannot be parsed. """ try: amount = int(maxAge[:-1]) except (ValueError, TypeError): raise InvalidMaxAge("Maximum age must be parseable as integer.") try: unit = _maxAgeUnits[maxAge[-1]] except (IndexError, KeyError): raise InvalidMaxAge( f"Maximum age's units must be one of {_maxAgeUnitNames}") if not (unit.minimum <= amount < unit.maximum): raise InvalidMaxAge( f"Maximum age in {unit.name} must be " f"greater than or equal to {unit.minimum} " f"and less than {unit.maximum}") return {unit.name: amount} @attr.s(auto_attribs=True) class IntersphinxCache(CacheT): """ An Intersphinx cache. """ _session: requests.Session """A session that may or may not cache requests.""" _logger: logging.Logger = logger @classmethod def fromParameters( cls, sessionFactory: Callable[[], requests.Session], cachePath: str, maxAgeDictionary: Mapping[str, int] ) -> 'IntersphinxCache': """ Construct an instance with the given parameters. @param sessionFactory: A zero-argument L{callable} that returns a L{requests.Session}. @param cachePath: Path of the cache directory. @param maxAgeDictionary: A mapping describing the maximum age of any cache entry. @see: L{parseMaxAge} """ session = CacheControl(sessionFactory(), cache=FileCache(cachePath), heuristic=ExpiresAfter(**maxAgeDictionary)) return cls(session) def get(self, url: str) -> Optional[bytes]: """ Retrieve a URL using the cache. @param url: The URL to retrieve. @return: The body of the URL, or L{None} on failure. """ try: return self._session.get(url).content except Exception: self._logger.exception( "Could not retrieve intersphinx object.inv from %s", url ) return None def close(self) -> None: self._session.close() def prepareCache( clearCache: bool, enableCache: bool, cachePath: str, maxAge: str, sessionFactory: Callable[[], requests.Session] = requests.Session, ) -> IntersphinxCache: """ Prepare an Intersphinx cache. @param clearCache: Remove the cache? @param enableCache: Enable the cache? @param cachePath: Path of the cache directory. @param maxAge: The maximum age in seconds of cached Intersphinx C{objects.inv} files. @param sessionFactory: (optional) A zero-argument L{callable} that returns a L{requests.Session}. @return: A L{IntersphinxCache} instance. """ if clearCache: shutil.rmtree(cachePath) if enableCache: maxAgeDictionary = parseMaxAge(maxAge) return IntersphinxCache.fromParameters( sessionFactory, cachePath, maxAgeDictionary, ) return IntersphinxCache(sessionFactory()) pydoctor-24.11.2/pydoctor/sphinx_ext/000077500000000000000000000000001473665144200175745ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/sphinx_ext/__init__.py000066400000000000000000000000621473665144200217030ustar00rootroot00000000000000""" Public and private extensions for Sphinx. """ pydoctor-24.11.2/pydoctor/sphinx_ext/build_apidocs.py000066400000000000000000000130251473665144200227500ustar00rootroot00000000000000""" Generate the API docs using pydoctor to be integrated into Sphinx build system. This was designed to generate pydoctor HTML files as part of the Read The Docs build process. Inside the Sphinx conf.py file you need to define the following configuration options: - C{pydoctor_url_path} - defined the URL path to the API documentation You can use C{{rtd_version}} to have the URL automatically updated based on Read The Docs build. - (private usage) a mapping with values URL path definition. Make sure each definition will produce a unique URL. - C{pydoctor_args} - Sequence with all the pydoctor command line arguments used to trigger the build. - (private usage) a mapping with values as sequence of pydoctor command line arguments. The following format placeholders are resolved for C{pydoctor_args} at runtime: - C{{outdir}} - the Sphinx output dir You must call pydoctor with C{--quiet} argument as otherwise any extra output is converted into Sphinx warnings. """ from __future__ import annotations import os import pathlib import shutil from contextlib import redirect_stdout from io import StringIO from typing import Any, Sequence, Mapping from sphinx.application import Sphinx from sphinx.errors import ConfigError from sphinx.util import logging from pydoctor import __version__ from pydoctor.driver import main from pydoctor.options import parse_args logger = logging.getLogger(__name__) def on_build_finished(app: Sphinx, exception: Exception) -> None: """ Called when Sphinx build is done. """ if not app.builder or app.builder.name != 'html': return runs = app.config.pydoctor_args placeholders = { 'outdir': str(app.outdir), } if not isinstance(runs, Mapping): # We have a single pydoctor call runs = {'main': runs} for key, value in runs.items(): arguments = _get_arguments(value, placeholders) options = parse_args(arguments) output_path = pathlib.Path(options.htmloutput) sphinx_files = output_path.with_suffix('.sphinx_files') temp_path = output_path.with_suffix('.pydoctor_temp') shutil.rmtree(sphinx_files, ignore_errors=True) output_path.rename(sphinx_files) temp_path.rename(output_path) def on_builder_inited(app: Sphinx) -> None: """ Called to build the API documentation HTML files and inject our own intersphinx inventory object. """ if not app.builder or app.builder.name != 'html': return rtd_version = 'latest' if os.environ.get('READTHEDOCS', '') == 'True': rtd_version = os.environ.get('READTHEDOCS_VERSION', 'latest') config = app.config if not config.pydoctor_args: raise ConfigError("Missing 'pydoctor_args'.") placeholders = { 'outdir': str(app.outdir), } runs = config.pydoctor_args if not isinstance(runs, Mapping): # We have a single pydoctor call runs = {'main': runs} pydoctor_url_path = config.pydoctor_url_path if not isinstance(pydoctor_url_path, Mapping): pydoctor_url_path = {'main': pydoctor_url_path} for key, value in runs.items(): arguments = _get_arguments(value, placeholders) options = parse_args(arguments) output_path = pathlib.Path(options.htmloutput) temp_path = output_path.with_suffix('.pydoctor_temp') # Update intersphinx_mapping. url_path = pydoctor_url_path.get(key) if url_path: intersphinx_mapping = config.intersphinx_mapping url = url_path.format(**{'rtd_version': rtd_version}) inv = (str(temp_path / 'objects.inv'),) intersphinx_mapping[f'{key}-api-docs'] = (key, (url, inv)) # Build the API docs in temporary path. shutil.rmtree(temp_path, ignore_errors=True) _run_pydoctor(key, arguments) output_path.rename(temp_path) def _run_pydoctor(name: str, arguments: Sequence[str]) -> None: """ Call pydoctor with arguments. @param name: A human-readable description of this pydoctor build. @param arguments: Command line arguments used to call pydoctor. """ logger.info(f"Building '{name}' pydoctor API docs as:") logger.info('\n'.join(arguments)) with StringIO() as stream: with redirect_stdout(stream): main(args=arguments) for line in stream.getvalue().splitlines(): logger.warning(line) def _get_arguments(arguments: Sequence[str], placeholders: Mapping[str, str]) -> Sequence[str]: """ Return the resolved arguments for pydoctor build. @param arguments: Sequence of proto arguments used to call pydoctor. @return: Sequence with actual acguments use to call pydoctor. """ args = ['--make-html', '--quiet'] for argument in arguments: args.append(argument.format(**placeholders)) return args def setup(app: Sphinx) -> Mapping[str, Any]: """ Called by Sphinx when the extension is initialized. @return: The extension version and runtime options. """ app.add_config_value("pydoctor_args", None, "env") app.add_config_value("pydoctor_url_path", None, "env") # Make sure we have a lower priority than intersphinx extension. app.connect('builder-inited', on_builder_inited, priority=490) app.connect('build-finished', on_build_finished) return { 'version': __version__, 'parallel_read_safe': True, 'parallel_write_safe': True, } pydoctor-24.11.2/pydoctor/stanutils.py000066400000000000000000000052651473665144200200130ustar00rootroot00000000000000""" Utilities related to Stan tree building and HTML flattening. """ import re from types import GeneratorType from typing import Union, List, TYPE_CHECKING from twisted.web.template import Tag, XMLString, flattenString from twisted.python.failure import Failure if TYPE_CHECKING: from twisted.web.template import Flattenable _RE_CONTROL = re.compile(( '[' + ''.join( ch for ch in map(chr, range(0, 32)) if ch not in '\r\n\t\f' ) + ']' ).encode()) def html2stan(html: Union[bytes, str]) -> Tag: """ Convert an HTML string to a Stan tree. @param html: An HTML fragment; multiple roots are allowed. @return: The fragment as a tree with a transparent root node. @raises xml.sax.SAXParseException: If L{XMLString} fails to parse the html data. See U{https://github.com/twisted/twisted/issues/11581}. """ if isinstance(html, str): html = html.encode('utf8') html = _RE_CONTROL.sub(lambda m:b'\\x%02x' % ord(m.group()), html) if not html.startswith(b'%s' % html).load()[0] assert isinstance(stan, Tag) assert stan.tagName == 'div' else: stan = XMLString(b'%s' % html).load()[0] assert isinstance(stan, Tag) assert stan.tagName == 'html' stan.tagName = '' return stan def flatten(stan: "Flattenable") -> str: """ Convert a document fragment from a Stan tree to HTML. @param stan: Document fragment to flatten. @return: An HTML string representation of the C{stan} tree. """ ret: List[bytes] = [] err: List[Failure] = [] flattenString(None, stan).addCallback(ret.append).addErrback(err.append) if err: raise err[0].value else: return ret[0].decode() def flatten_text(stan: 'Flattenable') -> str: """ Return the text inside a stan tree. @note: Only compatible with L{Tag}, generators, and lists. """ text = '' if isinstance(stan, (str)): text += stan elif isinstance(stan, (GeneratorType)): text += flatten_text(list(stan)) elif isinstance(stan, (list)): for child in stan: text += flatten_text(child) else: if isinstance(stan, Tag): for child in stan.children: text += flatten_text(child) else: pass # flatten_text() does not support the object received. # Since this function is currently only used in the tests # it's ok to silently ignore the unknown flattenable, which can be # a Comment for instance. # Actually, some tests fails if we try to raise # an error here instead of ignoring. return text pydoctor-24.11.2/pydoctor/templatewriter/000077500000000000000000000000001473665144200204535ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/templatewriter/__init__.py000066400000000000000000000411431473665144200225670ustar00rootroot00000000000000"""Render pydoctor data as HTML.""" from __future__ import annotations from typing import Iterable, Iterator, Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Protocol, runtime_checkable else: Protocol = object def runtime_checkable(f): return f import abc from pathlib import Path, PurePath import warnings from xml.dom import minidom # Newer APIs from importlib_resources should arrive to stdlib importlib.resources in Python 3.9. if TYPE_CHECKING: from importlib.resources.abc import Traversable else: Traversable = object from twisted.web.iweb import ITemplateLoader from twisted.web.template import TagLoader, XMLString, Element, tags from pydoctor.templatewriter.util import CaseInsensitiveDict from pydoctor.model import System, Documentable DOCTYPE = b'''\ ''' def parse_xml(text: str) -> minidom.Document: """ Create a L{minidom} representaton of the XML string. """ try: return minidom.parseString(text) except Exception as e: raise ValueError(f"Failed to parse template as XML: {e}") from e class TemplateError(Exception): """Raised when there is an problem with a template. TemplateErrors are fatal.""" class UnsupportedTemplateVersion(TemplateError): """Raised when custom template is designed for a newer version of pydoctor""" class OverrideTemplateNotAllowed(TemplateError): """Raised when a template path overrides a path of a different type (HTML/static/directory).""" class FailedToCreateTemplate(TemplateError): """Raised when a template could not be created because of an error""" @runtime_checkable class IWriter(Protocol): """ Interface class for pydoctor output writer. """ def __init__(self, build_directory: Path, template_lookup: 'TemplateLookup') -> None: ... def prepOutputDirectory(self) -> None: """ Called first. """ def writeSummaryPages(self, system: System) -> None: """ Called second. """ def writeIndividualFiles(self, obs: Iterable[Documentable]) -> None: """ Called third. """ def writeLinks(self, system: System) -> None: """ Called after writeIndividualFiles when option --html-subject is not used. """ class Template(abc.ABC): """ Represents a pydoctor template file. It holds references to template information. It's an additionnal level of abstraction to hook to the writer class. Use L{Template.fromfile} or L{Template.fromdir} to create Templates. @see: L{TemplateLookup}, L{StaticTemplate} and L{HtmlTemplate} @note: Directories are not L{Template}. The L{Template.name} attribute is the relative path to the template file, it may include subdirectories in it! Currently, subdirectories should only contains static templates. This is because the subdirectory creation is handled in L{StaticTemplate.write()}. """ def __init__(self, name: str): self.name = name """Template filename, may include subdirectories.""" @classmethod def fromdir(cls, basedir: Union[Traversable, Path], subdir: Optional[PurePath] = None) -> Iterator['Template']: """ Scan a directory for templates. @param basedir: A L{Path} or L{Traversable} object that should point to the root directory of the template directory structure. @param subdir: The subdirectory inside the template directory structure that we want to scan, relative to the C{basedir}. Scan the C{basedir} if C{None}. @raises FailedToCreateTemplate: If the path is not a directory or do not exist. """ path = basedir.joinpath(subdir.as_posix()) if subdir else basedir subdir = subdir or PurePath() if not path.is_dir(): raise FailedToCreateTemplate(f"Template folder do not exist or is not a directory: {path}") for entry in path.iterdir(): entry_path = subdir.joinpath(entry.name) if entry.is_dir(): yield from Template.fromdir(basedir, entry_path) else: template = Template.fromfile(basedir, entry_path) if template: yield template @classmethod def fromfile(cls, basedir: Union[Traversable, Path], templatepath: PurePath) -> Optional['Template']: """ Create a concrete template object. Type depends on the file extension. @param basedir: A L{Path} or L{Traversable} object that should point to the root directory of the template directory structure. @param templatepath: The path to the template file, relative to the C{basedir}. @returns: The template object or C{None} if a the path entry is not a file. @raises FailedToCreateTemplate: If there is an error while creating the template. """ path = basedir.joinpath(templatepath.as_posix()) if not path.is_file(): return None template: Template try: # Only try to decode the file text if the file is an HTML template if templatepath.suffix.lower() == '.html': try: text = path.read_text(encoding='utf-8') except UnicodeDecodeError as e: raise FailedToCreateTemplate("Cannot decode HTML Template" f" as UTF-8: '{path}'. {e}") from e else: # The template name is the relative path to the template. # Template files in subdirectories will have a name like: 'static/bar.svg'. template = HtmlTemplate(name=templatepath.as_posix(), text=text) else: # Treat the file as binary data. data = path.read_bytes() template = StaticTemplate(name=templatepath.as_posix(), data=data) # Catch io errors only once for the whole block, it's ok to do that since # we're reading only one file per call to fromfile() except IOError as e: raise FailedToCreateTemplate(f"Cannot read Template: '{path}'." " I/O error: {e}") from e return template class StaticTemplate(Template): """ Static template: no rendering, will be copied as is to build directory. For CSS and JS templates. """ def __init__(self, name: str, data: bytes) -> None: super().__init__(name) self.data: bytes = data """ Contents of the template file as L{bytes}. """ def write(self, build_directory: Path) -> None: """ Directly write the contents of this static template as is to the build dir. """ outfile = build_directory.joinpath(self.name) outfile.parent.mkdir(exist_ok=True, parents=True) with outfile.open('wb') as fobjb: fobjb.write(self.data) class HtmlTemplate(Template): """ HTML template that works with the Twisted templating system and use L{xml.dom.minidom} to parse the C{pydoctor-template-version} meta tag. @ivar text: Contents of the template file as UFT-8 decoded L{str}. @ivar version: Template version, C{-1} if no version could be read in the XML file. HTML Templates should have a version identifier as follow:: The version indentifier should be a integer. @ivar loader: Object used to render the final HTML file with the Twisted templating system. This is a L{ITemplateLoader}. """ def __init__(self, name: str, text: str): super().__init__(name=name) self.text = text if len(self.text.strip()) == 0: self._dom: Optional[minidom.Document] = None self.version = -1 self.loader: ITemplateLoader = TagLoader(tags.transparent) else: self._dom = parse_xml(self.text) self.version = self._extract_version(self._dom, self.name) self.loader = XMLString(self._dom.toxml()) @staticmethod def _extract_version(dom: minidom.Document, template_name: str) -> int: # If no meta pydoctor-template-version tag found, # it's most probably a placeholder template. version = -1 for meta in dom.getElementsByTagName("meta"): if meta.getAttribute("name") != "pydoctor-template-version": continue # Remove the meta tag as soon as found meta.parentNode.removeChild(meta) if not meta.hasAttribute("content"): warnings.warn(f"Could not read '{template_name}' template version: " f"the 'content' attribute is missing") continue version_str = meta.getAttribute("content") try: version = int(version_str) except ValueError: warnings.warn(f"Could not read '{template_name}' template version: " "the 'content' attribute must be an integer") else: break return version class TemplateLookup: """ The L{TemplateLookup} handles the HTML template files locations. A little bit like C{mako.lookup.TemplateLookup} but more simple. The location of the files depends wether the users set a template directory with the option C{--template-dir} and/or with the option C{--theme}, any files in a template directory will be loaded. This object allow the customization of any templates. For HTML templates, this can lead to warnings when upgrading pydoctor, then, please update your template from our repo. @note: The HTML templates versions are independent of the pydoctor version and are idependent from each other. @note: Template operations are case insensitive. @see: L{Template}, L{StaticTemplate}, L{HtmlTemplate} """ def __init__(self, path: Union[Traversable, Path]) -> None: """ Loads all templates from the given C{path} into the lookup. @param path: A L{Path} or L{Traversable} object pointing to a directory to load the default set of templates from. """ self._templates: CaseInsensitiveDict[Template] = CaseInsensitiveDict() self.add_templatedir(path) def _add_overriding_html_template(self, template: HtmlTemplate, current_template: HtmlTemplate) -> None: default_version = current_template.version template_version = template.version if default_version != -1 and template_version != -1: if template_version < default_version: warnings.warn(f"Your custom template '{template.name}' is out of date, " "information might be missing. " "Latest templates are available to download from our github." ) elif template_version > default_version: raise UnsupportedTemplateVersion(f"It appears that your custom template '{template.name}' " "is designed for a newer version of pydoctor." "Rendering will most probably fail. Upgrade to latest " "version of pydoctor with 'pip install -U pydoctor'. ") self._templates[template.name] = template def _raise_if_overrides_directory(self, template_name: str) -> None: # Since we cannot have a file named the same as a directory, # we must reject files that overrides direcotries. template_lowername = template_name.lower() for t in self.templates: current_lowername = t.name.lower() if current_lowername.startswith(f"{template_lowername}/"): raise OverrideTemplateNotAllowed(f"Cannot override a directory with " f"a template. Rename '{template_name}' to something else.") def add_template(self, template: Template) -> None: """ Add a template to the lookup. The custom template override the default. If the file doesn't already exist in the lookup, we assume it is additional data used by the custom template. For HTML, compare the new Template version with the currently loaded template, issue warnings if template are outdated. @raises UnsupportedTemplateVersion: If the custom template is designed for a newer version of pydoctor. @raises OverrideTemplateNotAllowed: If this template path overrides a path of a different type (HTML/static/directory). """ self._raise_if_overrides_directory(template.name) try: current_template = self._templates[template.name] except KeyError: self._templates[template.name] = template else: # The real template name might not have the same casing as current_template.name. # This variable is only used in error messages. _real_template_name = template.name # The L{Template.name} attribute is overriden # to make it match the original (case sensitive) name. # This way, we are sure to stay consistent in the output file names (keeping the original), # while accepting any casing variation in the template directory. template.name = current_template.name if isinstance(current_template, StaticTemplate): if isinstance(template, StaticTemplate): self._templates[template.name] = template else: raise OverrideTemplateNotAllowed(f"Cannot override a static template with " f"a HTML template. Rename '{_real_template_name}' to something else.") # we can assume the template is HTML since there is only # two types of concrete templates elif isinstance(current_template, HtmlTemplate): if isinstance(template, HtmlTemplate): self._add_overriding_html_template(template, current_template) else: raise OverrideTemplateNotAllowed(f"Cannot override an HTML template with " f"a static template. Rename '{_real_template_name}' to something else.") def add_templatedir(self, path: Union[Path, Traversable]) -> None: """ Scan a directory and add all templates in the given directory to the lookup. """ for template in Template.fromdir(path): self.add_template(template) def get_template(self, filename: str) -> Template: """ Lookup a template based on its filename. Return the custom template if provided, else the default template. @param filename: File name, (ie 'index.html') @return: The Template object @raises KeyError: If no template file is found with the given name """ try: t = self._templates[filename] except KeyError as e: raise KeyError(f"Cannot find template '{filename}' in template lookup: {self}. " f"Valid filenames are: {list(self._templates)}") from e return t def get_loader(self, filename: str) -> ITemplateLoader: """ Lookup a HTML template loader based on its filename. @raises ValueError: If the template is not an HTML file. """ template = self.get_template(filename) if not isinstance(template, HtmlTemplate): raise ValueError(f"Failed to get loader of template '{filename}': Not an HTML file.") return template.loader @property def templates(self) -> Iterable[Template]: """ All templates that can be looked up. For each name, the custom template will be included if it exists, otherwise the default template. """ return self._templates.values() class TemplateElement(Element, abc.ABC): """ Renderable element based on a template file. """ filename: str = NotImplemented """ Associated template filename. """ @classmethod def lookup_loader(cls, template_lookup: TemplateLookup) -> ITemplateLoader: """ Lookup the element L{ITemplateLoader} with the C{TemplateLookup}. """ return template_lookup.get_loader(cls.filename) from pydoctor.templatewriter.writer import TemplateWriter __all__ = ["TemplateWriter"] # re-export as pydoctor.templatewriter.TemplateWriter pydoctor-24.11.2/pydoctor/templatewriter/pages/000077500000000000000000000000001473665144200215525ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/templatewriter/pages/__init__.py000066400000000000000000000547411473665144200236760ustar00rootroot00000000000000"""The classes that turn L{Documentable} instances into objects we can render.""" from __future__ import annotations from typing import ( TYPE_CHECKING, Dict, Iterator, List, Optional, Mapping, Sequence, Type, Union ) import ast import abc from urllib.parse import urljoin from twisted.web.iweb import IRenderable, ITemplateLoader, IRequest from twisted.web.template import Element, Tag, renderer, tags from pydoctor.extensions import zopeinterface from pydoctor.stanutils import html2stan from pydoctor import epydoc2stan, model, linker, __version__ from pydoctor.astbuilder import node2fullname from pydoctor.templatewriter import util, TemplateLookup, TemplateElement from pydoctor.templatewriter.pages.table import ChildTable from pydoctor.templatewriter.pages.sidebar import SideBar from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval if TYPE_CHECKING: from typing_extensions import Final from twisted.web.template import Flattenable from pydoctor.templatewriter.pages.attributechild import AttributeChild from pydoctor.templatewriter.pages.functionchild import FunctionChild def format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Iterator["Flattenable"]: # Since we use this function to colorize the FunctionOverload decorators and it's not an actual Documentable subclass, we use the overload's # primary function for parts that requires an interface to Documentable methods or attributes documentable_obj = obj if not isinstance(obj, model.FunctionOverload) else obj.primary for dec in obj.decorators or (): if isinstance(dec, ast.Call): fn = node2fullname(dec.func, documentable_obj) # We don't want to show the deprecated decorator; # it shows up as an infobox. if fn in ("twisted.python.deprecate.deprecated", "twisted.python.deprecate.deprecatedProperty"): break # Colorize decorators! doc = colorize_inline_pyval(dec) stan = epydoc2stan.safe_to_stan(doc, documentable_obj.docstring_linker, documentable_obj, fallback=epydoc2stan.colorized_pyval_fallback, section='rendering of decorators') # Report eventual warnings. It warns when we can't colorize the expression for some reason. epydoc2stan.reportWarnings(documentable_obj, doc.warnings, section='colorize decorator') yield '@', stan.children, tags.br() def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Flattenable": """ Return a stan representation of a nicely-formatted source-like function signature for the given L{Function}. Arguments default values are linked to the appropriate objects when possible. """ broken = "(...)" try: return html2stan(str(func.signature)) if func.signature else broken except Exception as e: # We can't use safe_to_stan() here because we're using Signature.__str__ to generate the signature HTML. epydoc2stan.reportErrors(func.primary if isinstance(func, model.FunctionOverload) else func, [epydoc2stan.get_to_stan_error(e)], section='signature') return broken def format_class_signature(cls: model.Class) -> "Flattenable": """ The class signature is the formatted list of bases this class extends. It's not the class constructor. """ r: List["Flattenable"] = [] # the linker will only be used to resolve the generic arguments of the base classes, # it won't actually resolve the base classes (see comment few lines below). # this is why we're using the annotation linker. _linker = linker._AnnotationLinker(cls) if cls.rawbases: r.append('(') for idx, ((str_base, base_node), base_obj) in enumerate(zip(cls.rawbases, cls.baseobjects)): if idx != 0: r.append(', ') # Make sure we bypass the linker’s resolver process for base object, # because it has been resolved already (with two passes). # Otherwise, since the class declaration wins over the imported names, # a class with the same name as a base class confused pydoctor and it would link # to it self: https://github.com/twisted/pydoctor/issues/662 refmap = None if base_obj is not None: refmap = {str_base:base_obj.fullName()} # link to external class, using the colorizer here # to link to classes with generics (subscripts and other AST expr). stan = epydoc2stan.safe_to_stan(colorize_inline_pyval(base_node, refmap=refmap), _linker, cls, fallback=epydoc2stan.colorized_pyval_fallback, section='rendering of class signature') r.extend(stan.children) r.append(')') return r def format_overloads(func: model.Function) -> Iterator["Flattenable"]: """ Format a function overloads definitions as nice HTML signatures. """ for overload in func.overloads: yield from format_decorators(overload) yield tags.div(format_function_def(func.name, func.is_async, overload)) def format_function_def(func_name: str, is_async: bool, func: Union[model.Function, model.FunctionOverload]) -> List["Flattenable"]: """ Format a function definition as nice HTML signature. If the function is overloaded, it will return an empty list. We use L{format_overloads} for these. """ r:List["Flattenable"] = [] # If this is a function with overloads, we do not render the principal signature because the overloaded signatures will be shown instead. if isinstance(func, model.Function) and func.overloads: return r def_stmt = 'async def' if is_async else 'def' if func_name.endswith('.setter') or func_name.endswith('.deleter'): func_name = func_name[:func_name.rindex('.')] r.extend([ tags.span(def_stmt, class_='py-keyword'), ' ', tags.span(func_name, class_='py-defname'), tags.span(format_signature(func), class_='function-signature'), ':', ]) return r class Nav(TemplateElement): """ Common navigation header. """ filename = 'nav.html' class Head(TemplateElement): """ Common metadata. """ filename = 'head.html' def __init__(self, title: str, baseurl: str | None, pageurl: str, loader: ITemplateLoader) -> None: super().__init__(loader) self._title = title self._baseurl = baseurl self._pageurl = pageurl @renderer def canonicalurl(self, request: IRequest, tag: Tag) -> Flattenable: if not self._baseurl: return '' canonical_link = urljoin(self._baseurl, self._pageurl) return tags.link(rel='canonical', href=canonical_link) @renderer def title(self, request: IRequest, tag: Tag) -> str: return self._title class Page(TemplateElement): """ Abstract base class for output pages. Defines special HTML placeholders that are designed to be overriden by users: "header.html", "subheader.html" and "footer.html". """ def __init__(self, system: model.System, template_lookup: TemplateLookup, loader: Optional[ITemplateLoader] = None): self.system = system self.template_lookup = template_lookup if not loader: loader = self.lookup_loader(template_lookup) super().__init__(loader) @property def page_url(self) -> str: # This MUST be overriden in CommonPage """ The relative page url """ return self.filename def render(self, request: Optional[IRequest]) -> Tag: return tags.transparent(super().render(request)).fillSlots(**self.slot_map) @property def slot_map(self) -> Dict[str, "Flattenable"]: system = self.system if system.options.projecturl: project_tag = tags.a(href=system.options.projecturl, class_="projecthome") else: project_tag = tags.transparent project_tag(system.projectname) return dict( project=project_tag, pydoctor_version=__version__, buildtime=system.buildtime.strftime("%Y-%m-%d %H:%M:%S"), ) @abc.abstractmethod def title(self) -> str: raise NotImplementedError() @renderer def head(self, request: IRequest, tag: Tag) -> IRenderable: return Head(self.title(), self.system.options.htmlbaseurl, self.page_url, loader=Head.lookup_loader(self.template_lookup)) @renderer def nav(self, request: IRequest, tag: Tag) -> IRenderable: return Nav(Nav.lookup_loader(self.template_lookup)) @renderer def header(self, request: IRequest, tag: Tag) -> IRenderable: return Element(self.template_lookup.get_loader('header.html')) @renderer def subheader(self, request: IRequest, tag: Tag) -> IRenderable: return Element(self.template_lookup.get_loader('subheader.html')) @renderer def footer(self, request: IRequest, tag: Tag) -> IRenderable: return Element(self.template_lookup.get_loader('footer.html')) class CommonPage(Page): filename = 'common.html' ob: model.Documentable def __init__(self, ob: model.Documentable, template_lookup: TemplateLookup, docgetter: Optional[util.DocGetter]=None): super().__init__(ob.system, template_lookup) self.ob = ob if docgetter is None: docgetter = util.DocGetter() self.docgetter = docgetter self._order = ob.system.membersOrder(ob) @property def page_url(self) -> str: return self.ob.page_object.url def title(self) -> str: return self.ob.fullName() def heading(self) -> Tag: return tags.h1(class_=util.css_class(self.ob))( tags.code(self.namespace(self.ob)) ) def category(self) -> str: kind = self.ob.kind assert kind is not None return f"{epydoc2stan.format_kind(kind).lower()} documentation" def namespace(self, obj: model.Documentable) -> List[Union[Tag, str]]: page_url = self.page_url parts: List[Union[Tag, str]] = [] ob: Optional[model.Documentable] = obj while ob: if ob.documentation_location is model.DocLocation.OWN_PAGE: if parts: parts.extend(['.', tags.wbr]) parts.append(tags.code(epydoc2stan.taglink(ob, page_url, ob.name))) ob = ob.parent parts.reverse() return parts @renderer def deprecated(self, request: object, tag: Tag) -> "Flattenable": import warnings warnings.warn("Renderer 'CommonPage.deprecated' is deprecated, the twisted's deprecation system is now supported by default.") return '' @renderer def source(self, request: object, tag: Tag) -> "Flattenable": sourceHref = util.srclink(self.ob) if not sourceHref: return () return tag(href=sourceHref) @renderer def inhierarchy(self, request: object, tag: Tag) -> "Flattenable": return () def extras(self) -> List["Flattenable"]: return self.objectExtras(self.ob) def docstring(self) -> "Flattenable": return self.docgetter.get(self.ob) def children(self) -> Sequence[model.Documentable]: return sorted( (o for o in self.ob.contents.values() if o.isVisible), key=self._order) def packageInitTable(self) -> "Flattenable": return () @renderer def baseTables(self, request: object, tag: Tag) -> "Flattenable": return () def mainTable(self) -> "Flattenable": children = self.children() if children: return ChildTable(self.docgetter, self.ob, children, ChildTable.lookup_loader(self.template_lookup)) else: return () def methods(self) -> Sequence[model.Documentable]: return sorted((o for o in self.ob.contents.values() if o.documentation_location is model.DocLocation.PARENT_PAGE and o.isVisible), key=self._order) def childlist(self) -> List[Union["AttributeChild", "FunctionChild"]]: from pydoctor.templatewriter.pages.attributechild import AttributeChild from pydoctor.templatewriter.pages.functionchild import FunctionChild r: List[Union["AttributeChild", "FunctionChild"]] = [] func_loader = FunctionChild.lookup_loader(self.template_lookup) attr_loader = AttributeChild.lookup_loader(self.template_lookup) for c in self.methods(): if isinstance(c, model.Function): r.append(FunctionChild(self.docgetter, c, self.objectExtras(c), func_loader)) elif isinstance(c, model.Attribute): r.append(AttributeChild(self.docgetter, c, self.objectExtras(c), attr_loader)) else: assert False, type(c) return r def objectExtras(self, ob: model.Documentable) -> List["Flattenable"]: """ Flatten each L{model.Documentable.extra_info} list item. """ r: List["Flattenable"] = [] for extra in ob.extra_info: r.append(epydoc2stan.unwrap_docstring_stan( epydoc2stan.safe_to_stan(extra, ob.docstring_linker, ob, fallback = lambda _,__,___:epydoc2stan.BROKEN, section='extra'))) return r def functionBody(self, ob: model.Documentable) -> "Flattenable": return self.docgetter.get(ob) @renderer def maindivclass(self, request: IRequest, tag: Tag) -> str: return 'nosidebar' if self.ob.system.options.nosidebar else '' @renderer def sidebarcontainer(self, request: IRequest, tag: Tag) -> Union[Tag, str]: if self.ob.system.options.nosidebar: return "" else: return tag.fillSlots(sidebar=SideBar(ob=self.ob, template_lookup=self.template_lookup)) @property def slot_map(self) -> Dict[str, "Flattenable"]: slot_map = super().slot_map slot_map.update( heading=self.heading(), category=self.category(), extras=self.extras(), docstring=self.docstring(), mainTable=self.mainTable(), packageInitTable=self.packageInitTable(), childlist=self.childlist(), ) return slot_map class ModulePage(CommonPage): ob: model.Module def extras(self) -> List["Flattenable"]: r: List["Flattenable"] = [] sourceHref = util.srclink(self.ob) if sourceHref: r.append(tags.a("(source)", href=sourceHref, class_="sourceLink")) r.extend(super().extras()) return r class PackagePage(ModulePage): def children(self) -> Sequence[model.Documentable]: return sorted(self.ob.submodules(), key=self._order) def packageInitTable(self) -> "Flattenable": children = sorted( (o for o in self.ob.contents.values() if not isinstance(o, model.Module) and o.isVisible), key=self._order) if children: loader = ChildTable.lookup_loader(self.template_lookup) return [ tags.p("From ", tags.code("__init__.py"), ":", class_="fromInitPy"), ChildTable(self.docgetter, self.ob, children, loader) ] else: return () def methods(self) -> Sequence[model.Documentable]: return sorted([o for o in self.ob.contents.values() if o.documentation_location is model.DocLocation.PARENT_PAGE and o.isVisible], key=self._order) def assembleList( system: model.System, label: str, lst: Sequence[str], page_url: str ) -> Optional["Flattenable"]: """ Convert list of object names into a stan tree with clickable links. """ lst2 = [] for name in lst: o = system.allobjects.get(name) if o is None or o.isVisible: lst2.append(name) lst = lst2 if not lst: return None def one(item: str) -> "Flattenable": if item in system.allobjects: return tags.code(epydoc2stan.taglink(system.allobjects[item], page_url)) else: return item def commasep(items: Sequence[str]) -> List["Flattenable"]: r = [] for item in items: r.append(one(item)) r.append(', ') del r[-1] return r p: List["Flattenable"] = [label] p.extend(commasep(lst)) return p class ClassPage(CommonPage): ob: model.Class def __init__(self, ob: model.Documentable, template_lookup: TemplateLookup, docgetter: Optional[util.DocGetter] = None ): super().__init__(ob, template_lookup, docgetter) self.baselists = util.class_members(self.ob) def extras(self) -> List["Flattenable"]: r: List["Flattenable"] = [] sourceHref = util.srclink(self.ob) source: "Flattenable" if sourceHref: source = (" ", tags.a("(source)", href=sourceHref, class_="sourceLink")) else: source = tags.transparent r.append(tags.p(tags.code( tags.span("class", class_='py-keyword'), " ", tags.span(self.ob.name, class_='py-defname'), self.classSignature(), ":", source ), class_='class-signature')) subclasses = sorted(self.ob.subclasses, key=util.alphabetical_order_func) if subclasses: p = assembleList(self.ob.system, "Known subclasses: ", [o.fullName() for o in subclasses], self.page_url) if p is not None: r.append(tags.p(p)) constructor = epydoc2stan.get_constructors_extra(self.ob) if constructor: r.append(epydoc2stan.unwrap_docstring_stan( epydoc2stan.safe_to_stan(constructor, self.ob.docstring_linker, self.ob, fallback = lambda _,__,___:epydoc2stan.BROKEN, section='constructor extra'))) r.extend(super().extras()) return r def classSignature(self) -> "Flattenable": return format_class_signature(self.ob) @renderer def inhierarchy(self, request: object, tag: Tag) -> Tag: return tag(href="classIndex.html#"+self.ob.fullName()) @renderer def baseTables(self, request: object, item: Tag) -> "Flattenable": baselists = self.baselists[:] if not baselists: return [] if baselists[0][0][0] == self.ob: del baselists[0] loader = ChildTable.lookup_loader(self.template_lookup) return [item.clone().fillSlots( baseName=self.baseName(b), baseTable=ChildTable(self.docgetter, self.ob, sorted(attrs, key=self._order), loader)) for b, attrs in baselists] def baseName(self, bases: Sequence[model.Class]) -> "Flattenable": page_url = self.page_url r: List["Flattenable"] = [] source_base = bases[0] r.append(tags.code(epydoc2stan.taglink(source_base, page_url, source_base.name))) bases_to_mention = bases[1:-1] if bases_to_mention: tail: List["Flattenable"] = [] for b in reversed(bases_to_mention): tail.append(tags.code(epydoc2stan.taglink(b, page_url, b.name))) tail.append(', ') del tail[-1] r.extend([' (via ', tail, ')']) return r def objectExtras(self, ob: model.Documentable) -> List["Flattenable"]: r: List["Flattenable"] = list(get_override_info(self.ob, ob.name, self.page_url)) r.extend(super().objectExtras(ob)) return r def get_override_info(cls:model.Class, member_name:str, page_url:Optional[str]=None) -> Iterator["Flattenable"]: page_url = page_url or cls.page_object.url for b in cls.mro(include_self=False): if member_name not in b.contents: continue overridden = b.contents[member_name] yield tags.div(class_="interfaceinfo")( 'overrides ', tags.code(epydoc2stan.taglink(overridden, page_url))) break ocs = sorted(util.overriding_subclasses(cls, member_name), key=util.alphabetical_order_func) if ocs: l = assembleList(cls.system, 'overridden in ', [o.fullName() for o in ocs], page_url) if l is not None: yield tags.div(class_="interfaceinfo")(l) class ZopeInterfaceClassPage(ClassPage): ob: zopeinterface.ZopeInterfaceClass def extras(self) -> List["Flattenable"]: r = super().extras() if self.ob.isinterface: namelist = [o.fullName() for o in sorted(self.ob.implementedby_directly, key=util.alphabetical_order_func)] label = 'Known implementations: ' else: namelist = sorted(self.ob.implements_directly, key=lambda x:x.lower()) label = 'Implements interfaces: ' if namelist: l = assembleList(self.ob.system, label, namelist, self.page_url) if l is not None: r.append(tags.p(l)) return r def interfaceMeth(self, methname: str) -> Optional[model.Documentable]: system = self.ob.system for interface in self.ob.allImplementedInterfaces: if interface in system.allobjects: io = system.allobjects[interface] assert isinstance(io, zopeinterface.ZopeInterfaceClass) for io2 in io.mro(): method: Optional[model.Documentable] = io2.contents.get(methname) if method is not None: return method return None def objectExtras(self, ob: model.Documentable) -> List["Flattenable"]: imeth = self.interfaceMeth(ob.name) r: List["Flattenable"] = [] if imeth: iface = imeth.parent assert iface is not None r.append(tags.div(class_="interfaceinfo")('from ', tags.code( epydoc2stan.taglink(imeth, self.page_url, iface.fullName()) ))) r.extend(super().objectExtras(ob)) return r commonpages: 'Final[Mapping[str, Type[CommonPage]]]' = { 'Module': ModulePage, 'Package': PackagePage, 'Class': ClassPage, 'ZopeInterfaceClass': ZopeInterfaceClassPage, } """List all page classes: ties documentable class name with the page class used for rendering""" pydoctor-24.11.2/pydoctor/templatewriter/pages/attributechild.py000066400000000000000000000052031473665144200251330ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, List from twisted.web.iweb import ITemplateLoader from twisted.web.template import Tag, renderer, tags from pydoctor.model import Attribute from pydoctor import epydoc2stan from pydoctor.templatewriter import TemplateElement, util from pydoctor.templatewriter.pages import format_decorators if TYPE_CHECKING: from twisted.web.template import Flattenable class AttributeChild(TemplateElement): filename = 'attribute-child.html' def __init__(self, docgetter: util.DocGetter, ob: Attribute, extras: List["Flattenable"], loader: ITemplateLoader ): super().__init__(loader) self.docgetter = docgetter self.ob = ob self._functionExtras = extras @renderer def class_(self, request: object, tag: Tag) -> "Flattenable": class_ = util.css_class(self.ob) if self.ob.parent is not self.ob: class_ = 'base' + class_ return class_ @renderer def functionAnchor(self, request: object, tag: Tag) -> "Flattenable": return self.ob.fullName() @renderer def shortFunctionAnchor(self, request: object, tag: Tag) -> str: return self.ob.name @renderer def anchorHref(self, request: object, tag: Tag) -> str: name = self.shortFunctionAnchor(request, tag) return f'#{name}' @renderer def decorator(self, request: object, tag: Tag) -> "Flattenable": return list(format_decorators(self.ob)) @renderer def attribute(self, request: object, tag: Tag) -> "Flattenable": attr: List["Flattenable"] = [tags.span(self.ob.name, class_='py-defname')] _type = self.docgetter.get_type(self.ob) if _type: attr.extend([': ', _type]) return attr @renderer def sourceLink(self, request: object, tag: Tag) -> "Flattenable": if self.ob.sourceHref: return tag.fillSlots(sourceHref=self.ob.sourceHref) else: return () @renderer def objectExtras(self, request: object, tag: Tag) -> List["Flattenable"]: return self._functionExtras @renderer def functionBody(self, request: object, tag: Tag) -> "Flattenable": return self.docgetter.get(self.ob) @renderer def constantValue(self, request: object, tag: Tag) -> "Flattenable": if self.ob.kind not in self.ob.system.show_attr_value or self.ob.value is None: return tag.clear() # Attribute is a constant/type alias (with a value), then display it's value return epydoc2stan.format_constant_value(self.ob) pydoctor-24.11.2/pydoctor/templatewriter/pages/functionchild.py000066400000000000000000000044361473665144200247640ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, List from twisted.web.iweb import ITemplateLoader from twisted.web.template import Tag, renderer from pydoctor.model import Function from pydoctor.templatewriter import TemplateElement, util from pydoctor.templatewriter.pages import format_decorators, format_function_def, format_overloads if TYPE_CHECKING: from twisted.web.template import Flattenable class FunctionChild(TemplateElement): filename = 'function-child.html' def __init__(self, docgetter: util.DocGetter, ob: Function, extras: List["Flattenable"], loader: ITemplateLoader ): super().__init__(loader) self.docgetter = docgetter self.ob = ob self._functionExtras = extras @renderer def class_(self, request: object, tag: Tag) -> "Flattenable": class_ = util.css_class(self.ob) if self.ob.parent is not self.ob: class_ = 'base' + class_ return class_ @renderer def functionAnchor(self, request: object, tag: Tag) -> "Flattenable": return self.ob.fullName() @renderer def shortFunctionAnchor(self, request: object, tag: Tag) -> str: return self.ob.name @renderer def anchorHref(self, request: object, tag: Tag) -> str: name = self.shortFunctionAnchor(request, tag) return f'#{name}' @renderer def overloads(self, request: object, tag: Tag) -> "Flattenable": return list(format_overloads(self.ob)) @renderer def decorator(self, request: object, tag: Tag) -> "Flattenable": return list(format_decorators(self.ob)) @renderer def functionDef(self, request: object, tag: Tag) -> "Flattenable": return format_function_def(self.ob.name, self.ob.is_async, self.ob) @renderer def sourceLink(self, request: object, tag: Tag) -> "Flattenable": if self.ob.sourceHref: return tag.fillSlots(sourceHref=self.ob.sourceHref) else: return () @renderer def objectExtras(self, request: object, tag: Tag) -> List["Flattenable"]: return self._functionExtras @renderer def functionBody(self, request: object, tag: Tag) -> "Flattenable": return self.docgetter.get(self.ob) pydoctor-24.11.2/pydoctor/templatewriter/pages/sidebar.py000066400000000000000000000401561473665144200235430ustar00rootroot00000000000000""" Classes for the sidebar generation. """ from __future__ import annotations from typing import Any, Iterator, List, Optional, Sequence, Tuple, Type, Union from twisted.web.iweb import IRequest, ITemplateLoader from twisted.web.template import TagLoader, renderer, Tag, Element, tags from pydoctor import epydoc2stan from pydoctor.model import Attribute, Class, Function, Documentable, Module from pydoctor.templatewriter import util, TemplateLookup, TemplateElement from pydoctor.napoleon.iterators import peek_iter class SideBar(TemplateElement): """ Sidebar. Contains: - the object docstring table of contents if titles are defined - for classes: - information about the contents of the current class and parent module/package. - for modules/packages: - information about the contents of the module and parent package. """ filename = 'sidebar.html' def __init__(self, ob: Documentable, template_lookup: TemplateLookup): super().__init__(loader=self.lookup_loader(template_lookup)) self.ob = ob self.template_lookup = template_lookup @renderer def sections(self, request: IRequest, tag: Tag) -> Iterator['SideBarSection']: """ Sections are L{SideBarSection} elements. """ # The object itself yield SideBarSection(loader=TagLoader(tag), ob=self.ob, documented_ob=self.ob, template_lookup=self.template_lookup) parent: Optional[Documentable] = None if isinstance(self.ob, Module): # The object is a module, we document the parent package in the second section (if it's not a root module). if self.ob.parent: parent = self.ob.parent else: # The object is a class/function or attribute, we docuement the module that contains the object, not it's direct parent. # parent = self.ob.module if parent: yield SideBarSection(loader=TagLoader(tag), ob=parent, documented_ob=self.ob, template_lookup=self.template_lookup) class SideBarSection(Element): """ Main sidebar section. The sidebar typically contains two C{SideBarSection}: one for the documented object and one for it's parent. Root modules have only one section. """ def __init__(self, ob: Documentable, documented_ob: Documentable, loader: ITemplateLoader, template_lookup: TemplateLookup): super().__init__(loader) self.ob = ob self.documented_ob = documented_ob self.template_lookup = template_lookup # Does this sidebar section represents the object itself ? self._represents_documented_ob = self.ob is self.documented_ob @renderer def kind(self, request: IRequest, tag: Tag) -> str: return epydoc2stan.format_kind(self.ob.kind) if self.ob.kind else 'Unknown kind' @renderer def name(self, request: IRequest, tag: Tag) -> Tag: """Craft a block for the title with custom description when hovering. """ name = self.ob.name link = epydoc2stan.taglink(self.ob, self.ob.page_object.url, epydoc2stan.insert_break_points(name)) tag = tags.code(link(title=self.description())) if self._represents_documented_ob: tag(class_='thisobject') return tag def description(self) -> str: """ Short description of the sidebar section. """ return (f"This {epydoc2stan.format_kind(self.documented_ob.kind).lower() if self.documented_ob.kind else 'object'}" if self._represents_documented_ob else f"The parent of this {epydoc2stan.format_kind(self.documented_ob.kind).lower() if self.documented_ob.kind else 'object'}" if self.ob in [self.documented_ob.parent, self.documented_ob.module.parent] else "") @renderer def content(self, request: IRequest, tag: Tag) -> 'ObjContent': return ObjContent(ob=self.ob, loader=TagLoader(tag), documented_ob=self.documented_ob, template_lookup=self.template_lookup, depth=self.ob.system.options.sidebarexpanddepth) class ObjContent(Element): """ Object content displayed on the sidebar. Each L{SideBarSection} object uses one of these in the L{SideBarSection.content} renderer. This object is also used to represent the contents of nested expandable items. Composed by L{ContentList} elements. """ #FIXME: https://github.com/twisted/pydoctor/issues/600 def __init__(self, loader: ITemplateLoader, ob: Documentable, documented_ob: Documentable, template_lookup: TemplateLookup, depth: int, level: int = 0): super().__init__(loader) self.ob = ob self.documented_ob = documented_ob self.template_lookup = template_lookup self._order = ob.system.membersOrder(ob) self._depth = depth self._level = level + 1 _direct_children = self._children(inherited=False) self.classList = self._getContentList(_direct_children, Class) self.functionList = self._getContentList(_direct_children, Function) self.variableList = self._getContentList(_direct_children, Attribute) self.subModuleList = self._getContentList(_direct_children, Module) self.inheritedFunctionList: Optional[ContentList] = None self.inheritedVariableList: Optional[ContentList] = None if isinstance(self.ob, Class): _inherited_children = self._children(inherited=True) self.inheritedFunctionList = self._getContentList(_inherited_children, Function) self.inheritedVariableList = self._getContentList(_inherited_children, Attribute) #TODO: ensure not to crash if heterogeneous Documentable types are passed def _getContentList(self, children: Sequence[Documentable], type_: Type[Documentable]) -> Optional['ContentList']: # We use the filter and iterators (instead of lists) for performance reasons. things = peek_iter(filter(lambda o: isinstance(o, type_,), children)) if things.has_next(): assert self.loader is not None return ContentList(ob=self.ob, children=things, documented_ob=self.documented_ob, expand=self._isExpandable(type_), nested_content_loader=self.loader, template_lookup=self.template_lookup, level_depth=(self._level, self._depth)) else: return None def _children(self, inherited: bool = False) -> List[Documentable]: """ Compute the children of this object. """ if inherited: assert isinstance(self.ob, Class), "Use inherited=True only with Class instances" return sorted((o for o in util.inherited_members(self.ob) if o.isVisible), key=self._order) else: return sorted((o for o in self.ob.contents.values() if o.isVisible), key=self._order) def _isExpandable(self, list_type: Type[Documentable]) -> bool: """ Should the list items be expandable? """ can_be_expanded = False # Classes, modules and packages can be expanded in the sidebar. if issubclass(list_type, (Class, Module)): can_be_expanded = True return self._level < self._depth and can_be_expanded @renderer def docstringToc(self, request: IRequest, tag: Tag) -> Union[Tag, str]: toc = util.DocGetter().get_toc(self.ob) # Only show the TOC if visiting the object page itself, in other words, the TOC do dot show up # in the object's parent section or any other subsections except the main one. if toc and self.documented_ob is self.ob: return tag.fillSlots(titles=toc) else: return "" @renderer def classesTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return tag.clear()("Classes") if self.classList else "" @renderer def classes(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.classList or "" @renderer def functionsTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return (tag.clear()("Functions") if not isinstance(self.ob, Class) else tag.clear()("Methods")) if self.functionList else "" @renderer def functions(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.functionList or "" @renderer def inheritedFunctionsTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return tag.clear()("Inherited Methods") if self.inheritedFunctionList else "" @renderer def inheritedFunctions(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.inheritedFunctionList or "" @renderer def variablesTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return (tag.clear()("Variables") if not isinstance(self.ob, Class) else tag.clear()("Attributes")) if self.variableList else "" @renderer def variables(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.variableList or "" @renderer def inheritedVariablesTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return tag.clear()("Inherited Attributes") if self.inheritedVariableList else "" @renderer def inheritedVariables(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.inheritedVariableList or "" @renderer def subModulesTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return tag.clear()("Modules") if self.subModuleList else "" @renderer def subModules(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.subModuleList or "" @property def has_contents(self) -> bool: return bool(self.classList or self.functionList or self.variableList or self.subModuleList or self.inheritedFunctionList or self.inheritedVariableList) class ContentList(TemplateElement): """ List of child objects that share the same type. One L{ObjContent} element can have up to six C{ContentList}: - classes - functions/methods - variables/attributes - modules - inherited attributes - inherited methods """ # one table per module children types: classes, functions, variables, modules filename = 'sidebar-list.html' def __init__(self, ob: Documentable, children: Iterator[Documentable], documented_ob: Documentable, expand: bool, nested_content_loader: ITemplateLoader, template_lookup: TemplateLookup, level_depth: Tuple[int, int]): super().__init__(loader=self.lookup_loader(template_lookup)) self.ob = ob self.children = children self.documented_ob = documented_ob self._expand = expand self._level_depth = level_depth self.nested_content_loader = nested_content_loader self.template_lookup = template_lookup @renderer def items(self, request: IRequest, tag: Tag) -> Iterator['ContentItem']: return ( ContentItem( loader=TagLoader(tag), ob=self.ob, child=child, documented_ob=self.documented_ob, expand=self._expand, nested_content_loader=self.nested_content_loader, template_lookup=self.template_lookup, level_depth=self._level_depth) for child in self.children ) class ContentItem(Element): """ L{ContentList} item. """ def __init__(self, loader: ITemplateLoader, ob: Documentable, child: Documentable, documented_ob: Documentable, expand: bool, nested_content_loader: ITemplateLoader, template_lookup: TemplateLookup, level_depth: Tuple[int, int]): super().__init__(loader) self.child = child self.ob = ob self.documented_ob = documented_ob self._expand = expand self._level_depth = level_depth self.nested_content_loader = nested_content_loader self.template_lookup = template_lookup @renderer def class_(self, request: IRequest, tag: Tag) -> str: class_ = '' # We could keep same style as in the summary table. # But I found it a little bit too colorful. if self.child.isPrivate: class_ += "private" if self.child is self.documented_ob: class_ += " thisobject" return class_ def _contents(self) -> ObjContent: return ObjContent(ob=self.child, loader=self.nested_content_loader, documented_ob=self.documented_ob, level=self._level_depth[0], depth=self._level_depth[1], template_lookup=self.template_lookup) @renderer def expandableItem(self, request: IRequest, tag: Tag) -> Union[str, 'ExpandableItem']: if self._expand: nested_contents = self._contents() # pass do_not_expand=True also when an object do not have any members, # instead of expanding on an empty div. return ExpandableItem(TagLoader(tag), self.child, self.documented_ob, nested_contents, do_not_expand=self.child is self.documented_ob or not nested_contents.has_contents) else: return "" @renderer def linkOnlyItem(self, request: IRequest, tag: Tag) -> Union[str, 'LinkOnlyItem']: if not self._expand: return LinkOnlyItem(TagLoader(tag), self.child, self.documented_ob) else: return "" class LinkOnlyItem(Element): """ Sidebar leaf item: just a link to an object. Used by L{ContentItem.linkOnlyItem} """ def __init__(self, loader: ITemplateLoader, child: Documentable, documented_ob: Documentable): super().__init__(loader) self.child = child self.documented_ob = documented_ob @renderer def name(self, request: IRequest, tag: Tag) -> Tag: return tags.code(epydoc2stan.taglink(self.child, self.documented_ob.page_object.url, epydoc2stan.insert_break_points(self.child.name))) class ExpandableItem(LinkOnlyItem): """ Sidebar expandable item: link to an object and have a triangle that expand/collapse it's contents Used by L{ContentItem.expandableItem} @note: ExpandableItem can be created with C{do_not_expand} flag. This will generate a expandable item with a special C{notExpandable} CSS class. It differs from L{LinkOnlyItem}, wich do not show the expand button, here we show it but we make it unusable by assinging an empty CSS ID. """ last_ExpandableItem_id = 0 def __init__(self, loader: ITemplateLoader, child: Documentable, documented_ob: Documentable, contents: ObjContent, do_not_expand: bool = False): super().__init__(loader, child, documented_ob) self._contents = contents self._do_not_expand = do_not_expand ExpandableItem.last_ExpandableItem_id += 1 self._id = ExpandableItem.last_ExpandableItem_id @renderer def labelClass(self, request: IRequest, tag: Tag) -> str: assert all(isinstance(child, str) for child in tag.children) classes: List[Any] = tag.children if self._do_not_expand: classes.append('notExpandable') return ' '.join(classes) @renderer def contents(self, request: IRequest, tag: Tag) -> ObjContent: return self._contents @renderer def expandableItemId(self, request: IRequest, tag: Tag) -> str: return f"expandableItemId{self._id}" @renderer def labelForExpandableItemId(self, request: IRequest, tag: Tag) -> str: return f"expandableItemId{self._id}" if not self._do_not_expand else "" pydoctor-24.11.2/pydoctor/templatewriter/pages/table.py000066400000000000000000000052621473665144200232200ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Collection from twisted.web.iweb import ITemplateLoader from twisted.web.template import Element, Tag, TagLoader, renderer, tags from pydoctor import epydoc2stan from pydoctor.model import Documentable, Function from pydoctor.templatewriter import TemplateElement, util if TYPE_CHECKING: from twisted.web.template import Flattenable class TableRow(Element): def __init__(self, loader: ITemplateLoader, docgetter: util.DocGetter, ob: Documentable, child: Documentable, ): super().__init__(loader) self.docgetter = docgetter self.ob = ob self.child = child @renderer def class_(self, request: object, tag: Tag) -> "Flattenable": class_ = util.css_class(self.child) if self.child.parent is not self.ob: class_ = 'base' + class_ return class_ @renderer def kind(self, request: object, tag: Tag) -> Tag: child = self.child kind = child.kind assert kind is not None # 'kind is None' makes the object invisible kind_name = epydoc2stan.format_kind(kind) if isinstance(child, Function) and child.is_async: # The official name is "coroutine function", but that is both # a bit long and not as widely recognized. kind_name = f'Async {kind_name}' return tag.clear()(kind_name) @renderer def name(self, request: object, tag: Tag) -> Tag: return tag.clear()(tags.code( epydoc2stan.taglink(self.child, self.ob.url, epydoc2stan.insert_break_points(self.child.name)) )) @renderer def summaryDoc(self, request: object, tag: Tag) -> Tag: return tag.clear()(self.docgetter.get(self.child, summary=True)) class ChildTable(TemplateElement): last_id = 0 filename = 'table.html' def __init__(self, docgetter: util.DocGetter, ob: Documentable, children: Collection[Documentable], loader: ITemplateLoader, ): super().__init__(loader) self.children = children ChildTable.last_id += 1 self._id = ChildTable.last_id self.ob = ob self.docgetter = docgetter @renderer def id(self, request: object, tag: Tag) -> str: return f'id{self._id}' @renderer def rows(self, request: object, tag: Tag) -> "Flattenable": return [ TableRow( TagLoader(tag), self.docgetter, self.ob, child) for child in self.children if child.isVisible ] pydoctor-24.11.2/pydoctor/templatewriter/search.py000066400000000000000000000150221473665144200222720ustar00rootroot00000000000000""" Code building ``all-documents.html``, ``searchindex.json`` and ``fullsearchindex.json``. """ from __future__ import annotations from pathlib import Path from typing import Iterator, List, Optional, Tuple, Type, Dict, TYPE_CHECKING import json import attr from pydoctor.templatewriter.pages import Page from pydoctor import model, epydoc2stan, node2stan from twisted.web.template import Tag, renderer from lunr import lunr, get_default_builder if TYPE_CHECKING: from twisted.web.template import Flattenable def get_all_documents_flattenable(system: model.System) -> Iterator[Dict[str, "Flattenable"]]: """ Get a generator for all data to be writen into ``all-documents.html`` file. """ # This function accounts for a substantial proportion of pydoctor runtime. # So it's optimized. insert_break_points = epydoc2stan.insert_break_points format_kind = epydoc2stan.format_kind format_summary = epydoc2stan.format_summary return ({ 'id': ob.fullName(), 'name': ob.name, 'fullName': insert_break_points(ob.fullName()), 'kind': format_kind(ob.kind) if ob.kind else '', 'type': str(ob.__class__.__name__), 'summary': format_summary(ob), 'url': ob.url, 'privacy': str(ob.privacyClass.name)} for ob in system.allobjects.values() if ob.isVisible) class AllDocuments(Page): filename = 'all-documents.html' def title(self) -> str: return "All Documents" @renderer def documents(self, request: None, tag: Tag) -> Iterator[Tag]: for doc in get_all_documents_flattenable(self.system): yield tag.clone().fillSlots(**doc) @attr.s(auto_attribs=True) class LunrIndexWriter: """ Class to write lunr indexes with configurable fields. """ output_file: Path system: model.System fields: List[str] _BOOSTS = { 'name':6, 'names': 1, 'qname':2, 'docstring':1, 'kind':-1 } # For all pipeline functions, stop_word_filter, stemmer and trimmer, skip their action expect for the # docstring field. _SKIP_PIPELINES = list(_BOOSTS) _SKIP_PIPELINES.remove('docstring') @staticmethod def get_ob_boost(ob: model.Documentable) -> int: # Advantage container types because they hold more informations. if isinstance(ob, (model.Class, model.Module)): return 2 else: return 1 def format(self, ob: model.Documentable, field:str) -> Optional[str]: try: return getattr(self, f'format_{field}')(ob) #type:ignore[no-any-return] except AttributeError as e: raise AssertionError() from e def format_name(self, ob: model.Documentable) -> str: return ob.name def format_names(self, ob: model.Documentable) -> str: return ' '.join(stem_identifier(ob.name)) def format_qname(self, ob: model.Documentable) -> str: return ob.fullName() def format_docstring(self, ob: model.Documentable) -> Optional[str]: # sanitize docstring in a proper way to be more easily indexable by lunr. doc = None source = epydoc2stan.ensure_parsed_docstring(ob) if source is not None: assert ob.parsed_docstring is not None try: doc = ' '.join(node2stan.gettext(ob.parsed_docstring.to_node())) except NotImplementedError: # some ParsedDocstring subclass raises NotImplementedError on calling to_node() # Like ParsedPlaintextDocstring. doc = source.docstring return doc def format_kind(self, ob:model.Documentable) -> str: return epydoc2stan.format_kind(ob.kind) if ob.kind else '' def get_corpus(self) -> List[Tuple[Dict[str, Optional[str]], Dict[str, int]]]: return [ ( { f:self.format(ob, f) for f in self.fields }, { "boost": self.get_ob_boost(ob) } ) for ob in (o for o in self.system.allobjects.values() if o.isVisible) ] def write(self) -> None: builder = get_default_builder() # Skip some pipelines for better UX # https://lunr.readthedocs.io/en/latest/customisation.html#skip-a-pipeline-function-for-specific-field-names # We want classes named like "For" to be indexed with their name, even if it's matching stop words. # We don't want "name" and related fields to be stemmed since we're stemming ourselves the name. # see https://github.com/twisted/pydoctor/issues/648 for why. for pipeline_function in builder.pipeline.registered_functions.values(): builder.pipeline.skip(pipeline_function, self._SKIP_PIPELINES) # Removing the stemmer from the search pipeline, see https://github.com/yeraydiazdiaz/lunr.py/issues/112 builder.search_pipeline.reset() index = lunr( ref='qname', fields=[{'field_name':name, 'boost':self._BOOSTS[name]} for name in self.fields], documents=self.get_corpus(), builder=builder) serialized_index = json.dumps(index.serialize()) with self.output_file.open('w', encoding='utf-8') as fobj: fobj.write(serialized_index) # https://lunr.readthedocs.io/en/latest/ def write_lunr_index(output_dir: Path, system: model.System) -> None: """ Write ``searchindex.json`` and ``fullsearchindex.json`` to the output directory. @arg output_dir: Output directory. @arg system: System. """ LunrIndexWriter(output_dir / "searchindex.json", system=system, fields=["name", "names", "qname"] ).write() LunrIndexWriter(output_dir / "fullsearchindex.json", system=system, fields=["name", "names", "qname", "docstring", "kind"] ).write() def stem_identifier(identifier: str) -> Iterator[str]: # we are stemming the identifier ourselves because # lunr is removing too much of important data. # See issue https://github.com/twisted/pydoctor/issues/648 yielded = set() parts = epydoc2stan._split_indentifier_parts_on_case(identifier) for p in parts: p = p.strip('_') if p: if p not in yielded: yielded.add(p) yield p searchpages: List[Type[Page]] = [AllDocuments] pydoctor-24.11.2/pydoctor/templatewriter/summary.py000066400000000000000000000434151473665144200225310ustar00rootroot00000000000000"""Classes that generate the summary pages.""" from __future__ import annotations from collections import defaultdict from string import Template from textwrap import dedent from typing import ( TYPE_CHECKING, DefaultDict, Dict, Iterable, List, Mapping, MutableSet, Sequence, Tuple, Type, Union, cast ) from twisted.web.template import Element, Tag, TagLoader, renderer, tags from pydoctor import epydoc2stan, model, linker from pydoctor.templatewriter import TemplateLookup, util from pydoctor.templatewriter.pages import Page if TYPE_CHECKING: from twisted.web.template import Flattenable def moduleSummary(module: model.Module, page_url: str) -> Tag: r: Tag = tags.li( tags.code(linker.taglink(module, page_url, label=module.name)), ' - ', epydoc2stan.format_summary(module) ) if module.isPrivate: r(class_='private') if not isinstance(module, model.Package): return r contents = list(module.submodules()) if not contents: return r ul = tags.ul() if len(contents) > 50 and not any(any(s.submodules()) for s in contents): # If there are more than 50 modules and no submodule has # further submodules we use a more compact presentation. li = tags.li(class_='compact-modules') for m in sorted(contents, key=util.alphabetical_order_func): span = tags.span() span(tags.code(linker.taglink(m, m.url, label=m.name))) span(', ') if m.isPrivate: span(class_='private') li(span) # remove the last trailing comma li.children[-1].children.pop() # type: ignore ul(li) else: for m in sorted(contents, key=util.alphabetical_order_func): ul(moduleSummary(m, page_url)) r(ul) return r def _lckey(x: model.Documentable) -> Tuple[str, str]: return (x.fullName().lower(), x.fullName()) class ModuleIndexPage(Page): filename = 'moduleIndex.html' def __init__(self, system: model.System, template_lookup: TemplateLookup): # Override L{Page.loader} because here the page L{filename} # does not equal the template filename. super().__init__(system=system, template_lookup=template_lookup, loader=template_lookup.get_loader('summary.html') ) def title(self) -> str: return "Module Index" @renderer def stuff(self, request: object, tag: Tag) -> Tag: tag.clear() tag([moduleSummary(o, self.filename) for o in self.system.rootobjects]) return tag @renderer def heading(self, request: object, tag: Tag) -> Tag: tag().clear() tag("Module Index") return tag def findRootClasses( system: model.System ) -> Sequence[Tuple[str, Union[model.Class, Sequence[model.Class]]]]: roots: Dict[str, Union[model.Class, List[model.Class]]] = {} for cls in system.objectsOfType(model.Class): if ' ' in cls.name or not cls.isVisible: continue if cls.bases: for name, base in zip(cls.bases, cls.baseobjects): if base is None or not base.isVisible: # The base object is in an external library or filtered out (not visible) # Take special care to avoid AttributeError: 'Class' object has no attribute 'append'. if isinstance(roots.get(name), model.Class): roots[name] = [cast(model.Class, roots[name])] cast(List[model.Class], roots.setdefault(name, [])).append(cls) elif base.system is not system: # Edge case with multiple systems, is it even possible to run into this code? roots[base.fullName()] = base else: # This is a common root class. roots[cls.fullName()] = cls return sorted(roots.items(), key=lambda x:x[0].lower()) def isPrivate(obj: model.Documentable) -> bool: """Is the object itself private or does it live in a private context?""" while not obj.isPrivate: parent = obj.parent if parent is None: return False obj = parent return True def isClassNodePrivate(cls: model.Class) -> bool: """Are a class and all its subclasses are private?""" if not isPrivate(cls): return False for sc in cls.subclasses: if not isClassNodePrivate(sc): return False return True def subclassesFrom( hostsystem: model.System, cls: model.Class, anchors: MutableSet[str], page_url: str ) -> Tag: r: Tag = tags.li() if isClassNodePrivate(cls): r(class_='private') name = cls.fullName() if name not in anchors: r(tags.a(name=name)) anchors.add(name) r(tags.div(tags.code(linker.taglink(cls, page_url)), ' - ', epydoc2stan.format_summary(cls))) scs = [sc for sc in cls.subclasses if sc.system is hostsystem and ' ' not in sc.fullName() and sc.isVisible] if len(scs) > 0: ul = tags.ul() for sc in sorted(scs, key=_lckey): ul(subclassesFrom(hostsystem, sc, anchors, page_url)) r(ul) return r class ClassIndexPage(Page): filename = 'classIndex.html' def __init__(self, system: model.System, template_lookup: TemplateLookup): # Override L{Page.loader} because here the page L{filename} # does not equal the template filename. super().__init__(system=system, template_lookup=template_lookup, loader=template_lookup.get_loader('summary.html') ) def title(self) -> str: return "Class Hierarchy" @renderer def stuff(self, request: object, tag: Tag) -> Tag: t = tag anchors: MutableSet[str] = set() for b, o in findRootClasses(self.system): if isinstance(o, model.Class): t(subclassesFrom(self.system, o, anchors, self.filename)) else: url = self.system.intersphinx.getLink(b) if url: link:"Flattenable" = linker.intersphinx_link(b, url) else: # TODO: we should find a way to use the pyval colorizer instead # of manually creating the intersphinx link, this would allow to support # linking to namedtuple(), proxyForInterface() and all other ast constructs. # But the issue is that we're using the string form of base objects in order # to compare and aggregate them, as a consequence we can't directly use the colorizer. # Another side effect is that subclasses of collections.namedtuple() and namedtuple() # (depending on how the name is imported) will not be aggregated under the same list item :/ link = b item = tags.li(tags.code(link)) if all(isClassNodePrivate(sc) for sc in o): # This is an external class used only by private API; # mark the whole node private. item(class_='private') if o: ul = tags.ul() for sc in sorted(o, key=_lckey): ul(subclassesFrom(self.system, sc, anchors, self.filename)) item(ul) t(item) return t @renderer def heading(self, request: object, tag: Tag) -> Tag: tag.clear() tag("Class Hierarchy") return tag class LetterElement(Element): def __init__(self, loader: TagLoader, initials: Mapping[str, Sequence[model.Documentable]], letter: str ): super().__init__(loader=loader) self.initials = initials self.my_letter = letter @renderer def letter(self, request: object, tag: Tag) -> Tag: tag(self.my_letter) return tag @renderer def letterlinks(self, request: object, tag: Tag) -> Tag: letterlinks: List["Flattenable"] = [] for initial in sorted(self.initials): if initial == self.my_letter: letterlinks.append(initial) else: letterlinks.append(tags.a(href='#'+initial)(initial)) letterlinks.append(' - ') if letterlinks: del letterlinks[-1] tag(letterlinks) return tag @renderer def names(self, request: object, tag: Tag) -> "Flattenable": def link(obj: model.Documentable) -> Tag: # The "data-type" attribute helps doc2dash figure out what # category (class, method, etc.) an object belongs to. attributes = {} if obj.kind: attributes["data-type"] = epydoc2stan.format_kind(obj.kind) return tags.code( linker.taglink(obj, NameIndexPage.filename), **attributes ) name2obs: DefaultDict[str, List[model.Documentable]] = defaultdict(list) for obj in self.initials[self.my_letter]: name2obs[obj.name].append(obj) r = [] for name in sorted(name2obs, key=lambda x:(x.lower(), x)): item: Tag = tag.clone()(name) obs = name2obs[name] if all(isPrivate(ob) for ob in obs): item(class_='private') if len(obs) == 1: item(' - ', link(obs[0])) else: ul = tags.ul() for ob in sorted(obs, key=_lckey): subitem = tags.li(link(ob)) if isPrivate(ob): subitem(class_='private') ul(subitem) item(ul) r.append(item) return r class NameIndexPage(Page): filename = 'nameIndex.html' def __init__(self, system: model.System, template_lookup: TemplateLookup): super().__init__(system=system, template_lookup=template_lookup) self.initials: Dict[str, List[model.Documentable]] = {} for ob in self.system.allobjects.values(): if ob.isVisible: self.initials.setdefault(ob.name[0].upper(), []).append(ob) def title(self) -> str: return "Index of Names" @renderer def heading(self, request: object, tag: Tag) -> Tag: return tag.clear()("Index of Names") @renderer def index(self, request: object, tag: Tag) -> "Flattenable": r = [] for i in sorted(self.initials): r.append(LetterElement(TagLoader(tag), self.initials, i)) return r class IndexPage(Page): filename = 'index.html' def title(self) -> str: return f"API Documentation for {self.system.projectname}" @renderer def roots(self, request: object, tag: Tag) -> "Flattenable": r = [] for o in self.system.rootobjects: r.append(tag.clone().fillSlots(root=tags.code( linker.taglink(o, self.filename) ))) return r @renderer def rootkind(self, request: object, tag: Tag) -> Tag: rootkinds = sorted(set([o.kind for o in self.system.rootobjects]), key=lambda k:k.name) return tag.clear()('/'.join( epydoc2stan.format_kind(o, plural=True).lower() for o in rootkinds )) def hasdocstring(ob: model.Documentable) -> bool: for source in ob.docsources(): if source.docstring is not None: return True return False class UndocumentedSummaryPage(Page): filename = 'undoccedSummary.html' def __init__(self, system: model.System, template_lookup: TemplateLookup): # Override L{Page.loader} because here the page L{filename} # does not equal the template filename. super().__init__(system=system, template_lookup=template_lookup, loader=template_lookup.get_loader('summary.html') ) def title(self) -> str: return "Summary of Undocumented Objects" @renderer def heading(self, request: object, tag: Tag) -> Tag: return tag.clear()("Summary of Undocumented Objects") @renderer def stuff(self, request: object, tag: Tag) -> Tag: undoccedpublic = [o for o in self.system.allobjects.values() if o.isVisible and not hasdocstring(o)] undoccedpublic.sort(key=lambda o:o.fullName()) for o in undoccedpublic: kind = o.kind assert kind is not None # 'kind is None' makes the object invisible tag(tags.li( epydoc2stan.format_kind(kind), " - ", tags.code(linker.taglink(o, self.filename)) )) return tag # TODO: The help page should dynamically include notes about the (source) code links. class HelpPage(Page): filename = 'apidocs-help.html' RST_SOURCE_TEMPLATE = Template(''' Navigation ---------- There is one page per class, module and package. Each page present summary table(s) which feature the members of the object. Package or Module page ~~~~~~~~~~~~~~~~~~~~~~~ Each of these pages has two main sections consisting of: - summary tables submodules and subpackages and the members of the module or in the ``__init__.py`` file. - detailed descriptions of function and attribute members. Class page ~~~~~~~~~~ Each class has its own separate page. Each of these pages has three main sections consisting of: - declaration, constructors, know subclasses and description - summary tables of members, including inherited - detailed descriptions of method and attribute members Entries in each of these sections are omitted if they are empty or not applicable. Module Index ~~~~~~~~~~~~ Provides a high level overview of the packages and modules structure. Class Hierarchy ~~~~~~~~~~~~~~~ Provides a list of classes organized by inheritance structure. Note that ``object`` is ommited. Index Of Names ~~~~~~~~~~~~~~ The Index contains an alphabetic index of all objects in the documentation. Search ------ You can search for definitions of modules, packages, classes, functions, methods and attributes. These items can be searched using part or all of the name and/or from their docstrings if "search in docstrings" is enabled. Multiple search terms can be provided separated by whitespace. The search is powered by `lunrjs `_. Indexing ~~~~~~~~ By default the search only matches on the name of the object. Enable the full text search in the docstrings with the checkbox option. You can instruct the search to look only in specific fields by passing the field name in the search like ``docstring:term``. **Possible fields are**: - ``name``, the name of the object (example: "MyClassAdapter" or "my_fmin_opti"). - ``qname``, the fully qualified name of the object (example: "lib.classses.MyClassAdapter"). - ``names``, the name splitted on camel case or snake case (example: "My Class Adapter" or "my fmin opti") - ``docstring``, the docstring of the object (example: "This is an adapter for HTTP json requests that logs into a file...") - ``kind``, can be one of: $kind_names Last two fields are only applicable if "search in docstrings" is enabled. Other search features ~~~~~~~~~~~~~~~~~~~~~ Term presence. The default behaviour is to give a better ranking to object matching multiple terms of your query, but still show entries that matches only one of the two terms. To change this behavour, you can use the sign ``+``. - To indicate a term must exactly match use the plus sing: ``+``. - To indicate a term must not match use the minus sing: ``-``. Wildcards A trailling wildcard is automatically added to each term of your query if they don't contain an explicit term presence (``+`` or ``-``). Searching for ``foo`` is the same as searching for ``foo*``. If the query include a dot (``.``), a leading wildcard will to also added, searching for ``model.`` is the same as ``*model.*`` and ``.model`` is the same as ``*.model*``. In addition to this automatic feature, you can manually add a wildcard anywhere else in the query. Query examples ~~~~~~~~~~~~~~ - "doc" matches "pydoctor.model.Documentable" and "pydoctor.model.DocLocation". - "+doc" matches "pydoctor.model.DocLocation" but won't match "pydoctor.model.Documentable". - "ensure doc" matches "pydoctor.epydoc2stan.ensure_parsed_docstring" and other object whose matches either "doc" or "ensure". - "inp str" matches "java.io.InputStream" and other object whose matches either "in" or "str". - "model." matches everything in the pydoctor.model module. - ".web.*tag" matches "twisted.web.teplate.Tag" and related. - "docstring:ansi" matches object whose docstring matches "ansi". ''') def title(self) -> str: return 'Help' @renderer def heading(self, request: object, tag: Tag) -> Tag: return tag.clear()("Help") @renderer def helpcontent(self, request: object, tag: Tag) -> Tag: from pydoctor.epydoc.markup import restructuredtext, ParseError from pydoctor.linker import NotFoundLinker errs: list[ParseError] = [] parsed = restructuredtext.parse_docstring(dedent(self.RST_SOURCE_TEMPLATE.substitute( kind_names=', '.join(f'"{k.name}"' for k in model.DocumentableKind) )), errs) assert not errs return parsed.to_stan(NotFoundLinker()) def summaryPages(system: model.System) -> Iterable[Type[Page]]: pages: list[type[Page]] = [ ModuleIndexPage, ClassIndexPage, NameIndexPage, UndocumentedSummaryPage, HelpPage, ] if len(system.root_names) > 1: pages.append(IndexPage) return pages pydoctor-24.11.2/pydoctor/templatewriter/util.py000066400000000000000000000216321473665144200220060ustar00rootroot00000000000000"""Miscellaneous utilities for the HTML writer.""" from __future__ import annotations import warnings from typing import (Any, Callable, Dict, Generic, Iterable, Iterator, List, Mapping, Optional, MutableMapping, Tuple, TypeVar, Union, Sequence, TYPE_CHECKING) from pydoctor import epydoc2stan import collections.abc from pydoctor import model if TYPE_CHECKING: from typing import Literal from twisted.web.template import Tag class DocGetter: """L{epydoc2stan} bridge.""" def get(self, ob: model.Documentable, summary: bool = False) -> Tag: if summary: return epydoc2stan.format_summary(ob) else: return epydoc2stan.format_docstring(ob) def get_type(self, ob: model.Documentable) -> Optional[Tag]: return epydoc2stan.type2stan(ob) def get_toc(self, ob: model.Documentable) -> Optional[Tag]: return epydoc2stan.format_toc(ob) def srclink(o: model.Documentable) -> Optional[str]: """ Get object source code URL, i.e. hosted on github. """ return o.sourceHref def css_class(o: model.Documentable) -> str: """ A short, lower case description for use as a CSS class in HTML. Includes the kind and privacy. """ kind = o.kind assert kind is not None # if kind is None, object is invisible class_ = epydoc2stan.format_kind(kind).lower().replace(' ', '') if o.privacyClass is model.PrivacyClass.PRIVATE: class_ += ' private' return class_ def overriding_subclasses( classobj: model.Class, name: str, firstcall: bool = True ) -> Iterator[model.Class]: """ Helper function to retreive the subclasses that override the given name from the parent class object. """ if not firstcall and name in classobj.contents: yield classobj else: for subclass in classobj.subclasses: if subclass.isVisible: yield from overriding_subclasses(subclass, name, firstcall=False) def nested_bases(classobj: model.Class) -> Iterator[Tuple[model.Class, ...]]: """ Helper function to retreive the complete list of base classes chains (represented by tuples) for a given Class. A chain of classes is used to compute the member inheritence from the first element to the last element of the chain. The first yielded chain only contains the Class itself. Then for each of the super-classes: - the next yielded chain contains the super class and the class itself, - the the next yielded chain contains the super-super class, the super class and the class itself, etc... """ _mro = classobj.mro() for i, _ in enumerate(_mro): yield tuple(reversed(_mro[:(i+1)])) def unmasked_attrs(baselist: Sequence[model.Class]) -> Sequence[model.Documentable]: """ Helper function to reteive the list of inherited children given a base classes chain (As yielded by L{nested_bases}). The returned members are inherited from the Class listed first in the chain to the Class listed last: they are not overriden in between. """ maybe_masking = { o.name for b in baselist[1:] for o in b.contents.values() } return [o for o in baselist[0].contents.values() if o.isVisible and o.name not in maybe_masking] def alphabetical_order_func(o: model.Documentable) -> Tuple[Any, ...]: """ Sort by privacy, kind and fullname. Callable to use as the value of standard library's L{sorted} function C{key} argument. """ return (-o.privacyClass.value, -_map_kind(o.kind).value if o.kind else 0, o.fullName().lower()) def source_order_func(o: model.Documentable) -> Tuple[Any, ...]: """ Sort by privacy, kind and linenumber. Callable to use as the value of standard library's L{sorted} function C{key} argument. """ if isinstance(o, model.Module): # Still sort modules by name since they all have the same linenumber. return (-o.privacyClass.value, -_map_kind(o.kind).value if o.kind else 0, o.fullName().lower()) else: return (-o.privacyClass.value, -_map_kind(o.kind).value if o.kind else 0, o.linenumber) # last implicit orderring is the order of insertion. def _map_kind(kind: model.DocumentableKind) -> model.DocumentableKind: if kind == model.DocumentableKind.PACKAGE: # packages and modules should be listed together return model.DocumentableKind.MODULE return kind def objects_order(order: 'Literal["alphabetical", "source"]') -> Callable[[model.Documentable], Tuple[Any, ...]]: """ Function to craft a callable to use as the value of standard library's L{sorted} function C{key} argument such that the objects are sorted by: Privacy, Kind first, then by Name or Linenumber depending on C{order} argument. Example:: children = sorted((o for o in ob.contents.values() if o.isVisible), key=objects_order("alphabetical")) """ if order == "alphabetical": return alphabetical_order_func elif order == "source": return source_order_func else: assert False def class_members(cls: model.Class) -> List[Tuple[Tuple[model.Class, ...], Sequence[model.Documentable]]]: """ Returns the members as well as the inherited members of a class. @returns: Tuples of tuple: C{inherited_via:Tuple[model.Class, ...], attributes:Sequence[model.Documentable]}. """ baselists = [] for baselist in nested_bases(cls): attrs = unmasked_attrs(baselist) if attrs: baselists.append((baselist, attrs)) return baselists def inherited_members(cls: model.Class) -> List[model.Documentable]: """ Returns only the inherited members of a class, as a plain list. """ children : List[model.Documentable] = [] for inherited_via,attrs in class_members(cls): if len(inherited_via)>1: children.extend(attrs) return children def templatefile(filename: str) -> None: """Deprecated: can be removed once Twisted stops patching this.""" warnings.warn("pydoctor.templatewriter.util.templatefile() " "is deprecated and returns None. It will be remove in future versions. " "Please use the templating system.") return None _VT = TypeVar('_VT') # Credits: psf/requests see https://github.com/psf/requests/blob/main/AUTHORS.rst class CaseInsensitiveDict(MutableMapping[str, _VT], Generic[_VT]): """A case-insensitive ``dict``-like object. Implements all methods and operations of ``collections.MutableMapping`` as well as dict's ``copy``. Also provides ``lower_items``. All keys are expected to be strings. The structure remembers the case of the last key to be set, and ``iter(instance)``, ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` will contain case-sensitive keys. However, querying and contains testing is case insensitive:: cid = CaseInsensitiveDict() cid['Accept'] = 'application/json' cid['aCCEPT'] == 'application/json' # True list(cid) == ['Accept'] # True For example, ``headers['content-encoding']`` will return the value of a ``'Content-Encoding'`` response header, regardless of how the header name was originally stored. If the constructor, ``.update``, or equality comparison operations are given keys that have equal ``.lower()``s, the behavior is undefined. """ def __init__(self, data: Optional[Union[Mapping[str, _VT], Iterable[Tuple[str, _VT]]]] = None, **kwargs: Any) -> None: self._store: Dict[str, Tuple[str, _VT]] = collections.OrderedDict() if data is None: data = {} self.update(data, **kwargs) def __setitem__(self, key: str, value: _VT) -> None: # Use the lowercased key for lookups, but store the actual # key alongside the value. self._store[key.lower()] = (key, value) def __getitem__(self, key: str) -> _VT: return self._store[key.lower()][1] def __delitem__(self, key: str) -> None: del self._store[key.lower()] def __iter__(self) -> Iterator[str]: return (casedkey for casedkey, mappedvalue in self._store.values()) def __len__(self) -> int: return len(self._store) def lower_items(self) -> Iterator[Tuple[str, _VT]]: """Like iteritems(), but with all lowercase keys.""" return ( (lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items() ) def __eq__(self, other: Any) -> bool: if isinstance(other, collections.abc.Mapping): other = CaseInsensitiveDict(other) # Compare insensitively return dict(self.lower_items()) == dict(other.lower_items()) else: return NotImplemented # Copy is required def copy(self) -> 'CaseInsensitiveDict[_VT]': return CaseInsensitiveDict(self._store.values()) def __repr__(self) -> str: return str(dict(self.items())) pydoctor-24.11.2/pydoctor/templatewriter/writer.py000066400000000000000000000144611473665144200223470ustar00rootroot00000000000000"""Badly named module that contains the driving code for the rendering.""" from __future__ import annotations import itertools from pathlib import Path import shutil from typing import IO, Iterable, Type, TYPE_CHECKING from pydoctor import model from pydoctor.extensions import zopeinterface from pydoctor.templatewriter import ( DOCTYPE, pages, summary, search, TemplateLookup, IWriter, StaticTemplate ) from twisted.python.failure import Failure from twisted.web.template import flattenString if TYPE_CHECKING: from twisted.web.template import Flattenable def flattenToFile(fobj: IO[bytes], elem: "Flattenable") -> None: """ This method writes a page to a HTML file. @raises Exception: If the L{twisted.web.template.flatten} call fails. """ fobj.write(DOCTYPE) err = None def e(r: Failure) -> None: nonlocal err err = r.value flattenString(None, elem).addCallback(fobj.write).addErrback(e) if err: raise err class TemplateWriter(IWriter): """ HTML templates writer. """ @classmethod def __subclasshook__(cls, subclass: Type[object]) -> bool: for name in dir(cls): if not name.startswith('_'): if not hasattr(subclass, name): return False return True def __init__(self, build_directory: Path, template_lookup: TemplateLookup): """ @arg build_directory: Build directory. @arg template_lookup: L{TemplateLookup} object. """ self.build_directory = build_directory """Build directory""" self.template_lookup: TemplateLookup = template_lookup """Writer's L{TemplateLookup} object""" self.written_pages: int = 0 self.total_pages: int = 0 self.dry_run: bool = False def prepOutputDirectory(self) -> None: """ Write static CSS and JS files to build directory. """ self.build_directory.mkdir(exist_ok=True, parents=True) for template in self.template_lookup.templates: if isinstance(template, StaticTemplate): template.write(self.build_directory) def writeIndividualFiles(self, obs: Iterable[model.Documentable]) -> None: """ Iterate through C{obs} and call L{_writeDocsFor} method for each L{Documentable}. """ self.dry_run = True for ob in obs: self._writeDocsFor(ob) self.dry_run = False for ob in obs: self._writeDocsFor(ob) def writeSummaryPages(self, system: model.System) -> None: import time for pclass in itertools.chain(summary.summaryPages(system), search.searchpages): system.msg('html', 'starting ' + pclass.__name__ + ' ...', nonl=True) T = time.time() page = pclass(system=system, template_lookup=self.template_lookup) with self.build_directory.joinpath(pclass.filename).open('wb') as fobj: flattenToFile(fobj, page) system.msg('html', "took %fs"%(time.time() - T), wantsnl=False) # Generate the searchindex.json file system.msg('html', 'starting lunr search index ...', nonl=True) T = time.time() search.write_lunr_index(self.build_directory, system=system) system.msg('html', "took %fs"%(time.time() - T), wantsnl=False) def writeLinks(self, system: model.System) -> None: if len(system.root_names) == 1: # If there is just a single root module it is written to index.html to produce nicer URLs. # To not break old links we also create a link from the full module name to the index.html # file. This is also good for consistency: every module is accessible by .html root_module_path = (self.build_directory / (list(system.root_names)[0] + '.html')) root_module_path.unlink(missing_ok=True) # introduced in Python 3.8 try: if system.options.use_hardlinks: # The use wants only harlinks, so simulate an OSError # to jump directly to the hardlink part. raise OSError() root_module_path.symlink_to('index.html') except (OSError, NotImplementedError): # symlink is not implemented for windows on pypy :/ hardlink_path = (self.build_directory / 'index.html') shutil.copy(hardlink_path, root_module_path) def _writeDocsFor(self, ob: model.Documentable) -> None: if not ob.isVisible: return if ob.documentation_location is model.DocLocation.OWN_PAGE: if self.dry_run: self.total_pages += 1 else: with self.build_directory.joinpath(ob.url).open('wb') as fobj: self._writeDocsForOne(ob, fobj) for o in ob.contents.values(): self._writeDocsFor(o) def _writeDocsForOne(self, ob: model.Documentable, fobj: IO[bytes]) -> None: if not ob.isVisible: return pclass: Type[pages.CommonPage] = pages.CommonPage class_name = ob.__class__.__name__ # Special case the zope interface custom renderer. # TODO: Find a better way of handling renderer customizations and get rid of ZopeInterfaceClassPage completely. if class_name == 'Class' and isinstance(ob, zopeinterface.ZopeInterfaceClass): class_name = 'ZopeInterfaceClass' try: # This implementation relies on 'pages.commonpages' dict that ties # documentable class name (i.e. 'Class') with the # page class used for rendering: pages.ClassPage pclass = pages.commonpages[class_name] except KeyError: ob.system.msg(section="html", # This is typically only reached in tests, when rendering Functions or Attributes with this method. msg=f"Could not find page class suitable to render object type: {class_name!r}, using CommonPage.", once=True, thresh=-2) ob.system.msg('html', str(ob), thresh=1) page = pclass(ob=ob, template_lookup=self.template_lookup) self.written_pages += 1 ob.system.progress('html', self.written_pages, self.total_pages, 'pages written') flattenToFile(fobj, page) pydoctor-24.11.2/pydoctor/test/000077500000000000000000000000001473665144200163625ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/__init__.py000066400000000000000000000045261473665144200205020ustar00rootroot00000000000000"""PyDoctor's test suite.""" from logging import LogRecord from typing import Iterable, TYPE_CHECKING, Sequence import sys import pytest from pathlib import Path from pydoctor import epydoc2stan, model from pydoctor.templatewriter import IWriter, TemplateLookup from pydoctor.linker import NotFoundLinker posonlyargs = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python 3.8") typecomment = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python 3.8") NotFoundLinker = NotFoundLinker # Because pytest 6.1 does not yet export types for fixtures, we define # approximations that are good enough for our test cases: if TYPE_CHECKING: from typing_extensions import Protocol class CapLog(Protocol): records: Sequence[LogRecord] class CaptureResult(Protocol): out: str err: str class CapSys(Protocol): def readouterr(self) -> CaptureResult: ... from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch from _pytest.tmpdir import TempPathFactory else: CapLog = CaptureResult = CapSys = object FixtureRequest = MonkeyPatch = TempPathFactory = object class InMemoryWriter(IWriter): """ Minimal template writer that doesn't touches the filesystem but will trigger the rendering of epydoc for the targeted code. """ def __init__(self, build_directory: Path, template_lookup: 'TemplateLookup') -> None: pass def prepOutputDirectory(self) -> None: """ Does nothing. """ def writeIndividualFiles(self, obs: Iterable[model.Documentable]) -> None: """ Trigger in memory rendering for all objects. """ for ob in obs: self._writeDocsFor(ob) def writeSummaryPages(self, system: model.System) -> None: """ Rig the system to not created the inter sphinx inventory. """ system.options.makeintersphinx = False def writeLinks(self, system: model.System) -> None: """ Does nothing. """ def _writeDocsFor(self, ob: model.Documentable) -> None: """ Trigger in memory rendering of the object. """ if not ob.isVisible: return epydoc2stan.format_docstring(ob) for o in ob.contents.values(): self._writeDocsFor(o) pydoctor-24.11.2/pydoctor/test/epydoc/000077500000000000000000000000001473665144200176455ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/epydoc/__init__.py000066400000000000000000000013671473665144200217650ustar00rootroot00000000000000# epydoc -- Regression testing # # Copyright (C) 2005 Edward Loper # Author: Edward Loper # URL: # from typing import List from pydoctor.epydoc.markup import ParseError, ParsedDocstring, get_parser_by_name import pydoctor.epydoc.markup def parse_docstring(doc: str, markup: str, processtypes: bool = False) -> ParsedDocstring: parse = get_parser_by_name(markup) if processtypes: if markup in ('google','numpy'): raise AssertionError("don't process types twice.") parse = pydoctor.epydoc.markup.processtypes(parse) errors: List[ParseError] = [] parsed = parse(doc, errors) assert not errors, [f"{e.linenum()}:{e.descr()}" for e in errors] return parsed pydoctor-24.11.2/pydoctor/test/epydoc/epytext.doctest000066400000000000000000000121441473665144200227400ustar00rootroot00000000000000Regression Testing for epytext ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ These tests were taken pretty much verbatim out of the old unittests from epydoc 2.1. They could use some serious updating, when I get the time, esp. given that it's so much easier to write tests with doctest than it was with unittest. >>> from pydoctor.test.epydoc.test_epytext import parse >>> import re >>> def testparse(s): ... out = parse(s) ... # This is basically word-wrapping: ... out = re.sub(r'(()+)', r'\1\n', out).rstrip() ... out = re.sub(r'(?m)^(.{50,70}>)(.)', r'\1\n\2', out).rstrip() ... return out Paragraphs: >>> print(testparse(""" ... this is one paragraph. ... ... This is ... another. ... ... This is a third""")) this is one paragraph. This is another. This is a third Make sure that unindented fields are allowed: >>> print(testparse(""" ... This is a paragraph. ... ... @foo: This is a field.""")) This is a paragraph. foo This is a field. >>> print(testparse(""" ... This is a paragraph. ... @foo: This is a field.""")) This is a paragraph. foo This is a field. >>> print(testparse(""" ... This is a paragraph. ... @foo: This is a field. ... Hello.""")) This is a paragraph. foo This is a field. Hello. >>> print(testparse("""Paragraph\n@foo: field""")) Paragraph foo field >>> print(testparse("""Paragraph\n\n@foo: field""")) Paragraph foo field >>> print(testparse("""\nParagraph\n@foo: field""")) Paragraph foo field Make sure that unindented lists are not allowed: >>> print(testparse(""" ... This is a paragraph. ... ... - This is a list item.""")) Traceback (most recent call last): StructuringError: Line 4: Lists must be indented. >>> print(testparse(""" ... This is a paragraph. ... - This is a list item.""")) Traceback (most recent call last): StructuringError: Line 3: Lists must be indented. >>> print(testparse(""" ... This is a paragraph. ... - This is a list item. ... Hello. ... - Sublist item""")) Traceback (most recent call last): StructuringError: Line 5: Lists must be indented. >>> print(testparse(""" ... This is a paragraph. ... - This is a list item. ... Hello. ... ... - Sublist item""")) Traceback (most recent call last): StructuringError: Line 6: Lists must be indented. >>> print(testparse("""Paragraph\n\n- list item""")) Traceback (most recent call last): StructuringError: Line 3: Lists must be indented. >>> print(testparse("""\nParagraph\n- list item""")) Traceback (most recent call last): StructuringError: Line 3: Lists must be indented. Special case if there's text on the same line as the opening quote: >>> print(testparse("""Paragraph\n- list item""")) Paragraph
  • list item
  • Make sure that indented lists are allowed: >>> print(testparse('This is a paragraph.\n - This is a list item.\n'+ ... 'This is a paragraph')) This is a paragraph.
  • This is a list item.
  • This is a paragraph >>> print(testparse('This is a paragraph.\n\n - This is a list item.'+ ... '\n\nThis is a paragraph')) This is a paragraph.
  • This is a list item.
  • This is a paragraph >>> print(testparse(""" ... This is a paragraph. ... ... - This is a list item. ... ... This is a paragraph""")) This is a paragraph.
  • This is a list item.
  • This is a paragraph >>> print(testparse(""" ... This is a paragraph. ... ... - This is a list item. ... This is a paragraph""")) This is a paragraph.
  • This is a list item.
  • This is a paragraph >>> print(testparse(""" ... - This is a list item."""))
  • This is a list item.
  • >>> print(testparse("""- This is a list item."""))
  • This is a list item.
  • >>> print(testparse("""\n- This is a list item."""))
  • This is a list item.
  • pydoctor-24.11.2/pydoctor/test/epydoc/restructuredtext.doctest000066400000000000000000000072431473665144200247020ustar00rootroot00000000000000Regression Testing for restructuredtext ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :RequireModule: docutils >>> from pydoctor.epydoc.markup import restructuredtext >>> from pydoctor.stanutils import flatten >>> def parse_and_print(s): ... errors = [] ... parsed = restructuredtext.parse_docstring(s, errors) ... for error in errors: ... print(f'ERROR: {error}') ... if parsed is None: ... print('EMPTY BODY') ... else: ... print(flatten(parsed.to_stan(None))) ... for field in parsed.fields: ... body = flatten(field.body().to_stan(None)) ... arg = field.arg() ... if arg is None: ... print(f'{field.tag()}: {body}') ... else: ... print(f'{field.tag()} "{arg}": {body}') Fields ====== >>> parse_and_print( ... """A test module ... ... :Version: 1.0 ... :Parameter i: integer ... """) A test module version: 1.0 parameter "i": integer >>> parse_and_print( ... """A test function ... ... :Parameters: a b c ... """) ERROR: Line 4: Unable to split consolidated field "Parameters" - does not contain a bulleted list or definition list. A test function newfield "parameters":

    Parameters

    parameters: a b c >>> parse_and_print( ... """A test function ... ... :exceptions: - `KeyError`: if the key is not found ... - `ValueError`: if the value is bad ... """) A test function except "KeyError": if the key is not found except "ValueError": if the value is bad >>> parse_and_print( ... """ ... Return the maximum speed for a fox. ... ... :Parameters: ... size ... The size of the fox (in meters) ... weight : float ... The weight of the fox (in stones) ... age : int ... The age of the fox (in years) ... """) Return the maximum speed for a fox. param "size": The size of the fox (in meters) param "weight": The weight of the fox (in stones) type "weight": float param "age": The age of the fox (in years) type "age": int Python code =========== reStructuredText markup defines a ``python`` directive to represent a block as colorized Python code. >>> err = [] >>> p = restructuredtext.parse_docstring( ... """A test module ... ... .. python:: ... ... # This is some Python code ... def foo(): ... pass ... ... class Foo: ... def __init__(self): ... pass ... """, err) >>> err [] >>> print(flatten(p.to_stan(None)))

    A test module

    # This is some Python code
    def foo():
        pass
    
    class Foo:
        def __init__(self):
            pass
    >>> p = restructuredtext.parse_docstring( ... """The directives options are ignored and do not show up in the HTML. ... ... .. code:: python ... :number-lines: ... :linenos: ... ... # This is some Python code ... def foo(): ... pass ... ... class Foo: ... def __init__(self): ... pass ... """, err) >>> err [] >>> print(flatten(p.to_stan(None)))

    The directives options are ignored and do not show up in the HTML.

    # This is some Python code
    def foo():
        pass
    
    class Foo:
        def __init__(self):
            pass
    pydoctor-24.11.2/pydoctor/test/epydoc/test_epytext.py000066400000000000000000000073451473665144200227710ustar00rootroot00000000000000from typing import List from pydoctor.epydoc.markup import DocstringLinker, ParseError, epytext from pydoctor.test import NotFoundLinker from pydoctor.stanutils import flatten def epytext2html(s: str, linker: DocstringLinker = NotFoundLinker()) -> str: errs: List[ParseError] = [] v = flatten(epytext.parse_docstring(s, errs).to_stan(linker)) if errs: raise errs[0] return (v or '').rstrip() def parse(s: str) -> str: errors: List[ParseError] = [] element = epytext.parse(s, errors) if element is None: raise errors[0] else: # this strips off the ... return ''.join(str(n) for n in element.children) def test_basic_list() -> None: P1 = "This is a paragraph." P2 = "This is a \nparagraph." LI1 = " - This is a list item." LI2 = "\n - This is a list item." LI3 = " - This is a list\n item." LI4 = "\n - This is a list\n item." PARA = ('This is a paragraph.') ONELIST = ('
  • This is a ' 'list item.
  • ') TWOLIST = ('
  • This is a ' 'list item.
  • This is a ' 'list item.
  • ') for p in (P1, P2): for li1 in (LI1, LI2, LI3, LI4): assert parse(li1) == ONELIST assert parse(f'{p}\n{li1}') == PARA+ONELIST assert parse(f'{li1}\n{p}') == ONELIST+PARA assert parse(f'{p}\n{li1}\n{p}') == PARA+ONELIST+PARA for li2 in (LI1, LI2, LI3, LI4): assert parse(f'{li1}\n{li2}') == TWOLIST assert parse(f'{p}\n{li1}\n{li2}') == PARA+TWOLIST assert parse(f'{li1}\n{li2}\n{p}') == TWOLIST+PARA assert parse(f'{p}\n{li1}\n{li2}\n{p}') == PARA+TWOLIST+PARA LI5 = " - This is a list item.\n\n It contains two paragraphs." LI5LIST = ('
  • This is a list item.' 'It contains two paragraphs.
  • ') assert parse(LI5) == LI5LIST assert parse(f'{P1}\n{LI5}') == PARA+LI5LIST assert parse(f'{P2}\n{LI5}\n{P1}') == PARA+LI5LIST+PARA LI6 = (" - This is a list item with a literal block::\n" " hello\n there") LI6LIST = ('
  • This is a list item with a literal ' 'block: hello\n there' '
  • ') assert parse(LI6) == LI6LIST assert parse(f'{P1}\n{LI6}') == PARA+LI6LIST assert parse(f'{P2}\n{LI6}\n{P1}') == PARA+LI6LIST+PARA def test_item_wrap() -> None: LI = "- This is a list\n item." ONELIST = ('
  • This is a ' 'list item.
  • ') TWOLIST = ('
  • This is a ' 'list item.
  • This is a ' 'list item.
  • ') for indent in ('', ' '): for nl1 in ('', '\n'): assert parse(nl1+indent+LI) == ONELIST for nl2 in ('\n', '\n\n'): assert parse(nl1+indent+LI+nl2+indent+LI) == TWOLIST def test_literal_braces() -> None: """SF bug #1562530 reported some trouble with literal braces. This test makes sure that braces are getting rendered as desired. """ assert epytext2html("{1:{2:3}}") == '{1:{2:3}}' assert epytext2html("C{{1:{2:3}}}") == '{1:{2:3}}' assert epytext2html("{1:C{{2:3}}}") == '{1:{2:3}}' assert epytext2html("{{{}{}}{}}") == '{{{}{}}{}}' assert epytext2html("{{E{lb}E{lb}E{lb}}}") == '{{{{{}}' def test_slugify() -> None: assert epytext.slugify("Héllo Wörld 1.2.3") == "hello-world-123" pydoctor-24.11.2/pydoctor/test/epydoc/test_epytext2html.py000066400000000000000000000260621473665144200237350ustar00rootroot00000000000000""" Test how epytext is transformed to HTML using L{ParsedDocstring.to_node()} and L{node2stan.node2stan()} functions. Many of these test cases are adapted examples from U{the epytext documentation}. """ from typing import List import pytest from pydoctor.epydoc.markup import ParseError, ParsedDocstring from pydoctor.stanutils import flatten from pydoctor.epydoc.markup.epytext import parse_docstring from pydoctor.node2stan import node2stan from pydoctor.test import NotFoundLinker from pydoctor.test.epydoc.test_restructuredtext import prettify from docutils import nodes, __version_info__ as docutils_version_info def parse_epytext(s: str) -> ParsedDocstring: errors: List[ParseError] = [] parsed = parse_docstring(s, errors) assert not errors return parsed def epytext2node(s: str)-> nodes.document: return parse_epytext(s).to_node() def epytext2html(s: str) -> str: return squash(flatten(node2stan(epytext2node(s), NotFoundLinker()))) def squash(s: str) -> str: return ''.join(l.strip() for l in prettify(s).splitlines()) def test_epytext_paragraph() -> None: doc = ''' This is a paragraph. Paragraphs can span multiple lines, and can contain I{inline markup}. This is another paragraph. Paragraphs are separated by blank lines. ''' expected = '''

    This is a paragraph. Paragraphs can span multiple lines, and can contain inline markup .

    This is another paragraph. Paragraphs are separated by blank lines.

    ''' assert epytext2html(doc) == squash(expected) def test_epytext_ordered_list() -> None: doc = ''' 1. This is an ordered list item. 2. This is another ordered list item. 3. This is a third list item. Note that the paragraph may be indented more than the bullet. This ends the list. 4. This new list starts at four. ''' expected = '''
    1. This is an ordered list item.
    2. This is another ordered list item.
    3. This is a third list item. Note that the paragraph may be indented more than the bullet.

    This ends the list.

    1. This new list starts at four.
    ''' assert epytext2html(doc) == squash(expected) def test_epytext_nested_list() -> None: doc = ''' This is a paragraph. 1. This is a list item. 2. This is a second list item. - This is a sublist. ''' expected = '''

    This is a paragraph.

    1. This is a list item.
    2. This is a second list item.
      • This is a sublist.
    ''' assert epytext2html(doc) == squash(expected) def test_epytext_complex_list() -> None: doc = ''' This is a paragraph. 1. This is a list item. - This is a sublist. - The sublist contains two items. - The second item of the sublist has its own sublist. 2. This list item contains two paragraphs and a doctest block. >>> len('This is a doctest block') 23 This is the second paragraph. ''' expected = '''

    This is a paragraph.

    1. This is a list item.

      • This is a sublist.
      • The sublist contains two items.
        • The second item of the sublist has its own sublist.
    2. This list item contains two paragraphs and a doctest block.

      >>> 
              len('This is a doctest block')
              23

      This is the second paragraph.

    ''' assert epytext2html(doc) == squash(expected) def test_epytext_sections() -> None: doc = ''' This paragraph is not in any section. Section 1 ========= This is a paragraph in section 1. Section 1.1 ----------- This is a paragraph in section 1.1. Section 2 ========= This is a paragraph in section 2. ''' expected = '''

    This paragraph is not in any section.

    Section 1

    This is a paragraph in section 1.

    Section 1.1

    This is a paragraph in section 1.1.

    Section 2

    This is a paragraph in section 2.

    ''' assert epytext2html(doc) == squash(expected) def test_epytext_literal_block() -> None: doc = ''' The following is a literal block:: Literal / / Block This is a paragraph following the literal block. ''' expected = '''

    The following is a literal block:

        Literal /
               / Block
    

    This is a paragraph following the literal block.

    ''' assert epytext2html(doc) == squash(expected) def test_epytext_inline() -> None: doc = ''' I{B{Inline markup} may be nested; and it may span} multiple lines. - I{Italicized text} - B{Bold-faced text} - C{Source code} - Math: M{m*x+b} Without the capital letter, matching braces are not interpreted as markup: C{my_dict={1:2, 3:4}}. ''' expected = '''

    Inline markup may be nested; and it may span multiple lines.

    • Italicized text
    • Bold-faced text
    • Source code
    • Math: m * x + b

    Without the capital letter, matching braces are not interpreted as markup: my_dict={1:2, 3:4} .

    ''' assert epytext2html(doc) == squash(expected) def test_epytext_url() -> None: doc = ''' - U{www.python.org} - U{http://www.python.org} - U{The epydoc homepage} - U{The B{I{Python}} homepage } - U{Edward Loper} ''' expected = '''
    ''' assert epytext2html(doc) == squash(expected) def test_epytext_symbol() -> None: doc = ''' Symbols can be used in equations: - S{sum}S{alpha}/x S{<=} S{beta} S{<-} and S{larr} both give left arrows. Some other arrows are S{rarr}, S{uarr}, and S{darr}. ''' expected = '''

    Symbols can be used in equations:

    • α /x β

    and both give left arrows. Some other arrows are , , and .

    ''' assert epytext2html(doc) == squash(expected) def test_nested_markup() -> None: """ The Epytext nested inline markup are correctly transformed to HTML. """ doc = ''' I{B{Inline markup} may be nested; and it may span} multiple lines. ''' expected = ''' Inline markup may be nested; and it may spanmultiple lines.''' assert epytext2html(doc) == squash(expected) doc = ''' It becomes a little bit complicated with U{B{custom} links } ''' expected = ''' It becomes a little bit complicated withcustomlinks ''' assert epytext2html(doc) == squash(expected) # From docutils 0.18 the toc entries uses different ids. @pytest.mark.skipif(docutils_version_info < (0,18), reason="HTML ids in toc tree changed in docutils 0.18.0.") def test_get_toc() -> None: docstring = """ Titles ====== Level 2 ------- Level 3 ~~~~~~~ Level 4 ^^^^^^^ Level 5 !!!!!!! Level 2.2 --------- Level 22 -------- Lists ===== Other ===== """ errors: List[ParseError] = [] parsed = parse_docstring(docstring, errors) assert not errors, [str(e.descr()) for e in errors] toc = parsed.get_toc(4) assert toc is not None html = flatten(toc.to_stan(NotFoundLinker())) expected_html="""
  • Titles

  • Lists
  • Other
  • """ assert prettify(html) == prettify(expected_html) pydoctor-24.11.2/pydoctor/test/epydoc/test_epytext2node.py000066400000000000000000000025421473665144200237130ustar00rootroot00000000000000from pydoctor.test.epydoc.test_epytext2html import epytext2node def test_nested_markup() -> None: """ The Epytext nested inline markup are correctly transformed to L{docutils} nodes. """ doc = ''' I{B{Inline markup} may be nested; and it may span} multiple lines. ''' expected = ''' Inline markup may be nested; and it may span multiple lines. ''' assert epytext2node(doc).pformat() == expected doc = ''' It becomes a little bit complicated with U{B{custom} links } ''' expected = ''' It becomes a little bit complicated with custom links ''' assert epytext2node(doc).pformat() == expected doc = ''' It becomes a little bit complicated with L{B{custom} links } ''' expected = ''' It becomes a little bit complicated with custom links ''' assert epytext2node(doc).pformat() == expected pydoctor-24.11.2/pydoctor/test/epydoc/test_google_numpy.py000066400000000000000000000110551473665144200237640ustar00rootroot00000000000000from typing import List from pydoctor.epydoc.markup import ParseError from unittest import TestCase from pydoctor.test import NotFoundLinker from pydoctor.model import Attribute, Class, Module, System, Function from pydoctor.stanutils import flatten from pydoctor.epydoc2stan import _objclass from pydoctor.epydoc.markup.google import get_parser as get_google_parser from pydoctor.epydoc.markup.numpy import get_parser as get_numpy_parser class TestGetParser(TestCase): def test_get_google_parser_attribute(self) -> None: obj = Attribute(system = System(), name='attr1') parse_docstring = get_google_parser(_objclass(obj)) docstring = """\ numpy.ndarray: super-dooper attribute""" errors: List[ParseError] = [] parsed_doc = parse_docstring(docstring, errors) actual = flatten(parsed_doc.fields[-1].body().to_stan(NotFoundLinker())) expected = """numpy.ndarray""" self.assertEqual(expected, actual) self.assertEqual(errors, []) def test_get_google_parser_not_attribute(self) -> None: obj = Function(system = System(), name='whatever') parse_docstring = get_google_parser(_objclass(obj)) docstring = """\ numpy.ndarray: super-dooper attribute""" errors: List[ParseError] = [] assert not parse_docstring(docstring, errors).fields # the numpy inline attribute parsing is the same as google-style # as shown in the example_numpy.py from Sphinx docs def test_get_numpy_parser_attribute(self) -> None: obj = Attribute(system = System(), name='attr1') parse_docstring = get_numpy_parser(_objclass(obj)) docstring = """\ numpy.ndarray: super-dooper attribute""" errors: List[ParseError] = [] parsed_doc = parse_docstring(docstring, errors) actual = flatten(parsed_doc.fields[-1].body().to_stan(NotFoundLinker())) expected = """numpy.ndarray""" self.assertEqual(expected, actual) self.assertEqual(errors, []) def test_get_numpy_parser_not_attribute(self) -> None: obj = Function(system = System(), name='whatever') parse_docstring = get_numpy_parser(_objclass(obj)) docstring = """\ numpy.ndarray: super-dooper attribute""" errors: List[ParseError] = [] assert not parse_docstring(docstring, errors).fields def test_get_parser_for_modules_does_not_generates_ivar(self) -> None: obj = Module(system = System(), name='thing') parse_docstring = get_google_parser(_objclass(obj)) docstring = """\ Attributes: i: struff j: thing """ errors: List[ParseError] = [] parsed_doc = parse_docstring(docstring, errors) assert [f.tag() for f in parsed_doc.fields] == ['var', 'var'] def test_get_parser_for_classes_generates_ivar(self) -> None: obj = Class(system = System(), name='thing') parse_docstring = get_google_parser(_objclass(obj)) docstring = """\ Attributes: i: struff j: thing """ errors: List[ParseError] = [] parsed_doc = parse_docstring(docstring, errors) assert [f.tag() for f in parsed_doc.fields] == ['ivar', 'ivar'] class TestWarnings(TestCase): def test_warnings(self) -> None: obj = Function(system = System(), name='func') parse_docstring = get_numpy_parser(_objclass(obj)) docstring = """ Description of the function. Some more text. Some more text. Some more text. Some more text. Args ---- my attr: 'bar or 'foo' super-dooper attribute a valid typed param: List[Union[str, bytes]] Description. other: {hello Choices. Returns ------- 'spam' or 'baz, optional A string. Note ---- Some more text. """ errors: List[ParseError] = [] parse_docstring(docstring, errors) self.assertEqual(len(errors), 3) self.assertIn("malformed string literal (missing closing quote)", errors[2].descr()) self.assertIn("invalid value set (missing closing brace)", errors[1].descr()) self.assertIn("malformed string literal (missing opening quote)", errors[0].descr()) self.assertEqual(errors[2].linenum(), 21) # #FIXME: It should be 23 actually... self.assertEqual(errors[1].linenum(), 18) self.assertEqual(errors[0].linenum(), 14) pydoctor-24.11.2/pydoctor/test/epydoc/test_parsed_docstrings.py000066400000000000000000000031741473665144200250000ustar00rootroot00000000000000""" Test generic features of ParsedDocstring. """ from typing import List from twisted.web.template import Tag from pydoctor.epydoc.markup import ParsedDocstring, ParseError from pydoctor.stanutils import flatten from pydoctor.epydoc.markup.plaintext import parse_docstring from pydoctor.test.epydoc.test_epytext2html import parse_epytext from pydoctor.test.epydoc.test_restructuredtext import parse_rst, prettify from pydoctor.test import NotFoundLinker def parse_plaintext(s: str) -> ParsedDocstring: errors: List[ParseError] = [] parsed = parse_docstring(s, errors) assert not errors return parsed def flatten_(stan: Tag) -> str: return ''.join(l.strip() for l in prettify(flatten(stan)).splitlines()) def test_to_node_to_stan_caching() -> None: """ Test if we get the same value again and again. """ epy = parse_epytext('Just some B{strings}') assert epy.to_node() == epy.to_node() == epy.to_node() assert flatten_(epy.to_stan(NotFoundLinker())) == flatten_(epy.to_stan(NotFoundLinker())) == flatten_(epy.to_stan(NotFoundLinker())) rst = parse_rst('Just some **strings**') assert rst.to_node() == rst.to_node() == rst.to_node() assert flatten_(rst.to_stan(NotFoundLinker())) == flatten_(rst.to_stan(NotFoundLinker())) == flatten_(rst.to_stan(NotFoundLinker())) plain = parse_plaintext('Just some **strings**') # ParsedPlaintextDocstring does not currently implement to_node() # assert plain.to_node() == plain.to_node() == plain.to_node() assert flatten_(plain.to_stan(NotFoundLinker())) == flatten_(plain.to_stan(NotFoundLinker())) == flatten_(plain.to_stan(NotFoundLinker())) pydoctor-24.11.2/pydoctor/test/epydoc/test_pyval_repr.py000066400000000000000000001261501473665144200234460ustar00rootroot00000000000000import ast from functools import partial import sys from textwrap import dedent from typing import Any, Union import xml.sax import pytest from pydoctor.epydoc.markup._pyval_repr import PyvalColorizer, colorize_inline_pyval from pydoctor.test import NotFoundLinker from pydoctor.stanutils import flatten, flatten_text, html2stan from pydoctor.node2stan import gettext def color(v: Any, linebreakok:bool=True, maxlines:int=5, linelen:int=40) -> str: colorizer = PyvalColorizer(linelen=linelen, linebreakok=linebreakok, maxlines=maxlines) parsed_doc = colorizer.colorize(v) return parsed_doc.to_node().pformat() def colorhtml(v: Any, linebreakok:bool=True, maxlines:int=5, linelen:int=40) -> str: colorizer = PyvalColorizer(linelen=linelen, linebreakok=linebreakok, maxlines=maxlines) parsed_doc = colorizer.colorize(v) return flatten(parsed_doc.to_stan(NotFoundLinker())) def test_simple_types() -> None: """ Integers, floats, None, and complex numbers get printed using str, with no syntax highlighting. """ assert color(1) == """ 1\n""" assert color(0) == """ 0\n""" assert color(100) == """ 100\n""" assert color(1./4) == """ 0.25\n""" assert color(None) == """ None\n""" def test_long_numbers() -> None: """ Long ints will get wrapped if they're big enough. """ assert color(10000000) == """ 10000000\n""" assert color(10**90) == """ 1000000000000000000000000000000000000000 ↵ 0000000000000000000000000000000000000000 ↵ 00000000000\n""" def test_strings() -> None: """ Strings have their quotation marks tagged as 'quote'. Characters are escaped using the 'string-escape' encoding. """ assert color(bytes(range(255)), maxlines=9999) == r""" b ''' \x00\x01\x02\x03\x04\x05\x06\x07\x08 \t \x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x 15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x 1f !"#$%&\'()*+,-./0123456789:;<=>?@ABCD EFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijk lmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\ x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\ x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\ x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\ xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\ xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\ xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\ xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\ xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\ xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\ xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\ xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\ xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\ xfc\xfd\xfe ''' """ def test_non_breaking_spaces() -> None: """ This test might fail in the future, when twisted's XMLString supports XHTML entities (see https://github.com/twisted/twisted/issues/11581). But it will always fail for python 3.6 since twisted dropped support for these versions of python. """ with pytest.raises(xml.sax.SAXParseException): colorhtml(ast.parse('"These are non-breaking spaces."').body[0].value) == """""" # type:ignore with pytest.raises(xml.sax.SAXParseException): assert colorhtml("These are non-breaking spaces.") == """""" def test_strings_quote() -> None: """ Currently, the "'" quote is always used, because that's what the 'string-escape' encoding expects. """ assert color('Hello') == """ ' Hello ' """ assert color('"Hello"') == """ ' "Hello" ' """ assert color("'Hello'") == r""" ' \'Hello\' ' """ def test_strings_special_chars() -> None: assert color("'abc \t\r\n\f\v \xff 😀'\x0c\x0b\t\r \\") == r""" ''' \'abc \t\r \f\v ÿ 😀\'\f\v\t\r \\ ''' """ def test_strings_multiline() -> None: """Strings containing newlines are automatically rendered as multiline strings.""" assert color("This\n is a multiline\n string!") == """ ''' This is a multiline string! '''\n""" # Unless we ask for them not to be: assert color("This\n is a multiline\n string!", linebreakok=False) == r""" ' This\n is a multiline\n string! ' """ def test_bytes_multiline() -> None: # The same should work also for binary strings (bytes): assert color(b"This\n is a multiline\n string!") == """ b ''' This is a multiline string! '''\n""" assert color(b"This\n is a multiline\n string!", linebreakok=False) == r""" b ' This\n is a multiline\n string! ' """ def test_unicode_str() -> None: """Unicode strings are handled properly. """ assert color("\uaaaa And \ubbbb") == """ ' ꪪ And 뮻 '\n""" assert color("ÉéèÈÜÏïü") == """ ' ÉéèÈÜÏïü '\n""" def test_bytes_str() -> None: """ Binary strings (bytes) are handled properly:""" assert color(b"Hello world") == """ b ' Hello world '\n""" assert color(b"\x00 And \xff") == r""" b ' \x00 And \xff ' """ def test_inline_list() -> None: """Lists, tuples, and sets are all colorized using the same method. The braces and commas are tagged with "op". If the value can fit on the current line, it is displayed on one line. Otherwise, each value is listed on a separate line, indented by the size of the open-bracket.""" assert color(list(range(10))) == """ [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 ]\n""" def test_multiline_list() -> None: assert color(list(range(100))) == """ [ 0 , 1 , 2 , 3 , 4 , ...\n""" def test_multiline_list2() -> None: assert color([1,2,[5,6,[(11,22,33),9],10],11]+[99,98,97,96,95]) == """ [ 1 , 2 , [ 5 , 6 , [ ( 11 , 22 , 33 ) , 9 ] , 10 ] , 11 , 99 , ...\n""" def test_multiline_set() -> None: assert color(set(range(20))) == """ set([ 0 , 1 , 2 , 3 , 4 , ...\n""" def test_frozenset() -> None: assert color(frozenset([1, 2, 3])) == """ frozenset([ 1 , 2 , 3 ])\n""" def test_custom_live_object() -> None: class Custom: def __repr__(self) -> str: return '123' assert color(Custom()) == """ 123\n""" def test_buggy_live_object() -> None: class Buggy: def __repr__(self) -> str: raise NotImplementedError() assert color(Buggy()) == """ ??\n""" def test_tuples_one_value() -> None: """Tuples that contains only one value need an ending comma.""" assert color((1,)) == """ ( 1 ,) """ def extract_expr(_ast: ast.Module) -> ast.AST: elem = _ast.body[0] assert isinstance(elem, ast.Expr) return elem.value def test_ast_constants() -> None: assert color(extract_expr(ast.parse(dedent(""" 'Hello' """)))) == """ ' Hello '\n""" def test_ast_unary_op() -> None: assert color(extract_expr(ast.parse(dedent(""" not True """)))) == """ not True\n""" assert color(extract_expr(ast.parse(dedent(""" +3.0 """)))) == """ + 3.0\n""" assert color(extract_expr(ast.parse(dedent(""" -3.0 """)))) == """ - 3.0\n""" assert color(extract_expr(ast.parse(dedent(""" ~3.0 """)))) == """ ~ 3.0\n""" def test_ast_bin_op() -> None: assert color(extract_expr(ast.parse(dedent(""" 2.3*6 """)))) == """ 2.3 * 6\n""" assert color(extract_expr(ast.parse(dedent(""" (3-6)*2 """)))) == """ ( 3 - 6 ) * 2\n""" assert color(extract_expr(ast.parse(dedent(""" 101//4+101%4 """)))) == """ 101 // 4 + 101 % 4\n""" assert color(extract_expr(ast.parse(dedent(""" 1 & 0 """)))) == """ 1 & 0\n""" assert color(extract_expr(ast.parse(dedent(""" 1 | 0 """)))) == """ 1 | 0\n""" assert color(extract_expr(ast.parse(dedent(""" 1 ^ 0 """)))) == """ 1 ^ 0\n""" assert color(extract_expr(ast.parse(dedent(""" 1 << 0 """)))) == """ 1 << 0\n""" assert color(extract_expr(ast.parse(dedent(""" 1 >> 0 """)))) == """ 1 >> 0\n""" assert color(extract_expr(ast.parse(dedent(""" H @ beta """)))) == """ H @ beta\n""" def test_operator_precedences() -> None: assert color(extract_expr(ast.parse(dedent(""" (2 ** 3) ** 2 """)))) == """ ( 2 ** 3 ) ** 2\n""" assert color(extract_expr(ast.parse(dedent(""" 2 ** 3 ** 2 """)))) == """ 2 ** ( 3 ** 2 )\n""" assert color(extract_expr(ast.parse(dedent(""" (1 + 2) * 3 / 4 """)))) == """ ( 1 + 2 ) * 3 / 4\n""" assert color(extract_expr(ast.parse(dedent(""" ((1 + 2) * 3) / 4 """)))) == """ ( 1 + 2 ) * 3 / 4\n""" assert color(extract_expr(ast.parse(dedent(""" (1 + 2) * 3 / 4 """)))) == """ ( 1 + 2 ) * 3 / 4\n""" assert color(extract_expr(ast.parse(dedent(""" 1 + 2 * 3 / 4 - 1 """)))) == """ 1 + 2 * 3 / 4 - 1\n""" def test_ast_bool_op() -> None: assert color(extract_expr(ast.parse(dedent(""" True and 9 """)))) == """ True and 9\n""" assert color(extract_expr(ast.parse(dedent(""" 1 or 0 and 2 or 3 or 1 """)))) == """ 1 or 0 and 2 or 3 or 1\n""" def test_ast_list_tuple() -> None: assert color(extract_expr(ast.parse(dedent(""" [1,2,[5,6,[(11,22,33),9],10],11]+[99,98,97,96,95] """)))) == """ [ 1 , 2 , [ 5 , 6 , [ ( 11 , 22 , 33 ) , 9 ] , 10 ] , 11 ] + [ 99 , 98 , 97 , 96 , 95 ]\n""" assert color(extract_expr(ast.parse(dedent(""" (('1', 2, 3.14), (4, '5', 6.66)) """)))) == """ ( ( ' 1 ' , 2 , 3.14 ) , ( 4 , ' 5 ' , 6.66 ) )\n""" def test_ast_dict() -> None: """ Dictionnaries are treated just like lists. """ assert color(extract_expr(ast.parse(dedent(""" {'1':33, '2':[1,2,3,{7:'oo'*20}]} """))), linelen=45) == """ { ' 1 ' : 33 , ' 2 ' : [ 1 , 2 , 3 , { 7 : ' oo ' * 20 } ] }\n""" def test_ast_annotation() -> None: assert color(extract_expr(ast.parse(dedent(""" bar[typing.Sequence[dict[str, bytes]]] """))), linelen=999) == """ bar [ typing.Sequence [ dict [ str , bytes ] ] ]\n""" def test_ast_call() -> None: assert color(extract_expr(ast.parse(dedent(""" list(range(100)) """)))) == """ list ( range ( 100 ) )\n""" def test_ast_call_args() -> None: assert color(extract_expr(ast.parse(dedent(""" list(func(1, *two, three=2, **args)) """)))) == """ list ( func ( 1 , * two , three = 2 , ** args ) )\n""" def test_ast_ellipsis() -> None: assert color(extract_expr(ast.parse(dedent(""" ... """)))) == """ ...\n""" def test_ast_set() -> None: assert color(extract_expr(ast.parse(dedent(""" {1, 2} """)))) == """ set([ 1 , 2 ])\n""" assert color(extract_expr(ast.parse(dedent(""" set([1, 2]) """)))) == """ set ( [ 1 , 2 ] )\n""" def test_ast_slice() -> None: assert color(extract_expr(ast.parse(dedent(""" o[x:y] """)))) == """ o [ x:y ]\n""" assert color(extract_expr(ast.parse(dedent(""" o[x:y,z] """)))) == """ o [ x:y, (z) ]\n""" if sys.version_info < (3,9) else """ o [ x:y , z ]\n""" def test_ast_attribute() -> None: assert color(extract_expr(ast.parse(dedent(""" mod.attr """)))) == (""" mod.attr\n""") # ast.Attribute nodes that contains something else as ast.Name nodes are not handled explicitely. assert color(extract_expr(ast.parse(dedent(""" func().attr """)))) == (""" func().attr\n""") def test_ast_regex() -> None: # invalid arguments assert color(extract_expr(ast.parse(dedent(r""" re.compile(invalidarg='[A-Za-z0-9]+') """)))) == """ re.compile ( invalidarg = ' [A-Za-z0-9]+ ' )\n""" # invalid arguments 2 assert color(extract_expr(ast.parse(dedent(""" re.compile() """)))) == """ re.compile ( )\n""" # invalid arguments 3 assert color(extract_expr(ast.parse(dedent(""" re.compile(None) """)))) == """ re.compile ( None )\n""" # cannot colorize regex, be can't infer value assert color(extract_expr(ast.parse(dedent(""" re.compile(get_re()) """)))) == """ re.compile ( get_re ( ) )\n""" # cannot colorize regex, not a valid regex assert color(extract_expr(ast.parse(dedent(""" re.compile(r"[.*") """)))) == """ re.compile ( ' [.* ' )\n""" # actually colorize regex, with flags assert color(extract_expr(ast.parse(dedent(""" re.compile(r"[A-Za-z0-9]+", re.X) """)))) == """ re.compile ( r ' [ A - Z a - z 0 - 9 ] + ' , re.X )\n""" def color_re(s: Union[bytes, str], check_roundtrip:bool=True) -> str: colorizer = PyvalColorizer(linelen=55, maxlines=5) val = colorizer.colorize(extract_expr(ast.parse(f"re.compile({repr(s)})"))) if check_roundtrip: raw_text = ''.join(gettext(val.to_node())) re_begin = 13 raw_string = True if raw_text[11] != 'r': # the regex has failed to be colorized since we can't find the r prefix # meaning the string has been rendered as plaintext instead. raw_string = False re_begin -= 1 if isinstance(s, bytes): re_begin += 1 re_end = -2 round_trip: Union[bytes, str] = raw_text[re_begin:re_end] if isinstance(s, bytes): assert isinstance(round_trip, str) round_trip = bytes(round_trip, encoding='utf-8') expected = s if not raw_string: assert isinstance(expected, str) # we only test invalid regexes with strings currently expected = expected.replace('\\', '\\\\') assert round_trip == expected, "%s != %s" % (repr(round_trip), repr(s)) return flatten(val.to_stan(NotFoundLinker()))[17:-8] def test_re_literals() -> None: # Literal characters assert color_re(r'abc \t\r\n\f\v \xff \uffff', False) == r"""r'abc \t\r\n\f\v \xff \uffff'""" assert color_re(r'\.\^\$\\\*\+\?\{\}\[\]\|\(\)\'') == r"""r'\.\^\$\\\*\+\?\{\}\[\]\|\(\)\''""" # Any character & character classes assert color_re(r".\d\D\s\S\w\W\A^$\b\B\Z") == r"""r'.\d\D\s\S\w\W\A^$\b\B\Z'""" def test_re_branching() -> None: # Branching assert color_re(r"foo|bar") == """r'foo|bar'""" def test_re_char_classes() -> None: # Character classes assert color_re(r"[abcd]") == """r'[abcd]'""" def test_re_repeats() -> None: # Repeats assert color_re(r"a*b+c{4,}d{,5}e{3,9}f?") == ("""r'a*""" """b+c{4,}""" """d{,5}e{3,9}""" """f?'""") assert color_re(r"a*?b+?c{4,}?d{,5}?e{3,9}?f??") == ("""r'a*?""" """b+?c{4,}?""" """d{,5}?e{3,9}?""" """f??'""") def test_re_subpatterns() -> None: # Subpatterns assert color_re(r"(foo (bar) | (baz))") == ("""r'(""" """foo (bar) """ """| (""" """baz))""" """'""") assert color_re(r"(?:foo (?:bar) | (?:baz))") == ("""r'(?:""" """foo (?:bar) | """ """(?:baz))'""") assert color_re(r"(<)?(\w+@\w+(?:\.\w+)+)") == ("""r'(<""" """)?""" r"""(\w+@\w""" r"""+(?:\.\w""" """+)+""" """)'""") assert color_re("(foo (?Pbar) | (?Pbaz))") == ("""r'(""" """foo (?P<""" """a>bar) """ """| (?P<""" """boop>""" """baz))""" """'""") def test_re_references() -> None: # Group References assert color_re(r"(...) and (\1)") == ("""r'(...""" """) and (""" r"""\1)""" """'""") def test_re_ranges() -> None: # Ranges assert color_re(r"[a-bp-z]") == ("""r'[a""" """-bp-z""" """]'""") assert color_re(r"[^a-bp-z]") == ("""r'[""" """^a-bp""" """-z]""" """'""") assert color_re(r"[^abc]") == ("""r'[""" """^abc]""" """'""") def test_re_lookahead_behinds() -> None: # Lookahead/behinds assert color_re(r"foo(?=bar)") == ("""r'foo(?=""" """bar)'""") assert color_re(r"foo(?!bar)") == ("""r'foo(?!""" """bar)'""") assert color_re(r"(?<=bar)foo") == ("""r'(?<=""" """bar)foo'""") assert color_re(r"(?'(?<!""" """bar)foo'""") def test_re_flags() -> None: # Flags assert color_re(r"(?imu)^Food") == """r'(?imu)^Food'""" assert color_re(b"(?Limsx)^Food") == """rb'(?Limsx)^Food'""" assert color_re(b"(?Limstx)^Food") == """rb'(?Limstx)^Food'""" assert color_re(r"(?imstux)^Food") == """r'(?imstux)^Food'""" assert color_re(r"(?x)This is verbose", False) == """r'(?ux)Thisisverbose'""" def test_unsupported_regex_features() -> None: """ Because pydoctor uses the regex engine of python 3.6, it does not support the latest features introduced in python3.11 like atomic groupping and possesive qualifiers. But still, we should not crash. """ regexes = ['e*+e', '(e?){2,4}+a', r"^(\w){1,2}+$", # "^x{}+$", this one fails to round-trip :/ r'a++', r'(?:ab)++', r'(?:ab){1,3}+', r'(?>x++)x', r'(?>a{1,3})', r'(?>(?:ab){1,3})', ] for r in regexes: color_re(r) def test_re_not_literal() -> None: assert color_re(r"[^0-9]") == """r'[^0-9]'""" def test_re_named_groups() -> None: # This regex triggers some weird behaviour: it adds the ↵ element at the end where it should not be... # The regex is 42 caracters long, so more than 40, maybe that's why? # assert color_re(r'^<(?P.*) at (?P0x[0-9a-f]+)>$') == """""" assert color_re(r'^<(?P.*)>$') == """r'^<(?P<descr>.*)>$'""" def test_re_multiline() -> None: assert color(extract_expr(ast.parse(dedent(r'''re.compile(r"""\d + # the integral part \. # the decimal point \d * # some fractional digits""")''')))) == r""" re.compile ( ''' \\d + # the integral part \\. # the decimal point \\d * # some fractional digits ''' ↵ ) """ assert color(extract_expr(ast.parse(dedent(r'''re.compile(rb"""\d + # the integral part \. # the decimal point \d * # some fractional digits""")'''))), linelen=70) == r""" re.compile ( b ''' \\d + # the integral part \\. # the decimal point \\d * # some fractional digits ''' ) """ def test_line_wrapping() -> None: # If a line goes beyond linelen, it is wrapped using the ``↵`` element. # Check that the last line gets a ``↵`` when maxlines is exceeded: assert color('x'*1000) == """ ' xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...\n""" # If linebreakok is False, then line wrapping gives an ellipsis instead: assert color('x'*100, linebreakok=False) == """ ' xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...\n""" def color2(v: Any, linelen:int=50) -> str: """ Pain text colorize. """ colorizer = PyvalColorizer(linelen=linelen, maxlines=5) colorized = colorizer.colorize(v) text1 = ''.join(gettext(colorized.to_node())) text2 = flatten_text(html2stan(flatten(colorized.to_stan(NotFoundLinker())))) assert text1 == text2 return text2 def test_crash_surrogates_not_allowed() -> None: """ Test that the colorizer does not make the flatten function crash when passing surrogates unicode strings. """ assert color2('surrogates:\udc80\udcff') == "'surrogates:\\udc80\\udcff'" def test_surrogates_cars_in_re() -> None: """ Regex string are escaped their own way. See https://github.com/twisted/pydoctor/pull/493 """ assert color2(extract_expr(ast.parse("re.compile('surrogates:\\udc80\\udcff')"))) == "re.compile(r'surrogates:\\udc80\\udcff')" def test_repr_text() -> None: """Test a few representations, with a plain text version. """ class A: pass assert color2('hello') == "'hello'" assert color2(["hello", 123]) == "['hello', 123]" assert color2(A()) == ('.A object>') assert color2([A()]) == ('[.A object>]') assert color2([A(),1,2,3,4,5,6,7]) == ('[.A object>,\n' ' 1,\n' ' 2,\n' ' 3,\n' '...') def test_summary() -> None: """To generate summary-reprs, use maxlines=1 and linebreakok=False: """ summarizer = PyvalColorizer(linelen=60, maxlines=1, linebreakok=False) def summarize(v:Any) -> str: return(''.join(gettext(summarizer.colorize(v).to_node()))) assert summarize(list(range(100))) == "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16..." assert summarize('hello\nworld') == r"'hello\nworld'" assert summarize('hello\nworld'*100) == r"'hello\nworldhello\nworldhello\nworldhello\nworldhello\nw..." def test_refmap_explicit() -> None: """ The refmap argument allow to change the target of some links before the linker resolves them. """ doc = colorize_inline_pyval(extract_expr(ast.parse('Type[MyInt, str]')), refmap = { 'Type':'typing.Type', 'MyInt': '.MyInt'}) tree = doc.to_node() dump = tree.pformat() assert '' in dump assert '' in dump assert '' in dump def check_src_roundtrip(src:str, subtests:Any) -> None: # from cpython/Lib/test/test_unparse.py with subtests.test(msg="round trip", src=src): mod = ast.parse(src) assert len(mod.body)==1 expr = mod.body[0] assert isinstance(expr, ast.Expr) code = color2(expr.value) assert code==src def test_expressions_parens(subtests:Any) -> None: check_src = partial(check_src_roundtrip, subtests=subtests) check_src("1 << (10 | 1) << 1") check_src("int | float | complex | None") check_src("list[int | float | complex | None]") check_src("list[int | float | complex | None, int | None]") check_src("1 + 1") check_src("1 + 2 / 3") check_src("(1 + 2) / 3") check_src("(1 + 2) * 3 + 4 * (5 + 2)") check_src("(1 + 2) * 3 + 4 * (5 + 2) ** 2") check_src("~x") check_src("x and y") check_src("x and y and z") check_src("x and (y and x)") check_src("(x and y) and z") # cpython tests expected '(x**y)**z**q', # but too much reasonning is needed to obtain this result, # because the power operator is reassociative... check_src("(x ** y) ** (z ** q)") check_src("((x ** y) ** z) ** q") check_src("x >> y") check_src("x << y") check_src("x >> y and x >> z") check_src("x + y - z * q ^ t ** k") check_src("flag & (other | foo)") check_src("(x if x else y).C") check_src("not (x == y)") if sys.version_info>=(3,8): check_src("(a := b)") if sys.version_info >= (3,11): check_src("(lambda: int)()") else: check_src("(lambda : int)()") if sys.version_info > (3,9): check_src("3 .__abs__()") check_src("await x") check_src("x if x else y") check_src("lambda x: x") check_src("x == (not y)") check_src("P * V if P and V else n * R * T") check_src("lambda P, V, n: P * V == n * R * T") else: check_src("(3).__abs__()") if sys.version_info>=(3,7): check_src("(await x)") check_src("(x if x else y)") check_src("(lambda x: x)") check_src("(x == (not y))") check_src("(P * V if P and V else n * R * T)") check_src("(lambda P, V, n: P * V == n * R * T)") check_src("f(**x)") check_src("{**x}") check_src("(-1) ** 7") check_src("(-1.0) ** 8") check_src("(-1j) ** 6") check_src("not True or False") check_src("True or not False") check_src("f(**([] or 5))") check_src("{**([] or 5)}") check_src("{**(~{})}") check_src("{**(not {})}") check_src("{**({} == {})}") check_src("{**{'y': 2}, 'x': 1, None: True}") check_src("{**{'y': 2}, **{'x': 1}}") pydoctor-24.11.2/pydoctor/test/epydoc/test_restructuredtext.py000066400000000000000000000266451473665144200247330ustar00rootroot00000000000000from typing import List from textwrap import dedent from pydoctor.epydoc.markup import DocstringLinker, ParseError, ParsedDocstring, get_parser_by_name from pydoctor.epydoc.markup.restructuredtext import parse_docstring from pydoctor.test import NotFoundLinker from pydoctor.node2stan import node2stan from pydoctor.stanutils import flatten, flatten_text from docutils import nodes, __version_info__ as docutils_version_info from bs4 import BeautifulSoup import pytest def prettify(html: str) -> str: return BeautifulSoup(html, features="html.parser").prettify() # type: ignore[no-any-return] def parse_rst(s: str) -> ParsedDocstring: errors: List[ParseError] = [] parsed = parse_docstring(s, errors) assert not errors return parsed def rst2html(docstring: str, linker: DocstringLinker = NotFoundLinker()) -> str: """ Render a docstring to HTML. """ return flatten(parse_rst(docstring).to_stan(linker)) def node2html(node: nodes.Node, oneline: bool = True) -> str: if oneline: return ''.join(prettify(flatten(node2stan(node, NotFoundLinker()))).splitlines()) else: return flatten(node2stan(node, NotFoundLinker())) def rst2node(s: str) -> nodes.document: return parse_rst(s).to_node() def test_rst_partial() -> None: """ The L{node2html()} function can convert fragment of a L{docutils} document, it's not restricted to actual L{docutils.nodes.document} object. Really, any nodes can be passed to that function, the only requirement is that the node's C{document} attribute is set to a valid L{docutils.nodes.document} object. """ doc = dedent(''' This is a paragraph. Paragraphs can span multiple lines, and can contain `inline markup`. This is another paragraph. Paragraphs are separated by blank lines. ''') expected = dedent('''

    This is another paragraph. Paragraphs are separated by blank lines.

    ''').lstrip() node = rst2node(doc) for child in node[:]: assert isinstance(child, nodes.paragraph) assert node2html(node[-1], oneline=False) == expected assert node[-1].parent == node def test_rst_body_empty() -> None: src = """ :return: a number :rtype: int """ errors: List[ParseError] = [] pdoc = parse_docstring(src, errors) assert not errors assert not pdoc.has_body assert len(pdoc.fields) == 2 def test_rst_body_nonempty() -> None: src = """ Only body text, no fields. """ errors: List[ParseError] = [] pdoc = parse_docstring(src, errors) assert not errors assert pdoc.has_body assert len(pdoc.fields) == 0 def test_rst_anon_link_target_missing() -> None: src = """ This link's target is `not defined anywhere`__. """ errors: List[ParseError] = [] parse_docstring(src, errors) assert len(errors) == 1 assert errors[0].descr().startswith("Anonymous hyperlink mismatch:") assert errors[0].is_fatal() def test_rst_anon_link_email() -> None: src = "``__" html = rst2html(src) assert html.startswith('
    mailto:postmaster@example.net') def test_rst_xref_with_target() -> None: src = "`mapping `" html = rst2html(src) assert html.startswith('mapping') def test_rst_xref_implicit_target() -> None: src = "`func()`" html = rst2html(src) assert html.startswith('func()') def test_rst_directive_adnomitions() -> None: expected_html_multiline="""

    {}

    this is the first line

    and this is the second line

    """ expected_html_single_line = """

    {}

    this is a single line

    """ admonition_map = { 'Attention': 'attention', 'Caution': 'caution', 'Danger': 'danger', 'Error': 'error', 'Hint': 'hint', 'Important': 'important', 'Note': 'note', 'Tip': 'tip', 'Warning': 'warning', } for title, admonition_name in admonition_map.items(): # Multiline docstring = (".. {}::\n" "\n" " this is the first line\n" " \n" " and this is the second line\n" ).format(admonition_name) expect = expected_html_multiline.format( admonition_name, title ) actual = rst2html(docstring) assert prettify(expect)==prettify(actual) # Single line docstring = (".. {}:: this is a single line\n" ).format(admonition_name) expect = expected_html_single_line.format( admonition_name, title ) actual = rst2html(docstring) assert prettify(expect)==prettify(actual) def test_rst_directive_versionadded() -> None: """ It renders the C{versionadded} RST directive using a custom markup with dedicated CSS classes. """ html = rst2html(".. versionadded:: 0.6") expected_html="""
    New in version 0.6.
    """ assert html==expected_html, html def test_rst_directive_versionchanged() -> None: """ It renders the C{versionchanged} RST directive with custom markup and supports an extra text besides the version information. """ html = rst2html(""".. versionchanged:: 0.7 Add extras""") expected_html="""
    Changed in version 0.7: Add extras
    """ assert html==expected_html, html def test_rst_directive_deprecated() -> None: """ It renders the C{deprecated} RST directive with custom markup and supports an extra text besides the version information. """ html = rst2html(""".. deprecated:: 0.2 For security reasons""") expected_html="""
    Deprecated since version 0.2: For security reasons
    """ assert html==expected_html, html def test_rst_directive_seealso() -> None: html = rst2html(".. seealso:: Hey") expected_html = """

    See Also

    Hey

    """ assert prettify(html).strip() == prettify(expected_html).strip(), html @pytest.mark.parametrize( 'markup', ('epytext', 'plaintext', 'restructuredtext', 'numpy', 'google') ) def test_summary(markup:str) -> None: """ Summaries are generated from the inline text inside the first paragraph. The text is trimmed as soon as we reach a break point (or another docutils element) after 200 characters. """ cases = [ ("Single line", "Single line"), ("Single line.", "Single line."), ("Single line with period.", "Single line with period."), (""" Single line with period. @type: Also with a tag. """, "Single line with period."), ("Other lines with period.\nThis is attached", "Other lines with period. This is attached"), ("Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. ", "Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line..."), ("Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line. Single line Single line Single line ", "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line..."), ("Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line.", "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line."), (""" Return a fully qualified name for the possibly-dotted name. To explain what this means, consider the following modules... blabla""", "Return a fully qualified name for the possibly-dotted name.") ] for src, summary_text in cases: errors: List[ParseError] = [] pdoc = get_parser_by_name(markup)(dedent(src), errors) assert not errors assert pdoc.get_summary() == pdoc.get_summary() # summary is cached inside ParsedDocstring as well. assert flatten_text(pdoc.get_summary().to_stan(NotFoundLinker())) == summary_text # From docutils 0.18 the toc entries uses different ids. @pytest.mark.skipif(docutils_version_info < (0,18), reason="HTML ids in toc tree changed in docutils 0.18.0.") def test_get_toc() -> None: docstring = """ Titles ====== Level 2 ------- Level 3 ~~~~~~~ Level 4 ^^^^^^^ Level 5 !!!!!!! Level 2.2 --------- Level 22 -------- Lists ===== Other ===== """ errors: List[ParseError] = [] parsed = parse_docstring(docstring, errors) assert not errors, [str(e.descr) for e in errors] toc = parsed.get_toc(4) assert toc is not None html = flatten(toc.to_stan(NotFoundLinker())) expected_html="""
  • Titles

  • Lists
  • Other
  • """ assert prettify(html) == prettify(expected_html) pydoctor-24.11.2/pydoctor/test/test_astbuilder.py000066400000000000000000003241471473665144200221440ustar00rootroot00000000000000from typing import Optional, Tuple, Type, List, overload, cast import ast import sys from pydoctor import astbuilder, astutils, model from pydoctor import epydoc2stan from pydoctor.epydoc.markup import DocstringLinker, ParsedDocstring from pydoctor.options import Options from pydoctor.stanutils import flatten, html2stan, flatten_text from pydoctor.epydoc.markup.epytext import Element, ParsedEpytextDocstring from pydoctor.epydoc2stan import _get_docformat, format_summary, get_parsed_type from pydoctor.test.test_packages import processPackage from pydoctor.utils import partialclass from . import CapSys, NotFoundLinker, posonlyargs, typecomment import pytest class SimpleSystem(model.System): """ A system with no extensions. """ extensions:List[str] = [] class ZopeInterfaceSystem(model.System): """ A system with only the zope interface extension enabled. """ extensions = ['pydoctor.extensions.zopeinterface'] class DeprecateSystem(model.System): """ A system with only the twisted deprecated extension enabled. """ extensions = ['pydoctor.extensions.deprecate'] class PydanticSystem(model.System): # Add our custom extension as extra custom_extensions = ['pydoctor.test.test_pydantic_fields'] class AttrsSystem(model.System): """ A system with only the attrs extension enabled. """ extensions = ['pydoctor.extensions.attrs'] systemcls_param = pytest.mark.parametrize( 'systemcls', (model.System, # system with all extensions enalbed ZopeInterfaceSystem, # system with zopeinterface extension only DeprecateSystem, # system with deprecated extension only SimpleSystem, # system with no extensions PydanticSystem, AttrsSystem, ) ) def fromText( text: str, *, modname: str = '', is_package: bool = False, parent_name: Optional[str] = None, system: Optional[model.System] = None, systemcls: Type[model.System] = model.System ) -> model.Module: if system is None: _system = systemcls() else: _system = system assert _system is not None if parent_name is None: full_name = modname else: full_name = f'{parent_name}.{modname}' builder = _system.systemBuilder(_system) builder.addModuleString(text, modname, parent_name, is_package=is_package) builder.buildModules() mod = _system.allobjects[full_name] assert isinstance(mod, model.Module) return mod def unwrap(parsed_docstring: Optional[ParsedDocstring]) -> str: if parsed_docstring is None: raise TypeError("parsed_docstring cannot be None") if not isinstance(parsed_docstring, ParsedEpytextDocstring): raise TypeError(f"parsed_docstring must be a ParsedEpytextDocstring instance, not {parsed_docstring.__class__.__name__}") epytext = parsed_docstring._tree assert epytext is not None assert epytext.tag == 'epytext' assert len(epytext.children) == 1 para = epytext.children[0] assert isinstance(para, Element) assert para.tag == 'para' assert len(para.children) == 1 value = para.children[0] assert isinstance(value, str) return value def to_html( parsed_docstring: ParsedDocstring, linker: DocstringLinker = NotFoundLinker() ) -> str: return flatten(parsed_docstring.to_stan(linker)) @overload def type2str(type_expr: None) -> None: ... @overload def type2str(type_expr: ast.expr) -> str: ... def type2str(type_expr: Optional[ast.expr]) -> Optional[str]: if type_expr is None: return None else: from .epydoc.test_pyval_repr import color2 return color2(type_expr) def type2html(obj: model.Documentable) -> str: """ Uses the NotFoundLinker. """ parsed_type = get_parsed_type(obj) assert parsed_type is not None return to_html(parsed_type).replace('', '').replace('\n', '') def ann_str_and_line(obj: model.Documentable) -> Tuple[str, int]: """Return the textual representation and line number of an object's type annotation. @param obj: Documentable object with a type annotation. """ ann = obj.annotation # type: ignore[attr-defined] assert ann is not None return type2str(ann), ann.lineno def test_node2fullname() -> None: """The node2fullname() function finds the full (global) name for a name expression in the AST. """ mod = fromText(''' class session: from twisted.conch.interfaces import ISession ''', modname='test') def lookup(expr: str) -> Optional[str]: node = ast.parse(expr, mode='eval') assert isinstance(node, ast.Expression) return astbuilder.node2fullname(node.body, mod) # None is returned for non-name nodes. assert lookup('123') is None # Local names are returned with their full name. assert lookup('session') == 'test.session' # A name that has no match at the top level is returned as-is. assert lookup('nosuchname') == 'nosuchname' # Unknown names are resolved as far as possible. assert lookup('session.nosuchname') == 'test.session.nosuchname' # Aliases are resolved on local names. assert lookup('session.ISession') == 'twisted.conch.interfaces.ISession' # Aliases are resolved on global names. assert lookup('test.session.ISession') == 'twisted.conch.interfaces.ISession' @systemcls_param def test_no_docstring(systemcls: Type[model.System]) -> None: # Inheritance of the docstring of an overridden method depends on # methods with no docstring having None in their 'docstring' field. mod = fromText(''' def f(): pass class C: def m(self): pass ''', modname='test', systemcls=systemcls) f = mod.contents['f'] assert f.docstring is None m = mod.contents['C'].contents['m'] assert m.docstring is None @systemcls_param def test_function_simple(systemcls: Type[model.System]) -> None: src = ''' """ MOD DOC """ def f(): """This is a docstring.""" ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 func, = mod.contents.values() assert func.fullName() == '.f' assert func.docstring == """This is a docstring.""" assert isinstance(func, model.Function) assert func.is_async is False @systemcls_param def test_function_async(systemcls: Type[model.System]) -> None: src = ''' """ MOD DOC """ async def a(): """This is a docstring.""" ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 func, = mod.contents.values() assert func.fullName() == '.a' assert func.docstring == """This is a docstring.""" assert isinstance(func, model.Function) assert func.is_async is True @pytest.mark.parametrize('signature', ( '()', '(*, a, b=None)', '(*, a=(), b)', '(a, b=3, *c, **kw)', '(f=True)', '(x=0.1, y=-2)', r"(s='theory', t='con\'text')", )) @systemcls_param def test_function_signature(signature: str, systemcls: Type[model.System]) -> None: """ A round trip from source to inspect.Signature and back produces the original text. @note: Our inspect.Signature Paramters objects are now tweaked such that they might produce HTML tags, handled by the L{PyvalColorizer}. """ mod = fromText(f'def f{signature}: ...', systemcls=systemcls) docfunc, = mod.contents.values() assert isinstance(docfunc, model.Function) # This little trick makes it possible to back reproduce the original signature from the genrated HTML. text = flatten_text(html2stan(str(docfunc.signature))) assert text == signature @posonlyargs @pytest.mark.parametrize('signature', ( '(x, y, /)', '(x, y=0, /)', '(x, y, /, z, w)', '(x, y, /, z, w=42)', '(x, y, /, z=0, w=0)', '(x, y=3, /, z=5, w=7)', '(x, /, *v, a=1, b=2)', '(x, /, *, a=1, b=2, **kwargs)', )) @systemcls_param def test_function_signature_posonly(signature: str, systemcls: Type[model.System]) -> None: test_function_signature(signature, systemcls) @pytest.mark.parametrize('signature', ( '(a, a)', )) @systemcls_param def test_function_badsig(signature: str, systemcls: Type[model.System], capsys: CapSys) -> None: """When a function has an invalid signature, an error is logged and the empty signature is returned. Note that most bad signatures lead to a SyntaxError, which we cannot recover from. This test checks what happens if the AST can be produced but inspect.Signature() rejects the parsed parameters. """ mod = fromText(f'def f{signature}: ...', systemcls=systemcls, modname='mod') docfunc, = mod.contents.values() assert isinstance(docfunc, model.Function) assert str(docfunc.signature) == '()' captured = capsys.readouterr().out assert captured.startswith("mod:1: mod.f has invalid parameters: ") @systemcls_param def test_class(systemcls: Type[model.System]) -> None: src = ''' class C: def f(): """This is a docstring.""" ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 cls, = mod.contents.values() assert cls.fullName() == '.C' assert cls.docstring == None assert len(cls.contents) == 1 func, = cls.contents.values() assert func.fullName() == '.C.f' assert func.docstring == """This is a docstring.""" @systemcls_param def test_class_with_base(systemcls: Type[model.System]) -> None: src = ''' class C: def f(): """This is a docstring.""" class D(C): def f(): """This is a docstring.""" ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 2 clsC, clsD = mod.contents.values() assert clsC.fullName() == '.C' assert clsC.docstring == None assert len(clsC.contents) == 1 assert clsD.fullName() == '.D' assert clsD.docstring == None assert len(clsD.contents) == 1 assert isinstance(clsD, model.Class) assert len(clsD.bases) == 1 base, = clsD.bases assert base == '.C' @systemcls_param def test_follow_renaming(systemcls: Type[model.System]) -> None: src = ''' class C: pass D = C class E(D): pass ''' mod = fromText(src, systemcls=systemcls) C = mod.contents['C'] E = mod.contents['E'] assert isinstance(C, model.Class) assert isinstance(E, model.Class) assert E.baseobjects == [C], E.baseobjects @systemcls_param def test_relative_import_in_package(systemcls: Type[model.System]) -> None: """Relative imports in a package must be resolved by going up one level less, since we don't count "__init__.py" as a level. Hierarchy:: top: def f - pkg: imports f and g - mod: def g """ top_src = ''' def f(): pass ''' mod_src = ''' def g(): pass ''' pkg_src = ''' from .. import f from .mod import g ''' system = systemcls() top = fromText(top_src, modname='top', is_package=True, system=system) mod = fromText(mod_src, modname='top.pkg.mod', system=system) pkg = fromText(pkg_src, modname='pkg', parent_name='top', is_package=True, system=system) assert pkg.resolveName('f') is top.contents['f'] assert pkg.resolveName('g') is mod.contents['g'] @systemcls_param @pytest.mark.parametrize('level', (1, 2, 3, 4)) def test_relative_import_past_top( systemcls: Type[model.System], level: int, capsys: CapSys ) -> None: """A warning is logged when a relative import goes beyond the top-level package. """ system = systemcls() fromText('', modname='pkg', is_package=True, system=system) fromText(f''' from {'.' * level + 'X'} import A ''', modname='mod', parent_name='pkg', system=system) captured = capsys.readouterr().out if level == 1: assert 'relative import level' not in captured else: assert f'pkg.mod:2: relative import level ({level}) too high\n' in captured @systemcls_param def test_class_with_base_from_module(systemcls: Type[model.System]) -> None: src = ''' from X.Y import A from Z import B as C class D(A, C): def f(): """This is a docstring.""" ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 clsD, = mod.contents.values() assert clsD.fullName() == '.D' assert clsD.docstring == None assert len(clsD.contents) == 1 assert isinstance(clsD, model.Class) assert len(clsD.bases) == 2 base1, base2 = clsD.bases assert base1 == 'X.Y.A' assert base2 == 'Z.B' src = ''' import X import Y.Z as M class D(X.A, X.B.C, M.C): def f(): """This is a docstring.""" ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 clsD, = mod.contents.values() assert clsD.fullName() == '.D' assert clsD.docstring == None assert len(clsD.contents) == 1 assert isinstance(clsD, model.Class) assert len(clsD.bases) == 3 base1, base2, base3 = clsD.bases assert base1 == 'X.A', base1 assert base2 == 'X.B.C', base2 assert base3 == 'Y.Z.C', base3 @systemcls_param def test_aliasing(systemcls: Type[model.System]) -> None: def addsrc(system: model.System) -> None: src_private = ''' class A: pass ''' src_export = ''' from _private import A as B __all__ = ['B'] ''' src_user = ''' from public import B class C(B): pass ''' fromText(src_private, modname='_private', system=system) fromText(src_export, modname='public', system=system) fromText(src_user, modname='app', system=system) system = systemcls() addsrc(system) C = system.allobjects['app.C'] assert isinstance(C, model.Class) # An older version of this test expected _private.A as the result. # The expected behavior was changed because: # - relying on on-demand processing of other modules is unreliable when # there are cyclic imports: expandName() on a module that is still being # processed can return the not-found result for a name that does exist # - code should be importing names from their official home, so if we # import public.B then for the purposes of documentation public.B is # the name we should use assert C.bases == ['public.B'] @systemcls_param def test_more_aliasing(systemcls: Type[model.System]) -> None: def addsrc(system: model.System) -> None: src_a = ''' class A: pass ''' src_b = ''' from a import A as B ''' src_c = ''' from b import B as C ''' src_d = ''' from c import C class D(C): pass ''' fromText(src_a, modname='a', system=system) fromText(src_b, modname='b', system=system) fromText(src_c, modname='c', system=system) fromText(src_d, modname='d', system=system) system = systemcls() addsrc(system) D = system.allobjects['d.D'] assert isinstance(D, model.Class) # An older version of this test expected a.A as the result. # Read the comment in test_aliasing() to learn why this was changed. assert D.bases == ['c.C'] @systemcls_param def test_aliasing_recursion(systemcls: Type[model.System]) -> None: system = systemcls() src = ''' class C: pass from mod import C class D(C): pass ''' mod = fromText(src, modname='mod', system=system) D = mod.contents['D'] assert isinstance(D, model.Class) assert D.bases == ['mod.C'], D.bases @systemcls_param def test_documented_no_alias(systemcls: Type[model.System]) -> None: """A variable that is documented should not be considered an alias.""" # TODO: We should also verify this for inline docstrings, but the code # currently doesn't support that. We should perhaps store aliases # as Documentables as well, so we can change their 'kind' when # an inline docstring follows the assignment. mod = fromText(''' class SimpleClient: pass class Processor: """ @ivar clientFactory: Callable that returns a client. """ clientFactory = SimpleClient ''', systemcls=systemcls) P = mod.contents['Processor'] f = P.contents['clientFactory'] assert unwrap(f.parsed_docstring) == """Callable that returns a client.""" assert f.privacyClass is model.PrivacyClass.PUBLIC assert f.kind is model.DocumentableKind.INSTANCE_VARIABLE assert f.linenumber @systemcls_param def test_subclasses(systemcls: Type[model.System]) -> None: src = ''' class A: pass class B(A): pass ''' system = fromText(src, systemcls=systemcls).system A = system.allobjects['.A'] assert isinstance(A, model.Class) assert A.subclasses == [system.allobjects['.B']] @systemcls_param def test_inherit_names(systemcls: Type[model.System]) -> None: src = ''' class A: pass class A(A): pass ''' mod = fromText(src, systemcls=systemcls) A = mod.contents['A'] assert isinstance(A, model.Class) assert [b.name for b in A.allbases()] == ['A 0'] @systemcls_param def test_nested_class_inheriting_from_same_module(systemcls: Type[model.System]) -> None: src = ''' class A: pass class B: class C(A): pass ''' fromText(src, systemcls=systemcls) @systemcls_param def test_all_recognition(systemcls: Type[model.System]) -> None: """The value assigned to __all__ is parsed to Module.all.""" mod = fromText(''' def f(): pass __all__ = ['f'] ''', systemcls=systemcls) assert mod.all == ['f'] assert '__all__' not in mod.contents @systemcls_param def test_docformat_recognition(systemcls: Type[model.System]) -> None: """The value assigned to __docformat__ is parsed to Module.docformat.""" mod = fromText(''' __docformat__ = 'Epytext en' def f(): pass ''', systemcls=systemcls) assert mod.docformat == 'epytext' assert '__docformat__' not in mod.contents @systemcls_param def test_docformat_warn_not_str(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' __docformat__ = [i for i in range(3)] def f(): pass ''', systemcls=systemcls, modname='mod') captured = capsys.readouterr().out assert captured == 'mod:2: Cannot parse value assigned to "__docformat__": not a string\n' assert mod.docformat is None assert '__docformat__' not in mod.contents @systemcls_param def test_docformat_warn_not_str2(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' __docformat__ = 3.14 def f(): pass ''', systemcls=systemcls, modname='mod') captured = capsys.readouterr().out assert captured == 'mod:2: Cannot parse value assigned to "__docformat__": not a string\n' assert mod.docformat == None assert '__docformat__' not in mod.contents @systemcls_param def test_docformat_warn_empty(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' __docformat__ = ' ' def f(): pass ''', systemcls=systemcls, modname='mod') captured = capsys.readouterr().out assert captured == 'mod:2: Cannot parse value assigned to "__docformat__": empty value\n' assert mod.docformat == None assert '__docformat__' not in mod.contents @systemcls_param def test_docformat_warn_overrides(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' __docformat__ = 'numpy' def f(): pass __docformat__ = 'restructuredtext' ''', systemcls=systemcls, modname='mod') captured = capsys.readouterr().out assert captured == 'mod:7: Assignment to "__docformat__" overrides previous assignment\n' assert mod.docformat == 'restructuredtext' assert '__docformat__' not in mod.contents @systemcls_param def test_all_in_class_non_recognition(systemcls: Type[model.System]) -> None: """A class variable named __all__ is just an ordinary variable and does not affect Module.all. """ mod = fromText(''' class C: __all__ = ['f'] ''', systemcls=systemcls) assert mod.all is None assert '__all__' not in mod.contents assert '__all__' in mod.contents['C'].contents @systemcls_param def test_all_multiple(systemcls: Type[model.System], capsys: CapSys) -> None: """If there are multiple assignments to __all__, a warning is logged and the last assignment takes effect. """ mod = fromText(''' __all__ = ['f'] __all__ = ['g'] ''', modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == 'mod:3: Assignment to "__all__" overrides previous assignment\n' assert mod.all == ['g'] @systemcls_param def test_all_bad_sequence(systemcls: Type[model.System], capsys: CapSys) -> None: """Values other than lists and tuples assigned to __all__ have no effect and a warning is logged. """ mod = fromText(''' __all__ = {} ''', modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == 'mod:2: Cannot parse value assigned to "__all__"\n' assert mod.all is None @systemcls_param def test_all_nonliteral(systemcls: Type[model.System], capsys: CapSys) -> None: """Non-literals in __all__ are ignored.""" mod = fromText(''' __all__ = ['a', 'b', '.'.join(['x', 'y']), 'c'] ''', modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == 'mod:2: Cannot parse element 2 of "__all__"\n' assert mod.all == ['a', 'b', 'c'] @systemcls_param def test_all_nonstring(systemcls: Type[model.System], capsys: CapSys) -> None: """Non-string literals in __all__ are ignored.""" mod = fromText(''' __all__ = ('a', 'b', 123, 'c', True) ''', modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == ( 'mod:2: Element 2 of "__all__" has type "int", expected "str"\n' 'mod:2: Element 4 of "__all__" has type "bool", expected "str"\n' ) assert mod.all == ['a', 'b', 'c'] @systemcls_param def test_all_allbad(systemcls: Type[model.System], capsys: CapSys) -> None: """If no value in __all__ could be parsed, the result is an empty list.""" mod = fromText(''' __all__ = (123, True) ''', modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == ( 'mod:2: Element 0 of "__all__" has type "int", expected "str"\n' 'mod:2: Element 1 of "__all__" has type "bool", expected "str"\n' ) assert mod.all == [] @systemcls_param def test_classmethod(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: @classmethod def f(klass): pass ''', systemcls=systemcls) assert mod.contents['C'].contents['f'].kind is model.DocumentableKind.CLASS_METHOD mod = fromText(''' class C: def f(klass): pass f = classmethod(f) ''', systemcls=systemcls) assert mod.contents['C'].contents['f'].kind is model.DocumentableKind.CLASS_METHOD @systemcls_param def test_classdecorator(systemcls: Type[model.System]) -> None: mod = fromText(''' def cd(cls): pass @cd class C: pass ''', modname='mod', systemcls=systemcls) C = mod.contents['C'] assert isinstance(C, model.Class) assert C.decorators == [('mod.cd', None)] @systemcls_param def test_classdecorator_with_args(systemcls: Type[model.System]) -> None: mod = fromText(''' def cd(): pass class A: pass @cd(A) class C: pass ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert isinstance(C, model.Class) assert len(C.decorators) == 1 (name, args), = C.decorators assert name == 'test.cd' assert args is not None assert len(args) == 1 arg, = args assert astbuilder.node2fullname(arg, mod) == 'test.A' @systemcls_param def test_methoddecorator(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' class C: def method_undecorated(): pass @staticmethod def method_static(): pass @classmethod def method_class(cls): pass @staticmethod @classmethod def method_both(): pass ''', modname='mod', systemcls=systemcls) C = mod.contents['C'] assert C.contents['method_undecorated'].kind is model.DocumentableKind.METHOD assert C.contents['method_static'].kind is model.DocumentableKind.STATIC_METHOD assert C.contents['method_class'].kind is model.DocumentableKind.CLASS_METHOD captured = capsys.readouterr().out assert captured == "mod:14: mod.C.method_both is both classmethod and staticmethod\n" @systemcls_param def test_assignment_to_method_in_class(systemcls: Type[model.System]) -> None: """An assignment to a method in a class body does not change the type of the documentable. If the name we assign to exists and it does not belong to an Attribute (it's a Function instead, in this test case), the assignment will be ignored. """ mod = fromText(''' class Base: def base_method(): """Base method docstring.""" class Sub(Base): base_method = wrap_method(base_method) """Overriding the docstring is not supported.""" def sub_method(): """Sub method docstring.""" sub_method = wrap_method(sub_method) """Overriding the docstring is not supported.""" ''', systemcls=systemcls) assert isinstance(mod.contents['Base'].contents['base_method'], model.Function) assert mod.contents['Sub'].contents.get('base_method') is None sub_method = mod.contents['Sub'].contents['sub_method'] assert isinstance(sub_method, model.Function) assert sub_method.docstring == """Sub method docstring.""" @systemcls_param def test_assignment_to_method_in_init(systemcls: Type[model.System]) -> None: """An assignment to a method inside __init__() does not change the type of the documentable. If the name we assign to exists and it does not belong to an Attribute (it's a Function instead, in this test case), the assignment will be ignored. """ mod = fromText(''' class Base: def base_method(): """Base method docstring.""" class Sub(Base): def sub_method(): """Sub method docstring.""" def __init__(self): self.base_method = wrap_method(self.base_method) """Overriding the docstring is not supported.""" self.sub_method = wrap_method(self.sub_method) """Overriding the docstring is not supported.""" ''', systemcls=systemcls) assert isinstance(mod.contents['Base'].contents['base_method'], model.Function) assert mod.contents['Sub'].contents.get('base_method') is None sub_method = mod.contents['Sub'].contents['sub_method'] assert isinstance(sub_method, model.Function) assert sub_method.docstring == """Sub method docstring.""" @systemcls_param def test_import_star(systemcls: Type[model.System]) -> None: mod_a = fromText(''' def f(): pass ''', modname='a', systemcls=systemcls) mod_b = fromText(''' from a import * ''', modname='b', system=mod_a.system) assert mod_b.resolveName('f') == mod_a.contents['f'] @systemcls_param def test_import_func_from_package(systemcls: Type[model.System]) -> None: """Importing a function from a package should look in the C{__init__} module. In this test the following hierarchy is constructed:: package a module __init__ defines function 'f' module c imports function 'f' module b imports function 'f' We verify that when module C{b} and C{c} import the name C{f} from package C{a}, they import the function C{f} from the module C{a.__init__}. """ system = systemcls() mod_a = fromText(''' def f(): pass ''', modname='a', is_package=True, system=system) mod_b = fromText(''' from a import f ''', modname='b', system=system) mod_c = fromText(''' from . import f ''', modname='c', parent_name='a', system=system) assert mod_b.resolveName('f') == mod_a.contents['f'] assert mod_c.resolveName('f') == mod_a.contents['f'] @systemcls_param def test_import_module_from_package(systemcls: Type[model.System]) -> None: """Importing a module from a package should not look in C{__init__} module. In this test the following hierarchy is constructed:: package a module __init__ module b defines function 'f' module c imports module 'a.b' We verify that when module C{c} imports the name C{b} from package C{a}, it imports the module C{a.b} which contains C{f}. """ system = systemcls() fromText(''' # This module intentionally left blank. ''', modname='a', system=system, is_package=True) mod_b = fromText(''' def f(): pass ''', modname='b', parent_name='a', system=system) mod_c = fromText(''' from a import b f = b.f ''', modname='c', system=system) assert mod_c.resolveName('f') == mod_b.contents['f'] @systemcls_param def test_inline_docstring_modulevar(systemcls: Type[model.System]) -> None: mod = fromText(''' """regular module docstring @var b: doc for b """ """not a docstring""" a = 1 """inline doc for a""" b = 2 def f(): pass """not a docstring""" ''', modname='test', systemcls=systemcls) assert sorted(mod.contents.keys()) == ['a', 'b', 'f'] a = mod.contents['a'] assert a.docstring == """inline doc for a""" b = mod.contents['b'] assert unwrap(b.parsed_docstring) == """doc for b""" f = mod.contents['f'] assert not f.docstring @systemcls_param def test_inline_docstring_classvar(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: """regular class docstring""" def f(self): pass """not a docstring""" a = 1 """inline doc for a""" """not a docstring""" _b = 2 """inline doc for _b""" None """not a docstring""" ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert sorted(C.contents.keys()) == ['_b', 'a', 'f'] f = C.contents['f'] assert not f.docstring a = C.contents['a'] assert a.docstring == """inline doc for a""" assert a.privacyClass is model.PrivacyClass.PUBLIC b = C.contents['_b'] assert b.docstring == """inline doc for _b""" assert b.privacyClass is model.PrivacyClass.PRIVATE @systemcls_param def test_inline_docstring_annotated_classvar(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: """regular class docstring""" a: int """inline doc for a""" _b: int = 4 """inline doc for _b""" ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert sorted(C.contents.keys()) == ['_b', 'a'] a = C.contents['a'] assert a.docstring == """inline doc for a""" assert a.privacyClass is model.PrivacyClass.PUBLIC b = C.contents['_b'] assert b.docstring == """inline doc for _b""" assert b.privacyClass is model.PrivacyClass.PRIVATE @systemcls_param def test_inline_docstring_instancevar(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: """regular class docstring""" d = None """inline doc for d""" f = None """inline doc for f""" def __init__(self): self.a = 1 """inline doc for a""" """not a docstring""" self._b = 2 """inline doc for _b""" x = -1 """not a docstring""" self.c = 3 """inline doc for c""" self.d = 4 self.e = 5 """not a docstring""" def set_f(self, value): self.f = value ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert sorted(C.contents.keys()) == [ '__init__', '_b', 'a', 'c', 'd', 'e', 'f', 'set_f' ] a = C.contents['a'] assert a.docstring == """inline doc for a""" assert a.privacyClass is model.PrivacyClass.PUBLIC assert a.kind is model.DocumentableKind.INSTANCE_VARIABLE b = C.contents['_b'] assert b.docstring == """inline doc for _b""" assert b.privacyClass is model.PrivacyClass.PRIVATE assert b.kind is model.DocumentableKind.INSTANCE_VARIABLE c = C.contents['c'] assert c.docstring == """inline doc for c""" assert c.privacyClass is model.PrivacyClass.PUBLIC assert c.kind is model.DocumentableKind.INSTANCE_VARIABLE d = C.contents['d'] assert d.docstring == """inline doc for d""" assert d.privacyClass is model.PrivacyClass.PUBLIC assert d.kind is model.DocumentableKind.INSTANCE_VARIABLE e = C.contents['e'] assert not e.docstring f = C.contents['f'] assert f.docstring == """inline doc for f""" assert f.privacyClass is model.PrivacyClass.PUBLIC assert f.kind is model.DocumentableKind.INSTANCE_VARIABLE @systemcls_param def test_inline_docstring_annotated_instancevar(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: """regular class docstring""" a: int def __init__(self): self.a = 1 """inline doc for a""" self.b: int = 2 """inline doc for b""" ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert sorted(C.contents.keys()) == ['__init__', 'a', 'b'] a = C.contents['a'] assert a.docstring == """inline doc for a""" b = C.contents['b'] assert b.docstring == """inline doc for b""" @systemcls_param def test_docstring_assignment(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(r''' def fun(): pass class CLS: def method1(): """Temp docstring.""" pass def method2(): pass method1.__doc__ = "Override docstring #1" fun.__doc__ = "Happy Happy Joy Joy" CLS.__doc__ = "Clears the screen" CLS.method2.__doc__ = "Set docstring #2" None.__doc__ = "Free lunch!" real.__doc__ = "Second breakfast" fun.__doc__ = codecs.encode('Pnrfne fnynq', 'rot13') CLS.method1.__doc__ = 4 def mark_unavailable(func): # No warning: docstring updates in functions are ignored. func.__doc__ = func.__doc__ + '\n\nUnavailable on this system.' ''', systemcls=systemcls) fun = mod.contents['fun'] assert fun.kind is model.DocumentableKind.FUNCTION assert fun.docstring == """Happy Happy Joy Joy""" CLS = mod.contents['CLS'] assert CLS.kind is model.DocumentableKind.CLASS assert CLS.docstring == """Clears the screen""" method1 = CLS.contents['method1'] assert method1.kind is model.DocumentableKind.METHOD assert method1.docstring == "Override docstring #1" method2 = CLS.contents['method2'] assert method2.kind is model.DocumentableKind.METHOD assert method2.docstring == "Set docstring #2" captured = capsys.readouterr() assert captured.out == ( ':14: Existing docstring at line 8 is overriden\n' ':20: Unable to figure out target for __doc__ assignment\n' ':21: Unable to figure out target for __doc__ assignment: computed full name not found: real\n' ':22: Unable to figure out value for __doc__ assignment, maybe too complex\n' ':23: Ignoring value assigned to __doc__: not a string\n' ) @systemcls_param def test_docstring_assignment_detuple(systemcls: Type[model.System], capsys: CapSys) -> None: """We currently don't trace values for detupling assignments, so when assigning to __doc__ we get a warning about the unknown value. """ fromText(''' def fun(): pass fun.__doc__, other = 'Detupling to __doc__', 'is not supported' ''', modname='test', systemcls=systemcls) captured = capsys.readouterr().out assert captured == ( "test:5: Unable to figure out value for __doc__ assignment, maybe too complex\n" ) @systemcls_param def test_variable_scopes(systemcls: Type[model.System]) -> None: mod = fromText(''' l = 1 """module-level l""" m = 1 """module-level m""" class C: """class docstring @ivar k: class level doc for k """ a = None k = 640 m = 2 """class-level m""" def __init__(self): self.a = 1 """inline doc for a""" self.l = 2 """instance l""" ''', modname='test', systemcls=systemcls) l1 = mod.contents['l'] assert l1.kind is model.DocumentableKind.VARIABLE assert l1.docstring == """module-level l""" m1 = mod.contents['m'] assert m1.kind is model.DocumentableKind.VARIABLE assert m1.docstring == """module-level m""" C = mod.contents['C'] assert sorted(C.contents.keys()) == ['__init__', 'a', 'k', 'l', 'm'] a = C.contents['a'] assert a.kind is model.DocumentableKind.INSTANCE_VARIABLE assert a.docstring == """inline doc for a""" k = C.contents['k'] assert k.kind is model.DocumentableKind.INSTANCE_VARIABLE assert unwrap(k.parsed_docstring) == """class level doc for k""" l2 = C.contents['l'] assert l2.kind is model.DocumentableKind.INSTANCE_VARIABLE assert l2.docstring == """instance l""" m2 = C.contents['m'] assert m2.kind is model.DocumentableKind.CLASS_VARIABLE assert m2.docstring == """class-level m""" @systemcls_param def test_variable_types(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: """class docstring @cvar a: first @type a: string @type b: string @cvar b: second @type c: string @ivar d: fourth @type d: string @type e: string @ivar e: fifth @type f: string @type g: string """ a = "A" b = "B" c = "C" """third""" def __init__(self): self.d = "D" self.e = "E" self.f = "F" """sixth""" self.g = g = "G" """seventh""" ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert sorted(C.contents.keys()) == [ '__init__', 'a', 'b', 'c', 'd', 'e', 'f', 'g' ] a = C.contents['a'] assert unwrap(a.parsed_docstring) == """first""" assert str(unwrap(a.parsed_type)) == 'string' assert a.kind is model.DocumentableKind.CLASS_VARIABLE b = C.contents['b'] assert unwrap(b.parsed_docstring) == """second""" assert str(unwrap(b.parsed_type)) == 'string' assert b.kind is model.DocumentableKind.CLASS_VARIABLE c = C.contents['c'] assert c.docstring == """third""" assert str(unwrap(c.parsed_type)) == 'string' assert c.kind is model.DocumentableKind.CLASS_VARIABLE d = C.contents['d'] assert unwrap(d.parsed_docstring) == """fourth""" assert str(unwrap(d.parsed_type)) == 'string' assert d.kind is model.DocumentableKind.INSTANCE_VARIABLE e = C.contents['e'] assert unwrap(e.parsed_docstring) == """fifth""" assert str(unwrap(e.parsed_type)) == 'string' assert e.kind is model.DocumentableKind.INSTANCE_VARIABLE f = C.contents['f'] assert f.docstring == """sixth""" assert str(unwrap(f.parsed_type)) == 'string' assert f.kind is model.DocumentableKind.INSTANCE_VARIABLE g = C.contents['g'] assert g.docstring == """seventh""" assert str(unwrap(g.parsed_type)) == 'string' assert g.kind is model.DocumentableKind.INSTANCE_VARIABLE @systemcls_param def test_annotated_variables(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: """class docstring @cvar a: first @type a: string @type b: string @cvar b: second """ a: str = "A" b: str c: str = "C" """third""" d: str """fourth""" e: List['C'] """fifth""" f: 'List[C]' """sixth""" g: 'List["C"]' """seventh""" def __init__(self): self.s: List[str] = [] """instance""" m: bytes = b"M" """module-level""" ''', modname='test', systemcls=systemcls) C = mod.contents['C'] a = C.contents['a'] assert unwrap(a.parsed_docstring) == """first""" assert type2html(a) == 'string' b = C.contents['b'] assert unwrap(b.parsed_docstring) == """second""" assert type2html(b) == 'string' c = C.contents['c'] assert c.docstring == """third""" assert type2html(c) == 'str' d = C.contents['d'] assert d.docstring == """fourth""" assert type2html(d) == 'str' e = C.contents['e'] assert e.docstring == """fifth""" assert type2html(e) == 'List[C]' f = C.contents['f'] assert f.docstring == """sixth""" assert type2html(f) == 'List[C]' g = C.contents['g'] assert g.docstring == """seventh""" assert type2html(g) == 'List[C]' s = C.contents['s'] assert s.docstring == """instance""" assert type2html(s) == 'List[str]' m = mod.contents['m'] assert m.docstring == """module-level""" assert type2html(m) == 'bytes' @typecomment @systemcls_param def test_type_comment(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' d = {} # type: dict[str, int] i = [] # type: ignore[misc] ''', systemcls=systemcls) assert type2str(cast(model.Attribute, mod.contents['d']).annotation) == 'dict[str, int]' # We don't use ignore comments for anything at the moment, # but do verify that their presence doesn't break things. assert type2str(cast(model.Attribute, mod.contents['i']).annotation) == 'list' assert not capsys.readouterr().out @systemcls_param def test_unstring_annotation(systemcls: Type[model.System]) -> None: """Annotations or parts thereof that are strings are parsed and line number information is preserved. """ mod = fromText(''' a: "int" b: 'str' = 'B' c: list["Thingy"] ''', systemcls=systemcls) assert ann_str_and_line(mod.contents['a']) == ('int', 2) assert ann_str_and_line(mod.contents['b']) == ('str', 3) assert ann_str_and_line(mod.contents['c']) == ('list[Thingy]', 4) @systemcls_param def test_upgrade_annotation(systemcls: Type[model.System]) -> None: """Annotations using old style Unions or Optionals are upgraded to python 3.10+ style. Deprecated aliases like List, Tuple Dict are also translated to their built ins versions. """ mod = fromText('''\ from typing import Union, Optional, List a: Union[str, int] b: Optional[str] c: List[B] d: Optional[Union[str, int, bytes]] e: Union[str] f: Union[(str,)] g: Optional[1, 2] # wrong, so we don't process it h: Union[list[str]] class List: Union: Union[a, b] ''', systemcls=systemcls) assert ann_str_and_line(mod.contents['a']) == ('str | int', 2) assert ann_str_and_line(mod.contents['b']) == ('str | None', 3) assert ann_str_and_line(mod.contents['c']) == ('list[B]', 4) assert ann_str_and_line(mod.contents['d']) == ('str | int | bytes | None', 5) assert ann_str_and_line(mod.contents['e']) == ('str', 6) assert ann_str_and_line(mod.contents['f']) == ('str', 7) assert ann_str_and_line(mod.contents['g']) == ('Optional[1, 2]', 8) assert ann_str_and_line(mod.contents['h']) == ('list[str]', 9) assert ann_str_and_line(mod.contents['List'].contents['Union']) == ('a | b', 12) @pytest.mark.parametrize('annotation', ("[", "pass", "1 ; 2")) @systemcls_param def test_bad_string_annotation( annotation: str, systemcls: Type[model.System], capsys: CapSys ) -> None: """Invalid string annotations must be reported as syntax errors.""" mod = fromText(f''' x: "{annotation}" ''', modname='test', systemcls=systemcls) assert isinstance(cast(model.Attribute, mod.contents['x']).annotation, ast.expr) assert "syntax error in annotation" in capsys.readouterr().out @pytest.mark.parametrize('annotation,expected', ( ("Literal['[', ']']", "Literal['[', ']']"), ("typing.Literal['pass', 'raise']", "typing.Literal['pass', 'raise']"), ("Optional[Literal['1 ; 2']]", "Optional[Literal['1 ; 2']]"), ("'Literal'['!']", "Literal['!']"), (r"'Literal[\'if\', \'while\']'", "Literal['if', 'while']"), )) def test_literal_string_annotation(annotation: str, expected: str) -> None: """Strings inside Literal annotations must not be recursively parsed.""" stmt, = ast.parse(annotation).body assert isinstance(stmt, ast.Expr) unstringed = astutils._AnnotationStringParser().visit(stmt.value) assert astutils.unparse(unstringed).strip() == expected @systemcls_param def test_inferred_variable_types(systemcls: Type[model.System]) -> None: mod = fromText(''' class C: a = "A" b = 2 c = ['a', 'b', 'c'] d = {'a': 1, 'b': 2} e = (True, False, True) f = 1.618 g = {2, 7, 1, 8} h = [] i = ['r', 2, 'd', 2] j = ((), ((), ())) n = None x = list(range(10)) y = [n for n in range(10) if n % 2] def __init__(self): self.s = ['S'] self.t = t = 'T' m = b'octets' ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert ann_str_and_line(C.contents['a']) == ('str', 3) assert ann_str_and_line(C.contents['b']) == ('int', 4) assert ann_str_and_line(C.contents['c']) == ('list[str]', 5) assert ann_str_and_line(C.contents['d']) == ('dict[str, int]', 6) assert ann_str_and_line(C.contents['e']) == ('tuple[bool, ...]', 7) assert ann_str_and_line(C.contents['f']) == ('float', 8) assert ann_str_and_line(C.contents['g']) == ('set[int]', 9) # Element type is unknown, not uniform or too complex. assert ann_str_and_line(C.contents['h']) == ('list', 10) assert ann_str_and_line(C.contents['i']) == ('list', 11) assert ann_str_and_line(C.contents['j']) == ('tuple', 12) # It is unlikely that a variable actually will contain only None, # so we should treat this as not be able to infer the type. assert cast(model.Attribute, C.contents['n']).annotation is None # These expressions are considered too complex for pydoctor. # Maybe we can use an external type inferrer at some point. assert cast(model.Attribute, C.contents['x']).annotation is None assert cast(model.Attribute, C.contents['y']).annotation is None # Type inference isn't different for module and instance variables, # so we don't need to re-test everything. assert ann_str_and_line(C.contents['s']) == ('list[str]', 17) # Check that type is inferred on assignments with multiple targets. assert ann_str_and_line(C.contents['t']) == ('str', 18) assert ann_str_and_line(mod.contents['m']) == ('bytes', 19) @systemcls_param def test_detupling_assignment(systemcls: Type[model.System]) -> None: mod = fromText(''' a, b, c = range(3) ''', modname='test', systemcls=systemcls) assert sorted(mod.contents.keys()) == ['a', 'b', 'c'] @systemcls_param def test_property_decorator(systemcls: Type[model.System]) -> None: """A function decorated with '@property' is documented as an attribute.""" mod = fromText(''' class C: @property def prop(self) -> str: """For sale.""" return 'seaside' @property def oldschool(self): """ @return: For rent. @rtype: string @see: U{https://example.com/} """ return 'downtown' ''', modname='test', systemcls=systemcls) C = mod.contents['C'] prop = C.contents['prop'] assert isinstance(prop, model.Attribute) assert prop.kind is model.DocumentableKind.PROPERTY assert prop.docstring == """For sale.""" assert type2str(prop.annotation) == 'str' oldschool = C.contents['oldschool'] assert isinstance(oldschool, model.Attribute) assert oldschool.kind is model.DocumentableKind.PROPERTY assert isinstance(oldschool.parsed_docstring, ParsedEpytextDocstring) assert unwrap(oldschool.parsed_docstring) == """For rent.""" assert flatten(format_summary(oldschool)) == 'For rent.' assert isinstance(oldschool.parsed_type, ParsedEpytextDocstring) assert str(unwrap(oldschool.parsed_type)) == 'string' fields = oldschool.parsed_docstring.fields assert len(fields) == 1 assert fields[0].tag() == 'see' @systemcls_param def test_property_setter(systemcls: Type[model.System], capsys: CapSys) -> None: """Property setter and deleter methods are renamed, so they don't replace the property itself. """ mod = fromText(''' class C: @property def prop(self): """Getter.""" @prop.setter def prop(self, value): """Setter.""" @prop.deleter def prop(self): """Deleter.""" ''', modname='mod', systemcls=systemcls) C = mod.contents['C'] getter = C.contents['prop'] assert isinstance(getter, model.Attribute) assert getter.kind is model.DocumentableKind.PROPERTY assert getter.docstring == """Getter.""" setter = C.contents['prop.setter'] assert isinstance(setter, model.Function) assert setter.kind is model.DocumentableKind.METHOD assert setter.docstring == """Setter.""" deleter = C.contents['prop.deleter'] assert isinstance(deleter, model.Function) assert deleter.kind is model.DocumentableKind.METHOD assert deleter.docstring == """Deleter.""" @systemcls_param def test_property_custom(systemcls: Type[model.System], capsys: CapSys) -> None: """Any custom decorator with a name ending in 'property' makes a method into a property getter. """ mod = fromText(''' class C: @deprecate.deprecatedProperty(incremental.Version("Twisted", 18, 7, 0)) def processes(self): return {} @async_property async def remote_value(self): return await get_remote_value() @abc.abstractproperty def name(self): raise NotImplementedError ''', modname='mod', systemcls=systemcls) C = mod.contents['C'] deprecated = C.contents['processes'] assert isinstance(deprecated, model.Attribute) assert deprecated.kind is model.DocumentableKind.PROPERTY async_prop = C.contents['remote_value'] assert isinstance(async_prop, model.Attribute) assert async_prop.kind is model.DocumentableKind.PROPERTY abstract_prop = C.contents['name'] assert isinstance(abstract_prop, model.Attribute) assert abstract_prop.kind is model.DocumentableKind.PROPERTY @pytest.mark.parametrize('decoration', ('classmethod', 'staticmethod')) @systemcls_param def test_property_conflict( decoration: str, systemcls: Type[model.System], capsys: CapSys ) -> None: """Warn when a method is decorated as both property and class/staticmethod. These decoration combinations do not create class/static properties. """ mod = fromText(f''' class C: @{decoration} @property def prop(): raise NotImplementedError ''', modname='mod', systemcls=systemcls) C = mod.contents['C'] assert C.contents['prop'].kind is model.DocumentableKind.PROPERTY captured = capsys.readouterr().out assert captured == f"mod:3: mod.C.prop is both property and {decoration}\n" @systemcls_param def test_ignore_function_contents(systemcls: Type[model.System]) -> None: mod = fromText(''' def outer(): """Outer function.""" class Clazz: """Inner class.""" def func(): """Inner function.""" var = 1 """Local variable.""" ''', systemcls=systemcls) outer = mod.contents['outer'] assert not outer.contents @systemcls_param def test_overload(systemcls: Type[model.System], capsys: CapSys) -> None: # Confirm decorators retained on overloads, docstring ignored for overloads, # and that overloads after the primary function are skipped mod = fromText(""" from typing import overload def dec(fn): pass @dec @overload def parse(s:str)->str: ... @overload def parse(s:bytes)->bytes: '''Ignored docstring''' ... def parse(s:Union[str, bytes])->Union[str, bytes]: pass @overload def parse(s:str)->bytes: ... """, systemcls=systemcls) func = mod.contents['parse'] assert isinstance(func, model.Function) # Work around different space arrangements in Signature.__str__ between python versions assert flatten_text(html2stan(str(func.signature).replace(' ', ''))) == '(s:Union[str,bytes])->Union[str,bytes]' assert [astbuilder.node2dottedname(d) for d in (func.decorators or ())] == [] assert len(func.overloads) == 2 assert [astbuilder.node2dottedname(d) for d in func.overloads[0].decorators] == [['dec'], ['overload']] assert [astbuilder.node2dottedname(d) for d in func.overloads[1].decorators] == [['overload']] assert flatten_text(html2stan(str(func.overloads[0].signature).replace(' ', ''))) == '(s:str)->str' assert flatten_text(html2stan(str(func.overloads[1].signature).replace(' ', ''))) == '(s:bytes)->bytes' assert capsys.readouterr().out.splitlines() == [ ':11: .parse overload has docstring, unsupported', ':15: .parse overload appeared after primary function', ] @systemcls_param def test_constant_module(systemcls: Type[model.System]) -> None: """ Module variables with all-uppercase names are recognized as constants. """ mod = fromText(''' LANG = 'FR' ''', systemcls=systemcls) lang = mod.contents['LANG'] assert isinstance(lang, model.Attribute) assert lang.kind is model.DocumentableKind.CONSTANT assert ast.literal_eval(getattr(mod.resolveName('LANG'), 'value')) == 'FR' @systemcls_param def test_constant_module_with_final(systemcls: Type[model.System]) -> None: """ Module variables annotated with typing.Final are recognized as constants. """ mod = fromText(''' from typing import Final lang: Final = 'fr' ''', systemcls=systemcls) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 'fr' @systemcls_param def test_constant_module_with_typing_extensions_final(systemcls: Type[model.System]) -> None: """ Module variables annotated with typing_extensions.Final are recognized as constants. """ mod = fromText(''' from typing_extensions import Final lang: Final = 'fr' ''', systemcls=systemcls) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 'fr' @systemcls_param def test_constant_module_with_final_subscript1(systemcls: Type[model.System]) -> None: """ It can recognize constants defined with typing.Final[something] """ mod = fromText(''' from typing import Final lang: Final[Sequence[str]] = ('fr', 'en') ''', systemcls=systemcls) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == ('fr', 'en') assert attr.annotation assert astutils.unparse(attr.annotation).strip() == "Sequence[str]" @systemcls_param def test_constant_module_with_final_subscript2(systemcls: Type[model.System]) -> None: """ It can recognize constants defined with typing.Final[something]. And it automatically remove the Final part from the annotation. """ mod = fromText(''' import typing lang: typing.Final[tuple] = ('fr', 'en') ''', systemcls=systemcls) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == ('fr', 'en') assert astbuilder.node2fullname(attr.annotation, attr) == "tuple" @systemcls_param def test_constant_module_with_final_subscript_invalid_warns(systemcls: Type[model.System], capsys: CapSys) -> None: """ It warns if there is an invalid Final annotation. """ mod = fromText(''' from typing import Final lang: Final[tuple, 12:13] = ('fr', 'en') ''', systemcls=systemcls, modname='mod') attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == ('fr', 'en') captured = capsys.readouterr().out assert "mod:3: Annotation is invalid, it should not contain slices.\n" == captured assert attr.annotation assert astutils.unparse(attr.annotation).strip() == "tuple[str, ...]" @systemcls_param def test_constant_module_with_final_subscript_invalid_warns2(systemcls: Type[model.System], capsys: CapSys) -> None: """ It warns if there is an invalid Final annotation. """ mod = fromText(''' import typing lang: typing.Final[12:13] = ('fr', 'en') ''', systemcls=systemcls, modname='mod') attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == ('fr', 'en') captured = capsys.readouterr().out assert "mod:3: Annotation is invalid, it should not contain slices.\n" == captured assert attr.annotation assert astutils.unparse(attr.annotation).strip() == "tuple[str, ...]" @systemcls_param def test_constant_module_with_final_annotation_gets_infered(systemcls: Type[model.System]) -> None: """ It can recognize constants defined with typing.Final. It will infer the type of the constant if Final do not use subscripts. """ mod = fromText(''' import typing lang: typing.Final = 'fr' ''', systemcls=systemcls) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 'fr' assert astbuilder.node2fullname(attr.annotation, attr) == "str" @systemcls_param def test_constant_class(systemcls: Type[model.System]) -> None: """ Class variables with all-uppercase names are recognized as constants. """ mod = fromText(''' class Clazz: """Class.""" LANG = 'FR' ''', systemcls=systemcls) attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 'FR' @systemcls_param def test_all_caps_variable_in_instance_is_not_a_constant(systemcls: Type[model.System], capsys: CapSys) -> None: """ Currently, it does not mark instance members as constants, never. """ mod = fromText(''' from typing import Final class Clazz: """Class.""" def __init__(**args): self.LANG: Final = 'FR' ''', systemcls=systemcls) attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.INSTANCE_VARIABLE assert attr.value is not None assert ast.literal_eval(attr.value) == 'FR' captured = capsys.readouterr().out assert not captured @systemcls_param def test_constant_override_in_instace(systemcls: Type[model.System], capsys: CapSys) -> None: """ When an instance variable overrides a CONSTANT, it's flagged as INSTANCE_VARIABLE and no warning is raised. """ mod = fromText(''' class Clazz: """Class.""" LANG = 'EN' def __init__(self, **args): self.LANG = 'FR' ''', systemcls=systemcls, modname="mod") attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.INSTANCE_VARIABLE assert not capsys.readouterr().out @systemcls_param def test_constant_override_in_instace_bis(systemcls: Type[model.System], capsys: CapSys) -> None: """ When an instance variable overrides a CONSTANT, it's flagged as INSTANCE_VARIABLE and no warning is raised. """ mod = fromText(''' class Clazz: """Class.""" def __init__(self, **args): self.LANG = 'FR' LANG = 'EN' ''', systemcls=systemcls, modname="mod") attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.INSTANCE_VARIABLE assert attr.value is not None assert ast.literal_eval(attr.value) == 'EN' assert not capsys.readouterr().out @systemcls_param def test_constant_override_in_module(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' """Mod.""" import sys IS_64BITS = False if sys.maxsize > 2**32: IS_64BITS = True ''', systemcls=systemcls, modname="mod") attr = mod.resolveName('IS_64BITS') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.VARIABLE assert attr.value is not None assert ast.literal_eval(attr.value) == True assert not capsys.readouterr().out @systemcls_param def test_constant_override_do_not_warns_when_defined_in_class_docstring(systemcls: Type[model.System], capsys: CapSys) -> None: """ Constant can be documented as variables at docstring level without any warnings. """ mod = fromText(''' class Clazz: """ @cvar LANG: French. """ LANG = 99 ''', systemcls=systemcls, modname="mod") attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 99 captured = capsys.readouterr().out assert not captured @systemcls_param def test_constant_override_do_not_warns_when_defined_in_module_docstring(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' """ @var LANG: French. """ LANG = 99 ''', systemcls=systemcls, modname="mod") attr = mod.resolveName('LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 99 captured = capsys.readouterr().out assert not captured @systemcls_param def test_not_a_constant_module(systemcls: Type[model.System], capsys:CapSys) -> None: """ If the constant assignment has any kind of constraint or there are multiple assignments in the scope, then it's not flagged as a constant. """ mod = fromText(''' while False: LANG = 'FR' if True: THING = 'EN' OTHER = 1 OTHER += 1 E: typing.Final = 2 # it's considered a constant because it's explicitely marked Final E = 4 LIST = [2.14] LIST.insert(0,0) ''', systemcls=systemcls) assert mod.contents['LANG'].kind is model.DocumentableKind.VARIABLE assert mod.contents['THING'].kind is model.DocumentableKind.VARIABLE assert mod.contents['OTHER'].kind is model.DocumentableKind.VARIABLE assert mod.contents['E'].kind is model.DocumentableKind.CONSTANT # all-caps mutables variables are flagged as constant: this is a trade-off # in between our weeknesses in terms static analysis (that is we don't recognized list modifications) # and our will to do the right thing and display constant values. # This issue could be overcome by showing the value of variables with only one assigment no matter # their kind and restrict the checks to immutable types for a attribute to be flagged as constant. assert mod.contents['LIST'].kind is model.DocumentableKind.CONSTANT # we could warn when a constant is beeing overriden, but we don't: pydoctor is not a checker. assert not capsys.readouterr().out @systemcls_param def test__name__equals__main__is_skipped(systemcls: Type[model.System]) -> None: """ Code inside of C{if __name__ == '__main__'} should be skipped. """ mod = fromText(''' foo = True if __name__ == '__main__': var = True def fun(): pass class Class: pass def bar(): pass ''', modname='test', systemcls=systemcls) assert tuple(mod.contents) == ('foo', 'bar') @systemcls_param def test__name__equals__main__is_skipped_but_orelse_processes(systemcls: Type[model.System]) -> None: """ Code inside of C{if __name__ == '__main__'} should be skipped, but the else block should be processed. """ mod = fromText(''' foo = True if __name__ == '__main__': var = True def fun(): pass class Class: pass else: class Very: ... def bar(): pass ''', modname='test', systemcls=systemcls) assert tuple(mod.contents) == ('foo', 'Very', 'bar' ) @systemcls_param def test_variable_named_like_current_module(systemcls: Type[model.System]) -> None: """ Test for U{issue #474}. """ mod = fromText(''' example = True ''', systemcls=systemcls, modname="example") assert 'example' in mod.contents @systemcls_param def test_package_name_clash(systemcls: Type[model.System]) -> None: system = systemcls() builder = system.systemBuilder(system) builder.addModuleString('', 'mod', is_package=True) builder.addModuleString('', 'sub', parent_name='mod', is_package=True) assert isinstance(system.allobjects['mod.sub'], model.Module) # The following statement completely overrides module 'mod' and all it's submodules. builder.addModuleString('', 'mod', is_package=True) with pytest.raises(KeyError): system.allobjects['mod.sub'] builder.addModuleString('', 'sub2', parent_name='mod', is_package=True) assert isinstance(system.allobjects['mod.sub2'], model.Module) @systemcls_param def test_reexport_wildcard(systemcls: Type[model.System]) -> None: """ If a target module, explicitly re-export via C{__all__} a set of names that were initially imported from a sub-module via a wildcard, those names are documented as part of the target module. """ system = systemcls() builder = system.systemBuilder(system) builder.addModuleString(''' from ._impl import * from _impl2 import * __all__ = ['f', 'g', 'h', 'i', 'j'] ''', modname='top', is_package=True) builder.addModuleString(''' def f(): pass def g(): pass def h(): pass ''', modname='_impl', parent_name='top') builder.addModuleString(''' class i: pass class j: pass ''', modname='_impl2') builder.buildModules() assert system.allobjects['top._impl'].resolveName('f') == system.allobjects['top'].contents['f'] assert system.allobjects['_impl2'].resolveName('i') == system.allobjects['top'].contents['i'] assert all(n in system.allobjects['top'].contents for n in ['f', 'g', 'h', 'i', 'j']) @systemcls_param def test_module_level_attributes_and_aliases(systemcls: Type[model.System]) -> None: """ Variables and aliases defined in the main body of a Try node will have priority over the names defined in the except handlers. """ system = systemcls() builder = system.systemBuilder(system) builder.addModuleString(''' ssl = 1 ''', modname='twisted.internet') builder.addModuleString(''' try: from twisted.internet import ssl as _ssl # The names defined in the body of the if block wins over the # names defined in the except handler ssl = _ssl var = 1 VAR = 1 ALIAS = _ssl except ImportError: ssl = None var = 2 VAR = 2 ALIAS = None ''', modname='mod') builder.buildModules() mod = system.allobjects['mod'] # Test alias assert mod.expandName('ssl')=="twisted.internet.ssl" assert mod.expandName('_ssl')=="twisted.internet.ssl" s = mod.resolveName('ssl') assert isinstance(s, model.Attribute) assert s.value is not None assert ast.literal_eval(s.value)==1 assert s.kind == model.DocumentableKind.VARIABLE # Test variable assert mod.expandName('var')=="mod.var" v = mod.resolveName('var') assert isinstance(v, model.Attribute) assert v.value is not None assert ast.literal_eval(v.value)==1 assert v.kind == model.DocumentableKind.VARIABLE # Test variable 2 assert mod.expandName('VAR')=="mod.VAR" V = mod.resolveName('VAR') assert isinstance(V, model.Attribute) assert V.value is not None assert ast.literal_eval(V.value)==1 assert V.kind == model.DocumentableKind.VARIABLE # Test variable 3 assert mod.expandName('ALIAS')=="twisted.internet.ssl" s = mod.resolveName('ALIAS') assert isinstance(s, model.Attribute) assert s.value is not None assert ast.literal_eval(s.value)==1 assert s.kind == model.DocumentableKind.VARIABLE @systemcls_param def test_module_level_attributes_and_aliases_orelse(systemcls: Type[model.System]) -> None: """ We visit the try orelse body and these names have priority over the names in the except handlers. """ system = systemcls() builder = system.systemBuilder(system) builder.addModuleString(''' ssl = 1 ''', modname='twisted.internet') builder.addModuleString(''' try: from twisted.internet import ssl as _ssl except ImportError: ssl = None var = 2 VAR = 2 ALIAS = None newname = 2 else: # The names defined in the orelse or finally block wins over the # names defined in the except handler ssl = _ssl var = 1 finally: VAR = 1 ALIAS = _ssl if sys.version_info > (3,7): def func(): 'func doc' class klass: 'klass doc' var2 = 1 'var2 doc' else: # these definition will be ignored since they are # alreade definied in the body of the If block. func = None 'not this one' def klass(): 'not this one' class var2: 'not this one' ''', modname='mod') builder.buildModules() mod = system.allobjects['mod'] # Tes newname survives the override guard assert 'newname' in mod.contents # Test alias assert mod.expandName('ssl')=="twisted.internet.ssl" assert mod.expandName('_ssl')=="twisted.internet.ssl" s = mod.resolveName('ssl') assert isinstance(s, model.Attribute) assert s.value is not None assert ast.literal_eval(s.value)==1 assert s.kind == model.DocumentableKind.VARIABLE # Test variable assert mod.expandName('var')=="mod.var" v = mod.resolveName('var') assert isinstance(v, model.Attribute) assert v.value is not None assert ast.literal_eval(v.value)==1 assert v.kind == model.DocumentableKind.VARIABLE # Test variable 2 assert mod.expandName('VAR')=="mod.VAR" V = mod.resolveName('VAR') assert isinstance(V, model.Attribute) assert V.value is not None assert ast.literal_eval(V.value)==1 assert V.kind == model.DocumentableKind.VARIABLE # Test variable 3 assert mod.expandName('ALIAS')=="twisted.internet.ssl" s = mod.resolveName('ALIAS') assert isinstance(s, model.Attribute) assert s.value is not None assert ast.literal_eval(s.value)==1 assert s.kind == model.DocumentableKind.VARIABLE # Test if override guard func, klass, var2 = mod.resolveName('func'), mod.resolveName('klass'), mod.resolveName('var2') assert isinstance(func, model.Function) assert func.docstring == 'func doc' assert isinstance(klass, model.Class) assert klass.docstring == 'klass doc' assert isinstance(var2, model.Attribute) assert var2.docstring == 'var2 doc' @systemcls_param def test_method_level_orelse_handlers_use_case1(systemcls: Type[model.System]) -> None: system = systemcls() builder = system.systemBuilder(system) builder.addModuleString(''' class K: def test(self, ):... def __init__(self, text): try: self.test() except: # Even if this attribute is only defined in the except block in a function/method # it will be included in the documentation. self.error = True finally: self.name = text if sys.version_info > (3,0): pass elif sys.version_info > (2,6): # Idem for these instance attributes self.legacy = True self.still_supported = True else: # This attribute is ignored, the same rules that applies # at the module level applies here too. # since it's already defined in the upper block If.body # this assigment is ignored. self.still_supported = False ''', modname='mod') builder.buildModules() mod = system.allobjects['mod'] assert isinstance(mod, model.Module) K = mod.contents['K'] assert isinstance(K, model.Class) assert K.resolveName('legacy') == K.contents['legacy'] assert K.resolveName('error') == K.contents['error'] assert K.resolveName('name') == K.contents['name'] s = K.contents['still_supported'] assert K.resolveName('still_supported') == s assert isinstance(s, model.Attribute) assert ast.literal_eval(s.value or '') == True @systemcls_param def test_method_level_orelse_handlers_use_case2(systemcls: Type[model.System]) -> None: system = systemcls() builder = system.systemBuilder(system) builder.addModuleString(''' class K: def __init__(self, d:dict, g:Iterator): try: next(g) except StopIteration: # this should be documented self.data = d else: raise RuntimeError("the generator wasn't exhausted!") finally: if sys.version_info < (3,7): raise RuntimeError("please upadate your python version to 3.7 al least!") else: # Idem for this instance attribute self.ok = True ''', modname='mod') builder.buildModules() mod = system.allobjects['mod'] assert isinstance(mod, model.Module) K = mod.contents['K'] assert isinstance(K, model.Class) assert K.resolveName('data') == K.contents['data'] assert K.resolveName('ok') == K.contents['ok'] @systemcls_param def test_class_level_attributes_and_aliases_orelse(systemcls: Type[model.System]) -> None: system = systemcls() builder = system.systemBuilder(system) builder.addModuleString('crazy_var=2', modname='crazy') builder.addModuleString(''' if sys.version_info > (3,0): thing = object class klass(thing): 'klass doc' var2 = 3 # regular import from crazy import crazy_var as cv else: # these imports will be ignored because the names # have been defined in the body of the If block. from six import t as thing import klass from crazy27 import crazy_var as cv # Wildcard imports are still processed # in name override guard context from crazy import * # this import is not ignored from six import seven # this class is not ignored and will be part of the public docs. class klassfallback(thing): 'klassfallback doc' var2 = 1 # this overrides var2 var2 = 2 # this is ignored because the name 'klass' # has been defined in the body of the If block. klass = klassfallback 'ignored' var3 = 1 # this overrides var3 var3 = 2 ''', modname='mod') builder.buildModules() mod = system.allobjects['mod'] assert isinstance(mod, model.Module) klass, klassfallback, var2, var3 = \ mod.resolveName('klass'), \ mod.resolveName('klassfallback'), \ mod.resolveName('klassfallback.var2'), \ mod.resolveName('var3') assert isinstance(klass, model.Class) assert isinstance(klassfallback, model.Class) assert isinstance(var2, model.Attribute) assert isinstance(var3, model.Attribute) assert klassfallback.docstring == 'klassfallback doc' assert klass.docstring == 'klass doc' assert ast.literal_eval(var2.value or '') == 2 assert ast.literal_eval(var3.value or '') == 2 assert mod.expandName('cv') == 'crazy.crazy_var' assert mod.expandName('thing') == 'object' assert mod.expandName('seven') == 'six.seven' assert 'klass' not in mod._localNameToFullName_map assert 'crazy_var' in mod._localNameToFullName_map # from the wildcard @systemcls_param def test_exception_kind(systemcls: Type[model.System], capsys: CapSys) -> None: """ Exceptions are marked with the special kind "EXCEPTION". """ mod = fromText(''' class Clazz: """Class.""" class MyWarning(DeprecationWarning): """Warnings are technically exceptions""" class Error(SyntaxError): """An exeption""" class SubError(Error): """A exeption subclass""" ''', systemcls=systemcls, modname="mod") warn = mod.contents['MyWarning'] ex1 = mod.contents['Error'] ex2 = mod.contents['SubError'] cls = mod.contents['Clazz'] assert warn.kind is model.DocumentableKind.EXCEPTION assert ex1.kind is model.DocumentableKind.EXCEPTION assert ex2.kind is model.DocumentableKind.EXCEPTION assert cls.kind is model.DocumentableKind.CLASS assert not capsys.readouterr().out @systemcls_param def test_exception_kind_corner_cases(systemcls: Type[model.System], capsys: CapSys) -> None: src1 = '''\ class Exception:... class LooksLikeException(Exception):... # Not an exception ''' src2 = '''\ class Exception(BaseException):... class LooksLikeException(Exception):... # An exception ''' mod1 = fromText(src1, modname='src1', systemcls=systemcls) assert mod1.contents['LooksLikeException'].kind == model.DocumentableKind.CLASS mod2 = fromText(src2, modname='src2', systemcls=systemcls) assert mod2.contents['LooksLikeException'].kind == model.DocumentableKind.EXCEPTION assert not capsys.readouterr().out @systemcls_param def test_syntax_error(systemcls: Type[model.System], capsys: CapSys) -> None: systemcls = partialclass(systemcls, Options.from_args(['-q'])) fromText('''\ def f() return True ''', systemcls=systemcls) assert capsys.readouterr().out == ':???: cannot parse string\n' @systemcls_param def test_syntax_error_pack(systemcls: Type[model.System], capsys: CapSys) -> None: systemcls = partialclass(systemcls, Options.from_args(['-q'])) processPackage('syntax_error', systemcls) out = capsys.readouterr().out.strip('\n') assert "__init__.py:???: cannot parse file, " in out, out @systemcls_param def test_type_alias(systemcls: Type[model.System]) -> None: """ Type aliases and type variables are recognized as such. """ mod = fromText( ''' from typing import Callable, Tuple, TypeAlias, TypeVar T = TypeVar('T') Parser = Callable[[str], Tuple[int, bytes, bytes]] mylst = yourlst = list[str] alist: TypeAlias = 'list[str]' notanalias = 'Callable[[str], Tuple[int, bytes, bytes]]' class F: from ext import what L = _j = what.some = list[str] def __init__(self): self.Pouet: TypeAlias = 'Callable[[str], Tuple[int, bytes, bytes]]' self.Q = q = list[str] ''', systemcls=systemcls) assert mod.contents['T'].kind == model.DocumentableKind.TYPE_VARIABLE assert mod.contents['Parser'].kind == model.DocumentableKind.TYPE_ALIAS assert mod.contents['mylst'].kind == model.DocumentableKind.TYPE_ALIAS assert mod.contents['yourlst'].kind == model.DocumentableKind.TYPE_ALIAS assert mod.contents['alist'].kind == model.DocumentableKind.TYPE_ALIAS assert mod.contents['notanalias'].kind == model.DocumentableKind.VARIABLE assert mod.contents['F'].contents['L'].kind == model.DocumentableKind.TYPE_ALIAS assert mod.contents['F'].contents['_j'].kind == model.DocumentableKind.TYPE_ALIAS # Type variables in instance variables are not recognized assert mod.contents['F'].contents['Pouet'].kind == model.DocumentableKind.INSTANCE_VARIABLE assert mod.contents['F'].contents['Q'].kind == model.DocumentableKind.INSTANCE_VARIABLE @systemcls_param def test_typevartuple(systemcls: Type[model.System]) -> None: """ Variadic type variables are recognized. """ mod = fromText(''' from typing import TypeVarTuple Shape = TypeVarTuple('Shape') ''', systemcls=systemcls) assert mod.contents['Shape'].kind == model.DocumentableKind.TYPE_VARIABLE @systemcls_param def test_prepend_package(systemcls: Type[model.System]) -> None: """ Option --prepend-package option relies simply on the L{ISystemBuilder} interface, so we can test it by using C{addModuleString}, but it's not exactly what happens when we actually run pydoctor. See the other test L{test_prepend_package_real_path}. """ system = systemcls() builder = model.prepend_package(system.systemBuilder, package='lib.pack')(system) builder.addModuleString('"mod doc"\nclass C:\n "C doc"', modname='core') builder.buildModules() assert isinstance(system.allobjects['lib'], model.Package) assert isinstance(system.allobjects['lib.pack'], model.Package) assert isinstance(system.allobjects['lib.pack.core.C'], model.Class) assert 'core' not in system.allobjects @systemcls_param def test_prepend_package_real_path(systemcls: Type[model.System]) -> None: """ In this test, we closer mimics what happens in the driver when --prepend-package option is passed. """ _builderT_init = systemcls.systemBuilder try: systemcls.systemBuilder = model.prepend_package(systemcls.systemBuilder, package='lib.pack') system = processPackage('basic', systemcls=systemcls) assert isinstance(system.allobjects['lib'], model.Package) assert isinstance(system.allobjects['lib.pack'], model.Package) assert isinstance(system.allobjects['lib.pack.basic.mod.C'], model.Class) assert 'basic' not in system.allobjects finally: systemcls.systemBuilder = _builderT_init def getConstructorsText(cls: model.Documentable) -> str: assert isinstance(cls, model.Class) return '\n'.join( epydoc2stan.format_constructor_short_text(c, cls) for c in cls.public_constructors) @systemcls_param def test_crash_type_inference_unhashable_type(systemcls: Type[model.System], capsys:CapSys) -> None: """ This test is about not crashing. A TypeError is raised by ast.literal_eval() in some cases, when we're trying to do a set of lists or a dict with list keys. We do not bother reporting it because pydoctor is not a checker. """ src = ''' # Unhashable type, will raise an error in ast.literal_eval() x = {[1, 2]} class C: v = {[1,2]:1} def __init__(self): self.y = [{'str':2}, {[1,2]:1}] Y = [{'str':2}, {{[1, 2]}:1}] ''' mod = fromText(src, systemcls=systemcls, modname='m') for obj in ['m.x', 'm.C.v', 'm.C.y', 'm.Y']: o = mod.system.allobjects[obj] assert isinstance(o, model.Attribute) assert o.annotation is None assert not capsys.readouterr().out @systemcls_param def test_constructor_signature_init(systemcls: Type[model.System]) -> None: src = '''\ class Person(object): # pydoctor can infer the constructor to be: "Person(name, age)" def __init__(self, name, age): self.name = name self.age = age class Citizen(Person): # pydoctor can infer the constructor to be: "Citizen(nationality, *args, **kwargs)" def __init__(self, nationality, *args, **kwargs): self.nationality = nationality super(Citizen, self).__init__(*args, **kwargs) ''' mod = fromText(src, systemcls=systemcls) # Like "Available constructor: ``Person(name, age)``" that links to Person.__init__ documentation. assert getConstructorsText(mod.contents['Person']) == "Person(name, age)" # Like "Available constructor: ``Citizen(nationality, *args, **kwargs)``" that links to Citizen.__init__ documentation. assert getConstructorsText(mod.contents['Citizen']) == "Citizen(nationality, *args, **kwargs)" @systemcls_param def test_constructor_signature_new(systemcls: Type[model.System]) -> None: src = '''\ class Animal(object): # pydoctor can infer the constructor to be: "Animal(name)" def __new__(cls, name): obj = super().__new__(cls) # assignation not recognized by pydoctor, attribute 'name' will not be documented obj.name = name return obj ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "Animal(name)" @systemcls_param def test_constructor_signature_init_and_new(systemcls: Type[model.System]) -> None: """ Pydoctor can't infer the constructor signature when both __new__ and __init__ are defined. __new__ takes the precedence over __init__ because it's called first. Trying to infer what are the complete constructor signature when __new__ is defined might be very hard because the method can return an instance of another class, calling another __init__ method. We're not there yet in term of static analysis. """ src = '''\ class Animal(object): # both __init__ and __new__ are defined, pydoctor only looks at the __new__ method # pydoctor infers the constructor to be: "Animal(*args, **kw)" def __new__(cls, *args, **kw): print('__new__() called.') print('args: ', args, ', kw: ', kw) return super().__new__(cls) def __init__(self, name): print('__init__() called.') self.name = name class Cat(Animal): # Idem, but __new__ is inherited. # pydoctor infers the constructor to be: "Cat(*args, **kw)" # This is why it's important to still document __init__ as a regular method. def __init__(self, name, owner): super().__init__(name) self.owner = owner ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "Animal(*args, **kw)" assert getConstructorsText(mod.contents['Cat']) == "Cat(*args, **kw)" @systemcls_param def test_constructor_signature_classmethod(systemcls: Type[model.System]) -> None: src = '''\ def get_default_options() -> 'Options': """ This is another constructor for class 'Options'. But it's not recognized by pydoctor because it's not defined in the locals of Options. """ return Options() class Options: a,b,c = None, None, None @classmethod def create_no_hints(cls): """ Pydoctor can't deduce that this method is a constructor as well, because there is no type annotation. """ return cls() # thanks to type hints, # pydoctor can infer the constructor to be: "Options.create()" @staticmethod def create(important_arg) -> 'Options': # the fictional constructor is not detected by pydoctor, because it doesn't exists actually. return Options(1,2,3) # thanks to type hints, # pydoctor can infer the constructor to be: "Options.create_from_num(num)" @classmethod def create_from_num(cls, num) -> 'Options': c = cls.create() c.a = num return c ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Options']) == "Options.create(important_arg)\nOptions.create_from_num(num)" @systemcls_param def test_constructor_inner_class(systemcls: Type[model.System]) -> None: src = '''\ from typing import Self class Animal(object): class Bar(object): # pydoctor can infer the constructor to be: "Animal.Bar(name)" def __new__(cls, name): ... class Foo(object): # pydoctor can infer the constructor to be: "Animal.Bar.Foo.create(name)" @classmethod def create(cls, name) -> 'Self': c = cls.create() c.a = num return c ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal'].contents['Bar']) == "Animal.Bar(name)" assert getConstructorsText(mod.contents['Animal'].contents['Bar'].contents['Foo']) == "Animal.Bar.Foo.create(name)" @systemcls_param def test_constructor_many_parameters(systemcls: Type[model.System]) -> None: src = '''\ class Animal(object): def __new__(cls, name, lastname, age, spec, extinct, group, friends): ... ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "Animal(name, lastname, age, spec, ...)" @systemcls_param def test_constructor_five_paramters(systemcls: Type[model.System]) -> None: src = '''\ class Animal(object): def __new__(cls, name, lastname, age, spec, extinct): ... ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "Animal(name, lastname, age, spec, extinct)" @systemcls_param def test_default_constructors(systemcls: Type[model.System]) -> None: src = '''\ class Animal(object): def __init__(self): ... def __new__(cls): ... @classmethod def new(cls) -> 'Animal': ... ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "Animal.new()" src = '''\ class Animal(object): def __init__(self): ... ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "" src = '''\ class Animal(object): def __init__(self): "thing" ''' mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "Animal()" @systemcls_param def test_class_var_override(systemcls: Type[model.System]) -> None: src = '''\ from number import Number class Thing(object): def __init__(self): self.var: Number = 1 class Stuff(Thing): var:float ''' mod = fromText(src, systemcls=systemcls, modname='mod') var = mod.system.allobjects['mod.Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE @systemcls_param def test_class_var_override_traverse_subclasses(systemcls: Type[model.System]) -> None: src = '''\ from number import Number class Thing(object): def __init__(self): self.var: Number = 1 class _Stuff(Thing): ... class Stuff(_Stuff): var:float ''' mod = fromText(src, systemcls=systemcls, modname='mod') var = mod.system.allobjects['mod.Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE src = '''\ from number import Number class Thing(object): def __init__(self): self.var: Optional[Number] = 0 class _Stuff(Thing): var = None class Stuff(_Stuff): var: float ''' mod = fromText(src, systemcls=systemcls, modname='mod') var = mod.system.allobjects['mod._Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE mod = fromText(src, systemcls=systemcls, modname='mod') var = mod.system.allobjects['mod.Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE def test_class_var_override_attrs() -> None: systemcls = AttrsSystem src = '''\ import attr @attr.s class Thing(object): var = attr.ib() class Stuff(Thing): var: float ''' mod = fromText(src, systemcls=systemcls, modname='mod') var = mod.system.allobjects['mod.Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE @systemcls_param def test_explicit_annotation_wins_over_inferred_type(systemcls: Type[model.System]) -> None: """ Explicit annotations are the preffered way of presenting the type of an attribute. """ src = '''\ class Stuff(object): thing: List[Tuple[Thing, ...]] def __init__(self): self.thing = [] ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] assert flatten_text(epydoc2stan.type2stan(thing)) == "List[Tuple[Thing, ...]]" #type:ignore src = '''\ class Stuff(object): thing = [] def __init__(self): self.thing: List[Tuple[Thing, ...]] = [] ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] assert flatten_text(epydoc2stan.type2stan(thing)) == "List[Tuple[Thing, ...]]" #type:ignore @systemcls_param def test_explicit_inherited_annotation_looses_over_inferred_type(systemcls: Type[model.System]) -> None: """ Annotation are of inherited. """ src = '''\ class _Stuff(object): thing: List[Tuple[Thing, ...]] class Stuff(_Stuff): def __init__(self): self.thing = [] ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] assert flatten_text(epydoc2stan.type2stan(thing)) == "list" #type:ignore @systemcls_param def test_inferred_type_override(systemcls: Type[model.System]) -> None: """ The last visited value will be used to infer the type annotation of an unnanotated attribute. """ src = '''\ class Stuff(object): thing = 1 def __init__(self): self.thing = (1,2) ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] assert flatten_text(epydoc2stan.type2stan(thing)) == "tuple[int, ...]" #type:ignore @systemcls_param def test_inferred_type_is_not_propagated_to_subclasses(systemcls: Type[model.System]) -> None: """ Inferred type annotation should not be propagated to subclasses. """ src = '''\ class _Stuff(object): def __init__(self): self.thing = [] class Stuff(_Stuff): def __init__(self, thing): self.thing = thing ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] assert epydoc2stan.type2stan(thing) is None @systemcls_param def test_inherited_type_is_not_propagated_to_subclasses(systemcls: Type[model.System]) -> None: """ We can't repliably propage the annotations from one class to it's subclass because of issue https://github.com/twisted/pydoctor/issues/295. """ src1 = '''\ class _s:... class _Stuff(object): def __init__(self): self.thing:_s = [] ''' src2 = '''\ from base import _Stuff, _s class Stuff(_Stuff): def __init__(self, thing): self.thing = thing __all__=['Stuff', '_s'] ''' system = systemcls() builder = system.systemBuilder(system) builder.addModuleString(src1, 'base') builder.addModuleString(src2, 'mod') builder.buildModules() thing = system.allobjects['mod.Stuff.thing'] assert epydoc2stan.type2stan(thing) is None @systemcls_param def test_augmented_assignment(systemcls: Type[model.System]) -> None: mod = fromText(''' var = 1 var += 3 ''', systemcls=systemcls) attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1 + 3' if sys.version_info >= (3,9) else '(1 + 3)' @systemcls_param def test_augmented_assignment_in_class(systemcls: Type[model.System]) -> None: mod = fromText(''' class c: var = 1 var += 3 ''', systemcls=systemcls) attr = mod.contents['c'].contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1 + 3' if sys.version_info >= (3,9) else '(1 + 3)' @systemcls_param def test_augmented_assignment_conditionnal_else_ignored(systemcls: Type[model.System]) -> None: """ The If.body branch is the only one in use. """ mod = fromText(''' var = 1 if something(): var += 3 else: var += 4 ''', systemcls=systemcls) attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1 + 3' if sys.version_info >= (3,9) else '(1 + 3)' @systemcls_param def test_augmented_assignment_conditionnal_multiple_assignments(systemcls: Type[model.System]) -> None: """ The If.body branch is the only one in use, but several Ifs which have theoritical exclusive conditions might be wrongly interpreted. """ mod = fromText(''' var = 1 if something(): var += 3 if not_something(): var += 4 ''', systemcls=systemcls) attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1 + 3 + 4' if sys.version_info >= (3,9) else '(1 + 3 + 4)' @systemcls_param def test_augmented_assignment_instance_var(systemcls: Type[model.System]) -> None: """ Augmented assignments in instance var are not analyzed. """ mod = fromText(''' class c: def __init__(self, var): self.var = 1 self.var += var ''') attr = mod.contents['c'].contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1' if sys.version_info >= (3,9) else '(1)' @systemcls_param def test_augmented_assignment_not_suitable_for_inline_docstring(systemcls: Type[model.System]) -> None: """ Augmented assignments cannot have docstring attached. """ mod = fromText(''' var = 1 var += 1 """ this is not a docstring """ class c: var = 1 var += 1 """ this is not a docstring """ ''') attr = mod.contents['var'] assert not attr.docstring attr = mod.contents['c'].contents['var'] assert not attr.docstring @systemcls_param def test_augmented_assignment_alone_is_not_documented(systemcls: Type[model.System]) -> None: mod = fromText(''' var += 1 class c: var += 1 ''') assert 'var' not in mod.contents assert 'var' not in mod.contents['c'].contents @systemcls_param def test_typealias_unstring(systemcls: Type[model.System]) -> None: """ The type aliases are unstringed by the astbuilder """ mod = fromText(''' from typing import Callable ParserFunction = Callable[[str, List['ParseError']], 'ParsedDocstring'] ''', modname='pydoctor.epydoc.markup', systemcls=systemcls) typealias = mod.contents['ParserFunction'] assert isinstance(typealias, model.Attribute) assert typealias.value with pytest.raises(StopIteration): # there is not Constant nodes in the type alias anymore next(n for n in ast.walk(typealias.value) if isinstance(n, ast.Constant)) @systemcls_param def test_mutilple_docstrings_warnings(systemcls: Type[model.System], capsys: CapSys) -> None: """ When pydoctor encounters multiple places where the docstring is defined, it reports a warning. """ src = ''' class C: a: int;"docs" def _(self): self.a = 0; "re-docs" class B: """ @ivar a: docs """ a: int "re-docs" class A: """docs""" A.__doc__ = 're-docs' ''' fromText(src, systemcls=systemcls) assert capsys.readouterr().out == (':5: Existing docstring at line 3 is overriden\n' ':12: Existing docstring at line 9 is overriden\n' ':16: Existing docstring at line 15 is overriden\n') @systemcls_param def test_mutilple_docstring_with_doc_comments_warnings(systemcls: Type[model.System], capsys: CapSys) -> None: src = ''' class C: a: int;"docs" #: re-docs class B: """ @ivar a: docs """ #: re-docs a: int class B2: """ @ivar a: docs """ #: re-docs a: int "re-re-docs" ''' fromText(src, systemcls=systemcls) # TODO: handle doc comments.x assert capsys.readouterr().out == ':18: Existing docstring at line 14 is overriden\n' @systemcls_param def test_import_all_inside_else_branch_is_processed(systemcls: Type[model.System], capsys: CapSys) -> None: src1 = ''' Callable = ... ''' src2 = ''' Callable = ... TypeAlias = ... ''' src0 = ''' import sys if sys.version_info > (3, 10): from typing import * else: from typing_extensions import * ''' system = systemcls() builder = systemcls.systemBuilder(system) builder.addModuleString(src0, 'main') builder.addModuleString(src1, 'typing') builder.addModuleString(src2, 'typing_extensions') builder.buildModules() # assert not capsys.readouterr().out main = system.allobjects['main'] assert list(main.localNames()) == ['sys', 'Callable', 'TypeAlias'] # type: ignore assert main.expandName('Callable') == 'typing.Callable' assert main.expandName('TypeAlias') == 'typing_extensions.TypeAlias' @systemcls_param def test_inline_docstring_multiple_assigments(systemcls: Type[model.System], capsys: CapSys) -> None: # TODO: this currently does not support nested tuple assignments. src = ''' class C: def __init__(self): self.x, x = 1, 1; 'x docs' self.y = x = 1; 'y docs' x,y = 1,1; 'x and y docs' v = w = 1; 'v and w docs' ''' mod = fromText(src, systemcls=systemcls) assert not capsys.readouterr().out assert mod.contents['x'].docstring == 'x and y docs' assert mod.contents['y'].docstring == 'x and y docs' assert mod.contents['v'].docstring == 'v and w docs' assert mod.contents['w'].docstring == 'v and w docs' assert mod.contents['C'].contents['x'].docstring == 'x docs' assert mod.contents['C'].contents['y'].docstring == 'y docs' @systemcls_param def test_does_not_misinterpret_string_as_documentation(systemcls: Type[model.System], capsys: CapSys) -> None: # exmaple from numpy/distutils/ccompiler_opt.py src = ''' __docformat__ = 'numpy' class C: """ Attributes ---------- cc_noopt : bool docs """ def __init__(self): self.cc_noopt = x if True: """ this is not documentation """ ''' mod = fromText(src, systemcls=systemcls) assert _get_docformat(mod) == 'numpy' assert not capsys.readouterr().out assert mod.contents['C'].contents['cc_noopt'].docstring is None # The docstring is None... this is the sad side effect of processing ivar fields :/ assert to_html(mod.contents['C'].contents['cc_noopt'].parsed_docstring) == 'docs' #type:ignore @systemcls_param def test_unsupported_usage_of_self(systemcls: Type[model.System], capsys: CapSys) -> None: src = ''' class C: ... def C_init(self): self.x = True; 'not documentation' self.y += False # erroneous usage of augassign; 'not documentation' C.__init__ = C_init self = object() self.x = False """ not documentation """ ''' mod = fromText(src, systemcls=systemcls) assert not capsys.readouterr().out assert list(mod.contents['C'].contents) == [] assert not mod.contents['self'].docstring @systemcls_param def test_inline_docstring_at_wrong_place(systemcls: Type[model.System], capsys: CapSys) -> None: src = ''' a = objetc() a.b = True """ not documentation """ b = object() b.x: bool = False """ still not documentation """ c = {} c[1] = True """ Again not documenatation """ d = {} d[1].__init__ = True """ Again not documenatation """ e = {} e[1].__init__ += True """ Again not documenatation """ ''' mod = fromText(src, systemcls=systemcls) assert not capsys.readouterr().out assert list(mod.contents) == ['a', 'b', 'c', 'd', 'e'] assert not mod.contents['a'].docstring assert not mod.contents['b'].docstring assert not mod.contents['c'].docstring assert not mod.contents['d'].docstring assert not mod.contents['e'].docstring @systemcls_param def test_Final_constant_under_control_flow_block_is_still_constant(systemcls: Type[model.System], capsys: CapSys) -> None: """ Test for issue https://github.com/twisted/pydoctor/issues/818 """ src = ''' import sys, random, typing as t if sys.version_info > (3,10): v:t.Final = 1 else: v:t.Final = 2 if random.choice([True, False]): w:t.Final = 1 else: w:t.Final = 2 x: t.Final x = 34 ''' mod = fromText(src, systemcls=systemcls) assert not capsys.readouterr().out assert mod.contents['v'].kind == model.DocumentableKind.CONSTANT assert mod.contents['w'].kind == model.DocumentableKind.CONSTANT assert mod.contents['x'].kind == model.DocumentableKind.CONSTANT pydoctor-24.11.2/pydoctor/test/test_astutils.py000066400000000000000000000034421473665144200216460ustar00rootroot00000000000000import ast from textwrap import dedent from pydoctor import astutils def test_parentage() -> None: tree = ast.parse('class f(b):...') astutils.Parentage().visit(tree) assert tree.body[0].parent == tree # type:ignore assert tree.body[0].body[0].parent == tree.body[0] # type:ignore assert tree.body[0].bases[0].parent == tree.body[0] # type:ignore def test_get_assign_docstring_node() -> None: tree = ast.parse('var = 1\n\n\n"inline docs"') astutils.Parentage().visit(tree) assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) == "inline docs" # type:ignore tree = ast.parse('var:int = 1\n\n\n"inline docs"') astutils.Parentage().visit(tree) assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) == "inline docs" # type:ignore def test_get_assign_docstring_node_not_in_body() -> None: src = dedent(''' if True: pass else: v = True; 'inline docs' ''') tree = ast.parse(src) astutils.Parentage().visit(tree) assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].orelse[0])) == "inline docs" # type:ignore src = dedent(''' try: raise ValueError() except: v = True; 'inline docs' else: w = True; 'inline docs' finally: x = True; 'inline docs' ''') tree = ast.parse(src) astutils.Parentage().visit(tree) assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].handlers[0].body[0])) == "inline docs" # type:ignore assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].orelse[0])) == "inline docs" # type:ignore assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].finalbody[0])) == "inline docs" # type:ignore pydoctor-24.11.2/pydoctor/test/test_attrs.py000066400000000000000000000107621473665144200211360ustar00rootroot00000000000000from typing import Type from pydoctor import model from pydoctor.extensions import attrs from pydoctor.test import CapSys from pydoctor.test.test_astbuilder import fromText, AttrsSystem, type2str import pytest attrs_systemcls_param = pytest.mark.parametrize( 'systemcls', (model.System, # system with all extensions enalbed AttrsSystem, # system with attrs extension only )) @attrs_systemcls_param def test_attrs_attrib_type(systemcls: Type[model.System]) -> None: """An attr.ib's "type" or "default" argument is used as an alternative type annotation. """ mod = fromText(''' import attr from attr import attrib @attr.s class C: a = attr.ib(type=int) b = attrib(type=int) c = attr.ib(type='C') d = attr.ib(default=True) e = attr.ib(123) ''', modname='test', systemcls=systemcls) C = mod.contents['C'] A = C.contents['a'] B = C.contents['b'] _C = C.contents['c'] D = C.contents['d'] E = C.contents['e'] assert isinstance(A, model.Attribute) assert isinstance(B, model.Attribute) assert isinstance(_C, model.Attribute) assert isinstance(D, model.Attribute) assert isinstance(E, model.Attribute) assert type2str(A.annotation) == 'int' assert type2str(B.annotation) == 'int' assert type2str(_C.annotation) == 'C' assert type2str(D.annotation) == 'bool' assert type2str(E.annotation) == 'int' @attrs_systemcls_param def test_attrs_attrib_instance(systemcls: Type[model.System]) -> None: """An attr.ib attribute is classified as an instance variable.""" mod = fromText(''' import attr @attr.s class C: a = attr.ib(type=int) ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert C.contents['a'].kind is model.DocumentableKind.INSTANCE_VARIABLE @attrs_systemcls_param def test_attrs_attrib_badargs(systemcls: Type[model.System], capsys: CapSys) -> None: """.""" fromText(''' import attr @attr.s class C: a = attr.ib(nosuchargument='bad') ''', modname='test', systemcls=systemcls) captured = capsys.readouterr().out assert captured == ( 'test:5: Invalid arguments for attr.ib(): got an unexpected keyword argument "nosuchargument"\n' ) @attrs_systemcls_param def test_attrs_auto_instance(systemcls: Type[model.System]) -> None: """Attrs auto-attributes are classified as instance variables.""" mod = fromText(''' from typing import ClassVar import attr @attr.s(auto_attribs=True) class C: a: int b: bool = False c: ClassVar[str] # explicit class variable d = 123 # ignored by auto_attribs because no annotation ''', modname='test', systemcls=systemcls) C = mod.contents['C'] assert isinstance(C, attrs.AttrsClass) assert C.auto_attribs == True assert C.contents['a'].kind is model.DocumentableKind.INSTANCE_VARIABLE assert C.contents['b'].kind is model.DocumentableKind.INSTANCE_VARIABLE assert C.contents['c'].kind is model.DocumentableKind.CLASS_VARIABLE assert C.contents['d'].kind is model.DocumentableKind.CLASS_VARIABLE @attrs_systemcls_param def test_attrs_args(systemcls: Type[model.System], capsys: CapSys) -> None: """Non-existing arguments and invalid values to recognized arguments are rejected with a warning. """ fromText(''' import attr @attr.s() class C0: ... @attr.s(repr=False) class C1: ... @attr.s(auto_attribzzz=True) class C2: ... @attr.s(auto_attribs=not False) class C3: ... @attr.s(auto_attribs=1) class C4: ... ''', modname='test', systemcls=systemcls) captured = capsys.readouterr().out assert captured == ( 'test:10: Invalid arguments for attr.s(): got an unexpected keyword argument "auto_attribzzz"\n' 'test:13: Unable to figure out value for "auto_attribs" argument to attr.s(), maybe too complex\n' 'test:16: Value for "auto_attribs" argument to attr.s() has type "int", expected "bool"\n' ) @attrs_systemcls_param def test_attrs_class_else_branch(systemcls: Type[model.System]) -> None: mod = fromText(''' import attr foo = bar = lambda:False if foo(): pass else: @attr.s class C: if bar(): pass else: var = attr.ib() ''', systemcls=systemcls) var = mod.contents['C'].contents['var'] assert var.kind is model.DocumentableKind.INSTANCE_VARIABLEpydoctor-24.11.2/pydoctor/test/test_colorize.py000066400000000000000000000066651473665144200216360ustar00rootroot00000000000000from pydoctor.epydoc.doctest import colorize_codeblock, colorize_doctest from pydoctor.stanutils import flatten def test_colorize_codeblock() -> None: src = ''' def foo(): """A multi-line docstring. The "doc" part doesn't matter for this test, but the "string" part does. """ return list({1, 2, 3}) class Foo: def __init__(self): # Nothing to do. pass '''.lstrip() expected = '''
    def foo():
        """A multi-line docstring.
    
        The "doc" part doesn't matter for this test,
        but the "string" part does.
        """
        return list({1, 2, 3})
    
    class Foo:
        def __init__(self):
            # Nothing to do.
            pass
    
    '''.strip() assert flatten(colorize_codeblock(src)) == expected def test_colorize_doctest_more_string() -> None: src = ''' Test multi-line string: >>> """A ... B ... C""" 'A\\nB\\nC' '''.lstrip() expected = '''
    Test multi-line string:
    
        >>> """A
        ... B
        ... C"""
        'A\\nB\\nC'
    
    '''.strip() assert flatten(colorize_doctest(src)) == expected def test_colorize_doctest_more_input() -> None: src = ''' Test multi-line expression: >>> [chr(i + 65) ... for i in range(26) ... if i % 2 == 0] ['A', 'C', 'E', 'G', 'I', 'K', 'M', 'O', 'Q', 'S', 'U', 'W', 'Y'] '''.lstrip() expected = '''
    Test multi-line expression:
    
        >>> [chr(i + 65)
        ...  for i in range(26)
        ...  if i % 2 == 0]
        ['A', 'C', 'E', 'G', 'I', 'K', 'M', 'O', 'Q', 'S', 'U', 'W', 'Y']
    
    '''.strip() assert flatten(colorize_doctest(src)) == expected def test_colorize_doctest_exception() -> None: src = ''' Test division by zero: >>> 1/0 Traceback (most recent call last): ZeroDivisionError: integer division or modulo by zero '''.lstrip() expected = '''
    Test division by zero:
    
        >>> 1/0
        Traceback (most recent call last):
        ZeroDivisionError: integer division or modulo by zero
    
    '''.strip() assert flatten(colorize_doctest(src)) == expected def test_colorize_doctest_no_output() -> None: src = ''' Test expecting no output: >>> None '''.lstrip() expected = '''
    Test expecting no output:
    
        >>> None
    
    '''.strip() assert flatten(colorize_doctest(src)) == expected pydoctor-24.11.2/pydoctor/test/test_commandline.py000066400000000000000000000261751473665144200222740ustar00rootroot00000000000000from contextlib import redirect_stdout from io import StringIO from pathlib import Path import re import sys import pytest from pydoctor.options import Options from pydoctor import driver from . import CapSys def geterrtext(*options: str) -> str: """ Run CLI with options and return the output triggered by system exit. """ se = sys.stderr f = StringIO() print(options) sys.stderr = f try: try: driver.main(list(options)) except SystemExit: pass else: assert False, "did not fail" finally: sys.stderr = se return f.getvalue() def test_invalid_option() -> None: err = geterrtext('--no-such-option') assert 'unrecognized arguments: --no-such-option' in err def test_cannot_advance_blank_system() -> None: err = geterrtext('--make-html') assert 'No source paths given' in err def test_no_systemclasses_py3() -> None: err = geterrtext('--system-class') assert 'expected one argument' in err def test_invalid_systemclasses() -> None: err = geterrtext('--system-class=notdotted') assert 'dotted name' in err err = geterrtext('--system-class=no-such-module.System') assert 'could not import module' in err err = geterrtext('--system-class=pydoctor.model.Class') assert 'is not a subclass' in err def test_projectbasedir_absolute(tmp_path: Path) -> None: """ The --project-base-dir option, when given an absolute path, should set that path as the projectbasedirectory attribute on the options object. Previous versions of this test tried using non-existing paths and compared the string representations, but that was unreliable, since the input path might contain a symlink that will be resolved, such as "/home" on macOS. Using L{Path.samefile()} is reliable, but requires an existing path. """ assert tmp_path.is_absolute() options = Options.from_args(["--project-base-dir", str(tmp_path)]) assert options.projectbasedirectory is not None assert options.projectbasedirectory.samefile(tmp_path) assert options.projectbasedirectory.is_absolute() @pytest.mark.skipif("platform.python_implementation() == 'PyPy' and platform.system() == 'Windows'") def test_projectbasedir_symlink(tmp_path: Path) -> None: """ The --project-base-dir option, when given a path containing a symbolic link, should resolve the path to the target directory. """ target = tmp_path / 'target' target.mkdir() link = tmp_path / 'link' link.symlink_to('target', target_is_directory=True) assert link.samefile(target) options = Options.from_args(["--project-base-dir", str(link)]) assert options.projectbasedirectory is not None assert options.projectbasedirectory.samefile(target) assert options.projectbasedirectory.is_absolute() def test_projectbasedir_relative() -> None: """ The --project-base-dir option, when given a relative path, should convert that path to absolute and set it as the projectbasedirectory attribute on the options object. """ relative = "projbasedirvalue" options = Options.from_args(["--project-base-dir", relative]) assert options.projectbasedirectory is not None assert options.projectbasedirectory.is_absolute() assert options.projectbasedirectory.name == relative assert options.projectbasedirectory.parent == Path.cwd() def test_help_option(capsys: CapSys) -> None: """ pydoctor --help """ try: driver.main(args=['--help']) except SystemExit: assert '--project-name PROJECTNAME' in capsys.readouterr().out else: assert False def test_cache_enabled_by_default() -> None: """ Intersphinx object caching is enabled by default. """ options = Options.defaults() assert options.enable_intersphinx_cache def test_cli_warnings_on_error() -> None: """ The --warnings-as-errors option is disabled by default. This is the test for the long form of the CLI option. """ options = Options.defaults() assert options.warnings_as_errors == False options = Options.from_args(['--warnings-as-errors']) assert options.warnings_as_errors == True def test_project_version_default() -> None: """ When no --project-version is provided, it will default empty string. """ options = Options.defaults() assert options.projectversion == '' def test_project_version_string() -> None: """ --project-version can be passed as a simple string. """ options = Options.from_args(['--project-version', '1.2.3.rc1']) assert options.projectversion == '1.2.3.rc1' def test_main_project_name_guess(capsys: CapSys) -> None: """ When no project name is provided in the CLI arguments, a default name is used and logged. """ exit_code = driver.main(args=[ '-v', '--testing', 'pydoctor/test/testpackages/basic/' ]) assert exit_code == 0 assert "Guessing 'basic' for project name." in capsys.readouterr().out def test_main_project_name_option(capsys: CapSys) -> None: """ When a project name is provided in the CLI arguments nothing is logged. """ exit_code = driver.main(args=[ '-v', '--testing', '--project-name=some-name', 'pydoctor/test/testpackages/basic/' ]) assert exit_code == 0 assert 'Guessing ' not in capsys.readouterr().out def test_main_return_zero_on_warnings() -> None: """ By default it will return 0 as exit code even when there are warnings. """ stream = StringIO() with redirect_stdout(stream): exit_code = driver.main(args=[ '--html-writer=pydoctor.test.InMemoryWriter', 'pydoctor/test/testpackages/report_trigger/' ]) assert exit_code == 0 assert "__init__.py:8: Unknown field 'bad_field'" in stream.getvalue() assert 'report_module.py:9: Cannot find link target for "BadLink"' in stream.getvalue() def test_main_return_non_zero_on_warnings() -> None: """ When `-W` is used it returns 3 as exit code when there are warnings. """ stream = StringIO() with redirect_stdout(stream): exit_code = driver.main(args=[ '-W', '--html-writer=pydoctor.test.InMemoryWriter', 'pydoctor/test/testpackages/report_trigger/' ]) assert exit_code == 3 assert "__init__.py:8: Unknown field 'bad_field'" in stream.getvalue() assert 'report_module.py:9: Cannot find link target for "BadLink"' in stream.getvalue() @pytest.mark.skipif("platform.python_implementation() == 'PyPy' and platform.system() == 'Windows'") def test_main_symlinked_paths(tmp_path: Path) -> None: """ The project base directory and package/module directories are normalized in the same way, such that System.setSourceHref() can call Path.relative_to() on them. """ link = tmp_path / 'src' link.symlink_to(Path.cwd(), target_is_directory=True) exit_code = driver.main(args=[ '--project-base-dir=.', '--html-viewsource-base=http://example.com', f'{link}/pydoctor/test/testpackages/basic/' ]) assert exit_code == 0 def test_main_source_outside_basedir(capsys: CapSys) -> None: """ If a --project-base-dir is given, all package and module paths should be located inside that base directory if source links wants to be generated. Otherwise it's OK, but no source links will be genrated """ assert driver.main(args=[ '--html-viewsource-base=notnone', '--project-base-dir=docs', 'pydoctor/test/testpackages/basic/' ]) == 0 re.match("No source links can be generated for module .+/pydoctor/test/testpackages/basic/: source path lies outside base directory .+/docs\n", capsys.readouterr().out) assert driver.main(args=[ '--project-base-dir=docs', 'pydoctor/test/testpackages/basic/' ]) == 0 assert "No source links can be generated" not in capsys.readouterr().out assert driver.main(args=[ '--html-viewsource-base=notnone', '--project-base-dir=pydoctor/test/testpackages/', 'pydoctor/test/testpackages/basic/' ]) == 0 assert "No source links can be generated" not in capsys.readouterr().out def test_make_intersphix(tmp_path: Path) -> None: """ --make-intersphinx without --make-html will only produce the Sphinx inventory object. This is also an integration test for the Sphinx inventory writer. """ inventory = tmp_path / 'objects.inv' exit_code = driver.main(args=[ '--project-base-dir=.', '--make-intersphinx', '--project-name=acme-lib', '--project-version=20.12.0-dev123', '--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/' ]) assert exit_code == 0 # No other files are created, other than the inventory. assert [p.name for p in tmp_path.iterdir()] == ['objects.inv'] assert inventory.is_file() assert b'Project: acme-lib\n# Version: 20.12.0-dev123\n' in inventory.read_bytes() def test_index_symlink(tmp_path: Path) -> None: """ Test that the default behaviour is to create symlinks, at least on unix. For windows users, this has not been a success, so we automatically fallback to copying the file now. See https://github.com/twisted/pydoctor/issues/808, https://github.com/twisted/pydoctor/issues/720. """ import platform exit_code = driver.main(args=['--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) assert exit_code == 0 link = (tmp_path / 'basic.html') assert link.exists() if platform.system() == 'Windows': assert link.is_symlink() or link.is_file() else: assert link.is_symlink() def test_index_hardlink(tmp_path: Path) -> None: """ Test for option --use-hardlink wich enforce the usage of harlinks. """ exit_code = driver.main(args=['--use-hardlink', '--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) assert exit_code == 0 assert (tmp_path / 'basic.html').exists() assert not (tmp_path / 'basic.html').is_symlink() assert (tmp_path / 'basic.html').is_file() def test_apidocs_help(tmp_path: Path) -> None: """ Checks that the help page is well generated. """ exit_code = driver.main(args=['--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) assert exit_code == 0 help_page = (tmp_path / 'apidocs-help.html').read_text() assert '>Search

    ' in help_page def test_htmlbaseurl_option_all_pages(tmp_path: Path) -> None: """ Check that the canonical link is included in all html pages, including summary pages. """ exit_code = driver.main(args=[ '--html-base-url=https://example.com.abcde', '--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) assert exit_code == 0 for t in tmp_path.iterdir(): if not t.name.endswith('.html'): continue filename = t.name if t.stem == 'basic': filename = 'index.html' # since we have only one module it's linked as index.html assert f' None: assert unquote_str('string') == 'string' assert unquote_str('"string') == '"string' assert unquote_str('string"') == 'string"' assert unquote_str('"string"') == 'string' assert unquote_str('\'string\'') == 'string' assert unquote_str('"""string"""') == 'string' assert unquote_str('\'\'\'string\'\'\'') == 'string' assert unquote_str('"""\nstring"""') == '\nstring' assert unquote_str('\'\'\'string\n\'\'\'') == 'string\n' assert unquote_str('"""\nstring \n"""') == '\nstring \n' assert unquote_str('\'\'\'\n string\n\'\'\'') == '\n string\n' assert unquote_str('\'\'\'string') == '\'\'\'string' assert unquote_str('string\'\'\'') == 'string\'\'\'' assert unquote_str('"""string') == '"""string' assert unquote_str('string"""') == 'string"""' assert unquote_str('"""str"""ing"""') == '"""str"""ing"""' assert unquote_str('str\'ing') == 'str\'ing' assert unquote_str('""""value""""') == '""""value""""' def test_unquote_naughty_quoted_strings() -> None: # See https://github.com/minimaxir/big-list-of-naughty-strings/blob/master/blns.txt res = requests.get('https://raw.githubusercontent.com/minimaxir/big-list-of-naughty-strings/master/blns.txt') text = res.text for i, string in enumerate(text.split('\n')): if string.strip().startswith('#'): continue # gerenerate two quoted version of the naughty string # simply once naughty_string_quoted = repr(string) # quoted twice, once with repr, once with our colorizer # (we insert \n such that we force the colorier to produce tripple quoted strings) naughty_string_quoted2 = color2(f"\n{string!r}", linelen=0) assert naughty_string_quoted2.startswith("'''") naughty_string_quoted2_alt = repr(f"{string!r}") # test unquote that repr try: assert unquote_str(naughty_string_quoted) == string assert unquote_str(unquote_str(naughty_string_quoted2).strip()) == string assert unquote_str(unquote_str(naughty_string_quoted2_alt)) == string if is_quoted(string): assert unquote_str(string) == string[1:-1] else: assert unquote_str(string) == string except Exception as e: raise AssertionError(f'error with naughty string at line {i}: {e}') from e def test_parse_toml_section_keys() -> None: assert parse_toml_section_name('tool.pydoctor') == ('tool', 'pydoctor') assert parse_toml_section_name(' tool.pydoctor ') == ('tool', 'pydoctor') assert parse_toml_section_name(' "tool".pydoctor ') == ('tool', 'pydoctor') assert parse_toml_section_name(' tool."pydoctor" ') == ('tool', 'pydoctor') INI_SIMPLE_STRINGS: List[Dict[str, Any]] = [ {'line': 'key = value # not_a_comment # not_a_comment', 'expected': ('key', 'value # not_a_comment # not_a_comment', None)}, # that's normal behaviour for configparser {'line': 'key=value#not_a_comment ', 'expected': ('key', 'value#not_a_comment', None)}, {'line': 'key=value', 'expected': ('key', 'value', None)}, {'line': 'key =value', 'expected': ('key', 'value', None)}, {'line': 'key= value', 'expected': ('key', 'value', None)}, {'line': 'key = value', 'expected': ('key', 'value', None)}, {'line': 'key = value', 'expected': ('key', 'value', None)}, {'line': ' key = value ', 'expected': ('key', 'value', None)}, {'line': 'key:value', 'expected': ('key', 'value', None)}, {'line': 'key :value', 'expected': ('key', 'value', None)}, {'line': 'key: value', 'expected': ('key', 'value', None)}, {'line': 'key : value', 'expected': ('key', 'value', None)}, {'line': 'key : value', 'expected': ('key', 'value', None)}, {'line': ' key : value ', 'expected': ('key', 'value', None)}, ] INI_QUOTES_CORNER_CASES: List[Dict[str, Any]] = [ {'line': 'key="', 'expected': ('key', '"', None)}, {'line': 'key = "', 'expected': ('key', '"', None)}, {'line': ' key = " ', 'expected': ('key', '"', None)}, {'line': 'key = ""value""', 'expected': ('key', '""value""', None)}, # Not a valid python, so we get the original value, which is normal {'line': 'key = \'\'value\'\'', 'expected': ('key', "''value''", None)}, # Idem ] INI_QUOTED_STRINGS: List[Dict[str, Any]] = [ {'line': 'key="value"', 'expected': ('key', 'value', None)}, {'line': 'key = "value"', 'expected': ('key', 'value', None)}, {'line': ' key = "value" ', 'expected': ('key', 'value', None)}, {'line': 'key=" value "', 'expected': ('key', ' value ', None)}, {'line': 'key = " value "', 'expected': ('key', ' value ', None)}, {'line': ' key = " value " ', 'expected': ('key', ' value ', None)}, {'line': "key='value'", 'expected': ('key', 'value', None)}, {'line': "key = 'value'", 'expected': ('key', 'value', None)}, {'line': " key = 'value' ", 'expected': ('key', 'value', None)}, {'line': "key=' value '", 'expected': ('key', ' value ', None)}, {'line': "key = ' value '", 'expected': ('key', ' value ', None)}, {'line': " key = ' value ' ", 'expected': ('key', ' value ', None)}, {'line': 'key = \'"value"\'', 'expected': ('key', '"value"', None)}, {'line': 'key = "\'value\'"', 'expected': ('key', "'value'", None)}, ] INI_LOOKS_LIKE_QUOTED_STRINGS: List[Dict[str, Any]] = [ {'line': 'key="value', 'expected': ('key', '"value', None)}, {'line': 'key = "value', 'expected': ('key', '"value', None)}, {'line': ' key = "value ', 'expected': ('key', '"value', None)}, {'line': 'key=value"', 'expected': ('key', 'value"', None)}, {'line': 'key = value"', 'expected': ('key', 'value"', None)}, {'line': ' key = value " ', 'expected': ('key', 'value "', None)}, {'line': "key='value", 'expected': ('key', "'value", None)}, {'line': "key = 'value", 'expected': ('key', "'value", None)}, {'line': " key = 'value ", 'expected': ('key', "'value", None)}, {'line': "key=value'", 'expected': ('key', "value'", None)}, {'line': "key = value'", 'expected': ('key', "value'", None)}, {'line': " key = value ' ", 'expected': ('key', "value '", None)}, ] INI_BLANK_LINES: List[Dict[str, Any]] = [ {'line': 'key=', 'expected': ('key', '', None)}, {'line': 'key =', 'expected': ('key', '', None)}, {'line': 'key= ', 'expected': ('key', '', None)}, {'line': 'key = ', 'expected': ('key', '', None)}, {'line': 'key = ', 'expected': ('key', '', None)}, {'line': ' key = ', 'expected': ('key', '', None)}, {'line': 'key:', 'expected': ('key', '', None)}, {'line': 'key :', 'expected': ('key', '', None)}, {'line': 'key: ', 'expected': ('key', '', None)}, {'line': 'key : ', 'expected': ('key', '', None)}, {'line': 'key : ', 'expected': ('key', '', None)}, {'line': ' key : ', 'expected': ('key', '', None)}, ] INI_EQUAL_SIGN_VALUE: List[Dict[str, Any]] = [ {'line': 'key=:', 'expected': ('key', ':', None)}, {'line': 'key =:', 'expected': ('key', ':', None)}, {'line': 'key= :', 'expected': ('key', ':', None)}, {'line': 'key = :', 'expected': ('key', ':', None)}, {'line': 'key = :', 'expected': ('key', ':', None)}, {'line': ' key = : ', 'expected': ('key', ':', None)}, {'line': 'key:=', 'expected': ('key', '=', None)}, {'line': 'key :=', 'expected': ('key', '=', None)}, {'line': 'key: =', 'expected': ('key', '=', None)}, {'line': 'key : =', 'expected': ('key', '=', None)}, {'line': 'key : =', 'expected': ('key', '=', None)}, {'line': ' key : = ', 'expected': ('key', '=', None)}, {'line': 'key==', 'expected': ('key', '=', None)}, {'line': 'key ==', 'expected': ('key', '=', None)}, {'line': 'key= =', 'expected': ('key', '=', None)}, {'line': 'key = =', 'expected': ('key', '=', None)}, {'line': 'key = =', 'expected': ('key', '=', None)}, {'line': ' key = = ', 'expected': ('key', '=', None)}, {'line': 'key::', 'expected': ('key', ':', None)}, {'line': 'key ::', 'expected': ('key', ':', None)}, {'line': 'key: :', 'expected': ('key', ':', None)}, {'line': 'key : :', 'expected': ('key', ':', None)}, {'line': 'key : :', 'expected': ('key', ':', None)}, {'line': ' key : : ', 'expected': ('key', ':', None)}, ] INI_NEGATIVE_VALUES: List[Dict[str, Any]] = [ {'line': 'key = -10', 'expected': ('key', '-10', None)}, {'line': 'key : -10', 'expected': ('key', '-10', None)}, # {'line': 'key -10', 'expected': ('key', '-10', None)}, # Not supported {'line': 'key = "-10"', 'expected': ('key', '-10', None)}, {'line': "key = '-10'", 'expected': ('key', '-10', None)}, {'line': 'key=-10', 'expected': ('key', '-10', None)}, ] INI_KEY_SYNTAX_EMPTY: List[Dict[str, Any]] = [ {'line': 'key_underscore=', 'expected': ('key_underscore', '', None)}, {'line': '_key_underscore=', 'expected': ('_key_underscore', '', None)}, {'line': 'key_underscore_=', 'expected': ('key_underscore_', '', None)}, {'line': 'key-dash=', 'expected': ('key-dash', '', None)}, {'line': 'key@word=', 'expected': ('key@word', '', None)}, {'line': 'key$word=', 'expected': ('key$word', '', None)}, {'line': 'key.word=', 'expected': ('key.word', '', None)}, ] INI_KEY_SYNTAX: List[Dict[str, Any]] = [ {'line': 'key_underscore = value', 'expected': ('key_underscore', 'value', None)}, # {'line': 'key_underscore', 'expected': ('key_underscore', 'true', None)}, # Not supported {'line': '_key_underscore = value', 'expected': ('_key_underscore', 'value', None)}, # {'line': '_key_underscore', 'expected': ('_key_underscore', 'true', None)}, # Idem {'line': 'key_underscore_ = value', 'expected': ('key_underscore_', 'value', None)}, # {'line': 'key_underscore_', 'expected': ('key_underscore_', 'true', None)}, Idem {'line': 'key-dash = value', 'expected': ('key-dash', 'value', None)}, # {'line': 'key-dash', 'expected': ('key-dash', 'true', None)}, # Idem {'line': 'key@word = value', 'expected': ('key@word', 'value', None)}, # {'line': 'key@word', 'expected': ('key@word', 'true', None)}, Idem {'line': 'key$word = value', 'expected': ('key$word', 'value', None)}, # {'line': 'key$word', 'expected': ('key$word', 'true', None)}, Idem {'line': 'key.word = value', 'expected': ('key.word', 'value', None)}, # {'line': 'key.word', 'expected': ('key.word', 'true', None)}, Idem ] INI_LITERAL_LIST: List[Dict[str, Any]] = [ {'line': 'key = [1,2,3]', 'expected': ('key', ['1','2','3'], None)}, {'line': 'key = []', 'expected': ('key', [], None)}, {'line': 'key = ["hello", "world", ]', 'expected': ('key', ["hello", "world"], None)}, {'line': 'key = [\'hello\', \'world\', ]', 'expected': ('key', ["hello", "world"], None)}, {'line': 'key = [1,2,3] ', 'expected': ('key', ['1','2','3'], None)}, {'line': 'key = [\n ] \n', 'expected': ('key', [], None)}, {'line': 'key = [\n "hello", "world", ] \n\n\n\n', 'expected': ('key', ["hello", "world"], None)}, {'line': 'key = [\n\n \'hello\', \n \'world\', ]', 'expected': ('key', ["hello", "world"], None)}, {'line': r'key = "[\"hello\", \"world\", ]"', 'expected': ('key', "[\"hello\", \"world\", ]", None)}, ] INI_TRIPPLE_QUOTED_STRINGS: List[Dict[str, Any]] = [ {'line': 'key="""value"""', 'expected': ('key', 'value', None)}, {'line': 'key = """value"""', 'expected': ('key', 'value', None)}, {'line': ' key = """value""" ', 'expected': ('key', 'value', None)}, {'line': 'key=""" value """', 'expected': ('key', ' value ', None)}, {'line': 'key = """ value """', 'expected': ('key', ' value ', None)}, {'line': ' key = """ value """ ', 'expected': ('key', ' value ', None)}, {'line': "key='''value'''", 'expected': ('key', 'value', None)}, {'line': "key = '''value'''", 'expected': ('key', 'value', None)}, {'line': " key = '''value''' ", 'expected': ('key', 'value', None)}, {'line': "key=''' value '''", 'expected': ('key', ' value ', None)}, {'line': "key = ''' value '''", 'expected': ('key', ' value ', None)}, {'line': " key = ''' value ''' ", 'expected': ('key', ' value ', None)}, {'line': 'key = \'\'\'"value"\'\'\'', 'expected': ('key', '"value"', None)}, {'line': 'key = """\'value\'"""', 'expected': ('key', "'value'", None)}, {'line': 'key = """\\"value\\""""', 'expected': ('key', '"value"', None)}, ] # These test does not pass with TOML (even if toml support tripple quoted strings) because indentation # is lost while parsing the config with configparser. The bahaviour is basically the same as # running textwrap.dedent() on the text. INI_TRIPPLE_QUOTED_STRINGS_NOT_COMPATIABLE_WITH_TOML: List[Dict[str, Any]] = [ {'line': 'key = """"value\\""""', 'expected': ('key', '"value"', None)}, # This is valid for ast.literal_eval but not for TOML. {'line': 'key = """"value" """', 'expected': ('key', '"value" ', None)}, # Idem. {'line': 'key = \'\'\'\'value\\\'\'\'\'', 'expected': ('key', "'value'", None)}, # The rest of the test cases are not passing for TOML, # we get the indented string instead, anyway, it's not onus to test TOML. {'line': 'key="""\n value\n """', 'expected': ('key', '\nvalue\n', None)}, {'line': 'key = """\n value\n """', 'expected': ('key', '\nvalue\n', None)}, {'line': ' key = """\n value\n """ ', 'expected': ('key', '\nvalue\n', None)}, {'line': "key='''\n value\n '''", 'expected': ('key', '\nvalue\n', None)}, {'line': "key = '''\n value\n '''", 'expected': ('key', '\nvalue\n', None)}, {'line': " key = '''\n value\n ''' ", 'expected': ('key', '\nvalue\n', None)}, {'line': 'key= \'\'\'\n """\n \'\'\'', 'expected': ('key', '\n"""\n', None)}, {'line': 'key = \'\'\'\n """""\n \'\'\'', 'expected': ('key', '\n"""""\n', None)}, {'line': ' key = \'\'\'\n ""\n \'\'\' ', 'expected': ('key', '\n""\n', None)}, {'line': 'key = \'\'\'\n "value"\n \'\'\'', 'expected': ('key', '\n"value"\n', None)}, {'line': 'key = """\n \'value\'\n """', 'expected': ('key', "\n'value'\n", None)}, {'line': 'key = """"\n value\\"\n """', 'expected': ('key', '"\nvalue"\n', None)}, {'line': 'key = """\n \\"value\\"\n """', 'expected': ('key', '\n"value"\n', None)}, {'line': 'key = """\n "value" \n """', 'expected': ('key', '\n"value"\n', None)}, # trailling white spaces are removed by configparser {'line': 'key = \'\'\'\n \'value\\\'\n \'\'\'', 'expected': ('key', "\n'value'\n", None)}, ] INI_LOOKS_LIKE_TRIPPLE_QUOTED_STRINGS: List[Dict[str, Any]] = [ {'line': 'key= """', 'expected': ('key', '"""', None)}, {'line': 'key = """""', 'expected': ('key', '"""""', None)}, {'line': ' key = """" ', 'expected': ('key', '""""', None)}, {'line': 'key = """"value""""', 'expected': ('key', '""""value""""', None)}, # Not a valid python, so we get the original value, which is normal {'line': 'key = \'\'\'\'value\'\'\'\'', 'expected': ('key', "''''value''''", None)}, # Idem {'line': 'key="""value', 'expected': ('key', '"""value', None)}, {'line': 'key = """value', 'expected': ('key', '"""value', None)}, {'line': ' key = """value ', 'expected': ('key', '"""value', None)}, {'line': 'key=value"""', 'expected': ('key', 'value"""', None)}, {'line': 'key = value"""', 'expected': ('key', 'value"""', None)}, {'line': ' key = value """ ', 'expected': ('key', 'value """', None)}, {'line': "key='''value", 'expected': ('key', "'''value", None)}, {'line': "key = '''value", 'expected': ('key', "'''value", None)}, {'line': " key = '''value ", 'expected': ('key', "'''value", None)}, {'line': "key=value'''", 'expected': ('key', "value'''", None)}, {'line': "key = value'''", 'expected': ('key', "value'''", None)}, {'line': " key = value ''' ", 'expected': ('key', "value '''", None)}, ] INI_BLANK_LINES_QUOTED: List[Dict[str, Any]] = [ {'line': 'key=""', 'expected': ('key', '', None)}, {'line': 'key =""', 'expected': ('key', '', None)}, {'line': 'key= ""', 'expected': ('key', '', None)}, {'line': 'key = ""', 'expected': ('key', '', None)}, {'line': 'key = \'\'', 'expected': ('key', '', None)}, {'line': ' key =\'\' ', 'expected': ('key', '', None)}, ] INI_BLANK_LINES_QUOTED_COLONS: List[Dict[str, Any]] = [ {'line': 'key:\'\'', 'expected': ('key', '', None)}, {'line': 'key :\'\'', 'expected': ('key', '', None)}, {'line': 'key: \'\'', 'expected': ('key', '', None)}, {'line': 'key : \'\'', 'expected': ('key', '', None)}, {'line': 'key :\'\' ', 'expected': ('key', '', None)}, {'line': ' key : "" ', 'expected': ('key', '', None)}, ] INI_MULTILINE_STRING_LIST: List[Dict[str, Any]] = [ {'line': 'key = \n hello\n hoho', 'expected': ('key', ["hello", "hoho"], None)}, {'line': 'key = hello\n hoho', 'expected': ('key', ["hello", "hoho"], None)}, {'line': 'key : "hello"\n \'hoho\'', 'expected': ('key', ["\"hello\"", "'hoho'"], None)}, # quotes are kept when converting multine strings to list. {'line': 'key : \n hello\n hoho\n', 'expected': ('key', ["hello", "hoho"], None)}, {'line': 'key = \n hello\n hoho\n \n\n ', 'expected': ('key', ["hello", "hoho"], None)}, {'line': 'key = \n hello\n;comment\n\n hoho\n \n\n ', 'expected': ('key', ["hello", "hoho"], None)}, ] def get_IniConfigParser_cases() -> List[Dict[str, Any]]: return (INI_SIMPLE_STRINGS + INI_QUOTED_STRINGS + INI_BLANK_LINES + INI_NEGATIVE_VALUES + INI_BLANK_LINES_QUOTED + INI_BLANK_LINES_QUOTED_COLONS + INI_KEY_SYNTAX + INI_KEY_SYNTAX_EMPTY + INI_LITERAL_LIST + INI_TRIPPLE_QUOTED_STRINGS + INI_LOOKS_LIKE_TRIPPLE_QUOTED_STRINGS + INI_QUOTES_CORNER_CASES + INI_LOOKS_LIKE_QUOTED_STRINGS) def get_IniConfigParser_multiline_text_to_list_cases() -> List[Dict[str, Any]]: cases = get_IniConfigParser_cases() for case in INI_BLANK_LINES + INI_KEY_SYNTAX_EMPTY: # when multiline_text_to_list is enabled blank lines are simply ignored. cases.remove(case) cases.extend(INI_MULTILINE_STRING_LIST) return cases def get_TomlConfigParser_cases() -> List[Dict[str, Any]]: return (INI_QUOTED_STRINGS + INI_BLANK_LINES_QUOTED + INI_LITERAL_LIST + INI_TRIPPLE_QUOTED_STRINGS) def test_IniConfigParser() -> None: # Not supported by configparser (currently raises error) # {'line': 'key value', 'expected': ('key', 'value', None)}, # {'line': 'key value', 'expected': ('key', 'value', None)}, # {'line': ' key value ', 'expected': ('key', 'value', None)} # {'line': 'key ', 'expected': ('key', 'true', None)}, # {'line': 'key', 'expected': ('key', 'true', None)}, # {'line': 'key ', 'expected': ('key', 'true', None)}, # {'line': ' key ', 'expected': ('key', 'true', None)}, p = IniConfigParser(['soft'], False) for test in get_IniConfigParser_cases(): try: parsed_obj = p.parse(StringIO('[soft]\n'+test['line'])) except Exception as e: raise AssertionError("Line %r, error: %s" % (test['line'], str(e))) from e else: parsed_obj = dict(parsed_obj) expected = {test['expected'][0]: test['expected'][1]} assert parsed_obj==expected, "Line %r" % (test['line']) def test_IniConfigParser_multiline_text_to_list() -> None: p = IniConfigParser(['soft'], True) for test in get_IniConfigParser_multiline_text_to_list_cases(): try: parsed_obj = p.parse(StringIO('[soft]\n'+test['line'])) except Exception as e: raise AssertionError("Line %r, error: %s" % (test['line'], str(e))) from e else: parsed_obj = dict(parsed_obj) expected = {test['expected'][0]: test['expected'][1]} assert parsed_obj==expected, "Line %r" % (test['line']) def test_TomlConfigParser() -> None: p = TomlConfigParser(['soft']) for test in get_TomlConfigParser_cases(): try: parsed_obj = p.parse(StringIO('[soft]\n'+test['line'])) except Exception as e: raise AssertionError("Line %r, error: %s" % (test['line'], str(e))) from e else: parsed_obj = dict(parsed_obj) expected = {test['expected'][0]: test['expected'][1]} assert parsed_obj==expected, "Line %r" % (test['line']) pydoctor-24.11.2/pydoctor/test/test_cyclic_imports_base_classes.py000066400000000000000000000023311473665144200255240ustar00rootroot00000000000000""" This test case is in its own file because it requires the PYTHONHASHSEED=0 environment variable. See issue #482. """ import os import subprocess import sys def test_cyclic_imports_base_classes() -> None: if sys.platform == 'win32': # Running this script with the following subprocess call fails on Windows # with an ImportError that isn't actually related to what we want to test. # So we just skip for Windows. return process = subprocess.Popen( [sys.executable, os.path.basename(__file__)], env={'PYTHONHASHSEED': '0'}, cwd=os.path.dirname(__file__), ) assert process.wait() == 0 if __name__ == '__main__': from test_packages import processPackage, model # type: ignore assert os.environ['PYTHONHASHSEED'] == '0' def consistent_hash(self: model.Module) -> int: return hash(self.name) if model.Module.__hash__ == object.__hash__: model.Module.__hash__ = consistent_hash system = processPackage('cyclic_imports_base_classes') b_cls = system.allobjects['cyclic_imports_base_classes.b.B'] assert isinstance(b_cls, model.Class) assert b_cls.baseobjects == [system.allobjects['cyclic_imports_base_classes.a.A']] pydoctor-24.11.2/pydoctor/test/test_epydoc2stan.py000066400000000000000000002162471473665144200222420ustar00rootroot00000000000000from typing import List, Optional, Type, cast, TYPE_CHECKING import re from pytest import mark, raises import pytest from twisted.web.template import Tag, tags from pydoctor import epydoc2stan, model, linker from pydoctor.epydoc.markup import get_supported_docformats from pydoctor.stanutils import flatten, flatten_text from pydoctor.epydoc.markup.epytext import ParsedEpytextDocstring from pydoctor.sphinx import SphinxInventory from pydoctor.test.test_astbuilder import fromText, unwrap from pydoctor.test import CapSys, NotFoundLinker from pydoctor.templatewriter.search import stem_identifier from pydoctor.templatewriter.pages import format_signature, format_class_signature if TYPE_CHECKING: from twisted.web.template import Flattenable def test_multiple_types() -> None: mod = fromText(''' def f(a): """ @param a: it\'s a parameter! @type a: a pink thing! @type a: no, blue! aaaargh! """ class C: """ @ivar a: it\'s an instance var @type a: a pink thing! @type a: no, blue! aaaargh! """ class D: """ @cvar a: it\'s an instance var @type a: a pink thing! @type a: no, blue! aaaargh! """ class E: """ @cvar: missing name @type: name still missing """ ''') # basically "assert not fail": epydoc2stan.format_docstring(mod.contents['f']) epydoc2stan.format_docstring(mod.contents['C']) epydoc2stan.format_docstring(mod.contents['D']) epydoc2stan.format_docstring(mod.contents['E']) def docstring2html(obj: model.Documentable, docformat: Optional[str] = None) -> str: if docformat: obj.module.docformat = docformat stan = epydoc2stan.format_docstring(obj) assert stan.tagName == 'div', stan # We strip off break lines for the sake of simplicity. return flatten(stan).replace('><', '>\n<').replace('', '').replace('\n', '') def summary2html(obj: model.Documentable) -> str: stan = epydoc2stan.format_summary(obj) if stan.attributes.get('class') == 'undocumented': assert stan.tagName == 'span', stan else: # Summaries are now generated without englobing when we don't need one. assert stan.tagName == '', stan return flatten(stan.children) def test_html_empty_module() -> None: # checks the presence of at least one paragraph on all docstrings mod = fromText(''' """Empty module.""" ''') assert docstring2html(mod) == "
    \n

    Empty module.

    \n
    " mod = fromText(''' """ Empty module. Another paragraph. """ ''') assert docstring2html(mod) == "
    \n

    Empty module.

    \n

    Another paragraph.

    \n
    " mod = fromText(''' """C{thing}""" ''', modname='module') assert docstring2html(mod) == '
    \n

    \nthing\n

    \n
    ' mod = fromText(''' """My C{thing}.""" ''', modname='module') assert docstring2html(mod) == '
    \n

    My thing.

    \n
    ' mod = fromText(''' """ @note: There is no paragraph here. """ ''') assert '

    ' not in docstring2html(mod) def test_xref_link_not_found() -> None: """A linked name that is not found is output as text.""" mod = fromText(''' """This link leads L{nowhere}.""" ''', modname='test') html = docstring2html(mod) assert 'nowhere' in html def test_xref_link_same_page() -> None: """A linked name that is documented on the same page is linked using only a fragment as the URL. But that does not happend in summaries. """ src = ''' """The home of L{local_func}.""" def local_func(): pass ''' mod = fromText(src, modname='test') assert mod.page_object.url == 'index.html' html = docstring2html(mod) assert 'href="#local_func"' in html html = summary2html(mod) assert 'href="index.html#local_func"' in html html = docstring2html(mod) assert 'href="#local_func"' in html mod = fromText(src, modname='test') html = summary2html(mod) assert 'href="index.html#local_func"' in html html = docstring2html(mod) assert 'href="#local_func"' in html html = summary2html(mod) assert 'href="index.html#local_func"' in html def test_xref_link_other_page() -> None: """A linked name that is documented on a different page but within the same project is linked using a relative URL. """ mod1 = fromText(''' def func(): """This is not L{test2.func}.""" ''', modname='test1') fromText(''' def func(): pass ''', modname='test2', system=mod1.system) html = docstring2html(mod1.contents['func']) assert 'href="test2.html#func"' in html def test_xref_link_intersphinx() -> None: """A linked name that is documented in another project is linked using an absolute URL (retrieved via Intersphinx). """ mod = fromText(''' def func(): """This is a thin wrapper around L{external.func}.""" ''', modname='test') system = mod.system inventory = SphinxInventory(system.msg) inventory._links['external.func'] = ('https://example.net', 'lib.html#func') system.intersphinx = inventory html = docstring2html(mod.contents['func']) assert 'href="https://example.net/lib.html#func"' in html def test_func_undocumented_return_nothing() -> None: """When the returned value is undocumented (no 'return' field) and its type annotation is None, omit the "Returns" entry from the output. """ mod = fromText(''' def nop() -> None: pass ''') func = mod.contents['nop'] lines = docstring2html(func).split('\n') assert 'Returns' not in lines def test_func_undocumented_return_something() -> None: """When the returned value is undocumented (no 'return' field) and its type annotation is not None, do not include the "Returns" entry in the field table. It will be shown in the signature. """ mod = fromText(''' def get_answer() -> int: return 42 ''') func = mod.contents['get_answer'] lines = docstring2html(func).splitlines() expected_html = [ '

    ', '

    Undocumented

    ', '
    ', ] assert lines == expected_html, str(lines) def test_func_only_single_param_doc() -> None: """When only a single parameter is documented, all parameters show with undocumented parameters marked as such. """ mod = fromText(''' def f(x, y): """ @param x: Actual documentation. """ ''') lines = docstring2html(mod.contents['f']).splitlines() expected_html = [ '
    ', '', '', '', '', '', '', '', '', '', '', '', '', '
    Parameters
    ', 'x', 'Actual documentation.
    ', 'y', '', 'Undocumented', '
    ', '
    ', ] assert lines == expected_html, str(lines) def test_func_only_return_doc() -> None: """When only return is documented but not parameters, only the return section is visible. """ mod = fromText(''' def f(x: str): """ @return: Actual documentation. """ ''') lines = docstring2html(mod.contents['f']).splitlines() expected_html = [ '
    ', '', '', '', '', '', '', '', '
    Returns
    Actual documentation.
    ', '
    ', ] assert lines == expected_html, str(lines) # These 3 tests fails because AnnotationDocstring is not using node2stan() yet. @pytest.mark.xfail def test_func_arg_and_ret_annotation() -> None: annotation_mod = fromText(''' def f(a: List[str], b: "List[str]") -> bool: """ @param a: an arg, a the best of args @param b: a param to follow a @return: the best that we can do """ ''') classic_mod = fromText(''' def f(a, b): """ @param a: an arg, a the best of args @type a: C{List[str]} @param b: a param to follow a @type b: C{List[str]} @return: the best that we can do @rtype: C{bool} """ ''') annotation_fmt = docstring2html(annotation_mod.contents['f']) classic_fmt = docstring2html(classic_mod.contents['f']) assert annotation_fmt == classic_fmt @pytest.mark.xfail def test_func_arg_and_ret_annotation_with_override() -> None: annotation_mod = fromText(''' def f(a: List[str], b: List[str]) -> bool: """ @param a: an arg, a the best of args @param b: a param to follow a @type b: C{List[awesome]} @return: the best that we can do """ ''') classic_mod = fromText(''' def f(a, b): """ @param a: an arg, a the best of args @type a: C{List[str]} @param b: a param to follow a @type b: C{List[awesome]} @return: the best that we can do @rtype: C{bool} """ ''') annotation_fmt = docstring2html(annotation_mod.contents['f']) classic_fmt = docstring2html(classic_mod.contents['f']) assert annotation_fmt == classic_fmt def test_func_arg_when_doc_missing_ast_types() -> None: """ Type hints are now included in the signature, so no need to docucument them twice in the param table, only if non of them has documentation. """ annotation_mod = fromText(''' def f(a: List[str], b: int) -> bool: """ Today I will not document details """ ''') annotation_fmt = docstring2html(annotation_mod.contents['f']) assert 'fieldTable' not in annotation_fmt assert 'b:' not in annotation_fmt def _get_test_func_arg_when_doc_missing_docstring_fields_types_cases() -> List[str]: case1=""" @type a: C{List[str]} @type b: C{int} @rtype: C{bool}""" case2=""" Args ---- a: List[str] b: int Returns ------- bool:""" return [case1,case2] @pytest.mark.parametrize('sig', ['(a)', '(a:List[str])', '(a) -> bool', '(a:List[str], b:int) -> bool']) @pytest.mark.parametrize('doc', _get_test_func_arg_when_doc_missing_docstring_fields_types_cases()) def test_func_arg_when_doc_missing_docstring_fields_types(sig:str, doc:str) -> None: """ When type fields are present (whether they are coming from napoleon extension or epytext), always show the param table. """ classic_mod = fromText(f''' __docformat__ = "{'epytext' if '@type' in doc else 'numpy'}" def f{sig}: """ Today I will not document details {doc} """ ''') classic_fmt = docstring2html(classic_mod.contents['f']) assert 'fieldTable' in classic_fmt assert ':5: Parameter "x" was already documented\n' @mark.parametrize('field', ('param', 'type')) def test_func_no_such_arg(field: str, capsys: CapSys) -> None: """Warn about documented parameters that don't exist in the definition.""" mod = fromText(f''' def f(): """ This function takes no arguments... @{field} x: ...but it does document one. """ ''') epydoc2stan.format_docstring(mod.contents['f']) captured = capsys.readouterr().out assert captured == ':6: Documented parameter "x" does not exist\n' def test_func_no_such_arg_warn_once(capsys: CapSys) -> None: """Warn exactly once about a param/type combination not existing.""" mod = fromText(''' def f(): """ @param x: Param first. @type x: Param first. @type y: Type first. @param y: Type first. """ ''') epydoc2stan.format_docstring(mod.contents['f']) captured = capsys.readouterr().out assert captured == ( ':4: Documented parameter "x" does not exist\n' ':6: Documented parameter "y" does not exist\n' ) def test_func_arg_not_inherited(capsys: CapSys) -> None: """Do not warn when a subclass method lacks parameters that are documented in an inherited docstring. """ mod = fromText(''' class Base: def __init__(self, value): """ @param value: Preciousss. @type value: Gold. """ class Sub(Base): def __init__(self): super().__init__(1) ''', modname='test') epydoc2stan.format_docstring(mod.contents['Base'].contents['__init__']) assert capsys.readouterr().out == '' epydoc2stan.format_docstring(mod.contents['Sub'].contents['__init__']) assert capsys.readouterr().out == '' def test_func_param_as_keyword(capsys: CapSys) -> None: """Warn when a parameter is documented as a @keyword.""" mod = fromText(''' def f(p, **kwargs): """ @keyword a: Advanced. @keyword b: Basic. @type b: Type for previously introduced keyword. @keyword p: A parameter, not a keyword. """ ''') epydoc2stan.format_docstring(mod.contents['f']) assert capsys.readouterr().out == ':7: Parameter "p" is documented as keyword\n' def test_func_missing_param_name(capsys: CapSys) -> None: """Param and type fields must include the name of the parameter.""" mod = fromText(''' def f(a, b): """ @param a: The first parameter. @param: The other one. @type: C{str} """ ''') epydoc2stan.format_docstring(mod.contents['f']) captured = capsys.readouterr().out assert captured == ( ':5: Parameter name missing\n' ':6: Parameter name missing\n' ) def test_missing_param_computed_base(capsys: CapSys) -> None: """Do not warn if a parameter might be added by a computed base class.""" mod = fromText(''' from twisted.python import components import zope.interface class IFoo(zope.interface.Interface): pass class Proxy(components.proxyForInterface(IFoo)): """ @param original: The wrapped instance. """ ''') html = ''.join(docstring2html(mod.contents['Proxy']).splitlines()) assert 'The wrapped instance.' in html captured = capsys.readouterr().out assert captured == '' def test_constructor_param_on_class(capsys: CapSys) -> None: """Constructor parameters can be documented on the class.""" mod = fromText(''' class C: """ @param p: Constructor parameter. @param q: Not a constructor parameter. """ def __init__(self, p): pass ''', modname='test') html = ''.join(docstring2html(mod.contents['C']).splitlines()) assert 'Constructor parameter.' in html # Non-existing parameters should still end up in the output, because: # - pydoctor might be wrong about them not existing # - the documentation may still be useful, for example if belongs to # an existing parameter but the name in the @param field has a typo assert 'Not a constructor parameter.' in html captured = capsys.readouterr().out assert captured == 'test:5: Documented parameter "q" does not exist\n' def test_func_raise_linked() -> None: """Raise fields are formatted by linking the exception type.""" mod = fromText(''' class SpanishInquisition(Exception): pass def f(): """ @raise SpanishInquisition: If something unexpected happens. """ ''', modname='test') html = docstring2html(mod.contents['f']).split('\n') assert 'SpanishInquisition' in html def test_func_raise_missing_exception_type(capsys: CapSys) -> None: """When a C{raise} field is missing the exception type, a warning is logged and the HTML will list the exception type as unknown. """ mod = fromText(''' def f(x): """ @raise ValueError: If C{x} is rejected. @raise: On a blue moon. """ ''') func = mod.contents['f'] epydoc2stan.format_docstring(func) captured = capsys.readouterr().out assert captured == ':5: Exception type missing\n' html = docstring2html(func).split('\n') assert 'Unknown exception' in html def test_unexpected_field_args(capsys: CapSys) -> None: """Warn when field arguments that should be empty aren't.""" mod = fromText(''' def get_it(): """ @return value: The thing you asked for, probably. @rtype value: Not a clue. """ ''') epydoc2stan.format_docstring(mod.contents['get_it']) captured = capsys.readouterr().out assert captured == ":4: Unexpected argument in return field\n" \ ":5: Unexpected argument in rtype field\n" def test_func_starargs(capsys: CapSys) -> None: """ Var-args can be named in fields with or without asterixes. Constructor parameters can be documented on the class. @note: Asterixes need to be escaped with reStructuredText. """ mod_epy_star = fromText(''' class f: """ Do something with var-positional and var-keyword arguments. @param *args: var-positional arguments @param **kwargs: var-keyword arguments @type **kwargs: str """ def __init__(*args: int, **kwargs) -> None: ... ''', modname='great') mod_epy_no_star = fromText(''' class f: """ Do something with var-positional and var-keyword arguments. @param args: var-positional arguments @param kwargs: var-keyword arguments @type kwargs: str """ def __init__(*args: int, **kwargs) -> None: ... ''', modname='good') mod_rst_star = fromText(r''' __docformat__='restructuredtext' class f: r""" Do something with var-positional and var-keyword arguments. :param \*args: var-positional arguments :param \*\*kwargs: var-keyword arguments :type \*\*kwargs: str """ def __init__(*args: int, **kwargs) -> None: ... ''', modname='great') mod_rst_no_star = fromText(''' __docformat__='restructuredtext' class f: """ Do something with var-positional and var-keyword arguments. :param args: var-positional arguments :param kwargs: var-keyword arguments :type kwargs: str """ def __init__(*args: int, **kwargs) -> None: ... ''', modname='great') mod_epy_star_fmt = docstring2html(mod_epy_star.contents['f']) mod_epy_no_star_fmt = docstring2html(mod_epy_no_star.contents['f']) mod_rst_star_fmt = docstring2html(mod_rst_star.contents['f']) mod_rst_no_star_fmt = docstring2html(mod_rst_no_star.contents['f']) assert mod_rst_star_fmt == mod_rst_no_star_fmt == mod_epy_star_fmt == mod_epy_no_star_fmt expected_parts = ['*args', '**kwargs',] for part in expected_parts: assert part in mod_epy_star_fmt captured = capsys.readouterr().out assert not captured def test_func_starargs_more(capsys: CapSys) -> None: """ Star arguments, even if there are not named 'args' or 'kwargs', are recognized. """ mod_epy_with_asterixes = fromText(''' def f(args, kwargs, *a, **kwa) -> None: """ Do something with var-positional and var-keyword arguments. @param args: some regular argument @param kwargs: some regular argument @param *a: var-positional arguments @param **kwa: var-keyword arguments """ ''', modname='') mod_rst_with_asterixes = fromText(r''' def f(args, kwargs, *a, **kwa) -> None: r""" Do something with var-positional and var-keyword arguments. :param args: some regular argument :param kwargs: some regular argument :param \*a: var-positional arguments :param \*\*kwa: var-keyword arguments """ ''', modname='') mod_rst_without_asterixes = fromText(''' def f(args, kwargs, *a, **kwa) -> None: """ Do something with var-positional and var-keyword arguments. :param args: some regular argument :param kwargs: some regular argument :param a: var-positional arguments :param kwa: var-keyword arguments """ ''', modname='') mod_epy_without_asterixes = fromText(''' def f(args, kwargs, *a, **kwa) -> None: """ Do something with var-positional and var-keyword arguments. @param args: some regular argument @param kwargs: some regular argument @param a: var-positional arguments @param kwa: var-keyword arguments """ ''', modname='') epy_with_asterixes_fmt = docstring2html(mod_epy_with_asterixes.contents['f']) rst_with_asterixes_fmt = docstring2html(mod_rst_with_asterixes.contents['f'], docformat='restructuredtext') rst_without_asterixes_fmt = docstring2html(mod_rst_without_asterixes.contents['f'], docformat='restructuredtext') epy_without_asterixes_fmt = docstring2html(mod_epy_without_asterixes.contents['f']) assert epy_with_asterixes_fmt == rst_with_asterixes_fmt == rst_without_asterixes_fmt == epy_without_asterixes_fmt expected_parts = ['args', 'kwargs', '*a', '**kwa',] for part in expected_parts: assert part in epy_with_asterixes_fmt captured = capsys.readouterr().out assert not captured def test_func_starargs_hidden_when_keywords_documented(capsys:CapSys) -> None: """ When a function accept variable keywords (**kwargs) and keywords are specifically documented and the **kwargs IS NOT documented: entry for **kwargs IS NOT presented at all. In other words: They variable keywords argument documentation is optional when specific documentation is given for each keyword, and when missing, no warning is raised. """ # tests for issue https://github.com/twisted/pydoctor/issues/697 mod = fromText(''' __docformat__='restructuredtext' def f(one, two, **kwa) -> None: """ var-keyword arguments are specifically documented. :param one: some regular argument :param two: some regular argument :keyword something: An argument :keyword another: Another """ ''') html = docstring2html(mod.contents['f']) assert '**kwa' not in html assert not capsys.readouterr().out def test_func_starargs_shown_when_documented(capsys:CapSys) -> None: """ When a function accept variable keywords (**kwargs) and keywords are specifically documented and the **kwargs IS documented: entry for **kwargs IS presented AFTER all keywords. In other words: When a function has the keywords arguments, the keywords can have dedicated docstring, besides the separate documentation for each keyword. """ mod = fromText(''' __docformat__='restructuredtext' def f(one, two, **kwa) -> None: """ var-keyword arguments are specifically documented as well as other extra keywords. :param one: some regular argument :param two: some regular argument :param kwa: Other keywords are passed to ``parse`` function. :keyword something: An argument :keyword another: Another """ ''') html = docstring2html(mod.contents['f']) # **kwa should be presented AFTER all other parameters assert re.match('.+one.+two.+something.+another.+kwa', html, flags=re.DOTALL) assert not capsys.readouterr().out def test_func_starargs_shown_when_undocumented(capsys:CapSys) -> None: """ When a function accept variable keywords (**kwargs) and NO keywords are specifically documented and the **kwargs IS NOT documented: entry for **kwargs IS presented as undocumented. """ mod = fromText(''' __docformat__='restructuredtext' def f(one, two, **kwa) -> None: """ var-keyword arguments are not specifically documented :param one: some regular argument :param two: some regular argument """ ''') html = docstring2html(mod.contents['f']) assert re.match('.+one.+two.+kwa', html, flags=re.DOTALL) assert not capsys.readouterr().out def test_func_starargs_wrongly_documented(capsys: CapSys) -> None: numpy_wrong = fromText(''' __docformat__='numpy' def f(one, **kwargs): """ var-keyword arguments are wrongly documented with the "Arguments" section. Arguments --------- kwargs: var-keyword arguments stuff: a var-keyword argument """ ''', modname='numpy_wrong') rst_wrong = fromText(''' __docformat__='restructuredtext' def f(one, **kwargs): """ var-keyword arguments are wrongly documented with the "param" field. :param kwargs: var-keyword arguments :param stuff: a var-keyword argument """ ''', modname='rst_wrong') docstring2html(numpy_wrong.contents['f']) assert 'Documented parameter "stuff" does not exist, variable keywords should be documented with the "Keyword Arguments" section' in capsys.readouterr().out docstring2html(rst_wrong.contents['f']) assert 'Documented parameter "stuff" does not exist, variable keywords should be documented with the "keyword" field' in capsys.readouterr().out def test_summary() -> None: mod = fromText(''' def single_line_summary(): """ Lorem Ipsum Ipsum Lorem """ def still_summary_since_2022(): """ Foo Bar Baz Qux """ def three_lines_summary(): """ Foo Bar Baz Lorem Ipsum """ ''') assert 'Lorem Ipsum' == summary2html(mod.contents['single_line_summary']) assert 'Foo Bar Baz' == summary2html(mod.contents['three_lines_summary']) # We get a summary based on the first sentences of the first # paragraph until reached maximum number characters or the paragraph ends. # So no matter the number of lines the first paragraph is, we'll always get a summary. assert 'Foo Bar Baz Qux' == summary2html(mod.contents['still_summary_since_2022']) def test_ivar_overriding_attribute() -> None: """An 'ivar' field in a subclass overrides a docstring for the same attribute set in the base class. The 'a' attribute in the test code reproduces a regression introduced in pydoctor 20.7.0, where the summary would be constructed from the base class documentation instead. The problem was in the fact that a split field's docstring is stored in 'parsed_docstring', while format_summary() looked there only if no unparsed docstring could be found. The 'b' attribute in the test code is there to make sure that in the absence of an 'ivar' field, the docstring is inherited. """ mod = fromText(''' class Base: a: str """base doc details """ b: object """not overridden details """ class Sub(Base): """ @ivar a: sub doc @type b: sub type """ ''') base = mod.contents['Base'] base_a = base.contents['a'] assert isinstance(base_a, model.Attribute) assert summary2html(base_a) == "base doc" assert docstring2html(base_a) == "
    \n

    base doc

    \n

    details

    \n
    " base_b = base.contents['b'] assert isinstance(base_b, model.Attribute) assert summary2html(base_b) == "not overridden" assert docstring2html(base_b) == "
    \n

    not overridden

    \n

    details

    \n
    " sub = mod.contents['Sub'] sub_a = sub.contents['a'] assert isinstance(sub_a, model.Attribute) assert summary2html(sub_a) == 'sub doc' assert docstring2html(sub_a) == "
    \n

    sub doc

    \n
    " sub_b = sub.contents['b'] assert isinstance(sub_b, model.Attribute) assert summary2html(sub_b) == 'not overridden' assert docstring2html(sub_b) == "
    \n

    not overridden

    \n

    details

    \n
    " def test_missing_field_name(capsys: CapSys) -> None: mod = fromText(''' """ A test module. @ivar: Mystery variable. @type: str """ ''', modname='test') epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == "test:5: Missing field name in @ivar\n" \ "test:6: Missing field name in @type\n" def test_unknown_field_name(capsys: CapSys) -> None: mod = fromText(''' """ A test module. @zap: No such field. """ ''', modname='test') epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == "test:5: Unknown field 'zap'\n" def test_inline_field_type(capsys: CapSys) -> None: """The C{type} field in a variable docstring updates the C{parsed_type} of the Attribute it documents. """ mod = fromText(''' a = 2 """ Variable documented by inline docstring. @type: number """ ''', modname='test') a = mod.contents['a'] assert isinstance(a, model.Attribute) epydoc2stan.format_docstring(a) assert isinstance(a.parsed_type, ParsedEpytextDocstring) assert str(unwrap(a.parsed_type)) == 'number' assert not capsys.readouterr().out def test_inline_field_name(capsys: CapSys) -> None: """Warn if a name is given for a C{type} field in a variable docstring. A variable docstring only documents a single variable, so the name is redundant at best and misleading at worst. """ mod = fromText(''' a = 2 """ Variable documented by inline docstring. @type a: number """ ''', modname='test') a = mod.contents['a'] assert isinstance(a, model.Attribute) epydoc2stan.format_docstring(a) captured = capsys.readouterr().out assert captured == "test:5: Field in variable docstring should not include a name\n" @pytest.mark.parametrize('linkercls', [linker._EpydocLinker]) def test_EpydocLinker_switch_context(linkercls:Type[linker._EpydocLinker]) -> None: """ Test for switching the page context of the EpydocLinker. """ mod = fromText(''' v=0 class Klass: class InnerKlass(Klass): def f():... Klass = 'not this one!' class v: 'not this one!' ''', modname='test') Klass = mod.contents['Klass'] assert isinstance(Klass, model.Class) InnerKlass = Klass.contents['InnerKlass'] assert isinstance(InnerKlass, model.Class) # patch with the linkercls mod._linker = linkercls(mod) Klass._linker = linkercls(Klass) InnerKlass._linker = linkercls(InnerKlass) # Evaluating the name of the base classes must be done in the upper scope # in order to avoid the following to happen: assert 'href="#Klass"' in flatten(InnerKlass.docstring_linker.link_to('Klass', 'Klass')) with Klass.docstring_linker.switch_context(InnerKlass): assert 'href="test.Klass.html"' in flatten(Klass.docstring_linker.link_to('Klass', 'Klass')) assert 'href="#v"' in flatten(mod.docstring_linker.link_to('v', 'v')) with mod.docstring_linker.switch_context(InnerKlass): assert 'href="index.html#v"' in flatten(mod.docstring_linker.link_to('v', 'v')) @pytest.mark.parametrize('linkercls', [linker._EpydocLinker]) def test_EpydocLinker_switch_context_is_reentrant(linkercls:Type[linker._EpydocLinker], capsys:CapSys) -> None: """ We can nest several calls to switch_context(), and links will still be valid and warnings line will be correct. """ mod = fromText(''' "L{thing.notfound}" v=0 class Klass: "L{thing.notfound}" ... ''', modname='test') Klass = mod.contents['Klass'] assert isinstance(Klass, model.Class) for ob in mod.system.allobjects.values(): epydoc2stan.ensure_parsed_docstring(ob) # patch with the linkercls mod._linker = linkercls(mod) Klass._linker = linkercls(Klass) with Klass.docstring_linker.switch_context(mod): assert 'href="#v"' in flatten(Klass.docstring_linker.link_to('v', 'v')) with Klass.docstring_linker.switch_context(Klass): assert 'href="index.html#v"' in flatten(Klass.docstring_linker.link_to('v', 'v')) assert capsys.readouterr().out == '' mod.parsed_docstring.to_stan(mod.docstring_linker) #type:ignore mod.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore warnings = ['test:2: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)'] if linkercls is linker._EpydocLinker: warnings = warnings * 2 assert capsys.readouterr().out.strip().splitlines() == warnings # This is wrong: Klass.parsed_docstring.to_stan(mod.docstring_linker) # type:ignore Klass.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore # Because the warnings will be reported on line 2 warnings = ['test:2: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)'] warnings = warnings * 2 assert capsys.readouterr().out.strip().splitlines() == warnings # assert capsys.readouterr().out == '' # Reset stan and summary, because they are supposed to be cached. Klass.parsed_docstring._stan = None # type:ignore Klass.parsed_docstring._summary = None # type:ignore # This is better: with mod.docstring_linker.switch_context(Klass): Klass.parsed_docstring.to_stan(mod.docstring_linker) # type:ignore Klass.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore warnings = ['test:5: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)'] warnings = warnings * 2 assert capsys.readouterr().out.strip().splitlines() == warnings def test_EpydocLinker_look_for_intersphinx_no_link() -> None: """ Return None if inventory had no link for our markup. """ system = model.System() target = model.Module(system, 'ignore-name') sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) result = sut.look_for_intersphinx('base.module') assert None is result def test_EpydocLinker_look_for_intersphinx_hit() -> None: """ Return the link from inventory based on first package name. """ system = model.System() inventory = SphinxInventory(system.msg) inventory._links['base.module.other'] = ('http://tm.tld', 'some.html') system.intersphinx = inventory target = model.Module(system, 'ignore-name') sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) result = sut.look_for_intersphinx('base.module.other') assert 'http://tm.tld/some.html' == result def test_EpydocLinker_adds_intersphinx_link_css_class() -> None: """ The EpydocLinker return a link with the CSS class 'intersphinx-link' when it's using intersphinx. """ system = model.System() inventory = SphinxInventory(system.msg) inventory._links['base.module.other'] = ('http://tm.tld', 'some.html') system.intersphinx = inventory target = model.Module(system, 'ignore-name') sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) result1 = sut.link_xref('base.module.other', 'base.module.other', 0).children[0] # wrapped in a code tag result2 = sut.link_to('base.module.other', 'base.module.other') res = flatten(result2) assert flatten(result1) == res assert 'class="intersphinx-link"' in res def test_EpydocLinker_resolve_identifier_xref_intersphinx_absolute_id() -> None: """ Returns the link from Sphinx inventory based on a cross reference ID specified in absolute dotted path and with a custom pretty text for the URL. """ system = model.System() inventory = SphinxInventory(system.msg) inventory._links['base.module.other'] = ('http://tm.tld', 'some.html') system.intersphinx = inventory target = model.Module(system, 'ignore-name') sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) url = sut.link_to('base.module.other', 'o').attributes['href'] url_xref = sut._resolve_identifier_xref('base.module.other', 0) assert "http://tm.tld/some.html" == url assert "http://tm.tld/some.html" == url_xref def test_EpydocLinker_resolve_identifier_xref_intersphinx_relative_id() -> None: """ Return the link from inventory using short names, by resolving them based on the imports done in the module. """ system = model.System() inventory = SphinxInventory(system.msg) inventory._links['ext_package.ext_module'] = ('http://tm.tld', 'some.html') system.intersphinx = inventory target = model.Module(system, 'ignore-name') # Here we set up the target module as it would have this import. # from ext_package import ext_module ext_package = model.Module(system, 'ext_package') target.contents['ext_module'] = model.Module( system, 'ext_module', parent=ext_package) sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) # This is called for the L{ext_module} markup. url = sut.link_to('ext_module', 'ext').attributes['href'] url_xref = sut._resolve_identifier_xref('ext_module', 0) assert "http://tm.tld/some.html" == url assert "http://tm.tld/some.html" == url_xref def test_EpydocLinker_resolve_identifier_xref_intersphinx_link_not_found(capsys: CapSys) -> None: """ A message is sent to stdout when no link could be found for the reference, while returning the reference name without an A link tag. The message contains the full name under which the reference was resolved. FIXME: Use a proper logging system instead of capturing stdout. https://github.com/twisted/pydoctor/issues/112 """ system = model.System() target = model.Module(system, 'ignore-name') # Here we set up the target module as it would have this import. # from ext_package import ext_module ext_package = model.Module(system, 'ext_package') target.contents['ext_module'] = model.Module( system, 'ext_module', parent=ext_package) sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) # This is called for the L{ext_module} markup. assert sut.link_to('ext_module', 'ext').tagName == '' assert not capsys.readouterr().out with raises(LookupError): sut._resolve_identifier_xref('ext_module', 0) captured = capsys.readouterr().out expected = ( 'ignore-name:???: Cannot find link target for "ext_package.ext_module", ' 'resolved from "ext_module" ' '(you can link to external docs with --intersphinx)\n' ) assert expected == captured class InMemoryInventory: """ A simple inventory implementation which has an in-memory API link mapping. """ INVENTORY = { 'socket.socket': 'https://docs.python.org/3/library/socket.html#socket.socket', } def getLink(self, name: str) -> Optional[str]: return self.INVENTORY.get(name) def test_EpydocLinker_resolve_identifier_xref_order(capsys: CapSys) -> None: """ Check that the best match is picked when there are multiple candidates. """ mod = fromText(''' class C: socket = None ''') mod.system.intersphinx = cast(SphinxInventory, InMemoryInventory()) _linker = mod.docstring_linker assert isinstance(_linker, linker._EpydocLinker) url = _linker.link_to('socket.socket', 's').attributes['href'] url_xref = _linker._resolve_identifier_xref('socket.socket', 0) assert 'https://docs.python.org/3/library/socket.html#socket.socket' == url assert 'https://docs.python.org/3/library/socket.html#socket.socket' == url_xref assert not capsys.readouterr().out def test_EpydocLinker_resolve_identifier_xref_internal_full_name() -> None: """Link to an internal object referenced by its full name.""" # Object we want to link to. int_mod = fromText(''' class C: pass ''', modname='internal_module') system = int_mod.system # Dummy module that we want to link from. target = model.Module(system, 'ignore-name') sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) url = sut.link_to('internal_module.C','C').attributes['href'] xref = sut._resolve_identifier_xref('internal_module.C', 0) assert "internal_module.C.html" == url assert int_mod.contents['C'] is xref def test_EpydocLinker_None_context() -> None: """ The linker will create URLs with only the anchor if we're lnking to an object on the same page. Otherwise it will always use return a URL with a filename, this is used to generate the summaries. """ mod = fromText(''' base=1 class someclass: ... ''', modname='module') sut = mod.docstring_linker assert isinstance(sut, linker._EpydocLinker) assert sut.page_url == mod.url == cast(linker._EpydocLinker,mod.contents['base'].docstring_linker).page_url with sut.switch_context(None): assert sut.page_url =='' assert sut.link_to('base','module.base').attributes['href']=='index.html#base' assert sut.link_to('base','module.base').children[0]=='module.base' assert sut.link_to('base','base').attributes['href']=='index.html#base' assert sut.link_to('base','base').children[0]=='base' assert sut.link_to('someclass','some random name').attributes['href']=='module.someclass.html' assert sut.link_to('someclass','some random name').children[0]=='some random name' def test_EpydocLinker_warnings(capsys: CapSys) -> None: """ Warnings should be reported only once per invalid name per line, no matter the number of times we call summary2html() or docstring2html() or the order we call these functions. """ src = ''' """ L{base} L{regular text } L{notfound} L{regular text } L{B{look at the base} } L{I{Important class} } L{notfound} """ base=1 ''' mod = fromText(src, modname='module') assert 'href="#base"' in docstring2html(mod) captured = capsys.readouterr().out # The rationale about xref warnings is to warn when the target cannot be found. assert captured == ('module:3: Cannot find link target for "notfound"' '\nmodule:3: Cannot find link target for "notfound"' '\nmodule:5: Cannot find link target for "notfound"' '\nmodule:5: Cannot find link target for "notfound"\n') assert 'href="index.html#base"' in summary2html(mod) summary2html(mod) captured = capsys.readouterr().out # No warnings are logged when generating the summary. assert captured == '' def test_AnnotationLinker_xref(capsys: CapSys) -> None: """ Even if the annotation linker is not designed to resolve xref, it will still do the right thing by forwarding any xref requests to the initial object's linker. """ mod = fromText(''' class C: var="don't use annotation linker for xref!" ''') mod.system.intersphinx = cast(SphinxInventory, InMemoryInventory()) _linker = linker._AnnotationLinker(mod.contents['C']) url = flatten(_linker.link_xref('socket.socket', 'socket', 0)) assert 'https://docs.python.org/3/library/socket.html#socket.socket' in url assert not capsys.readouterr().out url = flatten(_linker.link_xref('var', 'var', 0)) assert 'href="#var"' in url assert not capsys.readouterr().out def test_xref_not_found_epytext(capsys: CapSys) -> None: """ When a link in an epytext docstring cannot be resolved, the reference and the line number of the link should be reported. """ mod = fromText(''' """ A test module. Link to limbo: L{NoSuchName}. """ ''', modname='test') epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == 'test:5: Cannot find link target for "NoSuchName"\n' def test_xref_not_found_restructured(capsys: CapSys) -> None: """ When a link in an reStructedText docstring cannot be resolved, the reference and the line number of the link should be reported. """ system = model.System() system.options.docformat = 'restructuredtext' mod = fromText(''' """ A test module. Link to limbo: `NoSuchName`. """ ''', modname='test', system=system) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == 'test:5: Cannot find link target for "NoSuchName"\n' def test_xref_not_found_restructured_in_para(capsys: CapSys) -> None: """ When an invalid link is in the middle of a paragraph, we still report the right line number. """ system = model.System() system.options.docformat = 'restructuredtext' mod = fromText(''' """ A test module. blabla bla blabla bla blabla bla blabla bla blabla blablabla blablabla blablabla blablabla bla blabla bla blabla blablabla blablabla blablabla blablabla bla Link to limbo: `NoSuchName`. """ ''', modname='test', system=system) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == 'test:8: Cannot find link target for "NoSuchName"\n' system = model.System() system.options.docformat = 'restructuredtext' mod = fromText(''' """ A test module. blabla bla blabla bla blabla bla blabla bla blabla blablabla blablabla blablabla blablabla bla blabla bla blabla blablabla blablabla blablabla blablabla bla Link to limbo: `NoSuchName`. blabla bla blabla bla blabla bla blabla bla blabla blablabla blablabla blablabla blablabla bla blabla bla blabla blablabla blablabla blablabla blablabla bla """ ''', modname='test', system=system) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == 'test:8: Cannot find link target for "NoSuchName"\n' class RecordingAnnotationLinker(NotFoundLinker): """A DocstringLinker implementation that cannot find any links, but does record which identifiers it was asked to link. """ def __init__(self) -> None: self.requests: List[str] = [] def link_to(self, target: str, label: "Flattenable") -> Tag: self.requests.append(target) return tags.transparent(label) def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: assert False @mark.parametrize('annotation', ( '', '', '[]', '[]', '[, ]', '[, ]', '[, ...]', '[[, ], ]', )) def test_annotation_formatting(annotation: str) -> None: """ Perform two checks on the annotation formatting: - all type names in the annotation are passed to the linker - the plain text version of the output matches the input @note: The annotation formatting is now handled by L{PyvalColorizer}. We use the function C{flatten_text} in order to back reproduce the original text annotations. """ expected_lookups = [found[1:-1] for found in re.findall('<[^>]*>', annotation)] expected_text = annotation.replace('<', '').replace('>', '') mod = fromText(f''' value: {expected_text} ''') obj = mod.contents['value'] parsed = epydoc2stan.get_parsed_type(obj) assert parsed is not None linker = RecordingAnnotationLinker() stan = parsed.to_stan(linker) assert linker.requests == expected_lookups html = flatten(stan) assert html.startswith('') assert html.endswith('') text = flatten_text(stan) assert text == expected_text def test_module_docformat(capsys: CapSys) -> None: """ Test if Module.docformat effectively override System.options.docformat """ system = model.System() system.options.docformat = 'epytext' mod = fromText(''' """ Link to pydoctor: `pydoctor `_. """ __docformat__ = "google" ''', modname='test_epy', system=system) epytext_output = epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert not captured system = model.System() system.options.docformat = 'epytext' mod = fromText(''' """ Link to pydoctor: `pydoctor `_. """ __docformat__ = "restructuredtext en" ''', modname='test_rst', system=system) restructuredtext_output = epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert not captured assert ('href="https://github.com/twisted/pydoctor"' in flatten(epytext_output)) assert ('href="https://github.com/twisted/pydoctor"' in flatten(restructuredtext_output)) def test_module_docformat_inheritence(capsys: CapSys) -> None: top_src = ''' def f(a: str, b: int): """ :param a: string :param b: integer """ pass ''' mod_src = ''' def f(a: str, b: int): """ @param a: string @param b: integer """ pass ''' pkg_src = ''' __docformat__ = 'epytext' ''' system = model.System() system.options.docformat = 'restructuredtext' builder = system.systemBuilder(system) builder.addModuleString(top_src, modname='top', is_package=True) builder.addModuleString(pkg_src, modname='pkg', parent_name='top', is_package=True) builder.addModuleString(mod_src, modname='mod', parent_name='top.pkg') builder.buildModules() top = system.allobjects['top'] mod = system.allobjects['top.pkg.mod'] assert isinstance(mod, model.Module) assert mod.docformat == 'epytext' captured = capsys.readouterr().out assert not captured assert ''.join(docstring2html(top.contents['f']).splitlines()) == ''.join(docstring2html(mod.contents['f']).splitlines()) def test_module_docformat_with_docstring_inheritence(capsys: CapSys) -> None: mod_src = ''' __docformat__ = "restructuredtext" class A: def f(self, a: str, b: int): """ .. note:: Note. """ ''' mod2_src = ''' from mod import A __docformat__ = "epytext" class B(A): def f(self, a: str, b: int): pass ''' system = model.System() builder = system.systemBuilder(system) system.options.docformat = 'epytext' builder.addModuleString(mod_src, modname='mod',) builder.addModuleString(mod2_src, modname='mod2',) builder.buildModules() mod = system.allobjects['mod'] mod2 = system.allobjects['mod2'] captured = capsys.readouterr().out assert not captured B_f = mod2.resolveName('B.f') A_f = mod.resolveName('A.f') assert B_f assert A_f assert ''.join(docstring2html(B_f).splitlines()) == ''.join(docstring2html(A_f).splitlines()) def test_cli_docformat_plaintext_overrides_module_docformat(capsys: CapSys) -> None: """ When System.options.docformat is set to C{plaintext} it overwrites any specific Module.docformat defined for a module. See https://github.com/twisted/pydoctor/issues/503 for the reason of this behavior. """ system = model.System() system.options.docformat = 'plaintext' mod = fromText(''' """ L{unknown} link. """ __docformat__ = "epytext" ''', system=system) epytext_output = epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert not captured assert flatten(epytext_output).startswith('

    ') def test_constant_values_rst(capsys: CapSys) -> None: """ Test epydoc2stan.format_constant_value(). """ mod1 = ''' def f(a, b): pass ''' mod2 = ''' from .mod1 import f CONST = (f,) ''' system = model.System() builder = system.systemBuilder(system) system.options.docformat = 'restructuredtext' builder.addModuleString("", modname='pack', is_package=True) builder.addModuleString(mod1, modname='mod1',parent_name='pack') builder.addModuleString(mod2, modname='mod2', parent_name='pack') builder.buildModules() mod = system.allobjects['pack.mod2'] captured = capsys.readouterr().out assert not captured expected = ('' '
    Value
    ' '
    ('
                    'f)
    ') attr = mod.contents['CONST'] assert isinstance(attr, model.Attribute) docstring2html(attr) assert ''.join(flatten(epydoc2stan.format_constant_value(attr)).splitlines()) == expected def test_warns_field(capsys: CapSys) -> None: """Test if the :warns: field is correctly recognized.""" mod = fromText(''' def func(): """ @warns: If there is an issue. """ pass ''') html = ''.join(docstring2html(mod.contents['func']).splitlines()) assert ('

    ' '' '
    Warns
    If there is an issue.
    ') == html captured = capsys.readouterr().out assert captured == '' mod = fromText(''' def func(): """ @warns RuntimeWarning: If there is an issue. """ pass ''') html = ''.join(docstring2html(mod.contents['func']).splitlines()) assert ('
    ' '' '' '
    Warns
    RuntimeWarningIf there is an issue.
    ') == html captured = capsys.readouterr().out assert captured == '' def test_yields_field(capsys: CapSys) -> None: """Test if the :warns: field is correctly recognized.""" mod = fromText(''' def func(): """ @yields: Each member of the sequence. @ytype: str """ pass ''') html = ''.join(docstring2html(mod.contents['func']).splitlines()) assert html == ('
    ' '' '' '
    Yields
    strEach member of the sequence.' '
    ') captured = capsys.readouterr().out assert captured == '' def insert_break_points(t:str) -> str: return flatten(epydoc2stan.insert_break_points(t)) def test_insert_break_points_identity() -> None: """ No break points are introduced for values containing a single world. """ assert insert_break_points('test') == 'test' assert insert_break_points('_test') == '_test' assert insert_break_points('_test_') == '_test_' assert insert_break_points('') == '' assert insert_break_points('____') == '____' assert insert_break_points('__test__') == '__test__' assert insert_break_points('__someverylongname__') == '__someverylongname__' assert insert_break_points('__SOMEVERYLONGNAME__') == '__SOMEVERYLONGNAME__' def test_insert_break_points_snake_case() -> None: assert insert_break_points('__some_very_long_name__') == '__some_very_long_name__' assert insert_break_points('__SOME_VERY_LONG_NAME__') == '__SOME_VERY_LONG_NAME__' def test_insert_break_points_camel_case() -> None: assert insert_break_points('__someVeryLongName__') == '__someVeryLongName__' assert insert_break_points('__einÜberlangerName__') == '__einÜberlangerName__' def test_insert_break_points_dotted_name() -> None: assert insert_break_points('mod.__some_very_long_name__') == 'mod.__some_very_long_name__' assert insert_break_points('_mod.__SOME_VERY_LONG_NAME__') == '_mod.__SOME_VERY_LONG_NAME__' assert insert_break_points('pack.mod.__someVeryLongName__') == 'pack.mod.__someVeryLongName__' assert insert_break_points('pack._mod_.__einÜberlangerName__') == 'pack._mod_.__einÜberlangerName__' def test_stem_identifier() -> None: assert list(stem_identifier('__some_very_long_name__')) == list(stem_identifier('__some_very_very_long_name__')) == [ 'some', 'very', 'long', 'name',] assert list(stem_identifier('transitivity_maximum')) == [ 'transitivity', 'maximum',] assert list(stem_identifier('ForEach')) == [ 'For', 'Each',] assert list(stem_identifier('__someVeryLongName__')) == [ 'some', 'Very', 'Long', 'Name', ] assert list(stem_identifier('_name')) == ['name'] assert list(stem_identifier('name')) == ['name'] assert list(stem_identifier('processModuleAST')) == ['process', 'Module', 'AST'] def test_self_cls_in_function_params(capsys: CapSys) -> None: """ 'self' and 'cls' in parameter table of regular function should appear because we don't know if it's a badly named argument OR it's actually assigned to a legit class/instance method outside of the class scope: https://github.com/twisted/pydoctor/issues/13 Until issue #13 is fixed (which is not so easy), the safe side is to show them. """ src = ''' __docformat__ = "google" def foo(cls, var, bar): """ 'cls' SHOULD shown in parameter table. Args: var: the thing bar: the other thing """ def bar(self, cls, var): """ 'self' SHOULD shown in parameter table. Args: var: the thing """ class Spectator: @staticmethod def watch(self, what): """ 'self' SHOULD shown in parameter table. Args: what: the thing """ def leave(cls, t): """ 'cls' SHOULD shown in parameter table. Args: t: thing """ @classmethod def which(cls, t): """ 'cls' SHOULD NOT shown in parameter table, because it's a legit class method. Args: t: the object """ def __init__(self, team): """ 'self' SHOULD NOT shown in parameter table, because it's a legit instance method. Args: team: the team """ def __bool__(self, other): """ 'self' SHOULD shown in parameter table, because it's explicitely documented. Args: self: the self other: the other """ ''' mod = fromText(src, modname='mod') html_foo = docstring2html(mod.contents['foo']) html_bar = docstring2html(mod.contents['bar']) html_watch = docstring2html(mod.contents['Spectator'].contents['watch']) html_leave = docstring2html(mod.contents['Spectator'].contents['leave']) html_which = docstring2html(mod.contents['Spectator'].contents['which']) html_init = docstring2html(mod.contents['Spectator'].contents['__init__']) html_bool = docstring2html(mod.contents['Spectator'].contents['__bool__']) assert not capsys.readouterr().out assert 'cls' in html_foo assert 'self' in html_bar assert 'self' in html_watch assert 'cls' in html_leave assert 'cls' not in html_which assert 'self' not in html_init assert 'self' in html_bool # tests for issue https://github.com/twisted/pydoctor/issues/661 def test_dup_names_resolves_function_signature() -> None: """ Annotations should always be resolved in the context of the module scope. For function signature, it's handled by having a special value formatter class for annotations. For the parameter table it's handled by the field handler. Annotation are currently rendered twice, which is suboptimal and can cause inconsistencies. """ src = '''\ class System: dup = Union[str, bytes] default = 3 def Attribute(self, t:'dup'=default) -> Type['Attribute']: """ @param t: do not confuse with L{the class level one }. @returns: stuff """ Attribute = 'thing' dup = Union[str, bytes] # yes this one default = 'not this one' ''' mod = fromText(src, modname='model') def_Attribute = mod.contents['System'].contents['Attribute'] assert isinstance(def_Attribute, model.Function) sig = flatten(format_signature(def_Attribute)) assert 'href="index.html#Attribute"' in sig assert 'href="index.html#dup"' in sig assert 'href="#default"' in sig docstr = docstring2html(def_Attribute) assert 'dup' in docstr assert 'the class level one' in docstr assert 'href="index.html#Attribute"' in docstr def test_dup_names_resolves_annotation() -> None: """ Annotations should always be resolved in the context of the module scope. PEP-563 says: Annotations can only use names present in the module scope as postponed evaluation using local names is not reliable. For Attributes, this is handled by the type2stan() function, because name linking is done at the stan tree generation step. """ src = '''\ class System: Attribute: typing.TypeAlias = 'str' class Inner: @property def Attribute(self) -> Type['Attribute']:... Attribute = Union[str, int] ''' mod = fromText(src, modname='model') property_Attribute = mod.contents['System'].contents['Inner'].contents['Attribute'] assert isinstance(property_Attribute, model.Attribute) stan = epydoc2stan.type2stan(property_Attribute) assert stan is not None assert 'href="index.html#Attribute"' in flatten(stan) src = '''\ class System: Attribute: Type['Attribute'] Attribute = Union[str, int] ''' mod = fromText(src, modname='model') property_Attribute = mod.contents['System'].contents['Attribute'] assert isinstance(property_Attribute, model.Attribute) stan = epydoc2stan.type2stan(property_Attribute) assert stan is not None assert 'href="index.html#Attribute"' in flatten(stan) # tests for issue https://github.com/twisted/pydoctor/issues/662 def test_dup_names_resolves_base_class() -> None: """ The class signature does not get confused when duplicate names are used. """ src1 = '''\ from model import System, Generic class System(System): ... class Generic(Generic[object]): ... ''' src2 = '''\ class System: ... class Generic: ... ''' system = model.System() builder = system.systemBuilder(system) builder.addModuleString(src1, modname='custom') builder.addModuleString(src2, modname='model') builder.buildModules() custommod,_ = system.rootobjects systemClass = custommod.contents['System'] genericClass = custommod.contents['Generic'] assert isinstance(systemClass, model.Class) and isinstance(genericClass, model.Class) assert 'href="model.System.html"' in flatten(format_class_signature(systemClass)) assert 'href="model.Generic.html"' in flatten(format_class_signature(genericClass)) def test_class_level_type_alias() -> None: src = ''' class C: typ = int|str def f(self, x:typ) -> typ: ... var: typ ''' mod = fromText(src, modname='m') C = mod.system.allobjects['m.C'] f = mod.system.allobjects['m.C.f'] var = mod.system.allobjects['m.C.var'] assert C.isNameDefined('typ') assert isinstance(f, model.Function) assert f.signature assert "href" in repr(f.signature.parameters['x'].annotation) assert "href" in repr(f.signature.return_annotation) assert isinstance(var, model.Attribute) assert "href" in flatten(epydoc2stan.type2stan(var) or '') def test_top_level_type_alias_wins_over_class_level(capsys:CapSys) -> None: """ Pydoctor resolves annotations like pyright when "from __future__ import annotations" is enable, even if it's not actually enabled. """ src = ''' typ = str|bytes # <- this IS the one class C: typ = int|str # <- This is NOT the one. def f(self, x:typ) -> typ: ... var: typ ''' system = model.System() system.options.verbosity = 1 mod = fromText(src, modname='m', system=system) f = mod.system.allobjects['m.C.f'] var = mod.system.allobjects['m.C.var'] assert isinstance(f, model.Function) assert f.signature assert 'href="index.html#typ"' in repr(f.signature.parameters['x'].annotation) assert 'href="index.html#typ"' in repr(f.signature.return_annotation) assert isinstance(var, model.Attribute) assert 'href="index.html#typ"' in flatten(epydoc2stan.type2stan(var) or '') assert capsys.readouterr().out == """\ m:5: ambiguous annotation 'typ', could be interpreted as 'm.C.typ' instead of 'm.typ' m:5: ambiguous annotation 'typ', could be interpreted as 'm.C.typ' instead of 'm.typ' m:7: ambiguous annotation 'typ', could be interpreted as 'm.C.typ' instead of 'm.typ' """ def test_not_found_annotation_does_not_create_link() -> None: """ The docstring linker cache does not create empty tags. """ from pydoctor.test.test_templatewriter import getHTMLOf src = '''\ __docformat__ = 'numpy' def link_to(identifier, label: NotFound): """ :param label: the lable of the link. :type identifier: Union[str, NotFound] """ ''' mod = fromText(src) html = getHTMLOf(mod) assert 'NotFound' not in html def test_docformat_skip_processtypes() -> None: assert all([d in get_supported_docformats() for d in epydoc2stan._docformat_skip_processtypes]) def test_returns_undocumented_still_show_up_if_params_documented() -> None: """ The returns section will show up if any of the parameter are documented and the fucntion has a return annotation. """ src = ''' def f(c:int) -> bool: """ @param c: stuff """ def g(c) -> bool: """ @type c: int """ def h(c): """ @param c: stuff """ def i(c) -> None: """ @param c: stuff """ ''' mod = fromText(src) html_f = docstring2html(mod.contents['f']) html_g = docstring2html(mod.contents['g']) html_h = docstring2html(mod.contents['h']) html_i = docstring2html(mod.contents['i']) assert 'Returns' in html_f assert 'Returns' in html_g assert 'Returns' not in html_h assert 'Returns' not in html_i def test_invalid_epytext_renders_as_plaintext(capsys: CapSys) -> None: """ An invalid epytext docstring will be rederered as plaintext. """ mod = fromText(''' def func(): """ Title ~~~~~ Hello ~~~~~ """ pass ''', modname='invalid') expected = """

    Title ~~~~~ Hello ~~~~~

    """ actual = docstring2html(mod.contents['func']) captured = capsys.readouterr().out assert captured == ('invalid:4: bad docstring: Wrong underline character for heading.\n' 'invalid:8: bad docstring: Wrong underline character for heading.\n') assert actual == expected assert docstring2html(mod.contents['func'], docformat='plaintext') == expected captured = capsys.readouterr().out assert captured == '' def test_regression_not_found_linenumbers(capsys: CapSys) -> None: """ Test for issue https://github.com/twisted/pydoctor/issues/745 """ code = ''' __docformat__ = 'restructuredtext' class Settings: """ Object that manages the configuration for Twine. This object can only be instantiated with keyword arguments. For example, .. code-block:: python Settings(True, username='fakeusername') Will raise a :class:`TypeError`. Instead, you would want .. code-block:: python Settings(sign=True, username='fakeusername') """ def check_repository_url(self) -> None: """ Verify we are not using legacy PyPI. """ ... def create_repository(self) -> repository.Repository: """ Create a new repository for uploading. """ ... ''' mod = fromText(code, ) docstring2html(mod.contents['Settings']) captured = capsys.readouterr().out assert captured == ':15: Cannot find link target for "TypeError"\n' def test_does_not_loose_type_linenumber(capsys: CapSys) -> None: # exmaple from numpy/distutils/ccompiler_opt.py src = ''' class C: """ Some docs bla bla bla bla @ivar one: trash @type cc_noopt: L{bool} @ivar cc_noopt: docs """ def __init__(self): self.cc_noopt = True """ docs again """ ''' system = model.System(model.Options.from_args('-q')) mod = fromText(src, system=system) assert mod.contents['C'].contents['cc_noopt'].docstring == 'docs again' from pydoctor.test.test_templatewriter import getHTMLOf # we use this function as a shortcut to trigger # the link not found warnings. getHTMLOf(mod.contents['C']) assert capsys.readouterr().out == (':16: Existing docstring at line 10 is overriden\n' ':10: Cannot find link target for "bool"\n')pydoctor-24.11.2/pydoctor/test/test_model.py000066400000000000000000000440061473665144200210770ustar00rootroot00000000000000""" Unit tests for model. """ import subprocess import os from inspect import signature from pathlib import Path, PurePosixPath, PureWindowsPath from typing import cast, Optional import zlib import pytest from twisted.web.template import Tag from pydoctor.options import Options from pydoctor import model, stanutils, extensions from pydoctor.templatewriter import pages from pydoctor.utils import parse_privacy_tuple from pydoctor.sphinx import CacheT from pydoctor.test import CapSys from pydoctor.test.test_astbuilder import fromText from pydoctor.test.test_packages import processPackage class FakeOptions: """ A fake options object as if it came from argparse. """ sourcehref = None htmlsourcebase: Optional[str] = None projectbasedirectory: Path docformat = 'epytext' class FakeDocumentable: """ A fake of pydoctor.model.Documentable that provides a system and sourceHref attribute. """ system: model.System sourceHref = None filepath: str @pytest.mark.parametrize('projectBaseDir', [ PurePosixPath("/foo/bar/ProjectName"), PureWindowsPath("C:\\foo\\bar\\ProjectName")] ) def test_setSourceHrefOption(projectBaseDir: Path) -> None: """ Test that the projectbasedirectory option sets the model.sourceHref properly. """ mod = cast(model.Module, FakeDocumentable()) options = FakeOptions() options.projectbasedirectory = projectBaseDir options.htmlsourcebase = "http://example.org/trac/browser/trunk" system = model.System(options) # type:ignore[arg-type] mod.system = system system.setSourceHref(mod, projectBaseDir / "package" / "module.py") assert mod.sourceHref == "http://example.org/trac/browser/trunk/package/module.py" def test_htmlsourcetemplate_auto_detect() -> None: """ Tests for the recognition of different version control providers that uses differents URL templates to point to line numbers. Supported templates are:: Github : {}#L{lineno} Bitbucket: {}#lines-{lineno} SourceForge : {}#l{lineno} """ cases = [ ("http://example.org/trac/browser/trunk", "http://example.org/trac/browser/trunk/pydoctor/test/testpackages/basic/mod.py#L7"), ("https://sourceforge.net/p/epydoc/code/HEAD/tree/trunk/epydoc", "https://sourceforge.net/p/epydoc/code/HEAD/tree/trunk/epydoc/pydoctor/test/testpackages/basic/mod.py#l7"), ("https://bitbucket.org/user/scripts/src/master", "https://bitbucket.org/user/scripts/src/master/pydoctor/test/testpackages/basic/mod.py#lines-7"), ] for base, var_href in cases: options = model.Options.from_args([f'--html-viewsource-base={base}', '--project-base-dir=.']) system = model.System(options) processPackage('basic', systemcls=lambda:system) assert system.allobjects['basic.mod.C'].sourceHref == var_href def test_htmlsourcetemplate_custom() -> None: """ The links to source code web pages can be customized via an CLI argument. """ options = model.Options.from_args([ '--html-viewsource-base=http://example.org/trac/browser/trunk', '--project-base-dir=.', '--html-viewsource-template={mod_source_href}#n{lineno}']) system = model.System(options) processPackage('basic', systemcls=lambda:system) assert system.allobjects['basic.mod.C'].sourceHref == "http://example.org/trac/browser/trunk/pydoctor/test/testpackages/basic/mod.py#n7" def test_initialization_default() -> None: """ When initialized without options, will use default options and default verbosity. """ sut = model.System() assert None is sut.options.projectname assert 3 == sut.options.verbosity def test_initialization_options() -> None: """ Can be initialized with options. """ options = Options.defaults() sut = model.System(options=options) assert options is sut.options def test_fetchIntersphinxInventories_empty() -> None: """ Convert option to empty dict. """ options = Options.defaults() options.intersphinx = [] sut = model.System(options=options) sut.fetchIntersphinxInventories(cast('CacheT', {})) # Use internal state since I don't know how else to # check for SphinxInventory state. assert {} == sut.intersphinx._links def test_fetchIntersphinxInventories_content() -> None: """ Download and parse intersphinx inventories for each configured intersphix. """ options = Options.defaults() options.intersphinx = [ 'http://sphinx/objects.inv', 'file:///twisted/index.inv', ] url_content = { 'http://sphinx/objects.inv': zlib.compress( b'sphinx.module py:module -1 sp.html -'), 'file:///twisted/index.inv': zlib.compress( b'twisted.package py:module -1 tm.html -'), } sut = model.System(options=options) log = [] def log_msg(part: str, msg: str) -> None: log.append((part, msg)) sut.msg = log_msg # type: ignore[assignment] class Cache(CacheT): """Avoid touching the network.""" def get(self, url: str) -> bytes: return url_content[url] def close(self) -> None: return None sut.fetchIntersphinxInventories(Cache()) assert [] == log assert ( 'http://sphinx/sp.html' == sut.intersphinx.getLink('sphinx.module') ) assert ( 'file:///twisted/tm.html' == sut.intersphinx.getLink('twisted.package') ) def test_docsources_class_attribute() -> None: src = ''' class Base: attr = False """documentation""" class Sub(Base): attr = True ''' mod = fromText(src) base_attr = mod.contents['Base'].contents['attr'] sub_attr = mod.contents['Sub'].contents['attr'] assert base_attr in list(sub_attr.docsources()) def test_constructor_params_empty() -> None: src = ''' class C: pass ''' mod = fromText(src) C = mod.contents['C'] assert isinstance(C, model.Class) assert C.constructor_params == {} def test_constructor_params_simple() -> None: src = ''' class C: def __init__(self, a: int, b: str): pass ''' mod = fromText(src) C = mod.contents['C'] assert isinstance(C, model.Class) assert C.constructor_params.keys() == {'self', 'a', 'b'} def test_constructor_params_inherited() -> None: src = ''' class A: def __init__(self, a: int, b: str): pass class B: def __init__(self): pass class C(A, B): pass ''' mod = fromText(src) C = mod.contents['C'] assert isinstance(C, model.Class) assert C.constructor_params.keys() == {'self', 'a', 'b'} def test_constructor_params_new() -> None: src = ''' class A: def __new__(cls, **kwargs): pass class B: def __init__(self, a: int, b: str): pass class C(A, B): pass ''' mod = fromText(src) C = mod.contents['C'] assert isinstance(C, model.Class) assert C.constructor_params.keys() == {'cls', 'kwargs'} def test_docstring_lineno() -> None: src = ''' def f(): """ This is a long docstring. Somewhat long, anyway. This should be enough. """ ''' mod = fromText(src) func = mod.contents['f'] assert func.linenumber == 2 assert func.docstring_lineno == 4 # first non-blank line class Dummy: def crash(self) -> None: """Mmm""" def dummy_function_with_complex_signature(foo: int, bar: float) -> str: return "foo" def test_introspection_python() -> None: """Find docstrings from this test using introspection on pure Python.""" system = model.System() system.introspectModule(Path(__file__), __name__, None) system.process() module = system.objForFullName(__name__) assert module is not None assert module.docstring == __doc__ func = module.contents['test_introspection_python'] assert isinstance(func, model.Function) assert func.docstring == "Find docstrings from this test using introspection on pure Python." assert func.signature == signature(test_introspection_python) method = system.objForFullName(__name__ + '.Dummy.crash') assert method is not None assert method.docstring == "Mmm" func = module.contents['dummy_function_with_complex_signature'] assert isinstance(func, model.Function) assert func.signature == signature(dummy_function_with_complex_signature) def test_introspection_extension() -> None: """Find docstrings from this test using introspection of an extension.""" try: import cython_test_exception_raiser.raiser except ImportError: pytest.skip("cython_test_exception_raiser not installed") system = model.System() package = system.introspectModule( Path(cython_test_exception_raiser.__file__), 'cython_test_exception_raiser', None) assert isinstance(package, model.Package) module = system.introspectModule( Path(cython_test_exception_raiser.raiser.__file__), 'raiser', package) system.process() assert not isinstance(module, model.Package) assert system.objForFullName('cython_test_exception_raiser') is package assert system.objForFullName('cython_test_exception_raiser.raiser') is module assert module.docstring is not None assert module.docstring.strip().split('\n')[0] == "A trivial extension that just raises an exception." cls = module.contents['RaiserException'] assert cls.docstring is not None assert cls.docstring.strip() == "A speficic exception only used to be identified in tests." func = module.contents['raiseException'] assert func.docstring is not None assert func.docstring.strip() == "Raise L{RaiserException}." testpackages = Path(__file__).parent / 'testpackages' @pytest.mark.skipif("platform.python_implementation() == 'PyPy' or platform.system() == 'Windows'") def test_c_module_text_signature(capsys:CapSys) -> None: c_module_invalid_text_signature = testpackages / 'c_module_invalid_text_signature' package_path = c_module_invalid_text_signature / 'mymod' # build extension try: cwd = os.getcwd() code, outstr = subprocess.getstatusoutput(f'cd {c_module_invalid_text_signature} && python3 setup.py build_ext --inplace') os.chdir(cwd) assert code==0, outstr system = model.System() system.options.introspect_c_modules = True builder = system.systemBuilder(system) builder.addModule(package_path) builder.buildModules() assert "Cannot parse signature of mymod.base.invalid_text_signature" in capsys.readouterr().out mymod_base = system.allobjects['mymod.base'] assert isinstance(mymod_base, model.Module) func = mymod_base.contents['invalid_text_signature'] assert isinstance(func, model.Function) assert func.signature == None valid_func = mymod_base.contents['valid_text_signature'] assert isinstance(valid_func, model.Function) assert "(...)" == pages.format_signature(func) assert "(a='r', b=-3.14)" == stanutils.flatten_text( cast(Tag, pages.format_signature(valid_func))) finally: # cleanup subprocess.getoutput(f'rm -f {package_path}/*.so') @pytest.mark.skipif("platform.python_implementation() == 'PyPy' or platform.system() == 'Windows'") def test_c_module_python_module_name_clash(capsys:CapSys) -> None: c_module_python_module_name_clash = testpackages / 'c_module_python_module_name_clash' package_path = c_module_python_module_name_clash / 'mymod' # build extension try: cwd = os.getcwd() code, outstr = subprocess.getstatusoutput(f'cd {c_module_python_module_name_clash} && python3 setup.py build_ext --inplace') os.chdir(cwd) assert code==0, outstr system = model.System() system.options.introspect_c_modules = True system.addPackage(package_path, None) system.process() mod = system.allobjects['mymod.base'] # there is only one mymod.base module assert [mod] == list(system.allobjects['mymod'].contents.values()) assert len(mod.contents) == 1 assert 'coming_from_c_module' == mod.contents.popitem()[0] finally: # cleanup subprocess.getoutput(f'rm -f {package_path}/*.so') def test_resolve_name_subclass(capsys:CapSys) -> None: """ C{Model.resolveName} knows about single inheritance. """ m = fromText( """ class B: v=1 class C(B): pass """ ) assert m.resolveName('C.v') == m.contents['B'].contents['v'] @pytest.mark.parametrize('privacy', [ (['public:m._public**', 'public:m.tests', 'public:m.tests.helpers', 'private:m._public.private', 'hidden:m._public.hidden', 'hidden:m.tests.*']), (reversed(['private:**private', 'hidden:**hidden', 'public:**_public', 'hidden:m.tests.test**', ])), ]) def test_privacy_switch(privacy:object) -> None: s = model.System() s.options.privacy = [parse_privacy_tuple(p, '--privacy') for p in privacy] # type:ignore fromText( """ class _public: class _still_public: ... class private: ... class hidden: ... class tests(B): # public class helpers: # public ... class test1: # everything else hidden ... class test2: ... class test3: ... """, system=s, modname='m' ) allobjs = s.allobjects assert allobjs['m._public'].privacyClass == model.PrivacyClass.PUBLIC assert allobjs['m._public._still_public'].privacyClass == model.PrivacyClass.PUBLIC assert allobjs['m._public.private'].privacyClass == model.PrivacyClass.PRIVATE assert allobjs['m._public.hidden'].privacyClass == model.PrivacyClass.HIDDEN assert allobjs['m.tests'].privacyClass == model.PrivacyClass.PUBLIC assert allobjs['m.tests.helpers'].privacyClass == model.PrivacyClass.PUBLIC assert allobjs['m.tests.test1'].privacyClass == model.PrivacyClass.HIDDEN assert allobjs['m.tests.test2'].privacyClass == model.PrivacyClass.HIDDEN assert allobjs['m.tests.test3'].privacyClass == model.PrivacyClass.HIDDEN def test_privacy_reparented() -> None: """ Test that the privacy of an object changes if the name of the object changes (with reparenting). """ system = model.System() mod_private = fromText(''' class _MyClass: pass ''', modname='private', system=system) mod_export = fromText( 'from private import _MyClass # not needed for the test to pass', modname='public', system=system) base = mod_private.contents['_MyClass'] assert base.privacyClass == model.PrivacyClass.PRIVATE # Manually reparent MyClass base.reparent(mod_export, 'MyClass') assert base.fullName() == 'public.MyClass' assert '_MyClass' not in mod_private.contents assert mod_export.resolveName("MyClass") == base assert base.privacyClass == model.PrivacyClass.PUBLIC def test_name_defined() -> None: src = ''' # module 'm' import pydoctor import twisted.web class c: class F: def f():... var:F = True ''' mod = fromText(src, modname='m') # builtins are not considered by isNameDefined() assert not mod.isNameDefined('bool') assert mod.isNameDefined('pydoctor') assert mod.isNameDefined('twisted.web') assert mod.isNameDefined('twisted') assert not mod.isNameDefined('m') assert mod.isNameDefined('c') assert mod.isNameDefined('c.anything') cls = mod.contents['c'] assert isinstance(cls, model.Class) assert cls.isNameDefined('pydoctor') assert cls.isNameDefined('twisted.web') assert cls.isNameDefined('twisted') assert not mod.isNameDefined('m') assert cls.isNameDefined('c') assert cls.isNameDefined('c.anything') assert cls.isNameDefined('var') var = cls.contents['var'] assert isinstance(var, model.Attribute) assert var.isNameDefined('c') assert var.isNameDefined('var') assert var.isNameDefined('F') assert not var.isNameDefined('m') assert var.isNameDefined('pydoctor') assert var.isNameDefined('twisted.web') innerCls = cls.contents['F'] assert isinstance(innerCls, model.Class) assert not innerCls.isNameDefined('F') assert not innerCls.isNameDefined('var') assert innerCls.isNameDefined('f') innerFn = innerCls.contents['f'] assert isinstance(innerFn, model.Function) assert not innerFn.isNameDefined('F') assert not innerFn.isNameDefined('var') assert innerFn.isNameDefined('f') def test_priority_processor(capsys:CapSys) -> None: system = model.System() r = extensions.ExtRegistrar(system) processor = system._post_processor processor._post_processors.clear() r.register_post_processor(lambda s:print('priority 200'), priority=200) r.register_post_processor(lambda s:print('priority 100')) r.register_post_processor(lambda s:print('priority 25'), priority=25) r.register_post_processor(lambda s:print('priority 150'), priority=150) r.register_post_processor(lambda s:print('priority 100 (bis)')) r.register_post_processor(lambda s:print('priority 200 (bis)'), priority=200) assert len(processor._post_processors)==6 processor.apply_processors() assert len(processor.applied)==6 assert capsys.readouterr().out.strip().splitlines() == ['priority 200', 'priority 200 (bis)', 'priority 150', 'priority 100', 'priority 100 (bis)', 'priority 25', ] pydoctor-24.11.2/pydoctor/test/test_mro.py000066400000000000000000000212631473665144200205740ustar00rootroot00000000000000from typing import List, Optional, Type import pytest from pydoctor import model, stanutils from pydoctor.templatewriter import pages, util from pydoctor.test.test_astbuilder import fromText, systemcls_param from pydoctor.test import CapSys def assert_mro_equals(klass: Optional[model.Documentable], expected_mro: List[str]) -> None: assert isinstance(klass, model.Class) assert [member.fullName() if isinstance(member, model.Documentable) else member for member in klass.mro(True)] == expected_mro @systemcls_param def test_mro(systemcls: Type[model.System],) -> None: mod = fromText("""\ from mod import External class C: pass class D(C): pass class A1: pass class B1(A1): pass class C1(A1): pass class D1(B1, C1): pass class E1(C1, B1): pass class F1(D1, E1): pass class G1(E1, D1): pass class Boat: pass class DayBoat(Boat): pass class WheelBoat(Boat): pass class EngineLess(DayBoat): pass class SmallMultihull(DayBoat): pass class PedalWheelBoat(EngineLess, WheelBoat): pass class SmallCatamaran(SmallMultihull): pass class Pedalo(PedalWheelBoat, SmallCatamaran): pass class OuterA: class Inner: pass class OuterB(OuterA): class Inner(OuterA.Inner): pass class OuterC(OuterA): class Inner(OuterA.Inner): pass class OuterD(OuterC): class Inner(OuterC.Inner, OuterB.Inner): pass class Duplicates(C, C): pass class Extension(External): pass class MycustomString(str): pass from typing import Generic class MyGeneric(Generic[T]):... class Visitor(MyGeneric[T]):... import ast class GenericPedalo(MyGeneric[ast.AST], Pedalo):... """, modname='mro', systemcls=systemcls ) assert_mro_equals(mod.contents["D"], ["mro.D", "mro.C"]) assert_mro_equals(mod.contents["D1"], ['mro.D1', 'mro.B1', 'mro.C1', 'mro.A1']) assert_mro_equals(mod.contents["E1"], ['mro.E1', 'mro.C1', 'mro.B1', 'mro.A1']) assert_mro_equals(mod.contents["Extension"], ["mro.Extension", "mod.External"]) assert_mro_equals(mod.contents["MycustomString"], ["mro.MycustomString", "str"]) assert_mro_equals( mod.contents["PedalWheelBoat"], ["mro.PedalWheelBoat", "mro.EngineLess", "mro.DayBoat", "mro.WheelBoat", "mro.Boat"], ) assert_mro_equals( mod.contents["SmallCatamaran"], ["mro.SmallCatamaran", "mro.SmallMultihull", "mro.DayBoat", "mro.Boat"], ) assert_mro_equals( mod.contents["Pedalo"], [ "mro.Pedalo", "mro.PedalWheelBoat", "mro.EngineLess", "mro.SmallCatamaran", "mro.SmallMultihull", "mro.DayBoat", "mro.WheelBoat", "mro.Boat" ], ) assert_mro_equals( mod.contents["OuterD"].contents["Inner"], ['mro.OuterD.Inner', 'mro.OuterC.Inner', 'mro.OuterB.Inner', 'mro.OuterA.Inner'] ) assert_mro_equals( mod.contents["Visitor"], ['mro.Visitor', 'mro.MyGeneric', 'typing.Generic'] ) assert_mro_equals( mod.contents["GenericPedalo"], ['mro.GenericPedalo', 'mro.MyGeneric', 'typing.Generic', 'mro.Pedalo', 'mro.PedalWheelBoat', 'mro.EngineLess', 'mro.SmallCatamaran', 'mro.SmallMultihull', 'mro.DayBoat', 'mro.WheelBoat', 'mro.Boat']) with pytest.raises(ValueError, match="Cannot compute linearization"): model.compute_mro(mod.contents["F1"]) # type:ignore with pytest.raises(ValueError, match="Cannot compute linearization"): model.compute_mro(mod.contents["G1"]) # type:ignore with pytest.raises(ValueError, match="Cannot compute linearization"): model.compute_mro(mod.contents["Duplicates"]) # type:ignore def test_mro_cycle(capsys:CapSys) -> None: fromText("""\ class A(D):... class B:... class C(A,B):... class D(C):... """, modname='cycle') assert capsys.readouterr().out == '''cycle:1: Cycle found while computing inheritance hierarchy: cycle.A -> cycle.D -> cycle.C -> cycle.A cycle:3: Cycle found while computing inheritance hierarchy: cycle.C -> cycle.A -> cycle.D -> cycle.C cycle:4: Cycle found while computing inheritance hierarchy: cycle.D -> cycle.C -> cycle.A -> cycle.D ''' def test_inherited_docsources()-> None: simple = fromText("""\ class A: def a():... class B: def b():... class C(A,B): def a():... def b():... """, modname='normal') assert [o.fullName() for o in list(simple.contents['A'].contents['a'].docsources())] == ['normal.A.a'] assert [o.fullName() for o in list(simple.contents['B'].contents['b'].docsources())] == ['normal.B.b'] assert [o.fullName() for o in list(simple.contents['C'].contents['b'].docsources())] == ['normal.C.b','normal.B.b'] assert [o.fullName() for o in list(simple.contents['C'].contents['a'].docsources())] == ['normal.C.a','normal.A.a'] dimond = fromText("""\ class _MyBase: def z():... class A(_MyBase): def a():... def z():... class B(_MyBase): def b():... class C(A,B): def a():... def b():... def z():... """, modname='diamond') assert [o.fullName() for o in list(dimond.contents['A'].contents['a'].docsources())] == ['diamond.A.a'] assert [o.fullName() for o in list(dimond.contents['A'].contents['z'].docsources())] == ['diamond.A.z', 'diamond._MyBase.z'] assert [o.fullName() for o in list(dimond.contents['B'].contents['b'].docsources())] == ['diamond.B.b'] assert [o.fullName() for o in list(dimond.contents['C'].contents['b'].docsources())] == ['diamond.C.b','diamond.B.b'] assert [o.fullName() for o in list(dimond.contents['C'].contents['a'].docsources())] == ['diamond.C.a','diamond.A.a'] assert [o.fullName() for o in list(dimond.contents['C'].contents['z'].docsources())] == ['diamond.C.z','diamond.A.z', 'diamond._MyBase.z'] def test_overriden_in()-> None: simple = fromText("""\ class A: def a():... class B: def b():... class C(A,B): def a():... def b():... """, modname='normal') assert stanutils.flatten_text( pages.get_override_info(simple.contents['A'], # type:ignore 'a')) == 'overridden in normal.C' assert stanutils.flatten_text( pages.get_override_info(simple.contents['B'], # type:ignore 'b')) == 'overridden in normal.C' dimond = fromText("""\ class _MyBase: def z():... class A(_MyBase): def a():... def z():... class B(_MyBase): def b():... class C(A,B): def a():... def b():... def z():... """, modname='diamond') assert stanutils.flatten_text( pages.get_override_info(dimond.contents['A'], # type:ignore 'a')) == 'overridden in diamond.C' assert stanutils.flatten_text( pages.get_override_info(dimond.contents['B'], # type:ignore 'b')) == 'overridden in diamond.C' assert stanutils.flatten_text( pages.get_override_info(dimond.contents['_MyBase'], #type:ignore 'z')) == 'overridden in diamond.A, diamond.C' assert stanutils.flatten_text( pages.get_override_info(dimond.contents['A'], # type:ignore 'z')) == ('overrides diamond._MyBase.z' 'overridden in diamond.C') assert stanutils.flatten_text( pages.get_override_info(dimond.contents['C'], # type:ignore 'z')) == 'overrides diamond.A.z' klass = dimond.contents['_MyBase'] assert isinstance(klass, model.Class) assert klass.subclasses == [dimond.contents['A'], dimond.contents['B']] assert list(util.overriding_subclasses(klass, 'z')) == [dimond.contents['A'], dimond.contents['C']] def test_inherited_members() -> None: """ The inherited_members() function computes only the inherited members of a given class. It does not include members defined in the class itself. """ dimond = fromText("""\ class _MyBase: def z():... class A(_MyBase): def a():... def z():... class B(_MyBase): def b():... class C(A,B): ... """, modname='diamond') assert len(util.inherited_members(dimond.contents['B']))==1 # type:ignore assert len(util.inherited_members(dimond.contents['C']))==3 # type:ignore assert len(util.inherited_members(dimond.contents['A']))==0 # type:ignore assert len(util.inherited_members(dimond.contents['_MyBase']))==0 # type:ignore pydoctor-24.11.2/pydoctor/test/test_napoleon_docstring.py000066400000000000000000002512231473665144200236670ustar00rootroot00000000000000 """ Forked from the tests for ``sphinx.ext.napoleon.docstring`` module. :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. """ import re from typing import Type, Union from unittest import TestCase from textwrap import dedent from pydoctor.napoleon.docstring import (GoogleDocstring as _GoogleDocstring, NumpyDocstring as _NumpyDocstring, TokenType, TypeDocstring, is_type, is_google_typed_arg) from pydoctor.utils import partialclass import sphinx.ext.napoleon as sphinx_napoleon __docformat__ = "restructuredtext" sphinx_napoleon_config = sphinx_napoleon.Config( napoleon_use_admonition_for_examples=True, napoleon_use_admonition_for_notes=True, napoleon_use_admonition_for_references=True, napoleon_use_ivar=True, napoleon_use_param=True, napoleon_use_keyword=True, napoleon_use_rtype=True, napoleon_preprocess_types=True) # Adapters for upstream Sphinx napoleon classes SphinxGoogleDocstring = partialclass(sphinx_napoleon.docstring.GoogleDocstring, config=sphinx_napoleon_config, what='function') SphinxNumpyDocstring = partialclass(sphinx_napoleon.docstring.NumpyDocstring, config=sphinx_napoleon_config, what='function') # Create adapter classes that uses process_type_fields=True for the testing purposes GoogleDocstring = partialclass(_GoogleDocstring, process_type_fields=True) NumpyDocstring = partialclass(_NumpyDocstring, process_type_fields=True) class BaseDocstringTest(TestCase): maxDiff = None # mypy get error: # Variable "pydoctor.test.test_napoleon_docstring.SphinxGoogleDocstring" is not valid as a type def assertAlmostEqualSphinxDocstring(self, expected: str, docstring: str, type_: Type[Union[SphinxGoogleDocstring, SphinxNumpyDocstring]]) -> None: #type: ignore[valid-type] """ Check if the upstream version of the parser class (from `sphinx.ext.napoleon`) parses the docstring as expected. This is used as a supplementary manner of testing the parser behaviour. Some approximation are applied with `re.sub` to the ``expected`` string and the reST docstring generated by `sphinx.ext.napoleon` classes. This is done in order to use the expected reST strings designed for `pydoctor.napoleon` and apply them to `sphinx.ext.napoleon` in the same test. Tho, not all tests cases can be adapted to pass this check. :param expected: The exact expected reST docstring generated by `pydoctor.napoleon` classes (trailling whitespaces ignored) """ expected_sphinx_output = re.sub( r"(`|\\\s|\\|:mod:|:func:|:class:|:obj:|:py:mod:|:py:func:|:py:class:|:py:obj:)", "", expected) # mypy error: Cannot instantiate type "Type[SphinxGoogleDocstring?] sphinx_docstring_output = re.sub( r"(`|\\|:mod:|:func:|:class:|:obj:|:py:mod:|:py:func:|:py:class:|:py:obj:|\s)", "", str(type_(docstring)).replace( #type: ignore[misc] ":kwtype", ":type").replace(":vartype", ":type").replace(" -- ", " - ").replace(':rtype:', ':returntype:').rstrip()) self.assertEqual(expected_sphinx_output.rstrip(), sphinx_docstring_output) class TypeDocstringTest(BaseDocstringTest): def test_is_type(self): self.assertFalse(is_type("Random words are not a type spec")) self.assertFalse(is_type("List of string or any kind fo sequences of strings")) self.assertTrue(is_type("Sequence(str), optional")) self.assertTrue(is_type("Sequence(str) or str")) self.assertTrue(is_type("List[str] or list(bytes), optional")) self.assertTrue(is_type('{"F", "C", "N"}, optional')) self.assertTrue(is_type("list of int or float or None, default: None")) self.assertTrue(is_type("`complicated string` or `strIO `")) def test_is_google_typed_arg(self): self.assertFalse(is_google_typed_arg("Random words are not a type spec")) self.assertFalse(is_google_typed_arg("List of string or any kind fo sequences of strings")) self.assertTrue(is_google_typed_arg("Sequence(str), optional")) self.assertTrue(is_google_typed_arg("Sequence(str) or str")) self.assertTrue(is_google_typed_arg("List[str] or list(bytes), optional")) self.assertTrue(is_google_typed_arg('{"F", "C", "N"}, optional')) self.assertTrue(is_google_typed_arg("list of int or float or None, default: None")) self.assertTrue(is_google_typed_arg("`complicated string` or `strIO `")) # Google-style specific self.assertFalse(is_google_typed_arg("foo (Random words are not a type spec)")) self.assertFalse(is_google_typed_arg("foo (List of string or any kind fo sequences of strings)")) self.assertTrue(is_google_typed_arg("foo (Sequence(str), optional)")) self.assertTrue(is_google_typed_arg("foo (Sequence[str] or str)")) self.assertTrue(is_google_typed_arg("foo (List[str] or list(bytes), optional)")) self.assertTrue(is_google_typed_arg('foo ({"F", "C", "N"}, optional)')) self.assertTrue(is_google_typed_arg("foo (list of int or float or None, default: None)")) self.assertTrue(is_google_typed_arg("foo (`complicated string` or `strIO `)")) self.assertTrue(is_google_typed_arg("Random words are not a type spec (List[str] or list(bytes), optional)")) self.assertTrue(is_google_typed_arg("Random words are not a type spec (list of int or float or None, default: None)")) self.assertTrue(is_google_typed_arg("Random words are not a type spec (`complicated string` or `strIO `, optional)")) def test_token_type(self): tokens = ( ("1", TokenType.LITERAL), ("-4.6", TokenType.LITERAL), ("2j", TokenType.LITERAL), ("'string'", TokenType.LITERAL), ('"another_string"', TokenType.LITERAL), ("{1, 2}", TokenType.LITERAL), ("{'va{ue', 'set'}", TokenType.LITERAL), ("optional", TokenType.CONTROL), ("default", TokenType.CONTROL), (", ", TokenType.DELIMITER), (" of ", TokenType.DELIMITER), (" or ", TokenType.DELIMITER), (": ", TokenType.DELIMITER), ("]", TokenType.DELIMITER), ("[", TokenType.DELIMITER), (")", TokenType.DELIMITER), ("(", TokenType.DELIMITER), ("True", TokenType.OBJ), ("None", TokenType.OBJ), ("name", TokenType.OBJ), (":py:class:`Enum`", TokenType.REFERENCE), ("`a complicated string`", TokenType.REFERENCE), ("just a string", TokenType.UNKNOWN), (len("not a string"), TokenType.ANY), ) type_spec = TypeDocstring('', 0) for token, _type in tokens: actual = type_spec._token_type(token) self.assertEqual(_type, actual) def test_tokenize_type_spec(self): specs = ( "str", "defaultdict", "int, float, or complex", "int or float or None, optional", '{"F", "C", "N"}', "{'F', 'C', 'N'}, default: 'F'", "{'F', 'C', 'N or C'}, default 'F'", "str, default: 'F or C'", "int, default: None", "int, default None", "int, default :obj:`None`", '"ma{icious"', r"'with \'quotes\''", ) tokens = ( ["str"], ["defaultdict"], ["int", ", ", "float", ", or ", "complex"], ["int", " or ", "float", " or ", "None", ", ", "optional"], ["{", '"F"', ", ", '"C"', ", ", '"N"', "}"], ["{", "'F'", ", ", "'C'", ", ", "'N'", "}", ", ", "default", ": ", "'F'"], ["{", "'F'", ", ", "'C'", ", ", "'N or C'", "}", ", ", "default", " ", "'F'"], ["str", ", ", "default", ": ", "'F or C'"], ["int", ", ", "default", ": ", "None"], ["int", ", ", "default", " ", "None"], ["int", ", ", "default", " ", ":obj:`None`"], ['"ma{icious"'], [r"'with \'quotes\''"], ) for spec, expected in zip(specs, tokens): actual = TypeDocstring._tokenize_type_spec(spec) self.assertEqual(expected, actual) def test_recombine_set_tokens(self): tokens = ( ["{", "1", ", ", "2", "}"], ["{", '"F"', ", ", '"C"', ", ", '"N"', "}", ", ", "optional"], ["{", "'F'", ", ", "'C'", ", ", "'N'", "}", ", ", "default", ": ", "None"], ["{", "'F'", ", ", "'C'", ", ", "'N'", "}", ", ", "default", " ", "None"], ) combined_tokens = ( ["{1, 2}"], ['{"F", "C", "N"}', ", ", "optional"], ["{'F', 'C', 'N'}", ", ", "default", ": ", "None"], ["{'F', 'C', 'N'}", ", ", "default", " ", "None"], ) for tokens_, expected in zip(tokens, combined_tokens): actual = TypeDocstring._recombine_set_tokens(tokens_) self.assertEqual(expected, actual) def test_recombine_set_tokens_invalid(self): tokens = ( ["{", "1", ", ", "2"], ['"F"', ", ", '"C"', ", ", '"N"', "}", ", ", "optional"], ["{", "1", ", ", "2", ", ", "default", ": ", "None"], ) combined_tokens = ( ["{1, 2"], ['"F"', ", ", '"C"', ", ", '"N"', "}", ", ", "optional"], ["{1, 2", ", ", "default", ": ", "None"], ) for tokens_, expected in zip(tokens, combined_tokens): actual = TypeDocstring._recombine_set_tokens(tokens_) self.assertEqual(expected, actual) def test_convert_numpy_type_spec(self): specs = ( "", "optional", "str, optional", "int or float or None, default: None", "int or float or None, default=None", "int or float or None, default = None", # corner case "int or float or None, default None", "int, default None", '{"F", "C", "N"}', "{'F', 'C', 'N'}, default: 'N'", "{'F', 'C', 'N'}, default 'N'", "DataFrame, optional", "default[str]", # corner cases... "optional[str]", ",[str]", ", [str]", " of [str]", " or [str]", ": [str]", " and [str]", "'hello'[str]", '"hello"[str]', "`hello`[str]", "`hello `_[str]", "**hello**[str]", ) converted = ( "", "*optional*", "`str`, *optional*", "`int` or `float` or `None`, *default*: `None`", "`int` or `float` or `None`, *default* `None`", "`int` or `float` or `None`, *default* = None", "`int` or `float` or `None`, *default* `None`", "`int`, *default* `None`", '``{"F", "C", "N"}``', "``{'F', 'C', 'N'}``, *default*: ``'N'``", "``{'F', 'C', 'N'}``, *default* ``'N'``", "`DataFrame`, *optional*", r"*default*\ [`str`]", r"*optional*\ [`str`]", ", [`str`]", ", [`str`]", " of [`str`]", " or [`str`]", ": [`str`]", " and [`str`]", r"``'hello'``\ [`str`]", r'``"hello"``\ [`str`]', r"`hello`\ [`str`]", r"`hello `_\ [`str`]", r"**hello**\ [`str`]", ) for spec, expected in zip(specs, converted): actual = str(TypeDocstring(spec)) self.assertEqual(expected, actual) def test_token_type_invalid(self): tokens = ( "{1, 2", "}", "'abc", "def'", '"ghi', 'jkl"', ) errors = ( r"invalid value set \(missing closing brace\):", r"invalid value set \(missing opening brace\):", r"malformed string literal \(missing closing quote\):", r"malformed string literal \(missing opening quote\):", r"malformed string literal \(missing closing quote\):", r"malformed string literal \(missing opening quote\):", ) for token, error in zip(tokens, errors): type_spec = TypeDocstring('') type_spec._token_type(token) match_re = re.compile(error) assert len(type_spec.warnings) == 1, type_spec.warnings assert match_re.match(str(type_spec.warnings.pop())) def test_unbalanced_parenthesis(self): strings = ( "list[union[str, bytes]", "list(union[str, bytes)", "list[union(str, bytes]", ) errors = ( r"unbalanced square braces", r"unbalanced square braces", r"unbalanced parenthesis", ) for string, error in zip(strings, errors): type_spec = TypeDocstring(string) match_re = re.compile(error) assert len(type_spec.warnings) == 1, type_spec.warnings assert match_re.match(str(type_spec.warnings.pop())) class InlineAttributeTest(BaseDocstringTest): def test_class_data_member(self): docstring = """\ data member description: - a: b """ actual = str(GoogleDocstring(docstring, what='attribute')) expected = """\ data member description: - a: b""" self.assertEqual(expected.rstrip(), actual) def test_attribute_colon_description(self): """ This is the correct behaviour as per: https://github.com/sphinx-doc/sphinx/issues/9273. But still, it feels a bit off. """ docstring = """:Returns one of: ``"Yes"`` or ``No``.""" actual = str(GoogleDocstring(docstring, what='attribute')) expected = """Returns one of: ``"Yes"`` or ``No``.""" self.assertEqual(expected.rstrip(), actual) docstring = """Returns one of: ``"Yes"`` or ``No``.""" actual = str(GoogleDocstring(docstring, what='attribute')) expected = """``"Yes"`` or ``No``.\n\n:type: Returns one of""" self.assertEqual(expected.rstrip(), actual) def test_class_data_member_inline(self): docstring = ("b: data member description with :ref:`reference` " 'inline description with ' '``a : in code``, ' 'a :ref:`reference`, ' 'a `link `_, ' 'an host:port and HH:MM strings.') actual = str(GoogleDocstring(docstring, what='attribute')) expected = ("""\ data member description with :ref:`reference` inline description with ``a : in code``, a :ref:`reference`, a `link `_, an host:port and HH:MM strings. :type: `b`""") self.assertEqual(expected.rstrip(), actual) def test_class_data_member_inline_no_type(self): docstring = """data with ``a : in code`` and :ref:`reference` and no type""" actual = str(GoogleDocstring(docstring, what='attribute')) expected = """data with ``a : in code`` and :ref:`reference` and no type""" self.assertEqual(expected.rstrip(), actual) def test_class_data_member_inline_ref_in_type(self): docstring = """:class:`int`: data member description""" actual = str(GoogleDocstring(docstring, what='attribute')) expected = ("""\ data member description :type: :class:`int`""") self.assertEqual(expected.rstrip(), actual) class AttributesSectionTest(BaseDocstringTest): # tests for https://github.com/twisted/pydoctor/issues/842 def test_attributes_in_module(self): docstring = """\ Attributes: in_attr: super-dooper attribute """ actual = str(GoogleDocstring(docstring, what='module')) expected = """\ :var in_attr: super-dooper attribute """ self.assertEqual(expected.rstrip(), actual) def test_attributes_in_class(self): docstring = """\ Attributes: in_attr: super-dooper attribute """ actual = str(GoogleDocstring(docstring, what='class')) expected = """\ :ivar in_attr: super-dooper attribute """ self.assertEqual(expected.rstrip(), actual) class GoogleDocstringTest(BaseDocstringTest): docstrings = [( """Single line summary""", """Single line summary""" ), ( """ Single line summary Extended description """, """ Single line summary Extended description """ ), ( """ Single line summary Args: arg1(str):Extended description of arg1 """, """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` """ ), ( """ Single line summary Args: arg1(str):Extended description of arg1 arg2 ( int ) : Extended description of arg2 Keyword Args: kwarg1(str):Extended description of kwarg1 kwarg2 ( int ) : Extended description of kwarg2""", """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param arg2: Extended description of arg2 :type arg2: `int` :keyword kwarg1: Extended description of kwarg1 :type kwarg1: `str` :keyword kwarg2: Extended description of kwarg2 :type kwarg2: `int` """ ), ( """ Single line summary Arguments: arg1(str):Extended description of arg1 arg2 ( int ) : Extended description of arg2 Keyword Arguments: kwarg1(str):Extended description of kwarg1 kwarg2 ( int ) : Extended description of kwarg2""", """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param arg2: Extended description of arg2 :type arg2: `int` :keyword kwarg1: Extended description of kwarg1 :type kwarg1: `str` :keyword kwarg2: Extended description of kwarg2 :type kwarg2: `int` """ ), ( """ Single line summary Return: str:Extended description of return value """, """ Single line summary :returns: Extended description of return value :returntype: `str` """ ), ( """ If no colon is detected in the return clause, then the text is treated as the description. Returns: ThisIsNotATypeEvenIfItLooksLike """, """ If no colon is detected in the return clause, then the text is treated as the description. :returns: ThisIsNotATypeEvenIfItLooksLike """), ( """ If a colon is detected in the return clause, then the text is treated as the type. Returns: ThisIsAType: """, """ If a colon is detected in the return clause, then the text is treated as the type. :returntype: `ThisIsAType` """), ( """ Left part of the colon will be considered as the type even if it's actually free form text. Returns: Extended type: of something. """, """ Left part of the colon will be considered as the type even if it's actually free form text. :returns: of something. :returntype: Extended type """), ( """ Idem Returns: Extended type: """, """ Idem :returntype: Extended type """), ( """ Single line summary Returns: str:Extended description of return value """, """ Single line summary :returns: Extended description of return value :returntype: `str` """ ), ( """ Single line summary Returns: Extended description of return value """, """ Single line summary :returns: Extended description of return value """ ), ( """ Single line summary Args: arg1(str):Extended description of arg1 *args: Variable length argument list. **kwargs: Arbitrary keyword arguments. """, r""" Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param \*args: Variable length argument list. :param \*\*kwargs: Arbitrary keyword arguments. """ ), ( """ Single line summary Args: arg1 (list(int)): Description arg2 (list[int]): Description arg3 (dict(str, int)): Description arg4 (dict[str, int]): Description """, r""" Single line summary :param arg1: Description :type arg1: `list`\ (`int`) :param arg2: Description :type arg2: `list`\ [`int`] :param arg3: Description :type arg3: `dict`\ (`str`, `int`) :param arg4: Description :type arg4: `dict`\ [`str`, `int`] """ ), ( """ Single line summary Receive: arg1 (list(int)): Description arg2 (list[int]): Description """, r""" Single line summary :param arg1: Description :type arg1: `list`\ (`int`) :param arg2: Description :type arg2: `list`\ [`int`] """ ), ( """ Single line summary Receives: arg1 (list(int)): Description arg2 (list[int]): Description """, r""" Single line summary :param arg1: Description :type arg1: `list`\ (`int`) :param arg2: Description :type arg2: `list`\ [`int`] """ ), ( """ Single line summary Yield: str:Extended description of yielded value """, """ Single line summary :yields: Extended description of yielded value :yieldtype: `str` """ ), ( """ Single line summary Yields: Extended description of yielded value """, """ Single line summary :yields: Extended description of yielded value """ ), ( """ Single line summary Args: arg1 (list(int)): desc arg1. arg2 (list[int]): desc arg2. """, r""" Single line summary :param arg1: desc arg1. :type arg1: `list`\ (`int`) :param arg2: desc arg2. :type arg2: `list`\ [`int`] """ ),( """ Single line summary Args: my first argument (list(int)): desc arg1. my second argument (list[int]): desc arg2. """, r""" Single line summary :param my first argument: desc arg1. :type my first argument: `list`\ (`int`) :param my second argument: desc arg2. :type my second argument: `list`\ [`int`] """ ), (""" Single line summary Usage: import stuff stuff.do() """, # nothing special about the headings that are not recognized as a section """ Single line summary Usage: import stuff stuff.do()"""),( """ Single line summary Todo: stuff """, """ Single line summary .. admonition:: Todo stuff """ ),( """ Single line summary Todo: """, """ Single line summary Todo: """),(""" Single line summary References: stuff """, """ Single line summary .. admonition:: References stuff """),(""" Single line summary See also: my thing """, """ Single line summary .. seealso:: my thing """)] def test_docstrings(self): for docstring, expected in self.docstrings: actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) if not 'Yield' in docstring and not 'Todo' in docstring: # The yield and todo sections are very different from sphinx's. self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_returns_section_type_only(self): docstring=""" Single line summary Returns: str: """ # See issue https://github.com/sphinx-doc/sphinx/issues/9932 expected=""" Single line summary :returntype: `str` """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.strip(), actual.strip()) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) docstring=""" Single line summary Returns: str """ expected=""" Single line summary :returns: str """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.strip(), actual.strip()) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_sphinx_admonitions(self): admonition_map = { 'Attention': 'attention', 'Caution': 'caution', 'Danger': 'danger', 'Error': 'error', 'Hint': 'hint', 'Important': 'important', 'Note': 'note', 'Tip': 'tip', 'Warning': 'warning', 'Warnings': 'warning', } for section, admonition in admonition_map.items(): # Multiline actual = str(GoogleDocstring(("{}:\n" " this is the first line\n" "\n" " and this is the second line\n" ).format(section))) expect = (".. {}::\n" "\n" " this is the first line\n" " \n" " and this is the second line\n" ).format(admonition) self.assertEqual(expect.rstrip(), actual) # Single line actual = str(GoogleDocstring(("{}:\n" " this is a single line\n" ).format(section))) expect = (".. {}:: this is a single line\n" ).format(admonition) self.assertEqual(expect.rstrip(), actual) def test_parameters_with_class_reference(self): # mot sure why this test include back slash in the type spec... # users should not write type like that in pydoctor anyway. docstring = r"""Construct a new XBlock. This class should only be used by runtimes. Arguments: runtime (:class:`~typing.Dict`[:class:`int`, :class:`str`]): Use it to access the environment. It is available in XBlock code as ``self.runtime``. field_data (:class:`FieldData`): Interface used by the XBlock fields to access their data from wherever it is persisted. scope_ids (:class:`ScopeIds`): Identifiers needed to resolve scopes. """ actual = str(GoogleDocstring(docstring)) expected = r"""Construct a new XBlock. This class should only be used by runtimes. :param runtime: Use it to access the environment. It is available in XBlock code as ``self.runtime``. :type runtime: :class:`~typing.Dict`\ [:class:`int`, :class:`str`] :param field_data: Interface used by the XBlock fields to access their data from wherever it is persisted. :type field_data: :class:`FieldData` :param scope_ids: Identifiers needed to resolve scopes. :type scope_ids: :class:`ScopeIds` """ self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_attributes_with_class_reference(self): docstring = """\ Attributes: in_attr(:class:`numpy.ndarray`): super-dooper attribute """ actual = str(GoogleDocstring(docstring)) expected = """\ :ivar in_attr: super-dooper attribute :type in_attr: :class:`numpy.ndarray` """ self.assertEqual(expected.rstrip(), actual) docstring = """\ Attributes: in_attr(numpy.ndarray): super-dooper attribute """ actual = str(GoogleDocstring(docstring)) expected = """\ :ivar in_attr: super-dooper attribute :type in_attr: `numpy.ndarray` """ self.assertEqual(expected.rstrip(), actual) def test_code_block_in_returns_section(self): docstring = """ Returns: foobar: foo:: codecode codecode """ expected = """ :returns: foo:: codecode codecode :returntype: `foobar` """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_colon_in_return_type(self): docstring = """Example property. Returns: :py:class:`~.module.submodule.SomeClass`: an example instance if available, None if not available. """ expected = """Example property. :returns: an example instance if available, None if not available. :returntype: :py:class:`~.module.submodule.SomeClass` """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_xrefs_in_return_type(self): docstring = """Example Function Returns: :class:`numpy.ndarray`: A :math:`n \\times 2` array containing a bunch of math items """ expected = """Example Function :returns: A :math:`n \\times 2` array containing a bunch of math items :returntype: :class:`numpy.ndarray` """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_raises_types(self): docstrings = [(""" Example Function Raises: RuntimeError: A setting wasn't specified, or was invalid. ValueError: Something something value error. :py:class:`AttributeError` errors for missing attributes. ~InvalidDimensionsError If the dimensions couldn't be parsed. `InvalidArgumentsError` If the arguments are invalid. :exc:`~ValueError` If the arguments are wrong. """, """ Example Function :raises RuntimeError: A setting wasn't specified, or was invalid. :raises ValueError: Something something value error. :raises AttributeError: errors for missing attributes. :raises ~InvalidDimensionsError: If the dimensions couldn't be parsed. :raises InvalidArgumentsError: If the arguments are invalid. :raises ~ValueError: If the arguments are wrong. """), ################################ (""" Example Function Raises: InvalidDimensionsError """, """ Example Function :raises InvalidDimensionsError: """), ################################ (""" Example Function Raises: Invalid Dimensions Error """, """ Example Function :raises Invalid Dimensions Error: """), ################################ (""" Example Function Raises: Invalid Dimensions Error: With description """, """ Example Function :raises Invalid Dimensions Error: With description """), ################################ (""" Example Function Raises: InvalidDimensionsError: If the dimensions couldn't be parsed. """, """ Example Function :raises InvalidDimensionsError: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises: Invalid Dimensions Error: If the dimensions couldn't be parsed. """, """ Example Function :raises Invalid Dimensions Error: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises: If the dimensions couldn't be parsed. """, """ Example Function :raises If the dimensions couldn't be parsed.: """), ################################ (""" Example Function Raises: :class:`exc.InvalidDimensionsError` """, """ Example Function :raises exc.InvalidDimensionsError: """), ################################ (""" Example Function Raises: :class:`exc.InvalidDimensionsError`: If the dimensions couldn't be parsed. """, """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises: :class:`exc.InvalidDimensionsError`: If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. """, """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. """), ################################ (""" Example Function Raises: :class:`exc.InvalidDimensionsError`: If the dimensions couldn't be parsed. :class:`exc.InvalidArgumentsError`: If the arguments are invalid. """, """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. :raises exc.InvalidArgumentsError: If the arguments are invalid. """), ################################ (""" Example Function Raises: :class:`exc.InvalidDimensionsError` :class:`exc.InvalidArgumentsError` """, """ Example Function :raises exc.InvalidDimensionsError: :raises exc.InvalidArgumentsError: """)] for docstring, expected in docstrings: actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_kwargs_in_arguments(self): docstring = """Allows to create attributes binded to this device. Some other paragraph. Code sample for usage:: dev.bind(loopback=Loopback) dev.loopback.configure() Arguments: **kwargs: name/class pairs that will create resource-managers bound as instance attributes to this instance. See code example above. """ expected = """Allows to create attributes binded to this device. Some other paragraph. Code sample for usage:: dev.bind(loopback=Loopback) dev.loopback.configure() :param \\*\\*kwargs: name/class pairs that will create resource-managers bound as instance attributes to this instance. See code example above. """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_section_header_formatting(self): docstrings = [(""" Summary line Example: Multiline reStructuredText literal code block """, """ Summary line .. admonition:: Example Multiline reStructuredText literal code block """), ################################ (""" Summary line Example:: Multiline reStructuredText literal code block """, """ Summary line Example:: Multiline reStructuredText literal code block """), ################################ (""" Summary line :Example: Multiline reStructuredText literal code block """, """ Summary line :Example: Multiline reStructuredText literal code block """)] for docstring, expected in docstrings: actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_list_in_parameter_description(self): docstring = """One line summary. Parameters: no_list (int): one_bullet_empty (int): * one_bullet_single_line (int): - first line one_bullet_two_lines (int): + first line continued two_bullets_single_line (int): - first line - second line two_bullets_two_lines (int): * first line continued * second line continued one_enumeration_single_line (int): 1. first line one_enumeration_two_lines (int): 1) first line continued two_enumerations_one_line (int): (iii) first line (iv) second line two_enumerations_two_lines (int): a. first line continued b. second line continued one_definition_one_line (int): item 1 first line one_definition_two_lines (int): item 1 first line continued two_definitions_one_line (int): item 1 first line item 2 second line two_definitions_two_lines (int): item 1 first line continued item 2 second line continued one_definition_blank_line (int): item 1 first line extra first line two_definitions_blank_lines (int): item 1 first line extra first line item 2 second line extra second line definition_after_inline_text (int): text line item 1 first line definition_after_normal_text (int): text line item 1 first line """ expected = """One line summary. :param no_list: :type no_list: `int` :param one_bullet_empty: * :type one_bullet_empty: `int` :param one_bullet_single_line: - first line :type one_bullet_single_line: `int` :param one_bullet_two_lines: + first line continued :type one_bullet_two_lines: `int` :param two_bullets_single_line: - first line - second line :type two_bullets_single_line: `int` :param two_bullets_two_lines: * first line continued * second line continued :type two_bullets_two_lines: `int` :param one_enumeration_single_line: 1. first line :type one_enumeration_single_line: `int` :param one_enumeration_two_lines: 1) first line continued :type one_enumeration_two_lines: `int` :param two_enumerations_one_line: (iii) first line (iv) second line :type two_enumerations_one_line: `int` :param two_enumerations_two_lines: a. first line continued b. second line continued :type two_enumerations_two_lines: `int` :param one_definition_one_line: item 1 first line :type one_definition_one_line: `int` :param one_definition_two_lines: item 1 first line continued :type one_definition_two_lines: `int` :param two_definitions_one_line: item 1 first line item 2 second line :type two_definitions_one_line: `int` :param two_definitions_two_lines: item 1 first line continued item 2 second line continued :type two_definitions_two_lines: `int` :param one_definition_blank_line: item 1 first line extra first line :type one_definition_blank_line: `int` :param two_definitions_blank_lines: item 1 first line extra first line item 2 second line extra second line :type two_definitions_blank_lines: `int` :param definition_after_inline_text: text line item 1 first line :type definition_after_inline_text: `int` :param definition_after_normal_text: text line item 1 first line :type definition_after_normal_text: `int` """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_attr_with_method(self): docstring = """ Attributes: arg : description Methods: func(abc, def): description """ expected = r""" :ivar arg: description .. admonition:: Methods `func`\ (`abc`, `def`) description """ # NOQA actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_return_formatting_indentation(self): docstring = """ Returns: bool: True if successful, False otherwise. The return type is optional and may be specified at the beginning of the ``Returns`` section followed by a colon. The ``Returns`` section may span multiple lines and paragraphs. Following lines should be indented to match the first line. The ``Returns`` section supports any reStructuredText formatting, including literal blocks:: { 'param1': param1, 'param2': param2 } """ expected = """ :returns: True if successful, False otherwise. The return type is optional and may be specified at the beginning of the ``Returns`` section followed by a colon. The ``Returns`` section may span multiple lines and paragraphs. Following lines should be indented to match the first line. The ``Returns`` section supports any reStructuredText formatting, including literal blocks:: { 'param1': param1, 'param2': param2 } :returntype: `bool` """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) def test_column_summary_lines_sphinx_issue_4016(self): # test https://github.com/sphinx-doc/sphinx/issues/4016 docstring = """Get time formated as ``HH:MM:SS``.""" expected = """Get time formated as ``HH:MM:SS``.""" actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) actual = str(GoogleDocstring(docstring, what='attribute')) self.assertEqual(expected.rstrip(), actual) docstring2 = """Put *key* and *value* into a dictionary. Returns: A dictionary ``{key: value}`` """ expected2 = """Put *key* and *value* into a dictionary. :returns: A dictionary ``{key: value}`` """ actual = str(GoogleDocstring(docstring2)) self.assertEqual(expected2.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected2, docstring2, type_=SphinxGoogleDocstring) actual = str(GoogleDocstring(docstring2, what='attribute')) self.assertEqual(expected2.rstrip(), actual) def test_multiline_types(self): # Real life example from # https://googleapis.github.io/google-api-python-client/docs/epy/index.html docstring = """ Scopes the credentials if necessary. Args: credentials (Union[ google.auth.credentials.Credentials, oauth2client.client.Credentials]): The credentials to scope. scopes (Sequence[str]): The list of scopes. errors (Sequence[Union[ParseError, ParseWarning, ParseInfo, ...]]): The list of errors, warnings or other informations. Returns: Union[google.auth.credentials.Credentials, oauth2client.client.Credentials]: The scoped credentials. """ expected = r""" Scopes the credentials if necessary. :param credentials: The credentials to scope. :type credentials: `Union`\ [`google.auth.credentials.Credentials`, `oauth2client.client.Credentials`] :param scopes: The list of scopes. :type scopes: `Sequence`\ [`str`] :param errors: The list of errors, warnings or other informations. :type errors: `Sequence`\ [`Union`\ [`ParseError`, `ParseWarning`, `ParseInfo`, `...`]] :returns: The scoped credentials. :returntype: `Union`\ [`google.auth.credentials.Credentials`, `oauth2client.client.Credentials`] """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_multiline_types_invalid_log_warning(self): # test robustness with invalid arg syntax + log warning docstring = """ Description... Args: docformat Can be one of: - "numpy" - "google" scopes (Sequence[str]): The list of scopes. """ expected = r""" Description... :param docformat: Can be one of: - "numpy" - "google" :param scopes: The list of scopes. :type scopes: `Sequence`\ [`str`] """ doc = GoogleDocstring(docstring) actual = str(doc) self.assertEqual(expected.rstrip(), actual) self.assertEqual(1, len(doc.warnings)) warning = doc.warnings.pop() self.assertIn("invalid type: 'docformatCan be one of'", warning[0]) self.assertEqual(5, warning[1]) docstring = """ Description... Args: docformat (Can be "numpy" or "google"): Desc scopes (Sequence[str]): The list of scopes. """ expected = r""" Description... :param docformat (Can be "numpy": or "google"): Desc :param scopes: The list of scopes. :type scopes: `Sequence`\ [`str`] """ doc = GoogleDocstring(docstring) actual = str(doc) self.assertEqual(expected.rstrip(), actual) self.assertEqual(1, len(doc.warnings)) warning = doc.warnings.pop() self.assertIn("invalid type: 'docformat (Can be \"numpy\"or \"google\")'", warning[0]) self.assertEqual(5, warning[1]) class NumpyDocstringTest(BaseDocstringTest): docstrings = [( """Single line summary""", """Single line summary""" ), ( """ Single line summary Extended description """, """ Single line summary Extended description """ ), ( """ Single line summary Parameters ---------- arg1:str Extended description of arg1 """, """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` """ ), ( """ Single line summary Parameters ---------- arg1:str Extended description of arg1 arg2 : int Extended description of arg2 Keyword Arguments ----------------- kwarg1:str Extended description of kwarg1 kwarg2 : int Extended description of kwarg2 """, """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param arg2: Extended description of arg2 :type arg2: `int` :keyword kwarg1: Extended description of kwarg1 :type kwarg1: `str` :keyword kwarg2: Extended description of kwarg2 :type kwarg2: `int` """ ), ( """ Single line summary Return ------ str Extended description of return value """, """ Single line summary :returns: Extended description of return value :returntype: `str` """ ),( """ Single line summary Return ------ the string of your life: str """, """ Single line summary :returns: **the string of your life** :returntype: `str` """ ),( """ Single line summary Return ------ """, """ Single line summary """ ), ( """ Single line summary Returns ------- str Extended description of return value """, """ Single line summary :returns: Extended description of return value :returntype: `str` """ ), ( """ Single line summary Parameters ---------- arg1:str Extended description of arg1 *args: Variable length argument list. **kwargs: Arbitrary keyword arguments. """, """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param \\*args: Variable length argument list. :param \\*\\*kwargs: Arbitrary keyword arguments. """ ), ( """ Single line summary Parameters ---------- arg1:str Extended description of arg1 *args, **kwargs: Variable length argument list and arbitrary keyword arguments. """, """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param \\*args: Variable length argument list and arbitrary keyword arguments. :param \\*\\*kwargs: Variable length argument list and arbitrary keyword arguments. """ ), ( """ Single line summary Receive ------- arg1:str Extended description of arg1 arg2 : int Extended description of arg2 """, """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param arg2: Extended description of arg2 :type arg2: `int` """ ), ( """ Single line summary Receives -------- arg1:str Extended description of arg1 arg2 : int Extended description of arg2 """, """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param arg2: Extended description of arg2 :type arg2: `int` """ ), ( """ Single line summary Yield ----- str Extended description of yielded value """, """ Single line summary :yields: Extended description of yielded value :yieldtype: `str` """ ), ( """ Single line summary Yields ------ str Extended description of yielded value """, """ Single line summary :yields: Extended description of yielded value :yieldtype: `str` """ ), (""" Derived from the NumpyDoc implementation of _parse_see_also:: See Also -------- func_name : Descriptive text continued text another_func_name : Descriptive text func_name1, func_name2, :meth:`func_name`, func_name3 """, """ Derived from the NumpyDoc implementation of _parse_see_also:: See Also -------- func_name : Descriptive text continued text another_func_name : Descriptive text func_name1, func_name2, :meth:`func_name`, func_name3 """),( """ Single line summary Args ---- my first argument: list(int) desc arg1. my second argument: list[int] desc arg2. """, r""" Single line summary :param my first argument: desc arg1. :type my first argument: `list`\ (`int`) :param my second argument: desc arg2. :type my second argument: `list`\ [`int`] """),(""" Single line summary Usage ----- import stuff stuff.do() """, """ Single line summary Usage ----- import stuff stuff.do() """), (""" Single line summary Generic admonition ------------------ """, # nothing special about the headings that are not recognized as a section """ Single line summary Generic admonition ------------------ """),( """ Single line summary Todo ---- stuff """, """ Single line summary .. admonition:: Todo stuff """),( """ Single line summary Todo ---- """, """ Single line summary .. admonition:: Todo """) ,( """ Single line summary References ---------- stuff """, """ Single line summary .. admonition:: References stuff """) ] def test_docstrings(self): for docstring, expected in self.docstrings: actual = str(NumpyDocstring(dedent(docstring))) expected = dedent(expected) self.assertEqual(actual, expected.rstrip()) if not 'Yield' in docstring and not 'Todo' in docstring: # The yield and todo sections are very different from sphinx's. self.assertAlmostEqualSphinxDocstring(expected, dedent(docstring), type_=SphinxNumpyDocstring) def test_sphinx_admonitions(self): admonition_map = { 'Attention': 'attention', 'Caution': 'caution', 'Danger': 'danger', 'Error': 'error', 'Hint': 'hint', 'Important': 'important', 'Note': 'note', 'Tip': 'tip', 'Warning': 'warning', 'Warnings': 'warning', } for section, admonition in admonition_map.items(): # Multiline actual = str(NumpyDocstring(("{}\n" "{}\n" " this is the first line\n" "\n" " and this is the second line\n" ).format(section, '-' * len(section)))) expected = (".. {}::\n" "\n" " this is the first line\n" " \n" " and this is the second line\n" ).format(admonition) self.assertEqual(expected.rstrip(), actual) # Single line actual = str(NumpyDocstring(("{}\n" "{}\n" " this is a single line\n" ).format(section, '-' * len(section)))) expected = (".. {}:: this is a single line\n" ).format(admonition) self.assertEqual(expected.rstrip(), actual) def test_parameters_with_class_reference(self): docstring = """\ Parameters ---------- param1 : :class:`MyClass ` instance """ actual = str(NumpyDocstring(docstring)) expected = """\ :param param1: :type param1: :class:`MyClass ` instance """ self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_multiple_parameters(self): docstring = """\ Parameters ---------- x1, x2 : array_like Input arrays, description of ``x1``, ``x2``. """ actual = str(NumpyDocstring(dedent(docstring))) expected = """\ :param x1: Input arrays, description of ``x1``, ``x2``. :type x1: `array_like` :param x2: Input arrays, description of ``x1``, ``x2``. :type x2: `array_like` """ self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_parameters_without_class_reference(self): docstring = """\ Parameters ---------- param1 : MyClass instance """ actual = str(NumpyDocstring(dedent(docstring))) expected = """\ :param param1: :type param1: MyClass instance """ self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_parameter_types(self): docstring = dedent("""\ Parameters ---------- param1 : DataFrame the data to work on param2 : int or float or None, optional a parameter with different types param3 : dict-like, optional a optional mapping param4 : int or float or None, optional a optional parameter with different types param5 : {"F", "C", "N"}, optional a optional parameter with fixed values param6 : int, default None different default format param7 : mapping of hashable to str, optional a optional mapping param8 : ... or Ellipsis ellipsis """) expected = dedent("""\ :param param1: the data to work on :type param1: `DataFrame` :param param2: a parameter with different types :type param2: `int` or `float` or `None`, *optional* :param param3: a optional mapping :type param3: `dict-like`, *optional* :param param4: a optional parameter with different types :type param4: `int` or `float` or `None`, *optional* :param param5: a optional parameter with fixed values :type param5: ``{"F", "C", "N"}``, *optional* :param param6: different default format :type param6: `int`, *default* `None` :param param7: a optional mapping :type param7: `mapping` of `hashable` to `str`, *optional* :param param8: ellipsis :type param8: `...` or `Ellipsis` """) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_see_also_refs_invalid(self): docstring = """\ See Also -------- $funcs 123 """ expected = """\ .. seealso:: $funcs 123 """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_see_also_refs(self): docstring = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) See Also -------- some, other, funcs otherfunc : relationship """ docstring2 = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) See Also -------- some, other, :func:`funcs` otherfunc : relationship """ expected = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) .. seealso:: `some`, `other`, `funcs` `otherfunc` relationship """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring2))) self.assertAlmostEqualSphinxDocstring(expected, docstring2, type_=SphinxNumpyDocstring) docstring = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) See Also -------- some, other, funcs otherfunc : relationship """ expected = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) .. seealso:: `some`, `other`, `funcs` `otherfunc` relationship """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) docstring = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) See Also -------- some, other, :func:`funcs` otherfunc : relationship """ expected = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) .. seealso:: `some`, `other`, `funcs` `otherfunc` relationship """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_colon_in_return_type(self): docstring = """ Summary Returns ------- :py:class:`~my_mod.my_class` an instance of :py:class:`~my_mod.my_class` """ expected = """ Summary :returns: an instance of :py:class:`~my_mod.my_class` :returntype: :py:class:`~my_mod.my_class` """ actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_underscore_in_attribute(self): docstring = """ Attributes ---------- arg_ : type some description """ expected = """ :ivar arg_: some description :type arg_: `type` """ actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_return_types(self): docstring = dedent(""" Returns ------- pandas.DataFrame a dataframe """) expected = dedent(""" :returns: a dataframe :returntype: `pandas.DataFrame` """) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_yield_types(self): docstring = dedent(""" Example Function Yields ------ scalar or array-like The result of the computation """) expected = dedent(""" Example Function :yields: The result of the computation :yieldtype: `scalar` or `array-like` """) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_raises_types(self): docstrings = [(""" Example Function Raises ------ RuntimeError A setting wasn't specified, or was invalid. ValueError Something something value error. """, """ Example Function :raises RuntimeError: A setting wasn't specified, or was invalid. :raises ValueError: Something something value error. """), ################################ (""" Example Function Raises ------ InvalidDimensionsError """, """ Example Function :raises InvalidDimensionsError: """), ################################ (""" Example Function Raises ------ Invalid Dimensions Error """, """ Example Function :raises Invalid Dimensions Error: """), ################################ (""" Example Function Raises ------ Invalid Dimensions Error With description """, """ Example Function :raises Invalid Dimensions Error: With description """), ################################ (""" Example Function Raises ------ InvalidDimensionsError If the dimensions couldn't be parsed. """, """ Example Function :raises InvalidDimensionsError: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises ------ Invalid Dimensions Error If the dimensions couldn't be parsed. """, """ Example Function :raises Invalid Dimensions Error: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises ------ If the dimensions couldn't be parsed. """, """ Example Function :raises If the dimensions couldn't be parsed.: """), ################################ (""" Example Function Raises ------ :class:`exc.InvalidDimensionsError` """, """ Example Function :raises exc.InvalidDimensionsError: """), ################################ (""" Example Function Raises ------ :class:`exc.InvalidDimensionsError` If the dimensions couldn't be parsed. """, """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises ------ :class:`exc.InvalidDimensionsError` If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. """, """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. """), ################################ (""" Example Function Raises ------ :class:`exc.InvalidDimensionsError` If the dimensions couldn't be parsed. :class:`exc.InvalidArgumentsError` If the arguments are invalid. """, """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. :raises exc.InvalidArgumentsError: If the arguments are invalid. """), ################################ (""" Example Function Raises ------ CustomError If the dimensions couldn't be parsed. """, """ Example Function :raises CustomError: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises ------ AnotherError If the dimensions couldn't be parsed. """, """ Example Function :raises AnotherError: If the dimensions couldn't be parsed. """), ################################ (""" Example Function Raises ------ :class:`exc.InvalidDimensionsError` :class:`exc.InvalidArgumentsError` """, """ Example Function :raises exc.InvalidDimensionsError: :raises exc.InvalidArgumentsError: """)] for docstring, expected in docstrings: actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_xrefs_in_return_type(self): docstring = """ Example Function Returns ------- :class:`numpy.ndarray` A :math:`n \\times 2` array containing a bunch of math items """ expected = """ Example Function :returns: A :math:`n \\times 2` array containing a bunch of math items :returntype: :class:`numpy.ndarray` """ actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_section_header_underline_length(self): docstrings = [(""" Summary line Example - Multiline example body """, """ Summary line Example - Multiline example body """), ################################ (""" Summary line Example -- Multiline example body """, """ Summary line .. admonition:: Example Multiline example body """), ################################ (""" Summary line Example ------- Multiline example body """, """ Summary line .. admonition:: Example Multiline example body """), ################################ (""" Summary line Example ------------ Multiline example body """, """ Summary line .. admonition:: Example Multiline example body """)] for docstring, expected in docstrings: actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_list_in_parameter_description(self): docstring = """One line summary. Parameters ---------- no_list : int one_bullet_empty : int * one_bullet_single_line : int - first line one_bullet_two_lines : int + first line continued two_bullets_single_line : int - first line - second line two_bullets_two_lines : int * first line continued * second line continued one_enumeration_single_line : int 1. first line one_enumeration_two_lines : int 1) first line continued two_enumerations_one_line : int (iii) first line (iv) second line two_enumerations_two_lines : int a. first line continued b. second line continued one_definition_one_line : int item 1 first line one_definition_two_lines : int item 1 first line continued two_definitions_one_line : int item 1 first line item 2 second line two_definitions_two_lines : int item 1 first line continued item 2 second line continued one_definition_blank_line : int item 1 first line extra first line two_definitions_blank_lines : int item 1 first line extra first line item 2 second line extra second line definition_after_normal_text : int text line item 1 first line """ expected = """One line summary. :param no_list: :type no_list: `int` :param one_bullet_empty: * :type one_bullet_empty: `int` :param one_bullet_single_line: - first line :type one_bullet_single_line: `int` :param one_bullet_two_lines: + first line continued :type one_bullet_two_lines: `int` :param two_bullets_single_line: - first line - second line :type two_bullets_single_line: `int` :param two_bullets_two_lines: * first line continued * second line continued :type two_bullets_two_lines: `int` :param one_enumeration_single_line: 1. first line :type one_enumeration_single_line: `int` :param one_enumeration_two_lines: 1) first line continued :type one_enumeration_two_lines: `int` :param two_enumerations_one_line: (iii) first line (iv) second line :type two_enumerations_one_line: `int` :param two_enumerations_two_lines: a. first line continued b. second line continued :type two_enumerations_two_lines: `int` :param one_definition_one_line: item 1 first line :type one_definition_one_line: `int` :param one_definition_two_lines: item 1 first line continued :type one_definition_two_lines: `int` :param two_definitions_one_line: item 1 first line item 2 second line :type two_definitions_one_line: `int` :param two_definitions_two_lines: item 1 first line continued item 2 second line continued :type two_definitions_two_lines: `int` :param one_definition_blank_line: item 1 first line extra first line :type one_definition_blank_line: `int` :param two_definitions_blank_lines: item 1 first line extra first line item 2 second line extra second line :type two_definitions_blank_lines: `int` :param definition_after_normal_text: text line item 1 first line :type definition_after_normal_text: `int` """ actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) def test_docstring_token_type_invalid_warnings_with_linenum(self): docstring = """ Description of the function. Args ---- param1: {1,2 param2: } param3: 'abc param4: def' Returns ------- list of int """ errors = ( r"invalid value set \(missing closing brace\):", r"invalid value set \(missing opening brace\):", r"malformed string literal \(missing closing quote\):", r"malformed string literal \(missing opening quote\):", ) numpy_docstring = NumpyDocstring(docstring) numpy_warnings = numpy_docstring.warnings self.assertEqual(len(numpy_warnings), 4, numpy_warnings) for i, error in enumerate(errors): warn = numpy_warnings.pop(0) match_re = re.compile(error) self.assertTrue(bool(match_re.match(warn[0])), f"{error} \n do not match \n {warn[0]}") self.assertEqual(i+6, warn[1], msg=f"msg={warn[0]}, docstring='{str(numpy_docstring)}'") # FIXME: The offset should be 5 actually, no big deal and it looks like an really painful issue to # fix due to the fact that the changes in the docstring line numbers are happening at the level of napoleon. # name, expected escape_kwargs_tests_cases = [("x, y, z", "x, y, z"), ("*args, **kwargs", r"\*args, \*\*kwargs"), ("*x, **y", r"\*x, \*\*y") ] def test_escape_args_and_kwargs(self): for name, expected in self.escape_kwargs_tests_cases: numpy_docstring = NumpyDocstring("") actual = numpy_docstring._escape_args_and_kwargs(name) assert actual == expected # test docstrings for the free form text in the return secion. # this feature is always enabled # see https://github.com/sphinx-doc/sphinx/issues/7077 docstrings_returns = [( """ Single line summary Return ------ the string of your life: `a complicated string` the strings of your life: list of `complicated string` or str, default: ["you", "me"] the str of your life: {"foo", "bob", "bar"} the int of your life: int the tuple of your life: tuple """, """ Single line summary :returns: * **the string of your life**: `a complicated string` * **the strings of your life**: `list` of `complicated string` or `str`, *default*: [``"you"``, ``"me"``] * **the str of your life**: ``{"foo", "bob", "bar"}`` * **the int of your life**: `int` * **the tuple of your life**: `tuple` """ ), (""" Summary line. Returns ------- list of strings Sequence of arguments, in the order in which they should be called. """, """ Summary line. :returns: Sequence of arguments, in the order in which they should be called. :returntype: `list` of `strings` """), (""" Summary line. Returns ------- Sequence of arguments, in the order in which they should be called. """, """ Summary line. :returns: Sequence of arguments, in the order in which they should be called. """), (""" Summary line. Returns ------- str """, """ Summary line. :returntype: `str` """),( """ Summary line. Returns ------- str A URL string """, """ Summary line. :returns: A URL string :returntype: `str` """ ), ( """ Summary line. Returns ------- a string, can you believe it? """, """ Summary line. :returns: a string, can you believe it? """ ),( """ Single line summary Return ------ the string of your life """, """ Single line summary :returns: the string of your life """ ) ,( """ Summary line. Returns ------- a string, can you believe it? Raises -- UserError """, """ Summary line. :returns: a string, can you believe it? :raises UserError: """ ),( """ Summary line. Returns ------- str Raises -- UserError Warns --- RuntimeWarning """, """ Summary line. :returntype: `str` :raises UserError: :warns: RuntimeWarning """ ),( """ Summary line. Returns ------- str Description of return value Raises -- UserError Description of raised exception Warns -------- RuntimeWarning Description of raised warnings """, """ Summary line. :returns: Description of return value :returntype: `str` :raises UserError: Description of raised exception :warns RuntimeWarning: Description of raised warnings """ ), ( """ Summary line. Returns ------- list(str) The lines of the docstring in a list. Note ---- Nested markup works. """, r""" Summary line. :returns: The lines of the docstring in a list. .. note:: Nested markup works. :returntype: `list`\ (`str`) """ ), ( """ Summary line. Returns ------- List[str] The lines of the docstring in a list. Note ---- Nested markup works. """, r""" Summary line. :returns: The lines of the docstring in a list. .. note:: Nested markup works. :returntype: `List`\ [`str`] """ ), ( """ Summary line. Methods ------- __str__() The lines of the docstring in a list. Note ---- Nested markup works. """, """ Summary line. .. admonition:: Methods `__str__`() The lines of the docstring in a list. .. note:: Nested markup works. """ ), ( """ Single line summary Return ------ a complicated string Extended description of return value int Extended description of return value the tuple of your life: tuple Extended description of return value """, """ Single line summary :returns: * a complicated string - Extended description of return value * `int` - Extended description of return value * **the tuple of your life**: `tuple` - Extended description of return value """ ),] # https://github.com/sphinx-contrib/napoleon/issues/12 # https://github.com/sphinx-doc/sphinx/issues/7077 def test_return_no_type_sphinx_issue_7077(self): for docstring, expected in self.docstrings_returns: actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_return_type_annotation_style(self): docstring = dedent(""" Summary line. Returns ------- List[Union[str, bytes, typing.Pattern]] """) expected = dedent(r""" Summary line. :returntype: `List`\ [`Union`\ [`str`, `bytes`, `typing.Pattern`]] """) actual = str(NumpyDocstring(docstring, )) self.assertEqual(expected.rstrip(), actual) def test_issue_with_link_end_of_section(self): # section breaks needs two white spaces with numpy-style docstrings, # even if footnotes are following-up docstring = """`PEP 484`_ type annotations are supported. Returns ------- bool True if successful, False otherwise. .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ """ expected = """`PEP 484`_ type annotations are supported. :returns: True if successful, False otherwise. :returntype: `bool` .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ """ actual = str(NumpyDocstring(docstring, )) self.assertEqual(expected.rstrip(), actual, str(actual)) self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxNumpyDocstring) # test that Sphinx also cannot parse correctly the docstring # without two blank lines before new section # if no section header is provided bogus = """`PEP 484`_ type annotations are supported. Returns ------- bool True if successful, False otherwise. .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ """ expected_bogus = """`PEP 484`_ type annotations are supported. :returns: * `bool` - True if successful, False otherwise. * .. _PEP 484 - https://www.python.org/dev/peps/pep-0484/ """ actual = str(NumpyDocstring(bogus, )) self.assertEqual(expected_bogus.rstrip(), actual, str(actual)) # test that we have the same interpretation with sphinx self.assertAlmostEqualSphinxDocstring(str(NumpyDocstring(bogus, )), bogus, type_=SphinxNumpyDocstring) def test_return_type_list_free_style_do_desc(self): docstring = dedent(""" Return ------ the list of your life: list of str the str of your life: {"foo", "bob", "bar"} the int of your life: int the tuple of your life: tuple """) expected = dedent(""" :returns: * **the list of your life**: `list` of `str` * **the str of your life**: ``{"foo", "bob", "bar"}`` * **the int of your life**: `int` * **the tuple of your life**: `tuple` """) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) docstring = dedent(""" Yields ------ the list of your life: list of str the str of your life: {"foo", "bob", "bar"} the int of your life: int the tuple of your life: tuple """) expected = dedent(""" :yields: * **the list of your life**: `list` of `str` * **the str of your life**: ``{"foo", "bob", "bar"}`` * **the int of your life**: `int` * **the tuple of your life**: `tuple` """) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_fields_blank_lines(self): """ Test for issue https://github.com/twisted/pydoctor/issues/366 """ docstring = dedent(""" Made my day Parameters ---------- foo: str a string bob: list of str Returns ------- bool: The lines of the docstring in a list. Note ---- Markup works. It is strong Yields ------ tuple(ice, cream) Yes""") expected = dedent(r""" Made my day :param foo: a string :type foo: `str` :param bob: :type bob: `list` of `str` :returns: The lines of the docstring in a list. :returntype: `bool` .. note:: Markup works. It is strong :yields: Yes :yieldtype: `tuple`\ (`ice`, `cream`) """) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_fields_blank_lines_sphinx_upstream(self): """ Test that sphinx napoleon upstream version of NumpyDocstring is actually generating wrong reST text (for now)... """ docstring = dedent(""" Made my day Parameters ---------- foo: str a string bob: list of str Returns ------- bool: The lines of the docstring in a list. Note ---- Markup works. It is strong Yields ------ tuple(ice, cream) Yes""") expected_wrong = dedent(r""" Made my day :param foo: a string :type foo: `str` :param bob: :type bob: `list` of `str` :returns: The lines of the docstring in a list. :returntype: `bool` .. note:: Markup works. It is strong :Yields: `tuple`\ (`ice`, `cream`) - Yes """) self.assertAlmostEqualSphinxDocstring(expected_wrong, docstring, type_=SphinxNumpyDocstring) pydoctor-24.11.2/pydoctor/test/test_napoleon_iterators.py000066400000000000000000000272511473665144200237110ustar00rootroot00000000000000""" Tests for :mod:`pydoctor.napoleon.iterators` module. :: :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. """ __docformat__ = "restructuredtext" from unittest import TestCase from pydoctor.napoleon.iterators import modify_iter, peek_iter class BaseIteratorsTest(TestCase): def assertEqualTwice(self, expected, func, *args): self.assertEqual(expected, func(*args)) self.assertEqual(expected, func(*args)) def assertFalseTwice(self, func, *args): self.assertFalse(func(*args)) self.assertFalse(func(*args)) def assertNext(self, it, expected, is_last): self.assertTrueTwice(it.has_next) self.assertEqualTwice(expected, it.peek) self.assertTrueTwice(it.has_next) self.assertEqualTwice(expected, it.peek) self.assertTrueTwice(it.has_next) self.assertEqual(expected, next(it)) if is_last: self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next) else: self.assertTrueTwice(it.has_next) def assertRaisesTwice(self, exc, func, *args): self.assertRaises(exc, func, *args) self.assertRaises(exc, func, *args) def assertTrueTwice(self, func, *args): self.assertTrue(func(*args)) self.assertTrue(func(*args)) class PeekIterTest(BaseIteratorsTest): def test_init_with_sentinel(self): a = iter(['1', '2', 'DONE']) sentinel = 'DONE' self.assertRaises(TypeError, peek_iter, a, sentinel) def get_next(): return next(a) it = peek_iter(get_next, sentinel) self.assertEqual(it.sentinel, sentinel) self.assertNext(it, '1', is_last=False) self.assertNext(it, '2', is_last=True) def test_iter(self): a = ['1', '2', '3'] it = peek_iter(a) self.assertTrue(it is it.__iter__()) a = [] b = [i for i in peek_iter(a)] self.assertEqual([], b) a = ['1'] b = [i for i in peek_iter(a)] self.assertEqual(['1'], b) a = ['1', '2'] b = [i for i in peek_iter(a)] self.assertEqual(['1', '2'], b) a = ['1', '2', '3'] b = [i for i in peek_iter(a)] self.assertEqual(['1', '2', '3'], b) def test_next_with_multi(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 2) a = ['1'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 2) self.assertTrueTwice(it.has_next) a = ['1', '2'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqual(['1', '2'], it.next(2)) self.assertFalseTwice(it.has_next) a = ['1', '2', '3'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqual(['1', '2'], it.next(2)) self.assertTrueTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 2) self.assertTrueTwice(it.has_next) a = ['1', '2', '3', '4'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqual(['1', '2'], it.next(2)) self.assertTrueTwice(it.has_next) self.assertEqual(['3', '4'], it.next(2)) self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 2) self.assertFalseTwice(it.has_next) def test_next_with_none(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next) self.assertFalseTwice(it.has_next) a = ['1'] it = peek_iter(a) self.assertEqual('1', it.__next__()) a = ['1'] it = peek_iter(a) self.assertNext(it, '1', is_last=True) a = ['1', '2'] it = peek_iter(a) self.assertNext(it, '1', is_last=False) self.assertNext(it, '2', is_last=True) a = ['1', '2', '3'] it = peek_iter(a) self.assertNext(it, '1', is_last=False) self.assertNext(it, '2', is_last=False) self.assertNext(it, '3', is_last=True) def test_next_with_one(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 1) a = ['1'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqual(['1'], it.next(1)) self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 1) a = ['1', '2'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqual(['1'], it.next(1)) self.assertTrueTwice(it.has_next) self.assertEqual(['2'], it.next(1)) self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 1) def test_next_with_zero(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertRaisesTwice(StopIteration, it.next, 0) a = ['1'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.next, 0) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.next, 0) a = ['1', '2'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.next, 0) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.next, 0) def test_peek_with_multi(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertEqualTwice([it.sentinel, it.sentinel], it.peek, 2) self.assertFalseTwice(it.has_next) a = ['1'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', it.sentinel], it.peek, 2) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', it.sentinel, it.sentinel], it.peek, 3) self.assertTrueTwice(it.has_next) a = ['1', '2'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', '2'], it.peek, 2) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', '2', it.sentinel], it.peek, 3) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', '2', it.sentinel, it.sentinel], it.peek, 4) self.assertTrueTwice(it.has_next) a = ['1', '2', '3'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', '2'], it.peek, 2) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', '2', '3'], it.peek, 3) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1', '2', '3', it.sentinel], it.peek, 4) self.assertTrueTwice(it.has_next) self.assertEqual('1', next(it)) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['2', '3'], it.peek, 2) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['2', '3', it.sentinel], it.peek, 3) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['2', '3', it.sentinel, it.sentinel], it.peek, 4) self.assertTrueTwice(it.has_next) def test_peek_with_none(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertEqualTwice(it.sentinel, it.peek) self.assertFalseTwice(it.has_next) a = ['1'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice('1', it.peek) self.assertEqual('1', next(it)) self.assertFalseTwice(it.has_next) self.assertEqualTwice(it.sentinel, it.peek) self.assertFalseTwice(it.has_next) a = ['1', '2'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice('1', it.peek) self.assertEqual('1', next(it)) self.assertTrueTwice(it.has_next) self.assertEqualTwice('2', it.peek) self.assertEqual('2', next(it)) self.assertFalseTwice(it.has_next) self.assertEqualTwice(it.sentinel, it.peek) self.assertFalseTwice(it.has_next) def test_peek_with_one(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertEqualTwice([it.sentinel], it.peek, 1) self.assertFalseTwice(it.has_next) a = ['1'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1'], it.peek, 1) self.assertEqual('1', next(it)) self.assertFalseTwice(it.has_next) self.assertEqualTwice([it.sentinel], it.peek, 1) self.assertFalseTwice(it.has_next) a = ['1', '2'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['1'], it.peek, 1) self.assertEqual('1', next(it)) self.assertTrueTwice(it.has_next) self.assertEqualTwice(['2'], it.peek, 1) self.assertEqual('2', next(it)) self.assertFalseTwice(it.has_next) self.assertEqualTwice([it.sentinel], it.peek, 1) self.assertFalseTwice(it.has_next) def test_peek_with_zero(self): a = [] it = peek_iter(a) self.assertFalseTwice(it.has_next) self.assertEqualTwice([], it.peek, 0) a = ['1'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.peek, 0) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.peek, 0) a = ['1', '2'] it = peek_iter(a) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.peek, 0) self.assertTrueTwice(it.has_next) self.assertEqualTwice([], it.peek, 0) def test_line_counter(self): a = ['1', '2', '3', '4'] it = peek_iter(a) self.assertEqual(it.counter, 0) it.peek(2) self.assertEqual(it.counter, 0) it.next(2) self.assertEqual(it.counter, 2) it.next() self.assertEqual(it.counter, 3) it.next() self.assertEqual(it.counter, 4) self.assertFalseTwice(it.has_next) class ModifyIterTest(BaseIteratorsTest): def test_init_with_sentinel_args(self): a = iter(['1', '2', '3', 'DONE']) sentinel = 'DONE' def get_next(): return next(a) it = modify_iter(get_next, sentinel, int) expected = [1, 2, 3] self.assertEqual(expected, [i for i in it]) def test_init_with_sentinel_kwargs(self): a = iter([1, 2, 3, 4]) sentinel = 4 def get_next(): return next(a) it = modify_iter(get_next, sentinel, modifier=str) expected = ['1', '2', '3'] self.assertEqual(expected, [i for i in it]) def test_modifier_default(self): a = ['', ' ', ' a ', 'b ', ' c', ' ', ''] it = modify_iter(a) expected = ['', ' ', ' a ', 'b ', ' c', ' ', ''] self.assertEqual(expected, [i for i in it]) def test_modifier_not_callable(self): self.assertRaises(TypeError, modify_iter, [1], modifier='not_callable') def test_modifier_rstrip(self): a = ['', ' ', ' a ', 'b ', ' c', ' ', ''] it = modify_iter(a, modifier=lambda s: s.rstrip()) expected = ['', '', ' a', 'b', ' c', '', ''] self.assertEqual(expected, [i for i in it]) def test_modifier_rstrip_unicode(self): a = ['', ' ', ' a ', 'b ', ' c', ' ', ''] it = modify_iter(a, modifier=lambda s: s.rstrip()) expected = ['', '', ' a', 'b', ' c', '', ''] self.assertEqual(expected, [i for i in it]) pydoctor-24.11.2/pydoctor/test/test_node2stan.py000066400000000000000000000107171473665144200216760ustar00rootroot00000000000000""" Tests for the L{node2stan} module. :See: {test.epydoc.test_epytext2html}, {test.epydoc.test_restructuredtext} """ from pydoctor.epydoc.docutils import get_lineno from pydoctor.test import CapSys from pydoctor.test.epydoc.test_epytext2html import epytext2node from pydoctor.test.epydoc.test_restructuredtext import rst2node, parse_rst from pydoctor.node2stan import gettext from docutils import nodes def test_gettext() -> None: doc = ''' This paragraph is not in any section. Section 1 ========= This is a paragraph in section 1. Section 1.1 ----------- This is a paragraph in section 1.1. Section 2 ========= This is a paragraph in section 2. ''' assert gettext(epytext2node(doc)) == [ 'This paragraph is not in any section.', 'Section 1', 'This is a paragraph in section 1.', 'Section 1.1', 'This is a paragraph in section 1.1.', 'Section 2', 'This is a paragraph in section 2.'] doc = ''' I{B{Inline markup} may be nested; and it may span} multiple lines. - I{Italicized text} - B{Bold-faced text} - C{Source code} - Math: M{m*x+b} Without the capital letter, matching braces are not interpreted as markup: C{my_dict={1:2, 3:4}}. ''' assert gettext(epytext2node(doc)) == [ 'Inline markup', ' may be nested; and it may span', ' multiple lines.', 'Italicized text', 'Bold-faced text', 'Source code', 'Math: ', 'm*x+b', 'Without the capital letter, matching braces are not interpreted as markup: ', 'my_dict=', '{', '1:2, 3:4', '}', '.'] doc = ''' - U{www.python.org} - U{http://www.python.org} - U{The epydoc homepage} - U{The B{I{Python}} homepage } - U{Edward Loper} ''' # TODO: Make it retreive the links refuri attribute. assert gettext(epytext2node(doc)) == ['www.python.org', 'http://www.python.org', 'The epydoc homepage', 'The ', 'Python', ' homepage', 'Edward Loper'] doc = ''' This paragraph is not in any section. ``__ .. note:: This is just a note with nested contents .. image:: https://avatars0.githubusercontent.com/u/50667087?s=200&v=4 :target: https://mfesiem.github.io/docs/msiempy/msiempy.html :alt: Nitro :width: 50 :height: 50 ''' assert gettext(rst2node(doc)) == ['This paragraph is not in any section.', 'mailto:postmaster@example.net', 'This is just a note with nested contents'] def count_parents(node:nodes.Node) -> int: count = 0 ctx = node while not isinstance(ctx, nodes.document): count += 1 ctx = ctx.parent return count class TitleReferenceDump(nodes.GenericNodeVisitor): def default_visit(self, node: nodes.Node) -> None: if not isinstance(node, nodes.title_reference): return print('{}{:<15} line: {}, get_lineno: {}, rawsource: {}'.format( '|'*count_parents(node), type(node).__name__, node.line, get_lineno(node), node.rawsource.replace('\n', '\\n'))) def test_docutils_get_lineno_title_reference(capsys:CapSys) -> None: """ We can get the exact line numbers for all `nodes.title_reference` nodes in a docutils document. """ parsed_doc = parse_rst(''' Fizz ==== Lorem ipsum `notfound`. Buzz **** Lorem ``ipsum`` .. code-block:: python x = 0 .. note:: Dolor sit amet `notfound`. .. code-block:: python y = 1 Dolor sit amet `another link `. Dolor sit amet `link `. bla blab balba. :var foo: Dolor sit amet `link `. ''') doc = parsed_doc.to_node() doc.walk(TitleReferenceDump(doc)) assert capsys.readouterr().out == r'''||title_reference line: None, get_lineno: 4, rawsource: `notfound` ||||title_reference line: None, get_lineno: 18, rawsource: `notfound` |||title_reference line: None, get_lineno: 24, rawsource: `another link ` |||title_reference line: None, get_lineno: 25, rawsource: `link ` ''' parsed_doc.fields[0].body().to_node().walk(TitleReferenceDump(doc)) assert capsys.readouterr().out == r'''||title_reference line: None, get_lineno: 28, rawsource: `link ` ''' pydoctor-24.11.2/pydoctor/test/test_options.py000066400000000000000000000214571473665144200214770ustar00rootroot00000000000000import os from pathlib import Path import warnings import pytest from io import StringIO from pydoctor import model from pydoctor.options import PydoctorConfigParser, Options from pydoctor.test import FixtureRequest, TempPathFactory EXAMPLE_TOML_CONF = """ [tool.poetry] packages = [ { include = "my_package" }, { include = "extra_package" }, ] name = "awesome" [tool.poetry.dependencies] # These packages are mandatory and form the core of this package’s distribution. mandatory = "^1.0" # A list of all of the optional dependencies, some of which are included in the # below `extras`. They can be opted into by apps. psycopg2 = { version = "^2.7", optional = true } mysqlclient = { version = "^1.3", optional = true } [tool.poetry.extras] mysql = ["mysqlclient"] pgsql = ["psycopg2"] """ EXAMPLE_INI_CONF = """ [metadata] name = setup.cfg version = 0.9.0.dev author = Erik M. Bray author-email = embray@stsci.edu summary = Reads a distributions's metadata from its setup.cfg file and passes it to setuptools.setup() description-file = README.rst CHANGES.rst home-page = http://pypi.python.org/pypi/setup.cfg requires-dist = setuptools classifier = Development Status :: 5 - Production/Stable Environment :: Plugins Framework :: Setuptools Plugin Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 3 Topic :: Software Development :: Build Tools Topic :: Software Development :: Libraries :: Python Modules Topic :: System :: Archiving :: Packaging [files] packages = setup setup.cfg setup.cfg.extern extra_files = CHANGES.rst LICENSE ez_setup.py """ PYDOCTOR_SECTIONS = [""" [pydoctor] intersphinx = ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv", "https://urllib3.readthedocs.io/en/latest/objects.inv", "https://requests.readthedocs.io/en/latest/objects.inv", "https://www.attrs.org/en/stable/objects.inv", "https://tristanlatr.github.io/apidocs/docutils/objects.inv"] docformat = 'restructuredtext' project-name = 'MyProject' project-url = "https://github.com/twisted/pydoctor" privacy = ["HIDDEN:pydoctor.test"] quiet = 1 warnings-as-errors = true """, # toml/ini """ [tool.pydoctor] intersphinx = ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv", "https://urllib3.readthedocs.io/en/latest/objects.inv", "https://requests.readthedocs.io/en/latest/objects.inv", "https://www.attrs.org/en/stable/objects.inv", "https://tristanlatr.github.io/apidocs/docutils/objects.inv"] docformat = "restructuredtext" project-name = "MyProject" project-url = "https://github.com/twisted/pydoctor" privacy = ["HIDDEN:pydoctor.test"] quiet = 1 warnings-as-errors = true """, # toml/ini """ [tool:pydoctor] intersphinx = https://docs.python.org/3/objects.inv https://twistedmatrix.com/documents/current/api/objects.inv https://urllib3.readthedocs.io/en/latest/objects.inv https://requests.readthedocs.io/en/latest/objects.inv https://www.attrs.org/en/stable/objects.inv https://tristanlatr.github.io/apidocs/docutils/objects.inv docformat = restructuredtext project-name = MyProject project-url = https://github.com/twisted/pydoctor privacy = HIDDEN:pydoctor.test quiet = 1 warnings-as-errors = true """, # ini only """ [pydoctor] intersphinx: ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv", "https://urllib3.readthedocs.io/en/latest/objects.inv", "https://requests.readthedocs.io/en/latest/objects.inv", "https://www.attrs.org/en/stable/objects.inv", "https://tristanlatr.github.io/apidocs/docutils/objects.inv"] docformat: restructuredtext project-name: MyProject project-url: '''https://github.com/twisted/pydoctor''' privacy = HIDDEN:pydoctor.test quiet = 1 warnings-as-errors = true """, # ini only ] @pytest.fixture(scope='module') def tempDir(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path: name = request.module.__name__.split('.')[-1] return tmp_path_factory.mktemp(f'{name}-cache') @pytest.mark.parametrize('project_conf', [EXAMPLE_TOML_CONF, EXAMPLE_INI_CONF]) @pytest.mark.parametrize('pydoctor_conf', PYDOCTOR_SECTIONS) def test_config_parsers(project_conf:str, pydoctor_conf:str, tempDir:Path) -> None: if '[tool:pydoctor]' in pydoctor_conf and '[tool.poetry]' in project_conf: # colons in section names are not supported in TOML (without quotes) return if 'intersphinx:' in pydoctor_conf and '[tool.poetry]' in project_conf: # colons to defined key pairs are not supported in TOML return parser = PydoctorConfigParser() stream = StringIO(project_conf + '\n' + pydoctor_conf) data = parser.parse(stream) assert data['docformat'] == 'restructuredtext', data assert data['project-url'] == 'https://github.com/twisted/pydoctor', data assert len(data['intersphinx']) == 6, data conf_file = (tempDir / "pydoctor_temp_conf") with conf_file.open('w') as f: f.write(project_conf + '\n' + pydoctor_conf) options = Options.from_args([f"--config={conf_file}"]) assert options.verbosity == -1 assert options.warnings_as_errors == True assert options.privacy == [(model.PrivacyClass.HIDDEN, 'pydoctor.test')] assert options.intersphinx[0] == "https://docs.python.org/3/objects.inv" assert options.intersphinx[-1] == "https://tristanlatr.github.io/apidocs/docutils/objects.inv" def test_repeatable_options_multiple_configs_and_args(tempDir:Path) -> None: config1 = """ [pydoctor] intersphinx = ["https://docs.python.org/3/objects.inv"] verbose = 1 """ config2 = """ [tool.pydoctor] intersphinx = ["https://twistedmatrix.com/documents/current/api/objects.inv"] verbose = -1 project-version = 2050.4C """ config3 = """ [tool:pydoctor] intersphinx = ["https://requests.readthedocs.io/en/latest/objects.inv"] verbose = 0 project-name = "Hello World!" """ cwd = os.getcwd() try: conf_file1 = (tempDir / "pydoctor.ini") conf_file2 = (tempDir / "pyproject.toml") conf_file3 = (tempDir / "setup.cfg") for cfg, file in zip([config1, config2, config3],[conf_file1, conf_file2, conf_file3]): with open(file, 'w') as f: f.write(cfg) os.chdir(tempDir) options = Options.defaults() assert options.verbosity == 1 assert options.intersphinx == ["https://docs.python.org/3/objects.inv",] assert options.projectname == "Hello World!" assert options.projectversion == "2050.4C" options = Options.from_args(['-vv']) assert options.verbosity == 3 assert options.intersphinx == ["https://docs.python.org/3/objects.inv",] assert options.projectname == "Hello World!" assert options.projectversion == "2050.4C" options = Options.from_args(['-vv', '--intersphinx=https://twistedmatrix.com/documents/current/api/objects.inv', '--intersphinx=https://urllib3.readthedocs.io/en/latest/objects.inv']) assert options.verbosity == 3 assert options.intersphinx == ["https://twistedmatrix.com/documents/current/api/objects.inv", "https://urllib3.readthedocs.io/en/latest/objects.inv"] assert options.projectname == "Hello World!" assert options.projectversion == "2050.4C" finally: os.chdir(cwd) def test_validations(tempDir:Path) -> None: config = """ [tool:pydoctor] # should be a string, but hard to detect - no warnings html-output = 1 # should be a string, but hard to detect - no warnings project-name = true # should be a list, but configargparse is smart enought - no warnings privacy = HIDDEN:pydoctor.test # configargparse accepts int when it should be bool - no warnings warnings-as-errors = 0 # no such option, here we warn not-found = 423 """ conf_file = (tempDir / "pydoctor_temp_conf") with conf_file.open('w') as f: f.write(config) with warnings.catch_warnings(record=True) as catch_warnings: warnings.simplefilter("always") options = Options.from_args([f"--config={conf_file}"]) warn_messages = [str(w.message) for w in catch_warnings] assert len(warn_messages) == 1, warn_messages assert warn_messages[0] == "No such config option: 'not-found'" assert options.docformat == 'epytext' assert options.projectname == 'true' assert options.privacy == [(model.PrivacyClass.HIDDEN, 'pydoctor.test')] assert options.quietness == 0 assert options.warnings_as_errors == False assert options.htmloutput == '1' pydoctor-24.11.2/pydoctor/test/test_packages.py000066400000000000000000000155501473665144200215570ustar00rootroot00000000000000from pathlib import Path from typing import Callable import pytest from pydoctor import model testpackages = Path(__file__).parent / 'testpackages' def processPackage(packname: str, systemcls: Callable[[], model.System] = model.System) -> model.System: system = systemcls() builder = system.systemBuilder(system) builder.addModule(testpackages / packname) builder.buildModules() return system def test_relative_import() -> None: system = processPackage("relativeimporttest") cls = system.allobjects['relativeimporttest.mod1.C'] assert isinstance(cls, model.Class) assert cls.bases == ['relativeimporttest.mod2.B'] def test_package_docstring() -> None: system = processPackage("relativeimporttest") assert system.allobjects['relativeimporttest'].docstring == "DOCSTRING" def test_modnamedafterbuiltin() -> None: # well, basically the test is that this doesn't explode: system = processPackage("modnamedafterbuiltin") # but let's test _something_ dict_class = system.allobjects['modnamedafterbuiltin.mod.Dict'] assert isinstance(dict_class, model.Class) assert dict_class.baseobjects == [None] def test_nestedconfusion() -> None: system = processPackage("nestedconfusion") A = system.allobjects['nestedconfusion.mod.nestedconfusion.A'] assert isinstance(A, model.Class) C = system.allobjects['nestedconfusion.mod.C'] assert A.baseobjects[0] is C def test_importingfrompackage() -> None: system = processPackage("importingfrompackage") system.getProcessedModule('importingfrompackage.mod') submod = system.allobjects['importingfrompackage.subpack.submod'] assert isinstance(submod, model.Module) assert submod.state is model.ProcessingState.PROCESSED def test_allgames() -> None: """ Test reparenting of documentables. A name which is defined in module 1, but included in __all__ of module 2 that it is imported into, should end up in the documentation of module 2. """ system = processPackage("allgames") mod1 = system.allobjects['allgames.mod1'] assert isinstance(mod1, model.Module) mod2 = system.allobjects['allgames.mod2'] assert isinstance(mod2, model.Module) # InSourceAll is not moved into mod2, but NotInSourceAll is. assert 'InSourceAll' in mod1.contents assert 'NotInSourceAll' in mod2.contents # Source paths must be unaffected by the move, so that error messages # point to the right source code. moved = mod2.contents['NotInSourceAll'] assert isinstance(moved, model.Class) assert moved.source_path is not None assert moved.source_path.parts[-2:] == ('allgames', 'mod1.py') assert moved.parentMod is mod2 assert moved.parentMod.source_path is not None assert moved.parentMod.source_path.parts[-2:] == ('allgames', 'mod2.py') def test_cyclic_imports() -> None: """ Test whether names are resolved correctly when we have import cycles. The test package contains module 'a' that defines class 'A' and module 'b' that defines class 'B'; each module imports the other. Since the test data is symmetrical, we will at some point be importing a module that has not been fully processed yet, no matter which module gets processed first. """ system = processPackage('cyclic_imports') mod_a = system.allobjects['cyclic_imports.a'] assert mod_a.expandName('B') == 'cyclic_imports.b.B' mod_b = system.allobjects['cyclic_imports.b'] assert mod_b.expandName('A') == 'cyclic_imports.a.A' def test_package_module_name_clash() -> None: """ When a module and a package have the same full name, the package wins. """ system = processPackage('package_module_name_clash') pack = system.allobjects['package_module_name_clash.pack'] assert 'package' == pack.contents.popitem()[0] def test_reparented_module() -> None: """ A module that is imported in a package as a different name and exported in that package under the new name via C{__all__} is presented using the new name. """ system = processPackage('reparented_module') mod = system.allobjects['reparented_module.module'] top = system.allobjects['reparented_module'] assert mod.fullName() == 'reparented_module.module' assert top.resolveName('module') is top.contents['module'] assert top.resolveName('module.f') is mod.contents['f'] # The module old name is not in allobjects assert 'reparented_module.mod' not in system.allobjects # But can still be resolved with it's old name assert top.resolveName('mod') is top.contents['module'] def test_reparenting_follows_aliases() -> None: """ Test for https://github.com/twisted/pydoctor/issues/505 Reparenting process follows aliases. """ system = processPackage('reparenting_follows_aliases') # reparenting_follows_aliases.main: imports MyClass from ._myotherthing and re-export it in it's __all__ variable. # reparenting_follows_aliases._mything: defines class MyClass. # reparenting_follows_aliases._myotherthing: imports class MyClass from ._mything, but do not export it. # Test that we do not get KeyError klass = system.allobjects['reparenting_follows_aliases.main.MyClass'] # Test older names still resolves to reparented object top = system.allobjects['reparenting_follows_aliases'] myotherthing = top.contents['_myotherthing'] mything = top.contents['_mything'] assert isinstance(mything, model.Module) assert isinstance(myotherthing, model.Module) assert mything._localNameToFullName('MyClass') == 'reparenting_follows_aliases.main.MyClass' assert myotherthing._localNameToFullName('MyClass') == 'reparenting_follows_aliases._mything.MyClass' system.find_object('reparenting_follows_aliases._mything.MyClass') == klass # This part of the test cannot pass for now since we don't recursively resolve aliases. # See https://github.com/twisted/pydoctor/pull/414 and https://github.com/twisted/pydoctor/issues/430 try: assert system.find_object('reparenting_follows_aliases._myotherthing.MyClass') == klass assert myotherthing.resolveName('MyClass') == klass assert mything.resolveName('MyClass') == klass assert top.resolveName('_myotherthing.MyClass') == klass assert top.resolveName('_mything.MyClass') == klass except (AssertionError, LookupError): return else: raise AssertionError("Congratulation!") @pytest.mark.parametrize('modname', ['reparenting_crash','reparenting_crash_alt']) def test_reparenting_crash(modname: str) -> None: """ Test for https://github.com/twisted/pydoctor/issues/513 """ system = processPackage(modname) mod = system.allobjects[modname] assert isinstance(mod.contents[modname], model.Class) assert isinstance(mod.contents['reparented_func'], model.Function) assert isinstance(mod.contents[modname].contents['reparented_func'], model.Function) pydoctor-24.11.2/pydoctor/test/test_pydantic_fields.py000066400000000000000000000050351473665144200231370ustar00rootroot00000000000000import ast from typing import List, Type from pydoctor import astutils, extensions, model class ModVisitor(extensions.ModuleVisitorExt): def depart_AnnAssign(self, node: ast.AnnAssign) -> None: """ Called after an annotated assignment definition is visited. """ ctx = self.visitor.builder.current if not isinstance(ctx, model.Class): # check if the current context object is a class return if not any(ctx.expandName(b) == 'pydantic.BaseModel' for b in ctx.bases): # check if the current context object if a class derived from ``pydantic.BaseModel`` return dottedname = astutils.node2dottedname(node.target) if not dottedname or len(dottedname)!=1: # check if the assignment is a simple name, otherwise ignore it return # Get the attribute from current context attr = ctx.contents[dottedname[0]] assert isinstance(attr, model.Attribute) # All class variables that are not annotated with ClassVar will be transformed to instance variables. if astutils.is_using_typing_classvar(attr.annotation, attr): return if attr.kind == model.DocumentableKind.CLASS_VARIABLE: attr.kind = model.DocumentableKind.INSTANCE_VARIABLE def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: r.register_astbuilder_visitor(ModVisitor) class PydanticSystem2(model.System): # Add our custom extension extensions: List[str] = [] custom_extensions = ['pydoctor.test.test_pydantic_fields'] ## Testing code import pytest from pydoctor.test.test_astbuilder import fromText, PydanticSystem pydantic_systemcls_param = pytest.mark.parametrize('systemcls', (PydanticSystem, PydanticSystem2)) @pydantic_systemcls_param def test_pydantic_fields(systemcls: Type[model.System]) -> None: src = ''' from typing import ClassVar from pydantic import BaseModel, Field class Model(BaseModel): a: int b: int = Field(...) name:str = 'Jane Doe' kind:ClassVar = 'person' ''' mod = fromText(src, modname='mod', systemcls=systemcls) assert mod.contents['Model'].contents['a'].kind == model.DocumentableKind.INSTANCE_VARIABLE assert mod.contents['Model'].contents['b'].kind == model.DocumentableKind.INSTANCE_VARIABLE assert mod.contents['Model'].contents['name'].kind == model.DocumentableKind.INSTANCE_VARIABLE assert mod.contents['Model'].contents['kind'].kind == model.DocumentableKind.CLASS_VARIABLE pydoctor-24.11.2/pydoctor/test/test_qnmatch.py000066400000000000000000000116741473665144200214370ustar00rootroot00000000000000import unittest from pydoctor.qnmatch import qnmatch, translate def test_qnmatch() -> None: assert(qnmatch('site.yml', 'site.yml')) assert(not qnmatch('site.yml', '**.site.yml')) assert(not qnmatch('site.yml', 'site.yml.**')) assert(not qnmatch('SITE.YML', 'site.yml')) assert(not qnmatch('SITE.YML', '**.site.yml')) assert(qnmatch('images.logo.png', '*.*.png')) assert(not qnmatch('images.images.logo.png', '*.*.png')) assert(not qnmatch('images.logo.png', '*.*.*.png')) assert(qnmatch('images.logo.png', '**.png')) assert(qnmatch('images.logo.png', '**.*.png')) assert(qnmatch('images.logo.png', '**png')) assert(not qnmatch('images.logo.png', 'images.**.*.png')) assert(not qnmatch('images.logo.png', '**.images.**.png')) assert(not qnmatch('images.logo.png', '**.images.**.???')) assert(not qnmatch('images.logo.png', '**.image?.**.???')) assert(qnmatch('images.logo.png', 'images.**.png')) assert(qnmatch('images.logo.png', 'images.**.png')) assert(qnmatch('images.logo.png', 'images.**.???')) assert(qnmatch('images.logo.png', 'image?.**.???')) assert(qnmatch('images.gitkeep', '**.*')) assert(qnmatch('output.gitkeep', '**.*')) assert(qnmatch('images.gitkeep', '*.**')) assert(qnmatch('output.gitkeep', '*.**')) assert(qnmatch('.hidden', '**.*')) assert(qnmatch('sub.hidden', '**.*')) assert(qnmatch('sub.sub.hidden', '**.*')) assert(qnmatch('.hidden', '**.hidden')) assert(qnmatch('sub.hidden', '**.hidden')) assert(qnmatch('sub.sub.hidden', '**.hidden')) assert(qnmatch('site.yml.Class', 'site.yml.*')) assert(not qnmatch('site.yml.Class.property', 'site.yml.*')) assert(not qnmatch('site.yml.Class.property', 'site.yml.Class')) assert(qnmatch('site.yml.Class.__init__', '**.__*__')) assert(qnmatch('site._yml.Class.property', '**._*.**')) assert(qnmatch('site.yml._Class.property', '**._*.**')) assert(not qnmatch('site.yml.Class.property', '**._*.**')) assert(not qnmatch('site.yml_.Class.property', '**._*.**')) assert(not qnmatch('site.yml.Class._property', '**._*.**')) class TranslateTestCase(unittest.TestCase): def test_translate(self) -> None: self.assertEqual(translate('*'), r'(?s:[^\.]*?)\Z') self.assertEqual(translate('**'), r'(?s:.*?)\Z') self.assertEqual(translate('?'), r'(?s:.)\Z') self.assertEqual(translate('a?b*'), r'(?s:a.b[^\.]*?)\Z') self.assertEqual(translate('[abc]'), r'(?s:[abc])\Z') self.assertEqual(translate('[]]'), r'(?s:[]])\Z') self.assertEqual(translate('[!x]'), r'(?s:[^x])\Z') self.assertEqual(translate('[^x]'), r'(?s:[\^x])\Z') self.assertEqual(translate('[x'), r'(?s:\[x)\Z') class FnmatchTestCase(unittest.TestCase): def check_match(self, filename, pattern, should_match=True, fn=qnmatch) -> None: # type: ignore if should_match: self.assertTrue(fn(filename, pattern), "expected %r to match pattern %r" % (filename, pattern)) else: self.assertFalse(fn(filename, pattern), "expected %r not to match pattern %r" % (filename, pattern)) def test_fnmatch(self) -> None: check = self.check_match check('abc', 'abc') check('abc', '?*?') check('abc', '???*') check('abc', '*???') check('abc', '???') check('abc', '*') check('abc', 'ab[cd]') check('abc', 'ab[!de]') check('abc', 'ab[de]', False) check('a', '??', False) check('a', 'b', False) # these test that '\' is handled correctly in character sets; # see SF bug #409651 check('\\', r'[\]') check('a', r'[!\]') check('\\', r'[!\]', False) # test that filenames with newlines in them are handled correctly. # http://bugs.python.org/issue6665 check('foo\nbar', 'foo*') check('foo\nbar\n', 'foo*') check('\nfoo', 'foo*', False) check('\n', '*') def test_mix_bytes_str(self) -> None: self.assertRaises(TypeError, qnmatch, 'test', b'*') self.assertRaises(TypeError, qnmatch, b'test', '*') self.assertRaises(TypeError, qnmatch, 'test', b'*') self.assertRaises(TypeError, qnmatch, b'test', '*') def test_fnmatchcase(self) -> None: check = self.check_match check('abc', 'abc', True, qnmatch) check('AbC', 'abc', False, qnmatch) check('abc', 'AbC', False, qnmatch) check('AbC', 'AbC', True, qnmatch) check('usr/bin', 'usr/bin', True, qnmatch) check('usr\\bin', 'usr/bin', False, qnmatch) check('usr/bin', 'usr\\bin', False, qnmatch) check('usr\\bin', 'usr\\bin', True, qnmatch) def test_case(self) -> None: check = self.check_match check('abc', 'abc') check('AbC', 'abc', False) check('abc', 'AbC', False) check('AbC', 'AbC') pydoctor-24.11.2/pydoctor/test/test_sphinx.py000066400000000000000000000530251473665144200213110ustar00rootroot00000000000000""" Tests for Sphinx integration. """ import datetime import io import string import zlib from contextlib import contextmanager from pathlib import Path from typing import Callable, Iterator, List, Optional, Tuple, cast import cachecontrol import pytest import requests from urllib3 import HTTPResponse from hypothesis import assume, given, settings from hypothesis import strategies as st from . import CapLog, FixtureRequest, MonkeyPatch, TempPathFactory from pydoctor import model, sphinx class PydoctorLogger: """ Partial implementation of pydoctor.model.System.msg() that records logged messages. """ def __init__(self) -> None: self.messages: List[Tuple[str, str, int]] = [] def __call__(self, section: str, msg: str, thresh: int = 0) -> None: self.messages.append((section, msg, thresh)) class PydoctorNoLogger: """ Partial implementation of pydoctor.model.System.msg() that asserts if any message is logged. """ def __call__(self, section: str, msg: str, thresh: int = 0) -> None: assert False class InvReader(sphinx.SphinxInventory): _logger: PydoctorLogger class InvWriter(sphinx.SphinxInventoryWriter): _logger: PydoctorLogger @pytest.fixture def inv_reader() -> InvReader: return InvReader(logger=PydoctorLogger()) @pytest.fixture def inv_reader_nolog() -> sphinx.SphinxInventory: return sphinx.SphinxInventory(logger=PydoctorNoLogger()) def get_inv_writer_with_logger(name: str = 'project_name', version: str = '1.2') -> Tuple[InvWriter, PydoctorLogger]: """ @return: Tuple of a Sphinx inventory writer connected to the logger. """ logger = PydoctorLogger() writer = InvWriter( logger=logger, project_name=name, project_version=version, ) return writer, logger @pytest.fixture def inv_writer_nolog() -> sphinx.SphinxInventoryWriter: """ @return: A Sphinx inventory writer that is connected to a null logger. """ return sphinx.SphinxInventoryWriter( logger=PydoctorNoLogger(), project_name='project_name', project_version='2.3.0', ) class IgnoreSystem: root_names = () IGNORE_SYSTEM = cast(model.System, IgnoreSystem()) """Passed as a System when we don't want the system to be accessed.""" def test_generate_empty_functional() -> None: """ Functional test for index generation of empty API. Header is plain text while content is compressed. """ inv_writer, logger = get_inv_writer_with_logger( name='project-name', version='1.2.0rc1', ) output = io.BytesIO() @contextmanager def openFileForWriting(path: str) -> Iterator[io.BytesIO]: yield output inv_writer._openFileForWriting = openFileForWriting # type: ignore inv_writer.generate(subjects=[], basepath='base-path') inventory_path = Path('base-path') / 'objects.inv' expected_log = [( 'sphinx', f'Generating objects inventory at {inventory_path}', 0 )] assert expected_log == logger.messages expected_ouput = b"""# Sphinx inventory version 2 # Project: project-name # Version: 1.2.0rc1 # The rest of this file is compressed with zlib. x\x9c\x03\x00\x00\x00\x00\x01""" assert expected_ouput == output.getvalue() def test_generateContent(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Return a string with inventory for all targeted objects, recursive. """ system = model.System() root1 = model.Package(system, 'package1') root2 = model.Package(system, 'package2') child1 = model.Package(system, 'child1', parent=root2) system.addObject(child1) subjects = [root1, root2] result = inv_writer_nolog._generateContent(subjects) expected_result = ( b'package1 py:module -1 package1.html -\n' b'package2 py:module -1 package2.html -\n' b'package2.child1 py:module -1 package2.child1.html -\n' ) assert expected_result == result def test_generateLine_package(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Check inventory for package. """ result = inv_writer_nolog._generateLine( model.Package(IGNORE_SYSTEM, 'package1')) assert 'package1 py:module -1 package1.html -\n' == result def test_generateLine_module(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Check inventory for module. """ result = inv_writer_nolog._generateLine( model.Module(IGNORE_SYSTEM, 'module1')) assert 'module1 py:module -1 module1.html -\n' == result def test_generateLine_class(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Check inventory for class. """ result = inv_writer_nolog._generateLine( model.Class(IGNORE_SYSTEM, 'class1')) assert 'class1 py:class -1 class1.html -\n' == result def test_generateLine_function(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Check inventory for function. Functions are inside a module. """ parent = model.Module(IGNORE_SYSTEM, 'module1') result = inv_writer_nolog._generateLine( model.Function(IGNORE_SYSTEM, 'func1', parent)) assert 'module1.func1 py:function -1 module1.html#func1 -\n' == result def test_generateLine_method(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Check inventory for method. Methods are functions inside a class. """ parent = model.Class(IGNORE_SYSTEM, 'class1') result = inv_writer_nolog._generateLine( model.Function(IGNORE_SYSTEM, 'meth1', parent)) assert 'class1.meth1 py:method -1 class1.html#meth1 -\n' == result def test_generateLine_attribute(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Check inventory for attributes. """ parent = model.Class(IGNORE_SYSTEM, 'class1') result = inv_writer_nolog._generateLine( model.Attribute(IGNORE_SYSTEM, 'attr1', parent)) assert 'class1.attr1 py:attribute -1 class1.html#attr1 -\n' == result class UnknownType(model.Documentable): """ Documentable type to help with testing. """ def test_generateLine_unknown() -> None: """ When object type is uknown a message is logged and is handled as generic object. """ inv_writer, logger = get_inv_writer_with_logger() result = inv_writer._generateLine( UnknownType(IGNORE_SYSTEM, 'unknown1')) assert 'unknown1 py:obj -1 unknown1.html -\n' == result assert [( 'sphinx', "Unknown type for unknown1.", -1 )] == logger.messages def test_getPayload_empty(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Return empty string. """ content = b"""# Sphinx inventory version 2 # Project: some-name # Version: 2.0 # The rest of this file is compressed with zlib. x\x9c\x03\x00\x00\x00\x00\x01""" result = inv_reader_nolog._getPayload('http://base.ignore', content) assert '' == result def test_getPayload_content(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Return content as string. """ payload = "first_line\nsecond line\nit's a snake: \U0001F40D" content = b"""# Ignored line # Project: some-name # Version: 2.0 # commented line. """ + zlib.compress(payload.encode('utf-8')) result = inv_reader_nolog._getPayload('http://base.ignore', content) assert payload == result def test_getPayload_invalid_uncompress(inv_reader: InvReader) -> None: """ Return empty string and log an error when failing to uncompress data. """ base_url = 'http://tm.tld' content = b"""# Project: some-name # Version: 2.0 not-valid-zlib-content""" result = inv_reader._getPayload(base_url, content) assert '' == result assert [( 'sphinx', 'Failed to uncompress inventory from http://tm.tld', -1, )] == inv_reader._logger.messages def test_getPayload_invalid_decode(inv_reader: InvReader) -> None: """ Return empty string and log an error when failing to uncompress data. """ payload = b'\x80' base_url = 'http://tm.tld' content = b"""# Project: some-name # Version: 2.0 """ + zlib.compress(payload) result = inv_reader._getPayload(base_url, content) assert '' == result assert [( 'sphinx', 'Failed to decode inventory from http://tm.tld', -1, )] == inv_reader._logger.messages def test_getLink_not_found(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Return None if link does not exists. """ assert None is inv_reader_nolog.getLink('no.such.name') def test_getLink_found(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Return the link from internal state. """ inv_reader_nolog._links['some.name'] = ('http://base.tld', 'some/url.php') assert 'http://base.tld/some/url.php' == inv_reader_nolog.getLink('some.name') def test_getLink_self_anchor(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Return the link with anchor as target name when link end with $. """ inv_reader_nolog._links['some.name'] = ('http://base.tld', 'some/url.php#$') assert 'http://base.tld/some/url.php#some.name' == inv_reader_nolog.getLink('some.name') def test_update_functional(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Functional test for updating from an empty inventory. """ payload = ( b'some.module1 py:module -1 module1.html -\n' b'other.module2 py:module 0 module2.html Other description\n' ) # Patch URL loader to avoid hitting the system. content = b"""# Sphinx inventory version 2 # Project: some-name # Version: 2.0 # The rest of this file is compressed with zlib. """ + zlib.compress(payload) url = 'http://some.url/api/objects.inv' inv_reader_nolog.update(cast('sphinx.CacheT', {url: content}), url) assert 'http://some.url/api/module1.html' == inv_reader_nolog.getLink('some.module1') assert 'http://some.url/api/module2.html' == inv_reader_nolog.getLink('other.module2') def test_update_bad_url(inv_reader: InvReader) -> None: """ Log an error when failing to get base url from url. """ inv_reader.update(cast('sphinx.CacheT', {}), 'really.bad.url') assert inv_reader._links == {} expected_log = [( 'sphinx', 'Failed to get remote base url for really.bad.url', -1 )] assert expected_log == inv_reader._logger.messages def test_update_fail(inv_reader: InvReader) -> None: """ Log an error when failing to get content from url. """ inv_reader.update(cast('sphinx.CacheT', {}), 'http://some.tld/o.inv') assert inv_reader._links == {} expected_log = [( 'sphinx', 'Failed to get object inventory from http://some.tld/o.inv', -1, )] assert expected_log == inv_reader._logger.messages def test_parseInventory_empty(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Return empty dict for empty input. """ result = inv_reader_nolog._parseInventory('http://base.tld', '') assert {} == result def test_parseInventory_single_line(inv_reader_nolog: sphinx.SphinxInventory) -> None: """ Return a dict with a single member. """ result = inv_reader_nolog._parseInventory( 'http://base.tld', 'some.attr py:attr -1 some.html De scription') assert {'some.attr': ('http://base.tld', 'some.html')} == result def test_parseInventory_spaces() -> None: """ Sphinx inventory lines always contain 5 values, separated by spaces. However, the first and fifth value can contain internal spaces. The parser must be able to tell apart separators from internal spaces. """ # Space in first (name) column. assert sphinx._parseInventoryLine( 'key function std:term -1 glossary.html#term-key-function -' ) == ( 'key function', 'std:term', -1, 'glossary.html#term-key-function', '-' ) # Space in last (display name) column. assert sphinx._parseInventoryLine( 'doctest-execution-context std:label -1 library/doctest.html#$ What’s the Execution Context?' ) == ( 'doctest-execution-context', 'std:label', -1, 'library/doctest.html#$', 'What’s the Execution Context?' ) # Space in both first and last column. assert sphinx._parseInventoryLine( 'async def std:label -1 reference/compound_stmts.html#async-def Coroutine function definition' ) == ( 'async def', 'std:label', -1, 'reference/compound_stmts.html#async-def', 'Coroutine function definition' ) def test_parseInventory_invalid_lines(inv_reader: InvReader) -> None: """ Skip line and log an error. """ base_url = 'http://tm.tld' content = ( 'good.attr py:attribute -1 some.html -\n' 'missing.display.name py:attribute 1 some.html\n' 'bad.attr bad format\n' 'very.bad\n' '\n' 'good.again py:module 0 again.html -\n' ) result = inv_reader._parseInventory(base_url, content) assert { 'good.attr': (base_url, 'some.html'), 'good.again': (base_url, 'again.html'), } == result assert [ ( 'sphinx', 'Failed to parse line "missing.display.name py:attribute 1 some.html" for http://tm.tld', -1, ), ( 'sphinx', 'Failed to parse line "bad.attr bad format" for http://tm.tld', -1, ), ('sphinx', 'Failed to parse line "very.bad" for http://tm.tld', -1), ('sphinx', 'Failed to parse line "" for http://tm.tld', -1), ] == inv_reader._logger.messages def test_parseInventory_type_filter(inv_reader: InvReader) -> None: """ Ignore entries that don't have a 'py:' type field. """ base_url = 'https://docs.python.org/3' content = ( 'dict std:label -1 reference/expressions.html#$ Dictionary displays\n' 'dict py:class 1 library/stdtypes.html#$ -\n' 'dict std:2to3fixer 1 library/2to3.html#2to3fixer-$ -\n' ) result = inv_reader._parseInventory(base_url, content) assert { 'dict': (base_url, 'library/stdtypes.html#$'), } == result assert [] == inv_reader._logger.messages maxAgeAmounts = st.integers() | st.just("\x00") maxAgeUnits = st.sampled_from(tuple(sphinx._maxAgeUnits)) | st.just("\x00") class TestParseMaxAge: """ Tests for L{sphinx.parseMaxAge} """ @given( amount=maxAgeAmounts, unit=maxAgeUnits, ) def test_toTimedelta(self, amount: int, unit: str) -> None: """ A parsed max age dictionary consists of valid arguments to L{datetime.timedelta}, and the constructed L{datetime.timedelta} matches the specification. """ maxAge = f"{amount}{unit}" try: parsedMaxAge = sphinx.parseMaxAge(maxAge) except sphinx.InvalidMaxAge: pass else: td = datetime.timedelta(**parsedMaxAge) converter = { 's': 1, 'm': 60, 'h': 60 * 60, 'd': 24 * 60 * 60, 'w': 7 * 24 * 60 * 60 } total_seconds = amount * converter[unit] assert pytest.approx(td.total_seconds()) == total_seconds class ClosingBytesIO(io.BytesIO): """ A L{io.BytesIO} instance that closes itself after all its data has been read. This mimics the behavior of L{http.client.HTTPResponse} in the standard library. """ def read(self, size: Optional[int] = None) -> bytes: data = super().read(size) if self.tell() >= len(self.getvalue()): self.close() return data def test_ClosingBytesIO() -> None: """ L{ClosingBytesIO} closes itself when all its data has been read. """ data = b'some data' cbio = ClosingBytesIO(data) buffer = [cbio.read(1)] assert not cbio.closed buffer.append(cbio.read()) assert cbio.closed assert b''.join(buffer) == data # type:ignore[unreachable] class TestIntersphinxCache: """ Tests for L{sphinx.IntersphinxCache} """ @pytest.fixture def send_returns(self, monkeypatch: MonkeyPatch) -> Callable[[HTTPResponse], MonkeyPatch]: """ Return a function that patches L{requests.adapters.HTTPAdapter.send} so that it returns the provided L{requests.Response}. """ def send_returns(urllib3_response: HTTPResponse) -> MonkeyPatch: def send( self: requests.adapters.HTTPAdapter, request: requests.PreparedRequest, *args:object, **kwargs: object ) -> requests.Response: response: requests.Response response = self.build_response(request, urllib3_response) return response monkeypatch.setattr( requests.adapters.HTTPAdapter, "send", send, ) return monkeypatch return send_returns def test_cache(self, tmp_path: Path, send_returns: Callable[[HTTPResponse], None]) -> None: """ L{IntersphinxCache.get} caches responses to the file system. """ url = "https://cache.example/objects.inv" content = b'content' send_returns( HTTPResponse( body=ClosingBytesIO(content), headers={ 'date': 'Sun, 06 Nov 1994 08:49:37 GMT', }, status=200, preload_content=False, decode_content=False, ), ) loadsCache = sphinx.IntersphinxCache.fromParameters( sessionFactory=requests.Session, cachePath=str(tmp_path), maxAgeDictionary={"weeks": 1} ) assert loadsCache.get(url) == content # Now the response contains different data that will not be # returned when the cache is enabled. send_returns( HTTPResponse( body=ClosingBytesIO(content * 2), headers={ 'date': 'Sun, 06 Nov 1994 08:49:37 GMT', }, status=200, preload_content=False, decode_content=False, ), ) assert loadsCache.get(url) == content readsCacheFromFileSystem = sphinx.IntersphinxCache.fromParameters( sessionFactory=requests.Session, cachePath=str(tmp_path), maxAgeDictionary={"weeks": 1} ) assert readsCacheFromFileSystem.get(url) == content def test_getRaisesException(self, caplog: CapLog) -> None: """ L{IntersphinxCache.get} returns L{None} if an exception is raised while C{GET}ing a URL and logs the exception. """ class _TestException(Exception): pass class _RaisesOnGet: @staticmethod def get(url: str) -> bytes: raise _TestException() session = cast(requests.Session, _RaisesOnGet) cache = sphinx.IntersphinxCache(session=session) assert cache.get("some url") is None assert len(caplog.records) == 1 assert caplog.records[0].levelname == "ERROR" assert caplog.records[0].exc_info is not None assert caplog.records[0].exc_info[0] is _TestException @pytest.fixture(scope='module') def cacheDirectory(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path: name = request.module.__name__.split('.')[-1] return tmp_path_factory.mktemp(f'{name}-cache') @given( clearCache=st.booleans(), enableCache=st.booleans(), cacheDirectoryName=st.text( alphabet=sorted(set(string.printable) - set('\\/:*?"<>|\x0c\x0b\t\r\n')), min_size=1, max_size=32, # Avoid upper length on path ), maxAgeAmount=maxAgeAmounts, maxAgeUnit=maxAgeUnits, ) @settings(max_examples=700, deadline=None) def test_prepareCache( cacheDirectory: Path, clearCache: bool, enableCache: bool, cacheDirectoryName: str, maxAgeAmount: int, maxAgeUnit: str, ) -> None: """ The cache directory is deleted when C{clearCache} is L{True}; an L{IntersphinxCache} is created with a session on which is mounted C{cachecontrol.CacheControlAdapter} for C{http} and C{https} URLs. """ # Windows doesn't like paths ending in a space or dot. assume(cacheDirectoryName[-1] not in '. ') # These DOS device names still have special meaning in modern Windows. assume(cacheDirectoryName.upper() not in {'CON', 'PRN', 'AUX', 'NUL'}) assume(not cacheDirectoryName.upper().startswith('COM')) assume(not cacheDirectoryName.upper().startswith('LPT')) cacheDirectory.mkdir(exist_ok=True) for child in cacheDirectory.iterdir(): child.unlink() with open(cacheDirectory / cacheDirectoryName, 'w', encoding='utf-8'): pass try: cache = sphinx.prepareCache( clearCache=clearCache, enableCache=enableCache, cachePath=str(cacheDirectory), maxAge=f"{maxAgeAmount}{maxAgeUnit}" ) except sphinx.InvalidMaxAge: pass else: assert isinstance(cache, sphinx.IntersphinxCache) for scheme in ('https://', 'http://'): hasCacheControl = isinstance( cache._session.adapters[scheme], cachecontrol.CacheControlAdapter, ) if enableCache: assert hasCacheControl else: assert not hasCacheControl if clearCache: assert not cacheDirectory.exists() pydoctor-24.11.2/pydoctor/test/test_templatewriter.py000066400000000000000000001061201473665144200230430ustar00rootroot00000000000000from io import BytesIO import re from typing import Callable, Union, Any, cast, Type, TYPE_CHECKING import pytest import warnings import sys import tempfile import os from pathlib import Path, PurePath from pydoctor import model, templatewriter, stanutils, __version__, epydoc2stan from pydoctor.templatewriter import (FailedToCreateTemplate, StaticTemplate, pages, writer, util, TemplateLookup, Template, HtmlTemplate, UnsupportedTemplateVersion, OverrideTemplateNotAllowed) from pydoctor.templatewriter.pages.table import ChildTable from pydoctor.templatewriter.pages.attributechild import AttributeChild from pydoctor.templatewriter.summary import isClassNodePrivate, isPrivate, moduleSummary, ClassIndexPage from pydoctor.test.test_astbuilder import fromText, systemcls_param from pydoctor.test.test_packages import processPackage, testpackages from pydoctor.test.test_epydoc2stan import InMemoryInventory from pydoctor.test import CapSys from pydoctor.themes import get_themes if TYPE_CHECKING: from twisted.web.template import Flattenable # Newer APIs from importlib_resources should arrive to stdlib importlib.resources in Python 3.9. if sys.version_info >= (3, 9): from importlib.abc import Traversable else: Traversable = Any else: Traversable = object if sys.version_info < (3, 9): import importlib_resources else: import importlib.resources as importlib_resources template_dir = importlib_resources.files("pydoctor.themes") / "base" def filetext(path: Union[Path, Traversable]) -> str: with path.open('r', encoding='utf-8') as fobj: t = fobj.read() return t def flatten(t: "Flattenable") -> str: io = BytesIO() writer.flattenToFile(io, t) return io.getvalue().decode() def getHTMLOf(ob: model.Documentable) -> str: wr = templatewriter.TemplateWriter(Path(), TemplateLookup(template_dir)) f = BytesIO() wr._writeDocsForOne(ob, f) return f.getvalue().decode() def getHTMLOfAttribute(ob: model.Attribute) -> str: assert isinstance(ob, model.Attribute) tlookup = TemplateLookup(template_dir) stan = AttributeChild(util.DocGetter(), ob, [], AttributeChild.lookup_loader(tlookup),) return flatten(stan) def test_sidebar() -> None: src = ''' class C: def f(): ... def h(): ... class D: def l(): ... ''' system = model.System(model.Options.from_args( ['--sidebar-expand-depth=3'])) mod = fromText(src, modname='mod', system=system) mod_html = getHTMLOf(mod) mod_parts = [ ' None: src = ''' def f(): """This is a docstring.""" ''' mod = fromText(src) v = getHTMLOf(mod.contents['f']) assert 'This is a docstring' in v def test_empty_table() -> None: mod = fromText('') t = ChildTable(util.DocGetter(), mod, [], ChildTable.lookup_loader(TemplateLookup(template_dir))) flattened = flatten(t) assert 'The renderer named' not in flattened def test_nonempty_table() -> None: mod = fromText('def f(): pass') t = ChildTable(util.DocGetter(), mod, mod.contents.values(), ChildTable.lookup_loader(TemplateLookup(template_dir))) flattened = flatten(t) assert 'The renderer named' not in flattened def test_rest_support() -> None: system = model.System() system.options.docformat = 'restructuredtext' system.options.verbosity = 4 src = ''' def f(): """This is a docstring for f.""" ''' mod = fromText(src, system=system) html = getHTMLOf(mod.contents['f']) assert "
    " not in html
    
    def test_document_code_in_init_module() -> None:
        system = processPackage("codeininit")
        html = getHTMLOf(system.allobjects['codeininit'])
        assert 'functionInInit' in html
    
    def test_basic_package(tmp_path: Path) -> None:
        system = processPackage("basic")
        w = writer.TemplateWriter(tmp_path, TemplateLookup(template_dir))
        w.prepOutputDirectory()
        root, = system.rootobjects
        w._writeDocsFor(root)
        w.writeSummaryPages(system)
        w.writeLinks(system)
        for ob in system.allobjects.values():
            url = ob.url
            if '#' in url:
                url = url[:url.find('#')]
            assert (tmp_path / url).is_file()
        with open(tmp_path / 'basic.html', encoding='utf-8') as f:
            assert 'Package docstring' in f.read()
    
    def test_hasdocstring() -> None:
        system = processPackage("basic")
        from pydoctor.templatewriter.summary import hasdocstring
        assert not hasdocstring(system.allobjects['basic._private_mod'])
        assert hasdocstring(system.allobjects['basic.mod.C.f'])
        sub_f = system.allobjects['basic.mod.D.f']
        assert hasdocstring(sub_f) and not sub_f.docstring
    
    def test_missing_variable() -> None:
        mod = fromText('''
        """Module docstring.
    
        @type thisVariableDoesNotExist: Type for non-existent variable.
        """
        ''')
        html = getHTMLOf(mod)
        assert 'thisVariableDoesNotExist' not in html
    
    
    @pytest.mark.parametrize(
        'className',
        ['NewClassThatMultiplyInherits', 
         'OldClassThatMultiplyInherits',
         'Diamond'],
    )
    def test_multipleInheritanceNewClass(className: str) -> None:
        """
        A class that has multiple bases has all methods in its MRO
        rendered.
        """
        system = processPackage("multipleinheritance")
    
        cls = next(
            cls
            for cls in system.allobjects.values()
            if cls.name == className
        )
    
        assert isinstance(cls, model.Class)
        html = getHTMLOf(cls)
    
        assert "methodA" in html
        assert "methodB" in html
    
        getob = system.allobjects.get
    
        if className == 'Diamond':
            assert util.class_members(cls) == [
                (
                    (getob('multipleinheritance.mod.Diamond'),),
                    [getob('multipleinheritance.mod.Diamond.newMethod')]
                ),
                (
                    (getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
                     getob('multipleinheritance.mod.Diamond')),
                    [getob('multipleinheritance.mod.OldClassThatMultiplyInherits.methodC')]
                ),
                (
                    (getob('multipleinheritance.mod.OldBaseClassA'),
                    getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
                    getob('multipleinheritance.mod.Diamond')),
                    [getob('multipleinheritance.mod.OldBaseClassA.methodA')]),
                    ((getob('multipleinheritance.mod.OldBaseClassB'),
                    getob('multipleinheritance.mod.OldBaseClassA'),
                    getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
                    getob('multipleinheritance.mod.Diamond')),
                    [getob('multipleinheritance.mod.OldBaseClassB.methodB')]),
                    ((getob('multipleinheritance.mod.CommonBase'),
                    getob('multipleinheritance.mod.NewBaseClassB'),
                    getob('multipleinheritance.mod.NewBaseClassA'),
                    getob('multipleinheritance.mod.NewClassThatMultiplyInherits'),
                    getob('multipleinheritance.mod.OldBaseClassB'),
                    getob('multipleinheritance.mod.OldBaseClassA'),
                    getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
                    getob('multipleinheritance.mod.Diamond')),
                    [getob('multipleinheritance.mod.CommonBase.fullName')]) ]
    
    def test_html_template_version() -> None:
        lookup = TemplateLookup(template_dir)
        for template in lookup._templates.values():
            if isinstance(template, HtmlTemplate) and not len(template.text.strip()) == 0:
                assert template.version >= 1
    
    def test_template_lookup_get_template() -> None:
    
        lookup = TemplateLookup(template_dir)
    
        here = Path(__file__).parent
    
        index = lookup.get_template('index.html')
        assert isinstance(index, HtmlTemplate)
        assert index.text == filetext(template_dir / 'index.html')
    
        lookup.add_template(HtmlTemplate(name='footer.html', 
                                text=filetext(here / 'testcustomtemplates' / 'faketemplate' / 'footer.html')))
    
        footer = lookup.get_template('footer.html')
        assert isinstance(footer, HtmlTemplate)
        assert footer.text == filetext(here / 'testcustomtemplates' / 'faketemplate' / 'footer.html')
    
        index2 = lookup.get_template('index.html')
        assert isinstance(index2, HtmlTemplate)
        assert index2.text == filetext(template_dir / 'index.html')
    
        lookup = TemplateLookup(template_dir)
    
        footer = lookup.get_template('footer.html')
        assert isinstance(footer, HtmlTemplate)
        assert footer.text == filetext(template_dir / 'footer.html')
    
        subheader = lookup.get_template('subheader.html')
        assert isinstance(subheader, HtmlTemplate)
        assert subheader.version == -1
    
        table = lookup.get_template('table.html')
        assert isinstance(table, HtmlTemplate)
        assert table.version == 1
    
    def test_template_lookup_add_template_warns() -> None:
    
        lookup = TemplateLookup(template_dir)
    
        here = Path(__file__).parent
    
        with pytest.warns(UserWarning) as catch_warnings:
            with (here / 'testcustomtemplates' / 'faketemplate' / 'nav.html').open('r', encoding='utf-8') as fobj:
                lookup.add_template(HtmlTemplate(text=fobj.read(), name='nav.html'))
        assert len(catch_warnings) == 1, [str(w.message) for w in catch_warnings]
        assert "Your custom template 'nav.html' is out of date" in str(catch_warnings.pop().message)
    
        with pytest.warns(UserWarning) as catch_warnings:
            with (here / 'testcustomtemplates' / 'faketemplate' / 'table.html').open('r', encoding='utf-8') as fobj:
                lookup.add_template(HtmlTemplate(text=fobj.read(), name='table.html'))
        assert len(catch_warnings) == 1, [str(w.message) for w in catch_warnings]
        assert "Could not read 'table.html' template version" in str(catch_warnings.pop().message)
    
        with pytest.warns(UserWarning) as catch_warnings:
            with (here / 'testcustomtemplates' / 'faketemplate' / 'summary.html').open('r', encoding='utf-8') as fobj:
                lookup.add_template(HtmlTemplate(text=fobj.read(), name='summary.html'))
        assert len(catch_warnings) == 1, [str(w.message) for w in catch_warnings]
        assert "Could not read 'summary.html' template version" in str(catch_warnings.pop().message)
    
        with pytest.warns(UserWarning) as catch_warnings:
            lookup.add_templatedir(here / 'testcustomtemplates' / 'faketemplate')
        assert len(catch_warnings) == 2, [str(w.message) for w in catch_warnings]
    
    def test_template_lookup_add_template_allok() -> None:
    
        here = Path(__file__).parent
    
        with warnings.catch_warnings(record=True) as catch_warnings:
            warnings.simplefilter("always")
            lookup = TemplateLookup(template_dir)
            lookup.add_templatedir(here / 'testcustomtemplates' / 'allok')
        assert len(catch_warnings) == 0, [str(w.message) for w in catch_warnings]
    
    def test_template_lookup_add_template_raises() -> None:
    
        here = Path(__file__).parent
    
        lookup = TemplateLookup(template_dir)
    
        with pytest.raises(UnsupportedTemplateVersion):
            lookup.add_template(HtmlTemplate(name="nav.html", text="""
            
            """))
    
        with pytest.raises(ValueError):
            lookup.add_template(HtmlTemplate(name="nav.html", text=" Words "))
        
        with pytest.raises(OverrideTemplateNotAllowed):
            lookup.add_template(HtmlTemplate(name="apidocs.css", text=""))
    
        with pytest.raises(OverrideTemplateNotAllowed):
            lookup.add_template(StaticTemplate(name="index.html", data=bytes()))
    
        lookup.add_templatedir(here / 'testcustomtemplates' / 'subfolders')
    
        with pytest.raises(OverrideTemplateNotAllowed):
            lookup.add_template(StaticTemplate('static', data=bytes()))
        with pytest.raises(OverrideTemplateNotAllowed):
            lookup.add_template(HtmlTemplate('static/fonts', text=""))
        with pytest.raises(OverrideTemplateNotAllowed):
            lookup.add_template(HtmlTemplate('Static/Fonts', text=""))
        # Should not fail
        lookup.add_template(StaticTemplate('tatic/fonts', data=bytes()))
    
    
    def test_template_fromdir_fromfile_failure() -> None:
    
        here = Path(__file__).parent
        
        with pytest.raises(FailedToCreateTemplate):
            [t for t in Template.fromdir(here / 'testcustomtemplates' / 'thisfolderdonotexist')]
        
        template = Template.fromfile(here / 'testcustomtemplates' / 'subfolders', PurePath())
        assert not template
    
        template = Template.fromfile(here / 'testcustomtemplates' / 'thisfolderdonotexist', PurePath('whatever'))
        assert not template
    
    def test_template() -> None:
    
        here = Path(__file__).parent
    
        js_template = Template.fromfile(here / 'testcustomtemplates' / 'faketemplate', PurePath('pydoctor.js'))
        html_template = Template.fromfile(here / 'testcustomtemplates' / 'faketemplate', PurePath('nav.html'))
    
        assert isinstance(js_template, StaticTemplate)
        assert isinstance(html_template, HtmlTemplate)
    
    def test_template_subfolders_write(tmp_path: Path) -> None:
        here = Path(__file__).parent
        test_build_dir = tmp_path
    
        lookup = TemplateLookup(here / 'testcustomtemplates' / 'subfolders')
    
         # writes only the static template
    
        for t in lookup.templates:
            if isinstance(t, StaticTemplate):
                t.write(test_build_dir)
    
        assert test_build_dir.joinpath('static').is_dir()
        assert not test_build_dir.joinpath('atemplate.html').exists()
        assert test_build_dir.joinpath('static/info.svg').is_file()
        assert test_build_dir.joinpath('static/lol.svg').is_file()
        assert test_build_dir.joinpath('static/fonts').is_dir()
        assert test_build_dir.joinpath('static/fonts/bar.svg').is_file()
        assert test_build_dir.joinpath('static/fonts/foo.svg').is_file()
    
    def test_template_subfolders_overrides() -> None:
        here = Path(__file__).parent
    
        lookup = TemplateLookup(here / 'testcustomtemplates' / 'subfolders')
    
        atemplate = lookup.get_template('atemplate.html')
        static_info = lookup.get_template('static/info.svg')
        static_lol = lookup.get_template('static/lol.svg')
        static_fonts_bar = lookup.get_template('static/fonts/bar.svg')
        static_fonts_foo = lookup.get_template('static/fonts/foo.svg')
    
        assert isinstance(atemplate, HtmlTemplate)
        assert isinstance(static_info, StaticTemplate)
        assert isinstance(static_lol, StaticTemplate)
        assert isinstance(static_fonts_bar, StaticTemplate)
        assert isinstance(static_fonts_foo, StaticTemplate)
    
        assert len(static_fonts_foo.data) == 0
    
        # Load subfolder contents that will override only one template: static/fonts/foo.svg
        lookup.add_templatedir(here / 'testcustomtemplates' / 'overridesubfolders')
    
        # test nothing changed
        atemplate = lookup.get_template('atemplate.html')
        static_info = lookup.get_template('static/info.svg')
        static_lol = lookup.get_template('static/lol.svg')
        static_fonts_bar = lookup.get_template('static/fonts/bar.svg')
        static_fonts_foo = lookup.get_template('static/fonts/foo.svg')
    
        assert isinstance(atemplate, HtmlTemplate)
        assert isinstance(static_info, StaticTemplate)
        assert isinstance(static_lol, StaticTemplate)
        assert isinstance(static_fonts_bar, StaticTemplate)
        assert isinstance(static_fonts_foo, StaticTemplate)
    
        # Except for the overriden file
        assert len(static_fonts_foo.data) > 0
    
    def test_template_casing() -> None:
        
        here = Path(__file__).parent
    
        html_template1 = Template.fromfile(here / 'testcustomtemplates' / 'casing', PurePath('test1/nav.HTML'))
        html_template2 = Template.fromfile(here / 'testcustomtemplates' / 'casing', PurePath('test2/nav.Html'))
        html_template3 = Template.fromfile(here / 'testcustomtemplates' / 'casing', PurePath('test3/nav.htmL'))
    
        assert isinstance(html_template1, HtmlTemplate)
        assert isinstance(html_template2, HtmlTemplate)
        assert isinstance(html_template3, HtmlTemplate)
    
    def test_templatelookup_casing() -> None:
        here = Path(__file__).parent
    
        lookup = TemplateLookup(here / 'testcustomtemplates' / 'casing' / 'test1')
        lookup.add_templatedir(here / 'testcustomtemplates' / 'casing' / 'test2')
        lookup.add_templatedir(here / 'testcustomtemplates' / 'casing' / 'test3')
    
        assert len(list(lookup.templates)) == 1
    
        lookup = TemplateLookup(here / 'testcustomtemplates' / 'subfolders')
    
        assert lookup.get_template('atemplate.html') == lookup.get_template('ATemplaTe.HTML')
        assert lookup.get_template('static/fonts/bar.svg') == lookup.get_template('StAtic/Fonts/BAr.svg')
    
        static_fonts_bar = lookup.get_template('static/fonts/bar.svg')
        assert static_fonts_bar.name == 'static/fonts/bar.svg'
    
        lookup.add_template(StaticTemplate('Static/Fonts/Bar.svg', bytes()))
    
        static_fonts_bar = lookup.get_template('static/fonts/bar.svg')
        assert static_fonts_bar.name == 'static/fonts/bar.svg' # the Template.name attribute has been changed by add_template()
    
    def is_fs_case_sensitive() -> bool:
        # From https://stackoverflow.com/a/36580834
        with tempfile.NamedTemporaryFile(prefix='TmP') as tmp_file:
            return(not os.path.exists(tmp_file.name.lower()))
    
    @pytest.mark.skipif(not is_fs_case_sensitive(), reason="This test requires a case sensitive file system.")
    def test_template_subfolders_write_casing(tmp_path: Path) -> None:
    
        here = Path(__file__).parent
        test_build_dir = tmp_path
    
        lookup = TemplateLookup(here / 'testcustomtemplates' / 'subfolders')
    
        lookup.add_template(StaticTemplate('static/Info.svg', data=bytes()))
        lookup.add_template(StaticTemplate('Static/Fonts/Bar.svg', data=bytes()))
    
        # writes only the static template
    
        for t in lookup.templates:
            if isinstance(t, StaticTemplate):
                t.write(test_build_dir)
    
        assert test_build_dir.joinpath('static/info.svg').is_file()
        assert not test_build_dir.joinpath('static/Info.svg').is_file()
    
        assert not test_build_dir.joinpath('Static/Fonts').is_dir()
        assert test_build_dir.joinpath('static/fonts/bar.svg').is_file()
    
    def test_themes_template_versions() -> None:
        """
        All our templates should be up to date.
        """
    
        for theme in get_themes():
            with warnings.catch_warnings(record=True) as w:
                warnings.simplefilter("always")
                lookup = TemplateLookup(importlib_resources.files('pydoctor.themes') / 'base')
                lookup.add_templatedir(importlib_resources.files('pydoctor.themes') / theme)
                assert len(w) == 0, [str(_w) for _w in w]
    
    @pytest.mark.parametrize('func', [isPrivate, isClassNodePrivate])
    def test_isPrivate(func: Callable[[model.Class], bool]) -> None:
        """A documentable object is private if it is private itself or
        lives in a private context.
        """
        mod = fromText('''
        class Public:
            class Inner:
                pass
        class _Private:
            class Inner:
                pass
        ''')
        public = mod.contents['Public']
        assert not func(cast(model.Class, public))
        assert not func(cast(model.Class, public.contents['Inner']))
        private = mod.contents['_Private']
        assert func(cast(model.Class, private))
        assert func(cast(model.Class, private.contents['Inner']))
    
    
    def test_isClassNodePrivate() -> None:
        """A node for a private class with public subclasses is considered public."""
        mod = fromText('''
        class _BaseForPublic:
            pass
        class _BaseForPrivate:
            pass
        class Public(_BaseForPublic):
            pass
        class _Private(_BaseForPrivate):
            pass
        ''')
        assert not isClassNodePrivate(cast(model.Class, mod.contents['Public']))
        assert isClassNodePrivate(cast(model.Class, mod.contents['_Private']))
        assert not isClassNodePrivate(cast(model.Class, mod.contents['_BaseForPublic']))
        assert isClassNodePrivate(cast(model.Class, mod.contents['_BaseForPrivate']))
    
    @systemcls_param
    def test_format_function_def_overloads(systemcls: Type[model.System]) -> None:
        mod = fromText("""
            from typing import overload, Union
            @overload
            def parse(s: str) -> str:
                ...
            @overload
            def parse(s: bytes) -> bytes:
                ...
            def parse(s: Union[str, bytes]) -> Union[str, bytes]:
                pass
            """, systemcls=systemcls)
        func = mod.contents['parse']
        assert isinstance(func, model.Function)
        
        # We intentionally remove spaces before comparing
        overloads_html = stanutils.flatten_text(list(pages.format_overloads(func))).replace(' ','')
        assert '''(s:str)->str:''' in overloads_html
        assert '''(s:bytes)->bytes:''' in overloads_html
    
        # Confirm the actual function definition is not rendered
        function_def_html = stanutils.flatten_text(list(pages.format_function_def(func.name, func.is_async, func)))
        assert function_def_html == ''
    
    def test_format_signature() -> None:
        """Test C{pages.format_signature}. 
        
        @note: This test will need to be adapted one we include annotations inside signatures.
        """
        mod = fromText(r'''
        def func(a:Union[bytes, str]=_get_func_default(str), b:Any=re.compile(r'foo|bar'), *args:str, **kwargs:Any) -> Iterator[Union[str, bytes]]:
            ...
        ''')
        assert ("""(a:Union[bytes,str]=_get_func_default(str),b:Any=re.compile(r'foo|bar'),*args:str,**kwargs:Any)->Iterator[Union[str,bytes]]""") in \
            stanutils.flatten_text(pages.format_signature(cast(model.Function, mod.contents['func']))).replace(' ','')
    
    def test_format_decorators() -> None:
        """Test C{pages.format_decorators}"""
        mod = fromText(r'''
        @string_decorator(set('\\/:*?"<>|\f\v\t\r\n'))
        @simple_decorator(max_examples=700, deadline=None, option=range(10))
        def func():
            ...
        ''')
        stan = stanutils.flatten(list(pages.format_decorators(cast(model.Function, mod.contents['func']))))
        assert stan == ("""@string_decorator(set('"""
                        r"""\\/:*?"<>|\f\v\t\r\n"""
                        """'))
    @simple_decorator""" """(max_examples=700, deadline=None, option=range(10))
    """) def test_compact_module_summary() -> None: system = model.System() top = fromText('', modname='top', is_package=True, system=system) for x in range(50): fromText('', parent_name='top', modname='sub' + str(x), system=system) ul = moduleSummary(top, '').children[-1] assert ul.tagName == 'ul' # type: ignore assert len(ul.children) == 50 # type: ignore # the 51th module triggers the compact summary, no matter if it's a package or module fromText('', parent_name='top', modname='_yet_another_sub', system=system, is_package=True) ul = moduleSummary(top, '').children[-1] assert ul.tagName == 'ul' # type: ignore assert len(ul.children) == 1 # type: ignore # test that the last module is private assert 'private' in ul.children[0].children[-1].attributes['class'] # type: ignore # for the compact summary no submodule (packages) may have further submodules fromText('', parent_name='top._yet_another_sub', modname='subsubmodule', system=system) ul = moduleSummary(top, '').children[-1] assert ul.tagName == 'ul' # type: ignore assert len(ul.children) == 51 # type: ignore def test_index_contains_infos(tmp_path: Path) -> None: """ Test if index.html contains the following informations: - meta generator tag - nav and links to modules, classes, names - link to the root packages - pydoctor github link in the footer """ infos = (f'
    allgames
    ', 'basic', 'pydoctor',) system = model.System() builder = system.systemBuilder(system) builder.addModule(testpackages / "allgames") builder.addModule(testpackages / "basic") builder.buildModules() w = writer.TemplateWriter(tmp_path, TemplateLookup(template_dir)) w.writeSummaryPages(system) with open(tmp_path / 'index.html', encoding='utf-8') as f: page = f.read() for i in infos: assert i in page, page @pytest.mark.parametrize('_order', ["alphabetical", "source"]) def test_objects_order_mixed_modules_and_packages(_order:str) -> None: """ Packages and modules are mixed when sorting with objects_order. """ system = model.System() top = fromText('', modname='top', is_package=True, system=system) fromText('', parent_name='top', modname='aaa', system=system) fromText('', parent_name='top', modname='bbb', system=system) fromText('', parent_name='top', modname='aba', system=system, is_package=True) _sorted = sorted(top.contents.values(), key=util.objects_order(_order)) # type:ignore names = [s.name for s in _sorted] assert names == ['aaa', 'aba', 'bbb'] def test_change_member_order() -> None: """ Default behaviour is to sort everything by privacy, kind and then by name. But we allow to customize the class and modules members independendly, the reason for this is to permit to match rustdoc behaviour, that is to sort class members by source, the rest by name. """ system = model.System() assert system.options.cls_member_order == system.options.mod_member_order == "alphabetical" mod = fromText('''\ class Foo: def start():... def process_link():... def process_emphasis():... def process_blockquote():... def process_table():... def end():... class Bar:... b,a = 1,2 ''', system=system) _sorted = sorted(mod.contents.values(), key=system.membersOrder(mod)) assert [s.name for s in _sorted] == ['Bar', 'Foo', 'a', 'b'] # default ordering is alphabetical system.options.mod_member_order = 'source' _sorted = sorted(mod.contents.values(), key=system.membersOrder(mod)) assert [s.name for s in _sorted] == ['Foo', 'Bar', 'b', 'a'] Foo = mod.contents['Foo'] _sorted = sorted(Foo.contents.values(), key=system.membersOrder(Foo)) names = [s.name for s in _sorted] assert names ==['end', 'process_blockquote', 'process_emphasis', 'process_link', 'process_table', 'start',] system.options.cls_member_order = "source" _sorted = sorted(Foo.contents.values(), key=system.membersOrder(Foo)) names = [s.name for s in _sorted] assert names == ['start', 'process_link', 'process_emphasis', 'process_blockquote', 'process_table', 'end'] def test_ivar_field_order_precedence(capsys: CapSys) -> None: """ We special case the linen umber coming from docstring fields such that they can get overriden by AST linenumber. """ system = model.System(model.Options.from_args(['--cls-member-order=source'])) mod = fromText(''' import attr __docformat__ = 'restructuredtext' @attr.s class Foo: """ :ivar a: `broken1 <>`_ Thing. :ivar b: `broken2 <>`_ Stuff. """ b = attr.ib() a = attr.ib() ''', system=system) Foo = mod.contents['Foo'] getHTMLOf(Foo) assert Foo.docstring_lineno == 7 assert Foo.parsed_docstring.fields[0].lineno == 0 # type:ignore assert Foo.parsed_docstring.fields[1].lineno == 1 # type:ignore assert Foo.contents['a'].linenumber == 12 assert Foo.contents['b'].linenumber == 11 assert Foo.contents['a'].docstring_lineno == 7 assert Foo.contents['b'].docstring_lineno == 8 _sorted = sorted(Foo.contents.values(), key=system.membersOrder(Foo)) names = [s.name for s in _sorted] assert names == ['b', 'a'] # should be 'b', 'a'. src_crash_xml_entities = '''\ """ These are non-breaking spaces ============================= docstring. """ A: Literal['These are non-breaking spaces.'] = True B = ({}, 'These are non-breaking spaces.') V = True """ These are non-breaking spaces. """ @thing('These are non-breaking spaces.') def g(): ... def h() -> Literal['These are non-breaking spaces.']: ... def f(a:Literal['These are non-breaking spaces.']='These are non-breaking spaces.') -> int: return {} def i(): """ Stuff @rtype: V of C """ ... class C(Literal['These are non-breaking spaces.']): ... ''' @pytest.mark.parametrize('processtypes', [True, False]) def test_crash_xmlstring_entities(capsys:CapSys, processtypes:bool) -> None: """ Crash test for https://github.com/twisted/pydoctor/issues/641 This test might fail in the future, when twisted's XMLString supports XHTML entities (see https://github.com/twisted/twisted/issues/11581). But it will always fail for python 3.6 since twisted dropped support for these versions of python. """ system = model.System() system.options.verbosity = -1 system.options.processtypes=processtypes mod = fromText(src_crash_xml_entities, system=system, modname='test') for o in mod.system.allobjects.values(): epydoc2stan.ensure_parsed_docstring(o) getHTMLOf(mod) getHTMLOf(mod.contents['C']) out = capsys.readouterr().out warnings = '''\ test:2: bad docstring: SAXParseException: .+ undefined entity test:25: bad signature: SAXParseException: .+ undefined entity test:17: bad rendering of decorators: SAXParseException: .+ undefined entity test:21: bad signature: SAXParseException: .+ undefined entity test:30: bad docstring: SAXParseException: .+ undefined entity test:8: bad annotation: SAXParseException: :.+ undefined entity test:10: bad rendering of constant: SAXParseException: .+ undefined entity test:14: bad docstring: SAXParseException: .+ undefined entity test:36: bad rendering of class signature: SAXParseException: .+ undefined entity '''.splitlines() # Some how the type processing get rid of the non breaking spaces, but it's more an implementation # detail rather than a fix for the bug. if processtypes is True: warnings.remove('test:30: bad docstring: SAXParseException: .+ undefined entity') assert re.match('\n'.join(warnings), out) @pytest.mark.parametrize('processtypes', [True, False]) def test_crash_xmlstring_entities_rst(capsys:CapSys, processtypes:bool) -> None: """Idem for RST""" system = model.System() system.options.verbosity = -1 system.options.processtypes=processtypes system.options.docformat = 'restructuredtext' mod = fromText(src_crash_xml_entities.replace('@type', ':type').replace('@rtype', ':rtype').replace('==', "--"), modname='test', system=system) for o in mod.system.allobjects.values(): epydoc2stan.ensure_parsed_docstring(o) getHTMLOf(mod) getHTMLOf(mod.contents['C']) out = capsys.readouterr().out warn_str = '''\ test:2: bad docstring: SAXParseException: .+ undefined entity test:25: bad signature: SAXParseException: .+ undefined entity test:17: bad rendering of decorators: SAXParseException: .+ undefined entity test:21: bad signature: SAXParseException: .+ undefined entity test:30: bad docstring: SAXParseException: .+ undefined entity test:8: bad annotation: SAXParseException: .+ undefined entity test:10: bad rendering of constant: SAXParseException: .+ undefined entity test:14: bad docstring: SAXParseException: .+ undefined entity test:36: bad rendering of class signature: SAXParseException: .+ undefined entity ''' warnings = warn_str.splitlines() if processtypes is True: warnings.remove('test:30: bad docstring: SAXParseException: .+ undefined entity') assert re.match('\n'.join(warnings), out) def test_constructor_renders(capsys:CapSys) -> None: src = '''\ class Animal(object): # pydoctor can infer the constructor to be: "Animal(name)" def __new__(cls, name): ... ''' mod = fromText(src) html = getHTMLOf(mod.contents['Animal']) assert 'Constructor: ' in html assert 'Animal(name)' in html def test_typealias_string_form_linked() -> None: """ The type aliases should be unstring before beeing presented to reader, such that all elements can be linked. Test for issue https://github.com/twisted/pydoctor/issues/704 """ mod = fromText(''' from typing import Callable ParserFunction = Callable[[str, List['ParseError']], 'ParsedDocstring'] class ParseError: ... class ParsedDocstring: ... ''', modname='pydoctor.epydoc.markup') typealias = mod.contents['ParserFunction'] assert isinstance(typealias, model.Attribute) html = getHTMLOfAttribute(typealias) assert 'href="pydoctor.epydoc.markup.ParseError.html"' in html assert 'href="pydoctor.epydoc.markup.ParsedDocstring.html"' in html def test_class_hierarchy_links_top_level_names() -> None: system = model.System() system.intersphinx = InMemoryInventory() # type:ignore src = '''\ from socket import socket class Stuff(socket): ... ''' mod = fromText(src, system=system) index = flatten(ClassIndexPage(mod.system, TemplateLookup(template_dir))) assert 'href="https://docs.python.org/3/library/socket.html#socket.socket"' in index def test_canonical_links() -> None: src = ''' var = True class Cls: foo = False ''' mod = fromText(src, modname='t', system=model.System(model.Options.from_args( ['--html-base-url=https://example.org/t/docs'] ))) html1 = getHTMLOf(mod) html2 = getHTMLOf(mod.contents['Cls']) assert ' None: src = ''' var = True class Cls: foo = False ''' mod = fromText(src, modname='t', system=model.System(model.Options.from_args( ['--html-base-url=https://example.org/t/docs'] ))) mod2 = fromText(src, modname='t2', system=mod.system) html1 = getHTMLOf(mod) html2 = getHTMLOf(mod.contents['Cls']) assert ' None: """ It recognizes Twisted deprecation decorators and add the deprecation info as part of the documentation. """ # Adjusted from Twisted's tests at # https://github.com/twisted/twisted/blob/3bbe558df65181ed455b0c5cc609c0131d68d265/src/twisted/python/test/test_release.py#L516 system = systemcls() system.options.verbosity = -1 mod = fromText( """ from twisted.python.deprecate import deprecated, deprecatedProperty from incremental import Version @deprecated(Version('Twisted', 15, 0, 0), 'Baz') def foo(): 'docstring' from twisted.python import deprecate import incremental @deprecate.deprecated(incremental.Version('Twisted', 16, 0, 0)) def _bar(): 'should appear' from twisted.python.versions import Version as AliasVersion @deprecated(AliasVersion('Twisted', 14, 2, 3), replacement='stuff') class Baz: @deprecatedProperty(AliasVersion('Twisted', 'NEXT', 0, 0), replacement='faam') @property def foom(self): ... @property def faam(self): ... class stuff: ... """, system=system, modname='mod') mod_html_text = flatten_text(html2stan(test_templatewriter.getHTMLOf(mod))) class_html_text = flatten_text(html2stan(test_templatewriter.getHTMLOf(mod.contents['Baz']))) assert capsys.readouterr().out == '' assert 'docstring' in mod_html_text assert 'should appear' in mod_html_text assert re.match(_html_template_with_replacement.format( name='foo', package='Twisted', version=r'15\.0\.0', replacement='Baz' ), mod_html_text, re.DOTALL), mod_html_text assert re.match(_html_template_without_replacement.format( name='_bar', package='Twisted', version=r'16\.0\.0' ), mod_html_text, re.DOTALL), mod_html_text _class = mod.contents['Baz'] assert len(_class.extra_info)==1 assert re.match(_html_template_with_replacement.format( name='Baz', package='Twisted', version=r'14\.2\.3', replacement='stuff' ), flatten_text(_class.extra_info[0].to_stan(mod.docstring_linker)).strip(), re.DOTALL) assert re.match(_html_template_with_replacement.format( name='Baz', package='Twisted', version=r'14\.2\.3', replacement='stuff' ), class_html_text, re.DOTALL), class_html_text assert re.match(_html_template_with_replacement.format( name='foom', package='Twisted', version=r'NEXT', replacement='faam' ), class_html_text, re.DOTALL), class_html_text @twisted_deprecated_systemcls_param def test_twisted_python_deprecate_arbitrary_text(capsys: CapSys, systemcls: Type[model.System]) -> None: """ The deprecated object replacement can be given as a free form text as well, it does not have to be an identifier or an object. """ system = systemcls() system.options.verbosity = -1 mod = fromText( """ from twisted.python.deprecate import deprecated from incremental import Version @deprecated(Version('Twisted', 15, 0, 0), replacement='just use something else') def foo(): ... """, system=system, modname='mod') mod_html = test_templatewriter.getHTMLOf(mod) assert not capsys.readouterr().out assert 'just use something else' in mod_html @twisted_deprecated_systemcls_param def test_twisted_python_deprecate_security(capsys: CapSys, systemcls: Type[model.System]) -> None: system = systemcls() system.options.verbosity = -1 mod = fromText( """ from twisted.python.deprecate import deprecated from incremental import Version @deprecated(Version('Twisted\\n.. raw:: html\\n\\n ', 15, 0, 0), 'Baz') def foo(): ... @deprecated(Version('Twisted', 16, 0, 0), replacement='\\n.. raw:: html\\n\\n ') def _bar(): ... """, system=system, modname='mod') mod_html = test_templatewriter.getHTMLOf(mod) assert capsys.readouterr().out == '''mod:4: Invalid package name: 'Twisted\\n.. raw:: html\\n\\n ' ''', capsys.readouterr().out assert '' not in mod_html @twisted_deprecated_systemcls_param def test_twisted_python_deprecate_corner_cases(capsys: CapSys, systemcls: Type[model.System]) -> None: """ It does not crash and report appropriate warnings while handling Twisted deprecation decorators. """ system = systemcls() system.options.verbosity = -1 mod = fromText( """ from twisted.python.deprecate import deprecated, deprecatedProperty from incremental import Version # wrong incremental.Version() call (missing micro) @deprecated(Version('Twisted', 15, 0), 'Baz') def foo(): 'docstring' # wrong incremental.Version() call (argument should be 'NEXT') @deprecated(Version('Twisted', 'latest', 0, 0)) def _bar(): 'should appear' # wrong deprecated() call (argument should be incremental.Version() call) @deprecated('14.2.3', replacement='stuff') class Baz: # bad deprecation text: replacement not found @deprecatedProperty(Version('Twisted', 'NEXT', 0, 0), replacement='notfound') @property def foom(self): ... # replacement as callable works @deprecatedProperty(Version('Twisted', 'NEXT', 0, 0), replacement=Baz.faam) @property def foum(self): ... @property def faam(self): ... class stuff: ... """, system=system, modname='mod') test_templatewriter.getHTMLOf(mod) class_html_text = flatten_text(html2stan(test_templatewriter.getHTMLOf(mod.contents['Baz']))) assert capsys.readouterr().out=="""mod:5: missing a required argument: 'micro' mod:10: Invalid call to incremental.Version(), 'major' should be an int or 'NEXT'. mod:15: Invalid call to twisted.python.deprecate.deprecated(), first argument should be a call to incremental.Version() mod:20: Cannot find link target for "notfound" """, capsys.readouterr().out assert re.match(_html_template_with_replacement.format( name='foom', package='Twisted', version='NEXT', replacement='notfound' ), class_html_text, re.DOTALL), class_html_text assert re.match(_html_template_with_replacement.format( name='foum', package='Twisted', version='NEXT', replacement='mod.Baz.faam' ), class_html_text, re.DOTALL), class_html_text @twisted_deprecated_systemcls_param def test_twisted_python_deprecate_else_branch(capsys: CapSys, systemcls: Type[model.System]) -> None: """ When @deprecated decorator is used within the else branch of a if block and the same name is defined in the body branch, the name is not marked as deprecated. """ mod = fromText(''' if sys.version_info>(3.8): def foo(): ... class Bar: ... else: from incremental import Version @twisted.python.deprecate.deprecated(Version('python', 3, 8, 0), replacement='just use newer python version') def foo(): ... @twisted.python.deprecate.deprecated(Version('python', 3, 8, 0), replacement='just use newer python version') class Bar: ... ''', systemcls=systemcls) assert not capsys.readouterr().out assert 'just use newer python version' not in test_templatewriter.getHTMLOf(mod.contents['foo']) assert 'just use newer python version' not in test_templatewriter.getHTMLOf(mod.contents['Bar'])pydoctor-24.11.2/pydoctor/test/test_type_fields.py000066400000000000000000000413401473665144200223040ustar00rootroot00000000000000from typing import List from textwrap import dedent from pydoctor.epydoc.markup import ParseError, get_parser_by_name from pydoctor.test.epydoc.test_restructuredtext import prettify from pydoctor.test.test_templatewriter import getHTMLOfAttribute from pydoctor.test import NotFoundLinker, CapSys from pydoctor.test.epydoc import parse_docstring from pydoctor.test.test_epydoc2stan import docstring2html from pydoctor.test.test_astbuilder import fromText from pydoctor.stanutils import flatten from pydoctor.napoleon.docstring import TokenType from pydoctor.epydoc.markup._types import ParsedTypeDocstring import pydoctor.epydoc.markup from pydoctor import model from twisted.web.template import Tag def doc2html(doc: str, markup: str, processtypes: bool = False) -> str: return ''.join(prettify(flatten(parse_docstring(doc, markup, processtypes).to_stan(NotFoundLinker()))).splitlines()) def test_types_to_node_no_markup() -> None: cases = [ 'rtype: list of int or float or None', "rtype: {'F', 'C', 'N'}, default 'N'", "rtype: DataFrame, optional", "rtype: List[str] or list(bytes), optional",] for s in cases: assert doc2html(':'+s, 'restructuredtext', False) == doc2html('@'+s, 'epytext') assert doc2html(':'+s, 'restructuredtext', True) == doc2html('@'+s, 'epytext') def test_to_node_markup() -> None: cases = [ ('L{me}', '`me`'), ('B{No!}', '**No!**'), ('I{here}', '*here*'), ('L{complicated string} or L{strIO }', '`complicated string` or `strIO `') ] for epystr, rststr in cases: assert doc2html(rststr, 'restructuredtext') == doc2html(epystr, 'epytext') def test_parsed_type_convert_obj_tokens_to_stan() -> None: convert_obj_tokens_cases = [ ([("list", TokenType.OBJ), ("(", TokenType.DELIMITER), ("int", TokenType.OBJ), (")", TokenType.DELIMITER)], [(Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ)]), ([("list", TokenType.OBJ), ("(", TokenType.DELIMITER), ("int", TokenType.OBJ), (")", TokenType.DELIMITER), (", ", TokenType.DELIMITER), ("optional", TokenType.CONTROL)], [(Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ), (", ", TokenType.DELIMITER), ("optional", TokenType.CONTROL)]), ] ann = ParsedTypeDocstring("") for tokens_types, expected_token_types in convert_obj_tokens_cases: assert str(ann._convert_obj_tokens_to_stan(tokens_types, NotFoundLinker()))==str(expected_token_types) def typespec2htmlvianode(s: str, markup: str) -> str: err: List[ParseError] = [] parsed_doc = get_parser_by_name(markup)(s, err) assert not err ann = ParsedTypeDocstring(parsed_doc.to_node(), warns_on_unknown_tokens=True) html = flatten(ann.to_stan(NotFoundLinker())) assert not ann.warnings return html def typespec2htmlviastr(s: str) -> str: ann = ParsedTypeDocstring(s, warns_on_unknown_tokens=True) html = flatten(ann.to_stan(NotFoundLinker())) assert not ann.warnings return html def test_parsed_type() -> None: parsed_type_cases = [ ('list of int or float or None', 'list of int or float or None'), ("{'F', 'C', 'N'}, default 'N'", """{'F', 'C', 'N'}, default 'N'"""), ("DataFrame, optional", "DataFrame, optional"), ("List[str] or list(bytes), optional", "List[str] or list(bytes), optional"), (('`complicated string` or `strIO `', 'L{complicated string} or L{strIO }'), 'complicated string or strIO'), ] for string, excepted_html in parsed_type_cases: rst_string = '' epy_string = '' if isinstance(string, tuple): rst_string, epy_string = string elif isinstance(string, str): rst_string = epy_string = string assert typespec2htmlviastr(rst_string) == excepted_html assert typespec2htmlvianode(rst_string, 'restructuredtext') == excepted_html assert typespec2htmlvianode(epy_string, 'epytext') == excepted_html def test_processtypes(capsys: CapSys) -> None: """ Currently, numpy and google type parsing happens both at the string level with L{pydoctor.napoleon.docstring.TypeDocstring} and at the docutils nodes L{ParsedTypeDocstring} for type fields (``type`` and ``rtype``). """ cases = [ ( ( """ @param arg: A param. @type arg: list of int or float or None """, """ :param arg: A param. :type arg: list of int or float or None """, """ Args: arg (list of int or float or None): A param. """, """ Args ---- arg: list of int or float or None A param. """, ), ("list of int or float or None", "list of int or float or None") ), ( ( """ @param arg: A param. @type arg: L{complicated string} or L{strIO }, optional """, """ :param arg: A param. :type arg: `complicated string` or `strIO `, optional """, """ Args: arg (`complicated string` or `strIO `, optional): A param. """, """ Args ---- arg: `complicated string` or `strIO `, optional A param. """, ), ("complicated string or strIO, optional", "complicated string or strIO, optional") ), ] for strings, excepted_html in cases: epy_string, rst_string, goo_string, numpy_string = strings excepted_html_no_process_types, excepted_html_type_processed = excepted_html assert flatten(parse_docstring(epy_string, 'epytext').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_no_process_types assert flatten(parse_docstring(rst_string, 'restructuredtext').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_no_process_types assert flatten(parse_docstring(dedent(goo_string), 'google').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed assert flatten(parse_docstring(dedent(numpy_string), 'numpy').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed assert flatten(parse_docstring(epy_string, 'epytext', processtypes=True).fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed assert flatten(parse_docstring(rst_string, 'restructuredtext', processtypes=True).fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed def test_processtypes_more() -> None: # Using numpy style-only because it suffice. cases = [ (""" Yields ------ working: bool Whether it's working. not_working: bool Whether it's not working. """, """
    • working: bool - Whether it's working.
    • not_working: bool - Whether it's not working.
    """), (""" Returns ------- name: str the name description. content: str the content description. """, """
    • name: str - the name description.
    • content: str - the content description.
    """), ] for string, excepted_html in cases: assert flatten(parse_docstring(dedent(string), 'numpy').fields[-1].body().to_stan(NotFoundLinker())).strip() == excepted_html def test_processtypes_with_system(capsys: CapSys) -> None: system = model.System() system.options.processtypes = True mod = fromText(''' a = None """ Variable documented by inline docstring. @type: list of int or float or None """ ''', modname='test', system=system) a = mod.contents['a'] docstring2html(a) assert isinstance(a.parsed_type, ParsedTypeDocstring) fmt = flatten(a.parsed_type.to_stan(NotFoundLinker())) captured = capsys.readouterr().out assert not captured assert "list of int or float or None" == fmt def test_processtypes_corner_cases(capsys: CapSys) -> None: """ The corner cases does not trigger any warnings because they are still valid types. Warnings should be triggered in L{pydoctor.napoleon.docstring.TypeDocstring._trigger_warnings}, we should be careful with triggering warnings because whether the type spec triggers warnings is used to check is a string is a valid type or not. """ def process(typestr: str) -> str: system = model.System() system.options.processtypes = True mod = fromText(f''' a = None """ @type: {typestr} """ ''', modname='test', system=system) a = mod.contents['a'] docstring2html(a) assert isinstance(a.parsed_type, ParsedTypeDocstring) fmt = flatten(a.parsed_type.to_stan(NotFoundLinker())) captured = capsys.readouterr().out assert not captured return fmt assert process('default[str]') == "default[str]" assert process('[str]') == "[str]" assert process('[,]') == "[, ]" assert process('[[]]') == "[[]]" assert process(', [str]') == ", [str]" assert process(' of [str]') == "of[str]" assert process(' or [str]') == "or[str]" assert process(': [str]') == ": [str]" assert process("'hello'[str]") == "'hello'[str]" assert process('"hello"[str]') == "\"hello\"[str]" assert process('`hello`[str]') == "hello[str]" assert process('`hello `_[str]') == """hello[str]""" assert process('**hello**[str]') == "hello[str]" assert process('["hello" or str, default: 2]') == """["hello" or str, default: 2]""" # HTML ids for problematic elements changed in docutils 0.18.0, and again in 0.19.0, so we're not testing for the exact content anymore. problematic = process('Union[`hello <>`_[str]]') assert "`hello <>`_" in problematic assert "str" in problematic def test_processtypes_warning_unexpected_element(capsys: CapSys) -> None: epy_string = """ @param arg: A param. @type arg: L{complicated string} or L{strIO }, optional >>> print('example') """ rst_string = """ :param arg: A param. :type arg: `complicated string` or `strIO `, optional >>> print('example') """ expected = """complicated string or strIO, optional""" # Test epytext epy_errors: List[ParseError] = [] epy_parsed = pydoctor.epydoc.markup.processtypes(get_parser_by_name('epytext'))(epy_string, epy_errors) assert len(epy_errors)==1 assert "Unexpected element in type specification field: element 'doctest_block'" in epy_errors.pop().descr() assert flatten(epy_parsed.fields[-1].body().to_stan(NotFoundLinker())).replace('\n', '') == expected # Test restructuredtext rst_errors: List[ParseError] = [] rst_parsed = pydoctor.epydoc.markup.processtypes(get_parser_by_name('restructuredtext'))(rst_string, rst_errors) assert len(rst_errors)==1 assert "Unexpected element in type specification field: element 'doctest_block'" in rst_errors.pop().descr() assert flatten(rst_parsed.fields[-1].body().to_stan(NotFoundLinker())).replace('\n', ' ') == expected def test_napoleon_types_warnings(capsys: CapSys) -> None: """ This is not the same as test_token_type_invalid() since this checks our integration with pydoctor and validates we **actually** trigger the warnings. """ # from napoleon upstream: # unbalanced parenthesis in type expression # unbalanced square braces in type expression # invalid value set (missing closing brace) # invalid value set (missing opening brace) # malformed string literal (missing closing quote) # malformed string literal (missing opening quote) # from our custom napoleon: # invalid type: '{before_colon}'. Probably missing colon. # from our integration with docutils: # Unexpected element in type specification field src = ''' __docformat__ = 'google' def foo(**args): """ Keyword Args: a (list(str): thing b (liststr]): stuff c ({1,2,3): num d ('1',2,3}): num or str e (str, '1', '2): str f (str, "1", 2"): str docformat Can be one of: - "numpy" - "google" h: things k: stuff :type h: stuff >>> python :type k: a paragraph another one """ ''' mod = fromText(src, modname='warns') docstring2html(mod.contents['foo']) # Filter docstring linker warnings lines = [line for line in capsys.readouterr().out.splitlines() if 'Cannot find link target' not in line] # Line numbers are off because they are based on the reStructuredText version of the docstring # which includes much more lines because of the :type arg: fields. assert '\n'.join(lines) == '''\ warns:13: bad docstring: invalid type: 'docformatCan be one of'. Probably missing colon. warns:7: bad docstring: unbalanced parenthesis in type expression warns:9: bad docstring: unbalanced square braces in type expression warns:11: bad docstring: invalid value set (missing closing brace): {1 warns:13: bad docstring: invalid value set (missing opening brace): 3} warns:15: bad docstring: malformed string literal (missing closing quote): '2 warns:17: bad docstring: malformed string literal (missing opening quote): 2" warns:24: bad docstring: Unexpected element in type specification field: element 'doctest_block'. This value should only contain text or inline markup. warns:28: bad docstring: Unexpected element in type specification field: element 'paragraph'. This value should only contain text or inline markup.''' def test_process_types_with_consolidated_fields(capsys: CapSys) -> None: """ Test for issue https://github.com/twisted/pydoctor/issues/765 """ src = ''' class V: """ Doc. :CVariables: `id` : int Classvar doc. """ ''' system = model.System() system.options.processtypes = True system.options.docformat = 'restructuredtext' mod = fromText(src, modname='do_not_warn_please', system=system) attr = mod.contents['V'].contents['id'] assert isinstance(attr, model.Attribute) html = getHTMLOfAttribute(attr) # Filter docstring linker warnings lines = [line for line in capsys.readouterr().out.splitlines() if 'Cannot find link target' not in line] assert not lines assert 'int' in html pydoctor-24.11.2/pydoctor/test/test_utils.py000066400000000000000000000032331473665144200211340ustar00rootroot00000000000000from typing import Dict, Optional import pytest from pydoctor.templatewriter.util import CaseInsensitiveDict class TestCaseInsensitiveDict: @pytest.fixture(autouse=True) def setup(self) -> None: """CaseInsensitiveDict instance with "Accept" header.""" self.case_insensitive_dict: CaseInsensitiveDict[str] = CaseInsensitiveDict() self.case_insensitive_dict['Accept'] = 'application/json' def test_list(self) -> None: assert list(self.case_insensitive_dict) == ['Accept'] possible_keys = pytest.mark.parametrize('key', ('accept', 'ACCEPT', 'aCcEpT', 'Accept')) @possible_keys def test_getitem(self, key: str) -> None: assert self.case_insensitive_dict[key] == 'application/json' @possible_keys def test_delitem(self, key: str) -> None: del self.case_insensitive_dict[key] assert key not in self.case_insensitive_dict def test_lower_items(self) -> None: assert list(self.case_insensitive_dict.lower_items()) == [('accept', 'application/json')] def test_repr(self) -> None: assert repr(self.case_insensitive_dict) == "{'Accept': 'application/json'}" def test_copy(self) -> None: copy = self.case_insensitive_dict.copy() assert copy is not self.case_insensitive_dict assert copy == self.case_insensitive_dict @pytest.mark.parametrize( 'other, result', ( ({'AccePT': 'application/json'}, True), ({}, False), (None, False) ) ) def test_instance_equality(self, other: Optional[Dict[str, str]], result: bool) -> None: assert (self.case_insensitive_dict == other) is result pydoctor-24.11.2/pydoctor/test/test_visitor.py000066400000000000000000000127301473665144200214750ustar00rootroot00000000000000 from typing import Iterable from pydoctor.test import CapSys from pydoctor.test.epydoc.test_restructuredtext import parse_rst from pydoctor import visitor from docutils import nodes def dump(node: nodes.Node, text:str='') -> None: print('{}{:<15} line: {}, rawsource: {}'.format( text, type(node).__name__, node.line, getattr(node, 'rawsource', node.astext()).replace('\n', '\\n'))) class DocutilsNodeVisitor(visitor.Visitor[nodes.Node]): def unknown_visit(self, ob: nodes.Node) -> None: pass def unknown_departure(self, ob: nodes.Node) -> None: pass @classmethod def get_children(cls, ob:nodes.Node) -> Iterable[nodes.Node]: if isinstance(ob, nodes.Element): return ob.children return [] class MainVisitor(DocutilsNodeVisitor): def visit_title_reference(self, node: nodes.Node) -> None: raise self.SkipNode() class ParagraphDump(visitor.VisitorExt[nodes.Node]): when = visitor.When.AFTER def visit_paragraph(self, node: nodes.Node) -> None: dump(node) class TitleReferenceDumpAfter(visitor.VisitorExt[nodes.Node]): when = visitor.When.AFTER def visit_title_reference(self, node: nodes.Node) -> None: dump(node) class GenericDump(DocutilsNodeVisitor): def unknown_visit(self, node: nodes.Node) -> None: dump(node, '[visit-main] ') def unknown_departure(self, node: nodes.Node) -> None: dump(node, '[depart-main] ') class GenericDumpAfter(visitor.VisitorExt[nodes.Node]): when = visitor.When.INNER def unknown_visit(self, node: nodes.Node) -> None: dump(node, '[visit-inner] ') def unknown_departure(self, node: nodes.Node) -> None: dump(node, '[depart-inner] ') class GenericDumpBefore(visitor.VisitorExt[nodes.Node]): when = visitor.When.OUTTER def unknown_visit(self, node: nodes.Node) -> None: dump(node, '[visit-outter] ') def unknown_departure(self, node: nodes.Node) -> None: dump(node, '[depart-outter] ') def test_visitor_ext(capsys:CapSys) -> None: parsed_doc = parse_rst(''' Hello ===== Dolor sit amet ''') doc = parsed_doc.to_node() vis = GenericDump() vis.extensions.add(GenericDumpAfter, GenericDumpBefore) vis.walkabout(doc) assert capsys.readouterr().out == r'''[visit-outter] document line: None, rawsource: [visit-main] document line: None, rawsource: [visit-inner] document line: None, rawsource: [visit-outter] title line: 3, rawsource: Hello [visit-main] title line: 3, rawsource: Hello [visit-inner] title line: 3, rawsource: Hello [visit-outter] Text line: None, rawsource: Hello [visit-main] Text line: None, rawsource: Hello [visit-inner] Text line: None, rawsource: Hello [depart-inner] Text line: None, rawsource: Hello [depart-main] Text line: None, rawsource: Hello [depart-outter] Text line: None, rawsource: Hello [depart-inner] title line: 3, rawsource: Hello [depart-main] title line: 3, rawsource: Hello [depart-outter] title line: 3, rawsource: Hello [visit-outter] paragraph line: 5, rawsource: Dolor sit amet [visit-main] paragraph line: 5, rawsource: Dolor sit amet [visit-inner] paragraph line: 5, rawsource: Dolor sit amet [visit-outter] Text line: None, rawsource: Dolor sit amet [visit-main] Text line: None, rawsource: Dolor sit amet [visit-inner] Text line: None, rawsource: Dolor sit amet [depart-inner] Text line: None, rawsource: Dolor sit amet [depart-main] Text line: None, rawsource: Dolor sit amet [depart-outter] Text line: None, rawsource: Dolor sit amet [depart-inner] paragraph line: 5, rawsource: Dolor sit amet [depart-main] paragraph line: 5, rawsource: Dolor sit amet [depart-outter] paragraph line: 5, rawsource: Dolor sit amet [depart-inner] document line: None, rawsource: [depart-main] document line: None, rawsource: [depart-outter] document line: None, rawsource: ''' def test_visitor(capsys:CapSys) -> None: parsed_doc = parse_rst(''' Fizz ==== Lorem ipsum `notfound`. Buzz **** Lorem ``ipsum`` .. code-block:: python x = 0 .. note:: Dolor sit amet `notfound`. .. code-block:: python y = 1 Dolor sit amet `another link `. Dolor sit amet `link `. bla blab balba. ''') doc = parsed_doc.to_node() MainVisitor(visitor.ExtList(TitleReferenceDumpAfter)).walkabout(doc) assert capsys.readouterr().out == r'''title_reference line: None, rawsource: `notfound` title_reference line: None, rawsource: `notfound` title_reference line: None, rawsource: `another link ` title_reference line: None, rawsource: `link ` ''' vis = MainVisitor() vis.extensions.add(ParagraphDump, TitleReferenceDumpAfter) vis.walkabout(doc) assert capsys.readouterr().out == r'''paragraph line: 4, rawsource: Lorem ipsum `notfound`. title_reference line: None, rawsource: `notfound` paragraph line: 9, rawsource: Lorem ``ipsum`` paragraph line: 17, rawsource: Dolor sit amet\n`notfound`. title_reference line: None, rawsource: `notfound` paragraph line: 24, rawsource: Dolor sit amet `another link `.\nDolor sit amet `link `.\nbla blab balba. title_reference line: None, rawsource: `another link ` title_reference line: None, rawsource: `link ` ''' pydoctor-24.11.2/pydoctor/test/test_zopeinterface.py000066400000000000000000000521651473665144200226420ustar00rootroot00000000000000 from typing import Any, Dict, Iterable, List, Type, cast from pydoctor.test.test_astbuilder import fromText, type2html, ZopeInterfaceSystem from pydoctor.test.test_packages import processPackage from pydoctor.test.test_templatewriter import getHTMLOf from pydoctor.extensions.zopeinterface import ZopeInterfaceClass from pydoctor.epydoc.markup import ParsedDocstring from pydoctor import model from pydoctor.stanutils import flatten import pytest from . import CapSys, NotFoundLinker zope_interface_systemcls_param = pytest.mark.parametrize( 'systemcls', (model.System, # system with all extensions enalbed ZopeInterfaceSystem, # system with zopeinterface extension only ) ) # we set up the same situation using both implements and # classImplements and run the same tests. @zope_interface_systemcls_param def test_implements(systemcls: Type[model.System]) -> None: src = ''' import zope.interface class IFoo(zope.interface.Interface): pass class IBar(zope.interface.Interface): pass class Foo: zope.interface.implements(IFoo) class FooBar(Foo): zope.interface.implements(IBar) class OnlyBar(Foo): zope.interface.implementsOnly(IBar) ''' implements_test(src, systemcls) @zope_interface_systemcls_param def test_classImplements(systemcls: Type[model.System]) -> None: src = ''' import zope.interface class IFoo(zope.interface.Interface): pass class IBar(zope.interface.Interface): pass class Foo: pass class FooBar(Foo): pass class OnlyBar(Foo): pass zope.interface.classImplements(Foo, IFoo) zope.interface.classImplements(FooBar, IBar) zope.interface.classImplementsOnly(OnlyBar, IBar) ''' implements_test(src, systemcls) @zope_interface_systemcls_param def test_implementer(systemcls: Type[model.System]) -> None: src = ''' import zope.interface class IFoo(zope.interface.Interface): pass class IBar(zope.interface.Interface): pass @zope.interface.implementer(IFoo) class Foo: pass @zope.interface.implementer(IBar) class FooBar(Foo): pass class OnlyBar(Foo): zope.interface.implementsOnly(IBar) ''' implements_test(src, systemcls) def implements_test(src: str, systemcls: Type[model.System]) -> None: mod = fromText(src, modname='zi', systemcls=systemcls) ifoo = mod.contents['IFoo'] ibar = mod.contents['IBar'] foo = mod.contents['Foo'] foobar = mod.contents['FooBar'] onlybar = mod.contents['OnlyBar'] assert isinstance(ifoo, ZopeInterfaceClass) assert isinstance(ibar, ZopeInterfaceClass) assert isinstance(foo, ZopeInterfaceClass) assert isinstance(foobar, ZopeInterfaceClass) assert isinstance(onlybar, ZopeInterfaceClass) assert ifoo.isinterface and ibar.isinterface assert not foo.isinterface and not foobar.isinterface and not foobar.isinterface assert not foo.implementsOnly and not foobar.implementsOnly assert onlybar.implementsOnly assert foo.implements_directly == ['zi.IFoo'] assert foo.allImplementedInterfaces == ['zi.IFoo'] assert foobar.implements_directly == ['zi.IBar'] assert foobar.allImplementedInterfaces == ['zi.IBar', 'zi.IFoo'] assert onlybar.implements_directly == ['zi.IBar'] assert onlybar.allImplementedInterfaces == ['zi.IBar'] assert ifoo.implementedby_directly == [foo] assert ibar.implementedby_directly == [foobar, onlybar] @zope_interface_systemcls_param def test_subclass_with_same_name(systemcls: Type[model.System]) -> None: src = ''' class A: pass class A(A): pass ''' fromText(src, modname='zi', systemcls=systemcls) @zope_interface_systemcls_param def test_multiply_inheriting_interfaces(systemcls: Type[model.System]) -> None: src = ''' from zope.interface import Interface, implements class IOne(Interface): pass class ITwo(Interface): pass class One: implements(IOne) class Two: implements(ITwo) class Both(One, Two): pass ''' mod = fromText(src, modname='zi', systemcls=systemcls) B = mod.contents['Both'] assert isinstance(B, ZopeInterfaceClass) assert len(list(B.allImplementedInterfaces)) == 2 @zope_interface_systemcls_param def test_attribute(capsys: CapSys, systemcls: Type[model.System]) -> None: src = ''' import zope.interface as zi class C(zi.Interface): attr = zi.Attribute("documented attribute") bad_attr = zi.Attribute(0) ''' mod = fromText(src, modname='mod', systemcls=systemcls) assert len(mod.contents['C'].contents) == 2 attr = mod.contents['C'].contents['attr'] assert attr.kind is model.DocumentableKind.ATTRIBUTE assert attr.name == 'attr' assert attr.docstring == "documented attribute" bad_attr = mod.contents['C'].contents['bad_attr'] assert bad_attr.kind is model.DocumentableKind.ATTRIBUTE assert bad_attr.name == 'bad_attr' assert bad_attr.docstring is None captured = capsys.readouterr().out assert captured == 'mod:5: definition of attribute "bad_attr" should have docstring as its sole argument\n' @zope_interface_systemcls_param def test_interfaceclass(systemcls: Type[model.System], capsys: CapSys) -> None: system = processPackage('interfaceclass', systemcls=systemcls) mod = system.allobjects['interfaceclass.mod'] I = mod.contents['MyInterface'] assert isinstance(I, ZopeInterfaceClass) assert I.isinterface assert I.docstring == "This is my interface." J = mod.contents['AnInterface'] assert isinstance(J, ZopeInterfaceClass) assert J.isinterface assert 'interfaceclass.mod duplicate' not in capsys.readouterr().out @zope_interface_systemcls_param def test_warnerproofing(systemcls: Type[model.System]) -> None: src = ''' from zope import interface Interface = interface.Interface class IMyInterface(Interface): pass ''' mod = fromText(src, systemcls=systemcls) I = mod.contents['IMyInterface'] assert isinstance(I, ZopeInterfaceClass) assert I.isinterface @zope_interface_systemcls_param def test_zopeschema(capsys: CapSys, systemcls: Type[model.System]) -> None: src = ''' from zope import schema, interface class IMyInterface(interface.Interface): text = schema.TextLine(description="fun in a bun") undoc = schema.Bool() bad = schema.ASCII(description=False) ''' mod = fromText(src, modname='mod', systemcls=systemcls) text = mod.contents['IMyInterface'].contents['text'] assert text.docstring == 'fun in a bun' assert type2html(text)== "schema.TextLine" assert text.kind is model.DocumentableKind.SCHEMA_FIELD undoc = mod.contents['IMyInterface'].contents['undoc'] assert undoc.docstring is None assert type2html(undoc) == "schema.Bool" assert undoc.kind is model.DocumentableKind.SCHEMA_FIELD bad = mod.contents['IMyInterface'].contents['bad'] assert bad.docstring is None assert type2html(bad) == "schema.ASCII" assert bad.kind is model.DocumentableKind.SCHEMA_FIELD captured = capsys.readouterr().out assert captured == 'mod:6: description of field "bad" is not a string literal\n' @zope_interface_systemcls_param def test_aliasing_in_class(systemcls: Type[model.System]) -> None: src = ''' from zope import interface class IMyInterface(interface.Interface): Attrib = interface.Attribute attribute = Attrib("fun in a bun") ''' mod = fromText(src, systemcls=systemcls) attr = mod.contents['IMyInterface'].contents['attribute'] assert attr.docstring == 'fun in a bun' assert attr.kind is model.DocumentableKind.ATTRIBUTE @zope_interface_systemcls_param def test_zopeschema_inheritance(systemcls: Type[model.System]) -> None: src = ''' from zope import schema, interface from zope.schema import Int as INTEGERSCHMEMAFIELD class MyTextLine(schema.TextLine): pass class MyOtherTextLine(MyTextLine): pass class IMyInterface(interface.Interface): mytext = MyTextLine(description="fun in a bun") myothertext = MyOtherTextLine(description="fun in another bun") myint = INTEGERSCHMEMAFIELD(description="not as much fun") ''' mod = fromText(src, modname='mod', systemcls=systemcls) mytext = mod.contents['IMyInterface'].contents['mytext'] assert mytext.docstring == 'fun in a bun' assert flatten(cast(ParsedDocstring, mytext.parsed_type).to_stan(NotFoundLinker())) == "MyTextLine" assert mytext.kind is model.DocumentableKind.SCHEMA_FIELD myothertext = mod.contents['IMyInterface'].contents['myothertext'] assert myothertext.docstring == 'fun in another bun' assert flatten(cast(ParsedDocstring, myothertext.parsed_type).to_stan(NotFoundLinker())) == "MyOtherTextLine" assert myothertext.kind is model.DocumentableKind.SCHEMA_FIELD myint = mod.contents['IMyInterface'].contents['myint'] assert flatten(cast(ParsedDocstring, myint.parsed_type).to_stan(NotFoundLinker())) == "INTEGERSCHMEMAFIELD" assert myint.kind is model.DocumentableKind.SCHEMA_FIELD @zope_interface_systemcls_param def test_docsources_includes_interface(systemcls: Type[model.System]) -> None: src = ''' from zope import interface class IInterface(interface.Interface): def method(self): """documentation""" class Implementation: interface.implements(IInterface) def method(self): pass ''' mod = fromText(src, systemcls=systemcls) imethod = mod.contents['IInterface'].contents['method'] method = mod.contents['Implementation'].contents['method'] assert imethod in method.docsources(), list(method.docsources()) @zope_interface_systemcls_param def test_docsources_includes_baseinterface(systemcls: Type[model.System]) -> None: src = ''' from zope import interface class IBase(interface.Interface): def method(self): """documentation""" class IExtended(IBase): pass class Implementation: interface.implements(IExtended) def method(self): pass ''' mod = fromText(src, systemcls=systemcls) imethod = mod.contents['IBase'].contents['method'] method = mod.contents['Implementation'].contents['method'] assert imethod in method.docsources(), list(method.docsources()) @zope_interface_systemcls_param def test_docsources_interface_attribute(systemcls: Type[model.System]) -> None: src = ''' from zope import interface class IInterface(interface.Interface): attr = interface.Attribute("""documentation""") @interface.implementer(IInterface) class Implementation: attr = True ''' mod = fromText(src, systemcls=systemcls) iattr = mod.contents['IInterface'].contents['attr'] attr = mod.contents['Implementation'].contents['attr'] assert iattr in list(attr.docsources()) @zope_interface_systemcls_param def test_implementer_decoration(systemcls: Type[model.System]) -> None: src = ''' from zope.interface import Interface, implementer class IMyInterface(Interface): def method(self): """documentation""" @implementer(IMyInterface) class Implementation: def method(self): pass ''' mod = fromText(src, systemcls=systemcls) iface = mod.contents['IMyInterface'] impl = mod.contents['Implementation'] assert isinstance(impl, ZopeInterfaceClass) assert impl.implements_directly == [iface.fullName()] @zope_interface_systemcls_param def test_docsources_from_moduleprovides(systemcls: Type[model.System]) -> None: src = ''' from zope import interface class IBase(interface.Interface): def bar(): """documentation""" interface.moduleProvides(IBase) def bar(): pass ''' mod = fromText(src, systemcls=systemcls) imethod = mod.contents['IBase'].contents['bar'] function = mod.contents['bar'] assert imethod in function.docsources(), list(function.docsources()) @zope_interface_systemcls_param def test_interfaceallgames(systemcls: Type[model.System]) -> None: system = processPackage('interfaceallgames', systemcls=systemcls) mod = system.allobjects['interfaceallgames.interface'] iface = mod.contents['IAnInterface'] assert isinstance(iface, ZopeInterfaceClass) assert [o.fullName() for o in iface.implementedby_directly] == [ 'interfaceallgames.implementation.Implementation' ] @zope_interface_systemcls_param def test_implementer_with_star(systemcls: Type[model.System]) -> None: """ If the implementer call contains a split out empty list, don't fail on attempting to process it. """ src = ''' from zope.interface import Interface, implementer extra_interfaces = () class IMyInterface(Interface): def method(self): """documentation""" @implementer(IMyInterface, *extra_interfaces) class Implementation: def method(self): pass ''' mod = fromText(src, systemcls=systemcls) iface = mod.contents['IMyInterface'] impl = mod.contents['Implementation'] assert isinstance(impl, ZopeInterfaceClass) assert isinstance(iface, ZopeInterfaceClass) assert impl.implements_directly == [iface.fullName()] @zope_interface_systemcls_param def test_implementer_nonname(capsys: CapSys, systemcls: Type[model.System]) -> None: """ Non-name arguments passed to @implementer are warned about and then ignored. """ src = ''' from zope.interface import implementer @implementer(123) class Implementation: pass ''' mod = fromText(src, modname='mod', systemcls=systemcls) impl = mod.contents['Implementation'] assert isinstance(impl, ZopeInterfaceClass) assert impl.implements_directly == [] captured = capsys.readouterr().out assert captured == 'mod:3: Interface argument 1 does not look like a name\n' @zope_interface_systemcls_param def test_implementer_nonclass(capsys: CapSys, systemcls: Type[model.System]) -> None: """ Non-class arguments passed to @implementer are warned about but are stored as implemented interfaces. """ src = ''' from zope.interface import implementer var = 'not a class' @implementer(var) class Implementation: pass ''' mod = fromText(src, modname='mod', systemcls=systemcls) impl = mod.contents['Implementation'] assert isinstance(impl, ZopeInterfaceClass) assert impl.implements_directly == ['mod.var'] captured = capsys.readouterr().out assert captured == 'mod:4: Supposed interface "mod.var" not detected as a class\n' @zope_interface_systemcls_param def test_implementer_plainclass(capsys: CapSys, systemcls: Type[model.System]) -> None: """ A non-interface class passed to @implementer will be warned about but will be stored as an implemented interface. """ src = ''' from zope.interface import implementer class C: pass @implementer(C) class Implementation: pass ''' mod = fromText(src, modname='mod', systemcls=systemcls) C = mod.contents['C'] impl = mod.contents['Implementation'] assert isinstance(impl, ZopeInterfaceClass) assert isinstance(C, ZopeInterfaceClass) assert not C.isinterface assert C.kind is model.DocumentableKind.CLASS assert impl.implements_directly == ['mod.C'] captured = capsys.readouterr().out assert captured == 'mod:5: Class "mod.C" is not an interface\n' @zope_interface_systemcls_param def test_implementer_not_found(capsys: CapSys, systemcls: Type[model.System]) -> None: """ An unknown class passed to @implementer is warned about if its full name is part of our system. """ src = ''' from zope.interface import implementer from twisted.logger import ILogObserver @implementer(ILogObserver, mod.INoSuchInterface) class Implementation: pass ''' fromText(src, modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == 'mod:4: Interface "mod.INoSuchInterface" not found\n' @zope_interface_systemcls_param def test_implementer_reparented(systemcls: Type[model.System]) -> None: """ A class passed to @implementer can be found even when it is moved to a different module. """ system = systemcls() mod_iface = fromText(''' from zope.interface import Interface class IMyInterface(Interface): pass ''', modname='_private', system=system) mod_export = fromText('', modname='public', system=system) mod_impl = fromText(''' from zope.interface import implementer from _private import IMyInterface @implementer(IMyInterface) class Implementation: pass ''', modname='app', system=system) iface = mod_iface.contents['IMyInterface'] assert isinstance(iface, ZopeInterfaceClass) iface.reparent(mod_export, 'IMyInterface') assert iface.fullName() == 'public.IMyInterface' assert 'IMyInterface' not in mod_iface.contents impl = mod_impl.contents['Implementation'] assert isinstance(impl, ZopeInterfaceClass) assert impl.implements_directly == ['_private.IMyInterface'] # The system is already post-processed at this time assert iface.implementedby_directly == [impl] # But since we've manually reparent 'IMyInterface' to 'public', # we need to post-process it again. system.postProcess() assert impl.implements_directly == ['public.IMyInterface'] assert iface.implementedby_directly == [impl] @zope_interface_systemcls_param def test_implementer_nocall(capsys: CapSys, systemcls: Type[model.System]) -> None: """ Report a warning when @implementer is used without calling it. """ src = ''' import zope.interface @zope.interface.implementer class C: pass ''' fromText(src, modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == "mod:3: @implementer requires arguments\n" @zope_interface_systemcls_param def test_classimplements_badarg(capsys: CapSys, systemcls: Type[model.System]) -> None: """ Report a warning when the arguments to classImplements() don't make sense. """ src = ''' from zope.interface import Interface, classImplements class IBar(Interface): pass def f(): pass classImplements() classImplements(None, IBar) classImplements(f, IBar) classImplements(g, IBar) ''' fromText(src, modname='mod', systemcls=systemcls) captured = capsys.readouterr().out assert captured == ( 'mod:7: required argument to classImplements() missing\n' 'mod:8: argument 1 to classImplements() is not a class name\n' 'mod:9: argument "mod.f" to classImplements() is not a class\n' 'mod:10: argument "g" to classImplements() not found\n' ) @zope_interface_systemcls_param def test_implements_renders_ok(systemcls: Type[model.System]) -> None: """ The Class renderer effectively includes the implemented interfaces. """ src = ''' import zope.interface class IFoo(zope.interface.Interface): pass @zope.interface.implementer(IFoo) class Foo: pass ''' mod = fromText(src, modname='zi', systemcls=systemcls) ifoo_html = getHTMLOf(mod.contents['IFoo']) foo_html = getHTMLOf(mod.contents['Foo']) assert 'Known implementations:' in ifoo_html assert 'zi.Foo' in ifoo_html assert 'Implements interfaces:' in foo_html assert 'zi.IFoo' in foo_html def _get_modules_test_zope_interface_imports_cycle_proof() -> List[Iterable[Dict[str, Any]]]: src_inteface = '''\ from zope.interface import Interface from top.impl import Address class IAddress(Interface): ... ''' src_impl = '''\ from zope.interface import implementer from top.interface import IAddress @implementer(IAddress) class Address(object): ... ''' mod_interface = {'modname': 'interface', 'text': src_inteface, 'parent_name':'top'} mod_top = {'modname':'top', 'text': 'pass', 'is_package': True} mod_impl = {'modname': 'impl', 'text': src_impl, 'parent_name':'top'} return [ (mod_top,mod_interface,mod_impl), (mod_top,mod_impl,mod_interface), ] @pytest.mark.parametrize('modules', _get_modules_test_zope_interface_imports_cycle_proof()) @zope_interface_systemcls_param def test_zope_interface_imports_cycle_proof(systemcls: Type[model.System], modules:Iterable[Dict[str, Any]]) -> None: """ Zope interface informations is collected no matter the cyclics imports and the order of processing of modules. This test only check some basic cyclic imports examples. """ system = systemcls() builder = system.systemBuilder(system) for m in modules: builder.addModuleString(**m) builder.buildModules() interface = system.objForFullName('top.interface.IAddress') impl = system.objForFullName('top.impl.Address') assert isinstance(interface, model.Class) assert isinstance(impl, model.Class) ihtml = getHTMLOf(interface) html = getHTMLOf(impl) assert 'top.impl.Address' in ihtml assert 'top.interface.IAddress' in html pydoctor-24.11.2/pydoctor/test/testcustomtemplates/000077500000000000000000000000001473665144200225135ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/allok/000077500000000000000000000000001473665144200236155ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/allok/nav.html000066400000000000000000000011021473665144200252610ustar00rootroot00000000000000 pydoctor-24.11.2/pydoctor/test/testcustomtemplates/allok/pydoctor.js000066400000000000000000000000311473665144200260100ustar00rootroot00000000000000alert( 'Hello, world!' );pydoctor-24.11.2/pydoctor/test/testcustomtemplates/casing/000077500000000000000000000000001473665144200237575ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/casing/test1/000077500000000000000000000000001473665144200250175ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/casing/test1/nav.HTML000066400000000000000000000000001473665144200262570ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/casing/test2/000077500000000000000000000000001473665144200250205ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/casing/test2/nav.Html000066400000000000000000000000001473665144200264200ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/casing/test3/000077500000000000000000000000001473665144200250215ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/casing/test3/nav.htmL000066400000000000000000000000001473665144200264210ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/000077500000000000000000000000001473665144200251555ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/footer.html000066400000000000000000000001271473665144200273410ustar00rootroot00000000000000
    Footer2
    pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/header.html000066400000000000000000000000701473665144200272700ustar00rootroot00000000000000
    Header
    pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/nav.html000066400000000000000000000011471473665144200266320ustar00rootroot00000000000000 pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/pydoctor.js000066400000000000000000000000321473665144200273510ustar00rootroot00000000000000alert( 'Hello, world!' ); pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/random.html000066400000000000000000000000341473665144200273200ustar00rootroot00000000000000 Random words pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/subheader.html000066400000000000000000000000531473665144200300030ustar00rootroot00000000000000
    Page header
    pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/summary.html000066400000000000000000000001161473665144200275360ustar00rootroot00000000000000
    pydoctor-24.11.2/pydoctor/test/testcustomtemplates/faketemplate/table.html000066400000000000000000000001651473665144200271340ustar00rootroot00000000000000
    Random words
    pydoctor-24.11.2/pydoctor/test/testcustomtemplates/overridesubfolders/000077500000000000000000000000001473665144200264235ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/overridesubfolders/static/000077500000000000000000000000001473665144200277125ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/overridesubfolders/static/fonts/000077500000000000000000000000001473665144200310435ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/overridesubfolders/static/fonts/foo.svg000066400000000000000000000000171473665144200323450ustar00rootroot00000000000000I'm not empty! pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/000077500000000000000000000000001473665144200246635ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/atemplate.html000066400000000000000000000000701473665144200275220ustar00rootroot00000000000000
    html template here
    pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/static/000077500000000000000000000000001473665144200261525ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/static/fonts/000077500000000000000000000000001473665144200273035ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/static/fonts/bar.svg000066400000000000000000000000001473665144200305560ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/static/fonts/foo.svg000066400000000000000000000000001473665144200305750ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/static/info.svg000066400000000000000000000000001473665144200276140ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testcustomtemplates/subfolders/static/lol.svg000066400000000000000000000000001473665144200274470ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/000077500000000000000000000000001473665144200210405ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/allgames/000077500000000000000000000000001473665144200226255ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/allgames/__init__.py000066400000000000000000000004071473665144200247370ustar00rootroot00000000000000# pre comment -7 # pre comment -6 # pre comment -5 # pre comment -4 # pre comment -3 # pre comment -2 # pre comment -1 """Package docstring.""" # post comment 1 # post comment 2 # post comment 3 # post comment 4 # post comment 5 # post comment 6 # post comment 7 pydoctor-24.11.2/pydoctor/test/testpackages/allgames/mod1.py000066400000000000000000000001301473665144200240310ustar00rootroot00000000000000 __all__ = ['InSourceAll'] class InSourceAll: pass class NotInSourceAll: pass pydoctor-24.11.2/pydoctor/test/testpackages/allgames/mod2.py000066400000000000000000000001431473665144200240360ustar00rootroot00000000000000__all__ = ['InSourceAll', 'NotInSourceAll'] from allgames.mod1 import InSourceAll, NotInSourceAll pydoctor-24.11.2/pydoctor/test/testpackages/basic/000077500000000000000000000000001473665144200221215ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/basic/__init__.py000066400000000000000000000004071473665144200242330ustar00rootroot00000000000000# pre comment -7 # pre comment -6 # pre comment -5 # pre comment -4 # pre comment -3 # pre comment -2 # pre comment -1 """Package docstring.""" # post comment 1 # post comment 2 # post comment 3 # post comment 4 # post comment 5 # post comment 6 # post comment 7 pydoctor-24.11.2/pydoctor/test/testpackages/basic/_private_mod.py000066400000000000000000000000221473665144200251350ustar00rootroot00000000000000def f(): pass pydoctor-24.11.2/pydoctor/test/testpackages/basic/mod.py000066400000000000000000000016341473665144200232560ustar00rootroot00000000000000""" Module docstring. @var CONSTANT: A shiny constant. """ class C: """Class docstring. This docstring has lines, paragraphs and everything! Please see L{CONSTANT}. @ivar notreally: even a field! @since: 2.1 """ class S: pass def f(self): """Method docstring of C.f.""" @some_random_decorator @some_other_decorator def h(self): """Method docstring.""" @some_random_decorator @classmethod def cls_method(cls): pass @staticmethod def static_method(): pass class D(C): """Subclass docstring.""" class T: pass def f(self): # no docstring, should be inherited from superclass pass def g(self): pass @classmethod def cls_method2(cls): pass def static_method2(): pass static_method2 = staticmethod(static_method2) def _private(): pass pydoctor-24.11.2/pydoctor/test/testpackages/c_module_invalid_text_signature/000077500000000000000000000000001473665144200274625ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/c_module_invalid_text_signature/mymod/000077500000000000000000000000001473665144200306075ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/c_module_invalid_text_signature/mymod/__init__.py000066400000000000000000000000001473665144200327060ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/c_module_invalid_text_signature/mymod/base.c000066400000000000000000000022431473665144200316660ustar00rootroot00000000000000/* Example of Python c module with an invalid __text_signature__ */ #include "Python.h" static PyObject* base_valid(PyObject *self, PyObject* args) { printf("Hello World\n"); return Py_None; } static PyObject* base_invalid(PyObject *self, PyObject* args) { printf("Hello World\n"); return Py_None; } static PyMethodDef base_methods[] = { {"valid_text_signature", base_valid, METH_VARARGS, "valid_text_signature($self, a='r', b=-3.14)\n" "--\n" "\n" "Function demonstrating a valid __text_signature__ from C code."}, {"invalid_text_signature", base_invalid, METH_VARARGS, "invalid_text_signature(!invalid) -> NotSupported\n" "--\n" "\n" "Function demonstrating an invalid __text_signature__ from C code."}, {NULL, NULL, 0, NULL} /* sentinel */ }; static PyModuleDef base_definition = { PyModuleDef_HEAD_INIT, "base", "A Python module demonstrating valid and invalid __text_signature__ from C code.", -1, base_methods }; PyObject* PyInit_base(void) { Py_Initialize(); return PyModule_Create(&base_definition); } pydoctor-24.11.2/pydoctor/test/testpackages/c_module_invalid_text_signature/setup.py000066400000000000000000000002641473665144200311760ustar00rootroot00000000000000from setuptools import setup, Extension cmodule = Extension("mymod.base", sources=["mymod/base.c"],) setup( name="mymod", ext_modules=[cmodule], packages=['mymod'], ) pydoctor-24.11.2/pydoctor/test/testpackages/c_module_python_module_name_clash/000077500000000000000000000000001473665144200277475ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/c_module_python_module_name_clash/mymod/000077500000000000000000000000001473665144200310745ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/c_module_python_module_name_clash/mymod/__init__.py000066400000000000000000000000001473665144200331730ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/c_module_python_module_name_clash/mymod/base.c000066400000000000000000000014151473665144200321530ustar00rootroot00000000000000/* Example of Python c module with an invalid __text_signature__ */ #include "Python.h" static PyObject* base_valid(PyObject *self, PyObject* args) { printf("Hello World\n"); return Py_None; } static PyMethodDef base_methods[] = { {"coming_from_c_module", base_valid, METH_VARARGS, "coming_from_c_module($self, a='r', b=-3.14)\n" "--\n" "\n" "Function demonstrating a valid __text_signature__ from C code."}, {NULL, NULL, 0, NULL} /* sentinel */ }; static PyModuleDef base_definition = { PyModuleDef_HEAD_INIT, "base", "Dummy c-module.", -1, base_methods }; PyObject* PyInit_base(void) { Py_Initialize(); return PyModule_Create(&base_definition); } pydoctor-24.11.2/pydoctor/test/testpackages/c_module_python_module_name_clash/mymod/base.py000066400000000000000000000011161473665144200323570ustar00rootroot00000000000000 # Example of stub loader generated by setuptools: # https://github.com/pypa/setuptools/blob/4d64156de17596dae33f2b12aaaea1d6c9327fd9/setuptools/command/build_ext.py#L238-L275 # We emulate this behaviour with this module. def __bootstrap__(): global __bootstrap__, __loader__, __file__ import sys, pkg_resources from importlib.machinery import ExtensionFileLoader __file__ = pkg_resources.resource_filename(__name__, 'base.cpython-39-darwin.so') __loader__ = None; del __bootstrap__, __loader__ ExtensionFileLoader(__name__,__file__).load_module() __bootstrap__()pydoctor-24.11.2/pydoctor/test/testpackages/c_module_python_module_name_clash/setup.py000066400000000000000000000002641473665144200314630ustar00rootroot00000000000000from setuptools import setup, Extension cmodule = Extension("mymod.base", sources=["mymod/base.c"],) setup( name="mymod", ext_modules=[cmodule], packages=['mymod'], ) pydoctor-24.11.2/pydoctor/test/testpackages/codeininit/000077500000000000000000000000001473665144200231655ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/codeininit/__init__.py000066400000000000000000000000751473665144200253000ustar00rootroot00000000000000def functionInInit(self): "why do people put code here?" pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports/000077500000000000000000000000001473665144200240635ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports/__init__.py000066400000000000000000000000001473665144200261620ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports/a.py000066400000000000000000000000441473665144200246530ustar00rootroot00000000000000from .b import B class A: b: B pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports/b.py000066400000000000000000000000441473665144200246540ustar00rootroot00000000000000from .a import A class B: a: A pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports_base_classes/000077500000000000000000000000001473665144200265725ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports_base_classes/__init__.py000066400000000000000000000000201473665144200306730ustar00rootroot00000000000000from . import b pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports_base_classes/a.py000066400000000000000000000000531473665144200273620ustar00rootroot00000000000000from . import x class A(object): pass pydoctor-24.11.2/pydoctor/test/testpackages/cyclic_imports_base_classes/b.py000066400000000000000000000000501473665144200273600ustar00rootroot00000000000000from . import a class B(a.A): pass pydoctor-24.11.2/pydoctor/test/testpackages/importingfrompackage/000077500000000000000000000000001473665144200252505ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/importingfrompackage/__init__.py000066400000000000000000000000001473665144200273470ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/importingfrompackage/mod.py000066400000000000000000000000601473665144200263750ustar00rootroot00000000000000from importingfrompackage.subpack import submod pydoctor-24.11.2/pydoctor/test/testpackages/importingfrompackage/subpack/000077500000000000000000000000001473665144200267005ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/importingfrompackage/subpack/__init__.py000066400000000000000000000000001473665144200307770ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/importingfrompackage/subpack/submod.py000066400000000000000000000000021473665144200305330ustar00rootroot00000000000000# pydoctor-24.11.2/pydoctor/test/testpackages/interfaceallgames/000077500000000000000000000000001473665144200245065ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/interfaceallgames/__init__.py000066400000000000000000000000021473665144200266070ustar00rootroot00000000000000# pydoctor-24.11.2/pydoctor/test/testpackages/interfaceallgames/_implementation.py000066400000000000000000000002211473665144200302370ustar00rootroot00000000000000from zope.interface import implements from interfaceallgames.interface import IAnInterface class Implementation: implements(IAnInterface) pydoctor-24.11.2/pydoctor/test/testpackages/interfaceallgames/implementation.py000066400000000000000000000001331473665144200301020ustar00rootroot00000000000000from interfaceallgames._implementation import Implementation __all__ = ["Implementation"] pydoctor-24.11.2/pydoctor/test/testpackages/interfaceallgames/interface.py000066400000000000000000000001171473665144200270170ustar00rootroot00000000000000from zope.interface import Interface class IAnInterface(Interface): pass pydoctor-24.11.2/pydoctor/test/testpackages/interfaceclass/000077500000000000000000000000001473665144200240265ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/interfaceclass/__init__.py000066400000000000000000000000071473665144200261340ustar00rootroot00000000000000#empty pydoctor-24.11.2/pydoctor/test/testpackages/interfaceclass/mod.py000066400000000000000000000004551473665144200251630ustar00rootroot00000000000000import zope.interface as zi import zope.schema as zs class MyInterfaceClass(zi.interface.InterfaceClass): pass MyInterface = MyInterfaceClass("MyInterface") """This is my interface.""" class AnInterface(MyInterface): def foo(): pass a = zi.Attribute("...") f = zs.Choice() pydoctor-24.11.2/pydoctor/test/testpackages/liveobject/000077500000000000000000000000001473665144200231665ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/liveobject/__init__.py000066400000000000000000000000101473665144200252660ustar00rootroot00000000000000# empty pydoctor-24.11.2/pydoctor/test/testpackages/liveobject/mod.py000066400000000000000000000002611473665144200243160ustar00rootroot00000000000000class C: def m(self): "this is a docstring" def __m(self): "this method's name gets mangled" m = C().m class B: pass exec('''class D(B): pass''') pydoctor-24.11.2/pydoctor/test/testpackages/modnamedafterbuiltin/000077500000000000000000000000001473665144200252355ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/modnamedafterbuiltin/__init__.py000066400000000000000000000000101473665144200273350ustar00rootroot00000000000000# empty pydoctor-24.11.2/pydoctor/test/testpackages/modnamedafterbuiltin/dict.py000066400000000000000000000000201473665144200265220ustar00rootroot00000000000000# empty too :-) pydoctor-24.11.2/pydoctor/test/testpackages/modnamedafterbuiltin/mod.py000066400000000000000000000000331473665144200263620ustar00rootroot00000000000000class Dict(dict): pass pydoctor-24.11.2/pydoctor/test/testpackages/multipleinheritance/000077500000000000000000000000001473665144200251055ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/multipleinheritance/__init__.py000066400000000000000000000000001473665144200272040ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/multipleinheritance/mod.py000066400000000000000000000015571473665144200262460ustar00rootroot00000000000000class CommonBase(object): def fullName(self): ... class NewBaseClassA(CommonBase): def methodA(self): """ This is method A. """ class NewBaseClassB(CommonBase): def methodB(self): """ This is method B. """ class NewClassThatMultiplyInherits(NewBaseClassA, NewBaseClassB): def methodC(self): """ This is method C. """ class OldBaseClassA(CommonBase): def methodA(self): """ This is method A. """ class OldBaseClassB(CommonBase): def methodB(self): """ This is method B. """ class OldClassThatMultiplyInherits(OldBaseClassA, OldBaseClassB): def methodC(self): """ This is method C. """ class Diamond(OldClassThatMultiplyInherits, NewClassThatMultiplyInherits): def newMethod(self):... pydoctor-24.11.2/pydoctor/test/testpackages/nestedconfusion/000077500000000000000000000000001473665144200242465ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/nestedconfusion/__init__.py000066400000000000000000000000021473665144200263470ustar00rootroot00000000000000# pydoctor-24.11.2/pydoctor/test/testpackages/nestedconfusion/mod.py000066400000000000000000000001061473665144200253740ustar00rootroot00000000000000class C: pass class nestedconfusion: class A(C): pass pydoctor-24.11.2/pydoctor/test/testpackages/package_module_name_clash/000077500000000000000000000000001473665144200261525ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/package_module_name_clash/__init__.py000066400000000000000000000000001473665144200302510ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/package_module_name_clash/pack.py000066400000000000000000000000131473665144200274340ustar00rootroot00000000000000module=Truepydoctor-24.11.2/pydoctor/test/testpackages/package_module_name_clash/pack/000077500000000000000000000000001473665144200270705ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/package_module_name_clash/pack/__init__.py000066400000000000000000000000141473665144200311740ustar00rootroot00000000000000package=Truepydoctor-24.11.2/pydoctor/test/testpackages/relativeimporttest/000077500000000000000000000000001473665144200250065ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/relativeimporttest/__init__.py000066400000000000000000000000471473665144200271200ustar00rootroot00000000000000# this is for testing! """DOCSTRING""" pydoctor-24.11.2/pydoctor/test/testpackages/relativeimporttest/mod1.py000066400000000000000000000001161473665144200262160ustar00rootroot00000000000000from .mod2 import B class C(B): """This is not a docstring.""" pass pydoctor-24.11.2/pydoctor/test/testpackages/relativeimporttest/mod2.py000066400000000000000000000000221473665144200262130ustar00rootroot00000000000000class B: pass pydoctor-24.11.2/pydoctor/test/testpackages/reparented_module/000077500000000000000000000000001473665144200245365ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/reparented_module/__init__.py000066400000000000000000000002521473665144200266460ustar00rootroot00000000000000""" Here the module C{mod} is made available under an alias name that is explicitly advertised under the alias name. """ from . import mod as module __all__=('module',) pydoctor-24.11.2/pydoctor/test/testpackages/reparented_module/mod.py000066400000000000000000000001771473665144200256740ustar00rootroot00000000000000""" This is the "origin" module which for testing purpose is used from the C{reparented_module} package. """ def f(): pass pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_crash/000077500000000000000000000000001473665144200245365ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_crash/__init__.py000066400000000000000000000001651473665144200266510ustar00rootroot00000000000000from .reparenting_crash import reparenting_crash, reparented_func __all__ = ['reparenting_crash', 'reparented_func']pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_crash/reparenting_crash.py000066400000000000000000000001501473665144200306020ustar00rootroot00000000000000 class reparenting_crash: ... def reparented_func(): ... def reparented_func(): ...pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_crash_alt/000077500000000000000000000000001473665144200253765ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_crash_alt/__init__.py000066400000000000000000000002021473665144200275010ustar00rootroot00000000000000from .reparenting_crash_alt import reparenting_crash_alt, reparented_func __all__ = ['reparenting_crash_alt', 'reparented_func'] pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_crash_alt/_impl.py000066400000000000000000000001531473665144200270470ustar00rootroot00000000000000class reparenting_crash_alt: ... def reparented_func(): ... def reparented_func(): ... pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_crash_alt/reparenting_crash_alt.py000066400000000000000000000000731473665144200323060ustar00rootroot00000000000000 from ._impl import reparenting_crash_alt, reparented_func pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_follows_aliases/000077500000000000000000000000001473665144200266245ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_follows_aliases/__init__.py000066400000000000000000000000001473665144200307230ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_follows_aliases/_myotherthing.py000066400000000000000000000001661473665144200320610ustar00rootroot00000000000000""" This module imports a class, it does not re-export it in it's __all__ variable. """ from ._mything import MyClass pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_follows_aliases/_mything.py000066400000000000000000000000711473665144200310120ustar00rootroot00000000000000"""This module defines a class""" class MyClass: ... pydoctor-24.11.2/pydoctor/test/testpackages/reparenting_follows_aliases/main.py000066400000000000000000000004531473665144200301240ustar00rootroot00000000000000""" This module imports MyClass from _myotherthing and re-export it in it's __all__ varaible But _myotherthing.MyClass is a alias to _mything.MyClass, so _mything.MyClass should be reparented to main.MyClass. """ from ._myotherthing import MyClass __all__=('myfunc', 'MyClass') def myfunc(): ... pydoctor-24.11.2/pydoctor/test/testpackages/report_trigger/000077500000000000000000000000001473665144200240765ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/report_trigger/__init__.py000066400000000000000000000002151473665144200262050ustar00rootroot00000000000000""" This is used to check reporting handling as part of functional tests. """ def top_level() -> None: """ @bad_field: bla """ pydoctor-24.11.2/pydoctor/test/testpackages/report_trigger/report_module.py000066400000000000000000000002351473665144200273300ustar00rootroot00000000000000""" Just a module which will raise some reports. """ def missing_arg_docs(x: int) -> None: """ There is no apidoc for `x`. L{Bad Link} """ pydoctor-24.11.2/pydoctor/test/testpackages/syntax_error/000077500000000000000000000000001473665144200235775ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/test/testpackages/syntax_error/__init__.py000066400000000000000000000000341473665144200257050ustar00rootroot00000000000000def f() return True pydoctor-24.11.2/pydoctor/themes/000077500000000000000000000000001473665144200166705ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/themes/__init__.py000066400000000000000000000013461473665144200210050ustar00rootroot00000000000000""" Package directory used to store pydoctor templates. Usage example: >>> template_lookup = TemplateLookup(importlib_resources.files('pydoctor.themes') / 'base') """ import sys from typing import Iterator # In newer Python versions, use importlib.resources from the standard library. # On older versions, a compatibility package must be installed from PyPI. if sys.version_info < (3, 9): import importlib_resources else: import importlib.resources as importlib_resources def get_themes() -> Iterator[str]: """ Get the list of the available themes. """ for path in importlib_resources.files('pydoctor.themes').iterdir(): if not path.name.startswith('_') and not path.is_file(): yield path.name pydoctor-24.11.2/pydoctor/themes/base/000077500000000000000000000000001473665144200176025ustar00rootroot00000000000000pydoctor-24.11.2/pydoctor/themes/base/ajax.js000066400000000000000000000020531473665144200210630ustar00rootroot00000000000000// Implement simple cached AJAX functions. var _cache = {}; /* * Get a promise for the HTTP get responseText. */ function httpGetPromise(url) { const promise = new Promise((_resolve, _reject) => { httpGet(url, (responseText) => { _resolve(responseText); }, (error) => { _reject(error); }); }); return promise } function httpGet(url, onload, onerror) { if (_cache[url]) { _cachedHttpGet(url, onload, onerror); } else{ _httpGet(url, onload, onerror); } } function _cachedHttpGet(url, onload, onerror) { setTimeout(() => { onload(_cache[url]) }, 0); } function _httpGet(url, onload, onerror) { var xobj = new XMLHttpRequest(); xobj.open('GET', url, true); // Asynchronous xobj.onload = function () { // add document to cache. _cache[url] = xobj.responseText; onload(xobj.responseText); }; xobj.onerror = function (error) { console.log(error) onerror(error) }; xobj.send(null); } pydoctor-24.11.2/pydoctor/themes/base/all-documents.html000066400000000000000000000016571473665144200232500ustar00rootroot00000000000000 Head
    Nav

    All Documents

    pydoctor-24.11.2/pydoctor/themes/base/apidocs-help.html000066400000000000000000000016571473665144200230510ustar00rootroot00000000000000 Head