Coverage for tests/test_internals.py: 90.91%

105 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-15 16:47 +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 Iterator 

9 

10import pytest 

11from mkdocstrings.inventory import Inventory 

12 

13import griffe 

14 

15 

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

17def _fixture_loader() -> griffe.GriffeLoader: # noqa: PT005 

18 loader = griffe.GriffeLoader() 

19 loader.load("griffe") 

20 loader.resolve_aliases() 

21 return loader 

22 

23 

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

25def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: # noqa: PT005 

26 return loader.modules_collection["_griffe"] 

27 

28 

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

30def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: # noqa: PT005 

31 return loader.modules_collection["griffe"] 

32 

33 

34def _yield_public_objects( 

35 obj: griffe.Module | griffe.Class, 

36 *, 

37 modules: bool = False, 

38 modulelevel: bool = True, 

39 inherited: bool = False, 

40 special: bool = False, 

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

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

43 try: 

44 if member.is_module: 

45 if member.is_alias: 

46 continue 

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

48 yield member 

49 yield from _yield_public_objects( 

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

51 modules=modules, 

52 modulelevel=modulelevel, 

53 inherited=inherited, 

54 special=special, 

55 ) 

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

57 yield member 

58 if member.is_class and not modulelevel: 

59 yield from _yield_public_objects( 

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

61 modules=modules, 

62 modulelevel=False, 

63 inherited=inherited, 

64 special=special, 

65 ) 

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

67 continue 

68 

69 

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

71def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: # noqa: PT005 

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

73 

74 

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

76def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: # noqa: PT005 

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

78 

79 

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

81def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: # noqa: PT005 

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

83 

84 

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

86def _fixture_inventory() -> Inventory: # noqa: PT005 

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

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

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

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

91 return Inventory.parse_sphinx(file) 

92 

93 

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

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

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

97 for cls in ( 

98 internal_api["models.Module"], 

99 internal_api["models.Class"], 

100 internal_api["models.Function"], 

101 internal_api["models.Attribute"], 

102 ): 

103 for name in cls.all_members: 

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

105 assert name in alias_members 

106 

107 

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

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

110 not_exposed = [] 

111 for obj in modulelevel_internal_objects: 

112 if obj.name not in griffe.__all__ or not hasattr(griffe, obj.name): 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true

113 not_exposed.append(obj.path) 

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

115 

116 

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

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

119 names_to_paths = defaultdict(list) 

120 for obj in modulelevel_internal_objects: 

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

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

123 assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) 123 ↛ exitline 123 didn't run the generator expression on line 123

124 

125 

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

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

128 

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

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

131 

132 multiple_locations = {} 

133 for obj_name in griffe.__all__: 

134 obj = public_api[obj_name] 

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

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

137 ): 

138 multiple_locations[obj.path] = public_aliases 

139 assert not multiple_locations, "Multiple public locations:\n" + "\n".join( 139 ↛ exitline 139 didn't run the generator expression on line 139

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

141 ) 

142 

143 

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

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

146 not_in_inventory = [] 

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

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

149 for obj in public_objects: 

150 if ( 150 ↛ 155line 150 didn't jump to line 155

151 obj.name not in ignore_names 

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

153 and obj.path not in inventory 

154 ): 

155 not_in_inventory.append(obj.path) 

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

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

158 

159 

160def test_inventory_matches_api( 

161 inventory: Inventory, 

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

163 loader: griffe.GriffeLoader, 

164) -> None: 

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

166 not_in_api = [] 

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

168 public_api_paths.add("griffe") 

169 for item in inventory.values(): 

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

171 obj = loader.modules_collection[item.name] 

172 if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): 172 ↛ exit,   172 ↛ 1732 missed branches: 1) line 172 didn't finish the generator expression on line 172, 2) line 172 didn't jump to line 173 because the condition on line 172 was never true

173 not_in_api.append(item.name) 

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

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

176 

177 

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

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

180 

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

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

183 """ 

184 

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

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

187 yield member 

188 yield from _modules(member) 

189 

190 for obj in _modules(internal_api): 

191 assert not obj.docstring