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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 23:22 +0200
1"""Tests for the `handler` module."""
3from __future__ import annotations
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
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
26from mkdocstrings_handlers.python import Inventory, PythonConfig, PythonHandler, PythonOptions
28if TYPE_CHECKING:
29 from mkdocstrings import MkdocstringsPlugin
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())
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())
44def test_collect_module(handler: PythonHandler) -> None:
45 """Assert existing module can be collected."""
46 assert handler.collect("mkdocstrings", PythonOptions())
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))
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.
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
83def test_expand_globs(tmp_path: Path, plugin: MkdocstringsPlugin) -> None:
84 """Assert globs are correctly expanded.
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
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
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
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.'''
162 class Class:
163 '''Class docstring.'''
165 def function(self):
166 '''Function docstring.'''
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))
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
191@pytest.mark.parametrize(
192 ("section", "code"),
193 [
194 (
195 "Attributes",
196 """
197 class A:
198 '''Summary.
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.
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.
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.
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.
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
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 )
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])
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())
325 # Get inventory URL and config
326 url, config = handler.get_inventory_urls()[0]
328 # Load the mocked inventory
329 _, item_url = next(handler.load_inventory(mocked_bytes, url, **config))
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