Coverage for packages / griffelib / src / griffe / _internal / merger.py: 88.20%
107 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
1# This module contains utilities to merge stubs data and concrete data.
3from __future__ import annotations
5from contextlib import suppress
6from typing import TYPE_CHECKING
8from griffe._internal.exceptions import AliasResolutionError, CyclicAliasError
9from griffe._internal.expressions import Expr
10from griffe._internal.logger import logger
12if TYPE_CHECKING:
13 from collections.abc import Sequence
15 from griffe._internal.models import Attribute, Class, Function, Module, Object, TypeAlias
18def _merge_module_stubs(module: Module, stubs: Module) -> None:
19 _merge_stubs_docstring(module, stubs)
20 _merge_stubs_overloads(module, stubs)
21 _merge_stubs_members(module, stubs)
24def _merge_class_stubs(class_: Class, stubs: Class) -> None:
25 _merge_stubs_docstring(class_, stubs)
26 _merge_stubs_overloads(class_, stubs)
27 _merge_stubs_type_parameters(class_, stubs)
28 _merge_stubs_members(class_, stubs)
31def _merge_function_stubs(function: Function, stubs: Function) -> None:
32 _merge_stubs_docstring(function, stubs)
33 for parameter in stubs.parameters:
34 with suppress(KeyError):
35 function.parameters[parameter.name].annotation = parameter.annotation
36 function.returns = stubs.returns
37 _merge_stubs_type_parameters(function, stubs)
40def _merge_attribute_stubs(attribute: Attribute, stubs: Attribute) -> None:
41 _merge_stubs_docstring(attribute, stubs)
42 attribute.annotation = stubs.annotation
43 if stubs.value not in (None, "..."):
44 attribute.value = stubs.value
47def _merge_type_alias_stubs(type_alias: TypeAlias, stubs: TypeAlias) -> None:
48 _merge_stubs_docstring(type_alias, stubs)
49 _merge_stubs_type_parameters(type_alias, stubs)
52def _merge_stubs_docstring(obj: Object, stubs: Object) -> None:
53 if not obj.docstring and stubs.docstring: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 obj.docstring = stubs.docstring
57def _merge_stubs_type_parameters(obj: Class | Function | TypeAlias, stubs: Class | Function | TypeAlias) -> None:
58 if not obj.type_parameters and stubs.type_parameters: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 obj.type_parameters = stubs.type_parameters
62def _merge_stubs_overloads(obj: Module | Class, stubs: Module | Class) -> None:
63 for function_name, overloads in list(stubs.overloads.items()):
64 if overloads:
65 with suppress(KeyError):
66 _merge_overload_annotations(obj.get_member(function_name), overloads)
67 del stubs.overloads[function_name]
70def _merge_annotations(annotations: Sequence[Expr]) -> Expr | None:
71 if len(annotations) == 1: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 return annotations[0]
73 if annotations:
74 return Expr._to_binop(annotations, op="|")
75 return None
78def _merge_overload_annotations(function: Function, overloads: list[Function]) -> None:
79 function.overloads = overloads
80 for parameter in function.parameters:
81 if parameter.annotation is None:
82 seen = set()
83 annotations = []
84 for overload in overloads:
85 with suppress(KeyError):
86 annotation = overload.parameters[parameter.name].annotation
87 str_annotation = str(annotation)
88 if isinstance(annotation, Expr) and str_annotation not in seen:
89 annotations.append(annotation)
90 seen.add(str_annotation)
91 parameter.annotation = _merge_annotations(annotations)
92 if function.returns is None:
93 seen = set()
94 return_annotations = []
95 for overload in overloads:
96 str_annotation = str(overload.returns)
97 if isinstance(overload.returns, Expr) and str_annotation not in seen: 97 ↛ 95line 97 didn't jump to line 95 because the condition on line 97 was always true
98 return_annotations.append(overload.returns)
99 seen.add(str_annotation)
100 function.returns = _merge_annotations(return_annotations)
103def _merge_stubs_members(obj: Module | Class, stubs: Module | Class) -> None:
104 # Merge imports to later know if objects coming from the stubs were imported.
105 obj.imports.update(stubs.imports)
107 # Override exports to later know if objects coming from the stubs were exported.
108 if stubs.exports is not None:
109 obj.exports = stubs.exports
111 for member_name, stub_member in stubs.members.items():
112 if member_name in obj.members:
113 # We don't merge imported stub objects that already exist in the concrete module.
114 # Stub objects must be defined where they are exposed in the concrete package,
115 # not be imported from other stub modules.
116 if stub_member.is_alias:
117 continue
118 obj_member = obj.get_member(member_name)
119 with suppress(AliasResolutionError, CyclicAliasError):
120 # An object's canonical location can differ from its equivalent stub location.
121 # Devs usually declare stubs at the public location of the corresponding object,
122 # not the canonical one. Therefore, we must allow merging stubs into the target of an alias,
123 # as long as the stub and target are of the same kind.
124 if obj_member.kind is not stub_member.kind: 124 ↛ 127line 124 didn't jump to line 127 because the condition on line 124 was never true
125 # If the stub and the target are not of the same kind, prefer the
126 # stub over the target.
127 logger.debug(
128 "Source object `%s` will be overwritten by stub object.",
129 obj_member.path,
130 )
131 obj.set_member(stub_member.name, stub_member)
132 elif obj_member.is_module:
133 _merge_module_stubs(obj_member, stub_member) # ty:ignore[invalid-argument-type]
134 elif obj_member.is_class:
135 _merge_class_stubs(obj_member, stub_member) # ty:ignore[invalid-argument-type]
136 elif obj_member.is_function:
137 _merge_function_stubs(obj_member, stub_member) # ty:ignore[invalid-argument-type]
138 elif obj_member.is_attribute: 138 ↛ 140line 138 didn't jump to line 140 because the condition on line 138 was always true
139 _merge_attribute_stubs(obj_member, stub_member) # ty:ignore[invalid-argument-type]
140 elif obj_member.is_type_alias:
141 _merge_type_alias_stubs(obj_member, stub_member) # ty:ignore[invalid-argument-type]
142 else:
143 stub_member.runtime = False
144 obj.set_member(member_name, stub_member)
147def merge_stubs(mod1: Module, mod2: Module) -> Module:
148 """Merge stubs into a module.
150 Parameters:
151 mod1: A regular module or stubs module.
152 mod2: A regular module or stubs module.
154 Raises:
155 ValueError: When both modules are regular modules (no stubs is passed).
157 Returns:
158 The regular module.
159 """
160 logger.debug("Trying to merge %s and %s", mod1.filepath, mod2.filepath)
161 if mod1.filepath.suffix == ".pyi": # ty:ignore[possibly-missing-attribute]
162 stubs = mod1
163 module = mod2
164 elif mod2.filepath.suffix == ".pyi": # ty:ignore[possibly-missing-attribute] 164 ↛ 168line 164 didn't jump to line 168 because the condition on line 164 was always true
165 stubs = mod2
166 module = mod1
167 else:
168 raise ValueError("cannot merge regular (non-stubs) modules together")
169 _merge_module_stubs(module, stubs)
170 return module