diff --git a/docs/guide/users/loading.md b/docs/guide/users/loading.md index c7e17092..bedc006d 100644 --- a/docs/guide/users/loading.md +++ b/docs/guide/users/loading.md @@ -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: diff --git a/packages/griffecli/src/griffecli/_internal/cli.py b/packages/griffecli/src/griffecli/_internal/cli.py index 22170eda..f1c6e330 100644 --- a/packages/griffecli/src/griffecli/_internal/cli.py +++ b/packages/griffecli/src/griffecli/_internal/cli.py @@ -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 @@ -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. @@ -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", @@ -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, @@ -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. @@ -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 @@ -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, @@ -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. @@ -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, ) @@ -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, ) @@ -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, ) @@ -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, ) @@ -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, ) diff --git a/packages/griffecli/tests/test_cli.py b/packages/griffecli/tests/test_cli.py index 2499ce98..04c80508 100644 --- a/packages/griffecli/tests/test_cli.py +++ b/packages/griffecli/tests/test_cli.py @@ -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.""" @@ -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. diff --git a/packages/griffelib/src/griffe/_internal/collections.py b/packages/griffelib/src/griffe/_internal/collections.py index c9303b79..ad0a7d5c 100644 --- a/packages/griffelib/src/griffe/_internal/collections.py +++ b/packages/griffelib/src/griffe/_internal/collections.py @@ -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] = {} diff --git a/packages/griffelib/src/griffe/_internal/helpers.py b/packages/griffelib/src/griffe/_internal/helpers.py index 966d0de0..714d4733 100644 --- a/packages/griffelib/src/griffe/_internal/helpers.py +++ b/packages/griffelib/src/griffe/_internal/helpers.py @@ -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. @@ -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. @@ -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, ) @@ -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. @@ -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. @@ -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()): diff --git a/packages/griffelib/src/griffe/_internal/loader.py b/packages/griffelib/src/griffe/_internal/loader.py index 70ac2a24..0ef72f67 100644 --- a/packages/griffelib/src/griffe/_internal/loader.py +++ b/packages/griffelib/src/griffe/_internal/loader.py @@ -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. @@ -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.""" @@ -110,6 +114,7 @@ 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.""" @@ -117,6 +122,8 @@ def __init__( """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, @@ -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( @@ -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, @@ -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 @@ -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, @@ -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, @@ -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 @@ -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, ) @@ -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, @@ -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 @@ -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, ) diff --git a/packages/griffelib/src/griffe/_internal/merger.py b/packages/griffelib/src/griffe/_internal/merger.py index f5acf5cc..8059e902 100644 --- a/packages/griffelib/src/griffe/_internal/merger.py +++ b/packages/griffelib/src/griffe/_internal/merger.py @@ -31,21 +31,21 @@ from griffe._internal.models import Attribute, Class, Function, Module, Object, Parameter, TypeAlias -def _merge_module_stubs(module: Module, stubs: Module) -> None: - _merge_stubs_docstring(module, stubs) +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) + _merge_stubs_members(module, stubs, psd=psd) -def _merge_class_stubs(class_: Class, stubs: Class) -> None: - _merge_stubs_docstring(class_, stubs) +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) + _merge_stubs_members(class_, stubs, psd=psd) -def _merge_function_stubs(function: Function, stubs: Function) -> None: - _merge_stubs_docstring(function, stubs) +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) @@ -56,20 +56,20 @@ def _merge_function_stubs(function: Function, stubs: Function) -> None: _merge_stubs_type_parameters(function, stubs) -def _merge_attribute_stubs(attribute: Attribute, stubs: Attribute) -> None: - _merge_stubs_docstring(attribute, 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) -> None: - _merge_stubs_docstring(type_alias, stubs) +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) -> None: - if not obj.docstring and stubs.docstring: +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 @@ -132,7 +132,7 @@ def _merge_overload_annotations(function: Function, overloads: list[Function]) - function.returns = _merge_annotations(return_annotations) -def _merge_stubs_members(obj: Module | Class, stubs: Module | Class) -> None: +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) @@ -162,26 +162,29 @@ def _merge_stubs_members(obj: Module | Class, stubs: Module | Class) -> None: ) obj.set_member(stub_member.name, stub_member) elif obj_member.is_module: - _merge_module_stubs(obj_member, stub_member) # ty:ignore[invalid-argument-type] + _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) # ty:ignore[invalid-argument-type] + _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) # ty:ignore[invalid-argument-type] + _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) # ty:ignore[invalid-argument-type] + _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) # ty:ignore[invalid-argument-type] + _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) -> Module: +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). @@ -198,5 +201,5 @@ def merge_stubs(mod1: Module, mod2: Module) -> Module: module = mod1 else: raise ValueError("cannot merge regular (non-stubs) modules together") - _merge_module_stubs(module, stubs) + _merge_module_stubs(module, stubs, psd=prefer_stubs_docs) return module diff --git a/packages/griffelib/src/griffe/_internal/mixins.py b/packages/griffelib/src/griffe/_internal/mixins.py index dac4f54d..4449fbc8 100644 --- a/packages/griffelib/src/griffe/_internal/mixins.py +++ b/packages/griffelib/src/griffe/_internal/mixins.py @@ -211,8 +211,9 @@ def set_member(self, key: str | Sequence[str], value: Object | Alias) -> None: # 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) # ty:ignore[invalid-argument-type] + 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 diff --git a/packages/griffelib/tests/test_merger.py b/packages/griffelib/tests/test_merger.py index 9841b696..26d45bfe 100644 --- a/packages/griffelib/tests/test_merger.py +++ b/packages/griffelib/tests/test_merger.py @@ -18,6 +18,8 @@ from __future__ import annotations +import pytest + from griffe import temporary_visited_package @@ -111,3 +113,21 @@ def func(x: float) -> float: ... 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