Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/guide/users/loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,34 @@ By default it will search in the paths found in [`sys.path`][sys.path], which ca

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:
Expand Down
21 changes: 21 additions & 0 deletions packages/griffecli/src/griffecli/_internal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def _load_packages(
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
Expand All @@ -104,6 +105,7 @@ def _load_packages(
allow_inspection=allow_inspection,
force_inspection=force_inspection,
store_source=store_source,
prefer_stubs_docs=prefer_stubs_docs,
)

# Load each package.
Expand Down Expand Up @@ -192,6 +194,14 @@ def add_common_options(subparser: argparse.ArgumentParser) -> None:
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",
Expand Down Expand Up @@ -358,6 +368,7 @@ def dump(
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,
Expand All @@ -380,6 +391,7 @@ def dump(
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.
Expand Down Expand Up @@ -422,6 +434,7 @@ def dump(
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

Expand Down Expand Up @@ -454,6 +467,7 @@ def check(
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,
Expand All @@ -470,6 +484,8 @@ def 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.
Expand Down Expand Up @@ -509,6 +525,7 @@ def check(
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,
)
Expand All @@ -530,6 +547,7 @@ def check(
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,
)
Expand All @@ -553,6 +571,7 @@ def check(
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,
)
Expand All @@ -567,6 +586,7 @@ def check(
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,
)
Expand All @@ -579,6 +599,7 @@ def check(
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,
)
Expand Down
25 changes: 25 additions & 0 deletions packages/griffecli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@

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."""
Expand All @@ -34,6 +39,26 @@ def test_main() -> None:
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.

Expand Down
4 changes: 4 additions & 0 deletions packages/griffelib/src/griffe/_internal/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ class ModulesCollection(GetMembersMixin, SetMembersMixin, DelMembersMixin):
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] = {}
Expand Down
10 changes: 10 additions & 0 deletions packages/griffelib/src/griffe/_internal/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def temporary_visited_package(
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.

Expand Down Expand Up @@ -178,6 +179,9 @@ def temporary_visited_package(
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.
Expand All @@ -198,6 +202,7 @@ def temporary_visited_package(
resolve_external=resolve_external,
resolve_implicit=resolve_implicit,
force_inspection=False,
prefer_stubs_docs=prefer_stubs_docs,
)


Expand All @@ -219,6 +224,7 @@ def temporary_inspected_package(
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.

Expand Down Expand Up @@ -246,6 +252,9 @@ def temporary_inspected_package(
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.
Expand All @@ -267,6 +276,7 @@ def temporary_inspected_package(
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()):
Expand Down
24 changes: 23 additions & 1 deletion packages/griffelib/src/griffe/_internal/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def __init__(
allow_inspection: bool = True,
force_inspection: bool = False,
store_source: bool = True,
prefer_stubs_docs: bool = False,
) -> None:
"""Initialize the loader.

Expand All @@ -100,6 +101,9 @@ def __init__(
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."""
Expand All @@ -110,13 +114,16 @@ def __init__(
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,
Expand Down Expand Up @@ -575,7 +582,7 @@ def _load_package(self, package: Package | NamespacePackage, *, submodules: bool
# 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)
return merge_stubs(top_module, stubs, prefer_stubs_docs=self.prefer_stubs_docs)
return top_module

def _load_module(
Expand Down Expand Up @@ -787,6 +794,7 @@ def load(
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,
Expand Down Expand Up @@ -845,6 +853,9 @@ def load(
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
Expand All @@ -864,6 +875,7 @@ def load(
allow_inspection=allow_inspection,
force_inspection=force_inspection,
store_source=store_source,
prefer_stubs_docs=prefer_stubs_docs,
)
result = loader.load(
objspec,
Expand Down Expand Up @@ -892,6 +904,7 @@ def load_git(
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,
Expand Down Expand Up @@ -928,6 +941,9 @@ def load_git(
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
Expand Down Expand Up @@ -958,6 +974,7 @@ def load_git(
resolve_aliases=resolve_aliases,
resolve_external=resolve_external,
resolve_implicit=resolve_implicit,
prefer_stubs_docs=prefer_stubs_docs,
)


Expand All @@ -976,6 +993,7 @@ def load_pypi(
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,
Expand All @@ -999,6 +1017,9 @@ def load_pypi(
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
Expand Down Expand Up @@ -1083,4 +1104,5 @@ def load_pypi(
resolve_aliases=resolve_aliases,
resolve_external=resolve_external,
resolve_implicit=resolve_implicit,
prefer_stubs_docs=prefer_stubs_docs,
)
Loading
Loading