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
« prev ^ index » next coverage.py v7.10.0, created at 2025-09-10 18:56 +1000
1"""Tests for our own API exposition."""
3from __future__ import annotations
5from collections import defaultdict
6from pathlib import Path
7from typing import TYPE_CHECKING
9import griffe
10import pytest
11from mkdocstrings import Inventory
13import geosdhydro
15if TYPE_CHECKING:
16 from collections.abc import Iterator
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
27@pytest.fixture(name="internal_api", scope="module")
28def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module:
29 return loader.modules_collection["geosdhydro._internal"]
32@pytest.fixture(name="public_api", scope="module")
33def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module:
34 return loader.modules_collection["geosdhydro"]
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
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))
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))
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))
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)
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))
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)
118def test_single_locations(public_api: griffe.Module) -> None:
119 """All objects have a single public location."""
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))
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 )
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)))
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)))
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.
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# """
175# def _modules(obj: griffe.Module) -> Iterator[griffe.Module]:
176# for member in obj.modules.values():
177# yield member
178# yield from _modules(member)
180# for obj in _modules(internal_api):
181# assert not obj.docstring