Coverage for tests/test_api.py: 76.09%

100 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-11 13:44 +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 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._internal"] 

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 member.is_public: 50 ↛ 62line 50 didn't jump to line 62 because the condition on line 50 was always true

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

52 yield member 

53 yield from _yield_public_objects( 

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

55 modules=modules, 

56 modulelevel=modulelevel, 

57 inherited=inherited, 

58 special=special, 

59 ) 

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

61 yield member 

62 if member.is_class and not modulelevel: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 yield from _yield_public_objects( 

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

65 modules=modules, 

66 modulelevel=False, 

67 inherited=inherited, 

68 special=special, 

69 ) 

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

71 continue 

72 

73 

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

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

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

77 

78 

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

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

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

82 

83 

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

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

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

87 

88 

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

90def _fixture_inventory() -> Inventory: 

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

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

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

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

95 return Inventory.parse_sphinx(file) 

96 

97 

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

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

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

101 for cls in ( 

102 internal_api["models.Module"], 

103 internal_api["models.Class"], 

104 internal_api["models.Function"], 

105 internal_api["models.Attribute"], 

106 ): 

107 for name in cls.all_members: 

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

109 assert name in alias_members 

110 

111 

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

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

114 not_exposed = [ 

115 obj.path 

116 for obj in modulelevel_internal_objects 

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

118 ] 

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

120 

121 

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

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

124 names_to_paths = defaultdict(list) 

125 for obj in modulelevel_internal_objects: 

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

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

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

129 

130 

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

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

133 

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

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

136 

137 multiple_locations = {} 

138 for obj_name in griffe.__all__: 

139 obj = public_api[obj_name] 

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

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

142 ): 

143 multiple_locations[obj.path] = public_aliases 

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

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

146 ) 

147 

148 

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

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

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

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

153 not_in_inventory = [ 

154 obj.path 

155 for obj in public_objects 

156 if ( 

157 obj.name not in ignore_names 

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

159 and obj.path not in inventory 

160 ) 

161 ] 

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

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

164 

165 

166def test_inventory_matches_api( 

167 inventory: Inventory, 

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

169 loader: griffe.GriffeLoader, 

170) -> None: 

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

172 not_in_api = [] 

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

174 public_api_paths.add("griffe") 

175 for item in inventory.values(): 

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

177 obj = loader.modules_collection[item.name] 

178 if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): 

179 not_in_api.append(item.name) 

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

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

182 

183 

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

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

186 

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

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

189 """ 

190 

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

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

193 yield member 

194 yield from _modules(member) 

195 

196 for obj in _modules(internal_api): 

197 assert not obj.docstring