python-griffe-2.3.0/ 0000775 0001750 0001750 00000000000 15246557273 014177 5 ustar carsten carsten python-griffe-2.3.0/LICENSE 0000664 0001750 0001750 00000001362 15246557273 015206 0 ustar carsten carsten ISC License
Copyright (c) 2021, Timothée Mazzucotelli
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
python-griffe-2.3.0/mkdocs.yml 0000664 0001750 0001750 00000024743 15246557273 016214 0 ustar carsten carsten site_name: "Griffe"
site_description: "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
site_url: "https://mkdocstrings.github.io/griffe"
repo_url: "https://github.com/mkdocstrings/griffe"
repo_name: "mkdocstrings/griffe"
site_dir: "site"
watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, packages]
copyright: Copyright © 2021 Timothée Mazzucotelli
edit_uri: edit/main/docs/
validation:
omitted_files: warn
absolute_links: warn
unrecognized_links: warn
not_in_nav: |
usage.md
nav:
- Home: index.md
- Getting started:
- getting-started.md
- Installation: installation.md
- Introduction: introduction.md
- Playground: playground.md
- License: license.md
- Community:
- community.md
- Getting help: getting-help.md
- Contributing: contributing.md
- Code of conduct: code-of-conduct.md
- Alternatives: alternatives.md
- Downstream projects: downstream-projects.md
- Credits: credits.md
- Guide:
- guide.md
- User guide:
- guide/users.md
- Manipulating APIs:
- Loading: guide/users/loading.md
- Navigating: guide/users/navigating.md
- Serializing: guide/users/serializing.md
- Checking: guide/users/checking.md
- Extending: guide/users/extending.md
- Recommendations:
- Public APIs: guide/users/recommendations/public-apis.md
- Python code: guide/users/recommendations/python-code.md
- Docstrings: guide/users/recommendations/docstrings.md
- How-to:
- Parse docstrings: guide/users/how-to/parse-docstrings.md
- Support custom decorators: guide/users/how-to/support-decorators.md
- Selectively inspect objects: guide/users/how-to/selectively-inspect.md
- Set objects' docstring style: guide/users/how-to/set-docstring-styles.md
- Set Git info on objects: guide/users/how-to/set-git-info.md
- Contributor guide:
- guide/contributors.md
- Environment setup: guide/contributors/setup.md
- Management commands: guide/contributors/commands.md
- Development workflow: guide/contributors/workflow.md
- Project architecture: guide/contributors/architecture.md
- Coverage report: guide/contributors/coverage.md
- Extensions:
- extensions.md
- Built-in:
- extensions/built-in.md
- dataclasses: extensions/built-in/dataclasses.md
- unpack-typeddict: extensions/built-in/unpack-typeddict.md
- Official:
- extensions/official.md
- autodocstringstyle: extensions/official/autodocstringstyle.md
- inherited-docstrings: extensions/official/inherited-docstrings.md
- public-redundant-aliases: extensions/official/public-redundant-aliases.md
- public-wildcard-imports: extensions/official/public-wildcard-imports.md
- pydantic: extensions/official/pydantic.md
- runtime-objects: extensions/official/runtime-objects.md
- sphinx: extensions/official/sphinx.md
- typingdoc: extensions/official/typingdoc.md
- warnings-deprecated: extensions/official/warnings-deprecated.md
- Third-party:
- extensions/third-party.md
- docstring-inheritance: extensions/third-party/docstring-inheritance.md
- fastapi: extensions/third-party/fastapi.md
- fieldz: extensions/third-party/fieldz.md
- generics: extensions/third-party/generics.md
- inherited-method-crossrefs: extensions/third-party/inherited-method-crossrefs.md
- modernized-annotations: extensions/third-party/modernized-annotations.md
- Reference:
- reference.md
- Command-line interface: reference/cli.md
- Docstring parsers: reference/docstrings.md
- Python API:
- reference/api.md
- CLI entrypoints: reference/api/cli.md
- Loaders: reference/api/loaders.md
- Finder: reference/api/finder.md
- Models:
- reference/api/models.md
- Module: reference/api/models/module.md
- Class: reference/api/models/class.md
- Function: reference/api/models/function.md
- Attribute: reference/api/models/attribute.md
- Type Alias: reference/api/models/type_alias.md
- Alias: reference/api/models/alias.md
- Agents: reference/api/agents.md
- Serializers: reference/api/serializers.md
- API checks: reference/api/checks.md
- Extensions: reference/api/extensions.md
- Docstrings:
- reference/api/docstrings.md
- Models: reference/api/docstrings/models.md
- Parsers: reference/api/docstrings/parsers.md
- Exceptions: reference/api/exceptions.md
- Expressions: reference/api/expressions.md
- Loggers: reference/api/loggers.md
- Helpers: reference/api/helpers.md
- Changelog: changelog.md
- Author's website: https://pawamoy.github.io/
theme:
name: material
custom_dir: docs/.overrides
logo: logo.svg
favicon: img/favicon.ico
features:
- announce.dismiss
- content.action.edit
- content.action.view
- content.code.annotate
- content.code.copy
- content.tooltips
- navigation.expand
- navigation.footer
- navigation.instant.preview
- navigation.path
- navigation.sections
- navigation.tabs
- navigation.tabs.sticky
- navigation.top
- search.highlight
- search.suggest
- toc.follow
palette:
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: teal
accent: purple
toggle:
icon: material/weather-sunny
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
accent: lime
toggle:
icon: material/weather-night
name: Switch to system preference
extra_css:
- css/custom.css
- css/material.css
- css/mkdocstrings.css
extra_javascript:
- js/feedback.js
- https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js
markdown_extensions:
- attr_list
- admonition
- callouts:
strip_period: no
- footnotes
- md_in_html
- pymdownx.blocks.tab:
alternate_style: true
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.keys
- pymdownx.magiclink
- pymdownx.snippets:
base_path: [!relative $config_dir]
check_paths: true
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
slugify: !!python/object/apply:pymdownx.slugs.slugify
kwds:
case: lower
- pymdownx.tasklist:
custom_checkbox: true
- toc:
permalink: "¤"
plugins:
- search
- autorefs
- markdown-exec:
ansi: required
- gen-files:
scripts:
- scripts/gen_griffe_json.py
- section-index
- coverage:
page_path: guide/contributors/coverage
- mkdocstrings:
enabled: !ENV [MKDOCSTRINGS_ENABLED, true]
handlers:
python:
inventories:
- url: https://docs.python.org/3/objects.inv
domains: [std, py]
- https://typing-extensions.readthedocs.io/en/latest/objects.inv
paths: [packages/griffelib/src, packages/griffecli/src, scripts, .]
options:
backlinks: tree
docstring_options:
per_style_options:
google:
ignore_init_summary: true
docstring_style: auto
docstring_section_style: list
extensions:
- griffe_inherited_docstrings
- unpack_typeddict
heading_level: 2
inherited_members: true
merge_init_into_class: true
parameter_headings: true
separate_signature: true
show_bases: true
show_inheritance_diagram: true
show_root_heading: true
show_root_full_path: false
show_signature_annotations: true
show_source: true
show_symbol_type_heading: true
show_symbol_type_toc: true
signature_crossrefs: true
summary: true
- llmstxt:
enabled: !ENV [DEPLOY, false]
full_output: llms-full.txt
sections:
Usage:
- index.md
- getting-started.md
- installation.md
- introduction.md
- guide/users.md
- guide/users/loading.md
- guide/users/navigating.md
- guide/users/serializing.md
- guide/users/checking.md
- guide/users/extending.md
Recommendations:
- guide/users/recommendations/*
How-to:
- guide/users/how-to/*
Reference:
- reference/cli.md
- reference/docstrings.md
- reference/api.md
- reference/api/*
Extensions:
- extensions/built-in.md
- extensions/built-in/*
- extensions/official.md
- extensions/official/*
- extensions/third-party.md
- extensions/third-party/*
Contributing:
- guide/contributors.md
- guide/contributors/*
Other:
- license.md
- community.md
- getting-help.md
- code-of-conduct.md
- alternatives.md
- downstream-projects.md
- credits.md
- changelog.md
- git-revision-date-localized:
enabled: !ENV [DEPLOY, false]
enable_creation_date: true
type: timeago
- minify:
minify_html: !ENV [DEPLOY, false]
- redirects:
redirect_maps:
cli_reference.md: reference/cli.md
checking.md: guide/users/checking.md
dumping.md: guide/users/serializing.md
loading.md: guide/users/loading.md
expressions.md: guide/users/navigating.md#expressions
best_practices.md: guide/users/recommendations/python-code.md
docstrings.md: reference/docstrings.md
parsing_docstrings.md: guide/users/how-to/parse-docstrings.md
try_it_out.md: playground.md
reference/griffe.md: reference/api.md
code_of_conduct.md: code-of-conduct.md
- typeset
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/pawamoy
- icon: fontawesome/brands/mastodon
link: https://fosstodon.org/@pawamoy
- icon: fontawesome/brands/twitter
link: https://twitter.com/pawamoy
- icon: fontawesome/brands/gitter
link: https://gitter.im/griffe/community
- icon: fontawesome/brands/python
link: https://pypi.org/project/griffe/
analytics:
feedback:
title: Was this page helpful?
ratings:
- icon: material/emoticon-happy-outline
name: This page was helpful
data: 1
note: Thanks for your feedback!
- icon: material/emoticon-sad-outline
name: This page could be improved
data: 0
note: Let us know how we can improve this page.
python-griffe-2.3.0/.envrc 0000664 0001750 0001750 00000000021 15246557273 015306 0 ustar carsten carsten PATH_add scripts
python-griffe-2.3.0/README.md 0000664 0001750 0001750 00000023505 15246557273 015463 0 ustar carsten carsten # Griffe
[](https://github.com/mkdocstrings/griffe/actions?query=workflow%3Aci)
[](https://mkdocstrings.github.io/griffe/)
[](https://pypi.org/project/griffe/)
[](https://app.gitter.im/#/room/#mkdocstrings_griffe:gitter.im)
[](https://app.radicle.at/nodes/seed.radicle.at/rad:z4M5XTPDD4Wh1sm8iPCenF85J3z8Z)
Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API.
Griffe, pronounced "grif" (`/ɡ�if/`), is a french word that means "claw",
but also "signature" in a familiar way. "On reconnaît bien là sa griffe."
- [User guide](https://mkdocstrings.github.io/griffe/guide/users/)
- [Contributor guide](https://mkdocstrings.github.io/griffe/guide/contributors/)
- [API reference](https://mkdocstrings.github.io/griffe/reference/api/)
## Installation
```bash
pip install griffe
```
With [`uv`](https://docs.astral.sh/uv/):
```bash
uv tool install griffe
```
## Usage
### Dump JSON-serialized API
**On the command line**, pass the names of packages to the `griffe dump` command:
```console
$ griffe dump httpx fastapi
{
"httpx": {
"name": "httpx",
...
},
"fastapi": {
"name": "fastapi",
...
}
}
```
See the [Serializing chapter](https://mkdocstrings.github.io/griffe/guide/users/serializing/) for more examples.
### Check for API breaking changes
Pass a relative path to the `griffe check` command:
```console
$ griffe check mypackage --verbose
mypackage/mymodule.py:10: MyClass.mymethod(myparam):
Parameter kind was changed:
Old: positional or keyword
New: keyword-only
```
For `src` layouts:
```console
$ griffe check --search src mypackage --verbose
src/mypackage/mymodule.py:10: MyClass.mymethod(myparam):
Parameter kind was changed:
Old: positional or keyword
New: keyword-only
```
It's also possible to directly **check packages from PyPI.org**
(or other indexes configured through `PIP_INDEX_URL`).
This feature requires that you install Griffe with the `pypi` extra:
```bash
pip install griffe[pypi]
```
The command syntax is:
```bash
griffe check package_name -b project-name==2.0 -a project-name==1.0
```
See the [Checking chapter](https://mkdocstrings.github.io/griffe/guide/users/checking/) for more examples.
### Load and navigate data with Python
**With Python**, loading a package:
```python
import griffe
fastapi = griffe.load("fastapi")
```
Finding breaking changes:
```python
import griffe
previous = griffe.load_git("mypackage", ref="0.2.0")
current = griffe.load("mypackage")
for breakage in griffe.find_breaking_changes(previous, current):
...
```
See the [Loading chapter](https://mkdocstrings.github.io/griffe/guide/users/loading/) for more examples.
## Sponsors
---
*And 4 more private sponsor(s).*
python-griffe-2.3.0/pyproject.toml 0000664 0001750 0001750 00000010753 15246557273 017121 0 ustar carsten carsten [build-system]
# pdm-backend is left here as a dependency of the version discovery script currently in use.
# It may be removed in the future. See mkdocstrings/griffe#430
requires = ["hatchling", "pdm-backend", "uv-dynamic-versioning>=0.7.0"]
build-backend = "hatchling.build"
[project]
name = "griffe"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}]
license = "ISC"
license-files = ["LICENSE"]
readme = "README.md"
requires-python = ">=3.10"
keywords = ["api", "signature", "breaking-changes", "static-analysis", "dynamic-analysis"]
dynamic = ["version", "dependencies", "optional-dependencies"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
# YORE: EOL 3.10: Remove line.
"Programming Language :: Python :: 3.10",
# YORE: EOL 3.11: Remove line.
"Programming Language :: Python :: 3.11",
# YORE: EOL 3.12: Remove line.
"Programming Language :: Python :: 3.12",
# YORE: EOL 3.13: Remove line.
"Programming Language :: Python :: 3.13",
# YORE: EOL 3.14: Remove line.
"Programming Language :: Python :: 3.14",
"Topic :: Documentation",
"Topic :: Software Development",
"Topic :: Software Development :: Documentation",
"Topic :: Utilities",
"Typing :: Typed",
]
[project.urls]
Homepage = "https://mkdocstrings.github.io/griffe"
Documentation = "https://mkdocstrings.github.io/griffe"
Changelog = "https://mkdocstrings.github.io/griffe/changelog"
Repository = "https://github.com/mkdocstrings/griffe"
Issues = "https://github.com/mkdocstrings/griffe/issues"
Discussions = "https://github.com/mkdocstrings/griffe/discussions"
Gitter = "https://gitter.im/mkdocstrings/griffe"
Funding = "https://github.com/sponsors/pawamoy"
[project.scripts]
griffe = "griffecli:main"
[tool.uv-dynamic-versioning]
pattern = '(?P \d+\.\d+\.\d+)'
[tool.hatch.version]
source = "code"
path = "scripts/get_version.py"
expression = "get_version()"
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
# Dependencies are dynamically versioned; {{version}} is substituted at build time.
# This ensures griffe, griffelib, and griffecli versions are always 1:1.
dependencies = ["griffelib=={{version}}", "griffecli=={{version}}"]
[tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies]
# The 'pypi' extra re-exports griffelib[pypi] for backward compatibility.
pypi = ["griffelib[pypi]=={{version}}"]
[tool.hatch.build]
# Include as much as possible in the source distribution, to help redistributors.
ignore-vcs = true
exclude = [".github"]
[tool.hatch.build.targets.sdist]
include = [
"/config",
"/docs",
"/scripts",
"/share",
"/tests",
"/duties.py",
"/mkdocs.yml",
"/*.md",
"/LICENSE",
]
[tool.hatch.build.targets.wheel]
# Manual pages can be included in the wheel.
# Depending on the installation tool, they will be accessible to users.
# pipx supports it, uv does not yet, see https://github.com/astral-sh/uv/issues/4731.
bypass-selection = true
artifacts = ["share/**/*"]
[dependency-groups]
maintain = [
"build>=1.2",
"git-changelog>=2.5",
"twine>=5.1",
"yore>=0.3.3",
]
ci = [
"duty>=1.6",
"griffe-inherited-docstrings>=1.1.2",
"jsonschema>=4.18",
"pysource-codegen>=0.7",
"pysource-minimize>=0.10",
"pytest>=8.2",
"pytest-cov>=5.0",
"pytest-gitconfig>=0.8.0",
"pytest-randomly>=3.15",
"pytest-xdist>=3.6",
"ruff>=0.4",
"ty>=0.0.14",
"types-markdown>=3.6",
"types-pyyaml>=6.0",
]
docs = [
"code2flow>=2.5",
"griffe-inherited-docstrings>=1.1.2",
"markdown-callouts>=0.4",
"markdown-exec[ansi]>=1.8",
"mkdocs>=1.6",
"mkdocs-coverage>=1.0",
"mkdocs-gen-files>=0.5",
"mkdocs-git-revision-date-localized-plugin>=1.2",
"mkdocs-llmstxt>=0.2",
"mkdocs-material>=9.5",
"mkdocs-minify-plugin>=0.8",
"mkdocs-section-index>=0.3",
"mkdocs-redirects>=1.2",
"mkdocstrings[python]>=0.29",
"pydeps>=1.12",
# YORE: EOL 3.10: Remove line.
"tomli>=2.0; python_version < '3.11'",
]
[tool.uv]
default-groups = ["maintain", "ci", "docs"]
workspace = { members = ["packages/*"] }
[tool.uv.sources]
griffelib = { workspace = true }
griffecli = { workspace = true }
python-griffe-2.3.0/scripts/ 0000775 0001750 0001750 00000000000 15246557273 015666 5 ustar carsten carsten python-griffe-2.3.0/scripts/gen_griffe_json.py 0000664 0001750 0001750 00000002164 15246557273 021367 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Generate the JSON API data file.
import mkdocs_gen_files
python_handler = mkdocs_gen_files.config.plugins["mkdocstrings"].get_handler("python")
data = python_handler.collect("griffe", options=python_handler.get_options({}))
with mkdocs_gen_files.open("griffe.json", "w") as fd:
print(data.as_json(full=True), file=fd)
python-griffe-2.3.0/scripts/gen_structure_docs.py 0000664 0001750 0001750 00000010200 15246557273 022132 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Render docs for our program structure.
import os
import subprocess
from io import StringIO
from pathlib import Path
from textwrap import dedent
from unittest.mock import MagicMock
from code2flow import code2flow, engine, model
engine.logging = MagicMock()
model.TRUNK_COLOR = "#fca311" # ty:ignore[invalid-assignment]
model.LEAF_COLOR = "#98c1d9" # ty:ignore[invalid-assignment]
model.EDGE_COLORS = ["#b8b8ff"] * 8
model.NODE_COLOR = "#e5e5e5" # ty:ignore[invalid-assignment]
def _render_call_graph(module: Path) -> None:
buffer = StringIO()
code2flow(str(module), buffer)
try:
svg = subprocess.check_output(["dot", "-Tsvg"], input=buffer.getvalue(), text=True) # noqa: S607
except subprocess.CalledProcessError:
# The subprocess dies with SIGSEGV in GHA...
return
if 'class="node"' not in svg:
print()
else:
print(f'{svg}
')
def _comment_block(module: Path) -> str:
lines = []
with module.open() as file:
for line in file:
if line.startswith("#"):
lines.append(line[1:])
else:
break
return dedent("".join(lines))
def _render_api(path: Path, root: Path, heading_level: int = 4) -> None:
for module in sorted(path.iterdir()):
if module.name in ("__main__.py", "__init__.py"):
continue
rel_path = str(module.relative_to(root).with_suffix("")).replace("/", "-")
if module.suffix == ".py":
print(f"{'#' * heading_level} `{module.name}` {{#{rel_path}}}\n")
print(_comment_block(module))
_render_call_graph(module)
elif module.is_dir() and module.joinpath("__init__.py").exists():
print(f"{'#' * heading_level} `{module.name}` {{#{rel_path}}}\n")
print(_comment_block(module / "__init__.py"))
_render_api(module, root, heading_level + 1)
def render_internal_api(heading_level: int = 4) -> None:
"""Render Griffe's internal API's structure docs.
This function prints Markdown headings, and the contents of the first comment block of a module,
for all modules in our internal API.
Parameters:
heading_level: The initial level of Markdown headings.
"""
root = Path(os.environ["MKDOCS_CONFIG_DIR"])
src = root / "packages" / "griffelib" / "src"
internal_api = src / "griffe" / "_internal"
print(_comment_block(internal_api / "__init__.py"))
_render_api(internal_api, internal_api, heading_level)
def render_public_api(heading_level: int = 4) -> None:
"""Render Griffe's main module's docs.
Parameters:
heading_level: The initial level of Markdown headings.
"""
root = Path(os.environ["MKDOCS_CONFIG_DIR"])
src = root / "packages" / "griffelib" / "src"
public_api = src / "griffe"
print(f"{'#' * heading_level} `griffe`\n")
print(_comment_block(public_api / "__init__.py"))
def render_entrypoint(heading_level: int = 4) -> None:
"""Render Griffe's main entrypoint's docs.
Parameters:
heading_level: The initial level of Markdown headings.
"""
root = Path(os.environ["MKDOCS_CONFIG_DIR"])
src = root / "packages" / "griffelib" / "src"
public_api = src / "griffe"
print(f"{'#' * heading_level} `griffe.__main__`\n")
print(_comment_block(public_api / "__main__.py"))
python-griffe-2.3.0/scripts/make 0000777 0001750 0001750 00000000000 15246557273 020010 2make.py ustar carsten carsten python-griffe-2.3.0/scripts/get_version.py 0000664 0001750 0001750 00000003550 15246557273 020567 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Get current project version from Git tags or changelog.
import re
from contextlib import suppress
from pathlib import Path
from pdm.backend.hooks.version import ( # ty:ignore[unresolved-import]
SCMVersion,
Version,
default_version_formatter,
get_version_from_scm,
)
_root = Path(__file__).parent.parent
_changelog = _root / "CHANGELOG.md"
_changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$")
_default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003
def get_version() -> str:
scm_version = get_version_from_scm(_root) or _default_scm_version
if scm_version.version <= Version("0.1"): # Missing Git tags?
with suppress(OSError, StopIteration): # noqa: SIM117
with _changelog.open("r", encoding="utf8") as file:
match = next(filter(None, map(_changelog_version_re.match, file)))
scm_version = scm_version._replace(version=Version(match.group(1)))
return default_version_formatter(scm_version)
if __name__ == "__main__":
print(get_version())
python-griffe-2.3.0/scripts/make.py 0000775 0001750 0001750 00000030777 15246557273 017176 0 ustar carsten carsten #!/usr/bin/env python3
# SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.10 3.11 3.12 3.13 3.14 3.15").split()
PYTHON_DEV = "3.15"
_commands = []
# -----------------------------------------------------------------------------
# Helper functions ------------------------------------------------------------
# -----------------------------------------------------------------------------
def _shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None:
if capture_output:
return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602
subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602
return None
@contextmanager
def _environ(**kwargs: str) -> Iterator[None]:
original = dict(os.environ)
os.environ.update(kwargs)
try:
yield
finally:
os.environ.clear()
os.environ.update(original)
def _uv_install(venv: Path) -> None:
with _environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"):
if "CI" in os.environ:
_shell("uv sync --no-editable")
else:
_shell("uv sync")
def _run(version: str, cmd: str, *args: str, **kwargs: Any) -> None:
kwargs = {"check": True, **kwargs}
uv_run = ["uv", "run", "--no-sync"]
try:
if version == "default":
with _environ(UV_PROJECT_ENVIRONMENT=".venv"):
subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510
else:
with _environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"):
subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510
except subprocess.CalledProcessError as process:
raise _RunError(
returncode=process.returncode,
python_version=version,
cmd=process.cmd,
output=process.output,
stderr=process.stderr,
) from process
def _command(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]:
def wrapper(func: Callable[..., None]) -> Callable[..., None]:
func.__cmdname__ = name # ty:ignore[unresolved-attribute]
_commands.append(func)
return func
return wrapper
# -----------------------------------------------------------------------------
# Commands --------------------------------------------------------------------
# -----------------------------------------------------------------------------
@_command("help")
def help(*args: str) -> None:
"""Print this help. Add task name to print help.
```bash
make help [TASK]
```
When the Python dependencies are not installed,
this command just print the available commands.
When the Python dependencies are installed,
[duty](https://github.com/pawamoy/duty) is available
so the command can also print the available tasks.
If you add a task name after the command, it will print help for this specific task.
"""
if len(args) > 1:
_run("default", "duty", "--help", args[1])
else:
print("Available commands", flush=True)
for cmd in _commands:
print(f" {cmd.__cmdname__:21} {cmd.__doc__.splitlines()[0]}", flush=True)
if Path(".venv").exists():
print("\nAvailable tasks", flush=True)
run("duty", "--list")
@_command("setup")
def setup() -> None:
"""Setup all virtual environments (install dependencies).
```bash
make setup
```
The `setup` command installs all the Python dependencies required to work on the project.
Virtual environments and dependencies are managed by [uv](https://github.com/astral-sh/uv).
Development dependencies are listed in the `devdeps.txt` file.
The command will create a virtual environment in the `.venv` folder,
as well as one virtual environment per supported Python version in the `.venvs/3.x` folders.
Supported Python versions are listed in the `scripts/make` file, and can be overridden
by setting the `PYTHON_VERSIONS` environment variable.
If you cloned the repository on the same file-system as uv's cache,
everything will be hard linked from the cache, so don't worry about wasting disk space.
Once dependencies are installed, try running `make` or `make help` again, to show additional tasks.
```console exec="1" source="console" id="make-help2"
$ alias make="$PWD/scripts/make" # markdown-exec: hide
$ make
```
These tasks are written using [duty](https://github.com/pawamoy/duty) (a task runner),
and located in the `duties.py` module in the repository root.
Some of these tasks will run in the default virtual environment (`.venv`),
while others will run in all the supported Python version environments (`.venvs/3.x`).
"""
if not shutil.which("uv"):
raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv")
print("Installing dependencies (default environment)")
default_venv = Path(".venv")
if not default_venv.exists():
_shell("uv venv --python python")
_uv_install(default_venv)
if PYTHON_VERSIONS:
for version in PYTHON_VERSIONS:
print(f"\nInstalling dependencies (python{version})")
venv_path = Path(f".venvs/{version}")
if not venv_path.exists():
_shell(f"uv venv --python {version} {venv_path}")
with _environ(VIRTUAL_ENV=str(venv_path.resolve())):
_uv_install(venv_path)
class _RunError(subprocess.CalledProcessError):
def __init__(self, *args: Any, python_version: str, **kwargs: Any):
super().__init__(*args, **kwargs)
self.python_version = python_version
@_command("run")
def run(cmd: str, *args: str, **kwargs: Any) -> None:
"""Run a command in the default virtual environment.
```bash
make run [ARG...]
```
This command runs an arbitrary command inside the default virtual environment (`.venv`).
It is especially useful to start a Python interpreter without having to first activate
the virtual environment: `make run python`.
"""
_run("default", cmd, *args, **kwargs)
@_command("multirun")
def multirun(cmd: str, *args: str, **kwargs: Any) -> None:
"""Run a command for all configured Python versions.
```bash
make multirun [ARG...]
```
This command runs an arbitrary command inside the environments
for all supported Python versions. It is especially useful for running tests.
"""
if PYTHON_VERSIONS:
for version in PYTHON_VERSIONS:
run3x(version, cmd, *args, **kwargs)
else:
run(cmd, *args, **kwargs)
@_command("allrun")
def allrun(cmd: str, *args: str, **kwargs: Any) -> None:
"""Run a command in all virtual environments.
```bash
make multirun [ARG...]
```
This command runs an arbitrary command inside the default environment,
as well as the environments for all supported Python versions.
This command is especially useful to install, remove or update dependencies
in all environments at once. For example, if you want to install a dependency
in editable mode, from a local source:
```bash
make allrun uv pip install -e ../other-project
```
"""
run(cmd, *args, **kwargs)
if PYTHON_VERSIONS:
multirun(cmd, *args, **kwargs)
@_command("3.x")
def run3x(version: str, cmd: str, *args: str, **kwargs: Any) -> None:
"""Run a command in the virtual environment for Python 3.x.
```bash
make 3.x [ARG...]
```
This command runs an arbitrary command inside the environment of the selected Python version.
It can be useful if you want to run a task that usually runs in the default environment
with a different Python version.
"""
_run(version, cmd, *args, **kwargs)
@_command("clean")
def clean() -> None:
"""Delete build artifacts and cache files.
```bash
make clean
```
This command simply deletes build artifacts and cache files and folders
such as `build/`, `.cache/`, etc.. The virtual environments (`.venv` and `.venvs/*`)
are not removed by this command.
"""
paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"]
for path in paths_to_clean:
_shell(f"rm -rf {path}")
cache_dirs = {".cache", ".pytest_cache", ".ruff_cache", "__pycache__"}
for dirpath in Path().rglob("*/"):
if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs:
shutil.rmtree(dirpath, ignore_errors=True)
@_command("vscode")
def vscode() -> None:
"""Configure VSCode to work on this project.
```bash
make vscode
```
This command configures the [VSCode editor](https://code.visualstudio.com/)
by copying the following files into the `.vscode` directory:
- `launch.json`, for run configurations (to run debug sessions)
- `settings.json`, for various editor settings like linting tools and their configuration
- `tasks.json`, for running tasks directly from VSCode's interface
Warning:
These files will be overwritten every time the command is run.
"""
Path(".vscode").mkdir(parents=True, exist_ok=True)
_shell("cp -v config/vscode/* .vscode")
# -----------------------------------------------------------------------------
# Main ------------------------------------------------------------------------
# -----------------------------------------------------------------------------
def main(args: list[str]) -> int:
"""Main entry point."""
if not args or args[0] == "help":
help(*args)
return 0
while args:
cmd = args.pop(0)
if cmd == "run":
if not args:
print("make: run: missing command", file=sys.stderr)
return 1
run(*args)
return 0
if cmd == "multirun":
if not args:
print("make: run: missing command", file=sys.stderr)
return 1
multirun(*args)
return 0
if cmd == "allrun":
if not args:
print("make: run: missing command", file=sys.stderr)
return 1
allrun(*args)
return 0
if cmd.startswith("3."):
if not args:
print("make: run: missing command", file=sys.stderr)
return 1
run3x(cmd, *args)
return 0
opts = []
while args and (args[0].startswith("-") or "=" in args[0]):
opts.append(args.pop(0))
if cmd == "clean":
clean()
elif cmd == "setup":
setup()
elif cmd == "vscode":
vscode()
elif cmd == "check":
multirun("duty", "check-quality", "check-types", "check-docs")
run("duty", "check-api")
elif cmd in {"check-quality", "check-docs", "check-types", "test"}:
multirun("duty", cmd, *opts)
else:
run("duty", cmd, *opts)
return 0
if __name__ == "__main__":
try:
sys.exit(main(sys.argv[1:]))
except _RunError as process:
if process.output:
print(process.output, file=sys.stderr)
if (code := process.returncode) == 139: # noqa: PLR2004
print(
f"✗ (python{process.python_version}) '{' '.join(process.cmd)}' failed with return code {code} (segfault)",
file=sys.stderr,
)
if process.python_version == PYTHON_DEV:
code = 0
sys.exit(code)
python-griffe-2.3.0/scripts/gen_credits.py 0000664 0001750 0001750 00000016325 15246557273 020535 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Script to generate the project's credits.
from __future__ import annotations
import os
import sys
from collections import defaultdict
from collections.abc import Iterable
from importlib.metadata import distributions
from itertools import chain
from pathlib import Path
from textwrap import dedent
from jinja2 import StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
from packaging.requirements import Requirement
# YORE: EOL 3.10: Replace block with line 2.
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", "."))
with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
project = pyproject["project"]
project_name = project["name"]
devdeps = [dep for group in pyproject["dependency-groups"].values() for dep in group if not dep.startswith("-e")]
PackageMetadata = dict[str, str | Iterable[str]]
Metadata = dict[str, PackageMetadata]
def _merge_fields(metadata: dict) -> PackageMetadata:
fields = defaultdict(list)
for header, value in metadata.items():
fields[header.lower()].append(value.strip())
return {
field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0]
for field, value in fields.items()
}
def _norm_name(name: str) -> str:
return name.replace("_", "-").replace(".", "-").lower()
def _requirements(deps: Iterable[str]) -> dict[str, Requirement]:
return {_norm_name((req := Requirement(dep)).name): req for dep in deps}
def _extra_marker(req: Requirement) -> str | None:
if not req.marker:
return None
try:
return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra")
except StopIteration:
return None
def _get_metadata() -> Metadata:
metadata = {}
for pkg in distributions():
name = _norm_name(pkg.name)
metadata[name] = _merge_fields(pkg.metadata) # ty:ignore[invalid-argument-type]
metadata[name]["spec"] = set()
metadata[name]["extras"] = set()
metadata[name].setdefault("summary", "")
_set_license(metadata[name])
return metadata
def _set_license(metadata: PackageMetadata) -> None:
license_field = metadata.get("license-expression", metadata.get("license", ""))
license_name = license_field if isinstance(license_field, str) else " + ".join(license_field)
check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n")
if check_classifiers:
license_names = [
classifier.rsplit("::", 1)[1].strip()
for classifier in metadata["classifier"]
if classifier.startswith("License ::")
]
license_name = " + ".join(license_names)
metadata["license"] = license_name or "?"
def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata:
deps = {}
for dep_name, dep_req in base_deps.items():
if dep_name not in metadata or dep_name == "griffe":
continue
metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # ty:ignore[unsupported-operator]
metadata[dep_name]["extras"] |= dep_req.extras # ty:ignore[unsupported-operator]
deps[dep_name] = metadata[dep_name]
again = True
while again:
again = False
for pkg_name in metadata:
if pkg_name in deps:
for pkg_dependency in metadata[pkg_name].get("requires-dist", []):
requirement = Requirement(pkg_dependency)
dep_name = _norm_name(requirement.name)
extra_marker = _extra_marker(requirement)
if (
dep_name in metadata
and dep_name not in deps
and dep_name != project["name"]
and (not extra_marker or extra_marker in deps[pkg_name]["extras"])
):
metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # ty:ignore[unsupported-operator]
deps[dep_name] = metadata[dep_name]
again = True
return deps
def _render_credits() -> str:
metadata = _get_metadata()
dev_dependencies = _get_deps(_requirements(devdeps), metadata)
prod_dependencies = _get_deps(
_requirements(
chain(
project.get("dependencies", []),
chain(*project.get("optional-dependencies", {}).values()),
),
),
metadata,
)
template_data = {
"project_name": project_name,
"prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()),
"dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()),
"more_credits": "http://pawamoy.github.io/credits/",
}
template_text = dedent(
"""
# Credits
These projects were used to build *{{ project_name }}*. **Thank you!**
[Python](https://www.python.org/) |
[uv](https://github.com/astral-sh/uv) |
[copier-uv](https://github.com/pawamoy/copier-uv)
{% macro dep_line(dep) -%}
[{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }}
{%- endmacro %}
{% if prod_dependencies -%}
### Runtime dependencies
Project | Summary | Version (accepted) | Version (last resolved) | License
------- | ------- | ------------------ | ----------------------- | -------
{% for dep in prod_dependencies -%}
{{ dep_line(dep) }}
{% endfor %}
{% endif -%}
{% if dev_dependencies -%}
### Development dependencies
Project | Summary | Version (accepted) | Version (last resolved) | License
------- | ------- | ------------------ | ----------------------- | -------
{% for dep in dev_dependencies -%}
{{ dep_line(dep) }}
{% endfor %}
{% endif -%}
{% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %}
""",
)
jinja_env = SandboxedEnvironment(undefined=StrictUndefined)
return jinja_env.from_string(template_text).render(**template_data)
print(_render_credits())
python-griffe-2.3.0/config/ 0000775 0001750 0001750 00000000000 15246557273 015444 5 ustar carsten carsten python-griffe-2.3.0/config/git-changelog.toml 0000664 0001750 0001750 00000000350 15246557273 021047 0 ustar carsten carsten bump = "auto"
convention = "angular"
in-place = true
output = "CHANGELOG.md"
parse-refs = false
parse-trailers = true
sections = ["build", "deps", "feat", "fix", "perf", "refactor"]
template = "keepachangelog"
versioning = "pep440"
python-griffe-2.3.0/config/vscode/ 0000775 0001750 0001750 00000000000 15246557273 016727 5 ustar carsten carsten python-griffe-2.3.0/config/vscode/tasks.json 0000664 0001750 0001750 00000004605 15246557273 020754 0 ustar carsten carsten {
"version": "2.0.0",
"tasks": [
{
"label": "changelog",
"type": "process",
"command": "scripts/make",
"args": ["changelog"]
},
{
"label": "check",
"type": "process",
"command": "scripts/make",
"args": ["check"]
},
{
"label": "check-quality",
"type": "process",
"command": "scripts/make",
"args": ["check-quality"]
},
{
"label": "check-types",
"type": "process",
"command": "scripts/make",
"args": ["check-types"]
},
{
"label": "check-docs",
"type": "process",
"command": "scripts/make",
"args": ["check-docs"]
},
{
"label": "check-api",
"type": "process",
"command": "scripts/make",
"args": ["check-api"]
},
{
"label": "clean",
"type": "process",
"command": "scripts/make",
"args": ["clean"]
},
{
"label": "docs",
"type": "process",
"command": "scripts/make",
"args": ["docs"]
},
{
"label": "docs-deploy",
"type": "process",
"command": "scripts/make",
"args": ["docs-deploy"]
},
{
"label": "format",
"type": "process",
"command": "scripts/make",
"args": ["format"]
},
{
"label": "release",
"type": "process",
"command": "scripts/make",
"args": ["release", "${input:version}"]
},
{
"label": "setup",
"type": "process",
"command": "scripts/make",
"args": ["setup"]
},
{
"label": "test",
"type": "process",
"command": "scripts/make",
"args": ["test", "coverage"],
"group": "test"
},
{
"label": "vscode",
"type": "process",
"command": "scripts/make",
"args": ["vscode"]
}
],
"inputs": [
{
"id": "version",
"type": "promptString",
"description": "Version"
}
]
} python-griffe-2.3.0/config/vscode/launch.json 0000664 0001750 0001750 00000002632 15246557273 021077 0 ustar carsten carsten {
"version": "0.2.0",
"configurations": [
{
"name": "python (current file)",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false,
"args": "${command:pickArgs}"
},
{
"name": "run",
"type": "debugpy",
"request": "launch",
"module": "griffe",
"console": "integratedTerminal",
"justMyCode": false,
"args": "${command:pickArgs}"
},
{
"name": "docs",
"type": "debugpy",
"request": "launch",
"module": "mkdocs",
"justMyCode": false,
"args": [
"serve",
"-v"
]
},
{
"name": "test",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"justMyCode": false,
"args": [
"-c=config/pytest.ini",
"-vvv",
"--no-cov",
"--dist=no",
"-k=${input:tests_selection}"
]
}
],
"inputs": [
{
"id": "tests_selection",
"type": "promptString",
"description": "Tests selection",
"default": ""
}
]
}
python-griffe-2.3.0/config/vscode/settings.json 0000664 0001750 0001750 00000001643 15246557273 021466 0 ustar carsten carsten {
"files.watcherExclude": {
"**/.venv*/**": true,
"**/.venvs*/**": true,
"**/venv*/**": true
},
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"--config-file=config/pytest.ini"
],
"ruff.enable": true,
"ruff.format.args": [
"--config=config/ruff.toml"
],
"ruff.lint.args": [
"--config=config/ruff.toml"
],
"ty.configurationFile": "config/ty.toml",
"yaml.schemas": {
"https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml"
},
"yaml.customTags": [
"!ENV scalar",
"!ENV sequence",
"!relative scalar",
"tag:yaml.org,2002:python/name:materialx.emoji.to_svg",
"tag:yaml.org,2002:python/name:materialx.emoji.twemoji",
"tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format"
]
} python-griffe-2.3.0/config/ty.toml 0000664 0001750 0001750 00000000274 15246557273 017000 0 ustar carsten carsten [src]
exclude = ["packages/*/tests/fixtures"]
[terminal]
error-on-warning = true
output-format = "concise"
[environment]
root = [".", "packages/griffelib/src", "packages/griffecli/src"]
python-griffe-2.3.0/config/coverage.ini 0000664 0001750 0001750 00000001002 15246557273 017731 0 ustar carsten carsten [coverage:run]
branch = true
parallel = true
source =
packages/griffelib/src/griffe
packages/griffecli/src/griffecli
packages/griffecli/tests/
packages/griffelib/tests/
[coverage:paths]
equivalent =
src/
.venv/lib/*/site-packages/
.venvs/*/lib/*/site-packages/
[coverage:report]
precision = 2
omit =
src/*/__init__.py
src/*/__main__.py
packages/*/tests/__init__.py
packages/*/tests/tmp/*
exclude_lines =
pragma: no cover
if TYPE_CHECKING
[coverage:json]
output = htmlcov/coverage.json
python-griffe-2.3.0/config/pytest.ini 0000664 0001750 0001750 00000000637 15246557273 017503 0 ustar carsten carsten [pytest]
addopts =
--cov
--cov-config config/coverage.ini
--import-mode=importlib
testpaths =
packages/*/tests
# action:message_regex:warning_class:module_regex:line
filterwarnings =
error
default::EncodingWarning
error::EncodingWarning:griffe
# TODO: remove once pytest-xdist 4 is released
ignore:.*rsyncdir:DeprecationWarning:xdist
ignore:.*slated for removal in Python:DeprecationWarning:.*
python-griffe-2.3.0/config/ruff.toml 0000664 0001750 0001750 00000006362 15246557273 017312 0 ustar carsten carsten target-version = "py310"
line-length = 120
output-format = "concise"
[lint]
exclude = [
"packages/*/tests/fixtures/*.py",
]
select = ["ALL"]
ignore = [
"A001", # Variable is shadowing a Python builtin
"A005", # Module shadows a Python standard-library module
"ANN204", # Missing return type annotation for special method __str__
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
"ARG005", # Unused lambda argument
"C901", # Too complex
"D105", # Missing docstring in magic method
"D417", # Missing argument description in the docstring
"E501", # Line too long
"EM", # Raw strings in exceptions
"FIX001", # Line contains FIXME
"FIX002", # Line contains TODO
"ERA001", # Commented out code
"PD", # Pandas-related
"PERF203", # Try-except block in for loop (zero-cost with Python 3.11+)
"PLR0911", # Too many return statements
"PLR0912", # Too many branches
"PLR0913", # Too many arguments to function call
"PLR0915", # Too many statements
"SLF001", # Private member accessed
"TD001", # Invalid TODO tag: FIXME
"TD002", # Missing author in TODO
"TD003", # Missing issue link on the line following this TODO
"TRY003", # Avoid specifying long messages outside the exception class
"UP007", # Use `X | Y` for type annotations
]
logger-objects = ["griffe.logger"]
[lint.per-file-ignores]
"packages/griffecli/src/griffecli/__main__.py" = [
"D100", # Missing module docstring
]
"packages/griffecli/src/griffecli/_internal/cli.py" = [
"T201", # Print statement
]
"packages/griffelib/src/griffe/_internal/git.py" = [
"S603", # `subprocess` call: check for execution of untrusted input
"S607", # Starting a process with a partial executable path
]
"packages/griffelib/src/griffe/_internal/agents/nodes/*.py" = [
"ARG001", # Unused function argument
"N812", # Lowercase `keyword` imported as non-lowercase `NodeKeyword`
]
"packages/griffelib/src/griffe/_internal/debug.py" = [
"T201", # Print statement
]
"packages/griffelib/src/griffe/_internal/**.py" = [
"D100", # Missing docstring in public module
]
"scripts/*.py" = [
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D103", # Missing docstring in public function
"INP001", # File is part of an implicit namespace package
"T201", # Print statement
]
"packages/griffelib/tests/test_git.py" = [
"S603", # `subprocess` call: check for execution of untrusted input
"S607", # Starting a process with a partial executable path
]
"packages/*/tests/**.py" = [
"ARG005", # Unused lambda argument
"D100", # Missing module docstring
"D104", # Missing package docstring
"FBT001", # Boolean positional arg in function definition
"PLC1901", # a == "" can be simplified to not a
"PLR2004", # Magic value used in comparison
"S101", # Use of assert detected
]
[lint.flake8-quotes]
docstring-quotes = "double"
[lint.flake8-tidy-imports]
ban-relative-imports = "all"
[lint.isort]
known-first-party = ["griffe", "griffecli"]
[lint.pydocstyle]
convention = "google"
[format]
exclude = [
"packages/*/tests/fixtures/*.py",
]
docstring-code-format = true
docstring-code-line-length = 80
python-griffe-2.3.0/CONTRIBUTING.md 0000664 0001750 0001750 00000001556 15246557273 016437 0 ustar carsten carsten # Contributing
Thank you for considering contributing to this project!
We provide a guide for contributors. If you are reading this locally or directly on GitHub, check the following documents:
- Contributors guide: [docs/guide/contributors.md](docs/guide/contributors.md)
- Environment setup: [docs/guide/contributors/setup.md](docs/guide/contributors/setup.md)
- Management commands: [docs/guide/contributors/commands.md](docs/guide/contributors/commands.md)
- Development workflow: [docs/guide/contributors/workflow.md](docs/guide/contributors/workflow.md)
- Project architecture: [docs/guide/contributors/architecture.md](docs/guide/contributors/architecture.md)
We strongly recommend reading the online version at https://mkdocstrings.github.io/griffe/guide/contributors/, as some content is dynamically generated when building the documentation pages.
python-griffe-2.3.0/packages/ 0000775 0001750 0001750 00000000000 15246557273 015755 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/ 0000775 0001750 0001750 00000000000 15246557273 017706 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/ 0000775 0001750 0001750 00000000000 15246557273 020475 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/ 0000775 0001750 0001750 00000000000 15246557273 021737 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/__init__.py 0000664 0001750 0001750 00000047233 15246557273 024061 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This top-level module imports all public names from the package,
# and exposes them as public objects. We have tests to make sure
# no object is forgotten in this list.
"""Griffe library package.
Signatures for entire Python programs.
Extract the structure, the frame, the skeleton of your project,
to generate API documentation or find breaking changes in your API.
The entirety of the library API is exposed here, in the top-level `griffe` module.
All messages written to standard output or error are logged using the `logging` module.
Our logger's name is set to `"griffe"` and is public (you can rely on it).
You can obtain the logger from the standard `logging` module: `logging.getLogger("griffe")`.
Actual logging messages are not part of the public API (they might change without notice).
Raised exceptions throughout the package are part of the public API (you can rely on them).
Their actual messages are not part of the public API (they might change without notice).
The following paragraphs will help you discover the package's content.
## Loaders
To load API data, Griffe provides several high-level functions.
- [`griffe.load`][]: Load and return a Griffe object.
- [`griffe.load_git`][]: Load and return a module from a specific Git reference.
- [`griffe.load_pypi`][]: Load and return a module from a specific package version downloaded using pip.
## Models
The data loaded by Griffe is represented by several classes.
- [`griffe.Module`][]: The class representing a Python module.
- [`griffe.Class`][]: The class representing a Python class.
- [`griffe.Function`][]: The class representing a Python function or method.
- [`griffe.Attribute`][]: The class representing a Python attribute.
- [`griffe.Alias`][]: This class represents an alias, or indirection, to an object declared in another module.
Additional classes are available to represent other concepts.
- [`griffe.Decorator`][]: This class represents a decorator.
- [`griffe.Parameters`][]: This class is a container for parameters.
- [`griffe.Parameter`][]: This class represent a function parameter.
## Agents
Griffe is able to analyze code both statically and dynamically, using the following "agents".
However most of the time you will only need to use the loaders above.
- [`griffe.visit`][]: Parse and visit a module file.
- [`griffe.inspect`][]: Inspect a module.
## Serializers
Griffe can serizalize data to dictionary and JSON.
- [`griffe.Object.as_json`][griffe.Object.as_json]
- [`griffe.Object.from_json`][griffe.Object.from_json]
- [`griffe.JSONEncoder`][]: JSON encoder for Griffe objects.
- [`griffe.json_decoder`][]: JSON decoder for Griffe objects.
## API checks
Griffe can compare two versions of the same package to find breaking changes.
- [`griffe.find_breaking_changes`][]: Find breaking changes between two versions of the same API.
- [`griffe.Breakage`][]: Breakage classes can explain what broke from a version to another.
## Extensions
Griffe supports extensions. You can create your own extension by subclassing the `griffe.Extension` class.
- [`griffe.load_extensions`][]: Load configured extensions.
- [`griffe.Extension`][]: Base class for Griffe extensions.
## Docstrings
Griffe can parse docstrings into structured data.
Main class:
- [`griffe.Docstring`][]: This class represents docstrings.
Docstring section and element classes all start with `Docstring`.
Docstring parsers:
- [`griffe.parse`][]: Parse the docstring.
- [`griffe.parse_auto`][]: Parse a docstring by automatically detecting the style it uses.
- [`griffe.parse_google`][]: Parse a Google-style docstring.
- [`griffe.parse_numpy`][]: Parse a Numpydoc-style docstring.
- [`griffe.parse_sphinx`][]: Parse a Sphinx-style docstring.
## Exceptions
Griffe uses several exceptions to signal errors.
- [`griffe.GriffeError`][]: The base exception for all Griffe errors.
- [`griffe.LoadingError`][]: Exception for loading errors.
- [`griffe.NameResolutionError`][]: Exception for names that cannot be resolved in a object scope.
- [`griffe.UnhandledEditableModuleError`][]: Exception for unhandled editables modules, when searching modules.
- [`griffe.UnimportableModuleError`][]: Exception for modules that cannot be imported.
- [`griffe.AliasResolutionError`][]: Exception for aliases that cannot be resolved.
- [`griffe.CyclicAliasError`][]: Exception raised when a cycle is detected in aliases.
- [`griffe.LastNodeError`][]: Exception raised when trying to access a next or previous node.
- [`griffe.RootNodeError`][]: Exception raised when trying to use siblings properties on a root node.
- [`griffe.BuiltinModuleError`][]: Exception raised when trying to access the filepath of a builtin module.
- [`griffe.ExtensionError`][]: Base class for errors raised by extensions.
- [`griffe.ExtensionNotLoadedError`][]: Exception raised when an extension could not be loaded.
- [`griffe.GitError`][]: Exception raised for errors related to Git.
# Expressions
Griffe stores snippets of code (attribute values, decorators, base class, type annotations) as expressions.
Expressions are basically abstract syntax trees (AST) with a few differences compared to the nodes returned by [`ast`][].
Griffe provides a few helpers to extract expressions from regular AST nodes.
- [`griffe.get_annotation`][]: Get a type annotation as expression.
- [`griffe.get_base_class`][]: Get a base class as expression.
- [`griffe.get_class_keyword`][]: Get a class keyword as expression.
- [`griffe.get_condition`][]: Get a condition as expression.
- [`griffe.get_expression`][]: Get an expression from an AST node.
- [`griffe.safe_get_annotation`][]: Get a type annotation as expression, safely (returns `None` on error).
- [`griffe.safe_get_base_class`][]: Get a base class as expression, safely (returns `None` on error).
- [`griffe.safe_get_class_keyword`][]: Get a class keyword as expression, safely (returns `None` on error).
- [`griffe.safe_get_condition`][]: Get a condition as expression, safely (returns `None` on error).
- [`griffe.safe_get_expression`][]: Get an expression from an AST node, safely (returns `None` on error).
The base class for expressions.
- [`griffe.Expr`][]
Expression classes all start with `Expr`.
# Loggers
If you want to log messages from extensions, get a logger with `get_logger`.
The `logger` attribute is used by Griffe itself. You can use it to temporarily disable Griffe logging.
- [`griffe.logger`][]: Our global logger, used throughout the library.
- [`griffe.get_logger`][]: Create and return a new logger instance.
# Helpers
To test your Griffe extensions, or to load API data from code in memory, Griffe provides the following helpers.
- [`griffe.temporary_pyfile`][]: Create a Python file containing the given code in a temporary directory.
- [`griffe.temporary_pypackage`][]: Create a package containing the given modules in a temporary directory.
- [`griffe.temporary_visited_module`][]: Create and visit a temporary module with the given code.
- [`griffe.temporary_visited_package`][]: Create and visit a temporary package.
- [`griffe.temporary_inspected_module`][]: Create and inspect a temporary module with the given code.
- [`griffe.temporary_inspected_package`][]: Create and inspect a temporary package.
"""
from __future__ import annotations
from griffe._internal.agents.inspector import Inspector, inspect
from griffe._internal.agents.nodes.assignments import get_instance_names, get_name, get_names
from griffe._internal.agents.nodes.ast import (
ast_children,
ast_first_child,
ast_kind,
ast_last_child,
ast_next,
ast_next_siblings,
ast_previous,
ast_previous_siblings,
ast_siblings,
)
from griffe._internal.agents.nodes.docstrings import get_docstring
from griffe._internal.agents.nodes.exports import get__all__, safe_get__all__
from griffe._internal.agents.nodes.imports import relative_to_absolute
from griffe._internal.agents.nodes.parameters import ParametersType, get_parameters
from griffe._internal.agents.nodes.runtime import ObjectNode
from griffe._internal.agents.nodes.values import get_value, safe_get_value
from griffe._internal.agents.visitor import Visitor, builtin_decorators, stdlib_decorators, typing_overload, visit
from griffe._internal.c3linear import c3linear_merge
from griffe._internal.collections import LinesCollection, ModulesCollection
from griffe._internal.diff import (
AttributeChangedTypeBreakage,
AttributeChangedValueBreakage,
Breakage,
ClassRemovedBaseBreakage,
ObjectChangedKindBreakage,
ObjectRemovedBreakage,
ParameterAddedRequiredBreakage,
ParameterChangedDefaultBreakage,
ParameterChangedKindBreakage,
ParameterChangedRequiredBreakage,
ParameterMovedBreakage,
ParameterRemovedBreakage,
ReturnChangedTypeBreakage,
find_breaking_changes,
)
from griffe._internal.docstrings.auto import (
AutoOptions,
DocstringDetectionMethod,
PerStyleOptions,
infer_docstring_style,
parse_auto,
)
from griffe._internal.docstrings.google import GoogleOptions, parse_google
from griffe._internal.docstrings.models import (
DocstringAdmonition,
DocstringAttribute,
DocstringClass,
DocstringDeprecated,
DocstringElement,
DocstringFunction,
DocstringModule,
DocstringNamedElement,
DocstringParameter,
DocstringRaise,
DocstringReceive,
DocstringReturn,
DocstringSection,
DocstringSectionAdmonition,
DocstringSectionAttributes,
DocstringSectionClasses,
DocstringSectionDeprecated,
DocstringSectionExamples,
DocstringSectionFunctions,
DocstringSectionModules,
DocstringSectionOtherParameters,
DocstringSectionParameters,
DocstringSectionRaises,
DocstringSectionReceives,
DocstringSectionReturns,
DocstringSectionText,
DocstringSectionTypeAliases,
DocstringSectionTypeParameters,
DocstringSectionWarns,
DocstringSectionYields,
DocstringTypeAlias,
DocstringTypeParameter,
DocstringWarn,
DocstringYield,
)
from griffe._internal.docstrings.numpy import NumpyOptions, parse_numpy
from griffe._internal.docstrings.parsers import (
DocstringOptions,
DocstringStyle,
parse,
parsers,
)
from griffe._internal.docstrings.sphinx import SphinxOptions, parse_sphinx
from griffe._internal.docstrings.utils import docstring_warning, parse_docstring_annotation
from griffe._internal.encoders import JSONEncoder, json_decoder
from griffe._internal.enumerations import (
BreakageKind,
DocstringSectionKind,
ExplanationStyle,
Kind,
LogLevel,
ObjectKind,
ParameterKind,
Parser,
TypeParameterKind,
)
from griffe._internal.exceptions import (
AliasResolutionError,
BuiltinModuleError,
CyclicAliasError,
ExtensionError,
ExtensionNotLoadedError,
GitError,
GriffeError,
LastNodeError,
LoadingError,
NameResolutionError,
RootNodeError,
UnhandledEditableModuleError,
UnimportableModuleError,
)
from griffe._internal.expressions import (
Expr,
ExprAttribute,
ExprAwait,
ExprBinOp,
ExprBoolOp,
ExprCall,
ExprCompare,
ExprComprehension,
ExprConstant,
ExprDict,
ExprDictComp,
ExprExtSlice,
ExprFormatted,
ExprGeneratorExp,
ExprIfExp,
ExprInterpolation,
ExprJoinedStr,
ExprKeyword,
ExprLambda,
ExprList,
ExprListComp,
ExprName,
ExprNamedExpr,
ExprParameter,
ExprSet,
ExprSetComp,
ExprSlice,
ExprSubscript,
ExprTemplateStr,
ExprTuple,
ExprUnaryOp,
ExprVarKeyword,
ExprVarPositional,
ExprYield,
ExprYieldFrom,
get_annotation,
get_base_class,
get_class_keyword,
get_condition,
get_expression,
safe_get_annotation,
safe_get_base_class,
safe_get_class_keyword,
safe_get_condition,
safe_get_expression,
)
from griffe._internal.extensions.base import (
Extension,
Extensions,
LoadableExtensionType,
builtin_extensions,
load_extensions,
)
from griffe._internal.extensions.dataclasses import DataclassesExtension
from griffe._internal.extensions.unpack_typeddict import UnpackTypedDictExtension
from griffe._internal.finder import ModuleFinder, NamePartsAndPathType, NamePartsType, NamespacePackage, Package
from griffe._internal.git import GitInfo, KnownGitService
from griffe._internal.helpers import (
TmpPackage,
htree,
module_vtree,
temporary_inspected_module,
temporary_inspected_package,
temporary_pyfile,
temporary_pypackage,
temporary_visited_module,
temporary_visited_package,
vtree,
)
from griffe._internal.importer import dynamic_import, sys_path
from griffe._internal.loader import GriffeLoader, load, load_git, load_pypi
from griffe._internal.logger import Logger, get_logger, logger, patch_loggers
from griffe._internal.merger import merge_stubs
from griffe._internal.mixins import (
DelMembersMixin,
GetMembersMixin,
ObjectAliasMixin,
SerializationMixin,
SetMembersMixin,
)
from griffe._internal.models import (
Alias,
Attribute,
Class,
Decorator,
Docstring,
Function,
Module,
Object,
Parameter,
Parameters,
TypeAlias,
TypeParameter,
TypeParameters,
)
from griffe._internal.stats import Stats
# Regenerate this list with the following Python snippet:
# import griffe
# names = sorted(n for n in dir(griffe) if not n.startswith("_") and n not in ("annotations",))
# print('__all__ = [\n "' + '",\n "'.join(names) + '",\n]')
__all__ = [
"Alias",
"AliasResolutionError",
"Attribute",
"AttributeChangedTypeBreakage",
"AttributeChangedValueBreakage",
"AutoOptions",
"Breakage",
"BreakageKind",
"BuiltinModuleError",
"Class",
"ClassRemovedBaseBreakage",
"CyclicAliasError",
"DataclassesExtension",
"Decorator",
"DelMembersMixin",
"Docstring",
"DocstringAdmonition",
"DocstringAttribute",
"DocstringClass",
"DocstringDeprecated",
"DocstringDetectionMethod",
"DocstringElement",
"DocstringFunction",
"DocstringModule",
"DocstringNamedElement",
"DocstringOptions",
"DocstringParameter",
"DocstringRaise",
"DocstringReceive",
"DocstringReturn",
"DocstringSection",
"DocstringSectionAdmonition",
"DocstringSectionAttributes",
"DocstringSectionClasses",
"DocstringSectionDeprecated",
"DocstringSectionExamples",
"DocstringSectionFunctions",
"DocstringSectionKind",
"DocstringSectionModules",
"DocstringSectionOtherParameters",
"DocstringSectionParameters",
"DocstringSectionRaises",
"DocstringSectionReceives",
"DocstringSectionReturns",
"DocstringSectionText",
"DocstringSectionTypeAliases",
"DocstringSectionTypeParameters",
"DocstringSectionWarns",
"DocstringSectionYields",
"DocstringStyle",
"DocstringTypeAlias",
"DocstringTypeParameter",
"DocstringWarn",
"DocstringYield",
"ExplanationStyle",
"Expr",
"ExprAttribute",
"ExprAwait",
"ExprBinOp",
"ExprBoolOp",
"ExprCall",
"ExprCompare",
"ExprComprehension",
"ExprConstant",
"ExprDict",
"ExprDictComp",
"ExprExtSlice",
"ExprFormatted",
"ExprGeneratorExp",
"ExprIfExp",
"ExprInterpolation",
"ExprJoinedStr",
"ExprKeyword",
"ExprLambda",
"ExprList",
"ExprListComp",
"ExprName",
"ExprNamedExpr",
"ExprParameter",
"ExprSet",
"ExprSetComp",
"ExprSlice",
"ExprSubscript",
"ExprTemplateStr",
"ExprTuple",
"ExprUnaryOp",
"ExprVarKeyword",
"ExprVarPositional",
"ExprYield",
"ExprYieldFrom",
"Extension",
"ExtensionError",
"ExtensionNotLoadedError",
"Extensions",
"Function",
"GetMembersMixin",
"GitError",
"GitInfo",
"GoogleOptions",
"GriffeError",
"GriffeLoader",
"Inspector",
"JSONEncoder",
"Kind",
"KnownGitService",
"LastNodeError",
"LinesCollection",
"LoadableExtensionType",
"LoadingError",
"LogLevel",
"Logger",
"Module",
"ModuleFinder",
"ModulesCollection",
"NamePartsAndPathType",
"NamePartsType",
"NameResolutionError",
"NamespacePackage",
"NumpyOptions",
"Object",
"ObjectAliasMixin",
"ObjectChangedKindBreakage",
"ObjectKind",
"ObjectNode",
"ObjectRemovedBreakage",
"Package",
"Parameter",
"ParameterAddedRequiredBreakage",
"ParameterChangedDefaultBreakage",
"ParameterChangedKindBreakage",
"ParameterChangedRequiredBreakage",
"ParameterKind",
"ParameterMovedBreakage",
"ParameterRemovedBreakage",
"Parameters",
"ParametersType",
"Parser",
"PerStyleOptions",
"ReturnChangedTypeBreakage",
"RootNodeError",
"SerializationMixin",
"SetMembersMixin",
"SphinxOptions",
"Stats",
"TmpPackage",
"TypeAlias",
"TypeParameter",
"TypeParameterKind",
"TypeParameters",
"UnhandledEditableModuleError",
"UnimportableModuleError",
"UnpackTypedDictExtension",
"Visitor",
"ast_children",
"ast_first_child",
"ast_kind",
"ast_last_child",
"ast_next",
"ast_next_siblings",
"ast_previous",
"ast_previous_siblings",
"ast_siblings",
"builtin_decorators",
"builtin_extensions",
"c3linear_merge",
"docstring_warning",
"dynamic_import",
"find_breaking_changes",
"get__all__",
"get_annotation",
"get_base_class",
"get_class_keyword",
"get_condition",
"get_docstring",
"get_expression",
"get_instance_names",
"get_logger",
"get_name",
"get_names",
"get_parameters",
"get_value",
"htree",
"infer_docstring_style",
"inspect",
"json_decoder",
"load",
"load_extensions",
"load_git",
"load_pypi",
"logger",
"merge_stubs",
"module_vtree",
"parse",
"parse_auto",
"parse_docstring_annotation",
"parse_google",
"parse_numpy",
"parse_sphinx",
"parsers",
"patch_loggers",
"relative_to_absolute",
"safe_get__all__",
"safe_get_annotation",
"safe_get_base_class",
"safe_get_class_keyword",
"safe_get_condition",
"safe_get_expression",
"safe_get_value",
"stdlib_decorators",
"sys_path",
"temporary_inspected_module",
"temporary_inspected_package",
"temporary_pyfile",
"temporary_pypackage",
"temporary_visited_module",
"temporary_visited_package",
"typing_overload",
"visit",
"vtree",
]
# Re-export griffecli for backward compatibility.
try:
from griffecli import * # noqa: F403
from griffecli import __all__ as __cli_all__
except ImportError:
# Keep this in sync with the exported members of griffecli.
_MISSING_FROM_GRIFFECLI = {
"DEFAULT_LOG_LEVEL",
"check",
"dump",
"get_parser",
"main",
}
def __getattr__(attr: str) -> object:
if attr in _MISSING_FROM_GRIFFECLI:
raise ImportError(f"Please install `griffecli` to use {'griffe.' + attr!r}")
raise AttributeError(attr)
else:
__all__ += __cli_all__ # noqa: PLE0605
# Ignore any re-exported API in internal API tests.
_REEXPORTED_EXTERNAL_API = set(__cli_all__)
python-griffe-2.3.0/packages/griffelib/src/griffe/py.typed 0000664 0001750 0001750 00000000000 15246557273 023424 0 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/__main__.py 0000664 0001750 0001750 00000002504 15246557273 024032 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
"""Entry-point module, in case you use `python -m griffe`.
Why does this file exist, and why `__main__`? For more info, read:
- https://www.python.org/dev/peps/pep-0338/
- https://docs.python.org/3/using/cmdline.html#cmdoption-m
"""
import sys
try:
from griffecli import main
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
"`griffecli` or its dependencies are not installed. Install `griffecli` to use `python -m griffe`.",
) from exc
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/ 0000775 0001750 0001750 00000000000 15246557273 023712 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/debug.py 0000664 0001750 0001750 00000007412 15246557273 025356 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module is here to help users report bugs.
# It provides a function to print environment information,
# which is called from the public `griffe.debug` module
# (when called with `python -m griffe.debug`)
# or thanks to the `--debug-info` CLI flag.
from __future__ import annotations
import os
import platform
import sys
from dataclasses import dataclass
from importlib import metadata
@dataclass
class _Variable:
"""Dataclass describing an environment variable."""
name: str
"""Variable name."""
value: str
"""Variable value."""
@dataclass
class _Package:
"""Dataclass describing a Python package."""
name: str
"""Package name."""
version: str
"""Package version."""
@dataclass
class _Environment:
"""Dataclass to store environment information."""
interpreter_name: str
"""Python interpreter name."""
interpreter_version: str
"""Python interpreter version."""
interpreter_path: str
"""Path to Python executable."""
platform: str
"""Operating System."""
packages: list[_Package]
"""Installed packages."""
variables: list[_Variable]
"""Environment variables."""
def _interpreter_name_version() -> tuple[str, str]:
if hasattr(sys, "implementation"):
impl = sys.implementation.version
version = f"{impl.major}.{impl.minor}.{impl.micro}"
kind = impl.releaselevel
if kind != "final":
version += kind[0] + str(impl.serial)
return sys.implementation.name, version
return "", "0.0.0"
def _get_version(dist: str = "griffelib") -> str:
"""Get version of the given distribution.
Parameters:
dist: A distribution name.
Returns:
A version number.
"""
try:
return metadata.version(dist)
except metadata.PackageNotFoundError:
return "0.0.0"
def _get_debug_info() -> _Environment:
"""Get debug/environment information.
Returns:
Environment information.
"""
py_name, py_version = _interpreter_name_version()
packages = ["griffelib", "griffecli", "griffe"]
variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("GRIFFE")]]
return _Environment(
interpreter_name=py_name,
interpreter_version=py_version,
interpreter_path=sys.executable,
platform=platform.platform(),
variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))],
packages=[_Package(pkg, _get_version(pkg)) for pkg in packages],
)
def _print_debug_info() -> None:
"""Print debug/environment information."""
info = _get_debug_info()
print(f"- __System__: {info.platform}")
print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})")
print("- __Environment variables__:")
for var in info.variables:
print(f" - `{var.name}`: `{var.value}`")
print("- __Installed packages__:")
for pkg in info.packages:
print(f" - `{pkg.name}` v{pkg.version}")
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/enumerations.py 0000664 0001750 0001750 00000015254 15246557273 027004 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains all the enumerations of the package.
from __future__ import annotations
from enum import Enum
class LogLevel(str, Enum):
"""Enumeration of available log levels."""
trace = "trace"
"""The TRACE log level."""
debug = "debug"
"""The DEBUG log level."""
info = "info"
"""The INFO log level."""
success = "success"
"""The SUCCESS log level."""
warning = "warning"
"""The WARNING log level."""
error = "error"
"""The ERROR log level."""
critical = "critical"
"""The CRITICAL log level."""
class DocstringSectionKind(str, Enum):
"""Enumeration of the possible docstring section kinds."""
text = "text"
"""Text section."""
parameters = "parameters"
"""Parameters section."""
other_parameters = "other parameters"
"""Other parameters (keyword arguments) section."""
type_parameters = "type parameters"
"""Type parameters section."""
raises = "raises"
"""Raises (exceptions) section."""
warns = "warns"
"""Warnings section."""
returns = "returns"
"""Returned value(s) section."""
yields = "yields"
"""Yielded value(s) (generators) section."""
receives = "receives"
"""Received value(s) (generators) section."""
examples = "examples"
"""Examples section."""
attributes = "attributes"
"""Attributes section."""
functions = "functions"
"""Functions section."""
classes = "classes"
"""Classes section."""
type_aliases = "type aliases"
"""Type aliases section."""
modules = "modules"
"""Modules section."""
deprecated = "deprecated"
"""Deprecation section."""
admonition = "admonition"
"""Admonition block."""
class ParameterKind(str, Enum):
"""Enumeration of the different parameter kinds."""
positional_only = "positional-only"
"""Positional-only parameter."""
positional_or_keyword = "positional or keyword"
"""Positional or keyword parameter."""
var_positional = "variadic positional"
"""Variadic positional parameter."""
keyword_only = "keyword-only"
"""Keyword-only parameter."""
var_keyword = "variadic keyword"
"""Variadic keyword parameter."""
class TypeParameterKind(str, Enum):
"""Enumeration of the different type parameter kinds."""
type_var = "type-var"
"""Type variable."""
type_var_tuple = "type-var-tuple"
"""Type variable tuple."""
param_spec = "param-spec"
"""Parameter specification variable."""
class Kind(str, Enum):
"""Enumeration of the different object kinds."""
MODULE = "module"
"""Modules."""
CLASS = "class"
"""Classes."""
FUNCTION = "function"
"""Functions and methods."""
ATTRIBUTE = "attribute"
"""Attributes and properties."""
ALIAS = "alias"
"""Aliases (imported objects)."""
TYPE_ALIAS = "type alias"
"""Type aliases."""
class ExplanationStyle(str, Enum):
"""Enumeration of the possible styles for explanations."""
ONE_LINE = "oneline"
"""Explanations on one-line."""
VERBOSE = "verbose"
"""Explanations on multiple lines."""
MARKDOWN = "markdown"
"""Explanations in Markdown, adapted to changelogs."""
GITHUB = "github"
"""Explanation as GitHub workflow commands warnings, adapted to CI."""
AZURE_DEVOPS = "azdo"
"""Explanations as Azure DevOps / Azure Pipelines logging commands."""
class BreakageKind(str, Enum):
"""Enumeration of the possible API breakages."""
PARAMETER_MOVED = "Positional parameter was moved"
"""Positional parameter was moved"""
PARAMETER_REMOVED = "Parameter was removed"
"""Parameter was removed"""
PARAMETER_CHANGED_KIND = "Parameter kind was changed"
"""Parameter kind was changed"""
PARAMETER_CHANGED_DEFAULT = "Parameter default was changed"
"""Parameter default was changed"""
PARAMETER_CHANGED_REQUIRED = "Parameter is now required"
"""Parameter is now required"""
PARAMETER_ADDED_REQUIRED = "Parameter was added as required"
"""Parameter was added as required"""
RETURN_CHANGED_TYPE = "Return types are incompatible"
"""Return types are incompatible"""
OBJECT_REMOVED = "Public object was removed"
"""Public object was removed"""
OBJECT_CHANGED_KIND = "Public object points to a different kind of object"
"""Public object points to a different kind of object"""
ATTRIBUTE_CHANGED_TYPE = "Attribute types are incompatible"
"""Attribute types are incompatible"""
ATTRIBUTE_CHANGED_VALUE = "Attribute value was changed"
"""Attribute value was changed"""
CLASS_REMOVED_BASE = "Base class was removed"
"""Base class was removed"""
class Parser(str, Enum):
"""Enumeration of the different docstring parsers."""
auto = "auto"
"""Infer docstring parser."""
google = "google"
"""Google-style docstrings parser."""
sphinx = "sphinx"
"""Sphinx-style docstrings parser."""
numpy = "numpy"
"""Numpydoc-style docstrings parser."""
class ObjectKind(str, Enum):
"""Enumeration of the different runtime object kinds."""
MODULE = "module"
"""Modules."""
CLASS = "class"
"""Classes."""
STATICMETHOD = "staticmethod"
"""Static methods."""
CLASSMETHOD = "classmethod"
"""Class methods."""
METHOD_DESCRIPTOR = "method_descriptor"
"""Method descriptors."""
METHOD = "method"
"""Methods."""
BUILTIN_METHOD = "builtin_method"
"""Built-in methods."""
COROUTINE = "coroutine"
"""Coroutines"""
FUNCTION = "function"
"""Functions."""
BUILTIN_FUNCTION = "builtin_function"
"""Built-in functions."""
CACHED_PROPERTY = "cached_property"
"""Cached properties."""
GETSET_DESCRIPTOR = "getset_descriptor"
"""Get/set descriptors."""
PROPERTY = "property"
"""Properties."""
TYPE_ALIAS = "type_alias"
"""Type aliases."""
ATTRIBUTE = "attribute"
"""Attributes."""
def __str__(self) -> str:
return self.value
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/loader.py 0000664 0001750 0001750 00000144761 15246557273 025547 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains all the logic for loading API data from sources or compiled modules.
from __future__ import annotations
import re
import shutil
import subprocess
import sys
import tempfile
from contextlib import suppress
from datetime import datetime, timezone
from functools import cached_property
from importlib.util import find_spec
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, cast
from griffe._internal.agents.inspector import inspect
from griffe._internal.agents.visitor import visit
from griffe._internal.collections import LinesCollection, ModulesCollection
from griffe._internal.enumerations import Kind
from griffe._internal.exceptions import (
AliasResolutionError,
CyclicAliasError,
LoadingError,
UnimportableModuleError,
)
from griffe._internal.expressions import ExprName
from griffe._internal.extensions.base import Extensions, load_extensions
from griffe._internal.finder import ModuleFinder, NamespacePackage, Package
from griffe._internal.git import GitInfo, _tmp_worktree
from griffe._internal.importer import dynamic_import
from griffe._internal.logger import logger
from griffe._internal.merger import merge_stubs
from griffe._internal.models import Alias, Module, Object
from griffe._internal.stats import Stats
if TYPE_CHECKING:
from collections.abc import Sequence
from griffe._internal.docstrings.parsers import DocstringOptions, DocstringStyle
from griffe._internal.enumerations import Parser
_load_events = (
"on_alias",
"on_object",
"on_module",
"on_class",
"on_function",
"on_attribute",
"on_type_alias",
)
class GriffeLoader:
"""The Griffe loader, allowing to load data from modules."""
ignored_modules: ClassVar[set[str]] = {"debugpy", "_pydev"}
"""Special modules to ignore when loading.
For example, `debugpy` and `_pydev` are used when debugging with VSCode and should generally never be loaded.
"""
def __init__(
self,
*,
extensions: Extensions | None = None,
search_paths: Sequence[str | Path] | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
allow_inspection: bool = True,
force_inspection: bool = False,
store_source: bool = True,
prefer_stubs_docs: bool = False,
) -> None:
"""Initialize the loader.
Parameters:
extensions: The extensions to use.
search_paths: The paths to search into.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
store_source: Whether to store code source in the lines collection.
prefer_stubs_docs: Whether to give precedence to stubs docstrings
rather than source docstrings. When both are present, the stubs one
will override the source one.
"""
self.extensions: Extensions = extensions or load_extensions()
"""Loaded Griffe extensions."""
self.docstring_parser: DocstringStyle | Parser | None = docstring_parser
"""Selected docstring parser."""
self.docstring_options: DocstringOptions = docstring_options or {}
"""Configured parsing options."""
self.lines_collection: LinesCollection = lines_collection or LinesCollection()
"""Collection of source code lines."""
self.modules_collection: ModulesCollection = modules_collection or ModulesCollection()
self.modules_collection._psd = prefer_stubs_docs
"""Collection of modules."""
self.allow_inspection: bool = allow_inspection
"""Whether to allow inspecting (importing) modules for which we can't find sources."""
self.force_inspection: bool = force_inspection
"""Whether to force inspecting (importing) modules, even when sources were found."""
self.store_source: bool = store_source
"""Whether to store source code in the lines collection."""
self.prefer_stubs_docs: bool = prefer_stubs_docs
"""Whether to give precedence to stubs docstrings rather than source ones."""
self._search_paths: Sequence[str | Path] | None = search_paths
self._time_stats: dict = {
"time_spent_visiting": 0,
"time_spent_inspecting": 0,
}
@cached_property
def finder(self) -> ModuleFinder:
"""The module source finder."""
return ModuleFinder(search_paths=self._search_paths)
def load(
self,
objspec: str | Path | None = None,
/,
*,
submodules: bool = True,
try_relative_path: bool = True,
find_stubs_package: bool = False,
) -> Object | Alias:
"""Load an object as a Griffe object, given its Python or file path.
Note that this will load the whole object's package,
and return only the specified object.
The rest of the package can be accessed from the returned object
with regular methods and properties (`parent`, `members`, etc.).
Examples:
>>> loader.load("griffe.Module")
Alias("Module", "griffe._internal.models.Module")
Parameters:
objspec: The Python path of an object, or file path to a module.
submodules: Whether to recurse on the submodules.
This parameter only makes sense when loading a package (top-level module).
try_relative_path: Whether to try finding the module as a relative path.
find_stubs_package: Whether to search for stubs-only package.
If both the package and its stubs are found, they'll be merged together.
If only the stubs are found, they'll be used as the package itself.
Raises:
LoadingError: When loading a module failed for various reasons.
ModuleNotFoundError: When a module was not found and inspection is disallowed.
Returns:
A Griffe object.
"""
obj_path: str
package = None
top_module = None
# We always start by searching paths on the disk,
# even if inspection is forced.
logger.debug("Searching path(s) for %s", objspec)
try:
obj_path, package = self.finder.find_spec(
objspec, # ty:ignore[invalid-argument-type]
try_relative_path=try_relative_path,
find_stubs_package=find_stubs_package,
)
except ModuleNotFoundError:
# If we couldn't find paths on disk and inspection is disabled,
# re-raise ModuleNotFoundError.
logger.debug("Could not find path for %s on disk", objspec)
if not (self.allow_inspection or self.force_inspection):
raise
# Otherwise we try to dynamically import the top-level module.
obj_path = str(objspec)
top_module_name = obj_path.split(".", 1)[0]
logger.debug("Trying to dynamically import %s", top_module_name)
top_module_object = dynamic_import(top_module_name, self.finder.search_paths)
try:
top_module_path = top_module_object.__path__
if not top_module_path:
raise ValueError(f"Module {top_module_name} has no paths set") # noqa: TRY301
except (AttributeError, ValueError):
# If the top-level module has no `__path__`, we inspect it as-is,
# and do not try to recurse into submodules (there shouldn't be any in builtin/compiled modules).
logger.debug("Module %s has no paths set (built-in module?). Inspecting it as-is.", top_module_name)
top_module = self._inspect_module(top_module_name)
self.modules_collection.set_member(top_module.path, top_module)
return self._post_load(top_module, obj_path)
# We found paths, and use them to build our intermediate Package or NamespacePackage struct.
logger.debug("Module %s has paths set: %s", top_module_name, top_module_path)
top_module_path = [Path(path) for path in top_module_path]
if len(top_module_path) > 1:
package = NamespacePackage(top_module_name, top_module_path)
else:
package = Package(top_module_name, top_module_path[0])
# We have an intermediate package, and an object path: we're ready to load.
logger.debug("Found %s: loading", objspec)
try:
top_module = self._load_package(package, submodules=submodules)
except LoadingError:
logger.exception("Could not load package %s", package)
raise
return self._post_load(top_module, obj_path)
def _fire_load_events(self, obj: Object) -> None:
# Wrapping in tuple() to avoid "dictionary changed size during iteration" errors.
for member in tuple(obj.members.values()):
if member.is_alias:
self.extensions.call("on_alias", alias=member, loader=self)
continue
self.extensions.call("on_object", obj=member, loader=self)
if member.is_module:
self.extensions.call("on_module", mod=member, loader=self)
elif member.is_class:
self.extensions.call("on_class", cls=member, loader=self)
elif member.is_function:
self.extensions.call("on_function", func=member, loader=self)
elif member.is_attribute:
self.extensions.call("on_attribute", attr=member, loader=self)
elif member.is_type_alias:
self.extensions.call("on_type_alias", type_alias=member, loader=self)
self._fire_load_events(member) # ty:ignore[invalid-argument-type]
def _post_load(self, module: Module, obj_path: str) -> Object | Alias:
# Pre-emptively expand exports (`__all__` values),
# as well as wildcard imports (without ever loading additional packages).
# This is a best-effort to return the most correct API data
# before firing the load events.
#
# Packages that wildcard imports from external, non-loaded packages
# will still have incomplete data, requiring subsequent calls to
# `load()` and/or `resolve_aliases()`.
self.expand_exports(module)
self.expand_wildcards(module, external=False)
# Populate Git information if possible.
module.git_info = GitInfo.from_package(module)
# Package is loaded, we now retrieve the initially requested object,
# fire load events, and return it.
obj = self.modules_collection.get_member(obj_path)
self.extensions.call("on_package", pkg=module, loader=self)
self.extensions.call("on_module", mod=module, loader=self)
if self.extensions.has_hooks(*_load_events):
self._fire_load_events(module)
return obj
def resolve_aliases(
self,
*,
implicit: bool = False,
external: bool | None = None,
max_iterations: int | None = None,
) -> tuple[set[str], int]:
"""Resolve aliases.
Parameters:
implicit: When false, only try to resolve an alias if it is explicitly exported.
external: When false, don't try to load unspecified modules to resolve aliases.
max_iterations: Maximum number of iterations on the loader modules collection.
Returns:
The unresolved aliases and the number of iterations done.
"""
if max_iterations is None:
max_iterations = float("inf") # ty:ignore[invalid-assignment]
prev_unresolved: set[str] = set()
unresolved: set[str] = set("0") # Init to enter loop.
iteration = 0
collection = self.modules_collection.members
# Before resolving aliases, we try to expand wildcard imports again
# (this was already done in `_post_load()`),
# this time with the user-configured `external` setting,
# and with potentially more packages loaded in the collection,
# allowing to resolve more aliases.
for wildcards_module in list(collection.values()):
self.expand_wildcards(wildcards_module, external=external)
load_failures: set[str] = set()
while unresolved and unresolved != prev_unresolved and iteration < max_iterations: # ty:ignore[unsupported-operator]
prev_unresolved = unresolved - {"0"}
unresolved = set()
resolved: set[str] = set()
iteration += 1
for module_name in list(collection.keys()):
module = collection[module_name]
next_resolved, next_unresolved = self.resolve_module_aliases(
module,
implicit=implicit,
external=external,
load_failures=load_failures,
)
resolved |= next_resolved
unresolved |= next_unresolved
logger.debug(
"Iteration %s finished, %s aliases resolved, still %s to go",
iteration,
len(resolved),
len(unresolved),
)
return unresolved, iteration
def expand_exports(self, module: Module, seen: set | None = None) -> None:
"""Expand exports: try to recursively expand all module exports (`__all__` values).
See also: [`Module.exports`][griffe.Module.exports].
Parameters:
module: The module to recurse on.
seen: Used to avoid infinite recursion.
"""
seen = seen or set()
seen.add(module.path)
if module.exports is None:
return
expanded: list[str | ExprName] = []
for export in module.exports:
# It's a name: we resolve it, get the module it comes from,
# recurse into it, and add its exports to the current ones.
if isinstance(export, ExprName):
module_path = export.canonical_path.rsplit(".", 1)[0] # Remove trailing `.__all__`.
try:
next_module = self.modules_collection.get_member(module_path)
except KeyError:
logger.debug("Cannot expand '%s', try pre-loading corresponding package", export.canonical_path)
continue
if next_module.path not in seen:
self.expand_exports(next_module, seen)
try:
expanded += [export for export in next_module.exports if export not in expanded]
except TypeError:
logger.warning("Unsupported item in %s.__all__: %s (use strings only)", module.path, export)
# It's a string, simply add it to the current exports.
else:
expanded.append(export)
module.exports = expanded
# Make sure to expand exports in all modules.
for submodule in module.modules.values():
if not submodule.is_alias and submodule.path not in seen:
self.expand_exports(submodule, seen)
def expand_wildcards(
self,
obj: Object,
*,
external: bool | None = None,
seen: set | None = None,
) -> None:
"""Expand wildcards: try to recursively expand all found wildcards.
See also: [`Alias.wildcard`][griffe.Alias.wildcard].
Parameters:
obj: The object and its members to recurse on.
external: When true, try to load unspecified modules to expand wildcards.
seen: Used to avoid infinite recursion.
"""
expanded = []
to_remove = []
seen = seen or set()
seen.add(obj.path)
# First we expand wildcard imports and store the objects in a temporary `expanded` variable,
# while also keeping track of the members representing wildcard import, to remove them later.
for member in obj.members.values():
# Handle a wildcard.
if member.is_alias and member.wildcard: # ty:ignore[unresolved-attribute]
package = member.wildcard.split(".", 1)[0] # ty:ignore[unresolved-attribute]
not_loaded = obj.package.path != package and package not in self.modules_collection
# Try loading the (unknown) package containing the wildcard importe module (if allowed to).
if not_loaded:
if external is False or (external is None and package != f"_{obj.package.name}"):
continue
try:
self.load(package, try_relative_path=False)
except (ImportError, LoadingError) as error:
logger.debug("Could not expand wildcard import %s in %s: %s", member.name, obj.path, error)
continue
# Try getting the module from which every public object is imported.
try:
target = self.modules_collection.get_member(member.target_path) # ty:ignore[unresolved-attribute]
except KeyError:
logger.debug(
"Could not expand wildcard import %s in %s: %s not found in modules collection",
member.name,
obj.path,
cast("Alias", member).target_path,
)
continue
# Recurse into this module, expanding wildcards there before collecting everything.
if target.path not in seen:
try:
self.expand_wildcards(target, external=external, seen=seen)
except (AliasResolutionError, CyclicAliasError) as error:
logger.debug("Could not expand wildcard import %s in %s: %s", member.name, obj.path, error)
continue
# Collect every imported object.
expanded.extend(self._expand_wildcard(member)) # ty:ignore[invalid-argument-type]
to_remove.append(member.name)
# Recurse in unseen submodules.
elif not member.is_alias and member.is_module and member.path not in seen:
self.expand_wildcards(member, external=external, seen=seen) # ty:ignore[invalid-argument-type]
# Then we remove the members representing wildcard imports.
for name in to_remove:
obj.del_member(name)
# Finally we process the collected objects.
for new_member, alias_lineno, alias_endlineno in expanded:
overwrite = False
already_present = new_member.name in obj.members
self_alias = (
new_member.is_alias and cast("Alias", new_member).target_path == f"{obj.path}.{new_member.name}"
)
# If a member with the same name is already present in the current object,
# we only overwrite it if the alias is imported lower in the module
# (meaning that the alias takes precedence at runtime).
if already_present:
old_member = obj.get_member(new_member.name)
old_lineno = old_member.alias_lineno if old_member.is_alias else old_member.lineno
overwrite = (alias_lineno or 0) > (old_lineno or 0)
# 1. If the expanded member is an alias with a target path equal to its own path, we stop.
# This situation can arise because of Griffe's mishandling of (abusive) wildcard imports.
# We have yet to check how Python handles this itself, or if there's an algorithm
# that we could follow to untangle back-and-forth wildcard imports.
# 2. If the expanded member was already present and we decided not to overwrite it, we stop.
# 3. Otherwise we proceed further.
if not self_alias and (not already_present or overwrite):
alias = Alias(
new_member.name,
new_member,
lineno=alias_lineno,
endlineno=alias_endlineno,
parent=obj, # ty:ignore[invalid-argument-type]
wildcard_imported=True,
)
# Special case: we avoid overwriting a submodule with an alias.
# Griffe suffers from this limitation where an object cannot store both
# a submodule and a member of the same name, while this poses (almost) no issue in Python.
# We always give precedence to the submodule.
# See the "avoid member-submodule name shadowing" section in the "Python code" docs page.
if already_present:
prev_member = obj.get_member(new_member.name)
with suppress(AliasResolutionError, CyclicAliasError):
if prev_member.is_module:
continue
# Everything went right (supposedly), we add the alias as a member of the current object.
obj.set_member(new_member.name, alias)
def resolve_module_aliases(
self,
obj: Object | Alias,
*,
implicit: bool = False,
external: bool | None = None,
seen: set[str] | None = None,
load_failures: set[str] | None = None,
) -> tuple[set[str], set[str]]:
"""Follow aliases: try to recursively resolve all found aliases.
Parameters:
obj: The object and its members to recurse on.
implicit: When false, only try to resolve an alias if it is explicitly exported.
external: When false, don't try to load unspecified modules to resolve aliases.
seen: Used to avoid infinite recursion.
load_failures: Set of external packages we failed to load (to prevent retries).
Returns:
Both sets of resolved and unresolved aliases.
"""
resolved = set()
unresolved = set()
if load_failures is None:
load_failures = set()
seen = seen or set()
seen.add(obj.path)
for member in obj.members.values():
# Handle aliases.
if member.is_alias:
if member.wildcard or member.resolved: # ty:ignore[unresolved-attribute]
continue
if not implicit and not member.is_exported:
continue
# Try resolving the alias. If it fails, check if it is because it comes
# from an external package, and decide if we should load that package
# to allow the alias to be resolved at the next iteration (maybe).
try:
member.resolve_target() # ty:ignore[unresolved-attribute]
except AliasResolutionError as error:
target = error.alias.target_path
unresolved.add(member.path)
package = target.split(".", 1)[0]
load_module = (
(external is True or (external is None and package == f"_{obj.package.name}"))
and package not in load_failures
and obj.package.path != package
and package not in self.modules_collection
)
if load_module:
logger.debug("Failed to resolve alias %s -> %s", member.path, target)
try:
self.load(package, try_relative_path=False)
except (ImportError, LoadingError) as error:
logger.debug("Could not follow alias %s: %s", member.path, error)
load_failures.add(package)
except CyclicAliasError as error:
logger.debug(str(error))
else:
logger.debug("Alias %s was resolved to %s", member.path, member.final_target.path) # ty:ignore[unresolved-attribute]
resolved.add(member.path)
# Recurse into unseen modules and classes.
elif member.kind in {Kind.MODULE, Kind.CLASS} and member.path not in seen:
sub_resolved, sub_unresolved = self.resolve_module_aliases(
member,
implicit=implicit,
external=external,
seen=seen,
load_failures=load_failures,
)
resolved |= sub_resolved
unresolved |= sub_unresolved
return resolved, unresolved
def stats(self) -> Stats:
"""Compute some statistics.
Returns:
Some statistics.
"""
stats = Stats(self)
stats.time_spent_visiting = self._time_stats["time_spent_visiting"]
stats.time_spent_inspecting = self._time_stats["time_spent_inspecting"]
return stats
def _load_package(self, package: Package | NamespacePackage, *, submodules: bool = True) -> Module:
top_module = self._load_module(package.name, package.path, submodules=submodules)
if isinstance(package, NamespacePackage):
return top_module
if package.stubs:
self.expand_wildcards(top_module)
# If stubs are in the package itself, they have been merged while loading modules,
# so only the top-level init module needs to be merged still.
# If stubs are in another package (a stubs-only package),
# then we need to load the entire stubs package to merge everything.
submodules = submodules and package.stubs.parent != package.path.parent
stubs = self._load_module(package.name, package.stubs, submodules=submodules)
return merge_stubs(top_module, stubs, prefer_stubs_docs=self.prefer_stubs_docs)
return top_module
def _load_module(
self,
module_name: str,
module_path: Path | list[Path],
*,
submodules: bool = True,
parent: Module | None = None,
) -> Module:
try:
return self._load_module_path(module_name, module_path, submodules=submodules, parent=parent)
except SyntaxError as error:
raise LoadingError(f"Syntax error: {error}") from error
except ImportError as error:
raise LoadingError(f"Import error: {error}") from error
except UnicodeDecodeError as error:
raise LoadingError(f"UnicodeDecodeError when loading {module_path}: {error}") from error
except OSError as error:
raise LoadingError(f"OSError when loading {module_path}: {error}") from error
def _load_module_path(
self,
module_name: str,
module_path: Path | list[Path],
*,
submodules: bool = True,
parent: Module | None = None,
) -> Module:
logger.debug("Loading path %s", module_path)
if isinstance(module_path, list):
module = self._create_module(module_name, module_path)
elif self.force_inspection:
module = self._inspect_module(module_name, module_path, parent)
elif module_path.suffix in {".py", ".pyi"}:
module = self._visit_module(module_name, module_path, parent)
elif self.allow_inspection:
module = self._inspect_module(module_name, module_path, parent)
else:
raise LoadingError("Cannot load compiled module without inspection")
if parent is None:
self.modules_collection.set_member(module.path, module)
if submodules:
self._load_submodules(module)
return module
def _load_submodules(self, module: Module) -> None:
for subparts, subpath in self.finder.submodules(module):
self._load_submodule(module, subparts, subpath)
def _load_submodule(self, module: Module, subparts: tuple[str, ...], subpath: Path) -> None:
for subpart in subparts:
if "." in subpart:
logger.debug("Skip %s, dots in filenames are not supported", subpath)
return
try:
parent_module = self._get_or_create_parent_module(module, subparts, subpath)
except UnimportableModuleError as error:
# NOTE: Why don't we load submodules when there's no init module in their folder?
# Usually when a folder with Python files does not have an __init__.py module,
# it's because the Python files are scripts that should never be imported.
# Django has manage.py somewhere for example, in a folder without init module.
# This script isn't part of the Python API, as it's meant to be called on the CLI exclusively
# (at least it was the case a few years ago when I was still using Django).
# The other case when there's no init module is when a package is a native namespace package (PEP 420).
# It does not make sense to have a native namespace package inside of a regular package (having init modules above),
# because the regular package above blocks the namespace feature from happening, so I consider it a user error.
# It's true that users could have a native namespace package inside of a pkg_resources-style namespace package,
# but I've never seen this happen.
# It's also true that Python can actually import the module under the (wrongly declared) native namespace package,
# so the Griffe debug log message is a bit misleading,
# but that's because in that case Python acts like the whole tree is a regular package.
# It works when the namespace package appears in only one search path (`sys.path`),
# but will fail if it appears in multiple search paths: Python will only find the first occurrence.
# It's better to not falsely support this, and to warn users.
logger.debug("%s. Missing __init__ module?", error)
return
submodule_name = subparts[-1]
try:
submodule = self._load_module(
submodule_name,
subpath,
submodules=False,
parent=parent_module,
)
except LoadingError as error:
logger.debug(str(error))
else:
if submodule_name in parent_module.members:
member = parent_module.members[submodule_name]
if member.is_alias or not member.is_module:
logger.debug(
"Submodule '%s' is shadowing the member at the same path. "
"We recommend renaming the member or the submodule (for example prefixing it with `_`), "
"see https://mkdocstrings.github.io/griffe/best_practices/#avoid-member-submodule-name-shadowing.",
submodule.path,
)
parent_module.set_member(submodule_name, submodule)
def _create_module(self, module_name: str, module_path: Path | list[Path]) -> Module:
return Module(
module_name,
filepath=module_path,
lines_collection=self.lines_collection,
modules_collection=self.modules_collection,
)
def _visit_module(self, module_name: str, module_path: Path, parent: Module | None = None) -> Module:
code = module_path.read_text(encoding="utf-8-sig")
if self.store_source:
self.lines_collection[module_path] = code.splitlines(keepends=False)
start = datetime.now(tz=timezone.utc)
module = visit(
module_name,
filepath=module_path,
code=code,
extensions=self.extensions,
parent=parent,
docstring_parser=self.docstring_parser,
docstring_options=self.docstring_options,
lines_collection=self.lines_collection,
modules_collection=self.modules_collection,
)
elapsed = datetime.now(tz=timezone.utc) - start
self._time_stats["time_spent_visiting"] += elapsed.microseconds
return module
def _inspect_module(self, module_name: str, filepath: Path | None = None, parent: Module | None = None) -> Module:
for prefix in self.ignored_modules:
if module_name.startswith(prefix):
raise ImportError(f"Ignored module '{module_name}'")
if self.store_source and filepath and filepath.suffix in {".py", ".pyi"}:
self.lines_collection[filepath] = filepath.read_text(encoding="utf-8-sig").splitlines(keepends=False)
start = datetime.now(tz=timezone.utc)
try:
module = inspect(
module_name,
filepath=filepath,
import_paths=self.finder.search_paths,
extensions=self.extensions,
parent=parent,
docstring_parser=self.docstring_parser,
docstring_options=self.docstring_options,
lines_collection=self.lines_collection,
modules_collection=self.modules_collection,
)
except SystemExit as error:
raise ImportError(f"Importing '{module_name}' raised a system exit") from error
except Exception as error:
raise ImportError(f"Importing '{module_name}' raised an exception") from error
elapsed = datetime.now(tz=timezone.utc) - start
self._time_stats["time_spent_inspecting"] += elapsed.microseconds
return module
def _get_or_create_parent_module(
self,
module: Module,
subparts: tuple[str, ...],
subpath: Path,
) -> Module:
parent_parts = subparts[:-1]
if not parent_parts:
return module
parent_module = module
parents = list(subpath.parents)
if subpath.stem == "__init__":
parents.pop(0)
for parent_offset, parent_part in enumerate(parent_parts, 2):
module_filepath = parents[len(subparts) - parent_offset]
try:
parent_module = parent_module.get_member(parent_part)
except KeyError as error:
if parent_module.is_namespace_package or parent_module.is_namespace_subpackage:
next_parent_module = self._create_module(parent_part, [module_filepath])
parent_module.set_member(parent_part, next_parent_module)
parent_module = next_parent_module
else:
raise UnimportableModuleError(f"Skip {subpath}, it is not importable") from error
else:
parent_namespace = parent_module.is_namespace_package or parent_module.is_namespace_subpackage
if parent_namespace and module_filepath not in parent_module.filepath:
parent_module.filepath.append(module_filepath)
return parent_module
def _expand_wildcard(self, wildcard_obj: Alias) -> list[tuple[Object | Alias, int | None, int | None]]:
module = self.modules_collection.get_member(wildcard_obj.wildcard) # ty:ignore[invalid-argument-type]
return [
(imported_member, wildcard_obj.alias_lineno, wildcard_obj.alias_endlineno)
for imported_member in module.members.values()
if imported_member.is_wildcard_exposed
]
def load(
objspec: str | Path | None = None,
/,
*,
submodules: bool = True,
try_relative_path: bool = True,
extensions: Extensions | None = None,
search_paths: Sequence[str | Path] | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
allow_inspection: bool = True,
force_inspection: bool = False,
store_source: bool = True,
find_stubs_package: bool = False,
prefer_stubs_docs: bool = False,
resolve_aliases: bool = False,
resolve_external: bool | None = None,
resolve_implicit: bool = False,
) -> Object | Alias:
"""Load and return a Griffe object.
In Griffe's context, loading means:
- searching for a package, and finding it on the file system or as a builtin module
(see the [`ModuleFinder`][griffe.ModuleFinder] class for more information)
- extracting information from each of its (sub)modules, by either parsing
the source code (see the [`visit`][griffe.visit] function)
or inspecting the module at runtime (see the [`inspect`][griffe.inspect] function)
The extracted information is stored in a collection of modules, which can be queried later.
Each collected module is a tree of objects, representing the structure of the module.
See the [`Module`][griffe.Module], [`Class`][griffe.Class],
[`Function`][griffe.Function], [`Attribute`][griffe.Attribute], and
[`TypeAlias`][griffe.TypeAlias] classes for more information.
The main class used to load modules is [`GriffeLoader`][griffe.GriffeLoader].
Convenience functions like this one and [`load_git`][griffe.load_git] are also available.
Example:
```python
import griffe
module = griffe.load(...)
```
This is a shortcut for:
```python
from griffe import GriffeLoader
loader = GriffeLoader(...)
module = loader.load(...)
```
See the documentation for the loader: [`GriffeLoader`][griffe.GriffeLoader].
Parameters:
objspec: The Python path of an object, or file path to a module.
submodules: Whether to recurse on the submodules.
This parameter only makes sense when loading a package (top-level module).
try_relative_path: Whether to try finding the module as a relative path.
extensions: The extensions to use.
search_paths: The paths to search into.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
force_inspection: Whether to force using dynamic analysis when loading data.
store_source: Whether to store code source in the lines collection.
find_stubs_package: Whether to search for stubs-only package.
If both the package and its stubs are found, they'll be merged together.
If only the stubs are found, they'll be used as the package itself.
prefer_stubs_docs: Whether to give precedence to stubs docstrings
rather than source docstrings. When both are present, the stubs one
will override the source one.
resolve_aliases: Whether to resolve aliases.
resolve_external: Whether to try to load unspecified modules to resolve aliases.
Default value (`None`) means to load external modules only if they are the private sibling
or the origin module (for example when `ast` imports from `_ast`).
resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
Returns:
A Griffe object.
"""
loader = GriffeLoader(
extensions=extensions,
search_paths=search_paths,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
store_source=store_source,
prefer_stubs_docs=prefer_stubs_docs,
)
result = loader.load(
objspec,
submodules=submodules,
try_relative_path=try_relative_path,
find_stubs_package=find_stubs_package,
)
if resolve_aliases:
loader.resolve_aliases(implicit=resolve_implicit, external=resolve_external)
return result
def load_git(
objspec: str | Path | None = None,
/,
*,
ref: str = "HEAD",
repo: str | Path = ".",
submodules: bool = True,
extensions: Extensions | None = None,
search_paths: Sequence[str | Path] | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
allow_inspection: bool = True,
force_inspection: bool = False,
find_stubs_package: bool = False,
prefer_stubs_docs: bool = False,
resolve_aliases: bool = False,
resolve_external: bool | None = None,
resolve_implicit: bool = False,
) -> Object | Alias:
"""Load and return a module from a specific Git reference.
This function will create a temporary
[git worktree](https://git-scm.com/docs/git-worktree) at the requested reference
before loading `module` with [`griffe.load`][griffe.load].
This function requires that the `git` executable is installed.
Examples:
```python
from griffe import load_git
old_api = load_git("my_module", ref="v0.1.0", repo="path/to/repo")
```
Parameters:
objspec: The Python path of an object, or file path to a module.
ref: A Git reference such as a commit, tag or branch.
repo: Path to the repository (i.e. the directory *containing* the `.git` directory)
submodules: Whether to recurse on the submodules.
This parameter only makes sense when loading a package (top-level module).
extensions: The extensions to use.
search_paths: The paths to search into (relative to the repository root).
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
force_inspection: Whether to force using dynamic analysis when loading data.
find_stubs_package: Whether to search for stubs-only package.
If both the package and its stubs are found, they'll be merged together.
If only the stubs are found, they'll be used as the package itself.
prefer_stubs_docs: Whether to give precedence to stubs docstrings
rather than source docstrings. When both are present, the stubs one
will override the source one.
resolve_aliases: Whether to resolve aliases.
resolve_external: Whether to try to load unspecified modules to resolve aliases.
Default value (`None`) means to load external modules only if they are the private sibling
or the origin module (for example when `ast` imports from `_ast`).
resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
Returns:
A Griffe object.
"""
with _tmp_worktree(repo, ref) as worktree:
search_paths = [worktree / path for path in search_paths or ["."]]
if isinstance(objspec, Path):
objspec = worktree / objspec
return load(
objspec,
submodules=submodules,
try_relative_path=False,
extensions=extensions,
search_paths=search_paths,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
find_stubs_package=find_stubs_package,
resolve_aliases=resolve_aliases,
resolve_external=resolve_external,
resolve_implicit=resolve_implicit,
prefer_stubs_docs=prefer_stubs_docs,
)
def load_pypi(
package: str,
distribution: str,
version_spec: str,
*,
submodules: bool = True,
extensions: Extensions | None = None,
search_paths: Sequence[str | Path] | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
allow_inspection: bool = True,
force_inspection: bool = False,
find_stubs_package: bool = False,
prefer_stubs_docs: bool = False,
resolve_aliases: bool = False,
resolve_external: bool | None = None,
resolve_implicit: bool = False,
) -> Object | Alias:
"""Load and return a module from a specific package version downloaded using pip.
Parameters:
package: The package import name.
distribution: The distribution name.
version_spec: The version specifier to use when installing with pip.
submodules: Whether to recurse on the submodules.
This parameter only makes sense when loading a package (top-level module).
extensions: The extensions to use.
search_paths: The paths to search into (relative to the repository root).
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
force_inspection: Whether to force using dynamic analysis when loading data.
find_stubs_package: Whether to search for stubs-only package.
If both the package and its stubs are found, they'll be merged together.
If only the stubs are found, they'll be used as the package itself.
prefer_stubs_docs: Whether to give precedence to stubs docstrings
rather than source docstrings. When both are present, the stubs one
will override the source one.
resolve_aliases: Whether to resolve aliases.
resolve_external: Whether to try to load unspecified modules to resolve aliases.
Default value (`None`) means to load external modules only if they are the private sibling
or the origin module (for example when `ast` imports from `_ast`).
resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
"""
if not all(find_spec(pkg) for pkg in ("pip", "wheel", "platformdirs")):
raise RuntimeError("Please install Griffe with the 'pypi' extra to use this feature.")
import platformdirs # noqa: PLC0415
pypi_cache_dir = Path(platformdirs.user_cache_dir("griffe"))
install_dir = pypi_cache_dir / f"{distribution}{version_spec}"
if install_dir.exists():
logger.debug("Using cached %s%s", distribution, version_spec)
else:
with tempfile.TemporaryDirectory(dir=pypi_cache_dir) as tmpdir:
install_dir = Path(tmpdir) / distribution
logger.debug("Downloading %s%s", distribution, version_spec)
process = subprocess.run( # noqa: S603
[
sys.executable,
"-mpip",
"install",
"--no-deps",
"--no-compile",
"--no-warn-script-location",
"--no-input",
"--disable-pip-version-check",
"--no-python-version-warning",
"-t",
str(install_dir),
f"{distribution}{version_spec}",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
if process.returncode:
logger.error(process.stdout)
raise RuntimeError(f"Could not pip install {distribution}{version_spec}")
logger.debug(process.stdout)
shutil.rmtree(install_dir / "bin", ignore_errors=True)
re_dist = re.sub("[._-]", "[._-]", distribution)
version = next(
match.group(1)
for file in install_dir.iterdir()
if (match := re.match(rf"{re_dist}-(.+)\.dist-info", file.name, re.IGNORECASE))
)
dest_dir = pypi_cache_dir / f"{distribution}=={version}"
if not dest_dir.exists():
install_dir.rename(dest_dir)
install_dir = dest_dir
if not package:
files = sorted((file.name.lower() for file in install_dir.iterdir()), reverse=True)
name = distribution.lower().replace("-", "_")
if name in files or f"{name}.py" in files:
package = name
elif len(files) == 1:
raise RuntimeError(f"No package found in {distribution}=={version}")
else:
try:
package = next(file.split(".", 1)[0] for file in files if not file.endswith(".dist-info"))
except StopIteration:
raise RuntimeError(f"Could not guess package name for {distribution}=={version} (files; {files})") # noqa: B904
return load(
package,
submodules=submodules,
try_relative_path=False,
extensions=extensions,
search_paths=[install_dir, *(search_paths or ())],
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
find_stubs_package=find_stubs_package,
resolve_aliases=resolve_aliases,
resolve_external=resolve_external,
resolve_implicit=resolve_implicit,
prefer_stubs_docs=prefer_stubs_docs,
)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/expressions.py 0000664 0001750 0001750 00000170417 15246557273 026660 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains the data classes that represent resolvable names and expressions.
# First we declare data classes for each kind of expression, mostly corresponding to Python's AST nodes.
# Then we declare builder methods, that iterate AST nodes and build the corresponding data classes,
# and two utilities `_yield` and `_join` to help iterate on expressions.
# Finally we declare a few public helpers to safely get expressions from AST nodes in different scenarios.
from __future__ import annotations
import ast
import sys
from dataclasses import dataclass
from dataclasses import fields as getfields
from enum import IntEnum, auto
from functools import cache, partial
from typing import TYPE_CHECKING, Any, Protocol
from griffe._internal.agents.nodes.parameters import get_parameters
from griffe._internal.enumerations import LogLevel, ParameterKind
from griffe._internal.exceptions import NameResolutionError
from griffe._internal.logger import logger
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from pathlib import Path
from griffe._internal.models import Class, Function, Module
class _OperatorPrecedence(IntEnum):
# Adapted from:
#
# - https://docs.python.org/3/reference/expressions.html#operator-precedence
# - https://github.com/python/cpython/blob/main/Lib/_ast_unparse.py
# - https://github.com/astral-sh/ruff/blob/6abafcb56575454f2caeaa174efcb9fd0a8362b1/crates/ruff_python_ast/src/operator_precedence.rs
# The enum members are declared in ascending order of precedence.
# A virtual precedence level for contexts that provide their own grouping, like list brackets or
# function call parentheses. This ensures parentheses will never be added for the direct children of these nodes.
# NOTE: `ruff_python_formatter::expression::parentheses`'s state machine would be more robust
# but would introduce significant complexity.
NONE = auto()
YIELD = auto() # `yield`, `yield from`
ASSIGN = auto() # `target := expr`
STARRED = auto() # `*expr` (omitted by Python docs, see ruff impl)
LAMBDA = auto()
IF_ELSE = auto() # `expr if cond else expr`
OR = auto()
AND = auto()
NOT = auto()
COMP_MEMB_ID = auto() # `<`, `<=`, `>`, `>=`, `!=`, `==`, `in`, `not in`, `is`, `is not`
BIT_OR = auto() # `|`
BIT_XOR = auto() # `^`
BIT_AND = auto() # `&`
LEFT_RIGHT_SHIFT = auto() # `<<`, `>>`
ADD_SUB = auto() # `+`, `-`
MUL_DIV_REMAIN = auto() # `*`, `@`, `/`, `//`, `%`
POS_NEG_BIT_NOT = auto() # `+x`, `-x`, `~x`
EXPONENT = auto() # `**`
AWAIT = auto()
CALL_ATTRIBUTE = auto() # `x[index]`, `x[index:index]`, `x(arguments...)`, `x.attribute`
ATOMIC = auto() # `(expressions...)`, `[expressions...]`, `{key: value...}`, `{expressions...}`
def _yield(
element: str | Expr | tuple[str | Expr, ...],
*,
flat: bool = True,
is_left: bool = False,
outer_precedence: _OperatorPrecedence = _OperatorPrecedence.ATOMIC,
) -> Iterator[str | Expr]:
if isinstance(element, Expr):
element_precedence = _get_precedence(element)
needs_parens = False
# Lower inner precedence, e.g. `(a + b) * c`, `+(10) < *(11)`.
if element_precedence < outer_precedence:
needs_parens = True
elif element_precedence == outer_precedence:
# Right-association, e.g. parenthesize left-hand side in `(a ** b) ** c`, (a if b else c) if d else e
is_right_assoc = isinstance(element, ExprIfExp) or (
isinstance(element, ExprBinOp) and element.operator == "**"
)
if is_right_assoc:
if is_left:
needs_parens = True
# Left-association, e.g. parenthesize right-hand side in `a - (b - c)`.
elif isinstance(element, (ExprBinOp, ExprBoolOp)) and not is_left:
needs_parens = True
if needs_parens:
yield "("
if flat:
yield from element.iterate(flat=True)
else:
yield element
yield ")"
elif flat:
yield from element.iterate(flat=True)
else:
yield element
elif isinstance(element, tuple):
for elem in element:
yield from _yield(elem, flat=flat, outer_precedence=outer_precedence, is_left=is_left)
else:
yield element
def _join(
elements: Iterable[str | Expr | tuple[str | Expr, ...]],
joint: str | Expr,
*,
flat: bool = True,
outer_precedence: _OperatorPrecedence = _OperatorPrecedence.NONE,
) -> Iterator[str | Expr]:
"""Apply a separator between elements.
The caller is assumed to provide their own grouping
(e.g. lists, tuples, slice) and will prevent parentheses from being added.
"""
it = iter(elements)
try:
# Since we are in a sequence, don't parenthesize items (unless told otherwise).
# Avoids [a + b, c + d] being serialized as [(a + b), (c + d)]
yield from _yield(next(it), flat=flat, outer_precedence=outer_precedence)
except StopIteration:
return
for element in it:
yield from _yield(joint, flat=flat, outer_precedence=outer_precedence)
yield from _yield(element, flat=flat, outer_precedence=outer_precedence)
def _field_as_dict(
element: str | bool | Expr | list[str | Expr] | None, # noqa: FBT001
**kwargs: Any,
) -> str | bool | list | dict | None:
if isinstance(element, Expr):
return _expr_as_dict(element, **kwargs)
if isinstance(element, list):
return [_field_as_dict(elem, **kwargs) for elem in element]
return element
@cache
def _serializable_expression_fields(expression_class: type[Expr]) -> tuple[str, ...]:
return tuple(
field.name
for field in sorted(getfields(expression_class), key=lambda field: field.name)
if field.name != "parent"
)
def _expr_as_dict(expression: Expr, **kwargs: Any) -> dict[str, Any]:
fields = {
field_name: _field_as_dict(getattr(expression, field_name), **kwargs)
for field_name in _serializable_expression_fields(type(expression))
}
fields["cls"] = expression.classname
return fields
_modern_types = {
"typing.Tuple": "tuple",
"typing.Dict": "dict",
"typing.List": "list",
"typing.Set": "set",
}
@dataclass
class Expr:
"""Base class for expressions."""
def __str__(self) -> str:
return "".join(elem if isinstance(elem, str) else elem.name for elem in self.iterate(flat=True)) # ty:ignore[unresolved-attribute]
def __iter__(self) -> Iterator[str | Expr]:
"""Iterate on the expression syntax and elements."""
yield from self.iterate(flat=False)
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]: # noqa: ARG002
"""Iterate on the expression elements.
Parameters:
flat: Expressions are trees.
When flat is false, this method iterates only on the first layer of the tree.
To iterate on all the subparts of the expression, you have to do so recursively.
It allows to handle each subpart specifically (for example subscripts, attribute, etc.),
without them getting rendered as strings.
On the contrary, when flat is true, the whole tree is flattened as a sequence
of strings and instances of [Names][griffe.ExprName].
Yields:
Strings and names when flat, strings and expressions otherwise.
"""
yield from ()
def modernize(self) -> Expr:
"""Modernize the expression.
For example, use PEP 604 type unions `|` instead of `typing.Union`.
Returns:
A modernized expression.
"""
return self
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return the expression as a dictionary.
Parameters:
**kwargs: Configuration options (none available yet).
Returns:
A dictionary.
"""
return _expr_as_dict(self, **kwargs)
@property
def classname(self) -> str:
"""The expression class name."""
return self.__class__.__name__
@property
def path(self) -> str:
"""Path of the expressed name/attribute."""
return str(self)
@property
def canonical_path(self) -> str:
"""Path of the expressed name/attribute."""
return str(self)
@property
def canonical_name(self) -> str:
"""Name of the expressed name/attribute/parameter."""
# We must handle things like `griffe.Visitor` and `griffe.Visitor(code)`.
return self.canonical_path.rsplit(".", 1)[-1].split("(", 1)[-1].removesuffix(")")
@property
def is_classvar(self) -> bool:
"""Whether this attribute is annotated with `ClassVar`."""
return isinstance(self, ExprSubscript) and self.canonical_name == "ClassVar"
@property
def is_tuple(self) -> bool:
"""Whether this expression is a tuple."""
return isinstance(self, ExprSubscript) and self.canonical_name.lower() == "tuple"
@property
def is_iterator(self) -> bool:
"""Whether this expression is an iterator."""
return isinstance(self, ExprSubscript) and self.canonical_name == "Iterator"
@property
def is_generator(self) -> bool:
"""Whether this expression is a generator."""
return isinstance(self, ExprSubscript) and self.canonical_name == "Generator"
@staticmethod
def _to_binop(elements: Sequence[Expr], op: str) -> ExprBinOp:
if len(elements) == 2: # noqa: PLR2004
left, right = elements
if isinstance(left, Expr):
left = left.modernize()
if isinstance(right, Expr):
right = right.modernize()
return ExprBinOp(left=left, operator=op, right=right)
left = ExprSubscript._to_binop(elements[:-1], op=op)
right = elements[-1]
if isinstance(right, Expr):
right = right.modernize()
return ExprBinOp(left=left, operator=op, right=right)
@dataclass(eq=True, slots=True)
class ExprAttribute(Expr):
"""Attributes like `a.b`."""
values: list[str | Expr]
"""The different parts of the dotted chain."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
precedence = _get_precedence(self)
first = self.values[0]
if isinstance(first, str) and first.isdigit():
# Integer literals need parentheses: `1.bit_length()` is a syntax error.
yield f"({first})"
else:
yield from _yield(first, flat=flat, outer_precedence=precedence, is_left=True)
for value in self.values[1:]:
yield "."
yield from _yield(value, flat=flat, outer_precedence=precedence)
def modernize(self) -> ExprName | ExprAttribute:
if modern := _modern_types.get(self.canonical_path):
return ExprName(modern, parent=self.last.parent)
return self
def append(self, value: ExprName) -> None:
"""Append a name to this attribute.
Parameters:
value: The expression name to append.
"""
if value.parent is None:
value.parent = self.last
self.values.append(value)
@property
def last(self) -> ExprName:
"""The last part of this attribute (on the right)."""
# All values except the first one can *only* be names:
# we can't do `a.(b or c)` or `a."string"`.
return self.values[-1] # ty:ignore[invalid-return-type]
@property
def first(self) -> str | Expr:
"""The first part of this attribute (on the left)."""
return self.values[0]
@property
def path(self) -> str:
"""The path of this attribute."""
return self.last.path
@property
def canonical_path(self) -> str:
"""The canonical path of this attribute."""
return self.last.canonical_path
@dataclass(eq=True, slots=True)
class ExprBinOp(Expr):
"""Binary operations like `a + b`."""
left: str | Expr
"""Left part."""
operator: str
"""Binary operator."""
right: str | Expr
"""Right part."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
precedence = _get_precedence(self)
right_precedence = precedence
if self.operator == "**" and isinstance(self.right, ExprUnaryOp):
# Unary operators on the right have higher precedence, e.g. `a ** -b`.
right_precedence = _OperatorPrecedence(precedence - 1)
yield from _yield(self.left, flat=flat, outer_precedence=precedence, is_left=True)
yield f" {self.operator} "
yield from _yield(self.right, flat=flat, outer_precedence=right_precedence, is_left=False)
@dataclass(eq=True, slots=True)
class ExprBoolOp(Expr):
"""Boolean operations like `a or b`."""
operator: str
"""Boolean operator."""
values: Sequence[str | Expr]
"""Operands."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
precedence = _get_precedence(self)
it = iter(self.values)
yield from _yield(next(it), flat=flat, outer_precedence=precedence, is_left=True)
for value in it:
yield f" {self.operator} "
yield from _yield(value, flat=flat, outer_precedence=precedence, is_left=False)
@dataclass(eq=True, slots=True)
class ExprCall(Expr):
"""Calls like `f()`."""
function: Expr
"""Function called."""
arguments: Sequence[str | Expr]
"""Passed arguments."""
@property
def canonical_path(self) -> str:
"""The canonical path of this subscript's left part."""
return self.function.canonical_path
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield from _yield(self.function, flat=flat, outer_precedence=_get_precedence(self))
yield "("
yield from _join(self.arguments, ", ", flat=flat)
yield ")"
@dataclass(eq=True, slots=True)
class ExprCompare(Expr):
"""Comparisons like `a > b`."""
left: str | Expr
"""Left part."""
operators: Sequence[str]
"""Comparison operators."""
comparators: Sequence[str | Expr]
"""Things compared."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
precedence = _get_precedence(self)
yield from _yield(self.left, flat=flat, outer_precedence=precedence, is_left=True)
for op, comp in zip(self.operators, self.comparators, strict=False):
yield f" {op} "
yield from _yield(comp, flat=flat, outer_precedence=precedence)
@dataclass(eq=True, slots=True)
class ExprComprehension(Expr):
"""Comprehensions like `a for b in c if d`."""
target: str | Expr
"""Comprehension target (value added to the result)."""
iterable: str | Expr
"""Value iterated on."""
conditions: Sequence[str | Expr]
"""Conditions to include the target in the result."""
is_async: bool = False
"""Async comprehension or not."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
if self.is_async:
yield "async "
yield "for "
yield from _yield(self.target, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield " in "
# The iterable and conditions are disjunctions in the grammar:
# lambdas, conditionals and walrus assignments need parentheses there.
yield from _yield(self.iterable, flat=flat, outer_precedence=_OperatorPrecedence.OR, is_left=True)
for condition in self.conditions:
yield " if "
yield from _yield(condition, flat=flat, outer_precedence=_OperatorPrecedence.OR, is_left=True)
# TODO: `ExprConstant` is never instantiated,
# see `_build_constant` below (it always returns the value directly).
# Maybe we could simply get rid of it, as it wouldn't bring much value
# if used anyway.
@dataclass(eq=True, slots=True)
class ExprConstant(Expr):
"""Constants like `"a"` or `1`."""
value: str
"""Constant value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]: # noqa: ARG002
yield self.value
@dataclass(eq=True, slots=True)
class ExprDict(Expr):
"""Dictionaries like `{"a": 0}`."""
keys: Sequence[str | Expr | None]
"""Dict keys."""
values: Sequence[str | Expr]
"""Dict values."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "{"
# Walrus assignments and yields need parentheses in keys and values,
# e.g. `{1: (x := 2)}`.
yield from _join(
(
("**", value) if key is None else (key, ": ", value)
for key, value in zip(self.keys, self.values, strict=False)
),
", ",
flat=flat,
outer_precedence=_OperatorPrecedence.STARRED,
)
yield "}"
@dataclass(eq=True, slots=True)
class ExprDictComp(Expr):
"""Dict comprehensions like `{k: v for k, v in a}`."""
key: str | Expr
"""Target key."""
value: str | Expr | None
"""Target value."""
generators: Sequence[Expr]
"""Generators iterated on."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "{"
if self.value:
yield from _yield(self.key, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield ": "
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
else:
yield "**"
yield from _yield(self.key, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield " "
yield from _join(self.generators, " ", flat=flat)
yield "}"
@dataclass(eq=True, slots=True)
class ExprExtSlice(Expr):
"""Extended slice like `a[x:y, z]`."""
dims: Sequence[str | Expr]
"""Dims."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield from _join(self.dims, ", ", flat=flat)
def _iterate_format_parts(
value: str | Expr,
conversion: str | None,
format_spec: Sequence[str | Expr] | None,
*,
flat: bool = True,
separate_braces: bool = True,
) -> Iterator[str | Expr]:
# Shared by `ExprFormatted` and `ExprInterpolation`.
yield "{"
if separate_braces and str(value).startswith("{"):
# Separate braces, `{{` would be read as an escaped brace, e.g. `f"{ {1: 2}}"`.
yield " "
# Lambdas and walrus assignments need parentheses:
# their colon would otherwise start the format specifier, e.g. `f"{(x := 1)}"`.
yield from _yield(value, flat=flat, outer_precedence=_OperatorPrecedence.IF_ELSE)
if conversion:
yield f"!{conversion}"
if format_spec:
yield ":"
for part in format_spec:
if isinstance(part, str):
# Literal braces must be doubled, as in literal f-string parts.
yield part.replace("{", "{{").replace("}", "}}")
elif flat and isinstance(part, (ExprFormatted, ExprInterpolation)):
# Nested format fields already have their own braces; adding a separating
# space here changes the JoinedStr shape when their value starts with `{`.
yield from _iterate_format_parts(
part.value,
part.conversion,
part.format_spec,
flat=True,
separate_braces=False,
)
else:
yield from _yield(part, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield "}"
@dataclass(eq=True, slots=True)
class ExprFormatted(Expr):
"""Formatted string like `{1 + 1}`."""
value: str | Expr
"""Formatted value."""
conversion: str | None = None
"""Conversion flag (`s`, `r` or `a`), without the leading exclamation mark."""
format_spec: Sequence[str | Expr] | None = None
"""Format specifier parts: literal strings and interpolated expressions."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield from _iterate_format_parts(self.value, self.conversion, self.format_spec, flat=flat)
@dataclass(eq=True, slots=True)
class ExprGeneratorExp(Expr):
"""Generator expressions like `(a for b in c for d in e)`."""
element: str | Expr
"""Yielded element."""
generators: Sequence[Expr]
"""Generators iterated on."""
implicit: bool = False
"""Whether the generator's parentheses are implicit (as the sole argument of a call)."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
# Generator expressions require parentheses everywhere
# except as the sole argument of a call, e.g. `f(a for a in b)`.
if not self.implicit:
yield "("
yield from _yield(self.element, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield " "
yield from _join(self.generators, " ", flat=flat)
if not self.implicit:
yield ")"
@dataclass(eq=True, slots=True)
class ExprIfExp(Expr):
"""Conditions like `a if b else c`."""
body: str | Expr
"""Value if test."""
test: str | Expr
"""Condition."""
orelse: str | Expr
"""Other expression."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
precedence = _get_precedence(self)
yield from _yield(self.body, flat=flat, outer_precedence=precedence, is_left=True)
yield " if "
# If the test itself is another if/else, its precedence is the same, which will not give
# a parenthesis: force it.
test_outer_precedence = _OperatorPrecedence(precedence + 1)
yield from _yield(self.test, flat=flat, outer_precedence=test_outer_precedence)
yield " else "
# If/else is right associative. For example, a nested if/else
# `a if b else c if d else e` is effectively `a if b else (c if d else e)`, so produce a
# flattened version without parentheses.
if isinstance(self.orelse, ExprIfExp):
yield from self.orelse.iterate(flat=flat)
else:
yield from _yield(self.orelse, flat=flat, outer_precedence=precedence, is_left=False)
@dataclass(eq=True, slots=True)
class ExprInterpolation(Expr):
"""Template string interpolation like `{name}`."""
value: str | Expr
"""Interpolated value."""
conversion: str | None = None
"""Conversion flag (`s`, `r` or `a`), without the leading exclamation mark."""
format_spec: Sequence[str | Expr] | None = None
"""Format specifier parts: literal strings and interpolated expressions."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield from _iterate_format_parts(self.value, self.conversion, self.format_spec, flat=flat)
_FSTRING_ALL_QUOTES = ("'", '"', "'''", '"""')
_FSTRING_MULTI_QUOTES = ('"""', "'''")
def _fstring_choose_quote(values: Sequence[str | Expr]) -> tuple[str, list[str]]:
# Follows ast.unparse's visit_JoinedStr quote-selection algorithm.
# Pre-render all parts: literals get brace-doubled, expressions get flattened.
fstring_parts: list[tuple[str, bool]] = []
for value in values:
if isinstance(value, str):
fstring_parts.append((value.replace("{", "{{").replace("}", "}}"), True))
else:
fstring_parts.append((str(value), False))
quote_types: list[str] = list(_FSTRING_ALL_QUOTES)
escaped_parts: list[str] = []
for raw, is_constant in fstring_parts:
if is_constant:
escaped = (
raw.replace("\\", "\\\\")
.replace("\r", "\\r")
.replace("\0", "\\x00")
.replace("\n", "\\n")
.replace("\t", "\\t")
)
quote_types = [q for q in quote_types if q not in escaped] or quote_types
escaped_parts.append(escaped)
else:
if "\n" in raw:
quote_types = [q for q in quote_types if q in _FSTRING_MULTI_QUOTES] or quote_types
quote_types = [q for q in quote_types if q not in raw] or quote_types
escaped_parts.append(raw)
return quote_types[0], escaped_parts
@dataclass(eq=True, slots=True)
class ExprJoinedStr(Expr):
"""Joined strings like `f"a {b} c"`."""
values: Sequence[str | Expr]
"""Joined values."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
quote, escaped_parts = _fstring_choose_quote(self.values)
yield f"f{quote}"
for value, escaped in zip(self.values, escaped_parts, strict=True):
if isinstance(value, str):
yield escaped
else:
yield from _yield(value, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield quote
@dataclass(eq=True, slots=True)
class ExprKeyword(Expr):
"""Keyword arguments like `a=b`."""
name: str
"""Name."""
value: str | Expr
"""Value."""
# Griffe is designed around accessing Python objects
# with the dot notation, for example `module.Class`.
# Function parameters were not taken into account
# because they are not accessible the same way.
# But we still want to be able to cross-reference
# documentation of function parameters in downstream
# tools like mkdocstrings. So we add a special case
# for keyword expressions, where they get a meaningful
# canonical path (contrary to most other expressions that
# aren't or do not begin with names or attributes)
# of the form `path.to.called_function(param_name)`.
# For this we need to store a reference to the `func` part
# of the call expression in the keyword one,
# hence the following field.
# We allow it to be None for backward compatibility.
function: Expr | None = None
"""Expression referencing the function called with this parameter."""
@property
def canonical_path(self) -> str:
"""Path of the expressed keyword."""
if self.function:
return f"{self.function.canonical_path}({self.name})"
return super(ExprKeyword, self).canonical_path
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield self.name
yield "="
# Walrus assignments and yields need parentheses, e.g. `f(a=(b := c))`.
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
@dataclass(eq=True, slots=True)
class ExprVarPositional(Expr):
"""Variadic positional parameters like `*args`."""
value: Expr
"""Starred value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "*"
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.BIT_OR, is_left=True)
@dataclass(eq=True, slots=True)
class ExprVarKeyword(Expr):
"""Variadic keyword parameters like `**kwargs`."""
value: Expr
"""Double-starred value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "**"
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.BIT_OR, is_left=True)
@dataclass(eq=True, slots=True)
class ExprLambda(Expr):
"""Lambda expressions like `lambda a: a.b`."""
parameters: Sequence[ExprParameter]
"""Lambda's parameters."""
body: str | Expr
"""Lambda's body."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
pos_only = False
star = False
yield "lambda"
if self.parameters:
yield " "
for index, parameter in enumerate(self.parameters):
if index:
yield ", "
if parameter.kind is ParameterKind.positional_only:
pos_only = True
elif pos_only:
# End of the positional-only section.
pos_only = False
yield "/, "
if parameter.kind is ParameterKind.var_positional:
star = True
yield "*"
elif parameter.kind is ParameterKind.var_keyword:
yield "**"
elif parameter.kind is ParameterKind.keyword_only and not star:
# `*args` also starts the keyword-only section.
star = True
yield "*, "
yield parameter.name
if parameter.default and parameter.kind not in (ParameterKind.var_positional, ParameterKind.var_keyword):
yield "="
yield from _yield(parameter.default, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
if pos_only:
# All parameters are positional-only.
yield ", /"
yield ": "
# Body of lambda should not have parentheses, avoiding `lambda: a.b`
yield from _yield(self.body, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
@dataclass(eq=True, slots=True)
class ExprList(Expr):
"""Lists like `[0, 1, 2]`."""
elements: Sequence[Expr]
"""List elements."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "["
yield from _join(self.elements, ", ", flat=flat)
yield "]"
@dataclass(eq=True, slots=True)
class ExprListComp(Expr):
"""List comprehensions like `[a for b in c]`."""
element: str | Expr
"""Target value."""
generators: Sequence[Expr]
"""Generators iterated on."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "["
yield from _yield(self.element, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield " "
yield from _join(self.generators, " ", flat=flat)
yield "]"
@dataclass(eq=False, slots=True)
class ExprName(Expr): # noqa: PLW1641
"""This class represents a Python object identified by a name in a given scope."""
name: str
"""Actual name."""
parent: str | ExprName | Module | Class | Function | None = None
"""Parent (for resolution in its scope)."""
member: str | None = None
"""Member name (for resolution in its scope)."""
def __eq__(self, other: object) -> bool:
"""Two name expressions are equal if they have the same `name` value (`parent` is ignored)."""
if isinstance(other, ExprName):
return self.name == other.name
return NotImplemented
def iterate(self, *, flat: bool = True) -> Iterator[ExprName]: # noqa: ARG002
yield self
def modernize(self) -> ExprName:
if modern := _modern_types.get(self.canonical_path):
return ExprName(modern, parent=self.parent)
return self
@property
def path(self) -> str:
"""The full, resolved name.
If it was given when creating the name, return that.
If a callable was given, call it and return its result.
It the name cannot be resolved, return the source.
"""
if isinstance(self.parent, ExprName):
return f"{self.parent.path}.{self.name}"
return self.name
@property
def canonical_path(self) -> str:
"""The canonical name (resolved one, not alias name)."""
if self.parent is None:
return self.name
if isinstance(self.parent, ExprName):
return f"{self.parent.canonical_path}.{self.name}"
if isinstance(self.parent, str):
return f"{self.parent}.{self.name}"
parent = self.parent.members.get(self.member, self.parent)
try:
return parent.resolve(self.name)
except NameResolutionError:
return self.name
@property
def resolved(self) -> Module | Class | None:
"""The resolved object this name refers to."""
try:
return self.parent.modules_collection[self.parent.resolve(self.name)] # ty:ignore[unresolved-attribute]
except Exception: # noqa: BLE001
return self.parent.resolved[self.name] # ty:ignore[not-subscriptable, unresolved-attribute]
@property
def is_enum_class(self) -> bool:
"""Whether this name resolves to an enumeration class."""
try:
bases = self.resolved.bases # ty:ignore[unresolved-attribute]
except Exception: # noqa: BLE001
return False
# TODO: Support inheritance?
# TODO: Support `StrEnum` and `IntEnum`.
return any(isinstance(base, Expr) and base.canonical_path == "enum.Enum" for base in bases)
@property
def is_enum_instance(self) -> bool:
"""Whether this name resolves to an enumeration instance."""
try:
return self.parent.is_enum_class # ty:ignore[unresolved-attribute]
except Exception: # noqa: BLE001
return False
@property
def is_enum_value(self) -> bool:
"""Whether this name resolves to an enumeration value."""
try:
return self.name == "value" and self.parent.is_enum_instance # ty:ignore[unresolved-attribute]
except Exception: # noqa: BLE001
return False
@property
def is_type_parameter(self) -> bool:
"""Whether this name resolves to a type parameter."""
return "[" in self.canonical_path
@dataclass(eq=True, slots=True)
class ExprNamedExpr(Expr):
"""Named/assignment expressions like `a := b`."""
target: Expr
"""Target name."""
value: str | Expr
"""Value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield from _yield(self.target, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield " := "
# Nested walrus assignments and yields need parentheses, e.g. `a := (b := c)`.
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
@dataclass(eq=True, slots=True)
class ExprParameter(Expr):
"""Parameters in function signatures like `a: int = 0`."""
name: str
"""Parameter name."""
kind: ParameterKind = ParameterKind.positional_or_keyword
"""Parameter kind."""
annotation: Expr | None = None
"""Parameter type."""
default: str | Expr | None = None
"""Parameter default."""
@dataclass(eq=True, slots=True)
class ExprSet(Expr):
"""Sets like `{0, 1, 2}`."""
elements: Sequence[str | Expr]
"""Set elements."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "{"
yield from _join(self.elements, ", ", flat=flat)
yield "}"
@dataclass(eq=True, slots=True)
class ExprSetComp(Expr):
"""Set comprehensions like `{a for b in c}`."""
element: str | Expr
"""Target value."""
generators: Sequence[Expr]
"""Generators iterated on."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "{"
yield from _yield(self.element, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield " "
yield from _join(self.generators, " ", flat=flat)
yield "}"
@dataclass(eq=True, slots=True)
class ExprSlice(Expr):
"""Slices like `[a:b:c]`."""
lower: str | Expr | None = None
"""Lower bound."""
upper: str | Expr | None = None
"""Upper bound."""
step: str | Expr | None = None
"""Iteration step."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
# Walrus assignments need parentheses in slice bounds, e.g. `a[(b := c):]`.
if self.lower is not None:
yield from _yield(self.lower, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
yield ":"
if self.upper is not None:
yield from _yield(self.upper, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
if self.step is not None:
yield ":"
yield from _yield(self.step, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
@dataclass(eq=True, slots=True)
class ExprSubscript(Expr):
"""Subscripts like `a[b]`."""
left: str | Expr
"""Left part."""
slice: str | Expr
"""Slice part."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield from _yield(self.left, flat=flat, outer_precedence=_get_precedence(self))
yield "["
# Prevent parentheses from being added, avoiding `a[(b)]`
yield from _yield(self.slice, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield "]"
def modernize(self) -> ExprBinOp | ExprSubscript:
if self.canonical_path == "typing.Union":
return self._to_binop(self.slice.elements, op="|") # ty:ignore[unresolved-attribute]
if self.canonical_path == "typing.Optional":
left = self.slice if isinstance(self.slice, str) else self.slice.modernize()
return ExprBinOp(left=left, operator="|", right="None")
return ExprSubscript(
left=self.left if isinstance(self.left, str) else self.left.modernize(),
slice=self.slice if isinstance(self.slice, str) else self.slice.modernize(),
)
@property
def path(self) -> str:
"""The path of this subscript's left part."""
if isinstance(self.left, str):
return self.left
return self.left.path
@property
def canonical_path(self) -> str:
"""The canonical path of this subscript's left part."""
if isinstance(self.left, str):
return self.left
return self.left.canonical_path
@dataclass(eq=True, slots=True)
class ExprTemplateStr(Expr):
"""Template strings like `t"a {name}"`."""
values: Sequence[str | Expr]
"""Joined values."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
quote, escaped_parts = _fstring_choose_quote(self.values)
yield f"t{quote}"
for value, escaped in zip(self.values, escaped_parts, strict=True):
if isinstance(value, str):
yield escaped
else:
yield from _yield(value, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
yield quote
@dataclass(eq=True, slots=True)
class ExprTuple(Expr):
"""Tuples like `(0, 1, 2)`."""
elements: Sequence[str | Expr]
"""Tuple elements."""
implicit: bool = False
"""Whether the tuple is implicit (e.g. without parentheses in a subscript's slice)."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
# We fixed `_build_tuple` to make sure we never build
# implicit tuples with no elements, but since users might load
# data built with previous Griffe versions, we must be defensive here.
if not self.elements:
yield "()"
return
if not self.implicit:
yield "("
yield from _join(self.elements, ", ", flat=flat)
if len(self.elements) == 1:
yield ","
if not self.implicit:
yield ")"
def modernize(self) -> ExprTuple:
return ExprTuple(
elements=[el if isinstance(el, str) else el.modernize() for el in self.elements],
implicit=self.implicit,
)
@dataclass(eq=True, slots=True)
class ExprUnaryOp(Expr):
"""Unary operations like `-1`."""
operator: str
"""Unary operator."""
value: str | Expr
"""Value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield self.operator
if self.operator == "not":
yield " "
yield from _yield(self.value, flat=flat, outer_precedence=_get_precedence(self))
@dataclass(eq=True, slots=True)
class ExprAwait(Expr):
"""Await expressions like `await call()`."""
value: str | Expr
"""Awaited value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "await "
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.CALL_ATTRIBUTE)
@dataclass(eq=True, slots=True)
class ExprYield(Expr):
"""Yield statements like `yield a`."""
value: str | Expr | None = None
"""Yielded value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "yield"
if self.value is not None:
yield " "
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
@dataclass(eq=True, slots=True)
class ExprYieldFrom(Expr):
"""Yield statements like `yield from a`."""
value: str | Expr
"""Yielded-from value."""
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
yield "yield from "
yield from _yield(self.value, flat=flat, outer_precedence=_OperatorPrecedence.STARRED)
_unary_op_map = {
ast.Invert: "~",
ast.Not: "not",
ast.UAdd: "+",
ast.USub: "-",
}
_binary_op_map = {
ast.Add: "+",
ast.BitAnd: "&",
ast.BitOr: "|",
ast.BitXor: "^",
ast.Div: "/",
ast.FloorDiv: "//",
ast.LShift: "<<",
ast.MatMult: "@",
ast.Mod: "%",
ast.Mult: "*",
ast.Pow: "**",
ast.RShift: ">>",
ast.Sub: "-",
}
_bool_op_map = {
ast.And: "and",
ast.Or: "or",
}
_compare_op_map = {
ast.Eq: "==",
ast.NotEq: "!=",
ast.Lt: "<",
ast.LtE: "<=",
ast.Gt: ">",
ast.GtE: ">=",
ast.Is: "is",
ast.IsNot: "is not",
ast.In: "in",
ast.NotIn: "not in",
}
_precedence_map = {
# Literals and names.
ExprName: lambda _: _OperatorPrecedence.ATOMIC,
ExprConstant: lambda _: _OperatorPrecedence.ATOMIC,
ExprJoinedStr: lambda _: _OperatorPrecedence.ATOMIC,
ExprFormatted: lambda _: _OperatorPrecedence.ATOMIC,
ExprTemplateStr: lambda _: _OperatorPrecedence.ATOMIC,
ExprInterpolation: lambda _: _OperatorPrecedence.ATOMIC,
# Container displays.
ExprList: lambda _: _OperatorPrecedence.ATOMIC,
ExprTuple: lambda _: _OperatorPrecedence.ATOMIC,
ExprSet: lambda _: _OperatorPrecedence.ATOMIC,
ExprDict: lambda _: _OperatorPrecedence.ATOMIC,
# Comprehensions are self-contained units that produce a container.
ExprListComp: lambda _: _OperatorPrecedence.ATOMIC,
ExprSetComp: lambda _: _OperatorPrecedence.ATOMIC,
ExprDictComp: lambda _: _OperatorPrecedence.ATOMIC,
ExprAttribute: lambda _: _OperatorPrecedence.CALL_ATTRIBUTE,
ExprSubscript: lambda _: _OperatorPrecedence.CALL_ATTRIBUTE,
ExprCall: lambda _: _OperatorPrecedence.CALL_ATTRIBUTE,
ExprAwait: lambda _: _OperatorPrecedence.AWAIT,
ExprUnaryOp: lambda e: {"not": _OperatorPrecedence.NOT}.get(e.operator, _OperatorPrecedence.POS_NEG_BIT_NOT),
ExprBinOp: lambda e: {
"**": _OperatorPrecedence.EXPONENT,
"*": _OperatorPrecedence.MUL_DIV_REMAIN,
"@": _OperatorPrecedence.MUL_DIV_REMAIN,
"/": _OperatorPrecedence.MUL_DIV_REMAIN,
"//": _OperatorPrecedence.MUL_DIV_REMAIN,
"%": _OperatorPrecedence.MUL_DIV_REMAIN,
"+": _OperatorPrecedence.ADD_SUB,
"-": _OperatorPrecedence.ADD_SUB,
"<<": _OperatorPrecedence.LEFT_RIGHT_SHIFT,
">>": _OperatorPrecedence.LEFT_RIGHT_SHIFT,
"&": _OperatorPrecedence.BIT_AND,
"^": _OperatorPrecedence.BIT_XOR,
"|": _OperatorPrecedence.BIT_OR,
}[e.operator],
ExprBoolOp: lambda e: {"and": _OperatorPrecedence.AND, "or": _OperatorPrecedence.OR}[e.operator],
ExprCompare: lambda _: _OperatorPrecedence.COMP_MEMB_ID,
ExprIfExp: lambda _: _OperatorPrecedence.IF_ELSE,
ExprNamedExpr: lambda _: _OperatorPrecedence.ASSIGN,
ExprLambda: lambda _: _OperatorPrecedence.LAMBDA,
ExprGeneratorExp: lambda _: _OperatorPrecedence.ATOMIC,
ExprVarPositional: lambda _: _OperatorPrecedence.STARRED,
ExprVarKeyword: lambda _: _OperatorPrecedence.STARRED,
ExprYield: lambda _: _OperatorPrecedence.YIELD,
ExprYieldFrom: lambda _: _OperatorPrecedence.YIELD,
# These are not standalone, they appear in specific contexts where precedence is not a concern.
# NOTE: `for ... in ... if` part, not the whole `[...]`.
ExprComprehension: lambda _: _OperatorPrecedence.NONE,
ExprExtSlice: lambda _: _OperatorPrecedence.NONE,
ExprKeyword: lambda _: _OperatorPrecedence.NONE,
ExprParameter: lambda _: _OperatorPrecedence.NONE,
ExprSlice: lambda _: _OperatorPrecedence.NONE,
}
def _get_precedence(expr: Expr) -> _OperatorPrecedence:
return _precedence_map.get(type(expr), lambda _: _OperatorPrecedence.NONE)(expr)
def _build_attribute(node: ast.Attribute, parent: Module | Class, **kwargs: Any) -> Expr:
left = _build(node.value, parent, **kwargs)
if isinstance(left, ExprAttribute):
left.append(ExprName(node.attr))
return left
if isinstance(left, ExprName):
return ExprAttribute([left, ExprName(node.attr, left)])
if isinstance(left, str):
return ExprAttribute([left, ExprName(node.attr, "str")])
return ExprAttribute([left, ExprName(node.attr)])
def _build_await(node: ast.Await, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprAwait(_build(node.value, parent, **kwargs))
def _build_binop(node: ast.BinOp, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprBinOp(
_build(node.left, parent, **kwargs),
_binary_op_map[type(node.op)], # ty:ignore[invalid-argument-type]
_build(node.right, parent, **kwargs),
)
def _build_boolop(node: ast.BoolOp, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprBoolOp(
_bool_op_map[type(node.op)], # ty:ignore[invalid-argument-type]
[_build(value, parent, **kwargs) for value in node.values],
)
def _build_call(node: ast.Call, parent: Module | Class, **kwargs: Any) -> Expr:
function = _build(node.func, parent, **kwargs)
if len(node.args) == 1 and not node.keywords and isinstance(node.args[0], ast.GeneratorExp):
# A sole generator argument reuses the call's parentheses, e.g. `f(a for a in b)`.
positional_args = [_build(node.args[0], parent, implicit_parens=True, **kwargs)]
else:
positional_args = [_build(arg, parent, **kwargs) for arg in node.args]
keyword_args = [_build(kwarg, parent, function=function, **kwargs) for kwarg in node.keywords]
return ExprCall(function, [*positional_args, *keyword_args])
def _build_compare(node: ast.Compare, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprCompare(
_build(node.left, parent, **kwargs),
[_compare_op_map[type(op)] for op in node.ops], # ty:ignore[invalid-argument-type]
[_build(comp, parent, **kwargs) for comp in node.comparators],
)
def _build_implicit_tuple(node: ast.AST, parent: Module | Class, **kwargs: Any) -> str | Expr:
expression = _build(node, parent, **kwargs)
# Empty tuples cannot be implicit: omitting their parentheses produces invalid Python.
if isinstance(expression, ExprTuple) and expression.elements:
expression.implicit = True
return expression
def _build_comprehension(node: ast.comprehension, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprComprehension(
_build_implicit_tuple(node.target, parent, **kwargs),
_build(node.iter, parent, **kwargs),
[_build(condition, parent, **kwargs) for condition in node.ifs],
is_async=bool(node.is_async),
)
def _build_constant(
node: ast.Constant,
parent: Module | Class,
*,
in_formatted_str: bool = False,
in_joined_str: bool = False,
parse_strings: bool = False,
literal_strings: bool = False,
**kwargs: Any,
) -> str | Expr:
if isinstance(node.value, str):
if in_joined_str and not in_formatted_str:
# We're in a f-string, not in a formatted value, don't keep quotes.
return node.value
if parse_strings and not literal_strings and not in_formatted_str:
# We're in a place where a string could be a type annotation
# (and not in a Literal[...] type annotation).
# We parse the string and build from the resulting nodes again.
# If we fail to parse it (syntax errors), we consider it's a literal string and log a message.
try:
parsed = compile(
node.value,
mode="eval",
filename="",
flags=ast.PyCF_ONLY_AST,
optimize=1,
)
except SyntaxError:
logger.debug(
"Tried and failed to parse %r as Python code, "
"falling back to using it as a string literal "
"(postponed annotations might help: https://peps.python.org/pep-0563/)",
node.value,
)
else:
return _build(parsed.body, parent, **kwargs) # ty:ignore[unresolved-attribute]
return {type(...): lambda _: "..."}.get(type(node.value), repr)(node.value)
def _build_dict(node: ast.Dict, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprDict(
[None if key is None else _build(key, parent, **kwargs) for key in node.keys],
[_build(value, parent, **kwargs) for value in node.values],
)
def _build_dictcomp(node: ast.DictComp, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprDictComp(
_build(node.key, parent, **kwargs),
None if node.value is None else _build(node.value, parent, **kwargs),
[_build(gen, parent, **kwargs) for gen in node.generators],
)
_conversion_map = {115: "s", 114: "r", 97: "a"}
def _build_format_spec(node: ast.expr | None, parent: Module | Class, **kwargs: Any) -> list[str | Expr] | None:
if node is None:
return None
# Format specifiers are joined-string parts without the `f` prefix and quotes:
# literal parts stay raw, interpolated parts are built recursively.
return [_build(value, parent, in_joined_str=True, **kwargs) for value in node.values] # ty:ignore[unresolved-attribute]
def _build_formatted(
node: ast.FormattedValue,
parent: Module | Class,
*,
in_formatted_str: bool = False, # noqa: ARG001
in_joined_str: bool = False, # noqa: ARG001
**kwargs: Any,
) -> Expr:
return ExprFormatted(
_build(node.value, parent, in_formatted_str=True, in_joined_str=True, **kwargs),
conversion=_conversion_map.get(node.conversion),
format_spec=_build_format_spec(node.format_spec, parent, **kwargs),
)
def _build_generatorexp(
node: ast.GeneratorExp,
parent: Module | Class,
*,
implicit_parens: bool = False,
**kwargs: Any,
) -> Expr:
return ExprGeneratorExp(
_build(node.elt, parent, **kwargs),
[_build(gen, parent, **kwargs) for gen in node.generators],
implicit=implicit_parens,
)
def _build_ifexp(node: ast.IfExp, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprIfExp(
_build(node.body, parent, **kwargs),
_build(node.test, parent, **kwargs),
_build(node.orelse, parent, **kwargs),
)
def _build_joinedstr(
node: ast.JoinedStr,
parent: Module | Class,
*,
in_joined_str: bool = False, # noqa: ARG001
**kwargs: Any,
) -> Expr:
return ExprJoinedStr([_build(value, parent, in_joined_str=True, **kwargs) for value in node.values])
def _build_keyword(node: ast.keyword, parent: Module | Class, function: Expr | None = None, **kwargs: Any) -> Expr:
if node.arg is None:
return ExprVarKeyword(_build(node.value, parent, **kwargs))
return ExprKeyword(node.arg, _build(node.value, parent, **kwargs), function=function)
def _build_lambda(node: ast.Lambda, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprLambda(
parameters=[
ExprParameter(
name=name,
kind=kind,
annotation=None,
default=default
if isinstance(default, str)
else safe_get_expression(default, parent=parent, parse_strings=False),
)
for name, _, kind, default in get_parameters(node.args)
],
body=_build(node.body, parent, **kwargs),
)
def _build_list(node: ast.List, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprList([_build(el, parent, **kwargs) for el in node.elts])
def _build_listcomp(node: ast.ListComp, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprListComp(_build(node.elt, parent, **kwargs), [_build(gen, parent, **kwargs) for gen in node.generators])
def _build_name(node: ast.Name, parent: Module | Class, member: str | None = None, **kwargs: Any) -> Expr: # noqa: ARG001
return ExprName(node.id, parent, member)
def _build_named_expr(node: ast.NamedExpr, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprNamedExpr(_build(node.target, parent, **kwargs), _build(node.value, parent, **kwargs))
def _build_set(node: ast.Set, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprSet([_build(el, parent, **kwargs) for el in node.elts])
def _build_setcomp(node: ast.SetComp, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprSetComp(_build(node.elt, parent, **kwargs), [_build(gen, parent, **kwargs) for gen in node.generators])
def _build_slice(node: ast.Slice, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprSlice(
None if node.lower is None else _build(node.lower, parent, **kwargs),
None if node.upper is None else _build(node.upper, parent, **kwargs),
None if node.step is None else _build(node.step, parent, **kwargs),
)
def _build_starred(node: ast.Starred, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprVarPositional(_build(node.value, parent, **kwargs))
def _build_subscript(
node: ast.Subscript,
parent: Module | Class,
*,
parse_strings: bool = False,
literal_strings: bool = False,
**kwargs: Any,
) -> Expr:
left = _build(node.value, parent, **kwargs)
if parse_strings:
if isinstance(left, (ExprAttribute, ExprName)) and left.canonical_path in {
"typing.Literal",
"typing_extensions.Literal",
}:
literal_strings = True
slice_expr = _build_implicit_tuple(
node.slice,
parent,
parse_strings=True,
literal_strings=literal_strings,
**kwargs,
)
else:
slice_expr = _build_implicit_tuple(node.slice, parent, **kwargs)
return ExprSubscript(left, slice_expr)
def _build_tuple(node: ast.Tuple, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprTuple([_build(el, parent, **kwargs) for el in node.elts])
def _build_unaryop(node: ast.UnaryOp, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprUnaryOp(_unary_op_map[type(node.op)], _build(node.operand, parent, **kwargs)) # ty:ignore[invalid-argument-type]
def _build_yield(node: ast.Yield, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprYield(None if node.value is None else _build(node.value, parent, **kwargs))
def _build_yield_from(node: ast.YieldFrom, parent: Module | Class, **kwargs: Any) -> Expr:
return ExprYieldFrom(_build(node.value, parent, **kwargs))
class _BuildCallable(Protocol):
def __call__(self, node: Any, parent: Module | Class, **kwargs: Any) -> Expr: ...
_node_map: dict[type, _BuildCallable] = {
ast.Attribute: _build_attribute,
ast.Await: _build_await,
ast.BinOp: _build_binop,
ast.BoolOp: _build_boolop,
ast.Call: _build_call,
ast.Compare: _build_compare,
ast.comprehension: _build_comprehension,
ast.Constant: _build_constant,
ast.Dict: _build_dict,
ast.DictComp: _build_dictcomp,
ast.FormattedValue: _build_formatted,
ast.GeneratorExp: _build_generatorexp,
ast.IfExp: _build_ifexp,
ast.JoinedStr: _build_joinedstr,
ast.keyword: _build_keyword,
ast.Lambda: _build_lambda,
ast.List: _build_list,
ast.ListComp: _build_listcomp,
ast.Name: _build_name,
ast.NamedExpr: _build_named_expr,
ast.Set: _build_set,
ast.SetComp: _build_setcomp,
ast.Slice: _build_slice,
ast.Starred: _build_starred,
ast.Subscript: _build_subscript,
ast.Tuple: _build_tuple,
ast.UnaryOp: _build_unaryop,
ast.Yield: _build_yield,
ast.YieldFrom: _build_yield_from,
} # ty:ignore[invalid-assignment]
if sys.version_info >= (3, 14):
def _build_interpolation(
node: ast.Interpolation,
parent: Module | Class,
*,
in_formatted_str: bool = False, # noqa: ARG001
in_joined_str: bool = False, # noqa: ARG001
**kwargs: Any,
) -> Expr:
return ExprInterpolation(
_build(node.value, parent, in_formatted_str=True, in_joined_str=True, **kwargs),
conversion=_conversion_map.get(node.conversion),
format_spec=_build_format_spec(node.format_spec, parent, **kwargs),
)
def _build_templatestr(
node: ast.TemplateStr,
parent: Module | Class,
**kwargs: Any,
) -> Expr:
return ExprTemplateStr([_build(value, parent, in_joined_str=True, **kwargs) for value in node.values])
_node_map.update(
{
ast.Interpolation: _build_interpolation,
ast.TemplateStr: _build_templatestr,
},
)
def _build(node: ast.AST, parent: Module | Class, /, **kwargs: Any) -> Expr:
return _node_map[type(node)](node, parent, **kwargs)
def get_expression(
node: ast.AST | None,
parent: Module | Class,
*,
member: str | None = None,
parse_strings: bool | None = None,
) -> Expr | None:
"""Build an expression from an AST.
Parameters:
node: The annotation node.
parent: The parent used to resolve the name.
member: The member name (for resolution in its scope).
parse_strings: Whether to try and parse strings as type annotations.
Returns:
A string or resovable name or expression.
"""
if node is None:
return None
if parse_strings is None:
try:
module = parent.module
except ValueError:
parse_strings = False
else:
parse_strings = not module.imports_future_annotations
return _build(node, parent, member=member, parse_strings=parse_strings)
def safe_get_expression(
node: ast.AST | None,
parent: Module | Class,
*,
member: str | None = None,
parse_strings: bool | None = None,
log_level: LogLevel | None = LogLevel.error,
msg_format: str = "{path}:{lineno}: Failed to get expression from {node_class}: {error}",
) -> Expr | None:
"""Safely (no exception) build a resolvable annotation.
Parameters:
node: The annotation node.
parent: The parent used to resolve the name.
member: The member name (for resolution in its scope).
parse_strings: Whether to try and parse strings as type annotations.
log_level: Log level to use to log a message. None to disable logging.
msg_format: A format string for the log message. Available placeholders:
path, lineno, node, error.
Returns:
A string or resovable name or expression.
"""
try:
return get_expression(node, parent, member=member, parse_strings=parse_strings)
except Exception as error: # noqa: BLE001
if log_level is None:
return None
node_class = node.__class__.__name__
try:
path: Path | str = parent.relative_filepath
except ValueError:
path = ""
lineno = node.lineno # ty:ignore[unresolved-attribute]
error_str = f"{error.__class__.__name__}: {error}"
message = msg_format.format(path=path, lineno=lineno, node_class=node_class, error=error_str)
getattr(logger, log_level.value)(message)
return None
_msg_format = "{path}:{lineno}: Failed to get %s expression from {node_class}: {error}"
get_annotation = partial(get_expression, parse_strings=None)
safe_get_annotation = partial(
safe_get_expression,
parse_strings=None,
msg_format=_msg_format % "annotation",
)
get_base_class = partial(get_expression, parse_strings=False)
safe_get_base_class = partial(
safe_get_expression,
parse_strings=False,
msg_format=_msg_format % "base class",
)
get_class_keyword = partial(get_expression, parse_strings=False)
safe_get_class_keyword = partial(
safe_get_expression,
parse_strings=False,
msg_format=_msg_format % "class keyword",
)
get_condition = partial(get_expression, parse_strings=False)
safe_get_condition = partial(
safe_get_expression,
parse_strings=False,
msg_format=_msg_format % "condition",
)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/git.py 0000664 0001750 0001750 00000024733 15246557273 025060 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains Git utilities, used by our [`load_git`][griffe.load_git] function,
# which in turn is used to load the API for different snapshots of a Git repository
# and find breaking changes between them.
from __future__ import annotations
import os
import re
import shutil
import subprocess
import unicodedata
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Literal
from urllib.parse import urlsplit, urlunsplit
from griffe._internal.exceptions import BuiltinModuleError, GitError
if TYPE_CHECKING:
from collections.abc import Iterator
from griffe._internal.models import Module
_WORKTREE_PREFIX = "griffe-worktree-"
def _normalize(value: str) -> str:
value = unicodedata.normalize("NFKC", value)
value = re.sub(r"[^\w]+", "-", value)
return re.sub(r"[-\s]+", "-", value).strip("-")
def _git(*args: str, check: bool = True) -> str:
process = subprocess.run(
["git", *args],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding="utf8",
)
if check and process.returncode != 0:
raise GitError(process.stdout.strip())
return process.stdout.strip()
def _assert_git_repo(path: str | Path) -> None:
"""Deprecated. Assert that a directory is a Git repository.
Parameters:
path: Path to a directory.
Raises:
OSError: When the directory is not a Git repository.
"""
if not shutil.which("git"):
raise RuntimeError("Could not find git executable. Please install git.")
try:
_git("-C", str(path), "rev-parse", "--is-inside-work-tree")
except GitError as error:
raise OSError(f"Not a git repository: {path}") from error
def _get_latest_tag(repo: str | Path) -> str:
"""Deprecated. Get latest tag of a Git repository.
Parameters:
repo: The path to Git repository.
Returns:
The latest tag.
"""
if isinstance(repo, str):
repo = Path(repo)
if not repo.is_dir():
repo = repo.parent
try:
output = _git("tag", "-l", "--sort=-creatordate")
except GitError as error:
raise GitError(f"Cannot list Git tags in {repo}: {error or 'no tags'}") from error
return output.split("\n", 1)[0]
def _get_repo_root(repo: str | Path) -> Path:
"""Deprecated. Get the root of a Git repository.
Parameters:
repo: The path to a Git repository.
Returns:
The root of the repository.
"""
if isinstance(repo, str):
repo = Path(repo)
if not repo.is_dir():
repo = repo.parent
return Path(_git("-C", str(repo), "rev-parse", "--show-toplevel"))
@contextmanager
def _tmp_worktree(repo: str | Path = ".", ref: str = "HEAD") -> Iterator[Path]:
"""Deprecated. Context manager that checks out the given reference in the given repository to a temporary worktree.
Parameters:
repo: Path to the repository (i.e. the directory *containing* the `.git` directory)
ref: A Git reference such as a commit, tag or branch.
Yields:
The path to the temporary worktree.
Raises:
OSError: If `repo` is not a valid `.git` repository
RuntimeError: If the `git` executable is unavailable, or if it cannot create a worktree
"""
_assert_git_repo(repo)
repo_name = Path(repo).resolve().name
normref = _normalize(ref) # Branch names can contain slashes.
with TemporaryDirectory(prefix=f"{_WORKTREE_PREFIX}{repo_name}-{normref}-") as tmp_dir:
location = os.path.join(tmp_dir, normref) # noqa: PTH118
tmp_branch = f"griffe-{normref}" # Temporary branch name must not already exist.
try:
_git("-C", str(repo), "worktree", "add", "-b", tmp_branch, location, ref)
except GitError as error:
raise RuntimeError(f"Could not create git worktree: {error}") from error
try:
yield Path(location)
finally:
_git("-C", str(repo), "worktree", "remove", location, check=False)
_git("-C", str(repo), "worktree", "prune", check=False)
_git("-C", str(repo), "branch", "-D", tmp_branch, check=False)
def _get_git_remote_url(repo: str | Path = ".") -> str:
if git_url := os.getenv("GRIFFE_GIT_REMOTE_URL"):
return git_url
remote = "remote." + os.getenv("GRIFFE_GIT_REMOTE", "origin") + ".url"
git_url = _git("-C", str(repo), "config", "--default", "", "--get", remote)
if git_url.startswith("git@"):
git_url = git_url.replace(":", "/", 1).replace("git@", "https://", 1)
git_url = git_url.removesuffix(".git")
# Remove credentials from the URL.
if git_url.startswith(("http://", "https://")):
# (addressing scheme, network location, path, query, fragment identifier)
urlparts = list(urlsplit(git_url))
urlparts[1] = urlparts[1].split("@", 1)[-1]
git_url = urlunsplit(urlparts)
return git_url
KnownGitService = Literal["github", "gitlab", "sourcehut", "gitea", "gogs", "forgejo", "codeberg", "radicle"]
"""Known Git hosting services."""
_service_re = re.compile(rf"({'|'.join(KnownGitService.__args__)})")
def _get_git_known_service(git_remote_url: str) -> KnownGitService | None:
if service := os.getenv("GRIFFE_GIT_SERVICE"):
if service not in KnownGitService.__args__:
return None
return service # ty:ignore[invalid-return-type]
if match := _service_re.search(urlsplit(git_remote_url).netloc):
return match.group(1) # ty:ignore[invalid-return-type]
return None
# For Radicle we use https://app.radicle.at/nodes/seed.radicle.at which I believe seeds everything?
# Line ranges do not seem to be supported.
# The rad remote is declared as such in .git/config:
#
# ```ini
# [remote "rad"]
# url = rad://z4M5XTPDD4Wh1sm8iPCenF85J3z8Z
# ```
_RADICLE_URL = "https://app.radicle.at/nodes/seed.radicle.at"
def _get_radicle_url(url_or_rid: str, commit_hash: str, filepath: str, lineno: int, endlineno: int) -> str: # noqa: ARG001
# This lets users override the full URL with `GRIFFE_GIT_REMOTE_URL=https://.../rad:...`.
url = f"{_RADICLE_URL}/{url_or_rid.replace('//', '')}" if url_or_rid.startswith("rad://") else url_or_rid
return f"{url}/tree/{commit_hash}/{filepath}#L{lineno}"
_service_to_url = {
"github": lambda url, ch, fp, ln, eln: f"{url}/blob/{ch}/{fp}#L{ln}-L{eln}",
"gitlab": lambda url, ch, fp, ln, eln: f"{url}/-/blob/{ch}/{fp}#L{ln}-L{eln}",
# SourceHut does not seem to support line ranges.
"sourcehut": lambda url, ch, fp, ln, eln: f"{url}/tree/{ch}/{fp}#L{ln}",
# Cannot find a demo Gogs instance so not sure about this URL template.
"gogs": lambda url, ch, fp, ln, eln: f"{url}/blob/{ch}/{fp}#L{ln}-L{eln}",
"gitea": lambda url, ch, fp, ln, eln: f"{url}/src/commit/{ch}/{fp}#L{ln}-L{eln}",
"codeberg": lambda url, ch, fp, ln, eln: f"{url}/src/commit/{ch}/{fp}#L{ln}-L{eln}",
"forgejo": lambda url, ch, fp, ln, eln: f"{url}/src/commit/{ch}/{fp}#L{ln}-L{eln}",
"radicle": _get_radicle_url,
}
def _get_source_link(
*,
service: KnownGitService,
remote_url: str,
commit_hash: str,
filepath: str | Path,
lineno: int,
endlineno: int,
) -> str | None:
if isinstance(filepath, Path):
filepath = filepath.as_posix()
return _service_to_url[service](remote_url, commit_hash, filepath, lineno, endlineno)
def _get_git_commit_hash(repo: str | Path = ".") -> str:
if commit_hash := os.getenv("GRIFFE_GIT_COMMIT_HASH"):
return commit_hash
return _git("-C", str(repo), "rev-parse", "HEAD")
def _is_tracked(filepath: str | Path, repo: str | Path = ".") -> bool:
return not _git("-C", str(repo), "check-ignore", str(filepath), check=False)
@dataclass
class GitInfo:
"""Information about a Git repository."""
repository: Path
"""The path to the Git repository."""
service: KnownGitService
"""The Git hosting service (used to build the right URLs)."""
remote_url: str
"""The remote URL of the Git repository."""
commit_hash: str
"""A commit hash (usually the current checked-out one)."""
@classmethod
def from_package(cls, package: Module) -> GitInfo | None:
"""Create a GitInfo instance from a Griffe package.
Parameters:
package: The package to create Git information for.
Returns:
The GitInfo instance, or None if unknown.
"""
try:
path = package.filepath[0] if isinstance(package.filepath, list) else package.filepath
except BuiltinModuleError:
return None
try:
repo = _get_repo_root(path)
if not _is_tracked(path.relative_to(repo), repo):
return None
remote_url = _get_git_remote_url(repo)
if not (service := _get_git_known_service(remote_url)):
return None
commit_hash = _get_git_commit_hash(repo)
except (GitError, ValueError, OSError):
# `ValueError` can happen if `path` is not relative to `repo`.
# `OSError` is caught just to be safe.
return None
return cls(repository=repo, service=service, remote_url=remote_url, commit_hash=commit_hash)
def get_source_link(self, filepath: str | Path, lineno: int, endlineno: int) -> str | None:
"""Get the source link for the file at the given line numbers.
Returns:
The source link, or None if unknown.
"""
return _get_source_link(
service=self.service,
remote_url=self.remote_url,
commit_hash=self.commit_hash,
filepath=filepath,
lineno=lineno,
endlineno=endlineno,
)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/__init__.py 0000664 0001750 0001750 00000001707 15246557273 026030 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# The internal API layout doesn't follow any particular paradigm:
# we simply organize code in different modules, depending on what the code is used for.
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/diff.py 0000664 0001750 0001750 00000065765 15246557273 025217 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module exports "breaking changes" related utilities.
# The logic here is to iterate on objects and their members recursively,
# to yield found breaking changes.
#
# The breakage class definitions might sound a bit verbose,
# but declaring them this way helps with (de)serialization,
# which we don't use yet, but could use in the future.
from __future__ import annotations
import contextlib
from logging import DEBUG
from pathlib import Path
from typing import TYPE_CHECKING, Any
from griffe._internal.enumerations import BreakageKind, ExplanationStyle, ParameterKind
from griffe._internal.exceptions import AliasResolutionError
from griffe._internal.git import _WORKTREE_PREFIX
from griffe._internal.logger import logger
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from griffe._internal.models import Alias, Attribute, Class, Function, Object
_POSITIONAL = frozenset((ParameterKind.positional_only, ParameterKind.positional_or_keyword))
_KEYWORD = frozenset((ParameterKind.keyword_only, ParameterKind.positional_or_keyword))
_POSITIONAL_KEYWORD_ONLY = frozenset((ParameterKind.positional_only, ParameterKind.keyword_only))
_VARIADIC = frozenset((ParameterKind.var_positional, ParameterKind.var_keyword))
# Colors for terminal output.
class _ANSI:
FG_BLACK = "\033[30m"
FG_RED = "\033[31m"
FG_GREEN = "\033[32m"
FG_YELLOW = "\033[33m"
FG_BLUE = "\033[34m"
FG_MAGENTA = "\033[35m"
FG_CYAN = "\033[36m"
FG_WHITE = "\033[37m"
FG_RESET = "\033[39m"
FG_LIGHTBLACK_EX = "\033[90m"
FG_LIGHTRED_EX = "\033[91m"
FG_LIGHTGREEN_EX = "\033[92m"
FG_LIGHTYELLOW_EX = "\033[93m"
FG_LIGHTBLUE_EX = "\033[94m"
FG_LIGHTMAGENTA_EX = "\033[95m"
FG_LIGHTCYAN_EX = "\033[96m"
FG_LIGHTWHITE_EX = "\033[97m"
BG_BLACK = "\033[40m"
BG_RED = "\033[41m"
BG_GREEN = "\033[42m"
BG_YELLOW = "\033[43m"
BG_BLUE = "\033[44m"
BG_MAGENTA = "\033[45m"
BG_CYAN = "\033[46m"
BG_WHITE = "\033[47m"
BG_RESET = "\033[49m"
BG_LIGHTBLACK_EX = "\033[100m"
BG_LIGHTRED_EX = "\033[101m"
BG_LIGHTGREEN_EX = "\033[102m"
BG_LIGHTYELLOW_EX = "\033[103m"
BG_LIGHTBLUE_EX = "\033[104m"
BG_LIGHTMAGENTA_EX = "\033[105m"
BG_LIGHTCYAN_EX = "\033[106m"
BG_LIGHTWHITE_EX = "\033[107m"
BRIGHT = "\033[1m"
DIM = "\033[2m"
NORMAL = "\033[22m"
RESET_ALL = "\033[0m"
class Breakage:
"""Breakages can explain what broke from a version to another."""
kind: BreakageKind
"""The kind of breakage."""
def __init__(self, obj: Object, old_value: Any, new_value: Any, details: str = "") -> None:
"""Initialize the breakage.
Parameters:
obj: The object related to the breakage.
old_value: The old value.
new_value: The new, incompatible value.
details: Some details about the breakage.
"""
self.obj = obj
"""The object related to the breakage."""
self.old_value = old_value
"""The old value."""
self.new_value = new_value
"""The new, incompatible value."""
self.details = details
"""Some details about the breakage."""
def __str__(self) -> str:
return self.kind.value
def __repr__(self) -> str:
return self.kind.name
def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
"""Return this object's data as a dictionary.
Parameters:
full: Whether to return full info, or just base info.
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
return {
"kind": self.kind,
"object_path": self.obj.path,
"old_value": self.old_value,
"new_value": self.new_value,
}
def explain(self, style: ExplanationStyle = ExplanationStyle.ONE_LINE) -> str:
"""Explain the breakage by showing old and new value.
Parameters:
style: The explanation style to use.
Returns:
An explanation.
"""
return getattr(self, f"_explain_{style.value}")()
@property
def _filepath(self) -> Path:
if self.obj.is_alias:
return self.obj.parent.filepath # ty:ignore[invalid-return-type, unresolved-attribute]
return self.obj.filepath # ty:ignore[invalid-return-type]
@property
def _relative_filepath(self) -> Path:
if self.obj.is_alias:
return self.obj.parent.relative_filepath # ty:ignore[unresolved-attribute]
return self.obj.relative_filepath
@property
def _relative_package_filepath(self) -> Path:
if self.obj.is_alias:
return self.obj.parent.relative_package_filepath # ty:ignore[unresolved-attribute]
return self.obj.relative_package_filepath
@property
def _location(self) -> Path:
# Absolute file path probably means temporary worktree.
# We use our worktree prefix to remove some components
# of the path on the left (`/tmp/griffe-worktree-*/griffe_*/repo`).
if self._relative_filepath.is_absolute():
parts = self._relative_filepath.parts
for index, part in enumerate(parts):
if part.startswith(_WORKTREE_PREFIX):
return Path(*parts[index + 2 :])
return self._relative_filepath
@property
def _canonical_path(self) -> str:
if self.obj.is_alias:
return self.obj.path
return self.obj.canonical_path
@property
def _module_path(self) -> str:
if self.obj.is_alias:
return self.obj.parent.module.path # ty:ignore[unresolved-attribute]
return self.obj.module.path
@property
def _relative_path(self) -> str:
return self._canonical_path[len(self._module_path) + 1 :] or ""
@property
def _lineno(self) -> int:
# If the object was removed, and we are able to get the location (file path)
# as a relative path, then we use 0 instead of the original line number
# (it helps when checking current sources, and avoids pointing to now missing contents).
if self.kind is BreakageKind.OBJECT_REMOVED and self._relative_filepath != self._location:
return 0
if self.obj.is_alias:
return self.obj.alias_lineno or 0 # ty:ignore[unresolved-attribute]
return self.obj.lineno or 0
def _format_location(self, *, colors: bool = True) -> str:
bright = _ANSI.BRIGHT if colors else ""
reset = _ANSI.RESET_ALL if colors else ""
return f"{bright}{self._location}{reset}:{self._lineno}"
def _format_title(self, *, colors: bool = True) -> str: # noqa: ARG002
return self._relative_path
def _format_kind(self, *, colors: bool = True) -> str:
yellow = _ANSI.FG_YELLOW if colors else ""
reset = _ANSI.FG_RESET if colors else ""
return f"{yellow}{self.kind.value}{reset}"
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return str(self.old_value)
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return str(self.new_value)
def _explain_oneline(self) -> str:
explanation = f"{self._format_location()}: {self._format_title()}: {self._format_kind()}"
old = self._format_old_value()
new = self._format_new_value()
if old and new:
change = f"{old} -> {new}"
elif old:
change = old
elif new:
change = new
else:
change = ""
if change:
return f"{explanation}: {change}"
return explanation
def _explain_verbose(self) -> str:
lines = [f"{self._format_location()}: {self._format_title()}:"]
kind = self._format_kind()
old = self._format_old_value()
new = self._format_new_value()
if old or new:
lines.append(f"{kind}:")
else:
lines.append(kind)
if old:
lines.append(f" Old: {old}")
if new:
lines.append(f" New: {new}")
if self.details:
lines.append(f" Details: {self.details}")
lines.append("")
return "\n".join(lines)
def _explain_markdown(self) -> str:
explanation = f"- `{self._relative_path}`: *{self.kind.value}*"
old = self._format_old_value(colors=False)
if old and old != "unset":
old = f"`{old}`"
new = self._format_new_value(colors=False)
if new and new != "unset":
new = f"`{new}`"
if old and new:
change = f"{old} -> {new}"
elif old:
change = old
elif new:
change = new
else:
change = ""
if change:
return f"{explanation}: {change}"
return explanation
def _explain_github(self) -> str:
location = f"file={self._location},line={self._lineno}"
title = f"title={self._format_title(colors=False)}"
explanation = f"::warning {location},{title}::{self.kind.value}"
old = self._format_old_value(colors=False)
if old and old != "unset":
old = f"`{old}`"
new = self._format_new_value(colors=False)
if new and new != "unset":
new = f"`{new}`"
if old and new:
change = f"{old} -> {new}"
elif old:
change = old
elif new:
change = new
else:
change = ""
if change:
return f"{explanation}: {change}"
return explanation
def _explain_azdo(self) -> str:
location = f"sourcepath={self._location},linenumber={self._lineno}"
title = f"title={self._format_title(colors=False)}"
explanation = f"###vso[task.logissue type=warning;{location},{title}]{self.kind.value}"
old = self._format_old_value(colors=False)
if old and old != "unset":
old = f"`{old}`"
new = self._format_new_value(colors=False)
if new and new != "unset":
new = f"`{new}`"
if old and new:
change = f"{old} -> {new}"
elif old:
change = old
elif new:
change = new
else:
change = ""
if change:
return f"{explanation}: {change}"
return explanation
class ParameterMovedBreakage(Breakage):
"""Specific breakage class for moved parameters."""
kind: BreakageKind = BreakageKind.PARAMETER_MOVED
@property
def _relative_path(self) -> str:
return f"{super()._relative_path}({self.old_value.name})"
def _format_title(self, *, colors: bool = True) -> str:
blue = _ANSI.FG_BLUE if colors else ""
reset = _ANSI.FG_RESET if colors else ""
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
class ParameterRemovedBreakage(Breakage):
"""Specific breakage class for removed parameters."""
kind: BreakageKind = BreakageKind.PARAMETER_REMOVED
@property
def _relative_path(self) -> str:
return f"{super()._relative_path}({self.old_value.name})"
def _format_title(self, *, colors: bool = True) -> str:
blue = _ANSI.FG_BLUE if colors else ""
reset = _ANSI.FG_RESET if colors else ""
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
class ParameterChangedKindBreakage(Breakage):
"""Specific breakage class for parameters whose kind changed."""
kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND
@property
def _relative_path(self) -> str:
return f"{super()._relative_path}({self.old_value.name})"
def _format_title(self, *, colors: bool = True) -> str:
blue = _ANSI.FG_BLUE if colors else ""
reset = _ANSI.FG_RESET if colors else ""
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return str(self.old_value.kind.value)
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return str(self.new_value.kind.value)
class ParameterChangedDefaultBreakage(Breakage):
"""Specific breakage class for parameters whose default value changed."""
kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT
@property
def _relative_path(self) -> str:
return f"{super()._relative_path}({self.old_value.name})"
def _format_title(self, *, colors: bool = True) -> str:
blue = _ANSI.FG_BLUE if colors else ""
reset = _ANSI.FG_RESET if colors else ""
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return str(self.old_value.default)
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return str(self.new_value.default)
class ParameterChangedRequiredBreakage(Breakage):
"""Specific breakage class for parameters which became required."""
kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED
@property
def _relative_path(self) -> str:
return f"{super()._relative_path}({self.old_value.name})"
def _format_title(self, *, colors: bool = True) -> str:
blue = _ANSI.FG_BLUE if colors else ""
reset = _ANSI.FG_RESET if colors else ""
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
class ParameterAddedRequiredBreakage(Breakage):
"""Specific breakage class for new parameters added as required."""
kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED
@property
def _relative_path(self) -> str:
return f"{super()._relative_path}({self.new_value.name})"
def _format_title(self, *, colors: bool = True) -> str:
blue = _ANSI.FG_BLUE if colors else ""
reset = _ANSI.FG_RESET if colors else ""
return f"{super()._relative_path}({blue}{self.new_value.name}{reset})"
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
class ReturnChangedTypeBreakage(Breakage):
"""Specific breakage class for return values which changed type."""
kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE
class ObjectRemovedBreakage(Breakage):
"""Specific breakage class for removed objects."""
kind: BreakageKind = BreakageKind.OBJECT_REMOVED
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return ""
class ObjectChangedKindBreakage(Breakage):
"""Specific breakage class for objects whose kind changed."""
kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return self.old_value.value
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return self.new_value.value
class AttributeChangedTypeBreakage(Breakage):
"""Specific breakage class for attributes whose type changed."""
kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE
class AttributeChangedValueBreakage(Breakage):
"""Specific breakage class for attributes whose value changed."""
kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE
class ClassRemovedBaseBreakage(Breakage):
"""Specific breakage class for removed base classes."""
kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return "[" + ", ".join(base.canonical_path for base in self.old_value) + "]"
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
return "[" + ", ".join(base.canonical_path for base in self.new_value) + "]"
# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
def _class_incompatibilities(
old_class: Class,
new_class: Class,
*,
seen_paths: set[str],
) -> Iterable[Breakage]:
yield from ()
if new_class.bases != old_class.bases and len(new_class.bases) < len(old_class.bases):
yield ClassRemovedBaseBreakage(new_class, old_class.bases, new_class.bases)
yield from _member_incompatibilities(old_class, new_class, seen_paths=seen_paths)
# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
def _function_incompatibilities(old_function: Function, new_function: Function) -> Iterator[Breakage]:
new_params = {}
for index, param in enumerate(new_function.parameters):
# Keep the first parameter with a given name, matching the behavior of `Parameters.__getitem__`.
new_params.setdefault(param.name, (index, param))
old_param_names = {param.name for param in old_function.parameters}
param_kinds = {param.kind for param in new_function.parameters}
has_variadic_args = ParameterKind.var_positional in param_kinds
has_variadic_kwargs = ParameterKind.var_keyword in param_kinds
for old_index, old_param in enumerate(old_function.parameters):
# Check if the parameter was removed.
try:
new_index, new_param = new_params[old_param.name]
except KeyError:
swallowed = (
(old_param.kind is ParameterKind.keyword_only and has_variadic_kwargs)
or (old_param.kind is ParameterKind.positional_only and has_variadic_args)
or (old_param.kind is ParameterKind.positional_or_keyword and has_variadic_args and has_variadic_kwargs)
)
if not swallowed:
yield ParameterRemovedBreakage(new_function, old_param, None)
continue
# Check if the parameter became required.
if new_param.required and not old_param.required:
yield ParameterChangedRequiredBreakage(new_function, old_param, new_param)
# Check if the parameter was moved.
if old_param.kind in _POSITIONAL and new_param.kind in _POSITIONAL and new_index != old_index:
details = f"position: from {old_index} to {new_index} ({new_index - old_index:+})"
yield ParameterMovedBreakage(new_function, old_param, new_param, details=details)
# Check if the parameter changed kind.
if old_param.kind is not new_param.kind:
incompatible_kind = any(
(
# Positional-only to keyword-only.
old_param.kind is ParameterKind.positional_only and new_param.kind is ParameterKind.keyword_only,
# Keyword-only to positional-only.
old_param.kind is ParameterKind.keyword_only and new_param.kind is ParameterKind.positional_only,
# Positional or keyword to positional-only/keyword-only.
old_param.kind is ParameterKind.positional_or_keyword
and new_param.kind in _POSITIONAL_KEYWORD_ONLY,
# Not keyword-only to variadic keyword, without variadic positional.
new_param.kind is ParameterKind.var_keyword
and old_param.kind is not ParameterKind.keyword_only
and not has_variadic_args,
# Not positional-only to variadic positional, without variadic keyword.
new_param.kind is ParameterKind.var_positional
and old_param.kind is not ParameterKind.positional_only
and not has_variadic_kwargs,
),
)
if incompatible_kind:
yield ParameterChangedKindBreakage(new_function, old_param, new_param)
# Check if the parameter changed default.
breakage = ParameterChangedDefaultBreakage(new_function, old_param, new_param)
non_required = not old_param.required and not new_param.required
non_variadic = old_param.kind not in _VARIADIC and new_param.kind not in _VARIADIC
if non_required and non_variadic:
try:
if old_param.default != new_param.default:
yield breakage
except Exception: # noqa: BLE001 (equality checks sometimes fail, e.g. numpy arrays)
# NOTE: Emitting breakage on a failed comparison could be a preference.
yield breakage
# Check if required parameters were added.
for new_param in new_function.parameters:
if new_param.name not in old_param_names and new_param.required:
yield ParameterAddedRequiredBreakage(new_function, None, new_param)
if not _returns_are_compatible(old_function, new_function):
yield ReturnChangedTypeBreakage(new_function, old_function.returns, new_function.returns)
def _attribute_incompatibilities(old_attribute: Attribute, new_attribute: Attribute) -> Iterable[Breakage]:
# TODO: Support annotation breaking changes.
if old_attribute.value != new_attribute.value:
if new_attribute.value is None:
yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, "unset")
else:
yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, new_attribute.value)
def _alias_incompatibilities(
old_obj: Object | Alias,
new_obj: Object | Alias,
*,
seen_paths: set[str],
) -> Iterable[Breakage]:
try:
old_member = old_obj.target if old_obj.is_alias else old_obj # ty:ignore[unresolved-attribute]
new_member = new_obj.target if new_obj.is_alias else new_obj # ty:ignore[unresolved-attribute]
except AliasResolutionError:
logger.debug("API check: %s | %s: skip alias with unknown target", old_obj.path, new_obj.path)
return
yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
def _member_incompatibilities(
old_obj: Object | Alias,
new_obj: Object | Alias,
*,
seen_paths: set[str] | None = None,
) -> Iterator[Breakage]:
seen_paths = set() if seen_paths is None else seen_paths
new_members = new_obj.all_members
debug = logger.isEnabledFor(DEBUG)
for name, old_member in old_obj.all_members.items():
if not old_member.is_public:
if debug:
logger.debug("API check: %s.%s: skip non-public object", old_obj.path, name)
continue
if debug:
logger.debug("API check: %s.%s", old_obj.path, name)
try:
new_member = new_members[name]
except KeyError:
if (not old_member.is_alias and old_member.is_module) or old_member.is_public:
yield ObjectRemovedBreakage(old_member, old_member, None) # ty:ignore[invalid-argument-type]
else:
yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
def _type_based_yield(
old_member: Object | Alias,
new_member: Object | Alias,
*,
seen_paths: set[str],
) -> Iterator[Breakage]:
if old_member.path in seen_paths:
return
seen_paths.add(old_member.path)
if old_member.is_alias or new_member.is_alias:
# Should be first, since there can be the case where there is an alias and another kind of object,
# which may not be a breaking change.
yield from _alias_incompatibilities(
old_member,
new_member,
seen_paths=seen_paths,
)
elif new_member.kind != old_member.kind:
yield ObjectChangedKindBreakage(new_member, old_member.kind, new_member.kind) # ty:ignore[invalid-argument-type]
elif old_member.is_module:
yield from _member_incompatibilities(
old_member,
new_member,
seen_paths=seen_paths,
)
elif old_member.is_class:
yield from _class_incompatibilities(
old_member, # ty:ignore[invalid-argument-type]
new_member, # ty:ignore[invalid-argument-type]
seen_paths=seen_paths,
)
elif old_member.is_function:
yield from _function_incompatibilities(old_member, new_member) # ty:ignore[invalid-argument-type]
elif old_member.is_attribute:
yield from _attribute_incompatibilities(old_member, new_member) # ty:ignore[invalid-argument-type]
def _returns_are_compatible(old_function: Function, new_function: Function) -> bool:
# We consider that a return value of `None` only is not a strong contract,
# it just means that the function returns nothing. We don't expect users
# to be asserting that the return value is `None`.
# Therefore we don't consider it a breakage if the return changes from `None`
# to something else: the function just gained a return value.
if old_function.returns is None:
return True
if new_function.returns is None:
# NOTE: Should it be configurable to allow/disallow removing a return type?
return False
with contextlib.suppress(AttributeError):
if new_function.returns == old_function.returns:
return True
# TODO: Support annotation breaking changes.
return True
_sentinel = object()
def find_breaking_changes(
old_obj: Object | Alias,
new_obj: Object | Alias,
) -> Iterator[Breakage]:
"""Find breaking changes between two versions of the same API.
The function will iterate recursively on all objects
and yield breaking changes with detailed information.
Parameters:
old_obj: The old version of an object.
new_obj: The new version of an object.
Yields:
Breaking changes.
Examples:
>>> import sys, griffe
>>> new = griffe.load("pkg")
>>> old = griffe.load_git("pkg", "1.2.3")
>>> for breakage in griffe.find_breaking_changes(old, new)
... print(breakage.explain(style=style), file=sys.stderr)
"""
yield from _member_incompatibilities(old_obj, new_obj)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/ 0000775 0001750 0001750 00000000000 15246557273 025173 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/ 0000775 0001750 0001750 00000000000 15246557273 026303 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/parameters.py 0000664 0001750 0001750 00000006432 15246557273 031025 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for extracting information from parameter nodes.
from __future__ import annotations
import ast
from griffe._internal.enumerations import ParameterKind
ParametersType = list[tuple[str, ast.AST | None, ParameterKind, str | ast.AST | None]]
"""Type alias for the list of parameters of a function."""
def get_parameters(node: ast.arguments) -> ParametersType:
parameters: ParametersType = []
# Python stores positional-only and positional-or-keyword arguments in separate lists,
# but stores their defaults together. The defaults are right-aligned with the combined
# positional parameters, so `default_offset` is the index of the first parameter with
# a default value.
positional_count = len(node.posonlyargs) + len(node.args)
default_offset = positional_count - len(node.defaults)
# Keep one index across both positional argument lists to preserve that alignment.
index = 0
for arg in node.posonlyargs:
default = node.defaults[index - default_offset] if index >= default_offset else None
parameters.append((arg.arg, arg.annotation, ParameterKind.positional_only, default))
index += 1
for arg in node.args:
default = node.defaults[index - default_offset] if index >= default_offset else None
parameters.append((arg.arg, arg.annotation, ParameterKind.positional_or_keyword, default))
index += 1
# Variadic positional parameters have no AST default. Use an empty tuple as their
# implicit default so they are not treated as required parameters later on.
if node.vararg:
parameters.append(
(
node.vararg.arg,
node.vararg.annotation,
ParameterKind.var_positional,
"()",
),
)
# Unlike positional defaults, keyword-only defaults are stored one-to-one with their
# arguments. A `None` entry means that the keyword-only parameter is required.
for kwarg, kwarg_default in zip(node.kwonlyargs, node.kw_defaults, strict=False):
parameters.append(
(kwarg.arg, kwarg.annotation, ParameterKind.keyword_only, kwarg_default),
)
# Likewise, use an empty mapping as the implicit default for variadic keyword arguments.
if node.kwarg:
parameters.append(
(
node.kwarg.arg,
node.kwarg.annotation,
ParameterKind.var_keyword,
"{}",
),
)
return parameters
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/__init__.py 0000664 0001750 0001750 00000001571 15246557273 030420 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# These submodules contain utilities for working with AST and object nodes.
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/docstrings.py 0000664 0001750 0001750 00000003431 15246557273 031035 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for extracting docstrings from nodes.
from __future__ import annotations
import ast
def get_docstring(
node: ast.AST,
*,
strict: bool = False,
) -> tuple[str | None, int | None, int | None]:
"""Extract a docstring.
Parameters:
node: The node to extract the docstring from.
strict: Whether to skip searching the body (functions).
Returns:
A tuple with the value and line numbers of the docstring.
"""
# TODO: Possible optimization using a type map.
if isinstance(node, ast.Expr):
doc = node.value
elif not strict and node.body and isinstance(node.body, list) and isinstance(node.body[0], ast.Expr): # ty:ignore[unresolved-attribute]
doc = node.body[0].value # ty:ignore[unresolved-attribute]
else:
return None, None, None
if isinstance(doc, ast.Constant) and isinstance(doc.value, str):
return doc.value, doc.lineno, doc.end_lineno
return None, None, None
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/exports.py 0000664 0001750 0001750 00000010020 15246557273 030352 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for extracting exports from `__all__` assignments.
from __future__ import annotations
import ast
from contextlib import suppress
from typing import TYPE_CHECKING, Any
from griffe._internal.agents.nodes.values import get_value
from griffe._internal.enumerations import LogLevel
from griffe._internal.expressions import ExprName
from griffe._internal.logger import logger
if TYPE_CHECKING:
from collections.abc import Callable
from griffe._internal.models import Module
def _extract_attribute(node: ast.Attribute, parent: Module) -> list[str | ExprName]:
return [ExprName(name=node.attr, parent=_extract(node.value, parent)[0])]
def _extract_binop(node: ast.BinOp, parent: Module) -> list[str | ExprName]:
left = _extract(node.left, parent)
right = _extract(node.right, parent)
return left + right
def _extract_constant(node: ast.Constant, parent: Module) -> list[str | ExprName]:
return [node.value] # ty:ignore[invalid-return-type]
def _extract_name(node: ast.Name, parent: Module) -> list[str | ExprName]:
return [ExprName(node.id, parent)]
def _extract_sequence(node: ast.List | ast.Set | ast.Tuple, parent: Module) -> list[str | ExprName]:
sequence = []
for elt in node.elts:
sequence.extend(_extract(elt, parent))
return sequence
def _extract_starred(node: ast.Starred, parent: Module) -> list[str | ExprName]:
return _extract(node.value, parent)
_node_map: dict[type, Callable[[Any, Module], list[str | ExprName]]] = {
ast.Attribute: _extract_attribute,
ast.BinOp: _extract_binop,
ast.Constant: _extract_constant,
ast.List: _extract_sequence,
ast.Name: _extract_name,
ast.Set: _extract_sequence,
ast.Starred: _extract_starred,
ast.Tuple: _extract_sequence,
}
def _extract(node: ast.AST, parent: Module) -> list[str | ExprName]:
return _node_map[type(node)](node, parent)
def get__all__(node: ast.Assign | ast.AnnAssign | ast.AugAssign, parent: Module) -> list[str | ExprName]:
"""Get the values declared in `__all__`.
Parameters:
node: The assignment node.
parent: The parent module.
Returns:
A set of names.
"""
if node.value is None:
return []
return _extract(node.value, parent)
def safe_get__all__(
node: ast.Assign | ast.AnnAssign | ast.AugAssign,
parent: Module,
log_level: LogLevel = LogLevel.debug, # TODO: Set to error when we handle more things?
) -> list[str | ExprName]:
"""Safely (no exception) extract values in `__all__`.
Parameters:
node: The `__all__` assignment node.
parent: The parent used to resolve the names.
log_level: Log level to use to log a message.
Returns:
A list of strings or resolvable names.
"""
try:
return get__all__(node, parent)
except Exception as error: # noqa: BLE001
message = f"Failed to extract `__all__` value: {get_value(node.value)}"
with suppress(Exception):
message += f" at {parent.relative_filepath}:{node.lineno}"
if isinstance(error, KeyError):
message += f": unsupported node {error}"
else:
message += f": {error}"
getattr(logger, log_level.value)(message)
return []
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/ast.py 0000664 0001750 0001750 00000012333 15246557273 027446 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for navigating AST nodes.
from __future__ import annotations
from ast import AST, boolop, cmpop, expr_context, operator, unaryop
from typing import TYPE_CHECKING
from griffe._internal.exceptions import LastNodeError
if TYPE_CHECKING:
from collections.abc import Iterator
# CPython reuses instances of these context/operator nodes across ASTs. Attaching
# a parent to one of them therefore keeps the most recently visited AST alive
# through the singleton, and gives the node an inherently incorrect parent.
_SHARED_AST_NODE_TYPES = (expr_context, operator, unaryop, boolop, cmpop)
def ast_kind(node: AST) -> str:
"""Return the kind of an AST node.
Parameters:
node: The AST node.
Returns:
The node kind.
"""
return node.__class__.__name__.lower()
def ast_children(node: AST) -> Iterator[AST]:
"""Return the children of an AST node.
Parameters:
node: The AST node.
Yields:
The node children.
"""
for field_name in node._fields:
try:
field = getattr(node, field_name)
except AttributeError:
continue
if isinstance(field, AST):
if isinstance(field, _SHARED_AST_NODE_TYPES):
vars(field).pop("parent", None)
else:
field.parent = node
yield field
elif isinstance(field, list):
for child in field:
if isinstance(child, AST):
if isinstance(child, _SHARED_AST_NODE_TYPES):
vars(child).pop("parent", None)
else:
child.parent = node
yield child
def ast_previous_siblings(node: AST) -> Iterator[AST]:
"""Return the previous siblings of this node, starting from the closest.
Parameters:
node: The AST node.
Yields:
The previous siblings.
"""
for sibling in ast_children(node.parent): # ty:ignore[unresolved-attribute]
if sibling is not node:
yield sibling
else:
return
def ast_next_siblings(node: AST) -> Iterator[AST]:
"""Return the next siblings of this node, starting from the closest.
Parameters:
node: The AST node.
Yields:
The next siblings.
"""
siblings = ast_children(node.parent) # ty:ignore[unresolved-attribute]
for sibling in siblings:
if sibling is node:
break
yield from siblings
def ast_siblings(node: AST) -> Iterator[AST]:
"""Return the siblings of this node.
Parameters:
node: The AST node.
Yields:
The siblings.
"""
siblings = ast_children(node.parent) # ty:ignore[unresolved-attribute]
for sibling in siblings:
if sibling is not node:
yield sibling
else:
break
yield from siblings
def ast_previous(node: AST) -> AST:
"""Return the previous sibling of this node.
Parameters:
node: The AST node.
Raises:
LastNodeError: When the node does not have previous siblings.
Returns:
The sibling.
"""
try:
*_, last = ast_previous_siblings(node)
except ValueError:
raise LastNodeError("there is no previous node") from None
return last
def ast_next(node: AST) -> AST:
"""Return the next sibling of this node.
Parameters:
node: The AST node.
Raises:
LastNodeError: When the node does not have next siblings.
Returns:
The sibling.
"""
try:
return next(ast_next_siblings(node))
except StopIteration:
raise LastNodeError("there is no next node") from None
def ast_first_child(node: AST) -> AST:
"""Return the first child of this node.
Parameters:
node: The AST node.
Raises:
LastNodeError: When the node does not have children.
Returns:
The child.
"""
try:
return next(ast_children(node))
except StopIteration as error:
raise LastNodeError("there are no children node") from error
def ast_last_child(node: AST) -> AST:
"""Return the lasts child of this node.
Parameters:
node: The AST node.
Raises:
LastNodeError: When the node does not have children.
Returns:
The child.
"""
try:
*_, last = ast_children(node)
except ValueError as error:
raise LastNodeError("there are no children node") from error
return last
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/runtime.py 0000664 0001750 0001750 00000032306 15246557273 030344 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for extracting information from runtime objects.
from __future__ import annotations
import inspect
import sys
import typing
from functools import cached_property
from types import GetSetDescriptorType, ModuleType
from typing import TYPE_CHECKING, Any, ClassVar
from griffe._internal.enumerations import ObjectKind
from griffe._internal.logger import logger
if TYPE_CHECKING:
import types
from collections.abc import Sequence
_TYPING_MODULES: tuple[types.ModuleType, ...]
try:
import typing_extensions
except ImportError:
_TYPING_MODULES = (typing,)
else:
_TYPING_MODULES = (typing, typing_extensions)
_builtin_module_names = {_.lstrip("_") for _ in sys.builtin_module_names}
_cyclic_relationships = {
("os", "nt"),
("os", "posix"),
("numpy.core._multiarray_umath", "numpy.core.multiarray"),
}
def _same_components(a: str, b: str, /) -> bool:
return [cpn.lstrip("_") for cpn in a.split(".")] == [cpn.lstrip("_") for cpn in b.split(".")]
class ObjectNode:
"""Helper class to represent an object tree.
It's not really a tree but more a backward-linked list:
each node has a reference to its parent, but not to its child (for simplicity purposes and to avoid bugs).
Each node stores an object, its name, and a reference to its parent node.
"""
exclude_specials: ClassVar[set[str]] = {"__builtins__", "__loader__", "__spec__"}
"""Low level attributes known to cause issues when resolving aliases."""
def __init__(self, obj: Any, name: str, parent: ObjectNode | None = None) -> None:
"""Initialize the object.
Parameters:
obj: A Python object.
name: The object's name.
parent: The object's parent node.
"""
# Unwrap object.
try:
obj = inspect.unwrap(obj)
except Exception as error: # noqa: BLE001
# `inspect.unwrap` at some point runs `hasattr(obj, "__wrapped__")`,
# which triggers the `__getattr__` method of the object, which in
# turn can raise various exceptions. Probably not just `__getattr__`.
# See https://github.com/pawamoy/pytkdocs/issues/45.
logger.debug("Could not unwrap %s: %r", name, error)
# Unwrap cached properties (`inspect.unwrap` doesn't do that).
if isinstance(obj, cached_property):
is_cached_property = True
obj = obj.func
else:
is_cached_property = False
self.obj: Any = obj
"""The actual Python object."""
self.name: str = name
"""The Python object's name."""
self.parent: ObjectNode | None = parent
"""The parent node."""
self.is_cached_property: bool = is_cached_property
"""Whether this node's object is a cached property."""
def __repr__(self) -> str:
return f"ObjectNode(name={self.name!r})"
@property
def path(self) -> str:
"""The object's (Python) path."""
if self.parent is None:
return self.name
return f"{self.parent.path}.{self.name}"
@property
def module(self) -> ObjectNode:
"""The object's module, fetched from the node tree."""
if self.is_module:
return self
if self.parent is not None:
return self.parent.module
raise ValueError(f"Object node {self.path} does not have a parent module")
@property
def module_path(self) -> str | None:
"""The object's module path."""
try:
module = self.obj.__module__
except AttributeError:
try:
module = inspect.getmodule(self.obj) or self.module.obj
except ValueError:
return None
try:
return module.__spec__.name # ty:ignore[unresolved-attribute]
except AttributeError:
return getattr(module, "__name__", None)
else:
# Some libraries mistakenly set `__module__` to a module object instead of a string.
# Handle this case specifically, but do not cast any other object to a string.
if isinstance(module, ModuleType):
logger.debug(
"Object %s has its `__module__` attribute set to a module object: "
"it must be a string instead (the module fully qualified name). "
"Please report to the maintainers of this library.",
self.path,
)
return getattr(module, "__qualname__", getattr(module, "__name__", None))
if isinstance(module, str):
return module
logger.debug(
"Object %s has its `__module__` attribute set to a %s object: "
"it must be a string instead (the module fully qualified name). "
"Please report to the maintainers of this library.",
self.path,
getattr(type(module), "__name__", str(type(module))),
)
return None
@property
def kind(self) -> ObjectKind:
"""The kind of this node."""
if self.is_module:
return ObjectKind.MODULE
if self.is_class:
return ObjectKind.CLASS
if self.is_staticmethod:
return ObjectKind.STATICMETHOD
if self.is_classmethod:
return ObjectKind.CLASSMETHOD
if self.is_cached_property:
return ObjectKind.CACHED_PROPERTY
if self.is_method:
return ObjectKind.METHOD
if self.is_builtin_method:
return ObjectKind.BUILTIN_METHOD
if self.is_coroutine:
return ObjectKind.COROUTINE
if self.is_builtin_function:
return ObjectKind.BUILTIN_FUNCTION
if self.is_method_descriptor:
return ObjectKind.METHOD_DESCRIPTOR
if self.is_function:
return ObjectKind.FUNCTION
if self.is_getset_descriptor:
return ObjectKind.GETSET_DESCRIPTOR
if self.is_property:
return ObjectKind.PROPERTY
if self.is_type_alias:
return ObjectKind.TYPE_ALIAS
return ObjectKind.ATTRIBUTE
@cached_property
def children(self) -> Sequence[ObjectNode]:
"""The children of this node."""
children = []
for name, member in inspect.getmembers(self.obj):
if self._pick_member(name, member):
children.append(ObjectNode(member, name, parent=self))
return children
@cached_property
def is_module(self) -> bool:
"""Whether this node's object is a module."""
return inspect.ismodule(self.obj)
@cached_property
def is_class(self) -> bool:
"""Whether this node's object is a class."""
return inspect.isclass(self.obj)
@cached_property
def is_function(self) -> bool:
"""Whether this node's object is a function."""
# `inspect.isfunction` returns `False` for partials.
return inspect.isfunction(self.obj) or (callable(self.obj) and not self.is_class)
@cached_property
def is_type_alias(self) -> bool:
"""Whether this node's object is a type alias."""
return isinstance(
self.obj,
tuple(module.TypeAliasType for module in _TYPING_MODULES if hasattr(module, "TypeAliasType")),
)
@cached_property
def is_builtin_function(self) -> bool:
"""Whether this node's object is a builtin function."""
return inspect.isbuiltin(self.obj)
@cached_property
def is_coroutine(self) -> bool:
"""Whether this node's object is a coroutine."""
return inspect.iscoroutinefunction(self.obj)
@cached_property
def is_getset_descriptor(self) -> bool:
"""Whether this node's object is a get/set descriptor."""
return isinstance(self.obj, GetSetDescriptorType)
@cached_property
def is_property(self) -> bool:
"""Whether this node's object is a property."""
return isinstance(self.obj, property) or self.is_cached_property
@cached_property
def parent_is_class(self) -> bool:
"""Whether the object of this node's parent is a class."""
return bool(self.parent and self.parent.is_class)
@cached_property
def is_method(self) -> bool:
"""Whether this node's object is a method."""
function_type = type(lambda: None)
return self.parent_is_class and isinstance(self.obj, function_type)
@cached_property
def is_method_descriptor(self) -> bool:
"""Whether this node's object is a method descriptor.
Built-in methods (e.g. those implemented in C/Rust) are often
method descriptors, rather than normal methods.
"""
return inspect.ismethoddescriptor(self.obj)
@cached_property
def is_builtin_method(self) -> bool:
"""Whether this node's object is a builtin method."""
return self.is_builtin_function and self.parent_is_class
@cached_property
def is_staticmethod(self) -> bool:
"""Whether this node's object is a staticmethod."""
if self.parent is None:
return False
try:
self_from_parent = self.parent.obj.__dict__.get(self.name, None)
except AttributeError:
return False
return self.parent_is_class and isinstance(self_from_parent, staticmethod)
@cached_property
def is_classmethod(self) -> bool:
"""Whether this node's object is a classmethod."""
if self.parent is None:
return False
try:
self_from_parent = self.parent.obj.__dict__.get(self.name, None)
except AttributeError:
return False
return self.parent_is_class and isinstance(self_from_parent, classmethod)
@cached_property
def is_attribute(self) -> bool:
"""Whether this node's object is an attribute."""
return self.kind is ObjectKind.ATTRIBUTE
@cached_property
def _ids(self) -> set[int]:
if self.parent is None:
return {id(self.obj)}
return {id(self.obj)} | self.parent._ids
def _pick_member(self, name: str, member: Any) -> bool:
return (
name not in self.exclude_specials
and member is not type
and member is not object
and id(member) not in self._ids
and name in vars(self.obj)
)
@cached_property
def alias_target_path(self) -> str | None:
"""Alias target path of this node, if the node should be an alias."""
# Top-level objects can't have been imported.
if self.parent is None:
return None
# We can't ever know if an attribute was imported.
if self.is_attribute:
return None
# Get the path of the module the child object was declared in.
child_module_path = self.module_path
if not child_module_path:
return None
# Get the path of the module the parent object was declared in.
parent_module_path = self.parent.module_path
if not parent_module_path:
return None
# Special cases: break cycles.
if (parent_module_path, child_module_path) in _cyclic_relationships:
return None
# If the current object was declared in the same module as its parent,
# or in a module with the same path components but starting/not starting with underscores,
# we don't want to alias it. Examples: (a, a), (a, _a), (_a, a), (_a, _a),
# (a.b, a.b), (a.b, _a.b), (_a._b, a.b), (a._b, _a.b), etc..
if _same_components(parent_module_path, child_module_path):
return None
# If the current object was declared in any other module, we alias it.
# We remove the leading underscore from the child module path
# if it's a built-in module (e.g. _io -> io). That's because objects
# in built-in modules inconsistently lie about their module path,
# so we prefer to use the non-underscored (public) version,
# as users most likely import from the public module and not the private one.
if child_module_path.lstrip("_") in _builtin_module_names:
child_module_path = child_module_path.lstrip("_")
# Child object is a module, return its path directly.
if self.is_module:
return child_module_path
# Rebuild the child object path.
child_name = getattr(self.obj, "__qualname__", self.path[len(self.module.path) + 1 :])
return f"{child_module_path}.{child_name}"
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/assignments.py 0000664 0001750 0001750 00000005022 15246557273 031207 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for extracting information from assignment nodes.
from __future__ import annotations
import ast
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
def _get_attribute_name(node: ast.Attribute) -> str:
return f"{get_name(node.value)}.{node.attr}"
def _get_name_name(node: ast.Name) -> str:
return node.id
_node_name_map: dict[type, Callable[[Any], str]] = {
ast.Name: _get_name_name,
ast.Attribute: _get_attribute_name,
}
def get_name(node: ast.AST) -> str:
"""Extract name from an assignment node.
Parameters:
node: The node to extract names from.
Returns:
A list of names.
"""
return _node_name_map[type(node)](node)
def _get_assign_names(node: ast.Assign) -> list[str]:
names = (get_name(target) for target in node.targets)
return [name for name in names if name]
def _get_annassign_names(node: ast.AnnAssign) -> list[str]:
name = get_name(node.target)
return [name] if name else []
_node_names_map: dict[type, Callable[[Any], list[str]]] = {
ast.Assign: _get_assign_names,
ast.AnnAssign: _get_annassign_names,
}
def get_names(node: ast.AST) -> list[str]:
"""Extract names from an assignment node.
Parameters:
node: The node to extract names from.
Returns:
A list of names.
"""
return _node_names_map[type(node)](node)
def get_instance_names(node: ast.AST) -> list[str]:
"""Extract names from an assignment node, only for instance attributes.
Parameters:
node: The node to extract names from.
Returns:
A list of names.
"""
return [name.split(".", 1)[1] for name in get_names(node) if name.startswith("self.")]
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/imports.py 0000664 0001750 0001750 00000003513 15246557273 030354 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for working with imports and relative imports.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import ast
from griffe._internal.models import Module
def relative_to_absolute(node: ast.ImportFrom, name: ast.alias, current_module: Module) -> str:
"""Convert a relative import path to an absolute one.
Parameters:
node: The "from ... import ..." AST node.
name: The imported name.
current_module: The module in which the import happens.
Returns:
The absolute import path.
"""
level = node.level
if (level > 0 and current_module.is_package) or current_module.is_subpackage:
level -= 1
while level > 0 and current_module.parent is not None:
current_module = current_module.parent # ty:ignore[invalid-assignment]
level -= 1
base = current_module.path + "." if node.level > 0 else ""
node_module = node.module + "." if node.module else ""
return base + node_module + name.name
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/nodes/values.py 0000664 0001750 0001750 00000004002 15246557273 030150 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for extracting attribute values.
from __future__ import annotations
import ast
from ast import unparse
from typing import TYPE_CHECKING
from griffe._internal.logger import logger
if TYPE_CHECKING:
from pathlib import Path
def get_value(node: ast.AST | None) -> str | None:
"""Get the string representation of a node.
Parameters:
node: The node to represent.
Returns:
The representing code for the node.
"""
if node is None:
return None
return unparse(node)
def safe_get_value(node: ast.AST | None, filepath: str | Path | None = None) -> str | None:
"""Safely (no exception) get the string representation of a node.
Parameters:
node: The node to represent.
filepath: An optional filepath from where the node comes.
Returns:
The representing code for the node.
"""
try:
return get_value(node)
except Exception as error: # noqa: BLE001
message = f"Failed to represent node {node}"
if filepath:
message += f" at {filepath}:{node.lineno}" # ty:ignore[unresolved-attribute]
message += f": {error}"
logger.exception(message)
return None
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/__init__.py 0000664 0001750 0001750 00000001571 15246557273 027310 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# These modules contain the different agents that are able to extract data.
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/inspector.py 0000664 0001750 0001750 00000066322 15246557273 027564 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains our dynamic analysis agent,
# capable of inspecting modules and objects in memory, at runtime.
from __future__ import annotations
import ast
import functools
import types
import typing
from inspect import Parameter as SignatureParameter
from inspect import Signature, cleandoc, getsourcelines, unwrap
from inspect import signature as getsignature
from typing import TYPE_CHECKING, Any
from griffe._internal.agents.nodes.runtime import ObjectNode
from griffe._internal.collections import LinesCollection, ModulesCollection
from griffe._internal.enumerations import Kind, ParameterKind, TypeParameterKind
from griffe._internal.expressions import Expr, ExprBinOp, ExprSubscript, ExprTuple, safe_get_annotation
from griffe._internal.extensions.base import Extensions, load_extensions
from griffe._internal.importer import dynamic_import
from griffe._internal.logger import logger
from griffe._internal.models import (
Alias,
Attribute,
Class,
Docstring,
Function,
Module,
Parameter,
Parameters,
TypeAlias,
TypeParameter,
TypeParameters,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
from griffe._internal.docstrings.parsers import DocstringOptions, DocstringStyle
from griffe._internal.enumerations import Parser
_TYPING_MODULES: tuple[types.ModuleType, ...]
try:
import typing_extensions
except ImportError:
_TYPING_MODULES = (typing,)
else:
_TYPING_MODULES = (typing, typing_extensions)
_empty = Signature.empty
def inspect(
module_name: str,
*,
filepath: Path | None = None,
import_paths: Sequence[str | Path] | None = None,
extensions: Extensions | None = None,
parent: Module | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
) -> Module:
"""Inspect a module.
Sometimes we cannot get the source code of a module or an object,
typically built-in modules like `itertools`.
The only way to know what they are made of is to actually import them and inspect their contents.
Sometimes, even if the source code is available,
loading the object is desired because it was created or modified dynamically,
and our static agent is not powerful enough to infer all these dynamic modifications.
In this case, we load the module using introspection.
Griffe therefore provides this function for dynamic analysis.
It uses a [`NodeVisitor`][ast.NodeVisitor]-like class, the [`Inspector`][griffe.Inspector],
to inspect the module with [`inspect.getmembers()`][inspect.getmembers].
The inspection agent works similarly to the regular [`Visitor`][griffe.Visitor] agent,
in that it maintains a state with the current object being handled, and recursively handle its members.
Important:
This function is generally not used directly.
In most cases, users can rely on the [`GriffeLoader`][griffe.GriffeLoader]
and its accompanying [`load`][griffe.load] shortcut and their respective options
to load modules using dynamic analysis.
Parameters:
module_name: The module name (as when importing [from] it).
filepath: The module file path.
import_paths: Paths to import the module from.
extensions: The extensions to use when inspecting the module.
parent: The optional parent of this module.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
Returns:
The module, with its members populated.
"""
return Inspector(
module_name,
filepath,
extensions or load_extensions(),
parent,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
).get_module(import_paths)
class Inspector:
"""This class is used to instantiate an inspector.
Inspectors iterate on objects members to extract data from them.
"""
def __init__( # noqa: PLR0917
self,
module_name: str,
filepath: Path | None,
extensions: Extensions,
parent: Module | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
) -> None:
"""Initialize the inspector.
Parameters:
module_name: The module name.
filepath: The optional filepath.
extensions: Extensions to use when inspecting.
parent: The module parent.
docstring_parser: The docstring parser to use.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
"""
super().__init__()
self.module_name: str = module_name
"""The module name."""
self.filepath: Path | None = filepath
"""The module file path."""
self.extensions: Extensions = extensions
"""The extensions to use when inspecting."""
self.parent: Module | None = parent
"""An optional parent for the final module object."""
self.current: Module | Class = None # ty:ignore[invalid-assignment]
"""The current object being inspected."""
self.docstring_parser: DocstringStyle | Parser | None = docstring_parser
"""The docstring parser to use."""
self.docstring_options: DocstringOptions = docstring_options or {}
"""The docstring parsing options."""
self.lines_collection: LinesCollection = lines_collection or LinesCollection()
"""A collection of source code lines."""
self.modules_collection: ModulesCollection = modules_collection or ModulesCollection()
"""A collection of modules."""
def _get_docstring(self, node: ObjectNode) -> Docstring | None:
try:
# Access `__doc__` directly to avoid taking the `__doc__` attribute from a parent class.
value = getattr(node.obj, "__doc__", None)
except Exception: # noqa: BLE001
return None
if value is None:
return None
try:
# We avoid `inspect.getdoc` to avoid getting
# the `__doc__` attribute from a parent class,
# but we still want to clean the doc.
cleaned = cleandoc(value)
except AttributeError:
# Triggered on method descriptors.
return None
return Docstring(
cleaned,
parser=self.docstring_parser,
parser_options=self.docstring_options,
)
def _get_linenos(self, node: ObjectNode) -> tuple[int, int] | tuple[None, None]:
# Line numbers won't be useful if we don't have the source code.
if not self.filepath or self.filepath not in self.lines_collection:
return None, None
try:
lines, lineno = getsourcelines(node.obj)
except (OSError, TypeError):
return None, None
return lineno, lineno + "".join(lines).rstrip().count("\n")
def get_module(self, import_paths: Sequence[str | Path] | None = None) -> Module:
"""Build and return the object representing the module attached to this inspector.
This method triggers a complete inspection of the module members.
Parameters:
import_paths: Paths replacing `sys.path` to import the module.
Returns:
A module instance.
"""
import_path = self.module_name
if self.parent is not None:
import_path = f"{self.parent.path}.{import_path}"
# Make sure `import_paths` is a list, in case we want to `insert` into it.
import_paths = list(import_paths or ())
# If the thing we want to import has a filepath,
# we make sure to insert the right parent directory
# at the front of our list of import paths.
# We do this by counting the number of dots `.` in the import path,
# corresponding to slashes `/` in the filesystem,
# and go up in the file tree the same number of times.
if self.filepath:
parent_path = self.filepath.parent
for _ in range(import_path.count(".")):
parent_path = parent_path.parent
# Climb up one more time for `__init__` modules.
if self.filepath.stem == "__init__":
parent_path = parent_path.parent
if parent_path not in import_paths:
import_paths.insert(0, parent_path)
value = dynamic_import(import_path, import_paths)
# We successfully imported the given object,
# and we now create the object tree with all the necessary nodes,
# from the root of the package to this leaf object.
parent_node = None
if self.parent is not None:
for part in self.parent.path.split("."):
parent_node = ObjectNode(None, name=part, parent=parent_node)
module_node = ObjectNode(value, self.module_name, parent=parent_node)
self.inspect(module_node)
return self.current.module
def inspect(self, node: ObjectNode) -> None:
"""Extend the base inspection with extensions.
Parameters:
node: The node to inspect.
"""
getattr(self, f"inspect_{node.kind}", self.generic_inspect)(node)
def generic_inspect(self, node: ObjectNode) -> None:
"""Extend the base generic inspection with extensions.
Parameters:
node: The node to inspect.
"""
for child in node.children:
if target_path := child.alias_target_path:
# If the child is an actual submodule of the current module,
# and has no `__file__` set, we won't find it on the disk so we must inspect it now.
# For that we instantiate a new inspector and use it to inspect the submodule,
# then assign the submodule as member of the current module.
# If the submodule has a `__file__` set, the loader should find it on the disk,
# so we skip it here (no member, no alias, just skip it).
if child.is_module and target_path == f"{self.current.path}.{child.name}":
if not hasattr(child.obj, "__file__"):
logger.debug("Module %s is not discoverable on disk, inspecting right now", target_path)
inspector = Inspector(
child.name,
filepath=None,
parent=self.current.module,
extensions=self.extensions,
docstring_parser=self.docstring_parser,
docstring_options=self.docstring_options,
lines_collection=self.lines_collection,
modules_collection=self.modules_collection,
)
inspector.inspect_module(child)
self.current.set_member(child.name, inspector.current.module)
# Otherwise, alias the object.
else:
alias = Alias(child.name, target_path, analysis="dynamic")
self.current.set_member(child.name, alias)
self.extensions.call("on_alias_instance", alias=alias, node=node, agent=self)
else:
self.inspect(child)
def inspect_module(self, node: ObjectNode) -> None:
"""Inspect a module.
Parameters:
node: The node to inspect.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_module_node", node=node, agent=self)
self.current = module = Module(
name=self.module_name,
filepath=self.filepath,
parent=self.parent,
docstring=self._get_docstring(node),
lines_collection=self.lines_collection,
modules_collection=self.modules_collection,
analysis="dynamic",
)
self.extensions.call("on_instance", node=node, obj=module, agent=self)
self.extensions.call("on_module_instance", node=node, mod=module, agent=self)
self.generic_inspect(node)
self.extensions.call("on_members", node=node, obj=module, agent=self)
self.extensions.call("on_module_members", node=node, mod=module, agent=self)
def inspect_class(self, node: ObjectNode) -> None:
"""Inspect a class.
Parameters:
node: The node to inspect.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_class_node", node=node, agent=self)
bases = []
for base in node.obj.__bases__:
if base is object:
continue
bases.append(f"{base.__module__}.{base.__qualname__}")
lineno, endlineno = self._get_linenos(node)
class_ = Class(
name=node.name,
docstring=self._get_docstring(node),
bases=bases,
type_parameters=TypeParameters(*_convert_type_parameters(node.obj, parent=self.current, member=node.name)),
lineno=lineno,
endlineno=endlineno,
analysis="dynamic",
)
self.current.set_member(node.name, class_)
self.current = class_
self.extensions.call("on_instance", node=node, obj=class_, agent=self)
self.extensions.call("on_class_instance", node=node, cls=class_, agent=self)
self.generic_inspect(node)
self.extensions.call("on_members", node=node, obj=class_, agent=self)
self.extensions.call("on_class_members", node=node, cls=class_, agent=self)
self.current = self.current.parent # ty:ignore[invalid-assignment]
def inspect_staticmethod(self, node: ObjectNode) -> None:
"""Inspect a static method.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"staticmethod"})
def inspect_classmethod(self, node: ObjectNode) -> None:
"""Inspect a class method.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"classmethod"})
def inspect_method_descriptor(self, node: ObjectNode) -> None:
"""Inspect a method descriptor.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"method descriptor"})
def inspect_builtin_method(self, node: ObjectNode) -> None:
"""Inspect a builtin method.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"builtin"})
def inspect_method(self, node: ObjectNode) -> None:
"""Inspect a method.
Parameters:
node: The node to inspect.
"""
self.handle_function(node)
def inspect_coroutine(self, node: ObjectNode) -> None:
"""Inspect a coroutine.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"async"})
def inspect_builtin_function(self, node: ObjectNode) -> None:
"""Inspect a builtin function.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"builtin"})
def inspect_function(self, node: ObjectNode) -> None:
"""Inspect a function.
Parameters:
node: The node to inspect.
"""
self.handle_function(node)
def inspect_cached_property(self, node: ObjectNode) -> None:
"""Inspect a cached property.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"cached", "property"})
def inspect_property(self, node: ObjectNode) -> None:
"""Inspect a property.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"property"})
def inspect_getset_descriptor(self, node: ObjectNode) -> None:
"""Inspect a get/set descriptor.
Parameters:
node: The node to inspect.
"""
self.handle_function(node, {"property"})
def handle_function(self, node: ObjectNode, labels: set | None = None) -> None:
"""Handle a function.
Parameters:
node: The node to inspect.
labels: Labels to add to the data object.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_function_node", node=node, agent=self)
try:
signature = getsignature(node.obj)
except Exception: # noqa: BLE001
# So many exceptions can be raised here:
# AttributeError, NameError, RuntimeError, ValueError, TokenError, TypeError...
parameters = None
returns = None
else:
parameters = Parameters(
*[
_convert_parameter(parameter, parent=self.current, member=node.name)
for parameter in signature.parameters.values()
],
)
return_annotation = signature.return_annotation
returns = (
None
if return_annotation is _empty
else _convert_object_to_annotation(return_annotation, parent=self.current, member=node.name)
)
lineno, endlineno = self._get_linenos(node)
obj: Attribute | Function
if labels and "property" in labels:
obj = Attribute(
name=node.name,
value=None,
annotation=returns,
docstring=self._get_docstring(node),
lineno=lineno,
endlineno=endlineno,
analysis="dynamic",
)
else:
obj = Function(
name=node.name,
parameters=parameters,
returns=returns,
type_parameters=TypeParameters(
*_convert_type_parameters(node.obj, parent=self.current, member=node.name),
),
docstring=self._get_docstring(node),
lineno=lineno,
endlineno=endlineno,
analysis="dynamic",
)
if labels:
obj.labels.update(labels)
self.current.set_member(node.name, obj)
self.extensions.call("on_instance", node=node, obj=obj, agent=self)
if obj.is_attribute:
self.extensions.call("on_attribute_instance", node=node, attr=obj, agent=self)
else:
self.extensions.call("on_function_instance", node=node, func=obj, agent=self)
def inspect_type_alias(self, node: ObjectNode) -> None:
"""Inspect a type alias.
Parameters:
node: The node to inspect.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_type_alias_node", node=node, agent=self)
lineno, endlineno = self._get_linenos(node)
type_alias = TypeAlias(
name=node.name,
value=_convert_type_to_annotation(node.obj.__value__, parent=self.current, member=node.name),
lineno=lineno,
endlineno=endlineno,
type_parameters=TypeParameters(*_convert_type_parameters(node.obj, parent=self.current, member=node.name)),
docstring=self._get_docstring(node),
parent=self.current,
analysis="dynamic",
)
self.current.set_member(node.name, type_alias)
self.extensions.call("on_instance", node=node, obj=type_alias, agent=self)
self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self)
def inspect_attribute(self, node: ObjectNode) -> None:
"""Inspect an attribute.
Parameters:
node: The node to inspect.
"""
self.handle_attribute(node)
def handle_attribute(self, node: ObjectNode, annotation: str | Expr | None = None) -> None:
"""Handle an attribute.
Parameters:
node: The node to inspect.
annotation: A potential annotation.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_attribute_node", node=node, agent=self)
# TODO: To improve.
parent = self.current
labels: set[str] = set()
if parent.kind is Kind.MODULE:
labels.add("module-attribute")
elif parent.kind is Kind.CLASS:
labels.add("class-attribute")
elif parent.kind is Kind.FUNCTION:
if parent.name != "__init__":
return
parent = parent.parent
labels.add("instance-attribute")
try:
value = repr(node.obj)
except Exception: # noqa: BLE001
value = None
try:
docstring = self._get_docstring(node)
except Exception: # noqa: BLE001
docstring = None
attribute = Attribute(
name=node.name,
value=value,
annotation=annotation,
docstring=docstring,
analysis="dynamic",
)
attribute.labels.update(labels)
parent.set_member(node.name, attribute) # ty:ignore[unresolved-attribute]
if node.name == "__all__":
parent.exports = list(node.obj) # ty:ignore[invalid-assignment]
self.extensions.call("on_instance", node=node, obj=attribute, agent=self)
self.extensions.call("on_attribute_instance", node=node, attr=attribute, agent=self)
_parameter_kind_map = {
SignatureParameter.POSITIONAL_ONLY: ParameterKind.positional_only,
SignatureParameter.POSITIONAL_OR_KEYWORD: ParameterKind.positional_or_keyword,
SignatureParameter.VAR_POSITIONAL: ParameterKind.var_positional,
SignatureParameter.KEYWORD_ONLY: ParameterKind.keyword_only,
SignatureParameter.VAR_KEYWORD: ParameterKind.var_keyword,
}
def _convert_parameter(
parameter: SignatureParameter,
*,
parent: Module | Class,
member: str | None = None,
) -> Parameter:
name = parameter.name
annotation = (
None
if parameter.annotation is _empty
else _convert_object_to_annotation(parameter.annotation, parent=parent, member=member)
)
kind = _parameter_kind_map[parameter.kind]
if parameter.default is _empty:
default = None
elif hasattr(parameter.default, "__name__"):
# Avoid `repr` containing chevrons and memory addresses.
default = parameter.default.__name__
else:
default = repr(parameter.default)
return Parameter(name, annotation=annotation, kind=kind, default=default)
def _convert_object_to_annotation(obj: Any, *, parent: Module | Class, member: str | None = None) -> str | Expr | None:
# Even when *we* import future annotations,
# the object from which we get a signature
# can come from modules which did *not* import them,
# so `inspect.signature` returns actual Python objects
# that we must deal with.
if isinstance(obj, str):
annotation = obj
else:
# Always give precedence to the object's representation...
obj_repr = repr(obj)
if hasattr(obj, "__name__"): # noqa: SIM108
# ...unless it contains chevrons (which likely means it's a class),
# in which case we use the object's name.
annotation = obj.__name__ if "<" in obj_repr else obj_repr
else:
annotation = obj_repr
try:
annotation_node = compile(annotation, mode="eval", filename="<>", flags=ast.PyCF_ONLY_AST, optimize=2)
except SyntaxError:
return obj
return safe_get_annotation(annotation_node.body, parent, member=member) # ty:ignore[unresolved-attribute]
_type_parameter_kind_map = {
getattr(module, attr): value
for attr, value in {
"TypeVar": TypeParameterKind.type_var,
"TypeVarTuple": TypeParameterKind.type_var_tuple,
"ParamSpec": TypeParameterKind.param_spec,
}.items()
for module in _TYPING_MODULES
if hasattr(module, attr)
}
def _convert_type_parameters(
obj: Any,
*,
parent: Module | Class,
member: str | None = None,
) -> list[TypeParameter]:
obj = unwrap(obj)
if not hasattr(obj, "__type_params__"):
return []
type_parameters = []
for type_parameter in obj.__type_params__:
bound = getattr(type_parameter, "__bound__", None)
if bound is not None:
bound = _convert_type_to_annotation(bound, parent=parent, member=member)
constraints: list[str | Expr] = [
_convert_type_to_annotation(constraint, parent=parent, member=member)
for constraint in getattr(type_parameter, "__constraints__", ())
] # ty:ignore[invalid-assignment]
if getattr(type_parameter, "has_default", lambda: False)():
default = _convert_type_to_annotation(
type_parameter.__default__,
parent=parent,
member=member,
)
else:
default = None
type_parameters.append(
TypeParameter(
type_parameter.__name__,
kind=_type_parameter_kind_map[type(type_parameter)],
bound=bound,
constraints=constraints or None,
default=default,
),
)
return type_parameters
def _convert_type_to_annotation(obj: Any, *, parent: Module | Class, member: str | None = None) -> str | Expr | None:
origin = typing.get_origin(obj)
if origin is None:
return _convert_object_to_annotation(obj, parent=parent, member=member)
args: list[str | Expr] = [
annotation
for arg in typing.get_args(obj)
if (annotation := _convert_type_to_annotation(arg, parent=parent, member=member)) is not None
]
def reduce(left: str | Expr, right: str | Expr) -> ExprBinOp:
return ExprBinOp(left, "|", right)
if origin is types.UnionType:
return functools.reduce(reduce, args)
origin = _convert_type_to_annotation(origin, parent=parent, member=member)
if origin is None:
return None
return ExprSubscript(origin, ExprTuple(args, implicit=True))
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/agents/visitor.py 0000664 0001750 0001750 00000076403 15246557273 027256 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains our static analysis agent,
# capable of parsing and visiting sources, statically.
from __future__ import annotations
import ast
import sys
from contextlib import suppress
from typing import TYPE_CHECKING, Final
from griffe._internal.agents.nodes.assignments import get_instance_names, get_names
from griffe._internal.agents.nodes.ast import ast_children, ast_next
from griffe._internal.agents.nodes.docstrings import get_docstring
from griffe._internal.agents.nodes.exports import safe_get__all__
from griffe._internal.agents.nodes.imports import relative_to_absolute
from griffe._internal.agents.nodes.parameters import get_parameters
from griffe._internal.collections import LinesCollection, ModulesCollection
from griffe._internal.enumerations import Kind, TypeParameterKind
from griffe._internal.exceptions import AliasResolutionError, CyclicAliasError, LastNodeError
from griffe._internal.expressions import (
Expr,
ExprName,
safe_get_annotation,
safe_get_base_class,
safe_get_class_keyword,
safe_get_condition,
safe_get_expression,
)
from griffe._internal.extensions.base import Extensions, load_extensions
from griffe._internal.models import (
Alias,
Attribute,
Class,
Decorator,
Docstring,
Function,
Module,
Parameter,
Parameters,
TypeAlias,
TypeParameter,
TypeParameters,
)
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from griffe._internal.docstrings.parsers import DocstringOptions, DocstringStyle
from griffe._internal.enumerations import Parser
builtin_decorators = {
"property": "property",
"staticmethod": "staticmethod",
"classmethod": "classmethod",
}
"""Mapping of builtin decorators to labels."""
stdlib_decorators = {
"abc.abstractmethod": {"abstractmethod"},
"functools.cache": {"cached"},
"functools.cached_property": {"cached", "property"},
"cached_property.cached_property": {"cached", "property"},
"functools.lru_cache": {"cached"},
"dataclasses.dataclass": {"dataclass"},
}
"""Mapping of standard library decorators to labels."""
typing_overload = {"typing.overload", "typing_extensions.overload"}
"""Set of recognized typing overload decorators.
When such a decorator is found, the decorated function becomes an overload.
"""
def visit(
module_name: str,
filepath: Path,
code: str,
*,
extensions: Extensions | None = None,
parent: Module | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
) -> Module:
"""Parse and visit a module file.
We provide this function for static analysis. It uses a [`NodeVisitor`][ast.NodeVisitor]-like class,
the [`Visitor`][griffe.Visitor], to compile and parse code (using [`compile`][])
then visit the resulting AST (Abstract Syntax Tree).
Important:
This function is generally not used directly.
In most cases, users can rely on the [`GriffeLoader`][griffe.GriffeLoader]
and its accompanying [`load`][griffe.load] shortcut and their respective options
to load modules using static analysis.
Parameters:
module_name: The module name (as when importing [from] it).
filepath: The module file path.
code: The module contents.
extensions: The extensions to use when visiting the AST.
parent: The optional parent of this module.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
Returns:
The module, with its members populated.
"""
return Visitor(
module_name,
filepath,
code,
extensions or load_extensions(),
parent,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
).get_module()
class Visitor:
"""This class is used to instantiate a visitor.
Visitors iterate on AST nodes to extract data from them.
"""
def __init__( # noqa: PLR0917
self,
module_name: str,
filepath: Path,
code: str,
extensions: Extensions,
parent: Module | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
) -> None:
"""Initialize the visitor.
Parameters:
module_name: The module name.
filepath: The module filepath.
code: The module source code.
extensions: The extensions to use when visiting.
parent: An optional parent for the final module object.
docstring_parser: The docstring parser to use.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
"""
super().__init__()
self.module_name: str = module_name
"""The module name."""
self.filepath: Path = filepath
"""The module filepath."""
self.code: str = code
"""The module source code."""
self.extensions: Extensions = extensions
"""The extensions to use when visiting the AST."""
self.parent: Module | None = parent
"""An optional parent for the final module object."""
self.current: Module | Class = None # ty:ignore[invalid-assignment]
"""The current object being visited."""
self.docstring_parser: DocstringStyle | Parser | None = docstring_parser
"""The docstring parser to use."""
self.docstring_options: DocstringOptions = docstring_options or {}
"""The docstring parsing options."""
self.lines_collection: LinesCollection = lines_collection or LinesCollection()
"""A collection of source code lines."""
self.modules_collection: ModulesCollection = modules_collection or ModulesCollection()
"""A collection of modules."""
self.type_guarded: bool = False
"""Whether the current code branch is type-guarded."""
self._visited_nodes: list[ast.AST] = []
def _get_docstring(self, node: ast.AST, *, strict: bool = False) -> Docstring | None:
value, lineno, endlineno = get_docstring(node, strict=strict)
if value is None:
return None
return Docstring(
value,
lineno=lineno,
endlineno=endlineno,
parser=self.docstring_parser,
parser_options=self.docstring_options,
)
# YORE: EOL 3.11: Replace block with lines 2-36.
if sys.version_info >= (3, 12):
_type_parameter_kind_map: Final[dict[type[ast.type_param], TypeParameterKind]] = {
ast.TypeVar: TypeParameterKind.type_var,
ast.TypeVarTuple: TypeParameterKind.type_var_tuple,
ast.ParamSpec: TypeParameterKind.param_spec,
}
def _get_type_parameters(
self,
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.TypeAlias,
*,
scope: str | None = None,
) -> list[TypeParameter]:
return [
TypeParameter(
type_param.name, # ty:ignore[unresolved-attribute,unused-ignore-comment,unused-ignore-comment]
kind=self._type_parameter_kind_map[type(type_param)],
bound=safe_get_annotation(getattr(type_param, "bound", None), parent=self.current, member=scope),
default=safe_get_annotation(
getattr(type_param, "default_value", None),
parent=self.current,
member=scope,
),
)
for type_param in node.type_params # ty:ignore[unresolved-attribute,unused-ignore-comment,unused-ignore-comment]
]
else:
def _get_type_parameters(
self,
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, # noqa: ARG002,
*,
scope: str | None = None, # noqa: ARG002,
) -> list[TypeParameter]:
return []
def get_module(self) -> Module:
"""Build and return the object representing the module attached to this visitor.
This method triggers a complete visit of the module nodes.
Returns:
A module instance.
"""
self._visited_nodes.clear()
# Optimization: equivalent to `ast.parse`, but with `optimize=1` to remove assert statements.
# TODO: With options, could use `optimize=2` to remove docstrings.
top_node = compile(self.code, mode="exec", filename=str(self.filepath), flags=ast.PyCF_ONLY_AST, optimize=1)
try:
self.visit(top_node)
return self.current.module
finally:
# Parent links are only needed during analysis. Detach them once the
# visit is over so parsed trees can be freed without cyclic GC.
for node in self._visited_nodes:
vars(node).pop("parent", None)
self._visited_nodes.clear()
def visit(self, node: ast.AST) -> None:
"""Extend the base visit with extensions.
Parameters:
node: The node to visit.
"""
self._visited_nodes.append(node)
self._visitor.get(type(node), type(self).generic_visit)(self, node)
def generic_visit(self, node: ast.AST) -> None:
"""Extend the base generic visit with extensions.
Parameters:
node: The node to visit.
"""
for child in ast_children(node):
self.visit(child)
def visit_module(self, node: ast.Module) -> None:
"""Visit a module node.
Parameters:
node: The node to visit.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_module_node", node=node, agent=self)
self.current = module = Module(
name=self.module_name,
filepath=self.filepath,
parent=self.parent,
docstring=self._get_docstring(node),
lines_collection=self.lines_collection,
modules_collection=self.modules_collection,
analysis="static",
)
self.extensions.call("on_instance", node=node, obj=module, agent=self)
self.extensions.call("on_module_instance", node=node, mod=module, agent=self)
self.generic_visit(node)
self.extensions.call("on_members", node=node, obj=module, agent=self)
self.extensions.call("on_module_members", node=node, mod=module, agent=self)
def visit_classdef(self, node: ast.ClassDef) -> None:
"""Visit a class definition node.
Parameters:
node: The node to visit.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_class_node", node=node, agent=self)
# Handle decorators.
decorators: list[Decorator] = []
if node.decorator_list:
lineno = node.decorator_list[0].lineno
decorators.extend(
Decorator(
safe_get_expression(decorator_node, parent=self.current, parse_strings=False), # ty:ignore[invalid-argument-type]
lineno=decorator_node.lineno,
endlineno=decorator_node.end_lineno,
)
for decorator_node in node.decorator_list
)
else:
lineno = node.lineno
# Handle base classes and keywords.
bases = [safe_get_base_class(base, parent=self.current, member=node.name) for base in node.bases]
keywords = {
kw.arg: safe_get_class_keyword(kw.value, parent=self.current) for kw in node.keywords if kw.arg is not None
}
class_ = Class(
name=node.name,
lineno=lineno,
endlineno=node.end_lineno,
docstring=self._get_docstring(node),
decorators=decorators,
type_parameters=TypeParameters(*self._get_type_parameters(node, scope=node.name)),
bases=bases, # ty:ignore[invalid-argument-type]
keywords=keywords,
runtime=not self.type_guarded,
analysis="static",
)
if decorators and (labels := self.decorators_to_labels(decorators)):
class_.labels.update(labels)
self.current.set_member(node.name, class_)
self.current = class_
self.extensions.call("on_instance", node=node, obj=class_, agent=self)
self.extensions.call("on_class_instance", node=node, cls=class_, agent=self)
self.generic_visit(node)
self.extensions.call("on_members", node=node, obj=class_, agent=self)
self.extensions.call("on_class_members", node=node, cls=class_, agent=self)
self.current = self.current.parent # ty:ignore[invalid-assignment]
def decorators_to_labels(self, decorators: list[Decorator]) -> set[str]:
"""Build and return a set of labels based on decorators.
Parameters:
decorators: The decorators to check.
Returns:
A set of labels.
"""
labels = set()
for decorator in decorators:
callable_path = decorator.callable_path
if callable_path in builtin_decorators:
labels.add(builtin_decorators[callable_path])
elif callable_path in stdlib_decorators:
labels |= stdlib_decorators[callable_path]
return labels
def get_base_property(self, decorators: list[Decorator], function: Function) -> str | None:
"""Check decorators to return the base property in case of setters and deleters.
Parameters:
decorators: The decorators to check.
Returns:
base_property: The property for which the setter/deleted is set.
property_function: Either `"setter"` or `"deleter"`.
"""
for decorator in decorators:
try:
path, prop_function = decorator.callable_path.rsplit(".", 1)
except ValueError:
continue
property_setter_or_deleter = (
prop_function in {"setter", "deleter"}
and path == function.path
and self.current.get_member(function.name).has_labels("property")
)
if property_setter_or_deleter:
return prop_function
return None
def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: set | None = None) -> None:
"""Handle a function definition node.
Parameters:
node: The node to visit.
labels: Labels to add to the data object.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_function_node", node=node, agent=self)
# Handle decorators.
decorators = []
overload = False
if node.decorator_list:
lineno = node.decorator_list[0].lineno
for decorator_node in node.decorator_list:
decorator_value = safe_get_expression(decorator_node, parent=self.current, parse_strings=False)
if decorator_value is None:
continue
decorator = Decorator(
decorator_value,
lineno=decorator_node.lineno,
endlineno=decorator_node.end_lineno,
)
decorators.append(decorator)
overload |= decorator.callable_path in typing_overload
else:
lineno = node.lineno
if decorators and (decorator_labels := self.decorators_to_labels(decorators)):
if labels:
labels.update(decorator_labels)
else:
labels = decorator_labels
if labels and "property" in labels:
attribute = Attribute(
name=node.name,
value=None,
annotation=safe_get_annotation(node.returns, parent=self.current, member=node.name),
lineno=node.lineno,
endlineno=node.end_lineno,
docstring=self._get_docstring(node),
runtime=not self.type_guarded,
analysis="static",
)
attribute.labels.update(labels)
self.current.set_member(node.name, attribute)
self.extensions.call("on_instance", node=node, obj=attribute, agent=self)
self.extensions.call("on_attribute_instance", node=node, attr=attribute, agent=self)
return
# Handle parameters.
parameters = Parameters(
*[
Parameter(
name,
kind=kind,
annotation=safe_get_annotation(annotation, parent=self.current, member=node.name),
default=default
if isinstance(default, str)
else safe_get_expression(default, parent=self.current, parse_strings=False),
)
for name, annotation, kind, default in get_parameters(node.args)
],
)
function = Function(
name=node.name,
lineno=lineno,
endlineno=node.end_lineno,
parameters=parameters,
returns=safe_get_annotation(node.returns, parent=self.current, member=node.name),
decorators=decorators,
type_parameters=TypeParameters(*self._get_type_parameters(node, scope=node.name)),
docstring=self._get_docstring(node),
runtime=not self.type_guarded,
parent=self.current,
analysis="static",
)
property_function = self.get_base_property(decorators, function)
if overload:
self.current.overloads[function.name].append(function)
elif property_function:
base_property: Attribute = self.current.members[node.name] # ty:ignore[invalid-assignment]
if property_function == "setter":
base_property.setter = function
base_property.labels.add("writable")
elif property_function == "deleter":
base_property.deleter = function
base_property.labels.add("deletable")
else:
self.current.set_member(node.name, function)
if self.current.kind in {Kind.MODULE, Kind.CLASS} and self.current.overloads[function.name]:
function.overloads = self.current.overloads[function.name]
del self.current.overloads[function.name]
if labels:
function.labels.update(labels)
self.extensions.call("on_instance", node=node, obj=function, agent=self)
self.extensions.call("on_function_instance", node=node, func=function, agent=self)
if self.current.kind is Kind.CLASS and function.name == "__init__":
self.current = function # ty:ignore[invalid-assignment]
self.generic_visit(node)
self.current = self.current.parent # ty:ignore[invalid-assignment]
def visit_functiondef(self, node: ast.FunctionDef) -> None:
"""Visit a function definition node.
Parameters:
node: The node to visit.
"""
self.handle_function(node)
def visit_asyncfunctiondef(self, node: ast.AsyncFunctionDef) -> None:
"""Visit an async function definition node.
Parameters:
node: The node to visit.
"""
self.handle_function(node, labels={"async"})
# YORE: EOL 3.11: Replace block with lines 2-36.
if sys.version_info >= (3, 12):
def visit_typealias(self, node: ast.TypeAlias) -> None:
"""Visit a type alias node.
Parameters:
node: The node to visit.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_type_alias_node", node=node, agent=self)
# A type alias's name attribute is syntactically a single NAME,
# but represented as an expression in the AST.
# https://jellezijlstra.github.io/pep695#ast
name = node.name.id
value = safe_get_expression(node.value, parent=self.current, member=name)
try:
docstring = self._get_docstring(ast_next(node), strict=True)
except (LastNodeError, AttributeError):
docstring = None
type_alias = TypeAlias(
name=name,
value=value,
lineno=node.lineno,
endlineno=node.end_lineno,
type_parameters=TypeParameters(*self._get_type_parameters(node, scope=name)),
docstring=docstring,
parent=self.current,
analysis="static",
)
self.current.set_member(name, type_alias)
self.extensions.call("on_instance", node=node, obj=type_alias, agent=self)
self.extensions.call("on_type_alias_instance", node=node, type_alias=type_alias, agent=self)
def visit_import(self, node: ast.Import) -> None:
"""Visit an import node.
Parameters:
node: The node to visit.
"""
for name in node.names:
alias_path = name.name if name.asname else name.name.split(".", 1)[0]
alias_name = name.asname or alias_path.split(".", 1)[0]
self.current.imports[alias_name] = alias_path
alias = Alias(
alias_name,
alias_path,
lineno=node.lineno,
endlineno=node.end_lineno,
runtime=not self.type_guarded,
analysis="static",
)
self.current.set_member(alias_name, alias)
self.extensions.call("on_alias_instance", alias=alias, node=node, agent=self)
def visit_importfrom(self, node: ast.ImportFrom) -> None:
"""Visit an "import from" node.
Parameters:
node: The node to visit.
"""
for name in node.names:
if not node.module and node.level == 1 and not name.asname and self.current.module.is_init_module:
# Special case: when being in `a/__init__.py` and doing `from . import b`,
# we are effectively creating a member `b` in `a` that is pointing to `a.b`
# -> cyclic alias! In that case, we just skip it, as both the member and module
# have the same name and can be accessed the same way.
continue
alias_path = relative_to_absolute(node, name, self.current.module)
if name.name == "*":
alias_name = alias_path.replace(".", "/")
alias_path = alias_path.replace(".*", "")
else:
alias_name = name.asname or name.name
self.current.imports[alias_name] = alias_path
# Do not create aliases pointing to themselves (it happens with
# `from package.current_module import Thing as Thing` or
# `from . import thing as thing`).
if alias_path != f"{self.current.path}.{alias_name}":
alias = Alias(
alias_name,
alias_path,
lineno=node.lineno,
endlineno=node.end_lineno,
runtime=not self.type_guarded,
analysis="static",
)
self.current.set_member(alias_name, alias)
self.extensions.call("on_alias_instance", alias=alias, node=node, agent=self)
def handle_attribute(
self,
node: ast.Assign | ast.AnnAssign,
annotation: str | Expr | None = None,
) -> None:
"""Handle an attribute (assignment) node.
Parameters:
node: The node to visit.
annotation: A potential annotation.
"""
self.extensions.call("on_node", node=node, agent=self)
self.extensions.call("on_attribute_node", node=node, agent=self)
parent = self.current
labels = set()
names = None
if parent.kind is Kind.MODULE:
try:
names = get_names(node)
except KeyError: # Unsupported nodes, like subscript.
return
labels.add("module-attribute")
elif parent.kind is Kind.CLASS:
try:
names = get_names(node)
except KeyError: # Unsupported nodes, like subscript.
return
if isinstance(annotation, Expr) and annotation.is_classvar:
# Explicit `ClassVar`: class attribute only.
annotation = annotation.slice # ty:ignore[unresolved-attribute]
labels.add("class-attribute")
elif node.value:
# Attribute assigned at class-level: available in instances as well.
labels.add("class-attribute")
labels.add("instance-attribute")
else:
# Annotated attribute only: not available at class-level.
labels.add("instance-attribute")
elif parent.kind is Kind.FUNCTION:
if parent.name != "__init__":
return
try:
names = get_instance_names(node)
except KeyError: # Unsupported nodes, like subscript.
return
parent = parent.parent
if parent is None:
return
labels.add("instance-attribute")
if not names:
return
value = safe_get_expression(node.value, parent=self.current, parse_strings=False)
try:
docstring = self._get_docstring(ast_next(node), strict=True)
except (LastNodeError, AttributeError):
docstring = None
for name in names:
# TODO: Handle assigns like `x.y = z`.
# We need to resolve `x.y` and add `z` in its members.
if "." in name:
continue
if name in parent.members:
# Assigning multiple times.
# TODO: Might be better to inspect.
if isinstance(node.parent, (ast.If, ast.ExceptHandler)): # ty:ignore[unresolved-attribute]
continue # Prefer "no-exception" case.
existing_member = parent.members[name]
with suppress(AliasResolutionError, CyclicAliasError):
labels |= existing_member.labels
# Forward previous docstring and annotation instead of erasing them.
if existing_member.docstring and not docstring:
docstring = existing_member.docstring
with suppress(AttributeError):
if existing_member.annotation and not annotation: # ty:ignore[unresolved-attribute]
annotation = existing_member.annotation # ty:ignore[unresolved-attribute]
attribute = Attribute(
name=name,
value=value,
annotation=annotation,
lineno=node.lineno,
endlineno=node.end_lineno,
docstring=docstring,
runtime=not self.type_guarded,
analysis="static",
)
attribute.labels.update(labels)
parent.set_member(name, attribute)
if name == "__all__":
with suppress(AttributeError):
parent.exports = [
name if isinstance(name, str) else ExprName(name.name, parent=name.parent)
for name in safe_get__all__(node, self.current) # ty:ignore[invalid-argument-type]
]
self.extensions.call("on_instance", node=node, obj=attribute, agent=self)
self.extensions.call("on_attribute_instance", node=node, attr=attribute, agent=self)
def visit_assign(self, node: ast.Assign) -> None:
"""Visit an assignment node.
Parameters:
node: The node to visit.
"""
self.handle_attribute(node)
def visit_annassign(self, node: ast.AnnAssign) -> None:
"""Visit an annotated assignment node.
Parameters:
node: The node to visit.
"""
self.handle_attribute(node, safe_get_annotation(node.annotation, parent=self.current))
def visit_augassign(self, node: ast.AugAssign) -> None:
"""Visit an augmented assignment node.
Parameters:
node: The node to visit.
"""
with suppress(AttributeError):
all_augment = (
node.target.id == "__all__" # ty:ignore[unresolved-attribute]
and self.current.is_module
and isinstance(node.op, ast.Add)
)
if all_augment:
# We assume `exports` is not `None` at this point.
self.current.exports.extend( # ty:ignore[unresolved-attribute]
[
name if isinstance(name, str) else ExprName(name.name, parent=name.parent)
for name in safe_get__all__(node, self.current) # ty:ignore[invalid-argument-type]
],
)
def visit_if(self, node: ast.If) -> None:
"""Visit an "if" node.
Parameters:
node: The node to visit.
"""
if isinstance(node.parent, (ast.Module, ast.ClassDef)): # ty:ignore[unresolved-attribute]
condition = safe_get_condition(node.test, parent=self.current, log_level=None)
if str(condition) in {"typing.TYPE_CHECKING", "TYPE_CHECKING"}:
self.type_guarded = True
self.generic_visit(node)
self.type_guarded = False
_visitor: dict[type[ast.AST], Callable] = { # noqa: RUF012
ast.Module: visit_module,
ast.ClassDef: visit_classdef,
ast.FunctionDef: visit_functiondef,
ast.AsyncFunctionDef: visit_asyncfunctiondef,
ast.Import: visit_import,
ast.ImportFrom: visit_importfrom,
ast.Assign: visit_assign,
ast.AnnAssign: visit_annassign,
ast.AugAssign: visit_augassign,
ast.If: visit_if,
}
# YORE: EOL 3.11: Replace block with line 1.
if sys.version_info >= (3, 12):
_visitor[ast.TypeAlias] = visit_typealias
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/encoders.py 0000664 0001750 0001750 00000034256 15246557273 026100 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains data encoders/serializers and decoders/deserializers.
# We only support JSON for now, but might want to add more formats in the future.
from __future__ import annotations
import json
from pathlib import Path, PosixPath, WindowsPath
from typing import TYPE_CHECKING, Any, cast
from griffe._internal import expressions
from griffe._internal.enumerations import Kind, ParameterKind, TypeParameterKind
from griffe._internal.git import GitInfo
from griffe._internal.models import (
Alias,
Attribute,
Class,
Decorator,
Docstring,
Function,
Module,
Object,
Parameter,
Parameters,
TypeAlias,
TypeParameter,
TypeParameters,
)
if TYPE_CHECKING:
from collections.abc import Callable
_expression_class_map: dict[str, type[expressions.Expr]] = {
name: value
for name, value in vars(expressions).items()
if isinstance(value, type) and issubclass(value, expressions.Expr)
}
_parameter_kinds = frozenset(ParameterKind.__members__.values())
_type_parameter_kinds = frozenset(TypeParameterKind.__members__.values())
_json_encoder_map: dict[type, Callable[[Any], Any]] = {
Path: str,
PosixPath: str,
WindowsPath: str,
set: sorted,
}
class JSONEncoder(json.JSONEncoder):
"""JSON encoder.
JSON encoders can be used directly, or through
the [`json.dump`][] or [`json.dumps`][] methods.
Examples:
>>> from griffe import JSONEncoder
>>> JSONEncoder(full=True).encode(..., **kwargs)
>>> import json
>>> from griffe import JSONEncoder
>>> json.dumps(..., cls=JSONEncoder, full=True, **kwargs)
"""
def __init__(
self,
*args: Any,
full: bool = False,
**kwargs: Any,
) -> None:
"""Initialize the encoder.
Parameters:
*args: See [`json.JSONEncoder`][].
full: Whether to dump full data or base data.
If you plan to reload the data in Python memory
using the [`json_decoder`][griffe.json_decoder],
you don't need the full data as it can be inferred again
using the base data. If you want to feed a non-Python
tool instead, dump the full data.
**kwargs: See [`json.JSONEncoder`][].
"""
super().__init__(*args, **kwargs)
self.full: bool = full
"""Whether to dump full data or base data."""
def default(self, obj: Any) -> Any: # ty:ignore[invalid-method-override]
"""Return a serializable representation of the given object.
Parameters:
obj: The object to serialize.
Returns:
A serializable representation.
"""
try:
return obj.as_dict(full=self.full)
except AttributeError:
return _json_encoder_map.get(type(obj), super().default)(obj)
def _load_docstring(obj_dict: dict) -> Docstring | None:
if "docstring" in obj_dict:
return Docstring(**obj_dict["docstring"])
return None
def _load_decorators(obj_dict: dict) -> list[Decorator]:
return [Decorator(**dec) for dec in obj_dict.get("decorators", [])]
def _load_expression(expression: dict) -> expressions.Expr:
# The expression class name is stored in the `cls` key-value.
cls = _expression_class_map[expression.pop("cls")]
expr = cls(**expression)
# For attributes, we need to re-attach names (`values`) together,
# as a single linked list, from right to left:
# in `a.b.c`, `c` links to `b` which links to `a`.
# In `(a or b).c` however, `c` does not link to `(a or b)`,
# as `(a or b)` is not a name and wouldn't allow to resolve `c`.
if cls is expressions.ExprAttribute:
previous = None
for value in cast("expressions.ExprAttribute", expr).values:
if previous is not None:
cast("expressions.ExprName", value).parent = previous
if isinstance(value, expressions.ExprName):
previous = value
return expr
def _load_parameter(obj_dict: dict[str, Any]) -> Parameter:
return Parameter(
obj_dict["name"],
annotation=obj_dict["annotation"],
kind=ParameterKind(obj_dict["kind"]),
default=obj_dict["default"],
docstring=_load_docstring(obj_dict),
)
def _load_type_parameter(obj_dict: dict[str, Any]) -> TypeParameter:
return TypeParameter(
obj_dict["name"],
kind=TypeParameterKind(obj_dict["kind"]),
bound=obj_dict["annotation"],
default=obj_dict["default"],
)
def _load_git_info(obj_dict: dict[str, Any]) -> GitInfo:
return GitInfo(
repository=Path(obj_dict["repository"]),
service=obj_dict["service"],
remote_url=obj_dict["remote_url"],
commit_hash=obj_dict["commit_hash"],
)
def _attach_parent_to_expr(expr: expressions.Expr | str | None, parent: Module | Class) -> None:
if not isinstance(expr, expressions.Expr):
return
for elem in expr:
if isinstance(elem, expressions.ExprName):
elem.parent = parent
elif isinstance(elem, expressions.ExprAttribute) and isinstance(elem.first, expressions.ExprName):
elem.first.parent = parent
def _attach_parent_to_exprs(obj: Class | Function | Attribute | TypeAlias, parent: Module | Class) -> None:
# Every name and attribute expression must be reattached
# to its parent Griffe object (using its `parent` attribute),
# to allow resolving names.
if isinstance(obj, Class):
if obj.docstring:
_attach_parent_to_expr(obj.docstring.value, parent)
for decorator in obj.decorators:
_attach_parent_to_expr(decorator.value, parent)
for type_parameter in obj.type_parameters:
_attach_parent_to_expr(type_parameter.annotation, parent)
_attach_parent_to_expr(type_parameter.default, parent)
elif isinstance(obj, Function):
if obj.docstring:
_attach_parent_to_expr(obj.docstring.value, parent)
for decorator in obj.decorators:
_attach_parent_to_expr(decorator.value, parent)
for type_parameter in obj.type_parameters:
_attach_parent_to_expr(type_parameter.annotation, parent)
_attach_parent_to_expr(type_parameter.default, parent)
for param in obj.parameters:
_attach_parent_to_expr(param.annotation, parent)
_attach_parent_to_expr(param.default, parent)
_attach_parent_to_expr(obj.returns, parent)
elif isinstance(obj, Attribute):
if obj.docstring:
_attach_parent_to_expr(obj.docstring.value, parent)
_attach_parent_to_expr(obj.value, parent)
elif isinstance(obj, TypeAlias):
if obj.docstring:
_attach_parent_to_expr(obj.docstring.value, parent)
for type_parameter in obj.type_parameters:
_attach_parent_to_expr(type_parameter.annotation, parent)
_attach_parent_to_expr(type_parameter.default, parent)
_attach_parent_to_expr(obj.value, parent)
def _load_module(obj_dict: dict[str, Any]) -> Module:
filepath = obj_dict.get("filepath")
if filepath is not None:
filepath: Path | list[Path] = [*map(Path, filepath)] if isinstance(filepath, list) else Path(filepath)
module = Module(
name=obj_dict["name"],
filepath=filepath,
docstring=_load_docstring(obj_dict),
runtime=obj_dict.get("runtime", True),
analysis=obj_dict.get("analysis"),
)
members = obj_dict.get("members", {}).values()
for module_member in members:
module.set_member(module_member.name, module_member)
_attach_parent_to_exprs(module_member, module)
if labels := obj_dict.get("labels"):
module.labels.update(labels)
module.exports = obj_dict.get("exports")
if imports := obj_dict.get("imports"):
module.imports = imports
module.deprecated = obj_dict.get("deprecated")
module.public = obj_dict.get("public")
module.source_link = obj_dict.get("source_link")
if git_info := obj_dict.get("git_info"):
module.git_info = _load_git_info(git_info)
return module
def _load_class(obj_dict: dict[str, Any]) -> Class:
class_ = Class(
name=obj_dict["name"],
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno"),
docstring=_load_docstring(obj_dict),
decorators=_load_decorators(obj_dict),
type_parameters=TypeParameters(*obj_dict["type_parameters"]) if "type_parameters" in obj_dict else None,
bases=obj_dict["bases"],
runtime=obj_dict.get("runtime", True),
analysis=obj_dict.get("analysis"),
)
members = obj_dict.get("members", {}).values()
for class_member in members:
class_.set_member(class_member.name, class_member)
_attach_parent_to_exprs(class_member, class_)
if labels := obj_dict.get("labels"):
class_.labels.update(labels)
if imports := obj_dict.get("imports"):
class_.imports = imports
class_.deprecated = obj_dict.get("deprecated")
class_.public = obj_dict.get("public")
class_.source_link = obj_dict.get("source_link")
_attach_parent_to_exprs(class_, class_)
if git_info := obj_dict.get("git_info"):
class_.git_info = _load_git_info(git_info)
return class_
def _load_function(obj_dict: dict[str, Any]) -> Function:
function = Function(
name=obj_dict["name"],
parameters=Parameters(*obj_dict["parameters"]),
returns=obj_dict["returns"],
decorators=_load_decorators(obj_dict),
type_parameters=TypeParameters(*obj_dict["type_parameters"]) if "type_parameters" in obj_dict else None,
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno"),
docstring=_load_docstring(obj_dict),
runtime=obj_dict.get("runtime", True),
analysis=obj_dict.get("analysis"),
)
if labels := obj_dict.get("labels"):
function.labels.update(labels)
function.deprecated = obj_dict.get("deprecated")
function.public = obj_dict.get("public")
function.source_link = obj_dict.get("source_link")
if git_info := obj_dict.get("git_info"):
function.git_info = _load_git_info(git_info)
return function
def _load_attribute(obj_dict: dict[str, Any]) -> Attribute:
attribute = Attribute(
name=obj_dict["name"],
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno"),
docstring=_load_docstring(obj_dict),
value=obj_dict.get("value"),
annotation=obj_dict.get("annotation"),
analysis=obj_dict.get("analysis"),
)
if labels := obj_dict.get("labels"):
attribute.labels.update(labels)
attribute.runtime = obj_dict.get("runtime", True)
attribute.deprecated = obj_dict.get("deprecated")
attribute.public = obj_dict.get("public")
attribute.source_link = obj_dict.get("source_link")
if git_info := obj_dict.get("git_info"):
attribute.git_info = _load_git_info(git_info)
return attribute
def _load_alias(obj_dict: dict[str, Any]) -> Alias:
alias = Alias(
name=obj_dict["name"],
target=obj_dict["target_path"],
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno"),
runtime=obj_dict.get("runtime", True),
inherited=obj_dict.get("inherited", False),
analysis=obj_dict.get("analysis"),
)
alias.public = obj_dict.get("public")
alias.deprecated = obj_dict.get("deprecated")
return alias
def _load_type_alias(obj_dict: dict[str, Any]) -> TypeAlias:
type_alias = TypeAlias(
name=obj_dict["name"],
value=obj_dict["value"],
type_parameters=TypeParameters(*obj_dict["type_parameters"]),
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno"),
docstring=_load_docstring(obj_dict),
analysis=obj_dict.get("analysis"),
)
if labels := obj_dict.get("labels"):
type_alias.labels.update(labels)
type_alias.runtime = obj_dict.get("runtime", True)
type_alias.deprecated = obj_dict.get("deprecated")
type_alias.public = obj_dict.get("public")
type_alias.source_link = obj_dict.get("source_link")
if git_info := obj_dict.get("git_info"):
type_alias.git_info = _load_git_info(git_info)
return type_alias
_loader_map: dict[Kind, Callable[[dict[str, Any]], Object | Alias]] = {
Kind.MODULE: _load_module,
Kind.CLASS: _load_class,
Kind.FUNCTION: _load_function,
Kind.ATTRIBUTE: _load_attribute,
Kind.ALIAS: _load_alias,
Kind.TYPE_ALIAS: _load_type_alias,
}
def json_decoder(
obj_dict: dict[str, Any],
) -> dict[str, Any] | Object | Alias | Parameter | TypeParameter | str | expressions.Expr:
"""Decode dictionaries as data classes.
The [`json.loads`][] method walks the tree from bottom to top.
Examples:
>>> import json
>>> from griffe import json_decoder
>>> json.loads(..., object_hook=json_decoder)
Parameters:
obj_dict: The dictionary to decode.
Returns:
An instance of a data class.
"""
# Load expressions.
if "cls" in obj_dict:
return _load_expression(obj_dict)
# Load objects and parameters.
if "kind" in obj_dict:
kind = obj_dict["kind"]
if loader := _loader_map.get(kind):
return loader(obj_dict)
if kind in _parameter_kinds:
return _load_parameter(obj_dict)
if kind in _type_parameter_kinds:
return _load_type_parameter(obj_dict)
# Return dict as is.
return obj_dict
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/helpers.py 0000664 0001750 0001750 00000043376 15246557273 025743 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains helpers. They simplify programmatic use of Griffe,
# for example to load data from strings or to create temporary packages.
# They are particularly useful for our own tests suite.
from __future__ import annotations
import sys
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from importlib import invalidate_caches
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING
from griffe._internal.agents.inspector import inspect
from griffe._internal.agents.visitor import visit
from griffe._internal.collections import LinesCollection
from griffe._internal.loader import load
from griffe._internal.models import Module, Object
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence
from griffe._internal.collections import ModulesCollection
from griffe._internal.docstrings.parsers import DocstringOptions, DocstringStyle
from griffe._internal.enumerations import Parser
from griffe._internal.extensions.base import Extensions
_TMPDIR_PREFIX = "griffe_"
@dataclass
class TmpPackage:
"""A temporary package.
The `tmpdir` and `path` parameters can be passed as relative path.
They will be resolved to absolute paths after initialization.
"""
tmpdir: Path
"""The temporary directory containing the package."""
name: str
"""The package name, as to dynamically import it."""
path: Path
"""The package path."""
def __post_init__(self) -> None:
self.tmpdir = self.tmpdir.resolve()
self.path = self.path.resolve()
@contextmanager
def temporary_pyfile(code: str, *, module_name: str = "module") -> Iterator[tuple[str, Path]]:
"""Create a Python file containing the given code in a temporary directory.
Parameters:
code: The code to write to the temporary file.
module_name: The name of the temporary module.
Yields:
module_name: The module name, as to dynamically import it.
module_path: The module path.
"""
with tempfile.TemporaryDirectory(prefix=_TMPDIR_PREFIX) as tmpdir:
tmpfile = Path(tmpdir) / f"{module_name}.py"
tmpfile.write_text(dedent(code), encoding="utf8")
yield module_name, tmpfile
@contextmanager
def temporary_pypackage(
package: str,
modules: Sequence[str] | Mapping[str, str] | None = None,
*,
init: bool = True,
inits: bool = True,
) -> Iterator[TmpPackage]:
"""Create a package containing the given modules in a temporary directory.
Parameters:
package: The package name. Example: `"a"` gives
a package named `a`, while `"a/b"` gives a namespace package
named `a` with a package inside named `b`.
If `init` is false, then `b` is also a namespace package.
modules: Additional modules to create in the package.
If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
If a dict, keys are the file names and values their contents:
`{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
init: Whether to create an `__init__` module in the top package.
inits: Whether to create `__init__` modules in subpackages.
Yields:
A temporary package.
"""
modules = modules or {}
if isinstance(modules, list):
modules = dict.fromkeys(modules, "")
mkdir_kwargs = {"parents": True, "exist_ok": True}
with tempfile.TemporaryDirectory(prefix=_TMPDIR_PREFIX) as tmpdir:
tmpdirpath = Path(tmpdir)
package_name = ".".join(Path(package).parts)
package_path = tmpdirpath / package
package_path.mkdir(**mkdir_kwargs)
if init:
package_path.joinpath("__init__.py").touch()
for module_name, module_contents in modules.items(): # ty:ignore[unresolved-attribute]
current_path = package_path
for part in Path(module_name).parts:
if part.endswith((".py", ".pyi")):
current_path.joinpath(part).write_text(dedent(module_contents), encoding="utf8")
else:
current_path /= part
current_path.mkdir(**mkdir_kwargs)
if inits:
current_path.joinpath("__init__.py").touch()
yield TmpPackage(tmpdirpath, package_name, package_path)
@contextmanager
def temporary_visited_package(
package: str,
modules: Sequence[str] | Mapping[str, str] | None = None,
*,
init: bool = True,
inits: bool = True,
extensions: Extensions | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
allow_inspection: bool = False,
store_source: bool = True,
resolve_aliases: bool = False,
resolve_external: bool | None = None,
resolve_implicit: bool = False,
search_sys_path: bool = False,
prefer_stubs_docs: bool = False,
) -> Iterator[Module]:
"""Create and visit a temporary package.
Parameters:
package: The package name. Example: `"a"` gives
a package named `a`, while `"a/b"` gives a namespace package
named `a` with a package inside named `b`.
If `init` is false, then `b` is also a namespace package.
modules: Additional modules to create in the package.
If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
If a dict, keys are the file names and values their contents:
`{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
init: Whether to create an `__init__` module in the top package.
inits: Whether to create `__init__` modules in subpackages.
extensions: The extensions to use.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
store_source: Whether to store code source in the lines collection.
resolve_aliases: Whether to resolve aliases.
resolve_external: Whether to try to load unspecified modules to resolve aliases.
Default value (`None`) means to load external modules only if they are the private sibling
or the origin module (for example when `ast` imports from `_ast`).
resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
search_sys_path: Whether to search the system paths for the package.
prefer_stubs_docs: Whether to give precedence to stubs docstrings
rather than source docstrings. When both are present, the stubs one
will override the source one.
Yields:
A module.
"""
search_paths = sys.path if search_sys_path else []
with temporary_pypackage(package, modules, init=init, inits=inits) as tmp_package:
yield load( # ty:ignore[invalid-yield]
tmp_package.name,
search_paths=[tmp_package.tmpdir, *search_paths],
extensions=extensions,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
allow_inspection=allow_inspection,
store_source=store_source,
resolve_aliases=resolve_aliases,
resolve_external=resolve_external,
resolve_implicit=resolve_implicit,
force_inspection=False,
prefer_stubs_docs=prefer_stubs_docs,
)
@contextmanager
def temporary_inspected_package(
package: str,
modules: Sequence[str] | Mapping[str, str] | None = None,
*,
init: bool = True,
inits: bool = True,
extensions: Extensions | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
allow_inspection: bool = True,
store_source: bool = True,
resolve_aliases: bool = False,
resolve_external: bool | None = None,
resolve_implicit: bool = False,
search_sys_path: bool = False,
prefer_stubs_docs: bool = False,
) -> Iterator[Module]:
"""Create and inspect a temporary package.
Parameters:
package: The package name. Example: `"a"` gives
a package named `a`, while `"a/b"` gives a namespace package
named `a` with a package inside named `b`.
If `init` is false, then `b` is also a namespace package.
modules: Additional modules to create in the package.
If a list, simply touch the files: `["b.py", "c/d.py", "e/f"]`.
If a dict, keys are the file names and values their contents:
`{"b.py": "b = 1", "c/d.py": "print('hey from c')"}`.
init: Whether to create an `__init__` module in the top package.
inits: Whether to create `__init__` modules in subpackages.
extensions: The extensions to use.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
allow_inspection: Whether to allow inspecting modules.
store_source: Whether to store code source in the lines collection.
resolve_aliases: Whether to resolve aliases.
resolve_external: Whether to try to load unspecified modules to resolve aliases.
Default value (`None`) means to load external modules only if they are the private sibling
or the origin module (for example when `ast` imports from `_ast`).
resolve_implicit: When false, only try to resolve an alias if it is explicitly exported.
search_sys_path: Whether to search the system paths for the package.
prefer_stubs_docs: Whether to give precedence to stubs docstrings
rather than source docstrings. When both are present, the stubs one
will override the source one.
Yields:
A module.
"""
search_paths = sys.path if search_sys_path else []
with temporary_pypackage(package, modules, init=init, inits=inits) as tmp_package:
try:
yield load( # ty:ignore[invalid-yield]
tmp_package.name,
search_paths=[tmp_package.tmpdir, *search_paths],
extensions=extensions,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
allow_inspection=allow_inspection,
store_source=store_source,
resolve_aliases=resolve_aliases,
resolve_external=resolve_external,
resolve_implicit=resolve_implicit,
force_inspection=True,
prefer_stubs_docs=prefer_stubs_docs,
)
finally:
for name in tuple(sys.modules.keys()):
if name == package or name.startswith(f"{package}."):
sys.modules.pop(name, None)
invalidate_caches()
@contextmanager
def temporary_visited_module(
code: str,
*,
module_name: str = "module",
extensions: Extensions | None = None,
parent: Module | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
) -> Iterator[Module]:
"""Create and visit a temporary module with the given code.
Parameters:
code: The code of the module.
module_name: The name of the temporary module.
extensions: The extensions to use when visiting the AST.
parent: The optional parent of this module.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
Yields:
The visited module.
"""
code = dedent(code)
with temporary_pyfile(code, module_name=module_name) as (_, path):
lines_collection = lines_collection or LinesCollection()
lines_collection[path] = code.splitlines()
module = visit(
module_name,
filepath=path,
code=code,
extensions=extensions,
parent=parent,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
)
module.modules_collection[module_name] = module
yield module
@contextmanager
def temporary_inspected_module(
code: str,
*,
module_name: str = "module",
import_paths: list[Path] | None = None,
extensions: Extensions | None = None,
parent: Module | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
) -> Iterator[Module]:
"""Create and inspect a temporary module with the given code.
Parameters:
code: The code of the module.
module_name: The name of the temporary module.
import_paths: Paths to import the module from.
extensions: The extensions to use when visiting the AST.
parent: The optional parent of this module.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
Yields:
The inspected module.
"""
with temporary_pyfile(code, module_name=module_name) as (_, path):
lines_collection = lines_collection or LinesCollection()
lines_collection[path] = code.splitlines()
try:
module = inspect(
module_name,
filepath=path,
import_paths=import_paths,
extensions=extensions,
parent=parent,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
lines_collection=lines_collection,
modules_collection=modules_collection,
)
module.modules_collection[module_name] = module
yield module
finally:
if module_name in sys.modules:
del sys.modules[module_name]
invalidate_caches()
def vtree(*objects: Object, return_leaf: bool = False) -> Object:
"""Link objects together, vertically.
Parameters:
*objects: A sequence of objects. The first one is at the top of the tree.
return_leaf: Whether to return the leaf instead of the root.
Raises:
ValueError: When no objects are provided.
Returns:
The top or leaf object.
"""
if not objects:
raise ValueError("At least one object must be provided")
top = objects[0]
leaf = top
for obj in objects[1:]:
leaf.set_member(obj.name, obj)
leaf = obj
return leaf if return_leaf else top
def htree(*objects: Object) -> Object:
"""Link objects together, horizontally.
Parameters:
*objects: A sequence of objects. All objects starting at the second become members of the first.
Raises:
ValueError: When no objects are provided.
Returns:
The first given object, with all the other objects as members of it.
"""
if not objects:
raise ValueError("At least one object must be provided")
top = objects[0]
for obj in objects[1:]:
top.set_member(obj.name, obj)
return top
def module_vtree(path: str, *, leaf_package: bool = True, return_leaf: bool = False) -> Module:
"""Link objects together, vertically.
Parameters:
path: The complete module path, like `"a.b.c.d"`.
leaf_package: Whether the deepest module should also be a package.
return_leaf: Whether to return the leaf instead of the root.
Raises:
ValueError: When no objects are provided.
Returns:
The top or leaf module.
"""
parts = path.split(".")
modules = [Module(name, filepath=Path(*parts[:index], "__init__.py")) for index, name in enumerate(parts)]
if not leaf_package:
filepath = modules[-1].filepath.with_stem(parts[-1]) # ty:ignore[unresolved-attribute]
modules[-1]._filepath = filepath
return vtree(*modules, return_leaf=return_leaf) # ty:ignore[invalid-return-type]
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/exceptions.py 0000664 0001750 0001750 00000007070 15246557273 026451 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains all the exceptions specific to Griffe.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from griffe._internal.models import Alias
class GriffeError(Exception):
"""The base exception for all Griffe errors."""
class LoadingError(GriffeError):
"""The base exception for all Griffe errors."""
class NameResolutionError(GriffeError):
"""Exception for names that cannot be resolved in a object scope."""
class UnhandledEditableModuleError(GriffeError):
"""Exception for unhandled editables modules, when searching modules."""
class UnimportableModuleError(GriffeError):
"""Exception for modules that cannot be imported."""
class AliasResolutionError(GriffeError):
"""Exception for alias that cannot be resolved."""
def __init__(self, alias: Alias) -> None:
"""Initialize the exception.
Parameters:
alias: The alias that could not be resolved.
"""
self.alias: Alias = alias
"""The alias that triggered the error."""
self._detailed_message: str | None = None
super().__init__(f"Could not resolve alias {alias.path} pointing at {self.alias.target_path}")
def _format_message(self) -> str:
message = super().__str__()
try:
filepath = self.alias.parent.relative_filepath # ty:ignore[unresolved-attribute]
except BuiltinModuleError:
return message
return message + f" (in {filepath}:{self.alias.alias_lineno})"
def __str__(self) -> str:
if self._detailed_message is None:
self._detailed_message = self._format_message()
return self._detailed_message
class CyclicAliasError(GriffeError):
"""Exception raised when a cycle is detected in aliases."""
def __init__(self, chain: list[str]) -> None:
"""Initialize the exception.
Parameters:
chain: The cyclic chain of items (such as target path).
"""
self.chain: list[str] = chain
"""The chain of aliases that created the cycle."""
super().__init__("Cyclic aliases detected:\n " + "\n ".join(self.chain))
class LastNodeError(GriffeError):
"""Exception raised when trying to access a next or previous node."""
class RootNodeError(GriffeError):
"""Exception raised when trying to use siblings properties on a root node."""
class BuiltinModuleError(GriffeError):
"""Exception raised when trying to access the filepath of a builtin module."""
class ExtensionError(GriffeError):
"""Base class for errors raised by extensions."""
class ExtensionNotLoadedError(ExtensionError):
"""Exception raised when an extension could not be loaded."""
class GitError(GriffeError):
"""Exception raised for errors related to Git."""
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/stats.py 0000664 0001750 0001750 00000014256 15246557273 025432 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities to compute loading statistics,
# like time spent visiting modules statically or dynamically.
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING
from griffe._internal.enumerations import Kind
if TYPE_CHECKING:
from griffe._internal.loader import GriffeLoader
from griffe._internal.models import Alias, Object
class Stats:
"""Load statistics for a Griffe loader."""
def __init__(self, loader: GriffeLoader) -> None:
"""Initialiwe the stats object.
Parameters:
loader: The loader to compute stats for.
"""
self.loader = loader
"""The loader to compute stats for."""
modules_by_extension = defaultdict(
int,
{
"": 0,
".py": 0,
".pyi": 0,
".pyc": 0,
".pyo": 0,
".pyd": 0,
".so": 0,
},
)
top_modules = loader.modules_collection.members.values()
self.by_kind = {
Kind.MODULE: 0,
Kind.CLASS: 0,
Kind.FUNCTION: 0,
Kind.ATTRIBUTE: 0,
Kind.TYPE_ALIAS: 0,
}
"""Number of objects by kind."""
self.packages = len(top_modules)
"""Number of packages."""
self.modules_by_extension = modules_by_extension
"""Number of modules by extension."""
self.lines = sum(len(lines) for lines in loader.lines_collection.values())
"""Total number of lines."""
self.time_spent_visiting = 0
"""Time spent visiting modules."""
self.time_spent_inspecting = 0
"""Time spent inspecting modules."""
self.time_spent_serializing = 0
"""Time spent serializing objects."""
for module in top_modules:
self._itercount(module)
def _itercount(self, root: Object | Alias) -> None:
if root.is_alias:
return
self.by_kind[root.kind] += 1
if root.is_module:
if isinstance(root.filepath, Path):
self.modules_by_extension[root.filepath.suffix] += 1
elif root.filepath is None:
self.modules_by_extension[""] += 1
for member in root.members.values():
self._itercount(member)
def as_text(self) -> str:
"""Format the statistics as text.
Returns:
Text stats.
"""
lines = []
packages = self.packages
modules = self.by_kind[Kind.MODULE]
classes = self.by_kind[Kind.CLASS]
functions = self.by_kind[Kind.FUNCTION]
attributes = self.by_kind[Kind.ATTRIBUTE]
type_aliases = self.by_kind[Kind.TYPE_ALIAS]
objects = sum((modules, classes, functions, attributes, type_aliases))
lines.append("Statistics")
lines.append("---------------------")
lines.append("Number of loaded objects")
lines.append(f" Modules: {modules}")
lines.append(f" Classes: {classes}")
lines.append(f" Functions: {functions}")
lines.append(f" Attributes: {attributes}")
lines.append(f" Type aliases: {type_aliases}")
lines.append(f" Total: {objects} across {packages} packages")
per_ext = self.modules_by_extension
builtin = per_ext[""]
regular = per_ext[".py"]
stubs = per_ext[".pyi"]
compiled = modules - builtin - regular - stubs
lines.append("")
lines.append(f"Total number of lines: {self.lines}")
lines.append("")
lines.append("Modules")
lines.append(f" Builtin: {builtin}")
lines.append(f" Compiled: {compiled}")
lines.append(f" Regular: {regular}")
lines.append(f" Stubs: {stubs}")
lines.append(" Per extension:")
for ext, number in sorted(per_ext.items()):
if ext:
lines.append(f" {ext}: {number}")
visit_time = self.time_spent_visiting / 1000
inspect_time = self.time_spent_inspecting / 1000
total_time = visit_time + inspect_time
visit_percent = visit_time / total_time * 100
inspect_percent = inspect_time / total_time * 100
force_inspection = self.loader.force_inspection
visited_modules = 0 if force_inspection else regular
try:
visit_time_per_module = visit_time / visited_modules
except ZeroDivisionError:
visit_time_per_module = 0
inspected_modules = builtin + compiled + (regular if force_inspection else 0)
try:
inspect_time_per_module = inspect_time / inspected_modules
except ZeroDivisionError:
inspect_time_per_module = 0
lines.append("")
lines.append(
f"Time spent visiting modules ({visited_modules}): "
f"{visit_time}ms, {visit_time_per_module:.02f}ms/module ({visit_percent:.02f}%)",
)
lines.append(
f"Time spent inspecting modules ({inspected_modules}): "
f"{inspect_time}ms, {inspect_time_per_module:.02f}ms/module ({inspect_percent:.02f}%)",
)
serialize_time = self.time_spent_serializing / 1000
serialize_time_per_module = serialize_time / modules
lines.append(f"Time spent serializing: {serialize_time}ms, {serialize_time_per_module:.02f}ms/module")
return "\n".join(lines)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/logger.py 0000664 0001750 0001750 00000010017 15246557273 025542 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains the logger used throughout Griffe.
# The logger is actually a wrapper around the standard Python logger.
# We wrap it so that it is easier for other downstream libraries to patch it.
# For example, mkdocstrings-python patches the logger to relocate it as a child
# of `mkdocs.plugins` so that it fits in the MkDocs logging configuration.
#
# We use a single, global logger because our public API is exposed in a single module, `griffe`.
# Extensions however should use their own logger, which is why we provide the `get_logger` function.
from __future__ import annotations
import logging
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
class Logger:
_default_logger: Any = logging.getLogger
_instances: ClassVar[dict[str, Logger]] = {}
def __init__(self, name: str) -> None:
# Default logger that can be patched by third-party.
self._logger = self.__class__._default_logger(name)
def __getattr__(self, name: str) -> Any:
# Forward everything to the logger.
return getattr(self._logger, name)
@contextmanager
def disable(self) -> Iterator[None]:
"""Temporarily disable logging."""
old_level = self._logger.level
self._logger.setLevel(100)
try:
yield
finally:
self._logger.setLevel(old_level)
@classmethod
def _get(cls, name: str = "griffe") -> Logger:
if name not in cls._instances:
cls._instances[name] = cls(name)
return cls._instances[name]
@classmethod
def _patch_loggers(cls, get_logger_func: Callable) -> None:
# Patch current instances.
for name, instance in cls._instances.items():
instance._logger = get_logger_func(name)
# Future instances will be patched as well.
cls._default_logger = get_logger_func
logger: Logger = Logger._get()
"""Our global logger, used throughout the library.
Griffe's output and error messages are logging messages.
Griffe provides the [`patch_loggers`][griffe.patch_loggers]
function so dependent libraries can patch Griffe loggers as they see fit.
For example, to fit in the MkDocs logging configuration
and prefix each log message with the module name:
```python
import logging
from griffe import patch_loggers
class LoggerAdapter(logging.LoggerAdapter):
def __init__(self, prefix, logger):
super().__init__(logger, {})
self.prefix = prefix
def process(self, msg, kwargs):
return f"{self.prefix}: {msg}", kwargs
def get_logger(name):
logger = logging.getLogger(f"mkdocs.plugins.{name}")
return LoggerAdapter(name, logger)
patch_loggers(get_logger)
```
"""
def get_logger(name: str = "griffe") -> Logger:
"""Create and return a new logger instance.
Parameters:
name: The logger name.
Returns:
The logger.
"""
return Logger._get(name)
def patch_loggers(get_logger_func: Callable[[str], Any]) -> None:
"""Patch Griffe logger and Griffe extensions' loggers.
Parameters:
get_logger_func: A function accepting a name as parameter and returning a logger.
"""
Logger._patch_loggers(get_logger_func)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/merger.py 0000664 0001750 0001750 00000021751 15246557273 025553 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities to merge stubs data and concrete data.
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING
from griffe._internal.exceptions import AliasResolutionError, CyclicAliasError
from griffe._internal.expressions import Expr
from griffe._internal.logger import logger
if TYPE_CHECKING:
from collections.abc import Sequence
from griffe._internal.models import Attribute, Class, Function, Module, Object, Parameter, TypeAlias
def _merge_module_stubs(module: Module, stubs: Module, *, psd: bool = False) -> None:
_merge_stubs_docstring(module, stubs, psd=psd)
_merge_stubs_overloads(module, stubs)
_merge_stubs_members(module, stubs, psd=psd)
def _merge_class_stubs(class_: Class, stubs: Class, *, psd: bool = False) -> None:
_merge_stubs_docstring(class_, stubs, psd=psd)
_merge_stubs_overloads(class_, stubs)
_merge_stubs_type_parameters(class_, stubs)
_merge_stubs_members(class_, stubs, psd=psd)
def _merge_function_stubs(function: Function, stubs: Function, *, psd: bool = False) -> None:
_merge_stubs_docstring(function, stubs, psd=psd)
parameters: dict[str, Parameter] = {}
for parameter in function.parameters:
parameters.setdefault(parameter.name, parameter)
for parameter in stubs.parameters:
with suppress(KeyError):
parameters[parameter.name].annotation = parameter.annotation
function.returns = stubs.returns
_merge_stubs_type_parameters(function, stubs)
def _merge_attribute_stubs(attribute: Attribute, stubs: Attribute, *, psd: bool = False) -> None:
_merge_stubs_docstring(attribute, stubs, psd=psd)
attribute.annotation = stubs.annotation
if stubs.value not in (None, "..."):
attribute.value = stubs.value
def _merge_type_alias_stubs(type_alias: TypeAlias, stubs: TypeAlias, *, psd: bool = False) -> None:
_merge_stubs_docstring(type_alias, stubs, psd=psd)
_merge_stubs_type_parameters(type_alias, stubs)
def _merge_stubs_docstring(obj: Object, stubs: Object, *, psd: bool = False) -> None:
if (psd or not obj.docstring) and stubs.docstring:
obj.docstring = stubs.docstring
def _merge_stubs_type_parameters(obj: Class | Function | TypeAlias, stubs: Class | Function | TypeAlias) -> None:
if not obj.type_parameters and stubs.type_parameters:
obj.type_parameters = stubs.type_parameters
def _merge_stubs_overloads(obj: Module | Class, stubs: Module | Class) -> None:
for function_name, overloads in list(stubs.overloads.items()):
if overloads:
with suppress(KeyError):
if (func := obj.get_member(function_name)).is_function:
_merge_overload_annotations(func, overloads)
else:
logger.debug(
"Cannot merge overload annotations into (non-function) %s %r",
func.kind.value.lower(),
func.path,
)
del stubs.overloads[function_name]
def _merge_annotations(annotations: Sequence[Expr]) -> Expr | None:
if len(annotations) == 1:
return annotations[0]
if annotations:
return Expr._to_binop(annotations, op="|")
return None
def _merge_overload_annotations(function: Function, overloads: list[Function]) -> None:
function.overloads = overloads
overload_parameters: list[dict[str, Parameter]] = []
for overload in overloads:
parameters: dict[str, Parameter] = {}
for parameter in overload.parameters:
parameters.setdefault(parameter.name, parameter)
overload_parameters.append(parameters)
for parameter in function.parameters:
if parameter.annotation is None:
seen = set()
annotations = []
for parameters in overload_parameters:
with suppress(KeyError):
annotation = parameters[parameter.name].annotation
str_annotation = str(annotation)
if isinstance(annotation, Expr) and str_annotation not in seen:
annotations.append(annotation)
seen.add(str_annotation)
parameter.annotation = _merge_annotations(annotations)
if function.returns is None:
seen = set()
return_annotations = []
for overload in overloads:
str_annotation = str(overload.returns)
if isinstance(overload.returns, Expr) and str_annotation not in seen:
return_annotations.append(overload.returns)
seen.add(str_annotation)
function.returns = _merge_annotations(return_annotations)
def _merge_stubs_members(obj: Module | Class, stubs: Module | Class, *, psd: bool = False) -> None:
# Merge imports to later know if objects coming from the stubs were imported.
obj.imports.update(stubs.imports)
# Override exports to later know if objects coming from the stubs were exported.
if stubs.exports is not None:
obj.exports = stubs.exports
for member_name, stub_member in stubs.members.items():
if member_name in obj.members:
# We don't merge imported stub objects that already exist in the concrete module.
# Stub objects must be defined where they are exposed in the concrete package,
# not be imported from other stub modules.
if stub_member.is_alias:
continue
obj_member = obj.get_member(member_name)
with suppress(AliasResolutionError, CyclicAliasError):
# An object's canonical location can differ from its equivalent stub location.
# Devs usually declare stubs at the public location of the corresponding object,
# not the canonical one. Therefore, we must allow merging stubs into the target of an alias,
# as long as the stub and target are of the same kind.
if obj_member.kind is not stub_member.kind:
# If the stub and the target are not of the same kind, prefer the
# stub over the target.
logger.debug(
"Source object `%s` will be overwritten by stub object.",
obj_member.path,
)
obj.set_member(stub_member.name, stub_member)
elif obj_member.is_module:
_merge_module_stubs(obj_member, stub_member, psd=psd) # ty:ignore[invalid-argument-type]
elif obj_member.is_class:
_merge_class_stubs(obj_member, stub_member, psd=psd) # ty:ignore[invalid-argument-type]
elif obj_member.is_function:
_merge_function_stubs(obj_member, stub_member, psd=psd) # ty:ignore[invalid-argument-type]
elif obj_member.is_attribute:
_merge_attribute_stubs(obj_member, stub_member, psd=psd) # ty:ignore[invalid-argument-type]
elif obj_member.is_type_alias:
_merge_type_alias_stubs(obj_member, stub_member, psd=psd) # ty:ignore[invalid-argument-type]
else:
stub_member.runtime = False
obj.set_member(member_name, stub_member)
def merge_stubs(mod1: Module, mod2: Module, *, prefer_stubs_docs: bool = False) -> Module:
"""Merge stubs into a module.
Parameters:
mod1: A regular module or stubs module.
mod2: A regular module or stubs module.
prefer_stubs_docs: Whether to give precedence to stubs docstrings
rather than source docstrings. When both are present, the stubs one
will override the source one.
Raises:
ValueError: When both modules are regular modules (no stubs is passed).
Returns:
The regular module.
"""
logger.debug("Trying to merge %s and %s", mod1.filepath, mod2.filepath)
if mod1.filepath.suffix == ".pyi": # ty:ignore[unresolved-attribute]
stubs = mod1
module = mod2
elif mod2.filepath.suffix == ".pyi": # ty:ignore[unresolved-attribute]
stubs = mod2
module = mod1
else:
raise ValueError("cannot merge regular (non-stubs) modules together")
_merge_module_stubs(module, stubs, psd=prefer_stubs_docs)
return module
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/importer.py 0000664 0001750 0001750 00000013213 15246557273 026125 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities to dynamically import objects.
# These utilities are used by our [`Inspector`][griffe.Inspector] to dynamically import objects
# specified as Python paths, like `package.module.Class.method`.
from __future__ import annotations
import sys
from contextlib import contextmanager
from importlib import import_module
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from pathlib import Path
def _error_details(error: BaseException, objpath: str) -> str:
return f"With sys.path = {sys.path!r}, accessing {objpath!r} raises {error.__class__.__name__}: {error}"
@contextmanager
def sys_path(*paths: str | Path) -> Iterator[None]:
"""Redefine `sys.path` temporarily.
Parameters:
*paths: The paths to use when importing modules.
If no paths are given, keep `sys.path` untouched.
Yields:
Nothing.
"""
if not paths:
yield
return
old_path = sys.path
sys.path = [str(path) for path in paths]
try:
yield
finally:
sys.path = old_path
def dynamic_import(import_path: str, import_paths: Sequence[str | Path] | None = None) -> Any:
"""Dynamically import the specified object.
It can be a module, class, method, function, attribute, type alias,
nested arbitrarily.
It works like this:
- for a given object path `a.b.x.y`
- it tries to import `a.b.x.y` as a module (with `importlib.import_module`)
- if it fails, it tries again with `a.b.x`, storing `y`
- then `a.b`, storing `x.y`
- then `a`, storing `b.x.y`
- if nothing worked, it raises an error
- if one of the iteration worked, it moves on, and...
- it tries to get the remaining (stored) parts with `getattr`
- for example it gets `b` from `a`, then `x` from `b`, etc.
- if a single attribute access fails, it raises an error
- if everything worked, it returns the last obtained attribute
Since the function potentially tries multiple things before succeeding,
all errors happening along the way are recorded, and re-emitted with
an `ImportError` when it fails, to let users know what was tried.
IMPORTANT: The paths given through the `import_paths` parameter are used
to temporarily patch `sys.path`: this function is therefore not thread-safe.
IMPORTANT: The paths given as `import_paths` must be *correct*.
The contents of `sys.path` must be consistent to what a user of the imported code
would expect. Given a set of paths, if the import fails for a user, it will fail here too,
with potentially unintuitive errors. If we wanted to make this function more robust,
we could add a loop to "roll the window" of given paths, shifting them to the left
(for example: `("/a/a", "/a/b", "/a/c/")`, then `("/a/b", "/a/c", "/a/a/")`,
then `("/a/c", "/a/a", "/a/b/")`), to make sure each entry is given highest priority
at least once, maintaining relative order, but we deem this unnecessary for now.
Parameters:
import_path: The path of the object to import.
import_paths: The (sys) paths to import the object from.
Raises:
ModuleNotFoundError: When the object's module could not be found.
ImportError: When there was an import error or when couldn't get the attribute.
Returns:
The imported object.
"""
module_parts: list[str] = import_path.split(".")
object_parts: list[str] = []
errors = []
with sys_path(*(import_paths or ())):
while module_parts:
module_path = ".".join(module_parts)
try:
module = import_module(module_path)
except BaseException as error: # noqa: BLE001
# pyo3's PanicException can only be caught with BaseException.
# We do want to catch base exceptions anyway (exit, interrupt, etc.).
errors.append(_error_details(error, module_path))
object_parts.insert(0, module_parts.pop(-1))
else:
break
else:
raise ImportError("; ".join(errors))
# Sometimes extra dependencies are not installed,
# so importing the leaf module fails with a ModuleNotFoundError,
# or later `getattr` triggers additional code that fails.
# In these cases, and for consistency, we always re-raise an ImportError
# instead of an any other exception (it's called "dynamic import" after all).
# See https://github.com/mkdocstrings/mkdocstrings/issues/380.
value = module
for part in object_parts:
try:
value = getattr(value, part)
except BaseException as error: # noqa: BLE001
errors.append(_error_details(error, module_path + ":" + ".".join(object_parts)))
raise ImportError("; ".join(errors)) # noqa: B904
return value
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/mixins.py 0000664 0001750 0001750 00000045113 15246557273 025577 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains some mixins classes that hold shared methods
# of the different kinds of objects, and aliases.
from __future__ import annotations
import json
from contextlib import suppress
from typing import TYPE_CHECKING, Any, TypeVar
from griffe._internal.enumerations import Kind
from griffe._internal.exceptions import AliasResolutionError, BuiltinModuleError, CyclicAliasError
from griffe._internal.merger import merge_stubs
if TYPE_CHECKING:
from collections.abc import Sequence
from griffe._internal.models import Alias, Attribute, Class, Function, Module, Object, TypeAlias
_ObjType = TypeVar("_ObjType")
def _get_parts(key: str | Sequence[str]) -> Sequence[str]:
if isinstance(key, str):
if not key:
raise ValueError("Empty strings are not supported")
parts = key.split(".")
else:
parts = list(key)
if not parts:
raise ValueError("Empty tuples are not supported")
return parts
class GetMembersMixin:
"""Mixin class to share methods for accessing members."""
def __getitem__(self, key: str | Sequence[str]) -> Any:
"""Get a member with its name or path.
This method is part of the consumer API:
do not use when producing Griffe trees!
Members will be looked up in both declared members and inherited ones,
triggering computation of the latter.
Parameters:
key: The name or path of the member.
Examples:
>>> foo = griffe_object["foo"]
>>> bar = griffe_object["path.to.bar"]
>>> qux = griffe_object[("path", "to", "qux")]
"""
parts = _get_parts(key)
if len(parts) == 1:
return self.all_members[parts[0]] # ty:ignore[unresolved-attribute]
return self.all_members[parts[0]][parts[1:]] # ty:ignore[unresolved-attribute]
def get_member(self, key: str | Sequence[str]) -> Any:
"""Get a member with its name or path.
This method is part of the producer API:
you can use it safely while building Griffe trees
(for example in Griffe extensions).
Members will be looked up in declared members only, not inherited ones.
Parameters:
key: The name or path of the member.
Examples:
>>> foo = griffe_object["foo"]
>>> bar = griffe_object["path.to.bar"]
>>> bar = griffe_object[("path", "to", "bar")]
"""
parts = _get_parts(key)
if len(parts) == 1:
return self.members[parts[0]] # ty:ignore[unresolved-attribute]
return self.members[parts[0]].get_member(parts[1:]) # ty:ignore[unresolved-attribute]
# FIXME: Are `aliases` in other objects correctly updated when we delete a member?
# Would weak references be useful there?
class DelMembersMixin:
"""Mixin class to share methods for deleting members."""
def __delitem__(self, key: str | Sequence[str]) -> None:
"""Delete a member with its name or path.
This method is part of the consumer API:
do not use when producing Griffe trees!
Members will be looked up in both declared members and inherited ones,
triggering computation of the latter.
Parameters:
key: The name or path of the member.
Examples:
>>> del griffe_object["foo"]
>>> del griffe_object["path.to.bar"]
>>> del griffe_object[("path", "to", "qux")]
"""
parts = _get_parts(key)
if len(parts) == 1:
name = parts[0]
try:
del self.members[name] # ty:ignore[unresolved-attribute]
except KeyError:
del self.inherited_members[name] # ty:ignore[unresolved-attribute]
else:
del self.all_members[parts[0]][parts[1:]] # ty:ignore[unresolved-attribute]
def del_member(self, key: str | Sequence[str]) -> None:
"""Delete a member with its name or path.
This method is part of the producer API:
you can use it safely while building Griffe trees
(for example in Griffe extensions).
Members will be looked up in declared members only, not inherited ones.
Parameters:
key: The name or path of the member.
Examples:
>>> griffe_object.del_member("foo")
>>> griffe_object.del_member("path.to.bar")
>>> griffe_object.del_member(("path", "to", "qux"))
"""
parts = _get_parts(key)
if len(parts) == 1:
name = parts[0]
del self.members[name] # ty:ignore[unresolved-attribute]
else:
self.members[parts[0]].del_member(parts[1:]) # ty:ignore[unresolved-attribute]
class SetMembersMixin:
"""Mixin class to share methods for setting members."""
def __setitem__(self, key: str | Sequence[str], value: Object | Alias) -> None:
"""Set a member with its name or path.
This method is part of the consumer API:
do not use when producing Griffe trees!
Parameters:
key: The name or path of the member.
value: The member.
Examples:
>>> griffe_object["foo"] = foo
>>> griffe_object["path.to.bar"] = bar
>>> griffe_object[("path", "to", "qux")] = qux
"""
parts = _get_parts(key)
if len(parts) == 1:
name = parts[0]
self.members[name] = value # ty:ignore[unresolved-attribute]
if self.is_collection: # ty:ignore[unresolved-attribute]
value._modules_collection = self # ty:ignore[invalid-assignment]
else:
value.parent = self # ty:ignore[invalid-assignment]
else:
self.members[parts[0]][parts[1:]] = value # ty:ignore[unresolved-attribute]
def set_member(self, key: str | Sequence[str], value: Object | Alias) -> None:
"""Set a member with its name or path.
This method is part of the producer API:
you can use it safely while building Griffe trees
(for example in Griffe extensions).
Parameters:
key: The name or path of the member.
value: The member.
Examples:
>>> griffe_object.set_member("foo", foo)
>>> griffe_object.set_member("path.to.bar", bar)
>>> griffe_object.set_member(("path", "to", "qux"), qux)
"""
parts = _get_parts(key)
if len(parts) == 1:
name = parts[0]
if name in self.members: # ty:ignore[unresolved-attribute]
member = self.members[name] # ty:ignore[unresolved-attribute]
if not member.is_alias:
# When reassigning a module to an existing one,
# try to merge them as one regular and one stubs module
# (implicit support for .pyi modules).
if member.is_module and not (member.is_namespace_package or member.is_namespace_subpackage):
# Accessing attributes of the value or member can trigger alias errors.
# Accessing file paths can trigger a builtin module error.
with suppress(AliasResolutionError, CyclicAliasError, BuiltinModuleError):
if value.is_module and value.filepath != member.filepath:
psd = self._psd if self.is_collection else self.modules_collection._psd # ty:ignore[unresolved-attribute]
with suppress(ValueError):
value = merge_stubs(member, value, prefer_stubs_docs=psd) # ty:ignore[invalid-argument-type]
for alias in member.aliases.values():
with suppress(CyclicAliasError):
alias.target = value
self.members[name] = value # ty:ignore[unresolved-attribute]
if self.is_collection: # ty:ignore[unresolved-attribute]
value._modules_collection = self # ty:ignore[invalid-assignment]
else:
value.parent = self # ty:ignore[invalid-assignment]
else:
self.members[parts[0]].set_member(parts[1:], value) # ty:ignore[unresolved-attribute]
class SerializationMixin:
"""Mixin class to share methods for de/serializing objects."""
def as_json(self, *, full: bool = False, **kwargs: Any) -> str:
"""Return this object's data as a JSON string.
Parameters:
full: Whether to return full info, or just base info.
**kwargs: Additional serialization options passed to encoder.
Returns:
A JSON string.
"""
from griffe._internal.encoders import JSONEncoder # Avoid circular import. # noqa: PLC0415
return json.dumps(self, cls=JSONEncoder, full=full, **kwargs)
@classmethod
def from_json(cls: type[_ObjType], json_string: str, **kwargs: Any) -> _ObjType: # noqa: PYI019
"""Create an instance of this class from a JSON string.
Parameters:
json_string: JSON to decode into Object.
**kwargs: Additional options passed to decoder.
Returns:
An Object instance.
Raises:
TypeError: When the json_string does not represent and object
of the class from which this classmethod has been called.
"""
from griffe._internal.encoders import json_decoder # Avoid circular import. # noqa: PLC0415
kwargs.setdefault("object_hook", json_decoder)
obj = json.loads(json_string, **kwargs)
if not isinstance(obj, cls):
raise TypeError(f"provided JSON object is not of type {cls}")
return obj
class ObjectAliasMixin(GetMembersMixin, SetMembersMixin, DelMembersMixin, SerializationMixin):
"""Mixin class to share methods that appear both in objects and aliases, unchanged."""
@property
def all_members(self) -> dict[str, Object | Alias]:
"""All members (declared and inherited).
This method is part of the consumer API:
do not use when producing Griffe trees!
"""
if self.is_class: # ty:ignore[unresolved-attribute]
return {**self.inherited_members, **self.members} # ty:ignore[unresolved-attribute]
return self.members # ty:ignore[unresolved-attribute]
@property
def modules(self) -> dict[str, Module]:
"""The module members.
This method is part of the consumer API:
do not use when producing Griffe trees!
"""
return {name: member for name, member in self.all_members.items() if member.kind is Kind.MODULE} # ty:ignore[invalid-return-type]
@property
def classes(self) -> dict[str, Class]:
"""The class members.
This method is part of the consumer API:
do not use when producing Griffe trees!
"""
return {name: member for name, member in self.all_members.items() if member.kind is Kind.CLASS} # ty:ignore[invalid-return-type]
@property
def functions(self) -> dict[str, Function]:
"""The function members.
This method is part of the consumer API:
do not use when producing Griffe trees!
"""
return {name: member for name, member in self.all_members.items() if member.kind is Kind.FUNCTION} # ty:ignore[invalid-return-type]
@property
def attributes(self) -> dict[str, Attribute]:
"""The attribute members.
This method is part of the consumer API:
do not use when producing Griffe trees!
"""
return {name: member for name, member in self.all_members.items() if member.kind is Kind.ATTRIBUTE} # ty:ignore[invalid-return-type]
@property
def type_aliases(self) -> dict[str, TypeAlias]:
"""The type alias members.
This method is part of the consumer API:
do not use when producing Griffe trees!
"""
return {name: member for name, member in self.all_members.items() if member.kind is Kind.TYPE_ALIAS} # ty:ignore[invalid-return-type]
@property
def is_private(self) -> bool:
"""Whether this object/alias is private (starts with `_`) but not special."""
return self.name.startswith("_") and not self.is_special # ty:ignore[unresolved-attribute]
@property
def is_special(self) -> bool:
"""Whether this object/alias is special ("dunder" attribute/method, starts and end with `__`)."""
return self.name.startswith("__") and self.name.endswith("__") # ty:ignore[unresolved-attribute]
@property
def is_class_private(self) -> bool:
"""Whether this object/alias is class-private (starts with `__` and is a class member)."""
return (
bool(self.parent) and self.parent.is_class and self.name.startswith("__") and not self.name.endswith("__") # ty:ignore[unresolved-attribute]
)
@property
def is_imported(self) -> bool:
"""Whether this object/alias was imported from another module."""
if not (parent := self.parent): # ty:ignore[unresolved-attribute]
return False
return self.name in parent.imports # ty:ignore[unresolved-attribute]
@property
def is_exported(self) -> bool:
"""Whether this object/alias is exported (listed in `__all__`)."""
return (
bool(self.parent) # ty:ignore[unresolved-attribute]
and self.parent.is_module # ty:ignore[unresolved-attribute]
and bool(self.parent.exports and self.name in self.parent.exports) # ty:ignore[unresolved-attribute]
)
@property
def is_wildcard_exposed(self) -> bool:
"""Whether this object/alias is exposed to wildcard imports.
To be exposed to wildcard imports, an object/alias must:
- be available at runtime
- have a module as parent
- be listed in `__all__` if `__all__` is defined
- or not be private (having a name starting with an underscore)
Special case for Griffe trees: a submodule is only exposed if its parent imports it.
Returns:
True or False.
"""
# If the object is not available at runtime or is not defined at the module level, it is not exposed.
if not self.runtime or not (bool(self.parent) and self.parent.is_module): # ty:ignore[unresolved-attribute]
return False
# If the parent module defines `__all__`, the object is exposed if it is listed in it.
if self.parent.exports is not None: # ty:ignore[unresolved-attribute]
return self.name in self.parent.exports # ty:ignore[unresolved-attribute]
# If the object's name starts with an underscore, it is not exposed.
# We don't use `is_private` or `is_special` here to avoid redundant string checks.
if self.name.startswith("_"): # ty:ignore[unresolved-attribute]
return False
# Special case for Griffe trees: a submodule is only exposed if its parent imports it.
return self.is_alias or not self.is_module or self.is_imported # ty:ignore[unresolved-attribute]
@property
def is_public(self) -> bool:
"""Whether this object is considered public.
In modules, developers can mark objects as public thanks to the `__all__` variable.
In classes however, there is no convention or standard to do so.
Therefore, to decide whether an object is public, we follow this algorithm:
- If the object's `public` attribute is set (boolean), return its value.
- If the object is listed in its parent's (a module) `__all__` attribute, it is public.
- If the parent (module) defines `__all__` and the object is not listed in, it is private.
- If the object has a private name, it is private.
- If the object was imported from another module, it is private.
- Otherwise, the object is public.
"""
# Give priority to the `public` attribute if it is set.
if self.public is not None: # ty:ignore[unresolved-attribute]
return self.public # ty:ignore[unresolved-attribute]
# If the object is a module and its name does not start with an underscore, it is public.
# Modules are not subject to the `__all__` convention, only the underscore prefix one.
if not self.is_alias and self.is_module and not self.name.startswith("_"): # ty:ignore[unresolved-attribute]
return True
# If the object is defined at the module-level and is listed in `__all__`, it is public.
# If the parent module defines `__all__` but does not list the object, it is private.
if self.parent and self.parent.is_module and bool(self.parent.exports): # ty:ignore[unresolved-attribute]
return self.name in self.parent.exports # ty:ignore[unresolved-attribute]
# Special objects are always considered public.
# Even if we don't access them directly, they are used through different *public* means
# like instantiating classes (`__init__`), using operators (`__eq__`), etc..
if self.is_private:
return False
# Imported objects are private by default.
# The two following conventions are supported through Griffe extensions:
# - `from a import x as x` marks `x` as public (griffe-public-redundant-alias)
# - `from a import *` marks all wildcard imported objects as public (griffe-public-wildcard-import)
if self.is_imported: # noqa: SIM103
return False
# If we reached this point, the object is public.
return True
@property
def is_deprecated(self) -> bool:
"""Whether this object is deprecated."""
# NOTE: We might want to add more ways to detect deprecations in the future.
return bool(self.deprecated) # ty:ignore[unresolved-attribute]
@property
def is_generic(self) -> bool:
"""Whether this object is generic."""
return bool(self.type_parameters) # ty:ignore[unresolved-attribute]
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/extensions/ 0000775 0001750 0001750 00000000000 15246557273 026111 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/extensions/unpack_typeddict.py 0000664 0001750 0001750 00000024260 15246557273 032021 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# TODO: Support `extra_items=type`.
# TODO: Support `closed=True/False`.
from __future__ import annotations
import ast
from itertools import chain
from typing import TYPE_CHECKING, Any, TypedDict
from griffe._internal.docstrings.models import (
DocstringParameter,
DocstringSectionParameters,
)
from griffe._internal.enumerations import DocstringSectionKind, ParameterKind
from griffe._internal.expressions import Expr, ExprSubscript
from griffe._internal.extensions.base import Extension
from griffe._internal.models import Class, Docstring, Function, Parameter, Parameters
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
class _TypedDictAttr(TypedDict):
name: str
annotation: str | Expr | None
docstring: Docstring | None
def _unwrap_annotation(annotation: str | Expr | None, *, default_required: bool) -> tuple[str | Expr | None, bool]:
required = default_required
# Annotations can be written ReadOnly[Required[T]] or Required[ReadOnly[T]],
# so we unwrap a first time here and a second time at the end.
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
"typing.ReadOnly",
"typing_extensions.ReadOnly",
}:
annotation = annotation.slice
# Unwrap `Required` and `NotRequired`, set `required` accordingly.
if isinstance(annotation, ExprSubscript):
if annotation.canonical_path in {
"typing.Required",
"typing_extensions.Required",
}:
annotation = annotation.slice
required = True
elif annotation.canonical_path in {
"typing.NotRequired",
"typing_extensions.NotRequired",
}:
annotation = annotation.slice
required = False
# Unwrap `ReadOnly` a second time here.
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
"typing.ReadOnly",
"typing_extensions.ReadOnly",
}:
annotation = annotation.slice
return annotation, required
def _get_or_set_attrs(cls: Class) -> tuple[list[_TypedDictAttr], list[_TypedDictAttr]]:
if (attrs := cls.extra.get("unpack_typeddict", {}).get("_attributes")) is not None:
return attrs
# Inspect `total` keyword argument to determine default requiredness.
default_required = True
for arg, value in cls.keywords.items():
if arg == "total":
try:
total = ast.literal_eval(str(value))
except (ValueError, SyntaxError):
break
if total is True:
default_required = True
elif total is False:
default_required = False
break
# Extract attributes.
required_attrs = []
optional_attrs = []
for attr in cls.attributes.values():
annotation, required = _unwrap_annotation(attr.annotation, default_required=default_required)
if required:
required_attrs.append(
_TypedDictAttr(
name=attr.name,
annotation=annotation,
docstring=attr.docstring,
),
)
else:
optional_attrs.append(
_TypedDictAttr(
name=attr.name,
annotation=annotation,
docstring=attr.docstring,
),
)
cls.extra["unpack_typeddict"]["_attributes"] = (required_attrs, optional_attrs)
return (required_attrs, optional_attrs)
def _update_docstring(
func: Function,
required: Iterable[_TypedDictAttr],
optional: Iterable[_TypedDictAttr],
kwparam: Parameter | None = None,
) -> None:
if not func.docstring:
func.docstring = Docstring("", parent=func)
params_section = None
sections = func.docstring.parsed
# Find existing "Parameters" section.
section_gen = (section for section in sections if section.kind is DocstringSectionKind.parameters)
params_section = next(section_gen, None)
# Pop original variadic keyword parameter from section.
if kwparam and params_section is not None:
param_gen = (i for i, arg in enumerate(params_section.value) if arg.name.lstrip("*") == kwparam.name)
if (kwarg_pos := next(param_gen, None)) is not None:
params_section.value.pop(kwarg_pos)
# If we have required parameters, add them to the "Parameters" section.
if required:
# Create a "Parameters" section if none exists.
if params_section is None:
params_section = DocstringSectionParameters([])
func.docstring.parsed.append(params_section)
# Add required parameters to the section.
for attr in required:
params_section.value.append(
DocstringParameter(
name=attr["name"],
description=attr["docstring"].value if attr["docstring"] else "",
annotation=attr["annotation"],
),
)
# If we have optional parameters, add them to the "Parameters" section too,
# with a default value of `...`.
if optional:
# Create a "Parameters" section if none exists.
if params_section is None:
params_section = DocstringSectionParameters([])
func.docstring.parsed.append(params_section)
# Add optional parameters to the section.
for attr in optional:
params_section.value.append(
DocstringParameter(
name=attr["name"],
description=attr["docstring"].value if attr["docstring"] else "",
annotation=attr["annotation"],
value="...",
),
)
# TODO: Add `**kwargs` parameter if extra items are allowed.
def _params_from_attrs(required: Iterable[_TypedDictAttr], optional: Iterable[_TypedDictAttr]) -> Iterator[Parameter]:
for attr in required:
yield Parameter(
name=attr["name"],
annotation=attr["annotation"],
kind=ParameterKind.keyword_only,
docstring=attr["docstring"],
)
for attr in optional:
yield Parameter(
name=attr["name"],
annotation=attr["annotation"],
kind=ParameterKind.keyword_only,
default="...",
docstring=attr["docstring"],
)
class UnpackTypedDictExtension(Extension):
"""An extension to handle `Unpack[TypeDict]`."""
def on_class(self, *, cls: Class, **kwargs: Any) -> None: # noqa: ARG002
"""Add an `__init__` method to `TypedDict` classes if missing."""
for base in cls.bases:
if isinstance(base, Expr) and base.canonical_path in {"typing.TypedDict", "typing_extensions.TypedDict"}:
cls.labels.add("typed-dict")
break
else:
return
required, optional = _get_or_set_attrs(cls)
if "__init__" not in cls.members:
# Build the `__init__` method and add it to the class.
parameters = Parameters(
Parameter(name="self", kind=ParameterKind.positional_or_keyword),
*_params_from_attrs(required, optional),
)
# TODO: Add `**kwargs` parameter if extra items are allowed.
init = Function(name="__init__", parameters=parameters, returns="None")
cls.set_member("__init__", init)
# Update the `__init__` docstring.
_update_docstring(init, required, optional)
# Remove attributes from the class, as they are now in the `__init__` method.
for attr in chain(required, optional):
cls.del_member(attr["name"])
def on_function(self, *, func: Function, **kwargs: Any) -> None: # noqa: ARG002
"""Expand `**kwargs: Unpack[TypedDict]` in function signatures."""
# Find any `**kwargs: Unpack[TypedDict]` parameter.
for parameter in func.parameters:
if parameter.kind is ParameterKind.var_keyword:
annotation = parameter.annotation
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
"typing.Annotated",
"typing_extensions.Annotated",
}:
annotation = annotation.slice.elements[0]
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
"typing.Unpack",
"typing_extensions.Unpack",
}:
slice_path = annotation.slice.canonical_path
typed_dict = func.modules_collection[slice_path]
break
else:
return
required, optional = _get_or_set_attrs(typed_dict)
# Update any parameter section in the docstring.
# We do this before updating the signature so that
# parsing the docstring doesn't emit warnings.
_update_docstring(func, required, optional, parameter)
# Update the function parameters.
del func.parameters[parameter.name]
for param in _params_from_attrs(required, optional):
func.parameters[param.name] = Parameter(
name=param.name,
annotation=param.annotation,
kind=ParameterKind.keyword_only,
default=param.default,
docstring=param.docstring,
)
# TODO: Add `**kwargs` parameter if extra items are allowed.
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/extensions/__init__.py 0000664 0001750 0001750 00000001600 15246557273 030217 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# These submodules contain our extension system,
# as well as built-in extensions.
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/extensions/base.py 0000664 0001750 0001750 00000055515 15246557273 027410 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains the base class for extensions
# and the functions to load them.
from __future__ import annotations
import os
import sys
from importlib.util import module_from_spec, spec_from_file_location
from inspect import isclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from griffe._internal.agents.nodes.ast import ast_children, ast_kind
from griffe._internal.exceptions import ExtensionNotLoadedError
from griffe._internal.importer import dynamic_import
if TYPE_CHECKING:
import ast
from types import ModuleType
from griffe._internal.agents.inspector import Inspector
from griffe._internal.agents.nodes.runtime import ObjectNode
from griffe._internal.agents.visitor import Visitor
from griffe._internal.loader import GriffeLoader
from griffe._internal.models import Alias, Attribute, Class, Function, Module, Object, TypeAlias
_missing = object()
class Extension:
"""Base class for Griffe extensions."""
def visit(self, node: ast.AST) -> None:
"""Visit a node.
Parameters:
node: The node to visit.
"""
getattr(self, f"visit_{ast_kind(node)}", lambda _: None)(node)
def generic_visit(self, node: ast.AST) -> None:
"""Visit children nodes.
Parameters:
node: The node to visit the children of.
"""
for child in ast_children(node):
self.visit(child)
def inspect(self, node: ObjectNode) -> None:
"""Inspect a node.
Parameters:
node: The node to inspect.
"""
getattr(self, f"inspect_{node.kind}", lambda _: None)(node)
def generic_inspect(self, node: ObjectNode) -> None:
"""Extend the base generic inspection with extensions.
Parameters:
node: The node to inspect.
"""
for child in node.children:
if not child.alias_target_path:
self.inspect(child)
def on_node(self, *, node: ast.AST | ObjectNode, agent: Visitor | Inspector, **kwargs: Any) -> None:
"""Run when visiting a new node during static/dynamic analysis.
Parameters:
node: The currently visited node.
"""
def on_instance(
self,
*,
node: ast.AST | ObjectNode,
obj: Object,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when an Object has been created.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
obj: The object instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_object(self, *, obj: Object, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run on objects (every kind) once the object tree has been fully constructed.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
obj: The object instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
def on_members(self, *, node: ast.AST | ObjectNode, obj: Object, agent: Visitor | Inspector, **kwargs: Any) -> None:
"""Run when members of an Object have been loaded.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
obj: The object instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_module_node(self, *, node: ast.AST | ObjectNode, agent: Visitor | Inspector, **kwargs: Any) -> None:
"""Run when visiting a new module node during static/dynamic analysis.
Parameters:
node: The currently visited node.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_module_instance(
self,
*,
node: ast.AST | ObjectNode,
mod: Module,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when a Module has been created.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
mod: The module instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_module(self, *, mod: Module, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run on modules once the object tree has been fully constructed.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
mod: The module instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
def on_module_members(
self,
*,
node: ast.AST | ObjectNode,
mod: Module,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when members of a Module have been loaded.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
mod: The module instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_class_node(self, *, node: ast.AST | ObjectNode, agent: Visitor | Inspector, **kwargs: Any) -> None:
"""Run when visiting a new class node during static/dynamic analysis.
Parameters:
node: The currently visited node.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_class_instance(
self,
*,
node: ast.AST | ObjectNode,
cls: Class,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when a Class has been created.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
cls: The class instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_class(self, *, cls: Class, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run on classes once the object tree has been fully constructed.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
cls: The class instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
def on_class_members(
self,
*,
node: ast.AST | ObjectNode,
cls: Class,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when members of a Class have been loaded.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
cls: The class instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_function_node(self, *, node: ast.AST | ObjectNode, agent: Visitor | Inspector, **kwargs: Any) -> None:
"""Run when visiting a new function node during static/dynamic analysis.
Parameters:
node: The currently visited node.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_function_instance(
self,
*,
node: ast.AST | ObjectNode,
func: Function,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when a Function has been created.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
func: The function instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_function(self, *, func: Function, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run on functions once the object tree has been fully constructed.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
func: The function instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
def on_attribute_node(self, *, node: ast.AST | ObjectNode, agent: Visitor | Inspector, **kwargs: Any) -> None:
"""Run when visiting a new attribute node during static/dynamic analysis.
Parameters:
node: The currently visited node.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_attribute_instance(
self,
*,
node: ast.AST | ObjectNode,
attr: Attribute,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when an Attribute has been created.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
attr: The attribute instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_attribute(self, *, attr: Attribute, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run on attributes once the object tree has been fully constructed.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
attr: The attribute instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
def on_type_alias_node(self, *, node: ast.AST | ObjectNode, agent: Visitor | Inspector, **kwargs: Any) -> None:
"""Run when visiting a new type alias node during static/dynamic analysis.
Parameters:
node: The currently visited node.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_type_alias_instance(
self,
*,
node: ast.AST | ObjectNode,
type_alias: TypeAlias,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when a TypeAlias has been created.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
type_alias: The type alias instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
def on_type_alias(self, *, type_alias: TypeAlias, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run on type aliases once the object tree has been fully constructed.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
type_alias: The type alias instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
def on_alias_instance(
self,
*,
node: ast.AST | ObjectNode,
alias: Alias,
agent: Visitor | Inspector,
**kwargs: Any,
) -> None:
"""Run when an Alias has been created.
Warning:
This method runs while the object tree is still being constructed:
data might be incomplete (class inheritance, alias resolution, etc.).
Only hook onto this event if you know what you're doing.
Parameters:
node: The currently visited node.
alias: The alias instance.
agent: The analysis agent currently running.
**kwargs: For forward-compatibility.
"""
if getattr(self, "__old_on_alias", False):
self.on_alias(node=node, alias=alias, agent=agent, **kwargs)
def on_alias(self, *, alias: Alias, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run on aliases once the object tree has been fully constructed.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
alias: The alias instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
def on_package(self, *, pkg: Module, loader: GriffeLoader, **kwargs: Any) -> None:
"""Run when a package has been completely loaded.
Note:
This method runs once the object tree has been fully constructed:
data is therefore complete and you can safely hook onto this event.
Parameters:
pkg: The package (Module) instance.
loader: The loader currently in use.
**kwargs: For forward-compatibility.
"""
LoadableExtensionType = str | dict[str, Any] | Extension | type[Extension]
"""All the types that can be passed to `load_extensions`."""
class Extensions:
"""This class helps iterating on extensions that should run at different times."""
def __init__(self, *extensions: Extension) -> None:
"""Initialize the extensions container.
Parameters:
*extensions: The extensions to add.
"""
self._extensions: list[Extension] = []
self.add(*extensions)
def add(self, *extensions: Extension) -> None:
"""Add extensions to this container.
Parameters:
*extensions: The extensions to add.
"""
for extension in extensions:
self._extensions.append(extension)
def has_hooks(self, *events: str) -> bool:
"""Whether any extension implements one of the given events.
Parameters:
*events: The extension events to check.
Returns:
Whether at least one hook is implemented.
"""
for event in events:
base_callback = getattr(Extension, event, _missing)
for extension in self._extensions:
callback = getattr(extension, event, _missing)
if callback is not _missing and getattr(callback, "__func__", callback) is not base_callback:
return True
return False
def _noop(self, **kwargs: Any) -> None:
"""No-op method for extension hooks."""
def call(self, event: str, **kwargs: Any) -> None:
"""Call the extension hook for the given event.
Parameters:
event: The triggered event.
**kwargs: Arguments passed to the hook.
"""
for extension in self._extensions:
getattr(extension, event, self._noop)(**kwargs)
builtin_extensions: set[str] = {
"dataclasses",
"unpack_typeddict",
}
"""The names of built-in Griffe extensions."""
def _load_extension_path(path: str) -> ModuleType:
module_name = os.path.basename(path).rsplit(".", 1)[0] # noqa: PTH119
spec = spec_from_file_location(module_name, path)
if not spec:
raise ExtensionNotLoadedError(f"Could not import module from path '{path}'")
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module) # ty:ignore[unresolved-attribute]
return module
def _load_extension(
extension: str | dict[str, Any] | Extension | type[Extension],
) -> Extension | list[Extension]:
"""Load a configured extension.
Parameters:
extension: An extension, with potential configuration options.
Raises:
ExtensionNotLoadedError: When the extension cannot be loaded,
either because the module is not found, or because it does not expose
the Extension attribute. ImportError will bubble up so users can see
the traceback.
Returns:
An extension instance.
"""
ext_object = None
# If it's already an extension instance, return it.
if isinstance(extension, Extension):
return extension
# If it's an extension class, instantiate it (without options) and return it.
if isclass(extension) and issubclass(extension, Extension):
return extension()
# If it's a dictionary, we expect the only key to be an import path
# and the value to be a dictionary of options.
if isinstance(extension, dict):
import_path, options = next(iter(extension.items()))
# Force path to be a string, as it could have been passed from `mkdocs.yml`,
# using the custom YAML tag `!relative`, which gives an instance of MkDocs
# path placeholder classes, which are not iterable.
import_path = str(import_path)
# Otherwise we consider it's an import path, without options.
else:
import_path = str(extension)
options = {}
# If the import path contains a colon, we split into path and class name.
colons = import_path.count(":")
# Special case for The Annoying Operating System.
if colons > 1 or (colons and ":" not in Path(import_path).drive):
import_path, extension_name = import_path.rsplit(":", 1)
else:
extension_name = None
# If the import path corresponds to a built-in extension, expand it.
if import_path in builtin_extensions:
import_path = f"griffe._internal.extensions.{import_path}"
# If the import path is a path to an existing file, load it.
elif os.path.exists(import_path): # noqa: PTH110
try:
ext_object = _load_extension_path(import_path)
except ImportError as error:
raise ExtensionNotLoadedError(f"Extension module '{import_path}' could not be found") from error
# If the extension wasn't loaded yet, we consider the import path
# to be a Python dotted path like `package.module` or `package.module.Extension`.
if not ext_object:
try:
ext_object = dynamic_import(import_path)
except ModuleNotFoundError as error:
raise ExtensionNotLoadedError(f"Extension module '{import_path}' could not be found") from error
except ImportError as error:
raise ExtensionNotLoadedError(f"Error while importing extension '{import_path}': {error}") from error
# If the loaded object is an extension class, instantiate it with options and return it.
if isclass(ext_object) and issubclass(ext_object, Extension):
return ext_object(**options)
# Otherwise the loaded object is a module, so we get the extension class by name,
# instantiate it with options and return it.
if extension_name:
try:
return getattr(ext_object, extension_name)(**options)
except AttributeError as error:
raise ExtensionNotLoadedError(
f"Extension module '{import_path}' has no '{extension_name}' attribute",
) from error
# No class name was specified so we search all extension classes in the module,
# instantiate each with the same options, and return them.
extensions = [
obj for obj in vars(ext_object).values() if isclass(obj) and issubclass(obj, Extension) and obj is not Extension
]
return [ext(**options) for ext in extensions]
def load_extensions(*exts: LoadableExtensionType) -> Extensions:
"""Load configured extensions.
Parameters:
exts: Extensions with potential configuration options.
Returns:
An extensions container.
"""
extensions = Extensions()
for extension in exts:
ext = _load_extension(extension)
if isinstance(ext, list):
extensions.add(*ext)
else:
extensions.add(ext)
# TODO: Deprecate and remove at some point?
# Always add our built-in dataclasses extension.
from griffe._internal.extensions.dataclasses import DataclassesExtension # noqa: PLC0415
for ext in extensions._extensions:
if type(ext) is DataclassesExtension:
break
else:
extensions.add(*_load_extension("dataclasses")) # ty:ignore[not-iterable]
return extensions
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/extensions/dataclasses.py 0000664 0001750 0001750 00000022070 15246557273 030753 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Built-in extension adding support for dataclasses.
#
# This extension re-creates `__init__` methods of dataclasses
# during static analysis.
from __future__ import annotations
import ast
from contextlib import suppress
from functools import cache
from typing import Any, cast
from griffe._internal.enumerations import ParameterKind
from griffe._internal.expressions import (
Expr,
ExprAttribute,
ExprCall,
ExprDict,
)
from griffe._internal.extensions.base import Extension
from griffe._internal.logger import logger
from griffe._internal.models import Attribute, Class, Decorator, Function, Module, Parameter, Parameters
def _dataclass_decorator(decorators: list[Decorator]) -> Expr | None:
for decorator in decorators:
if isinstance(decorator.value, Expr) and decorator.value.canonical_path == "dataclasses.dataclass":
return decorator.value
return None
def _expr_args(expr: Expr) -> dict[str, str | Expr]:
args = {}
if isinstance(expr, ExprCall):
for argument in expr.arguments:
try:
args[argument.name] = argument.value # ty:ignore[unresolved-attribute]
except AttributeError:
# Argument is a unpacked variable.
with suppress(Exception):
collection = expr.function.parent.modules_collection # ty:ignore[unresolved-attribute]
var = collection[argument.value.canonical_path] # ty:ignore[unresolved-attribute]
args.update(_expr_args(var.value))
elif isinstance(expr, ExprDict):
args.update({ast.literal_eval(str(key)): value for key, value in zip(expr.keys, expr.values, strict=False)})
return args
def _dataclass_arguments(decorators: list[Decorator]) -> dict[str, Any]:
if (expr := _dataclass_decorator(decorators)) and isinstance(expr, ExprCall):
return _expr_args(expr)
return {}
def _field_arguments(attribute: Attribute) -> dict[str, Any]:
if attribute.value:
value = attribute.value
if isinstance(value, ExprAttribute):
value = value.last
if isinstance(value, ExprCall) and value.canonical_path == "dataclasses.field":
return _expr_args(value)
return {}
@cache
def _dataclass_parameters(class_: Class) -> list[Parameter]:
# Fetch `@dataclass` arguments if any.
dec_args = _dataclass_arguments(class_.decorators)
# Parameters not added to `__init__`, return empty list.
if dec_args.get("init") == "False":
return []
# All parameters marked as keyword-only.
kw_only = dec_args.get("kw_only") == "True"
# Iterate on current attributes to find parameters.
parameters = []
for member in class_.members.values():
if member.is_attribute:
member = cast("Attribute", member)
# All dataclass parameters have annotations.
if member.annotation is None:
continue
# Attributes that have labels for these characteristics are
# not class parameters:
# - @property
# - @cached_property
# - ClassVar annotation
if "property" in member.labels or (
# TODO: It is better to explicitly check for `ClassVar`, but
# `Visitor.handle_attribute` unwraps it from the annotation.
# Maybe create `internal_labels` and store "classvar" in there.
"class-attribute" in member.labels and "instance-attribute" not in member.labels
):
continue
# Start of keyword-only parameters.
if isinstance(member.annotation, Expr) and member.annotation.canonical_path == "dataclasses.KW_ONLY":
kw_only = True
continue
# Fetch `field` arguments if any.
field_args = _field_arguments(member)
# Parameter not added to `__init__`, skip it.
if field_args.get("init") == "False":
continue
# Determine parameter kind.
kind = (
ParameterKind.keyword_only
if kw_only or field_args.get("kw_only") == "True"
else ParameterKind.positional_or_keyword
)
# Determine parameter default.
if "default_factory" in field_args:
default: str | Expr | None = ExprCall(function=field_args["default_factory"], arguments=[])
else:
default = field_args.get("default", None if field_args else member.value)
# Add parameter to the list.
parameters.append(
Parameter(
member.name,
annotation=member.annotation,
kind=kind,
default=default,
docstring=member.docstring,
),
)
return parameters
def _reorder_parameters(parameters: list[Parameter]) -> list[Parameter]:
# De-duplicate, overwriting previous parameters.
params_dict = {param.name: param for param in parameters}
# Re-order, putting positional-only in front and keyword-only at the end.
pos_only = []
pos_kw = []
kw_only = []
for param in params_dict.values():
if param.kind is ParameterKind.positional_only:
pos_only.append(param)
elif param.kind is ParameterKind.keyword_only:
kw_only.append(param)
else:
pos_kw.append(param)
return pos_only + pos_kw + kw_only
def _set_dataclass_init(class_: Class) -> None:
# Retrieve parameters from all parent dataclasses.
parameters = []
try:
mro = class_.mro()
except ValueError:
mro = ()
for parent in reversed(mro):
if _dataclass_decorator(parent.decorators):
parameters.extend(_dataclass_parameters(parent))
# At least one parent dataclass makes the current class a dataclass:
# that's how `dataclasses.is_dataclass` works.
class_.labels.add("dataclass")
# If the class is not decorated with `@dataclass`, skip it.
if not _dataclass_decorator(class_.decorators):
return
logger.debug("Handling dataclass: %s", class_.path)
# Add current class parameters.
parameters.extend(_dataclass_parameters(class_))
# Create `__init__` method with re-ordered parameters.
init = Function(
"__init__",
lineno=0,
endlineno=0,
parent=class_,
parameters=Parameters(
Parameter(name="self", annotation=None, kind=ParameterKind.positional_or_keyword, default=None),
*_reorder_parameters(parameters),
),
returns="None",
)
class_.set_member("__init__", init)
def _del_members_annotated_as_initvar(class_: Class) -> None:
# Definitions annotated as InitVar are not class members.
attributes = [member for member in class_.members.values() if isinstance(member, Attribute)]
for attribute in attributes:
if isinstance(attribute.annotation, Expr) and attribute.annotation.canonical_path == "dataclasses.InitVar":
class_.del_member(attribute.name)
def _apply_recursively(mod_cls: Module | Class, processed: set[str]) -> None:
if mod_cls.canonical_path in processed:
return
processed.add(mod_cls.canonical_path)
if isinstance(mod_cls, Class):
if "__init__" not in mod_cls.members:
_set_dataclass_init(mod_cls)
_del_members_annotated_as_initvar(mod_cls)
for member in mod_cls.members.values():
if not member.is_alias and member.is_class:
_apply_recursively(member, processed) # ty:ignore[invalid-argument-type]
elif isinstance(mod_cls, Module):
for member in mod_cls.members.values():
if not member.is_alias and (member.is_module or member.is_class):
_apply_recursively(member, processed) # ty:ignore[invalid-argument-type]
class DataclassesExtension(Extension):
"""Built-in extension adding support for dataclasses.
This extension creates `__init__` methods of dataclasses
if they don't already exist.
"""
def on_package(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002
"""Hook for loaded packages.
Parameters:
pkg: The loaded package.
"""
_apply_recursively(pkg, set())
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/models.py 0000664 0001750 0001750 00000304241 15246557273 025553 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains our models definitions,
# to represent Python objects (and other aspects of Python APIs)... in Python.
from __future__ import annotations
import inspect
from collections import defaultdict
from contextlib import suppress
from contextvars import ContextVar
from dataclasses import asdict
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING, Any, Literal, cast
from griffe._internal.c3linear import c3linear_merge
from griffe._internal.docstrings.parsers import DocstringOptions, DocstringStyle, parse
from griffe._internal.enumerations import Kind, ParameterKind, Parser, TypeParameterKind
from griffe._internal.exceptions import AliasResolutionError, BuiltinModuleError, CyclicAliasError, NameResolutionError
from griffe._internal.expressions import ExprCall, ExprName, ExprTuple
from griffe._internal.logger import logger
from griffe._internal.mixins import ObjectAliasMixin
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from griffe._internal.collections import LinesCollection, ModulesCollection
from griffe._internal.docstrings.models import DocstringSection
from griffe._internal.expressions import Expr
from griffe._internal.git import GitInfo
from functools import cached_property
class _ModuleSerializationContext:
"""Values shared by objects serialized from the same module."""
__slots__ = (
"_source_filepaths",
"filepath",
"git_info",
"module",
"relative_filepath",
"relative_package_filepath",
)
def __init__(self, module: Module) -> None:
self.module = module
self.filepath = module.filepath
self.git_info = module.git_info
self.relative_package_filepath = module.relative_package_filepath
try:
self.relative_filepath: Path | None = module.relative_filepath
except ValueError:
self.relative_filepath = None
self._source_filepaths: dict[Path, Path] = {}
def relative_source_filepath(self, filepath: Path, repository: Path) -> Path:
"""Return the module file path relative to a Git repository."""
try:
return self._source_filepaths[repository]
except KeyError:
relative_filepath = filepath.relative_to(repository)
self._source_filepaths[repository] = relative_filepath
return relative_filepath
_module_serialization_context: ContextVar[_ModuleSerializationContext | None] = ContextVar(
"_module_serialization_context",
default=None,
)
class Decorator:
"""This class represents decorators."""
def __init__(self, value: str | Expr, *, lineno: int | None, endlineno: int | None) -> None:
"""Initialize the decorator.
Parameters:
value: The decorator code.
lineno: The starting line number.
endlineno: The ending line number.
"""
self.value: str | Expr = value
"""The decorator value (as a Griffe expression or string)."""
self.lineno: int | None = lineno
"""The starting line number of the decorator."""
self.endlineno: int | None = endlineno
"""The ending line number of the decorator."""
@property
def callable_path(self) -> str:
"""The path of the callable used as decorator."""
value = self.value.function if isinstance(self.value, ExprCall) else self.value
return value if isinstance(value, str) else value.canonical_path
def as_dict(self, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
"""Return this decorator's data as a dictionary.
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
return {
"value": self.value,
"lineno": self.lineno,
"endlineno": self.endlineno,
}
class Docstring:
"""This class represents docstrings."""
def __init__(
self,
value: str,
*,
lineno: int | None = None,
endlineno: int | None = None,
parent: Object | None = None,
parser: DocstringStyle | Parser | None = None,
parser_options: DocstringOptions | None = None,
) -> None:
"""Initialize the docstring.
Parameters:
value: The docstring value.
lineno: The starting line number.
endlineno: The ending line number.
parent: The parent object on which this docstring is attached.
parser: The docstring parser to use. By default, no parsing is done.
parser_options: Additional docstring parsing options.
"""
self.value: str = inspect.cleandoc(value.rstrip())
"""The original value of the docstring, cleaned by `inspect.cleandoc`.
See also: [`source`][griffe.Docstring.source].
"""
self.lineno: int | None = lineno
"""The starting line number of the docstring.
See also: [`endlineno`][griffe.Docstring.endlineno]."""
self.endlineno: int | None = endlineno
"""The ending line number of the docstring.
See also: [`lineno`][griffe.Docstring.lineno]."""
self.parent: Object | None = parent
"""The object this docstring is attached to."""
self.parser: DocstringStyle | Parser | None = parser
"""The selected docstring parser.
See also: [`parser_options`][griffe.Docstring.parser_options],
[`parse`][griffe.Docstring.parse].
"""
self.parser_options: DocstringOptions = parser_options or {}
"""The configured parsing options.
See also: [`parser`][griffe.Docstring.parser],
[`parse`][griffe.Docstring.parse].
"""
@property
def lines(self) -> list[str]:
"""The lines of the docstring.
See also: [`source`][griffe.Docstring.source].
"""
return self.value.split("\n")
@property
def source(self) -> str:
"""The original, uncleaned value of the docstring as written in the source.
It is a simple concatenation of the source lines. These source lines will include
quotes (single/double/triple) and might include leading whitespace and indentation,
as well as trailing comments.
Raises:
ValueError: If the original docstring cannot be retrieved
(no parent, no line numbers, or attached to namespace package).
See also: [`value`][griffe.Docstring.value].
"""
if self.parent is None:
raise ValueError("Cannot get original docstring without parent object")
if isinstance(self.parent.filepath, list):
raise ValueError("Cannot get original docstring for namespace package") # noqa: TRY004
if self.lineno is None or self.endlineno is None:
raise ValueError("Cannot get original docstring without line numbers")
return "\n".join(self.parent.lines_collection[self.parent.filepath][self.lineno - 1 : self.endlineno])
@cached_property
def parsed(self) -> list[DocstringSection]:
"""The docstring sections, parsed into structured data."""
return self.parse()
def parse(
self,
parser: DocstringStyle | Parser | None = None,
**options: Any,
) -> list[DocstringSection]:
"""Parse the docstring into structured data.
See also: [`parser`][griffe.Docstring.parser],
[`parser_options`][griffe.Docstring.parser_options].
Parameters:
parser: The docstring parser to use.
In order: use the given parser, or the self parser, or no parser (return a single text section).
**options: Additional docstring parsing options.
Returns:
The parsed docstring as a list of sections.
"""
return parse(self, parser or self.parser, **(options or self.parser_options))
def as_dict(
self,
*,
full: bool = False,
**kwargs: Any, # noqa: ARG002
) -> dict[str, Any]:
"""Return this docstring's data as a dictionary.
Parameters:
full: Whether to return full info, or just base info.
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base: dict[str, Any] = {
"value": self.value,
"lineno": self.lineno,
"endlineno": self.endlineno,
}
if full:
base["parsed"] = self.parsed
return base
class Parameter: # noqa: PLW1641
"""This class represent a function parameter.
See also: [`Parameters`][griffe.Parameters].
"""
def __init__(
self,
name: str,
*,
annotation: str | Expr | None = None,
kind: ParameterKind | None = None,
default: str | Expr | None = None,
docstring: Docstring | None = None,
) -> None:
"""Initialize the parameter.
Parameters:
name: The parameter name, without leading stars (`*` or `**`).
annotation: The parameter annotation, if any.
kind: The parameter kind.
default: The parameter default, if any.
docstring: The parameter docstring.
"""
self.name: str = name
"""The parameter name."""
self.annotation: str | Expr | None = annotation
"""The parameter type annotation."""
self.kind: ParameterKind | None = kind
"""The parameter kind."""
self.default: str | Expr | None = default
"""The parameter default value."""
self.docstring: Docstring | None = docstring
"""The parameter docstring."""
# The parent function is set in `Function.__init__`,
# when the parameters are assigned to the function.
self.function: Function | None = None
"""The parent function of the parameter."""
def __str__(self) -> str:
param = f"{self.name}: {self.annotation} = {self.default}"
if self.kind:
return f"[{self.kind.value}] {param}"
return param
def __repr__(self) -> str:
return f"Parameter(name={self.name!r}, annotation={self.annotation!r}, kind={self.kind!r}, default={self.default!r})"
def __eq__(self, value: object, /) -> bool:
"""Parameters are equal if all their attributes except `docstring` and `function` are equal."""
if not isinstance(value, Parameter):
return NotImplemented
return (
self.name == value.name
and self.annotation == value.annotation
and self.kind == value.kind
and self.default == value.default
)
@property
def required(self) -> bool:
"""Whether this parameter is required."""
return self.default is None
def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
"""Return this parameter's data as a dictionary.
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base: dict[str, Any] = {
"name": self.name,
"annotation": self.annotation,
"kind": self.kind,
"default": self.default,
}
if self.docstring:
base["docstring"] = self.docstring.as_dict(full=full)
return base
class Parameters:
"""This class is a container for parameters.
It allows to get parameters using their position (index) or their name:
```pycon
>>> parameters = Parameters(Parameter("hello"))
>>> parameters[0] is parameters["hello"]
True
```
See also: [`Parameter`][griffe.Parameter].
"""
def __init__(self, *parameters: Parameter) -> None:
"""Initialize the parameters container.
Parameters:
*parameters: The initial parameters to add to the container.
"""
self._params: list[Parameter] = list(parameters)
def __repr__(self) -> str:
return f"Parameters({', '.join(repr(param) for param in self._params)})"
def __getitem__(self, name_or_index: int | str) -> Parameter:
"""Get a parameter by index or name."""
if isinstance(name_or_index, int):
return self._params[name_or_index]
name = name_or_index.lstrip("*")
try:
return next(param for param in self._params if param.name == name)
except StopIteration as error:
raise KeyError(f"parameter {name_or_index} not found") from error
def __setitem__(self, name_or_index: int | str, parameter: Parameter) -> None:
"""Set a parameter by index or name."""
if isinstance(name_or_index, int):
self._params[name_or_index] = parameter
else:
name = name_or_index.lstrip("*")
try:
index = next(idx for idx, param in enumerate(self._params) if param.name == name)
except StopIteration:
self._params.append(parameter)
else:
self._params[index] = parameter
def __delitem__(self, name_or_index: int | str) -> None:
"""Delete a parameter by index or name."""
if isinstance(name_or_index, int):
del self._params[name_or_index]
else:
name = name_or_index.lstrip("*")
try:
index = next(idx for idx, param in enumerate(self._params) if param.name == name)
except StopIteration as error:
raise KeyError(f"parameter {name_or_index} not found") from error
del self._params[index]
def __len__(self):
"""The number of parameters."""
return len(self._params)
def __iter__(self):
"""Iterate over the parameters, in order."""
return iter(self._params)
def __contains__(self, param_name: str):
"""Whether a parameter with the given name is present."""
try:
next(param for param in self._params if param.name == param_name.lstrip("*"))
except StopIteration:
return False
return True
def add(self, parameter: Parameter) -> None:
"""Add a parameter to the container.
Parameters:
parameter: The function parameter to add.
Raises:
ValueError: When a parameter with the same name is already present.
"""
if parameter.name in self:
raise ValueError(f"parameter {parameter.name} already present")
self._params.append(parameter)
class TypeParameter:
"""This class represents a type parameter."""
def __init__(
self,
name: str,
*,
kind: TypeParameterKind,
bound: str | Expr | None = None,
constraints: Sequence[str | Expr] | None = None,
default: str | Expr | None = None,
) -> None:
"""Initialize the type parameter.
Parameters:
name: The type parameter name, without leading stars (`*` or `**`).
kind: The type parameter kind.
bound: The type parameter bound, if any.
Mutually exclusive with `constraints`.
constraints: The type parameter constraints, if any.
Mutually exclusive with `bound`.
default: The type parameter default, if any.
Raises:
ValueError: When more than one of `bound` and `constraints` is set.
"""
if bound is not None and constraints:
raise ValueError("bound and constraints are mutually exclusive")
self.name: str = name
"""The type parameter name."""
self.kind: TypeParameterKind = kind
"""The type parameter kind."""
self.annotation: str | Expr | None
"""The type parameter bound or constraints."""
if constraints:
self.constraints = constraints
else:
self.bound = bound
self.default: str | Expr | None = default
"""The type parameter default value."""
def __repr__(self) -> str:
return f"TypeParameter(name={self.name!r}, kind={self.kind!r}, bound={self.annotation!r}, default={self.default!r})"
@property
def bound(self) -> str | Expr | None:
"""The type parameter bound."""
if not isinstance(self.annotation, ExprTuple):
return self.annotation
return None
@bound.setter
def bound(self, bound: str | Expr | None) -> None:
self.annotation = bound
@property
def constraints(self) -> tuple[str | Expr, ...] | None:
"""The type parameter constraints."""
if isinstance(self.annotation, ExprTuple):
return tuple(self.annotation.elements)
return None
@constraints.setter
def constraints(self, constraints: Sequence[str | Expr] | None) -> None:
if constraints is not None:
self.annotation = ExprTuple(constraints)
else:
self.annotation = None
def as_dict(self, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
"""Return this type parameter's data as a dictionary.
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base: dict[str, Any] = {
"name": self.name,
"kind": self.kind,
"annotation": self.annotation,
"default": self.default,
}
return base
class TypeParameters:
"""This class is a container for type parameters.
It allows to get type parameters using their position (index) or their name:
```pycon
>>> type_parameters = TypeParameters(TypeParameter("hello"), kind=TypeParameterKind.type_var)
>>> type_parameters[0] is type_parameters["hello"]
True
```
"""
def __init__(self, *type_parameters: TypeParameter) -> None:
"""Initialize the type parameters container.
Parameters:
*type_parameters: The initial type parameters to add to the container.
"""
self._type_params: list[TypeParameter] = list(type_parameters)
def __repr__(self) -> str:
return f"TypeParameters({', '.join(repr(type_param) for type_param in self._type_params)})"
def __getitem__(self, name_or_index: int | str) -> TypeParameter:
"""Get a type parameter by index or name."""
if isinstance(name_or_index, int):
return self._type_params[name_or_index]
name = name_or_index.lstrip("*")
try:
return next(param for param in self._type_params if param.name == name)
except StopIteration as error:
raise KeyError(f"type parameter {name_or_index} not found") from error
def __setitem__(self, name_or_index: int | str, type_parameter: TypeParameter) -> None:
"""Set a type parameter by index or name."""
if isinstance(name_or_index, int):
self._type_params[name_or_index] = type_parameter
else:
name = name_or_index.lstrip("*")
try:
index = next(idx for idx, param in enumerate(self._type_params) if param.name == name)
except StopIteration:
self._type_params.append(type_parameter)
else:
self._type_params[index] = type_parameter
def __delitem__(self, name_or_index: int | str) -> None:
"""Delete a type parameter by index or name."""
if isinstance(name_or_index, int):
del self._type_params[name_or_index]
else:
name = name_or_index.lstrip("*")
try:
index = next(idx for idx, param in enumerate(self._type_params) if param.name == name)
except StopIteration as error:
raise KeyError(f"type parameter {name_or_index} not found") from error
del self._type_params[index]
def __len__(self):
"""The number of type parameters."""
return len(self._type_params)
def __iter__(self):
"""Iterate over the type parameters, in order."""
return iter(self._type_params)
def __contains__(self, type_param_name: str):
"""Whether a type parameter with the given name is present."""
try:
next(param for param in self._type_params if param.name == type_param_name.lstrip("*"))
except StopIteration:
return False
return True
def add(self, type_parameter: TypeParameter) -> None:
"""Add a type parameter to the container.
Parameters:
type_parameter: The function parameter to add.
Raises:
ValueError: When a type parameter with the same name is already present.
"""
if type_parameter.name in self:
raise ValueError(f"type parameter {type_parameter.name} already present")
self._type_params.append(type_parameter)
class Object(ObjectAliasMixin):
"""An abstract class representing a Python object."""
kind: Kind
"""The object kind."""
is_alias: bool = False
"""Always false for objects."""
is_collection: bool = False
"""Always false for objects."""
inherited: bool = False
"""Always false for objects.
Only aliases can be marked as inherited.
"""
def __init__(
self,
name: str,
*,
lineno: int | None = None,
endlineno: int | None = None,
runtime: bool = True,
docstring: Docstring | None = None,
type_parameters: TypeParameters | None = None,
parent: Module | Class | None = None,
lines_collection: LinesCollection | None = None,
modules_collection: ModulesCollection | None = None,
git_info: GitInfo | None = None,
analysis: Literal["static", "dynamic"] | None = None,
) -> None:
"""Initialize the object.
Parameters:
name: The object name, as declared in the code.
lineno: The object starting line, or None for modules. Lines start at 1.
endlineno: The object ending line (inclusive), or None for modules.
runtime: Whether this object is present at runtime or not.
docstring: The object docstring.
type_parameters: The object type parameters, if any.
parent: The object parent.
lines_collection: A collection of source code lines.
modules_collection: A collection of modules.
git_info: Git information.
analysis: The type of analysis used to load this object.
None means the object was created manually.
"""
self.name: str = name
"""The object name."""
self.lineno: int | None = lineno
"""The starting line number of the object.
See also: [`endlineno`][griffe.Object.endlineno].
"""
self.endlineno: int | None = endlineno
"""The ending line number of the object.
See also: [`lineno`][griffe.Object.lineno].
"""
self.docstring: Docstring | None = docstring
"""The object docstring.
See also: [`has_docstring`][griffe.Object.has_docstring],
[`has_docstrings`][griffe.Object.has_docstrings].
"""
# TODO: Maybe move these into `Class` and `Function`.
# Then always return them in `Class` and `Function`'s `as_dict` methods,
# and remove the conditional in the `_load_class` and `_load_function` decoders.
self.type_parameters: TypeParameters = type_parameters or TypeParameters()
"""The object type parameters."""
self.parent: Module | Class | None = parent
"""The parent of the object (none if top module)."""
self.members: dict[str, Object | Alias] = {}
"""The object members (modules, classes, functions, attributes, type aliases).
See also: [`inherited_members`][griffe.Object.inherited_members],
[`get_member`][griffe.Object.get_member],
[`set_member`][griffe.Object.set_member],
[`filter_members`][griffe.Object.filter_members].
"""
self.labels: set[str] = set()
"""The object labels (`property`, `dataclass`, etc.).
See also: [`has_labels`][griffe.Object.has_labels]."""
self.imports: dict[str, str] = {}
"""The other objects imported by this object.
Keys are the names within the object (`from ... import ... as AS_NAME`),
while the values are the actual names of the objects (`from ... import REAL_NAME as ...`).
"""
self.exports: list[str | ExprName] | None = None
"""The names of the objects exported by this (module) object through the `__all__` variable.
Exports can contain string (object names) or resolvable names,
like other lists of exports coming from submodules:
```python
from .submodule import __all__ as submodule_all
__all__ = ["hello", *submodule_all]
```
Exports get expanded by the loader before it expands wildcards and resolves aliases.
See also: [`GriffeLoader.expand_exports`][griffe.GriffeLoader.expand_exports].
"""
self.aliases: dict[str, Alias] = {}
"""The aliases pointing to this object."""
self.runtime: bool = runtime
"""Whether this object is available at runtime.
Typically, type-guarded objects (under an `if TYPE_CHECKING` condition)
are not available at runtime.
"""
self.extra: dict[str, dict[str, Any]] = defaultdict(dict)
"""Namespaced dictionaries storing extra metadata for this object, used by extensions."""
self.public: bool | None = None
"""Whether this object is public."""
self.deprecated: bool | str | None = None
"""Whether this object is deprecated (boolean or deprecation message)."""
self.analysis: Literal["static", "dynamic"] | None = analysis
"""The type of analysis used to load this object.
None means the object was created manually.
"""
self._lines_collection: LinesCollection | None = lines_collection
self._modules_collection: ModulesCollection | None = modules_collection
self._git_info: GitInfo | None = git_info
self._source_link: str | None = None
# Attach the docstring to this object.
if docstring:
docstring.parent = self
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.name!r}, {self.lineno!r}, {self.endlineno!r})"
# Prevent using `__len__`.
def __bool__(self) -> bool:
"""An object is always true-ish."""
return True
def __len__(self) -> int:
"""The number of members in this object, recursively."""
return len(self.members) + sum(len(member) for member in self.members.values())
@property
def git_info(self) -> GitInfo | None:
"""Git information for this object, if available."""
if self._git_info is not None or self.parent is None:
return self._git_info
return self.parent.git_info
@git_info.setter
def git_info(self, value: GitInfo | None) -> None:
"""Set the Git information for this object."""
self._git_info = value
@property
def source_link(self) -> str | None:
"""Source link for this object, if available."""
return self._get_source_link()
def _get_source_link(self, context: _ModuleSerializationContext | None = None) -> str | None:
if self._source_link is not None:
return self._source_link
with suppress(BuiltinModuleError, ValueError):
if context is None:
git_info = self.git_info
filepath = self.filepath
else:
git_info = self._git_info or context.git_info
filepath = context.filepath
if git_info and isinstance(filepath, Path):
# We don't use `self.relative_filepath` because it is computed
# relative to the current working directory, which isn't what we want.
if context is None:
filepath = filepath.relative_to(git_info.repository)
else:
filepath = context.relative_source_filepath(filepath, git_info.repository)
if self.lineno is not None and self.endlineno is not None:
self._source_link = git_info.get_source_link(filepath, self.lineno, self.endlineno)
return self._source_link
@source_link.setter
def source_link(self, value: str | None) -> None:
"""Set the source link for this object."""
self._source_link = value
@property
def has_docstring(self) -> bool:
"""Whether this object has a docstring (empty or not).
See also: [`docstring`][griffe.Object.docstring],
[`has_docstrings`][griffe.Object.has_docstrings].
"""
return bool(self.docstring)
# NOTE: (pawamoy) I'm not happy with `has_docstrings`.
# It currently recurses into submodules, but that doesn't make sense
# if downstream projects use it to know if they should render an init module
# while not rendering submodules too: the property could tell them there are
# docstrings, but they could be in submodules, not in the init module.
# Maybe we should derive it into new properties: `has_local_docstrings`,
# `has_docstrings`, `has_public_docstrings`... Maybe we should make it a function?`
# For now it's used in mkdocstrings-python so we must be careful with changes.
@property
def has_docstrings(self) -> bool:
"""Whether this object or any of its members has a docstring (empty or not).
Inherited members are not considered. Imported members are not considered,
unless they are also public.
See also: [`docstring`][griffe.Object.docstring],
[`has_docstring`][griffe.Object.has_docstring].
"""
if self.has_docstring:
return True
for member in self.members.values():
try:
if (not member.is_imported or member.is_public) and member.has_docstrings:
return True
except AliasResolutionError:
continue
return False
def is_kind(self, kind: str | Kind | set[str | Kind]) -> bool:
"""Tell if this object is of the given kind.
See also: [`is_module`][griffe.Object.is_module],
[`is_class`][griffe.Object.is_class],
[`is_function`][griffe.Object.is_function],
[`is_attribute`][griffe.Object.is_attribute],
[`is_type_alias`][griffe.Object.is_type_alias],
[`is_alias`][griffe.Object.is_alias].
Parameters:
kind: An instance or set of kinds (strings or enumerations).
Raises:
ValueError: When an empty set is given as argument.
Returns:
True or False.
"""
if isinstance(kind, set):
if not kind:
raise ValueError("kind must not be an empty set")
return self.kind in (knd if isinstance(knd, Kind) else Kind(knd) for knd in kind)
if isinstance(kind, str):
kind = Kind(kind)
return self.kind is kind
@property
def inherited_members(self) -> dict[str, Alias]:
"""Members that are inherited from base classes.
This method is part of the consumer API:
do not use when producing Griffe trees!
See also: [`members`][griffe.Object.members].
"""
if not isinstance(self, Class):
return {}
try:
mro = self.mro()
except ValueError as error:
logger.debug(error)
return {}
inherited_members = {}
for base in reversed(mro):
for name, member in base.members.items():
if name not in self.members:
inherited_members[name] = Alias(name, member, parent=self, inherited=True)
return inherited_members
@property
def is_module(self) -> bool:
"""Whether this object is a module.
See also: [`is_init_module`][griffe.Object.is_init_module].
[`is_class`][griffe.Object.is_class],
[`is_function`][griffe.Object.is_function],
[`is_attribute`][griffe.Object.is_attribute],
[`is_type_alias`][griffe.Object.is_type_alias],
[`is_alias`][griffe.Object.is_alias],
[`is_kind`][griffe.Object.is_kind].
"""
return self.kind is Kind.MODULE
@property
def is_class(self) -> bool:
"""Whether this object is a class.
See also: [`is_module`][griffe.Object.is_module].
[`is_function`][griffe.Object.is_function],
[`is_attribute`][griffe.Object.is_attribute],
[`is_type_alias`][griffe.Object.is_type_alias],
[`is_alias`][griffe.Object.is_alias],
[`is_kind`][griffe.Object.is_kind].
"""
return self.kind is Kind.CLASS
@property
def is_function(self) -> bool:
"""Whether this object is a function.
See also: [`is_module`][griffe.Object.is_module].
[`is_class`][griffe.Object.is_class],
[`is_attribute`][griffe.Object.is_attribute],
[`is_type_alias`][griffe.Object.is_type_alias],
[`is_alias`][griffe.Object.is_alias],
[`is_kind`][griffe.Object.is_kind].
"""
return self.kind is Kind.FUNCTION
@property
def is_attribute(self) -> bool:
"""Whether this object is an attribute.
See also: [`is_module`][griffe.Object.is_module].
[`is_class`][griffe.Object.is_class],
[`is_function`][griffe.Object.is_function],
[`is_type_alias`][griffe.Object.is_type_alias],
[`is_alias`][griffe.Object.is_alias],
[`is_kind`][griffe.Object.is_kind].
"""
return self.kind is Kind.ATTRIBUTE
@property
def is_type_alias(self) -> bool:
"""Whether this object is a type alias.
See also: [`is_module`][griffe.Object.is_module].
[`is_class`][griffe.Object.is_class],
[`is_function`][griffe.Object.is_function],
[`is_attribute`][griffe.Object.is_attribute],
[`is_alias`][griffe.Object.is_alias],
[`is_kind`][griffe.Object.is_kind].
"""
return self.kind is Kind.TYPE_ALIAS
@property
def is_init_method(self) -> bool:
"""Whether this function is an `__init__` method."""
return False
@property
def is_init_module(self) -> bool:
"""Whether this object is an `__init__.py` module.
See also: [`is_module`][griffe.Object.is_module].
"""
return False
@property
def is_package(self) -> bool:
"""Whether this object is a package (top module).
See also: [`is_subpackage`][griffe.Object.is_subpackage].
"""
return False
@property
def is_subpackage(self) -> bool:
"""Whether this object is a subpackage.
See also: [`is_package`][griffe.Object.is_package].
"""
return False
@property
def is_namespace_package(self) -> bool:
"""Whether this object is a namespace package (top folder, no `__init__.py`).
See also: [`is_namespace_subpackage`][griffe.Object.is_namespace_subpackage].
"""
return False
@property
def is_namespace_subpackage(self) -> bool:
"""Whether this object is a namespace subpackage.
See also: [`is_namespace_package`][griffe.Object.is_namespace_package].
"""
return False
def has_labels(self, *labels: str) -> bool:
"""Tell if this object has all the given labels.
See also: [`labels`][griffe.Object.labels].
Parameters:
*labels: Labels that must be present.
Returns:
True or False.
"""
return set(labels).issubset(self.labels)
def filter_members(self, *predicates: Callable[[Object | Alias], bool]) -> dict[str, Object | Alias]:
"""Filter and return members based on predicates.
See also: [`members`][griffe.Object.members].
Parameters:
*predicates: A list of predicates, i.e. callables accepting a member as argument and returning a boolean.
Returns:
A dictionary of members.
"""
if not predicates:
return self.members
members: dict[str, Object | Alias] = {
name: member for name, member in self.members.items() if all(predicate(member) for predicate in predicates)
}
return members
@property
def module(self) -> Module:
"""The parent module of this object.
See also: [`package`][griffe.Object.package].
Examples:
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown["core.Markdown.references"].module
Module(PosixPath('~/project/.venv/lib/python3.11/site-packages/markdown/core.py'))
>>> # The `module` of a module is itself.
>>> markdown["core"].module
Module(PosixPath('~/project/.venv/lib/python3.11/site-packages/markdown/core.py'))
Raises:
ValueError: When the object is not a module and does not have a parent.
"""
if isinstance(self, Module):
return self
if self.parent is not None:
return self.parent.module
raise ValueError(f"Object {self.name} does not have a parent module")
@property
def package(self) -> Module:
"""The absolute top module (the package) of this object.
See also: [`module`][griffe.Object.module].
Examples:
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown["core.Markdown.references"].package
Module(PosixPath('~/project/.venv/lib/python3.11/site-packages/markdown/__init__.py'))
"""
module = self.module
while module.parent:
module = module.parent
return module # ty:ignore[invalid-return-type]
@property
def filepath(self) -> Path | list[Path]:
"""The file path (or directory list for namespace packages) where this object was defined.
See also: [`relative_filepath`][griffe.Object.relative_filepath],
[`relative_package_filepath`][griffe.Object.relative_package_filepath].
Examples:
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown.filepath
PosixPath('~/project/.venv/lib/python3.11/site-packages/markdown/__init__.py')
"""
return self.module.filepath
@property
def relative_package_filepath(self) -> Path:
"""The file path where this object was defined, relative to the top module path.
See also: [`filepath`][griffe.Object.filepath],
[`relative_filepath`][griffe.Object.relative_filepath].
Raises:
ValueError: When the relative path could not be computed.
"""
package_path = self.package.filepath
# Current "module" is a namespace package.
if isinstance(self.filepath, list):
# Current package is a namespace package.
if isinstance(package_path, list):
for pkg_path in package_path:
for self_path in self.filepath:
with suppress(ValueError):
return self_path.relative_to(pkg_path.parent)
# Current package is a regular package.
# NOTE: Technically it makes no sense to have a namespace package
# under a non-namespace one, so we should never enter this branch.
else:
for self_path in self.filepath:
with suppress(ValueError):
return self_path.relative_to(package_path.parent.parent)
raise ValueError
# Current package is a namespace package,
# and current module is a regular module or package.
if isinstance(package_path, list):
for pkg_path in package_path:
with suppress(ValueError):
return self.filepath.relative_to(pkg_path.parent)
raise ValueError
# Current package is a regular package,
# and current module is a regular module or package,
# try to compute the path relative to the parent folder
# of the package (search path).
return self.filepath.relative_to(package_path.parent.parent)
@property
def relative_filepath(self) -> Path:
"""The file path where this object was defined, relative to the current working directory.
If this object's file path is not relative to the current working directory, return its absolute path.
See also: [`filepath`][griffe.Object.filepath],
[`relative_package_filepath`][griffe.Object.relative_package_filepath].
Raises:
ValueError: When the relative path could not be computed.
"""
cwd = Path.cwd()
if isinstance(self.filepath, list):
for self_path in self.filepath:
with suppress(ValueError):
return self_path.relative_to(cwd)
raise ValueError(f"No directory in {self.filepath!r} is relative to the current working directory {cwd}")
try:
return self.filepath.relative_to(cwd)
except ValueError:
return self.filepath
@property
def path(self) -> str:
"""The dotted path of this object.
On regular objects (not aliases), the path is the canonical path.
See also: [`canonical_path`][griffe.Object.canonical_path].
Examples:
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown["core.Markdown.references"].path
'markdown.core.Markdown.references'
"""
return self.canonical_path
@property
def canonical_path(self) -> str:
"""The full dotted path of this object.
The canonical path is the path where the object was defined (not imported).
See also: [`path`][griffe.Object.path].
"""
if self.parent is None:
return self.name
return f"{self.parent.path}.{self.name}"
@property
def modules_collection(self) -> ModulesCollection:
"""The modules collection attached to this object or its parents.
Raises:
ValueError: When no modules collection can be found in the object or its parents.
"""
if self._modules_collection is not None:
return self._modules_collection
if self.parent is None:
raise ValueError("no modules collection in this object or its parents")
return self.parent.modules_collection
@property
def lines_collection(self) -> LinesCollection:
"""The lines collection attached to this object or its parents.
See also: [`lines`][griffe.Object.lines],
[`source`][griffe.Object.source].
Raises:
ValueError: When no modules collection can be found in the object or its parents.
"""
if self._lines_collection is not None:
return self._lines_collection
if self.parent is None:
raise ValueError("no lines collection in this object or its parents")
return self.parent.lines_collection
@property
def lines(self) -> list[str]:
"""The lines containing the source of this object.
See also: [`lines_collection`][griffe.Object.lines_collection],
[`source`][griffe.Object.source].
"""
try:
filepath = self.filepath
except BuiltinModuleError:
return []
if isinstance(filepath, list):
return []
try:
lines = self.lines_collection[filepath]
except KeyError:
return []
if self.is_module:
return lines
if self.lineno is None or self.endlineno is None:
return []
return lines[self.lineno - 1 : self.endlineno]
@property
def source(self) -> str:
"""The source code of this object.
See also: [`lines`][griffe.Object.lines],
[`lines_collection`][griffe.Object.lines_collection].
"""
return dedent("\n".join(self.lines))
def resolve(self, name: str) -> str:
"""Resolve a name within this object's and parents' scope.
Parameters:
name: The name to resolve.
Raises:
NameResolutionError: When the name could not be resolved.
Returns:
The resolved name.
"""
# TODO: Better match Python's own scoping rules?
# Also, maybe return regular paths instead of canonical ones?
# Name is a type parameter.
if name in self.type_parameters:
type_parameter = self.type_parameters[name]
if type_parameter.kind is TypeParameterKind.type_var_tuple:
prefix = "*"
elif type_parameter.kind is TypeParameterKind.param_spec:
prefix = "**"
else:
prefix = ""
return f"{self.path}[{prefix}{name}]"
# Name is a member of this object.
if name in self.members:
if self.members[name].is_alias:
return self.members[name].target_path # ty:ignore[unresolved-attribute]
return self.members[name].path
# Name unknown and no more parent scope, could be a built-in.
if self.parent is None:
raise NameResolutionError(f"{name} could not be resolved in the scope of {self.path}")
# Name is parent, non-module object.
if name == self.parent.name and not self.parent.is_module:
return self.parent.path
# Recurse in parent.
return self.parent.resolve(name)
def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]:
"""Return this object's data as a dictionary.
See also: [`as_json`][griffe.Object.as_json].
Parameters:
full: Whether to return full info, or just base info.
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
current_context = _module_serialization_context.get()
context = current_context
if full and (context is None or self.kind is Kind.MODULE):
module = cast("Module", self) if self.kind is Kind.MODULE else self.module
if context is None or context.module is not module:
context = _ModuleSerializationContext(module)
base: dict[str, Any] = {
"kind": self.kind,
"name": self.name,
"runtime": self.runtime,
}
if self.public is not None:
base["public"] = self.public
if self.exports is not None:
base["exports"] = [str(export) for export in self.exports]
if self.imports:
base["imports"] = self.imports
if self.deprecated is not None:
base["deprecated"] = self.deprecated
if self.lineno is not None:
base["lineno"] = self.lineno
if self.endlineno is not None:
base["endlineno"] = self.endlineno
if self.docstring:
base["docstring"] = self.docstring
if self.type_parameters:
base["type_parameters"] = [type_param.as_dict(**kwargs) for type_param in self.type_parameters]
if self.labels:
base["labels"] = self.labels
if self.members:
context_token = _module_serialization_context.set(context) if context is not current_context else None
try:
base["members"] = {name: member.as_dict(full=full, **kwargs) for name, member in self.members.items()}
finally:
if context_token is not None:
_module_serialization_context.reset(context_token)
if self.analysis:
base["analysis"] = self.analysis
if self._git_info is not None:
base["git_info"] = asdict(self._git_info)
if self._source_link is not None:
base["source_link"] = self._source_link
# TODO: Include `self.extra`?
if full and context is not None:
base.update(
{
"path": self.path,
"filepath": context.filepath,
"relative_package_filepath": context.relative_package_filepath,
"is_public": self.is_public,
"is_deprecated": self.is_deprecated,
"is_private": self.is_private,
"is_class_private": self.is_class_private,
"is_special": self.is_special,
"is_imported": self.is_imported,
"is_exported": self.is_exported,
"is_wildcard_exposed": self.is_wildcard_exposed,
# TODO: Add these properties?
# "is_alias": self.is_alias,
# "is_collection": self.is_collection,
# "is_module": self.is_module,
# "is_class": self.is_class,
# "is_function": self.is_function,
# "is_attribute": self.is_attribute,
# "is_type_alias": self.is_type_alias,
# "is_init_module": self.is_init_module,
# "is_package": self.is_package,
# "is_subpackage": self.is_subpackage,
# "is_namespace_package": self.is_namespace_package,
# "is_namespace_subpackage": self.is_namespace_subpackage,
# "has_docstring": self.has_docstring,
# "has_docstrings": self.has_docstrings,
},
)
if context.relative_filepath is not None:
base["relative_filepath"] = context.relative_filepath
if "source_link" not in base and (source_link := self._get_source_link(context)) is not None:
base["source_link"] = source_link
return base
class Alias(ObjectAliasMixin):
"""This class represents an alias, or indirection, to an object declared in another module.
Aliases represent objects that are in the scope of a module or class,
but were imported from another module.
They behave almost exactly like regular objects, to a few exceptions:
- line numbers are those of the alias, not the target
- the path is the alias path, not the canonical one
- the name can be different from the target's
- if the target can be resolved, the kind is the target's kind
- if the target cannot be resolved, the kind becomes [Kind.ALIAS][griffe.Kind]
"""
is_alias: bool = True
"""Always true for aliases."""
is_collection: bool = False
"""Always false for aliases.
See also: [`ModulesCollection`][griffe.ModulesCollection].
"""
def __init__(
self,
name: str,
target: str | Object | Alias,
*,
lineno: int | None = None,
endlineno: int | None = None,
runtime: bool = True,
parent: Module | Class | Alias | None = None,
inherited: bool = False,
wildcard_imported: bool = False,
analysis: Literal["static", "dynamic"] | None = None,
) -> None:
"""Initialize the alias.
Parameters:
name: The alias name.
target: If it's a string, the target resolution is delayed until accessing the target property.
If it's an object, or even another alias, the target is immediately set.
lineno: The alias starting line number.
endlineno: The alias ending line number.
runtime: Whether this alias is present at runtime or not.
parent: The alias parent.
inherited: Whether this alias wraps an inherited member.
wildcard_imported: Whether this alias was created using a wildcard import.
analysis: The type of analysis used to load this alias.
None means the alias was created manually.
"""
self.name: str = name
"""The alias name."""
self.alias_lineno: int | None = lineno
"""The starting line number of the alias."""
self.alias_endlineno: int | None = endlineno
"""The ending line number of the alias."""
self.runtime: bool = runtime
"""Whether this alias is available at runtime."""
self.inherited: bool = inherited
"""Whether this alias represents an inherited member."""
self.wildcard_imported: bool = wildcard_imported
"""Whether this alias was created using a wildcard import."""
self.public: bool | None = None
"""Whether this alias is public."""
self.deprecated: str | bool | None = None
"""Whether this alias is deprecated (boolean or deprecation message)."""
self.analysis: Literal["static", "dynamic"] | None = analysis
"""The type of analysis used to load this alias.
None means the alias was created manually.
"""
self._parent: Module | Class | Alias | None = parent
self._passed_through: bool = False
self.target_path: str
"""The path of this alias' target."""
if isinstance(target, str):
self._target: Object | Alias | None = None
self.target_path = target
else:
self._target = target
self.target_path = target.path
self._update_target_aliases()
def __repr__(self) -> str:
return f"Alias({self.name!r}, {self.target_path!r})"
# Prevent using `__len__`.
def __bool__(self) -> bool:
"""An alias is always true-ish."""
return True
def __len__(self) -> int:
"""The length of an alias is always 1."""
return 1
# SPECIAL PROXIES -------------------------------
# The following methods and properties exist on the target(s),
# but we must handle them in a special way.
@property
def kind(self) -> Kind:
"""The target's kind, or `Kind.ALIAS` if the target cannot be resolved.
See also: [`is_kind`][griffe.Alias.is_kind].
"""
# custom behavior to avoid raising exceptions
try:
return self.final_target.kind
except (AliasResolutionError, CyclicAliasError):
return Kind.ALIAS
@property
def has_docstring(self) -> bool:
"""Whether this alias' target has a non-empty docstring.
See also: [`has_docstrings`][griffe.Alias.has_docstrings],
[`docstring`][griffe.Alias.docstring].
"""
try:
return self.final_target.has_docstring
except (AliasResolutionError, CyclicAliasError):
return False
@property
def has_docstrings(self) -> bool:
"""Whether this alias' target or any of its members has a non-empty docstring.
See also: [`has_docstring`][griffe.Alias.has_docstring],
[`docstring`][griffe.Alias.docstring].
"""
try:
return self.final_target.has_docstrings
except (AliasResolutionError, CyclicAliasError):
return False
@property
def parent(self) -> Module | Class | Alias | None:
"""The parent of this alias."""
return self._parent
@parent.setter
def parent(self, value: Module | Class | Alias) -> None:
self._parent = value
self._update_target_aliases()
@property
def path(self) -> str:
"""The dotted path / import path of this object.
See also: [`canonical_path`][griffe.Alias.canonical_path].
"""
return f"{self.parent.path}.{self.name}" # ty:ignore[unresolved-attribute]
@property
def modules_collection(self) -> ModulesCollection:
"""The modules collection attached to the alias parents."""
# No need to forward to the target.
return self.parent.modules_collection # ty:ignore[unresolved-attribute]
@property
def members(self) -> dict[str, Object | Alias]:
"""The target's members (modules, classes, functions, attributes, type aliases).
See also: [`inherited_members`][griffe.Alias.inherited_members],
[`get_member`][griffe.Alias.get_member],
[`set_member`][griffe.Alias.set_member],
[`filter_members`][griffe.Alias.filter_members].
"""
final_target = self.final_target
# We recreate aliases to maintain a correct hierarchy,
# and therefore correct paths. The path of an alias member
# should be the path of the alias plus the member's name,
# not the original member's path.
return {
name: Alias(name, target=member, parent=self, inherited=False)
for name, member in final_target.members.items()
}
@property
def inherited_members(self) -> dict[str, Alias]:
"""Members that are inherited from base classes.
Each inherited member of the target will be wrapped in an alias,
to preserve correct object access paths.
This method is part of the consumer API:
do not use when producing Griffe trees!
See also: [`members`][griffe.Alias.members].
"""
final_target = self.final_target
# We recreate aliases to maintain a correct hierarchy,
# and therefore correct paths. The path of an alias member
# should be the path of the alias plus the member's name,
# not the original member's path.
return {
name: Alias(name, target=member, parent=self, inherited=True)
for name, member in final_target.inherited_members.items()
}
def as_json(self, *, full: bool = False, **kwargs: Any) -> str:
"""Return this target's data as a JSON string.
See also: [`as_dict`][griffe.Alias.as_dict].
Parameters:
full: Whether to return full info, or just base info.
**kwargs: Additional serialization options passed to encoder.
Returns:
A JSON string.
"""
try:
return self.final_target.as_json(full=full, **kwargs)
except (AliasResolutionError, CyclicAliasError):
return super().as_json(full=full, **kwargs)
# GENERIC OBJECT PROXIES --------------------------------
# The following methods and properties exist on the target(s).
# We first try to reach the final target, triggering alias resolution errors
# and cyclic aliases errors early. We avoid recursing in the alias chain.
@property
def git_info(self) -> GitInfo | None:
"""Get the Git information for this object, if available."""
return self.final_target.git_info
@git_info.setter
def git_info(self, value: GitInfo | None) -> None:
"""Set the Git information for this object."""
self.final_target.git_info = value
@property
def source_link(self) -> str | None:
"""Get the source link for this object, if available."""
return self.final_target.source_link
@source_link.setter
def source_link(self, value: str | None) -> None:
"""Set the source link for this object."""
self.final_target.source_link = value
@property
def extra(self) -> dict:
"""Namespaced dictionaries storing extra metadata for this object, used by extensions."""
return self.final_target.extra
@property
def lineno(self) -> int | None:
"""The starting line number of the target object.
See also: [`endlineno`][griffe.Alias.endlineno].
"""
return self.final_target.lineno
@lineno.setter
def lineno(self, lineno: int | None) -> None:
self.final_target.lineno = lineno
@property
def endlineno(self) -> int | None:
"""The ending line number of the target object.
See also: [`lineno`][griffe.Alias.lineno].
"""
return self.final_target.endlineno
@endlineno.setter
def endlineno(self, endlineno: int | None) -> None:
self.final_target.endlineno = endlineno
@property
def docstring(self) -> Docstring | None:
"""The target docstring.
See also: [`has_docstring`][griffe.Alias.has_docstring],
[`has_docstrings`][griffe.Alias.has_docstrings].
"""
return self.final_target.docstring
@docstring.setter
def docstring(self, docstring: Docstring | None) -> None:
self.final_target.docstring = docstring
@property
def type_parameters(self) -> TypeParameters:
"""The target type parameters."""
return self.final_target.type_parameters
@type_parameters.setter
def type_parameters(self, type_parameters: TypeParameters) -> None:
self.final_target.type_parameters = type_parameters
@property
def labels(self) -> set[str]:
"""The target labels (`property`, `dataclass`, etc.).
See also: [`has_labels`][griffe.Alias.has_labels].
"""
return self.final_target.labels
@property
def imports(self) -> dict[str, str]:
"""The other objects imported by this alias' target.
Keys are the names within the object (`from ... import ... as AS_NAME`),
while the values are the actual names of the objects (`from ... import REAL_NAME as ...`).
See also: [`is_imported`][griffe.Alias.is_imported].
"""
return self.final_target.imports
@property
def exports(self) -> list[str | ExprName] | None:
"""The names of the objects exported by this (module) object through the `__all__` variable.
Exports can contain string (object names) or resolvable names,
like other lists of exports coming from submodules:
```python
from .submodule import __all__ as submodule_all
__all__ = ["hello", *submodule_all]
```
Exports get expanded by the loader before it expands wildcards and resolves aliases.
See also: [`GriffeLoader.expand_exports`][griffe.GriffeLoader.expand_exports].
"""
return self.final_target.exports
@property
def aliases(self) -> dict[str, Alias]:
"""The aliases pointing to this object."""
return self.final_target.aliases
def is_kind(self, kind: str | Kind | set[str | Kind]) -> bool:
"""Tell if this object is of the given kind.
See also: [`is_module`][griffe.Alias.is_module],
[`is_class`][griffe.Alias.is_class],
[`is_function`][griffe.Alias.is_function],
[`is_attribute`][griffe.Alias.is_attribute],
[`is_type_alias`][griffe.Alias.is_type_alias],
[`is_alias`][griffe.Alias.is_alias].
Parameters:
kind: An instance or set of kinds (strings or enumerations).
Raises:
ValueError: When an empty set is given as argument.
Returns:
True or False.
"""
return self.final_target.is_kind(kind)
@property
def is_module(self) -> bool:
"""Whether this object is a module.
See also: [`is_init_module`][griffe.Alias.is_init_module].
[`is_class`][griffe.Alias.is_class],
[`is_function`][griffe.Alias.is_function],
[`is_attribute`][griffe.Alias.is_attribute],
[`is_type_alias`][griffe.Alias.is_type_alias],
[`is_alias`][griffe.Alias.is_alias],
[`is_kind`][griffe.Alias.is_kind].
"""
return self.final_target.is_module
@property
def is_class(self) -> bool:
"""Whether this object is a class.
See also: [`is_module`][griffe.Alias.is_module],
[`is_function`][griffe.Alias.is_function],
[`is_attribute`][griffe.Alias.is_attribute],
[`is_type_alias`][griffe.Alias.is_type_alias],
[`is_alias`][griffe.Alias.is_alias],
[`is_kind`][griffe.Alias.is_kind].
"""
return self.final_target.is_class
@property
def is_function(self) -> bool:
"""Whether this object is a function.
See also: [`is_module`][griffe.Alias.is_module],
[`is_class`][griffe.Alias.is_class],
[`is_attribute`][griffe.Alias.is_attribute],
[`is_type_alias`][griffe.Alias.is_type_alias],
[`is_alias`][griffe.Alias.is_alias],
[`is_kind`][griffe.Alias.is_kind].
"""
return self.final_target.is_function
@property
def is_attribute(self) -> bool:
"""Whether this object is an attribute.
See also: [`is_module`][griffe.Alias.is_module],
[`is_class`][griffe.Alias.is_class],
[`is_function`][griffe.Alias.is_function],
[`is_type_alias`][griffe.Alias.is_type_alias],
[`is_alias`][griffe.Alias.is_alias],
[`is_kind`][griffe.Alias.is_kind].
"""
return self.final_target.is_attribute
@property
def is_type_alias(self) -> bool:
"""Whether this object is a type alias.
See also: [`is_module`][griffe.Alias.is_module],
[`is_class`][griffe.Alias.is_class],
[`is_function`][griffe.Alias.is_function],
[`is_attribute`][griffe.Alias.is_attribute],
[`is_alias`][griffe.Alias.is_alias],
[`is_kind`][griffe.Alias.is_kind].
"""
return self.final_target.is_type_alias
def has_labels(self, *labels: str) -> bool:
"""Tell if this object has all the given labels.
See also: [`labels`][griffe.Alias.labels].
Parameters:
*labels: Labels that must be present.
Returns:
True or False.
"""
return self.final_target.has_labels(*labels)
def filter_members(self, *predicates: Callable[[Object | Alias], bool]) -> dict[str, Object | Alias]:
"""Filter and return members based on predicates.
See also: [`members`][griffe.Alias.members],
[`get_member`][griffe.Alias.get_member],
[`set_member`][griffe.Alias.set_member].
Parameters:
*predicates: A list of predicates, i.e. callables accepting a member as argument and returning a boolean.
Returns:
A dictionary of members.
"""
return self.final_target.filter_members(*predicates)
@property
def module(self) -> Module:
"""The parent module of this object.
See also: [`package`][griffe.Alias.package].
Raises:
ValueError: When the object is not a module and does not have a parent.
"""
return self.final_target.module
@property
def package(self) -> Module:
"""The absolute top module (the package) of this object.
See also: [`module`][griffe.Alias.module].
"""
return self.final_target.package
@property
def filepath(self) -> Path | list[Path]:
"""The file path (or directory list for namespace packages) where this object was defined.
See also: [`relative_filepath`][griffe.Alias.relative_filepath],
[`relative_package_filepath`][griffe.Alias.relative_package_filepath].
"""
return self.final_target.filepath
@property
def relative_filepath(self) -> Path:
"""The file path where this object was defined, relative to the current working directory.
If this object's file path is not relative to the current working directory, return its absolute path.
See also: [`filepath`][griffe.Alias.filepath],
[`relative_package_filepath`][griffe.Alias.relative_package_filepath].
Raises:
ValueError: When the relative path could not be computed.
"""
return self.final_target.relative_filepath
@property
def relative_package_filepath(self) -> Path:
"""The file path where this object was defined, relative to the top module path.
See also: [`filepath`][griffe.Alias.filepath],
[`relative_filepath`][griffe.Alias.relative_filepath].
Raises:
ValueError: When the relative path could not be computed.
"""
return self.final_target.relative_package_filepath
@property
def canonical_path(self) -> str:
"""The full dotted path of this object.
The canonical path is the path where the object was defined (not imported).
See also: [`path`][griffe.Alias.path].
"""
return self.final_target.canonical_path
@property
def lines_collection(self) -> LinesCollection:
"""The lines collection attached to this object or its parents.
See also: [`lines`][griffe.Alias.lines],
[`source`][griffe.Alias.source].
Raises:
ValueError: When no modules collection can be found in the object or its parents.
"""
return self.final_target.lines_collection
@property
def lines(self) -> list[str]:
"""The lines containing the source of this object.
See also: [`source`][griffe.Alias.source],
[`lines_collection`][griffe.Alias.lines_collection].
"""
return self.final_target.lines
@property
def source(self) -> str:
"""The source code of this object.
See also: [`lines`][griffe.Alias.lines],
[`lines_collection`][griffe.Alias.lines_collection].
"""
return self.final_target.source
def resolve(self, name: str) -> str:
"""Resolve a name within this object's and parents' scope.
Parameters:
name: The name to resolve.
Raises:
NameResolutionError: When the name could not be resolved.
Returns:
The resolved name.
"""
return self.final_target.resolve(name)
# SPECIFIC MODULE/CLASS/FUNCTION/ATTRIBUTE/TYPE ALIAS PROXIES ---------------
# These methods and properties exist on targets of specific kind.
# We first try to reach the final target, triggering alias resolution errors
# and cyclic aliases errors early. We avoid recursing in the alias chain.
@property
def _filepath(self) -> Path | list[Path] | None:
return cast("Module", self.final_target)._filepath
@property
def bases(self) -> list[Expr | str]:
"""The class bases.
See also: [`Class`][griffe.Class],
[`resolved_bases`][griffe.Alias.resolved_bases],
[`mro`][griffe.Alias.mro].
"""
return cast("Class", self.final_target).bases
@property
def keywords(self) -> dict[str, Expr | str]:
"""The class keywords."""
return cast("Class", self.final_target).keywords
@property
def decorators(self) -> list[Decorator]:
"""The class/function decorators.
See also: [`Function`][griffe.Function],
[`Class`][griffe.Class].
"""
return cast("Class | Function", self.target).decorators
@property
def imports_future_annotations(self) -> bool:
"""Whether this module import future annotations."""
return cast("Module", self.final_target).imports_future_annotations
@property
def is_init_method(self) -> bool:
"""Whether this method is an `__init__` method."""
return cast("Function", self.final_target).is_init_method
@property
def is_init_module(self) -> bool:
"""Whether this module is an `__init__.py` module.
See also: [`is_module`][griffe.Alias.is_module].
"""
return cast("Module", self.final_target).is_init_module
@property
def is_package(self) -> bool:
"""Whether this module is a package (top module).
See also: [`is_subpackage`][griffe.Alias.is_subpackage].
"""
return cast("Module", self.final_target).is_package
@property
def is_subpackage(self) -> bool:
"""Whether this module is a subpackage.
See also: [`is_package`][griffe.Alias.is_package].
"""
return cast("Module", self.final_target).is_subpackage
@property
def is_namespace_package(self) -> bool:
"""Whether this module is a namespace package (top folder, no `__init__.py`).
See also: [`is_namespace_subpackage`][griffe.Alias.is_namespace_subpackage].
"""
return cast("Module", self.final_target).is_namespace_package
@property
def is_namespace_subpackage(self) -> bool:
"""Whether this module is a namespace subpackage.
See also: [`is_namespace_package`][griffe.Alias.is_namespace_package].
"""
return cast("Module", self.final_target).is_namespace_subpackage
@property
def overloads(self) -> dict[str, list[Function]] | list[Function] | None:
"""The overloaded signatures declared in this class/module or for this function."""
return cast("Module | Class | Function", self.final_target).overloads
@overloads.setter
def overloads(self, overloads: list[Function] | None) -> None:
cast("Module | Class | Function", self.final_target).overloads = overloads # ty:ignore[invalid-assignment]
@property
def parameters(self) -> Parameters:
"""The parameters of the current function or `__init__` method for classes.
This property can fetch inherited members,
and therefore is part of the consumer API:
do not use when producing Griffe trees!
"""
return cast("Class | Function", self.final_target).parameters
@property
def returns(self) -> str | Expr | None:
"""The function return type annotation."""
return cast("Function", self.final_target).returns
@returns.setter
def returns(self, returns: str | Expr | None) -> None:
cast("Function", self.final_target).returns = returns
@property
def setter(self) -> Function | None:
"""The setter linked to this function (property)."""
return cast("Attribute", self.final_target).setter
@property
def deleter(self) -> Function | None:
"""The deleter linked to this function (property)."""
return cast("Attribute", self.final_target).deleter
@property
def value(self) -> str | Expr | None:
"""The attribute or type alias value."""
return cast("Attribute | TypeAlias", self.final_target).value
@value.setter
def value(self, value: str | Expr | None) -> None:
cast("Attribute", self.final_target).value = value
@property
def annotation(self) -> str | Expr | None:
"""The attribute type annotation."""
return cast("Attribute", self.final_target).annotation
@annotation.setter
def annotation(self, annotation: str | Expr | None) -> None:
cast("Attribute", self.final_target).annotation = annotation
@property
def resolved_bases(self) -> list[Object]:
"""Resolved class bases.
This method is part of the consumer API:
do not use when producing Griffe trees!
"""
return cast("Class", self.final_target).resolved_bases
def mro(self) -> list[Class]:
"""Return a list of classes in order corresponding to Python's MRO."""
return cast("Class", self.final_target).mro()
def signature(self, *, return_type: bool = False, name: str | None = None) -> str:
"""Construct the class/function signature.
Parameters:
return_type: Whether to include the return type in the signature.
name: The name of the class/function to use in the signature.
Returns:
A string representation of the class/function signature.
"""
return cast("Class | Function", self.final_target).signature(return_type=return_type, name=name)
# SPECIFIC ALIAS METHOD AND PROPERTIES -----------------
# These methods and properties do not exist on targets,
# they are specific to aliases.
@property
def target(self) -> Object | Alias:
"""The resolved target (actual object), if possible.
Upon accessing this property, if the target is not already resolved,
a lookup is done using the modules collection to find the target.
See also: [`final_target`][griffe.Alias.final_target],
[`resolve_target`][griffe.Alias.resolve_target],
[`resolved`][griffe.Alias.resolved].
"""
if not self.resolved:
self.resolve_target()
return self._target # ty:ignore[invalid-return-type]
@target.setter
def target(self, value: Object | Alias) -> None:
if value is self or value.path == self.path:
raise CyclicAliasError([self.target_path])
self._target = value
self.target_path = value.path
if self.parent is not None:
self._target.aliases[self.path] = self
@property
def final_target(self) -> Object:
"""The final, resolved target, if possible.
This will iterate through the targets until a non-alias object is found.
See also: [`target`][griffe.Alias.target],
[`resolve_target`][griffe.Alias.resolve_target],
[`resolved`][griffe.Alias.resolved].
"""
# Here we quickly iterate on the alias chain,
# remembering which path we've seen already to detect cycles.
# The cycle detection is needed because alias chains can be created
# as already resolved, and can contain cycles.
# Using a dict as an ordered set.
paths_seen: dict[str, None] = {}
target = self
while target.is_alias:
if target.path in paths_seen:
raise CyclicAliasError([*paths_seen, target.path])
paths_seen[target.path] = None
target = target.target # ty:ignore[unresolved-attribute]
return target # ty:ignore[invalid-return-type]
def resolve_target(self) -> None:
"""Resolve the target.
See also: [`target`][griffe.Alias.target],
[`final_target`][griffe.Alias.final_target],
[`resolved`][griffe.Alias.resolved].
Raises:
AliasResolutionError: When the target cannot be resolved.
It happens when the target does not exist,
or could not be loaded (unhandled dynamic object?),
or when the target is from a module that was not loaded
and added to the collection.
CyclicAliasError: When the resolved target is the alias itself.
"""
# Here we try to resolve the whole alias chain recursively.
# We detect cycles by setting a "passed through" state variable
# on each alias as we pass through it. Passing a second time
# through an alias will raise a CyclicAliasError.
# If a single link of the chain cannot be resolved,
# the whole chain stays unresolved. This prevents
# bad surprises later, in code that checks if
# an alias is resolved by checking only
# the first link of the chain.
if self._passed_through:
raise CyclicAliasError([self.target_path])
self._passed_through = True
try:
self._resolve_target()
finally:
self._passed_through = False
def _resolve_target(self) -> None:
try:
resolved = self.modules_collection.get_member(self.target_path)
except KeyError as error:
raise AliasResolutionError(self) from error
if resolved is self:
raise CyclicAliasError([self.target_path])
if resolved.is_alias and not resolved.resolved:
try:
resolved.resolve_target()
except CyclicAliasError as error:
raise CyclicAliasError([self.target_path, *error.chain]) from error
self._target = resolved
if self.parent is not None:
self._target.aliases[self.path] = self
def _update_target_aliases(self) -> None:
with suppress(AttributeError, AliasResolutionError, CyclicAliasError):
self._target.aliases[self.path] = self # ty:ignore[unresolved-attribute]
@property
def resolved(self) -> bool:
"""Whether this alias' target is resolved."""
return self._target is not None
@property
def wildcard(self) -> str | None:
"""The module on which the wildcard import is performed (if any).
See also: [`GriffeLoader.expand_wildcards`][griffe.GriffeLoader.expand_wildcards].
"""
if self.name.endswith("/*"):
return self.target_path
return None
def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
"""Return this alias' data as a dictionary.
See also: [`as_json`][griffe.Alias.as_json].
Parameters:
full: Whether to return full info, or just base info.
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base: dict[str, Any] = {
"kind": Kind.ALIAS,
"name": self.name,
"target_path": self.target_path,
"runtime": self.runtime,
"inherited": self.inherited,
}
if self.public is not None:
base["public"] = self.public
if self.deprecated is not None:
base["deprecated"] = self.deprecated
if self.alias_lineno:
base["lineno"] = self.alias_lineno
if self.alias_endlineno:
base["endlineno"] = self.alias_endlineno
if self.analysis:
base["analysis"] = self.analysis
if full:
base.update(
{
"path": self.path,
"is_public": self.is_public,
"is_deprecated": self.is_deprecated,
"is_private": self.is_private,
"is_class_private": self.is_class_private,
"is_special": self.is_special,
"is_imported": self.is_imported,
"is_exported": self.is_exported,
"is_wildcard_exposed": self.is_wildcard_exposed,
},
)
return base
class Module(Object):
"""The class representing a Python module."""
kind = Kind.MODULE
def __init__(self, *args: Any, filepath: Path | list[Path] | None = None, **kwargs: Any) -> None:
"""Initialize the module.
Parameters:
*args: See [`griffe.Object`][].
filepath: The module file path (directory for namespace [sub]packages, none for builtin modules).
**kwargs: See [`griffe.Object`][].
"""
super().__init__(*args, **kwargs)
self._filepath: Path | list[Path] | None = filepath
self.overloads: dict[str, list[Function]] = defaultdict(list)
"""The overloaded signatures declared in this module."""
def __repr__(self) -> str:
try:
return f"Module({self.filepath!r})"
except BuiltinModuleError:
return f"Module({self.name!r})"
@property
def filepath(self) -> Path | list[Path]:
"""The file path of this module.
Raises:
BuiltinModuleError: When the instance filepath is None.
"""
if self._filepath is None:
raise BuiltinModuleError(self.name)
return self._filepath
@property
def imports_future_annotations(self) -> bool:
"""Whether this module import future annotations."""
return (
"annotations" in self.members
and self.members["annotations"].is_alias
and self.members["annotations"].target_path == "__future__.annotations" # ty:ignore[unresolved-attribute]
)
@property
def is_init_module(self) -> bool:
"""Whether this module is an `__init__.py` module.
See also: [`is_module`][griffe.Module.is_module].
"""
if isinstance(self.filepath, list):
return False
try:
return self.filepath.name.split(".", 1)[0] == "__init__"
except BuiltinModuleError:
return False
@property
def is_package(self) -> bool:
"""Whether this module is a package (top module).
See also: [`is_subpackage`][griffe.Module.is_subpackage].
"""
return not bool(self.parent) and self.is_init_module
@property
def is_subpackage(self) -> bool:
"""Whether this module is a subpackage.
See also: [`is_package`][griffe.Module.is_package].
"""
return bool(self.parent) and self.is_init_module
@property
def is_namespace_package(self) -> bool:
"""Whether this module is a namespace package (top folder, no `__init__.py`).
See also: [`is_namespace_subpackage`][griffe.Module.is_namespace_subpackage].
"""
try:
return self.parent is None and isinstance(self.filepath, list)
except BuiltinModuleError:
return False
@property
def is_namespace_subpackage(self) -> bool:
"""Whether this module is a namespace subpackage.
See also: [`is_namespace_package`][griffe.Module.is_namespace_package].
"""
try:
return (
self.parent is not None
and isinstance(self.filepath, list)
and (
cast("Module", self.parent).is_namespace_package
or cast("Module", self.parent).is_namespace_subpackage
)
)
except BuiltinModuleError:
return False
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this module's data as a dictionary.
See also: [`as_json`][griffe.Module.as_json].
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base = super().as_dict(**kwargs)
if isinstance(self._filepath, list):
base["filepath"] = [str(path) for path in self._filepath]
elif self._filepath:
base["filepath"] = str(self._filepath)
else:
base["filepath"] = None
return base
class Class(Object):
"""The class representing a Python class."""
kind = Kind.CLASS
def __init__(
self,
*args: Any,
bases: Sequence[Expr | str] | None = None,
decorators: list[Decorator] | None = None,
keywords: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the class.
Parameters:
*args: See [`griffe.Object`][].
bases: The list of base classes, if any.
decorators: The class decorators, if any.
keywords: The class keywords arguments, if any.
**kwargs: See [`griffe.Object`][].
"""
super().__init__(*args, **kwargs)
self.bases: list[Expr | str] = list(bases) if bases else []
"""The class bases.
See also: [`resolved_bases`][griffe.Class.resolved_bases],
[`mro`][griffe.Class.mro].
"""
self.decorators: list[Decorator] = decorators or []
"""The class decorators."""
self.keywords: dict[str, Any] = keywords or {}
"""The class keywords arguments."""
self.overloads: dict[str, list[Function]] = defaultdict(list)
"""The overloaded signatures declared in this class."""
@property
def parameters(self) -> Parameters:
"""The parameters of this class' `__init__` method, if any.
This property fetches inherited members,
and therefore is part of the consumer API:
do not use when producing Griffe trees!
"""
try:
return self.all_members["__init__"].parameters # ty:ignore[unresolved-attribute]
except KeyError:
return Parameters()
def signature(self, *, return_type: bool = False, name: str | None = None) -> str:
"""Construct the class signature.
Parameters:
return_type: Whether to include the return type in the signature.
name: The name of the class to use in the signature.
Returns:
A string representation of the class signature.
"""
all_members = self.all_members
if "__init__" in all_members:
init = all_members["__init__"]
if isinstance(init, Function):
return init.signature(return_type=return_type, name=name or self.name)
return ""
@property
def resolved_bases(self) -> list[Object]:
"""Resolved class bases.
This method is part of the consumer API:
do not use when producing Griffe trees!
See also: [`bases`][griffe.Class.bases],
[`mro`][griffe.Class.mro].
"""
resolved_bases = []
for base in self.bases:
base_path = base if isinstance(base, str) else base.canonical_path
try:
resolved_base = self.modules_collection.get_member(base_path)
if resolved_base.is_alias:
resolved_base = resolved_base.final_target
except (AliasResolutionError, CyclicAliasError, KeyError):
logger.debug("Base class %s is not loaded, or not static, it cannot be resolved", base_path)
else:
resolved_bases.append(resolved_base)
return resolved_bases
def _mro(self, seen: tuple[str, ...] = ()) -> list[Class]:
seen = (*seen, self.path)
bases: list[Class] = [base for base in self.resolved_bases if base.is_class] # ty:ignore[invalid-assignment]
if not bases:
return [self]
for base in bases:
if base.path in seen:
cycle = " -> ".join(seen) + f" -> {base.path}"
raise ValueError(f"Cannot compute C3 linearization, inheritance cycle detected: {cycle}")
return [self, *c3linear_merge(*[base._mro(seen) for base in bases], bases)]
def mro(self) -> list[Class]:
"""Return a list of classes in order corresponding to Python's MRO.
See also: [`bases`][griffe.Class.bases],
[`resolved_bases`][griffe.Class.resolved_bases].
"""
return self._mro()[1:] # Remove self.
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this class' data as a dictionary.
See also: [`as_json`][griffe.Class.as_json].
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base = super().as_dict(**kwargs)
base["bases"] = self.bases
base["decorators"] = [dec.as_dict(**kwargs) for dec in self.decorators]
return base
class Function(Object):
"""The class representing a Python function."""
kind = Kind.FUNCTION
def __init__(
self,
*args: Any,
parameters: Parameters | None = None,
returns: str | Expr | None = None,
decorators: list[Decorator] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the function.
Parameters:
*args: See [`griffe.Object`][].
parameters: The function parameters.
returns: The function return annotation.
decorators: The function decorators, if any.
**kwargs: See [`griffe.Object`][].
"""
super().__init__(*args, **kwargs)
self.parameters: Parameters = parameters or Parameters()
"""The function parameters."""
self.returns: str | Expr | None = returns
"""The function return type annotation."""
self.decorators: list[Decorator] = decorators or []
"""The function decorators."""
self.overloads: list[Function] | None = None
"""The overloaded signatures of this function."""
for parameter in self.parameters:
parameter.function = self
@property
def annotation(self) -> str | Expr | None:
"""The type annotation of the returned value."""
return self.returns
def resolve(self, name: str) -> str:
"""Resolve a name within this object's and parents' scope.
Parameters:
name: The name to resolve.
Raises:
NameResolutionError: When the name could not be resolved.
Returns:
The resolved name.
"""
# We're in an `__init__` method...
if self.parent and self.name == "__init__":
# ...and name is a parameter name: resolve to the parameter.
if name in self.parameters:
return f"{self.parent.path}({name})"
# Kind of a special case: we avoid resolving to instance-attributes from a function scope.
# See issue https://github.com/mkdocstrings/griffe/issues/367.
resolved = super().resolve(name)
try:
obj = self.modules_collection.get_member(resolved)
except KeyError:
return resolved
try:
if obj.is_attribute and "instance-attribute" in obj.labels:
raise NameResolutionError(name)
except AliasResolutionError:
pass
return resolved
return super().resolve(name)
@property
def is_init_method(self) -> bool:
"""Whether this function is an `__init__` method."""
return bool(self.parent and self.parent.is_class and self.name == "__init__")
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this function's data as a dictionary.
See also: [`as_json`][griffe.Function.as_json].
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base = super().as_dict(**kwargs)
base["decorators"] = [dec.as_dict(**kwargs) for dec in self.decorators]
base["parameters"] = [param.as_dict(**kwargs) for param in self.parameters]
base["returns"] = self.returns
return base
def signature(self, *, return_type: bool = True, name: str | None = None) -> str:
"""Construct the function signature.
Parameters:
return_type: Whether to include the return type in the signature.
name: The name of the function to use in the signature.
Returns:
A string representation of the function signature.
"""
signature = f"{name or self.name}("
has_pos_only = any(p.kind == ParameterKind.positional_only for p in self.parameters)
render_pos_only_separator = True
render_kw_only_separator = True
param_strs = []
for index, param in enumerate(self.parameters):
# Skip 'self' or 'cls' for class methods if it's the first parameter.
if index == 0 and param.name in ("self", "cls") and self.parent and self.parent.is_class:
continue
param_str = ""
# Handle parameter kind and separators.
if param.kind != ParameterKind.positional_only:
if has_pos_only and render_pos_only_separator:
render_pos_only_separator = False
param_strs.append("/")
if param.kind == ParameterKind.keyword_only and render_kw_only_separator:
render_kw_only_separator = False
param_strs.append("*")
# Handle variadic parameters.
if param.kind == ParameterKind.var_positional:
param_str = "*"
render_kw_only_separator = False
elif param.kind == ParameterKind.var_keyword:
param_str = "**"
# Add parameter name.
param_str += param.name
# Handle type annotation
if param.annotation is not None:
param_str += f": {param.annotation}"
equal = " = " # Space around equal when annotation is present.
else:
equal = "=" # No space when no annotation.
# Handle default value.
if param.default is not None and param.kind not in {
ParameterKind.var_positional,
ParameterKind.var_keyword,
}:
param_str += f"{equal}{param.default}"
param_strs.append(param_str)
# If we have positional-only parameters but no '/' was added yet
if has_pos_only and render_pos_only_separator:
param_strs.append("/")
signature += ", ".join(param_strs)
signature += ")"
# Add return type if present.
if return_type and self.annotation:
signature += f" -> {self.annotation}"
return signature
class Attribute(Object):
"""The class representing a Python module/class/instance attribute."""
kind = Kind.ATTRIBUTE
def __init__(
self,
*args: Any,
value: str | Expr | None = None,
annotation: str | Expr | None = None,
**kwargs: Any,
) -> None:
"""Initialize the function.
Parameters:
*args: See [`griffe.Object`][].
value: The attribute value, if any.
annotation: The attribute annotation, if any.
**kwargs: See [`griffe.Object`][].
"""
super().__init__(*args, **kwargs)
self.value: str | Expr | None = value
"""The attribute value."""
self.annotation: str | Expr | None = annotation
"""The attribute type annotation."""
self.setter: Function | None = None
"""The setter linked to this property."""
self.deleter: Function | None = None
"""The deleter linked to this property."""
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this attribute's data as a dictionary.
See also: [`as_json`][griffe.Attribute.as_json].
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base = super().as_dict(**kwargs)
if self.value is not None:
base["value"] = self.value
if self.annotation is not None:
base["annotation"] = self.annotation
return base
class TypeAlias(Object):
"""The class representing a Python type alias."""
kind = Kind.TYPE_ALIAS
def __init__(
self,
*args: Any,
value: str | Expr | None = None,
**kwargs: Any,
) -> None:
"""Initialize the function.
Parameters:
*args: See [`griffe.Object`][].
value: The type alias value.
**kwargs: See [`griffe.Object`][].
"""
super().__init__(*args, **kwargs)
self.value: str | Expr | None = value
"""The type alias value."""
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this type alias's data as a dictionary.
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base = super().as_dict(**kwargs)
base["value"] = self.value
return base
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/finder.py 0000664 0001750 0001750 00000053776 15246557273 025555 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains the code allowing to find modules.
#
# Note: It might be possible to replace a good part of this module's logic
# with utilities from `importlib` (however the util in question is private):
#
# ```pycon
# >>> from importlib.util import _find_spec
# >>> _find_spec("griffe.agents", _find_spec("griffe", None).submodule_search_locations)
# ModuleSpec(
# name='griffe.agents',
# loader=<_frozen_importlib_external.SourceFileLoader object at 0x7fa5f34e8110>,
# origin='/media/data/dev/griffelib/packages/griffe/src/griffe/agents/__init__.py',
# submodule_search_locations=['/media/data/dev/griffelib/packages/griffe/src/griffe/agents'],
# )
# ```
from __future__ import annotations
import ast
import os
import re
import sys
from collections import defaultdict
from contextlib import suppress
from dataclasses import dataclass
from itertools import chain
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
from griffe._internal.exceptions import UnhandledEditableModuleError
from griffe._internal.logger import logger
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from re import Pattern
from griffe._internal.models import Module
_editable_editables_patterns = [re.compile(pat) for pat in (r"^__editables_\w+\.py$", r"^_editable_impl_\w+\.py$")]
_editable_setuptools_patterns = [re.compile(pat) for pat in (r"^__editable__\w+\.py$",)]
_editable_scikit_build_core_patterns = [re.compile(pat) for pat in (r"^_\w+_editable.py$",)]
_editable_meson_python_patterns = [re.compile(pat) for pat in (r"^_\w+_editable_loader.py$",)]
NamePartsType = tuple[str, ...]
"""Type alias for the parts of a module name."""
NamePartsAndPathType = tuple[NamePartsType, Path]
"""Type alias for the parts of a module name and its path."""
def _match_pattern(string: str, patterns: Sequence[Pattern]) -> bool:
return any(pattern.match(string) for pattern in patterns)
@dataclass
class Package:
"""This class is a simple placeholder used during the process of finding packages.
Parameters:
name: The package name.
path: The package path(s).
stubs: An optional path to the related stubs file (.pyi).
"""
name: str
"""Package name."""
path: Path
"""Package folder path."""
stubs: Path | None = None
"""Package stubs file."""
@dataclass
class NamespacePackage:
"""This class is a simple placeholder used during the process of finding packages.
Parameters:
name: The package name.
path: The package paths.
"""
name: str
"""Namespace package name."""
path: list[Path]
"""Namespace package folder paths."""
class ModuleFinder:
"""The Griffe finder, allowing to find modules on the file system.
The module finder is generally not used directly.
Each [`GriffeLoader`][griffe.GriffeLoader] instance creates its own module finder instance.
The finder can be configured when instantiating the loader
thanks to the [loader][griffe.GriffeLoader]'s `search_paths` parameter.
"""
accepted_py_module_extensions: ClassVar[list[str]] = [".py", ".pyc", ".pyo", ".pyd", ".pyi", ".so"]
"""List of extensions supported by the finder."""
extensions_set: ClassVar[set[str]] = set(accepted_py_module_extensions)
"""Set of extensions supported by the finder."""
def __init__(self, search_paths: Sequence[str | Path] | None = None) -> None:
"""Initialize the finder.
Parameters:
search_paths: Optional paths to search into.
"""
self._paths_contents: dict[Path, list[Path]] = {}
self.search_paths: list[Path] = []
"""The finder search paths."""
# Optimization: pre-compute Paths to relieve CPU when joining paths.
for path in search_paths or sys.path:
self.append_search_path(Path(path))
self._always_scan_for: dict[str, list[Path]] = defaultdict(list)
self._extend_from_pth_files()
def append_search_path(self, path: Path) -> None:
"""Append a search path.
The path will be resolved (absolute, normalized).
The path won't be appended if it is already in the search paths list.
Parameters:
path: The path to append.
"""
self._append_search_path(path.resolve())
def _append_search_path(self, path: Path) -> None:
if path not in self.search_paths:
self.search_paths.append(path)
def insert_search_path(self, position: int, path: Path) -> None:
"""Insert a search path at the given position.
The path will be resolved (absolute, normalized).
The path won't be inserted if it is already in the search paths list.
Parameters:
position: The insert position in the list.
path: The path to insert.
"""
path = path.resolve()
if path not in self.search_paths:
self.search_paths.insert(position, path)
def find_spec(
self,
module: str | Path,
*,
try_relative_path: bool = True,
find_stubs_package: bool = False,
) -> tuple[str, Package | NamespacePackage]:
"""Find the top-level parent module of a module.
If a Path is passed, only try to find the module as a file path.
If a string is passed, first try to find the module as a file path,
then look into the search paths.
Parameters:
module: The module name or path.
try_relative_path: Whether to try finding the module as a relative path,
when the given module is not already a path.
find_stubs_package: Whether to search for stubs-only package.
If both the package and its stubs are found, they'll be merged together.
If only the stubs are found, they'll be used as the package itself.
Raises:
FileNotFoundError: When a Path was passed and the module could not be found:
- the directory has no `__init__.py` file in it
- the path does not exist
ModuleNotFoundError: When a string was passed and the module could not be found:
- no `module/__init__.py`
- no `module.py`
- no `module.pth`
- no `module` directory (namespace packages)
- or unsupported .pth file
Returns:
The name of the module, and an instance representing its (namespace) package.
"""
module_path: Path | list[Path]
if isinstance(module, Path):
module_name, module_path = self._module_name_path(module)
top_module_name = self._top_module_name(module_path)
elif try_relative_path:
try:
module_name, module_path = self._module_name_path(Path(module))
except FileNotFoundError:
module_name = module
top_module_name = module.split(".", 1)[0]
else:
top_module_name = self._top_module_name(module_path)
else:
module_name = module
top_module_name = module.split(".", 1)[0]
# Only search for actual package, let exceptions bubble up.
if not find_stubs_package:
return module_name, self.find_package(top_module_name)
# Search for both package and stubs-only package.
try:
package = self.find_package(top_module_name)
except ModuleNotFoundError:
package = None
try:
stubs = self.find_package(top_module_name + "-stubs")
except ModuleNotFoundError:
stubs = None
# None found, raise error.
if package is None and stubs is None:
raise ModuleNotFoundError(top_module_name)
# Both found, assemble them to be merged later.
if package and stubs:
if isinstance(package, Package) and isinstance(stubs, Package):
package.stubs = stubs.path
elif isinstance(package, NamespacePackage) and isinstance(stubs, NamespacePackage):
package.path += stubs.path
return module_name, package
# Return either one.
return module_name, package or stubs # ty:ignore[invalid-return-type]
def find_package(self, module_name: str) -> Package | NamespacePackage:
"""Find a package or namespace package.
Parameters:
module_name: The module name.
Raises:
ModuleNotFoundError: When the module cannot be found.
Returns:
A package or namespace package wrapper.
"""
filepaths = [
Path(module_name),
# TODO: Handle .py[cod] and .so files?
# This would be needed for package that are composed
# solely of a file with such an extension.
Path(f"{module_name}.py"),
]
real_module_name = module_name
real_module_name = real_module_name.removesuffix("-stubs")
namespace_dirs = []
for path in self.search_paths:
path_contents = self._contents(path)
if path_contents:
for choice in filepaths:
abs_path = path / choice
if abs_path in path_contents:
if abs_path.suffix:
stubs = abs_path.with_suffix(".pyi")
return Package(real_module_name, abs_path, stubs if stubs.exists() else None)
init_module = abs_path / "__init__.py"
if init_module.exists() and not _is_pkg_style_namespace(init_module):
stubs = init_module.with_suffix(".pyi")
return Package(real_module_name, init_module, stubs if stubs.exists() else None)
init_module = abs_path / "__init__.pyi"
if init_module.exists():
# Stubs package.
return Package(real_module_name, init_module, None)
namespace_dirs.append(abs_path)
if namespace_dirs:
return NamespacePackage(module_name, namespace_dirs)
raise ModuleNotFoundError(module_name)
def iter_submodules(
self,
path: Path | list[Path],
seen: set | None = None,
) -> Iterator[NamePartsAndPathType]:
"""Iterate on a module's submodules, if any.
Parameters:
path: The module path.
seen: If not none, this set is used to skip some files.
The goal is to replicate the behavior of Python by
only using the first packages (with `__init__` modules)
of the same name found in different namespace packages.
As soon as we find an `__init__` module, we add its parent
path to the `seen` set, which will be reused when scanning
the next namespace packages.
Yields:
name_parts (tuple[str, ...]): The parts of a submodule name.
filepath (Path): A submodule filepath.
"""
if isinstance(path, list):
# We never enter this condition again in recursive calls,
# so we just have to set `seen` once regardless of its value.
seen = set()
for path_elem in path:
yield from self.iter_submodules(path_elem, seen)
return
if path.stem == "__init__":
path = path.parent
# Optimization: just check if the file name ends with .py[icod]/.so
# (to distinguish it from a directory), not if it's an actual file.
elif path.suffix in self.extensions_set:
return
# `seen` is only set when we scan a list of paths (namespace package).
# `skip` is used to prevent yielding modules
# of a regular subpackage that we already yielded
# from another part of the namespace.
skip = set(seen or ())
for subpath in self._filter_py_modules(path):
rel_subpath = subpath.relative_to(path)
if rel_subpath.parent in skip:
logger.debug("Skip %s, another module took precedence", subpath)
continue
py_file = rel_subpath.suffix == ".py"
stem = rel_subpath.stem
if not py_file:
# `.py[cod]` and `.so` files look like `name.cpython-38-x86_64-linux-gnu.ext`.
stem = stem.split(".", 1)[0]
if stem == "__init__":
# Optimization: since it's a relative path, if it has only one part
# and is named __init__, it means it's the starting path
# (no need to compare it against starting path).
if len(rel_subpath.parts) == 1:
continue
yield rel_subpath.parts[:-1], subpath
if seen is not None:
seen.add(rel_subpath.parent)
elif py_file:
yield rel_subpath.with_suffix("").parts, subpath
else:
yield rel_subpath.with_name(stem).parts, subpath
def submodules(self, module: Module) -> list[NamePartsAndPathType]:
"""Return the list of a module's submodules.
Parameters:
module: The parent module.
Returns:
A list of tuples containing the parts of the submodule name and its path.
"""
return sorted(
chain(
self.iter_submodules(module.filepath),
self.iter_submodules(self._always_scan_for[module.name]),
),
key=_module_depth,
)
def _module_name_path(self, path: Path) -> tuple[str, Path]:
# Always return absolute paths to avoid working-directory-dependent issues.
path = path.absolute()
if path.is_dir():
for ext in self.accepted_py_module_extensions:
module_path = path / f"__init__{ext}"
if module_path.exists():
return path.name, module_path
return path.name, path
if path.exists():
if path.stem == "__init__":
return path.parent.name, path
return path.stem, path
raise FileNotFoundError
def _contents(self, path: Path) -> list[Path]:
if path not in self._paths_contents:
try:
self._paths_contents[path] = list(path.iterdir())
except (FileNotFoundError, NotADirectoryError):
self._paths_contents[path] = []
return self._paths_contents[path]
def _extend_from_pth_files(self) -> None:
for path in self.search_paths:
for item in self._contents(path):
if item.suffix == ".pth":
for directory in _handle_pth_file(item):
if scan := directory.always_scan_for:
self._always_scan_for[scan].append(directory.path.joinpath(scan))
self.append_search_path(directory.path)
def _filter_py_modules(self, path: Path) -> Iterator[Path]:
for root, dirs, files in os.walk(path, topdown=True, followlinks=True):
# Optimization: modify dirs in-place to exclude `__pycache__` directories.
dirs[:] = [dir for dir in dirs if dir != "__pycache__"]
for relfile in files:
if os.path.splitext(relfile)[1] in self.extensions_set: # noqa: PTH122
yield Path(root, relfile)
def _top_module_name(self, path: Path) -> str:
# First find if a parent is in search paths.
parent_path = path if path.is_dir() else path.parent
# Always resolve parent path to compare for relativeness against resolved search paths.
parent_path = parent_path.resolve()
for search_path in self.search_paths:
with suppress(ValueError, IndexError):
rel_path = parent_path.relative_to(search_path.resolve())
return rel_path.parts[0]
# If not, get the highest directory with an `__init__` module,
# add its parent to search paths and return it.
while parent_path.parent != parent_path and (parent_path.parent / "__init__.py").exists():
parent_path = parent_path.parent
self.insert_search_path(0, parent_path.parent)
return parent_path.name
_re_pkgresources = re.compile(r"(?:__import__\([\"']pkg_resources[\"']\).declare_namespace\(__name__\))")
_re_pkgutil = re.compile(r"(?:__path__ = __import__\([\"']pkgutil[\"']\).extend_path\(__path__, __name__\))")
_re_import_line = re.compile(r"^import[ \t]+\w+$")
# TODO: For more robustness, we should load and minify the AST
# to search for particular call statements.
def _is_pkg_style_namespace(init_module: Path) -> bool:
code = init_module.read_text(encoding="utf-8-sig")
return bool(_re_pkgresources.search(code) or _re_pkgutil.search(code))
def _module_depth(name_parts_and_path: NamePartsAndPathType) -> int:
return len(name_parts_and_path[0])
@dataclass
class _SP:
path: Path
always_scan_for: str = ""
def _handle_pth_file(path: Path) -> list[_SP]:
# Support for .pth files pointing to directories.
# From https://docs.python.org/3/library/site.html:
# A path configuration file is a file whose name has the form name.pth
# and exists in one of the four directories mentioned above;
# its contents are additional items (one per line) to be added to sys.path.
# Non-existing items are never added to sys.path,
# and no check is made that the item refers to a directory rather than a file.
# No item is added to sys.path more than once.
# Blank lines and lines beginning with # are skipped.
# Lines starting with import (followed by space or tab) are executed.
directories: list[_SP] = []
try:
# It turns out PyTorch recommends its users to use `.pth` as the extension
# when saving models on the disk. These model files are not encoded in UTF8.
# If UTF8 decoding fails, we skip the .pth file.
text = path.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
return directories
for line in text.strip().replace(";", "\n").splitlines(keepends=False):
line = line.strip() # noqa: PLW2901
if _re_import_line.match(line):
editable_module = path.parent / f"{line[len('import') :].lstrip()}.py"
with suppress(UnhandledEditableModuleError):
return _handle_editable_module(editable_module)
if line and not line.startswith("#") and os.path.exists(line): # noqa: PTH110
directories.append(_SP(Path(line)))
return directories
def _handle_editable_module(path: Path) -> list[_SP]:
if _match_pattern(path.name, (*_editable_editables_patterns, *_editable_scikit_build_core_patterns)):
# Support for how 'editables' write these files:
# example line: `F.map_module('pkg', '/data/dev/pkg/src/pkg/__init__.py')`.
# And how 'scikit-build-core' writes these files:
# example line: `install({'pkg': '/data/dev/pkg/src/pkg/__init__.py'}, {'cmake_example': ...}, None, False, True)`.
try:
editable_lines = path.read_text(encoding="utf-8-sig").strip().splitlines(keepends=False)
except FileNotFoundError as error:
raise UnhandledEditableModuleError(path) from error
new_path = Path(editable_lines[-1].split("'")[3])
if new_path.name.startswith("__init__"):
return [_SP(new_path.parent.parent)]
return [_SP(new_path)]
if _match_pattern(path.name, _editable_setuptools_patterns):
# Support for how 'setuptools' writes these files:
# example line: `MAPPING = {'pkg': '/data/dev/pkg/src/pkg', 'pkg2': '/data/dev/pkg/src/pkg2'}`.
# with annotation: `MAPPING: dict[str, str] = {...}`.
parsed_module = ast.parse(path.read_text(encoding="utf8"))
for node in parsed_module.body:
if isinstance(node, ast.Assign):
target = node.targets[0]
elif isinstance(node, ast.AnnAssign):
target = node.target
else:
continue
if isinstance(target, ast.Name) and target.id == "MAPPING" and isinstance(node.value, ast.Dict):
return [_SP(Path(cst.value).parent) for cst in node.value.values if isinstance(cst, ast.Constant)] # ty:ignore[invalid-argument-type]
if _match_pattern(path.name, _editable_meson_python_patterns):
# Support for how 'meson-python' writes these files:
# example line: `install({'package', 'module1'}, '/media/data/dev/griffe/build/cp311', ["path"], False)`.
# Compiled modules then found in the cp311 folder, under src/package.
parsed_module = ast.parse(path.read_text(encoding="utf8"))
for node in parsed_module.body:
if (
isinstance(node, ast.Expr)
and isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Name)
and node.value.func.id == "install"
and isinstance(node.value.args[1], ast.Constant)
):
build_path = Path(node.value.args[1].value, "src") # ty:ignore[invalid-argument-type]
# NOTE: What if there are multiple packages?
pkg_name = next(build_path.iterdir()).name
return [_SP(build_path, always_scan_for=pkg_name)]
raise UnhandledEditableModuleError(path)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/c3linear.py 0000664 0001750 0001750 00000006723 15246557273 025774 0 ustar carsten carsten # This module contains a single function, `c3linear_merge`.
# The function is generic enough to be in its own module.
#
# - Copyright (c) 2019 Vitaly R. Samigullin
# - Adapted from https://github.com/pilosus/c3linear
# - Adapted from https://github.com/tristanlatr/pydocspec
from __future__ import annotations
from collections import deque
from itertools import islice
from typing import TypeVar
_T = TypeVar("_T")
class _Dependency(deque[_T]):
"""A class representing a (doubly-ended) queue of items."""
@property
def head(self) -> _T | None:
"""Head of the dependency."""
try:
return self[0]
except IndexError:
return None
@property
def tail(self) -> islice:
"""Tail of the dependency.
The `islice` object is sufficient for iteration or testing membership (`in`).
"""
try:
return islice(self, 1, self.__len__())
except (ValueError, IndexError):
return islice([], 0, 0)
class _DependencyList:
"""A class representing a list of linearizations (dependencies).
The last element of DependencyList is a list of parents.
It's needed to the merge process preserves the local
precedence order of direct parent classes.
"""
def __init__(self, *lists: list[_T | None]) -> None:
"""Initialize the list.
Parameters:
*lists: Lists of items.
"""
self._lists = [_Dependency(lst) for lst in lists]
def __contains__(self, item: _T) -> bool:
"""Return True if any linearization's tail contains an item."""
return any(item in lst.tail for lst in self._lists)
def __len__(self) -> int:
size = len(self._lists)
return (size - 1) if size else 0
def __repr__(self) -> str:
return self._lists.__repr__()
@property
def heads(self) -> list[_T | None]:
"""Return the heads."""
return [lst.head for lst in self._lists] # ty:ignore[invalid-return-type]
@property
def tails(self) -> _DependencyList:
"""Return self so that `__contains__` could be called."""
return self
@property
def exhausted(self) -> bool:
"""True if all elements of the lists are exhausted."""
return all(len(x) == 0 for x in self._lists)
def remove(self, item: _T | None) -> None:
"""Remove an item from the lists.
Once an item removed from heads, the leftmost elements of the tails
get promoted to become the new heads.
"""
for i in self._lists:
if i and i.head == item:
i.popleft()
def c3linear_merge(*lists: list[_T]) -> list[_T]:
"""Merge lists of lists in the order defined by the C3Linear algorithm.
Parameters:
*lists: Lists of items.
Returns:
The merged list of items.
"""
result: list[_T] = []
linearizations = _DependencyList(*lists) # ty:ignore[invalid-argument-type]
while True:
if linearizations.exhausted:
return result
for head in linearizations.heads:
if head and (head not in linearizations.tails):
result.append(head)
linearizations.remove(head)
# Once candidate is found, continue iteration
# from the first element of the list.
break
else:
# Loop never broke, no linearization could possibly be found.
raise ValueError("Cannot compute C3 linearization")
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/ 0000775 0001750 0001750 00000000000 15246557273 026071 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/google.py 0000664 0001750 0001750 00000121177 15246557273 027730 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module defines functions to parse Google-style docstrings into structured data.
from __future__ import annotations
import re
from contextlib import suppress
from typing import TYPE_CHECKING, TypedDict
from griffe._internal.docstrings.models import (
DocstringAttribute,
DocstringClass,
DocstringFunction,
DocstringModule,
DocstringParameter,
DocstringRaise,
DocstringReceive,
DocstringReturn,
DocstringSection,
DocstringSectionAdmonition,
DocstringSectionAttributes,
DocstringSectionClasses,
DocstringSectionExamples,
DocstringSectionFunctions,
DocstringSectionModules,
DocstringSectionOtherParameters,
DocstringSectionParameters,
DocstringSectionRaises,
DocstringSectionReceives,
DocstringSectionReturns,
DocstringSectionText,
DocstringSectionTypeAliases,
DocstringSectionTypeParameters,
DocstringSectionWarns,
DocstringSectionYields,
DocstringTypeAlias,
DocstringTypeParameter,
DocstringWarn,
DocstringYield,
)
from griffe._internal.docstrings.utils import docstring_warning, parse_docstring_annotation
from griffe._internal.enumerations import DocstringSectionKind, LogLevel
if TYPE_CHECKING:
from re import Pattern
from typing import Any, Literal
from griffe._internal.expressions import Expr
from griffe._internal.models import Docstring
_section_kind = {
"args": DocstringSectionKind.parameters,
"arguments": DocstringSectionKind.parameters,
"params": DocstringSectionKind.parameters,
"parameters": DocstringSectionKind.parameters,
"keyword args": DocstringSectionKind.other_parameters,
"keyword arguments": DocstringSectionKind.other_parameters,
"other args": DocstringSectionKind.other_parameters,
"other arguments": DocstringSectionKind.other_parameters,
"other params": DocstringSectionKind.other_parameters,
"other parameters": DocstringSectionKind.other_parameters,
"type args": DocstringSectionKind.type_parameters,
"type arguments": DocstringSectionKind.type_parameters,
"type params": DocstringSectionKind.type_parameters,
"type parameters": DocstringSectionKind.type_parameters,
"raises": DocstringSectionKind.raises,
"exceptions": DocstringSectionKind.raises,
"returns": DocstringSectionKind.returns,
"yields": DocstringSectionKind.yields,
"receives": DocstringSectionKind.receives,
"examples": DocstringSectionKind.examples,
"attributes": DocstringSectionKind.attributes,
"functions": DocstringSectionKind.functions,
"methods": DocstringSectionKind.functions,
"classes": DocstringSectionKind.classes,
"type aliases": DocstringSectionKind.type_aliases,
"modules": DocstringSectionKind.modules,
"warns": DocstringSectionKind.warns,
"warnings": DocstringSectionKind.warns,
}
_BlockItem = tuple[int, list[str]]
_BlockItems = list[_BlockItem]
_ItemsBlock = tuple[_BlockItems, int]
_RE_ADMONITION: Pattern = re.compile(r"^(?P[\w][\s\w-]*):(\s+(?P[^\s].*))?\s*$", re.IGNORECASE)
_RE_NAME_ANNOTATION_DESCRIPTION: Pattern = re.compile(r"^(?:(?P\w+)?\s*(?:\((?P.+)\))?:\s*)?(?P.*)$")
_RE_DOCTEST_BLANKLINE: Pattern = re.compile(r"^\s*\s*$")
_RE_DOCTEST_FLAGS: Pattern = re.compile(r"(\s*#\s*doctest:.+)$")
def _read_block_items(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> _ItemsBlock:
if offset >= len(lines):
return [], offset
new_offset = offset
items: _BlockItems = []
# Skip first empty lines.
while _is_empty_line(lines[new_offset]):
new_offset += 1
# Get initial indent.
indent = len(lines[new_offset]) - len(lines[new_offset].lstrip())
if indent == 0:
# First non-empty line was not indented, abort.
return [], new_offset - 1
# Start processing first item.
current_item = (new_offset, [lines[new_offset][indent:]])
new_offset += 1
# Loop on next lines.
while new_offset < len(lines):
line = lines[new_offset]
if _is_empty_line(line):
# Empty line: preserve it in the current item.
current_item[1].append("")
elif line.startswith(indent * 2 * " "):
# Continuation line.
current_item[1].append(line[indent * 2 :])
elif line.startswith((indent + 1) * " "):
# Indent between initial and continuation: append but warn.
cont_indent = len(line) - len(line.lstrip())
current_item[1].append(line[cont_indent:])
if warnings:
docstring_warning(
docstring,
new_offset,
f"Confusing indentation for continuation line {new_offset + 1} in docstring, "
f"should be {indent} * 2 = {indent * 2} spaces, not {cont_indent}",
)
elif line.startswith(indent * " "):
# Indent equal to initial one: new item.
items.append(current_item)
current_item = (new_offset, [line[indent:]])
else:
# Indent lower than initial one: end of section.
break
new_offset += 1
if current_item:
items.append(current_item)
return items, new_offset - 1
def _read_block(
lines: list[str],
*,
offset: int,
**options: Any, # noqa: ARG001
) -> tuple[str, int]:
if offset >= len(lines):
return "", offset - 1
new_offset = offset
block: list[str] = []
# skip first empty lines.
while _is_empty_line(lines[new_offset]):
new_offset += 1
# Get initial indent.
indent = len(lines[new_offset]) - len(lines[new_offset].lstrip())
if indent == 0:
# First non-empty line was not indented, abort.
return "", offset - 1
# Start processing first item.
block.append(lines[new_offset].lstrip())
new_offset += 1
# Loop on next lines.
while new_offset < len(lines) and (lines[new_offset].startswith(indent * " ") or _is_empty_line(lines[new_offset])):
block.append(lines[new_offset][indent:])
new_offset += 1
return "\n".join(block).rstrip("\n"), new_offset - 1
def _read_parameters(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warn_unknown_params: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[list[DocstringParameter], int]:
parameters = []
annotation: str | Expr | None
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
# TODO: Use `get_name_annotation_description` here too?
for line_number, param_lines in block:
# Check the presence of a name and description, separated by a colon.
try:
name_with_type, description = param_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'name: description' pair from '{param_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *param_lines[1:]]).rstrip("\n")
# Use the type given after the parameter name, if any.
if "(" in name_with_type and name_with_type.endswith(")"):
name, annotation = name_with_type.split("(", 1)
name = name.strip()
annotation = annotation.removesuffix(")").removesuffix(", optional").strip()
# Try to compile the annotation to transform it into an expression.
annotation = parse_docstring_annotation(annotation, docstring)
else:
name = name_with_type
# Try to use the annotation from the signature.
try:
annotation = docstring.parent.parameters[name].annotation # ty:ignore[unresolved-attribute]
except (AttributeError, KeyError):
annotation = None
try:
default = docstring.parent.parameters[name].default # ty:ignore[unresolved-attribute]
except (AttributeError, KeyError):
default = None
if warnings and warn_missing_types and annotation is None:
docstring_warning(docstring, line_number, f"No type or annotation for parameter '{name}'")
if warnings and warn_unknown_params:
with suppress(AttributeError): # For Parameters sections in objects without parameters.
params = docstring.parent.parameters # ty:ignore[unresolved-attribute]
if name not in params:
message = f"Parameter '{name}' does not appear in the function signature"
for starred_name in (f"*{name}", f"**{name}"):
if starred_name in params:
message += f". Did you mean '{starred_name}'?"
break
docstring_warning(docstring, line_number, message)
parameters.append(DocstringParameter(name=name, value=default, annotation=annotation, description=description))
return parameters, new_offset
def _read_parameters_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
**options: Any,
) -> tuple[DocstringSectionParameters | None, int]:
parameters, new_offset = _read_parameters(lines, docstring, offset=offset, **options)
return DocstringSectionParameters(parameters), new_offset
def _read_other_parameters_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warn_unknown_params: bool = True, # noqa: ARG001
**options: Any,
) -> tuple[DocstringSectionOtherParameters | None, int]:
parameters, new_offset = _read_parameters(lines, docstring, offset=offset, warn_unknown_params=False, **options)
return DocstringSectionOtherParameters(parameters), new_offset
def _read_type_parameters_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warn_unknown_params: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionTypeParameters | None, int]:
type_parameters = []
bound: str | Expr | None
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
for line_number, type_param_lines in block:
# check the presence of a name and description, separated by a colon
try:
name_with_bound, description = type_param_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'name: description' pair from '{type_param_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *type_param_lines[1:]]).rstrip("\n")
# use the type given after the type parameter name, if any
if " " in name_with_bound:
name, bound = name_with_bound.split(" ", 1)
if bound.startswith("(") and bound.endswith(")"):
bound = bound[1:-1]
# try to compile the annotation to transform it into an expression
bound = parse_docstring_annotation(bound, docstring)
else:
name = name_with_bound
# try to use the annotation from the signature
try:
bound = docstring.parent.type_parameters[name].annotation # ty:ignore[unresolved-attribute]
except (AttributeError, KeyError):
bound = None
try:
default = docstring.parent.type_parameters[name].default # ty:ignore[unresolved-attribute]
except (AttributeError, KeyError):
default = None
if warnings and warn_unknown_params:
with suppress(AttributeError): # for type parameters sections in objects without type parameters
type_params = docstring.parent.type_parameters # ty:ignore[unresolved-attribute]
if name not in type_params:
message = f"Type parameter '{name}' does not appear in the {docstring.parent.kind.value} signature" # ty:ignore[unresolved-attribute]
for starred_name in (f"*{name}", f"**{name}"):
if starred_name in type_params:
message += f". Did you mean '{starred_name}'?"
break
docstring_warning(docstring, line_number, message)
type_parameters.append(
DocstringTypeParameter(
name=name,
value=default,
annotation=bound,
description=description,
),
)
return DocstringSectionTypeParameters(type_parameters), new_offset
def _read_attributes_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionAttributes | None, int]:
attributes = []
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
annotation: str | Expr | None = None
for line_number, attr_lines in block:
try:
name_with_type, description = attr_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'name: description' pair from '{attr_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *attr_lines[1:]]).rstrip("\n")
if " " in name_with_type:
name, annotation = name_with_type.split(" ", 1)
annotation = annotation.strip("()")
annotation = annotation.removesuffix(", optional")
# Try to compile the annotation to transform it into an expression.
annotation = parse_docstring_annotation(annotation, docstring)
else:
name = name_with_type
with suppress(AttributeError, KeyError, TypeError):
# Use subscript syntax to fetch annotation from inherited members too.
annotation = docstring.parent[name].annotation # ty:ignore[not-subscriptable]
attributes.append(DocstringAttribute(name=name, annotation=annotation, description=description))
return DocstringSectionAttributes(attributes), new_offset
def _read_functions_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionFunctions | None, int]:
functions = []
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
signature: str | Expr | None = None
for line_number, func_lines in block:
try:
name_with_signature, description = func_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'signature: description' pair from '{func_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *func_lines[1:]]).rstrip("\n")
if "(" in name_with_signature:
name = name_with_signature.split("(", 1)[0]
signature = name_with_signature
else:
name = name_with_signature
signature = None
functions.append(DocstringFunction(name=name, annotation=signature, description=description))
return DocstringSectionFunctions(functions), new_offset
def _read_classes_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionClasses | None, int]:
classes = []
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
signature: str | Expr | None = None
for line_number, class_lines in block:
try:
name_with_signature, description = class_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'signature: description' pair from '{class_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *class_lines[1:]]).rstrip("\n")
if "(" in name_with_signature:
name = name_with_signature.split("(", 1)[0]
signature = name_with_signature
else:
name = name_with_signature
signature = None
classes.append(DocstringClass(name=name, annotation=signature, description=description))
return DocstringSectionClasses(classes), new_offset
def _read_type_aliases_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionTypeAliases | None, int]:
type_aliases = []
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
for line_number, type_alias_lines in block:
try:
name, description = type_alias_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'name: description' pair from '{type_alias_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *type_alias_lines[1:]]).rstrip("\n")
type_aliases.append(DocstringTypeAlias(name=name, description=description))
return DocstringSectionTypeAliases(type_aliases), new_offset
def _read_modules_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionModules | None, int]:
modules = []
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
for line_number, module_lines in block:
try:
name, description = module_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'name: description' pair from '{module_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *module_lines[1:]]).rstrip("\n")
modules.append(DocstringModule(name=name, description=description))
return DocstringSectionModules(modules), new_offset
def _read_raises_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionRaises | None, int]:
exceptions = []
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
annotation: str | Expr
for line_number, exception_lines in block:
try:
annotation, description = exception_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'exception: description' pair from '{exception_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *exception_lines[1:]]).rstrip("\n")
# Try to compile the annotation to transform it into an expression.
annotation = parse_docstring_annotation(annotation, docstring)
exceptions.append(DocstringRaise(annotation=annotation, description=description))
return DocstringSectionRaises(exceptions), new_offset
def _read_warns_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionWarns | None, int]:
warns = []
block, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
for line_number, warning_lines in block:
try:
annotation, description = warning_lines[0].split(":", 1)
except ValueError:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get 'warning: description' pair from '{warning_lines[0]}'",
)
continue
description = "\n".join([description.lstrip(), *warning_lines[1:]]).rstrip("\n")
warns.append(DocstringWarn(annotation=annotation, description=description))
return DocstringSectionWarns(warns), new_offset
def _read_block_items_maybe(
lines: list[str],
docstring: Docstring,
*,
offset: int,
multiple: bool = True,
warnings: bool = True,
**options: Any,
) -> _ItemsBlock:
if multiple:
return _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
one_block, new_offset = _read_block(lines, offset=offset, **options)
return [(new_offset, one_block.splitlines())], new_offset
def _get_name_annotation_description(
docstring: Docstring,
line_number: int,
lines: list[str],
*,
named: bool = True,
warnings: bool = True,
) -> tuple[str | None, Any, str]:
if named:
match = _RE_NAME_ANNOTATION_DESCRIPTION.match(lines[0])
if not match:
if warnings:
docstring_warning(
docstring,
line_number,
f"Failed to get name, annotation or description from '{lines[0]}'",
)
raise ValueError
name, annotation, description = match.groups()
else:
name = None
if ":" in lines[0]:
annotation, description = lines[0].split(":", 1)
annotation = annotation.lstrip("(").rstrip(")")
else:
annotation = None
description = lines[0]
description = "\n".join([description.lstrip(), *lines[1:]]).rstrip("\n")
return name, annotation, description
def _annotation_from_parent(
docstring: Docstring,
*,
gen_index: Literal[0, 1, 2],
multiple: bool = False,
index: int = 0,
) -> str | Expr | None:
annotation = None
with suppress(Exception):
annotation = docstring.parent.annotation # ty:ignore[unresolved-attribute]
if annotation.is_generator:
annotation = annotation.slice.elements[gen_index]
elif annotation.is_iterator and gen_index == 0:
annotation = annotation.slice
if multiple and annotation.is_tuple:
annotation = annotation.slice.elements[index]
return annotation
def _read_returns_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
returns_multiple_items: bool = True,
returns_named_value: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionReturns | None, int]:
returns = []
block, new_offset = _read_block_items_maybe(
lines,
docstring,
offset=offset,
multiple=returns_multiple_items,
warnings=warnings,
**options,
)
for index, (line_number, return_lines) in enumerate(block):
try:
name, annotation, description = _get_name_annotation_description(
docstring,
line_number,
return_lines,
named=returns_named_value,
warnings=warnings,
)
except ValueError:
continue
if annotation:
# Try to compile the annotation to transform it into an expression.
annotation = parse_docstring_annotation(annotation, docstring)
else:
# Try to retrieve the annotation from the docstring parent.
annotation = _annotation_from_parent(docstring, gen_index=2, multiple=len(block) > 1, index=index)
if warnings and warn_missing_types and annotation is None:
returned_value = repr(name) if name else index + 1
docstring_warning(docstring, line_number, f"No type or annotation for returned value {returned_value}")
returns.append(DocstringReturn(name=name or "", annotation=annotation, description=description))
return DocstringSectionReturns(returns), new_offset
def _read_yields_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
returns_multiple_items: bool = True,
returns_named_value: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionYields | None, int]:
yields = []
block, new_offset = _read_block_items_maybe(
lines,
docstring,
offset=offset,
multiple=returns_multiple_items,
warnings=warnings,
**options,
)
for index, (line_number, yield_lines) in enumerate(block):
try:
name, annotation, description = _get_name_annotation_description(
docstring,
line_number,
yield_lines,
named=returns_named_value,
warnings=warnings,
)
except ValueError:
continue
if annotation:
# Try to compile the annotation to transform it into an expression.
annotation = parse_docstring_annotation(annotation, docstring)
else:
# Try to retrieve the annotation from the docstring parent.
annotation = _annotation_from_parent(docstring, gen_index=0, multiple=len(block) > 1, index=index)
if warnings and warn_missing_types and annotation is None:
yielded_value = repr(name) if name else index + 1
docstring_warning(docstring, line_number, f"No type or annotation for yielded value {yielded_value}")
yields.append(DocstringYield(name=name or "", annotation=annotation, description=description))
return DocstringSectionYields(yields), new_offset
def _read_receives_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
receives_multiple_items: bool = True,
receives_named_value: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionReceives | None, int]:
receives = []
block, new_offset = _read_block_items_maybe(
lines,
docstring,
offset=offset,
multiple=receives_multiple_items,
warnings=warnings,
**options,
)
for index, (line_number, receive_lines) in enumerate(block):
try:
name, annotation, description = _get_name_annotation_description(
docstring,
line_number,
receive_lines,
named=receives_named_value,
warnings=warnings,
)
except ValueError:
continue
if annotation:
# Try to compile the annotation to transform it into an expression.
annotation = parse_docstring_annotation(annotation, docstring)
else:
# Try to retrieve the annotation from the docstring parent.
annotation = _annotation_from_parent(docstring, gen_index=1, multiple=len(block) > 1, index=index)
if warnings and warn_missing_types and annotation is None:
received_value = repr(name) if name else index + 1
docstring_warning(docstring, line_number, f"No type or annotation for received value {received_value}")
receives.append(DocstringReceive(name=name or "", annotation=annotation, description=description))
return DocstringSectionReceives(receives), new_offset
def _read_examples_section(
lines: list[str],
docstring: Docstring, # noqa: ARG001
*,
offset: int,
trim_doctest_flags: bool = True,
**options: Any,
) -> tuple[DocstringSectionExamples | None, int]:
text, new_offset = _read_block(lines, offset=offset, **options)
sub_sections: list[tuple[Literal[DocstringSectionKind.text, DocstringSectionKind.examples], str]] = []
in_code_example = False
in_code_block = False
current_text: list[str] = []
current_example: list[str] = []
for line in text.split("\n"):
if _is_empty_line(line):
if in_code_example:
if current_example:
sub_sections.append((DocstringSectionKind.examples, "\n".join(current_example)))
current_example = []
in_code_example = False
else:
current_text.append(line)
elif in_code_example:
if trim_doctest_flags:
line = _RE_DOCTEST_FLAGS.sub("", line) # noqa: PLW2901
line = _RE_DOCTEST_BLANKLINE.sub("", line) # noqa: PLW2901
current_example.append(line)
elif line.startswith("```"):
in_code_block = not in_code_block
current_text.append(line)
elif in_code_block:
current_text.append(line)
elif line.startswith(">>>"):
if current_text:
sub_sections.append((DocstringSectionKind.text, "\n".join(current_text).rstrip("\n")))
current_text = []
in_code_example = True
if trim_doctest_flags:
line = _RE_DOCTEST_FLAGS.sub("", line) # noqa: PLW2901
current_example.append(line)
else:
current_text.append(line)
if current_text:
sub_sections.append((DocstringSectionKind.text, "\n".join(current_text).rstrip("\n")))
elif current_example:
sub_sections.append((DocstringSectionKind.examples, "\n".join(current_example)))
return DocstringSectionExamples(sub_sections), new_offset
def _is_empty_line(line: str) -> bool:
return not line or line.isspace()
_section_reader = {
DocstringSectionKind.parameters: _read_parameters_section,
DocstringSectionKind.other_parameters: _read_other_parameters_section,
DocstringSectionKind.type_parameters: _read_type_parameters_section,
DocstringSectionKind.raises: _read_raises_section,
DocstringSectionKind.warns: _read_warns_section,
DocstringSectionKind.examples: _read_examples_section,
DocstringSectionKind.attributes: _read_attributes_section,
DocstringSectionKind.functions: _read_functions_section,
DocstringSectionKind.classes: _read_classes_section,
DocstringSectionKind.type_aliases: _read_type_aliases_section,
DocstringSectionKind.modules: _read_modules_section,
DocstringSectionKind.returns: _read_returns_section,
DocstringSectionKind.yields: _read_yields_section,
DocstringSectionKind.receives: _read_receives_section,
}
class GoogleOptions(TypedDict, total=False):
"""Options for parsing Google-style docstrings."""
ignore_init_summary: bool
"""Whether to ignore the summary in `__init__` methods' docstrings."""
trim_doctest_flags: bool
"""Whether to remove doctest flags from Python example blocks."""
returns_multiple_items: bool
"""Whether to parse multiple items in `Yields` and `Returns` sections."""
returns_named_value: bool
"""Whether to parse `Yields` and `Returns` section items as name and description, rather than type and description."""
returns_type_in_property_summary: bool
"""Whether to parse the return type of properties at the beginning of their summary."""
receives_multiple_items: bool
"""Whether to parse multiple items in `Receives` sections."""
receives_named_value: bool
"""Whether to parse `Receives` section items as name and description, rather than type and description."""
warn_unknown_params: bool
"""Whether to warn about unknown parameters."""
warn_missing_types: bool
"""Whether to warn about missing types/annotations for parameters, return values, etc."""
warnings: bool
"""Whether to issue warnings for parsing issues."""
def parse_google(
docstring: Docstring,
*,
ignore_init_summary: bool = False,
trim_doctest_flags: bool = True,
returns_multiple_items: bool = True,
returns_named_value: bool = True,
returns_type_in_property_summary: bool = False,
receives_multiple_items: bool = True,
receives_named_value: bool = True,
warn_unknown_params: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
) -> list[DocstringSection]:
"""Parse a Google-style docstring.
This function iterates on lines of a docstring to build sections.
It then returns this list of sections.
Parameters:
docstring: The docstring to parse.
ignore_init_summary: Whether to ignore the summary in `__init__` methods' docstrings.
trim_doctest_flags: Whether to remove doctest flags from Python example blocks.
returns_multiple_items: Whether to parse multiple items in `Yields` and `Returns` sections.
When true, each item's continuation lines must be indented.
When false (single item), no further indentation is required.
returns_named_value: Whether to parse `Yields` and `Returns` section items as name and description, rather than type and description.
When true, type must be wrapped in parentheses: `(int): Description.`. Names are optional: `name (int): Description.`.
When false, parentheses are optional but the items cannot be named: `int: Description`.
receives_multiple_items: Whether to parse multiple items in `Receives` sections.
When true, each item's continuation lines must be indented.
When false (single item), no further indentation is required.
receives_named_value: Whether to parse `Receives` section items as name and description, rather than type and description.
When true, type must be wrapped in parentheses: `(int): Description.`. Names are optional: `name (int): Description.`.
When false, parentheses are optional but the items cannot be named: `int: Description`.
returns_type_in_property_summary: Whether to parse the return type of properties
at the beginning of their summary: `str: Summary of the property`.
warn_unknown_params: Warn about documented parameters not appearing in the signature.
warn_missing_types: Warn about missing types/annotations for parameters, return values, etc.
warnings: Whether to log warnings at all.
Returns:
A list of docstring sections.
"""
sections: list[DocstringSection] = []
current_section = []
in_code_block = False
lines = docstring.lines
options = {
"ignore_init_summary": ignore_init_summary,
"trim_doctest_flags": trim_doctest_flags,
"returns_multiple_items": returns_multiple_items,
"returns_named_value": returns_named_value,
"returns_type_in_property_summary": returns_type_in_property_summary,
"receives_multiple_items": receives_multiple_items,
"receives_named_value": receives_named_value,
"warn_unknown_params": warn_unknown_params,
"warn_missing_types": warn_missing_types,
"warnings": warnings,
}
ignore_summary = (
options["ignore_init_summary"]
and docstring.parent is not None
and docstring.parent.name == "__init__"
and docstring.parent.is_function
and docstring.parent.parent is not None
and docstring.parent.parent.is_class
)
offset = 2 if ignore_summary else 0
while offset < len(lines):
line = lines[offset]
is_code_fence = line.lstrip(" ").startswith("```")
if in_code_block:
if is_code_fence:
in_code_block = False
current_section.append(line)
elif is_code_fence:
in_code_block = True
current_section.append(line)
elif ":" in line and (match := _RE_ADMONITION.match(line)):
groups = match.groupdict()
title = groups["title"]
admonition_type = groups["type"]
is_section = admonition_type.lower() in _section_kind
has_previous_line = offset > 0
blank_line_above = not has_previous_line or _is_empty_line(lines[offset - 1])
has_next_line = offset < len(lines) - 1
has_next_lines = offset < len(lines) - 2
blank_line_below = has_next_line and _is_empty_line(lines[offset + 1])
blank_lines_below = has_next_lines and _is_empty_line(lines[offset + 2])
indented_line_below = has_next_line and not blank_line_below and lines[offset + 1].startswith(" ")
indented_lines_below = has_next_lines and not blank_lines_below and lines[offset + 2].startswith(" ")
if not (indented_line_below or indented_lines_below):
# Do not warn when there are no contents,
# this is most probably not a section or admonition.
current_section.append(lines[offset])
offset += 1
continue
reasons = []
kind = "section" if is_section else "admonition"
if (indented_line_below or indented_lines_below) and not blank_line_above:
reasons.append(f"Missing blank line above {kind}")
if indented_lines_below and blank_line_below:
reasons.append(f"Extraneous blank line below {kind} title")
if reasons:
if warnings:
reasons_string = "; ".join(reasons)
docstring_warning(
docstring,
offset,
f"Possible {kind} skipped, reasons: {reasons_string}",
LogLevel.debug,
)
current_section.append(lines[offset])
offset += 1
continue
if is_section:
if current_section:
if any(current_section):
sections.append(DocstringSectionText("\n".join(current_section).rstrip("\n")))
current_section = []
reader = _section_reader[_section_kind[admonition_type.lower()]]
section, offset = reader(lines, docstring, offset=offset + 1, **options)
if section:
section.title = title
sections.append(section)
else:
contents, offset = _read_block(lines, offset=offset + 1)
if contents:
if current_section:
if any(current_section):
sections.append(DocstringSectionText("\n".join(current_section).rstrip("\n")))
current_section = []
if title is None:
title = admonition_type
admonition_type = admonition_type.lower().replace(" ", "-")
sections.append(DocstringSectionAdmonition(kind=admonition_type, text=contents, title=title))
else:
with suppress(IndexError):
current_section.append(lines[offset])
else:
current_section.append(line)
offset += 1
if current_section and any(current_section):
sections.append(DocstringSectionText("\n".join(current_section).rstrip("\n")))
if (
returns_type_in_property_summary
and sections
and docstring.parent
and docstring.parent.is_attribute
and "property" in docstring.parent.labels
):
lines = sections[0].value.lstrip().split("\n")
if ":" in lines[0]:
annotation, line = lines[0].split(":", 1)
lines = [line, *lines[1:]]
sections[0].value = "\n".join(lines)
sections.append(
DocstringSectionReturns(
[DocstringReturn("", description="", annotation=parse_docstring_annotation(annotation, docstring))],
),
)
return sections
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/sphinx.py 0000664 0001750 0001750 00000045167 15246557273 027771 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module defines functions to parse Sphinx docstrings into structured data.
# Credits to Patrick Lannigan ([@plannigan](https://github.com/plannigan))
# who originally added the parser in the [pytkdocs project](https://github.com/mkdocstrings/pytkdocs).
# See https://github.com/mkdocstrings/pytkdocs/pull/71.
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
from inspect import cleandoc
from typing import TYPE_CHECKING, Any, TypedDict
from griffe._internal.docstrings.models import (
DocstringAttribute,
DocstringParameter,
DocstringRaise,
DocstringReturn,
DocstringSection,
DocstringSectionAttributes,
DocstringSectionParameters,
DocstringSectionRaises,
DocstringSectionReturns,
DocstringSectionText,
)
from griffe._internal.docstrings.utils import docstring_warning, parse_docstring_annotation
if TYPE_CHECKING:
from collections.abc import Callable
from griffe._internal.expressions import Expr
from griffe._internal.models import Docstring
# TODO: Examples: from the documentation, we're not sure there is a standard format for examples
_PARAM_NAMES = frozenset(("param", "parameter", "arg", "argument", "key", "keyword"))
_PARAM_TYPE_NAMES = frozenset(("type",))
_ATTRIBUTE_NAMES = frozenset(("var", "ivar", "cvar"))
_ATTRIBUTE_TYPE_NAMES = frozenset(("vartype",))
_RETURN_NAMES = frozenset(("returns", "return"))
_RETURN_TYPE_NAMES = frozenset(("rtype",))
_EXCEPTION_NAMES = frozenset(("raises", "raise", "except", "exception"))
@dataclass(frozen=True)
class _FieldType:
"""Maps directive names to parser functions."""
names: frozenset[str]
reader: Callable[..., int]
prefixes: tuple[str, ...] = field(init=False, repr=False, compare=False)
def __post_init__(self) -> None:
object.__setattr__(self, "prefixes", tuple(f":{name}" for name in self.names))
def matches(self, line: str) -> bool:
"""Check if a line matches the field type.
Parameters:
line: Line to check against
Returns:
True if the line matches the field type, False otherwise.
"""
return line.startswith(self.prefixes)
@dataclass
class _ParsedDirective:
"""Directive information that has been parsed from a docstring."""
line: str
next_index: int
directive_parts: list[str]
value: str
invalid: bool = False
@dataclass
class _ParsedValues:
"""Values parsed from the docstring to be used to produce sections."""
description: list[str] = field(default_factory=list)
parameters: dict[str, DocstringParameter] = field(default_factory=dict)
param_types: dict[str, str | Expr] = field(default_factory=dict)
attributes: dict[str, DocstringAttribute] = field(default_factory=dict)
attribute_types: dict[str, str] = field(default_factory=dict)
exceptions: list[DocstringRaise] = field(default_factory=list)
return_value: DocstringReturn | None = None
return_type: str | None = None
class SphinxOptions(TypedDict, total=False):
"""Options for parsing Sphinx-style docstrings."""
warn_unknown_params: bool
"""Whether to warn about unknown parameters."""
warn_missing_types: bool
"""Whether to warn about missing types or annotations."""
warnings: bool
"""Whether to issue warnings for parsing issues."""
def parse_sphinx(
docstring: Docstring,
*,
warn_unknown_params: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
) -> list[DocstringSection]:
"""Parse a Sphinx-style docstring.
Parameters:
docstring: The docstring to parse.
warn_unknown_params: Warn about documented parameters not appearing in the signature.
warn_missing_types: Warn about missing types or annotations.
warnings: Whether to log warnings at all.
Returns:
A list of docstring sections.
"""
parsed_values = _ParsedValues()
options = {
"warn_unknown_params": warn_unknown_params,
"warn_missing_types": warn_missing_types,
"warnings": warnings,
}
lines = docstring.lines
curr_line_index = 0
while curr_line_index < len(lines):
line = lines[curr_line_index]
if not line.startswith(":"):
parsed_values.description.append(line)
curr_line_index += 1
continue
for field_type in _field_types:
if field_type.matches(line):
# https://github.com/python/mypy/issues/5485
curr_line_index = field_type.reader(lines, docstring, curr_line_index, parsed_values, **options)
break
else:
parsed_values.description.append(line)
curr_line_index += 1
return _parsed_values_to_sections(parsed_values)
def _read_parameter(
lines: list[str],
docstring: Docstring,
offset: int,
parsed_values: _ParsedValues,
*,
warn_unknown_params: bool = True,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> int:
parsed_directive = _parse_directive(lines, docstring, offset, warnings=warnings)
if parsed_directive.invalid:
return parsed_directive.next_index
directive_type = None
if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004
# no type info
name = parsed_directive.directive_parts[1]
elif len(parsed_directive.directive_parts) == 3: # noqa: PLR2004
directive_type = parse_docstring_annotation(
parsed_directive.directive_parts[1],
docstring,
)
name = parsed_directive.directive_parts[2]
elif len(parsed_directive.directive_parts) > 3: # noqa: PLR2004
# Ignoring type info, only a type with a single word is valid
# https://www.sphinx-doc.org/en/master/usage/domains/python.html#info-field-lists
name = parsed_directive.directive_parts[-1]
if warnings:
docstring_warning(docstring, 0, f"Failed to parse field directive from '{parsed_directive.line}'")
else:
if warnings:
docstring_warning(docstring, 0, f"Failed to parse field directive from '{parsed_directive.line}'")
return parsed_directive.next_index
if name in parsed_values.parameters:
if warnings:
docstring_warning(docstring, 0, f"Duplicate parameter entry for '{name}'")
return parsed_directive.next_index
if warnings and warn_unknown_params:
with suppress(AttributeError): # For Parameters sections in objects without parameters.
params = docstring.parent.parameters # ty:ignore[unresolved-attribute]
if name not in params:
message = f"Parameter '{name}' does not appear in the function signature"
for starred_name in (f"*{name}", f"**{name}"):
if starred_name in params:
message += f". Did you mean '{starred_name}'?"
break
docstring_warning(docstring, 0, message)
annotation = _determine_param_annotation(docstring, name, directive_type, parsed_values, warnings=warnings)
default = _determine_param_default(docstring, name)
parsed_values.parameters[name] = DocstringParameter(
name=name,
annotation=annotation,
description=parsed_directive.value,
value=default,
)
return parsed_directive.next_index
def _determine_param_default(docstring: Docstring, name: str) -> str | None:
try:
return docstring.parent.parameters[name.lstrip()].default # ty:ignore[unresolved-attribute]
except (AttributeError, KeyError):
return None
def _determine_param_annotation(
docstring: Docstring,
name: str,
directive_type: str | Expr | None,
parsed_values: _ParsedValues,
*,
warnings: bool = True,
) -> Any:
# Annotation precedence:
# - in-line directive type
# - "type" directive type
# - signature annotation
# - none
annotation: str | Expr | None = None
parsed_param_type = parsed_values.param_types.get(name)
if parsed_param_type is not None:
annotation = parsed_param_type
if directive_type is not None:
annotation = directive_type
if warnings and directive_type is not None and parsed_param_type is not None:
docstring_warning(docstring, 0, f"Duplicate parameter information for '{name}'")
if annotation is None:
try:
annotation = docstring.parent.parameters[name.lstrip()].annotation # ty:ignore[unresolved-attribute]
except (AttributeError, KeyError):
if warnings:
docstring_warning(docstring, 0, f"No matching parameter for '{name}'")
return annotation
def _read_parameter_type(
lines: list[str],
docstring: Docstring,
offset: int,
parsed_values: _ParsedValues,
*,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> int:
parsed_directive = _parse_directive(lines, docstring, offset, warnings=warnings)
if parsed_directive.invalid:
return parsed_directive.next_index
param_type_str = _consolidate_descriptive_type(parsed_directive.value.strip())
param_type = parse_docstring_annotation(param_type_str, docstring)
if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004
param_name = parsed_directive.directive_parts[1]
else:
if warnings:
docstring_warning(docstring, 0, f"Failed to get parameter name from '{parsed_directive.line}'")
return parsed_directive.next_index
parsed_values.param_types[param_name] = param_type
param = parsed_values.parameters.get(param_name)
if param is not None:
if param.annotation is None:
param.annotation = param_type
elif warnings:
docstring_warning(docstring, 0, f"Duplicate parameter information for '{param_name}'")
return parsed_directive.next_index
def _read_attribute(
lines: list[str],
docstring: Docstring,
offset: int,
parsed_values: _ParsedValues,
*,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> int:
parsed_directive = _parse_directive(lines, docstring, offset, warnings=warnings)
if parsed_directive.invalid:
return parsed_directive.next_index
if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004
name = parsed_directive.directive_parts[1]
else:
if warnings:
docstring_warning(docstring, 0, f"Failed to parse field directive from '{parsed_directive.line}'")
return parsed_directive.next_index
annotation: str | Expr | None = None
# Annotation precedence:
# - "vartype" directive type
# - annotation in the parent
# - none
parsed_attribute_type = parsed_values.attribute_types.get(name)
if parsed_attribute_type is not None:
annotation = parsed_attribute_type
else:
# try to use the annotation from the parent
with suppress(AttributeError, KeyError, TypeError):
# Use subscript syntax to fetch annotation from inherited members too.
annotation = docstring.parent[name].annotation # ty:ignore[not-subscriptable]
if name in parsed_values.attributes:
if warnings:
docstring_warning(docstring, 0, f"Duplicate attribute entry for '{name}'")
else:
parsed_values.attributes[name] = DocstringAttribute(
name=name,
annotation=annotation,
description=parsed_directive.value,
)
return parsed_directive.next_index
def _read_attribute_type(
lines: list[str],
docstring: Docstring,
offset: int,
parsed_values: _ParsedValues,
*,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> int:
parsed_directive = _parse_directive(lines, docstring, offset, warnings=warnings)
if parsed_directive.invalid:
return parsed_directive.next_index
attribute_type = _consolidate_descriptive_type(parsed_directive.value.strip())
if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004
attribute_name = parsed_directive.directive_parts[1]
else:
if warnings:
docstring_warning(docstring, 0, f"Failed to get attribute name from '{parsed_directive.line}'")
return parsed_directive.next_index
parsed_values.attribute_types[attribute_name] = attribute_type
attribute = parsed_values.attributes.get(attribute_name)
if attribute is not None:
if attribute.annotation is None:
attribute.annotation = attribute_type
elif warnings:
docstring_warning(docstring, 0, f"Duplicate attribute information for '{attribute_name}'")
return parsed_directive.next_index
def _read_exception(
lines: list[str],
docstring: Docstring,
offset: int,
parsed_values: _ParsedValues,
*,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> int:
parsed_directive = _parse_directive(lines, docstring, offset, warnings=warnings)
if parsed_directive.invalid:
return parsed_directive.next_index
if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004
ex_type = parsed_directive.directive_parts[1]
parsed_values.exceptions.append(DocstringRaise(annotation=ex_type, description=parsed_directive.value))
elif warnings:
docstring_warning(docstring, 0, f"Failed to parse exception directive from '{parsed_directive.line}'")
return parsed_directive.next_index
def _read_return(
lines: list[str],
docstring: Docstring,
offset: int,
parsed_values: _ParsedValues,
*,
warn_missing_types: bool = True,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> int:
parsed_directive = _parse_directive(lines, docstring, offset, warnings=warnings)
if parsed_directive.invalid:
return parsed_directive.next_index
# Annotation precedence:
# - "rtype" directive type
# - signature annotation
# - None
annotation: str | Expr | None
if parsed_values.return_type is not None:
annotation = parsed_values.return_type
else:
try:
annotation = docstring.parent.annotation # ty:ignore[unresolved-attribute]
except AttributeError:
if warnings and warn_missing_types:
docstring_warning(docstring, 0, f"No return type or annotation at '{parsed_directive.line}'")
annotation = None
# TODO: maybe support names
parsed_values.return_value = DocstringReturn(name="", annotation=annotation, description=parsed_directive.value)
return parsed_directive.next_index
def _read_return_type(
lines: list[str],
docstring: Docstring,
offset: int,
parsed_values: _ParsedValues,
*,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> int:
parsed_directive = _parse_directive(lines, docstring, offset, warnings=warnings)
if parsed_directive.invalid:
return parsed_directive.next_index
return_type = _consolidate_descriptive_type(parsed_directive.value.strip())
parsed_values.return_type = return_type
return_value = parsed_values.return_value
if return_value is not None:
return_value.annotation = return_type
return parsed_directive.next_index
def _parsed_values_to_sections(parsed_values: _ParsedValues) -> list[DocstringSection]:
text = "\n".join(_strip_blank_lines(parsed_values.description))
result: list[DocstringSection] = [DocstringSectionText(text)]
if parsed_values.parameters:
param_values = list(parsed_values.parameters.values())
result.append(DocstringSectionParameters(param_values))
if parsed_values.attributes:
attribute_values = list(parsed_values.attributes.values())
result.append(DocstringSectionAttributes(attribute_values))
if parsed_values.return_value is not None:
result.append(DocstringSectionReturns([parsed_values.return_value]))
if parsed_values.exceptions:
result.append(DocstringSectionRaises(parsed_values.exceptions))
return result
def _parse_directive(
lines: list[str],
docstring: Docstring,
offset: int,
*,
warnings: bool = True,
) -> _ParsedDirective:
line, next_index = _consolidate_continuation_lines(lines, offset)
try:
_, directive, value = line.split(":", 2)
except ValueError:
if warnings:
docstring_warning(docstring, 0, f"Failed to get ':directive: value' pair from '{line}'")
return _ParsedDirective(line, next_index, [], "", invalid=True)
value = value.strip()
return _ParsedDirective(line, next_index, directive.split(" "), value)
def _consolidate_continuation_lines(lines: list[str], offset: int) -> tuple[str, int]:
curr_line_index = offset
block = [lines[curr_line_index].lstrip()]
# start processing after first item
curr_line_index += 1
while curr_line_index < len(lines) and not lines[curr_line_index].startswith(":"):
block.append(lines[curr_line_index])
curr_line_index += 1
return cleandoc("\n".join(block)).rstrip("\n"), curr_line_index - 1
def _consolidate_descriptive_type(descriptive_type: str) -> str:
return descriptive_type.replace(" or ", " | ")
def _strip_blank_lines(lines: list[str]) -> list[str]:
if not lines:
return lines
# remove blank lines from the start and end
content_found = False
initial_content = 0
final_content = 0
for index, line in enumerate(lines):
if not line or line.isspace():
if not content_found:
initial_content += 1
else:
content_found = True
final_content = index
return lines[initial_content : final_content + 1]
_field_types = [
_FieldType(_PARAM_TYPE_NAMES, _read_parameter_type),
_FieldType(_PARAM_NAMES, _read_parameter),
_FieldType(_ATTRIBUTE_TYPE_NAMES, _read_attribute_type),
_FieldType(_ATTRIBUTE_NAMES, _read_attribute),
_FieldType(_EXCEPTION_NAMES, _read_exception),
_FieldType(_RETURN_NAMES, _read_return),
_FieldType(_RETURN_TYPE_NAMES, _read_return_type),
]
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/__init__.py 0000664 0001750 0001750 00000001552 15246557273 030205 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# These submodules define models and parsers for docstrings.
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/parsers.py 0000664 0001750 0001750 00000005132 15246557273 030123 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module imports all the defined parsers
# and provides a generic function to parse docstrings.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from griffe._internal.docstrings.auto import AutoOptions, parse_auto
from griffe._internal.docstrings.google import GoogleOptions, parse_google
from griffe._internal.docstrings.models import DocstringSection, DocstringSectionText
from griffe._internal.docstrings.numpy import NumpyOptions, parse_numpy
from griffe._internal.docstrings.sphinx import SphinxOptions, parse_sphinx
from griffe._internal.enumerations import Parser
if TYPE_CHECKING:
from collections.abc import Callable
from griffe._internal.models import Docstring
DocstringStyle = Literal["google", "numpy", "sphinx", "auto"]
"""The supported docstring styles (literal values of the Parser enumeration)."""
DocstringOptions = GoogleOptions | NumpyOptions | SphinxOptions | AutoOptions
"""The options for each docstring style."""
parsers: dict[Parser, Callable[[Docstring], list[DocstringSection]]] = {
Parser.auto: parse_auto,
Parser.google: parse_google,
Parser.sphinx: parse_sphinx,
Parser.numpy: parse_numpy,
}
def parse(
docstring: Docstring,
parser: DocstringStyle | Parser | None,
**options: Any,
) -> list[DocstringSection]:
"""Parse the docstring.
Parameters:
docstring: The docstring to parse.
parser: The docstring parser to use. If None, return a single text section.
**options: The options accepted by the parser.
Returns:
A list of docstring sections.
"""
if parser:
if not isinstance(parser, Parser):
parser = Parser(parser)
return parsers[parser](docstring, **options)
return [DocstringSectionText(docstring.value)] if docstring.value else []
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/numpy.py 0000664 0001750 0001750 00000111352 15246557273 027616 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module defines functions to parse Numpy-style docstrings into structured data.
#
# Based on https://numpydoc.readthedocs.io/en/latest/format.html,
# it seems Numpydoc is a superset of RST.
# Since fully parsing RST is a non-goal of this project,
# some things are stripped from the Numpydoc specification.
#
# Rejected as non particularly Pythonic or useful as sections:
#
# - See also: this section feels too subjective (specially crafted as a standard for Numpy itself),
# and there are may ways to reference related items in a docstring, depending on the chosen markup.
#
# Rejected as naturally handled by the user-chosen markup:
#
# - Warnings: this is just markup.
# - Notes: again, just markup.
# - References: again, just markup.
from __future__ import annotations
import re
from contextlib import suppress
from textwrap import dedent
from typing import TYPE_CHECKING, TypedDict
from griffe._internal.docstrings.models import (
DocstringAttribute,
DocstringClass,
DocstringFunction,
DocstringModule,
DocstringParameter,
DocstringRaise,
DocstringReceive,
DocstringReturn,
DocstringSection,
DocstringSectionAdmonition,
DocstringSectionAttributes,
DocstringSectionClasses,
DocstringSectionDeprecated,
DocstringSectionExamples,
DocstringSectionFunctions,
DocstringSectionModules,
DocstringSectionOtherParameters,
DocstringSectionParameters,
DocstringSectionRaises,
DocstringSectionReceives,
DocstringSectionReturns,
DocstringSectionText,
DocstringSectionTypeAliases,
DocstringSectionTypeParameters,
DocstringSectionWarns,
DocstringSectionYields,
DocstringTypeAlias,
DocstringTypeParameter,
DocstringWarn,
DocstringYield,
)
from griffe._internal.docstrings.utils import docstring_warning, parse_docstring_annotation
from griffe._internal.enumerations import DocstringSectionKind, LogLevel
from griffe._internal.expressions import ExprName
if TYPE_CHECKING:
from re import Pattern
from typing import Any, Literal
from griffe._internal.expressions import Expr
from griffe._internal.models import Docstring
_section_kind = {
"deprecated": DocstringSectionKind.deprecated,
"parameters": DocstringSectionKind.parameters,
"other parameters": DocstringSectionKind.other_parameters,
"type parameters": DocstringSectionKind.type_parameters,
"returns": DocstringSectionKind.returns,
"yields": DocstringSectionKind.yields,
"receives": DocstringSectionKind.receives,
"raises": DocstringSectionKind.raises,
"warns": DocstringSectionKind.warns,
"examples": DocstringSectionKind.examples,
"attributes": DocstringSectionKind.attributes,
"functions": DocstringSectionKind.functions,
"methods": DocstringSectionKind.functions,
"classes": DocstringSectionKind.classes,
"type aliases": DocstringSectionKind.type_aliases,
"modules": DocstringSectionKind.modules,
}
def _is_empty_line(line: str) -> bool:
return not line or line.isspace()
def _is_dash_line(line: str) -> bool:
return "-" in line and _is_empty_line(line.replace("-", ""))
def _read_block_items(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any, # noqa: ARG001
) -> tuple[list[list[str]], int]:
if offset >= len(lines):
return [], offset
new_offset = offset
items: list[list[str]] = []
# skip first empty lines
while _is_empty_line(lines[new_offset]):
new_offset += 1
# start processing first item
current_item = [lines[new_offset]]
new_offset += 1
# loop on next lines
while new_offset < len(lines):
line = lines[new_offset]
if _is_empty_line(line):
# empty line: preserve it in the current item
current_item.append("")
elif line.startswith(4 * " "):
# continuation line
current_item.append(line[4:])
elif line.startswith(" "):
# indent between initial and continuation: append but warn
cont_indent = len(line) - len(line.lstrip())
current_item.append(line[cont_indent:])
if warnings:
docstring_warning(
docstring,
new_offset,
f"Confusing indentation for continuation line {new_offset + 1} in docstring, "
f"should be 4 spaces, not {cont_indent}",
)
elif new_offset + 1 < len(lines) and _is_dash_line(lines[new_offset + 1]):
# detect the start of a new section
break
else:
items.append(current_item)
current_item = [line]
new_offset += 1
if current_item:
items.append(current_item)
return items, new_offset - 1
def _read_block(
lines: list[str],
*,
offset: int,
**options: Any, # noqa: ARG001
) -> tuple[str, int]:
if offset >= len(lines):
return "", offset
new_offset = offset
block: list[str] = []
# skip first empty lines
while _is_empty_line(lines[new_offset]):
new_offset += 1
while new_offset < len(lines):
is_empty = _is_empty_line(lines[new_offset])
if is_empty and new_offset < len(lines) - 1 and _is_dash_line(lines[new_offset + 1]):
break # Break if a new unnamed section is reached.
if is_empty and new_offset < len(lines) - 2 and _is_dash_line(lines[new_offset + 2]):
break # Break if a new named section is reached.
block.append(lines[new_offset])
new_offset += 1
return "\n".join(block).rstrip("\n"), new_offset - 1
_RE_OB: str = r"\{" # Opening bracket.
_RE_CB: str = r"\}" # Closing bracket.
_RE_NAME: str = r"\*{0,2}[_a-z][_a-z0-9]*"
_RE_TYPE: str = r".+"
_RE_RETURNS: Pattern = re.compile(
rf"""
(?:
(?P{_RE_NAME})\s*:\s*(?P{_RE_TYPE}) # name and type
| # or
(?P{_RE_NAME})\s*:\s* # just name
| # or
\s*:\s*$ # no name, no type
| # or
(?::\s*)?(?P{_RE_TYPE})\s* # just type
)
""",
re.IGNORECASE | re.VERBOSE,
)
_RE_YIELDS: Pattern = _RE_RETURNS
_RE_RECEIVES: Pattern = _RE_RETURNS
_RE_PARAMETER: Pattern = re.compile(
rf"""
(?P{_RE_NAME}(?:,\s{_RE_NAME})*)
(?:
\s:\s
(?:
(?:{_RE_OB}(?P.+){_RE_CB})|
(?P{_RE_TYPE})
)?
)?
""",
re.IGNORECASE | re.VERBOSE,
)
_RE_DOCTEST_BLANKLINE: Pattern = re.compile(r"^\s*\s*$")
_RE_DOCTEST_FLAGS: Pattern = re.compile(r"(\s*#\s*doctest:.+)$")
def _read_parameters(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warn_unknown_params: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[list[DocstringParameter], int]:
parameters: list[DocstringParameter] = []
annotation: str | Expr | None
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
for item in items:
match = _RE_PARAMETER.match(item[0])
if not match:
if warnings:
docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
continue
names = match.group("names").split(", ")
annotation = match.group("type") or None
choices = match.group("choices")
default = None
if choices:
annotation = choices
default = choices.split(", ", 1)[0]
elif annotation:
match = re.match(r"^(?P.+),\s+default(?: |: |=)(?P.+)$", annotation)
if match:
default = match.group("default")
annotation = match.group("annotation")
if annotation and annotation.endswith(", optional"):
annotation = annotation[:-10]
description = "\n".join(item[1:]).rstrip() if len(item) > 1 else ""
if annotation is None:
# try to use the annotation from the signature
for name in names:
with suppress(AttributeError, KeyError):
annotation = docstring.parent.parameters[name].annotation # ty:ignore[unresolved-attribute]
break
else:
if warnings and warn_missing_types:
docstring_warning(docstring, new_offset, f"No types or annotations for parameters {names}")
else:
annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
if default is None:
for name in names:
with suppress(AttributeError, KeyError):
default = docstring.parent.parameters[name].default # ty:ignore[unresolved-attribute]
break
if warnings and warn_unknown_params:
with suppress(AttributeError): # For Parameters sections in objects without parameters.
params = docstring.parent.parameters # ty:ignore[unresolved-attribute]
for name in names:
if name not in params:
message = f"Parameter '{name}' does not appear in the function signature"
for starred_name in (f"*{name}", f"**{name}"):
if starred_name in params:
message += f". Did you mean '{starred_name}'?"
break
docstring_warning(docstring, new_offset, message)
parameters.extend(
DocstringParameter(name, value=default, annotation=annotation, description=description) for name in names
)
return parameters, new_offset
def _read_parameters_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionParameters | None, int]:
parameters, new_offset = _read_parameters(lines, docstring, offset=offset, warnings=warnings, **options)
if parameters:
return DocstringSectionParameters(parameters), new_offset
if warnings:
docstring_warning(docstring, new_offset, f"Empty parameters section at line {offset}")
return None, new_offset
def _read_other_parameters_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warn_unknown_params: bool = True, # noqa: ARG001
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionOtherParameters | None, int]:
parameters, new_offset = _read_parameters(
lines,
docstring,
offset=offset,
warn_unknown_params=False,
warnings=warnings,
**options,
)
if parameters:
return DocstringSectionOtherParameters(parameters), new_offset
if warnings:
docstring_warning(docstring, new_offset, f"Empty other parameters section at line {offset}")
return None, new_offset
def _read_type_parameters_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warn_unknown_params: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionTypeParameters | None, int]:
type_parameters: list[DocstringTypeParameter] = []
bound: str | Expr | None
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
for item in items:
match = _RE_PARAMETER.match(item[0])
if not match:
if warnings:
docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
continue
names = match.group("names").split(", ")
bound = match.group("type") or None
choices = match.group("choices")
default = None
if choices:
bound = choices
default = choices.split(", ", 1)[0]
elif bound:
match = re.match(r"^(?P.+),\s+default(?: |: |=)(?P.+)$", bound)
if match:
default = match.group("default")
bound = match.group("annotation")
description = "\n".join(item[1:]).rstrip() if len(item) > 1 else ""
if bound is None:
# try to use the bound from the signature
for name in names:
with suppress(AttributeError, KeyError):
bound = docstring.parent.type_parameters[name].annotation # ty:ignore[unresolved-attribute]
break
else:
bound = parse_docstring_annotation(bound, docstring, log_level=LogLevel.debug)
if default is None:
for name in names:
with suppress(AttributeError, KeyError):
default = docstring.parent.type_parameters[name].default # ty:ignore[unresolved-attribute]
break
if warnings and warn_unknown_params:
with suppress(AttributeError): # for parameters sections in objects without parameters
type_params = docstring.parent.type_parameters # ty:ignore[unresolved-attribute]
for name in names:
if name not in type_params:
message = f"Type parameter '{name}' does not appear in the {docstring.parent.kind} signature" # ty:ignore[unresolved-attribute]
for starred_name in (f"*{name}", f"**{name}"):
if starred_name in type_params:
message += f". Did you mean '{starred_name}'?"
break
docstring_warning(docstring, new_offset, message)
type_parameters.extend(
DocstringTypeParameter(name, value=default, annotation=bound, description=description) for name in names
)
if type_parameters:
return DocstringSectionTypeParameters(type_parameters), new_offset
if warnings:
docstring_warning(docstring, new_offset, f"Empty type parameters section at line {offset}")
return None, new_offset
def _read_deprecated_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionDeprecated | None, int]:
# deprecated
# SINCE_VERSION
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty deprecated section at line {offset}")
return None, new_offset
if warnings and len(items) > 1:
docstring_warning(docstring, new_offset, f"Too many deprecated items at {offset}")
item = items[0]
version = item[0]
text = dedent("\n".join(item[1:]))
return DocstringSectionDeprecated(version=version, text=text), new_offset
def _read_returns_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionReturns | None, int]:
# (NAME : )?TYPE
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty returns section at line {offset}")
return None, new_offset
returns = []
for index, item in enumerate(items):
match = _RE_RETURNS.match(item[0])
if not match:
if warnings:
docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
continue
groups = match.groupdict()
name = groups["nt_name"] or groups["name"]
annotation = groups["nt_type"] or groups["type"]
text = dedent("\n".join(item[1:]))
if annotation is None:
# try to retrieve the annotation from the docstring parent
with suppress(AttributeError, KeyError, ValueError):
if docstring.parent.is_function: # ty:ignore[unresolved-attribute]
annotation = docstring.parent.returns # ty:ignore[unresolved-attribute]
elif docstring.parent.is_attribute: # ty:ignore[unresolved-attribute]
annotation = docstring.parent.annotation # ty:ignore[unresolved-attribute]
else:
raise ValueError
if len(items) > 1:
if annotation.is_tuple:
annotation = annotation.slice.elements[index]
else:
if annotation.is_iterator:
return_item = annotation.slice
elif annotation.is_generator:
return_item = annotation.slice.elements[2]
else:
raise ValueError
if isinstance(return_item, ExprName):
annotation = return_item
elif return_item.is_tuple:
annotation = return_item.slice.elements[index]
else:
annotation = return_item
else:
annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
returns.append(DocstringReturn(name=name or "", annotation=annotation, description=text))
return DocstringSectionReturns(returns), new_offset
def _read_yields_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionYields | None, int]:
# yields
# (NAME : )?TYPE
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty yields section at line {offset}")
return None, new_offset
yields = []
for index, item in enumerate(items):
match = _RE_YIELDS.match(item[0])
if not match:
if warnings:
docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
continue
groups = match.groupdict()
name = groups["nt_name"] or groups["name"]
annotation = groups["nt_type"] or groups["type"]
text = dedent("\n".join(item[1:]))
if annotation is None:
# try to retrieve the annotation from the docstring parent
with suppress(AttributeError, IndexError, KeyError, ValueError):
annotation = docstring.parent.annotation # ty:ignore[unresolved-attribute]
if annotation.is_iterator:
yield_item = annotation.slice
elif annotation.is_generator:
yield_item = annotation.slice.elements[0]
else:
raise ValueError
if isinstance(yield_item, ExprName):
annotation = yield_item
elif yield_item.is_tuple:
annotation = yield_item.slice.elements[index]
else:
annotation = yield_item
else:
annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
yields.append(DocstringYield(name=name or "", annotation=annotation, description=text))
return DocstringSectionYields(yields), new_offset
def _read_receives_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionReceives | None, int]:
# receives
# (NAME : )?TYPE
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty receives section at line {offset}")
return None, new_offset
receives = []
for index, item in enumerate(items):
match = _RE_RECEIVES.match(item[0])
if not match:
if warnings:
docstring_warning(docstring, new_offset, f"Could not parse line '{item[0]}'")
continue
groups = match.groupdict()
name = groups["nt_name"] or groups["name"]
annotation = groups["nt_type"] or groups["type"]
text = dedent("\n".join(item[1:]))
if annotation is None:
# try to retrieve the annotation from the docstring parent
with suppress(AttributeError, KeyError):
annotation = docstring.parent.returns # ty:ignore[unresolved-attribute]
if annotation.is_generator:
receives_item = annotation.slice.elements[1]
if isinstance(receives_item, ExprName):
annotation = receives_item
elif receives_item.is_tuple:
annotation = receives_item.slice.elements[index]
else:
annotation = receives_item
else:
annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
receives.append(DocstringReceive(name=name or "", annotation=annotation, description=text))
return DocstringSectionReceives(receives), new_offset
def _read_raises_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionRaises | None, int]:
# raises
# EXCEPTION
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty raises section at line {offset}")
return None, new_offset
raises = []
for item in items:
annotation = parse_docstring_annotation(item[0], docstring)
text = dedent("\n".join(item[1:]))
raises.append(DocstringRaise(annotation=annotation, description=text))
return DocstringSectionRaises(raises), new_offset
def _read_warns_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionWarns | None, int]:
# warns
# WARNING
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty warns section at line {offset}")
return None, new_offset
warns = []
for item in items:
annotation = parse_docstring_annotation(item[0], docstring)
text = dedent("\n".join(item[1:]))
warns.append(DocstringWarn(annotation=annotation, description=text))
return DocstringSectionWarns(warns), new_offset
def _read_attributes_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionAttributes | None, int]:
# attributes (for classes)
# NAME( : TYPE)?
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty attributes section at line {offset}")
return None, new_offset
annotation: str | Expr | None
attributes = []
for item in items:
name_type = item[0]
if ":" in name_type:
name, annotation = name_type.split(":", 1)
name = name.strip()
annotation = annotation.strip() or None
else:
name = name_type
annotation = None
if annotation is None:
with suppress(AttributeError, KeyError, TypeError):
# Use subscript syntax to fetch annotation from inherited members too.
annotation = docstring.parent[name].annotation # ty:ignore[not-subscriptable]
else:
annotation = parse_docstring_annotation(annotation, docstring, log_level=LogLevel.debug)
text = dedent("\n".join(item[1:]))
attributes.append(DocstringAttribute(name=name, annotation=annotation, description=text))
return DocstringSectionAttributes(attributes), new_offset
def _read_functions_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionFunctions | None, int]:
# SIGNATURE
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty functions/methods section at line {offset}")
return None, new_offset
functions = []
signature: str | Expr | None
for item in items:
name_signature = item[0]
if "(" in name_signature:
name = name_signature.split("(", 1)[0]
name = name.strip()
signature = name_signature.strip()
else:
name = name_signature
signature = None
text = dedent("\n".join(item[1:])).strip()
functions.append(DocstringFunction(name=name, annotation=signature, description=text))
return DocstringSectionFunctions(functions), new_offset
def _read_classes_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionClasses | None, int]:
# SIGNATURE
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty classes section at line {offset}")
return None, new_offset
classes = []
signature: str | Expr | None
for item in items:
name_signature = item[0]
if "(" in name_signature:
name = name_signature.split("(", 1)[0]
name = name.strip()
signature = name_signature.strip()
else:
name = name_signature
signature = None
text = dedent("\n".join(item[1:])).strip()
classes.append(DocstringClass(name=name, annotation=signature, description=text))
return DocstringSectionClasses(classes), new_offset
def _read_type_aliases_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionTypeAliases | None, int]:
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty type aliases section at line {offset}")
return None, new_offset
type_aliases = []
for item in items:
name = item[0]
text = dedent("\n".join(item[1:])).strip()
type_aliases.append(DocstringTypeAlias(name=name, description=text))
return DocstringSectionTypeAliases(type_aliases), new_offset
def _read_modules_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionModules | None, int]:
# NAME
# TEXT?
items, new_offset = _read_block_items(lines, docstring, offset=offset, warnings=warnings, **options)
if not items:
if warnings:
docstring_warning(docstring, new_offset, f"Empty modules section at line {offset}")
return None, new_offset
modules = []
signature: str | Expr | None
for item in items:
name_signature = item[0]
if "(" in name_signature:
name = name_signature.split("(", 1)[0]
name = name.strip()
signature = name_signature.strip()
else:
name = name_signature
signature = None
text = dedent("\n".join(item[1:])).strip()
modules.append(DocstringModule(name=name, annotation=signature, description=text))
return DocstringSectionModules(modules), new_offset
def _read_examples_section(
lines: list[str],
docstring: Docstring,
*,
offset: int,
trim_doctest_flags: bool = True,
warnings: bool = True,
**options: Any,
) -> tuple[DocstringSectionExamples | None, int]:
text, new_offset = _read_block(lines, offset=offset, warnings=warnings, **options)
sub_sections: list[tuple[Literal[DocstringSectionKind.text, DocstringSectionKind.examples], str]] = []
in_code_example = False
in_code_block = False
current_text: list[str] = []
current_example: list[str] = []
for line in text.split("\n"):
if _is_empty_line(line):
if in_code_example:
if current_example:
sub_sections.append((DocstringSectionKind.examples, "\n".join(current_example)))
current_example = []
in_code_example = False
else:
current_text.append(line)
elif in_code_example:
if trim_doctest_flags:
line = _RE_DOCTEST_FLAGS.sub("", line) # noqa: PLW2901
line = _RE_DOCTEST_BLANKLINE.sub("", line) # noqa: PLW2901
current_example.append(line)
elif line.startswith("```"):
in_code_block = not in_code_block
current_text.append(line)
elif in_code_block:
current_text.append(line)
elif line.startswith(">>>"):
if current_text:
sub_sections.append((DocstringSectionKind.text, "\n".join(current_text).rstrip("\n")))
current_text = []
in_code_example = True
if trim_doctest_flags:
line = _RE_DOCTEST_FLAGS.sub("", line) # noqa: PLW2901
current_example.append(line)
else:
current_text.append(line)
if current_text:
sub_sections.append((DocstringSectionKind.text, "\n".join(current_text).rstrip("\n")))
elif current_example:
sub_sections.append((DocstringSectionKind.examples, "\n".join(current_example)))
if sub_sections:
return DocstringSectionExamples(sub_sections), new_offset
if warnings:
docstring_warning(docstring, new_offset, f"Empty examples section at line {offset}")
return None, new_offset
def _append_section(sections: list, current: list[str], admonition_title: str) -> None:
if admonition_title:
kind = admonition_title.lower().replace(" ", "-")
if kind in ("warnings", "notes"):
# NumpyDoc sections are pluralised but admonitions aren't.
# We can special-case these explicitly so that it renders
# as one would expect.
kind = kind[:-1]
sections.append(
DocstringSectionAdmonition(
kind=kind,
text="\n".join(current).rstrip("\n"),
title=admonition_title,
),
)
elif current and any(current):
sections.append(DocstringSectionText("\n".join(current).rstrip("\n")))
_section_reader = {
DocstringSectionKind.parameters: _read_parameters_section,
DocstringSectionKind.other_parameters: _read_other_parameters_section,
DocstringSectionKind.type_parameters: _read_type_parameters_section,
DocstringSectionKind.deprecated: _read_deprecated_section,
DocstringSectionKind.raises: _read_raises_section,
DocstringSectionKind.warns: _read_warns_section,
DocstringSectionKind.examples: _read_examples_section,
DocstringSectionKind.attributes: _read_attributes_section,
DocstringSectionKind.functions: _read_functions_section,
DocstringSectionKind.classes: _read_classes_section,
DocstringSectionKind.type_aliases: _read_type_aliases_section,
DocstringSectionKind.modules: _read_modules_section,
DocstringSectionKind.returns: _read_returns_section,
DocstringSectionKind.yields: _read_yields_section,
DocstringSectionKind.receives: _read_receives_section,
}
class NumpyOptions(TypedDict, total=False):
"""Options for parsing Numpydoc-style docstrings."""
ignore_init_summary: bool
"""Whether to ignore the summary in `__init__` methods' docstrings."""
trim_doctest_flags: bool
"""Whether to remove doctest flags from Python example blocks."""
warn_unknown_params: bool
"""Whether to warn about unknown parameters."""
warn_missing_types: bool
"""Whether to warn about missing types/annotations for parameters, return values, etc."""
warnings: bool
"""Whether to issue warnings for parsing issues."""
def parse_numpy(
docstring: Docstring,
*,
ignore_init_summary: bool = False,
trim_doctest_flags: bool = True,
warn_unknown_params: bool = True,
warn_missing_types: bool = True,
warnings: bool = True,
) -> list[DocstringSection]:
"""Parse a Numpydoc-style docstring.
This function iterates on lines of a docstring to build sections.
It then returns this list of sections.
Parameters:
docstring: The docstring to parse.
ignore_init_summary: Whether to ignore the summary in `__init__` methods' docstrings.
trim_doctest_flags: Whether to remove doctest flags from Python example blocks.
warn_unknown_params: Warn about documented parameters not appearing in the signature.
warn_missing_types: Warn about missing types/annotations for parameters, return values, etc.
warnings: Whether to log warnings at all.
Returns:
A list of docstring sections.
"""
sections: list[DocstringSection] = []
current_section = []
admonition_title = ""
in_code_block = False
lines = docstring.lines
options = {
"trim_doctest_flags": trim_doctest_flags,
"ignore_init_summary": ignore_init_summary,
"warn_unknown_params": warn_unknown_params,
"warn_missing_types": warn_missing_types,
"warnings": warnings,
}
ignore_summary = (
options["ignore_init_summary"]
and docstring.parent is not None
and docstring.parent.name == "__init__"
and docstring.parent.is_function
and docstring.parent.parent is not None
and docstring.parent.parent.is_class
)
offset = 2 if ignore_summary else 0
while offset < len(lines):
line = lines[offset]
is_code_fence = line.lstrip(" ").startswith("```")
# Code blocks can contain dash lines that we must not interpret.
if in_code_block:
# End of code block.
if is_code_fence:
in_code_block = False
# Lines in code block must not be interpreted in any way.
current_section.append(line)
# Start of code block.
elif is_code_fence:
in_code_block = True
current_section.append(line)
# Dash lines after empty lines lose their meaning.
elif _is_empty_line(line):
current_section.append("")
# End of the docstring, wrap up.
elif offset == len(lines) - 1:
current_section.append(line)
_append_section(sections, current_section, admonition_title)
admonition_title = ""
current_section = []
# Dash line after regular, non-empty line.
elif _is_dash_line(lines[offset + 1]):
# Finish reading current section.
_append_section(sections, current_section, admonition_title)
current_section = []
line_lower = line.lower()
# Start parsing new (known) section.
if line_lower in _section_kind:
admonition_title = ""
reader = _section_reader[_section_kind[line_lower]]
section, offset = reader(lines, docstring, offset=offset + 2, **options)
if section:
sections.append(section)
# Start parsing admonition.
else:
admonition_title = line
offset += 1 # Skip next dash line.
# Regular line.
else:
current_section.append(line)
offset += 1
# Finish current section.
_append_section(sections, current_section, admonition_title)
return sections
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/auto.py 0000664 0001750 0001750 00000020605 15246557273 027416 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module defines functions to parse docstrings by guessing their style.
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Literal, TypedDict
from griffe._internal.enumerations import Parser
if TYPE_CHECKING:
from griffe._internal.docstrings.google import GoogleOptions
from griffe._internal.docstrings.models import DocstringSection
from griffe._internal.docstrings.numpy import NumpyOptions
from griffe._internal.docstrings.parsers import DocstringStyle
from griffe._internal.docstrings.sphinx import SphinxOptions
from griffe._internal.models import Docstring
# This is not our preferred order, but the safest order for proper detection
# using heuristics. Indeed, Google style sections sometimes appear in otherwise
# plain markup docstrings, which could lead to false positives. Same for Numpy
# sections, whose syntax is regular rST markup, and which can therefore appear
# in plain markup docstrings too, even more often than Google sections.
_default_style_order = [Parser.sphinx, Parser.google, Parser.numpy]
DocstringDetectionMethod = Literal["heuristics", "max_sections"]
"""The supported methods to infer docstring styles."""
_pattern_templates = {
Parser.google: (
r"\n[ \t]*{0}:([ \t]+.+)?\n[ \t]+.+",
[
"args",
"arguments",
"attributes",
"classes",
"example",
"examples",
"functions",
"exceptions",
"keyword args",
"keyword arguments",
"methods",
"modules",
"note",
"other args",
"other arguments",
"other params",
"other parameters",
"params",
"parameters",
"raises",
"returns",
"receives",
"warns",
"warning",
"warnings",
"yields",
],
),
Parser.numpy: (
r"\n[ \t]*{0}\n[ \t]*---+\n",
[
"attributes",
"classes",
"deprecated",
"example",
"functions",
"methods",
"note",
"modules",
"other parameters",
"parameters",
"raises",
"receives",
"returns",
"warning",
"warns",
"yields",
],
),
Parser.sphinx: (
r"\n[ \t]*:{0}([ \t]+\w+)*:([ \t]+.+)?\n",
[
"arg",
"argument",
"cvar",
"except",
"exception",
"ivar",
"key",
"keyword",
"param",
"parameter",
"raise",
"raises",
"return",
"returns",
"rtype",
"type",
"var",
"vartype",
],
),
}
_patterns = {
style: re.compile(
pattern.format(f"(?:{'|'.join(map(re.escape, replacements))})"),
re.IGNORECASE | re.MULTILINE,
)
for style, (pattern, replacements) in _pattern_templates.items()
}
class PerStyleOptions(TypedDict, total=False):
"""Per-style options for docstring parsing."""
google: GoogleOptions
"""Options for Google-style docstrings."""
numpy: NumpyOptions
"""Options for Numpy-style docstrings."""
sphinx: SphinxOptions
"""Options for Sphinx-style docstrings."""
def infer_docstring_style(
docstring: Docstring,
*,
method: DocstringDetectionMethod = "heuristics",
style_order: list[Parser] | list[DocstringStyle] | None = None,
default: Parser | DocstringStyle | None = None,
per_style_options: PerStyleOptions | None = None,
) -> tuple[Parser | None, list[DocstringSection] | None]:
"""Infer the parser to use for the docstring.
The 'heuristics' method uses regular expressions. The 'max_sections' method
parses the docstring with all parsers specified in `style_order` and returns
the one who parsed the most sections.
If heuristics fail, the `default` parser is returned. If multiple parsers
parsed the same number of sections, `style_order` is used to decide which
one to return. The `default` parser is never used with the 'max_sections' method.
Additional options are parsed to the detected parser, if any.
Parameters:
docstring: The docstring to parse.
method: The method to use to infer the parser.
style_order: The order of the styles to try when inferring the parser.
default: The default parser to use if the inference fails.
per_style_options: Additional parsing options per style.
Returns:
The inferred parser, and optionally parsed sections (when method is 'max_sections').
"""
from griffe._internal.docstrings.parsers import parsers # noqa: PLC0415
per_style_options = per_style_options or {}
if not style_order:
style_order = _default_style_order
else:
style_order = [Parser(style) if isinstance(style, str) else style for style in style_order]
if method == "heuristics":
for style in style_order:
if _patterns[style].search(docstring.value):
return style, None
return default if default is None or isinstance(default, Parser) else Parser(default), None
if method == "max_sections":
style_sections = {}
for style in style_order:
style_sections[style] = parsers[style](docstring, **per_style_options.get(style, {}))
style_lengths = {style: len(section) for style, section in style_sections.items()}
max_sections = max(style_lengths.values())
for style in style_order:
if style_lengths[style] == max_sections:
return style, style_sections[style]
raise ValueError(f"Invalid method '{method}'.")
class AutoOptions(TypedDict, total=False):
"""Options for Auto-style docstrings."""
method: DocstringDetectionMethod
"""The method to use to infer the parser."""
style_order: list[Parser] | list[DocstringStyle] | None
"""The order of styles to try when inferring the parser."""
default: Parser | DocstringStyle | None
"""The default parser to use if the inference fails."""
per_style_options: PerStyleOptions | None
"""Additional parsing options per style."""
def parse_auto(
docstring: Docstring,
*,
method: DocstringDetectionMethod = "heuristics",
style_order: list[Parser] | list[DocstringStyle] | None = None,
default: Parser | DocstringStyle | None = None,
per_style_options: PerStyleOptions | None = None,
) -> list[DocstringSection]:
"""Parse a docstring by automatically detecting the style it uses.
See [`infer_docstring_style`][griffe.infer_docstring_style] for more information
on the available parameters.
Parameters:
docstring: The docstring to parse.
method: The method to use to infer the parser.
style_order: The order of the styles to try when inferring the parser.
default: The default parser to use if the inference fails.
per_style_options: Additional parsing options per style.
Returns:
A list of docstring sections.
"""
from griffe._internal.docstrings.parsers import parse # noqa: PLC0415
per_style_options = per_style_options or {}
style, sections = infer_docstring_style(
docstring,
method=method,
style_order=style_order,
default=default,
per_style_options=per_style_options,
)
if sections is None:
return parse(docstring, style, **per_style_options.get(style, {})) # ty:ignore[no-matching-overload]
return sections
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/utils.py 0000664 0001750 0001750 00000007337 15246557273 027615 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains utilities for docstrings parsers.
from __future__ import annotations
from ast import Expression, PyCF_ONLY_AST
from contextlib import suppress
from functools import lru_cache
from typing import TYPE_CHECKING, cast
from griffe._internal.enumerations import LogLevel
from griffe._internal.exceptions import BuiltinModuleError
from griffe._internal.expressions import safe_get_annotation
from griffe._internal.logger import logger
if TYPE_CHECKING:
from griffe._internal.expressions import Expr
from griffe._internal.models import Docstring
def docstring_warning(
docstring: Docstring,
offset: int,
message: str,
log_level: LogLevel = LogLevel.warning,
) -> None:
"""Log a warning when parsing a docstring.
This function logs a warning message by prefixing it with the filepath and line number.
Parameters:
docstring: The docstring object.
offset: The offset in the docstring lines.
message: The message to log.
Returns:
A function used to log parsing warnings if `name` was passed, else none.
"""
def warn(docstring: Docstring, offset: int, message: str, log_level: LogLevel = LogLevel.warning) -> None:
try:
prefix = docstring.parent.relative_filepath # ty:ignore[unresolved-attribute]
except (AttributeError, ValueError):
prefix = ""
except BuiltinModuleError:
prefix = f"" # ty:ignore[unresolved-attribute]
log = getattr(logger, log_level.value)
log(f"{prefix}:{(docstring.lineno or 0) + offset}: {message}")
warn(docstring, offset, message, log_level)
@lru_cache(maxsize=512)
def _compile_docstring_annotation(annotation: str) -> Expression | None:
try:
return cast("Expression", compile(annotation, mode="eval", filename="", flags=PyCF_ONLY_AST, optimize=2))
except SyntaxError:
return None
def parse_docstring_annotation(
annotation: str,
docstring: Docstring,
log_level: LogLevel = LogLevel.error,
) -> str | Expr:
"""Parse a string into a true name or expression that can be resolved later.
Parameters:
annotation: The annotation to parse.
docstring: The docstring in which the annotation appears.
The docstring's parent is accessed to bind a resolver to the resulting name/expression.
log_level: Log level to use to log a message.
Returns:
The string unchanged, or a new name or expression.
"""
with suppress(AttributeError): # Docstring has no parent that can be used to resolve names.
if (code := _compile_docstring_annotation(annotation)) and code.body:
name_or_expr = safe_get_annotation(
code.body,
parent=docstring.parent, # ty:ignore[invalid-argument-type]
log_level=log_level,
)
return name_or_expr or annotation
return annotation
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/docstrings/models.py 0000664 0001750 0001750 00000040365 15246557273 027736 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains the models for storing docstrings structured data.
from __future__ import annotations
from typing import TYPE_CHECKING
from griffe._internal.enumerations import DocstringSectionKind
from griffe._internal.expressions import ExprTuple
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Any, Literal
from griffe._internal.expressions import Expr
# Elements -----------------------------------------------
class DocstringElement:
"""This base class represents annotated, nameless elements."""
def __init__(self, *, description: str, annotation: str | Expr | None = None) -> None:
"""Initialize the element.
Parameters:
annotation: The element annotation, if any.
description: The element description.
"""
self.description: str = description
"""The element description."""
self.annotation: str | Expr | None = annotation
"""The element annotation."""
def as_dict(self, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
"""Return this element's data as a dictionary.
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
return {
"annotation": self.annotation,
"description": self.description,
}
class DocstringNamedElement(DocstringElement):
"""This base class represents annotated, named elements."""
def __init__(
self,
name: str,
*,
description: str,
annotation: str | Expr | None = None,
value: str | Expr | None = None,
) -> None:
"""Initialize the element.
Parameters:
name: The element name.
description: The element description.
annotation: The element annotation, if any.
value: The element value, as a string.
"""
super().__init__(description=description, annotation=annotation)
self.name: str = name
"""The element name."""
self.value: str | Expr | None = value
"""The element value, if any"""
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this element's data as a dictionary.
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
base = {"name": self.name, **super().as_dict(**kwargs)}
if self.value is not None:
base["value"] = self.value
return base
class DocstringAdmonition(DocstringElement):
"""This class represents an admonition."""
@property
def kind(self) -> str | Expr | None:
"""The kind of this admonition."""
return self.annotation
@kind.setter
def kind(self, value: str | Expr) -> None:
self.annotation = value
@property
def contents(self) -> str:
"""The contents of this admonition."""
return self.description
@contents.setter
def contents(self, value: str) -> None:
self.description = value
class DocstringDeprecated(DocstringElement):
"""This class represents a documented deprecated item."""
@property
def version(self) -> str:
"""The version of this deprecation."""
return self.annotation # ty:ignore[invalid-return-type]
@version.setter
def version(self, value: str) -> None:
self.annotation = value
class DocstringRaise(DocstringElement):
"""This class represents a documented raise value."""
class DocstringWarn(DocstringElement):
"""This class represents a documented warn value."""
class DocstringReturn(DocstringNamedElement):
"""This class represents a documented return value."""
class DocstringYield(DocstringNamedElement):
"""This class represents a documented yield value."""
class DocstringReceive(DocstringNamedElement):
"""This class represents a documented receive value."""
class DocstringParameter(DocstringNamedElement):
"""This class represent a documented function parameter."""
@property
def default(self) -> str | Expr | None:
"""The default value of this parameter."""
return self.value
@default.setter
def default(self, value: str) -> None:
self.value = value
class DocstringTypeParameter(DocstringNamedElement):
"""This class represent a documented type parameter."""
@property
def default(self) -> str | Expr | None:
"""The default value of this type parameter."""
return self.value
@default.setter
def default(self, value: str) -> None:
self.value = value
@property
def bound(self) -> str | Expr | None:
"""The bound of this type parameter."""
if not isinstance(self.annotation, ExprTuple):
return self.annotation
return None
@bound.setter
def bound(self, bound: str | Expr | None) -> None:
self.annotation = bound
@property
def constraints(self) -> tuple[str | Expr, ...] | None:
"""The constraints of this type parameter."""
if isinstance(self.annotation, ExprTuple):
return tuple(self.annotation.elements)
return None
@constraints.setter
def constraints(self, constraints: Sequence[str | Expr] | None) -> None:
if constraints is not None:
self.annotation = ExprTuple(constraints)
else:
self.annotation = None
class DocstringAttribute(DocstringNamedElement):
"""This class represents a documented module/class attribute."""
class DocstringFunction(DocstringNamedElement):
"""This class represents a documented function."""
@property
def signature(self) -> str | Expr | None:
"""The function signature."""
return self.annotation
class DocstringClass(DocstringNamedElement):
"""This class represents a documented class."""
@property
def signature(self) -> str | Expr | None:
"""The class signature."""
return self.annotation
class DocstringTypeAlias(DocstringNamedElement):
"""This class represents a documented type alias."""
class DocstringModule(DocstringNamedElement):
"""This class represents a documented module."""
# Sections -----------------------------------------------
class DocstringSection:
"""This class represents a docstring section."""
kind: DocstringSectionKind
"""The section kind."""
def __init__(self, title: str | None = None) -> None:
"""Initialize the section.
Parameters:
title: An optional title.
"""
self.title: str | None = title
"""The section title."""
self.value: Any = None
"""The section value."""
def __bool__(self) -> bool:
"""Whether this section has a true-ish value."""
return bool(self.value)
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this section's data as a dictionary.
Parameters:
**kwargs: Additional serialization options.
Returns:
A dictionary.
"""
if hasattr(self.value, "as_dict"): # noqa: SIM108
serialized_value = self.value.as_dict(**kwargs)
else:
serialized_value = self.value
base = {"kind": self.kind.value, "value": serialized_value}
if self.title:
base["title"] = self.title
return base
class DocstringSectionText(DocstringSection):
"""This class represents a text section."""
kind: DocstringSectionKind = DocstringSectionKind.text
def __init__(self, value: str, title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section text.
title: An optional title.
"""
super().__init__(title)
self.value: str = value
class DocstringSectionParameters(DocstringSection):
"""This class represents a parameters section."""
kind: DocstringSectionKind = DocstringSectionKind.parameters
def __init__(self, value: list[DocstringParameter], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section parameters.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringParameter] = value
class DocstringSectionOtherParameters(DocstringSectionParameters):
"""This class represents an other parameters section."""
kind: DocstringSectionKind = DocstringSectionKind.other_parameters
class DocstringSectionTypeParameters(DocstringSection):
"""This class represents a type parameters section."""
kind: DocstringSectionKind = DocstringSectionKind.type_parameters
def __init__(self, value: list[DocstringTypeParameter], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section type parameters.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringTypeParameter] = value
class DocstringSectionRaises(DocstringSection):
"""This class represents a raises section."""
kind: DocstringSectionKind = DocstringSectionKind.raises
def __init__(self, value: list[DocstringRaise], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section exceptions.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringRaise] = value
class DocstringSectionWarns(DocstringSection):
"""This class represents a warns section."""
kind: DocstringSectionKind = DocstringSectionKind.warns
def __init__(self, value: list[DocstringWarn], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section warnings.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringWarn] = value
class DocstringSectionReturns(DocstringSection):
"""This class represents a returns section."""
kind: DocstringSectionKind = DocstringSectionKind.returns
def __init__(self, value: list[DocstringReturn], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section returned items.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringReturn] = value
class DocstringSectionYields(DocstringSection):
"""This class represents a yields section."""
kind: DocstringSectionKind = DocstringSectionKind.yields
def __init__(self, value: list[DocstringYield], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section yielded items.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringYield] = value
class DocstringSectionReceives(DocstringSection):
"""This class represents a receives section."""
kind: DocstringSectionKind = DocstringSectionKind.receives
def __init__(self, value: list[DocstringReceive], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section received items.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringReceive] = value
class DocstringSectionExamples(DocstringSection):
"""This class represents an examples section."""
kind: DocstringSectionKind = DocstringSectionKind.examples
def __init__(
self,
value: list[tuple[Literal[DocstringSectionKind.text, DocstringSectionKind.examples], str]],
title: str | None = None,
) -> None:
"""Initialize the section.
Parameters:
value: The section examples.
title: An optional title.
"""
super().__init__(title)
self.value: list[tuple[Literal[DocstringSectionKind.text, DocstringSectionKind.examples], str]] = value
class DocstringSectionAttributes(DocstringSection):
"""This class represents an attributes section."""
kind: DocstringSectionKind = DocstringSectionKind.attributes
def __init__(self, value: list[DocstringAttribute], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section attributes.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringAttribute] = value
class DocstringSectionFunctions(DocstringSection):
"""This class represents a functions/methods section."""
kind: DocstringSectionKind = DocstringSectionKind.functions
def __init__(self, value: list[DocstringFunction], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section functions.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringFunction] = value
class DocstringSectionClasses(DocstringSection):
"""This class represents a classes section."""
kind: DocstringSectionKind = DocstringSectionKind.classes
def __init__(self, value: list[DocstringClass], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section classes.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringClass] = value
class DocstringSectionTypeAliases(DocstringSection):
"""This class represents a type aliases section."""
kind: DocstringSectionKind = DocstringSectionKind.type_aliases
def __init__(self, value: list[DocstringTypeAlias], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section classes.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringTypeAlias] = value
class DocstringSectionModules(DocstringSection):
"""This class represents a modules section."""
kind: DocstringSectionKind = DocstringSectionKind.modules
def __init__(self, value: list[DocstringModule], title: str | None = None) -> None:
"""Initialize the section.
Parameters:
value: The section modules.
title: An optional title.
"""
super().__init__(title)
self.value: list[DocstringModule] = value
class DocstringSectionDeprecated(DocstringSection):
"""This class represents a deprecated section."""
kind: DocstringSectionKind = DocstringSectionKind.deprecated
def __init__(self, version: str, text: str, title: str | None = None) -> None:
"""Initialize the section.
Parameters:
version: The deprecation version.
text: The deprecation text.
title: An optional title.
"""
super().__init__(title)
self.value: DocstringDeprecated = DocstringDeprecated(annotation=version, description=text)
class DocstringSectionAdmonition(DocstringSection):
"""This class represents an admonition section."""
kind: DocstringSectionKind = DocstringSectionKind.admonition
def __init__(self, kind: str, text: str, title: str | None = None) -> None:
"""Initialize the section.
Parameters:
kind: The admonition kind.
text: The admonition text.
title: An optional title.
"""
super().__init__(title)
self.value: DocstringAdmonition = DocstringAdmonition(annotation=kind, description=text)
python-griffe-2.3.0/packages/griffelib/src/griffe/_internal/collections.py 0000664 0001750 0001750 00000006726 15246557273 026615 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains collection-related classes,
# which are used throughout the API.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from griffe._internal.mixins import DelMembersMixin, GetMembersMixin, SetMembersMixin
if TYPE_CHECKING:
from collections.abc import ItemsView, KeysView, ValuesView
from pathlib import Path
from griffe._internal.models import Module
class LinesCollection:
"""A simple dictionary containing the modules source code lines."""
def __init__(self) -> None:
"""Initialize the collection."""
self._data: dict[Path, list[str]] = {}
def __getitem__(self, key: Path) -> list[str]:
"""Get the lines of a file path."""
return self._data[key]
def __setitem__(self, key: Path, value: list[str]) -> None:
"""Set the lines of a file path."""
self._data[key] = value
def __contains__(self, item: Path) -> bool:
"""Check if a file path is in the collection."""
return item in self._data
def __bool__(self) -> bool:
"""A lines collection is always true-ish."""
return True
def keys(self) -> KeysView:
"""Return the collection keys.
Returns:
The collection keys.
"""
return self._data.keys()
def values(self) -> ValuesView:
"""Return the collection values.
Returns:
The collection values.
"""
return self._data.values()
def items(self) -> ItemsView:
"""Return the collection items.
Returns:
The collection items.
"""
return self._data.items()
class ModulesCollection(GetMembersMixin, SetMembersMixin, DelMembersMixin):
"""A collection of modules, allowing easy access to members."""
is_collection = True
"""Marked as collection to distinguish from objects."""
# "Prefer stubs docstrings": we store it here
# to be able to access it in `SetMembersMixin.set_member`.
_psd: bool = False
def __init__(self) -> None:
"""Initialize the collection."""
self.members: dict[str, Module] = {}
"""Members (modules) of the collection."""
def __bool__(self) -> bool:
"""A modules collection is always true-ish."""
return True
def __contains__(self, item: Any) -> bool:
"""Check if a module is in the collection."""
return item in self.members
@property
def all_members(self) -> dict[str, Module]:
"""Members of the collection.
This property is overwritten to simply return `self.members`,
as `all_members` does not make sense for a modules collection.
"""
return self.members
python-griffe-2.3.0/packages/griffelib/pyproject.toml 0000664 0001750 0001750 00000004310 15246557273 022620 0 ustar carsten carsten [build-system]
# pdm-backend is left here as a dependency of the version discovery script currently in use.
# It may be removed in the future. See mkdocstrings/griffe#430
requires = ["hatchling", "pdm-backend", "uv-dynamic-versioning>=0.7.0"]
build-backend = "hatchling.build"
[project]
name = "griffelib"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}]
license = "ISC"
license-files = ["LICENSE"]
requires-python = ">=3.10"
keywords = ["api", "signature", "breaking-changes", "static-analysis", "dynamic-analysis"]
dynamic = ["version"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
# YORE: EOL 3.10: Remove line.
"Programming Language :: Python :: 3.10",
# YORE: EOL 3.11: Remove line.
"Programming Language :: Python :: 3.11",
# YORE: EOL 3.12: Remove line.
"Programming Language :: Python :: 3.12",
# YORE: EOL 3.13: Remove line.
"Programming Language :: Python :: 3.13",
# YORE: EOL 3.14: Remove line.
"Programming Language :: Python :: 3.14",
"Topic :: Documentation",
"Topic :: Software Development",
"Topic :: Software Development :: Documentation",
"Topic :: Utilities",
"Typing :: Typed",
]
[project.optional-dependencies]
# The 'pypi' extra provides dependencies needed for the load_pypi functionality
# to download and inspect packages from PyPI.
pypi = ["pip>=24.0", "platformdirs>=4.2", "wheel>=0.42"]
[dependency-groups]
dev = ["pytest>=8.2", "pytest-gitconfig>=0.8.0", "jsonschema>=4.18"]
[tool.uv-dynamic-versioning]
pattern = '(?P \d+\.\d+\.\d+)'
[tool.hatch.version]
source = "code"
path = "../../scripts/get_version.py"
expression = "get_version()"
[tool.hatch.build.targets.sdist.force-include]
"../../CHANGELOG.md" = "CHANGELOG.md"
"../../LICENSE" = "LICENSE"
"../../README.md" = "README.md"
[tool.hatch.build.targets.wheel]
packages = ["src/griffe"]
python-griffe-2.3.0/packages/griffelib/tests/ 0000775 0001750 0001750 00000000000 15246557273 021050 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/__init__.py 0000664 0001750 0001750 00000001751 15246557273 023165 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests suite for `griffe`.
from __future__ import annotations
from pathlib import Path
TESTS_DIR = Path(__file__).parent
TMP_DIR = TESTS_DIR / "tmp"
FIXTURES_DIR = TESTS_DIR / "fixtures"
python-griffe-2.3.0/packages/griffelib/tests/test_nodes.py 0000664 0001750 0001750 00000031412 15246557273 023572 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Test nodes utilities.
from __future__ import annotations
import logging
import sys
import weakref
from ast import AST, Add, Name, PyCF_ONLY_AST
import pytest
from griffe import (
Expr,
ExprName,
ast_children,
module_vtree,
relative_to_absolute,
temporary_visited_module,
)
from griffe._internal.agents import visitor as visitor_module
syntax_examples = [
# Operations.
"b + c",
"b - c",
"b * c",
"b / c",
"b // c",
"b ** c",
"b ^ c",
"b & c",
"b | c",
"b @ c",
"b % c",
"b >> c",
"b << c",
# Unary operations.
"+b",
"-b",
"~b",
# Comparisons.
"b == c",
"b >= c",
"b > c",
"b <= c",
"b < c",
"b != c",
# Boolean logic.
"b and c",
"b or c",
"not b",
# Identify.
"b is c",
"b is not c",
# Membership.
"b in c",
"b not in c",
# Calls.
"call()",
"call(something)",
"call(something=something)",
# Strings.
"f'a {round(key, 2)} {z}'",
'f"it\'s {x}"', # ' only -> " delimiter
"f\"don't {x} won't {y}\"", # multiple ' -> " delimiter
"f'say \"hello\" to {x}'", # " only -> ' delimiter
'f\'"quoted" and "re-quoted" {x}\'', # multiple " -> ' delimiter
"f'''it's \"complicated\" {x}'''", # both -> triple-' delimiter
"f'''she said \"it's fine\" to {x}'''", # both, different parts -> triple-'
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
*(["t'a {round(key, 2)} {z}'"] if sys.version_info >= (3, 14) else []),
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
*(['t"it\'s {x}"'] if sys.version_info >= (3, 14) else []),
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
*(["t'say \"hello\" to {x}'"] if sys.version_info >= (3, 14) else []),
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
*(["t'''it's \"complicated\" {x}'''"] if sys.version_info >= (3, 14) else []),
# Formatted values: conversions and format specifiers.
"f'{x!r}'",
"f'{x!s}'",
"f'{x!a}'",
"f'{x:>10}'",
"f'{x:{width}}'",
"f'{x!r:>{width}}'",
"f'a {x:%Y-%m-%d} b'",
"f'{(x := 1)}'",
"f'{ {1: 2}}'",
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
*(["t'{x!r}'"] if sys.version_info >= (3, 14) else []),
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
*(["t'{x!s:>{width}}'"] if sys.version_info >= (3, 14) else []),
# Slices.
"o[x]",
"o[x, y]",
"o[x:y]",
"o[x:y, z]",
"o[x, y(z)]",
"o[(a, b):y]",
"o[a + b:]",
# Walrus operator.
"a if (a := b) else c",
"{(a := 1): 2}",
"{1: (a := 2)}",
"[a for a in b if (c := a)]",
# Starred.
"a(*b, **c)",
"a(*b | c)",
# Structs.
"(a, b, c)",
"{a, b, c}",
"{a: b, c: d}",
"{a + b: c + d for a, b in e}",
"[a, b, c]",
"[a + b for a in c]",
# Generator expressions.
"(a + b for a in c)",
"a(b for b in c)",
"sum((a for a in b), c)",
"[(a for a in b), c]",
"{1: (a for a in b)}",
"(a for a in b if (c := a))",
# Yields.
"yield",
"yield a",
"yield a + b",
"yield from a",
# Lambdas.
"lambda a: a",
"lambda a, b: a",
"lambda *a, **b: a",
"lambda a, b=0: a",
"lambda a=b + c: a",
"lambda a, /, b, c: a",
"lambda a, *, b, c: a",
"lambda a, /, b, *, c: a",
"lambda a, /: a",
"lambda a, b, /: a",
"lambda a, /, *args: args",
"lambda a, /, **kwargs: kwargs",
"lambda a, /, *, b: b",
"lambda a, /, b=0, *args, c=1, **kwargs: a",
"lambda *args, b: b",
"lambda *args, b=0: b",
# Calls with expression-level arguments.
"call(a=b + c)",
# Attribute access on integer literals.
"(1).bit_length()",
]
def test_shared_ast_nodes_do_not_retain_parent_trees() -> None:
"""Shared AST operator/context instances must not keep parsed trees alive."""
expression = compile("left + right", filename="", mode="eval", flags=PyCF_ONLY_AST).body # ty:ignore[unresolved-attribute]
children = list(ast_children(expression))
assert any(isinstance(child, Name) and child.parent is expression for child in children) # ty:ignore[unresolved-attribute]
assert any(isinstance(child, Add) and not hasattr(child, "parent") for child in children)
assert all(isinstance(child, AST) for child in children)
def test_visitor_releases_ast_without_cyclic_gc(monkeypatch: pytest.MonkeyPatch) -> None:
"""A completed static analysis must not leave parent cycles behind."""
references: list[weakref.ReferenceType[AST]] = []
builtin_compile = compile
def capture_ast(*args: object, **kwargs: object) -> AST:
node = builtin_compile(*args, **kwargs) # ty:ignore[no-matching-overload]
references.append(weakref.ref(node))
return node
monkeypatch.setattr(visitor_module, "compile", capture_ast, raising=False)
with temporary_visited_module("value = left + right"):
pass
assert references
assert all(reference() is None for reference in references)
@pytest.mark.parametrize(
("code", "path", "is_package", "expected"),
[
("from . import b", "a", False, "a.b"),
("from . import b", "a", True, "a.b"),
("from . import c", "a.b", False, "a.c"),
("from . import c", "a.b", True, "a.b.c"),
("from . import d", "a.b.c", False, "a.b.d"),
("from .c import d", "a", False, "a.c.d"),
("from .c import d", "a.b", False, "a.c.d"),
("from .b import c", "a.b", True, "a.b.b.c"),
("from .. import e", "a.c.d.i", False, "a.c.e"),
("from ..d import e", "a.c.d.i", False, "a.c.d.e"),
("from ... import f", "a.c.d.i", False, "a.f"),
("from ...b import f", "a.c.d.i", False, "a.b.f"),
("from ...c.d import e", "a.c.d.i", False, "a.c.d.e"),
("from .c import *", "a", False, "a.c.*"),
("from .c import *", "a.b", False, "a.c.*"),
("from .b import *", "a.b", True, "a.b.b.*"),
("from .. import *", "a.c.d.i", False, "a.c.*"),
("from ..d import *", "a.c.d.i", False, "a.c.d.*"),
("from ... import *", "a.c.d.i", False, "a.*"),
("from ...b import *", "a.c.d.i", False, "a.b.*"),
("from ...c.d import *", "a.c.d.i", False, "a.c.d.*"),
],
)
def test_relative_to_absolute_imports(code: str, path: str, is_package: bool, expected: str) -> None:
"""Check if relative imports are correctly converted to absolute ones.
Parameters:
code: The parametrized module code.
path: The parametrized module path.
is_package: Whether the module is a package (or subpackage) (parametrized).
expected: The parametrized expected absolute path.
"""
node = compile(code, mode="exec", filename="<>", flags=PyCF_ONLY_AST).body[0] # ty:ignore[unresolved-attribute]
module = module_vtree(path, leaf_package=is_package, return_leaf=True)
for name in node.names:
assert relative_to_absolute(node, name, module) == expected
def test_multipart_imports() -> None:
"""Assert that a multipart path like `a.b.c` imported as `x` points to the right target."""
with temporary_visited_module(
"""
import pkg.b.c
import pkg.b.c as alias
""",
) as module:
pkg = module["pkg"]
alias = module["alias"]
assert pkg.target_path == "pkg"
assert alias.target_path == "pkg.b.c"
@pytest.mark.parametrize(
"expression",
[
"A",
"A.B",
"A[B]",
"A.B[C.D]",
"~A",
"A | B",
"A[[B, C], D]",
"A(b=c, d=1)",
"A[-1, +2.3]",
"A[B, C.D(e='syntax error')]",
],
)
def test_building_annotations_from_nodes(expression: str) -> None:
"""Test building annotations from AST nodes.
Parameters:
expression: An expression (parametrized).
"""
class_defs = "\n\n".join(f"class {letter}: ..." for letter in "ABCD")
with temporary_visited_module(f"{class_defs}\n\nx: {expression}\ny: {expression} = 0") as module:
assert "x" in module.members
assert "y" in module.members
assert str(module["x"].annotation) == expression
assert str(module["y"].annotation) == expression
@pytest.mark.parametrize("code", syntax_examples)
def test_building_expressions_from_nodes(code: str) -> None:
"""Test building annotations from AST nodes.
Parameters:
code: An expression (parametrized).
"""
with temporary_visited_module(f"__z__ = {code}") as module:
assert "__z__" in module.members
# Make space after comma non-significant.
value = str(module["__z__"].value).replace(", ", ",")
assert value == code.replace(", ", ",")
@pytest.mark.parametrize(
("code", "has_name"),
[
("import typing\nclass A: ...\na: typing.Literal['A']", False),
("from typing import Literal\nclass A: ...\na: Literal['A']", False),
("import typing_extensions\nclass A: ...\na: typing.Literal['A']", False),
("from typing_extensions import Literal\nclass A: ...\na: Literal['A']", False),
("from mod import A\na: 'A'", True),
("from mod import A\na: list['A']", True),
],
)
def test_forward_references(code: str, has_name: bool) -> None:
"""Check that we support forward references (type names as strings).
Parameters:
code: Parametrized code.
has_name: Whether the annotation should contain a Name rather than a string.
"""
with temporary_visited_module(code) as module:
annotation = list(module["a"].annotation.iterate(flat=True))
if has_name:
assert any(isinstance(item, ExprName) and item.name == "A" for item in annotation)
assert all(not (isinstance(item, str) and item == "A") for item in annotation)
else:
assert "'A'" in annotation
assert all(not (isinstance(item, ExprName) and item.name == "A") for item in annotation)
@pytest.mark.parametrize(
"default",
[
"1",
"'test_string'",
"dict(key=1)",
"{'key': 1}",
"DEFAULT_VALUE",
"None",
],
)
def test_default_value_from_nodes(default: str) -> None:
"""Test getting default value from AST nodes.
Parameters:
default: A default value (parametrized).
"""
module_defs = f"def f(x={default}):\n return x"
with temporary_visited_module(module_defs) as module:
assert "f" in module.members
params = module.members["f"].parameters # ty:ignore[unresolved-attribute]
assert len(params) == 1
assert str(params[0].default) == default
# https://github.com/mkdocstrings/griffe/issues/159
def test_parsing_complex_string_annotations() -> None:
"""Test parsing of complex, stringified annotations."""
with temporary_visited_module(
"""
class ArgsKwargs:
def __init__(self, args: 'tuple[Any, ...]', kwargs: 'dict[str, Any] | None' = None) -> None:
...
@property
def args(self) -> 'tuple[Any, ...]':
...
@property
def kwargs(self) -> 'dict[str, Any] | None':
...
""",
) as module:
init_args_annotation = module["ArgsKwargs.__init__"].parameters["args"].annotation
assert isinstance(init_args_annotation, Expr)
assert init_args_annotation.is_tuple
kwargs_return_annotation = module["ArgsKwargs.kwargs"].annotation
assert isinstance(kwargs_return_annotation, Expr)
def test_parsing_dynamic_base_classes(caplog: pytest.LogCaptureFixture) -> None:
"""Assert parsing dynamic base classes does not trigger errors.
Parameters:
caplog: Pytest fixture to capture logs.
"""
with (
caplog.at_level(logging.ERROR),
temporary_visited_module(
"""
from collections import namedtuple
class Thing(namedtuple('Thing', 'attr1 attr2')):
...
""",
),
):
pass
assert not caplog.records
python-griffe-2.3.0/packages/griffelib/tests/helpers.py 0000664 0001750 0001750 00000004075 15246557273 023072 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Helpers for tests.
from __future__ import annotations
import os
import sys
from tempfile import gettempdir
from griffe._internal.helpers import _TMPDIR_PREFIX
def clear_sys_modules(name: str | None = None) -> None:
"""Clear `sys.modules` of a module and its submodules.
Use this function after having used `temporary_pypackage` and `inspect` together.
Better yet, use `temporary_inspected_package` and `temporary_inspected_module`
which will automatically clear `sys.modules` when exiting.
Parameters:
name: A top-level module name. If None, clear all temporary inspected modules
(located in the OS' default temporary directory).
"""
if name:
for module_name in tuple(sys.modules.keys()):
if module_name == name or module_name.startswith(f"{name}."):
sys.modules.pop(module_name, None)
else:
prefix = os.path.join(gettempdir(), _TMPDIR_PREFIX) # noqa: PTH118
for module_name, module in tuple(sys.modules.items()):
if ((file := getattr(module, "__file__", "")) and file.startswith(prefix)) or (
(paths := getattr(module, "__path__", ())) and any(path.startswith(prefix) for path in paths)
):
sys.modules.pop(module_name, None)
python-griffe-2.3.0/packages/griffelib/tests/test_extensions/ 0000775 0001750 0001750 00000000000 15246557273 024306 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/test_extensions/__init__.py 0000664 0001750 0001750 00000001457 15246557273 026426 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
python-griffe-2.3.0/packages/griffelib/tests/test_extensions/test_unpack_typeddict.py 0000664 0001750 0001750 00000022673 15246557273 031263 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Test the unpack extension.
from griffe import DocstringSectionKind, load_extensions, temporary_visited_package
def test_typeddict_support() -> None:
"""Test our `TypedDict` support."""
code = """
from typing import TypedDict
class Kwargs(TypedDict):
a: int
'''Docstring for a.'''
b: str
'''Docstring for b.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("unpack_typeddict"),
docstring_parser="google",
) as pkg:
td = pkg["Kwargs"]
# Signature of the `__init__` method.
assert "__init__" in td.members
init = td["__init__"]
assert len(init.parameters) == 3
assert "self" in init.parameters
assert "a" in init.parameters
assert "b" in init.parameters
assert str(init.parameters["a"].annotation) == "int"
assert str(init.parameters["b"].annotation) == "str"
assert init.parameters["a"].docstring.value == "Docstring for a."
assert init.parameters["b"].docstring.value == "Docstring for b."
assert init.returns == "None"
# Docstring and its "Parameters" section.
assert init.docstring
sections = init.docstring.parsed
assert len(sections) == 1
params_section = sections[0]
assert params_section.kind is DocstringSectionKind.parameters
assert len(params_section.value) == 2
assert params_section.value[0].name == "a"
assert params_section.value[0].description == "Docstring for a."
assert str(params_section.value[0].annotation) == "int"
assert params_section.value[1].name == "b"
assert params_section.value[1].description == "Docstring for b."
assert str(params_section.value[1].annotation) == "str"
def test_unpack_support() -> None:
"""Test our `Unpack` support."""
code = """
from typing import TypedDict, Unpack
class Kwargs(TypedDict):
a: int
'''Docstring for a.'''
b: str
'''Docstring for b.'''
def func(**kwargs: Unpack[Kwargs]) -> None:
'''A function.
Parameters:
**kwargs: The keyword arguments.
'''
pass
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("unpack_typeddict"),
docstring_parser="google",
) as pkg:
func = pkg["func"]
# Signature of the `func` function.
assert len(func.parameters) == 2
assert "a" in func.parameters
assert "b" in func.parameters
assert str(func.parameters["a"].annotation) == "int"
assert str(func.parameters["b"].annotation) == "str"
assert func.parameters["a"].docstring.value == "Docstring for a."
assert func.parameters["b"].docstring.value == "Docstring for b."
# Docstring and its "Parameters" section.
assert func.docstring is not None
sections = func.docstring.parsed
assert len(sections) == 2
params_section = sections[1]
assert params_section.kind is DocstringSectionKind.parameters
assert len(params_section.value) == 2
param_a = next(param for param in params_section.value if param.name == "a")
param_b = next(param for param in params_section.value if param.name == "b")
assert str(param_a.annotation) == "int"
assert str(param_b.annotation) == "str"
assert param_a.description == "Docstring for a."
assert param_b.description == "Docstring for b."
def test_non_total_typeddict() -> None:
"""Test our `TypedDict` support with non-total `TypedDict`s."""
code = """
from typing import TypedDict
class Kwargs(TypedDict, total=False):
a: int
'''Docstring for a.'''
b: str
'''Docstring for b.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("unpack_typeddict"),
docstring_parser="google",
) as pkg:
td = pkg["Kwargs"]
# Docstring and its "Parameters" section.
init = td["__init__"]
assert init.docstring
sections = init.docstring.parsed
assert len(sections) == 1
params_section = sections[0]
assert params_section.kind is DocstringSectionKind.parameters
assert len(params_section.value) == 2
assert params_section.value[0].name == "a"
assert params_section.value[0].description == "Docstring for a."
assert params_section.value[0].value == "..."
assert str(params_section.value[0].annotation) == "int"
assert params_section.value[1].name == "b"
assert params_section.value[1].description == "Docstring for b."
assert params_section.value[1].value == "..."
assert str(params_section.value[1].annotation) == "str"
def test_non_total_unpack() -> None:
"""Test unpacking non-total `TypedDict`s."""
code = """
from typing import TypedDict, Unpack
class Kwargs(TypedDict, total=False):
a: int
'''Docstring for a.'''
b: str
'''Docstring for b.'''
def func(**kwargs: Unpack[Kwargs]) -> None:
'''A function.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("unpack_typeddict"),
docstring_parser="google",
) as pkg:
td = pkg["Kwargs"]
# Docstring and its "Parameters" section.
init = td["__init__"]
assert init.docstring
sections = init.docstring.parsed
assert len(sections) == 1
params_section = sections[0]
assert params_section.kind is DocstringSectionKind.parameters
assert len(params_section.value) == 2
assert params_section.value[0].name == "a"
assert params_section.value[0].description == "Docstring for a."
assert params_section.value[0].value == "..."
assert str(params_section.value[0].annotation) == "int"
assert params_section.value[1].name == "b"
assert params_section.value[1].description == "Docstring for b."
assert params_section.value[1].value == "..."
assert str(params_section.value[1].annotation) == "str"
def test_explicit_requiredness() -> None:
"""Test our `TypedDict` support with explicit requiredness."""
code = """
from typing import TypedDict
from typing_extensions import Required, NotRequired
class Kwargs(TypedDict):
a: NotRequired[int]
'''Docstring for a.'''
b: Required[str]
'''Docstring for b.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("unpack_typeddict"),
docstring_parser="google",
) as pkg:
td = pkg["Kwargs"]
# Signature of the `__init__` method.
assert "__init__" in td.members
init = td["__init__"]
assert len(init.parameters) == 3
assert "self" in init.parameters
assert "a" in init.parameters
assert "b" in init.parameters
assert str(init.parameters["a"].annotation) == "int"
assert str(init.parameters["b"].annotation) == "str"
assert init.parameters["a"].docstring.value == "Docstring for a."
assert init.parameters["b"].docstring.value == "Docstring for b."
assert init.parameters["a"].default == "..."
assert init.parameters["b"].default is None
assert [p.name for p in init.parameters] == ["self", "b", "a"]
def test_readonly_fields() -> None:
"""Test our `TypedDict` support with `ReadOnly` fields."""
code = """
from typing import TypedDict
from typing_extensions import ReadOnly, Required, NotRequired
class Kwargs(TypedDict):
a: ReadOnly[int]
'''Docstring for a.'''
b: ReadOnly[Required[str]]
'''Docstring for b.'''
c: Required[ReadOnly[float]]
'''Docstring for c.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("unpack_typeddict"),
docstring_parser="google",
) as pkg:
td = pkg["Kwargs"]
# Signature of the `__init__` method.
assert "__init__" in td.members
init = td["__init__"]
assert len(init.parameters) == 4
assert "self" in init.parameters
assert "a" in init.parameters
assert "b" in init.parameters
assert "c" in init.parameters
assert str(init.parameters["a"].annotation) == "int"
assert str(init.parameters["b"].annotation) == "str"
assert str(init.parameters["c"].annotation) == "float"
assert init.parameters["a"].docstring.value == "Docstring for a."
assert init.parameters["b"].docstring.value == "Docstring for b."
assert init.parameters["c"].docstring.value == "Docstring for c."
assert init.returns == "None"
python-griffe-2.3.0/packages/griffelib/tests/test_extensions/test_dataclasses.py 0000664 0001750 0001750 00000013373 15246557273 030215 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Test the dataclasses extension.
from griffe import ParameterKind, load_extensions, temporary_visited_package
def test_dataclass_support() -> None:
"""Test our `dataclass` support."""
code = """
from dataclasses import dataclass
@dataclass
class Point:
x: int
'''Docstring for x.'''
y: int
'''Docstring for y.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("dataclasses"),
) as pkg:
point = pkg["Point"]
assert "__init__" in point.members
init = point["__init__"]
assert len(init.parameters) == 3
assert "self" in init.parameters
assert "x" in init.parameters
assert "y" in init.parameters
assert str(init.parameters["x"].annotation) == "int"
assert str(init.parameters["y"].annotation) == "int"
assert init.parameters["x"].docstring.value == "Docstring for x."
assert init.parameters["y"].docstring.value == "Docstring for y."
assert init.returns == "None"
def test_non_init_fields() -> None:
"""Test that non-init fields are not included in the `__init__` method."""
code = """
from dataclasses import dataclass, field
@dataclass
class Point:
x: int
'''Docstring for x.'''
y: int = field(init=False)
'''Docstring for y.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("dataclasses"),
) as pkg:
point = pkg["Point"]
assert "__init__" in point.members
init = point["__init__"]
assert len(init.parameters) == 2
assert "self" in init.parameters
assert "x" in init.parameters
assert "y" not in init.parameters
def test_classvar_fields() -> None:
"""Test that `ClassVar` fields are not included in the `__init__` method."""
code = """
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class Point:
x: int
'''Docstring for x.'''
y: ClassVar[int]
'''Docstring for y.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("dataclasses"),
) as pkg:
point = pkg["Point"]
assert "__init__" in point.members
init = point["__init__"]
assert len(init.parameters) == 2
assert "self" in init.parameters
assert "x" in init.parameters
assert "y" not in init.parameters
def test_kw_only_fields() -> None:
"""Test that `kw_only` fields are included as keyword-only parameters in the `__init__` method."""
code = """
from dataclasses import dataclass, field
@dataclass
class Point:
x: int
'''Docstring for x.'''
y: int = field(kw_only=True)
'''Docstring for y.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("dataclasses"),
) as pkg:
point = pkg["Point"]
assert "__init__" in point.members
init = point["__init__"]
assert len(init.parameters) == 3
assert "self" in init.parameters
assert "x" in init.parameters
assert "y" in init.parameters
assert init.parameters["y"].kind is ParameterKind.keyword_only
def test_kw_only_sentinel() -> None:
"""Test that the `KW_ONLY` sentinel works."""
code = """
from dataclasses import dataclass, KW_ONLY
@dataclass
class Point:
x: int
'''Docstring for x.'''
_: KW_ONLY
y: int
'''Docstring for y.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("dataclasses"),
) as pkg:
point = pkg["Point"]
assert "__init__" in point.members
init = point["__init__"]
assert len(init.parameters) == 3
assert "self" in init.parameters
assert "x" in init.parameters
assert "y" in init.parameters
assert init.parameters["y"].kind is ParameterKind.keyword_only
def test_all_kw_only_fields() -> None:
"""Test that all fields can be made keyword-only."""
code = """
from dataclasses import dataclass
@dataclass(kw_only=True)
class Point:
x: int
'''Docstring for x.'''
y: int
'''Docstring for y.'''
"""
with temporary_visited_package(
"pkg",
{"__init__.py": code},
extensions=load_extensions("dataclasses"),
) as pkg:
point = pkg["Point"]
assert "__init__" in point.members
init = point["__init__"]
assert len(init.parameters) == 3
assert "self" in init.parameters
assert "x" in init.parameters
assert "y" in init.parameters
assert init.parameters["x"].kind is ParameterKind.keyword_only
assert init.parameters["y"].kind is ParameterKind.keyword_only
python-griffe-2.3.0/packages/griffelib/tests/test_extensions/test_base.py 0000664 0001750 0001750 00000027614 15246557273 026643 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the base extension functionality.
from __future__ import annotations
import os
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from griffe import (
Alias,
Extension,
Extensions,
GriffeLoader,
ObjectNode,
load_extensions,
sys_path,
temporary_visited_module,
temporary_visited_package,
)
from griffe._internal.models import Attribute, Class, Function, Module, Object, TypeAlias
if TYPE_CHECKING:
import ast
from collections.abc import Iterator
from griffe import Attribute, Class, Function, Object, ObjectNode, TypeAlias
PACKAGE_ROOT = Path(__file__).parent.parent.parent
@contextmanager
def cd(path: str | Path) -> Iterator[None]:
"""Change directory to `path` and restore the previous directory on exit."""
old_path = Path.cwd()
os.chdir(path)
try:
yield
finally:
os.chdir(old_path)
class AnalysisEventsTest(Extension): # noqa: D101
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D107
super().__init__()
self.records: list[str] = []
self.args = args
self.kwargs = kwargs
def on_attribute_instance(self, *, node: ast.AST | ObjectNode, attr: Attribute, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_attribute_instance")
def on_attribute_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_attribute_node")
def on_class_instance(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_class_instance")
def on_class_members(self, *, node: ast.AST | ObjectNode, cls: Class, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_class_members")
def on_class_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_class_node")
def on_function_instance(self, *, node: ast.AST | ObjectNode, func: Function, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_function_instance")
def on_function_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_function_node")
def on_instance(self, *, node: ast.AST | ObjectNode, obj: Object, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_instance")
def on_members(self, *, node: ast.AST | ObjectNode, obj: Object, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_members")
def on_module_instance(self, *, node: ast.AST | ObjectNode, mod: Module, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_module_instance")
def on_module_members(self, *, node: ast.AST | ObjectNode, mod: Module, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_module_members")
def on_module_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_module_node")
def on_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_node")
def on_type_alias_instance(self, *, node: ast.AST | ObjectNode, type_alias: TypeAlias, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_type_alias_instance")
def on_type_alias_node(self, *, node: ast.AST | ObjectNode, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_type_alias_node")
def on_alias_instance(self, *, node: ast.AST | ObjectNode, alias: Alias, **kwargs: Any) -> None: # noqa: D102,ARG002
self.records.append("on_alias_instance")
@pytest.mark.parametrize(
"extension",
[
# With module path.
"tests.test_extensions.test_base",
{"tests.test_extensions.test_base": {"option": 0}},
# With extension path.
"tests.test_extensions.test_base.AnalysisEventsTest",
{"tests.test_extensions.test_base.AnalysisEventsTest": {"option": 0}},
# With filepath.
"tests/test_extensions/test_base.py",
{"tests/test_extensions/test_base.py": {"option": 0}},
# With filepath and extension name.
"tests/test_extensions/test_base.py:AnalysisEventsTest",
{"tests/test_extensions/test_base.py:AnalysisEventsTest": {"option": 0}},
# With instance.
AnalysisEventsTest(option=0),
# With class.
AnalysisEventsTest,
# With absolute paths (esp. important to test for Windows).
PACKAGE_ROOT.joinpath("tests/test_extensions/test_base.py").absolute().as_posix(),
PACKAGE_ROOT.joinpath("tests/test_extensions/test_base.py:AnalysisEventsTest").absolute().as_posix(),
],
)
def test_loading_extensions(extension: str | dict[str, dict[str, Any]] | Extension | type[Extension]) -> None:
"""Test the extensions loading mechanisms.
Parameters:
extension: Extension specification (parametrized).
"""
with cd(PACKAGE_ROOT), sys_path(PACKAGE_ROOT, *sys.path):
extensions = load_extensions(extension)
loaded: AnalysisEventsTest = extensions._extensions[0] # ty:ignore[invalid-assignment]
# We cannot use isinstance here,
# because loading from a filepath drops the parent `tests` package,
# resulting in a different object than the present ExtensionTest.
assert loaded.__class__.__name__ == "AnalysisEventsTest"
if isinstance(extension, (dict, AnalysisEventsTest)):
assert loaded.kwargs == {"option": 0}
# YORE: EOL 3.11: Remove block.
def test_analysis_events_without_type_aliases() -> None:
"""Test analysis events triggering."""
extension = AnalysisEventsTest()
with temporary_visited_module(
"""
import x
attr = 0
def func(): ...
class Class:
cattr = 1
def method(self): ...
""",
extensions=load_extensions(extension),
):
pass
events = [
"on_alias_instance",
"on_attribute_instance",
"on_attribute_node",
"on_class_instance",
"on_class_members",
"on_class_node",
"on_function_instance",
"on_function_node",
"on_instance",
"on_members",
"on_module_instance",
"on_module_members",
"on_module_node",
"on_node",
]
assert set(events) == set(extension.records)
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 type aliases")
def test_analysis_events() -> None:
"""Test analysis events triggering."""
extension = AnalysisEventsTest()
with temporary_visited_module(
"""
import x
attr = 0
def func(): ...
class Class:
cattr = 1
def method(self): ...
type TypeAlias = list[int]
""",
extensions=load_extensions(extension),
):
pass
events = [
"on_alias_instance",
"on_attribute_instance",
"on_attribute_node",
"on_class_instance",
"on_class_members",
"on_class_node",
"on_function_instance",
"on_function_node",
"on_instance",
"on_members",
"on_module_instance",
"on_module_members",
"on_module_node",
"on_node",
"on_type_alias_instance",
"on_type_alias_node",
]
assert set(events) == set(extension.records)
class LoadEventsTest(Extension): # noqa: D101
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D107
super().__init__()
self.records: list[str] = []
self.args = args
self.kwargs = kwargs
def on_alias(self, *, alias: Alias, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_alias")
def on_attribute(self, *, attr: Attribute, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_attribute")
def on_class(self, *, cls: Class, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_class")
def on_function(self, *, func: Function, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_function")
def on_module(self, *, mod: Module, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_module")
def on_object(self, *, obj: Object, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_object")
def on_package(self, *, pkg: Module, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_package")
def on_type_alias(self, *, type_alias: TypeAlias, loader: GriffeLoader, **kwargs: Any) -> None: # noqa: ARG002,D102
self.records.append("on_type_alias")
def test_has_hooks_after_adding_extensions() -> None:
"""Discover hooks from extensions added to the container."""
extensions = Extensions()
module = Module("module")
loader = GriffeLoader(extensions=extensions)
extensions.call("on_package", pkg=module, loader=loader)
assert not extensions.has_hooks("on_package")
extension = LoadEventsTest()
extensions.add(extension)
extensions.call("on_package", pkg=module, loader=loader)
assert extensions.has_hooks("on_package")
assert extension.records == ["on_package"]
# YORE: EOL 3.11: Remove block.
def test_load_events_without_type_aliases() -> None:
"""Test load events triggering."""
extension = LoadEventsTest()
with temporary_visited_package(
"pkg",
{
"__init__.py": """
import x
attr = 0
def func(): ...
class Class:
cattr = 1
def method(self): ...
""",
},
extensions=load_extensions(extension),
):
pass
events = [
"on_alias",
"on_attribute",
"on_class",
"on_function",
"on_module",
"on_object",
"on_package",
]
assert set(events) == set(extension.records)
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 type aliases")
def test_load_events() -> None:
"""Test load events triggering."""
extension = LoadEventsTest()
with temporary_visited_package(
"pkg",
{
"__init__.py": """
import x
attr = 0
def func(): ...
class Class:
cattr = 1
def method(self): ...
type TypeAlias = list[int]
""",
},
extensions=load_extensions(extension),
):
pass
events = [
"on_alias",
"on_attribute",
"on_class",
"on_function",
"on_module",
"on_object",
"on_package",
"on_type_alias",
]
assert set(events) == set(extension.records)
python-griffe-2.3.0/packages/griffelib/tests/test_git.py 0000664 0001750 0001750 00000010256 15246557273 023250 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for creating a griffe Module from specific commits in a git repository.
from __future__ import annotations
import shutil
from subprocess import run
from typing import TYPE_CHECKING
import pytest
from griffe import Module, load_git
from tests import FIXTURES_DIR
if TYPE_CHECKING:
from pathlib import Path
from pytest_gitconfig import GitConfig
REPO_NAME = "my-repo"
REPO_SOURCE = FIXTURES_DIR / "_repo"
MODULE_NAME = "my_module"
def _copy_contents(src: Path, dst: Path) -> None:
"""Copy *contents* of src into dst.
Parameters:
src: the folder whose contents will be copied to dst
dst: the destination folder
"""
dst.mkdir(exist_ok=True, parents=True)
for src_path in src.iterdir():
dst_path = dst / src_path.name
if src_path.is_dir():
_copy_contents(src_path, dst_path)
else:
shutil.copy(src_path, dst_path)
@pytest.fixture
def git_repo(tmp_path: Path, gitconfig: GitConfig) -> Path: # noqa: ARG001
"""Fixture that creates a git repo with multiple tagged versions.
For each directory in `tests/test_git/_repo/`
- the contents of the directory will be copied into the temporary repo
- all files will be added and commited
- the commit will be tagged with the name of the directory
To add to these tests (i.e. to simulate change over time), either modify one of
the files in the existing `v0.1.0`, `v0.2.0` folders, or continue adding new
version folders following the same pattern.
Parameters:
tmp_path: temporary directory fixture
Returns:
Path: path to temporary repo.
"""
repo_path = tmp_path / REPO_NAME
repo_path.mkdir()
run(["git", "-C", str(repo_path), "init"], check=True)
for tagdir in REPO_SOURCE.iterdir():
ver = tagdir.name
_copy_contents(tagdir, repo_path)
run(["git", "-C", str(repo_path), "add", "."], check=True)
run(["git", "-C", str(repo_path), "commit", "-m", f"feat: {ver} stuff"], check=True)
run(["git", "-C", str(repo_path), "tag", ver], check=True)
return repo_path
def test_load_git(git_repo: Path) -> None:
"""Test that we can load modules from different commits from a git repo.
Parameters:
git_repo: temporary git repo
"""
v1 = load_git(MODULE_NAME, ref="v0.1.0", repo=git_repo)
v2 = load_git(MODULE_NAME, ref="v0.2.0", repo=git_repo)
assert isinstance(v1, Module)
assert isinstance(v2, Module)
assert v1.attributes["__version__"].value == "'0.1.0'"
assert v2.attributes["__version__"].value == "'0.2.0'"
def test_load_git_errors(git_repo: Path) -> None:
"""Test that we get informative errors for various invalid inputs.
Parameters:
git_repo: temporary git repo
"""
with pytest.raises(OSError, match="Not a git repository"):
load_git(MODULE_NAME, ref="v0.2.0", repo="not-a-repo")
with pytest.raises(RuntimeError, match="Could not create git worktree"):
load_git(MODULE_NAME, ref="invalid-tag", repo=git_repo)
with pytest.raises(ImportError, match="ModuleNotFoundError: No module named 'not_a_real_module'"):
load_git("not_a_real_module", ref="v0.2.0", repo=git_repo)
def test_git_failures(tmp_path: Path) -> None:
"""Test failures to use Git."""
pytest.importorskip("griffecli")
from griffe import check # noqa: PLC0415
assert check(tmp_path) == 2
python-griffe-2.3.0/packages/griffelib/tests/test_loader.py 0000664 0001750 0001750 00000057344 15246557273 023744 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the `loader` module.
from __future__ import annotations
import logging
from textwrap import dedent
from typing import TYPE_CHECKING
import pytest
from griffe import (
AliasResolutionError,
ExprName,
Extensions,
GriffeLoader,
temporary_inspected_package,
temporary_pyfile,
temporary_pypackage,
temporary_visited_package,
)
def test_skip_load_event_traversal_without_hooks(monkeypatch: pytest.MonkeyPatch) -> None:
"""Do not traverse loaded objects when no extension implements load-event hooks."""
with temporary_pypackage("package", {"__init__.py": "def function(): ..."}) as package:
loader = GriffeLoader(extensions=Extensions(), search_paths=[package.tmpdir])
def fail_if_called(_module: object) -> None:
raise AssertionError("load events were fired without hooks")
monkeypatch.setattr(loader, "_fire_load_events", fail_if_called)
loader.load("package")
def test_resolve_aliases_defers_caught_error_messages(monkeypatch: pytest.MonkeyPatch) -> None:
"""Do not format alias errors that the resolver catches internally."""
with temporary_pypackage("package", {"__init__.py": "from missing import value"}) as package:
loader = GriffeLoader(search_paths=[package.tmpdir])
loader.load("package")
def fail_if_formatted(_error: AliasResolutionError) -> str:
raise AssertionError("caught alias-resolution error was formatted")
monkeypatch.setattr(AliasResolutionError, "_format_message", fail_if_formatted)
unresolved, _ = loader.resolve_aliases(implicit=True, external=False)
assert unresolved == {"package.value"}
if TYPE_CHECKING:
from pathlib import Path
from griffe import Alias
def test_has_docstrings_does_not_try_to_resolve_alias() -> None:
"""Assert that checkins presence of docstrings does not trigger alias resolution."""
with temporary_pyfile("""from abc import abstractmethod""") as (module_name, path):
loader = GriffeLoader(search_paths=[path.parent])
module = loader.load(module_name)
loader.resolve_aliases()
assert "abstractmethod" in module.members
assert not module.has_docstrings
def test_recursive_wildcard_expansion() -> None:
"""Assert that wildcards are expanded recursively."""
with temporary_pypackage("package", ["mod_a/mod_b/mod_c.py"]) as tmp_package:
mod_a_dir = tmp_package.path / "mod_a"
mod_b_dir = mod_a_dir / "mod_b"
mod_a = mod_a_dir / "__init__.py"
mod_b = mod_b_dir / "__init__.py"
mod_c = mod_b_dir / "mod_c.py"
mod_c.write_text("CONST_X = 'X'\nCONST_Y = 'Y'", encoding="utf8")
mod_b.write_text("from .mod_c import *", encoding="utf8")
mod_a.write_text("from .mod_b import *", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
assert "CONST_X" in package["mod_a.mod_b.mod_c"].members
assert "CONST_Y" in package["mod_a.mod_b.mod_c"].members
assert "CONST_X" not in package.members
assert "CONST_Y" not in package.members
loader.expand_wildcards(package) # ty:ignore[invalid-argument-type]
assert "CONST_X" in package["mod_a"].members
assert "CONST_Y" in package["mod_a"].members
assert "CONST_X" in package["mod_a.mod_b"].members
assert "CONST_Y" in package["mod_a.mod_b"].members
def test_dont_shortcut_alias_chain_after_expanding_wildcards() -> None:
"""Assert public aliases paths are not resolved to canonical paths when expanding wildcards."""
with temporary_pypackage("package", ["mod_a.py", "mod_b.py", "mod_c.py"]) as tmp_package:
mod_a = tmp_package.path / "mod_a.py"
mod_b = tmp_package.path / "mod_b.py"
mod_c = tmp_package.path / "mod_c.py"
mod_a.write_text("from package.mod_b import *\nclass Child(Base): ...\n", encoding="utf8")
mod_b.write_text("from package.mod_c import Base\n__all__ = ['Base']\n", encoding="utf8")
mod_c.write_text("class Base: ...\n", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
child = package["mod_a.Child"]
assert child.bases
base = child.bases[0]
assert isinstance(base, ExprName)
assert base.name == "Base"
assert base.canonical_path == "package.mod_b.Base"
def test_dont_overwrite_lower_member_when_expanding_wildcard() -> None:
"""Check that we don't overwrite a member defined after the import when expanding a wildcard."""
with temporary_pypackage("package", ["mod_a.py", "mod_b.py"]) as tmp_package:
mod_a = tmp_package.path / "mod_a.py"
mod_b = tmp_package.path / "mod_b.py"
mod_a.write_text("overwritten = 0\nfrom package.mod_b import *\nnot_overwritten = 0\n", encoding="utf8")
mod_b.write_text("overwritten = 1\nnot_overwritten = 1\n", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert package["mod_a.overwritten"].value == "1"
assert package["mod_a.not_overwritten"].value == "0"
def test_load_data_from_stubs() -> None:
"""Check that the loader is able to load data from stubs / `*.pyi` files."""
with temporary_pypackage("package", ["_rust_notify.pyi"]) as tmp_package:
# Code taken from samuelcolvin/watchfiles project.
code = '''
from typing import List, Literal, Optional, Protocol, Set, Tuple, Union
__all__ = ['RustNotify']
class AbstractEvent(Protocol):
def is_set(self) -> bool: ...
class RustNotify:
"""
Interface to the Rust [notify](https://crates.io/crates/notify) crate which does
the heavy lifting of watching for file changes and grouping them into a single event.
"""
def __init__(self, watch_paths: List[str], debug: bool) -> None:
"""
Create a new RustNotify instance and start a thread to watch for changes.
`FileNotFoundError` is raised if one of the paths does not exist.
Args:
watch_paths: file system paths to watch for changes, can be directories or files
debug: if true, print details about all events to stderr
"""
'''
tmp_package.path.joinpath("_rust_notify.pyi").write_text(dedent(code), encoding="utf8")
tmp_package.path.joinpath("__init__.py").write_text(
"from ._rust_notify import RustNotify\n__all__ = ['RustNotify']",
encoding="utf8",
)
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert "_rust_notify" in package.members
assert "RustNotify" in package.members
assert package["RustNotify"].resolved
def test_load_from_both_py_and_pyi_files() -> None:
"""Check that the loader is able to merge data loaded from `*.py` and `*.pyi` files."""
with temporary_pypackage("package", ["mod.py", "mod.pyi"]) as tmp_package:
tmp_package.path.joinpath("mod.py").write_text(
dedent(
"""
CONST = 0
class Class:
class_attr = True
def function1(self, arg1):
pass
def function2(self, arg1=2.2):
pass
""",
),
encoding="utf8",
)
tmp_package.path.joinpath("mod.pyi").write_text(
dedent(
"""
from typing import Sequence, overload
CONST: int
class Class:
class_attr: bool
@overload
def function1(self, arg1: str) -> Sequence[str]: ...
@overload
def function1(self, arg1: bytes) -> Sequence[bytes]: ...
def function2(self, arg1: float) -> float: ...
""",
),
encoding="utf8",
)
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert "mod" in package.members
mod = package["mod"]
assert mod.filepath.suffix == ".py"
assert "CONST" in mod.members
const = mod["CONST"]
assert const.value == "0"
assert const.annotation.name == "int"
assert "Class" in mod.members
class_ = mod["Class"]
assert "class_attr" in class_.members
class_attr = class_["class_attr"]
assert class_attr.value == "True"
assert class_attr.annotation.name == "bool"
assert "function1" in class_.members
function1 = class_["function1"]
assert len(function1.overloads) == 2
assert "function2" in class_.members
function2 = class_["function2"]
assert function2.returns.name == "float"
assert function2.parameters["arg1"].annotation.name == "float"
assert function2.parameters["arg1"].default == "2.2"
def test_overwrite_module_with_attribute() -> None:
"""Check we are able to overwrite a module with an attribute."""
with temporary_pypackage("package", ["mod.py"]) as tmp_package:
tmp_package.path.joinpath("mod.py").write_text("mod: list = [0, 1, 2]", encoding="utf8")
tmp_package.path.joinpath("__init__.py").write_text("from package.mod import *", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
loader.load(tmp_package.name)
loader.resolve_aliases()
def test_load_package_from_both_py_and_pyi_files() -> None:
"""Check that the loader is able to merge a package loaded from `*.py` and `*.pyi` files.
This is a special case of the previous test: where the package itself has a top level
`__init__.pyi` (not so uncommon).
"""
with temporary_pypackage("package", ["__init__.py", "__init__.pyi"]) as tmp_package:
tmp_package.path.joinpath("__init__.py").write_text("globals()['f'] = lambda x: str(x)", encoding="utf8")
tmp_package.path.joinpath("__init__.pyi").write_text("def f(x: int) -> str: ...", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
assert "f" in package.members
def test_load_single_module_from_both_py_and_pyi_files() -> None:
"""Check that the loader is able to merge a single-module package loaded from `*.py` and `*.pyi` files.
This is a special case of the previous test: where the package is a single module
distribution that also drops a `.pyi` file in site-packages.
"""
with temporary_pypackage("just_a_folder", ["mod.py", "mod.pyi"]) as tmp_folder:
tmp_folder.path.joinpath("__init__.py").unlink()
tmp_folder.path.joinpath("mod.py").write_text("globals()['f'] = lambda x: str(x)", encoding="utf8")
tmp_folder.path.joinpath("mod.pyi").write_text("def f(x: int) -> str: ...", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_folder.path])
package = loader.load("mod")
assert "f" in package.members
def test_unsupported_item_in_all(caplog: pytest.LogCaptureFixture) -> None:
"""Check that unsupported items in `__all__` log a warning.
Parameters:
caplog: Pytest fixture to capture logs.
"""
item_name = "XXX"
with temporary_pypackage("package", ["mod.py"]) as tmp_folder:
tmp_folder.path.joinpath("__init__.py").write_text(
f"from .mod import {item_name}\n__all__ = [{item_name}]",
encoding="utf8",
)
tmp_folder.path.joinpath("mod.py").write_text(f"class {item_name}: ...", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_folder.tmpdir])
loader.expand_exports(loader.load("package")) # ty:ignore[invalid-argument-type]
assert any(item_name in record.message and record.levelname == "WARNING" for record in caplog.records)
def test_skip_modules_with_dots_in_filename(caplog: pytest.LogCaptureFixture) -> None:
"""Check that modules with dots in their filenames are skipped.
Parameters:
caplog: Pytest fixture to capture logs.
"""
caplog.set_level(logging.DEBUG)
with temporary_pypackage("package", ["gunicorn.conf.py"]) as tmp_folder:
loader = GriffeLoader(search_paths=[tmp_folder.tmpdir])
loader.load("package")
assert any("gunicorn.conf.py" in record.message and record.levelname == "DEBUG" for record in caplog.records)
def test_nested_namespace_packages() -> None:
"""Load a deeply nested namespace package."""
with temporary_pypackage("a/b/c/d", ["mod.py"]) as tmp_folder:
loader = GriffeLoader(search_paths=[tmp_folder.tmpdir])
a_package = loader.load("a")
assert "b" in a_package.members
b_package = a_package.members["b"]
assert "c" in b_package.members
c_package = b_package.members["c"]
assert "d" in c_package.members
d_package = c_package.members["d"]
assert "mod" in d_package.members
def test_multiple_nested_namespace_packages() -> None:
"""Load a deeply nested namespace package appearing in several places."""
with temporary_pypackage("a/b/c/d", ["mod1.py"], init=False) as tmp_ns1: # noqa: SIM117
with temporary_pypackage("a/b/c/d", ["mod2.py"], init=False) as tmp_ns2:
with temporary_pypackage("a/b/c/d", ["mod3.py"], init=False) as tmp_ns3:
tmp_namespace_pkgs = [tmp_ns.tmpdir for tmp_ns in (tmp_ns1, tmp_ns2, tmp_ns3)]
loader = GriffeLoader(search_paths=tmp_namespace_pkgs)
a_package = loader.load("a")
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a") in a_package.filepath # ty:ignore[unsupported-operator]
assert "b" in a_package.members
b_package = a_package.members["b"]
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a/b") in b_package.filepath # ty:ignore[unsupported-operator]
assert "c" in b_package.members
c_package = b_package.members["c"]
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a/b/c") in c_package.filepath # ty:ignore[unsupported-operator]
assert "d" in c_package.members
d_package = c_package.members["d"]
for tmp_ns in tmp_namespace_pkgs:
assert tmp_ns.joinpath("a/b/c/d") in d_package.filepath # ty:ignore[unsupported-operator]
assert "mod1" in d_package.members
assert "mod2" in d_package.members
assert "mod3" in d_package.members
def test_stop_at_first_package_inside_namespace_package() -> None:
"""Stop loading similar paths once we found a non-namespace package."""
with temporary_pypackage("a/b/c/d", ["mod1.py"], init=True) as tmp_ns1: # noqa: SIM117
with temporary_pypackage("a/b/c/d", ["mod2.py"], init=True) as tmp_ns2:
tmp_namespace_pkgs = [tmp_ns.tmpdir for tmp_ns in (tmp_ns1, tmp_ns2)]
loader = GriffeLoader(search_paths=tmp_namespace_pkgs)
a_package = loader.load("a")
assert "b" in a_package.members
b_package = a_package.members["b"]
assert "c" in b_package.members
c_package = b_package.members["c"]
assert "d" in c_package.members
d_package = c_package.members["d"]
assert d_package.is_subpackage
assert d_package.filepath == tmp_ns1.tmpdir.joinpath("a/b/c/d/__init__.py")
assert "mod1" in d_package.members
assert "mod2" not in d_package.members
def test_load_builtin_modules() -> None:
"""Assert builtin/compiled modules can be loaded."""
loader = GriffeLoader()
loader.load("_ast")
loader.load("_collections")
loader.load("_operator")
assert "_ast" in loader.modules_collection
assert "_collections" in loader.modules_collection
assert "_operator" in loader.modules_collection
def test_resolve_aliases_of_builtin_modules() -> None:
"""Assert builtin/compiled modules can be loaded."""
loader = GriffeLoader()
loader.load("io")
loader.load("_io")
unresolved, _ = loader.resolve_aliases(external=True, implicit=True, max_iterations=1)
io_unresolved = {un for un in unresolved if un.startswith(("io", "_io"))}
assert len(io_unresolved) < 5
@pytest.mark.parametrize("namespace", [False, True])
def test_loading_stubs_only_packages(tmp_path: Path, namespace: bool) -> None:
"""Test loading and merging of stubs-only packages.
Parameters:
tmp_path: Pytest fixture.
namespace: Whether the package and stubs are namespace packages.
"""
# Create package.
package_parent = tmp_path / "pkg_parent"
package_parent.mkdir()
package = package_parent / "package"
package.mkdir()
if not namespace:
package.joinpath("__init__.py").write_text("a: int = 0", encoding="utf8")
package.joinpath("module.py").write_text("a: int = 0", encoding="utf8")
# Create stubs.
stubs_parent = tmp_path / "stubs_parent"
stubs_parent.mkdir()
stubs = stubs_parent / "package-stubs"
stubs.mkdir()
if not namespace:
stubs.joinpath("__init__.pyi").write_text("b: int", encoding="utf8")
stubs.joinpath("module.pyi").write_text("b: int", encoding="utf8")
# Exposing stubs first, to make sure order doesn't matter.
loader = GriffeLoader(search_paths=[stubs_parent, package_parent])
# Loading package and stubs, checking their contents.
top_module = loader.load("package", try_relative_path=False, find_stubs_package=True)
if not namespace:
assert "a" in top_module.members
assert "b" in top_module.members
assert "a" in top_module["module"].members
assert "b" in top_module["module"].members
@pytest.mark.parametrize(
"init",
[
"from package.thing import thing",
"thing = False",
],
)
def test_submodule_shadowing_member(init: str, caplog: pytest.LogCaptureFixture) -> None:
"""Warn when a submodule shadows a member of the same name.
Parameters:
init: Contents of the top-level init module.
"""
caplog.set_level(logging.DEBUG)
with temporary_visited_package(
"package",
{"__init__.py": init, "thing.py": "thing = True"},
init=True,
):
assert "shadowing" in caplog.text
@pytest.mark.parametrize("wildcard", [True, False])
@pytest.mark.parametrize(("external", "foo_is_resolved"), [(None, True), (True, True), (False, False)])
def test_side_loading_sibling_private_module(wildcard: bool, external: bool | None, foo_is_resolved: bool) -> None:
"""Automatically load `_a` when `a` (wildcard) imports from it.
Parameters:
wildcard: Whether the import is a wildcard import.
external: Value for the `external` parameter when resolving aliases.
foo_is_resolved: Whether the `foo` alias should be resolved.
"""
with temporary_pypackage("_a", {"__init__.py": "def foo():\n '''Docstring.'''"}) as pkg_a: # noqa: SIM117
with temporary_pypackage("a", {"__init__.py": f"from _a import {'*' if wildcard else 'foo'}"}) as pkg_a_private:
loader = GriffeLoader(search_paths=[pkg_a.tmpdir, pkg_a_private.tmpdir])
package = loader.load("a")
loader.resolve_aliases(external=external, implicit=True)
if foo_is_resolved:
assert "foo" in package.members
assert package["foo"].is_alias
assert package["foo"].resolved
assert package["foo"].docstring.value == "Docstring."
elif wildcard:
assert "foo" not in package.members
else:
assert "foo" in package.members
assert package["foo"].is_alias
assert not package["foo"].resolved
def test_forcing_inspection() -> None:
"""Load a package with forced dynamic analysis."""
modules = {"__init__.py": "a = 0", "mod.py": "b = 1"}
with (
temporary_visited_package("static_pkg", modules) as static_package,
temporary_inspected_package("dynamic_pkg", modules) as dynamic_package,
):
for name in static_package.members:
assert name in dynamic_package.members
for name in static_package["mod"].members:
assert name in dynamic_package["mod"].members
def test_relying_on_modules_path_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
"""Load a package that relies on the `__path__` attribute of a module."""
def raise_module_not_found_error(*args, **kwargs) -> None: # noqa: ARG001,ANN002,ANN003
raise ModuleNotFoundError
loader = GriffeLoader()
monkeypatch.setattr(loader.finder, "find_spec", raise_module_not_found_error)
assert loader.load("griffe")
def test_not_calling_package_hook_on_something_else_than_package() -> None:
"""Always call the `on_package` hook on a package, not any other object."""
with temporary_pypackage("pkg", {"__init__.py": "from typing import List as L"}) as pkg:
loader = GriffeLoader(search_paths=[pkg.tmpdir])
alias: Alias = loader.load("pkg.L") # ty:ignore[invalid-assignment]
assert alias.is_alias
assert not alias.resolved
def test_not_overriding_module_with_alias_from_wildcard_import() -> None:
"""Do not override a submodule with an imported object with the same name."""
with temporary_visited_package(
"pkg",
{
"__init__.py": "",
"a/__init__.py": "from .m import *",
"a/m.py": "def m(): pass",
"b/__init__.py": "from .m import *",
"b/m.py": "from pkg.a.m import m",
},
resolve_aliases=True,
) as pkg:
assert pkg["a.m"].is_module
assert pkg["a.m.m"].is_function
assert pkg["b.m"].is_module
assert pkg["b.m.m"].is_alias
def test_loading_utf8_with_bom_files(tmp_path: Path) -> None:
"""Check that the loader can handle UTF-8 files with BOM."""
pkg = tmp_path / "pkg"
pkg.mkdir()
init_file = pkg / "__init__.py"
init_file.write_text("\ufeff# This is a UTF-8 file with BOM\n\ndef func() -> int: ...", encoding="utf-8")
loader = GriffeLoader(search_paths=[tmp_path])
package = loader.load("pkg")
assert "func" in package.members # Just checking all went well, no SyntaxError exceptions raised.
def test_deferred_finder(tmp_path: Path) -> None:
"""Check that the deferred finder works as expected."""
ns = tmp_path / "ns"
l1 = GriffeLoader(search_paths=[tmp_path])
ns.mkdir(exist_ok=False)
l2 = GriffeLoader(search_paths=[tmp_path])
l1_result = l1.load("ns")
l2_result = l2.load("ns")
assert l1_result.as_dict() == l2_result.as_dict()
python-griffe-2.3.0/packages/griffelib/tests/test_functions.py 0000664 0001750 0001750 00000014132 15246557273 024472 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Test functions loading.
from __future__ import annotations
import pytest
from griffe import ParameterKind, temporary_visited_module
def test_visit_simple_function() -> None:
"""Test functions parameters loading."""
with temporary_visited_module("def f(foo='<>'): ...") as module:
function = module["f"]
assert len(function.parameters) == 1
param = function.parameters[0]
assert param is function.parameters["foo"]
assert param.name == "foo"
assert param.kind is ParameterKind.positional_or_keyword
assert param.default == "'<>'"
def test_visit_function_positional_only_param() -> None:
"""Test functions parameters loading."""
with temporary_visited_module("def f(posonly, /): ...") as module:
function = module["f"]
assert len(function.parameters) == 1
param = function.parameters[0]
assert param is function.parameters["posonly"]
assert param.name == "posonly"
assert param.kind is ParameterKind.positional_only
assert param.default is None
def test_visit_function_positional_only_param_with_default() -> None:
"""Test functions parameters loading."""
with temporary_visited_module("def f(posonly=0, /): ...") as module:
function = module["f"]
assert len(function.parameters) == 1
param = function.parameters[0]
assert param is function.parameters["posonly"]
assert param.name == "posonly"
assert param.kind is ParameterKind.positional_only
assert param.default == "0"
def test_visit_function_positional_or_keyword_param() -> None:
"""Test functions parameters loading."""
with temporary_visited_module("def f(posonly, /, poskw): ...") as module:
function = module["f"]
assert len(function.parameters) == 2
param = function.parameters[1]
assert param is function.parameters["poskw"]
assert param.name == "poskw"
assert param.kind is ParameterKind.positional_or_keyword
assert param.default is None
def test_visit_function_positional_or_keyword_param_with_default() -> None:
"""Test functions parameters loading."""
with temporary_visited_module("def f(posonly, /, poskw=0): ...") as module:
function = module["f"]
assert len(function.parameters) == 2
param = function.parameters[1]
assert param is function.parameters["poskw"]
assert param.name == "poskw"
assert param.kind is ParameterKind.positional_or_keyword
assert param.default == "0"
def test_visit_function_keyword_only_param() -> None:
"""Test functions parameters loading."""
with temporary_visited_module("def f(*, kwonly): ...") as module:
function = module["f"]
assert len(function.parameters) == 1
param = function.parameters[0]
assert param is function.parameters["kwonly"]
assert param.name == "kwonly"
assert param.kind is ParameterKind.keyword_only
assert param.default is None
def test_visit_function_keyword_only_param_with_default() -> None:
"""Test functions parameters loading."""
with temporary_visited_module("def f(*, kwonly=0): ...") as module:
function = module["f"]
assert len(function.parameters) == 1
param = function.parameters[0]
assert param is function.parameters["kwonly"]
assert param.name == "kwonly"
assert param.kind is ParameterKind.keyword_only
assert param.default == "0"
def test_visit_function_syntax_error() -> None:
"""Test functions parameters loading."""
with pytest.raises(SyntaxError), temporary_visited_module("def f(/, poskw=0): ..."):
...
def test_visit_function_variadic_params() -> None:
"""Test functions variadic parameters visit."""
with temporary_visited_module("def f(*args: str, kw=1, **kwargs: int): ...") as module:
function = module["f"]
assert len(function.parameters) == 3
param = function.parameters[0]
assert param.name == "args"
assert param.annotation.name == "str"
assert param.annotation.canonical_path == "str"
param = function.parameters[1]
assert param.annotation is None
param = function.parameters[2]
assert param.name == "kwargs"
assert param.annotation.name == "int"
assert param.annotation.canonical_path == "int"
def test_visit_function_params_annotations() -> None:
"""Test functions parameters loading."""
with temporary_visited_module(
"""
import typing
from typing import Any
def f_annorations(
a: str,
b: Any,
c: typing.Optional[typing.List[int]],
d: float | None):
...
""",
) as module:
function = module["f_annorations"]
assert len(function.parameters) == 4
param = function.parameters[0]
assert param.annotation.name == "str"
assert param.annotation.canonical_path == "str"
param = function.parameters[1]
assert param.annotation.name == "Any"
assert param.annotation.canonical_path == "typing.Any"
param = function.parameters[2]
assert str(param.annotation) == "typing.Optional[typing.List[int]]"
param = function.parameters[3]
assert str(param.annotation) == "float | None"
python-griffe-2.3.0/packages/griffelib/tests/test_encoders.py 0000664 0001750 0001750 00000024235 15246557273 024271 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the `encoders` module.
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from jsonschema import ValidationError, validate
from griffe import (
Attribute,
Class,
Function,
GriffeLoader,
Kind,
Module,
Object,
temporary_inspected_package,
temporary_visited_module,
)
def test_full_serialization_caches_paths_per_module_and_call(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Cache module paths during one serialization without retaining stale values."""
package_path = tmp_path / "package" / "__init__.py"
module = Module("package", filepath=package_path)
module.set_member("function", Function("function"))
submodule = Module("submodule", filepath=tmp_path / "package" / "submodule.py")
submodule.set_member("function", Function("function"))
module.set_member("submodule", submodule)
calls = {"package": 0, "cwd": 0}
relative_package_filepath = Object.relative_package_filepath
relative_filepath = Object.relative_filepath
def get_relative_package_filepath(self: Object) -> Path:
calls["package"] += 1
return relative_package_filepath.__get__(self, type(self))
def get_relative_filepath(self: Object) -> Path:
calls["cwd"] += 1
return relative_filepath.__get__(self, type(self))
monkeypatch.setattr(Object, "relative_package_filepath", property(get_relative_package_filepath))
monkeypatch.setattr(Object, "relative_filepath", property(get_relative_filepath))
first = json.loads(module.as_json(full=True))
assert calls == {"package": 2, "cwd": 2}
module._filepath = tmp_path / "other" / "__init__.py"
submodule._filepath = tmp_path / "other" / "submodule.py"
second = json.loads(module.as_json(full=True))
assert calls == {"package": 4, "cwd": 4}
assert first["relative_package_filepath"] != second["relative_package_filepath"]
assert (
first["members"]["submodule"]["relative_package_filepath"]
!= second["members"]["submodule"]["relative_package_filepath"]
)
def test_minimal_data_is_enough() -> None:
"""Test serialization and de-serialization.
This is an end-to-end test that asserts
we can load back a serialized tree and
infer as much data as within the original tree.
"""
loader = GriffeLoader()
module = loader.load("griffe")
dump_options = {"indent": 2, "sort_keys": True}
minimal = module.as_json(full=False, **dump_options)
full = module.as_json(full=True, **dump_options)
reloaded = Module.from_json(minimal)
assert reloaded.as_json(full=False, **dump_options) == minimal
assert reloaded.as_json(full=True, **dump_options) == full
# Also works (but will result in a different type hint).
assert Object.from_json(minimal)
# Won't work if the JSON doesn't represent the type requested.
with pytest.raises(TypeError, match="provided JSON object is not of type"):
Function.from_json(minimal)
def test_namespace_packages() -> None:
"""Test support for namespace packages.
Namespace packages are a bit special as they have no `__init__.py` file.
"""
with temporary_inspected_package("namespace_package", init=False) as package:
dump_options = {"indent": 2, "sort_keys": True}
as_json = package.as_json(full=True, **dump_options)
from_json = Object.from_json(as_json)
assert from_json.as_json(full=True, **dump_options) == as_json
@pytest.mark.parametrize(
"symbol",
[
# Attribute.
"_internal.loader.GriffeLoader.finder",
# Function/method.
"_internal.loader.GriffeLoader.load",
# Class.
"_internal.mixins.GetMembersMixin",
# Module.
"_internal.debug",
],
)
def test_minimal_light_data_is_enough(symbol: str) -> None:
"""Test serialization and de-serialization."""
loader = GriffeLoader()
package = loader.load("griffe")
obj = package[symbol]
dump_options = {"indent": 2, "sort_keys": True}
minimal = obj.as_json(full=False, **dump_options)
full = obj.as_json(full=True, **dump_options)
reloaded = {
Kind.MODULE: Module,
Kind.CLASS: Class,
Kind.FUNCTION: Function,
Kind.ATTRIBUTE: Attribute,
}[obj.kind].from_json(minimal)
reloaded.parent = obj.parent
assert reloaded.as_json(full=False, **dump_options) == minimal
assert reloaded.as_json(full=True, **dump_options) == full
# YORE: EOL 3.12: Remove block.
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics")
def test_encoding_pep695_generics_without_defaults() -> None:
"""Test serialization and de-serialization of PEP 695 generics without defaults.
Defaults are only possible from Python 3.13 onwards.
"""
with temporary_visited_module(
"""
class Class[X: Exception]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str)] = dict[str, T]
""",
) as module:
minimal = module.as_json(full=False)
full = module.as_json(full=True)
reloaded = Module.from_json(minimal)
assert reloaded.as_json(full=False) == minimal
assert reloaded.as_json(full=True) == full
# Also works (but will result in a different type hint).
assert Object.from_json(minimal)
# Won't work if the JSON doesn't represent the type requested.
with pytest.raises(TypeError, match="provided JSON object is not of type"):
Function.from_json(minimal)
# YORE: EOL 3.12: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip
def test_encoding_pep695_generics() -> None:
"""Test serialization and de-serialization of PEP 695 generics with defaults.
Defaults are only possible from Python 3.13 onwards.
"""
with temporary_visited_module(
"""
class Class[X: Exception = OSError]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str) = str] = dict[str, T]
""",
) as module:
minimal = module.as_json(full=False)
full = module.as_json(full=True)
reloaded = Module.from_json(minimal)
assert reloaded.as_json(full=False) == minimal
assert reloaded.as_json(full=True) == full
# Also works (but will result in a different type hint).
assert Object.from_json(minimal)
# Won't work if the JSON doesn't represent the type requested.
with pytest.raises(TypeError, match="provided JSON object is not of type"):
Function.from_json(minimal)
# use this function in test_json_schema to ease schema debugging
def _validate(obj: dict, schema: dict) -> None:
if "members" in obj:
for member in obj["members"]:
_validate(member, schema)
try:
validate(obj, schema)
except ValidationError:
print(obj["path"]) # noqa: T201
raise
@pytest.mark.skipif(not Path("docs", "schema.json").exists(), reason="schema.json not available")
def test_json_schema() -> None:
"""Assert that our serialized data matches our JSON schema."""
loader = GriffeLoader()
module = loader.load("griffe")
loader.resolve_aliases()
data = json.loads(module.as_json(full=True))
with open("docs/schema.json", encoding="utf8") as f: # noqa: PTH123
schema = json.load(f)
validate(data, schema)
# YORE: EOL 3.12: Remove block.
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics")
@pytest.mark.skipif(not Path("docs", "schema.json").exists(), reason="schema.json not available")
def test_json_schema_for_pep695_generics_without_defaults() -> None:
"""Assert that serialized PEP 695 generics without defaults match our JSON schema.
Defaults are only possible from Python 3.13 onwards.
"""
with temporary_visited_module(
"""
class Class[X: Exception]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str)] = dict[str, T]
""",
) as module:
data = json.loads(module.as_json(full=True))
with open("docs/schema.json", encoding="utf8") as f: # noqa: PTH123
schema = json.load(f)
validate(data, schema)
# YORE: EOL 3.12: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip
@pytest.mark.skipif(not Path("docs", "schema.json").exists(), reason="schema.json not available")
def test_json_schema_for_pep695_generics() -> None:
"""Assert that serialized PEP 695 generics with defaults match our JSON schema.
Defaults are only possible from Python 3.13 onwards.
"""
with temporary_visited_module(
"""
class Class[X: Exception = OSError]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str) = str] = dict[str, T]
""",
) as module:
data = json.loads(module.as_json(full=True))
with open("docs/schema.json", encoding="utf8") as f: # noqa: PTH123
schema = json.load(f)
validate(data, schema)
python-griffe-2.3.0/packages/griffelib/tests/test_models.py 0000664 0001750 0001750 00000056073 15246557273 023757 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the `dataclasses` module.
from __future__ import annotations
import sys
from copy import deepcopy
from textwrap import dedent
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from griffe import (
Alias,
AliasResolutionError,
Attribute,
Class,
Docstring,
Function,
GriffeLoader,
Module,
NameResolutionError,
Parameter,
ParameterKind,
Parameters,
TypeParameter,
TypeParameterKind,
TypeParameters,
module_vtree,
temporary_inspected_module,
temporary_pypackage,
temporary_visited_module,
temporary_visited_package,
)
def test_lazy_alias_resolution_error_message(tmp_path: Path) -> None:
"""Build detailed alias-resolution messages only when rendered."""
module = Module("module", filepath=tmp_path / "module.py")
alias = Alias("alias", "missing.target", parent=module)
summary = "Could not resolve alias module.alias pointing at missing.target"
expected = f"Could not resolve alias module.alias pointing at missing.target (in {module.relative_filepath}:None)"
error = AliasResolutionError(alias)
assert error.args == (summary,)
assert error._detailed_message is None
assert str(error) == expected
assert error.args == (summary,)
assert error._detailed_message == expected
def test_submodule_exports() -> None:
"""Check that a module is exported depending on whether it was also imported."""
root = Module("root")
sub = Module("sub")
private = Attribute("_private")
root["sub"] = sub
root["_private"] = private
assert not sub.is_wildcard_exposed
root.imports["sub"] = "root.sub"
assert sub.is_wildcard_exposed
assert not private.is_wildcard_exposed
root.exports = ["_private"]
assert private.is_wildcard_exposed
def test_has_docstrings() -> None:
"""Assert the `.has_docstrings` method is recursive."""
with temporary_visited_module("class A:\n '''Hello.'''") as module:
assert module.has_docstrings
def test_has_docstrings_submodules() -> None:
"""Assert the `.has_docstrings` method descends into submodules."""
module = module_vtree("a.b.c.d")
module["b.c.d"].docstring = Docstring("Hello.")
assert module.has_docstrings
def test_handle_aliases_chain_in_has_docstrings() -> None:
"""Assert the `.has_docstrings` method can handle aliases chains in members."""
with temporary_pypackage("package", ["mod_a.py", "mod_b.py"]) as tmp_package:
mod_a = tmp_package.path / "mod_a.py"
mod_b = tmp_package.path / "mod_b.py"
mod_a.write_text("from .mod_b import someobj", encoding="utf8")
mod_b.write_text("from somelib import someobj", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
assert not package.has_docstrings
loader.resolve_aliases(implicit=True)
assert not package.has_docstrings
def test_has_docstrings_does_not_trigger_alias_resolution() -> None:
"""Assert the `.has_docstrings` method does not trigger alias resolution."""
with temporary_pypackage("package", ["mod_a.py", "mod_b.py"]) as tmp_package:
mod_a = tmp_package.path / "mod_a.py"
mod_b = tmp_package.path / "mod_b.py"
mod_a.write_text("from .mod_b import someobj", encoding="utf8")
mod_b.write_text("from somelib import someobj", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
assert not package.has_docstrings
assert not package["mod_a.someobj"].resolved
def test_deepcopy() -> None:
"""Assert we can deep-copy object trees."""
loader = GriffeLoader()
mod = loader.load("griffe")
deepcopy(mod)
deepcopy(mod.as_dict())
def test_dataclass_properties_and_class_variables() -> None:
"""Don't return properties or class variables as parameters of dataclasses."""
code = """
from dataclasses import dataclass
from functools import cached_property
from typing import ClassVar
@dataclass
class Point:
x: float
y: float
# These definitions create class variables.
r: ClassVar[float]
s: float = 3
t: ClassVar[float] = 3
@property
def a(self):
return 0
@cached_property
def b(self):
return 0
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
params = module["Point"].parameters
assert [p.name for p in params] == ["self", "x", "y", "s"]
@pytest.mark.parametrize(
"code",
[
"""
@dataclass
class Dataclass:
x: float
y: float = field(kw_only=True)
class Class:
def __init__(self, x: float, *, y: float): ...
""",
"""
@dataclass
class Dataclass:
x: float = field(kw_only=True)
y: float
class Class:
def __init__(self, y: float, *, x: float): ...
""",
"""
@dataclass
class Dataclass:
x: float
_: KW_ONLY
y: float
class Class:
def __init__(self, x: float, *, y: float): ...
""",
"""
@dataclass
class Dataclass:
_: KW_ONLY
x: float
y: float
class Class:
def __init__(self, *, x: float, y: float): ...
""",
"""
@dataclass(kw_only=True)
class Dataclass:
x: float
y: float
class Class:
def __init__(self, *, x: float, y: float): ...
""",
],
)
def test_dataclass_parameter_kinds(code: str) -> None:
"""Check dataclass and equivalent non-dataclass parameters.
The parameter kinds for each pair should be the same.
Parameters:
code: Python code to visit.
"""
code = f"from dataclasses import dataclass, field, KW_ONLY\n\n{dedent(code)}"
with temporary_visited_package("package", {"__init__.py": code}) as module:
for dataclass_param, regular_param in zip(
module["Dataclass"].parameters,
module["Class"].parameters,
strict=False,
):
assert dataclass_param == regular_param
def test_regular_class_inheriting_dataclass_dont_get_its_own_params() -> None:
"""A regular class inheriting from a dataclass don't have its attributes added to `__init__`."""
code = """
from dataclasses import dataclass
@dataclass
class Base:
a: int
b: str
@dataclass
class Derived1(Base):
c: float
class Derived2(Base):
d: float
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
params1 = module["Derived1"].parameters
params2 = module["Derived2"].parameters
assert [p.name for p in params1] == ["self", "a", "b", "c"]
assert [p.name for p in params2] == ["self", "a", "b"]
def test_regular_class_inheriting_dataclass_is_labelled_dataclass() -> None:
"""A regular class inheriting from a dataclass is labelled as a dataclass too."""
code = """
from dataclasses import dataclass
@dataclass
class Base:
pass
class Derived(Base):
pass
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
obj = module["Derived"]
assert "dataclass" in obj.labels
def test_fields_with_init_false() -> None:
"""Fields marked with `init=False` are not added to the `__init__` method."""
code = """
from dataclasses import dataclass, field
@dataclass
class PointA:
x: float
y: float
z: float = field(init=False)
@dataclass(init=False)
class PointB:
x: float
y: float
@dataclass(init=False)
class PointC:
x: float
y: float = field(init=True) # `init=True` has no effect.
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
params_a = module["PointA"].parameters
params_b = module["PointB"].parameters
params_c = module["PointC"].parameters
assert "z" not in params_a
assert "x" not in params_b
assert "y" not in params_b
assert "x" not in params_c
assert "y" not in params_c
def test_parameters_are_reorderd_to_match_their_kind() -> None:
"""Keyword-only parameters in base class are pushed back to the end of the signature."""
code = """
from dataclasses import dataclass
@dataclass(kw_only=True)
class Base:
a: int
b: str
@dataclass
class Reordered(Base):
b: float
c: float
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
params_base = module["Base"].parameters
params_reordered = module["Reordered"].parameters
assert [p.name for p in params_base] == ["self", "a", "b"]
assert [p.name for p in params_reordered] == ["self", "b", "c", "a"]
assert str(params_reordered["b"].annotation) == "float"
def test_parameters_annotated_as_initvar() -> None:
"""Don't return InitVar annotated fields as class members.
But if __init__ is defined, InitVar has no effect.
"""
code = """
from dataclasses import dataclass, InitVar
@dataclass
class PointA:
x: float
y: float
z: InitVar[float]
@dataclass
class PointB:
x: float
y: float
z: InitVar[float]
def __init__(self, r: float): ...
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
point_a = module["PointA"]
assert [p.name for p in point_a.parameters] == ["self", "x", "y", "z"]
assert list(point_a.members) == ["x", "y", "__init__"]
point_b = module["PointB"]
assert [p.name for p in point_b.parameters] == ["self", "r"]
assert list(point_b.members) == ["x", "y", "z", "__init__"]
def test_visited_module_source() -> None:
"""Check the source property of a module."""
code = "print('hello')\nprint('world')"
with temporary_visited_package("package", {"__init__.py": code}) as module:
assert module.source == code
def test_visited_class_source() -> None:
"""Check the source property of a class."""
code = """
class A:
def __init__(self, x: int):
self.x = x
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
assert module["A"].source == dedent(code).strip()
def test_visited_object_source_with_missing_line_number() -> None:
"""Check the source property of an object with missing line number."""
code = """
class A:
def __init__(self, x: int):
self.x = x
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
module["A"].endlineno = None
assert not module["A"].source
module["A"].endlineno = 3
module["A"].lineno = None
assert not module["A"].source
def test_inspected_module_source() -> None:
"""Check the source property of a module."""
code = "print('hello')\nprint('world')"
with temporary_inspected_module(code) as module:
assert module.source == code
def test_inspected_class_source() -> None:
"""Check the source property of a class."""
code = """
class A:
def __init__(self, x: int):
self.x = x
"""
with temporary_inspected_module(code) as module:
assert module["A"].source == dedent(code).strip()
def test_inspected_object_source_with_missing_line_number() -> None:
"""Check the source property of an object with missing line number."""
code = """
class A:
def __init__(self, x: int):
self.x = x
"""
with temporary_inspected_module(code) as module:
module["A"].endlineno = None
assert not module["A"].source
module["A"].endlineno = 3
module["A"].lineno = None
assert not module["A"].source
def test_dataclass_parameter_docstrings() -> None:
"""Class parameters should have a docstring attribute."""
code = """
from dataclasses import dataclass, InitVar
@dataclass
class Base:
a: int
"Parameter a"
b: InitVar[int] = 3
"Parameter b"
@dataclass
class Derived(Base):
c: float
d: InitVar[float]
"Parameter d"
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
base = module["Base"]
param_self = base.parameters[0]
param_a = base.parameters[1]
param_b = base.parameters[2]
assert param_self.docstring is None
assert param_a.docstring.value == "Parameter a"
assert param_b.docstring.value == "Parameter b"
derived = module["Derived"]
param_self = derived.parameters[0]
param_a = derived.parameters[1]
param_b = derived.parameters[2]
param_c = derived.parameters[3]
param_d = derived.parameters[4]
assert param_self.docstring is None
assert param_a.docstring.value == "Parameter a"
assert param_b.docstring.value == "Parameter b"
assert param_c.docstring is None
assert param_d.docstring.value == "Parameter d"
def test_attributes_that_have_no_annotations() -> None:
"""Dataclass attributes that have no annotatations are not parameters."""
code = """
from dataclasses import dataclass, field
@dataclass
class Base:
a: int
b: str = field(init=False)
c = 3 # Class attribute.
@dataclass
class Derived(Base):
a = 1 # No effect on the parameter status of `a`.
b = "b" # Inherited non-parameter.
d: float = 4
"""
with temporary_visited_package("package", {"__init__.py": code}) as module:
base_params = [p.name for p in module["Base"].parameters]
derived_params = [p.name for p in module["Derived"].parameters]
assert base_params == ["self", "a"]
assert derived_params == ["self", "a", "d"]
def test_name_resolution() -> None:
"""Name are correctly resolved in the scope of an object."""
code = """
module_attribute = 0
class Class:
import imported
class_attribute = 0
def __init__(self):
self.instance_attribute = 0
def method(self):
local_variable = 0
"""
with temporary_visited_module(code) as module:
assert module.resolve("module_attribute") == "module.module_attribute"
assert module.resolve("Class") == "module.Class"
assert module["module_attribute"].resolve("Class") == "module.Class"
with pytest.raises(NameResolutionError):
module["module_attribute"].resolve("class_attribute")
assert module["Class"].resolve("module_attribute") == "module.module_attribute"
assert module["Class"].resolve("imported") == "imported"
assert module["Class"].resolve("class_attribute") == "module.Class.class_attribute"
assert module["Class"].resolve("instance_attribute") == "module.Class.instance_attribute"
assert module["Class"].resolve("method") == "module.Class.method"
assert module["Class.class_attribute"].resolve("module_attribute") == "module.module_attribute"
assert module["Class.class_attribute"].resolve("Class") == "module.Class"
assert module["Class.class_attribute"].resolve("imported") == "imported"
assert module["Class.class_attribute"].resolve("instance_attribute") == "module.Class.instance_attribute"
assert module["Class.class_attribute"].resolve("method") == "module.Class.method"
assert module["Class.instance_attribute"].resolve("module_attribute") == "module.module_attribute"
assert module["Class.instance_attribute"].resolve("Class") == "module.Class"
assert module["Class.instance_attribute"].resolve("imported") == "imported"
assert module["Class.instance_attribute"].resolve("class_attribute") == "module.Class.class_attribute"
assert module["Class.instance_attribute"].resolve("method") == "module.Class.method"
assert module["Class.method"].resolve("module_attribute") == "module.module_attribute"
assert module["Class.method"].resolve("Class") == "module.Class"
assert module["Class.method"].resolve("imported") == "imported"
assert module["Class.method"].resolve("class_attribute") == "module.Class.class_attribute"
assert module["Class.method"].resolve("instance_attribute") == "module.Class.instance_attribute"
def test_set_parameters() -> None:
"""We can set parameters."""
parameters = Parameters()
# Does not exist yet.
parameters["x"] = Parameter(name="x")
assert "x" in parameters
# Already exists, by name.
parameters["x"] = Parameter(name="x")
assert "x" in parameters
assert len(parameters) == 1
# Already exists, by index.
parameters[0] = Parameter(name="y")
assert "y" in parameters
assert len(parameters) == 1
def test_delete_parameters() -> None:
"""We can delete parameters."""
parameters = Parameters()
# By name.
parameters["x"] = Parameter(name="x")
del parameters["x"]
assert "x" not in parameters
assert len(parameters) == 0
# By index.
parameters["x"] = Parameter(name="x")
del parameters[0]
assert "x" not in parameters
assert len(parameters) == 0
def test_not_resolving_attribute_value_to_itself() -> None:
"""Attribute values with same name don't resolve to themselves."""
with temporary_visited_module(
"""
class A:
def __init__(self):
x = "something"
self.x = x
""",
) as module:
assert module["A.x"].value.canonical_path == "x" # Not `module.A.x`.
def test_resolving_never_raises_alias_errors() -> None:
"""Resolving never raises alias errors."""
with temporary_visited_package(
"package",
{
"__init__.py": """
from package.mod import pd
class A:
def __init__(self):
pass
""",
"mod.py": "import pandas as pd",
},
) as module:
assert module["A.__init__"].resolve("pd") == "package.mod.pd"
def test_building_function_and_class_signatures() -> None:
"""Test the construction of a class/function signature."""
# Test simple function signatures.
simple_params = Parameters(
Parameter("x", annotation="int"),
Parameter("y", annotation="int", default="0"),
)
simple_func = Function("simple_function", parameters=simple_params, returns="int")
assert simple_func.signature() == "simple_function(x: int, y: int = 0) -> int"
# Test class signatures.
init = Function("__init__", parameters=simple_params, returns="None")
cls = Class("TestClass")
cls.set_member("__init__", init)
assert cls.signature() == "TestClass(x: int, y: int = 0)"
# Create a more complex function with various parameter types.
params = Parameters(
Parameter("a", kind=ParameterKind.positional_only),
Parameter("b", kind=ParameterKind.positional_only, annotation="int", default="0"),
Parameter("c", kind=ParameterKind.positional_or_keyword),
Parameter("d", kind=ParameterKind.positional_or_keyword, annotation="str", default="''"),
Parameter("args", kind=ParameterKind.var_positional),
Parameter("e", kind=ParameterKind.keyword_only),
Parameter("f", kind=ParameterKind.keyword_only, annotation="bool", default="False"),
Parameter("kwargs", kind=ParameterKind.var_keyword),
)
func = Function("test_function", parameters=params, returns="None")
expected = "test_function(a, b: int = 0, /, c, d: str = '', *args, e, f: bool = False, **kwargs) -> None"
assert func.signature() == expected
def test_set_type_parameters() -> None:
"""We can set type parameters."""
type_parameters = TypeParameters()
# Does not exist yet.
type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var)
assert "x" in type_parameters
# Already exists, by name.
type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var)
assert "x" in type_parameters
assert len(type_parameters) == 1
# Already exists, by name, with different kind.
type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.param_spec)
assert "x" in type_parameters
assert len(type_parameters) == 1
# Already exists, by index.
type_parameters[0] = TypeParameter(name="y", kind=TypeParameterKind.type_var)
assert "y" in type_parameters
assert len(type_parameters) == 1
def test_delete_type_parameters() -> None:
"""We can delete type parameters."""
type_parameters = TypeParameters()
# By name.
type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var)
del type_parameters["x"]
assert "x" not in type_parameters
assert len(type_parameters) == 0
# By index.
type_parameters["x"] = TypeParameter(name="x", kind=TypeParameterKind.type_var)
del type_parameters[0]
assert "x" not in type_parameters
assert len(type_parameters) == 0
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics")
def test_annotation_resolution() -> None:
"""Names are correctly resolved in the annotation scope of an object."""
with temporary_visited_module(
"""
class C[T]:
class D[T]:
def func[Y](self, arg1: T, arg2: Y): pass
def func[Z](arg1: T, arg2: Y): pass
""",
) as module:
assert module["C.D"].resolve("T") == "module.C.D[T]"
assert module["C.D.func"].resolve("T") == "module.C.D[T]"
assert module["C.D.func"].resolve("Y") == "module.C.D.func[Y]"
assert module["C"].resolve("T") == "module.C[T]"
assert module["C.func"].resolve("T") == "module.C[T]"
with pytest.raises(NameResolutionError):
module["C.func"].resolve("Y")
python-griffe-2.3.0/packages/griffelib/tests/test_merger.py 0000664 0001750 0001750 00000010700 15246557273 023740 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the `merger` module.
from __future__ import annotations
import pytest
from griffe import temporary_visited_package
def test_dont_trigger_alias_resolution_when_merging_stubs() -> None:
"""Assert that we don't trigger alias resolution when merging stubs."""
with temporary_visited_package(
"package",
{
"mod.py": "import pathlib\n\ndef f() -> pathlib.Path:\n return pathlib.Path()",
"mod.pyi": "import pathlib\n\ndef f() -> pathlib.Path: ...",
},
) as pkg:
assert not pkg["mod.pathlib"].resolved
def test_merge_stubs_on_wildcard_imported_objects() -> None:
"""Assert that stubs can be merged on wildcard imported objects."""
with temporary_visited_package(
"package",
{
"mod.py": "class A:\n def hello(value: int | str) -> int | str:\n return value",
"__init__.py": "from .mod import *",
"__init__.pyi": """
from typing import overload
class A:
@overload
def hello(value: int) -> int: ...
@overload
def hello(value: str) -> str: ...
""",
},
) as pkg:
assert pkg["A.hello"].overloads
def test_merge_imports() -> None:
"""Assert that imports are merged correctly."""
with temporary_visited_package(
"package",
{
"mod.py": "import abc",
"mod.pyi": "import collections",
},
) as pkg:
assert set(pkg["mod"].imports) == {"abc", "collections"}
def test_override_exports() -> None:
"""Assert that exports are overridden too (like imports are merged)."""
with temporary_visited_package(
"package",
{
"__init__.py": "import dynamic_all\n__all__ = dynamic_all()",
"__init__.pyi": "from ._hello import hello\n__all__ = ['hello']",
"_hello.py": "def hello() -> None:\n '''Say hello.'''",
},
) as pkg:
assert pkg.exports == ["hello"]
def test_merge_attribute_values() -> None:
"""Assert that attribute values are merged correctly."""
with temporary_visited_package(
"package",
{
"__init__.py": "import dynamic_all\n__all__ = dynamic_all()",
"__init__.pyi": "from ._hello import hello\n__all__ = ['hello']",
"_hello.py": "def hello() -> None:\n '''Say hello.'''",
},
) as pkg:
assert str(pkg["__all__"].value) == "['hello']"
def test_merge_overload_annotations() -> None:
"""Assert that overload annotations are merged correctly."""
with temporary_visited_package(
"package",
{
"mod.py": "def func(x): ...",
"mod.pyi": """
from typing import overload
@overload
def func(x: int) -> int: ...
@overload
def func(x: float) -> float: ...
""",
},
) as pkg:
func = pkg["mod.func"]
assert str(func.parameters["x"].annotation) == "int | float"
assert str(func.returns) == "int | float"
@pytest.mark.parametrize(
("psd", "expected"),
[(True, "Stubs"), (False, "Source")],
)
def test_prefer_stubs_docstrings(psd: bool, expected: str) -> None:
"""The "prefer stubs docstrings" option is respected."""
with temporary_visited_package(
"package",
{
"mod.py": "def func():\n '''Source'''",
"mod.pyi": "def func():\n '''Stubs'''",
},
prefer_stubs_docs=psd,
) as pkg:
func = pkg["mod.func"]
assert func.docstring.value == expected
python-griffe-2.3.0/packages/griffelib/tests/conftest.py 0000664 0001750 0001750 00000002353 15246557273 023252 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Configuration for the pytest test suite.
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pytest_gitconfig import GitConfig
@pytest.fixture(name="gitconfig", scope="session")
def _default_gitconfig(default_gitconfig: GitConfig) -> GitConfig:
default_gitconfig.set({"user.name": "My Name"})
default_gitconfig.set({"user.email": "my@email.com"})
return default_gitconfig
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/ 0000775 0001750 0001750 00000000000 15246557273 024266 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/__init__.py 0000664 0001750 0001750 00000001510 15246557273 026374 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for docstrings.
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/helpers.py 0000664 0001750 0001750 00000005676 15246557273 026320 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains helpers for testing docstring parsing.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol
from griffe import (
Attribute,
Class,
Docstring,
DocstringSection,
Function,
LogLevel,
Module,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from types import ModuleType
ParentType = Module | Class | Function | Attribute | None
ParseResultType = tuple[list[DocstringSection], list[str]]
class ParserType(Protocol): # noqa: D101
def __call__( # noqa: D102
self,
docstring: str,
parent: ParentType | None = None,
**parser_opts: Any,
) -> ParseResultType: ...
def parser(parser_module: ModuleType) -> Iterator[ParserType]:
"""Wrap a parser to help testing.
Parameters:
parser_module: The parser module containing a `parse` function.
Yields:
The wrapped function.
"""
original_warn = parser_module.docstring_warning
def parse(docstring: str, parent: ParentType | None = None, **parser_opts: Any) -> ParseResultType:
"""Parse a docstring.
Parameters:
docstring: The docstring to parse.
parent: The docstring's parent object.
**parser_opts: Additional options accepted by the parser.
Returns:
The parsed sections, and warnings.
"""
docstring_object = Docstring(docstring, lineno=1, endlineno=None)
docstring_object.endlineno = len(docstring_object.lines) + 1
if parent is not None:
docstring_object.parent = parent
parent.docstring = docstring_object
warnings = []
parser_module.docstring_warning = ( # ty:ignore[unresolved-attribute]
lambda _docstring, _offset, message, log_level=LogLevel.warning: warnings.append(message)
)
func_name = f"parse_{parser_module.__name__.split('.')[-1]}"
func = getattr(parser_module, func_name)
sections = func(docstring_object, **parser_opts)
return sections, warnings
yield parse
parser_module.docstring_warning = original_warn # ty:ignore[unresolved-attribute]
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/test_warnings.py 0000664 0001750 0001750 00000003124 15246557273 027527 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the docstrings utility functions.
from __future__ import annotations
from griffe import Docstring, Function, Parameter, ParameterKind, Parameters, Parser, parse
def test_can_warn_without_parent_module() -> None:
"""Assert we can parse a docstring even if it does not have a parent module."""
function = Function(
"func",
parameters=Parameters(
Parameter("param1", annotation=None, kind=ParameterKind.positional_or_keyword),
Parameter("param2", annotation="int", kind=ParameterKind.keyword_only),
),
)
text = """
Hello I'm a docstring!
Parameters:
param1: Description.
param2: Description.
"""
docstring = Docstring(text, lineno=1, parent=function)
assert parse(docstring, Parser.google)
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/test_numpy.py 0000664 0001750 0001750 00000111146 15246557273 027053 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the [Numpy-style parser][griffe.docstrings.numpy].
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pytest
from griffe import (
Attribute,
Class,
Docstring,
DocstringSectionKind,
ExprName,
Function,
Module,
Parameter,
Parameters,
parse_docstring_annotation,
)
if TYPE_CHECKING:
from tests.test_docstrings.helpers import ParserType
# =============================================================================================
# Markup flow (multilines, indentation, etc.)
def test_simple_docstring(parse_numpy: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_numpy: Fixture parser.
"""
sections, warnings = parse_numpy("A simple docstring.")
assert len(sections) == 1
assert not warnings
def test_multiline_docstring(parse_numpy: ParserType) -> None:
"""Parse a multi-line docstring.
Parameters:
parse_numpy: Fixture parser.
"""
sections, warnings = parse_numpy(
"""
A somewhat longer docstring.
Blablablabla.
""",
)
assert len(sections) == 1
assert not warnings
def test_code_blocks(parse_numpy: ParserType) -> None:
"""Parse code blocks.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
This docstring contains a code block!
```python
print("hello")
```
"""
sections, warnings = parse_numpy(docstring)
assert len(sections) == 1
assert not warnings
def test_indented_code_block(parse_numpy: ParserType) -> None:
"""Parse indented code blocks.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
This docstring contains a docstring in a code block o_O!
\"\"\"
This docstring is contained in another docstring O_o!
Parameters:
s: A string.
\"\"\"
"""
sections, warnings = parse_numpy(docstring)
assert len(sections) == 1
assert not warnings
def test_empty_indented_lines_in_section_with_items(parse_numpy: ParserType) -> None:
"""In sections with items, don't treat lines with just indentation as items.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = "Returns\n-------\nonly_item : type\n Description.\n \n \n\nSomething."
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
assert len(sections[0].value) == 2
def test_doubly_indented_lines_in_section_items(parse_numpy: ParserType) -> None:
"""In sections with items, don't remove all spaces on the left of indented lines.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = "Returns\n-------\nonly_item : type\n Description:\n\n - List item.\n - Sublist item."
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
lines = sections[0].value[0].description.split("\n")
assert lines[-1].startswith(4 * " " + "- ")
# =============================================================================================
# Admonitions
def test_admonition_see_also(parse_numpy: ParserType) -> None:
"""Test a "See Also" admonition.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary text.
See Also
--------
some_function
more text
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 2
assert sections[0].value == "Summary text."
assert sections[1].title == "See Also"
assert sections[1].value.description == "some_function\n\nmore text"
def test_admonition_empty(parse_numpy: ParserType) -> None:
"""Test an empty "See Also" admonition.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary text.
See Also
--------
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 2
assert sections[0].value == "Summary text."
assert sections[1].title == "See Also"
assert sections[1].value.description == ""
def test_isolated_dash_lines_do_not_create_sections(parse_numpy: ParserType) -> None:
"""An isolated dash-line (`---`) should not be parsed as a section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary text.
---
Text.
Note
----
Note contents.
---
Text.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 2
assert sections[0].value == "Summary text.\n\n---\nText."
assert sections[1].title == "Note"
assert sections[1].value.description == "Note contents.\n\n---\nText."
def test_admonition_warnings_special_case(parse_numpy: ParserType) -> None:
"""Test that the "Warnings" section renders as a warning admonition.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary text.
Warnings
--------
Be careful!!!
more text
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 2
assert sections[0].value == "Summary text."
assert sections[1].title == "Warnings"
assert sections[1].value.description == "Be careful!!!\n\nmore text"
assert sections[1].value.kind == "warning"
def test_admonition_notes_special_case(parse_numpy: ParserType) -> None:
"""Test that the "Warnings" section renders as a warning admonition.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary text.
Notes
-----
Something noteworthy.
more text
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 2
assert sections[0].value == "Summary text."
assert sections[1].title == "Notes"
assert sections[1].value.description == "Something noteworthy.\n\nmore text"
assert sections[1].value.kind == "note"
# =============================================================================================
# Annotations
def test_prefer_docstring_type_over_annotation(parse_numpy: ParserType) -> None:
"""Prefer the type written in the docstring over the annotation in the parent.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
a : int
"""
sections, _ = parse_numpy(
docstring,
parent=Function("func", parameters=Parameters(Parameter("a", annotation="str"))),
)
assert len(sections) == 1
param = sections[0].value[0]
assert param.name == "a"
assert param.description == ""
assert param.annotation.name == "int"
def test_parse_complex_annotations(parse_numpy: ParserType) -> None:
"""Check the type regex accepts all the necessary characters.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
a : typing.Tuple[str, random0123456789]
b : int | float | None
c : Literal['hello'] | Literal["world"]
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
param_a, param_b, param_c = sections[0].value
assert param_a.name == "a"
assert param_a.description == ""
assert param_a.annotation == "typing.Tuple[str, random0123456789]"
assert param_b.name == "b"
assert param_b.description == ""
assert param_b.annotation == "int | float | None"
assert param_c.name == "c"
assert param_c.description == ""
assert param_c.annotation == "Literal['hello'] | Literal[\"world\"]"
@pytest.mark.parametrize(
("docstring", "name"),
[
("Attributes\n---\na : {name}\n Description.\n", "int"),
("Parameters\n---\na : {name}\n Description.\n", "int"),
("Other Parameters\n---\na : {name}\n Description.\n", "int"),
("Yields\n---\na : {name}\n Description.\n", "int"),
("Receives\n---\na : {name}\n Description.\n", "int"),
("Returns\n---\na : {name}\n Description.\n", "int"),
("Raises\n---\n{name}\n Description.\n", "RuntimeError"),
("Warns\n---\n{name}\n Description.\n", "UserWarning"),
],
)
def test_parse_annotations_in_all_sections(parse_numpy: ParserType, docstring: str, name: str) -> None:
"""Assert annotations are parsed in all relevant sections.
Parameters:
parse_numpy: Fixture parser.
docstring: Parametrized docstring.
name: Parametrized name in annotation.
"""
docstring = docstring.format(name=name)
sections, _ = parse_numpy(docstring, parent=Function("f"))
assert len(sections) == 1
assert sections[0].value[0].annotation.name == name
def test_dont_crash_on_text_annotations(parse_numpy: ParserType, caplog: pytest.LogCaptureFixture) -> None:
"""Don't crash while parsing annotations containing unhandled nodes.
Parameters:
parse_numpy: Fixture parser.
caplog: Pytest fixture used to capture logs.
"""
docstring = """
Attributes
----------
region : str, list-like, geopandas.GeoSeries, geopandas.GeoDataFrame, geometric
Description.
Parameters
----------
region : str, list-like, geopandas.GeoSeries, geopandas.GeoDataFrame, geometric
Description.
Returns
-------
str or bytes
Description.
Receives
--------
region : str, list-like, geopandas.GeoSeries, geopandas.GeoDataFrame, geometric
Description.
Yields
------
str or bytes
Description.
"""
caplog.set_level(logging.DEBUG)
assert parse_numpy(docstring, parent=Function("f"))
assert all(record.levelname == "DEBUG" for record in caplog.records if "Failed to parse" in record.message)
# =============================================================================================
# Sections
def test_parameters_section(parse_numpy: ParserType) -> None:
"""Parse parameters section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
a
b : int
c : str, optional
d : float, default=1.0
e, f
g, h : bytes, optional, default=b''
i : {0, 1, 2}
j : {"a", 1, None, True}
k
K's description.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
def test_parse_starred_parameters(parse_numpy: ParserType) -> None:
"""Parse parameters names with stars in them.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
*a : str
**b : int
***c : float
"""
sections, warnings = parse_numpy(docstring)
assert len(sections) == 1
assert len(warnings) == 1
def test_other_parameters_section(parse_numpy: ParserType) -> None:
"""Parse other parameters section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Other Parameters
----------------
a
b : int
c : str, optional
d : float, default=1.0
e, f
g, h : bytes, optional, default=b''
i : {0, 1, 2}
j : {"a", 1, None, True}
k
K's description.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
def test_retrieve_annotation_from_parent(parse_numpy: ParserType) -> None:
"""Retrieve parameter annotation from the parent object.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
a
"""
sections, _ = parse_numpy(
docstring,
parent=Function("func", parameters=Parameters(Parameter("a", annotation="str"))),
)
assert len(sections) == 1
param = sections[0].value[0]
assert param.name == "a"
assert param.description == ""
assert param.annotation == "str"
def test_deprecated_section(parse_numpy: ParserType) -> None:
"""Parse deprecated section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Deprecated
----------
1.23.4
Deprecated.
Sorry.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
assert sections[0].value.version == "1.23.4"
assert sections[0].value.description == "Deprecated.\nSorry."
def test_returns_section(parse_numpy: ParserType) -> None:
"""Parse returns section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Returns
-------
list of int
A list of integers.
flag : bool
Some kind
of flag.
x :
Name only
:
No name or annotation
: int
Only annotation
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
param = sections[0].value[0]
assert param.name == ""
assert param.description == "A list of integers."
assert param.annotation == "list of int"
param = sections[0].value[1]
assert param.name == "flag"
assert param.description == "Some kind\nof flag."
assert param.annotation == "bool"
param = sections[0].value[2]
assert param.name == "x"
assert param.description == "Name only"
assert param.annotation is None
param = sections[0].value[3]
assert param.name == ""
assert param.description == "No name or annotation"
assert param.annotation is None
param = sections[0].value[4]
assert param.name == ""
assert param.description == "Only annotation"
assert param.annotation == "int"
def test_yields_section(parse_numpy: ParserType) -> None:
"""Parse yields section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Yields
------
list of int
A list of integers.
flag : bool
Some kind
of flag.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
param = sections[0].value[0]
assert param.name == ""
assert param.description == "A list of integers."
assert param.annotation == "list of int"
param = sections[0].value[1]
assert param.name == "flag"
assert param.description == "Some kind\nof flag."
assert param.annotation == "bool"
def test_receives_section(parse_numpy: ParserType) -> None:
"""Parse receives section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Receives
--------
list of int
A list of integers.
flag : bool
Some kind
of flag.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
param = sections[0].value[0]
assert param.name == ""
assert param.description == "A list of integers."
assert param.annotation == "list of int"
param = sections[0].value[1]
assert param.name == "flag"
assert param.description == "Some kind\nof flag."
assert param.annotation == "bool"
def test_raises_section(parse_numpy: ParserType) -> None:
"""Parse raises section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Raises
------
RuntimeError
There was an issue.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
param = sections[0].value[0]
assert param.description == "There was an issue."
assert param.annotation == "RuntimeError"
def test_warns_section(parse_numpy: ParserType) -> None:
"""Parse warns section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Warns
-----
ResourceWarning
Heads up.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
param = sections[0].value[0]
assert param.description == "Heads up."
assert param.annotation == "ResourceWarning"
def test_attributes_section(parse_numpy: ParserType) -> None:
"""Parse attributes section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Attributes
----------
a
Hello.
m
z : int
Bye.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
param = sections[0].value[0]
assert param.name == "a"
assert param.description == "Hello."
assert param.annotation is None
param = sections[0].value[1]
assert param.name == "m"
assert param.description == ""
assert param.annotation is None
param = sections[0].value[2]
assert param.name == "z"
assert param.description == "Bye."
assert param.annotation == "int"
def test_parse_functions_section(parse_numpy: ParserType) -> None:
"""Parse Functions/Methods sections.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Functions
---------
f(a, b=2)
Hello.
g
Hi.
Methods
-------
f(a, b=2)
Hello.
g
Hi.
"""
sections, warnings = parse_numpy(docstring)
assert len(sections) == 2
for section in sections:
assert section.kind is DocstringSectionKind.functions
func_f = section.value[0]
assert func_f.name == "f"
assert func_f.signature == "f(a, b=2)"
assert func_f.description == "Hello."
func_g = section.value[1]
assert func_g.name == "g"
assert func_g.signature is None
assert func_g.description == "Hi."
assert not warnings
def test_parse_classes_section(parse_numpy: ParserType) -> None:
"""Parse Classes sections.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Classes
-------
C(a, b=2)
Hello.
D
Hi.
"""
sections, warnings = parse_numpy(docstring)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.classes
class_c = sections[0].value[0]
assert class_c.name == "C"
assert class_c.signature == "C(a, b=2)"
assert class_c.description == "Hello."
class_d = sections[0].value[1]
assert class_d.name == "D"
assert class_d.signature is None
assert class_d.description == "Hi."
assert not warnings
def test_parse_modules_section(parse_numpy: ParserType) -> None:
"""Parse Modules sections.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Modules
-------
m
Hello.
n
Hi.
"""
sections, warnings = parse_numpy(docstring)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.modules
module_m = sections[0].value[0]
assert module_m.name == "m"
assert module_m.description == "Hello."
module_n = sections[0].value[1]
assert module_n.name == "n"
assert module_n.description == "Hi."
assert not warnings
def test_examples_section(parse_numpy: ParserType) -> None:
"""Parse examples section.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Examples
--------
Hello.
>>> 1 + 2
3
```pycon
>>> print("Hello again.")
```
>>> a = 0 # doctest: +SKIP
>>> b = a + 1
>>> print(b)
1
Bye.
--------
Not in the section.
"""
sections, _ = parse_numpy(docstring, trim_doctest_flags=False)
assert len(sections) == 2
examples = sections[0]
assert len(examples.value) == 5
assert examples.value[0] == (DocstringSectionKind.text, "Hello.")
assert examples.value[1] == (DocstringSectionKind.examples, ">>> 1 + 2\n3")
assert examples.value[3][1].startswith(">>> a = 0 # doctest: +SKIP")
def test_examples_section_when_followed_by_named_section(parse_numpy: ParserType) -> None:
"""Parse examples section followed by another section.
Parameters:
parse_numpy: Parse function (fixture).
"""
docstring = """
Examples
--------
Hello, hello.
Parameters
----------
foo : int
"""
sections, _ = parse_numpy(docstring, trim_doctest_flags=False)
assert len(sections) == 2
assert sections[0].kind is DocstringSectionKind.examples
assert sections[1].kind is DocstringSectionKind.parameters
def test_examples_section_as_last(parse_numpy: ParserType) -> None:
"""Parse examples section being last in the docstring.
Parameters:
parse_numpy: Parse function (fixture).
"""
docstring = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit...
Examples
--------
```pycon
>>> LoremIpsum.from_string("consectetur")
```
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 2
assert sections[0].kind is DocstringSectionKind.text
assert sections[1].kind is DocstringSectionKind.examples
def test_blank_lines_in_section(parse_numpy: ParserType) -> None:
"""Support blank lines in the middle of sections.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Examples
--------
Line 1.
Line 2.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
# =============================================================================================
# Attributes sections
def test_retrieve_attributes_annotation_from_parent(parse_numpy: ParserType) -> None:
"""Retrieve the annotations of attributes from the parent object.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary.
Attributes
----------
a :
Whatever.
b :
Whatever.
"""
parent = Class("cls")
parent["a"] = Attribute("a", annotation=ExprName("int"))
parent["b"] = Attribute("b", annotation=ExprName("str"))
sections, _ = parse_numpy(docstring, parent=parent)
attributes = sections[1].value
assert attributes[0].name == "a"
assert attributes[0].annotation.name == "int"
assert attributes[1].name == "b"
assert attributes[1].annotation.name == "str"
# =============================================================================================
# Parameters sections
def test_warn_about_unknown_parameters(parse_numpy: ParserType) -> None:
"""Warn about unknown parameters in "Parameters" sections.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
x : int
Integer.
y : int
Integer.
"""
_, warnings = parse_numpy(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("a"),
Parameter("y"),
),
),
)
assert len(warnings) == 1
assert "'x' does not appear in the function signature" in warnings[0]
def test_never_warn_about_unknown_other_parameters(parse_numpy: ParserType) -> None:
"""Never warn about unknown parameters in "Other parameters" sections.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Other Parameters
----------------
x : int
Integer.
z : int
Integer.
"""
_, warnings = parse_numpy(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("a"),
Parameter("y"),
),
),
)
assert not warnings
def test_unknown_params_scan_doesnt_crash_without_parameters(parse_numpy: ParserType) -> None:
"""Assert we don't crash when parsing parameters sections and parent object does not have parameters.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
this : str
This.
that : str
That.
"""
_, warnings = parse_numpy(docstring, parent=Module("mod"))
assert not warnings
def test_class_uses_init_parameters(parse_numpy: ParserType) -> None:
"""Assert we use the `__init__` parameters when parsing classes' parameters sections.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
x :
X value.
"""
parent = Class("c")
parent["__init__"] = Function("__init__", parameters=Parameters(Parameter("x", annotation="int")))
sections, warnings = parse_numpy(docstring, parent=parent)
assert not warnings
argx = sections[0].value[0]
assert argx.name == "x"
assert argx.annotation == "int"
assert argx.description == "X value."
def test_detect_optional_flag(parse_numpy: ParserType) -> None:
"""Detect the optional part of a parameter docstring.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Parameters
----------
a : str, optional
g, h : bytes, optional, default=b''
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
assert sections[0].value[0].annotation == "str"
assert sections[0].value[1].annotation == "bytes"
assert sections[0].value[1].default == "b''"
assert sections[0].value[2].annotation == "bytes"
assert sections[0].value[2].default == "b''"
@pytest.mark.parametrize("newlines", [1, 2, 3])
def test_blank_lines_in_item_descriptions(parse_numpy: ParserType, newlines: int) -> None:
"""Support blank lines in the middle of item descriptions.
Parameters:
parse_numpy: Fixture parser.
newlines: Number of new lines between item summary and its body.
"""
nl = "\n"
nlindent = "\n" + " " * 12
docstring = f"""
Parameters
----------
a : str
Summary.{nlindent * newlines}Body.
"""
sections, _ = parse_numpy(docstring)
assert len(sections) == 1
assert sections[0].value[0].annotation == "str"
assert sections[0].value[0].description == f"Summary.{nl * newlines}Body."
# =============================================================================================
# Yields sections
@pytest.mark.parametrize(
"return_annotation",
[
"Iterator[tuple[int, float]]",
"Generator[tuple[int, float], ..., ...]",
],
)
def test_parse_yields_tuple_in_iterator_or_generator(parse_numpy: ParserType, return_annotation: str) -> None:
"""Parse Yields annotations in Iterator or Generator types.
Parameters:
parse_numpy: Fixture parser.
return_annotation: Parametrized return annotation as a string.
"""
docstring = """
Summary.
Yields
------
a :
Whatever.
b :
Whatever.
"""
sections, _ = parse_numpy(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))),
),
)
yields = sections[1].value
assert yields[0].name == "a"
assert yields[0].annotation.name == "int"
assert yields[1].name == "b"
assert yields[1].annotation.name == "float"
@pytest.mark.parametrize(
"return_annotation",
[
"Iterator[int]",
"Generator[int, None, None]",
],
)
def test_extract_yielded_type_with_single_return_item(parse_numpy: ParserType, return_annotation: str) -> None:
"""Extract main type annotation from Iterator or Generator.
Parameters:
parse_numpy: Fixture parser.
return_annotation: Parametrized return annotation as a string.
"""
docstring = """
Summary.
Yields
------
a :
A number.
"""
sections, _ = parse_numpy(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))),
),
)
yields = sections[1].value
assert yields[0].annotation.name == "int"
def test_yield_section_in_property(parse_numpy: ParserType) -> None:
"""No warnings when parsing Yields section in a property.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary.
Yields
------
:
A number.
"""
sections, warnings = parse_numpy(
docstring,
parent=Attribute(
"prop",
annotation=parse_docstring_annotation("Iterator[int]", Docstring("d", parent=Attribute("a"))),
),
)
assert not warnings
yields = sections[1].value
assert yields[0].annotation.name == "int"
# =============================================================================================
# Receives sections
def test_parse_receives_tuple_in_generator(parse_numpy: ParserType) -> None:
"""Parse Receives annotations in Generator type.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary.
Receives
--------
a :
Whatever.
b :
Whatever.
"""
sections, _ = parse_numpy(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(
"Generator[..., tuple[int, float], ...]",
Docstring("d", parent=Function("f")),
),
),
)
receives = sections[1].value
assert receives[0].name == "a"
assert receives[0].annotation.name == "int"
assert receives[1].name == "b"
assert receives[1].annotation.name == "float"
@pytest.mark.parametrize(
"return_annotation",
[
"Generator[int, float, None]",
],
)
def test_extract_received_type_with_single_return_item(parse_numpy: ParserType, return_annotation: str) -> None:
"""Extract main type annotation from Iterator or Generator.
Parameters:
parse_numpy: Fixture parser.
return_annotation: Parametrized return annotation as a string.
"""
docstring = """
Summary.
Receives
--------
a :
A floating point number.
"""
sections, _ = parse_numpy(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))),
),
)
receives = sections[1].value
assert receives[0].annotation.name == "float"
# =============================================================================================
# Returns sections
def test_parse_returns_tuple_in_generator(parse_numpy: ParserType) -> None:
"""Parse Returns annotations in Generator type.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = """
Summary.
Returns
-------
a :
Whatever.
b :
Whatever.
"""
sections, _ = parse_numpy(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(
"Generator[..., ..., tuple[int, float]]",
Docstring("d", parent=Function("f")),
),
),
)
returns = sections[1].value
assert returns[0].name == "a"
assert returns[0].annotation.name == "int"
assert returns[1].name == "b"
assert returns[1].annotation.name == "float"
# =============================================================================================
# Parser special features
@pytest.mark.parametrize(
"docstring",
[
"",
"\n",
"\n\n",
"Summary.",
"Summary.\n\n\n",
"Summary.\n\nParagraph.",
"Summary\non two lines.",
"Summary\non two lines.\n\nParagraph.",
],
)
def test_ignore_init_summary(parse_numpy: ParserType, docstring: str) -> None:
"""Correctly ignore summary in `__init__` methods' docstrings.
Parameters:
parse_numpy: Fixture parser.
docstring: The docstring to parse (parametrized).
"""
sections, _ = parse_numpy(docstring, parent=Function("__init__", parent=Class("C")), ignore_init_summary=True)
for section in sections:
assert "Summary" not in section.value
if docstring.strip():
sections, _ = parse_numpy(docstring, parent=Function("__init__", parent=Module("M")), ignore_init_summary=True)
assert "Summary" in sections[0].value
sections, _ = parse_numpy(docstring, parent=Function("f", parent=Class("C")), ignore_init_summary=True)
assert "Summary" in sections[0].value
sections, _ = parse_numpy(docstring, ignore_init_summary=True)
assert "Summary" in sections[0].value
@pytest.mark.parametrize(
"docstring",
[
"""
Examples
--------
Base case 1. We want to skip the following test.
>>> 1 + 1 == 3 # doctest: +SKIP
True
""",
r"""
Examples
--------
Base case 2. We have a blankline test.
>>> print("a\n\nb")
a
b
""",
],
)
def test_trim_doctest_flags_basic_example(parse_numpy: ParserType, docstring: str) -> None:
"""Correctly parse simple example docstrings when `trim_doctest_flags` option is turned on.
Parameters:
parse_numpy: Fixture parser.
docstring: The docstring to parse_numpy (parametrized).
"""
sections, warnings = parse_numpy(docstring, trim_doctest_flags=True)
assert len(sections) == 1
assert len(sections[0].value) == 2
assert not warnings
# Verify that doctest flags have indeed been trimmed.
example_str = sections[0].value[1][1]
assert "# doctest: +SKIP" not in example_str
assert "" not in example_str
def test_trim_doctest_flags_multi_example(parse_numpy: ParserType) -> None:
"""Correctly parse multiline example docstrings when `trim_doctest_flags` option is turned on.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = r"""
Examples
--------
Test multiline example blocks.
We want to skip the following test.
>>> 1 + 1 == 3 # doctest: +SKIP
True
And then a few more examples here:
>>> print("a\n\nb")
a
b
>>> 1 + 1 == 2 # doctest: +SKIP
>>> print(list(range(1, 100))) # doctest: +ELLIPSIS
[1, 2, ..., 98, 99]
"""
sections, warnings = parse_numpy(docstring, trim_doctest_flags=True)
assert len(sections) == 1
assert len(sections[0].value) == 4
assert not warnings
# Verify that doctest flags have indeed been trimmed.
example_str = sections[0].value[1][1]
assert "# doctest: +SKIP" not in example_str
example_str = sections[0].value[3][1]
assert "" not in example_str
assert "\n>>> print(list(range(1, 100)))\n" in example_str
def test_parsing_choices(parse_numpy: ParserType) -> None:
"""Correctly parse choices.
Parameters:
parse_numpy: Fixture parser.
"""
docstring = r"""
Parameters
--------
order : {'C', 'F', 'A'}
Description of `order`.
"""
sections, warnings = parse_numpy(docstring, trim_doctest_flags=True)
assert sections[0].value[0].annotation == "'C', 'F', 'A'"
assert not warnings
# =============================================================================================
# Warnings
@pytest.mark.parametrize(
"docstring",
[
"""
Parameters
----------
x :
X value.
""",
"""
Parameters
----------
x : int
Confusing indentation.
""",
"""
Type Parameters
---------------
!!!
""",
"""
Type Aliases
------------
""",
],
)
def test_disabled_warnings(parse_numpy: ParserType, docstring: str) -> None:
"""Assert warnings are disabled.
Parameters:
parse_numpy: Fixture parser.
docstring: A docstring that triggers a parser warning.
"""
_, warnings = parse_numpy(docstring, warnings=True)
assert warnings
_, warnings = parse_numpy(docstring, warnings=False)
assert not warnings
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/conftest.py 0000664 0001750 0001750 00000003241 15246557273 026465 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Pytest fixture for docstrings tests.
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from griffe._internal.docstrings import google, numpy, sphinx
from tests.test_docstrings.helpers import ParserType, parser
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture
def parse_google() -> Iterator[ParserType]:
"""Yield a function to parse Google docstrings.
Yields:
A parser function.
"""
yield from parser(google)
@pytest.fixture
def parse_numpy() -> Iterator[ParserType]:
"""Yield a function to parse Numpy docstrings.
Yields:
A parser function.
"""
yield from parser(numpy)
@pytest.fixture
def parse_sphinx() -> Iterator[ParserType]:
"""Yield a function to parse Sphinx docstrings.
Yields:
A parser function.
"""
yield from parser(sphinx)
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/test_google.py 0000664 0001750 0001750 00000157157 15246557273 027173 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the [Google-style parser][griffe.docstrings.google].
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING
import pytest
from griffe import (
Attribute,
Class,
Docstring,
DocstringReceive,
DocstringReturn,
DocstringSectionKind,
DocstringYield,
ExprName,
Function,
Module,
Parameter,
Parameters,
TypeParameter,
TypeParameterKind,
TypeParameters,
parse_docstring_annotation,
)
if TYPE_CHECKING:
from tests.test_docstrings.helpers import ParserType
# =============================================================================================
# Markup flow (multilines, indentation, etc.)
def test_simple_docstring(parse_google: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_google: Fixture parser.
"""
sections, warnings = parse_google("A simple docstring.")
assert len(sections) == 1
assert not warnings
def test_multiline_docstring(parse_google: ParserType) -> None:
"""Parse a multi-line docstring.
Parameters:
parse_google: Fixture parser.
"""
sections, warnings = parse_google(
"""
A somewhat longer docstring.
Blablablabla.
""",
)
assert len(sections) == 1
assert not warnings
def test_parse_partially_indented_lines(parse_google: ParserType) -> None:
"""Properly handle partially indented lines.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
The available formats are:
- JSON
The unavailable formats are:
- YAML
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 2
assert sections[0].kind is DocstringSectionKind.admonition
assert sections[1].kind is DocstringSectionKind.admonition
assert not warnings
def test_multiple_lines_in_sections_items(parse_google: ParserType) -> None:
"""Parse multi-line item description.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
p (int): This parameter
has a description
spawning on multiple lines.
It even has blank lines in it.
Some of these lines
are indented for no reason.
q (int):
What if the first line is blank?
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert len(sections[0].value) == 2
assert warnings
for warning in warnings:
assert "should be 4 * 2 = 8 spaces, not" in warning
def test_code_blocks(parse_google: ParserType) -> None:
"""Parse code blocks.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
This docstring contains a code block!
```python
print("hello")
```
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert not warnings
def test_indented_code_block(parse_google: ParserType) -> None:
"""Parse indented code blocks.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
This docstring contains a docstring in a code block o_O!
\"\"\"
This docstring is contained in another docstring O_o!
Parameters:
s: A string.
\"\"\"
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert not warnings
def test_different_indentation(parse_google: ParserType) -> None:
"""Parse different indentations, warn on confusing indentation.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Raises:
StartAt5: this section's items starts with 5 spaces of indentation.
Well indented continuation line.
Badly indented continuation line (will trigger a warning).
Empty lines are preserved, as well as extra-indentation (this line is a code block).
AnyOtherLine: ...starting with exactly 5 spaces is a new item.
AnyLine: ...indented with less than 5 spaces signifies the end of the section.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 2
assert len(sections[0].value) == 2
assert sections[0].value[0].description == (
"this section's items starts with 5 spaces of indentation.\n"
"Well indented continuation line.\n"
"Badly indented continuation line (will trigger a warning).\n"
"\n"
" Empty lines are preserved, as well as extra-indentation (this line is a code block)."
)
assert sections[1].value == " AnyLine: ...indented with less than 5 spaces signifies the end of the section."
assert len(warnings) == 1
assert "should be 5 * 2 = 10 spaces, not 6" in warnings[0]
def test_empty_indented_lines_in_section_with_items(parse_google: ParserType) -> None:
"""In sections with items, don't treat lines with just indentation as items.
Parameters:
parse_google: Fixture parser.
"""
docstring = "Returns:\n only_item: Description.\n \n \n\nSomething."
sections, _ = parse_google(docstring)
assert len(sections) == 2
assert len(sections[0].value) == 1
@pytest.mark.parametrize(
"section",
[
"Attributes",
"Other Parameters",
"Parameters",
"Type Parameters",
"Raises",
"Receives",
"Returns",
"Warns",
"Type aliases",
"Yields",
],
)
def test_starting_item_description_on_new_line(parse_google: ParserType, section: str) -> None:
"""In sections with items, allow starting item descriptions on a new (indented) line.
Parameters:
parse_google: Fixture parser.
section: A parametrized section name.
"""
docstring = f"\n{section}:\n only_item:\n Description."
sections, _ = parse_google(docstring)
assert len(sections) == 1
assert len(sections[0].value) == 1
assert sections[0].value[0].description.strip() == "Description."
# =============================================================================================
# Annotations
def test_parse_without_parent(parse_google: ParserType) -> None:
"""Parse a docstring without a parent function.
Parameters:
parse_google: Fixture parser.
"""
sections, warnings = parse_google(
"""
Parameters:
void: SEGFAULT.
niet: SEGFAULT.
nada: SEGFAULT.
rien: SEGFAULT.
Keyword Args:
keywd: SEGFAULT.
Exceptions:
GlobalError: when nothing works as expected.
Returns:
Itself.
""",
)
assert len(sections) == 4
assert len(warnings) == 6 # Missing annotations for parameters and return.
for warning in warnings[:-1]:
assert "parameter" in warning
assert "return" in warnings[-1]
def test_parse_without_annotations(parse_google: ParserType) -> None:
"""Parse a function docstring without signature annotations.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x: X value.
Keyword Args:
y: Y value.
Returns:
Sum X + Y + Z.
"""
sections, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("x"),
Parameter("y"),
),
),
)
assert len(sections) == 3
assert len(warnings) == 3
for warning in warnings[:-1]:
assert "parameter" in warning
assert "return" in warnings[-1]
def test_parse_with_annotations(parse_google: ParserType) -> None:
"""Parse a function docstring with signature annotations.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x: X value.
Keyword Parameters:
y: Y value.
Returns:
Sum X + Y.
"""
sections, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("x", annotation="int"),
Parameter("y", annotation="int"),
),
returns="int",
),
)
assert len(sections) == 3
assert not warnings
# =============================================================================================
# Sections
def test_parse_attributes_section(parse_google: ParserType) -> None:
"""Parse Attributes sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Attributes:
hey: Hey.
ho: Ho.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert not warnings
def test_parse_functions_section(parse_google: ParserType) -> None:
"""Parse Functions/Methods sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Functions:
f(a, b=2): Hello.
g: Hi.
Methods:
f(a, b=2): Hello.
g: Hi.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 2
for section in sections:
assert section.kind is DocstringSectionKind.functions
func_f = section.value[0]
assert func_f.name == "f"
assert func_f.signature == "f(a, b=2)"
assert func_f.description == "Hello."
func_g = section.value[1]
assert func_g.name == "g"
assert func_g.signature is None
assert func_g.description == "Hi."
assert not warnings
def test_parse_classes_section(parse_google: ParserType) -> None:
"""Parse Classes sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Classes:
C(a, b=2): Hello.
D: Hi.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.classes
class_c = sections[0].value[0]
assert class_c.name == "C"
assert class_c.signature == "C(a, b=2)"
assert class_c.description == "Hello."
class_d = sections[0].value[1]
assert class_d.name == "D"
assert class_d.signature is None
assert class_d.description == "Hi."
assert not warnings
def test_parse_type_aliases_section(parse_google: ParserType) -> None:
"""Parse Type Aliases sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Type Aliases:
TC: Hello.
TD: Hi.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.type_aliases
type_alias_c = sections[0].value[0]
assert type_alias_c.name == "TC"
assert type_alias_c.description == "Hello."
type_alias_d = sections[0].value[1]
assert type_alias_d.name == "TD"
assert type_alias_d.description == "Hi."
assert not warnings
def test_parse_modules_section(parse_google: ParserType) -> None:
"""Parse Modules sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Modules:
m: Hello.
n: Hi.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.modules
module_m = sections[0].value[0]
assert module_m.name == "m"
assert module_m.description == "Hello."
module_n = sections[0].value[1]
assert module_n.name == "n"
assert module_n.description == "Hi."
assert not warnings
def test_parse_examples_sections(parse_google: ParserType) -> None:
"""Parse a function docstring with examples.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Examples:
Some examples that will create a unified code block:
>>> 2 + 2 == 5
False
>>> print("examples")
"examples"
This is just a random comment in the examples section.
These examples will generate two different code blocks. Note the blank line.
>>> print("I'm in the first code block!")
"I'm in the first code block!"
>>> print("I'm in other code block!")
"I'm in other code block!"
We also can write multiline examples:
>>> x = 3 + 2 # doctest: +SKIP
>>> y = x + 10
>>> y
15
This is just a typical Python code block:
```python
print("examples")
return 2 + 2
```
Even if it contains doctests, the following block is still considered a normal code-block.
```pycon
>>> print("examples")
"examples"
>>> 2 + 2
4
```
The blank line before an example is optional.
>>> x = 3
>>> y = "apple"
>>> z = False
>>> l = [x, y, z]
>>> my_print_list_function(l)
3
"apple"
False
"""
sections, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("x", annotation="int"),
Parameter("y", annotation="int"),
),
returns="int",
),
trim_doctest_flags=False,
)
assert len(sections) == 1
examples = sections[0]
assert len(examples.value) == 9
assert examples.value[6][1].startswith(">>> x = 3 + 2 # doctest: +SKIP")
assert not warnings
def test_parse_yields_section(parse_google: ParserType) -> None:
"""Parse Yields section.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Yields:
x: Floats.
(int): Integers.
y (int): Same.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
annotated = sections[0].value[0]
assert annotated.name == "x"
assert annotated.annotation is None
assert annotated.description == "Floats."
annotated = sections[0].value[1]
assert annotated.name == ""
assert annotated.annotation == "int"
assert annotated.description == "Integers."
annotated = sections[0].value[2]
assert annotated.name == "y"
assert annotated.annotation == "int"
assert annotated.description == "Same."
assert len(warnings) == 1
assert "'x'" in warnings[0]
def test_invalid_sections(parse_google: ParserType) -> None:
"""Warn on invalid sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
Exceptions:
Exceptions:
Returns:
Note:
Important:
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert not warnings
def test_no_empty_text_section(parse_google: ParserType) -> None:
"""Don't create a single empty text section for an empty docstring.
Parameters:
parse_google: Fixture parser.
"""
sections, warnings = parse_google("")
assert not sections
assert not warnings
# =============================================================================================
# Parameters sections
def test_parse_args_and_kwargs(parse_google: ParserType) -> None:
"""Parse args and kwargs.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
a (str): a parameter.
*args (str): args parameters.
**kwargs (str): kwargs parameters.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
expected_parameters = {"a": "a parameter.", "*args": "args parameters.", "**kwargs": "kwargs parameters."}
for parameter in sections[0].value:
assert parameter.name in expected_parameters
assert expected_parameters[parameter.name] == parameter.description
assert not warnings
def test_parse_args_kwargs_keyword_only(parse_google: ParserType) -> None:
"""Parse args and kwargs.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
a (str): a parameter.
*args (str): args parameters.
Keyword Args:
**kwargs (str): kwargs parameters.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 2
expected_parameters = {"a": "a parameter.", "*args": "args parameters."}
for parameter in sections[0].value:
assert parameter.name in expected_parameters
assert expected_parameters[parameter.name] == parameter.description
expected_parameters = {"**kwargs": "kwargs parameters."}
for kwarg in sections[1].value:
assert kwarg.name in expected_parameters
assert expected_parameters[kwarg.name] == kwarg.description
assert not warnings
def test_parse_types_in_docstring(parse_google: ParserType) -> None:
"""Parse types in docstring.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x (int): X value.
Keyword Args:
y (int): Y value.
Returns:
s (int): Sum X + Y + Z.
"""
sections, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("x"),
Parameter("y"),
),
),
)
assert len(sections) == 3
assert not warnings
assert sections[0].kind is DocstringSectionKind.parameters
assert sections[1].kind is DocstringSectionKind.other_parameters
assert sections[2].kind is DocstringSectionKind.returns
(argx,) = sections[0].value
(argy,) = sections[1].value
(returns,) = sections[2].value
assert argx.name == "x"
assert argx.annotation.name == "int"
assert argx.annotation.canonical_path == "int"
assert argx.description == "X value."
assert argx.value is None
assert argy.name == "y"
assert argy.annotation.name == "int"
assert argy.annotation.canonical_path == "int"
assert argy.description == "Y value."
assert argy.value is None
assert returns.annotation.name == "int"
assert returns.annotation.canonical_path == "int"
assert returns.description == "Sum X + Y + Z."
def test_parse_optional_type_in_docstring(parse_google: ParserType) -> None:
"""Parse optional types in docstring.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x (int): X value.
y (int, optional): Y value.
Keyword Args:
z (int, optional): Z value.
"""
sections, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("x", default="1"),
Parameter("y", default="None"),
Parameter("z", default="None"),
),
),
)
assert len(sections) == 2
assert not warnings
assert sections[0].kind is DocstringSectionKind.parameters
assert sections[1].kind is DocstringSectionKind.other_parameters
argx, argy = sections[0].value
(argz,) = sections[1].value
assert argx.name == "x"
assert argx.annotation.name == "int"
assert argx.annotation.canonical_path == "int"
assert argx.description == "X value."
assert argx.value == "1"
assert argy.name == "y"
assert argy.annotation.name == "int"
assert argy.annotation.canonical_path == "int"
assert argy.description == "Y value."
assert argy.value == "None"
assert argz.name == "z"
assert argz.annotation.name == "int"
assert argz.annotation.canonical_path == "int"
assert argz.description == "Z value."
assert argz.value == "None"
def test_prefer_docstring_types_over_annotations(parse_google: ParserType) -> None:
"""Prefer the docstring type over the annotation.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x (str): X value.
Keyword Args:
y (str): Y value.
Returns:
(str): Sum X + Y + Z.
"""
sections, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("x", annotation="int"),
Parameter("y", annotation="int"),
),
returns="int",
),
)
assert len(sections) == 3
assert not warnings
assert sections[0].kind is DocstringSectionKind.parameters
assert sections[1].kind is DocstringSectionKind.other_parameters
assert sections[2].kind is DocstringSectionKind.returns
(argx,) = sections[0].value
(argy,) = sections[1].value
(returns,) = sections[2].value
assert argx.name == "x"
assert argx.annotation.name == "str"
assert argx.annotation.canonical_path == "str"
assert argx.description == "X value."
assert argy.name == "y"
assert argy.annotation.name == "str"
assert argy.annotation.canonical_path == "str"
assert argy.description == "Y value."
assert returns.annotation.name == "str"
assert returns.annotation.canonical_path == "str"
assert returns.description == "Sum X + Y + Z."
def test_parameter_line_without_colon(parse_google: ParserType) -> None:
"""Warn when missing colon.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x is an integer.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 0 # Empty sections are discarded.
assert len(warnings) == 1
assert "pair" in warnings[0]
def test_parameter_line_without_colon_keyword_only(parse_google: ParserType) -> None:
"""Warn when missing colon.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Keyword Args:
x is an integer.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 0 # Empty sections are discarded.
assert len(warnings) == 1
assert "pair" in warnings[0]
def test_warn_about_unknown_parameters(parse_google: ParserType) -> None:
"""Warn about unknown parameters in "Parameters" sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x (int): Integer.
y (int): Integer.
"""
_, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("a"),
Parameter("y"),
),
),
)
assert len(warnings) == 1
assert "'x' does not appear in the function signature" in warnings[0]
def test_never_warn_about_unknown_other_parameters(parse_google: ParserType) -> None:
"""Never warn about unknown parameters in "Other parameters" sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Other Parameters:
x (int): Integer.
z (int): Integer.
"""
_, warnings = parse_google(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("a"),
Parameter("y"),
),
),
)
assert not warnings
def test_unknown_params_scan_doesnt_crash_without_parameters(parse_google: ParserType) -> None:
"""Assert we don't crash when parsing parameters sections and parent object does not have parameters.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
this (str): This.
that (str): That.
"""
_, warnings = parse_google(docstring, parent=Module("mod"))
assert not warnings
def test_class_uses_init_parameters(parse_google: ParserType) -> None:
"""Assert we use the `__init__` parameters when parsing classes' parameters sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x: X value.
"""
parent = Class("c")
parent["__init__"] = Function("__init__", parameters=Parameters(Parameter("x", annotation="int")))
sections, warnings = parse_google(docstring, parent=parent)
assert not warnings
argx = sections[0].value[0]
assert argx.name == "x"
assert argx.annotation == "int"
assert argx.description == "X value."
def test_parse_parameters_and_type_without_space(parse_google: ParserType) -> None:
"""Parse parameter name and type without space.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Parameters:
x(int): X value.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert not warnings
argx = sections[0].value[0]
assert argx.name == "x"
assert argx.annotation == "int"
assert argx.description == "X value."
# TODO: possible feature
# def test_missing_parameter(parse_google: ParserType) -> None:
# """Warn on missing parameter in docstring.
#
# Parameters:
# parse_google: Fixture parser.
# """
# docstring = """
# Parameters:
# x: Integer.
# """
# assert not warnings
# =============================================================================================
# Type parameters sections
def test_parse_type_var_tuples_and_param_specs(parse_google: ParserType) -> None:
"""Parse type variable tuples and parameter specifications.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Type Parameters:
T: A type parameter.
C (str, (int, float)): A constrained type parameter.
D complex: A bounded type parameter.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
expected_type_parameters = {
"T": ("A type parameter.", None),
"C": ("A constrained type parameter.", "str, (int, float)"),
"D": ("A bounded type parameter.", "complex"),
}
for type_parameter in sections[0].value:
assert type_parameter.name in expected_type_parameters
assert expected_type_parameters[type_parameter.name][0] == type_parameter.description
assert expected_type_parameters[type_parameter.name][1] == type_parameter.annotation
assert not warnings
def test_prefer_docstring_bounds_over_annotations(parse_google: ParserType) -> None:
"""Prefer the docstring bound over the annotation.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Type Parameters:
X (str): X type.
Y str, int: Y type.
"""
sections, warnings = parse_google(
docstring,
parent=Function(
"func",
type_parameters=TypeParameters(
TypeParameter("X", kind=TypeParameterKind.type_var, constraints=["complex"]),
TypeParameter("Y", kind=TypeParameterKind.type_var, bound="int"),
),
),
)
assert len(sections) == 1
assert not warnings
assert sections[0].kind is DocstringSectionKind.type_parameters
(x, y) = sections[0].value
assert x.name == "X"
assert str(x.bound) == "str"
assert x.constraints is None
assert y.name == "Y"
assert y.bound is None
assert [str(constraint) for constraint in y.constraints] == ["str", "int"]
def test_type_parameter_line_without_colon(parse_google: ParserType) -> None:
"""Warn when missing colon.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Type Parameters:
X is an integer type.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 0 # empty sections are discarded
assert len(warnings) == 1
assert "pair" in warnings[0]
def test_warn_about_unknown_type_parameters(parse_google: ParserType) -> None:
"""Warn about unknown type parameters in "Type Parameters" sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Type Parameters:
X (int): Integer.
Y (int): Integer.
"""
_, warnings = parse_google(
docstring,
parent=Function(
"func",
type_parameters=TypeParameters(
TypeParameter("A", kind=TypeParameterKind.type_var),
TypeParameter("Y", kind=TypeParameterKind.type_var),
),
),
)
assert len(warnings) == 1
assert "'X' does not appear in the function signature" in warnings[0]
def test_unknown_type_params_scan_doesnt_crash_without_type_parameters(parse_google: ParserType) -> None:
"""Assert we don't crash when parsing type parameters sections and parent object does not have type parameters.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
TypeParameters:
This (str): This.
That: That.
"""
_, warnings = parse_google(docstring, parent=Module("mod"))
assert not warnings
# =============================================================================================
# Attributes sections
def test_retrieve_attributes_annotation_from_parent(parse_google: ParserType) -> None:
"""Retrieve the annotations of attributes from the parent object.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Attributes:
a: Whatever.
b: Whatever.
"""
parent = Class("cls")
parent["a"] = Attribute("a", annotation=ExprName("int"))
parent["b"] = Attribute("b", annotation=ExprName("str"))
sections, _ = parse_google(docstring, parent=parent)
attributes = sections[1].value
assert attributes[0].name == "a"
assert attributes[0].annotation.name == "int"
assert attributes[1].name == "b"
assert attributes[1].annotation.name == "str"
# =============================================================================================
# Yields sections
def test_parse_yields_section_with_return_annotation(parse_google: ParserType) -> None:
"""Parse Yields section with a return annotation in the parent function.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Yields:
Integers.
"""
function = Function("func", returns="Iterator[int]")
sections, warnings = parse_google(docstring, function)
assert len(sections) == 1
annotated = sections[0].value[0]
assert annotated.annotation == "Iterator[int]"
assert annotated.description == "Integers."
assert not warnings
@pytest.mark.parametrize(
"return_annotation",
[
"Iterator[tuple[int, float]]",
"Generator[tuple[int, float], ..., ...]",
],
)
def test_parse_yields_tuple_in_iterator_or_generator(parse_google: ParserType, return_annotation: str) -> None:
"""Parse Yields annotations in Iterator or Generator types.
Parameters:
parse_google: Fixture parser.
return_annotation: Parametrized return annotation as a string.
"""
docstring = """
Summary.
Yields:
a: Whatever.
b: Whatever.
"""
sections, _ = parse_google(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))),
),
)
yields = sections[1].value
assert yields[0].name == "a"
assert yields[0].annotation.name == "int"
assert yields[1].name == "b"
assert yields[1].annotation.name == "float"
@pytest.mark.parametrize(
"return_annotation",
[
"Iterator[int]",
"Generator[int, None, None]",
],
)
def test_extract_yielded_type_with_single_return_item(parse_google: ParserType, return_annotation: str) -> None:
"""Extract main type annotation from Iterator or Generator.
Parameters:
parse_google: Fixture parser.
return_annotation: Parametrized return annotation as a string.
"""
docstring = """
Summary.
Yields:
A number.
"""
sections, _ = parse_google(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))),
),
)
yields = sections[1].value
assert yields[0].annotation.name == "int"
def test_yield_section_in_property(parse_google: ParserType) -> None:
"""No warnings when parsing Yields section in a property.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Yields:
A number.
"""
sections, warnings = parse_google(
docstring,
parent=Attribute(
"prop",
annotation=parse_docstring_annotation("Iterator[int]", Docstring("d", parent=Attribute("a"))),
),
)
assert not warnings
yields = sections[1].value
assert yields[0].annotation.name == "int"
# =============================================================================================
# Receives sections
def test_parse_receives_tuple_in_generator(parse_google: ParserType) -> None:
"""Parse Receives annotations in Generator type.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Receives:
a: Whatever.
b: Whatever.
"""
sections, _ = parse_google(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(
"Generator[..., tuple[int, float], ...]",
Docstring("d", parent=Function("f")),
),
),
)
receives = sections[1].value
assert receives[0].name == "a"
assert receives[0].annotation.name == "int"
assert receives[1].name == "b"
assert receives[1].annotation.name == "float"
@pytest.mark.parametrize(
"return_annotation",
[
"Generator[int, float, None]",
],
)
def test_extract_received_type_with_single_return_item(parse_google: ParserType, return_annotation: str) -> None:
"""Extract main type annotation from Iterator or Generator.
Parameters:
parse_google: Fixture parser.
return_annotation: Parametrized return annotation as a string.
"""
docstring = """
Summary.
Receives:
A floating point number.
"""
sections, _ = parse_google(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))),
),
)
receives = sections[1].value
assert receives[0].annotation.name == "float"
# =============================================================================================
# Returns sections
def test_parse_returns_tuple_in_generator(parse_google: ParserType) -> None:
"""Parse Returns annotations in Generator type.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Returns:
a: Whatever.
b: Whatever.
"""
sections, _ = parse_google(
docstring,
parent=Function(
"func",
returns=parse_docstring_annotation(
"Generator[..., ..., tuple[int, float]]",
Docstring("d", parent=Function("f")),
),
),
)
returns = sections[1].value
assert returns[0].name == "a"
assert returns[0].annotation.name == "int"
assert returns[1].name == "b"
assert returns[1].annotation.name == "float"
# =============================================================================================
# Parser special features
def test_parse_admonitions(parse_google: ParserType) -> None:
"""Parse admonitions.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Important note:
Hello.
Note: With title.
Hello again.
Something:
Something.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 3
assert not warnings
assert sections[0].title == "Important note"
assert sections[0].value.kind == "important-note"
assert sections[0].value.contents == "Hello."
assert sections[1].title == "With title."
assert sections[1].value.kind == "note"
assert sections[1].value.contents == "Hello again."
assert sections[2].title == "Something"
assert sections[2].value.kind == "something"
assert sections[2].value.contents == "Something."
@pytest.mark.parametrize(
"docstring",
[
"""
******************************
This looks like an admonition:
******************************
""",
"""
Warning: this line also looks
like an admonition.
""",
"""
Matching but not an admonition:
- Multiple empty lines above.
""",
"""Last line:""",
],
)
def test_handle_false_admonitions_correctly(parse_google: ParserType, docstring: str) -> None:
"""Correctly handle lines that look like admonitions.
Parameters:
parse_google: Fixture parser.
docstring: The docstring to parse (parametrized).
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.text
assert len(sections[0].value.splitlines()) == len(inspect.cleandoc(docstring).splitlines())
assert not warnings
def test_dont_insert_admonition_before_current_section(parse_google: ParserType) -> None:
"""Check that admonitions are inserted at the right place.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Short description.
Info:
Something useful.
"""
sections, _ = parse_google(docstring)
assert len(sections) == 2
assert sections[0].kind is DocstringSectionKind.text
assert sections[1].kind is DocstringSectionKind.admonition
@pytest.mark.parametrize(
"docstring",
[
"",
"\n",
"\n\n",
"Summary.",
"Summary.\n\n\n",
"Summary.\n\nParagraph.",
"Summary\non two lines.",
"Summary\non two lines.\n\nParagraph.",
],
)
def test_ignore_init_summary(parse_google: ParserType, docstring: str) -> None:
"""Correctly ignore summary in `__init__` methods' docstrings.
Parameters:
parse_google: Fixture parser.
docstring: The docstring to parse_google (parametrized).
"""
sections, _ = parse_google(docstring, parent=Function("__init__", parent=Class("C")), ignore_init_summary=True)
for section in sections:
assert "Summary" not in section.value
if docstring.strip():
sections, _ = parse_google(docstring, parent=Function("__init__", parent=Module("M")), ignore_init_summary=True)
assert "Summary" in sections[0].value
sections, _ = parse_google(docstring, parent=Function("f", parent=Class("C")), ignore_init_summary=True)
assert "Summary" in sections[0].value
sections, _ = parse_google(docstring, ignore_init_summary=True)
assert "Summary" in sections[0].value
@pytest.mark.parametrize(
"docstring",
[
"""
Examples:
Base case 1. We want to skip the following test.
>>> 1 + 1 == 3 # doctest: +SKIP
True
""",
r"""
Examples:
Base case 2. We have a blankline test.
>>> print("a\n\nb")
a
b
""",
],
)
def test_trim_doctest_flags_basic_example(parse_google: ParserType, docstring: str) -> None:
"""Correctly parse simple example docstrings when `trim_doctest_flags` option is turned on.
Parameters:
parse_google: Fixture parser.
docstring: The docstring to parse (parametrized).
"""
sections, warnings = parse_google(docstring, trim_doctest_flags=True)
assert len(sections) == 1
assert len(sections[0].value) == 2
assert not warnings
# Verify that doctest flags have indeed been trimmed.
example_str = sections[0].value[1][1]
assert "# doctest: +SKIP" not in example_str
assert "" not in example_str
def test_trim_doctest_flags_multi_example(parse_google: ParserType) -> None:
"""Correctly parse multiline example docstrings when `trim_doctest_flags` option is turned on.
Parameters:
parse_google: Fixture parser.
"""
docstring = r"""
Examples:
Test multiline example blocks.
We want to skip the following test.
>>> 1 + 1 == 3 # doctest: +SKIP
True
And then a few more examples here:
>>> print("a\n\nb")
a
b
>>> 1 + 1 == 2 # doctest: +SKIP
>>> print(list(range(1, 100))) # doctest: +ELLIPSIS
[1, 2, ..., 98, 99]
"""
sections, warnings = parse_google(docstring, trim_doctest_flags=True)
assert len(sections) == 1
assert len(sections[0].value) == 4
assert not warnings
# Verify that doctest flags have indeed been trimmed.
example_str = sections[0].value[1][1]
assert "# doctest: +SKIP" not in example_str
example_str = sections[0].value[3][1]
assert "" not in example_str
assert "\n>>> print(list(range(1, 100)))\n" in example_str
def test_single_line_with_trailing_whitespace(parse_google: ParserType) -> None:
"""Don't crash on single line docstrings with trailing whitespace.
Parameters:
parse_google: Fixture parser.
"""
docstring = "a: b\n "
sections, warnings = parse_google(docstring, trim_doctest_flags=True)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.text
assert not warnings
@pytest.mark.parametrize(
("returns_multiple_items", "return_annotation", "expected"),
[
(
False,
None,
[DocstringReturn("", description="XXXXXXX\n YYYYYYY\nZZZZZZZ", annotation=None)],
),
(
False,
"tuple[int, int]",
[DocstringReturn("", description="XXXXXXX\n YYYYYYY\nZZZZZZZ", annotation="tuple[int, int]")],
),
(
True,
None,
[
DocstringReturn("", description="XXXXXXX\nYYYYYYY", annotation=None),
DocstringReturn("", description="ZZZZZZZ", annotation=None),
],
),
(
True,
"tuple[int,int]",
[
DocstringReturn("", description="XXXXXXX\nYYYYYYY", annotation="int"),
DocstringReturn("", description="ZZZZZZZ", annotation="int"),
],
),
],
)
def test_parse_returns_multiple_items(
parse_google: ParserType,
returns_multiple_items: bool,
return_annotation: str,
expected: list[DocstringReturn],
) -> None:
"""Parse Returns section with and without multiple items.
Parameters:
parse_google: Fixture parser.
returns_multiple_items: Whether the `Returns` section has multiple items.
return_annotation: The return annotation of the function to parse.
expected: The expected value of the parsed Returns section.
"""
parent = (
Function("func", returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))))
if return_annotation is not None
else None
)
docstring = """
Returns:
XXXXXXX
YYYYYYY
ZZZZZZZ
"""
sections, _ = parse_google(
docstring,
returns_multiple_items=returns_multiple_items,
parent=parent,
)
assert len(sections) == 1
assert len(sections[0].value) == len(expected)
for annotated, expected_ in zip(sections[0].value, expected, strict=False):
assert annotated.name == expected_.name
assert str(annotated.annotation) == str(expected_.annotation)
assert annotated.description == expected_.description
@pytest.mark.parametrize(
("returns_multiple_items", "return_annotation", "expected"),
[
(
False,
None,
[DocstringYield("", description="XXXXXXX\n YYYYYYY\nZZZZZZZ", annotation=None)],
),
(
False,
"Iterator[tuple[int, int]]",
[DocstringYield("", description="XXXXXXX\n YYYYYYY\nZZZZZZZ", annotation="tuple[int, int]")],
),
(
True,
None,
[
DocstringYield("", description="XXXXXXX\nYYYYYYY", annotation=None),
DocstringYield("", description="ZZZZZZZ", annotation=None),
],
),
(
True,
"Iterator[tuple[int,int]]",
[
DocstringYield("", description="XXXXXXX\nYYYYYYY", annotation="int"),
DocstringYield("", description="ZZZZZZZ", annotation="int"),
],
),
],
)
def test_parse_yields_multiple_items(
parse_google: ParserType,
returns_multiple_items: bool,
return_annotation: str,
expected: list[DocstringYield],
) -> None:
"""Parse Returns section with and without multiple items.
Parameters:
parse_google: Fixture parser.
returns_multiple_items: Whether the `Returns` and `Yields` sections have multiple items.
return_annotation: The return annotation of the function to parse. Usually an `Iterator`.
expected: The expected value of the parsed Yields section.
"""
parent = (
Function("func", returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))))
if return_annotation is not None
else None
)
docstring = """
Yields:
XXXXXXX
YYYYYYY
ZZZZZZZ
"""
sections, _ = parse_google(
docstring,
returns_multiple_items=returns_multiple_items,
parent=parent,
)
assert len(sections) == 1
assert len(sections[0].value) == len(expected)
for annotated, expected_ in zip(sections[0].value, expected, strict=False):
assert annotated.name == expected_.name
assert str(annotated.annotation) == str(expected_.annotation)
assert annotated.description == expected_.description
@pytest.mark.parametrize(
("receives_multiple_items", "return_annotation", "expected"),
[
(
False,
None,
[DocstringReceive("", description="XXXXXXX\n YYYYYYY\nZZZZZZZ", annotation=None)],
),
(
False,
"Generator[..., tuple[int, int], ...]",
[DocstringReceive("", description="XXXXXXX\n YYYYYYY\nZZZZZZZ", annotation="tuple[int, int]")],
),
(
True,
None,
[
DocstringReceive("", description="XXXXXXX\nYYYYYYY", annotation=None),
DocstringReceive("", description="ZZZZZZZ", annotation=None),
],
),
(
True,
"Generator[..., tuple[int, int], ...]",
[
DocstringReceive("", description="XXXXXXX\nYYYYYYY", annotation="int"),
DocstringReceive("", description="ZZZZZZZ", annotation="int"),
],
),
],
)
def test_parse_receives_multiple_items(
parse_google: ParserType,
receives_multiple_items: bool,
return_annotation: str,
expected: list[DocstringReceive],
) -> None:
"""Parse Returns section with and without multiple items.
Parameters:
parse_google: Fixture parser.
receives_multiple_items: Whether the `Receives` section has multiple items.
return_annotation: The return annotation of the function to parse. Usually a `Generator`.
expected: The expected value of the parsed Receives section.
"""
parent = (
Function("func", returns=parse_docstring_annotation(return_annotation, Docstring("d", parent=Function("f"))))
if return_annotation is not None
else None
)
docstring = """
Receives:
XXXXXXX
YYYYYYY
ZZZZZZZ
"""
sections, _ = parse_google(
docstring,
receives_multiple_items=receives_multiple_items,
parent=parent,
)
assert len(sections) == 1
assert len(sections[0].value) == len(expected)
for annotated, expected_ in zip(sections[0].value, expected, strict=False):
assert annotated.name == expected_.name
assert str(annotated.annotation) == str(expected_.annotation)
assert annotated.description == expected_.description
def test_avoid_false_positive_sections(parse_google: ParserType) -> None:
"""Avoid false positive when parsing sections.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Modules:
Not a modules section.
No blank line before title:
Not an admonition.
Blank line after title:
Not an admonition.
Modules:
Not a modules section.
Modules:
Not a modules section.
No blank line before and blank line after:
Not an admonition.
Classes:
- Text.
"""
sections, warnings = parse_google(docstring)
assert len(sections) == 1
assert "Classes" in sections[0].value
assert "Text" in sections[0].value
assert len(warnings) == 6
assert warnings == [
"Possible section skipped, reasons: Missing blank line above section",
"Possible admonition skipped, reasons: Missing blank line above admonition",
"Possible admonition skipped, reasons: Extraneous blank line below admonition title",
"Possible section skipped, reasons: Extraneous blank line below section title",
"Possible section skipped, reasons: Missing blank line above section; Extraneous blank line below section title",
"Possible admonition skipped, reasons: Missing blank line above admonition; Extraneous blank line below admonition title",
]
def test_type_in_returns_without_parentheses(parse_google: ParserType) -> None:
"""Assert we can parse the return type without parentheses.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Returns:
int: Description
on several lines.
"""
sections, warnings = parse_google(docstring, returns_named_value=False)
assert len(sections) == 2
assert not warnings
retval = sections[1].value[0]
assert retval.name == ""
assert retval.annotation == "int"
assert retval.description == "Description\non several lines."
docstring = """
Summary.
Returns:
Description
on several lines.
"""
sections, warnings = parse_google(docstring, returns_named_value=False)
assert len(sections) == 2
assert len(warnings) == 1
retval = sections[1].value[0]
assert retval.name == ""
assert retval.annotation is None
assert retval.description == "Description\non several lines."
def test_type_in_yields_without_parentheses(parse_google: ParserType) -> None:
"""Assert we can parse the return type without parentheses.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Yields:
int: Description
on several lines.
"""
sections, warnings = parse_google(docstring, returns_named_value=False)
assert len(sections) == 2
assert not warnings
retval = sections[1].value[0]
assert retval.name == ""
assert retval.annotation == "int"
assert retval.description == "Description\non several lines."
docstring = """
Summary.
Yields:
Description
on several lines.
"""
sections, warnings = parse_google(docstring, returns_named_value=False)
assert len(sections) == 2
assert len(warnings) == 1
retval = sections[1].value[0]
assert retval.name == ""
assert retval.annotation is None
assert retval.description == "Description\non several lines."
def test_type_in_receives_without_parentheses(parse_google: ParserType) -> None:
"""Assert we can parse the return type without parentheses.
Parameters:
parse_google: Fixture parser.
"""
docstring = """
Summary.
Receives:
int: Description
on several lines.
"""
sections, warnings = parse_google(docstring, receives_named_value=False)
assert len(sections) == 2
assert not warnings
retval = sections[1].value[0]
assert retval.name == ""
assert retval.annotation == "int"
assert retval.description == "Description\non several lines."
docstring = """
Summary.
Receives:
Description
on several lines.
"""
sections, warnings = parse_google(docstring, receives_named_value=False)
assert len(sections) == 2
assert len(warnings) == 1
retval = sections[1].value[0]
assert retval.name == ""
assert retval.annotation is None
assert retval.description == "Description\non several lines."
def test_reading_property_type_in_summary(parse_google: ParserType) -> None:
"""Assert we can parse the return type of properties in their summary.
Parameters:
parse_google: Fixture parser.
"""
docstring = "str: Description of the property."
parent = Attribute("prop")
parent.labels.add("property")
sections, _ = parse_google(docstring, returns_type_in_property_summary=True, parent=parent)
assert len(sections) == 2
assert sections[0].kind is DocstringSectionKind.text
assert sections[1].kind is DocstringSectionKind.returns
retval = sections[1].value[0]
assert retval.name == ""
assert retval.annotation.name == "str"
assert retval.description == ""
# =============================================================================================
# Warnings
@pytest.mark.parametrize(
"docstring",
[
"""
Parameters:
x: X value.
""",
"""
Type Parameters:
T without a description
""",
"""
Type Aliases:
Alias without a description
""",
"""
Returns:
int: Return value.
Confusing indentation.
""",
"""
Yields:
int: Yielded value.
Confusing indentation.
""",
"""
Receives:
int: Received value.
Confusing indentation.
""",
],
)
def test_disabled_warnings(parse_google: ParserType, docstring: str) -> None:
"""Assert warnings are disabled.
Parameters:
parse_google: Fixture parser.
docstring: A docstring that triggers a parser warning.
"""
_, warnings = parse_google(docstring, warnings=True)
assert warnings
_, warnings = parse_google(docstring, warnings=False)
assert not warnings
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/test_sphinx.py 0000664 0001750 0001750 00000120323 15246557273 027211 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the [Sphinx-style parser][griffe.docstrings.sphinx].
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING, Any
import pytest
from griffe import (
Attribute,
Class,
DocstringAttribute,
DocstringParameter,
DocstringRaise,
DocstringReturn,
DocstringSectionKind,
Expr,
ExprAttribute,
ExprBinOp,
ExprName,
ExprSubscript,
ExprTuple,
Function,
Module,
Parameter,
Parameters,
)
if TYPE_CHECKING:
from tests.test_docstrings.helpers import ParserType
SOME_NAME = "foo"
SOME_TEXT = "descriptive test text"
SOME_EXTRA_TEXT = "more test text"
SOME_EXCEPTION_NAME = "SomeException"
SOME_OTHER_EXCEPTION_NAME = "SomeOtherException"
@pytest.mark.parametrize(
"docstring",
[
"One line docstring description",
"""
Multiple line docstring description.
With more text.
""",
],
)
def test_parse__description_only_docstring__single_markdown_section(parse_sphinx: ParserType, docstring: str) -> None:
"""Parse a single or multiline docstring.
Parameters:
parse_sphinx: Fixture parser.
docstring: A parametrized docstring.
"""
sections, warnings = parse_sphinx(docstring)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.text
assert sections[0].value == inspect.cleandoc(docstring)
assert not warnings
def test_parse__no_description__single_markdown_section(parse_sphinx: ParserType) -> None:
"""Parse an empty docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
sections, warnings = parse_sphinx("")
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.text
assert sections[0].value == ""
assert not warnings
def test_parse__multiple_blank_lines_before_description__single_markdown_section(parse_sphinx: ParserType) -> None:
"""Parse a docstring with initial blank lines.
Parameters:
parse_sphinx: Fixture parser.
"""
sections, warnings = parse_sphinx(
"""
Now text""",
)
assert len(sections) == 1
assert sections[0].kind is DocstringSectionKind.text
assert sections[0].value == "Now text"
assert not warnings
def test_parse__param_field__param_section(parse_sphinx: ParserType) -> None:
"""Parse a parameter section.
Parameters:
parse_sphinx: Fixture parser.
"""
sections, _ = parse_sphinx(
f"""
Docstring with one line param.
:param {SOME_NAME}: {SOME_TEXT}
""",
)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__only_param_field__empty_markdown(parse_sphinx: ParserType) -> None:
"""Parse only a parameter section.
Parameters:
parse_sphinx: Fixture parser.
"""
sections, _ = parse_sphinx(":param foo: text")
assert len(sections) == 2
assert sections[0].kind is DocstringSectionKind.text
assert sections[0].value == ""
@pytest.mark.parametrize(
"param_directive_name",
[
"param",
"parameter",
"arg",
"arguments",
"key",
"keyword",
],
)
def test_parse__all_param_names__param_section(parse_sphinx: ParserType, param_directive_name: str) -> None:
"""Parse all parameters directives.
Parameters:
parse_sphinx: Fixture parser.
param_directive_name: A parametrized directive name.
"""
sections, _ = parse_sphinx(
f"""
Docstring with one line param.
:{param_directive_name} {SOME_NAME}: {SOME_TEXT}
""",
)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
@pytest.mark.parametrize(
"docstring",
[
f"""
Docstring with param with continuation, no indent.
:param {SOME_NAME}: {SOME_TEXT}
{SOME_EXTRA_TEXT}
""",
f"""
Docstring with param with continuation, with indent.
:param {SOME_NAME}: {SOME_TEXT}
{SOME_EXTRA_TEXT}
""",
],
)
def test_parse__param_field_multi_line__param_section(parse_sphinx: ParserType, docstring: str) -> None:
"""Parse multiline directives.
Parameters:
parse_sphinx: Fixture parser.
docstring: A parametrized docstring.
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, description=f"{SOME_TEXT}\n{SOME_EXTRA_TEXT}")
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__param_field_for_function__param_section_with_kind(parse_sphinx: ParserType) -> None:
"""Parse parameters.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param foo: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__param_field_docs_type__param_section_with_type(parse_sphinx: ParserType) -> None:
"""Parse parameters with types.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param str foo: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
@pytest.mark.parametrize("type_", ["str", "int"])
def test_parse__param_field_type_field__param_section_with_type(parse_sphinx: ParserType, type_: str) -> None:
"""Parse parameters with separated types.
Parameters:
parse_sphinx: Fixture parser.
type_: The type to use in the type directive.
"""
docstring = f"""
Docstring with line continuation.
:param {SOME_NAME}: {SOME_TEXT}
:type {SOME_NAME}: {type_}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation=f"{type_}", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
@pytest.mark.parametrize("type_", ["str", "int"])
def test_parse__param_field_type_field__param_section_with_type_with_parent(
parse_sphinx: ParserType,
type_: str,
) -> None:
"""Parse parameters with separated types.
Parameters:
parse_sphinx: Fixture parser.
type_: The type to use in the type directive.
"""
docstring = f"""
Docstring with line continuation.
:param {SOME_NAME}: {SOME_TEXT}
:type {SOME_NAME}: {type_}
"""
parent_fn = Function("func", parameters=Parameters(Parameter(SOME_NAME)))
sections, _ = parse_sphinx(docstring, parent=parent_fn)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected_annotation = ExprName(name=f"{type_}")
expected = DocstringParameter(SOME_NAME, annotation=expected_annotation, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
assert isinstance(actual.annotation, type(expected.annotation))
assert isinstance(actual.annotation, ExprName)
assert isinstance(actual.annotation, Expr)
assert actual.annotation.as_dict() == expected_annotation.as_dict()
def test_parse__param_field_type_field_first__param_section_with_type(parse_sphinx: ParserType) -> None:
"""Parse parameters with separated types first.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:type foo: str
:param foo: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__param_field_type_field_first__param_section_with_type_with_parent(parse_sphinx: ParserType) -> None:
"""Parse parameters with separated types first.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:type {SOME_NAME}: str
:param {SOME_NAME}: {SOME_TEXT}
"""
parent_fn = Function("func", parameters=Parameters(Parameter(SOME_NAME)))
sections, _ = parse_sphinx(docstring, parent=parent_fn)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected_annotation = ExprName("str", parent=Class("C"))
expected = DocstringParameter(SOME_NAME, annotation=expected_annotation, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
assert isinstance(actual.annotation, type(expected.annotation))
assert isinstance(actual.annotation, ExprName)
assert isinstance(actual.annotation, Expr)
assert actual.annotation.as_dict() == expected_annotation.as_dict()
@pytest.mark.parametrize("union", ["str or None", "None or str", "str or int", "str or int or float"])
def test_parse__param_field_type_field_or_none__param_section_with_optional(
parse_sphinx: ParserType,
union: str,
) -> None:
"""Parse parameters with separated union types.
Parameters:
parse_sphinx: Fixture parser.
union: A parametrized union type.
"""
docstring = f"""
Docstring with line continuation.
:param foo: {SOME_TEXT}
:type foo: {union}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation=union.replace(" or ", " | "), description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
@pytest.mark.parametrize(
("union", "expected_annotation"),
[
("str or None", ExprBinOp(ExprName("str"), "|", "None")),
("None or str", ExprBinOp("None", "|", ExprName("str"))),
("str or int", ExprBinOp(ExprName("str"), "|", ExprName("int"))),
("str or int or float", ExprBinOp(ExprBinOp(ExprName("str"), "|", ExprName("int")), "|", ExprName("float"))),
],
)
def test_parse__param_field_type_field_or_none__param_section_with_optional_with_parent(
parse_sphinx: ParserType,
union: str,
expected_annotation: Expr,
) -> None:
"""Parse parameters with separated union types.
Parameters:
parse_sphinx: Fixture parser.
union: A parametrized union type.
expected_annotation: The expected annotation as an expression
"""
docstring = f"""
Docstring with line continuation.
:param {SOME_NAME}: {SOME_TEXT}
:type {SOME_NAME}: {union}
"""
parent_fn = Function("func", parameters=Parameters(Parameter(SOME_NAME)))
sections, _ = parse_sphinx(docstring, parent=parent_fn)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation=expected_annotation, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
assert isinstance(actual.annotation, type(expected.annotation))
assert isinstance(actual.annotation, Expr)
assert actual.annotation.as_dict() == expected_annotation.as_dict()
def test_parse__param_field_annotate_type__param_section_with_type(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param foo: {SOME_TEXT}
"""
sections, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", annotation="str", kind=None))),
)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
assert not warnings
def test_parse__param_field_no_matching_param__result_from_docstring(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param other: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter("other", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__param_field_with_default__result_from_docstring(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param foo: {SOME_TEXT}
"""
sections, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", kind=None, default=repr("")))),
)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter("foo", description=SOME_TEXT, value=repr(""))
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
assert not warnings
def test_parse__param_field_no_matching_param__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param other: {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "No matching parameter for 'other'" in warnings[0]
def test_parse__invalid_param_field_only_initial_marker__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param foo {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to get ':directive: value' pair" in warnings[0]
def test_parse__invalid_param_field_wrong_part_count__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param: {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to parse field directive" in warnings[0]
def test_parse__invalid_param_field_wrong_part_count_spaces_4__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param typing.Union[str, int] {SOME_NAME}: {SOME_TEXT}
"""
sections, warnings = parse_sphinx(docstring)
# Assert that the warning is shown
assert "Failed to parse field directive" in warnings[0]
# Assert that the parameter is still collected, but ignores the invalid type
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation=None, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__valid_param_field_part_count_3(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param typing.Union[str,int] {SOME_NAME}: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
expected = DocstringParameter(SOME_NAME, annotation="typing.Union[str,int]", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__valid_param_field_part_count_3_with_parent(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param typing.Union[str,int] {SOME_NAME}: {SOME_TEXT}
"""
parent_fn = Function("func3", parameters=Parameters(Parameter(name=SOME_NAME)))
sections, _ = parse_sphinx(docstring, parent=parent_fn)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.parameters
actual = sections[1].value[0]
typing_expr = ExprName("typing", parent=parent_fn)
expected_annotation = ExprSubscript(
left=ExprAttribute(values=[typing_expr, ExprName("Union", parent=typing_expr)]),
slice=ExprTuple([ExprName("str", parent=parent_fn), ExprName("int", parent=parent_fn)], implicit=True),
)
expected = DocstringParameter(SOME_NAME, annotation=expected_annotation, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__param_twice__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param foo: {SOME_TEXT}
:param foo: {SOME_TEXT} again
"""
_, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", kind=None))),
)
assert "Duplicate parameter entry for 'foo'" in warnings[0]
def test_parse__param_type_twice_doc__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param str foo: {SOME_TEXT}
:type foo: str
"""
_, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", kind=None))),
)
assert "Duplicate parameter information for 'foo'" in warnings[0]
def test_parse__param_type_twice_type_directive_first__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:type foo: str
:param str foo: {SOME_TEXT}
"""
_, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", kind=None))),
)
assert "Duplicate parameter information for 'foo'" in warnings[0]
def test_parse__param_type_twice_annotated__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param str foo: {SOME_TEXT}
:type foo: str
"""
_, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", annotation="str", kind=None))),
)
assert "Duplicate parameter information for 'foo'" in warnings[0]
def test_warn_about_unknown_parameters(parse_sphinx: ParserType) -> None:
"""Warn about unknown parameters in "Parameters" sections.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = """
:param str a: {SOME_TEXT}
"""
_, warnings = parse_sphinx(
docstring,
parent=Function(
"func",
parameters=Parameters(
Parameter("b"),
),
),
)
assert len(warnings) == 1
assert "Parameter 'a' does not appear in the function signature" in warnings[0]
def test_parse__param_type_no_type__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param str foo: {SOME_TEXT}
:type str
"""
_, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", annotation="str", kind=None))),
)
assert "Failed to get ':directive: value' pair from" in warnings[0]
def test_parse__param_type_no_name__error_message(parse_sphinx: ParserType) -> None:
"""Parse a simple docstring.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Docstring with line continuation.
:param str foo: {SOME_TEXT}
:type: str
"""
_, warnings = parse_sphinx(
docstring,
parent=Function("func", parameters=Parameters(Parameter("foo", annotation="str", kind=None))),
)
assert "Failed to get parameter name from" in warnings[0]
def test_parse__param_multiline(parse_sphinx: ParserType) -> None:
"""Parse multiline parameter descriptions.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = """Do something.
:param foo: This is a docstring that is long enough to run onto a second line,
because it is quite long.
A second paragraph is also required.
:param bar: This is an example that is quite long, and also requires bullet
points to be clear about intent:
* First thing
```
a code block
```
* Second thing
* Third thing
"""
sections, _ = parse_sphinx(docstring)
param_section = sections[1]
param_foo = param_section.value[0]
assert param_foo.description == (
"This is a docstring that is long enough to run onto a second line,\n"
"because it is quite long.\n\n"
"A second paragraph is also required."
)
param_bar = param_section.value[1]
assert param_bar.description == (
"This is an example that is quite long, and also requires bullet\n"
"points to be clear about intent:\n\n"
"* First thing\n\n"
" ```\n"
" a code block\n"
" ```\n\n"
"* Second thing\n"
"* Third thing"
)
@pytest.mark.parametrize(
"docstring",
[
f"""
Docstring with param with continuation, no indent.
:var {SOME_NAME}: {SOME_TEXT}
{SOME_EXTRA_TEXT}
""",
f"""
Docstring with param with continuation, with indent.
:var {SOME_NAME}: {SOME_TEXT}
{SOME_EXTRA_TEXT}
""",
],
)
def test_parse__attribute_field_multi_line__param_section(parse_sphinx: ParserType, docstring: str) -> None:
"""Parse multiline attributes.
Parameters:
parse_sphinx: Fixture parser.
docstring: A parametrized docstring.
"""
sections, warnings = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.attributes
actual = sections[1].value[0]
expected = DocstringAttribute(SOME_NAME, description=f"{SOME_TEXT}\n{SOME_EXTRA_TEXT}")
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
assert not warnings
@pytest.mark.parametrize(
"attribute_directive_name",
[
"var",
"ivar",
"cvar",
],
)
def test_parse__all_attribute_names__param_section(parse_sphinx: ParserType, attribute_directive_name: str) -> None:
"""Parse all attributes directives.
Parameters:
parse_sphinx: Fixture parser.
attribute_directive_name: A parametrized directive name.
"""
sections, warnings = parse_sphinx(
f"""
Docstring with one line attribute.
:{attribute_directive_name} {SOME_NAME}: {SOME_TEXT}
""",
)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.attributes
actual = sections[1].value[0]
expected = DocstringAttribute(SOME_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
assert not warnings
def test_parse__class_attributes__attributes_section(parse_sphinx: ParserType) -> None:
"""Parse class attributes.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Class docstring with attributes
:var foo: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring, parent=Class("klass"))
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.attributes
actual = sections[1].value[0]
expected = DocstringAttribute(SOME_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__class_attributes_with_type__annotation_in_attributes_section(parse_sphinx: ParserType) -> None:
"""Parse typed class attributes.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Class docstring with attributes
:vartype foo: str
:var foo: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring, parent=Class("klass"))
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.attributes
actual = sections[1].value[0]
expected = DocstringAttribute(SOME_NAME, annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__attribute_invalid_directive___error(parse_sphinx: ParserType) -> None:
"""Warn on invalid attribute directive.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Class docstring with attributes
:var {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to get ':directive: value' pair from" in warnings[0]
def test_parse__attribute_no_name__error(parse_sphinx: ParserType) -> None:
"""Warn on invalid attribute directive.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Class docstring with attributes
:var: {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to parse field directive from" in warnings[0]
def test_parse__attribute_duplicate__error(parse_sphinx: ParserType) -> None:
"""Warn on duplicate attribute directive.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Class docstring with attributes
:var foo: {SOME_TEXT}
:var foo: {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Duplicate attribute entry for 'foo'" in warnings[0]
def test_parse__class_attributes_type_invalid__error(parse_sphinx: ParserType) -> None:
"""Warn on invalid attribute type directive.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Class docstring with attributes
:vartype str
:var foo: {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to get ':directive: value' pair from " in warnings[0]
def test_parse__class_attributes_type_no_name__error(parse_sphinx: ParserType) -> None:
"""Warn on invalid attribute directive.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Class docstring with attributes
:vartype: str
:var foo: {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to get attribute name from" in warnings[0]
def test_parse__return_directive__return_section_no_type(parse_sphinx: ParserType) -> None:
"""Parse return directives.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return directive
:return: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.returns
actual = sections[1].value[0]
expected = DocstringReturn(name="", annotation=None, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__return_directive_rtype__return_section_with_type(parse_sphinx: ParserType) -> None:
"""Parse typed return directives.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return & rtype directive
:return: {SOME_TEXT}
:rtype: str
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.returns
actual = sections[1].value[0]
expected = DocstringReturn(name="", annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__return_directive_rtype_first__return_section_with_type(parse_sphinx: ParserType) -> None:
"""Parse typed-first return directives.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return & rtype directive
:rtype: str
:return: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.returns
actual = sections[1].value[0]
expected = DocstringReturn(name="", annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__return_directive_annotation__return_section_with_type(parse_sphinx: ParserType) -> None:
"""Parse return directives with return annotation.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with return directive, rtype directive, & annotation
:return: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring, parent=Function("func", returns="str"))
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.returns
actual = sections[1].value[0]
expected = DocstringReturn(name="", annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__return_directive_annotation__prefer_return_directive(parse_sphinx: ParserType) -> None:
"""Prefer docstring type over return annotation.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with return directive, rtype directive, & annotation
:return: {SOME_TEXT}
:rtype: str
"""
sections, _ = parse_sphinx(docstring, parent=Function("func", returns="int"))
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.returns
actual = sections[1].value[0]
expected = DocstringReturn(name="", annotation="str", description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__return_invalid__error(parse_sphinx: ParserType) -> None:
"""Warn on invalid return directive.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return directive
:return {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to get ':directive: value' pair from " in warnings[0]
def test_parse__rtype_invalid__error(parse_sphinx: ParserType) -> None:
"""Warn on invalid typed return directive.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = """
Function with only return directive
:rtype str
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to get ':directive: value' pair from " in warnings[0]
def test_parse__raises_directive__exception_section(parse_sphinx: ParserType) -> None:
"""Parse raise directives.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return directive
:raise SomeException: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.raises
actual = sections[1].value[0]
expected = DocstringRaise(annotation=SOME_EXCEPTION_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__multiple_raises_directive__exception_section_with_two(parse_sphinx: ParserType) -> None:
"""Parse multiple raise directives.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return directive
:raise SomeException: {SOME_TEXT}
:raise SomeOtherException: {SOME_TEXT}
"""
sections, _ = parse_sphinx(docstring)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.raises
actual = sections[1].value[0]
expected = DocstringRaise(annotation=SOME_EXCEPTION_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
actual = sections[1].value[1]
expected = DocstringRaise(annotation=SOME_OTHER_EXCEPTION_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
@pytest.mark.parametrize(
"raise_directive_name",
[
"raises",
"raise",
"except",
"exception",
],
)
def test_parse__all_exception_names__param_section(parse_sphinx: ParserType, raise_directive_name: str) -> None:
"""Parse all raise directives.
Parameters:
parse_sphinx: Fixture parser.
raise_directive_name: A parametrized directive name.
"""
sections, _ = parse_sphinx(
f"""
Docstring with one line attribute.
:{raise_directive_name} {SOME_EXCEPTION_NAME}: {SOME_TEXT}
""",
)
assert len(sections) == 2
assert sections[1].kind is DocstringSectionKind.raises
actual = sections[1].value[0]
expected = DocstringRaise(annotation=SOME_EXCEPTION_NAME, description=SOME_TEXT)
assert isinstance(actual, type(expected))
assert actual.as_dict() == expected.as_dict()
def test_parse__raise_invalid__error(parse_sphinx: ParserType) -> None:
"""Warn on invalid raise directives.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return directive
:raise {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to get ':directive: value' pair from " in warnings[0]
def test_parse__raise_no_name__error(parse_sphinx: ParserType) -> None:
"""Warn on invalid raise directives.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = f"""
Function with only return directive
:raise: {SOME_TEXT}
"""
_, warnings = parse_sphinx(docstring)
assert "Failed to parse exception directive from" in warnings[0]
def test_parse__module_attributes_section__expected_attributes_section(parse_sphinx: ParserType) -> None:
"""Parse attributes section in modules.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = """
Let's describe some attributes.
:var A: Alpha.
:vartype B: bytes
:var B: Beta.
:var C: Gamma.
:var D: Delta.
:var E: Epsilon.
:vartype E: float
"""
module = Module("mod", filepath=None)
module["A"] = Attribute("A", annotation="int", value="0")
module["B"] = Attribute("B", annotation="str", value=repr("ŧ"))
module["C"] = Attribute("C", annotation="bool", value="True")
module["D"] = Attribute("D", annotation=None, value="3.0")
module["E"] = Attribute("E", annotation=None, value="None")
sections, warnings = parse_sphinx(docstring, parent=module)
attr_section = sections[1]
assert attr_section.kind is DocstringSectionKind.attributes
assert len(attr_section.value) == 5
expected_data: list[dict[str, Any]] = [
{"name": "A", "annotation": "int", "description": "Alpha."},
{"name": "B", "annotation": "bytes", "description": "Beta."},
{"name": "C", "annotation": "bool", "description": "Gamma."},
{"name": "D", "annotation": None, "description": "Delta."},
{"name": "E", "annotation": "float", "description": "Epsilon."},
]
for index, expected_kwargs in enumerate(expected_data):
actual = attr_section.value[index]
expected = DocstringAttribute(**expected_kwargs)
assert isinstance(actual, type(expected))
assert actual.name == expected.name
assert actual.as_dict() == expected.as_dict()
assert not warnings
def test_parse__properties_return_type(parse_sphinx: ParserType) -> None:
"""Parse attributes section in modules.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = """
Property that returns True for explaining the issue.
:return: True
"""
prop = Attribute("example", annotation="bool")
sections, warnings = parse_sphinx(docstring, parent=prop)
assert not warnings
assert sections[1].value[0].annotation == "bool"
# =============================================================================================
# Warnings
@pytest.mark.parametrize(
"docstring",
[
":param x: X value.",
"""
:param int x: X value.
:type x: str
""",
],
)
def test_disabled_warnings(parse_sphinx: ParserType, docstring: str) -> None:
"""Assert warnings are disabled.
Parameters:
parse_sphinx: Fixture parser.
docstring: A docstring that triggers a parser warning.
"""
_, warnings = parse_sphinx(docstring, warnings=True)
assert warnings
_, warnings = parse_sphinx(docstring, warnings=False)
assert not warnings
def test_disabled_missing_type_warnings(parse_sphinx: ParserType) -> None:
"""Assert missing type warnings can be disabled.
Parameters:
parse_sphinx: Fixture parser.
"""
docstring = ":return: A value."
_, warnings = parse_sphinx(docstring, warn_missing_types=True)
assert warnings
_, warnings = parse_sphinx(docstring, warn_missing_types=False)
assert not warnings
python-griffe-2.3.0/packages/griffelib/tests/test_docstrings/test_auto.py 0000664 0001750 0001750 00000002726 15246557273 026656 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
from __future__ import annotations
import pytest
from griffe import Docstring, Parser, infer_docstring_style
@pytest.mark.parametrize(
("style", "value"),
[
(Parser.google, "Summary.\n\nArgs:\n value: Description."),
(Parser.numpy, "Summary.\n\nParameters\n----------\nvalue : str"),
(Parser.sphinx, "Summary.\n\n:param value: Description.\nMore details."),
],
)
def test_infer_docstring_style(style: Parser, value: str) -> None:
"""Infer each supported docstring style with its combined pattern."""
inferred_style, sections = infer_docstring_style(Docstring(value))
assert inferred_style is style
assert sections is None
python-griffe-2.3.0/packages/griffelib/tests/test_visitor.py 0000664 0001750 0001750 00000044733 15246557273 024173 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Test visit mechanisms.
from __future__ import annotations
import sys
from textwrap import dedent
import pytest
from griffe import (
Expr,
GriffeLoader,
TypeParameterKind,
temporary_pypackage,
temporary_visited_module,
temporary_visited_package,
)
def test_not_defined_at_runtime() -> None:
"""Assert that objects not defined at runtime are not added to wildcards expansions."""
with temporary_pypackage("package", ["module_a.py", "module_b.py", "module_c.py"]) as tmp_package:
tmp_package.path.joinpath("__init__.py").write_text("from package.module_a import *", encoding="utf8")
tmp_package.path.joinpath("module_a.py").write_text(
dedent(
"""
import typing
from typing import TYPE_CHECKING
from package.module_b import CONST_B
from package.module_c import CONST_C
if typing.TYPE_CHECKING: # Always false.
from package.module_b import TYPE_B
if TYPE_CHECKING: # Always false.
from package.module_c import TYPE_C
""",
),
encoding="utf8",
)
tmp_package.path.joinpath("module_b.py").write_text("CONST_B = 'hi'\nTYPE_B = str", encoding="utf8")
tmp_package.path.joinpath("module_c.py").write_text("CONST_C = 'ho'\nTYPE_C = str", encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert "CONST_B" in package.members
assert "CONST_C" in package.members
assert "TYPE_B" not in package.members
assert "TYPE_C" not in package.members
@pytest.mark.parametrize(
("decorator", "labels"),
[
("property", ("property",)),
("staticmethod", ("staticmethod",)),
("classmethod", ("classmethod",)),
("functools.cache", ("cached",)),
("cache", ("cached",)),
("functools.cached_property", ("cached", "property")),
("cached_property", ("cached", "property")),
("functools.lru_cache", ("cached",)),
("functools.lru_cache(maxsize=8)", ("cached",)),
("lru_cache", ("cached",)),
("lru_cache(maxsize=8)", ("cached",)),
("abc.abstractmethod", ("abstractmethod",)),
("abstractmethod", ("abstractmethod",)),
("dataclasses.dataclass", ("dataclass",)),
("dataclass", ("dataclass",)),
],
)
def test_set_function_labels_using_decorators(decorator: str, labels: tuple[str, ...]) -> None:
"""Assert decorators are used to set labels on functions.
Parameters:
decorator: A parametrized decorator.
labels: The parametrized tuple of expected labels.
"""
code = f"""
import abc
import dataclasses
import functools
from abc import abstractmethod
from dataclasses import dataclass
from functools import cache, cached_property, lru_cache
class A:
@{decorator}
def f(self):
return 0
"""
with temporary_visited_module(code) as module:
assert module["A.f"].has_labels(*labels)
@pytest.mark.parametrize(
("decorator", "labels"),
[
("dataclasses.dataclass", ("dataclass",)),
("dataclass", ("dataclass",)),
],
)
def test_set_class_labels_using_decorators(decorator: str, labels: tuple[str, ...]) -> None:
"""Assert decorators are used to set labels on classes.
Parameters:
decorator: A parametrized decorator.
labels: The parametrized tuple of expected labels.
"""
code = f"""
import dataclasses
from dataclasses import dataclass
@{decorator}
class A: ...
"""
with temporary_visited_module(code) as module:
assert module["A"].has_labels(*labels)
def test_handle_property_setter_and_deleter() -> None:
"""Assert property setters and deleters are supported."""
code = """
class A:
def __init__(self): self._thing = 0
@property
def thing(self): return self._thing
@thing.setter
def thing(self, value): self._thing = value
@thing.deleter
def thing(self): del self._thing
"""
with temporary_visited_module(code) as module:
assert module["A.thing"].has_labels("property", "writable", "deletable")
assert module["A.thing"].setter.is_function
assert module["A.thing"].deleter.is_function
@pytest.mark.parametrize(
"decorator",
[
"overload",
"typing.overload",
],
)
def test_handle_typing_overaload(decorator: str) -> None:
"""Assert `typing.overload` is supported.
Parameters:
decorator: A parametrized overload decorator.
"""
code = f"""
import typing
from typing import overload
from pathlib import Path
class A:
@{decorator}
def absolute(self, path: str) -> str:
...
@{decorator}
def absolute(self, path: Path) -> Path:
...
def absolute(self, path: str | Path) -> str | Path:
...
"""
with temporary_visited_module(code) as module:
overloads = module["A.absolute"].overloads
assert len(overloads) == 2
assert overloads[0].parameters["path"].annotation.name == "str"
assert overloads[1].parameters["path"].annotation.name == "Path"
assert overloads[0].returns.name == "str"
assert overloads[1].returns.name == "Path"
@pytest.mark.parametrize(
"statements",
[
"""__all__ = ["CONST_INIT"] + moda_all + modb_all + modc_all""",
"""__all__ = ["CONST_INIT", *moda_all, *modb_all, *modc_all]""",
"""
__all__ = ["CONST_INIT"]
__all__ += moda_all + modb_all + modc_all
""",
"""
__all__ = ["CONST_INIT"] + moda_all + modb_all
__all__ += modc_all
""",
"""
__all__ = ["CONST_INIT"] + moda_all + modb_all
__all__ += [*modc_all]
""",
"""
__all__ = ["CONST_INIT"]
__all__ += moda_all
__all__ += modb_all + modc_all
""",
],
)
def test_parse_complex__all__assignments(statements: str) -> None:
"""Check our ability to expand exports based on `__all__` [augmented] assignments.
Parameters:
statements: Parametrized text containing `__all__` [augmented] assignments.
"""
with temporary_pypackage("package", ["moda.py", "modb.py", "modc.py"]) as tmp_package:
tmp_package.path.joinpath("moda.py").write_text("CONST_A = 1\n\n__all__ = ['CONST_A']", encoding="utf8")
tmp_package.path.joinpath("modb.py").write_text("CONST_B = 1\n\n__all__ = ['CONST_B']", encoding="utf8")
tmp_package.path.joinpath("modc.py").write_text("CONST_C = 2\n\n__all__ = ['CONST_C']", encoding="utf8")
code = """
from package.moda import *
from package.moda import __all__ as moda_all
from package.modb import *
from package.modb import __all__ as modb_all
from package.modc import *
from package.modc import __all__ as modc_all
CONST_INIT = 0
"""
tmp_package.path.joinpath("__init__.py").write_text(dedent(code) + dedent(statements), encoding="utf8")
loader = GriffeLoader(search_paths=[tmp_package.tmpdir])
package = loader.load(tmp_package.name)
loader.resolve_aliases()
assert package.exports == ["CONST_INIT", "CONST_A", "CONST_B", "CONST_C"]
def test_dont_crash_on_nested_functions_in_init() -> None:
"""Assert we don't crash when visiting a nested function in `__init__` methods."""
with temporary_visited_module(
"""
class C:
def __init__(self):
def pl(i: int):
return i + 1
""",
) as module:
assert module
def test_get_correct_docstring_starting_line_number() -> None:
"""Assert we get the correct line numbers for docstring."""
with temporary_visited_module(
"""
'''
Module docstring.
'''
class C:
'''
Class docstring.
'''
def method(self):
'''
Method docstring.
'''
""",
) as module:
assert module.docstring.lineno == 2 # ty:ignore[unresolved-attribute]
assert module["C"].docstring.lineno == 6
assert module["C.method"].docstring.lineno == 10
def test_visit_properties_as_attributes() -> None:
"""Assert properties are created as attributes and not functions."""
with temporary_visited_module(
"""
from functools import cached_property
class C:
@property
def prop(self) -> bool:
return True
@cached_property
def cached_prop(self) -> int:
return 0
""",
) as module:
assert module["C.prop"].is_attribute
assert "property" in module["C.prop"].labels
assert module["C.cached_prop"].is_attribute
assert "cached" in module["C.cached_prop"].labels
def test_forward_docstrings() -> None:
"""Assert docstrings of class attributes are forwarded to instance assignments.
This is a regression test for https://github.com/mkdocstrings/griffe/issues/128.
"""
with temporary_visited_module(
'''
class C:
attr: int
"""This is a non-empty docstring."""
def __init__(self, attr: int) -> None:
self.attr = attr
''',
) as module:
assert module["C.attr"].docstring
def test_classvar_annotations() -> None:
"""Assert class variable and instance variable annotations are correctly parsed and merged."""
with temporary_visited_module(
"""
from typing import ClassVar
class C:
w: ClassVar[str] = "foo"
x: ClassVar[int]
y: str
z: int = 5
def __init__(self) -> None:
self.a: ClassVar[float]
self.y = ""
self.b: bytes
""",
) as module:
assert module["C.w"].annotation.canonical_path == "str"
assert module["C.w"].labels == {"class-attribute"}
assert module["C.w"].value == "'foo'"
assert module["C.x"].annotation.canonical_path == "int"
assert module["C.x"].labels == {"class-attribute"}
assert module["C.y"].annotation.canonical_path == "str"
assert module["C.y"].labels == {"instance-attribute"}
assert module["C.y"].value == "''"
assert module["C.z"].annotation.canonical_path == "int"
assert module["C.z"].labels == {"class-attribute", "instance-attribute"}
assert module["C.z"].value == "5"
# This is syntactically valid, but semantically invalid
assert module["C.a"].annotation.canonical_path == "typing.ClassVar"
assert module["C.a"].annotation.slice.canonical_path == "float"
assert module["C.a"].labels == {"instance-attribute"}
assert module["C.b"].annotation.canonical_path == "bytes"
assert module["C.b"].labels == {"instance-attribute"}
def test_visiting_if_statement_in_class_for_type_guards() -> None:
"""Don't fail on various if statements when checking for type-guards."""
with temporary_visited_module(
"""
class A:
if something("string1 string2"):
class B:
pass
""",
) as module:
assert module["A.B"].runtime
def test_visiting_relative_imports_triggering_cyclic_aliases() -> None:
"""Skip specific imports to avoid cyclic aliases."""
with temporary_visited_package(
"pkg",
{
"__init__.py": "from . import a",
"a.py": "from . import b",
"b.py": "",
},
) as pkg:
assert "a" not in pkg.imports
assert "b" in pkg["a"].imports
assert pkg["a"].imports["b"] == "pkg.b"
def test_parse_attributes_in__all__() -> None:
"""Parse attributes in `__all__`."""
with temporary_visited_package(
"package",
{
"__init__.py": "from package import module\n__all__ = module.__all__",
"module.py": "def hello(): ...\n__all__ = ['hello']",
},
) as package:
assert "hello" in package.exports # ty:ignore[unsupported-operator]
def test_parse_deep_attributes_in__all__() -> None:
"""Parse deep attributes in `__all__`."""
with temporary_visited_package(
"package",
{
"__init__.py": "from package import subpackage\n__all__ = subpackage.module.__all__",
"subpackage/__init__.py": "from package.subpackage import module",
"subpackage/module.py": "def hello(): ...\n__all__ = ['hello']",
},
) as package:
assert "hello" in package.exports # ty:ignore[unsupported-operator]
# YORE: EOL 3.12: Remove block.
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics")
def test_parse_pep695_generics_without_defaults() -> None:
"""Assert PEP 695 generics are correctly inspected."""
with temporary_visited_module(
"""
class Class[X: Exception]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str)] = dict[str, T]
""",
) as module:
class_ = module["Class"]
assert class_.is_class
assert class_.type_parameters[0].name == "X"
assert class_.type_parameters[0].kind == TypeParameterKind.type_var
assert class_.type_parameters[0].bound.name == "Exception"
assert not class_.type_parameters[0].constraints
assert class_.type_parameters[0].default is None
func = module["func"]
assert func.is_function
assert func.type_parameters[0].name == "P"
assert func.type_parameters[0].kind == TypeParameterKind.param_spec
assert func.type_parameters[0].bound is None
assert not func.type_parameters[0].constraints
assert func.type_parameters[0].default is None
assert func.type_parameters[1].name == "T"
assert func.type_parameters[1].kind == TypeParameterKind.type_var
assert func.type_parameters[1].bound is None
assert not func.type_parameters[1].constraints
assert func.type_parameters[1].default is None
assert func.type_parameters[2].name == "R"
assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple
assert func.type_parameters[2].bound is None
assert not func.type_parameters[2].constraints
assert func.type_parameters[2].default is None
type_alias = module["TA"]
assert type_alias.is_type_alias
assert type_alias.type_parameters[0].name == "T"
assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var
assert type_alias.type_parameters[0].bound is None
assert type_alias.type_parameters[0].constraints[0].name == "int"
assert type_alias.type_parameters[0].constraints[1].name == "str"
assert type_alias.type_parameters[0].default is None
assert isinstance(type_alias.value, Expr)
assert str(type_alias.value) == "dict[str, T]"
# YORE: EOL 3.12: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip
def test_parse_pep695_generics() -> None:
"""Assert PEP 695 generics are correctly parsed."""
with temporary_visited_module(
"""
class Class[X: Exception = OSError]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str) = str] = dict[str, T]
""",
) as module:
class_ = module["Class"]
assert class_.is_class
assert class_.type_parameters[0].name == "X"
assert class_.type_parameters[0].kind == TypeParameterKind.type_var
assert class_.type_parameters[0].bound.name == "Exception"
assert not class_.type_parameters[0].constraints
assert class_.type_parameters[0].default.name == "OSError"
func = module["func"]
assert func.is_function
assert func.type_parameters[0].name == "P"
assert func.type_parameters[0].kind == TypeParameterKind.param_spec
assert func.type_parameters[0].bound is None
assert not func.type_parameters[0].constraints
assert func.type_parameters[0].default is None
assert func.type_parameters[1].name == "T"
assert func.type_parameters[1].kind == TypeParameterKind.type_var
assert func.type_parameters[1].bound is None
assert not func.type_parameters[1].constraints
assert func.type_parameters[1].default is None
assert func.type_parameters[2].name == "R"
assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple
assert func.type_parameters[2].bound is None
assert not func.type_parameters[2].constraints
assert func.type_parameters[2].default is None
type_alias = module["TA"]
assert type_alias.is_type_alias
assert type_alias.type_parameters[0].name == "T"
assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var
assert type_alias.type_parameters[0].bound is None
assert type_alias.type_parameters[0].constraints[0].name == "int"
assert type_alias.type_parameters[0].constraints[1].name == "str"
assert type_alias.type_parameters[0].default.name == "str"
assert isinstance(type_alias.value, Expr)
assert str(type_alias.value) == "dict[str, T]"
python-griffe-2.3.0/packages/griffelib/tests/test_expressions.py 0000664 0001750 0001750 00000051721 15246557273 025051 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Test names and expressions methods.
from __future__ import annotations
import ast
import sys
import pytest
from griffe import ExprAwait, Module, Parser, get_expression, temporary_visited_module
from tests.test_nodes import syntax_examples
@pytest.mark.parametrize(
("annotation", "items"),
[
("tuple[int, float] | None", 2),
("None | tuple[int, float]", 2),
("Optional[tuple[int, float]]", 2),
("typing.Optional[tuple[int, float]]", 2),
],
)
def test_explode_return_annotations(annotation: str, items: int) -> None:
"""Check that we correctly split items from return annotations.
Parameters:
annotation: The return annotation.
items: The number of items to write in the docstring returns section.
"""
newline = "\n "
returns = newline.join(f"x{_}: Some value." for _ in range(items))
code = f"""
import typing
from typing import Optional
def function() -> {annotation}:
'''This function returns either two ints or None
Returns:
{returns}
'''
"""
with temporary_visited_module(code) as module:
sections = module["function"].docstring.parse(Parser.google)
assert sections[1].value
@pytest.mark.parametrize(
"annotation",
[
"int",
"tuple[int]",
"dict[str, str]",
"Optional[tuple[int, float]]",
],
)
def test_full_expressions(annotation: str) -> None:
"""Assert we can transform expressions to their full form without errors."""
code = f"x: {annotation}"
with temporary_visited_module(code) as module:
assert str(module["x"].annotation) == annotation
def test_resolving_full_names() -> None:
"""Assert expressions are correctly transformed to their fully-resolved form."""
with temporary_visited_module(
"""
from package import module
attribute1: module.Class
from package import module as mod
attribute2: mod.Class
""",
) as module:
assert module["attribute1"].annotation.canonical_path == "package.module.Class"
assert module["attribute2"].annotation.canonical_path == "package.module.Class"
@pytest.mark.parametrize("code", syntax_examples)
def test_expressions(code: str) -> None:
"""Test building annotations from AST nodes.
Parameters:
code: An expression (parametrized).
"""
top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2)
expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute]
assert str(expression) == code
@pytest.mark.parametrize(
("source", "expected"),
[
("await dependency()", "await dependency()"),
("(await dependency()).attr", "(await dependency()).attr"),
("(await dependency())()", "(await dependency())()"),
("await dependency() ** exponent", "await dependency() ** exponent"),
("-await dependency()", "-await dependency()"),
("await (dependency() ** exponent)", "await (dependency() ** exponent)"),
("await (await dependency())", "await (await dependency())"),
("await (left + right)", "await (left + right)"),
],
)
def test_await_expression(source: str, expected: str) -> None:
"""Build Await expressions with their correct precedence."""
node = ast.parse(source, mode="eval").body
expression = get_expression(node, parent=Module("module"))
rendered = str(expression)
if isinstance(node, ast.Await):
assert isinstance(expression, ExprAwait)
assert rendered == expected
assert ast.dump(ast.parse(rendered, mode="eval").body) == ast.dump(node)
# Expressions from each precedence level, and contexts embedding them.
# Not all combinations are valid Python: invalid ones are skipped.
_expression_shapes = [
"x",
"a.b",
"'s'",
"x + 1",
"a | b",
"-x",
"not x",
"a or b",
"a < b",
"x ** 2",
"a if b else c",
"lambda: 1",
"(w := 1)",
"(g for g in y)",
"[g for g in y]",
"{g: g for g in y}",
"f(1)",
"o[1]",
"(1, 2)",
"[1, 2]",
"{1: 2}",
"{1, 2}",
"f'{x!r}'",
"f'{x:>{width}}'",
]
_expression_contexts = [
"f(%s)",
"f(%s, 1)",
"f(a=%s)",
"f(*%s)",
"f(**%s)",
"[%s, 1]",
"{%s, 1}",
"(%s, 1)",
"{%s: 1}",
"{1: %s}",
"o[%s]",
"o[%s:1]",
"o[1:%s]",
"o[1:1:%s]",
"[%s for u in y]",
"[u for u in %s]",
"[u for u in y if %s]",
"(%s for u in y)",
"lambda p=%s: p",
"%s + 1",
"-%s",
"%s.m",
"%s()",
"%s if c else d",
"f'{%s}'",
"f'{v:{%s}}'",
"[(nv := %s)]",
]
@pytest.mark.parametrize("shape", _expression_shapes)
@pytest.mark.parametrize("context", _expression_contexts)
def test_expressions_stay_valid_and_equivalent(shape: str, context: str) -> None:
"""Stringified expressions must re-parse as valid, semantically-equivalent Python.
Parameters:
shape: An expression to embed in each code context.
context: A code template embedding the expression.
"""
code = context % shape
try:
original = ast.parse(code, mode="eval")
except SyntaxError:
pytest.skip("shape is invalid in this context")
expression = get_expression(original.body, parent=Module("module"), parse_strings=False)
rendered = str(expression)
reparsed = ast.parse(rendered, mode="eval") # Output must be valid Python.
assert ast.dump(reparsed) == ast.dump(original), f"{code!r} rendered as {rendered!r}"
def test_nested_container_format_spec_is_roundtrip_safe() -> None:
"""Container replacement fields in format specs must preserve the source AST."""
code = "f'{value:{{1, 2}}}'"
original = ast.parse(code, mode="eval")
expression = get_expression(original.body, parent=Module("module"), parse_strings=False)
rendered = str(expression)
reparsed = ast.parse(rendered, mode="eval")
assert ast.dump(reparsed) == ast.dump(original), f"{code!r} rendered as {rendered!r}"
@pytest.mark.parametrize(
"code",
[
"o[[(x, y)]]",
"o[f((x, y))]",
"o[[(x, y) for x in xs for y in ys]]",
"[(x, y) for [x, (y, z)] in values]",
],
)
def test_nested_tuples_keep_parentheses_in_implicit_contexts(code: str) -> None:
"""Implicit-tuple contexts must only affect the immediate tuple node."""
original = ast.parse(code, mode="eval")
expression = get_expression(original.body, parent=Module("module"), parse_strings=False)
rendered = str(expression)
reparsed = ast.parse(rendered, mode="eval")
assert ast.dump(reparsed) == ast.dump(original), f"{code!r} rendered as {rendered!r}"
@pytest.mark.parametrize(
("code", "expected"),
[
("o[{(x, y)}]", "o[{(x, y)}]"),
("o[{(x, y): value}]", "o[{(x, y): value}]"),
("o[{(x, y): value for value in values}]", "o[{(x, y): value for value in values}]"),
("o[{(x, y) for value in values}]", "o[{(x, y) for value in values}]"),
("o[p[(x, y)]]", "o[p[x, y]]"),
("o[p[(x, y):]]", "o[p[(x, y):]]"),
("o[f(arg=(x, y))]", "o[f(arg=(x, y))]"),
("o[((x, y)).attribute]", "o[(x, y).attribute]"),
("o[(x, y) + z]", "o[(x, y) + z]"),
("o[not (x, y)]", "o[not (x, y)]"),
("o[(x, y) and z]", "o[(x, y) and z]"),
("o[(x, y) == z]", "o[(x, y) == z]"),
("o[(x, y) if condition else z]", "o[(x, y) if condition else z]"),
("o[lambda: (x, y)]", "o[lambda: (x, y)]"),
("o[(item := (x, y))]", "o[item := (x, y)]"),
("o[f'{(x, y)}']", "o[f'{(x, y)}']"),
("o[((x, y) for value in values)]", "o[((x, y) for value in values)]"),
("o[await (x, y)]", "o[await (x, y)]"),
("[(x, y) for (x, (y, z)) in values]", "[(x, y) for x, (y, z) in values]"),
],
)
def test_only_root_tuples_are_implicit_in_tuple_contexts(code: str, expected: str) -> None:
"""Only a tuple at the root of a subscript slice or comprehension target is implicit."""
original = ast.parse(code, mode="eval")
expression = get_expression(original.body, parent=Module("module"), parse_strings=False)
rendered = str(expression)
assert rendered == expected
assert ast.dump(ast.parse(rendered, mode="eval")) == ast.dump(original)
@pytest.mark.parametrize(
("code", "expected"),
[
('o["x, y"]', "o[x, y]"),
('o["x,"]', "o[x,]"),
('o["()"]', "o[()]"),
('o[["x, y"]]', "o[[(x, y)]]"),
('o[f("x, y")]', "o[f((x, y))]"),
('o[p["x, y"]]', "o[p[x, y]]"),
('o[p[["x, y"]]]', "o[p[[(x, y)]]]"),
('typing.Literal["x, y"]', "typing.Literal['x, y']"),
],
)
def test_parsed_string_tuples_respect_tuple_contexts(code: str, expected: str) -> None:
"""Parsed strings inherit only their immediate tuple context."""
node = ast.parse(code, mode="eval")
expression = get_expression(node.body, parent=Module("module"), parse_strings=True)
assert str(expression) == expected
def test_length_one_tuple_as_string() -> None:
"""Length-1 tuples must have a trailing comma."""
code = "x = ('a',)"
with temporary_visited_module(code) as module:
assert str(module["x"].value) == "('a',)"
@pytest.mark.parametrize(
("annotation", "modernized"),
[
("Union[str, int, float]", "str | int | float"),
("typing.Union[str, int, float]", "str | int | float"),
("Union[Tuple[str, ...], Dict[str, int]]", "tuple[str, ...] | dict[str, int]"),
("typing.Union[typing.Tuple[str, ...], typing.Dict[str, int]]", "tuple[str, ...] | dict[str, int]"),
("Tuple[List[Dict[str, Set[str]]]]", "tuple[list[dict[str, set[str]]]]"),
("typing.Tuple[typing.List[typing.Dict[str, typing.Set[str]]]]", "tuple[list[dict[str, set[str]]]]"),
("Optional[Tuple[List[bool]]]", "tuple[list[bool]] | None"),
("typing.Optional[typing.Tuple[typing.List[bool]]]", "tuple[list[bool]] | None"),
],
)
def test_modernizing_specific_expressions(annotation: str, modernized: str) -> None:
"""Modernize expressions correctly.
Parameters:
annotation: Original annotation (parametrized).
modernized: Expected modernized annotation (parametrized).
"""
with temporary_visited_module(
f"""
import typing
from typing import Union, Optional, Tuple, Dict, List, Set, Literal
a: {annotation}
""",
) as module:
expression = module["a"].annotation
assert str(expression.modernize()) == modernized
@pytest.mark.parametrize(
"annotation",
[
"typing.Literal['s']",
"Literal['s']",
],
)
def test_handling_modernization_without_crashing(annotation: str) -> None:
"""Modernizing expressions never crashes.
Parameters:
annotation: Original annotation (parametrized).
"""
with temporary_visited_module(
f"""
import typing
from typing import Union, Optional, Tuple, Dict, List, Set, Literal
a: {annotation}
""",
) as module:
module["a"].annotation.modernize()
@pytest.mark.parametrize("code", syntax_examples)
def test_modernizing_idempotence(code: str) -> None:
"""Modernize expressions that can't be modernized.
Parameters:
code: An expression (parametrized).
"""
top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2)
expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute]
modernized = expression.modernize() # ty:ignore[unresolved-attribute]
assert expression == modernized
assert str(expression) == str(modernized)
def test_resolving_init_parameter() -> None:
"""Instance attribute values should resolve to matching parameters.
They must not resolve to the member of the same name in the same class,
or to objects with the same name in higher scopes.
"""
with temporary_visited_module(
"""
x = 1
class Class:
def __init__(self, x: int):
self.x: int = x
""",
) as module:
assert module["Class.x"].value.canonical_path == "module.Class(x)"
@pytest.mark.parametrize(
"code",
[
# Core.
"a * (b + c)", # Lower precedence as a sub-expression of one that has higher precedence.
"(a and b) == c",
"((a | b) + c).d",
"a - (b - c)", # Left-association.
"(a ** b) ** c", # Right-association.
# Unary operator and edge cases:
# > The power operator `**` binds less tightly than an arithmetic
# > or bitwise unary operator on its right, that is, `2**-1` is `0.5`.
"a ** -b",
"-a ** b",
"(-a) ** b",
# Misc: conditionals, lambdas, comprehensions and generators.
"(lambda: 0).a",
"(lambda x: a + x if b else c)(d).e",
"a if (b if c else d) else e", # Right-association.
"(a if b else c) if d else e", # Forced left-association.
"(a for a in b).c",
],
)
def test_parentheses_preserved(code: str) -> None:
"""Parentheses used to enforce an order of operations should not be removed."""
with temporary_visited_module(f"val = {code}") as module:
value_expr = module["val"].value
assert str(value_expr) == code
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics")
def test_resolving_type_parameters() -> None:
"""Assert type parameters are correctly transformed to their fully-resolved form."""
with temporary_visited_module(
"""
class C[T]:
class D[T]:
def func[Y](self, arg1: T, arg2: Y): pass
attr: T
def func[Z](arg1: T, arg2: Y): pass
""",
) as module:
assert module["C.D.func"].parameters["arg1"].annotation.canonical_path == "module.C.D[T]"
assert module["C.D.func"].parameters["arg2"].annotation.canonical_path == "module.C.D.func[Y]"
assert module["C.D.attr"].annotation.canonical_path == "module.C.D[T]"
assert module["C.func"].parameters["arg1"].annotation.canonical_path == "module.C[T]"
assert module["C.func"].parameters["arg2"].annotation.canonical_path == "Y"
@pytest.mark.parametrize(
("code", "expected"),
[
# Single quotes only in literal parts → double-quote delimiter.
('f"it\'s {x}"', 'f"it\'s {x}"'),
("f\"don't {x} won't {y}\"", "f\"don't {x} won't {y}\""),
# Double quotes only in literal parts → single-quote delimiter.
("f'say \"hello\" to {x}'", "f'say \"hello\" to {x}'"),
('f\'"open" and "close" around {x}\'', 'f\'"open" and "close" around {x}\''),
# Both quote types in literal parts → triple-single-quote delimiter.
(r"""f'it\'s "complicated" {x}'""", "f'''it's \"complicated\" {x}'''"),
(r"""f'she said "it\'s fine" to {x}'""", "f'''she said \"it's fine\" to {x}'''"),
(r"""f'can\'t stop, won\'t stop: "the {x} motto"'""", "f'''can't stop, won't stop: \"the {x} motto\"'''"),
# Literal braces must be doubled ({{/}}) in the output.
("f'{a} {{b}}'", "f'{a} {{b}}'"),
("f'{{opening}} {x} {{closing}}'", "f'{{opening}} {x} {{closing}}'"),
# String literals inside f-string expressions are never type annotations.
# The expression content drives delimiter choice (single → use double outer).
("f'{print(\"1\")}'", "f\"{print('1')}\""),
("f'{x + \"hello\"}'", "f\"{x + 'hello'}\""),
],
)
def test_fstring_quote_selection(code: str, expected: str) -> None:
"""ExprJoinedStr produces valid Python source for f-strings with tricky quote content.
Regression test for https://github.com/mkdocstrings/griffe/issues/444.
"""
top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2)
expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute]
assert str(expression) == expected
def test_fstring_conversions_and_format_specs() -> None:
"""Conversion flags and format specifiers must be preserved.
Regression test: they used to be dropped entirely (`f"{x!r:>10}"` rendered as `f'{x}'`).
"""
with temporary_visited_module(
"""
a = f"{x!r}"
b = f"{x:>10}"
c = f"{x:{width}.{precision}}"
d = f"{x!a:>{width}}"
e = f"{x:%Y-%m-%d}"
def func(param=f"{x!r:>3}"): ...
""",
) as module:
assert str(module["a"].value) == "f'{x!r}'"
assert str(module["b"].value) == "f'{x:>10}'"
assert str(module["c"].value) == "f'{x:{width}.{precision}}'"
assert str(module["d"].value) == "f'{x!a:>{width}}'"
assert str(module["e"].value) == "f'{x:%Y-%m-%d}'"
assert str(module["func"].parameters["param"].default) == "f'{x!r:>3}'"
@pytest.mark.skipif(sys.version_info < (3, 14), reason="t-strings require Python 3.14+")
def test_tstring_conversions_and_format_specs() -> None:
"""Same as f-strings: t-string interpolations must keep conversions, specs and quotes."""
with temporary_visited_module(
"""
a = t"{x!r}"
b = t"{x:>10}"
c = t"{x!s:>{width}}"
d = t"{'quoted'}"
""",
) as module:
assert str(module["a"].value) == "t'{x!r}'"
assert str(module["b"].value) == "t'{x:>10}'"
assert str(module["c"].value) == "t'{x!s:>{width}}'"
assert str(module["d"].value) == "t\"{'quoted'}\""
@pytest.mark.skipif(sys.version_info < (3, 14), reason="t-strings require Python 3.14+")
@pytest.mark.parametrize(
("code", "expected"),
[
('t"it\'s {x}"', 't"it\'s {x}"'),
("t'say \"hello\" to {x}'", "t'say \"hello\" to {x}'"),
(r"""t'it\'s "complicated" {x}'""", "t'''it's \"complicated\" {x}'''"),
],
)
def test_tstring_quote_selection(code: str, expected: str) -> None:
"""ExprTemplateStr produces valid Python source for t-strings with tricky quote content.
Regression test for https://github.com/mkdocstrings/griffe/issues/444.
"""
top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2)
expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute]
assert str(expression) == expected
def test_render_dict_comprehension() -> None:
"""Assert dict comprehensions are rendered correctly."""
with temporary_visited_module(
"""
d = {k: v for k, v in items if k}
""",
) as module:
assert str(module["d"].value) == "{k: v for k, v in items if k}"
def test_render_dict_with_unpacking() -> None:
"""Assert dictionaries using `**`-unpacking are rendered correctly.
The AST marks an unpacked value with a `None` key, which must render as
`**value` rather than as a literal `None` key. A genuine `None` literal key
(an `ast.Constant`, not the AST's unpacking marker) must be preserved.
"""
with temporary_visited_module(
"""
a = {**base, "x": 1}
b = {**d1, **d2}
c = {None: 1, "y": 2}
""",
) as module:
assert str(module["a"].value) == "{**base, 'x': 1}"
assert str(module["b"].value) == "{**d1, **d2}"
assert str(module["c"].value) == "{None: 1, 'y': 2}"
def test_empty_tuple_annotation_str() -> None:
"""Check that empty-tuple annotations round-trip correctly.
`tuple[()]` is a valid annotation for a zero-element tuple.
Its string representation must remain `tuple[()]`, not the
invalid `tuple[]`.
"""
with temporary_visited_module(
"""
from typing import Tuple
def f1() -> tuple[()]: ...
def f2() -> Tuple[()]: ...
""",
) as module:
assert not module["f1"].returns.slice.implicit
assert not module["f2"].returns.slice.implicit
assert str(module["f1"].returns) == "tuple[()]"
assert str(module["f2"].returns) == "Tuple[()]"
@pytest.mark.skipif(sys.version_info < (3, 15), reason="Unpackings in Comprehensions require Python 3.15+")
def test_render_dict_comprehension_with_unpacking() -> None:
"""Docstring stub."""
with temporary_visited_module(
"""
a = {**d for d in dicts}
""",
) as module:
assert str(module["a"].value) == "{**d for d in dicts}"
python-griffe-2.3.0/packages/griffelib/tests/test_finder.py 0000664 0001750 0001750 00000031427 15246557273 023737 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the `finder` module.
from __future__ import annotations
import os
from pathlib import Path
from textwrap import dedent
import pytest
from griffe import Module, ModuleFinder, NamespacePackage, Package, temporary_pypackage
from griffe._internal.finder import _handle_editable_module, _handle_pth_file
@pytest.mark.parametrize(
("pypackage", "module", "add_to_search_path", "expected_top_name", "expected_top_path"),
[
(("a", ["b.py"]), "a/b.py", True, "a", "a/__init__.py"),
(("a", ["b.py"]), "a/b.py", False, "a", "a/__init__.py"),
(("a/b", ["c.py"]), "a/b/c.py", True, "a", "a"),
(("a/b", ["c.py"]), "a/b/c.py", False, "b", "a/b/__init__.py"),
],
)
def test_find_module_with_path(
pypackage: tuple[str, list[str]],
module: str,
add_to_search_path: bool,
expected_top_name: str,
expected_top_path: str,
) -> None:
"""Check that the finder can find modules using strings and Paths.
Parameters:
pypackage: A temporary package (metadata) on the file system (parametrized).
module: The module path to load (parametrized).
add_to_search_path: Whether to add the temporary package parent path to the finder search paths (parametrized).
expected_top_name: Expected top module name (parametrized).
expected_top_path: Expected top module path (parametrized).
"""
with temporary_pypackage(*pypackage) as tmp_package:
finder = ModuleFinder(search_paths=[tmp_package.tmpdir] if add_to_search_path else None)
_, package = finder.find_spec(tmp_package.tmpdir / module)
assert package.name == expected_top_name
if isinstance(package, NamespacePackage):
assert package.path == [tmp_package.tmpdir / expected_top_path]
else:
assert package.path == tmp_package.tmpdir / expected_top_path
@pytest.mark.parametrize(
"statement",
[
"__import__('pkg_resources').declare_namespace(__name__)",
"__path__ = __import__('pkgutil').extend_path(__path__, __name__)",
],
)
def test_find_pkg_style_namespace_packages(statement: str) -> None:
"""Check that the finder can find pkg-style namespace packages.
Parameters:
statement: The statement in the `__init__` module allowing to mark the package as namespace.
"""
with (
temporary_pypackage("namespace/package1") as tmp_package1,
temporary_pypackage("namespace/package2") as tmp_package2,
):
tmp_package1.path.parent.joinpath("__init__.py").write_text(statement, encoding="utf8")
tmp_package2.path.parent.joinpath("__init__.py").write_text(statement, encoding="utf8")
finder = ModuleFinder(search_paths=[tmp_package1.tmpdir, tmp_package2.tmpdir])
_, package = finder.find_spec("namespace")
assert package.name == "namespace"
assert isinstance(package, NamespacePackage)
assert package.path == [tmp_package1.path.parent, tmp_package2.path.parent]
@pytest.mark.skipif(not Path("packages").exists(), reason="not running from monorepo root")
def test_pth_file_handling(tmp_path: Path) -> None:
"""Assert .pth files are correctly handled.
Parameters:
tmp_path: Pytest fixture.
"""
pth_file = tmp_path / "hello.pth"
pth_file.write_text(
dedent(
"""
# comment
import thing
import\tthing
/doesnotexist
packages/griffelib/tests
""",
),
encoding="utf8",
)
paths = [sp.path for sp in _handle_pth_file(pth_file)]
assert paths == [Path("packages/griffelib/tests")]
@pytest.mark.skipif(not Path("packages").exists(), reason="not running from monorepo root")
def test_pth_file_handling_with_semi_colon(tmp_path: Path) -> None:
"""Assert .pth files are correctly handled.
Parameters:
tmp_path: Pytest fixture.
"""
pth_file = tmp_path / "hello.pth"
pth_file.write_text(
dedent(
"""
# comment
import thing; import\tthing; /doesnotexist; packages/griffelib/tests
""",
),
encoding="utf8",
)
paths = [sp.path for sp in _handle_pth_file(pth_file)]
assert paths == [Path("packages/griffelib/tests")]
@pytest.mark.parametrize("editable_file_name", ["__editables_whatever.py", "_editable_impl_whatever.py"])
def test_editables_file_handling(tmp_path: Path, editable_file_name: str) -> None:
"""Assert editable modules by `editables` are handled.
Parameters:
tmp_path: Pytest fixture.
"""
pth_file = tmp_path / editable_file_name
pth_file.write_text("hello\nF.map_module('griffe', 'packages/griffelib/src/griffe/__init__.py')", encoding="utf8")
paths = [sp.path for sp in _handle_editable_module(pth_file)]
assert paths == [Path("packages/griffelib/src")]
@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"])
def test_setuptools_file_handling(tmp_path: Path, annotation: str) -> None:
"""Assert editable modules by `setuptools` are handled.
Parameters:
tmp_path: Pytest fixture.
annotation: The type annotation of the MAPPING variable.
"""
pth_file = tmp_path / "__editable__whatever.py"
pth_file.write_text(f"hello\nMAPPING{annotation} = {{'griffe': 'src/griffe'}}", encoding="utf8")
paths = [sp.path for sp in _handle_editable_module(pth_file)]
assert paths == [Path("src")]
@pytest.mark.parametrize("annotation", ["", ": dict[str, str]"])
def test_setuptools_file_handling_multiple_paths(tmp_path: Path, annotation: str) -> None:
"""Assert editable modules by `setuptools` are handled when multiple packages are installed in the same editable.
Parameters:
tmp_path: Pytest fixture.
annotation: The type annotation of the MAPPING variable.
"""
pth_file = tmp_path / "__editable__whatever.py"
pth_file.write_text(
"hello=1\n"
f"MAPPING{annotation} = {{\n'griffe':\n 'src1/griffe', 'briffe':'src2/briffe'}}\n"
"def printer():\n print(hello)",
encoding="utf8",
)
paths = [sp.path for sp in _handle_editable_module(pth_file)]
assert paths == [Path("src1"), Path("src2")]
def test_scikit_build_core_file_handling(tmp_path: Path) -> None:
"""Assert editable modules by `scikit-build-core` are handled.
Parameters:
tmp_path: Pytest fixture.
"""
pth_file = tmp_path / "_whatever_editable.py"
pth_file.write_text(
"hello=1\ninstall({'whatever': '/path/to/whatever'}, {'whatever.else': '/else'}, None, False, True)",
encoding="utf8",
)
# The second dict is not handled: scikit-build-core puts these files
# in a location that Griffe won't be able to discover anyway
# (they don't respect standard package or namespace package layouts,
# and rely on dynamic meta path finder stuff).
paths = [sp.path for sp in _handle_editable_module(pth_file)]
assert paths == [Path("/path/to/whatever")]
@pytest.mark.skipif(not Path("packages").exists(), reason="not running from monorepo root")
def test_meson_python_file_handling(tmp_path: Path) -> None:
"""Assert editable modules by `meson-python` are handled.
Parameters:
tmp_path: Pytest fixture.
"""
pth_file = tmp_path / "_whatever_editable_loader.py"
pth_file.write_text(
# The path in argument 2 suffixed with src must exist, so we pass `packages/griffelib`.
"hello=1\ninstall({'griffe', 'hello'}, 'packages/griffelib', ['/tmp/ninja'], False)",
encoding="utf8",
)
search_paths = _handle_editable_module(pth_file)
assert all(sp.always_scan_for == "griffe" for sp in search_paths)
paths = [sp.path for sp in search_paths]
assert paths == [Path("packages/griffelib/src")]
@pytest.mark.parametrize(
("first", "second", "find_stubs", "expect"),
[
("package", "stubs", True, "both"),
("stubs", "package", True, "both"),
("package", None, True, "package"),
(None, "package", True, "package"),
("stubs", None, True, "stubs"),
(None, "stubs", True, "stubs"),
(None, None, True, "none"),
("package", "stubs", False, "package"),
("stubs", "package", False, "package"),
("package", None, False, "package"),
(None, "package", False, "package"),
("stubs", None, False, "none"),
(None, "stubs", False, "none"),
(None, None, False, "none"),
],
)
def test_finding_stubs_packages(
tmp_path: Path,
first: str | None,
second: str | None,
find_stubs: bool,
expect: str,
) -> None:
"""Find stubs-only packages.
Parameters:
tmp_path: Pytest fixture.
"""
search_path1 = tmp_path / "sp1"
search_path2 = tmp_path / "sp2"
search_path1.mkdir()
search_path2.mkdir()
if first == "package":
package = search_path1 / "package"
package.mkdir()
package.joinpath("__init__.py").touch()
elif first == "stubs":
stubs = search_path1 / "package-stubs"
stubs.mkdir()
stubs.joinpath("__init__.pyi").touch()
if second == "package":
package = search_path2 / "package"
package.mkdir()
package.joinpath("__init__.py").touch()
elif second == "stubs":
stubs = search_path2 / "package-stubs"
stubs.mkdir()
stubs.joinpath("__init__.pyi").touch()
finder = ModuleFinder([search_path1, search_path2])
if expect == "none":
with pytest.raises(ModuleNotFoundError):
finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs)
return
name, result = finder.find_spec("package", try_relative_path=False, find_stubs_package=find_stubs)
assert name == "package"
if expect == "both":
assert isinstance(result, Package)
assert result.path.suffix == ".py"
assert not result.path.parent.name.endswith("-stubs")
assert result.stubs
assert result.stubs.suffix == ".pyi"
assert result.stubs.parent.name.endswith("-stubs")
elif expect == "package":
assert isinstance(result, Package)
assert result.path.suffix == ".py"
assert not result.path.parent.name.endswith("-stubs")
assert result.stubs is None
elif expect == "stubs":
assert isinstance(result, Package)
assert result.path.suffix == ".pyi"
assert result.path.parent.name.endswith("-stubs")
assert result.stubs is None
@pytest.mark.parametrize("namespace_package", [False, True])
def test_scanning_package_and_module_with_same_names(namespace_package: bool) -> None:
"""The finder correctly scans package and module having same the name.
Parameters:
namespace_package: Whether the temporary package is a namespace one.
"""
init = not namespace_package
with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=init, inits=init) as tmp_package:
# Here we must make sure that all paths are relative
# to correctly assert the finder's behavior,
# so we pass `.` and actually enter the temporary directory.
path = Path(tmp_package.name)
filepath: Path | list[Path] = [path] if namespace_package else path
old = Path.cwd()
os.chdir(tmp_package.path.parent)
try:
finder = ModuleFinder(search_paths=[])
found = [path for _, path in finder.submodules(Module("pkg", filepath=filepath))]
finally:
os.chdir(old)
check = (path / "pkg/mod.py", path / "mod/mod.py")
for mod in check:
assert mod in found
def test_not_finding_namespace_package_twice() -> None:
"""Deduplicate paths when finding namespace packages."""
with temporary_pypackage("pkg", ["pkg/mod.py", "mod/mod.py"], init=False, inits=False) as tmp_package:
old = Path.cwd()
os.chdir(tmp_package.tmpdir)
try:
finder = ModuleFinder(search_paths=[Path(), tmp_package.tmpdir])
found = finder.find_package("pkg")
finally:
os.chdir(old)
assert isinstance(found, NamespacePackage)
assert len(found.path) == 1
python-griffe-2.3.0/packages/griffelib/tests/test_diff.py 0000664 0001750 0001750 00000016701 15246557273 023376 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the `diff` module.
from __future__ import annotations
import pytest
from griffe import Breakage, BreakageKind, find_breaking_changes, temporary_visited_module, temporary_visited_package
@pytest.mark.parametrize(
("old_code", "new_code", "expected_breakages"),
[
(
"a = True",
"a = False",
[BreakageKind.ATTRIBUTE_CHANGED_VALUE],
),
(
"class a(int, str): ...",
"class a(int): ...",
[BreakageKind.CLASS_REMOVED_BASE],
),
(
"a = 0",
"class a: ...",
[BreakageKind.OBJECT_CHANGED_KIND],
),
(
"a = True",
"",
[BreakageKind.OBJECT_REMOVED],
),
(
"def a(): ...",
"def a(x): ...",
[BreakageKind.PARAMETER_ADDED_REQUIRED],
),
(
"def a(x=0): ...",
"def a(x=1): ...",
[BreakageKind.PARAMETER_CHANGED_DEFAULT],
),
(
# Positional-only to keyword-only.
"def a(x, /): ...",
"def a(*, x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Keyword-only to positional-only.
"def a(*, x): ...",
"def a(x, /): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Positional or keyword to positional-only.
"def a(x): ...",
"def a(x, /): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Positional or keyword to keyword-only.
"def a(x): ...",
"def a(*, x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
# To variadic positional.
(
# Positional-only to variadic positional.
"def a(x, /): ...",
"def a(*x): ...",
[],
),
(
# Positional or keyword to variadic positional.
"def a(x): ...",
"def a(*x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Keyword-only to variadic positional.
"def a(*, x): ...",
"def a(*x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Variadic keyword to variadic positional.
"def a(**x): ...",
"def a(*x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Positional or keyword to variadic positional, with variadic keyword.
"def a(x): ...",
"def a(*x, **y): ...",
[],
),
(
# Keyword-only to variadic positional, with variadic keyword.
"def a(*, x): ...",
"def a(*x, **y): ...",
[],
),
# To variadic keyword.
(
# Positional-only to variadic keyword.
"def a(x, /): ...",
"def a(**x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Positional or keyword to variadic keyword.
"def a(x): ...",
"def a(**x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Keyword-only to variadic keyword.
"def a(*, x): ...",
"def a(**x): ...",
[],
),
(
# Variadic positional to variadic keyword.
"def a(*x): ...",
"def a(**x): ...",
[BreakageKind.PARAMETER_CHANGED_KIND],
),
(
# Positional-only to variadic keyword, with variadic positional.
"def a(x, /): ...",
"def a(*y, **x): ...",
[],
),
(
# Positional or keyword to variadic keyword, with variadic positional.
"def a(x): ...",
"def a(*y, **x): ...",
[],
),
(
"def a(x=1): ...",
"def a(x): ...",
[BreakageKind.PARAMETER_CHANGED_REQUIRED],
),
(
"def a(x, y): ...",
"def a(y, x): ...",
[BreakageKind.PARAMETER_MOVED, BreakageKind.PARAMETER_MOVED],
),
(
"def a(x, y): ...",
"def a(x): ...",
[BreakageKind.PARAMETER_REMOVED],
),
(
"class a:\n\tb: int | None = None",
"class a:\n\tb: int",
[BreakageKind.ATTRIBUTE_CHANGED_VALUE],
),
(
"def a() -> int: ...",
"def a() -> str: ...",
[], # Not supported yet: `BreakageKind.RETURN_CHANGED_TYPE`.
),
],
)
def test_diff_griffe(old_code: str, new_code: str, expected_breakages: list[Breakage]) -> None:
"""Test the different incompatibility finders.
Parameters:
old_code: Parametrized code of the old module version.
new_code: Parametrized code of the new module version.
expected_breakages: A list of breakage kinds to expect.
"""
# Check without any alias.
with temporary_visited_module(old_code) as old_package, temporary_visited_module(new_code) as new_package:
breaking = list(find_breaking_changes(old_package, new_package))
assert len(breaking) == len(expected_breakages)
for breakage, expected_kind in zip(breaking, expected_breakages, strict=False):
assert breakage.kind is expected_kind
# Check with aliases.
import_a = "from ._mod_a import a\n__all__ = ['a']"
old_modules = {"__init__.py": import_a, "_mod_a.py": old_code}
new_modules = {"__init__.py": new_code and import_a, "_mod_a.py": new_code}
with temporary_visited_package("package_old", old_modules) as old_package: # noqa: SIM117
with temporary_visited_package("package_new", new_modules) as new_package:
breaking = list(find_breaking_changes(old_package, new_package))
assert len(breaking) == len(expected_breakages)
for breakage, expected_kind in zip(breaking, expected_breakages, strict=False):
assert breakage.kind is expected_kind
def test_moving_members_in_parent_classes() -> None:
"""Test that moving an object from a base class to a parent class doesn't trigger a breakage."""
old_code = """
class Parent:
...
class Base(Parent):
def method(self):
...
"""
new_code = """
class Parent:
def method(self):
...
class Base(Parent):
...
"""
with temporary_visited_module(old_code) as old_package, temporary_visited_module(new_code) as new_package:
assert not list(find_breaking_changes(old_package, new_package))
python-griffe-2.3.0/packages/griffelib/tests/test_inspector.py 0000664 0001750 0001750 00000033611 15246557273 024473 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Test inspection mechanisms.
from __future__ import annotations
import sys
import pytest
from griffe import (
Expr,
TypeParameterKind,
inspect,
temporary_inspected_module,
temporary_inspected_package,
temporary_pypackage,
)
from tests.helpers import clear_sys_modules
def test_annotations_from_builtin_types() -> None:
"""Assert builtin types are correctly transformed to annotations."""
with temporary_inspected_module("def func(a: int) -> str: pass") as module:
func = module["func"]
assert func.parameters[0].name == "a"
assert func.parameters[0].annotation.name == "int"
assert func.returns.name == "str"
def test_annotations_from_classes() -> None:
"""Assert custom classes are correctly transformed to annotations."""
with temporary_inspected_module("class A: pass\ndef func(a: A) -> A: pass") as module:
func = module["func"]
assert func.parameters[0].name == "a"
param = func.parameters[0].annotation
assert param.name == "A"
assert param.canonical_path == f"{module.name}.A"
returns = func.returns
assert returns.name == "A"
assert returns.canonical_path == f"{module.name}.A"
# YORE: EOL 3.13: Remove block.
@pytest.mark.skipif(sys.version_info >= (3, 14), reason="3.14 changes type annotations, see test below")
@pytest.mark.parametrize(
("annotation", "expected"),
[
("tuple[int, str]", "tuple[int, str]"),
("Union[int, str]", "typing.Union[int, str]"),
("int | str", "int | str"),
("int | Literal[1]", "typing.Union[int, typing.Literal[1]]"),
],
)
def test_annotations_from_types_before_314(annotation: str, expected: str) -> None:
"""Assert annotations are correctly converted to string."""
with temporary_inspected_module(
f"""
from typing import Literal, Union
def func(param: {annotation}): ...
""",
) as module:
param = module["func"].parameters["param"]
assert str(param.annotation) == expected
# YORE: EOL 3.13: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 14), reason="3.14 modernizes type unions")
@pytest.mark.parametrize(
("annotation", "expected"),
[
("tuple[int, str]", "tuple[int, str]"),
("Union[int, str]", "int | str"),
("int | str", "int | str"),
("int | Literal[1]", "int | typing.Literal[1]"),
],
)
def test_annotations_from_types(annotation: str, expected: str) -> None:
"""Assert annotations are correctly converted to string."""
with temporary_inspected_module(
f"""
from typing import Literal, Union
def func(param: {annotation}): ...
""",
) as module:
param = module["func"].parameters["param"]
assert str(param.annotation) == expected
def test_class_level_imports() -> None:
"""Assert annotations using class-level imports are resolved."""
with temporary_inspected_module(
"""
class A:
from io import StringIO
def method(self, p: StringIO):
pass
""",
) as module:
method = module["A.method"]
name = method.parameters["p"].annotation
assert name.name == "StringIO"
assert name.canonical_path == "io.StringIO"
def test_missing_dependency() -> None:
"""Assert missing dependencies are handled during dynamic imports."""
with (
pytest.raises(ImportError, match="ModuleNotFoundError: No module named 'missing'"),
temporary_inspected_module("import missing"),
):
pass
def test_inspect_properties_as_attributes() -> None:
"""Assert properties are created as attributes and not functions."""
with temporary_inspected_module(
"""
try:
from functools import cached_property
except ImportError:
from cached_property import cached_property
class C:
@property
def prop(self) -> bool:
return True
@cached_property
def cached_prop(self) -> int:
return 0
""",
) as module:
assert module["C.prop"].is_attribute
assert "property" in module["C.prop"].labels
assert module["C.cached_prop"].is_attribute
assert "cached" in module["C.cached_prop"].labels
def test_inspecting_module_importing_other_module() -> None:
"""Assert aliases to modules are correctly inspected and aliased."""
with temporary_inspected_module("import itertools as it") as module:
assert module["it"].is_alias
assert module["it"].target_path == "itertools"
def test_inspecting_parameters_with_functions_as_default_values() -> None:
"""Assert functions as default parameter values are serialized with their name."""
with temporary_inspected_module("def func(): ...\ndef other_func(f=func): ...") as module:
default = module["other_func"].parameters["f"].default
assert default == "func"
def test_inspecting_package_and_module_with_same_names() -> None:
"""Package and module having same name shouldn't cause issues."""
with temporary_inspected_package("package", {"package.py": "a = 0"}):
pass
def test_inspecting_module_with_submodules() -> None:
"""Inspecting a module shouldn't register any of its submodules if they're not imported."""
with temporary_pypackage("pkg", ["mod.py"]) as tmp_package:
pkg = inspect("pkg", filepath=tmp_package.path / "__init__.py")
assert "mod" not in pkg.members
clear_sys_modules("pkg")
def test_inspecting_module_with_imported_submodules() -> None:
"""When inspecting a package on the disk, direct submodules should be skipped entirely."""
with temporary_pypackage(
"pkg",
{
"__init__.py": "from pkg import subpkg\nfrom pkg.subpkg import mod",
"subpkg/__init__.py": "a = 0",
"subpkg/mod.py": "b = 0",
},
) as tmp_package:
pkg = inspect("pkg", filepath=tmp_package.path / "__init__.py")
assert "subpkg" not in pkg.members
assert "mod" in pkg.members
assert pkg["mod"].is_alias
assert pkg["mod"].target_path == "pkg.subpkg.mod"
clear_sys_modules("pkg")
def test_inspecting_objects_from_private_builtin_stdlib_moduless() -> None:
"""Inspect objects from private built-in modules in the standard library."""
ast = inspect("ast")
assert "Assign" in ast.members
assert not ast["Assign"].is_alias
ast = inspect("_ast")
assert "Assign" in ast.members
assert not ast["Assign"].is_alias
def test_inspecting_partials_as_functions() -> None:
"""Assert partials are correctly inspected as functions."""
with temporary_inspected_module(
"""
from functools import partial
def func(a: int, b: int) -> int: pass
partial_func = partial(func, 1)
partial_func.__module__ = __name__
""",
) as module:
partial_func = module["partial_func"]
assert partial_func.is_function
assert partial_func.parameters[0].name == "b"
assert partial_func.parameters[0].annotation.name == "int"
assert partial_func.returns.name == "int"
def test_inspecting_class_instance() -> None:
"""Assert class instances are correctly inspected."""
with temporary_inspected_package(
"pkg",
{
"__init__.py": "",
"foo.py": "from . import bar\nx = bar.X()",
"bar.py": "class X: pass",
},
) as tmp_package:
assert not tmp_package["foo.x"].is_alias
def test_inheriting_self_from_parent_class() -> None:
"""Inspect self only once when inheriting it from parent class."""
with temporary_inspected_module(
"""
class A: ...
class B(A): ...
A.B = B
""",
) as module:
assert "B" in module["A"].members
assert "B" in module["B"].all_members
# Continue indefinitely.
assert "B" in module["A.B"].all_members
assert "B" in module["B.B"].all_members
assert "B" in module["A.B.B"].all_members
assert "B" in module["B.B.B"].all_members
# All resolve to A.B.
assert module["A.B.B"].final_target is module["A.B"]
assert module["B.B.B"].final_target is module["A.B"]
assert module["A.B.B.B"].final_target is module["A.B"]
assert module["B.B.B.B"].final_target is module["A.B"]
# YORE: EOL 3.12: Remove block.
# YORE: EOL 3.11: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Python less than 3.12 does not have PEP 695 generics")
def test_inspecting_pep695_generics_without_defaults() -> None:
"""Assert PEP 695 generics are correctly inspected."""
with temporary_inspected_module(
"""
class Class[X: Exception]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str)] = dict[str, T]
""",
) as module:
class_ = module["Class"]
assert class_.is_class
assert class_.type_parameters[0].name == "X"
assert class_.type_parameters[0].kind == TypeParameterKind.type_var
assert class_.type_parameters[0].bound.name == "Exception"
assert not class_.type_parameters[0].constraints
assert class_.type_parameters[0].default is None
func = module["func"]
assert func.is_function
assert func.type_parameters[0].name == "P"
assert func.type_parameters[0].kind == TypeParameterKind.param_spec
assert func.type_parameters[0].bound is None
assert not func.type_parameters[0].constraints
assert func.type_parameters[0].default is None
assert func.type_parameters[1].name == "T"
assert func.type_parameters[1].kind == TypeParameterKind.type_var
assert func.type_parameters[1].bound is None
assert not func.type_parameters[1].constraints
assert func.type_parameters[1].default is None
assert func.type_parameters[2].name == "R"
assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple
assert func.type_parameters[2].bound is None
assert not func.type_parameters[2].constraints
assert func.type_parameters[2].default is None
type_alias = module["TA"]
assert type_alias.is_type_alias
assert type_alias.type_parameters[0].name == "T"
assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var
assert type_alias.type_parameters[0].bound is None
assert type_alias.type_parameters[0].constraints[0].name == "int"
assert type_alias.type_parameters[0].constraints[1].name == "str"
assert type_alias.type_parameters[0].default is None
assert isinstance(type_alias.value, Expr)
assert str(type_alias.value) == "dict[str, T]"
# YORE: EOL 3.12: Remove line.
@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python less than 3.13 does not have defaults in PEP 695 generics") # fmt: skip
def test_inspecting_pep695_generics() -> None:
"""Assert PEP 695 generics are correctly inspected."""
with temporary_inspected_module(
"""
class Class[X: Exception = OSError]: pass
def func[**P, T, *R](arg: T, *args: P.args, **kwargs: P.kwargs) -> tuple[*R]: pass
type TA[T: (int, str) = str] = dict[str, T]
""",
) as module:
class_ = module["Class"]
assert class_.is_class
assert class_.type_parameters[0].name == "X"
assert class_.type_parameters[0].kind == TypeParameterKind.type_var
assert class_.type_parameters[0].bound.name == "Exception"
assert not class_.type_parameters[0].constraints
assert class_.type_parameters[0].default.name == "OSError"
func = module["func"]
assert func.is_function
assert func.type_parameters[0].name == "P"
assert func.type_parameters[0].kind == TypeParameterKind.param_spec
assert func.type_parameters[0].bound is None
assert not func.type_parameters[0].constraints
assert func.type_parameters[0].default is None
assert func.type_parameters[1].name == "T"
assert func.type_parameters[1].kind == TypeParameterKind.type_var
assert func.type_parameters[1].bound is None
assert not func.type_parameters[1].constraints
assert func.type_parameters[1].default is None
assert func.type_parameters[2].name == "R"
assert func.type_parameters[2].kind == TypeParameterKind.type_var_tuple
assert func.type_parameters[2].bound is None
assert not func.type_parameters[2].constraints
assert func.type_parameters[2].default is None
type_alias = module["TA"]
assert type_alias.is_type_alias
assert type_alias.type_parameters[0].name == "T"
assert type_alias.type_parameters[0].kind == TypeParameterKind.type_var
assert type_alias.type_parameters[0].bound is None
assert type_alias.type_parameters[0].constraints[0].name == "int"
assert type_alias.type_parameters[0].constraints[1].name == "str"
assert type_alias.type_parameters[0].default.name == "str"
assert isinstance(type_alias.value, Expr)
assert str(type_alias.value) == "dict[str, T]"
python-griffe-2.3.0/packages/griffelib/tests/test_public_api.py 0000664 0001750 0001750 00000003115 15246557273 024570 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for public API handling.
from griffe import temporary_visited_module
def test_not_detecting_imported_objects_as_public() -> None:
"""Imported objects not listed in `__all__` must not be considered public."""
with temporary_visited_module("from abc import ABC\ndef func(): ...") as module:
assert not module["ABC"].is_public
assert module["func"].is_public # Control case.
def test_detecting_dunder_attributes_as_public() -> None:
"""Dunder attributes (methods, etc.) must be considered public."""
with temporary_visited_module(
"""
def __getattr__(name): ...
class A:
def __init__(self): ...
""",
) as module:
assert module["__getattr__"].is_public
assert module["A.__init__"].is_public
python-griffe-2.3.0/packages/griffelib/tests/fixtures/ 0000775 0001750 0001750 00000000000 15246557273 022721 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/fixtures/_repo/ 0000775 0001750 0001750 00000000000 15246557273 024025 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/fixtures/_repo/v0.2.0/ 0000775 0001750 0001750 00000000000 15246557273 024650 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/fixtures/_repo/v0.2.0/my_module/ 0000775 0001750 0001750 00000000000 15246557273 026642 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/fixtures/_repo/v0.2.0/my_module/__init__.py 0000664 0001750 0001750 00000000026 15246557273 030751 0 ustar carsten carsten __version__ = "0.2.0"
python-griffe-2.3.0/packages/griffelib/tests/fixtures/_repo/v0.1.0/ 0000775 0001750 0001750 00000000000 15246557273 024647 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/fixtures/_repo/v0.1.0/my_module/ 0000775 0001750 0001750 00000000000 15246557273 026641 5 ustar carsten carsten python-griffe-2.3.0/packages/griffelib/tests/fixtures/_repo/v0.1.0/my_module/__init__.py 0000664 0001750 0001750 00000000026 15246557273 030750 0 ustar carsten carsten __version__ = "0.1.0"
python-griffe-2.3.0/packages/griffelib/tests/test_stdlib.py 0000664 0001750 0001750 00000004415 15246557273 023746 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Fuzzing on the standard library.
from __future__ import annotations
import sys
from contextlib import suppress
from typing import TYPE_CHECKING
import pytest
from griffe import GriffeLoader, LoadingError
if TYPE_CHECKING:
from collections.abc import Iterator
from griffe import Alias, Object
def _access_inherited_members(obj: Object | Alias) -> None:
try:
is_class = obj.is_class
except Exception: # noqa: BLE001
return
if is_class:
assert obj.inherited_members is not None
else:
for cls in obj.classes.values():
_access_inherited_members(cls)
@pytest.fixture(name="stdlib_loader", scope="session")
def fixture_stdlib_loader() -> Iterator[GriffeLoader]:
"""Yield a GriffeLoader instance.
During teardown, resolve aliases and access inherited members
to make sure that no exception is raised when computing MRO.
"""
loader = GriffeLoader(allow_inspection=False, store_source=False)
yield loader
loader.resolve_aliases(implicit=True, external=None)
for module in loader.modules_collection.members.values():
_access_inherited_members(module)
loader.stats()
@pytest.mark.parametrize("mod", sorted([m for m in getattr(sys, "stdlib_module_names", ()) if not m.startswith("_")]))
def test_fuzzing_on_stdlib(stdlib_loader: GriffeLoader, mod: str) -> None:
"""Run Griffe on the standard library."""
with suppress(ImportError, LoadingError):
stdlib_loader.load(mod)
python-griffe-2.3.0/packages/griffelib/tests/test_mixins.py 0000664 0001750 0001750 00000002324 15246557273 023771 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for the `mixins` module.
from __future__ import annotations
from griffe import module_vtree
def test_access_members_using_string_and_tuples() -> None:
"""Assert wa can access the same members with both strings and tuples."""
module = module_vtree("a.b.c.d")
assert module["b"] is module[("b",)]
assert module["b.c"] is module[("b", "c")]
assert module["b.c.d"] is module[("b", "c", "d")]
python-griffe-2.3.0/packages/griffelib/tests/test_api.py 0000664 0001750 0001750 00000023271 15246557273 023237 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for our own API exposition.
from __future__ import annotations
from collections import defaultdict
from fnmatch import fnmatch
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
import griffe
try:
import griffecli
except ModuleNotFoundError:
griffecli = None
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from types import ModuleType
from mkdocstrings import Inventory
TESTED_MODULES = (griffe, griffecli) if griffecli else (griffe,)
_test_all_modules = pytest.mark.parametrize("tested_module", TESTED_MODULES)
@pytest.fixture(name="inventory", scope="module")
def _fixture_inventory() -> Inventory:
mkdocstrings = pytest.importorskip("mkdocstrings")
inventory_file = Path(__file__).parent.parent / "site" / "objects.inv"
if not inventory_file.exists():
pytest.skip("The objects inventory is not available.")
with inventory_file.open("rb") as file:
return mkdocstrings.Inventory.parse_sphinx(file)
def _load_modules(*modules: ModuleType) -> griffe.GriffeLoader:
extensions = ["unpack_typeddict"]
try:
import griffe_inherited_docstrings # noqa: F401, PLC0415
extensions.append("griffe_inherited_docstrings")
except ImportError:
pass
loader = griffe.GriffeLoader(
extensions=griffe.load_extensions(*extensions),
)
for module in modules:
loader.load(module.__name__)
loader.resolve_aliases()
return loader
def _get_internal_api(module: ModuleType, loader: griffe.GriffeLoader | None = None) -> griffe.Module:
if loader is None:
loader = _load_modules(module)
return loader.modules_collection[module.__name__ + "._internal"]
def _get_reexported_names(module: ModuleType) -> Iterable[str]:
return getattr(module, "_REEXPORTED_EXTERNAL_API", ())
def _get_public_api(module: ModuleType, loader: griffe.GriffeLoader | None = None) -> griffe.Module:
if loader is None:
loader = _load_modules(module)
return loader.modules_collection[module.__name__]
def _yield_public_objects(
obj: griffe.Module | griffe.Class,
*,
modules: bool = False,
modulelevel: bool = True,
inherited: bool = False,
special: bool = False,
) -> Iterator[griffe.Object | griffe.Alias]:
for member in obj.all_members.values() if inherited else obj.members.values():
try:
if member.is_module:
if member.is_alias:
continue
if member.is_public:
if modules:
yield member
yield from _yield_public_objects(
member, # ty:ignore[invalid-argument-type]
modules=modules,
modulelevel=modulelevel,
inherited=inherited,
special=special,
)
elif member.is_public and (special or not member.is_special):
yield member
if member.is_class and not modulelevel:
yield from _yield_public_objects(
member, # ty:ignore[invalid-argument-type]
modules=modules,
modulelevel=False,
inherited=inherited,
special=special,
)
except (griffe.AliasResolutionError, griffe.CyclicAliasError):
continue
def _get_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
return list(_yield_public_objects(internal_api, modulelevel=True))
def _get_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True))
def test_alias_proxies() -> None:
"""The Alias class has all the necessary methods and properties."""
internal_api = _get_internal_api(griffe)
alias_members = set(internal_api["models.Alias"].all_members.keys())
for cls in (
internal_api["models.Module"],
internal_api["models.Class"],
internal_api["models.Function"],
internal_api["models.Attribute"],
):
for name in cls.all_members:
if not name.startswith("_") or name.startswith("__"):
assert name in alias_members
@_test_all_modules
def test_exposed_objects(tested_module: ModuleType) -> None:
"""All public objects in the internal API are exposed under `griffe`."""
modulelevel_internal_objects = _get_modulelevel_internal_objects(_get_internal_api(tested_module))
not_exposed = [
obj.path
for obj in modulelevel_internal_objects
if obj.name not in tested_module.__all__ or not hasattr(tested_module, obj.name)
]
assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed))
@_test_all_modules
def test_unique_names(tested_module: ModuleType) -> None:
"""All internal objects have unique names."""
modulelevel_internal_objects = _get_modulelevel_internal_objects(_get_public_api(tested_module))
names_to_paths = defaultdict(list)
for obj in modulelevel_internal_objects:
names_to_paths[obj.name].append(obj.path)
non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1]
assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique)
@_test_all_modules
def test_single_locations(tested_module: ModuleType) -> None:
"""All objects have a single public location."""
def _public_path(obj: griffe.Object | griffe.Alias) -> bool:
return obj.is_public and (obj.parent is None or _public_path(obj.parent))
public_api = _get_public_api(tested_module)
multiple_locations = {}
for obj_name in set(tested_module.__all__).difference(_get_reexported_names(tested_module)):
obj = public_api[obj_name]
if obj.aliases and (
public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)]
):
multiple_locations[obj.path] = public_aliases
assert not multiple_locations, "Multiple public locations:\n" + "\n".join(
f"{path}: {aliases}" for path, aliases in multiple_locations.items()
)
@_test_all_modules
def test_api_matches_inventory(inventory: Inventory, tested_module: ModuleType) -> None:
"""All public objects are added to the inventory."""
ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"}
ignore_names.update(_get_reexported_names(tested_module))
ignore_paths = {"griffe.DataclassesExtension.*", "griffe.UnpackTypedDictExtension.*"}
loader = _load_modules(tested_module)
public_api = _get_public_api(tested_module, loader=loader)
public_objects = _get_public_objects(public_api)
not_in_inventory = [
f"{obj.relative_filepath}:{obj.lineno}: {obj.path}"
for obj in public_objects
if (
obj.name not in ignore_names
and not any(fnmatch(obj.path, pat) for pat in ignore_paths)
and obj.path not in inventory
)
]
msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}"
assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory)))
def test_inventory_matches_api(inventory: Inventory) -> None:
"""The inventory doesn't contain any additional Python object."""
loader = _load_modules(*TESTED_MODULES)
not_in_api = []
public_objects = []
public_api_paths = set()
for tested_module in TESTED_MODULES:
public_api = _get_public_api(tested_module, loader=loader)
module_public_objects = _get_public_objects(public_api)
public_api_paths.add(tested_module.__name__)
public_api_paths.update({obj.path for obj in module_public_objects})
public_objects.extend(module_public_objects)
for item in inventory.values():
if item.domain == "py" and "(" not in item.name:
obj = loader.modules_collection[item.name]
if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases):
not_in_api.append(item.name)
msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}"
assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api)))
@_test_all_modules
def test_no_module_docstrings_in_internal_api(tested_module: ModuleType) -> None:
"""No module docstrings should be written in our internal API.
The reasoning is that docstrings are addressed to users of the public API,
but internal modules are not exposed to users, so they should not have docstrings.
"""
internal_api = _get_internal_api(tested_module)
def _modules(obj: griffe.Module) -> Iterator[griffe.Module]:
for member in obj.modules.values():
yield member
yield from _modules(member)
for obj in _modules(internal_api):
assert not obj.docstring
python-griffe-2.3.0/packages/griffelib/tests/test_inheritance.py 0000664 0001750 0001750 00000015071 15246557273 024756 0 ustar carsten carsten # SPDX-License-Identifier: ISC
#
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Tests for class inheritance.
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from griffe import ModulesCollection, temporary_inspected_module, temporary_visited_module
if TYPE_CHECKING:
from collections.abc import Callable
from griffe import Class
def _mro_paths(cls: Class) -> list[str]:
return [base.path for base in cls.mro()]
@pytest.mark.parametrize("agent1", [temporary_visited_module, temporary_inspected_module])
@pytest.mark.parametrize("agent2", [temporary_visited_module, temporary_inspected_module])
def test_loading_inherited_members(agent1: Callable, agent2: Callable) -> None:
"""Test basic class inheritance.
Parameters:
agent1: A parametrized agent to load a module.
agent2: A parametrized agent to load a module.
"""
code1 = """
class A:
attr_from_a = 0
def method_from_a(self): ...
class B(A):
attr_from_a = 1
attr_from_b = 1
def method_from_b(self): ...
"""
code2 = """
from module1 import B
class C(B):
attr_from_c = 2
def method_from_c(self): ...
class D(C):
attr_from_a = 3
attr_from_d = 3
def method_from_d(self): ...
"""
inspection_options = {}
collection = ModulesCollection()
with agent1(code1, module_name="module1", modules_collection=collection) as module1:
if agent2 is temporary_inspected_module:
inspection_options["import_paths"] = [module1.filepath.parent]
with agent2(code2, module_name="module2", modules_collection=collection, **inspection_options) as module2:
classa = module1["A"]
classb = module1["B"]
classc = module2["C"]
classd = module2["D"]
assert classa in classb.resolved_bases
assert classb in classc.resolved_bases
assert classc in classd.resolved_bases
classd_mro = classd.mro()
assert classa in classd_mro
assert classb in classd_mro
assert classc in classd_mro
inherited_members = classd.inherited_members
assert "attr_from_a" not in inherited_members # Overwritten.
assert "attr_from_b" in inherited_members
assert "attr_from_c" in inherited_members
assert "attr_from_d" not in inherited_members # Own-declared.
assert "method_from_a" in inherited_members
assert "method_from_b" in inherited_members
assert "method_from_c" in inherited_members
assert "method_from_d" not in inherited_members # Own-declared.
assert "attr_from_b" in classd.all_members
@pytest.mark.parametrize("agent", [temporary_visited_module, temporary_inspected_module])
def test_nested_class_inheritance(agent: Callable) -> None:
"""Test nested class inheritance.
Parameters:
agent: A parametrized agent to load a module.
"""
code = """
class A:
class B:
attr_from_b = 0
class C(A.B):
attr_from_c = 1
"""
with agent(code) as module:
assert "attr_from_b" in module["C"].inherited_members
code = """
class OuterA:
class Inner: ...
class OuterB(OuterA):
class Inner(OuterA.Inner): ...
class OuterC(OuterA):
class Inner(OuterA.Inner): ...
class OuterD(OuterC):
class Inner(OuterC.Inner, OuterB.Inner): ...
"""
with temporary_visited_module(code) as module:
assert _mro_paths(module["OuterD.Inner"]) == [
"module.OuterC.Inner",
"module.OuterB.Inner",
"module.OuterA.Inner",
]
@pytest.mark.parametrize(
("classes", "cls", "expected_mro"),
[
(["A", "B(A)"], "B", ["A"]),
(["A", "B(A)", "C(A)", "D(B, C)"], "D", ["B", "C", "A"]),
(["A", "B(A)", "C(A)", "D(C, B)"], "D", ["C", "B", "A"]),
(["A(Z)"], "A", []),
(["A(str)"], "A", []),
(["A", "B(A)", "C(B)", "D(C)"], "D", ["C", "B", "A"]),
(["A", "B(A)", "C(B)", "D(C)", "E(A)", "F(B)", "G(F, E)", "H(G, D)"], "H", ["G", "F", "D", "C", "B", "E", "A"]),
(["A", "B(A[T])", "C(B[T])"], "C", ["B", "A"]),
],
)
def test_computing_mro(classes: list[str], cls: str, expected_mro: list[str]) -> None:
"""Test computing MRO.
Parameters:
classes: A list of classes inheriting from each other.
cls: The class to compute the MRO of.
expected_mro: The expected computed MRO.
"""
code = "class " + ": ...\nclass ".join(classes) + ": ..."
with temporary_visited_module(code) as module:
assert _mro_paths(module[cls]) == [f"module.{base}" for base in expected_mro]
@pytest.mark.parametrize(
("classes", "cls"),
[
(["A", "B(A, A)"], "B"),
(["A(D)", "B", "C(A, B)", "D(C)"], "D"),
],
)
def test_uncomputable_mro(classes: list[str], cls: str) -> None:
"""Test uncomputable MRO.
Parameters:
classes: A list of classes inheriting from each other.
cls: The class to compute the MRO of.
"""
code = "class " + ": ...\nclass ".join(classes) + ": ..."
with temporary_visited_module(code) as module, pytest.raises(ValueError, match="Cannot compute C3 linearization"):
_mro_paths(module[cls])
def test_dynamic_base_classes() -> None:
"""Test dynamic base classes."""
code = """
from collections import namedtuple
class A(namedtuple("B", "attrb")):
attra = 0
"""
with temporary_visited_module(code) as module:
assert _mro_paths(module["A"]) == [] # Not supported.
with temporary_inspected_module(code) as module:
assert _mro_paths(module["A"]) == [] # Not supported either.
python-griffe-2.3.0/packages/griffecli/ 0000775 0001750 0001750 00000000000 15246557273 017707 5 ustar carsten carsten python-griffe-2.3.0/packages/griffecli/src/ 0000775 0001750 0001750 00000000000 15246557273 020476 5 ustar carsten carsten python-griffe-2.3.0/packages/griffecli/src/griffecli/ 0000775 0001750 0001750 00000000000 15246557273 022430 5 ustar carsten carsten python-griffe-2.3.0/packages/griffecli/src/griffecli/__init__.py 0000664 0001750 0001750 00000003062 15246557273 024542 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This top-level module imports all public names from the CLI package,
# and exposes them as public objects.
"""Griffe CLI package.
The CLI (Command Line Interface) for the griffe library.
This package provides command-line tools for interacting with griffe.
## CLI entrypoints
- [`griffecli.main`][]: Run the main program.
- [`griffecli.check`][]: Check for API breaking changes in two versions of the same package.
- [`griffecli.dump`][]: Load packages data and dump it as JSON.
- [`griffecli.get_parser`][]: Get the argument parser for the CLI.
"""
from __future__ import annotations
from griffecli._internal.cli import DEFAULT_LOG_LEVEL, check, dump, get_parser, main
__all__ = [
"DEFAULT_LOG_LEVEL",
"check",
"dump",
"get_parser",
"main",
]
python-griffe-2.3.0/packages/griffecli/src/griffecli/py.typed 0000664 0001750 0001750 00000000000 15246557273 024115 0 ustar carsten carsten python-griffe-2.3.0/packages/griffecli/src/griffecli/__main__.py 0000664 0001750 0001750 00000002221 15246557273 024517 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Entry-point module, in case you use `python -m griffecli`.
#
# Why does this file exist, and why `__main__`? For more info, read:
#
# - https://www.python.org/dev/peps/pep-0338/
# - https://docs.python.org/3/using/cmdline.html#cmdoption-m
import sys
from griffecli._internal.cli import main
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
python-griffe-2.3.0/packages/griffecli/src/griffecli/_internal/ 0000775 0001750 0001750 00000000000 15246557273 024403 5 ustar carsten carsten python-griffe-2.3.0/packages/griffecli/src/griffecli/_internal/__init__.py 0000664 0001750 0001750 00000001533 15246557273 026516 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# Internal modules for the griffecli package.
python-griffe-2.3.0/packages/griffecli/src/griffecli/_internal/cli.py 0000664 0001750 0001750 00000061367 15246557273 025541 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
# This module contains all CLI-related things.
# Why does this file exist, and why not put this in `__main__`?
#
# We might be tempted to import things from `__main__` later,
# but that will cause problems; the code will get executed twice:
#
# - When we run `python -m griffecli`, Python will execute
# `__main__.py` as a script. That means there won't be any
# `griffecli.__main__` in `sys.modules`.
# - When you import `__main__` it will get executed again (as a module) because
# there's no `griffecli.__main__` in `sys.modules`.
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any
import colorama
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from griffe._internal.docstrings.parsers import DocstringOptions, DocstringStyle
from griffe._internal.enumerations import ExplanationStyle, Parser
from griffe._internal.extensions.base import Extension, Extensions
from griffe._internal.loader import GriffeLoader
DEFAULT_LOG_LEVEL = os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper()
"""The default log level for the CLI.
This can be overridden by the `GRIFFE_LOG_LEVEL` environment variable.
"""
class _DebugInfo(argparse.Action):
def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
super().__init__(nargs=nargs, **kwargs)
def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
from griffe._internal import debug # noqa: PLC0415
debug._print_debug_info()
sys.exit(0)
def _print_data(data: str, output_file: str | IO | None) -> None:
if isinstance(output_file, str):
with open(output_file, "w", encoding="utf8") as fd: # noqa: PTH123
print(data, file=fd)
else:
if output_file is None:
output_file = sys.stdout
print(data, file=output_file)
def _load_packages(
packages: Sequence[str],
*,
extensions: Extensions | None = None,
search_paths: Sequence[str | Path] | None = None,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
resolve_aliases: bool = True,
resolve_implicit: bool = False,
resolve_external: bool | None = None,
allow_inspection: bool = True,
force_inspection: bool = False,
store_source: bool = True,
find_stubs_package: bool = False,
prefer_stubs_docs: bool = False,
) -> GriffeLoader:
from griffe._internal.loader import GriffeLoader # noqa: PLC0415
from griffe._internal.logger import logger # noqa: PLC0415
# Create a single loader.
loader = GriffeLoader(
extensions=extensions,
search_paths=search_paths,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
store_source=store_source,
prefer_stubs_docs=prefer_stubs_docs,
)
# Load each package.
for package in packages:
if not package:
logger.debug("Empty package name, continuing")
continue
logger.info("Loading package %s", package)
try:
loader.load(package, try_relative_path=True, find_stubs_package=find_stubs_package)
except ModuleNotFoundError as error:
logger.error("Could not find package %s: %s", package, error)
except ImportError:
logger.exception("Tried but could not import package %s", package)
logger.info("Finished loading packages")
# Resolve aliases.
if resolve_aliases:
logger.info("Starting alias resolution")
unresolved, iterations = loader.resolve_aliases(implicit=resolve_implicit, external=resolve_external)
if unresolved:
logger.info("%s aliases were still unresolved after %s iterations", len(unresolved), iterations)
else:
logger.info("All aliases were resolved after %s iterations", iterations)
return loader
_level_choices = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
def _extensions_type(value: str) -> Sequence[str | dict[str, Any]]:
try:
return json.loads(value)
except json.JSONDecodeError:
return value.split(",")
def get_parser() -> argparse.ArgumentParser:
"""Return the CLI argument parser.
Returns:
An argparse parser.
"""
from griffe._internal import debug # noqa: PLC0415
from griffe._internal.enumerations import ExplanationStyle, Parser # noqa: PLC0415
usage = "%(prog)s [GLOBAL_OPTS...] COMMAND [COMMAND_OPTS...]"
description = "Signatures for entire Python programs. "
"Extract the structure, the frame, the skeleton of your project, "
"to generate API documentation or find breaking changes in your API."
parser = argparse.ArgumentParser(add_help=False, usage=usage, description=description, prog="griffe")
main_help = "Show this help message and exit. Commands also accept the -h/--help option."
subcommand_help = "Show this help message and exit."
global_options = parser.add_argument_group(title="Global options")
global_options.add_argument("-h", "--help", action="help", help=main_help)
global_options.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
global_options.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
def add_common_options(subparser: argparse.ArgumentParser) -> None:
common_options = subparser.add_argument_group(title="Common options")
common_options.add_argument("-h", "--help", action="help", help=subcommand_help)
search_options = subparser.add_argument_group(title="Search options")
search_options.add_argument(
"-s",
"--search",
dest="search_paths",
action="append",
type=Path,
help="Paths to search packages into.",
)
search_options.add_argument(
"-y",
"--sys-path",
dest="append_sys_path",
action="store_true",
help="Whether to append `sys.path` to search paths specified with `-s`.",
)
loading_options = subparser.add_argument_group(title="Loading options")
loading_options.add_argument(
"-B",
"--find-stubs-packages",
dest="find_stubs_package",
action="store_true",
default=False,
help="Whether to look for stubs-only packages and merge them with concrete ones.",
)
loading_options.add_argument(
"-P",
"--prefer-stubs-docstrings",
dest="prefer_stubs_docs",
action="store_true",
default=False,
help="Whether to prefer docstrings from stubs over those from sources.",
)
loading_options.add_argument(
"-e",
"--extensions",
default={},
type=_extensions_type,
help="A list of extensions to use.",
)
loading_options.add_argument(
"-X",
"--no-inspection",
dest="allow_inspection",
action="store_false",
default=True,
help="Disallow inspection of builtin/compiled/not found modules.",
)
loading_options.add_argument(
"-x",
"--force-inspection",
dest="force_inspection",
action="store_true",
default=False,
help="Force inspection of everything, even when sources are found.",
)
debug_options = subparser.add_argument_group(title="Debugging options")
debug_options.add_argument(
"-L",
"--log-level",
metavar="LEVEL",
default=DEFAULT_LOG_LEVEL,
choices=_level_choices,
type=str.upper,
help="Set the log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`.",
)
# ========= SUBPARSERS ========= #
subparsers = parser.add_subparsers(
dest="subcommand",
title="Commands",
metavar="COMMAND",
prog="griffe",
required=True,
)
def add_subparser(command: str, text: str, **kwargs: Any) -> argparse.ArgumentParser:
return subparsers.add_parser(command, add_help=False, help=text, description=text, **kwargs)
# ========= DUMP PARSER ========= #
dump_parser = add_subparser("dump", "Load package-signatures and dump them as JSON.")
dump_options = dump_parser.add_argument_group(title="Dump options")
dump_options.add_argument("packages", metavar="PACKAGE", nargs="+", help="Packages to find, load and dump.")
dump_options.add_argument(
"-f",
"--full",
action="store_true",
default=False,
help="Whether to dump full data in JSON.",
)
dump_options.add_argument(
"-o",
"--output",
default=sys.stdout,
help="Output file. Supports templating to output each package in its own file, with `{package}`.",
)
dump_options.add_argument(
"-d",
"--docstyle",
dest="docstring_parser",
default=None,
type=Parser,
help="The docstring style to parse.",
)
dump_options.add_argument(
"-D",
"--docopts",
dest="docstring_options",
default={},
type=json.loads,
help="The options for the docstring parser.",
)
dump_options.add_argument(
"-r",
"--resolve-aliases",
action="store_true",
help="Whether to resolve aliases.",
)
dump_options.add_argument(
"-I",
"--resolve-implicit",
action="store_true",
help="Whether to resolve implicitly exported aliases as well. "
"Aliases are explicitly exported when defined in `__all__`.",
)
dump_options.add_argument(
"-U",
"--resolve-external",
dest="resolve_external",
action="store_true",
help="Always resolve aliases pointing to external/unknown modules (not loaded directly)."
"Default is to resolve only from one module to its private sibling (`ast` -> `_ast`).",
)
dump_options.add_argument(
"--no-resolve-external",
dest="resolve_external",
action="store_false",
help="Never resolve aliases pointing to external/unknown modules (not loaded directly)."
"Default is to resolve only from one module to its private sibling (`ast` -> `_ast`).",
)
dump_options.add_argument(
"-S",
"--stats",
action="store_true",
help="Show statistics at the end.",
)
add_common_options(dump_parser)
# ========= CHECK PARSER ========= #
check_parser = add_subparser("check", "Check for API breakages or possible improvements.")
check_options = check_parser.add_argument_group(title="Check options")
check_options.add_argument("package", metavar="PACKAGE", help="Package to find, load and check, as path.")
check_options.add_argument(
"-a",
"--against",
metavar="REF",
help="Older Git reference (commit, branch, tag) to check against. Default: load latest tag.",
)
check_options.add_argument(
"-b",
"--base-ref",
metavar="BASE_REF",
help="Git reference (commit, branch, tag) to check. Default: load current code.",
)
check_options.add_argument(
"--color",
dest="color",
action="store_true",
default=None,
help="Force enable colors in the output.",
)
check_options.add_argument(
"--no-color",
dest="color",
action="store_false",
default=None,
help="Force disable colors in the output.",
)
check_options.add_argument("-v", "--verbose", action="store_true", help="Verbose output.")
formats = [fmt.value for fmt in ExplanationStyle]
check_options.add_argument("-f", "--format", dest="style", choices=formats, default=None, help="Output format.")
add_common_options(check_parser)
return parser
def dump(
packages: Sequence[str],
*,
output: str | IO | None = None,
full: bool = False,
docstring_parser: DocstringStyle | Parser | None = None,
docstring_options: DocstringOptions | None = None,
extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None,
resolve_aliases: bool = False,
resolve_implicit: bool = False,
resolve_external: bool | None = None,
search_paths: Sequence[str | Path] | None = None,
find_stubs_package: bool = False,
prefer_stubs_docs: bool = False,
append_sys_path: bool = False,
allow_inspection: bool = True,
force_inspection: bool = False,
stats: bool = False,
) -> int:
"""Load packages data and dump it as JSON.
Parameters:
packages: The packages to load and dump.
output: Where to output the JSON-serialized data.
full: Whether to output full or minimal data.
docstring_parser: The docstring parser to use. By default, no parsing is done.
docstring_options: Docstring parsing options.
resolve_aliases: Whether to resolve aliases (indirect objects references).
resolve_implicit: Whether to resolve every alias or only the explicitly exported ones.
resolve_external: Whether to load additional, unspecified modules to resolve aliases.
Default is to resolve only from one module to its private sibling (`ast` -> `_ast`).
extensions: The extensions to use.
search_paths: The paths to search into.
find_stubs_package: Whether to search for stubs-only packages.
If both the package and its stubs are found, they'll be merged together.
If only the stubs are found, they'll be used as the package itself.
prefer_stubs_docs: Whether to give precedence to docstrings from stubs over those from sources.
append_sys_path: Whether to append the contents of `sys.path` to the search paths.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
force_inspection: Whether to force using dynamic analysis when loading data.
stats: Whether to compute and log stats about loading.
Returns:
`0` for success, `1` for failure.
"""
from griffe._internal.encoders import JSONEncoder # noqa: PLC0415
from griffe._internal.exceptions import ExtensionError # noqa: PLC0415
from griffe._internal.extensions.base import load_extensions # noqa: PLC0415
from griffe._internal.logger import logger # noqa: PLC0415
# Prepare options.
per_package_output = False
if isinstance(output, str) and output.format(package="package") != output:
per_package_output = True
search_paths = list(search_paths) if search_paths else []
if append_sys_path:
search_paths.extend(sys.path)
try:
loaded_extensions = load_extensions(*(extensions or ()))
except ExtensionError:
logger.exception("Could not load extensions")
return 1
# Load packages.
loader = _load_packages(
packages,
extensions=loaded_extensions,
search_paths=search_paths,
docstring_parser=docstring_parser,
docstring_options=docstring_options,
resolve_aliases=resolve_aliases,
resolve_implicit=resolve_implicit,
resolve_external=resolve_external,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
store_source=False,
find_stubs_package=find_stubs_package,
prefer_stubs_docs=prefer_stubs_docs,
)
data_packages = loader.modules_collection.members
# Serialize and dump packages.
started = datetime.now(tz=timezone.utc)
if per_package_output:
for package_name, data in data_packages.items():
serialized = data.as_json(indent=2, full=full, sort_keys=True)
_print_data(serialized, output.format(package=package_name)) # ty:ignore[unresolved-attribute]
else:
serialized = json.dumps(data_packages, cls=JSONEncoder, indent=2, full=full, sort_keys=True)
_print_data(serialized, output)
elapsed = datetime.now(tz=timezone.utc) - started
if stats:
loader_stats = loader.stats()
loader_stats.time_spent_serializing = elapsed.microseconds
logger.info(loader_stats.as_text())
return 0 if len(data_packages) == len(packages) else 1
def check(
package: str | Path,
against: str | None = None,
against_path: str | Path | None = None,
*,
base_ref: str | None = None,
extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None,
search_paths: Sequence[str | Path] | None = None,
append_sys_path: bool = False,
find_stubs_package: bool = False,
prefer_stubs_docs: bool = False,
allow_inspection: bool = True,
force_inspection: bool = False,
verbose: bool = False,
color: bool | None = None,
style: str | ExplanationStyle | None = None,
) -> int:
"""Check for API breaking changes in two versions of the same package.
Parameters:
package: The package to load and check.
against: Older Git reference (commit, branch, tag) to check against.
against_path: Path when the "against" reference is checked out.
base_ref: Git reference (commit, branch, tag) to check.
extensions: The extensions to use.
search_paths: The paths to search into.
append_sys_path: Whether to append the contents of `sys.path` to the search paths.
find_stubs_package: Whether to search for stubs-only packages.
prefer_stubs_docs: Whether to give precedence to docstrings from stubs over those from sources.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
force_inspection: Whether to force using dynamic analysis when loading data.
verbose: Use a verbose output.
Returns:
`0` for success, `1` for failure.
"""
from griffe._internal.diff import find_breaking_changes # noqa: PLC0415
from griffe._internal.enumerations import ExplanationStyle # noqa: PLC0415
from griffe._internal.exceptions import ExtensionError, GitError # noqa: PLC0415
from griffe._internal.extensions.base import load_extensions # noqa: PLC0415
from griffe._internal.git import _get_latest_tag, _get_repo_root # noqa: PLC0415
from griffe._internal.loader import load, load_git, load_pypi # noqa: PLC0415
from griffe._internal.logger import logger # noqa: PLC0415
# Prepare options.
search_paths = list(search_paths) if search_paths else []
if append_sys_path:
search_paths.extend(sys.path)
against_path = against_path or package
try:
loaded_extensions = load_extensions(*(extensions or ()))
except ExtensionError:
logger.exception("Could not load extensions")
return 1
if match_against := re.match(r"([\w.-]+)?((==|<=|<|>=|>|!=).+)", against or ""):
against_dist = (match_against.group(1) or str(package)).lower().replace("-", "_")
against_version = match_against.group(2)
old_package = load_pypi(
str(package),
against_dist,
against_version,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
find_stubs_package=find_stubs_package,
prefer_stubs_docs=prefer_stubs_docs,
resolve_aliases=True,
resolve_external=None,
)
if base_ref:
if not (match_base := re.match(r"([\w.-]+)?((==|<=|<|>=|>|!=).+)", base_ref)):
raise ValueError(f"Base {base_ref} is not a valid dependency specifier.")
base_dist = (match_base.group(1) or str(package)).lower().replace("-", "_")
base_version = match_base.group(2)
else:
base_dist = against_dist
base_version = ""
new_package = load_pypi(
str(package),
base_dist,
base_version,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
find_stubs_package=find_stubs_package,
prefer_stubs_docs=prefer_stubs_docs,
resolve_aliases=True,
resolve_external=None,
)
else:
against_path = against_path or package
try:
against = against or _get_latest_tag(package)
repository = _get_repo_root(against_path)
except GitError as error:
print(f"griffe: error: {error}", file=sys.stderr)
return 2
# Load old and new version of the package.
old_package = load_git(
against_path,
ref=against,
repo=repository,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
find_stubs_package=find_stubs_package,
prefer_stubs_docs=prefer_stubs_docs,
resolve_aliases=True,
resolve_external=None,
)
if base_ref:
new_package = load_git(
package,
ref=base_ref,
repo=repository,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
find_stubs_package=find_stubs_package,
prefer_stubs_docs=prefer_stubs_docs,
resolve_aliases=True,
resolve_external=None,
)
else:
new_package = load(
package,
try_relative_path=True,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
force_inspection=force_inspection,
find_stubs_package=find_stubs_package,
prefer_stubs_docs=prefer_stubs_docs,
resolve_aliases=True,
resolve_external=None,
)
# Find and display API breakages.
breakages = list(find_breaking_changes(old_package, new_package))
if color is None and (force_color := os.getenv("FORCE_COLOR", None)) is not None:
color = force_color.lower() in {"1", "true", "y", "yes", "on"}
colorama.deinit()
colorama.init(strip=color if color is None else not color)
if style is None:
style = ExplanationStyle.VERBOSE if verbose else ExplanationStyle.ONE_LINE
else:
style = ExplanationStyle(style)
for breakage in breakages:
print(breakage.explain(style=style), file=sys.stderr)
if breakages:
return 1
return 0
def main(args: list[str] | None = None) -> int:
"""Run the main program.
This function is executed when you type `griffe` or `python -m griffe`.
Parameters:
args: Arguments passed from the command line.
Returns:
An exit code.
"""
# Parse arguments.
parser = get_parser()
opts: argparse.Namespace = parser.parse_args(args)
opts_dict = opts.__dict__
opts_dict.pop("debug_info")
subcommand = opts_dict.pop("subcommand")
# Initialize logging.
log_level = opts_dict.pop("log_level", DEFAULT_LOG_LEVEL)
try:
level = getattr(logging, log_level)
except AttributeError:
choices = "', '".join(_level_choices)
print(
f"griffe: error: invalid log level '{log_level}' (choose from '{choices}')",
file=sys.stderr,
)
return 1
else:
logging.basicConfig(format="%(levelname)-10s %(message)s", level=level)
# Increase maximum recursion limit to 2000.
sys.setrecursionlimit(max(2000, sys.getrecursionlimit()))
# Run subcommand.
commands: dict[str, Callable[..., int]] = {"check": check, "dump": dump}
return commands[subcommand](**opts_dict)
python-griffe-2.3.0/packages/griffecli/pyproject.toml 0000664 0001750 0001750 00000004317 15246557273 022630 0 ustar carsten carsten [build-system]
# pdm-backend is left here as a dependency of the version discovery script currently in use.
# It may be removed in the future. See mkdocstrings/griffe#430
requires = ["hatchling", "pdm-backend", "uv-dynamic-versioning>=0.7.0"]
build-backend = "hatchling.build"
[project]
name = "griffecli"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}]
license = "ISC"
license-files = ["LICENSE"]
requires-python = ">=3.10"
keywords = ["api", "signature", "breaking-changes", "static-analysis", "dynamic-analysis"]
dynamic = ["version", "dependencies"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
# YORE: EOL 3.10: Remove line.
"Programming Language :: Python :: 3.10",
# YORE: EOL 3.11: Remove line.
"Programming Language :: Python :: 3.11",
# YORE: EOL 3.12: Remove line.
"Programming Language :: Python :: 3.12",
# YORE: EOL 3.13: Remove line.
"Programming Language :: Python :: 3.13",
# YORE: EOL 3.14: Remove line.
"Programming Language :: Python :: 3.14",
"Topic :: Documentation",
"Topic :: Software Development",
"Topic :: Software Development :: Documentation",
"Topic :: Utilities",
"Typing :: Typed",
]
[project.scripts]
griffecli = "griffecli:main"
[tool.uv-dynamic-versioning]
pattern = '(?P \d+\.\d+\.\d+)'
[tool.hatch.version]
source = "code"
path = "../../scripts/get_version.py"
expression = "get_version()"
[tool.hatch.build.targets.sdist.force-include]
"../../CHANGELOG.md" = "CHANGELOG.md"
"../../LICENSE" = "LICENSE"
"../../README.md" = "README.md"
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
# Dependencies are dynamically versioned; {{version}} is substituted at build time.
dependencies = ["griffelib=={{version}}", "colorama>=0.4"]
[tool.hatch.build.targets.wheel]
packages = ["src/griffecli"]
[tool.uv.sources]
griffelib = { workspace = true }
python-griffe-2.3.0/packages/griffecli/tests/ 0000775 0001750 0001750 00000000000 15246557273 021051 5 ustar carsten carsten python-griffe-2.3.0/packages/griffecli/tests/__init__.py 0000664 0001750 0001750 00000001520 15246557273 023160 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
"""Tests suite for `griffecli`."""
python-griffe-2.3.0/packages/griffecli/tests/test_cli.py 0000664 0001750 0001750 00000006124 15246557273 023234 0 ustar carsten carsten # SPDX-License-Identifier: ISC
# Copyright (c) 2021, Timothée Mazzucotelli and contributors
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR 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, DIRECT, 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.
"""Tests for the CLI."""
from __future__ import annotations
import json
import sys
from typing import TYPE_CHECKING
import pytest
from griffe._internal import debug
from griffecli._internal import cli
if TYPE_CHECKING:
from pathlib import Path
def test_main() -> None:
"""Basic CLI test."""
if sys.platform == "win32":
assert cli.main(["dump", "griffe", "-s", "src", "-oNUL"]) == 0
else:
assert cli.main(["dump", "griffe", "-s", "src", "-o/dev/null"]) == 0
@pytest.mark.parametrize("flag", ["-P", "--prefer-stubs-docstrings"])
def test_prefer_stubs_docstrings(tmp_path: Path, flag: str) -> None:
"""Prefer docstrings from stubs when requested.
Parameters:
tmp_path: Pytest fixture providing a temporary directory.
flag: Short or long spelling of the CLI flag.
"""
package_path = tmp_path / "package"
package_path.mkdir()
package_path.joinpath("__init__.py").write_text('"""Source."""', encoding="utf8")
package_path.joinpath("__init__.pyi").write_text('"""Stubs."""', encoding="utf8")
output_path = tmp_path / "output.json"
assert cli.main(["dump", str(package_path), flag, "-o", str(output_path)]) == 0
output = json.loads(output_path.read_text(encoding="utf8"))
assert output["package"]["docstring"]["value"] == "Stubs."
def test_show_help(capsys: pytest.CaptureFixture) -> None:
"""Show help.
Parameters:
capsys: Pytest fixture to capture output.
"""
with pytest.raises(SystemExit):
cli.main(["-h"])
captured = capsys.readouterr()
assert "griffe" in captured.out
def test_show_version(capsys: pytest.CaptureFixture) -> None:
"""Show version.
Parameters:
capsys: Pytest fixture to capture output.
"""
with pytest.raises(SystemExit):
cli.main(["-V"])
captured = capsys.readouterr()
assert debug._get_version() in captured.out
def test_show_debug_info(capsys: pytest.CaptureFixture) -> None:
"""Show debug information.
Parameters:
capsys: Pytest fixture to capture output.
"""
with pytest.raises(SystemExit):
cli.main(["--debug-info"])
captured = capsys.readouterr().out.lower()
assert "python" in captured
assert "system" in captured
assert "environment" in captured
assert "packages" in captured
python-griffe-2.3.0/docs/ 0000775 0001750 0001750 00000000000 15246557273 015127 5 ustar carsten carsten python-griffe-2.3.0/docs/changelog.md 0000664 0001750 0001750 00000000103 15246557273 017372 0 ustar carsten carsten ---
title: Changelog
hide:
- navigation
---
--8<-- "CHANGELOG.md"
python-griffe-2.3.0/docs/community.md 0000664 0001750 0001750 00000003574 15246557273 017506 0 ustar carsten carsten # Community
Griffe is part of is the [mkdocstrings](https://mkdocstrings.github.io/) ecosystem, and therefore part of the [MkDocs](https://www.mkdocs.org/) ecosystem too. These two ecosystems have wonderful communities and we invite you to join them :octicons-heart-fill-24:{ .pulse } Make sure to read and follow our [code of conduct](code-of-conduct.md) when engaging with the community.
You can start new discussions on GitHub, in the following repositories, depending on the specificity of the discussion: [griffe](https://github.com/mkdocstrings/griffe), [mkdocstrings-python](https://github.com/mkdocstrings/python), [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings), and [MkDocs](https://github.com/mkdocs/mkdocs).
You can also join our dedicated Gitter channels: [Griffe channel](https://app.gitter.im/#/room/#mkdocstrings_griffe:gitter.im){ target="_blank" }, [mkdocstrings-python channel](https://app.gitter.im/#/room/#mkdocstrings_python:gitter.im){ target="_blank" }, [mkdocstrings channel](https://app.gitter.im/#/room/#mkdocstrings_community:gitter.im){ target="_blank" }, and [MkDocs channel](https://app.gitter.im/#/room/#mkdocs_community:gitter.im){ target="_blank" }.
The best place to share about Griffe is of course our Gitter channel.
[:simple-gitter: Join Griffe's Gitter channel](https://app.gitter.im/#/room/#mkdocstrings_griffe:gitter.im){ .md-button target="_blank" }
More generally, Griffe is also related to **API documentation** and **API analysis** (static or dynamic): if your project is related to these two domains, but in different ecosystems (other programming languages, static site generators, or environments), feel free to drop us a message! We are always happy to share with other actors in these domains :material-handshake:
- [Getting help](getting-help.md)
- [Contributing](contributing.md)
- [Code of conduct](code-of-conduct.md)
- [Credits](credits.md)
python-griffe-2.3.0/docs/getting-help.md 0000664 0001750 0001750 00000002137 15246557273 020043 0 ustar carsten carsten # Getting help
If you have a quick question regarding Griffe, ask on our [Gitter channel](https://app.gitter.im/#/room/#mkdocstrings_griffe:gitter.im){ target="_blank" }.
For more complex questions, or actual issues that require showing code and configuration, please create [new Q/A discussions](https://github.com/mkdocstrings/griffe/discussions/categories/q-a) or [new bug reports](https://github.com/mkdocstrings/griffe/issues) respectively. Make sure to search previous discussions and issues to avoid creating duplicates. Also make sure to read our documentation before asking questions or opening bug reports. Don't hesitate to report unclear or missing documentation, we will do our best to improve it.
In any case (quick or complex questions) please remember to be **kind**, and to follow our [code of conduct](code-of-conduct.md). The people helping you do so voluntarily, in their free time. Be respectful of their time, and of your own. Help them help you by providing all the necessary information in minimal, reproducible examples. When creating a bug report, make sure to fill out the issue template.
python-griffe-2.3.0/docs/downstream-projects.md 0000664 0001750 0001750 00000007732 15246557273 021474 0 ustar carsten carsten # Downstream projects
Griffe is used by various projects in the Python ecosystem. They are listed below in alphabetical order. [Griffe extensions are listed on their own page.](extensions.md)
## api2mdx
[api2mdx](https://github.com/Mirascope/api2mdx) is a Python tool that generates `mdx` documentation for Python APIs. Under the hood, it uses Griffe.
## Cybersecurity AI (CAI)
[Cybersecurity AI (CAI)](https://github.com/aliasrobotics/cai) is a lightweight, open-source framework that empowers security professionals to build and deploy AI-powered offensive and defensive automation. It uses Griffe to parse docstrings in order to generate function schema for MCP tools. I believe it reused code from Pydantic AI.
## Fumadocs
[Fumadocs](https://fumadocs.dev/) is a beautiful documentation framework for developers, flexible, performant, running on your React framework. It uses Griffe to extract and render Python API documentation.
## griffe2md
[griffe2md](https://mkdocstrings.github.io/griffe2md/) outputs API docs in Markdown. It uses Griffe to load the data, and then use Jinja templates to render documentation in Markdown, just like [mkdocstrings-python](https://mkdocstrings.github.io/python/), but in Markdown instead of HTML.
## Griffe TUI
[Griffe TUI](https://mkdocstrings.github.io/griffe-tui/) is a textual user interface for Griffe. It offers 100% offline, beautiful Python API docs, in your terminal, thanks to Griffe and [Textual](https://textual.textualize.io/).
## Griffonner
[Griffonner](https://will-langdale.github.io/griffonner/) is a template-first Python documentation generator that gets out of your way. Griffonner uses Griffe to parse your Python code and Jinja2 templates to generate docs in any format you want.
## Hippogriffe
[Hippogriffe](https://github.com/patrick-kidger/hippogriffe) is a set of tweaks on top of the MkDocs + mkdocstrings-python + Griffe documentation stack. It adds source links to GitHub to each top-level class or function, pretty-formats type annotations, improves unions/generics display, and more. Hippogriffe is used as a MkDocs plugin.
## mdxify
[mdxify](https://github.com/zzstoatzz/mdxify) generates API documentation from Python modules with automatic navigation and source links. MDX is the default output and Markdown is also supported via `--format md`.
## Mistral AI Python Client
The [Python client of Mistral AI](https://github.com/mistralai/client-python) uses Griffe to parse docstrings of Python-written MCP tools.
## mkdocstrings-python
Of course, Griffe is what powers [the Python handler of mkdocstrings](https://mkdocstrings.github.io/python/). mkdocstrings is a plugin for [MkDocs](https://www.mkdocs.org/) that allows rendering API docs easily.
## OpenAI Agents SDK
The [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) is a lightweight yet powerful framework for building multi-agent workflows. It was inspired by Pydantic AI and uses Griffe the same way, to parse docstrings in order to generate function schemas.
## pydanclick
[Pydanclick](https://pypi.org/project/pydanclick/) allows to use [Pydantic](https://docs.pydantic.dev/latest/) models as [Click](https://click.palletsprojects.com/en/8.1.x/) options. It uses Griffe to parse docstrings and find Attributes sections, to help itself build Click options.
## PydanticAI
[PydanticAI](https://ai.pydantic.dev/) is a Python Agent Framework designed to make it less painful to build production grade applications with Generative AI. It uses Griffe to extract tool and parameter descriptions from docstrings.
## quartodoc
[quartodoc](https://machow.github.io/quartodoc/) lets you quickly generate Python package API reference documentation using Markdown and [Quarto](https://quarto.org/). quartodoc is designed as an alternative to [Sphinx](https://www.sphinx-doc.org/en/master/). It uses Griffe to load API data and parse docstrings in order to render HTML documentation, just like [mkdocstrings-python](https://mkdocstrings.github.io/python/), but for Quarto instead of Mkdocs.
python-griffe-2.3.0/docs/guide.md 0000664 0001750 0001750 00000001500 15246557273 016542 0 ustar carsten carsten # Guide
Welcome to the Griffe guide! Here, you’ll find a series of topics on using Griffe, along with an explanation of the project’s inner workings. Although the more detailed explanations are primarily intended for contributors, users are encouraged to read them as well, since this can deepen their understanding of Griffe and help them make the most of it.
- :fontawesome-solid-user:{ .lg .middle } **User guide**
---
A collection of topics for Griffe users.
[:octicons-arrow-right-24: Browse the user guide](guide/users.md)
- :fontawesome-solid-helmet-safety:{ .lg .middle } **Contributor guide**
---
Explanation of the project and more, for Griffe contributors.
[:octicons-arrow-right-24: Browse the contributor guide](guide/contributors.md)
python-griffe-2.3.0/docs/credits.md 0000664 0001750 0001750 00000000152 15246557273 017104 0 ustar carsten carsten ---
title: Credits
hide:
- toc
---
```python exec="yes" id="credits"
--8<-- "scripts/gen_credits.py"
```
python-griffe-2.3.0/docs/license.md 0000664 0001750 0001750 00000000121 15246557273 017065 0 ustar carsten carsten ---
title: License
hide:
- feedback
---
# License
```text
--8<-- "LICENSE"
```
python-griffe-2.3.0/docs/schema-docstrings-options.json 0000664 0001750 0001750 00000002070 15246557273 023127 0 ustar carsten carsten {
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Griffe docstrings parsing options.",
"type": "object",
"properties": {
"ignore_init_summary": {
"title": "Whether to discard the summary line in `__init__` methods' docstrings.",
"markdownDescription": "https://mkdocstrings.github.io/griffe/reference/griffe/docstrings/google/#griffe.docstrings.google.parse",
"type": "boolean",
"default": false
},
"trim_doctest_flags": {
"title": "Whether to remove doctest flags from Python example blocks.",
"markdownDescription": "https://mkdocstrings.github.io/griffe/reference/griffe/docstrings/google/#griffe.docstrings.google.parse",
"type": "boolean",
"default": true
},
"returns_multiple_items": {
"title": "Whether the `Returns` section has multiple items.",
"markdownDescription": "https://mkdocstrings.github.io/griffe/reference/griffe/docstrings/google/#griffe.docstrings.google.parse",
"type": "boolean",
"default": true
}
},
"additionalProperties": false
} python-griffe-2.3.0/docs/code-of-conduct.md 0000664 0001750 0001750 00000000074 15246557273 020423 0 ustar carsten carsten ---
title: Code of Conduct
---
--8<-- "CODE_OF_CONDUCT.md"
python-griffe-2.3.0/docs/css/ 0000775 0001750 0001750 00000000000 15246557273 015717 5 ustar carsten carsten python-griffe-2.3.0/docs/css/custom.css 0000664 0001750 0001750 00000000237 15246557273 017745 0 ustar carsten carsten /* Prevent selection of prompts in pycon code blocks. */
.language-pycon .gp, .language-pycon .go { /* Generic.Prompt, Generic.Output */
user-select: none;
} python-griffe-2.3.0/docs/css/mkdocstrings.css 0000664 0001750 0001750 00000005420 15246557273 021141 0 ustar carsten carsten /* Indentation. */
div.doc-contents:not(.first) {
padding-left: 25px;
border-left: .05rem solid var(--md-typeset-table-color);
}
/* Mark external links as such. */
a.external::after,
a.autorefs-external::after {
/* https://primer.style/octicons/arrow-up-right-24 */
mask-image: url('data:image/svg+xml, ');
-webkit-mask-image: url('data:image/svg+xml, ');
content: ' ';
display: inline-block;
vertical-align: middle;
position: relative;
height: 1em;
width: 1em;
background-color: currentColor;
}
a.external:hover::after,
a.autorefs-external:hover::after {
background-color: var(--md-accent-fg-color);
}
/* Light blue color for `param` symbols`. */
[data-md-color-scheme="default"] {
--doc-symbol-parameter-fg-color: #829bd1;
--doc-symbol-parameter-bg-color: #829bd11a;
}
[data-md-color-scheme="slate"] {
--doc-symbol-parameter-fg-color: #829bd1;
--doc-symbol-parameter-bg-color: #829bd11a;
}
/* Hide parameters in ToC. */
li.md-nav__item:has(> a[href*="("]) {
display: none;
}
/* Tree-like output for backlinks. */
.doc-backlink-list {
--tree-clr: var(--md-default-fg-color);
--tree-font-size: 1rem;
--tree-item-height: 1;
--tree-offset: 1rem;
--tree-thickness: 1px;
--tree-style: solid;
display: grid;
list-style: none !important;
}
.doc-backlink-list li > span:first-child {
text-indent: .3rem;
}
.doc-backlink-list li {
padding-inline-start: var(--tree-offset);
border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr);
position: relative;
margin-left: 0 !important;
&:last-child {
border-color: transparent;
}
&::before{
content: '';
position: absolute;
top: calc(var(--tree-item-height) / 2 * -1 * var(--tree-font-size) + var(--tree-thickness));
left: calc(var(--tree-thickness) * -1);
width: calc(var(--tree-offset) + var(--tree-thickness) * 2);
height: calc(var(--tree-item-height) * var(--tree-font-size));
border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr);
border-bottom: var(--tree-thickness) var(--tree-style) var(--tree-clr);
}
&::after{
content: '';
position: absolute;
border-radius: 50%;
background-color: var(--tree-clr);
top: calc(var(--tree-item-height) / 2 * 1rem);
left: var(--tree-offset) ;
translate: calc(var(--tree-thickness) * -1) calc(var(--tree-thickness) * -1);
}
}
python-griffe-2.3.0/docs/css/material.css 0000664 0001750 0001750 00000015767 15246557273 020247 0 ustar carsten carsten /* More space at the bottom of the page. */
.md-main__inner {
margin-bottom: 1.5rem;
}
/* Invert logo colors in navbar. */
.md-logo img {
filter: invert(100%);
}
/* Add space between Material buttons on mobile. */
.md-button {
margin-bottom: 0.2rem;
}
/* Add space above bold ToC entries. */
.md-sidebar--secondary .md-nav__item:not(:first-child):has(strong) {
margin-top: 1rem;
}
/* Griffe admonitions. */
:root {
--md-admonition-icon--griffe: url('data:image/svg+xml;charset=utf-8, ')
}
.md-typeset .admonition.griffe,
.md-typeset details.griffe {
border-color: rgb(166, 226, 55);
}
.md-typeset .griffe > .admonition-title,
.md-typeset .griffe > summary {
background-color: rgba(43, 155, 70, 0.1);
}
.md-typeset .griffe > .admonition-title::before,
.md-typeset .griffe > summary::before {
background-color: rgb(166, 226, 55);
-webkit-mask-image: var(--md-admonition-icon--griffe);
mask-image: var(--md-admonition-icon--griffe);
}
/* Don't uppercase H5 headings. */
.md-typeset h5 {
text-transform: none;
}
python-griffe-2.3.0/docs/guide/ 0000775 0001750 0001750 00000000000 15246557273 016224 5 ustar carsten carsten python-griffe-2.3.0/docs/guide/contributors/ 0000775 0001750 0001750 00000000000 15246557273 020761 5 ustar carsten carsten python-griffe-2.3.0/docs/guide/contributors/workflow.md 0000664 0001750 0001750 00000015646 15246557273 023171 0 ustar carsten carsten # Development workflow
This document describes our workflow when developing features, fixing bugs and updating the documentation. It also includes guidelines for pull requests on GitHub.
## Features and bug fixes
The development worklow is rather usual.
**For a new feature:**
1. create a new branch: `git switch -c feat-summary`
2. edit the code and the documentation
3. write new tests
**For a bug fix:**
1. create a new branch: `git switch -c fix-summary`
2. write tests that fail but are expected to pass once the bug is fixed
3. run [`make test`][task-test] to make sure the new tests fail
4. fix the code
**For a docs update:**
1. create a new branch: `git switch -c docs-summary`
2. start the live reloading server: `make docs` (1)
3. update the documentation
4. preview changes at http://localhost:8000
1. To speed-up the live reloading, disable mkdocstrings with `MKDOCSTRINGS_ENABLED=false make docs`.
**Before committing:**
1. run [`make format`][task-format] to auto-format the code
2. run [`make check`][task-check] to check everything (fix any warning)
3. run [`make test`][task-test] to run the tests (fix any issue)
4. if you updated the documentation or the project dependencies:
1. run [`make docs`][task-docs]
2. go to http://localhost:8000 and check that everything looks good
Once you are ready to commit, follow our [commit message convention](#commit-message-convention).
NOTE: **Occasional contributors**
If you are unsure about how to fix or ignore a warning, just let the continuous integration fail, and we will help you during review. Don't bother updating the changelog, we will take care of this.
## Breaking changes and deprecations
Breaking changes should generally be avoided. If we decide to add a breaking change anyway, we should first allow a deprecation period. To deprecate parts of the API, check [Griffe's hints on how to deprecate things](../users/checking.md).
Use [`make check-api`][task-check-api] to check if there are any breaking changes. All of them should allow deprecation periods. Run this command again until no breaking changes are detected.
Deprecated code should also be marked as legacy code. We use [Yore](https://pawamoy.github.io/yore/) to mark legacy code. Similarly, code branches made to support older version of Python should be marked as legacy code using Yore too.
Examples:
```python title="Remove function when we bump to 5.0"
# YORE: Bump 5: Remove block.
def deprecated_function():
...
```
```python title="Simplify imports when Python 3.15 is EOL"
# YORE: EOL 3.15: Replace block with line 4.
try:
import ...
except ImportError:
import ...
```
Check [Yore's docs](https://pawamoy.github.io/yore/), and Yore-comments in our own code base (`git grep -A1 YORE`) to learn how to use it.
NOTE: **Occasional contributors**
If you are unsure about how to deprecate something or mark legacy code, let us do it during review.
## Commit message convention
Commit messages must follow our convention based on the [Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html):
```text
type(scope): Subject
Body.
```
**Subject and body must be valid Markdown.** Subject must have proper casing (uppercase for first letter if it makes sense), but no dot at the end, and no punctuation in general. Example:
```text
feat: Add CLI option to run in verbose mode
```
Scope and body are optional. Type can be:
- `build`: About packaging, building wheels, etc.
- `chore`: About packaging or repo/files management.
- `ci`: About Continuous Integration.
- `deps`: Dependencies update.
- `docs`: About documentation.
- `feat`: New feature.
- `fix`: Bug fix.
- `perf`: About performance.
- `refactor`: Changes that are not features or bug fixes.
- `style`: A change in code style/format.
- `tests`: About tests.
If you write a body, please add trailers at the end (for example issues and PR references, or co-authors), without relying on GitHub's flavored Markdown:
```text
This is the body of the commit message.
Issue-10: https://github.com/namespace/project/issues/10
Related-to-PR-namespace/other-project#15: https://github.com/namespace/other-project/pull/15
```
These "trailers" must appear at the end of the body, without any blank lines between them. The trailer title can contain any character except colons `:`. We expect a full URI for each trailer, not just GitHub autolinks (for example, full GitHub URLs for commits and issues, not the hash or the #issue-number).
We do not enforce a line length on commit messages summary and body.
NOTE: **Occasional contributors**
If this convention seems unclear to you, just write the message of your choice, and we will rewrite it ourselves before merging.
## Pull requests guidelines
Link to any related issue in the Pull Request message.
During the review, we recommend using fixups:
```bash
# SHA is the SHA of the commit you want to fix
git commit --fixup=SHA
```
Once all the changes are approved, you can squash your commits:
```bash
git rebase -i --autosquash main
```
And force-push:
```bash
git push -f
```
NOTE: **Occasional contributors**
If this seems all too complicated, you can push or force-push each new commit, and we will squash them ourselves if needed, before merging.
## Release process
Occasional or even regular contributors don't *have* to read this, but can anyway if they are interested in our release process.
Once we are ready for a new release (a few bugfixes and/or features merged in the main branch), maintainers should update the changelog. If our [commit message convention](workflow.md#commit-message-convention) was properly followed, the changelog can be automatically updated from the messages in the Git history with [`make changelog`][task-changelog]. This task updates the changelog in place to add a new version entry.
Once the changelog is updated, maintainers should review the new version entry, to:
- (optionally) add general notes for this new version, like highlights
- insert **Breaking changes** and **Deprecations** sections if needed, before other sections
- add links to the relevant parts of the documentation
- fix typos or markup if needed
Once the changelog is ready, a new release can be made with [`make release`][task-release]. If the version wasn't passed on the command-line with `make release version=x.x.x`, the task will prompt you for it. **Use the same version as the one that was just added to the changelog.** For example if the new version added to the changelog is `7.8.9`, use `make release version=7.8.9`.
The [release task][task-release] will stage the changelog, commit, tag, push, then build distributions and upload them to PyPI.org, and finally deploy the documentation. If any of these steps fail, you can manually run each step with Git commands, then [`make build`][task-build], [`make publish`][task-publish] and [`make docs-deploy`][task-docs-deploy].
python-griffe-2.3.0/docs/guide/contributors/architecture.md 0000664 0001750 0001750 00000027422 15246557273 023774 0 ustar carsten carsten # Project architecture
This document describes how the project is architectured, both regarding boilerplate and actual code. We start by giving an overview of the project's contents:
```python exec="1" session="filetree"
from fnmatch import fnmatch
from pathlib import Path
exclude = {"dist", "*cache*", ".devbox", ".hypothesis", ".pdm*", ".coverage*", "profile.*", ".gitpod*"}
no_recurse = {".venv*", "site", "htmlcov", ".git", "fixtures"}
descriptions = {
".github": "GitHub workflows, issue templates and other configuration.",
".venv": "The default virtual environment (git-ignored). See [`make setup`][command-setup] command.",
".venvs": "The virtual environments for all supported Python versions (git-ignored). See [`make setup`][command-setup] command.",
".vscode": "The configuration for VSCode (git-ignored). See [`make vscode`][command-vscode] command.",
"docs": "Documentation sources (Markdown pages). See [`make docs`][task-docs] task.",
"docs/.overrides": "Customization of [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/)' templates.",
"docs/reference/api": "Python API reference, injected with [mkdocstrings](https://mkdocstrings.github.io/).",
"config": "Contains our tooling configuration. See [Scripts, configuration](#scripts-configuration).",
"htmlcov": "HTML report for Python code coverage (git-ignored), integrated in the [Coverage report](../coverage/) page. See [`make coverage`][task-coverage] task.",
"scripts": "Our different scripts. See [Scripts, configuration](#scripts-configuration).",
"site": "Documentation site, built with `make run mkdocs build` (git-ignored).",
"src": "The source of our Python package(s). See [Sources](#sources) and [Program structure](#program-structure).",
"src/griffe": "Our public API, exposed to users. See [Program structure](#program-structure).",
"packages/griffelib/src/griffe/_internal": "Our internal API, hidden from users. See [Program structure](#program-structure).",
"packages/griffecli/tests": "Our test suite. See [Tests](#tests).",
"packages/griffelib/tests": "Our test suite. See [Tests](#tests).",
".copier-answers.yml": "The answers file generated by [Copier](https://copier.readthedocs.io/en/stable/). See [Boilerplate](#boilerplate).",
"devdeps.txt": "Our development dependencies specification. See [`make setup`][command-setup] command.",
"duties.py": "Our project tasks, written with [duty](https://pawamoy.github.io/duty). See [Tasks][tasks].",
".envrc": "The environment configuration, automatically sourced by [direnv](https://direnv.net/). See [commands](../commands/).",
"Makefile": "A dummy makefile, only there for auto-completion. See [commands](../commands/).",
"mkdocs.yml": "The build configuration for our docs. See [`make docs`][task-docs] task.",
"pyproject.toml": "The project metadata and production dependencies.",
}
def exptree(path):
files = []
dirs = []
for node in Path(path).iterdir():
if any(fnmatch(node.name, pattern) for pattern in exclude):
continue
if node.is_dir():
dirs.append(node)
else:
files.append(node)
annotated = []
recurse = set()
print("```tree")
for directory in sorted(dirs):
if not any(fnmatch(directory.name, pattern) for pattern in no_recurse):
recurse.add(directory)
annotated.append(directory)
print(f"{directory.name}/ # ({len(annotated)})!")
elif str(directory) in descriptions:
annotated.append(directory)
print(f"{directory.name}/ # ({len(annotated)})!")
else:
print(f"{directory.name}/")
for file in sorted(files):
if str(file) in descriptions:
annotated.append(file)
print(f"{file.name} # ({len(annotated)})!")
else:
print(file.name)
print("```\n")
for index, node in enumerate(annotated, 1):
print(f"{index}. {descriptions.get(str(node), '')}\n")
if node.is_dir() and node in recurse:
print(' ```python exec="1" session="filetree" idprefix=""')
print(f' exptree("{node.as_posix()}")')
print(" ```\n")
```
```python exec="1" session="filetree" idprefix="" id="filetree"
exptree(".")
```
## Boilerplate
This project's skeleton (the file-tree shown above) is actually generated from a [Copier](https://copier.readthedocs.io/en/stable/) template called [copier-uv](https://pawamoy.github.io/copier-uv/). When generating the project, Copier asks a series of questions (configured by the template itself), and the answers are used to render the file and directory names, as well as the file contents. Copier also records answers in the `.copier-answers.yml` file, allowing to update the project with latest changes from the template while reusing previous answers.
To update the project (in order to apply latest changes from the template), we use the following command:
```bash
copier update --trust --skip-answered
```
## Scripts, configuration
We have a few scripts that let us manage the various maintenance aspects for this project. The entry-point is the `make` script located in the `scripts` folder. It doesn't need any dependency to be installed to run. See [Management commands](commands.md) for more information.
The `make` script can also invoke what we call "[tasks][]". Tasks need our development dependencies to be installed to run. These tasks are written in the `duties.py` file, and the development dependencies are listed in `devdeps.txt`.
The tools used in tasks have their configuration files stored in the `config` folder, to unclutter the root of the repository. The tasks take care of calling the tools with the right options to locate their respective configuration files.
## Sources
Sources are located in the `packages/` subfolders, following the [src-layout](https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/).
We use [Hatch](https://hatch.pypa.io/latest/) to build source and wheel distributions, and configure it in `pyproject.toml`.
## Tests
Our test suite is located in the `tests` folder. It is located outside of the sources as to not pollute distributions (it would be very wrong to publish a `tests` package as part of our distributions, since this name is extremely common), or worse, the public API. The `tests` folder is however included in our source distributions (`.tar.gz`), alongside most of our metadata and configuration files. Check out our `pyproject.toml` files to get the full list of files included in our source distributions for every individual package within the `packages/` folder.
The test suite is based on [pytest](https://docs.pytest.org/en/8.2.x/). Test modules reflect our internal API structure, and except for a few test modules that test specific aspects of our API, each test module tests the logic from the corresponding module in the internal API. For example, `test_finder.py` tests code of the `griffe._internal.finder` internal module, while `test_functions` tests our ability to extract correct information from function signatures, statically. The general rule of thumb when writing new tests is to mirror the internal API. If a test touches to many aspects of the loading process, it can be added to the `test_loader` test module.
## Program structure
Griffe is split into two pieces: the library and the CLI.
Each of them has an internal API contained within an `_internal` folder:
- `packages/griffelib/src/griffe/_internal` for the library,
- `packages/griffecli/src/griffecli/_internal` for the CLI.
Griffe can be installed in library-only mode, which means that the CLI package from `packages/griffecli` is not present.
Library-only mode can be preferred if the user does not utilize the CLI functionality of Griffe and does not want to incorporate its dependencies.
The top-level `packages/griffelib/src/griffe/__init__.py` module exposes all the public API available: it always re-exports internal objects from various submodules of `griffe._internal` and, if the CLI is installed, it re-exports the public API of `griffecli` as well.
Users then import `griffe` directly, or import objects from it. If they don't have `griffecli` installed, they cannot import the CLI-related functionality,
such as [`griffecli.check`][].
We'll be honest: our code organization is not the most elegant, but it works :shrug: Have a look at the following module dependency graph, which will basically tell you nothing except that we have a lot of inter-module dependencies. Arrows read as "imports from". The code base is generally pleasant to work with though.
```python exec="true" html="true" id="pydeps-module-graph"
import os
if os.getenv("DEPLOY") == "true":
from pydeps import cli, colors, dot, py2depgraph
from pydeps.pydeps import depgraph_to_dotsrc
from pydeps.target import Target
cli.verbose = cli._not_verbose
options = cli.parse_args(["packages/griffelib/src/griffe", "--noshow", "--reverse"])
colors.START_COLOR = 128
target = Target(options["fname"])
with target.chdir_work():
dep_graph = py2depgraph.py2dep(target, **options)
dot_src = depgraph_to_dotsrc(target, dep_graph, **options)
svg = dot.call_graphviz_dot(dot_src, "svg").decode()
svg = "".join(svg.splitlines()[6:])
svg = svg.replace('fill="white"', 'fill="transparent"')
print(f'{svg}
')
```
You can zoom and pan all diagrams on this page with mouse inputs.
The following sections are generated automatically by iterating on the modules of our public and internal APIs respectively, and extracting the comment blocks at the top of each module. The comment blocks are addressed to readers of the code (maintainers, contributors), while module docstrings are addressed to users of the API. Module docstrings in our internal API are never written, because our [module layout][module-layout] is hidden, and therefore modules aren't part of the public API, so it doesn't make much sense to write "user documentation" in them.
```python exec="1" session="comment_blocks"
--8<-- "scripts/gen_structure_docs.py"
```
### CLI entrypoint
```python exec="1" idprefix="entrypoint-" session="comment_blocks" id="entrypoint"
render_entrypoint(heading_level=4)
```
### Public API
```python exec="1" idprefix="public-" session="comment_blocks" id="public-api"
render_public_api(heading_level=4)
```
### Internal API
```python exec="1" idprefix="internal-" session="comment_blocks" id="internal-api"
import os
if os.getenv("DEPLOY") == "true":
render_internal_api(heading_level=4)
```
python-griffe-2.3.0/docs/guide/contributors/commands.md 0000664 0001750 0001750 00000015005 15246557273 023105 0 ustar carsten carsten # Management commands
The entry-point to run commands to manage the project is our Python `make` script, located in the `scripts` folder. You can either call it directly with `./scripts/make`, or you can use [direnv](https://direnv.net/) to add the script to your command line path. Once direnv is installed and hooked into your shell, allow it once for this directory with `direnv allow`. Now you can directly call the Python script with `make`. The `Makefile` is just here to provide auto-completion.
Try typing `make` or `make help` to show the available commands.
```console exec="1" source="console" id="make-help1"
$ alias make="$PWD/scripts/make"; cd # markdown-exec: hide
$ make
```
## Commands
Commands are always available: they don't require any Python dependency to be installed.
[](){#command-setup}
### `setup`
::: make.setup
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#command-help}
### `help`
::: make.help
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#command-run}
### `run`
::: make.run
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#command-multirun}
### `multirun`
::: make.multirun
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#command-allrun}
### `allrun`
::: make.allrun
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#command-3.x}
### `3.x`
::: make.run3x
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#command-clean}
### `clean`
::: make.clean
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#command-vscode}
### `vscode`
::: make.vscode
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
## Tasks
Tasks require the Python dependencies to be installed. They use various tools and libraries to assert code quality, run tests, serve the documentation locally, or build and publish distributions of your project. There are multiple ways to run tasks:
- `make TASK`, the main, configured way to run a task
- `make run duty TASK`, to run a task in the default environment
- `make multirun duty TASK`, to run a task on all supported Python versions
- `make allrun duty TASK`, to run a task in *all* environments
- `make 3.x duty TASK`, to run a task on a specific Python version
[](){#task-build}
### `build`
::: duties.build
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-changelog}
### `changelog`
::: duties.changelog
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-check}
### `check`
::: duties.check
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-check-api}
### `check-api`
::: duties.check_api
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-check-docs}
### `check-docs`
::: duties.check_docs
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-check-quality}
### `check-quality`
::: duties.check_quality
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-check-types}
### `check-types`
::: duties.check_types
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-coverage}
### `coverage`
::: duties.coverage
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-docs}
### `docs`
::: duties.docs
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-docs-deploy}
### `docs-deploy`
::: duties.docs_deploy
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-format}
### `format`
::: duties.format
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-fuzz}
### `fuzz`
::: duties.fuzz
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-publish}
### `publish`
::: duties.publish
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-release}
### `release`
::: duties.release
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
[](){#task-test}
### `test`
::: duties.test
options:
heading_level: 3
show_root_heading: false
show_root_toc_entry: false
separate_signature: false
parameter_headings: false
python-griffe-2.3.0/docs/guide/contributors/setup.md 0000664 0001750 0001750 00000006254 15246557273 022452 0 ustar carsten carsten # Environment setup
To work on the project, whether to update the code or the documentation, you will have to setup a development environment.
## Requirements
The only requirement is that you have [Python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/) installed and available on your command line path.
=== ":simple-astral: official installer"
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
See [Installation methods](https://docs.astral.sh/uv/getting-started/installation/).
=== ":simple-python: pip"
```bash
pip install --user uv
```
[pip](https://pip.pypa.io/en/stable/) is the main package installer for Python.
=== ":simple-pipx: pipx"
```bash
pipx install uv
```
[pipx](https://pipx.pypa.io/stable/) allows to install and run Python applications in isolated environments.
=== ":simple-rye: rye"
```bash
rye install uv
```
[Rye](https://rye.astral.sh/) is an all-in-one solution for Python project management, written in Rust.
Optionally, we recommend using [direnv](https://direnv.net/), which will add our `scripts` folder to your path when working on the project, allowing to call our `make` Python script with the usual `make` command.
## Fork and clone
[Fork the repository on GitHub](https://github.com/mkdocstrings/griffe/fork), then clone it locally:
=== "GitHub CLI"
```bash
gh repo clone griffe
```
The [`gh` GitHub CLI](https://cli.github.com/) allows you to interact with GitHub on the command line.
=== "Git + SSH"
```bash
git clone git@github.com:your-username/griffe
```
See the documentation on GitHub for [Connecting with SSH](https://docs.github.com/en/authentication/connecting-to-github-with-ssh) and for [Cloning a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository).
=== "Git + HTTPS"
```bash
git clone https://github.com/your-username/griffe
```
See the documentation on GitHub for [Cloning a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository).
## Install dependencies
First, enter the repository.
If you installed [direnv](https://direnv.net/):
- run `direnv allow`
- run `make setup`
If you didn't install [direnv](https://direnv.net/), just run `./scripts/make setup`.
The setup command will install all the Python dependencies required to work on the project. This command will create a virtual environment in the `.venv` folder, as well as one virtual environment per supported Python version in the `.venvs/3.x` folders. If you cloned the repository on the same file-system as [uv](https://docs.astral.sh/uv/)'s cache, everything will be hard linked from the cache, so don't worry about wasting disk space.
## IDE setup
If you work in VSCode, we provide [a command to configure VSCode](commands.md/#vscode) for the project.
python-griffe-2.3.0/docs/guide/contributors.md 0000664 0001750 0001750 00000002271 15246557273 021305 0 ustar carsten carsten # Contributor guide
Welcome to the Griffe contributor guide! If you are familiar with Python tooling, development, and contributions to open-source projects, see the [TL;DR](#tldr) at the end, otherwise we recommend you walk through the following pages:
- [Environment setup](contributors/setup.md)
- [Management commands](contributors/commands.md)
- [Development workflow](contributors/workflow.md)
Regular contributors might be interested in the following documents that explain Griffe's design and inner workings:
- [Architecture](contributors/architecture.md)
If you are unsure about what to contribute to, you can check out [our issue tracker](https://github.com/mkdocstrings/griffe/issues) to see if some issues are interesting to you, or you could check out [our coverage report](contributors/coverage.md) to help us cover more of the codebase with tests.
## TL;DR
- Install [Python](https://www.python.org/), [uv](https://docs.astral.sh/uv/) and [direnv](https://direnv.net/)
- Fork, clone, and enter repository
- Run `direnv allow` and `make setup`
- Checkout a new branch
- Edit code, tests and/or documentation
- Run `make format check test docs` to check everything
- Commit, push, open PR
python-griffe-2.3.0/docs/guide/users/ 0000775 0001750 0001750 00000000000 15246557273 017365 5 ustar carsten carsten python-griffe-2.3.0/docs/guide/users/how-to/ 0000775 0001750 0001750 00000000000 15246557273 020602 5 ustar carsten carsten python-griffe-2.3.0/docs/guide/users/how-to/support-decorators.md 0000664 0001750 0001750 00000006363 15246557273 025013 0 ustar carsten carsten # Supporting custom decorators
Griffe aims to support the Python language itself, as well as its standard library. It means that built-in objects and objects from the standard library that can be used or are often used as decorators, should be supported natively or through official extensions, for example `@property`, `@functools.cache`, `@warnings.deprecated`, etc.
Custom decorators however (the ones you define in your code-base) won't be supported by default, at least statically (dynamic analysis might be able to support them), because Griffe doesn't try to infer anything more than the obvious. Griffe is not a type-checker and so doesn't have the same inference abilities.
Therefore, to support your own decorators (at least statically), you have to write Griffe extensions. Don't worry, extensions that support custom decorators are generally super easy to write.
---
Lets assume we have a decorator whose path is `my_package.utils.enhance`. It is used throughout our code base like so:
```python
from my_package.utils import enhance
@enhance
def my_function() -> ...:
...
```
Start by creating an extensions module (a simple Python file) somewhere in your repository, if you don't already have one. Within it, create an extension class:
```python
import griffe
class MyDecorator(griffe.Extension):
"""An extension to support my decorator."""
```
Now we can declare the [`on_instance`][griffe.Extension.on_instance] hook, which receives any kind of Griffe object ([`Module`][griffe.Module], [`Class`][griffe.Class], [`Function`][griffe.Function], [`Attribute`][griffe.Attribute], [`TypeAlias`][griffe.TypeAlias]), or we could use a kind-specific hook such as [`on_module_instance`][griffe.Extension.on_module_instance], [`on_class_instance`][griffe.Extension.on_class_instance], [`on_function_instance`][griffe.Extension.on_function_instance], [`on_attribute_instance`][griffe.Extension.on_attribute_instance] and [`on_type_alias_instance`][griffe.Extension.on_type_alias_instance]. For example, if you know your decorator is only ever used on class declarations, it would make sense to use `on_class_instance`.
For the example, lets use the `on_function_instance` hook, which receives `Function` instances.
```python hl_lines="7-8"
import griffe
class MyDecorator(griffe.Extension):
"""An extension to support my decorator."""
def on_function_instance(self, *, func: griffe.Function, **kwargs) -> None:
...
```
In this hook, we check if our function is decorated with our custom decorator:
```python hl_lines="8-10"
import griffe
class MyDecorator(griffe.Extension):
"""An extension to support my decorator."""
def on_function_instance(self, *, func: griffe.Function, **kwargs) -> None:
for decorator in func.decorators:
if decorator.callable_path == "my_package.utils.enhance":
... # Update the function attributes.
```
Now all that is left to do is to actually write the code that updates the function according to what the decorator is doing. We could update the function's docstring, or its return type, or its parameters: it all depends on your decorator and what it does to the objects it decorates. Check out the [API reference for function objects][griffe.Function] to see what data this object stores.
python-griffe-2.3.0/docs/guide/users/how-to/set-docstring-styles.md 0000664 0001750 0001750 00000011062 15246557273 025232 0 ustar carsten carsten # Setting the right docstring style for every docstring
Griffe attaches the specified docstring style and parsing options to each object in the tree of the package(s) you load. If your package(s) use several docstring styles, some of these objects will have the wrong style attached to them. This is problematic because other Griffe extensions rely on this attached style to parse docstrings and modify them. We plan to alleviate this limitation in the future (see [issue-340](https://github.com/mkdocstrings/griffe/issues/340)), but the most robust thing you can do is to make sure each object has the *right style* attached, as easily as possible, so that other extensions can work without issue.
There are currently two ways to make sure objects have the right docstring style attached as early as possible:
1. Use the [`auto` docstring style](https://mkdocstrings.github.io/griffe/reference/docstrings/#auto-style). Griffe will use regular expressions to infer the docstring style used. 100% accuracy is impossible to achieve, so it's possible that you get incorrect styles for some objects.
2. Write and use a custom Griffe extension.
This how-to provides a few extension-based solutions to correctly set docstring styles in your packages. **Just make sure to enable these extensions in first position.**
## Markup comment
Depending on the markup you use in docstrings, you can add a comment that tells Griffe which docstring style to use.
=== "Markdown"
```python
def function():
"""Summary.
Body.
"""
```
=== "reStructuredText"
```python
def function():
"""Summary.
Body.
.. style: google
"""
```
Your Griffe extension can then use regular expressions to search for such comments. For example with Markdown (HTML) comments:
```python
import re
import griffe
class ApplyDocstringStyle(griffe.Extension):
def __init__(self, regex: str = "") -> None:
self.regex = re.compile(regex)
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.docstring:
if match := self.regex.search(obj.docstring.value):
obj.docstring.parser = match.group(1)
```
## Python comment
You could also decide to add a trailing comment to your docstrings to indicate which style to use.
```python
def function():
"""Summary.
Body.
""" # style: google
```
Your extension can then pick up this comment to assign the right style:
```python
import re
import griffe
class ApplyDocstringStyle(griffe.Extension):
def __init__(self, regex: str = ".*# style: (google|numpy|sphinx)$") -> None:
self.regex = re.compile(regex)
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.docstring:
if match := self.regex.search(obj.docstring.source):
obj.docstring.parser = match.group(1)
```
## Explicit configuration
Finally, you could decide to map a list of objects to the docstring style they should use. Your extension can either accept options, or it could hard-code that list:
```python
import griffe
from fnmatch import fnmatch
class ApplyDocstringStyle(griffe.Extension):
def __init__(self, config: dict[str, str]):
self.instances = {}
self.globs = {}
for key, value in config.items():
if "*" in key:
self.globs[key] = value
else:
self.instances[key] = value
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.path in self.instances:
if obj.docstring:
obj.docsring.parser = self.instances[obj.path]
else:
for pattern, style in self.globs:
if fnmatch(obj.path, pattern):
if obj.docstring:
obj.docstring.parser = style
```
Example configuration in MkDocs:
```yaml
plugins:
- mkdocstrings:
handlers:
python:
options:
extensions:
- your_griffe_extension.py:
config:
path.to.obj1: google
path.to.obj2: numpy
path.to.obj3.*: sphinx
path.to.obj4*: google
```
The benefit of this last solution is that it works for code you don't have control over. An alternative solution is to use the [griffe-autodocstringstyle extension](https://mkdocstrings.github.io/griffe/extensions/official/autodocstringstyle/), which automatically assigns the `auto` style to all objects coming from sources found in a virtual environment.
python-griffe-2.3.0/docs/guide/users/how-to/parse-docstrings.md 0000664 0001750 0001750 00000002434 15246557273 024416 0 ustar carsten carsten # Using Griffe as a docstring-parsing library
You can use Griffe to parse arbitrary docstrings. You don't have to load anything through the Griffe loader. You just need to import the [`Docstring`][griffe.Docstring] class. Then you can build a `Docstring` instance and call its `parse` method, choosing the parsing-style to use:
```python
from griffe import Docstring
text = "Hello I'm a docstring!"
docstring = Docstring(text, lineno=1)
parsed = docstring.parse("google")
```
If you want to take advantage of the parsers ability to fetch annotations from the object from which the docstring originates, you can manually create the parent objects and link them to the docstring:
```python
from griffe import Docstring, Function, Parameters, Parameter, ParameterKind
function = Function(
"func",
parameters=Parameters(
Parameter("param1", annotation="str", kind=ParameterKind.positional_or_keyword),
Parameter("param2", annotation="int", kind=ParameterKind.keyword_only),
),
)
text = """
Hello I'm a docstring!
Parameters:
param1: Description.
param2: Description.
"""
docstring = Docstring(text, lineno=1, parent=function)
parsed = docstring.parse("google")
```
With this the parser will fetch the `str` and `int` annotations from the parent function's parameters.
python-griffe-2.3.0/docs/guide/users/how-to/set-git-info.md 0000664 0001750 0001750 00000011543 15246557273 023435 0 ustar carsten carsten # Set Git information and source link on objects
Griffe tries to set [source information][source-information] on each package it loads. Sometimes it won't be able to find such information, or to find the correct information. In this case, you can programmatically set the right information with a Griffe extension. This will let you fix or customize the source links for many objects at once or for specific objects.
## Git information on whole packages
In this example we see how to set the Git information for whole packages. This will affect every object in these packages, and therefore the source link for each object.
Start by creating an extensions module (a simple Python file) somewhere in your repository, if you don't already have one. Within it, create an extension class:
```python
import griffe
class GitInfo(griffe.Extension):
"""An extension to set the right Git information."""
```
Next we hook onto the `on_package` event to override the `git_info` attribute of the packages we are interested into.
```python
from pathlib import Path
from typing import Any
import griffe
class GitInfo(griffe.Extension):
"""An extension to set the right Git information."""
def on_package(self, *, pkg: griffe.Module, **kwargs: Any) -> None:
if pkg.name == "my_package_name":
pkg.git_info = griffe.GitInfo(
repository=Path("/path/to/this/package/local/repository"),
service="forgejo",
remote_url="https://myhostedforge.mydomain.com/myaccount/myproject",
commit_hash="77f928aeab857cb45564462a4f849c2df2cca99a",
)
```
Here we hardcode the commit hash, but ideally we would obtain it by running a Git command in a subprocess, or any other way that gives a relevant commit hash.
```python
import subprocess
process = subprocess.run(["git", "-C", repo, "rev-parse", "HEAD"], text=True, capture_output=True)
commit_hash = process.stdout.strip()
```
We could also reuse properties that Griffe found:
```python
# Here we reuse `repository` and `commit_hash` while overriding only `service` and `remote_url`.
pkg.git_info = griffe.GitInfo(
repository=pkg.git_info.repository,
service="forgejo",
remote_url="https://myhostedforge.mydomain.com/myaccount/myproject",
commit_hash=pkg.git_info.commit_hash,
)
# We could also mutate the original `GitInfo` object:
pkg.git_info.service = "forgejo"
```
Now, with this extension enabled (see [Using extensions][using-extensions]), every object source link in our `my_package_name` package will be based on this Git information. For example, the source link for `my_package_name.my_function` would be something like `https://myhostedforge.mydomain.com/myaccount/myproject/src/commit/77f928aeab857cb45564462a4f849c2df2cca99a/src/my_package_name/__init__.py#L35-L48`.
## Source links on specific objects
Let say you expose Python objects in your API that are compiled from other sources (C extension, Pyo3 code, etc.). Let say you also know the filepath and line numbers for each of these compiled objects. With this information, you could fix the source link for these compiled objects so that they point to the actual sources, and not to the final modules, where the line numbers would be incorrect (or to nowhere since we wouldn't have line numbers in the first place).
Start by creating an extensions module (a simple Python file) somewhere in your repository, if you don't already have one. Within it, create an extension class:
```python
import griffe
class SourceLinks(griffe.Extension):
"""An extension to set the right source links."""
```
Next we hook onto the `on_object` event to override the `source_link` attribute of the objects we are interested into.
```python
from pathlib import Path
from typing import Any
import griffe
class SourceLinks(griffe.Extension):
"""An extension to set the right source links."""
def on_object(self, *, obj: griffe.Object, **kwargs: Any) -> None:
if obj.path == "my_package_name.my_function":
obj.source_link = "https://myhostedforge.mydomain.com/myaccount/myproject/src/commit/77f928aeab857cb45564462a4f849c2df2cca99a/src/lib.rs#L35-L48"
# Handle any other object you want.
elif ...:
...
```
Here we hardcode the link, but we can also reuse the Git information of the package to just correct the filepath and line numbers:
```python
from pathlib import Path
from typing import Any
import griffe
class SourceLinks(griffe.Extension):
"""An extension to set the right source links."""
def on_object(self, *, obj: griffe.Object, **kwargs: Any) -> None:
if obj.path == "my_package_name.my_function":
obj.source_link = obj.git_info.get_source_link(
filepath="src/lib.rs",
lineno=35,
endlineno=48,
)
# Handle any other object you want.
elif ...:
...
```
python-griffe-2.3.0/docs/guide/users/how-to/selectively-inspect.md 0000664 0001750 0001750 00000017326 15246557273 025130 0 ustar carsten carsten # Inspecting specific objects
Griffe by default parses and visits your code (static analysis) instead of importing it and inspecting objects in memory (dynamic analysis). There are various reasons why static analysis is generally a better approach, but sometimes it is insufficient to handle particularly dynamic objects. When this happens and Griffe cannot handle specific objects, you have a few solutions:
1. enable dynamic analysis for the whole package
2. write a Griffe extension that dynamically handles just the problematic objects
3. write a Griffe extension that statically handles the objects
This document will help you achieve point 2.
Enabling dynamic analysis for whole packages is [not recommended][forcing-dynamic-analysis-not-recommended], but it can be useful to do it once and check the results, to see if our dynamic analysis agent is able to handle your code natively. Whether it is or not is not very important, you will be able to move onto creating an extension that will selectively inspect the relevant objects in any case. It could just be a bit more difficult in the latter case, and if you have trouble writing the extension we invite you to create a [Q&A discussion](https://github.com/mkdocstrings/griffe/discussions/categories/q-a) to get guidance.
---
Start by creating an extensions module (a simple Python file) somewhere in your repository, if you don't already have one. Within it, create an extension class:
```python
import griffe
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
```
Make it accept configuration options by declaring an `__init__` method:
```python hl_lines="7-8"
import griffe
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
def __init__(self, objects: list[str]) -> None:
self.objects = objects
```
Here we choose to store a list of strings, where each string is an object path, like `module.Class.method`. Feel free to store different values to help you filter objects according to your needs. For example, maybe you want to inspect all functions with a given label, in that case you could accept a single string which is the label name. Or you may want to inspect all functions decorated with a specific decorator, etc.
With this `__init__` method, users (or simply yourself) will be able to configure the extension by passing a list of object paths. You could also hard-code everything in the extension if you don't want or need to configure it.
Now that our extension accepts options, we implement its core functionality. We assume that the static analysis agent is able to see the objects we are interested in, and will actually create instances that represent them (Griffe objects). Therefore we hook onto the `on_instance` event, which runs each time a Griffe object is created.
```python hl_lines="10-11"
import griffe
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
def __init__(self, objects: list[str]) -> None:
self.objects = objects
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
...
```
Check out the [available hooks][griffe.Extension] to see if there more appropriate hooks for your needs.
Lets now use our configuration option to decide whether to do something or skip:
```python hl_lines="11-12"
import griffe
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
def __init__(self, objects: list[str]) -> None:
self.objects = objects
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.path not in self.objects:
return
```
Now we know that only the objects we're interested in will be handled, so lets handle them.
```python hl_lines="3 16-20"
import griffe
logger = griffe.get_logger("griffe_inspect_specific_objects") # (1)!
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
def __init__(self, objects: list[str]) -> None:
self.objects = objects
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.path not in self.objects:
return
try:
runtime_obj = griffe.dynamic_import(obj.path)
except ImportError as error:
logger.warning(f"Could not import {obj.path}: {error}") # (2)!
return
```
1. We integrate with Griffe's logging (which also ensures integration with MkDocs' logging) by creating a logger. The name should look like a package name, with underscores.
2. We decide to log the exception as a warning (causing MkDocs builds to fail in `--strict` mode), but you could also log an error, or a debug message.
Now that we have a reference to our runtime object, we can use it to alter the Griffe object.
For example, we could use the runtime object's `__doc__` attribute, which could have been declared dynamically, to fix the Griffe object docstring:
```python hl_lines="22-25"
import griffe
logger = griffe.get_logger("griffe_inspect_specific_objects")
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
def __init__(self, objects: list[str]) -> None:
self.objects = objects
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.path not in self.objects:
return
try:
runtime_obj = griffe.dynamic_import(obj.path)
except ImportError as error:
logger.warning(f"Could not import {obj.path}: {error}")
return
if obj.docstring:
obj.docstring.value = runtime_obj.__doc__
else:
obj.docstring = griffe.Docstring(runtime_obj.__doc__)
```
Or we could alter the Griffe object parameters in case of functions, which could have been modified by a signature-changing decorator:
```python hl_lines="1 23-27"
import inspect
import griffe
logger = griffe.get_logger("griffe_inspect_specific_objects")
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
def __init__(self, objects: list[str]) -> None:
self.objects = objects
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.path not in self.objects:
return
try:
runtime_obj = griffe.dynamic_import(obj.path)
except ImportError as error:
logger.warning(f"Could not import {obj.path}: {error}")
return
# Update default values modified by decorator.
signature = inspect.signature(runtime_obj)
for param in signature.parameters:
if param.name in obj.parameters:
obj.parameters[param.name].default = repr(param.default)
```
We could also entirely replace the Griffe object obtained from static analysis by the same one obtained from dynamic analysis:
```python hl_lines="14-25"
import griffe
class InspectSpecificObjects(griffe.Extension):
"""An extension to inspect just a few specific objects."""
def __init__(self, objects: list[str]) -> None:
self.objects = objects
def on_instance(self, *, obj: griffe.Object, **kwargs) -> None:
if obj.path not in self.objects:
return
inspected_module = griffe.inspect(obj.module.path, filepath=obj.filepath)
obj.parent.set_member(obj.name, inspected_module[obj.name]) # (1)!
```
1. This assumes the object we're interested in is declared at the module level.
python-griffe-2.3.0/docs/guide/users/loading.md 0000664 0001750 0001750 00000050365 15246557273 021335 0 ustar carsten carsten # Loading APIs
Griffe can load API data from both source code (static analysis) and objects at runtime through introspection (dynamic analysis). Both static and dynamic analysis can be used at the same time: Griffe will first try to find sources, and will fall back to introspection if it cannot find any. When Griffe finds compiled modules within a packages, it uses introspection again to extract API information. There are various options to configure how Griffe loads data, for example to force or disallow dynamic analysis, but first let see the interface.
## The `load` function
The main interface to load API data is Griffe's [`load`][griffe.load] function:
```python
import griffe
my_package = griffe.load("my_package")
```
You can ask to load a specific object rather than a package:
```python
import griffe
my_method = griffe.load("my_package.MyClass.my_method")
```
Griffe will load the whole package anyway, but return the specified object directly, so that you don't have to access it manually. To manually access the object representing the method called `my_method`, you would have used the `my_package` variable instantiated before, like this:
```python
my_method = my_package["MyClass.my_method"]
```
The [Navigating](navigating.md) topic will show you all the ways Griffe objects can be navigated.
Finally, you can even load packages or modules by passing absolute or relative file paths. This is useful when the module or package is not installed within the current Python environment and therefore cannot be found in the default search paths (see [Search paths](#search-paths) below).
```python
import griffe
griffe.load("src/my_package")
griffe.load("some_script.py")
```
In case of ambiguity, you can instruct Griffe to ignore existing relative file paths with `try_relative_paths=False`. For example, when using [the flat layout (in contrast to the src-layout)](https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/), your Python package is in the root of the repository.
```tree
./
my_package/
__init__.py
pyproject.toml
```
Here if you ask Griffe to load `my_package`, it will find it as a relative path, in `./my_package`. If you want Griffe to use the version installed in your environment's site packages instead, set `try_relative_path` to false:
```python
import griffe
my_installed_package = griffe.load("my_package", try_relative_path=False)
```
## The `GriffeLoader` class
The [`load`][griffe.load] function is a shortcut for instantiating the [`GriffeLoader`][griffe.GriffeLoader] class and calling its [`load`][griffe.GriffeLoader.load] method. Calling the [`load`][griffe.load] function multiple times will instantiate a new Griffe loader each time. If you care about efficiency, it is better to instantiate the loader yourself and use its `load` method:
```python
import griffe
loader = GriffeLoader()
my_package = loader.load("my_package")
my_other_package = loader.load("my_other_package")
```
Keeping a reference to the loader will reduce the number of IO operations on the file-system, as the contents of the directories that the loader searches into will be cached (only the lists of files and directories will be cached, not the file contents).
Reusing the same loader will also help resolving aliases across different packages. See [Alias resolution](#alias-resolution) below.
## Search paths
To specify in which directories Griffe should search for packages and modules, you can use the `search_paths` parameter on both the [`load` function][griffe.load] and the [`GriffeLoader` class][griffe.GriffeLoader].
=== "`load`"
```python
import griffe
my_package = griffe.load("my_package", search_paths=["src"])
```
=== "`GriffeLoader`"
```python
import griffe
loader = GriffeLoader(search_paths=["src"])
my_package = loader.load("my_package")
```
By default it will search in the paths found in [`sys.path`][sys.path], which can be influenced through the [`PYTHONPATH`][PYTHONPATH] environment variable.
If Griffe cannot find sources for the specified object in the given search paths, it will try to import the specified object and use dynamic analysis on it (introspection). See [Forcing dynamic analysis](#forcing-dynamic-analysis) and [Disallowing dynamic analysis](#disallowing-dynamic-analysis).
## Preferring stub docstrings
When Griffe finds both a Python source file (`.py`) and its corresponding stub file (`.pyi`), it merges the information from both files. By default, a docstring from the source file takes precedence, and a docstring from the stub is only used when the matching object in the source has no docstring.
If your stubs are the authoritative source of documentation, set `prefer_stubs_docs=True`. A docstring from a stub will then replace the corresponding source docstring. If the stub has no docstring, the source docstring is kept.
=== "`load`"
```python
import griffe
my_package = griffe.load("my_package", prefer_stubs_docs=True)
```
=== "`GriffeLoader`"
```python
import griffe
loader = griffe.GriffeLoader(prefer_stubs_docs=True)
my_package = loader.load("my_package")
```
=== "CLI"
```bash
griffe dump my_package --prefer-stubs-docstrings
```
The same Python option is available on [`load_git`][griffe.load_git] and [`load_pypi`][griffe.load_pypi], while the `-P`/`--prefer-stubs-docstrings` CLI flag is shared by the `dump` and `check` commands. The option only controls which docstring wins when source and stub data are merged. To search for a separately installed stubs-only package, also set `find_stubs_package=True` in Python or pass `-B`/`--find-stubs-packages` on the command line.
## Forcing dynamic analysis
Griffe always tries first to find sources for the specified object. Then, unless told otherwise, it uses static analysis to load API data, i.e. it parses the sources and visits the AST (Abstract Syntax Tree) to extract information. If for some reason you want Griffe to use dynamic analysis instead (importing and inspecting runtime objects), you can pass the `force_inspection=True` argument:
```python
import griffe
my_package = griffe.load("my_package", force_inspection=True)
```
[](){#forcing-dynamic-analysis-not-recommended}
Forcing inspection can be useful when your code is highly dynamic, and static analysis has trouble keeping up.
**However we don't recommend forcing inspection**, for a few reasons:
- dynamic analysis requires that you either mock your dependencies, or install them
- dynamic analysis will **execute code**, possibly ***arbitrary code*** if you import third-party dependencies, putting you at risk
- dynamic analysis will potentially consume more resources (CPU, RAM) since it executes code
- dynamic analysis will sometimes give you less precise or incomplete information
- it's possible to write Griffe extensions that will *statically handle* the highly dynamic parts of your code (like custom decorators) that Griffe doesn't understand by default
- if really needed, it's possible to [handle only a subset of objects with dynamic analysis](how-to/selectively-inspect.md), while the rest is loaded with static analysis, again thanks to Griffe extensions
The [Extending](extending.md) topic will explain how to write and use extensions for Griffe.
## Disallowing dynamic analysis
If you want to be careful about what gets executed in the current Python process, you can choose to disallow dynamic analysis by passing the `allow_inspection=False` argument. If Griffe cannot find sources for a package, it will not try to import it and will instead fail with a `ModuleNotFoundError` directly.
```python
import griffe
# Here Griffe will fall back on dynamic analysis and import `itertools`.
griffe.load("itertools")
# While here it will raise `ModuleNotFoundError`.
griffe.load("itertools", allow_inspection=False)
```
## Alias resolution
>? QUESTION: **What's that?**
> In Griffe, indirections to objects are called *aliases*. These indirections, or aliases, represent two kinds of objects: imported objects and inherited objects. Indeed, an imported object is "aliased" in the module that imports it, while its true location is in the module it was imported from. Similarly, a method inherited from a parent class is "aliased" in the subclass, while its true location is in the parent class.
>
> The name "alias" comes from the fact that imported objects can be aliased under a different name: `from X import A as B`. In the case of inherited members, this doesn't really apply, but we reuse the concept for conciseness.
>
> An [`Alias`][griffe.Alias] instance is therefore a pointer to another object. It has its own name, parent, line numbers, and stores the path to the target object. Thanks to this path, we can access the actual target object and all its metadata, such as name, parent, line numbers, docstring, etc.. Obtaining a reference to the target object is what we call "alias resolution".
>
> **To summarize, alias resolution is a post-process task that resolves imports after loading everything.**
To resolve an alias, i.e. obtain a reference to the object it targets, we have to wait for this object to be loaded. Indeed, during analysis, objects are loaded in breadth-first order (in the object hierarchy, highest objects are loaded first, deepest ones are loaded last), so when we encounter an imported object, we often haven't loaded this object yet.
Once a whole package is loaded, we are ready to try and resolve all aliases. But we don't *have* to resolve them. First, because the user might not need aliases to be resolved, and second, because each alias can be resolved individually and transparently when accessing its target object properties.
Therefore, alias resolution is optional and enabled with the `resolve_aliases` parameter.
Lets take an example.
```tree title="File layout"
./
my_package/
__init__.py
my_module.py
```
```python title="my_package/__init__.py"
from my_package.my_module import my_function
```
```python title="my_package/my_module.py"
def my_function():
print("hello")
```
When loading this package, `my_package.my_function` will be an alias pointing at `my_package.my_module.my_function`:
```python
import griffe
my_package = griffe.load("my_package")
my_package["my_function"].resolved # False
```
```python
import griffe
my_package = griffe.load("my_package", resolve_aliases=True)
my_package["my_function"].resolved # True
my_package["my_function"].target is my_package["my_module.my_function"] # True
```
The [Navigating](navigating.md) topic will tell you more about aliases and how they behave.
### Modules collection
In the first section of this page, we briefly mentioned that Griffe always loads the entire package containing the object you requested. One of the reasons it always loads entire packages and not just single, isolated objects, is that alias resolution requires all objects of a package to be loaded. Which means that if an alias points to an object that is part of *another* package, it can only be resolved if the *other* package is *also loaded*. For example:
```tree title="File layout"
./
package1/
__init__.py
package2/
__init__.py
```
```python title="package1/__init__.py"
X = 0
```
```python title="package2/__init__.py"
from package1 import X
```
```pycon
>>> import griffe
>>> package2 = griffe.load("package2", resolve_aliases=True)
>>> package2["X"].target_path
'package1.X'
>>> package2["X"].resolved
False
>>> package2["X"].target
Traceback (most recent call last):
File "griffe/_internal/models.py", line 1375, in _resolve_target
resolved = self.modules_collection.get_member(self.target_path)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "griffe/_internal/mixins.py", line 84, in get_member
return self.members[parts[0]].get_member(parts[1:])
~~~~~~~~~~~~^^^^^^^^^^
KeyError: 'package1'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "", line 1, in
File "griffe/_internal/dataclasses.py", line 1310, in target
self.resolve_target()
File "griffe/_internal/dataclasses.py", line 1369, in resolve_target
self._resolve_target()
File "griffe/_internal/dataclasses.py", line 1377, in _resolve_target
raise AliasResolutionError(self) from error
griffe._internal.exceptions.AliasResolutionError: Could not resolve alias package2.X pointing at package1.X (in package2/__init__.py:1)
```
As you can see in the interpreter session above, Griffe did not resolve the `X` alias. When we tried to access its target object anyway, it failed with a `KeyError`, which was raised again as an [`AliasResolutionError`][griffe.AliasResolutionError].
Lets try again, but this time by loading both packages.
```pycon
>>> import griffe
>>> package1 = griffe.load("package1") # nothing to resolve
>>> package2 = griffe.load("package2", resolve_aliases=True)
>>> package2["X"].target_path
'package1.X'
>>> package2["X"].resolved
False # Hmm?
>>> package2["X"].target
Traceback (most recent call last):
...
griffe._internal.exceptions.AliasResolutionError: Could not resolve alias package2.X pointing at package1.X (in package2/__init__.py:1)
```
The same exception again? What happened here? We loaded both packages, but Griffe still failed to resolve the alias. That is expected; here is the explanation.
If you look closely at the first exception traceback, you will see that Griffe searched the target path in `self.modules_collection`. So what is this modules collection?
Each instance of [`GriffeLoader`][griffe.GriffeLoader] holds a reference to an instance of [`ModulesCollection`][griffe.ModulesCollection]. If you don't create such a collection manually to pass it to the loader, it will instantiate one itself. All objects loaded with this loader are added to this very modules collection, and gain a reference to it.
Since the [`load` function][griffe.load] is just a shortcut for creating a loader and calling its [`load` method][griffe.GriffeLoader.load], when we called `griffe.load(...)` twice, it actually created two distinct collections of modules. When Griffe tried to resolve aliases of `package2`, it looked for `package1` in `package2`'s collection, and couldn't find it. Indeed, `package1` was in another modules collection.
Therefore, to resolve aliases *across different packages*, these packages must be loaded within the same modules collection. In order to do that, you have a few options:
- instantiate a single loader, and use it to load both packages
- create your own modules collection, and pass it to the [`load` function][griffe.load] each time you call it
- create your own modules collection, and pass it to the different instances of [`GriffeLoader`][griffe.GriffeLoader] you create
=== "Same loader"
```pycon
>>> import griffe
>>> loader = griffe.GriffeLoader()
>>> package1 = loader.load("package1")
>>> package2 = loader.load("package2")
>>> loader.resolve_aliases()
>>> package2["X"].resolved
True
>>> package2["X"].target
Attribute('X', lineno=1, endlineno=1)
```
=== "Same collection with `load`"
```pycon
>>> import griffe
>>> collection = griffe.ModulesCollection()
>>> package1 = griffe.load("package1", modules_collection=collection)
>>> package2 = griffe.load("package2", modules_collection=collection, resolve_aliases=True)
>>> package2["X"].resolved
True
>>> package2["X"].target
Attribute('X', lineno=1, endlineno=1)
```
=== "Same collection, different loaders"
```pycon
>>> import griffe
>>> collection = griffe.ModulesCollection()
>>> loader1 = griffe.GriffeLoader(modules_collection=collection, ...)
>>> package1 = loader1.load("package1")
>>> loader2 = griffe.GriffeLoader(modules_collection=collection, ...) # different parameters
>>> package2 = loader2.load("package2")
>>> package2["X"].resolved
True
>>> package2["X"].target
Attribute('X', lineno=1, endlineno=1)
```
There is no preferred way, it depends on whether you need to instantiate different loaders with different parameters (search paths for example) while keeping every loaded module in the same collection, or if a single loader is enough, or if you explicitly need a reference to the collection, etc..
### Loading external packages automatically
By default, when resolving aliases, Griffe loaders will not be able to resolve aliases pointing at objects from "external" packages. By external, we mean that these packages are external to the current modules collection: they are not loaded. But sometimes users don't know in advance which packages need to be loaded in order to resolve aliases (and compute class inheritance). For these cases, Griffe loaders can be instructed to automatically load external packages. If we take the previous example again:
```python
import griffe
package2 = griffe.load("package2", resolve_aliases=True, resolve_external=True)
print(package2["X"].target.name) # X
```
Here Griffe automatically loaded `package1` while resolving aliases, even though we didn't explicitly load it ourselves.
While automatically resolving aliases pointing at external packages can be convenient, we advise cautiousness: this can trigger the loading of *a lot* of external packages, *recursively*.
One special case that we must mention is that Griffe will by default automatically load *private sibling packages*. For example, when resolving aliases for the `ast` module, Griffe will automatically try and load `_ast` too (if dynamic analysis is allowed, since this is a builtin module), even without `resolve_external=True`. If you want to prevent this behavior, you can pass `resolve_external=False` (it is `None` by default).
## Source information
By default, Griffe runs some Git commands to find the following information about a package:
- the repository local path
- the Git remote URL
- what service it corresponds to (GitHub, etc.)
- the current commit hash
It then assigns this information to each package it loads, in the [`git_info`][griffe.Object.git_info] attribute. This attribute can be reassigned on any object, if necessary. Each object who has it set to `None` will look into its parents.
In the following cases, the information will not be set:
- Griffe couldn't find the source for an object, or line numbers in the source
- the source of a package is not tracked within the identified repository
- Griffe cannot identify a known, supported service from the remote URL
- any Git command failed
Griffe supports the services listed in the [`KnownGitService`][griffe.KnownGitService] symbol. Please open a feature request if you would like to add support for other services.
Thanks to this source information, Griffe can then compute [source links][griffe.Object.source_link] for each objects, by combining the information with the object's filepath and line numbers.
You can globally change how Griffe obtains the source information with the following environment variables:
- `GRIFFE_GIT_REMOTE_URL`: It is the repository remote URL, as an HTTPS link that readers of your documentation can access to see the repository online, on the service it is hosted on. Example: `GRIFFE_GIT_REMOTE_URL=https://app.radicle.at/nodes/seed.radicle.at/rad:z4M5XTPDD4Wh1sm8iPCenF85J3z8Z`.
- `GRIFFE_GIT_REMOTE`: You can also let Griffe obtain the remote URL by getting it from the Git local configuration. The Git remote defaults to `origin`. This environment variable lets you change it to something else. Example: `GRIFFE_GIT_REMOTE=upstream`.
- `GRIFFE_GIT_SERVICE`: Griffe infers the service by looking at the remote URL. If the remote URL contains a [known service name][griffe.KnownGitService], Griffe will use it as service. You can otherwise explicitly set the service using this environment variable. Example: `GRIFFE_GIT_SERVICE=codeberg`.
- `GRIFFE_GIT_COMMIT_HASH`: Griffe gets the commit hash by running a Git command. If you prefer using another commit hash, you can set it using this environment variable. Example: `GRIFFE_GIT_COMMIT_HASH=77f928aeab857cb45564462a4f849c2df2cca99a`.
For more complex cases, see [How to programmatically set the correct Git information or source link on objects](how-to/set-git-info.md).
## Next steps
Now that the API is loaded, you can start [navigating it](navigating.md), [serializing it](serializing.md) or [checking for API breaking changes](checking.md). If you find out that the API data is incorrect or incomplete, you might want to learn how to [extend it](extending.md).
python-griffe-2.3.0/docs/guide/users/serializing.md 0000664 0001750 0001750 00000010575 15246557273 022237 0 ustar carsten carsten # Serializing APIs
Griffe can be used to load API data and output it as JSON on standard output or in writable files. An example of what real data looks like can be found here: [Griffe's full JSON dump](../../griffe.json). We also provide a [JSON schema](../../schema.json).
## Command-line
The easiest way to load and serialize API data is to use the command-line tool:
```console
$ griffe dump httpx fastapi
{
"httpx": {
"name": "httpx",
...
},
"fastapi": {
"name": "fastapi",
...
}
}
```
It will output a JSON-serialized version of the package's API data.
Try it out on Griffe itself:
```console
$ griffe dump griffe
{
"griffe": {
"name": "griffe",
...
}
}
```
To output in a file instead of standard output, use the `-o`, `--output` option:
```console
$ griffe dump griffe -o griffe.json
```
If you load multiple packages' signatures, you can dump each in its own file with a templated filepath:
```console
$ griffe dump griffe -o './dumps/{package}.json'
```
By default, Griffe will search in `sys.path`, so if you installed it through *pipx*, there are few chances it will find your packages. To explicitly specify search paths, use the `-s, --search ` option. You can use it multiple times. You can also add the search paths to the `PYTHONPATH` environment variable. If Griffe can't find the packages, it will fail with a `ModuleNotFoundError`.
See all the options for the `dump` command in the [CLI reference](../../reference/cli.md).
## Python API
If you have read through the [Navigating](navigating.md) chapter, you know about our six data models for modules, classes, functions, attributes, type aliases and aliases. Each one of these model provide the two following methods:
- [`as_json`][griffe.Object.as_json], which allows to serialize an object into JSON,
- [`from_json`][griffe.Object.from_json], which allows loading JSON back into a model instance.
These two methods are convenient wrappers around our [JSON encoder][griffe.JSONEncoder] and [JSON decoder][griffe.json_decoder]. The JSON encoder and decoder will give you finer-grain control over what you serialize or load, as the methods above are only available on data models, and not on sub-structures like decorators or parameters.
Under the hood, `as_json` just calls [`as_dict`][griffe.Object.as_dict], which converts the model instance into a dictionary, and then serializes this dictionary to JSON.
When serializing an object, by default the JSON will only contain the fields required to load it back to a Griffe model instance. If you are not planning on loading back the data into our data models, or if you want to load them in a different implementation which is not able to infer back all the other fields, you can choose to serialize every possible field. We call this a full dump, and it is enabled with the `full` option of the [encoder][griffe.JSONEncoder] or the [`as_json`][griffe.Object.as_json] method.
## Schema
For anything automated, we suggest relying on our [JSON schema](../../schema.json).
When serializing multiple packages with the `dump` command, you get a map with package names as keys. Map values are the serialized objects (modules, classes, functions, etc.). They are maps too, with field names and values as key-value pairs.
For example:
```json
{
"kind": "class",
"name": "Expr",
"lineno": 82,
"endlineno": 171,
"docstring": {
"value": "Base class for expressions.",
"lineno": 84,
"endlineno": 84
},
"labels": [
"dataclass"
],
"members": [
...
],
"bases": [],
"decorators": [
{
"value": {
"name": "dataclass",
"cls": "ExprName"
},
"lineno": 82,
"endlineno": 82
}
]
}
```
The `members` value, truncated here, just repeats the pattern: it's an array of maps. We use an array for members instead of a map to preserve order, which could be important to downstream tools.
The other fields do not require explanations, except maybe for expressions. You will sometimes notice deeply nested structures with `cls` keys. These are serialized Griffe [expressions](../../reference/api/expressions.md). They represent actual code.
## Next steps
That's it! There is not much to say about serialization. We are interested in getting your feedback regarding serialization as we didn't see it being used a lot. Next you might be interested in learning how to [check](checking.md) or [extend](extending.md) your API data.
python-griffe-2.3.0/docs/guide/users/recommendations/ 0000775 0001750 0001750 00000000000 15246557273 022554 5 ustar carsten carsten python-griffe-2.3.0/docs/guide/users/recommendations/python-code.md 0000664 0001750 0001750 00000035600 15246557273 025333 0 ustar carsten carsten # Python code best practices
This document describes some best practices to adopt when using Griffe, or when writing Python code that will be analyzed by Griffe.
## Avoid member-submodule name shadowing
Sometimes we find that an `__init__` module defines or imports an object which has the same name as a submodule of the parent package.
**Case 1**
```tree
package/
__init__.py
subpackage/
__init__.py
thing.py
```
```python title="package/subpackage/__init__.py"
thing = "thing from init module"
```
```python title="package/subpackage/thing.py"
other_thing = "other thing from thing submodule"
```
We recommend not doing that.
Why? Because the `package.subpackage.thing` submodule can eventually **shadow** the `package.subpackage.thing` attribute. Try this:
```bash
# Replicate the file tree from above.
mkdir -p package/subpackage
echo 'thing = "thing from init module"' > package/subpackage/__init__.py
echo 'other_thing = "other thing from thing submodule"' > package/subpackage/thing.py
# Run a Python interpreter.
python
```
```pycon
>>> from package import subpackage
>>> subpackage.thing
'thing from init module'
>>> # OK, but...
>>> from package.subpackage.thing import other_thing
>>> subpackage.thing
```
By simply importing from the `thing` submodule, the `thing` attribute of `subpackage` was overwritten by the `thing` submodule.
**Case 2**
In a particular case though, the situation improves: if we *import* `thing` in the init module instead of declaring it, then further imports will not overwrite anything:
```python title="package/subpackage/__init__.py"
from package.subpackage.thing import thing
```
```python title="package/subpackage/thing.py"
thing = "thing from thing submodule"
```
```bash
# Update the modules.
echo 'from package.subpackage.thing import thing' > package/subpackage/__init__.py
echo 'thing = "thing from thing submodule"' > package/subpackage/thing.py
# Run a Python interpreter.
python
```
```pycon
>>> from package import subpackage
>>> subpackage.thing
'thing from thing'
>>> # OK
>>> from package.subpackage.thing import thing
>>> subpackage.thing
'thing from thing'
>>> # Still OK
```
From an API perspective, and given that both cases are very similar but differ in behavior, we recommend not doing that either.
If the goal is to isolate a single object into its own module, to then expose it in the parent module, then it would make sense that this object is the only object of the submodule to be exposed in the public API, and therefore the submodule could be marked as private by prefixing its name with an underscore:
```tree
package/
__init__.py
subpackage/
__init__.py
_thing.py
```
With this, there is no ambiguity as to what `subpackage.thing` points to.
For the reasons mentioned above, **Griffe does not support this kind of name shadowing.** During static analysis, the submodule will take precedence over the attribute. During dynamic analysis, Griffe's behavior is undefined.
## Avoid wildcard imports
Wildcard imports allow importing from a module all objects that do not start with an underscore `_`, or all objects that are listed in the module's `__all__` attribute, if it is defined.
```tree
package/
__init__.py
module.py
```
**Explicitly exposed to wildcard imports**
```python title="package/module.py"
__all__ = [
"SomeClass",
"some_function",
"some_attribute",
]
class SomeClass: ...
class SomeOtherClass: ...
def some_function(): ...
def some_other_function(): ...
some_attribute = 0
some_other_attribute = 1
```
**Implicitly exposed to wildcard imports**
```python title="package/module.py"
class SomeClass: ...
class _SomeOtherClass: ...
def some_function(): ...
def _some_other_function(): ...
some_attribute = 0
_some_other_attribute = 1
```
In both cases, using a wildcard import will only import `SomeClass`, `some_function` and `some_attribute`, and not their "other" counterparts:
```python title="package/__init__.py"
from package.module import *
```
While we recommend declaring your public API with `__all__` lists, we do not recommend using wildcard imports.
In the implicit case, any other object imported in `module.py` will also be exported by the wildcard:
```python title="package/module.py"
from somewhere_else import this, that
class SomeClass: ...
class _SomeOtherClass: ...
def some_function(): ...
def _some_other_function(): ...
some_attribute = 0
_some_other_attribute = 1
```
Here, `this` and `that` will also be imported when we do `from package.module import *`. To prevent that, we would have to alias these names as such:
```python title="package/module.py"
from somewhere_else import this as _this, that as _that
```
...which is not ideal.
It gets even worse if `module.py` itself uses wildcard imports:
```python title="package/module.py"
from somewhere_else import *
```
Now using `from package.module import *` will import all objects that do not start with an underscore declared in the module, but also all the objects imported by it that do not start with an underscore, and also all the objects imported by the modules of the imported objects that do not start with an underscore, etc., recursively. Soon enough, we end up with dozens and dozens of objects exposed in `package`, while just a few of them are useful/meaningful to users.
Not only that, but it also increases the risk of creating cycles in imports. Python can handle some of these cycles, but static analysis tools such as Griffe can have a much harder time trying to resolve them.
In the explicit case, the situation improves, as only the objects listed in `__all__` will be exported to the modules that wildcard imports them. It effectively stops namespace pollution, but it does not remove the risk of cyclic imports, only decreases it.
We have seen code bases where parent modules wildcard imports from submodules, while these submodules also wildcard imports from the parent modules... Python somehow handles this, but it is *hell* to handle statically, and it is just too error prone (cyclic imports, name shadowing, namespaces become dependent on the order of imports, etc.).
For these reasons, we recommend not using wildcard imports. Instead, we recommend declaring your public API explicitly with `__all__`, and combining `__all__` lists together if needed:
```tree
package/
__init__.py
module.py
other_module.py
```
Completely explicit:
```python title="package/__init__.py"
from package.module import only, needed, objects
from package.other_module import some, more
__all__ = [
"only",
"needed",
"some",
"function",
]
def function(): ...
```
Combining `__all__` lists:
```python title="package/__init__.py"
from package.module import only, needed, objects, __all__ as module_all
from package.other_module import some, more, __all__ as other_module_all
__all__ = [
*module_all,
*other_module_all,
"function",
]
def function(): ...
```
Most Python linting tools allow you to forbid the use of wildcard imports.
## Prefer canonical imports
Within your own code base, we recommend using canonical imports. By canonical, we mean importing objects from the module they are declared in, and not from another module that also imports them.
Given the following tree:
```tree
package/
__init__.py
module_a.py
module_b.py
```
```python title="package/module_a.py"
from package.module_b import thing
```
```python title="package/module_b.py"
thing = True
```
Don't do that:
```python title="package/__init__.py"
from package.module_a import thing # Indirect import, bad.
```
Instead, do this:
```python title="package/__init__.py"
from package.module_b import thing # Canonical import, good.
```
---
We especially recommend canonical imports over indirect imports from sibling modules passing through the parent:
```python title="package/__init__.py"
from package.module_a import thing # Canonical import, good.
```
```python title="package/module_a.py"
thing = True
```
```python title="package/module_b.py"
from package import thing # Indirect import passing through parent, bad.
# Do this instead:
from package.module_a import thing # Canonical import, good.
```
---
Similarly, avoid exposing the API of external packages from your own package and recommending to use this indirect API.
```python title="package.py"
import numpy as np
__all__ = ["np"] # Bad.
# Recommending users to do `from package import np`
# or `import package; package.np.etc`: bad.
```
Instead, let users import Numpy themselves, with `import numpy as np`. This will help other analysis tools, for example to detect that Numpy is used directly and should therefore be listed as a dependency. To quote [PEP 8](https://peps.python.org/pep-0008/#public-and-internal-interfaces):
> Imported names should always be considered an implementation detail. Other modules must not rely on indirect access to such imported names unless they are an explicitly documented part of the containing module’s API, such as os.path or a package’s `__init__` module that exposes functionality from submodules.
Emphasis on *exposes functionality from submodules*: PEP 8 does not state *exposing functionality from external packages*.
---
Using canonical imports provides several benefits:
- it can reduce the risk of cyclic imports
- it can increase performance by reducing hoops and importing less things (for example by not passing through a parent module that imports many things from siblings modules)
- it makes the code more readable and easier to refactor (less indirections)
- it makes the life of static analysis tools easier (less indirections)
We recommend using the [canonical-imports](https://github.com/15r10nk/canonical-imports) tool to automatically rewrite your imports as canonical.
Note however that we recommend using public imports (importing from the "public" locations rather than the canonical ones) when:
- importing from other packages
- importing from your own package within your tests suite
Apply these recommendations at your discretion: there may be other special cases where it might not make sense to use canonical imports.
## Make your compiled objects tell their true location
Python modules can be written in other languages (C, C++, Rust) and compiled. To extract information from such compiled modules, we have to use dynamic analysis, since sources are not available.
A practice that seems common in projects including compiled modules in their distributions is to make the compiled modules private (prefix their names with an underscore), and to expose their objects from a public module higher-up in the module layout, for example by wildcard importing everything from it.
```tree
package/
__init__.py
module.py
_module.cpython-312-x86_64-linux-gnu.so
```
```python title="package/module.py"
from package._module import *
```
Since the objects are exposed in `package.module` instead of `package._module`, developers sometimes decide to make their compiled objects lie about their location, and make them say that they are defined in `package.module` instead of `package._module`. Example:
```pycon
>>> from package._module import MyObject
>>> MyObject.__module__
'package.module'
```
**Don't do that.**
When using dynamic analysis and inspecting modules, Griffe must distinguish objects that were declared in the inspected module from objects that were imported from other modules. The reason is that if we didn't care where objects come from, we could end up inspecting the same objects and their members again and again, since they can be imported in many places. This could lead to infinite loops, recursivity errors, and would generally decrease performance.
So, when Griffe inspects a member of the compiled `_module`, and this member lies and says it comes from `package.module`, Griffe thinks it was imported. It means that Griffe will record the object as an indirection, or alias, instead of visiting it in-place. But that is wrong: the object was actually declared in the module, and should not have been recorded as an indirection.
Fortunately, we were able to put some guard-rails in place, which means that the case above where the compiled and public modules have the same name, except for the leading underscore, is supported, and will not trigger errors. But other cases where modules have different names will trigger issues, and we have to special case them in Griffe itself, after issues are reported.
Please, use your framework features to correctly set the `__module__` attribute of your objects (functions, classes and their methods too) as their *canonical location*, not their public location or any other location in the final package.
For example with [PyO3](https://github.com/PyO3/pyo3):
```rust
// Your module is compiled and added as `_module` into `package`,
// but its objects are exposed in `package` or `package.module`.
// Set `module = "package._module"`, not `module = "package"` or `module = "package.module"`!
#[pyclass(name = "MyClass", module = "package._module")]
struct MyClass {
// ...
}
```
Some modules of the standard library are guilty of this too, and do so inconsistently (`ast` and `_ast`, `io` and `_io`, depending on the Python version...). For this reason, when checking if an object was declared in the currently inspected module, Griffe ultimately considers that any qualified name is equal to itself with each component stripped from leading underscores:
```python
a.b.c == _a.b.c
a.b.c == _a._b._c
a.__b._c == __a.b.c
...
```
When the qualified name of the object's parent module and the currently inspected module match like above, the object is inspected in-place (added as a member of the currently inspected module) instead of created as an alias.
## Avoid forward references in base classes
Python's type system will let you use forward references in generic types when they are used as base classes. For example:
=== "Before Python 3.12"
```python
from typing import TypeVar, Generic
T = TypeVar('T')
class Foo(Generic[T]):
...
class FooBar(Foo['Bar']):
...
class Bar:
...
```
=== "Python 3.12+"
```python
class Foo[T]:
...
class FooBar(Foo['Bar']):
...
class Bar:
...
```
While Griffe will load this code without error, the `'Bar'` forward reference won't be resolved to the actual `Bar` class. As a consequence, downstream tools like documentation renderers won't be able to output a link to the `Bar` class. We therefore recommend to avoid using forward references in base classes, if possible.
Instead, you can try one of the following approach:
- declare or import the `Bar` class earlier
- declare a proper type:
```python
class Foo[T]:
...
type TBar = Bar
class FooBar(Foo[TBar]):
...
class Bar:
...
```
- make `FooBar` generic again but with a default type:
```python
class Foo[T]:
...
class FooBar[T=Bar](Foo[T]):
...
class Bar:
...
```
python-griffe-2.3.0/docs/guide/users/recommendations/public-apis.md 0000664 0001750 0001750 00000110204 15246557273 025304 0 ustar carsten carsten # Public APIs
## What is a public API?
An API (Application Programming Interface) in the interface with which developers interact with your software. In the Python world, the API of your Python library is the set of modules, classes, functions and other attributes made available to your users. For example, users can do `from your_library import this_function`: `this_function` is part of the API of `your_library`.
Oftentimes, when you develop a library, you create functions, classes, etc. that are only useful internally: they are not supposed to be used by your users. Python does not provide easy or standard ways to actually *prevent* users from using internal objects, so, to distinguish public objects from internal objects, we usually rely on conventions, such as prefixing internal objects' names with an underscore, for example `def _internal_function(): ...`, to mark them as "internal".
Prefixing an object's name with an underscore still does not prevent users from importing and using this object, but it *informs* them that they are not supposed to import and use it, and that this object might change or even disappear in the future, *without notice*.
On the other hand, public objects are supposed to stay compatible with previous versions of your library for at least a definite amount of time, to prevent downstream code from breaking. Any change that could break downstream code is supposed to be communicated *before* it is actually released. Maintainers of the library usually allow a period of time where the public object can still be used as before, but will emit deprecation warnings when doing so, hinting users that they should upgrade their use of the object (or use another object that will replace it). This period of time is usually called a deprecation period.
So, how do we mark an object as public? How do we inform our users which objects can safely be used, and which ones are subject to unnotified changes? Usually, we rely again on the underscore prefix convention: if an object isn't prefixed with an underscore, it means that it is public. But essentially, your public API is what you say it is. If you clearly document that a single function of your package is public, and that all others are subject to unnotified changes and whose usage by users is not supported, then your public API is composed of this single function, and nothing else. **Public APIs are a matter of communication.** Concretely, it's about deciding what parts of your code base are public, and communicating that clearly.
Some components are obviously considered for the public API of a Python package:
- the module layout
- functions and their signature
- classes (their inheritance), their methods and signatures
- the rest of the module or class attributes, their types and values
Other components *should* be considered for the public API but are often forgotten:
- CLI options: see [The CLI is API too](#the-cli-is-api-too) section
- logger names: users might rely on them to filter logs (see [Logger names](#logger-names))
- exceptions raised: users definitely rely on them to catch errors
Other components *could* be considered for the public API, but usually require too much maintenance:
- logging messages: users might rely on them to grep the logs
- exception messages: users might rely on them for various things
Besides, logging and exception messages simply cannot allow deprecation periods where both old and new messages are emitted. Maintainers could however consider adding unique, short codes to messages for more robust consumption.
> GRIFFE: **Our recommendation — Communicate your public API, verify what you can.**
> Take the time to learn about and use ways to declare, communicate and deprecate your public API. Your users will have an easier time using your library. On the maintenance side, you won't get bug reports for uses that are not supported, or you will be able to quickly close them by pointing at the documentation explaining what your public API is, or why something was deprecated, for how long, and how to upgrade.
>
> Automate verifications around your public API with tools like Griffe. Currently Griffe doesn't support checking CLI configuration options, logger names or raised exceptions. If you have the capacity to, verify these manually before each release. [Griffe checks](../checking.md) and [API rules enforcement](#ensuring-api-rules) are a very good starting point.
## Conventions
Python does not provide any way to *enforce* public APIs: users can always import and use internal objects if they really want to. However, Python *does* specify a standard way to *declare* public APIs: the `__all__` attribute (see below), which is complemented by official recommendations and a few conventions.
### Underscore prefix
In the Python ecosystem we very often prefix objects with an underscore to mark them as internal, or private. Objects that are not prefixed are then implicitly considered public. For example:
```python
def public_function():
...
def _internal_function():
...
```
The exception to this rule is that imported objects are not considered public. For example:
```python
from elsewhere import something
```
Even though `something` doesn't start with an underscore, it was imported so it is not considered public.
Note that this exception comes from [PEP 8](https://peps.python.org/pep-0008/#public-and-internal-interfaces) ("imported names should always be considered an implementation detail") and from the conventions used by type checkers, not from the language reference: the latter states that when `__all__` is not defined, the set of public names includes *all* names found in the module's namespace which do not begin with an underscore, imported ones included.
### `__all__` list
Python also provides a mechanism that lets you do the opposite: explicitly mark objects as public. This mechanism uses the `__all__` module-level attribute, which is a list of strings containing the names of the public objects.
Contrary to popular belief, `__all__` is not merely a convention, nor just a way to control wildcard imports: it is actually specified in [the Python language reference](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement) as *the* mechanism that determines the public names of a module:
> The public names defined by a module are determined by checking the module's namespace for a variable named `__all__`; if defined, it must be a sequence of strings which are names defined or imported by that module. [...] The names given in `__all__` are all considered public and are required to exist. If `__all__` is not defined, the set of public names includes all names found in the module's namespace which do not begin with an underscore character (`'_'`). `__all__` should contain the entire public API. It is intended to avoid accidentally exporting items that are not part of the API (such as library modules which were imported and used within the module).
```python title="package/module.py"
__all__ = [
"this_function",
"ThisClass",
]
def this_function():
...
def this_other_function():
...
class ThisClass:
...
class ThisOtherClass:
...
```
Here, even though `this_other_function` and `ThisOtherClass` are *not* prefixed with underscores, they are not considered public, because we explicitly and only marked `this_function` and `ThisClass` as public.
Declaring `__all__` has a beneficial side-effect, which is often mistaken for its primary purpose: it affects wildcard imports. When your users use wildcard imports to import things from one of your modules, Python will only import the objects that are listed in `__all__`. Without `__all__`, it would import all objects that are not prefixed with an underscore, *including objects already imported from elsewhere*. This can cause serious namespace pollution, and even slow down Python code when wildcard imports are chained. [We actually recommend avoiding wildcard imports](python-code.md#avoid-wildcard-imports).
By declaring `__all__`, your public API becomes explicit, and explicit is better than implicit. But `__all__` only works for module-level objects. Within classes, you will still have to rely on the underscore prefix convention to mark methods or attributes as internal/private.
```python
class Thing:
def public_method(self):
...
def _internal_method(self):
...
```
### Redundant aliases
When you expose your public API in `__init__` modules by importing most objects from the underlying modules, it can be a bit tedious to import everything, and then list everything again in the `__all__` list attribute. For this reason, another convention emerged where objects imported and aliased with the same name are considered public.
```python title="my_package/__init__.py"
from elsewhere import something as something
from my_package._internal_module import Thing as Thing
```
Here `Thing` and `something` are considered public even though they were imported. If `__all__` was defined, it would take precedence and redundant aliases wouldn't apply.
### Wildcard imports
Same as for redundant aliases, this convention says that all objects imported thanks to wildcard imports are public. This can again be useful in `__init__` modules where you expose lots of objects declared in submodules.
```python title="my_package/__init__.py"
from my_package._internal_module1 import *
from my_package._internal_module2 import *
```
Note that the wildcard imports logic stays the same, and imports either all objects that do not start with an underscore (imported objects included!), or all objects listed in `__all__` if it is defined. It doesn't care about other conventions such as redundant aliases, or the wildcard imports convention itself.
---
> GRIFFE: **Our recommendation — Use the underscore prefix and `__all__` conventions.**
> Use both the underscore prefix convention for consistent naming at module and class levels, and the `__all__` convention for declaring your public API. We do not recommend using the redundant aliases convention, because it doesn't provide any information at runtime. We do not recommend the wildcard import convention either, for the same reason and [for additional reasons mentioned here](python-code.md#avoid-wildcard-imports). We still provide the [`griffe-public-redundant-aliases`](https://mkdocstrings.github.io/griffe-public-redundant-aliases/) and [`griffe-public-wildcard-imports`](https://mkdocstrings.github.io/griffe-public-wildcard-imports/) extensions for those who would still like to rely on these conventions.
>
> Our recommendation matches the language reference (quoted above) as well as [PEP 8](https://peps.python.org/pep-0008/#public-and-internal-interfaces):
>
> > To better support introspection, modules should explicitly declare the names in their public API using the `__all__` attribute. Setting `__all__` to an empty list indicates that the module has no public API.
>
> > Even with `__all__` set appropriately, internal interfaces (packages, modules, classes, functions, attributes or other names) should still be prefixed with a single leading underscore.
> TIP: **Concatenating `__all__` for easier maintenance of `__init__` modules.**
> If you worry about maintenance of your `__init__` modules, know that you can very well concatenate `__all__` lists from submodules into the current one:
>
> ```tree
> my_package/
> __init__.py
> module.py
> subpackage1/
> __init__.py
> _module1a.py
> subpackage2/
> __init__.py
> _module2a.py
> ```
>
> ```python title="my_package/subpackage1/__init__.py"
> from my_package.subpackage1.module1a import this1a, that1a
>
> __all__ = ["this1a", "that1a"]
> ```
>
> ```python title="my_package/subpackage2/__init__.py"
> from my_package.subpackage2.module2a import this2a, that2a
>
> __all__ = ["this2a", "that2a"]
> ```
>
> ```python title="my_package/__init__.py"
> from my_package.module import this
> from my_package.subpackage1 import this1a, that1a, __all__ as subpackage1_all
> from my_package.subpackage2 import this2a, that2a, __all__ as subpackage2_all
>
> __all__ = ["this", *subpackage1_all, *subpackage2_all]
>
> # Griffe supports the `+` and `+=` operators too:
> # __all__ = ["this"] + subpackage1_all + subpackage2_all
> # __all__ = ["this"]; __all__ += subpackage1_all; __all__ += subpackage2_all
> ```
>
> However we would argue that `this1a`, `that1a`, `this2a` and `that2a` should not be exposed publicly in more than one location. See our section on [unique names and public locations](#unique-names-and-public-locations).
## Module layout
We usually split the code of our packages into different modules. The code can be split according to domains, types of objects, logic, etc.: we don't have any recommendation on that. However, your package layout is part of your API, so it should be taken into account when deciding what you expose as your public API.
Most of the time, packages implicitly expose their module layout in their public API. Indeed, when you start a new project, you create new modules but don't immediately think about making them private. Then the project grows organically, you add more modules, and users start actually relying on their layout, importing specific objects from specific modules. Now when you want to move objects around, to reorganize your layout, you introduce breaking changes. So you have to create a deprecation period where objects that moved around are still importable in the old locations, but emit deprecation warnings. A module-level `__getattr__` function is commonly used for that.
```python title="package/old_module.py"
import warnings
from typing import Any
def __getattr__(name: str) -> Any:
if name == "my_object":
warnings.warn(
"Importing `my_object` from `old_module` is deprecated, import it from `new_module` instead.",
DeprecationWarning,
stacklevel=2,
)
from package.new_module import my_object
return my_object
raise AttributeError(f"module 'old_module' has no attribute '{name}'")
```
Such changes sometimes go unnoticed before the breaking change is released, because users don't enable deprecation warnings. These changes can also be confusing to users when they do notice the warnings: maybe they don't use the deprecated import themselves, and are not sure where to report the deprecated use. These changes also require time to upgrade, and time to maintain.
What if we could make this easier?
By hiding your module layout from your public API, you're removing all these pain points at once. Any object can freely move around without ever impacting users. Maintainers do not need to set deprecation periods where old and new uses are supported, or bump the major part of their semantic version when they stop supporting the old use. Hiding the module layout also removes the ambiguity of whether a submodule is considered public or not: [PEP 8](https://peps.python.org/pep-0008/#public-and-internal-interfaces) doesn't mention anything about it, and it doesn't look like the `__all__` mechanism expects developers to list their submodules too. In the end it looks like submodules are only subject to the underscore prefix convention.
So, how do we hide the module layout from the public API?
The most common way to hide the module layout is to make all your modules private, by prefixing their name with an underscore:
```tree
my_package/
__init__.py
_combat.py
_exploration.py
_sorcery.py
```
Then, you expose public objects in the top-level `__init__` module thanks to its `__all__` attribute:
```python title="my_package/__init__.py"
from my_package._combat import Combat
from my_package._exploration import navigate
from my_package._sorcery import cast_spell
__all__ = [
"Combat",
"navigate",
"cast_spell",
]
```
Now, if you want to move `cast_spell` into the `_combat` module, you can do so without impacting users. You can even rename your modules. All you have to do when doing so is update your top-level `__init__` module to import the objects from the right locations.
If you have more than one layer of submodules, you don't have to make the next layer private: only the first one is enough, as it informs users that they shouldn't import from this layer anyway:
```tree
my_package/
__init__.py
_combat.py
_exploration.py
_sorcery/
__init__.py
dark.py
light.py
```
If you don't want to bother prefixing every module with an underscore, you could go one step further and do one of these two things:
- move everything into an `_internal` directory:
```tree
my_package/
__init__.py
_internal/
__init__.py
combat.py
exploration.py
sorcery/
__init__.py
dark.py
light.py
```
- or move everything into a private package:
```tree
my_package/
__init__.py
_my_package/
__init__.py
combat.py
exploration.py
sorcery/
__init__.py
dark.py
light.py
```
Whatever *hidden* layout you choose (private modules, internals, private package), it is not very important, as you will be able to switch from one to another easily. In Griffe we chose to experiment and go with the private package approach. This highlighted a few shortcomings that we were able to address in both Griffe and mkdocstrings-python. We later moved the private package under an internal folder.
WARNING: **Top-level-only exposition doesn't play well with large packages.**
The *fully* hidden layout plays well with small to medium projects. If you maintain a large project, it can become very impractical for both you and your users to expose every single object in the top-level `__init__` module. For large projects, it therefore makes sense to keep at least one or two additional public layers in your module layout. Sometimes packages also implement many variations of the same abstract class, using the same name in many different modules: in these cases, the modules are effective namespaces that could be kept in the public API.
GRIFFE: **Our recommendation — Hide your module layout early.**
Start hiding your module layout early! It is much easier to (partially) expose the layout later than to hide it after your users started relying on it. It will also make code reorganizations much easier.
## Unique names and public locations
Whether or not you are planning to hide your module layout, as recommended in the previous section, one thing that will help both you and your users is making sure your object names are unique across your code base. Having unique names ensures that you can expose everything at the top-level module of your package without having to alias objects (using `from ... import x as y`). It will also ensure that your users don't end up importing multiple different objects with the same name, again having to alias them. Finally, it forces you to use meaningful names for your objects, names that don't need the context of the above namespaces (generally modules) to understand what they mean. For example, in Griffe we previously exposed `griffe.docstrings.utils.warning`. Exposing `warning` at the top-level made it very vague: what does it do? So we renamed it `docstring_warning`, which is much clearer.
Ensuring unique names across a code base is sometimes not feasible, or not desirable; in this case, try to use namespacing while still hiding the module layout the best you can.
In accordance with our recommendation on module layouts, it is also useful to ensure that a single public object is exposed in a single location. Ensuring a unique public location for each object removes any ambiguity on the user side as to where to import the object from. It also helps documentation generators that try to cross-reference objects: with several locations, they cannot know for sure which one is the best to reference (which path is best to use and display in the generated documentation). With a fully hidden layout, all objects are *only* exposed in the top-level module, so there is no ambiguity. With partially hidden layouts, or completely public layouts, make sure to declare your public API so that each object is only exposed in a single location. Example:
```tree
my_package/
__init__.py
module.py
```
=== "Multiple locations, bad"
Here the `Hello` class is exposed in both `my_package.module` and `my_package`.
```python title="my_package/module.py"
__all__ = ["Hello"]
class Hello:
...
```
```python title="my_package/__init__"
from my_package.module import Hello
__all__ = ["Hello"]
```
=== "Single location, good"
Here the `Hello` class is only exposed in `my_package.module`.
```python title="my_package/module.py"
__all__ = ["Hello"]
class Hello:
...
```
```python title="my_package/__init__"
# Nothing to see here.
```
If you wanted to expose it in the top-level `__init__` module instead, then you should hide your module layout by making `module.py` private, renaming it `_module.py`, or using other hiding techniques such as described in the [Module layout](#module-layout) section.
=== "Single location (top-level), good"
Here the `Hello` class is only exposed in `package`.
```python title="my_package/module.py"
__all__ = []
class Hello:
...
```
```python title="my_package/__init__"
from my_package.module import Hello
__all__ = ["Hello"]
```
It feels weird to "unpublicize" the `Hello` class in `my_package.module` by declaring an empty `__all__`, so maybe the module should be made private instead: `my_package/_module.py`. See other hiding techniques in the [Module layout](#module-layout) section.
GRIFFE: **Our recommendation — Expose public objects in single locations, use meaningful names.**
We recommend making sure that each public object is exposed in a single location. Ensuring unique names might be more tricky depending on the code base, so we recommend ensuring meaningful names at least, not requiring the context of modules above to understand what the objects are for.
## Logger names
The documentation of the standard `logging` library recommends to use `__name__` as logger name when obtaining a logger with `logging.getLogger()`, *unless we have a specific reason for not doing that*. Unfortunately, no examples of such specific reasons are given. So let us give one.
Using `__name__` as logger names means that your loggers have the same name as your module paths. For example, the module `package/module.py`, whose path and `__name__` value are `package.module`, will have a logger with the same name, i.e. `package.module`. If your module layout is public, that's fine: renaming the module or moving it around is already a breaking change that you must document.
However if your module layout is hidden, or if this particular module is private, then even though renaming it or moving it around is *not* breaking change, the change of name of its logger *is*. Indeed, by renaming your module (or moving it), you changed its `__name__` value, and therefore you changed its logger name.
Now, users that were relying on this name (for example to silence WARNING-level logs and below coming from this particular module) will see their logic break without any error and without any deprecation warning.
```python
# For example, the following would have zero effect if `_module` was renamed `_other_module`.
package_module_logger = logging.getLogger("package._module")
package_module_logger.setLevel(logging.ERROR)
```
Could we emit a deprecation warning when users obtain the logger with the old name? Unfortunately, there is no standard way to do that. This would require patching `logging.getLogger`, which means it would only work when users actually use this method, in a Python interpreter, and not for all the other ways logging can be configured (configuration files, configuration dicts, etc.).
Since it is essentially impossible to deprecate a logger name, we recommend to avoid using `__name__` as logger name, at the very least in private modules.
GRIFFE: **Our recommendation — Use a single logger.**
Absolutely avoid using `__name__` as logger name in private modules. If your module layout is hidden, or does not matter for logging purposes, just use the same logger everywhere by using your package name as logger name. Example: `logger = logging.getLogger("griffe")`. Show your users how to temporarily alter your global logger (typically with context managers) so that altering subloggers becomes unnecessary. Maybe even provide the utilities to do that.
## Documentation
Obviously, your public API should be documented. Each object should have a docstring that explains why the object is useful and how it is used. More on that in our [docstrings recommendations](docstrings.md). Docstrings work well for offline documentation; we recommend exposing your public API online too, for example with [MkDocs](https://www.mkdocs.org/) and [mkdocstrings' Python handler](https://mkdocstrings.github.io/python/), or with other SSGs (Static Site Generators). Prefer a tool that is able to create a [Sphinx-like](https://sphobjinv.readthedocs.io/en/stable/syntax.html) inventory of objects (an `objects.inv` file) that will allow other projects to easily cross-reference your API from their own documentation. Make sure each and every object of your public API is documented in your web docs and therefore added to the objects inventory (and maybe that nothing else is added to this inventory as "public API").
> GRIFFE: **Our recommendation — Document your public API extensively.**
> Write docstrings for each and every object of your public API. Deploy online documentation where each object is documented and added to an object inventory that can be consumed by third-party projects. If you find yourself reluctant to document a public object, it means that this object should maybe be internal instead.
>
> Our documentation framework of choice is of course [MkDocs](https://www.mkdocs.org) combined with our [mkdocstrings](https://mkdocstrings.github.io/) plugin.
## Ensuring API rules
If you already follow some of these recommendations, or if you decide to start following them, it might be a good idea to make sure that these recommendations keep being followed as your code base evolves. The intent of these recommendations, or "rules", can be captured in tests relatively easily thanks to Griffe.
We invite you to check out our own test file: [`test_api.py`](https://github.com/mkdocstrings/griffe/blob/main/tests/test_api.py). This test module asserts several things:
- all public objects are exposed in the top-level `griffe` module
- all public objects have unique names
- all public objects have single locations
- all public objects are added to the inventory (which means they are documented in our API docs)
- no private object is added to the inventory
GRIFFE: **Our recommendation — Test your API declaration early.**
The sooner you test your API declaration, the better your code base will evolve. This will force you to really think about how your API is exposed to your users. This will prevent mistakes like leaving a new object as public while you don't want users to start relying on it, or forgetting to expose a public object in your top-level module or to document it in your API docs.
## Linters
Depending on their configuration, many popular Python linters will warn you that you access or import private objects. This doesn't play well with hidden module layouts, where modules are private or moved under a private (sub-)package. Sometimes it doesn't even play well with private methods
> GRIFFE: **Our recommendation — Ignore "protected access" warnings for your own package, or make the warnings smarter.**
> To users of linters, we recommend adding `# noqa` comments on the relevant code lines, or globally disabling warnings related to "private object access" if per-line exclusion requires too much maintenance.
>
> To authors of linters, we recommend (if possible) making these warnings smarter: they shouldn't be triggered when private objects are accessed from within the *same package*. Marking objects as private is meant to prevent downstream code from using them, not to prevent the developers of the current package themselves from using them: they know what they are doing and should be allowed to use their own private objects without warnings. At the same time, they don't want to disable these warnings *globally*, so the warnings should be derived in multiple versions, or made smarter.
## The CLI is API too
This section deserves an entire article, but we will try to stay succinct here.
Generally, we distinguish the API (Application Programming Interface) from the CLI (Command Line Interface), TUI (Textual User Interface) or GUI (Graphical User Interface). Contrary to TUIs or GUIs which are not likely to be controlled programmatically (they typically work with keyboard and mouse inputs), the CLI can easily be called by various scripts or programs, including from Python programs.
Even if a project was not designed to be used programmatically (doesn't expose a public API), it is *a certainty* that with enough popularity, it *will* be used programmatically. And the CLI will even more so be used programmatically if there is no API. Even if there is an API, sometimes it makes more sense to hook into the CLI rather than the API (cross-language integrations, wrappers, etc.).
Therefore, we urge everyone to consider their CLI as an API too. We urge everyone to always design their project as library-first APIs rather than CLI-first tools.
The first user of your CLI as API is... you. When you declare your project's CLI entrypoint in pyproject.toml:
```toml
[project.scripts]
griffe = "griffe:main"
```
...this entrypoint ends up as a Python script in the `bin` directory of your virtual environment:
```python
#!/media/data/dev/griffe/.venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from griffe import main
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
sys.exit(main())
```
In this script, we find our entrypoint, `griffe.main`, used programmatically.
---
The second user of your CLI as API is... you again. When you write tests for your CLI, you import your entrypoints and call them by passing CLI options and arguments, maybe asserting the exit code raised with a `SystemExit` or the standard output/error thanks to [pytest's capture fixtures](https://docs.pytest.org/en/6.2.x/capture.html). Some simplified examples from our own test suite:
```python
import pytest
import griffe
def test_main() -> None:
assert griffe.main(["dump", "griffe", "-s", "src", "-o/dev/null"]) == 0
def test_show_help(capsys: pytest.CaptureFixture) -> None:
with pytest.raises(SystemExit):
griffe.main(["-h"])
captured = capsys.readouterr()
assert "griffe" in captured.out
def test_show_version(capsys: pytest.CaptureFixture) -> None:
with pytest.raises(SystemExit):
griffe.main(["-V"])
captured = capsys.readouterr()
assert griffe.get_version() in captured.out
```
Now, when you start testing the logic of your CLI subcommands, such as our `dump` subcommand above, you might feel like passing again and again through the command-line arguments parser (here `argparse`) is wasteful and redundant. It is important to test that your arguments are parsed correctly (as you expect them to be parsed), but they shouldn't *have* to be parsed when you are testing the underlying logic.
It's a hint that your command-line arguments parsing (and command-line handling generally) should be *decoupled* from the logic below it: write functions with proper parameters! Then call these functions from your main CLI entrypoint, with the arguments obtained from parsing the command-line arguments and options. It will make testing and debugging much, much easier:
```python
import argparse
import sys
def dump(...):
...
def main(args: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(...)
opts = parser.parse_args(args)
if opts.subcommand == "dump":
return dump(opts.arg1, opts.arg2, ...)
elif ...
print(f"Unknown subcommand {opts.subcommand}", file=sys.stderr)
return 1
```
Now instead of having to call `main(["dump", "..."])` in your tests, you can directly call `dump(...)`, with all the benefits from static-typing and your IDE features, such as autocompletion, linting, etc..
---
The third and next users of your CLI as API are your users: just as you made your own life easier, you made their life easier for when they want to call some subcommands of your tool programmatically. No more messing with lists of strings without autocompletion or linting, no more patching of `sys.argv`, no more following the maze of transformations applied by this fancy CLI framework before finally reaching the crux of the subcommand you want to call, no more trying to replicate these transformations yourself with the CLI framework's API to avoid copy-pasting the dozens of lines you're only interested in.
> GRIFFE: **Our recommendation — Decouple command-line parsing from your CLI entrypoints.**
> Do not tie the command parsing logic with your program's logic. Create functions early, make them accept arguments using basic types (`int`, `str`, `list`, etc.) so that your users can call your main command or subcommands with a single import and single statement. Do not encode all the logic in a single big `main` function. Decoupling the CLI-parsing logic from your entrypoints will make them much easier to test and use programmatically. Consider your entrypoints part of your API!
>
> Our CLI framework of choice is [Cappa](https://pypi.org/project/cappa/).
## Deprecations
With time, the code base of your project evolves. You add features, you fix bugs, and you generally reorganize code. Some of these changes might make your project's public API incompatible with previous versions. In that case, you usually have to "deprecate" previous usage in favor of the new usage. That means you have to support both, and emit deprecation warnings when old usage is detected.
There are many different ways of deprecating previous usage of code, which depend on the change itself. We invite you to read our [Checking APIs](../checking.md) chapter, which describes all the API changes Griffe is able to detect, and provides hints on how to allow deprecation periods for each kind of change.
In addition to emitting deprecation warnings, you should also update the docstrings and documentation for the old usage to point at the new usage, add "deprecated" labels where possible, and mark objects as deprecated when possible.
GRIFFE: **Our recommendation — Allow a deprecation period, document deprecations.**
Try allowing deprecation periods for every breaking change. Most changes can be made backward-compatible at the cost of writing legacy code. Use tools like [Yore](https://pawamoy.github.io/yore) to manage legacy code, and standard utilities like [`warnings.deprecated`][] to mark objects as deprecated. Griffe extensions such as [griffe-warnings-deprecated](https://mkdocstrings.github.io/griffe-warnings-deprecated/) can help you by dynamically augmenting docstrings for your API documentation.
## Third-party libraries
A few third-party libraries directly or indirectly related to public APIs deserve to be mentioned here.
[public](https://pypi.org/project/public/) lets you decorate objects with `@public.add` to dynamically add them to `__all__`, so that you don't have to build a list of strings yourself. The "public visibility" marker is closer to each object, and might help avoid mistakes like forgetting to update `__all__` when an object is removed or renamed.
[modul](https://pypi.org/project/modul/), from Frost Ming, the author of [PDM](https://pdm-project.org/en/latest/), goes one step further and actually hides attributes that are not marked "exported" from users: they won't be able to access un-exported attributes, leaving *only* the public API visible.
[Deprecated](https://pypi.org/project/Deprecated/), which was probably a source of inspiration for [PEP 702](https://peps.python.org/pep-0702/), allows decorating objects with `@deprecated` to mark them as deprecated. Such decorated callables will emit deprecation warnings when called. PEP 702's `warnings.deprecated` could be seen as its successor, bringing the feature directly into the standard library so that type checkers and other static analysis tools can converge on this way to mark objects as deprecated.
python-griffe-2.3.0/docs/guide/users/recommendations/docstrings.md 0000664 0001750 0001750 00000036261 15246557273 025265 0 ustar carsten carsten # Docstrings
Here are explanations on what docstrings are, and a few recommendations on how to write them. This guide uses the [Google-style](../../../reference/docstrings.md#google-style), because that is our preferred and recommended style, but you can also use any other supported style. Skip to the [Styles](#styles) section to learn about the existing docstring styles. We invite you to read their own style guides as they are full of examples and good advice.
## Definition
A docstring is a line or block of text describing objects such as modules, classes, functions, attributes and type aliases. They are written below the object signature or assignment, or appear as first expression in a module:
```python title="module.py"
"""This is the module docstring."""
type X = dict[str, int]
"""This is a type alias docstring."""
a = 0
"""This is an attribute docstring."""
def b():
"""This is a function docstring."""
class C:
"""This is a class docstring."""
def d(self):
"""This is a method docstring."""
```
## Multi-line docstrings
Each docstring can span multiple lines if it is wrapped in triple double-quotes (which is generally the case and the official recommendation even for single-line docstrings):
```python
def function():
"""This is a longer docstring.
It spans on multiple lines.
Blank lines are allowed, too.
"""
```
When writing multi-line docstrings, it is recommended to write a short description on the first line, then separate the rest of the docstring with a blank line. The first line is called the **summary**, and the rest of docstring is called the **body**. The summary is useful to documentation generators and other tools to show the short description of an object.
## Markup
Docstrings are just text, so you can use any markup you want. The markup you choose will generally depend on what you decide to do with your docstrings: if you generate API documentation from your docstrings, and the documentation renderer expects Markdown, then you should write your docstrings in Markdown.
Examples of markups are [Markdown](https://daringfireball.net/projects/markdown/) (which has many different implementations and many different "flavors"), [reStructuredText](https://docutils.sourceforge.io/rst.html), [AsciiDoc](https://asciidoc.org/), and [Djot](https://djot.net/).
For example, if you are using [MkDocs](https://www.mkdocs.org) and [mkdocstrings](https://mkdocstrings.github.io/) to generate your API documentation, you should write your docstrings in Markdown. If you are using [Sphinx](https://www.sphinx-doc.org/en/master/), you should probably write your docstrings in reStructuredText, unless you are also using the [MyST](https://myst-parser.readthedocs.io/en/latest/index.html) extension.
Whatever markup you choose, try to stay consistent within your code base.
## Styles
Docstrings can be written for modules, classes, functions, attributes, and type aliases. But there are other aspects of a Python API that need to be documented, such as function parameters, returned values, and raised exceptions, to name a few. We could document everything in natural language, but that would make it hard for downstream tools such as documentation generators to extract information in a structured way, to allow dedicated rendering such as tables for parameters.
To compensate for the lack of structure in natural languages, docstring "styles" emerged. A docstring style is a micro-format for docstrings, allowing to structure the information by following a specific format. With the most popular Google and Numpydoc styles, information in docstrings is decomposed into **sections** of different kinds, for example "parameter" sections or "return" sections. Some kinds of section then support documenting multiple items, or support a single block of markup. For example, we can document multiple parameters in "parameter" sections, but a "note" section is only composed of a text block.
Structuring the information in sections and items allows documentation-related tools to extract and provide this information in a structured way, by parsing the docstrings according to the style they follow. Griffe has parsers for [Google-style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings), [Numpydoc-style](https://numpydoc.readthedocs.io/en/latest/format.html), and [Sphinx-style](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html) docstrings. See the complete reference for these parsers and styles in the [Docstrings reference](../../../reference/docstrings.md). We recommend that you read the style guides mentioned here as they are full of examples and good advice too.
```python title="Google-style"
def greet(name: str, end: str = "!") -> None:
"""Greet someone.
Parameters:
name: The name to greet.
end: The punctuation mark at the end.
Note:
Greetings are cool!
"""
print(f"Hey {name}{end}")
‎
```
```python title="Numpydoc-style"
def greet(name: str, end: str = "!") -> None:
"""Greet someone.
Parameters
----------
name
The name to greet.
end
The punctuation mark at the end.
Note
----
Greetings are cool!
"""
print(f"Hey {name}{end}")
```
Our preferred style for docstrings is the **Google-style**, because it is in our opinion the most markup-agnostic style: it is based on any kind of markup or documentation generator. Our second choice would be the Numpydoc-style, for its readability.
For the adventurers, have a look at [PEP 727](https://peps.python.org/pep-0727/) (draft) and [griffe-typingdoc](https://mkdocstrings.github.io/griffe-typingdoc/), a Griffe extension to support PEP 727. PEP 727 proposes an alternative way to provide information in a structured way, that does not rely on a docstring micro-format. It takes advantage of `typing.Annotated` to attach documentation to any type-annotated object, like attributes, parameters and returned values. With PEP 727, docstrings styles and their sections aren't required anymore, and docstrings can be written in plain markup, without following any particular style. This makes it easier for tools like Griffe who then don't have to parse docstrings *at all*. The PEP is a bit controversial (lots of different opinions), so we invite you to make your own opinion by looking at real-world projects using it, such as [FastAPI](https://github.com/tiangolo/fastapi/blob/master/fastapi/applications.py), or by reading the (very-long) [discussion on discuss.python.org](https://discuss.python.org/t/pep-727-documentation-metadata-in-typing/32566/17). The PEP was actually written by FastAPI's author, Sebastián RamÃrez.
```python title="PEP 727"
from typing_extensions import Annotated, Doc
def greet(
name: Annotated[str, Doc("The name to greet."),
end: Annotated[str, Doc("The punctuation mark at the end.")] = "!",
) -> None:
"""Greet someone.
> [!NOTE]
> Greetings are cool!
""" # (1)!
print(f"Hey {name}{end}")
```
1. Here we use the [GitHub syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) for a "note" callout. It assumes our documentation renderer supports this syntax. The point is that we rely purely on Markdown rather than docstrings styles.
## General tips
Your docstrings will typically be used to document your API, either on a deployed (static) website, or locally, on the command line or in a Python interpreter. Therefore, when writing your docstrings, you should address the right audience: the users of your code. Try to stay succinct and give clear examples. Docstrings are not really the place to explain architectural or technical decisions made while developing the project: this information, while extremely valuable, is better written in *code comments*, where the audience is other developers working on the code base.
Your docstrings will typically again be read online (HTML) or other types of documents such as manual pages or PDFs. Make sure to write complete sentences, with correct punctuation. That means for example, to start each parameter description with a capital letter, and to end it with a period.
When documenting objects acting as namespaces (modules, classes, enumerations), prefer documenting each attribute separately than with an Attributes section in the namespace object docstring. For example, add a docstring to each enumeration value rather than describing each value in the docstring of the enumeration class.
## Modules
Module docstrings should briefly explain what the module contains, and for what purposes these objects can be used. If the documentation generator you chose does not support generating member summaries automatically, you might want to add docstrings sections for attributes, functions, classes, type aliases and submodules.
```python title="package/__init__.py"
"""A generic package to demonstrate docstrings.
This package does not really exist, and is only used as a demonstration purpose for docstrings.
Anyway, this package contains the following API, exposed directly at the top-level,
meaning you can import everything from `package` directly.
Attributes:
ghost: A ghost wandering in this desolated land.
dummy: A dummy you can practice on. Not for ghosts.
Classes:
Ghost: Ah, so this is where our ghost comes from.
Maybe create some additional ghosts so they can pass the time together?
Functions:
deploy(): Deploy something on the web (we're not sure what exactly).
"""
```
Do the same thing for every other module of the package, except if you are [hiding your module layout](public-apis.md#module-layout).
## Classes, methods, properties
Class docstrings follow the same logic as module docstrings. Explain what the class is used for, and maybe show a few of its attributes and methods thanks to sections of the same name. A class is already more concrete than a module, so we can maybe start adding usage examples too. Such examples should only show how to create instances of the class. Examples of use for methods can be written in each respective method.
```python
class Ghost:
"""Ghosts that wander the earth.
Ghosts are meant to... we're actually unsure.
All we know is that, as a user, you might find it amusing to instantiate
a few of them and put them together to see what they do.
Methods:
wander: Wander the earth.
spook: Spook living organisms.
pass_through: Pass through anything.
Examples:
Create a new ghost with a cool nickname:
>>> ghost = Ghost(nickname="Rattlesnake")
"""
def wander(self) -> None:
"""Wander the earth.
That's it, really.
Examples:
>>> ghost.wander()
"""
...
@property
def weight(self) -> int:
"""The ghost's weight (spoiler: it's close to 0)."""
...
```
Note that blocks of lines starting with `>>>` or `...` are automatically parsed as code blocks by Griffe, until a blank line is found. This only works in Examples (plural!) sections. If you rely on [Python-Markdown](https://python-markdown.github.io/) to convert Markdown to HTML (which is the case for MkDocs), you can use the [markdown-pycon](https://pawamoy.github.io/markdown-pycon/) extension to recognize such `pycon` code blocks anywhere, without having to wrap them in fences. You can also choose to use explicit fences everywhere:
````python
"""
Examples:
Create a new ghost with a cool nickname:
```pycon
>>> ghost = Ghost(nickname="Rattlesnake")
```
"""
````
## Functions
Function and method docstrings will typically describe their parameters and return values. For generators, it's also possible to describe what the generator yields and what it can receive, though the latter is not often used.
```python
import datetime
from typing import Generator, Iterator
class GhostOlympicGames:
...
class GOGTicket:
...
def organize_gog(date: datetime.date) -> GhostOlympicGames:
"""Organize Olympic Games for Ghosts.
The ghost world is much simpler than the living world,
so it's super easy to organize big events like this.
Parameters:
date: The date of the games.
Returns:
The prepared games.
"""
...
def yield_athletes(quantity: int) -> Iterator[Ghost]:
"""Yield a certain quantity of athletes.
Parameters:
quantity: How many ghost athletes you want.
Yields:
Ghost athletes. They're just regular ghosts.
"""
...
def gog_tickets_factory() -> Generator[GOGTicket, int, None]:
"""Generate tickets for the GOG.
We value fairness: tickets are priced randomly.
Unless we send a specific price to the generator.
Yields:
Tickets for the games.
Receives:
Price for the next ticket, in ghost money (???).
"""
...
```
## Attributes
Attribute docstrings are written below their assignment. As usual, they should have a short summary, and an optional, longer body.
```python
GHOST_MASS_CONSTANT: float = 1e-100
"""The ghost mass constant.
This is a very small number. Use it scientifically
for all ghost-related things.
Note:
There is actually nothing scientific about any of this.
""" # (1)!
```
1. Our `Note` section here is parsed as an admonition. See [Google-style admonitions](../../../reference/docstrings.md#google-admonitions) for reference.
Class and instance attributes can be documented the same way:
```python
class GhostTown:
instances: str
"""All the existing towns."""
def __init__(self, name: str, size: int) -> None:
self.name = name
"""The town's name."""
self.size = size
"""The town's size."""
```
## Type aliases
Type alias docstrings are written below their assignment. As usual, they should have a short summary, and an optional, longer body.
```python
type Callback = typing.Callable[[int, str], typing.Any]
"""Callback type for Frobnicators.
The first argument is the number of rounds to run, the second argument
is the name of the widget being frobnicated.
"""
```
## Exceptions, warnings
Callables that raise exceptions or emit warnings can document each of these exceptions and warnings. Documenting them informs your users that they could or should catch the raised exceptions, or that they could filter or configure warnings differently. The description next to each exception or warning should explain how or when they are raised or emitted.
```python
def verify_spirit_chest():
"""Do a verification routine on the spirit chest.
Raises:
OverflowError: When the verification failed
and all the ghosts escaped from the spirit chest.
"""
...
def negotiate_return_to_the_spirit_chest():
"""Negotiate with ghost for them to return in the spirit chest.
Warns:
ResourceWarning: When the ghosts refuse to go back in the chest
because the chest is too tight.
"""
...
```
## Going further
There are more sections and more features to discover and use. For a complete reference on docstring styles syntax, see our [reference](../../../reference/docstrings.md).
python-griffe-2.3.0/docs/guide/users/extending.md 0000664 0001750 0001750 00000071700 15246557273 021701 0 ustar carsten carsten # Extending APIs
Griffe has an extension system that can be used to enhance or customize the data that Griffe collects. Extensions are written in Python.
## Using extensions
Extensions can be specified both on the command-line (in the terminal), and programmatically (in Python).
### On the command-line
On the command-line, you can specify extensions to use with the `-e`, `--extensions` option. This option accepts a single positional argument which can take two forms:
- a comma-separated list of extensions
- a JSON list of extensions
Extensions can accept options: the comma-separated list does not allow to specify options, while the JSON list does. See examples below.
With both forms, each extension refers to one of these three things:
- the name of a built-in extension's module, for example `dynamic_docstrings` (this is just an example, this built-in extension does not exist)
- the Python dotted-path to a module containing one or more extensions, or to an extension directly, for example `package.module` and `package.module.ThisExtension`
- the file path to a Python script, and an optional extension name, separated by a colon, for example `scripts/griffe_exts.py` and `scripts/griffe_exts.py:ThisExtension`
The specified extension modules can contain more than one extension: Griffe will pick up and load every extension declared or imported within the modules. If options are specified for a module that contains multiple extensions, the same options will be passed to all the extensions, so extension writers must make sure that all extensions within a single module accept the same options. If they don't, Griffe will abort with an error.
To specify options in the JSON form, use a dictionary instead of a string: the dictionary's only key is the extension identifier (built-in name, Python path, file path) and its value is a dictionary of options.
Some examples:
```bash
griffe dump griffe -e pydantic,scripts/exts.py:DynamicDocstrings,griffe_attrs
```
```bash
griffe check --search src griffe -e '[
{"pydantic": {"schema": true}},
{
"scripts/exts.py:DynamicDocstrings": {
"paths": ["mypkg.mymod.myobj"]
}
},
"griffe_attrs"
]'
```
In the above two examples, `pydantic` would be a built-in extension, `scripts/exts.py:DynamicDocstrings` the file path plus name of a local extension, and `griffe_attrs` the name of a third-party package that exposes one or more extensions.
### Programmatically
Within Python code, extensions can be specified with the `extensions` parameter of the [`GriffeLoader` class][griffe.GriffeLoader] or [`load` function][griffe.load].
The parameter accepts an instance of the [`Extensions` class][griffe.Extensions]. Such an instance is created with the help of the [`load_extensions` function][griffe.load_extensions], which itself accepts a list of strings, dictionaries, extension classes and extension instances.
Strings and dictionaries are used the same way as [on the command-line](#on-the-command-line). Extension instances are used as such, and extension classes are instantiated without any options.
Example:
```python
import griffe
from mypackage.extensions import ThisExtension, ThisOtherExtension
extensions = griffe.load_extensions(
{"pydantic": {"schema": true}},
{"scripts/exts.py:DynamicDocstrings": {"paths": ["mypkg.mymod.myobj"]}},
"griffe_attrs",
ThisExtension(option="value"),
ThisOtherExtension,
)
data = griffe.load("mypackage", extensions=extensions)
```
### In MkDocs
MkDocs and its mkdocstrings plugin can be configured to use Griffe extensions:
```yaml title="mkdocs.yml"
plugins:
- mkdocstrings:
handlers:
python:
options:
extensions:
- pydantic: {schema: true}
- scripts/exts.py:DynamicDocstrings:
paths: [mypkg.mymod.myobj]
- griffe_attrs
```
The `extensions` key accepts a list that is passed to the [`load_extensions` function][griffe.load_extensions]. See [how to use extensions programmatically](#programmatically) to learn more.
## Writing extensions
In the next section we give a bit of context on how Griffe works, to show how extensions can integrate into the data collection process. Feel free to skip to the [Events and hooks](#events-and-hooks) section or the [Full example](#full-example) section if you'd prefer to see concrete examples first.
### How it works
To extract information from your Python sources, Griffe tries to build Abstract Syntax Trees by parsing the sources with [`ast`][] utilities.
If the source code is not available (the modules are built-in or compiled), Griffe imports the modules and builds object trees instead.
Griffe then follows the [Visitor pattern](https://www.wikiwand.com/en/Visitor_pattern) to walk the tree and extract information. For ASTs, Griffe uses its [Visitor agent][griffe.Visitor] and for object trees, it uses its [Inspector agent][griffe.Inspector].
Sometimes during the walk through the source or runtime objects, both the visitor and inspector agents will trigger events, called **analysis events**. These events can be hooked on by extensions to alter or enhance Griffe's behavior. Some hooks will be passed just the current node being visited, others will be passed both the node and an instance of an [Object][griffe.Object] subclass, such as a [Module][griffe.Module], a [Class][griffe.Class], a [Function][griffe.Function], an [Attribute][griffe.Attribute], or a [Type Alias][griffe.TypeAlias]. Extensions will therefore be able to modify these instances.
Once the Griffe tree for a given package has been fully constructed, Griffe will trigger a second set of events, called **load events**, by walking the tree again. **It is safer to use load events as they are triggered only once data is complete for a given package**, contrary to the analysis events which are triggered *while the Griffe tree is still being built*.
The following flow chart shows an example of an AST visit. The tree is simplified: actual trees have a lot more nodes like `if/elif/else` nodes, `try/except/else/finally` nodes, [and many more][ast.AST].
```mermaid
flowchart TB
M(Module definition) --- C(Class definition) & F(Function definition)
C --- m(Function definition) & A(Variable assignment)
```
The following flow chart shows an example of an object tree inspection. The tree is simplified as well: [many more types of objects are handled][griffe.ObjectKind].
```mermaid
flowchart TB
M(Module) --- C(Class) & F(Function)
C --- m(Method) & A(Attribute)
```
For a more concrete example, let say that we visit (or inspect) an AST (or object tree) for a given module, and that this module contains a single class, which itself contains a single method:
- the agent (visitor or inspector) will walk through the tree by starting with the module node
- it will instantiate a [Module][griffe.Module], then walk through its members, continuing with the class node
- it will instantiate a [Class][griffe.Class], then walk through its members, continuing with the function node
- it will instantiate a [Function][griffe.Function]
- then it will go back up and finish walking since there are no more nodes to walk through
Every time the agent enters a node, creates an object instance, or finishes handling members of an object, it will trigger an event.
The flow of events is drawn in the following flowchart:
```mermaid
flowchart TB
visit_mod{{enter module node}}
event_mod_node{{"on_node eventon_module_node event"}}
create_mod{{create module instance}}
event_mod_instance{{"on_instance eventon_module_instance event"}}
visit_mod_members{{visit module members}}
visit_cls{{enter class node}}
event_cls_node{{"on_node eventon_class_node event"}}
create_cls{{create class instance}}
event_cls_instance{{"on_instance eventon_class_instance event"}}
visit_cls_members{{visit class members}}
visit_func{{enter func node}}
event_func_node{{"on_node eventon_function_node event"}}
create_func{{create function instance}}
event_func_instance{{"on_instance eventon_function_instance event"}}
event_cls_members{{"on_members eventon_class_members event"}}
event_mod_members{{"on_members eventon_module_members event"}}
start{start} --> visit_mod
visit_mod --> event_mod_node
event_mod_node --> create_mod
create_mod --> event_mod_instance
event_mod_instance --> visit_mod_members
visit_mod_members --1--> visit_cls
visit_cls --> event_cls_node
event_cls_node --> create_cls
create_cls --> event_cls_instance
event_cls_instance --> visit_cls_members
visit_cls_members --1--> visit_func
visit_func --> event_func_node
event_func_node --> create_func
create_func --> event_func_instance
event_func_instance --> visit_cls_members
visit_cls_members --2--> event_cls_members
event_cls_members --> visit_mod_members
visit_mod_members --2--> event_mod_members
event_mod_members --> finish{finish}
class event_mod_node event
class event_mod_instance event
class event_cls_node event
class event_cls_instance event
class event_func_node event
class event_func_instance event
class event_cls_members event
class event_mod_members event
classDef event stroke:#3cc,stroke-width:2
```
Hopefully this flowchart gives you a pretty good idea of what happens when Griffe collects data from a Python module. The next section will explain in more details the different events that are triggered, and how to hook onto them in your extensions.
### Events and hooks
There are two kinds of events in Griffe: [**load events**](#load-events) and [**analysis events**](#analysis-events). Load events are scoped to the Griffe loader (triggered once a package is fully loaded). Analysis events are scoped to the visitor and inspector agents (triggered during static and dynamic analysis).
**Hooks** are methods that are called when a particular event is triggered. To target a specific event, the hook must be named after it. See [Extensions and hooks](#extensions-and-hooks).
#### Load events
**Load events** are triggered once the tree for a given package has been fully constructed.
There is 1 generic **load event**:
- [`on_object`][griffe.Extension.on_object]: The "on object" event is triggered on any kind of object (except for aliases and packages, so modules, classes, functions, attributes and type aliases), once the tree for the object's package has been fully constructed.
There are also specific **load events** for each object kind:
- [`on_module`][griffe.Extension.on_module]: The "on module" event is triggered on modules.
- [`on_class`][griffe.Extension.on_class]: The "on class" event is triggered on classes.
- [`on_function`][griffe.Extension.on_function]: The "on function" event is triggered on functions.
- [`on_attribute`][griffe.Extension.on_attribute]: The "on attribute" event is triggered on attributes.
- [`on_type_alias`][griffe.Extension.on_type_alias]: The "on type alias" event is triggered on type aliases.
- [`on_alias`][griffe.Extension.on_alias]: The "on alias" event is triggered on aliases (imported/inherited objects).
- [`on_package`][griffe.Extension.on_package]: The "on package" event is triggered on top-level modules (packages) only.
#### Analysis events
**Analysis events** are triggered while modules are being scanned (with static or dynamic analysis). Data is incomplete when these events are triggered, so we recommend only hooking onto these events if you know what you are doing. In doubt, prefer using **load events** above.
There are 3 generic **analysis events**:
- [`on_node`][griffe.Extension.on_node]: The "on node" events are triggered when the agent (visitor or inspector) starts handling a node in the tree (AST or object tree).
- [`on_instance`][griffe.Extension.on_instance]: The "on instance" events are triggered when the agent just created an instance of [Module][griffe.Module], [Class][griffe.Class], [Function][griffe.Function], [Attribute][griffe.Attribute], or [Type Alias][griffe.TypeAlias], and added it as a member of its parent. The "on instance" event is **not** triggered when an [Alias][griffe.Alias] is created.
- [`on_members`][griffe.Extension.on_members]: The "on members" events are triggered when the agent just finished handling all the members of an object. Functions, attributes and type aliases do not have members, so there are no "on members" events for these kinds.
There are also specific **analysis events** for each object kind:
- [`on_module_node`][griffe.Extension.on_module_node]
- [`on_module_instance`][griffe.Extension.on_module_instance]
- [`on_module_members`][griffe.Extension.on_module_members]
- [`on_class_node`][griffe.Extension.on_class_node]
- [`on_class_instance`][griffe.Extension.on_class_instance]
- [`on_class_members`][griffe.Extension.on_class_members]
- [`on_function_node`][griffe.Extension.on_function_node]
- [`on_function_instance`][griffe.Extension.on_function_instance]
- [`on_attribute_node`][griffe.Extension.on_attribute_node]
- [`on_attribute_instance`][griffe.Extension.on_attribute_instance]
- [`on_type_alias_node`][griffe.Extension.on_type_alias_node]
- [`on_type_alias_instance`][griffe.Extension.on_type_alias_instance]
- [`on_alias_instance`][griffe.Extension.on_alias_instance]
#### Extensions and hooks
**Extensions** are classes that inherit from [Griffe's Extension base class][griffe.Extension] and define some hooks as methods:
```python
import griffe
class MyExtension(griffe.Extension):
def on_object(
self,
*,
obj: griffe.Object,
loader: griffe.GriffeLoader,
**kwargs,
) -> None:
"""Do something with `obj`."""
```
Hooks are always defined as methods of a class inheriting from [Extension][griffe.Extension], never as standalone functions. IDEs should autocomplete the signature when you start typing `def` followed by a hook name.
Since hooks are declared in a class, feel free to also declare state variables (or any other variable) in the `__init__` method:
```python
import ast
from griffe import Extension, Object, ObjectNode
class MyExtension(Extension):
def __init__(self) -> None:
super().__init__()
self.state_thingy = "initial stuff"
self.list_of_things = []
def on_object(
self,
*,
obj: griffe.Object,
loader: griffe.GriffeLoader,
**kwargs,
) -> None:
"""Do something with `obj`."""
```
### Static/dynamic support
Extensions can support both static and dynamic analysis of modules.
Objects have an `analysis` attribute whose value will be `"static"` if they were loaded using static analysis, or `"dynamic"` if they were loaded using dynamic analysis. If the value is `None`, it means the object was created manually (for example by another extension).
To support static analysis, dynamic analysis, or both in your load events, you can therefore check the value of the `analysis` attribute:
```python
import griffe
class MyExtension(griffe.Extension):
def on_object(self, *, obj: griffe.Object, **kwargs) -> None:
"""Do something with `obj`."""
if obj.analysis == "static":
... # Apply logic for static analysis.
elif obj.analysis == "dynamic":
... # Apply logic for dynamic analysis.
else:
... # Apply logic for manually built objects.
```
### Visiting trees
Extensions provide basic functionality to help you visit trees during analysis of the code:
- [`visit`][griffe.Extension.visit]: call `self.visit(node)` to start visiting an abstract syntax tree.
- [`generic_visit`][griffe.Extension.generic_visit]: call `self.generic_visit(node)` to visit each subnode of a given node.
- [`inspect`][griffe.Extension.inspect]: call `self.inspect(node)` to start visiting an object tree. Nodes contain references to the runtime objects, see [`ObjectNode`][griffe.ObjectNode].
- [`generic_inspect`][griffe.Extension.generic_inspect]: call `self.generic_inspect(node)` to visit each subnode of a given node.
Calling `self.visit(node)` or `self.inspect(node)` will do nothing unless you actually implement methods that handle specific types of nodes:
- for ASTs, methods must be named `visit_` where `` is replaced with the lowercase name of the node's class. For example, to allow visiting [`ClassDef`][ast.ClassDef] nodes, you must implement the `visit_classdef` method:
```python
import ast
from griffe import Extension
class MyExtension(Extension):
def visit_classdef(node: ast.ClassDef) -> None:
# Do something with the node...
...
# ...then visit the subnodes
# (it only makes sense if you implement other methods
# such as visit_functiondef or visit_assign for example).
self.generic_visit(node)
```
See the [list of existing AST classes](#ast-nodes) to learn what method you can implement.
- for object trees, methods must be named `inspect_`, where `` is replaced with the string value of the node's kind. The different kinds are listed in the [`ObjectKind`][griffe.ObjectKind] enumeration. For example, to allow inspecting coroutine nodes, you must implement the `inspect_coroutine` method:
```python
from griffe import Extension, ObjectNode
class MyExtension(Extension):
def inspect_coroutine(node: ObjectNode) -> None:
# Do something with the node...
...
# ...then visit the subnodes if it makes sense.
self.generic_inspect(node)
```
### Triggering other extensions
If your extension creates new objects, you might want to trigger the other enabled extensions on these object instances. To do this you can use [`agent.extensions.call`][griffe.Extensions.call]:
```python
import ast
import griffe
class MyExtension(griffe.Extension):
# Example from within a load event:
def on_package(self, *, pkg: griffe.Module, loader: griffe.GriffeLoader, **kwargs) -> None:
# New object created for whatever reason.
function = griffe.Function(...)
# Trigger other extensions.
loader.extensions.call("on_function", func=function, loader=loader)
# Example from within an analysis event:
def on_node(self, *, node: ast.AST | griffe.ObjectNode, agent: griffe.Visitor | griffe.Inspector, **kwargs) -> None:
# New object created for whatever reason.
function = griffe.Function(...)
# Trigger other extensions.
agent.extensions.call("on_function_instance", node=node, agent=agent, func=function, **kwargs)
```
### Extra data
All Griffe objects (modules, classes, functions, attributes, type aliases) can store additional (meta)data in their `extra` attribute. This attribute is a dictionary of dictionaries. The first layer is used as namespacing: each extension writes into its own namespace, or integrates with other projects by reading/writing in their namespaces, according to what they support and document.
```python
import griffe
self_namespace = "my_extension"
class MyExtension(griffe.Extension):
def on_object(self, obj: griffe.Object, **kwargs) -> None:
obj.extra[self_namespace]["some_key"] = "some_value"
```
For example, [mkdocstrings-python](https://mkdocstrings.github.io/python) looks into the `mkdocstrings` namespace for a `template` key. Extensions can therefore provide a custom template value by writing into `extra["mkdocstrings"]["template"]`:
```python
import griffe
self_namespace = "my_extension"
mkdocstrings_namespace = "mkdocstrings"
class MyExtension(griffe.Extension):
def on_class(self, cls: griffe.Class, **kwargs) -> None:
cls.extra[mkdocstrings_namespace]["template"] = "my_custom_template"
```
[Read more about mkdocstrings handler extensions.](https://mkdocstrings.github.io/usage/handlers/#handler-extensions)
### Options
Extensions can be made to support options. These options can then be passed from the [command-line](#on-the-command-line) using JSON, from Python directly, or from other tools like MkDocs, in `mkdocs.yml`.
```python
import griffe
class MyExtension(griffe.Extension):
def __init__(self, option1: str, option2: bool = False) -> None:
super().__init__()
self.option1 = option1
self.option2 = option2
def on_attribute(self, attr: griffe.Attribute, **kwargs) -> None:
if self.option2:
... # Do something.
```
### Logging
To better integrate with Griffe and other tools in the ecosystem (notably MkDocs), use Griffe loggers to log messages:
```python
import griffe
logger = griffe.get_logger(__name__)
class MyExtension(griffe.Extension):
def on_module(self, mod: griffe.Module, **kwargs) -> None:
logger.info("Doing some work on module %s", mod.path)
```
### Full example
The following example shows how one could write a "dynamic docstrings" extension that dynamically imports objects that declare their docstrings dynamically, to improve support for such docstrings. The extension is configurable to run only on user-selected objects.
Package structure (or just write your extension in a local script):
```tree
./
pyproject.toml
src/
dynamic_docstrings/
__init__.py
extension.py
```
```python title="./src/dynamic_docstrings/extension.py"
import ast
import inspect
import griffe
logger = griffe.get_logger(__name__)
class DynamicDocstrings(griffe.Extension):
def __init__(self, object_paths: list[str] | None = None) -> None:
self.object_paths = object_paths
def on_object(
self,
obj: griffe.Object,
loader: griffe.GriffeLoader,
**kwargs,
) -> None:
if obj.analysis == "dynamic":
return # Skip runtime objects, their docstrings are already right.
if self.object_paths and obj.path not in self.object_paths:
return # Skip objects that were not selected.
# Import object to get its evaluated docstring.
try:
runtime_obj = griffe.dynamic_import(obj.path)
docstring = runtime_obj.__doc__
except ImportError:
logger.debug(f"Could not get dynamic docstring for {obj.path}")
return
except AttributeError:
logger.debug(f"Object {obj.path} does not have a __doc__ attribute")
return
# Update the object instance with the evaluated docstring.
docstring = inspect.cleandoc(docstring)
if obj.docstring:
obj.docstring.value = docstring
else:
obj.docstring = griffe.Docstring(
docstring,
parent=obj,
docstring_parser=loader.docstring_parser,
docstring_options=loader.docstring_options,
)
```
You can then expose this extension in the top-level module of your package:
```python title="./src/dynamic_docstrings/__init__.py"
from dynamic_docstrings.extension import DynamicDocstrings
__all__ = ["DynamicDocstrings"]
```
This will allow users to load and use this extension by referring to it as `dynamic_docstrings` (your Python package name).
See [how to use extensions](#using-extensions) to learn more about how to load and use your new extension.
## AST nodes
>
>
> - [`Add`][ast.Add]
> - [`alias`][ast.alias]
> - [`And`][ast.And]
> - [`AnnAssign`][ast.AnnAssign]
> - [`arg`][ast.arg]
> - [`arguments`][ast.arguments]
> - [`Assert`][ast.Assert]
> - [`Assign`][ast.Assign]
> - [`AsyncFor`][ast.AsyncFor]
> - [`AsyncFunctionDef`][ast.AsyncFunctionDef]
> - [`AsyncWith`][ast.AsyncWith]
> - [`Attribute`][ast.Attribute]
> - [`AugAssign`][ast.AugAssign]
> - [`Await`][ast.Await]
> - [`BinOp`][ast.BinOp]
> - [`BitAnd`][ast.BitAnd]
> - [`BitOr`][ast.BitOr]
> - [`BitXor`][ast.BitXor]
> - [`BoolOp`][ast.BoolOp]
> - [`Break`][ast.Break]
> - `Bytes`[^1]
> - [`Call`][ast.Call]
> - [`ClassDef`][ast.ClassDef]
> - [`Compare`][ast.Compare]
> - [`comprehension`][ast.comprehension]
> - [`Constant`][ast.Constant]
> - [`Continue`][ast.Continue]
> - [`Del`][ast.Del]
> - [`Delete`][ast.Delete]
> - [`Dict`][ast.Dict]
>
>
>
> - [`DictComp`][ast.DictComp]
> - [`Div`][ast.Div]
> - `Ellipsis`[^1]
> - [`Eq`][ast.Eq]
> - [`ExceptHandler`][ast.ExceptHandler]
> - [`Expr`][ast.Expr]
> - `Expression`[^1]
> - `ExtSlice`[^2]
> - [`FloorDiv`][ast.FloorDiv]
> - [`For`][ast.For]
> - [`FormattedValue`][ast.FormattedValue]
> - [`FunctionDef`][ast.FunctionDef]
> - [`GeneratorExp`][ast.GeneratorExp]
> - [`Global`][ast.Global]
> - [`Gt`][ast.Gt]
> - [`GtE`][ast.GtE]
> - [`If`][ast.If]
> - [`IfExp`][ast.IfExp]
> - [`Import`][ast.Import]
> - [`ImportFrom`][ast.ImportFrom]
> - [`In`][ast.In]
> - `Index`[^2]
> - `Interactive`[^3]
> - [`Invert`][ast.Invert]
> - [`Is`][ast.Is]
> - [`IsNot`][ast.IsNot]
> - [`JoinedStr`][ast.JoinedStr]
> - [`keyword`][ast.keyword]
> - [`Lambda`][ast.Lambda]
> - [`List`][ast.List]
>
>
>
> - [`ListComp`][ast.ListComp]
> - [`Load`][ast.Load]
> - [`LShift`][ast.LShift]
> - [`Lt`][ast.Lt]
> - [`LtE`][ast.LtE]
> - [`Match`][ast.Match]
> - [`MatchAs`][ast.MatchAs]
> - [`match_case`][ast.match_case]
> - [`MatchClass`][ast.MatchClass]
> - [`MatchMapping`][ast.MatchMapping]
> - [`MatchOr`][ast.MatchOr]
> - [`MatchSequence`][ast.MatchSequence]
> - [`MatchSingleton`][ast.MatchSingleton]
> - [`MatchStar`][ast.MatchStar]
> - [`MatchValue`][ast.MatchValue]
> - [`MatMult`][ast.MatMult]
> - [`Mod`][ast.Mod]
> - `Module`[^3]
> - [`Mult`][ast.Mult]
> - [`Name`][ast.Name]
> - `NameConstant`[^1]
> - [`NamedExpr`][ast.NamedExpr]
> - [`Nonlocal`][ast.Nonlocal]
> - [`Not`][ast.Not]
> - [`NotEq`][ast.NotEq]
> - [`NotIn`][ast.NotIn]
> - `Num`[^1]
> - [`Or`][ast.Or]
> - [`ParamSpec`][ast.ParamSpec]
> - [`Pass`][ast.Pass]
>
>
>
> - `pattern`[^3]
> - [`Pow`][ast.Pow]
> - `Print`[^4]
> - [`Raise`][ast.Raise]
> - [`Return`][ast.Return]
> - [`RShift`][ast.RShift]
> - [`Set`][ast.Set]
> - [`SetComp`][ast.SetComp]
> - [`Slice`][ast.Slice]
> - [`Starred`][ast.Starred]
> - [`Store`][ast.Store]
> - `Str`[^1]
> - [`Sub`][ast.Sub]
> - [`Subscript`][ast.Subscript]
> - [`Try`][ast.Try]
> - `TryExcept`[^5]
> - `TryFinally`[^6]
> - [`Tuple`][ast.Tuple]
> - [`TypeAlias`][ast.TypeAlias]
> - [`TypeVar`][ast.TypeVar]
> - [`TypeVarTuple`][ast.TypeVarTuple]
> - [`UAdd`][ast.UAdd]
> - [`UnaryOp`][ast.UnaryOp]
> - [`USub`][ast.USub]
> - [`While`][ast.While]
> - [`With`][ast.With]
> - [`withitem`][ast.withitem]
> - [`Yield`][ast.Yield]
> - [`YieldFrom`][ast.YieldFrom]
>
>
[^1]: Deprecated since Python 3.8.
[^2]: Deprecated since Python 3.9.
[^3]: Not documented.
[^4]: `print` became a builtin (instead of a keyword) in Python 3.
[^5]: Now `ExceptHandler`, in the `handlers` attribute of `Try` nodes.
[^6]: Now a list of expressions in the `finalbody` attribute of `Try` nodes.
## Next steps
Extensions are a powerful mechanism to customize or enhance the data loaded by Griffe. But sometimes, all you need to do to improve the data is to make Griffe happy by following a few conventions. We therefore invite you to read our recommendations on [public APIs](recommendations/public-apis.md), [Python code best practices](recommendations/python-code.md) and [docstrings](recommendations/docstrings.md).
python-griffe-2.3.0/docs/guide/users/checking.md 0000664 0001750 0001750 00000064531 15246557273 021473 0 ustar carsten carsten # Checking APIs
Griffe is able to compare two snapshots of your project to detect API breakages between the old and the new snapshot. By snapshot we mean a specific point in your Git history. For example, you can ask Griffe to compare your current code against a specific tag.
## Command-line
### Using Git
By default, Griffe will compare the current code to the latest tag:
```console
$ griffe check mypackage
```
To specify another Git reference to check against, use the `--against` or `-a` option:
```console
$ griffe check mypackage -a 0.2.0
```
You can specify a Git tag, commit (hash), or even a branch: Griffe will create a worktree at this reference in a temporary directory, and clean it up after finishing.
If you want to also specify the *base* reference to use (instead of the current code), use the `--base` or `-b` option. Some examples:
```console
$ griffe check mypackage -b HEAD -a 2.0.0
$ griffe check mypackage -b 2.0.0 -a 1.0.0
$ griffe check mypackage -b fix-issue-90 -a 1.2.3
$ griffe check mypackage -b 8afcfd6e
```
TIP: **Important:** Remember that the base is the most recent reference, and the one we compare it against is the oldest one.
The package name you pass to `griffe check` must be found relative to the repository root. For Griffe to find packages in subfolders, pass the parent subfolder to the `--search` or `-s` option. Example for `src`-layouts:
```console
$ griffe check -s src griffe
```
Example in a monorepo, within a deeper file tree:
```console
$ griffe check -s back/services/identity-provider/src identity_provider
```
### Using PyPI
It's also possible to directly **check packages from PyPI.org** (or other indexes configured through `PIP_INDEX_URL`). This feature requires that you install Griffe with the `pypi` extra:
```console
$ pip install griffe[pypi]
```
The command syntax is:
```console
$ griffe check package_name -b project-name==2.0 -a project-name==1.0
```
You can let Griffe guess the package name by passing an empty string:
```console
$ griffe check "" -b project-name==2.0 -a project-name==1.0
```
[PEP 508 version specifiers](https://peps.python.org/pep-0508/) are supported (`<`, `<=`, `!=`, `==`, `>=`, `>`, `~=`). For example, to compare v2 against the version just before it:
```console
$ griffe check "" -b project-name==2.0 -a project-name<2.0
```
Without a version specifier on the base reference, or without a base reference at all, Griffe will use the latest available version. The two following commands compare the latest version against v1:
```console
$ griffe check "" -b project-name -a project-name==1.0
$ griffe check "" -a project-name==1.0
```
Griffe will actually install packages in a cache directory. It means a few things: source distributions are supported, and only packages that are compatible with your current environment can be checked.
## Python API
To programmatically check for API breaking changes, you have to load two snapshots of your code base, for example using our [`load_git()`][griffe.load_git] utility, and then passing them both to the [`find_breaking_changes()`][griffe.find_breaking_changes] function. This function will yield instances of [`Breakage`][griffe.Breakage]. It's up to you how you want to use these breakage instances.
```python
import griffe
my_pkg_v1 = griffe.load_git("my_pkg", ref="v1")
my_pkg_v2 = griffe.load_git("my_pkg", ref="v2")
for breaking_change in find_breaking_changes(my_pkg_v1, my_pkg_v2):
print(breaking_change.explain())
```
## In CI
It is of course possible to Griffe in CI (Continuous Integration) to make sure no breaking changes are introduced in pull/merge requests.
### GitHub {#ci-github}
Here is a quick example on how to use Griffe in a GitHub workflow:
```yaml
jobs:
check-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # We the need the full Git history.
- uses: astral-sh/setup-uv@v6
# The following command will compare current changes to latest tag.
- run: uvx griffe check --search src --format github your_package_name
```
The last step will fail the workflow if any breaking change is found.
### Azure Pipelines {#ci-azdo}
Here is a quick example on how to use Griffe in an Azure Pipeline. Griffe can format its output using Azure DevOps [task logging commands](https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands?view=azure-devops&tabs=bash#task-commands), similar to GitHub Actions messages:
```yaml
jobs:
- name: check-api
pool:
vmImage: 'ubuntu-latest'
steps:
- checkout: self
fetchDepth: 0 # We the need the full Git history.
- task: CmdLine@2
inputs:
script: pip install --user griffe
- task: CmdLine@2
inputs:
# The following command will compare current changes to latest tag.
script: |
griffe check --search src --format azdo your_package_name
```
The last step will fail the workflow if any breaking change is found.
## Detected breakages
In this section, we will describe the breakages that Griffe detects, giving some code examples and hints on how to properly communicate breakages with deprecation messages before actually releasing them.
Obviously, these explanations and the value of the hints we provide depend on your definition of what is a public Python API. There is no clear and generally agreed upon definition of "public Python API". A public Python API might vary from one project to another. In essence, your public API is what you say it is.
However, we do have conventions like prefixing objects with an underscore to tell users these objects are part of the private API, or internals, and therefore should not be used. For the rest, Griffe can detect changes that *will* trigger immediate errors in your users code', and changes that *might* cause issues in your users' code. Although the latter sound less impactful, they do have a serious impact, because they can *silently* change the behavior of your users' code, leading to issues that are hard to detect, understand and fix.
[Knowing that every change is a breaking change](https://xkcd.com/1172/), the more we detect and document (potentially) breaking changes in our changelogs, the better.
### Parameter moved
> Positional parameter was moved.
Moving the order of positional parameters can *silently* break your users' code.
```python title="before"
# your code
def greet(prefix, name):
print(prefix + " " + name)
# user's code
greet("hello", "world")
```
```python title="after"
# your code
def greet(name, prefix):
print(prefix + " " + name)
# user's code: no immediate error, broken behavior
greet("hello", "world")
```
NOTE: Moving required parameters around is not really an API breakage, depending on our definition of API, since this won't raise immediate errors like `TypeError`. The function expects a number of arguments, and the developer passes it this same number of arguments: the contract is fulfilled. But parameters very often have specific meaning, and changing their order will *silently lead* (no immediate error) to incorrect behavior, potentially making it difficult to detect, understand and fix the issue. That is why it is important to warn developers about such changes.
> TIP: **Hint**
> If you often add, move or remove parameters, consider making them keyword-only, so that their order doesn't matter.
>
> ```python title="before"
> def greet(*, prefix, name):
> print(prefix + " " + name)
>
> greet(prefix="hello", name="world")
> ```
>
> ```python title="after"
> def greet(*, name, prefix):
> print(prefix + " " + name)
>
> # still working as expected
> greet(prefix="hello", name="world")
> ```
### Parameter removed
> Parameter was removed.
Removing a parameter can immediately break your users' code.
```python title="before"
# your code
def greet(prefix, name):
print(prefix + " " + name)
# user's code
greet("hello", "world")
```
```python title="after"
# your code
def greet(name):
print("hello " + name)
# user's code: immediate error
greet("hello", "world")
# even with keyword parameters: immediate error
greet(prefix="hello", name="world")
```
> TIP: **Hint**
> Allow a deprecation period for the removed parameter by swallowing it in a variadic positional parameter, a variadic keyword parameter, or both.
>
> === "positional-only"
> ```python title="before"
> # your parameters are positional-only parameters (difficult deprecation)
> def greet(prefix, name, /):
> print(prefix + " " + name)
>
> greet("hello", "world")
> ```
>
> ```python title="after"
> # swallow prefix using a variadic positional parameter
> def greet(*args):
> if len(args) == 2:
> prefix, name = args
> elif len(args) == 1:
> prefix = None
> name = args[0]
> else:
> raise ValueError("missing parameter 'name'")
> if prefix is not None:
> warnings.warn(DeprecationWarning, "prefix is deprecated")
> print("hello " + name)
>
> # still working as expected
> greet("hello", "world")
> ```
>
> === "keyword-only"
> ```python title="before"
> # your parameters are keyword-only parameters (easy deprecation)
> def greet(*, prefix, name):
> print(prefix + " " + name)
>
> greet(prefix="hello", name="world")
> ```
>
> ```python title="after"
> # swallow prefix using a variadic keyword parameter
> def greet(name, **kwargs):
> prefix = kwargs.get("prefix", None)
> if prefix is not None:
> warnings.warn(DeprecationWarning, "prefix is deprecated")
> print("hello " + name)
>
> # still working as expected
> greet(prefix="hello", name="world")
> ```
>
> === "positional or keyword"
> ```python title="before"
> # your parameters are positional or keyword parameters (very difficult deprecation)
> def greet(prefix, name):
> print(prefix + " " + name)
>
> greet("hello", name="world")
> ```
>
> ```python title="after"
> # no other choice than swallowing both forms...
> # ignoring the deprecated parameter becomes quite complex
> def greet(*args, **kwargs):
> if len(args) == 2:
> prefix, name = args
> elif len(args) == 1:
> prefix = None
> name = args[0]
> if "name" in kwargs:
> name = kwargs["name"]
> if "prefix" in kwargs:
> prefix = kwargs["prefix"]
> if prefix is not None:
> warnings.warn(DeprecationWarning, "prefix is deprecated")
> print("hello " + name)
>
> # still working as expected
> greet("hello", "world")
> greet("hello", name="world")
> greet(prefix="hello", name="world")
> ```
### Parameter changed kind
> Parameter kind was changed
Changing the kind of a parameter to another (positional-only, keyword-only, positional or keyword, variadic positional, variadic keyword) can immediately break your users' code.
```python title="before"
# your code
def greet(name):
print("hello " + name)
def greet2(name):
print("hello " + name)
# user's code: all working fine
greet("tim")
greet(name="tim")
greet2("tim")
greet2(name="tim")
```
```python title="after"
# your code
def greet(name, /):
print("hello " + name)
def greet2(*, name):
print("hello " + name)
# user's code: working as expected
greet("tim")
greet2(name="tim")
# immediate error
greet(name="tim")
greet2("tim")
```
> TIP: **Hint**
> Although it actually is a breaking change, changing your positional or keyword parameters' kind to keyword-only makes your public function more robust to future changes (forward-compatibility).
>
> For functions with lots of optional parameters, and a few (one or two) required parameters, it can be a good idea to accept the required parameters as positional or keyword, while accepting the optional parameters as keyword-only parameters:
>
> ```python
> def greet(name, *, punctuation=False, bold=False, italic=False):
> ...
>
> # simple cases are easy to write
> greet("tim")
> greet("tiff")
>
> # complex cases are never ambiguous
> greet("tim", italic=True, bold=True)
> greet(name="tiff", bold=True, punctuation=True)
> ```
>
> Positional-only parameters are useful in some specific cases, such as when a function takes two or more numeric values, and their order does not matter, and naming the parameters would not make sense:
>
> ```python
> def multiply3(a, b, c, /):
> return a * b * c
>
> # all the following are equivalent
> multiply3(4, 2, 3)
> multiply3(4, 3, 2)
> multiply3(2, 3, 4)
> # etc.
> ```
### Parameter changed default
> Parameter default was changed
Changing the default value of a parameter can *silently* break your users' code.
```python title="before"
# your code
def compute_something(value: int, to_float=True):
value = ...
if to_float:
return float(value)
return value
# user's code: condition is entered
if isinstance(compute_something(7), float):
...
```
```python title="after"
# your code
def compute_something(value: int, to_float=False):
value = ...
if to_float:
return float(value)
return value
# user's code: condition is not entered anymore
if isinstance(compute_something(7), float):
...
```
NOTE: Changing default value of parameters is not really an API breakage, depending on our definition of API, since this won't raise immediate errors like `TypeError`. Not using the parameter still provides the argument with a default value: the contract is fulfilled. But default values very often have specific meaning, and changing them will *silently lead* (no immediate error) to incorrect behavior, potentially making it difficult to detect, understand and fix the issue. That is why it is important to warn developers about such changes.
> TIP: **Hint**
> Allow a deprecation period for the old default value by using a sentinel value to detect when the parameter wasn't used by the user:
>
> ```python title="in the coming release"
> _sentinel = object()
>
> def compute_something(value: int, to_float=_sentinel):
> value = ...
> if to_float is _sentinel:
> to_float = True
> warnings.warn(
> DeprecationWarning,
> "default value of 'to_float' will change from True to False, "
> "please provide 'to_float=True' if you want to retain the current behavior"
> )
> if to_float:
> return float(value)
> return value
> ```
>
> In a later release you can remove the sentinel, the deprecation warning, and set `False` as default to `to_float`.
>
> ```python title="in a later release"
> def compute_something(value: int, to_float=False):
> value = ...
> if to_float:
> return float(value)
> return value
> ```
### Parameter changed required
> Parameter is now required
Changing an optional parameter to a required one (by removing its default value) can immediately break your users' code.
```python title="before"
# your code
def greet(name, prefix="hello"):
print(prefix + " " + name)
# user's code
greet("tiff")
```
```python title="after"
# your code
def greet(name, prefix):
print(prefix + " " + name)
# user's code: immediate error
greet("tiff")
```
> TIP: **Hint**
> Allow a deprecation period for the default value by using a sentinel value to detect when the parameter wasn't used by the user:
>
> ```python title="in the coming release"
> _sentinel = object()
>
> def greet(name, prefix=_sentinel):
> if prefix is _sentinel:
> prefix = "hello"
> warnings.warn(DeprecationWarning, "'prefix' will become required in the next release")
> print(prefix + " " + name)
> ```
>
> In a later release you can remove the sentinel, the deprecation warning, and the default value of `prefix`.
>
> ```python title="in a later release"
> def greet(name, prefix):
> print(prefix + " " + name)
> ```
### Parameter added required
> Parameter was added as required
Adding a new, required parameter can immediately break your users' code.
```python title="before"
# your code
def greet(name):
print("hello " + name)
# user's code
greet("tiff")
```
```python title="after"
# your code
def greet(name, prefix):
print(prefix + " " + name)
# user's code: immediate error
greet("tiff")
```
> TIP: **Hint**
> You can delay (or avoid) and inform your users about the upcoming breakage by temporarily (or permanently) providing a default value for the new parameter:
>
> ```python title="in the coming release"
> def greet(name, prefix="hello"):
> print(prefix + " " + name)
> ```
### Return changed type
> Return types are incompatible
WARNING: **Not yet supported!** Telling if a type construct is compatible with another one is not trivial, especially statically. Support for this will be implemented later.
### Object removed
> Public object was removed
Removing a public object from a module can immediately break your users' code.
```python title="before"
# your/module.py
special_thing = "hey"
# user/module.py
from your.module import special_thing
# other/user/module.py
from your import module
print(module.special_thing)
```
```python title="after"
# user/module.py: import error
from your.module import special_thing
# other/user/module.py: attribute error
from your import module
print(module.special_thing)
```
> TIP: **Hint**
> Allow a deprecation period by declaring a module-level `__getattr__` function that returns the given object while warning about its deprecation:
>
> ```python
> def __getattr__(name):
> if name == "special_thing":
> warnings.warn(DeprecationWarning, "'special_thing' is deprecated and will be removed")
> return "hey"
> ```
### Object changed kind
> Public object points to a different kind of object
Changing the kind (type alias, attribute, function, class, module) of a public object can *silently* break your users' code.
```python title="before"
# your code
class Factory:
def __call__(self, ...):
...
factory = Factory(...)
# user's code: condition is entered
if isinstance(factory, Factory):
...
```
```python title="after"
# your code
class Factory:
...
def factory(...):
...
# user's code: condition is not entered anymore
if isinstance(factory, Factory):
...
```
NOTE: Changing the kind of an object is not really an API breakage, depending on our definition of API, since this won't always raise immediate errors like `TypeError`. The object is still here and accessed: the contract is fulfilled. But developers sometimes rely on the kind of an object, so changing it will lead to incorrect behavior, potentially making it difficult to detect, understand and fix the issue. That is why it is important to warn developers about such changes.
### Attribute changed type
> Attribute types are incompatible
WARNING: **Not yet supported!** Telling if a type construct is compatible with another one is not trivial, especially statically. Support for this will be implemented later.
### Attribute changed value
> Attribute value was changed
Changing the value of an attribute can *silently* break your users' code.
```python title="before"
# your code
PY_VERSION = os.getenv("PY_VERSION")
# user's code: condition is entered
if PY_VERSION is None:
...
```
```python title="after"
# your code
PY_VERSION = os.getenv("PY_VERSION", "3.8")
# user's code: condition is not entered anymore
if PY_VERSION is None:
...
```
NOTE: Changing the value of an attribute is not really an API breakage, depending on our definition of API, since this won't raise immediate errors like `TypeError`. The attribute is still here and accessed: the contract is fulfilled. But developers heavily rely on the value of public attributes, so changing it will lead to incorrect behavior, potentially making it difficult to detect, understand and fix the issue. That is why it is important to warn developers about such changes.
TIP: **Hint**
Make sure to document the change of value of the attribute in your changelog, particularly the previous and new range of values it can take.
### Class removed base
> Base class was removed
Removing a class from another class' bases can *silently* break your users' code.
```python title="before"
# your code
class A: ...
class B: ...
class C(A, B): ...
# user's code: condition is entered
if B in klass.__bases__:
...
```
```python title="after"
# your code
class A: ...
class B: ...
class C(A): ...
# user's code: condition is not entered anymore
if B in klass.__bases__:
...
```
NOTE: Unless inherited members are lost in the process, removing a class base is not really an API breakage, depending on our definition of API, since this won't raise immediate errors like `TypeError`. The class is here, its members as well: the contract is fulfilled. But developers sometimes rely on the actual bases of a class, so changing them will lead to incorrect behavior, potentially making it difficult to detect, understand and fix the issue. That is why it is important to warn developers about such changes.
## Output style
Griffe supports writing detected breakages in multiple formats, or styles.
[](){#format-oneline}
### One-line
- **CLI**: `-f oneline` / no flags
- **API**: `check(...)` / `check(..., style="oneline")` / `check(..., style=ExplanationStyle.ONE_LINE)`
This is the default format. Griffe will print each detected breakage on a single line:
```console exec="1" source="console" result="ansi" returncode="1" id="griffe-check-oneline"
$ export FORCE_COLOR=1 # markdown-exec: hide
$ griffe check griffe -ssrc -b0.46.0 -a0.45.0
```
[](){#format-verbose}
### Verbose
- **CLI**: `-f verbose` / `-v`
- **API**: `check(..., style="verbose")` / `check(..., style=ExplanationStyle.VERBOSE)` / `check(..., verbose=True)`
Depending on the detected breakages, the lines might be hard to read (being too compact), so `griffe check` also accepts a `--verbose` or `-v` option to add some space to the output:
```console exec="1" source="console" result="ansi" returncode="1" id="griffe-check-verbose"
$ export FORCE_COLOR=1 # markdown-exec: hide
$ griffe check griffe -ssrc -b0.46.0 -a0.45.0 --verbose
```
[](){#format-markdown}
### Markdown
- **CLI**: `-f markdown`
- **API**: `check(..., style="markdown")` / `check(..., style=ExplanationStyle.MARKDOWN)`
The Markdown format is adapted for changelogs. It doesn't show the file and line number, and instead prints out the complete path of your API objects. With a bit of automation, you will be able to automatically insert a summary of breaking changes in your changelog entries.
```md exec="1" source="tabbed-left" tabs="Output|Result"
- `griffe.loader.GriffeLoader.resolve_aliases(only_exported)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.loader.GriffeLoader.resolve_aliases(only_exported)`: *Parameter default was changed*: `True` -> `None`
- `griffe.loader.GriffeLoader.resolve_aliases(only_known_modules)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.loader.GriffeLoader.resolve_aliases(only_known_modules)`: *Parameter default was changed*: `True` -> `None`
- `griffe.loader.GriffeLoader.resolve_aliases(max_iterations)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.loader.GriffeLoader.resolve_module_aliases(only_exported)`: *Parameter was removed*
- `griffe.loader.GriffeLoader.resolve_module_aliases(only_known_modules)`: *Parameter was removed*
- `griffe.git.tmp_worktree(commit)`: *Parameter was removed*
- `griffe.git.tmp_worktree(repo)`: *Positional parameter was moved*: position: from 2 to 1 (-1)
- `griffe.git.load_git(commit)`: *Parameter was removed*
- `griffe.git.load_git(repo)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(submodules)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(try_relative_path)`: *Parameter was removed*
- `griffe.git.load_git(extensions)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(search_paths)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(docstring_parser)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(docstring_options)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(lines_collection)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(modules_collection)`: *Parameter kind was changed*: positional or keyword -> keyword-only
- `griffe.git.load_git(allow_inspection)`: *Parameter kind was changed*: positional or keyword -> keyword-only
```
[](){#format-github}
### GitHub
- **CLI**: `-f github`
- **API**: `check(..., style="github")` / `check(..., style=ExplanationStyle.GITHUB)`
When running `griffe check` in CI, you can enable GitHub's annotations thanks to the GitHub output style. Annotations are displayed on specific lines of code. They are visible in the Checks tab. When you create an annotation for a file that is part of the pull request, the annotations are also shown in the Files changed tab.
/// tab | Files changed tab

///
/// tab | Checks tab

///
```console
% python -m griffe check -fgithub -ssrc griffe
::warning file=src/griffe/finder.py,line=58,title=Package.name::Attribute value was changed: `name` -> unset
::warning file=src/griffe/finder.py,line=60,title=Package.path::Attribute value was changed: `path` -> unset
::warning file=src/griffe/finder.py,line=62,title=Package.stubs::Attribute value was changed: `stubs` -> `None`
::warning file=src/griffe/finder.py,line=75,title=NamespacePackage.name::Attribute value was changed: `name` -> unset
::warning file=src/griffe/finder.py,line=77,title=NamespacePackage.path::Attribute value was changed: `path` -> unset
```
[](){#format-azdo}
### Azure DevOps / Azure Pipelines
- **CLI**: `-f azdo`
- **API**: `check(..., style="azdo")` / `check(..., style=ExplanationStyle.AZURE_DEVOPS)`
Similarly to the GitHub workflow syntax, Griffe is capable of using the task logging command syntax used by Azure Pipelines as part of the Azure DevOps Services.
When running `griffe check` in CI, warnings are displayed in the warnings panel of your pipeline.
## Next steps
If you are using a third-party library to mark objects as public, or if you follow conventions different than the one Griffe understands, you might get false-positives, or breaking changes could go undetected. In that case, you might be interested in [extending](extending.md) how Griffe loads API data to support these third-party libraries or other conventions.
python-griffe-2.3.0/docs/guide/users/navigating.md 0000664 0001750 0001750 00000067367 15246557273 022061 0 ustar carsten carsten # Navigating APIs
Griffe loads API data into data models. These models provide various attributes and methods to access or update specific fields. The different models are:
- [`Module`][griffe.Module], representing Python modules;
- [`Class`][griffe.Class], representing Python classes;
- [`Function`][griffe.Function], representing Python functions and class methods;
- [`Attribute`][griffe.Attribute], representing object attributes that weren't identified as modules, classes or functions;
- [`Type Alias`][griffe.TypeAlias], representing Python type aliases;
- [`Alias`][griffe.Alias], representing indirections such as imported objects or class members inherited from parent classes.
When [loading an object](loading.md), Griffe will give you back an instance of one of these models. A few examples:
```python
>>> import griffe
>>> type(griffe.load("markdown"))
>>> type(griffe.load("markdown.core.Markdown"))
>>> type(griffe.load("markdown.Markdown"))
>>> type(griffe.load("markdown.core.markdown"))
>>> type(griffe.load("markdown.markdown"))
>>> type(griffe.load("markdown.Markdown.references"))
```
However deep the object is, Griffe loads the entire package. It means that in all the cases above, Griffe loaded the whole `markdown` package. The model instance Griffe gives you back is therefore part of a tree that you can navigate.
## Moving up: parents
Each object holds a reference to its [`parent`][griffe.Object.parent] (except for the top-level module, for which the parent is `None`). Shortcuts are provided to climb up directly to the parent [`module`][griffe.Object.module], or the top-level [`package`][griffe.Object.package]. As we have seen in the [Loading chapter](loading.md), Griffe stores all loaded modules in a modules collection; this collection can be accessed too, through the [`modules_collection`][griffe.Object.modules_collection] attribute.
## Moving down: members
To access an object's members, there are a few options:
- Access to regular members through the [`members`][griffe.Object.members] attribute, which is a dictionary. The keys are member names, the values are Griffe models.
```pycon
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown.members["Markdown"]
Alias('Markdown', 'markdown.core.Markdown')
>>> markdown.members["core"].members["Markdown"]
Class('Markdown', 46, 451)
```
- Access to both regular and inherited members through the [`all_members`][griffe.Object.all_members] attribute, which is a dictionary again. See [Inherited members](#inherited-members).
- Convenient dictionary-like item access, thanks to the subscript syntax `[]`. With this syntax, you will not only be able to chain accesses, but also merge them into a single access by using dot-separated paths to objects:
```pycon
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown["core"]["Markdown"] # chained access
Class('Markdown', 46, 451)
>>> markdown["core.Markdown"] # merged access
Class('Markdown', 46, 451)
```
The dictionary-like item access also accepts tuples of strings. So if for some reason you don't have a string equal to `"core.Markdown"` but a tuple equal to `("core", "Markdown")` (for example obtained from splitting another string), you can use it too:
```pycon
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown[("core", "Markdown")] # tuple access
Class('Markdown', 46, 451)
>>> # Due to the nature of the subscript syntax,
>>> # you can even use implicit tuples.
>>> markdown["core", "Markdown"]
Class('Markdown', 46, 451)
```
- Less convenient, but safer access to members while the object tree is being built (while a package is still being loaded), using the [`get_member()`][griffe.GetMembersMixin.get_member] method.
```pycon
>>> import griffe
>>> markdown = griffe.load("markdown")
>>> markdown.get_member("core.Markdown")
Class('Markdown', 46, 451)
```
In particular, Griffe extensions should always use `get_member` instead of the subscript syntax `[]`. The `get_member` method only looks into regular members, while the subscript syntax looks into inherited members too (for classes), which cannot be correctly computed until a package is fully loaded (which is generally not the case when an extension is running).
- In addition to this, models provide the [`attributes`][griffe.Object.attributes], [`functions`][griffe.Object.functions], [`classes`][griffe.Object.classes], [`type_aliases`][griffe.Object.type_aliases] or [`modules`][griffe.Object.modules] attributes, which return only members of the corresponding kind. These attributes are computed dynamically each time (they are Python properties).
The same way members are accessed, they can also be set:
- Dictionary-like item assignment: `markdown["thing"] = ...`, also supporting dotted-paths and string tuples. This will (re)assign only regular members: inherited members (classes only) are re-computed every time they are accessed.
- Safer method for extensions: `markdown.set_member("thing", ...)`, also supporting dotted-paths and string tuples.
- Regular member assignment: `markdown.members["thing"] = ...`. **This is not recommended, as the assigned member's `parent` attribute will not be automatically updated.**
...and deleted:
- Dictionary-like item deletion: `del markdown["thing"]`, also supporting dotted-paths and string tuples. This will delete only regular members: inherited members (classes only) are re-computed every time they are accessed.
- Safer method for extensions: `markdown.del_member("thing")`, also supporting dotted-paths and string tuples.
- Regular member deletion: `del markdown.members["thing"]`. **This is not recommended, as the [`aliases`][griffe.Object.aliases] attribute of other objects in the tree will not be automatically updated.**
### Inherited members
Griffe supports class inheritance, both when visiting and inspecting modules.
To access members of a class that are inherited from base classes, use the [`inherited_members`][griffe.Object.inherited_members] attribute. Every time you access inherited members, the base classes of the given class will be resolved, then the MRO (Method Resolution Order) will be computed for these base classes, and a dictionary of inherited members will be built. Make sure to store the result in a variable to avoid re-computing it every time (you are responsible for the caching part). Also make sure to only access `inherited_members` once everything is loaded by Griffe, to avoid computing things too early. Don't try to access inherited members in extensions, while visiting or inspecting modules.
Inherited members are aliases that point at the corresponding members in parent classes. These aliases will have their [`inherited`][griffe.Alias.inherited] attribute set to true.
**Important:** only classes from already loaded packages will be used when computing inherited members. This gives users control over how deep into inheritance to go, by pre-loading packages from which you want to inherit members. For example, if `package_c.ClassC` inherits from `package_b.ClassB`, itself inheriting from `package_a.ClassA`, and you want to load `ClassB` members only:
```python
import griffe
loader = griffe.GriffeLoader()
# note that we don't load package_a
loader.load("package_b")
loader.load("package_c")
```
If a base class cannot be resolved during computation of inherited members, Griffe logs a DEBUG message.
If you want to access all members at once (both declared and inherited), use the [`all_members`][griffe.Object.all_members] attribute. If you want to access only declared members, use the [`members`][griffe.Object] attribute.
Accessing the [`attributes`][griffe.Object.attributes], [`functions`][griffe.Object.functions], [`classes`][griffe.Object.classes], [`type_aliases`][griffe.Object.type_aliases] or [`modules`][griffe.Object.modules] attributes will trigger inheritance computation, so make sure to only access them once everything is loaded by Griffe. Don't try to access inherited members in extensions, while visiting or inspecting modules.
#### Limitations
Currently, there are three limitations to our class inheritance support:
1. when visiting (static analysis), some objects are not yet properly recognized as classes, for example named tuples. If you inherit from a named tuple, its members won't be added to the inherited members of the inheriting class.
```python
MyTuple = namedtuple("MyTuple", "attr1 attr2")
class MyClass(MyTuple):
...
```
2. when visiting (static analysis), subclasses using the same name as one of their parent classes will prevent Griffe from computing the MRO and therefore the inherited members. To circumvent that, give a different name to your subclass:
```python
from package import SomeClass
# instead of
class SomeClass(SomeClass):
...
# do
class SomeOtherClass(SomeClass):
...
```
3. when inspecting (dynamic analysis), ephemeral base classes won't be resolved, and therefore their members won't appear in child classes. To circumvent that, assign these dynamic classes to variables:
```python
# instead of
class MyClass(namedtuple("MyTuple", "attr1 attr2")):
...
# do
MyTuple = namedtuple("MyTuple", "attr1 attr2")
class MyClass(MyTuple):
...
```
We will try to lift these limitations in the future.
## Aliases
Aliases represent indirections, such as objects imported from elsewhere, attributes, or methods inherited from parent classes. They are pointers to the object they represent. The path of the object they represent is stored in their [`target_path`][griffe.Alias.target_path] attribute. Once they are resolved, the target object can be accessed through their [`target`][griffe.Alias.target] attribute.
Aliases can be found in objects' members. Each object can also access its own aliases (the aliases pointing at it) through its [`aliases`][griffe.Object.aliases] attribute. This attribute is a dictionary whose keys are the aliases paths and values are the aliases themselves.
Most of the time, aliases simply act as proxies to their target objects. For example, accessing the `docstring` of an alias will simply return the docstring of the object it targets.
Accessing fields on aliases will trigger their resolution. If they are already resolved (their `target` attribute is set to the target object), the field is returned. If they are not resolved, their target path will be looked up in the modules collection, and if it is found, the object at this location will be assigned to the alias' `target` attribute. If it isn't found, an [`AliasResolutionError`][griffe.AliasResolutionError] exception will be raised.
Since merely accessing an alias field can raise an exception, it is often useful to check if an object is an alias before accessing its fields. There are multiple ways to check if an object is an alias:
- using the `is_alias` boolean ([`Object.is_alias`][griffe.Object.is_alias], [`Alias.is_alias`][griffe.Alias.is_alias]), which won't trigger resolution
- using `isinstance` to check if the object is an instance of [`Alias`][griffe.Alias]
```pycon
>>> import griffe
>>> load = griffe.load("griffe.load")
>>> load.is_alias
True
>>> isinstance(load, griffe.Alias)
True
```
The [`kind`][griffe.Alias.kind] of an alias will only return [`ALIAS`][griffe.Kind.ALIAS] if the alias is not resolved and cannot be resolved within the current modules collection.
You can of course also catch any raised exception with a regular try/except block:
```python
try:
print(obj.source)
except griffe.AliasResolutionError:
pass
```
To check if an alias is already resolved, you can use its [`resolved`][griffe.Alias.resolved] attribute.
### Alias chains
Aliases can be chained. For example, if module `a` imports `X` from module `b`, which itself imports `X` from module `c`, then `a.X` is an alias to `b.X` which is an alias to `c.X`: `a.X` -> `b.X` -> `c.X`. To access the final target directly, you can use the [`final_target`][griffe.Alias.final_target] attribute. Most alias properties that act like proxies actually fetch the final target rather than the next one to return the final field.
Sometimes, when a package makes use of complicated imports (wildcard imports from parents and submodules), or when runtime objects are hard to inspect, it is possible to end up with a cyclic chain of aliases. You could for example end up with a chain like `a.X` -> `b.X` -> `c.X` -> `a.X`. In this case, the alias *cannot* be resolved, since the chain goes in a loop. Griffe will raise a [`CyclicAliasError`][griffe.CyclicAliasError] when trying to resolve such cyclic chains.
Aliases chains are never partially resolved: either they are resolved down to their final target, or none of their links are resolved.
## Object kind
The kind of an object (module, class, function, attribute, type alias or alias) can be obtained in several ways.
- With the [`kind`][griffe.Object.kind] attribute and the [`Kind`][griffe.Kind] enumeration: `obj.kind is Kind.MODULE`.
- With the [`is_kind()`][griffe.Object.is_kind] method:
- `obj.is_kind(Kind.MODULE)`
- `obj.is_kind("class")`
- `obj.is_kind({"function", Kind.ATTRIBUTE})`
When given a set of kinds, the method returns true if the object is of one of the given kinds.
- With the [`is_module`][griffe.Object.is_module], [`is_class`][griffe.Object.is_class], [`is_function`][griffe.Object.is_function], [`is_attribute`][griffe.Object.is_attribute], [`is_type_alias`][griffe.Object.is_type_alias], and [`is_alias`][griffe.Object.is_alias] attributes.
Additionally, it is possible to check if an object is a sub-kind of module, with the following attributes:
- [`is_init_module`][griffe.Object.is_init_module], for `__init__.py` modules
- [`is_package`][griffe.Object.is_package], for top-level packages
- [`is_subpackage`][griffe.Object.is_subpackage], for non-top-level packages
- [`is_namespace_package`][griffe.Object.is_namespace_package], for top-level [namespace packages](https://packaging.python.org/en/latest/guides/packaging-namespace-packages/)
- [`is_namespace_subpackage`][griffe.Object.is_namespace_subpackage], for non-top-level namespace packages
Finally, additional [`labels`][griffe.Object.labels] are attached to objects to further specify their kind. The [`has_labels()`][griffe.Object.has_labels] method can be used to check if an object has several specific labels.
## Object location
An object is identified by its [`path`][griffe.Object.path], which is its location in the object tree. The path is composed of all the parent names and the object name, separated by dots, for example `mod.Class.meth`. This `path` is the [`canonical_path`][griffe.Object.canonical_path] on regular objects. For aliases however, the `path` is *where they are imported* while the canonical path is *where they come from*. Example:
```python
# pkg1.py
from pkg2 import A as B
```
```pycon
>>> import griffe
>>> B = griffe.load("pkg1.B")
>>> B.path
'pkg1.B'
>>> B.canonical_path
'pkg2.A'
```
### Source
Information on the actual source code of objects is available through the following attributes:
- [`filepath`][griffe.Object.filepath], the absolute path to the module the object appears in, for example `~/project/src/pkg/mod.py`
- [`relative_filepath`][griffe.Object.relative_filepath], the relative path to the module, compared to the current working directory, for example `src/pkg/mod.py`
- [`relative_package_filepath`][griffe.Object.relative_package_filepath], the relative path to the module, compared to the parent of the top-level package, for example `pkg/mod.py`
- [`lineno`][griffe.Object.lineno] and [`endlineno`][griffe.Object.endlineno], the starting and ending line numbers of the object in the source
- [`lines`][griffe.Object.lines], the lines of code defining the object (or importing the alias)
- [`source`][griffe.Object.source], the source lines concatenated as a single multiline string
Each object holds a reference to a [`lines_collection`][griffe.Object.lines_collection]. Similar to the modules collection, this lines collection is a dictionary whose keys are module file-paths and values are their contents as list of lines. The lines collection is populated by the loader.
## Object visibility
Each object has fields that are related to their visibility within the API.
- [`is_public`][griffe.Object.is_public]: whether this object is public (destined to be consumed by your users). For module-level objects, Griffe considers that the object is public if:
- it is listed in its parent module's `__all__` attribute
- or if its parent module does not declare `__all__`, and the object doesn't have a private name, and the object is not imported from elsewhere
```python
# package1/__init__.py
from package2 import A # not public
from package1 import submodule # not public
b = 0 # public
_c = 1 # not public
__d = 2 # not public
def __getattr__(name: str): # public
...
```
For class-level objects, Griffe considers that the object is public if the object doesn't have a private name, and the object is not imported from elsewhere.
```python
# package1/__init__.py
class A:
from package1.module import X # not public
from package2 import Y # not public
b = 0 # public
_c = 1 # not public
__d = 2 # not public
def __eq__(self, other): # public
...
```
- [`is_deprecated`][griffe.Object.is_deprecated]: whether this object is deprecated and shouldn't be used.
- [`is_special`][griffe.Object.is_special]: whether this object has a special name like `__special__`
- [`is_private`][griffe.Object.is_private]: whether this object has a private name like `_private` or `__private`, but not `__special__`
- [`is_class_private`][griffe.Object.is_class_private]: whether this object has a class-private name like `__private` and is a member of a class
Since `is_private` only checks the name of the object, it is not mutually exclusive with `is_public`. It means an object can return true for both `is_public` and `is_private`. We invite Griffe users to mostly rely on `is_public` and `not is_public`.
It is possible to force `is_public` and `is_deprecated` to return true or false by setting the [`public`][griffe.Object.public] and [`deprecated`][griffe.Object.deprecated] fields respectively. These fields are typically set by extensions that support new ways of marking objects as public or deprecated.
## Imports/exports
Modules and classes populate their [`imports`][griffe.Object.imports] field with names that were imported from other modules. Similarly, modules populate their [`exports`][griffe.Object.exports] field with names that were exported by being listed into the module's `__all__` attribute. Each object then provides then [`is_imported`][griffe.Object.is_imported] and [`is_exported`][griffe.Object.is_exported] fields, which tell if an object was imported or exported respectively. Additionally, objects also provide an [`is_wildcard_exposed`][griffe.Object.is_wildcard_exposed] field that tells if an object is exposed to wildcard imports, i.e. will be imported when another module does `from this_module import *`.
## Docstrings
Each object has an optional [`docstring`][griffe.Object.docstring] attached to it. To check whether it has one without comparing against `None`, the two following fields can be used:
- [`has_docstring`][griffe.Object.has_docstring]: whether this object has a docstring (even empty)
- [`has_docstrings`][griffe.Object.has_docstrings]: same thing, but recursive; whether this object or any of its members has a docstring (even empty)
[Docstrings][griffe.Docstring] provide their cleaned-up [`value`][griffe.Docstring.value] (de-indented string, stripped from leading and trailing newlines), as well as their starting and ending line numbers with [`lineno`][griffe.Docstring.lineno] and [`endlineno`][griffe.Docstring.endlineno].
Docstrings can be parsed against several [docstring-styles](../../reference/docstrings.md), which are micro-formats that allow documenting things such as parameters, returned values, raised exceptions, etc..
When loading a package, it is possible to specify the docstring style to attach to every docstring (see the `docstring_parser` parameter of [`griffe.load`][griffe.load]). Accessing the [`parsed`][griffe.Docstring.parsed] field of a docstring will use this style to parse the docstring and return a list of [docstring sections][advanced-api-sections]. Each section has a `value` whose shape depends on the section kind. For example, parameter sections have a list of parameter representations as value, while a text section only has a string as value.
After a package is loaded, it is still possible to change the style used for specific docstrings by either overriding their [`parser`][griffe.Docstring.parser] and [`parser_options`][griffe.Docstring.parser_options] attributes, or by calling their [`parse()`][griffe.Docstring.parse] method with a different style:
```pycon
>>> import griffe
>>> markdown = griffe.load("markdown", docstring_parser="google")
>>> markdown["Markdown"].docstring.parse("numpy")
[...]
```
Do note, however, that the `parsed` attribute is cached, and won't be reset when overriding the `parser` or `parser_options` values.
Docstrings have a [`parent`][griffe.Docstring.parent] field too, that is a reference to their respective module, class, function, attribute or type alias.
## Model-specific fields
Models have most fields in common, but also have specific fields.
### Modules
- [`imports_future_annotations`][griffe.Module.imports_future_annotations]: Whether the module imports [future annotations](https://peps.python.org/pep-0563/), which changes the way we parse type annotations.
- [`overloads`][griffe.Module.overloads]: A dictionary to store overloads for module-level functions.
### Classes
- [`bases`][griffe.Class.bases]: A list of class bases in the form of [expressions][griffe.Expr].
- [`resolved_bases`][griffe.Class.resolved_bases]: A list of class bases, in the form of [Class][griffe.Class] objects. Only the bases that were loaded are returned, the others are discarded.
- [`mro()`][griffe.Class.mro]: A method to compute the Method Resolution Order in the form of a list of [Class][griffe.Class] objects.
- [`overloads`][griffe.Class.overloads]: A dictionary to store overloads for class-level methods.
- [`decorators`][griffe.Class.decorators]: The [decorators][griffe.Decorator] applied to the class.
- [`parameters`][griffe.Class.parameters]: The [parameters][griffe.Parameters] of the class' `__init__` method, if any.
- [`type_parameters`][griffe.Class.type_parameters]: The [type parameters][griffe.TypeParameters] of the class.
### Functions
- [`decorators`][griffe.Function.decorators]: The [decorators][griffe.Decorator] applied to the function.
- [`overloads`][griffe.Function.overloads]: The overloaded signatures of the function.
- [`parameters`][griffe.Function.parameters]: The [parameters][griffe.Parameters] of the function.
- [`returns`][griffe.Function.returns]: The type annotation of the returned value, in the form of an [expression][griffe.Expr]. The `annotation` field can also be used, for compatibility with attributes.
- [`type_parameters`][griffe.Function.type_parameters]: The [type parameters][griffe.TypeParameters] of the function.
### Attributes
- [`annotation`][griffe.Attribute.annotation]: The type annotation of the attribute, in the form of an [expression][griffe.Expr].
- [`value`][griffe.Attribute.value]: The value of the attribute, in the form of an [expression][griffe.Expr].
- [`deleter`][griffe.Attribute.deleter]: The property deleter.
- [`setter`][griffe.Attribute.setter]: The property setter.
### Type aliases
- [`value`][griffe.TypeAlias.value]: The value of the type alias, in the form of an [expression][griffe.Expr].
- [`type_parameters`][griffe.TypeAlias.type_parameters]: The [type parameters][griffe.TypeParameters] of the type alias.
### Alias
- [`alias_lineno`][griffe.Alias.alias_lineno]: The alias line number (where the object is imported).
- [`alias_endlineno`][griffe.Alias.alias_endlineno]: The alias ending line number (where the object is imported).
- [`target`][griffe.Alias.target]: The alias target (a module, class, function or attribute).
- [`target_path`][griffe.Alias.target_path]: The path of the alias target, as a string.
- [`wildcard`][griffe.Alias.wildcard]: Whether this alias represents a wildcard import, and if so from which module.
- [`resolve_target()`][griffe.Alias.resolve_target]: A method that resolves the target when called.
## Expressions
When parsing source code, Griffe builds enhanced ASTs for type annotations, decorators, parameter defaults, attribute values, etc.
These "expressions" are very similar to what Python's [ast][] module gives you back when parsing source code, with a few differences: attributes like `a.b.c.` are flattened, and names like `a` have a parent object attached to them, a Griffe object, allowing to resolve this name to its full path given the scope of its parent.
You can write some code below and print annotations or attribute values with [Rich]'s pretty printer to see how expressions look like.
```pyodide install="griffe,rich" theme="tomorrow,dracula"
from griffe import temporary_visited_module
from rich.pretty import pprint
code = """
from dataclasses import dataclass
from random import randint
@dataclass
class Bar:
baz: int
def get_some_baz() -> int:
return randint(0, 10)
foo: Bar = Bar(baz=get_some_baz())
"""
with temporary_visited_module(code) as module:
pprint(module["foo"].annotation)
pprint(module["foo"].value)
```
Ultimately, these expressions are what allow downstream tools such as [mkdocstrings' Python handler][mkdocstrings-python] to render cross-references to every object it knows of, coming from the current code base or loaded from object inventories (objects.inv files).
During static analysis, these expressions also allow analyzing decorators, dataclass fields, and many more things in great detail, and in a robust manner, to build third-party libraries support in the form of [Griffe extensions](extending.md).
To learn more about expressions, read their [API reference](../../reference/api/expressions.md).
### Modernization
The Python language keeps evolving, and often library developers must continue supporting a few minor versions of Python. Therefore they cannot use some features that were introduced in the latest versions.
Yet this doesn't mean they can't enjoy latest features in their own docs: Griffe allows to "modernize" expressions, for example by replacing `typing.Union` with PEP 604 type unions `|`. Thanks to this, downstream tools like [mkdocstrings][mkdocstrings-python] can automatically transform type annotations into their modern equivalent. This improves consistency in your docs, and shows users how to use your code with the latest features of the language.
To modernize an expression, simply call its [`modernize()`][griffe.Expr.modernize] method. It returns a new, modernized expression. Some parts of the expression might be left unchanged, so be careful if you decide to mutate them.
Modernizations applied:
- `typing.Dict[A, B]` becomes `dict[A, B]`
- `typing.List[A]` becomes `list[A]`
- `typing.Set[A]` becomes `set[A]`
- `typing.Tuple[A]` becomes `tuple[A]`
- `typing.Union[A, B]` becomes `A | B`
- `typing.Optional[A]` becomes `A | None`
## Next steps
In this chapter we saw many of the fields that compose our models, and how and why to use them. Now you might be interested in [extending](extending.md) or [serializing](serializing.md) the API data, or [checking for API breaking changes](checking.md).
[mkdocstrings-python]: https://mkdocstrings.github.io/python
[rich]: https://rich.readthedocs.io/en/stable/
python-griffe-2.3.0/docs/guide/users.md 0000664 0001750 0001750 00000007666 15246557273 017726 0 ustar carsten carsten # User guide
Welcome to the Griffe user guide!
## Manipulating APIs
The following topics will guide you through the various methods Griffe offers for exploring and exploiting Python APIs.
- :material-cube-scan:{ .lg .middle } **Loading**
---
Griffe can find packages and modules to scan them statically or dynamically and extract API-related information.
[:octicons-arrow-right-24: Learn how to load data](users/loading.md)
- :material-navigation-variant-outline:{ .lg .middle } **Navigating**
---
Griffe exposes the extracted API information into data models, making it easy to navigate your API.
[:octicons-arrow-right-24: Learn how to navigate data](users/navigating.md)
- :material-code-json:{ .lg .middle } **Serializing**
---
Griffe can serialize your API data into JSON, for other tools to navigate or manipulate it.
[:octicons-arrow-right-24: Learn how to serialize data](users/serializing.md)
- :material-target:{ .lg .middle } **Checking**
---
Griffe can compare snapshots of the same API to find breaking changes.
[:octicons-arrow-right-24: Learn how to detect and handle breaking changes](users/checking.md)
- :material-puzzle-plus:{ .lg .middle } **Extending**
---
API data can be augmented or modified thanks to Griffe's extension system.
[:octicons-arrow-right-24: Learn how to write and use extensions](users/extending.md)
## Recommendations
These topics explore the user side: how to write code to better integrate with Griffe.
- :material-gift-open:{ .lg .middle } **Public API**
---
See our recommendations for exposing public APIs to your users.
[:octicons-arrow-right-24: See our public API recommendations](users/recommendations/public-apis.md)
- :material-star-face:{ .lg .middle } **Python code best practices**
---
See our best practices for writing Python code.
[:octicons-arrow-right-24: See our best practices](users/recommendations/python-code.md)
- :material-text:{ .lg .middle } **Docstrings**
---
Griffe supports multiple docstring styles. Learn about these different styles, and see our recommendations to write docstrings.
[:octicons-arrow-right-24: See our docstring recommendations](users/recommendations/docstrings.md)
## How-to
These how-tos will show you how to achieve specific things with Griffe.
- :octicons-ai-model-24:{ .lg .middle } **Parse docstrings**
---
Griffe can be used as a docstring-parsing library.
[:octicons-arrow-right-24: See how to parse docstrings](users/how-to/parse-docstrings.md)
- **@ Support custom decorators**
---
Griffe will rarely support custom decorators through static analysis, but you can easily write extensions to do so.
[:octicons-arrow-right-24: See how to support custom decorators](users/how-to/support-decorators.md)
- :material-select:{ .lg .middle } **Selectively inspect objects**
---
Sometimes static analysis is not enough, so you might want to use dynamic analysis (inspection) on certain objects.
[:octicons-arrow-right-24: See how to selectively inspect objects](users/how-to/selectively-inspect.md)
- :material-bow-tie:{ .lg .middle } **Set objects' docstring style**
---
Sometimes the wrong docstring styles are attached to objects. You can fix this with a few different methods.
[:octicons-arrow-right-24: See how to set the correct docstring styles on objects](users/how-to/set-docstring-styles.md)
- :simple-git:{ .lg .middle } **Set Git source info on objects**
---
Griffe tries to find the right Git remote URL to provide source links to loaded objects. In some cases you might want to override the Git information or the source link directly.
[:octicons-arrow-right-24: See how to set the correct Git information or source link on objects](users/how-to/set-git-info.md)
python-griffe-2.3.0/docs/reference.md 0000664 0001750 0001750 00000000547 15246557273 017415 0 ustar carsten carsten # Reference
Our reference pages contain comprehensive information on various aspects of Griffe, such as its Command Line Interface (CLI), its Application Programming Interface (API), or its docstring parsers.
- [Command Line Interface](reference/cli.md)
- [Application Programming Interface](reference/api.md)
- [Docstring parsers](reference/docstrings.md)
python-griffe-2.3.0/docs/alternatives.md 0000664 0001750 0001750 00000006444 15246557273 020162 0 ustar carsten carsten # Alternatives
Similar projects exist in the ecosystem. They generally allow to extract API data from Python source, or to build a representation of the Python source or Python API. Some of them also allow to compare representations to find breaking changes.
## Docspec
[Docspec](https://github.com/NiklasRosenstein/python-docspec) is a JSON object specification for representing API documentation of programming languages. While in it's current form it is targeting Python APIs, it is intended to be able to represent other programming languages in the future as well.
The repository contains two projects, docspec and docspec-python. **docspec** is the reference implementation for reading/writing the JSON format and API for representing API objects in memory. **docspec-python** is a parser for Python packages and modules based on lib2to3 producing docspec API object representations.
## Frappucino
[Frappucino](https://github.com/Carreau/frappuccino) allows you to make sure you haven't broken your API, by first taking an imprint of your API at one point in time and then compare it to the current project state. The goal is to warn you when incompatible changes have been introduced, and to list these changes.
## Other related projects
The work of [@tristanlatr](https://github.com/tristanlatr) is worth checking out, notably his [ast-nodes](https://github.com/tristanlatr/ast-nodes) and [astuce](https://github.com/tristanlatr/astuce) projects, which aim at providing lower-level Python AST utilities to help implementing API data extraction with powerful inference. Tristan is [advocating for more interoperability](https://github.com/mkdocstrings/griffe/discussions/287) between Docspec, Griffe and his own projects.
We should also mention our own simplified "Griffe" variants for other programming languages, such as [Griffe TypeDoc](https://mkdocstrings.github.io/griffe-typedoc/), which extracts API data from TypeScript sources thanks to [TypeDoc](https://typedoc.org/), and builds Python objects from it.
---
The following projects are more than API data extraction tools, but deserve being mentioned.
### Papyri
[Papyri](https://github.com/jupyter/papyri) is a set of tools to build, publish (future functionality - to be done), install and render documentation within IPython and Jupyter.
Papyri [has considered using Griffe in the past](https://github.com/jupyter/papyri/issues/249) :heart:, but eventually went with their own solution, trying to be compatible with Griffe serialization format.
### pdoc
[pdoc](https://github.com/mitmproxy/pdoc) is a documentation renderer for Python projects. pdoc's main feature is a focus on simplicity: pdoc aims to do one thing and do it well.
### Sphinx AutoAPI
[Sphinx AutoAPI](https://github.com/readthedocs/sphinx-autoapi) is a new approach to API documentation in Sphinx. It is a Sphinx extension for generating complete API documentation without needing to load, run, or import the project being documented. In contrast to the traditional [Sphinx autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html), which requires manual authoring and uses code imports, AutoAPI finds and generates documentation by parsing source code.
Sphinx AutoAPI is [considering Griffe as a data extraction backend](https://github.com/readthedocs/sphinx-autoapi/issues/444) :heart:
python-griffe-2.3.0/docs/extensions.md 0000664 0001750 0001750 00000001124 15246557273 017646 0 ustar carsten carsten # Extensions
Griffe has an extension system that allows Python developers to enhance or modify the data that Griffe collects. To learn more about how to use or develop extensions, please see the [Extending APIs topic](guide/users/extending.md).
We group extensions under three categories:
- [built-in extensions](extensions/built-in.md): maintained directly within Griffe's codebase
- [official extensions](extensions/official.md): maintained in separated repositories, by the authors/maintainers of Griffe
- [third-party extensions](extensions/third-party.md): maintained by other developers
python-griffe-2.3.0/docs/contributing.md 0000664 0001750 0001750 00000004555 15246557273 020171 0 ustar carsten carsten ---
title: Contributing
---
# Contributing
Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given.
There are multiple ways to contribute to this project:
- with **feature requests**: we are always happy to receive feedback and new ideas! If you have any, you can create [new feature requests](https://github.com/mkdocstrings/griffe/issues/new?assignees=pawamoy&labels=feature&projects=&template=feature_request.md&title=feature%3A+) on our issue tracker. Make sure to search issues first, to avoid creating duplicate requests.
- with **bug reports**: only you (the users) can help us find and fix bugs! We greatly appreciate if you can give us a bit of your time to create a proper [bug report](https://github.com/mkdocstrings/griffe/issues/new?assignees=pawamoy&labels=unconfirmed&projects=&template=bug_report.md&title=bug%3A+) on our issue tracker. Same as for feature requests, make sure the bug is not already reported, by searching through issues first.
- with **user support**: watch activity on the [Github repository](https://github.com/mkdocstrings/griffe) and our [Gitter channel](https://app.gitter.im/#/room/#mkdocstrings_griffe:gitter.im){ target="_blank" } to answer issues and discussions created by users. Answering questions from users can take a lot of time off maintenance and new features: helping us with user support means more time for us to work on the project.
- with **documentation**: spotted a mistake in the documentation? Found a paragraph unclear or a section missing? Reporting those already helps a lot, and if you can, sending pull requests is even better.
- with **code**: if you are interested in a feature request, or are experiencing a reported bug, you can contribute a feature or a fix. You can simply drop a comment on the relevant issue, and we will do our best to guide you.
For easy documentation fixes, you can edit a file and send a pull request [directly from the GitHub web interface](https://docs.github.com/en/repositories/working-with-files/managing-files/editing-files#editing-files-in-another-users-repository). For more complex fixes or improvements, please read our contributor guide. The guide will show you how to set up a development environment to run tests or serve the documentation locally.
[:fontawesome-solid-helmet-safety: Contributor guide](guide/contributors.md){ .md-button }
python-griffe-2.3.0/docs/extensions/ 0000775 0001750 0001750 00000000000 15246557273 017326 5 ustar carsten carsten python-griffe-2.3.0/docs/extensions/third-party/ 0000775 0001750 0001750 00000000000 15246557273 021575 5 ustar carsten carsten python-griffe-2.3.0/docs/extensions/third-party/fastapi.md 0000664 0001750 0001750 00000001035 15246557273 023545 0 ustar carsten carsten # `griffe-fastapi`
- **PyPI**: [`griffe-fastapi`](https://pypi.org/project/griffe-fastapi/)
- **GitHub**: [fbraem/griffe-fastapi](https://github.com/fbraem/griffe-fastapi)
- **Extension name:** `griffe_fastapi`
---
This extension will search for functions that are decorated with an APIRouter and adds the following extra
fields to a function:
- api: the path of the api
- method: the HTTP method
- responses: a dictionary with the responses
These fields are then rendered with a custom mkdocstrings template provided by the extension.
python-griffe-2.3.0/docs/extensions/third-party/inherited-method-crossrefs.md 0000664 0001750 0001750 00000002071 15246557273 027357 0 ustar carsten carsten # `griffe-inherited-method-crossrefs`
- **PyPI**: [`griffe-inherited-method-crossrefs`](https://pypi.org/project/griffe-inherited-method-crossrefs/)
- **GitHub**: [mlprt/griffe-inherited-method-crossrefs](https://github.com/mlprt/griffe-inherited-method-crossrefs)
- **Extension name:** `griffe_inherited_method_crossrefs`
---
This extension replaces docstrings of inherited methods with cross-references to parent methods. For example, if a class `foo.Child` inherits the method `do_something` from `bar.Parent`, then in the generated documentation, the docstring of `Child.do_something` will appear similar to
> Inherited from [bar.Parent](https://example.com/link/to/bar.Parent.do_something)
whereas the docstring of `bar.Parent.do_something` will be unaffected.
This is contrast to the official [`inherited-docstrings`](../official/inherited-docstrings.md) extension which simply attaches the docstring of the parent method to the subclass method, which means that modifying the subclass method docstring also modifies the parent method docstring (it's the same object).
python-griffe-2.3.0/docs/extensions/third-party/generics.md 0000664 0001750 0001750 00000001072 15246557273 023716 0 ustar carsten carsten # `griffe-generics`
- **PyPI**: [`griffe-generics`](https://pypi.org/project/griffe-generics/)
- **GitHub**: [jonghwanhyeon/griffe-generics](https://github.com/jonghwanhyeon/griffe-generics)
- **Extension name:** `griffe_generics`
---
This extension resolves generic type parameters as bound types in subclasses. For example, if a parent class inherits from `Generics[L]`, and a subclass specifies `L` as `Hashable`, then all type annotations using `L` in the class methods or attributes inherited from the parent class will be transformed to use `Hashable` instead.
python-griffe-2.3.0/docs/extensions/third-party/modernized-annotations.md 0000664 0001750 0001750 00000001065 15246557273 026614 0 ustar carsten carsten # `griffe-modernized-annotations`
- **PyPI**: [`griffe-modernized-annotations`](https://pypi.org/project/griffe-modernized-annotations/)
- **GitHub**: [jonghwanhyeon/griffe-modernized-annotations](https://github.com/jonghwanhyeon/griffe-modernized-annotations)
- **Extension name:** `griffe_modernized_annotations`
---
This extension modernizes type annotations by adopting [PEP 585](https://peps.python.org/pep-0585/) and [PEP 604](https://peps.python.org/pep-0604/). For example, it will transform `Union[A, B]` into `A | B`, and `List[str]` into `list[str]`.
python-griffe-2.3.0/docs/extensions/third-party/fieldz.md 0000664 0001750 0001750 00000001502 15246557273 023372 0 ustar carsten carsten # `griffe-fieldz`
- **PyPI**: [`griffe-fieldz`](https://pypi.org/project/griffe-fieldz/)
- **GitHub**: [pyapp-kit/griffe-fieldz](https://github.com/pyapp-kit/griffe-fieldz)
- **Extension name:** `griffe_fieldz`
---
This extension adds support for data-class like things (pydantic, attrs, etc...). This extension will inject the fields of the data-class into the documentation, preventing you from duplicating field metadata in your docstrings.
It supports anything that [fieldz](https://github.com/pyapp-kit/fieldz) supports, which is currently:
- [`dataclasses.dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass)
- [`pydantic.BaseModel`](https://docs.pydantic.dev/latest/)
- [`attrs.define`](https://www.attrs.org/en/stable/overview.html)
- [`msgspec.Struct`](https://jcristharif.com/msgspec/)
python-griffe-2.3.0/docs/extensions/third-party/docstring-inheritance.md 0000664 0001750 0001750 00000001576 15246557273 026413 0 ustar carsten carsten # `docstring-inheritance`
- **PyPI**: [`docstring-inheritance`](https://pypi.org/project/docstring-inheritance/)
- **GitHub**: [AntoineD/docstring-inheritance](https://github.com/AntoineD/docstring-inheritance)
- **Extension name:** `docstring_inheritance.griffe`
---
`docstring-inheritance` is a Python package that allows to avoid writing and maintaining duplicated Python docstrings. The typical usage is to enable the inheritance of the docstrings from a base class such that its derived classes fully or partially inherit the docstrings. It provides a Griffe extension and recommends to use it alongside the official [`inherited-docstrings`](../official/inherited-docstrings.md) extension in MkDocs:
```yaml
plugins:
- mkdocstrings:
handlers:
python:
options:
extensions:
- griffe_inherited_docstrings
- docstring_inheritance.griffe
```
python-griffe-2.3.0/docs/extensions/third-party.md 0000664 0001750 0001750 00000002674 15246557273 022130 0 ustar carsten carsten # Third-party extensions
Third-party extensions are developed and maintained outside of the mkdocstrings organization, by various developers. They generally bring support for third-party libraries.
Extension | Description
--------- | -----------
[`docstring-inheritance`](third-party/docstring-inheritance.md) | A more advanced docstring inheritance utility that also provides a Griffe extension.
[`fastapi`](https://github.com/fbraem/griffe-fastapi) | Search for functions that are decorated with an APIRouter and adds the extra fields to a function.
[`fieldz`](third-party/fieldz.md) | Support for data-class like objects (dataclasses, pydantic, attrs, etc.) using [fieldz](https://github.com/pyapp-kit/fieldz).
[`generics`](third-party/generics.md) | Resolve generic type parameters as bound types in subclasses.
[`inherited-method-crossrefs`](third-party/inherited-method-crossrefs.md) | Replace docstrings of inherited methods with cross-references to parents.
[`modernized-annotations`](third-party/modernized-annotations.md) | Modernize type annotations by adopting PEP 585 and PEP 604.
You can find more third-party extensions by exploring the [`griffe-extension` topic on GitHub](https://github.com/topics/griffe-extension). You can also check out the "in-project" extensions (not published to PyPI) used in various projects on GitHub by [searching for "griffe extension" in code](https://github.com/search?q=griffe+Extension+language%3Apython&type=code).
python-griffe-2.3.0/docs/extensions/official/ 0000775 0001750 0001750 00000000000 15246557273 021102 5 ustar carsten carsten python-griffe-2.3.0/docs/extensions/official/public-redundant-aliases.md 0000664 0001750 0001750 00000001261 15246557273 026303 0 ustar carsten carsten # `griffe-public-redundant-aliases`
- **PyPI**: [`griffe-public-redundant-aliases`](https://pypi.org/project/griffe-public-redundant-aliases/)
- **GitHub**: [mkdocstrings/griffe-public-redundant-aliases](https://github.com/mkdocstrings/griffe-public-redundant-aliases)
- **Documentation:** [mkdocstrings.github.io/griffe-public-redundant-aliases](https://mkdocstrings.github.io/griffe-public-redundant-aliases)
- **Extension name:** `griffe_public_redundant_aliases`
---
This extension marks every object that was imported with a redundant alias as public. See our documentation on the [redundant aliases convention](../../guide/users/recommendations/public-apis.md#redundant-aliases).
python-griffe-2.3.0/docs/extensions/official/public-wildcard-imports.md 0000664 0001750 0001750 00000001247 15246557273 026170 0 ustar carsten carsten # `griffe-public-wildcard-imports`
- **PyPI**: [`griffe-public-wildcard-imports`](https://pypi.org/project/griffe-public-wildcard-imports/)
- **GitHub**: [mkdocstrings/griffe-public-wildcard-imports](https://github.com/mkdocstrings/griffe-public-wildcard-imports)
- **Documentation:** [mkdocstrings.github.io/griffe-public-wildcard-imports](https://mkdocstrings.github.io/griffe-public-wildcard-imports)
- **Extension name:** `griffe_public_wildcard_imports`
---
This extension marks every object that was imported with a wildcard import as public. See our documentation on the [wildcard imports convention](../../guide/users/recommendations/public-apis.md#wildcard-imports).
python-griffe-2.3.0/docs/extensions/official/autodocstringstyle.md 0000664 0001750 0001750 00000001441 15246557273 025372 0 ustar carsten carsten # `griffe-autodocstringstyle`
- **PyPI**: [`griffe-autodocstringstyle`](https://pypi.org/project/griffe-autodocstringstyle/)
- **GitHub**: [mkdocstrings/griffe-autodocstringstyle](https://github.com/mkdocstrings/griffe-autodocstringstyle)
- **Documentation:** [mkdocstrings.github.io/griffe-autodocstringstyle](https://mkdocstrings.github.io/griffe-autodocstringstyle)
- **Extension name:** `griffe_autodocstringstyle`
---
This extension sets the docstring parser to `auto` for all the docstrings of external packages. Packages are considered "external" when their sources are found in a virtual environment instead of a folder under the current working directory. Setting their docstring style to `auto` is useful if you plan on rendering the docstring of these objects in your own documentation.
python-griffe-2.3.0/docs/extensions/official/warnings-deprecated.md 0000664 0001750 0001750 00000002133 15246557273 025351 0 ustar carsten carsten # `griffe-warnings-deprecated`
- **PyPI**: [`griffe-warnings-deprecated`](https://pypi.org/project/griffe-warnings-deprecated/)
- **GitHub**: [mkdocstrings/griffe-warnings-deprecated](https://github.com/mkdocstrings/griffe-warnings-deprecated)
- **Documentation:** [mkdocstrings.github.io/griffe-warnings-deprecated](https://mkdocstrings.github.io/griffe-warnings-deprecated)
- **Extension name:** `griffe_warnings_deprecated`
---
This extension adds support for functions and classes decorated with [`@warnings.deprecated(...)`][warnings.deprecated], as implemented thanks to [PEP 702](https://peps.python.org/pep-0702/). The message provided in the decorator call will be stored in the corresponding Griffe object's [`deprecated`][griffe.Object.deprecated] attribute (usable by downstream rendering templates), and will also add an admonition to the object's docstring with the provided message as text.
```python
from warnings import deprecated
@deprecated("This function is **deprecated**. Use [another one][package.another_func] instead.")
def deprecated_func():
...
def another_func():
...
```
python-griffe-2.3.0/docs/extensions/official/typingdoc.md 0000664 0001750 0001750 00000003745 15246557273 023435 0 ustar carsten carsten # `griffe-typingdoc`
- **PyPI**: [`griffe-typingdoc`](https://pypi.org/project/griffe-typingdoc/)
- **GitHub**: [mkdocstrings/griffe-typingdoc](https://github.com/mkdocstrings/griffe-typingdoc)
- **Documentation:** [mkdocstrings.github.io/griffe-typingdoc](https://mkdocstrings.github.io/griffe-typingdoc)
- **Extension name:** `griffe_typingdoc`
---
This extension reads docstrings for parameters, return values and more from type annotations using [`Annotated`][typing.Annotated] and the [`Doc`][typing_extensions.Doc] class suggested in [PEP 727](https://peps.python.org/pep-0727/). Documenting parameters and return values this way makes it possible to completely avoid relying on a particular "docstring style" (Google, Numpydoc, Sphinx, etc.) and just use plain markup in module/classes/function docstrings. Docstrings therefore do not have to be parsed at all.
```python
from typing import Annotated as An
from typing_extensions import Doc
def function(
param1: An[int, Doc("Some integer value.")],
param2: An[
str,
Doc(
"""
Summary of the parameter.
Multi-line docstrings can be used, as usual.
Any **markup** is supported, as usual.
"""
)
]
) -> An[bool, Doc("Whether you like PEP 727.")]:
"""Summary of the function.
No more "Args", "Parameters" or "Returns" sections.
Just plain markup.
"""
...
```
PEP 727 is likely to be withdrawn or rejected, but the `Doc` class will remain in `typing_extensions`, [as told by Jelle Zijlstra](https://discuss.python.org/t/pep-727-documentation-metadata-in-typing/32566/183):
> We’ll probably keep it in `typing_extensions` indefinitely even if the PEP gets withdrawn or rejected, for backwards compatibility reasons.
>
> You are free to use it in your own code using the typing-extensions version. If usage of `typing_extensions.Doc` becomes widespread, that will be a good argument for accepting the PEP and putting it in the standard library.
python-griffe-2.3.0/docs/extensions/official/inherited-docstrings.md 0000664 0001750 0001750 00000002076 15246557273 025561 0 ustar carsten carsten # `griffe-inherited-docstrings`
- **PyPI**: [`griffe-inherited-docstrings`](https://pypi.org/project/griffe-inherited-docstrings/)
- **GitHub**: [mkdocstrings/griffe-inherited-docstrings](https://github.com/mkdocstrings/griffe-inherited-docstrings)
- **Documentation:** [mkdocstrings.github.io/griffe-inherited-docstrings](https://mkdocstrings.github.io/griffe-inherited-docstrings)
- **Extension name:** `griffe_inherited_docstrings`
---
This extension, when enabled, iterates over the declared members of all classes found within a package, and if they don't have a docstring, but do have a parent member with a docstring, sets their docstring to that parent's docstring.
```python
class Base:
attr = "hello"
"""Hello."""
def hello(self):
"""Hello again."""
...
class Derived(Base):
attr = "bye"
def hello(self):
...
```
In the example above, *without* the extension `Derived.attr` and `Derived.hello` have no docstrings, while *with* the extension they will have the `Base.attr` and `Base.hello` docstrings attached, respectively.
python-griffe-2.3.0/docs/extensions/official/pydantic.md 0000664 0001750 0001750 00000001273 15246557273 023242 0 ustar carsten carsten # `griffe-pydantic`
- **PyPI**: [`griffe-pydantic`](https://pypi.org/project/griffe-pydantic/)
- **GitHub**: [mkdocstrings/griffe-pydantic](https://github.com/mkdocstrings/griffe-pydantic)
- **Documentation:** [mkdocstrings.github.io/griffe-pydantic](https://mkdocstrings.github.io/griffe-pydantic)
- **Extension name:** `griffe_pydantic`
---
This extension adds support for [Pydantic](https://docs.pydantic.dev/latest/) models. It extracts useful information from them, stores this information into the `extra` attribute of objects, and binds custom mkdocstrings templates to the objects for better rendering. The extension works both statically and dynamically, and supports model inheritance.
python-griffe-2.3.0/docs/extensions/official/runtime-objects.md 0000664 0001750 0001750 00000002333 15246557273 024537 0 ustar carsten carsten # `griffe-runtime-objects`
- **PyPI**: [`griffe-runtime-objects`](https://pypi.org/project/griffe-runtime-objects/)
- **GitHub**: [mkdocstrings/griffe-runtime-objects](https://github.com/mkdocstrings/griffe-runtime-objects)
- **Documentation:** [mkdocstrings.github.io/griffe-runtime-objects](https://mkdocstrings.github.io/griffe-runtime-objects)
- **Extension name:** `griffe_runtime_objects`
---
This extension stores runtime objects corresponding to each loaded Griffe object into its `extra` attribute, under the `runtime-objects` namespace.
```pycon
>>> import griffe
>>> griffe_data = griffe.load("griffe", extensions=griffe.load_extensions("griffe_runtime_objects"), resolve_aliases=True)
>>> griffe_data["parse"].extra
defaultdict(, {'runtime-objects': {'object': }})
>>> griffe_data["Module"].extra
defaultdict(, {'runtime-objects': {'object': }})
```
It can be useful in combination with mkdocstrings-python and custom templates, to iterate over object values or their attributes that couldn't be loaded by Griffe itself (for example, objects built dynamically and loaded as attributes won't have "members" to iterate over).
python-griffe-2.3.0/docs/extensions/official/sphinx.md 0000664 0001750 0001750 00000002134 15246557273 022735 0 ustar carsten carsten # `griffe-sphinx`
- **PyPI**: [`griffe-sphinx`](https://pypi.org/project/griffe-sphinx/)
- **GitHub**: [mkdocstrings/griffe-sphinx](https://github.com/mkdocstrings/griffe-sphinx)
- **Documentation:** [mkdocstrings.github.io/griffe-sphinx](https://mkdocstrings.github.io/griffe-sphinx)
- **Extension name:** `griffe_sphinx`
---
This extension reads Sphinx comments placed above attribute assignments and uses them as docstrings.
```python
#: Summary of `module_attr`.
module_attr = "hello"
class Hello:
#: Summary of `class_attr`.
#:
#: Description of the class attribute.
#: *Markup* and [cross-references][] are __supported__,
#: just like in regular docstrings.
class_attr = "hello"
def __init__(self):
#: Summary of `instance_attr`.
self.instance_attr = "hello"
```
Comments are treated exactly like regular docstrings: they are "cleaned" (dedented and stripped of leading and trailing newlines) and can contain any markup you want, be it Markdown, rST, AsciiDoc, etc.
Trailing comments are not supported:
```python
module_attr #: This is not supported.
```
python-griffe-2.3.0/docs/extensions/official.md 0000664 0001750 0001750 00000002775 15246557273 021437 0 ustar carsten carsten # Official extensions
Official extensions are developed and maintained within the mkdocstrings organization on GitHub, in separate repositories. They generally bring support for various third-party libraries or other documentation-related features that are part of Python's standard library.
Extension | Description
--------- | -----------
[`autodocstringstyle`](official/autodocstringstyle.md) | Set docstring style to `auto` for external packages.
[`inherited-docstrings`](official/inherited-docstrings.md) | Inherit docstrings from parent classes.
[`public-redundant-aliases`](official/public-redundant-aliases.md) | Mark objects imported with redundant aliases as public.
[`public-wildcard-imports`](official/public-wildcard-imports.md) | Mark wildcard imported objects as public.
[`pydantic`](official/pydantic.md) | Support for [Pydantic](https://docs.pydantic.dev/latest/) models.
[`runtime-objects`](official/runtime-objects.md) | Access runtime objects corresponding to each loaded Griffe object through their `extra` attribute.
[`sphinx`](official/sphinx.md) | Parse [Sphinx](https://www.sphinx-doc.org/)-comments above attributes (`#:`) as docstrings.
[`typing-doc`](official/typingdoc.md) | Support for [PEP 727](https://peps.python.org/pep-0727/)'s [`typing.Doc`][typing_extensions.Doc], "Documentation in Annotated Metadata".
[`warnings-deprecated`](official/warnings-deprecated.md) | Support for [PEP 702](https://peps.python.org/pep-0702/)'s [`warnings.deprecated`][], "Marking deprecations using the type system".
python-griffe-2.3.0/docs/extensions/built-in.md 0000664 0001750 0001750 00000000634 15246557273 021376 0 ustar carsten carsten # Built-in extensions
Built-in extensions are maintained in Griffe's code base. They generally bring support for core features of the Python language or its standard library.
Extension | Description
--------- | -----------
[`dataclasses`](built-in/dataclasses.md) | Support for [`dataclasses`][].
[`unpack_typeddict`](built-in/unpack-typeddict.md) | Support for [`typing.Unpack`][] and [`typing.TypedDict`][].
python-griffe-2.3.0/docs/extensions/built-in/ 0000775 0001750 0001750 00000000000 15246557273 021051 5 ustar carsten carsten python-griffe-2.3.0/docs/extensions/built-in/unpack-typeddict.md 0000664 0001750 0001750 00000004674 15246557273 024656 0 ustar carsten carsten # `unpack_typeddict`
The `unpack_typeddict` extension adds support for [`Unpack`][typing.Unpack] and [`TypedDict`][typing.TypedDict] from the standard library. When enabled, it will add an `__init__` method to typed dictionaries, and expand `**kwargs: Unpack[...]` (the ellipsis being a [typed dict][typing.TypedDict] class) in function signatures to the relevant parameters, using the typed dict attributes or added signature. The extension will also update any Parameters section in the function docstring, to reflect the signature update.
Example:
```python
from typing import TypedDict, Unpack
class GreetKwargs(TypedDict):
name: str
"""The name of a person to greet."""
shout: bool
"""Whether to shout."""
def greet(**kwargs: Unpack[GreetKwargs]) -> str:
"""Greet someone.
Parameters:
**kwargs: Greet parameters.
Returns:
A message.
"""
message = f"Hello {kwargs['name']}!"
if kwargs["shout"]:
return message.upper() + "!!"
return message
```
With the `unpack_typeddict` extension enabled, the data loaded by Griffe will be updated as follows:
```python
class GreetKwargs(TypedDict):
# Attributes removed from Griffe data.
# Added by the extension to Griffe data (not to the runtime class):
def __init__(self, *, name: str, shout: bool) -> None:
"""
Parameters:
name: The name of a person to greet.
shout: Whether to shout.
"""
def greet(*, name: str, shout: bool) -> str:
"""Greet someone.
Parameters:
name: The name of a person to greet.
shout: Whether to shout.
Returns:
A message.
"""
```
Thanks to this `__init__` method now appearing in the typed dictionary, tools like mkdocstrings can now render a proper signature for `GreetKwargs`.
NOTE: Our example shows a Google-style docstring, but we actually insert a structured docstring section into the parsed data, which is style-agnostic, so it works with any docstring style.
To enable the extension:
=== "CLI"
```console
$ griffe dump -e unpack_typeddict my_package
```
=== "Python"
```python
import griffe
my_package = griffe.load("my_package", extensions=griffe.load_extensions("unpack_typeddict"))
```
=== "mkdocstrings"
```yaml title="mkdocs.yml"
plugins:
- mkdocstrings:
handlers:
python:
options:
extensions:
- unpack_typeddict
```
python-griffe-2.3.0/docs/extensions/built-in/dataclasses.md 0000664 0001750 0001750 00000003237 15246557273 023667 0 ustar carsten carsten # `dataclasses`
The `dataclasses` extension adds support for [dataclasses][] from the standard library. It works both statically and dynamically. When used statically, it re-creates the `__init__` methods and their signatures (as Griffe objects), that would otherwise be created at runtime. When used dynamically, it does nothing since `__init__` methods are created by the library and can be inspected normally.
Example:
```python
from dataclasses import dataclass
@dataclass
class Room:
uid: int
name: str
capacity: int = 10
available: bool = True
```
With the `dataclasses` extension enabled, the Griffe object for the `Room` class will get an `__init__` method with the following signature:
```python
def __init__(self, uid: int, name: str, capacity: int = 10, available: bool = True) -> None:
...
```
Additional metadata like `ClassVar`, the `init` and `kw_only` parameters, or the `KW_ONLY` sentinel are also recognized and will update the `__init__` method signature accordingly.
**This extension is enabled by default.** It is always added last. If you need to give it a higher priority, you can explicitly enable it to change its position in the list of extensions (it will run only once):
=== "CLI"
```console
$ griffe dump -e dataclasses,other my_package
```
=== "Python"
```python
import griffe
my_package = griffe.load("my_package", extensions=griffe.load_extensions("dataclasses", "other"))
```
=== "mkdocstrings"
```yaml title="mkdocs.yml"
plugins:
- mkdocstrings:
handlers:
python:
options:
extensions:
- dataclasses
- other
```
python-griffe-2.3.0/docs/reference/ 0000775 0001750 0001750 00000000000 15246557273 017065 5 ustar carsten carsten python-griffe-2.3.0/docs/reference/api/ 0000775 0001750 0001750 00000000000 15246557273 017636 5 ustar carsten carsten python-griffe-2.3.0/docs/reference/api/finder.md 0000664 0001750 0001750 00000000261 15246557273 021426 0 ustar carsten carsten # Finder
## **Advanced API**
::: griffe.ModuleFinder
::: griffe.Package
::: griffe.NamespacePackage
## **Types**
::: griffe.NamePartsType
::: griffe.NamePartsAndPathType
python-griffe-2.3.0/docs/reference/api/exceptions.md 0000664 0001750 0001750 00000000620 15246557273 022337 0 ustar carsten carsten # Exceptions
::: griffe.GriffeError
::: griffe.LoadingError
::: griffe.NameResolutionError
::: griffe.UnhandledEditableModuleError
::: griffe.UnimportableModuleError
::: griffe.AliasResolutionError
::: griffe.CyclicAliasError
::: griffe.LastNodeError
::: griffe.RootNodeError
::: griffe.BuiltinModuleError
::: griffe.ExtensionError
::: griffe.ExtensionNotLoadedError
::: griffe.GitError
python-griffe-2.3.0/docs/reference/api/loaders.md 0000664 0001750 0001750 00000000400 15246557273 021603 0 ustar carsten carsten # Loaders
## **Main API**
::: griffe.load
::: griffe.load_git
::: griffe.load_pypi
## **Advanced API**
::: griffe.GriffeLoader
::: griffe.ModulesCollection
::: griffe.LinesCollection
## **Additional API**
::: griffe.Stats
::: griffe.merge_stubs
python-griffe-2.3.0/docs/reference/api/serializers.md 0000664 0001750 0001750 00000000324 15246557273 022513 0 ustar carsten carsten # Serializers
## **Main API**
See the [`as_json()`][griffe.Object.as_json] and [`from_json()`][griffe.Object.from_json] methods of objects.
## **Advanced API**
::: griffe.JSONEncoder
::: griffe.json_decoder
python-griffe-2.3.0/docs/reference/api/models/ 0000775 0001750 0001750 00000000000 15246557273 021121 5 ustar carsten carsten python-griffe-2.3.0/docs/reference/api/models/attribute.md 0000664 0001750 0001750 00000000027 15246557273 023445 0 ustar carsten carsten # ::: griffe.Attribute
python-griffe-2.3.0/docs/reference/api/models/alias.md 0000664 0001750 0001750 00000000023 15246557273 022527 0 ustar carsten carsten # ::: griffe.Alias
python-griffe-2.3.0/docs/reference/api/models/type_alias.md 0000664 0001750 0001750 00000000043 15246557273 023572 0 ustar carsten carsten # Type Alias
::: griffe.TypeAlias
python-griffe-2.3.0/docs/reference/api/models/function.md 0000664 0001750 0001750 00000000216 15246557273 023267 0 ustar carsten carsten # ::: griffe.Function
::: griffe.Parameters
::: griffe.Parameter
::: griffe.ParameterKind
::: griffe.ParametersType
::: griffe.Decorator
python-griffe-2.3.0/docs/reference/api/models/class.md 0000664 0001750 0001750 00000000100 15246557273 022537 0 ustar carsten carsten # ::: griffe.Class
## **Utilities**
::: griffe.c3linear_merge
python-griffe-2.3.0/docs/reference/api/models/module.md 0000664 0001750 0001750 00000000024 15246557273 022724 0 ustar carsten carsten # ::: griffe.Module
python-griffe-2.3.0/docs/reference/api/expressions.md 0000664 0001750 0001750 00000002524 15246557273 022545 0 ustar carsten carsten # Expressions
::: griffe._internal.expressions
options:
members: false
show_root_heading: false
show_root_toc_entry: false
summary:
classes: true
## **Helpers**
::: griffe.get_annotation
::: griffe.get_base_class
::: griffe.get_class_keyword
::: griffe.get_condition
::: griffe.get_expression
::: griffe.safe_get_annotation
::: griffe.safe_get_base_class
::: griffe.safe_get_class_keyword
::: griffe.safe_get_condition
::: griffe.safe_get_expression
## **Expression nodes**
::: griffe.Expr
::: griffe.ExprAttribute
::: griffe.ExprBinOp
::: griffe.ExprBoolOp
::: griffe.ExprCall
::: griffe.ExprCompare
::: griffe.ExprComprehension
::: griffe.ExprConstant
::: griffe.ExprDict
::: griffe.ExprDictComp
::: griffe.ExprExtSlice
::: griffe.ExprFormatted
::: griffe.ExprGeneratorExp
::: griffe.ExprIfExp
::: griffe.ExprInterpolation
::: griffe.ExprJoinedStr
::: griffe.ExprKeyword
::: griffe.ExprVarPositional
::: griffe.ExprVarKeyword
::: griffe.ExprLambda
::: griffe.ExprList
::: griffe.ExprListComp
::: griffe.ExprName
::: griffe.ExprNamedExpr
::: griffe.ExprParameter
::: griffe.ExprSet
::: griffe.ExprSetComp
::: griffe.ExprSlice
::: griffe.ExprSubscript
::: griffe.ExprTemplateStr
::: griffe.ExprTuple
::: griffe.ExprUnaryOp
::: griffe.ExprYield
::: griffe.ExprYieldFrom
python-griffe-2.3.0/docs/reference/api/helpers.md 0000664 0001750 0001750 00000000465 15246557273 021627 0 ustar carsten carsten # Helpers
::: griffe.TmpPackage
::: griffe.temporary_pyfile
::: griffe.temporary_pypackage
::: griffe.temporary_visited_module
::: griffe.temporary_visited_package
::: griffe.temporary_inspected_module
::: griffe.temporary_inspected_package
::: griffe.vtree
::: griffe.htree
::: griffe.module_vtree
python-griffe-2.3.0/docs/reference/api/cli.md 0000664 0001750 0001750 00000000217 15246557273 020727 0 ustar carsten carsten # CLI entrypoints
## **Main API**
::: griffecli.main
::: griffecli.check
::: griffecli.dump
## **Advanced API**
::: griffecli.get_parser
python-griffe-2.3.0/docs/reference/api/extensions.md 0000664 0001750 0001750 00000000610 15246557273 022354 0 ustar carsten carsten # Extensions
## **Main API**
::: griffe.load_extensions
::: griffe.Extension
## **Advanced API**
::: griffe.Extensions
## **Types**
::: griffe.LoadableExtensionType
## **Builtin extensions**
::: griffe.builtin_extensions
::: griffe.DataclassesExtension
options:
inherited_members: false
::: griffe.UnpackTypedDictExtension
options:
inherited_members: false
python-griffe-2.3.0/docs/reference/api/docstrings.md 0000664 0001750 0001750 00000000214 15246557273 022334 0 ustar carsten carsten # Docstrings
Docstrings are [parsed](docstrings/parsers.md) and the extracted information is structured in [models](docstrings/models.md).
python-griffe-2.3.0/docs/reference/api/agents.md 0000664 0001750 0001750 00000001652 15246557273 021445 0 ustar carsten carsten # Agents
Griffe is able to analyze code both statically and dynamically.
## **Main API**
::: griffe.visit
::: griffe.inspect
## **Advanced API**
::: griffe.Visitor
::: griffe.Inspector
## **Dynamic analysis helpers**
::: griffe.sys_path
::: griffe.dynamic_import
::: griffe.ObjectNode
::: griffe.ObjectKind
## **Static analysis helpers**
::: griffe.builtin_decorators
::: griffe.stdlib_decorators
::: griffe.typing_overload
::: griffe.ast_kind
::: griffe.ast_children
::: griffe.ast_previous_siblings
::: griffe.ast_next_siblings
::: griffe.ast_siblings
::: griffe.ast_previous
::: griffe.ast_next
::: griffe.ast_first_child
::: griffe.ast_last_child
::: griffe.get_docstring
::: griffe.get_name
::: griffe.get_names
::: griffe.get_instance_names
::: griffe.get__all__
::: griffe.safe_get__all__
::: griffe.relative_to_absolute
::: griffe.get_parameters
::: griffe.get_value
::: griffe.safe_get_value
python-griffe-2.3.0/docs/reference/api/loggers.md 0000664 0001750 0001750 00000000351 15246557273 021621 0 ustar carsten carsten # Loggers
## **Main API**
::: griffe.logger
::: griffe.get_logger
::: griffe.Logger
::: griffe.LogLevel
::: griffecli.DEFAULT_LOG_LEVEL
options:
annotations_path: full
## **Advanced API**
::: griffe.patch_loggers
python-griffe-2.3.0/docs/reference/api/models.md 0000664 0001750 0001750 00000002510 15246557273 021441 0 ustar carsten carsten # Models
Griffe stores information extracted from Python source code into data models.
These models represent trees of objects, starting with modules, and containing classes, functions, attributes, and type aliases.
Modules can have submodules, classes, functions, attributes, and type aliases. Classes can have nested classes, methods, attributes, and type aliases. Functions and attributes do not have any members.
Indirections to objects declared in other modules are represented as "aliases". An alias therefore represents an imported object, and behaves almost exactly like the object it points to: it is a light wrapper around the object, with special methods and properties that allow to access the target's data transparently.
The 6 models:
- [`Module`][griffe.Module]
- [`Class`][griffe.Class]
- [`Function`][griffe.Function]
- [`Attribute`][griffe.Attribute]
- [`Alias`][griffe.Alias]
- [`TypeAlias`][griffe.TypeAlias]
## **Model kind enumeration**
::: griffe.Kind
## **Model base classes**
::: griffe.GetMembersMixin
::: griffe.SetMembersMixin
::: griffe.DelMembersMixin
::: griffe.SerializationMixin
::: griffe.ObjectAliasMixin
::: griffe.Object
## **Type parameters**
::: griffe.TypeParameters
::: griffe.TypeParameter
::: griffe.TypeParameterKind
## **Git information**
::: griffe.KnownGitService
::: griffe.GitInfo
python-griffe-2.3.0/docs/reference/api/docstrings/ 0000775 0001750 0001750 00000000000 15246557273 022015 5 ustar carsten carsten python-griffe-2.3.0/docs/reference/api/docstrings/parsers.md 0000664 0001750 0001750 00000001060 15246557273 024013 0 ustar carsten carsten # Docstring parsers
## **Main API**
::: griffe.parse
::: griffe.parse_auto
::: griffe.parse_google
::: griffe.parse_numpy
::: griffe.parse_sphinx
::: griffe.DocstringStyle
## **Parser options**
::: griffe.DocstringOptions
::: griffe.GoogleOptions
::: griffe.NumpyOptions
::: griffe.SphinxOptions
::: griffe.AutoOptions
::: griffe.PerStyleOptions
## **Advanced API**
::: griffe.Parser
::: griffe.parsers
::: griffe.parse_docstring_annotation
::: griffe.docstring_warning
::: griffe.DocstringDetectionMethod
::: griffe.infer_docstring_style
python-griffe-2.3.0/docs/reference/api/docstrings/models.md 0000664 0001750 0001750 00000002464 15246557273 023630 0 ustar carsten carsten # Docstring models
## **Main API**
::: griffe.Docstring
## **Advanced API: Sections**
::: griffe.DocstringSectionKind
::: griffe.DocstringSectionText
::: griffe.DocstringSectionParameters
::: griffe.DocstringSectionOtherParameters
::: griffe.DocstringSectionTypeParameters
::: griffe.DocstringSectionRaises
::: griffe.DocstringSectionWarns
::: griffe.DocstringSectionReturns
::: griffe.DocstringSectionYields
::: griffe.DocstringSectionReceives
::: griffe.DocstringSectionExamples
::: griffe.DocstringSectionAttributes
::: griffe.DocstringSectionFunctions
::: griffe.DocstringSectionClasses
::: griffe.DocstringSectionTypeAliases
::: griffe.DocstringSectionModules
::: griffe.DocstringSectionDeprecated
::: griffe.DocstringSectionAdmonition
## **Advanced API: Section items**
::: griffe.DocstringAdmonition
::: griffe.DocstringDeprecated
::: griffe.DocstringRaise
::: griffe.DocstringWarn
::: griffe.DocstringReturn
::: griffe.DocstringYield
::: griffe.DocstringReceive
::: griffe.DocstringParameter
::: griffe.DocstringTypeParameter
::: griffe.DocstringAttribute
::: griffe.DocstringFunction
::: griffe.DocstringClass
::: griffe.DocstringTypeAlias
::: griffe.DocstringModule
## **Models base classes**
::: griffe.DocstringElement
::: griffe.DocstringNamedElement
::: griffe.DocstringSection
python-griffe-2.3.0/docs/reference/api/checks.md 0000664 0001750 0001750 00000001125 15246557273 021417 0 ustar carsten carsten # API checks
::: griffe.find_breaking_changes
::: griffe.ExplanationStyle
::: griffe.Breakage
::: griffe.BreakageKind
::: griffe.AttributeChangedTypeBreakage
::: griffe.AttributeChangedValueBreakage
::: griffe.ClassRemovedBaseBreakage
::: griffe.ObjectChangedKindBreakage
::: griffe.ObjectRemovedBreakage
::: griffe.ParameterAddedRequiredBreakage
::: griffe.ParameterChangedDefaultBreakage
::: griffe.ParameterChangedKindBreakage
::: griffe.ParameterChangedRequiredBreakage
::: griffe.ParameterMovedBreakage
::: griffe.ParameterRemovedBreakage
::: griffe.ReturnChangedTypeBreakage
python-griffe-2.3.0/docs/reference/cli.md 0000664 0001750 0001750 00000003451 15246557273 020161 0 ustar carsten carsten # CLI reference
```python exec="true" idprefix="cli-" id="cli-reference"
import argparse
import sys
from griffe import get_parser
parser = get_parser()
def render_parser(parser: argparse.ArgumentParser, title: str, heading_level: int = 2) -> str:
"""Render the parser help documents as a string."""
result = [f"{'#' * heading_level} {title}\n"]
if parser.description and title != "pdm":
result.append("> " + parser.description + "\n")
for group in sorted(parser._action_groups, key=lambda g: g.title.lower(), reverse=True):
if not any(
bool(action.option_strings or action.dest) or isinstance(action, argparse._SubParsersAction)
for action in group._group_actions
):
continue
result.append(f"{group.title.title()}:\n")
for action in group._group_actions:
if isinstance(action, argparse._SubParsersAction):
for name, subparser in action._name_parser_map.items():
result.append(render_parser(subparser, name, heading_level + 1))
continue
opts = [f"`{opt}`" for opt in action.option_strings]
if not opts:
line = f"- `{action.dest}`"
else:
line = f"- {', '.join(opts)}"
if action.metavar:
line += f" `{action.metavar}`"
line += f": {action.help}"
if action.default and action.default != argparse.SUPPRESS:
if action.default is sys.stdout:
default = "sys.stdout"
else:
default = str(action.default)
line += f" Default: `{default}`."
result.append(line)
result.append("")
return "\n".join(result)
print(render_parser(parser, "griffe"))
```
python-griffe-2.3.0/docs/reference/docstrings.md 0000664 0001750 0001750 00000122407 15246557273 021574 0 ustar carsten carsten # Docstrings
Griffe provides different docstring parsers allowing to extract even more structured data from source code.
The available parsers are:
- `google`, to parse Google-style docstrings, see [Napoleon's documentation][napoleon]
- `numpy`, to parse Numpydoc docstrings, see [Numpydoc's documentation][numpydoc]
- `sphinx`, to parse Sphinx-style docstrings, see [Sphinx's documentation][sphinx]
- `auto`, to automatically detect the docstring style, see [Auto-style](#auto-style)
Most of the time, the syntax specified in the aforementioned docs is supported. In some cases, the original syntax is not supported, or is supported but with subtle differences. We will try to document these differences in the following sections.
No assumption is made on the markup used in docstrings: it's retrieved as regular text. Tooling making use of Griffe can then choose to render the text as if it is Markdown, or AsciiDoc, or reStructuredText, etc..
## Google-style
Google-style docstrings, see [Napoleon's documentation][napoleon].
### Syntax {#google-syntax}
Sections are written like this:
```text
section identifier: optional section title
section contents
```
All sections identifiers are case-insensitive. All sections support multiple lines in descriptions, as well as blank lines. The first line must not be blank. Each section must be separated from contents above by a blank line.
� This is **invalid** and will be parsed as regular markup:
```python
Some text.
Note: # (1)!
Some information.
Blank lines allowed.
```
1. Missing blank line above.
� This is **invalid** and will be parsed as regular markup:
```python
Some text.
Note: # (1)!
Some information.
Blank lines allowed.
```
1. Extraneous blank line below.
✅ This is **valid** and will parsed as a text section followed by a note admonition:
```python
Some text.
Note:
Some information.
Blank lines allowed.
```
Find out possibly invalid section syntax by grepping for "reasons" in Griffe debug logs:
```bash
griffe dump -Ldebug -o/dev/null -fdgoogle your_package 2>&1 | grep reasons
```
Some sections support documenting multiple items (attributes, parameters, etc.). When multiple items are supported, each item description can use multiple lines, and continuation lines must be indented once more so that the parser is able to differentiate items.
```python
def foo(a, b):
"""Foo.
Parameters:
a: Here's a.
Continuation line 1.
Continuation line 2.
b: Here's b.
"""
```
It's possible to start a description with a newline if you find it less confusing:
```python
def foo(a, b):
"""Foo.
Parameters:
a:
Here's a.
Continuation line 1.
Continuation line 2.
b: Here's b.
"""
```
### Admonitions {#google-admonitions}
When a section identifier does not match one of the [supported sections](#google-sections), the section is parsed as an "admonition" (or "callout").
Identifiers are case-insensitive, however singular and plural forms are distinct. For example, `Note:` is not the same as `Notes:`.
In particular, `Examples` is parsed as the [Examples section](#google-section-examples), while `Example` is parsed as an admonition whose kind is `example`.
The kind is obtained by lower-casing the identifier and replacing spaces with dashes. For example, an admonition whose identifier is `See also:` will have a kind equal to `see-also`.
Custom section titles are preserved in admonitions: `Tip: Check this out:` is parsed as a `tip` admonition with `Check this out:` as title.
It is up to any downstream documentation renderer to make use of these kinds and titles.
### Parser options {#google-options}
The parser accepts a few options:
- `ignore_init_summary`: Ignore the first line in `__init__` methods' docstrings. Useful when merging `__init__` docstring into class' docstrings with mkdocstrings-python's [`merge_init_into_class`][merge_init] option. Default: false.
- `returns_multiple_items`: Parse [Returns sections](#google-section-returns) and [Yields sections](#google-section-yields) as if they contain multiple items. It means that continuation lines must be indented. Default: true.
- `returns_named_value`: Whether to parse `thing: Description` in [Returns sections](#google-section-returns) and [Yields sections](#google-section-yields) as a name and description, rather than a type and description. When true, type must be wrapped in parentheses: `(int): Description.`. When false, parentheses are optional but the items cannot be named: `int: Description`. Default: true.
- `receives_multiple_items`: Parse [Receives sections](#google-section-receives) as if they contain multiple items. It means that continuation lines must be indented. Default: true.
- `receives_named_value`: Whether to parse `thing: Description` in [Receives sections](#google-section-receives) as a name and description, rather than a type and description. When true, type must be wrapped in parentheses: `(int): Description.`. When false, parentheses are optional but the items cannot be named: `int: Description`. Default: true.
- `returns_type_in_property_summary`: Whether to parse the return type of properties at the beginning of their summary: `str: Summary of the property`. Default: false.
- `trim_doctest_flags`: Remove the [doctest flags] written as comments in `pycon` snippets within a docstring. These flags are used to alter the behavior of [doctest] when testing docstrings, and should not be visible in your docs. Default: true.
- `warn_unknown_params`: Warn about parameters documented in docstrings that do not appear in the signature. Default: true.
- `warn_missing_types`: Warn about missing type or annotation for parameters, return values, etc. Default: true.
- `warnings`: Generally enable/disable warnings when parsing docstrings. Default: true.
### Sections {#google-sections}
The following sections are supported.
#### Attributes {#google-section-attributes}
- Multiple items allowed
Attributes sections allow to document attributes of a module, class, or class instance. They should be used in modules and classes docstrings only.
```python
"""My module.
Attributes:
foo: Description for `foo`.
bar: Description for `bar`.
"""
foo: int = 0
bar: bool = True
class MyClass:
"""My class.
Attributes:
foofoo: Description for `foofoo`.
barbar: Description for `barbar`.
"""
foofoo: int = 0
def __init__(self):
self.barbar: bool = True
```
Type annotations are fetched from the related attributes definitions. You can override those by adding types between parentheses before the colon:
```python
"""My module.
Attributes:
foo (Integer): Description for `foo`.
bar (Boolean): Description for `bar`.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
When documenting an attribute with `attr_name (attr_type): Attribute description`, `attr_type` will be resolved using the scope of the docstrings' parent object (class or module). For example, a type of `list[str]` will be parsed just as if it was an actual Python annotation. You can therefore use complex types (available in the current scope) in docstrings, for example `Optional[Union[int, Tuple[float, float]]]`.
#### Functions/Methods {#google-section-functions}
- Multiple items allowed
Functions or Methods sections allow to document functions of a module, or methods of a class. They should be used in modules and classes docstrings only.
```python
"""My module.
Functions:
foo: Description for `foo`.
bar: Description for `bar`.
"""
def foo():
return "foo"
def bar(baz: int) -> int:
return baz * 2
class MyClass:
"""My class.
Methods:
foofoo: Description for `foofoo`.
barbar: Description for `barbar`.
"""
def foofoo(self):
return "foofoo"
@staticmethod
def barbar():
return "barbar"
```
It's possible to write the function/method signature as well as its name:
```python
"""
Functions:
foo(): Description for `foo`.
bar(baz=1): Description for `bar`.
"""
```
The signatures do not have to match the real ones: you can shorten them to only show the important parameters.
#### Classes {#google-section-classes}
- Multiple items allowed
Classes sections allow to document classes of a module or class. They should be used in modules and classes docstrings only.
```python
"""My module.
Classes:
Foo: Description for `foo`.
Bar: Description for `bar`.
"""
class Foo:
...
class Bar:
def __init__(self, baz: int) -> int:
return baz * 2
class MyClass:
"""My class.
Classes:
FooFoo: Description for `foofoo`.
BarBar: Description for `barbar`.
"""
class FooFoo:
...
class BarBar:
...
```
It's possible to write the class signature as well as its name:
```python
"""
Functions:
Foo(): Description for `Foo`.
Bar(baz=1): Description for `Bar`.
"""
```
The signatures do not have to match the real ones: you can shorten them to only show the important initialization parameters.
#### Modules {#google-section-modules}
- Multiple items allowed
Modules sections allow to document submodules of a module. They should be used in modules docstrings only.
```tree
my_pkg/
__init__.py
foo.py
bar.py
```
```python title="my_pkg/__init__.py"
"""My package.
Modules:
foo: Description for `foo`.
bar: Description for `bar`.
"""
```
#### Examples {#google-section-examples}
Examples sections allow to add examples of Python code without the use of markup code blocks. They are a mix of prose and interactive console snippets. They can be used in every docstring.
```python
"""My module.
Examples:
Some explanation of what is possible.
>>> print("hello!")
hello!
Blank lines delimit prose vs. console blocks.
>>> a = 0
>>> a += 1
>>> a
1
"""
```
WARNING: **Not the same as *Example* sections.**
*Example* (singular) sections are parsed as admonitions. Console code blocks will only be understood in *Examples* (plural) sections.
#### Parameters {#google-section-parameters}
- Aliases: Args, Arguments, Params
- Multiple items allowed
Parameters sections allow to document parameters of a function. They are typically used in functions docstrings, but can also be used in dataclasses docstrings.
```python
def foo(a: int, b: str):
"""Foo.
Parameters:
a: Here's a.
b: Here's b.
"""
```
```python
from dataclasses import dataclass
@dataclass
class Foo:
"""Foo.
Parameters:
a: Here's a.
b: Here's b.
"""
foo: int
bar: str
```
Type annotations are fetched from the related parameters definitions. You can override those by adding types between parentheses before the colon:
```python
"""My function.
Parameters:
foo (Integer): Description for `foo`.
bar (String): Description for `bar`.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
When documenting a parameter with `param_name (param_type): Parameter description`, `param_type` will be resolved using the scope of the function (or class). For example, a type of `list[str]` will be parsed just as if it was an actual Python annotation. You can therefore use complex types (available in the current scope) in docstrings, for example `Optional[Union[int, Tuple[float, float]]]`.
#### Other Parameters {#google-section-other-parameters}
- Aliases: Keyword Args, Keyword Arguments, Other Args, Other Arguments, Other Params
- Multiple items allowed
Other parameters sections allow to document secondary parameters such as variadic keyword arguments, or parameters that should be of lesser interest to the user. They are used the same way Parameters sections are, but can also be useful in decorators / to document returned callables.
```python
def foo(a, b, **kwargs):
"""Foo.
Parameters:
a: Here's a.
b: Here's b.
Other parameters:
c (int): Here's c.
d (bool): Here's d.
"""
```
```python
def foo(a, b):
"""Returns a callable.
Parameters:
a: Here's a.
b: Here's b.
Other parameters: Parameters of the returned callable:
c (int): Here's c.
d (bool): Here's d.
"""
def inner(c, d):
...
return inner
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
See the same tip for parameters.
#### Raises {#google-section-raises}
- Aliases: Exceptions
- Multiple items allowed
Raises sections allow to document exceptions that are raised by a function. They are usually only used in functions docstrings.
```python
def foo(a: int):
"""Foo.
Parameters:
a: A value.
Raises:
ValueError: When `a` is less than 0.
"""
if a < 0:
raise ValueError("message")
```
TIP: **Exceptions names are resolved using the function's scope.**
`ValueError` and other built-in exceptions are resolved as such. You can document custom exception, using the names available in the current scope, for example `my_exceptions.MyCustomException` or `MyCustomException` directly, depending on what you imported/defined in the current module.
#### Warns {#google-section-warns}
- Aliases: Warnings
- Multiple items allowed
Warns sections allow to document warnings emitted by the following code. They are usually only used in functions docstrings.
```python
import warnings
def foo():
"""Foo.
Warns:
UserWarning: To annoy users.
"""
warnings.warn("Just messing with you.", UserWarning)
```
TIP: **Warnings names are resolved using the function's scope.**
`UserWarning` and other built-in warnings are resolved as such. You can document custom warnings, using the names available in the current scope, for example `my_warnings.MyCustomWarning` or `MyCustomWarning` directly, depending on what you imported/defined in the current module.
TIP: **Warnings section are not Warning admonitions!**
To create a warning [admonition/callout][google-admonitions], use the singular form:
```
Warning:
This is a warning.
```
#### Yields {#google-section-yields}
- Multiple items allowed
Yields sections allow to document values that generator yield. They should be used only in generators docstrings. Documented items can be given a name when it makes sense.
```python
from typing import Iterator
def foo() -> Iterator[int]:
"""Foo.
Yields:
Integers from 0 to 9.
"""
for i in range(10):
yield i
```
Type annotations are fetched from the function return annotation when the annotation is `typing.Generator` or `typing.Iterator`. If your generator yields tuples, you can document each item of the tuple separately, and the type annotation will be fetched accordingly:
```python
from datetime import datetime
def foo() -> Iterator[tuple[float, float, datetime]]:
"""Foo.
Yields:
x: Absissa.
y: Ordinate.
t: Time.
...
"""
...
```
You have to indent each continuation line when documenting yielded values, even if there's only one value yielded:
```python
"""Foo.
Yields:
partial_result: Some partial result.
A longer description of details and other information
for this partial result.
"""
```
If you don't want to indent continuation lines for the only yielded value, use the [`returns_multiple_items=False`](#google-options) parser option.
Type annotations can as usual be overridden using types in parentheses in the docstring itself:
```python
"""Foo.
Yields:
x (int): Absissa.
y (int): Ordinate.
t (int): Timestamp.
"""
```
If you want to specify the type without a name, you still have to wrap the type in parentheses:
```python
"""Foo.
Yields:
(int): Absissa.
(int): Ordinate.
(int): Timestamp.
"""
```
If you don't want to wrap the type in parentheses, use the [`returns_named_value=False`](#google-options) parser option. Setting it to false will disallow specifying a name.
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
See previous tips for types in docstrings.
#### Receives {#google-section-receives}
- Multiple items allowed
Receives sections allow to document values that can be sent to generators using their `send` method. They should be used only in generators docstrings. Documented items can be given a name when it makes sense.
```python
from typing import Generator
def foo() -> Generator[int, str, None]:
"""Foo.
Receives:
reverse: Reverse the generator if `"reverse"` is received.
Yields:
Integers from 0 to 9.
Examples:
>>> gen = foo()
>>> next(gen)
0
>>> next(gen)
1
>>> next(gen)
2
>>> gen.send("reverse")
2
>>> next(gen)
1
>>> next(gen)
0
>>> next(gen)
Traceback (most recent call last):
File "", line 1, in
StopIteration
"""
for i in range(10):
received = yield i
if received == "reverse":
for j in range(i, -1, -1):
yield j
break
```
Type annotations are fetched from the function return annotation when the annotation is `typing.Generator`. If your generator is able to receive tuples, you can document each item of the tuple separately, and the type annotation will be fetched accordingly:
```python
def foo() -> Generator[int, tuple[str, bool], None]:
"""Foo.
Receives:
mode: Some mode.
flag: Some flag.
...
"""
...
```
You have to indent each continuation line when documenting received values, even if there's only one value received:
```python
"""Foo.
Receives:
data: Input data.
A longer description of what this data actually is,
and what it isn't.
"""
```
If you don't want to indent continuation lines for the only received value, use the [`receives_multiple_items=False`](#google-options) parser option.
Type annotations can as usual be overridden using types in parentheses in the docstring itself:
```python
"""Foo.
Receives:
mode (ModeEnum): Some mode.
flag (int): Some flag.
"""
```
If you want to specify the type without a name, you still have to wrap the type in parentheses:
```python
"""Foo.
Receives:
(ModeEnum): Some mode.
(int): Some flag.
"""
```
If you don't want to wrap the type in parentheses, use the [`receives_named_value=False`](#google-options) parser option. Setting it to false will disallow specifying a name.
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
See previous tips for types in docstrings.
#### Returns {#google-section-returns}
- Multiple items allowed
Returns sections allow to document values returned by functions. They should be used only in functions docstrings. Documented items can be given a name when it makes sense.
```python
import random
def foo() -> int:
"""Foo.
Returns:
A random integer.
"""
return random.randint(0, 100)
```
Type annotations are fetched from the function return annotation. If your function returns tuples of values, you can document each item of the tuple separately, and the type annotation will be fetched accordingly:
```python
def foo() -> tuple[bool, float]:
"""Foo.
Returns:
success: Whether it succeeded.
precision: Final precision.
...
"""
...
```
You have to indent each continuation line when documenting returned values, even if there's only one value returned:
```python
"""Foo.
Returns:
success: Whether it succeeded.
A longer description of what is considered success,
and what is considered failure.
"""
```
If you don't want to indent continuation lines for the only returned value, use the [`returns_multiple_items=False`](#google-options) parser option.
Type annotations can as usual be overridden using types in parentheses in the docstring itself:
```python
"""Foo.
Returns:
success (int): Whether it succeeded.
precision (Decimal): Final precision.
"""
```
If you want to specify the type without a name, you still have to wrap the type in parentheses:
```python
"""Foo.
Returns:
(int): Whether it succeeded.
(Decimal): Final precision.
"""
```
If you don't want to wrap the type in parentheses, use the [`returns_named_value=False`](#google-options) parser option. Setting it to false will disallow specifying a name.
TIP: **Types in docstrings are resolved using the docstrings' function scope.**
See previous tips for types in docstrings.
## Numpydoc-style
Numpydoc docstrings, see [Numpydoc's documentation][numpydoc].
### Syntax {#numpydoc-syntax}
Sections are written like this:
```text
section identifier
------------------
section contents
```
All sections identifiers are case-insensitive. All sections support multiple lines in descriptions.
Some sections support documenting items items. Item descriptions start on a new, indented line. When multiple items are supported, each item description can use multiple lines.
```python
def foo(a, b):
"""Foo.
Parameters
----------
a
Here's a.
Continuation line 1.
Continuation line 2.
b
Here's b.
"""
```
For items that have an optional name and type, several syntaxes are supported:
- specifying both the name and type:
```python
"""
name : type
description
"""
```
- specifying just the name:
```python
"""
name
description
"""
```
or
```python
"""
name :
description
"""
```
- specifying just the type:
```python
"""
: type
description
"""
```
- specifying neither the name nor type:
```python
"""
:
description
"""
```
### Admonitions {#numpydoc-admonitions}
When a section identifier does not match one of the [supported sections](#numpydoc-sections), the section is parsed as an "admonition" (or "callout").
Identifiers are case-insensitive, however singular and plural forms are distinct, except for notes and warnings. In particular, `Examples` is parsed as the [Examples section](#numpydoc-section-examples), while `Example` is parsed as an admonition whose kind is `example`.
The kind is obtained by lower-casing the identifier and replacing spaces with dashes. For example, an admonition whose identifier is `See also` will have a kind equal to `see-also`.
It is up to any downstream documentation renderer to make use of these kinds.
### Parser options {#numpydoc-options}
The parser accepts a few options:
- `ignore_init_summary`: Ignore the first line in `__init__` methods' docstrings. Useful when merging `__init__` docstring into class' docstrings with mkdocstrings-python's [`merge_init_into_class`][merge_init] option. Default: false.
- `trim_doctest_flags`: Remove the [doctest flags] written as comments in `pycon` snippets within a docstring. These flags are used to alter the behavior of [doctest] when testing docstrings, and should not be visible in your docs. Default: true.
- `warn_unknown_params`: Warn about parameters documented in docstrings that do not appear in the signature. Default: true.
- `warn_missing_types`: Warn about missing type or annotation for parameters, return values, etc. Default: true.
- `warnings`: Generally enable/disable warnings when parsing docstrings. Default: true.
### Sections {#numpydoc-sections}
The following sections are supported.
#### Attributes {#numpydoc-section-attributes}
- Multiple items allowed
Attributes sections allow to document attributes of a module, class, or class instance. They should be used in modules and classes docstrings only.
```python
"""My module.
Attributes
----------
foo
Description for `foo`.
bar
Description for `bar`.
"""
foo: int = 0
bar: bool = True
class MyClass:
"""My class.
Attributes
----------
foofoo
Description for `foofoo`.
barbar
Description for `barbar`.
"""
foofoo: int = 0
def __init__(self):
self.barbar: bool = True
```
Type annotations are fetched from the related attributes definitions. You can override those by adding types between parentheses before the colon:
```python
"""My module.
Attributes
----------
foo : Integer
Description for `foo`.
bar : Boolean
Description for `bar`.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
When documenting an attribute with `attr_name : attr_type`, `attr_type` will be resolved using the scope of the docstrings' parent object (class or module). For example, a type of `list[str]` will be parsed just as if it was an actual Python annotation. You can therefore use complex types (available in the current scope) in docstrings, for example `Optional[Union[int, Tuple[float, float]]]`.
#### Functions/Methods {#numpydoc-section-functions}
- Multiple items allowed
Functions or Methods sections allow to document functions of a module, or methods of a class. They should be used in modules and classes docstrings only.
```python
"""My module.
Functions
---------
foo
Description for `foo`.
bar
Description for `bar`.
"""
def foo():
return "foo"
def bar(baz: int) -> int:
return baz * 2
class MyClass:
"""My class.
Methods
-------
foofoo
Description for `foofoo`.
barbar
Description for `barbar`.
"""
def foofoo(self):
return "foofoo"
@staticmethod
def barbar():
return "barbar"
```
It's possible to write the function/method signature as well as its name:
```python
"""
Functions
---------
foo()
Description for `foo`.
bar(baz=1)
Description for `bar`.
"""
```
The signatures do not have to match the real ones: you can shorten them to only show the important parameters.
#### Classes {#numpydoc-section-classes}
- Multiple items allowed
Classes sections allow to document classes of a module or class. They should be used in modules and classes docstrings only.
```python
"""My module.
Classes
-------
Foo
Description for `foo`.
Bar
Description for `bar`.
"""
class Foo:
...
class Bar:
def __init__(self, baz: int) -> int:
return baz * 2
class MyClass:
"""My class.
Classes
-------
FooFoo
Description for `foofoo`.
BarBar
Description for `barbar`.
"""
class FooFoo:
...
class BarBar:
...
```
It's possible to write the class signature as well as its name:
```python
"""
Functions
---------
Foo()
Description for `Foo`.
Bar(baz=1)
Description for `Bar`.
"""
```
The signatures do not have to match the real ones: you can shorten them to only show the important initialization parameters.
#### Modules {#numpydoc-section-modules}
- Multiple items allowed
Modules sections allow to document submodules of a module. They should be used in modules docstrings only.
```tree
my_pkg/
__init__.py
foo.py
bar.py
```
```python title="my_pkg/__init__.py"
"""My package.
Modules
-------
foo
Description for `foo`.
bar
Description for `bar`.
"""
```
#### Examples {#numpydoc-section-examples}
Examples sections allow to add examples of Python code without the use of markup code blocks. They are a mix of prose and interactive console snippets. They can be used in every docstring.
```python
"""My module.
Examples
--------
Some explanation of what is possible.
>>> print("hello!")
hello!
Blank lines delimit prose vs. console blocks.
>>> a = 0
>>> a += 1
>>> a
1
"""
```
#### Parameters {#numpydoc-section-parameters}
- Aliases: Args, Arguments, Params
- Multiple items allowed
Parameters sections allow to document parameters of a function. They are typically used in functions docstrings, but can also be used in dataclasses docstrings.
```python
def foo(a: int, b: str):
"""Foo.
Parameters
----------
a
Here's a.
b
Here's b.
"""
```
```python
from dataclasses import dataclass
@dataclass
class Foo:
"""Foo.
Parameters
----------
a
Here's a.
b
Here's b.
"""
foo: int
bar: str
```
Type annotations are fetched from the related parameters definitions. You can override those by adding types between parentheses before the colon:
```python
"""My function.
Parameters
----------
foo : Integer
Description for `foo`.
bar : String
Description for `bar`.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
When documenting a parameter with `param_name : param_type`, `param_type` will be resolved using the scope of the function (or class). For example, a type of `list[str]` will be parsed just as if it was an actual Python annotation. You can therefore use complex types (available in the current scope) in docstrings, for example `Optional[Union[int, Tuple[float, float]]]`.
#### Other Parameters {#numpydoc-section-other-parameters}
- Aliases: Keyword Args, Keyword Arguments, Other Args, Other Arguments, Other Params
- Multiple items allowed
Other parameters sections allow to document secondary parameters such as variadic keyword arguments, or parameters that should be of lesser interest to the user. They are used the same way Parameters sections are.
```python
def foo(a, b, **kwargs):
"""Foo.
Parameters
----------
a
Here's a.
b
Here's b.
Other parameters
----------------
c : int
Here's c.
d : bool
Here's d.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
See the same tip for parameters.
#### Raises {#numpydoc-section-raises}
- Aliases: Exceptions
- Multiple items allowed
Raises sections allow to document exceptions that are raised by a function. They are usually only used in functions docstrings.
```python
def foo(a: int):
"""Foo.
Parameters
----------
a
A value.
Raises
------
ValueError
When `a` is less than 0.
"""
if a < 0:
raise ValueError("message")
```
TIP: **Exceptions names are resolved using the function's scope.**
`ValueError` and other built-in exceptions are resolved as such. You can document custom exception, using the names available in the current scope, for example `my_exceptions.MyCustomException` or `MyCustomException` directly, depending on what you imported/defined in the current module.
#### Warns {#numpydoc-section-warns}
- Multiple items allowed
Warns sections allow to document warnings emitted by the following code. They are usually only used in functions docstrings.
```python
import warnings
def foo():
"""Foo.
Warns
-----
UserWarning
To annoy users.
"""
warnings.warn("Just messing with you.", UserWarning)
```
TIP: **Warnings names are resolved using the function's scope.**
`UserWarning` and other built-in warnings are resolved as such. You can document custom warnings, using the names available in the current scope, for example `my_warnings.MyCustomWarning` or `MyCustomWarning` directly, depending on what you imported/defined in the current module.
#### Yields {#numpydoc-section-yields}
- Multiple items allowed
Yields sections allow to document values that generator yield. They should be used only in generators docstrings. Documented items can be given a name when it makes sense.
```python
from typing import Iterator
def foo() -> Iterator[int]:
"""Foo.
Yields
------
:
Integers from 0 to 9.
"""
for i in range(10):
yield i
```
Type annotations are fetched from the function return annotation when the annotation is `typing.Generator` or `typing.Iterator`. If your generator yields tuples, you can document each item of the tuple separately, and the type annotation will be fetched accordingly:
```python
from datetime import datetime
def foo() -> Iterator[tuple[float, float, datetime]]:
"""Foo.
Yields
------
x
Absissa.
y
Ordinate.
t
Time.
"""
...
```
Type annotations can as usual be overridden using types in parentheses in the docstring itself:
```python
"""Foo.
Yields
------
x : int
Absissa.
y : int
Ordinate.
t : int
Timestamp.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
See previous tips for types in docstrings.
#### Receives {#numpydoc-section-receives}
- Multiple items allowed
Receives sections allow to document values that can be sent to generators using their `send` method. They should be used only in generators docstrings. Documented items can be given a name when it makes sense.
```python
from typing import Generator
def foo() -> Generator[int, str, None]:
"""Foo.
Receives
--------
reverse
Reverse the generator if `"reverse"` is received.
Yields
------
:
Integers from 0 to 9.
Examples
--------
>>> gen = foo()
>>> next(gen)
0
>>> next(gen)
1
>>> next(gen)
2
>>> gen.send("reverse")
2
>>> next(gen)
1
>>> next(gen)
0
>>> next(gen)
Traceback (most recent call last):
File "", line 1, in
StopIteration
"""
for i in range(10):
received = yield i
if received == "reverse":
for j in range(i, -1, -1):
yield j
break
```
Type annotations are fetched from the function return annotation when the annotation is `typing.Generator`. If your generator is able to receive tuples, you can document each item of the tuple separately, and the type annotation will be fetched accordingly:
```python
def foo() -> Generator[int, tuple[str, bool], None]:
"""Foo.
Receives
--------
mode
Some mode.
flag
Some flag.
"""
...
```
Type annotations can as usual be overridden using types in parentheses in the docstring itself:
```python
"""Foo.
Receives
--------
mode : ModeEnum
Some mode.
flag : int
Some flag.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' parent scope.**
See previous tips for types in docstrings.
#### Returns {#numpydoc-section-returns}
- Multiple items allowed
Returns sections allow to document values returned by functions. They should be used only in functions docstrings. Documented items can be given a name when it makes sense.
```python
import random
def foo() -> int:
"""Foo.
Returns
-------
:
A random integer.
"""
return random.randint(0, 100)
```
Type annotations are fetched from the function return annotation. If your function returns tuples of values, you can document each item of the tuple separately, and the type annotation will be fetched accordingly:
```python
def foo() -> tuple[bool, float]:
"""Foo.
Returns
-------
success
Whether it succeeded.
precision
Final precision.
"""
...
```
Type annotations can as usual be overridden using types in parentheses in the docstring itself:
```python
"""Foo.
Returns
-------
success : int
Whether it succeeded.
precision : Decimal
Final precision.
"""
```
TIP: **Types in docstrings are resolved using the docstrings' function scope.**
See previous tips for types in docstrings.
## Auto-style
Automatic style detection. This parser will first try to detect the style used in the docstring, and call the corresponding parser on it.
### Parser options {#auto-options}
The parser accepts a few options:
- `method`: The method to use to detect the style and infer the parser. Method 'heuristics' will use regular expressions, while method 'max_sections' will parse the docstring with all parsers specified in `style_order` and return the one who parsed the most sections. Default: `"heuristics"`.
- `style_order`: If multiple parsers parsed the same number of sections, `style_order` is used to decide which one to return. Default: `["sphinx", "google", "numpy"]`.
- `default`: If heuristics fail, the `default` parser is returned. The `default` parser is never used with the 'max_sections' method. Default: `None`.
- Any other option is passed down to the detected parser, if any.
## Parsers features
!!! tip "Want to contribute?"
Each red cross is a link to an issue on the bugtracker. You will find some guidance on how to add support for the corresponding item.
The sections are easier to deal in that order:
- Raises, Warns (multiple items, no names, single type each)
- Attributes, Other Parameters, Parameters (multiple items, one name and one optional type each)
- Returns (multiple items, optional name and/or type each, annotation to split when multiple names)
- Receives, Yields (multiple items, optional name and/or type each, several types of annotations to split when multiple names)
"Examples" section are a bit different as they require to parse the examples. But you can probably reuse the code in the Google parser. We can probably even factorize the examples parsing into a single function.
You can tackle several items at once in a single PR, as long as they relate to a single parser or a single section (a line or a column of the following tables).
### Sections
Section | Google | Numpy | Sphinx
---------------- | ------ | ----- | ------
Attributes | ✅ | ✅ | ✅
Functions | ✅ | ✅ | �
Methods | ✅ | ✅ | �
Classes | ✅ | ✅ | �
Modules | ✅ | ✅ | �
Examples | ✅ | ✅ | [�][issue-section-sphinx-examples]
Parameters | ✅ | ✅ | ✅
Other Parameters | ✅ | ✅ | [�][issue-section-sphinx-other-parameters]
Raises | ✅ | ✅ | ✅
Warns | ✅ | ✅ | [�][issue-section-sphinx-warns]
Yields | ✅ | ✅ | [�][issue-section-sphinx-yields]
Receives | ✅ | ✅ | [�][issue-section-sphinx-receives]
Returns | ✅ | ✅ | ✅
### Getting annotations/defaults from parent
Section | Google | Numpy | Sphinx
---------------- | ------ | ----- | ------
Attributes | ✅ | ✅ | [�][issue-parent-sphinx-attributes]
Functions | / | / | /
Methods | / | / | /
Classes | / | / | /
Modules | / | / | /
Examples | / | / | /
Parameters | ✅ | ✅ | ✅
Other Parameters | ✅ | ✅ | [�][issue-parent-sphinx-other-parameters]
Raises | / | / | /
Warns | / | / | /
Yields | ✅ | ✅ | [�][issue-parent-sphinx-yields]
Receives | ✅ | ✅ | [�][issue-parent-sphinx-receives]
Returns | ✅ | ✅ | ✅
### Cross-references for annotations in docstrings
Section | Google | Numpy | Sphinx
---------------- | ------ | ----- | ------
Attributes | ✅ | ✅ | [�][issue-xref-sphinx-attributes]
Functions | [�][issue-xref-google-func-cls] | [�][issue-xref-numpy-func-cls] | /
Methods | [�][issue-xref-google-func-cls] | [�][issue-xref-numpy-func-cls] | /
Classes | [�][issue-xref-google-func-cls] | [�][issue-xref-numpy-func-cls] | /
Modules | / | / | /
Examples | / | / | /
Parameters | ✅ | ✅ | [�][issue-xref-sphinx-parameters]
Other Parameters | ✅ | ✅ | [�][issue-xref-sphinx-other-parameters]
Raises | ✅ | ✅ | [�][issue-xref-sphinx-raises]
Warns | ✅ | ✅ | [�][issue-xref-sphinx-warns]
Yields | ✅ | ✅ | [�][issue-xref-sphinx-yields]
Receives | ✅ | ✅ | [�][issue-xref-sphinx-receives]
Returns | ✅ | ✅ | [�][issue-xref-sphinx-returns]
[doctest]: https://docs.python.org/3/library/doctest.html#module-doctest
[doctest flags]: https://docs.python.org/3/library/doctest.html#option-flags
[issue-parent-sphinx-attributes]: https://github.com/mkdocstrings/griffe/issues/33
[issue-parent-sphinx-other-parameters]: https://github.com/mkdocstrings/griffe/issues/34
[issue-parent-sphinx-receives]: https://github.com/mkdocstrings/griffe/issues/35
[issue-parent-sphinx-yields]: https://github.com/mkdocstrings/griffe/issues/36
[issue-section-sphinx-examples]: https://github.com/mkdocstrings/griffe/issues/7
[issue-section-sphinx-other-parameters]: https://github.com/mkdocstrings/griffe/issues/27
[issue-section-sphinx-receives]: https://github.com/mkdocstrings/griffe/issues/8
[issue-section-sphinx-warns]: https://github.com/mkdocstrings/griffe/issues/9
[issue-section-sphinx-yields]: https://github.com/mkdocstrings/griffe/issues/10
[issue-xref-google-func-cls]: https://github.com/mkdocstrings/griffe/issues/199
[issue-xref-numpy-func-cls]: https://github.com/mkdocstrings/griffe/issues/200
[issue-xref-sphinx-attributes]: https://github.com/mkdocstrings/griffe/issues/19
[issue-xref-sphinx-other-parameters]: https://github.com/mkdocstrings/griffe/issues/20
[issue-xref-sphinx-parameters]: https://github.com/mkdocstrings/griffe/issues/21
[issue-xref-sphinx-raises]: https://github.com/mkdocstrings/griffe/issues/22
[issue-xref-sphinx-receives]: https://github.com/mkdocstrings/griffe/issues/23
[issue-xref-sphinx-returns]: https://github.com/mkdocstrings/griffe/issues/24
[issue-xref-sphinx-warns]: https://github.com/mkdocstrings/griffe/issues/25
[issue-xref-sphinx-yields]: https://github.com/mkdocstrings/griffe/issues/26
[merge_init]: https://mkdocstrings.github.io/python/usage/configuration/docstrings/#merge_init_into_class
[napoleon]: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html
[numpydoc]: https://numpydoc.readthedocs.io/en/latest/format.html
[sphinx]: https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html
python-griffe-2.3.0/docs/reference/api.md 0000664 0001750 0001750 00000000174 15246557273 020162 0 ustar carsten carsten ---
title: API reference
---
# ::: griffe
options:
summary:
functions: true
members: false
python-griffe-2.3.0/docs/img/ 0000775 0001750 0001750 00000000000 15246557273 015703 5 ustar carsten carsten python-griffe-2.3.0/docs/img/gha_annotations_2.png 0000664 0001750 0001750 00000041627 15246557273 022020 0 ustar carsten carsten ‰PNG
IHDR ‘ ¶ —�': sBITÛáOà IDATxœíÝgXÇ àovO½wAQ@@Œ`C±·ØkÌ5ͨѨijLŒ¹ILÔ“ØcÔØ» "‚
©J‘Ò9½ìÎý(*E¼Nœ÷ñàž™ÙÙÝogf÷Ì ‘‘4âÜÉ%ëÞ] ‚h—¨×] ‚ ˆ6 1‹ mòÒcmã?qöÜÉÓfOž6{ò´™ƒÝD €Òwî2qöÔ³Æñ±¾ìR<7®ë’ÛëW…ryCf�M™Þ‹[÷W^à¶
G¾uá÷ù)gÕ{Ýh @sE�Ú�g1-|ãïóL) à:}xeSLþÆy#SVþ¼¢»
·p[–øÉ;ôB÷ª=xæ: =FìÎùx¤9Çcå7Q»zê½Îb¯ç¥çÀãSù1Ïg¨ý
xöëmZxnOx×¾ïˆ~>ŽÆäª›Mâ5Â
©DbÌ2r‰˜‘Õÿ••J”’9#UÈÔ
©×m*+±LÊ*%bVV«ÄõÛV·rC<Ë·ëúþÎYË‹òço)c_Ïδ+m¨C,‘Ë4r‰ËÄr¥D¡z�Å&^»—³¸\«R>q‘ò,m
ËRÎ?PbPæÝNí:ÌÉœÎ-d^vYžVHjå¸cV.3²ú8„eµrI³
©T.‘ÖÿU%V¨Är#—J™¸!f5$¤ÌOÞ¿¿hgßÂ-e*gß…?‡úZph¶2zõö�gªX àù-ŸúÞ(—Ç�ÿqé…ÔšFi �Dz…Ÿ;_ødÞÕB
2ýÅWoYó)¨LßùážÓ™ àuö[þÛÐnBYALt¢Ýh‡=KWœÑP®³âÊe”’Û¿ïùiw�¢™}åúO9ð¡tOšesÉé쾦ÀÐD^œ^oïü˜w
û÷^°6Ù¡è‡u«WÁ3çÕ¦:ĹTª�ªY™X!y²b‰7ÍËî".�‹L<Ç͘4uÚð /s> `Õõw°JáêëóÛgï+¤µr‰c©\Z+“5Ä^i\R£À �ÔÊ%â†H&‘ÕÔÈU˜‘Š%ÉÓm)DóhVͰH§ÿò ]b¶LîùÅ´/Ëú¯ìÅ d9eú'^é_õ[6¢û�‡¸ƒW,rjÔ�¤,FO]î—üÍÂøB
¯óŒµÞyŸ}=ÞwÅŠXû÷{ê
\>Òæä¦·û|ûÕyÛ�½)†@zVÏè—s`Žï—ÇFr>š>Ù�nvgY–êán~z×'£×,ÜÉ™ô�—5“fyž¼Ûïo=¬<¾àëûÞ¡…mÊ«-uˆ%
i�L
«•Ëj夕úf{éí,VRRP,¾{ûL™Æ¸³È€žÕGcrU%ù•¾îîV¹·ËhK�Φ´tv¿Flõñ™ë‘’eqÔÂiÐÐ^d²×¯û+±ZúçÐí¬´~[õåCs®±*`®~úí5µæñ„(ÝŽ=fL6LX{W�eQï}~)U êĬ\3>€F¯ÇÇ{¶fË "üƒ•1Xõ°·,ôYù1Þ6étrݵJÿ¹ß7´B ™×
ÀÇЀmÓµkõ•µÅ*Àª‹—/æùX Ï©OŸêó#ïV³ %·N_óÎ ã�©ÍtN10÷ïDÞ�c€²´Bé(CC
j›Ê«›[˜#–ˆ*äù÷ª”R±L¤«#hK^mªCyâ7Co«Õ˜=¼eÒ´Çö8ñê¼ì˜…¥÷¯\º_÷sYÆí{®vÆTnimrÜUC¿þo»àšü”üµ¹JÝN[üX#¯?Ñ(d�þʪ”J ¬’6†cÔr €Q(]W”ñ¸C¿ŒÆXUQ¿eÛO'Å(³~�~äe/d4HÏŽW ”Ž¡#®®ïø°
…äa
Û‰«l¡&®¼¶áòGzîÓÆÌm«‡¬o*,ÏE €ººòûuA�(ÇV ÐÑYO>øÍh €âsk÷êQÐ쀖« 3˜k 7™ V©Ô 3j
Æâ·-¯6Ô!0J) VË›I�xc¼ì˜Eé˜ÙŠ”¥u—E!ÌÖ_TY—ÎdÆ eÚs¸¢¢ê_;°Z?ßèÔé:o}OÙÂæDˆ±™ßºË ¬¬ºŠ60æ#Ð` ÊÀØN$Ï/�³ ÀVž˜»éδ…K>MúÏWw¥ œÁŸ¿'Ü5ü¿gò5ü�9Gæ VÊ==€�› ¹¤º:ï¯Ñ?ý“ûœ÷„¦ój’âÿÍ‹ žÁK×Ahß;ÀÏË„‡€kÜÉÃòïW°€tÜOÚÅ€Ú°“·“y·òͤ@<=Aíý ¼Ž£½;r¸|>,¹y>×e|_'! ×(èÇÅkf˜×ñ±²ŠÒª¸U‡Ò†Lšë'D H_G§¦$»D<³À‘N<>�‹ ˜âŒ“ž�<@Fý{÷³¥ ÔÙq—L‚¦9é! ¡Õ°5ÓFº¶Þt^Mj)/^¿Ÿ×ÙÐ]çùªŒ yÙí,¶âvdbßþƒ&½ÅeäU¹×£¯1 »{ã¶m¿á3ßeUÎÕ˜;oPÈ\sçàN¿O~Öï~yÚßçw]™1sÛàÌñgÓþÞ½Î~êêK„4S¶ò—ÜÆ7¸2é·•Þ~?2nèþkW.î+œ½ú\§¢¼‚sžŒýyäÊÏr?X�vîû°>¿.Ø?±:+"6ö¶Æ
°,få.§'íHà«Ì=uêÇ{mR7•×ü³MîXyažHdÄå’W˜‰ÿ"ß7ü7¢LÞ>ºØñç/¿�Ö´¾1Ahrãû÷@fßý{Kp<ú8§g�'lÄ¿ÏËžxUpyXÄ™!S¿6«Ä7×î
/&cáÄ¿é¡MHß� mBbAÚ„Ä,‚ ´ ‰YAh³‚Ð&$f¡MHÌ"B›¼ŠwJùÝúøyZéÒʲ”+Ñ Er @:¶>~½ÝL ¬Èˆ�¾Z û¼ÿˆŒÆýö÷«÷ö•5ìe9õ¯cÝvô_ù|óV ]˃k:``+%Òð¨{‹/ÔV7_U´¹ý‰ù˜]eʆlü½bºì*W6û!‚Ð&/¿�Åsôñw®½|`ßî®Ö:zw6¥ @àäÓß¾âÒ¾}ï»XbÕ»o'ÁK/Ç+ÀéàâÜÖ5*Z§©ZôåE³±ž–èºìÒÒ¼