Coverage for tests/test_internals.py: 93.33%

99 statements  

« prev     ^ index     » next       coverage.py v7.6.2, created at 2024-10-12 01:34 +0200

1"""Tests for our own API exposition.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6from fnmatch import fnmatch 

7from pathlib import Path 

8from typing import TYPE_CHECKING 

9 

10import pytest 

11from mkdocstrings.inventory import Inventory 

12 

13import griffe 

14 

15if TYPE_CHECKING: 

16 from collections.abc import Iterator 

17 

18 

19@pytest.fixture(name="loader", scope="module") 

20def _fixture_loader() -> griffe.GriffeLoader: 

21 loader = griffe.GriffeLoader() 

22 loader.load("griffe") 

23 loader.resolve_aliases() 

24 return loader 

25 

26 

27@pytest.fixture(name="internal_api", scope="module") 

28def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: 

29 return loader.modules_collection["_griffe"] 

30 

31 

32@pytest.fixture(name="public_api", scope="module") 

33def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: 

34 return loader.modules_collection["griffe"] 

35 

36 

37def _yield_public_objects( 

38 obj: griffe.Module | griffe.Class, 

39 *, 

40 modules: bool = False, 

41 modulelevel: bool = True, 

42 inherited: bool = False, 

43 special: bool = False, 

44) -> Iterator[griffe.Object | griffe.Alias]: 

45 for member in obj.all_members.values() if inherited else obj.members.values(): 

46 try: 

47 if member.is_module: 

48 if member.is_alias: 

49 continue 

50 if modules: 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true

51 yield member 

52 yield from _yield_public_objects( 

53 member, # type: ignore[arg-type] 

54 modules=modules, 

55 modulelevel=modulelevel, 

56 inherited=inherited, 

57 special=special, 

58 ) 

59 elif member.is_public and (special or not member.is_special): 

60 yield member 

61 if member.is_class and not modulelevel: 

62 yield from _yield_public_objects( 

63 member, # type: ignore[arg-type] 

64 modules=modules, 

65 modulelevel=False, 

66 inherited=inherited, 

67 special=special, 

68 ) 

69 except (griffe.AliasResolutionError, griffe.CyclicAliasError): 

70 continue 

71 

72 

73@pytest.fixture(name="modulelevel_internal_objects", scope="module") 

74def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 

75 return list(_yield_public_objects(internal_api, modulelevel=True)) 

76 

77 

78@pytest.fixture(name="internal_objects", scope="module") 

79def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 

80 return list(_yield_public_objects(internal_api, modulelevel=False, special=True)) 

81 

82 

83@pytest.fixture(name="public_objects", scope="module") 

84def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 

85 return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True)) 

86 

87 

88@pytest.fixture(name="inventory", scope="module") 

89def _fixture_inventory() -> Inventory: 

90 inventory_file = Path(__file__).parent.parent / "site" / "objects.inv" 

91 if not inventory_file.exists(): 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true

92 raise pytest.skip("The objects inventory is not available.") 

93 with inventory_file.open("rb") as file: 

94 return Inventory.parse_sphinx(file) 

95 

96 

97def test_alias_proxies(internal_api: griffe.Module) -> None: 

98 """The Alias class has all the necessary methods and properties.""" 

99 alias_members = set(internal_api["models.Alias"].all_members.keys()) 

100 for cls in ( 

101 internal_api["models.Module"], 

102 internal_api["models.Class"], 

103 internal_api["models.Function"], 

104 internal_api["models.Attribute"], 

105 ): 

106 for name in cls.all_members: 

107 if not name.startswith("_") or name.startswith("__"): 

108 assert name in alias_members 

109 

110 

111def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 

112 """All public objects in the internal API are exposed under `griffe`.""" 

113 not_exposed = [ 

114 obj.path 

115 for obj in modulelevel_internal_objects 

116 if obj.name not in griffe.__all__ or not hasattr(griffe, obj.name) 

117 ] 

118 assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed)) 

119 

120 

121def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 

122 """All internal objects have unique names.""" 

123 names_to_paths = defaultdict(list) 

124 for obj in modulelevel_internal_objects: 

125 names_to_paths[obj.name].append(obj.path) 

126 non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1] 

127 assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) 

128 

129 

130def test_single_locations(public_api: griffe.Module) -> None: 

131 """All objects have a single public location.""" 

132 

133 def _public_path(obj: griffe.Object | griffe.Alias) -> bool: 

134 return obj.is_public and (obj.parent is None or _public_path(obj.parent)) 

135 

136 multiple_locations = {} 

137 for obj_name in griffe.__all__: 

138 obj = public_api[obj_name] 

139 if obj.aliases and ( 139 ↛ 142line 139 didn't jump to line 142 because the condition on line 139 was never true

140 public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)] 

141 ): 

142 multiple_locations[obj.path] = public_aliases 

143 assert not multiple_locations, "Multiple public locations:\n" + "\n".join( 

144 f"{path}: {aliases}" for path, aliases in multiple_locations.items() 

145 ) 

146 

147 

148def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None: 

149 """All public objects are added to the inventory.""" 

150 ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"} 

151 ignore_paths = {"griffe.DataclassesExtension.*"} 

152 not_in_inventory = [ 

153 obj.path 

154 for obj in public_objects 

155 if ( 

156 obj.name not in ignore_names 

157 and not any(fnmatch(obj.path, pat) for pat in ignore_paths) 

158 and obj.path not in inventory 

159 ) 

160 ] 

161 msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}" 

162 assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory))) 

163 

164 

165def test_inventory_matches_api( 

166 inventory: Inventory, 

167 public_objects: list[griffe.Object | griffe.Alias], 

168 loader: griffe.GriffeLoader, 

169) -> None: 

170 """The inventory doesn't contain any additional Python object.""" 

171 not_in_api = [] 

172 public_api_paths = {obj.path for obj in public_objects} 

173 public_api_paths.add("griffe") 

174 for item in inventory.values(): 

175 if item.domain == "py" and "(" not in item.name: 

176 obj = loader.modules_collection[item.name] 

177 if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true

178 not_in_api.append(item.name) 

179 msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}" 

180 assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api))) 

181 

182 

183def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None: 

184 """No module docstrings should be written in our internal API. 

185 

186 The reasoning is that docstrings are addressed to users of the public API, 

187 but internal modules are not exposed to users, so they should not have docstrings. 

188 """ 

189 

190 def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: 

191 for member in obj.modules.values(): 

192 yield member 

193 yield from _modules(member) 

194 

195 for obj in _modules(internal_api): 

196 assert not obj.docstring