Coverage for tests/test_api.py: 90.18%

86 statements  

« prev     ^ index     » next       coverage.py v7.10.0, created at 2025-09-10 18:56 +1000

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

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6from pathlib import Path 

7from typing import TYPE_CHECKING 

8 

9import griffe 

10import pytest 

11from mkdocstrings import Inventory 

12 

13import geosdhydro 

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("geosdhydro") 

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["geosdhydro._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["geosdhydro"] 

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 or not member.is_public: 

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 else: 

62 continue 

63 if member.is_class and not modulelevel: 

64 yield from _yield_public_objects( 

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

66 modules=modules, 

67 modulelevel=False, 

68 inherited=inherited, 

69 special=special, 

70 ) 

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

72 continue 

73 

74 

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

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

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

78 

79 

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

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

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

83 

84 

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

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

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

88 

89 

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

91def _fixture_inventory() -> Inventory: 

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

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

94 pytest.skip("The objects inventory is not available.") # ty: ignore[call-non-callable] 

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

96 return Inventory.parse_sphinx(file) 

97 

98 

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

100 """All public objects in the internal API are exposed under `geosdhydro`.""" 

101 not_exposed = [ 

102 obj.path 

103 for obj in modulelevel_internal_objects 

104 if obj.name not in geosdhydro.__all__ or not hasattr(geosdhydro, obj.name) 

105 ] 

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

107 

108 

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

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

111 names_to_paths = defaultdict(list) 

112 for obj in modulelevel_internal_objects: 

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

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

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

116 

117 

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

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

120 

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

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

123 

124 multiple_locations = {} 

125 for obj_name in geosdhydro.__all__: 

126 obj = public_api[obj_name] 

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

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

129 ): 

130 multiple_locations[obj.path] = public_aliases 

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

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

133 ) 

134 

135# @pytest.mark.skip(reason="I have no idea what is going on, perhaps no doc module indeed in api doc generated, but how do I fix this????") 

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

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

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

139 not_in_inventory = [ 

140 obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory 

141 ] 

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

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

144 

145 

146def test_inventory_matches_api( 

147 inventory: Inventory, 

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

149 loader: griffe.GriffeLoader, 

150) -> None: 

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

152 not_in_api = [] 

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

154 public_api_paths.add("geosdhydro") 

155 for item in inventory.values(): 

156 if ( 156 ↛ 155line 156 didn't jump to line 155 because the condition on line 156 was always true

157 item.domain == "py" 

158 and "(" not in item.name 

159 and (item.name == "geosdhydro" or item.name.startswith("geosdhydro.")) 

160 ): 

161 obj = loader.modules_collection[item.name] 

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

163 not_in_api.append(item.name) 

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

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

166 

167 

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

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

170 

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

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

173# """ 

174 

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

176# for member in obj.modules.values(): 

177# yield member 

178# yield from _modules(member) 

179 

180# for obj in _modules(internal_api): 

181# assert not obj.docstring