Coverage for tests/test_handler.py: 98.18%

96 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 23:22 +0200

1"""Tests for the `handler` module.""" 

2 

3from __future__ import annotations 

4 

5import os 

6import sys 

7from dataclasses import replace 

8from glob import glob 

9from io import BytesIO 

10from pathlib import Path 

11from textwrap import dedent 

12from typing import TYPE_CHECKING 

13 

14import mkdocstrings 

15import pytest 

16from griffe import ( 

17 Docstring, 

18 DocstringSectionExamples, 

19 DocstringSectionKind, 

20 Module, 

21 temporary_inspected_module, 

22 temporary_visited_module, 

23) 

24from mkdocstrings import CollectionError 

25 

26from mkdocstrings_handlers.python import Inventory, PythonConfig, PythonHandler, PythonOptions 

27 

28if TYPE_CHECKING: 

29 from mkdocstrings import MkdocstringsPlugin 

30 

31 

32def test_collect_missing_module(handler: PythonHandler) -> None: 

33 """Assert error is raised for missing modules.""" 

34 with pytest.raises(CollectionError): 

35 handler.collect("aaaaaaaa", PythonOptions()) 

36 

37 

38def test_collect_missing_module_item(handler: PythonHandler) -> None: 

39 """Assert error is raised for missing items within existing modules.""" 

40 with pytest.raises(CollectionError): 

41 handler.collect("mkdocstrings.aaaaaaaa", PythonOptions()) 

42 

43 

44def test_collect_module(handler: PythonHandler) -> None: 

45 """Assert existing module can be collected.""" 

46 assert handler.collect("mkdocstrings", PythonOptions()) 

47 

48 

49def test_collect_with_null_parser(handler: PythonHandler) -> None: 

50 """Assert we can pass `None` as parser when collecting.""" 

51 assert handler.collect("mkdocstrings", PythonOptions(docstring_style=None)) 

52 

53 

54@pytest.mark.parametrize( 

55 "handler", 

56 [ 

57 {"theme": "mkdocs"}, 

58 {"theme": "readthedocs"}, 

59 {"theme": {"name": "material"}}, 

60 ], 

61 indirect=["handler"], 

62) 

63def test_render_docstring_examples_section(handler: PythonHandler) -> None: 

64 """Assert docstrings' examples section can be rendered. 

65 

66 Parameters: 

67 handler: A handler instance (parametrized). 

68 """ 

69 section = DocstringSectionExamples( 

70 value=[ 

71 (DocstringSectionKind.text, "This is an example."), 

72 (DocstringSectionKind.examples, ">>> print('Hello')\nHello"), 

73 ], 

74 ) 

75 template = handler.env.get_template("docstring/examples.html.jinja") 

76 rendered = template.render(section=section, locale="en") 

77 template.render(section=section, locale="not_existing") 

78 assert "<p>This is an example.</p>" in rendered 

79 assert "print" in rendered 

80 assert "Hello" in rendered 

81 

82 

83def test_expand_globs(tmp_path: Path, plugin: MkdocstringsPlugin) -> None: 

84 """Assert globs are correctly expanded. 

85 

86 Parameters: 

87 tmp_path: Pytext fixture that creates a temporary directory. 

88 """ 

89 globbed_names = ( 

90 "expanded_a", 

91 "expanded_b", 

92 "other_expanded_c", 

93 "other_expanded_d", 

94 ) 

95 globbed_paths = [tmp_path.joinpath(globbed_name) for globbed_name in globbed_names] 

96 for path in globbed_paths: 

97 path.touch() 

98 plugin.handlers._tool_config.config_file_path = str(tmp_path.joinpath("mkdocs.yml")) 

99 handler: PythonHandler = plugin.handlers.get_handler("python", {"paths": ["*exp*"]}) # type: ignore[assignment] 

100 for path in globbed_paths: 

101 assert str(path) in handler._paths 

102 

103 

104def test_expand_globs_without_changing_directory(plugin: MkdocstringsPlugin) -> None: 

