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

1# This module contains utilities to merge stubs data and concrete data. 

2 

3from __future__ import annotations 

4 

5from contextlib import suppress 

6from typing import TYPE_CHECKING 

7 

8from griffe._internal.exceptions import AliasResolutionError, CyclicAliasError 

9from griffe._internal.expressions import Expr 

10from griffe._internal.logger import logger 

11 

12if TYPE_CHECKING: 

13 from collections.abc import Sequence 

14 

15 from griffe._internal.models import Attribute, Class, Function, Module, Object, TypeAlias 

16 

17 

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) 

22 

23 

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) 

29 

30 

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) 

38 

39 

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 

45 

46 

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) 

50 

51 

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 

55 

56 

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 

60 

61 

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] 

68 

69 

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 

76 

77 

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) 

101 

102 

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) 

106 

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 

110 

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) 

145 

146 

147def merge_stubs(mod1: Module, mod2: Module) -> Module: 

148 """Merge stubs into a module. 

149 

150 Parameters: 

151 mod1: A regular module or stubs module. 

152 mod2: A regular module or stubs module. 

153 

154 Raises: 

155 ValueError: When both modules are regular modules (no stubs is passed). 

156 

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