Coverage for src/griffe/_internal/merger.py: 86.61%

76 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-11 13:44 +0200

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.logger import logger 

10 

11if TYPE_CHECKING: 

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

13 

14 

15def _merge_module_stubs(module: Module, stubs: Module) -> None: 

16 _merge_stubs_docstring(module, stubs) 

17 _merge_stubs_overloads(module, stubs) 

18 _merge_stubs_members(module, stubs) 

19 

20 

21def _merge_class_stubs(class_: Class, stubs: Class) -> None: 

22 _merge_stubs_docstring(class_, stubs) 

23 _merge_stubs_overloads(class_, stubs) 

24 _merge_stubs_type_parameters(class_, stubs) 

25 _merge_stubs_members(class_, stubs) 

26 

27 

28def _merge_function_stubs(function: Function, stubs: Function) -> None: 

29 _merge_stubs_docstring(function, stubs) 

30 for parameter in stubs.parameters: 

31 with suppress(KeyError): 

32 function.parameters[parameter.name].annotation = parameter.annotation 

33 function.returns = stubs.returns 

34 _merge_stubs_type_parameters(function, stubs) 

35 

36 

37def _merge_attribute_stubs(attribute: Attribute, stubs: Attribute) -> None: 

38 _merge_stubs_docstring(attribute, stubs) 

39 attribute.annotation = stubs.annotation 

40 if stubs.value not in (None, "..."): 

41 attribute.value = stubs.value 

42 

43 

44def _merge_type_alias_stubs(type_alias: TypeAlias, stubs: TypeAlias) -> None: 

45 _merge_stubs_docstring(type_alias, stubs) 

46 _merge_stubs_type_parameters(type_alias, stubs) 

47 

48 

49def _merge_stubs_docstring(obj: Object, stubs: Object) -> None: 

50 if not obj.docstring and stubs.docstring: 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true

51 obj.docstring = stubs.docstring 

52 

53 

54def _merge_stubs_type_parameters(obj: Class | Function | TypeAlias, stubs: Class | Function | TypeAlias) -> None: 

55 if not obj.type_parameters and stubs.type_parameters: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true

56 obj.type_parameters = stubs.type_parameters 

57 

58 

59def _merge_stubs_overloads(obj: Module | Class, stubs: Module | Class) -> None: 

60 for function_name, overloads in list(stubs.overloads.items()): 

61 if overloads: 

62 with suppress(KeyError): 

63 obj.get_member(function_name).overloads = overloads 

64 del stubs.overloads[function_name] 

65 

66 

67def _merge_stubs_members(obj: Module | Class, stubs: Module | Class) -> None: 

68 # Merge imports to later know if objects coming from the stubs were imported. 

69 obj.imports.update(stubs.imports) 

70 

71 # Override exports to later know if objects coming from the stubs were exported. 

72 if stubs.exports is not None: 

73 obj.exports = stubs.exports 

74 

75 for member_name, stub_member in stubs.members.items(): 

76 if member_name in obj.members: 

77 # We don't merge imported stub objects that already exist in the concrete module. 

78 # Stub objects must be defined where they are exposed in the concrete package, 

79 # not be imported from other stub modules. 

80 if stub_member.is_alias: 

81 continue 

82 obj_member = obj.get_member(member_name) 

83 with suppress(AliasResolutionError, CyclicAliasError): 

84 # An object's canonical location can differ from its equivalent stub location. 

85 # Devs usually declare stubs at the public location of the corresponding object, 

86 # not the canonical one. Therefore, we must allow merging stubs into the target of an alias, 

87 # as long as the stub and target are of the same kind. 

88 if obj_member.kind is not stub_member.kind: 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true

89 logger.debug( 

90 "Cannot merge stubs for %s: kind %s != %s", 

91 obj_member.path, 

92 stub_member.kind.value, 

93 obj_member.kind.value, 

94 ) 

95 elif obj_member.is_module: 

96 _merge_module_stubs(obj_member, stub_member) # type: ignore[arg-type] 

97 elif obj_member.is_class: 

98 _merge_class_stubs(obj_member, stub_member) # type: ignore[arg-type] 

99 elif obj_member.is_function: 

100 _merge_function_stubs(obj_member, stub_member) # type: ignore[arg-type] 

101 elif obj_member.is_attribute: 101 ↛ 103line 101 didn't jump to line 103 because the condition on line 101 was always true

102 _merge_attribute_stubs(obj_member, stub_member) # type: ignore[arg-type] 

103 elif obj_member.is_type_alias: 

104 _merge_type_alias_stubs(obj_member, stub_member) # type: ignore[arg-type] 

105 else: 

106 stub_member.runtime = False 

107 obj.set_member(member_name, stub_member) 

108 

109 

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

111 """Merge stubs into a module. 

112 

113 Parameters: 

114 mod1: A regular module or stubs module. 

115 mod2: A regular module or stubs module. 

116 

117 Raises: 

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

119 

120 Returns: 

121 The regular module. 

122 """ 

123 logger.debug("Trying to merge %s and %s", mod1.filepath, mod2.filepath) 

124 if mod1.filepath.suffix == ".pyi": # type: ignore[union-attr] 

125 stubs = mod1 

126 module = mod2 

127 elif mod2.filepath.suffix == ".pyi": # type: ignore[union-attr] 127 ↛ 131line 127 didn't jump to line 131 because the condition on line 127 was always true

128 stubs = mod2 

129 module = mod1 

130 else: 

131 raise ValueError("cannot merge regular (non-stubs) modules together") 

132 _merge_module_stubs(module, stubs) 

133 return module