105 """Assert globs are correctly expanded when we are already in the right directory.""" 

106 plugin.handlers._tool_config.config_file_path = "mkdocs.yml" 

107 handler: PythonHandler = plugin.handlers.get_handler("python", {"paths": ["*.md"]}) # type: ignore[assignment] 

108 for path in list(glob(os.path.abspath(".") + "/*.md")): 

109 assert path in handler._paths 

110 

111 

112@pytest.mark.parametrize( 

113 ("expect_change", "extension"), 

114 [ 

115 (True, "extension.py"), 

116 (True, "extension.py:SomeExtension"), 

117 (True, "path/to/extension.py"), 

118 (True, "path/to/extension.py:SomeExtension"), 

119 (True, {"extension.py": {"option": "value"}}), 

120 (True, {"extension.py:SomeExtension": {"option": "value"}}), 

121 (True, {"path/to/extension.py": {"option": "value"}}), 

122 (True, {"path/to/extension.py:SomeExtension": {"option": "value"}}), 

123 # True because OS path normalization. 

124 (True, "/absolute/path/to/extension.py"), 

125 (True, "/absolute/path/to/extension.py:SomeExtension"), 

126 (True, {"/absolute/path/to/extension.py": {"option": "value"}}), 

127 (True, {"/absolute/path/to/extension.py:SomeExtension": {"option": "value"}}), 

128 (False, "dot.notation.path.to.extension"), 

129 (False, "dot.notation.path.to.pyextension"), 

130 (False, {"dot.notation.path.to.extension": {"option": "value"}}), 

131 (False, {"dot.notation.path.to.pyextension": {"option": "value"}}), 

132 ], 

133) 

134def test_extension_paths( 

135 tmp_path: Path, 

136 expect_change: bool, 

137 extension: str | dict, 

138 plugin: MkdocstringsPlugin, 

139) -> None: 

140 """Assert extension paths are resolved relative to config file.""" 

141 plugin.handlers._tool_config.config_file_path = str(tmp_path.joinpath("mkdocs.yml")) 

142 handler: PythonHandler = plugin.handlers.get_handler("python") # type: ignore[assignment] 

143 normalized = handler.normalize_extension_paths([extension])[0] 

144 if expect_change: 

145 if isinstance(normalized, str) and isinstance(extension, str): 

146 assert normalized == str(tmp_path.joinpath(extension)) 

147 elif isinstance(normalized, dict) and isinstance(extension, dict): 147 ↛ 151line 147 didn't jump to line 151 because the condition on line 147 was always true

148 pth, options = next(iter(extension.items())) 

149 assert normalized == {str(tmp_path.joinpath(pth)): options} 

150 else: 

151 raise ValueError("Normalization must not change extension items type") 

152 else: 

153 assert normalized == extension 

154 

155 

156def test_rendering_object_source_without_lineno(handler: PythonHandler) -> None: 

157 """Test rendering objects without a line number.""" 

158 code = dedent( 

159 """ 

160 '''Module docstring.''' 

161 

162 class Class: 

163 '''Class docstring.''' 

164 

165 def function(self): 

166 '''Function docstring.''' 

167 

168 attribute = 0 

169 '''Attribute docstring.''' 

170 """, 

171 ) 

172 with temporary_visited_module(code) as module: 

173 module["Class"].lineno = None 

174 module["Class.function"].lineno = None 

175 module["attribute"].lineno = None 

176 assert handler.render(module, PythonOptions(show_source=True)) 

177 

178 

179def test_give_precedence_to_user_paths() -> None: 

180 """Assert user paths take precedence over default paths.""" 

181 last_sys_path = sys.path[-1] 

182 handler = PythonHandler( 

183 base_dir=Path("."), 

184 config=PythonConfig.from_data(paths=[last_sys_path]), 

185 mdx=[], 

186 mdx_config={}, 

187 ) 

188 assert handler._paths[0] == last_sys_path 

189 

190 

191@pytest.mark.parametrize( 

192 ("section", "code"), 

193 [ 

194 ( 

195 "Attributes", 

196 """ 

197 class A: 

198 '''Summary. 

199 

200 Attributes: 

201 x: X. 

202 y: Y. 

203 ''' 

204 x: int = 0 

205 '''X.''' 

206 y: int = 0 

207 '''Y.''' 

208 """, 

209 ), 

210 ( 

211 "Methods", 

212 """ 

213 class A: 

214 '''Summary. 

215 

216 Methods: 

217 x: X. 

218 y: Y. 

219 ''' 

220 def x(self): ... 

221 '''X.''' 

222 def y(self): ... 

223 '''Y.''' 

224 """, 

225 ), 

226 ( 

227 "Functions", 

228 """ 

229 '''Summary. 

230 

231 Functions: 

232 x: X. 

233 y: Y. 

234 ''' 

235 def x(): ... 

236 '''X.''' 

237 def y(): ... 

238 '''Y.''' 

239 """, 

240 ), 

241 ( 

242 "Classes", 

243 """ 

244 '''Summary. 

245 

246 Classes: 

247 A: A. 

248 B: B. 

249 ''' 

250 class A: ... 

251 '''A.''' 

252 class B: ... 

253 '''B.''' 

254 """, 

255 ), 

256 ( 

257 "Modules", 

258 """ 

259 '''Summary. 

260 

261 Modules: 

262 a: A. 

263 b: B. 

264 ''' 

265 """, 

266 ), 

267 ], 

268) 

269def test_deduplicate_summary_sections(handler: PythonHandler, section: str, code: str) -> None: 

270 """Assert summary sections are deduplicated.""" 

271 summary_section = section.lower() 

272 summary_section = "functions" if summary_section == "methods" else summary_section 

273 with temporary_visited_module(code, docstring_parser="google") as module: 

274 if summary_section == "modules": 

275 module.set_member("a", Module("A", docstring=Docstring("A."))) 

276 module.set_member("b", Module("B", docstring=Docstring("B."))) 

277 html = handler.render( 

278 module, 

279 handler.get_options( 

280 { 

281 "summary": {summary_section: True}, 

282 "show_source": False, 

283 "show_submodules": True, 

284 }, 

285 ), 

286 ) 

287 assert html.count(f"{section}:") == 1 

288 

289 

290def test_inheriting_self_from_parent_class(handler: PythonHandler) -> None: 

291 """Inspect self only once when inheriting it from parent class.""" 

292 with temporary_inspected_module( 

293 """ 

294 class A: ... 

295 class B(A): ... 

296 A.B = B 

297 """, 

298 ) as module: 

299 # Assert no recusrion error. 

300 handler.render( 

301 module, 

302 handler.get_options({"inherited_members": True}), 

303 ) 

304 

305 

306def test_specifying_inventory_base_url(handler: PythonHandler) -> None: 

307 """Assert that the handler renders inventory URLs using the specified base_url.""" 

308 # Update handler config to include an inventory with a base URL 

309 base_url = "https://docs.com/my_library" 

310 inventory = Inventory(url="https://example.com/objects.inv", base_url=base_url) 

311 handler.config = replace(handler.config, inventories=[inventory]) 

312 

313 # Mock inventory bytes 

314 item_name = "my_library.my_module.MyClass" 

315 mocked_inventory = mkdocstrings.Inventory() 

316 mocked_inventory.register( 

317 name=item_name, 

318 domain="py", 

319 role="class", 

320 uri=f"api-reference/#{item_name}", 

321 dispname=item_name, 

322 ) 

323 mocked_bytes = BytesIO(mocked_inventory.format_sphinx()) 

324 

325 # Get inventory URL and config 

326 url, config = handler.get_inventory_urls()[0] 

327 

328 # Load the mocked inventory 

329 _, item_url = next(handler.load_inventory(mocked_bytes, url, **config)) 

330 

331 # Assert the URL is based on the provided base URL 

332 msg = "Expected inventory URL to start with base_url" 

333 assert item_url.startswith(base_url), msg