diff --git a/AUTHORS.rst b/AUTHORS.rst index a8ee2e9130c..f5a692a5302 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -53,6 +53,7 @@ Contributors * Eric Larson -- better error messages * Eric N. Vander Weele -- autodoc improvements * Eric Wieser -- autodoc improvements +* Erik Bedard -- config options for :mod:`sphinx.ext.duration` * Etienne Desautels -- apidoc module * Ezio Melotti -- collapsible sidebar JavaScript * Filip Vavera -- napoleon todo directive diff --git a/CHANGES.rst b/CHANGES.rst index 5244ed4f077..35b73a2b0c1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -84,6 +84,8 @@ Features added Patch by Fazeel Usmani and James Addison. * #14075: autosummary: Provide more context in import exception stack traces. Patch by Philipp A. +* #13468: Add config options to :mod:`sphinx.ext.duration`. + Patch by Erik Bedard and Adam Turner. Bugs fixed ---------- diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 78d739a005b..8608a534225 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1420,6 +1420,7 @@ Options for warning control * ``autosectionlabel.`` * ``autosummary`` * ``autosummary.import_cycle`` + * ``duration`` * ``intersphinx.external`` You can choose from these types. You can also give only the first @@ -1485,6 +1486,9 @@ Options for warning control ``ref.any``, ``toc.duplicate_entry``, ``toc.empty_glob``, and ``toc.not_included``. + .. versionadded:: 9.0 + ``duration``. + Builder options =============== diff --git a/doc/usage/extensions/duration.rst b/doc/usage/extensions/duration.rst index 1213811daf0..b2c5f15dd67 100644 --- a/doc/usage/extensions/duration.rst +++ b/doc/usage/extensions/duration.rst @@ -6,6 +6,87 @@ .. versionadded:: 2.4 -This extension measures durations of Sphinx processing and show its -result at end of the build. It is useful for inspecting what document -is slowly built. +This extension measures durations of Sphinx processing when reading +documents and is useful for inspecting what document is slowly built. +Durations are printed to console at the end of the build and saved +to a JSON file in the output directory by default. + +Enable this extension by adding ``'sphinx.ext.duration'`` to +the :confval:`extensions` list in your :file:`conf.py`: + +.. code-block:: python + + extensions = [ + ... + 'sphinx.ext.duration', + ] + + +Configuration +============= + +.. confval:: duration_print_total + :type: :code-py:`bool` + :default: :code-py:`True` + + Show the total reading duration in the build summary, e.g.: + + .. code-block:: text + + ====================== total reading duration ========================== + Total time reading 31 files: 0m 3.142s + + .. versionadded:: 9.0 + +.. confval:: duration_print_slowest + :type: :code-py:`bool` + :default: :code-py:`True` + + Show the slowest durations in the build summary. + The durations are sorted in order from slowest to fastest. + This prints up to :confval:`duration_n_slowest` durations, e.g.: + + .. code-block:: text + + ====================== slowest 5 reading durations ======================= + 0.012s spam + 0.011s ham + 0.011s eggs + 0.006s lobster + 0.005s beans + + .. versionadded:: 9.0 + +.. confval:: duration_n_slowest + :type: :code-py:`int` + :default: :code-py:`5` + + Maximum number of slowest durations to show in the build summary + when :confval:`duration_print_slowest` is enabled. + By default, only the ``5`` slowest durations are shown. + Set this to ``0`` to show all durations. + + .. versionadded:: 9.0 + +.. confval:: duration_write_json + :type: :code-py:`str | None` + :default: :code-py:`'sphinx-reading-durations.json'` + + Write all reading durations to a JSON file in the output directory + The file contents are a map of the document names to reading durations, + where document names are strings and durations are floats in seconds. + Set this value to an empty string or ``None`` to disable writing the file, + or set it to a relative path to customize it. + + This may be useful for testing and setting a limit on reading times. + + .. versionadded:: 9.0 + +.. confval:: duration_limit + :type: :code-py:`float | int | None` + :default: :code-py:`None` + + Set a duration limit (in seconds) for reading a document. + If any duration exceeds this value, a warning is emitted. + + .. versionadded:: 9.0 diff --git a/sphinx/ext/duration.py b/sphinx/ext/duration.py index 3f7f64c2875..6207869c218 100644 --- a/sphinx/ext/duration.py +++ b/sphinx/ext/duration.py @@ -2,9 +2,11 @@ from __future__ import annotations +import json import time from itertools import islice from operator import itemgetter +from types import NoneType from typing import TYPE_CHECKING import sphinx @@ -13,7 +15,8 @@ from sphinx.util import logging if TYPE_CHECKING: - from collections.abc import Set + from collections.abc import Collection, Set + from pathlib import Path from typing import TypedDict from docutils import nodes @@ -39,6 +42,15 @@ def reading_durations(self) -> dict[str, float]: def note_reading_duration(self, duration: float) -> None: self.reading_durations[self.env.current_document.docname] = duration + def warn_reading_duration(self, duration: float, duration_limit: float) -> None: + logger.warning( + __('Reading duration %.3fs exceeded the duration limit %.3fs'), + duration, + duration_limit, + type='duration', + location=self.env.docname, + ) + def clear(self) -> None: self.reading_durations.clear() @@ -75,22 +87,65 @@ def on_doctree_read(app: Sphinx, doctree: nodes.document) -> None: domain = app.env.domains['duration'] domain.note_reading_duration(duration) + duration_limit: float | None = app.config.duration_limit + if duration_limit is not None and duration > duration_limit: + domain.warn_reading_duration(duration, duration_limit) + def on_build_finished(app: Sphinx, error: Exception) -> None: """Display duration ranking on the current build.""" domain = app.env.domains['duration'] if not domain.reading_durations: return - durations = sorted( - domain.reading_durations.items(), key=itemgetter(1), reverse=True + + # Get default options and update with user-specified values + if app.config.duration_print_total: + _print_total_duration(domain.reading_durations.values()) + + if app.config.duration_print_slowest: + _print_slowest_durations( + domain.reading_durations, app.config.duration_n_slowest + ) + + if write_json := app.config.duration_write_json: + _write_json_durations(domain.reading_durations, app.outdir / write_json) + + +def _print_total_duration(durations: Collection[float]) -> None: + logger.info('') + logger.info( + __('====================== total reading duration ==========================') + ) + + n_files = len(durations) + s = 's' if n_files != 1 else '' + minutes, seconds = divmod(sum(durations), 60) + logger.info( + __('Total time reading %d file%s: %dm %.3fs'), n_files, s, minutes, seconds ) + +def _print_slowest_durations(durations: dict[str, float], n_slowest: int) -> None: + sorted_durations = sorted(durations.items(), key=itemgetter(1), reverse=True) + n_slowest = n_slowest or len(sorted_durations) + n_slowest = min(n_slowest, len(sorted_durations)) + + logger.info('') logger.info('') logger.info( __('====================== slowest reading durations =======================') ) - for docname, d in islice(durations, 5): - logger.info(f'{d:.3f} {docname}') # NoQA: G004 + for docname, duration in islice(sorted_durations, n_slowest): + logger.info(__('%.3fs %s'), duration, docname) + + logger.info('') + + +def _write_json_durations(durations: dict[str, float], out_file: Path) -> None: + durations = {k: round(v, 3) for k, v in durations.items()} + out_file.parent.mkdir(parents=True, exist_ok=True) + durations_json = json.dumps(durations, ensure_ascii=False, indent=4, sort_keys=True) + out_file.write_text(durations_json, encoding='utf-8') def setup(app: Sphinx) -> dict[str, bool | str]: @@ -100,6 +155,19 @@ def setup(app: Sphinx) -> dict[str, bool | str]: app.connect('doctree-read', on_doctree_read) app.connect('build-finished', on_build_finished) + app.add_config_value('duration_print_total', True, '', types=frozenset({bool})) + app.add_config_value('duration_print_slowest', True, '', types=frozenset({bool})) + app.add_config_value('duration_n_slowest', 5, '', types=frozenset({int})) + app.add_config_value( + 'duration_write_json', + 'sphinx-reading-durations.json', + '', + types=frozenset({str, NoneType}), + ) + app.add_config_value( + 'duration_limit', None, '', types=frozenset({float, int, NoneType}) + ) + return { 'version': sphinx.__display_version__, 'parallel_read_safe': True, diff --git a/tests/test_extensions/test_ext_duration.py b/tests/test_extensions/test_ext_duration.py index 2e48e8fe5e5..b1cacd07701 100644 --- a/tests/test_extensions/test_ext_duration.py +++ b/tests/test_extensions/test_ext_duration.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import re +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -15,9 +17,146 @@ 'dummy', testroot='basic', confoverrides={'extensions': ['sphinx.ext.duration']}, + freshenv=True, ) -def test_githubpages(app: SphinxTestApp) -> None: +def test_duration(app: SphinxTestApp) -> None: app.build() assert 'slowest reading durations' in app.status.getvalue() - assert re.search('\\d+\\.\\d{3} index\n', app.status.getvalue()) + assert re.search('\\d+\\.\\d{3}s index\n', app.status.getvalue()) + + assert 'total reading duration' in app.status.getvalue() + assert 'Total time reading 1 file: ' in app.status.getvalue() + assert re.search( + r'Total time reading 1 file: \d+m \d+\.\d{3}s', app.status.getvalue() + ) + + fname = app.outdir / 'sphinx-reading-durations.json' + assert fname.is_file() + + data = json.loads(fname.read_bytes()) + keys = list(data.keys()) + values = list(data.values()) + assert keys == ['index'] + assert len(values) == 1 + assert isinstance(values[0], float) + + +@pytest.mark.sphinx( + 'dummy', + testroot='root', + confoverrides={'extensions': ['sphinx.ext.duration'], 'duration_n_slowest': 2}, + freshenv=True, +) +def test_n_slowest_value(app: SphinxTestApp) -> None: + app.build() + + matches = re.findall(r'\d+\.\d{3}s\s+[A-Za-z0-9]+\n', app.status.getvalue()) + assert len(matches) == 2 + + +@pytest.mark.sphinx( + 'dummy', + testroot='basic', + confoverrides={'extensions': ['sphinx.ext.duration'], 'duration_n_slowest': 0}, + freshenv=True, +) +def test_n_slowest_all(app: SphinxTestApp) -> None: + app.build() + + assert 'slowest reading durations' in app.status.getvalue() + matches = re.findall(r'\d+\.\d{3}s\s+[A-Za-z0-9]+\n', app.status.getvalue()) + assert len(matches) > 0 + + +@pytest.mark.sphinx( + 'dummy', + testroot='basic', + confoverrides={ + 'extensions': ['sphinx.ext.duration'], + 'duration_print_slowest': False, + }, + freshenv=True, +) +def test_print_slowest_false(app: SphinxTestApp) -> None: + app.build() + + assert 'slowest reading durations' not in app.status.getvalue() + + +@pytest.mark.sphinx( + 'dummy', + testroot='basic', + confoverrides={ + 'extensions': ['sphinx.ext.duration'], + 'duration_print_total': False, + }, + freshenv=True, +) +def test_print_total_false(app: SphinxTestApp) -> None: + app.build() + + assert 'total reading duration' not in app.status.getvalue() + + +@pytest.mark.parametrize('write_json', [True, False]) +@pytest.mark.sphinx( + 'dummy', + testroot='basic', + confoverrides={'extensions': ['sphinx.ext.duration']}, + freshenv=True, +) +def test_write_json(app: SphinxTestApp, write_json: bool) -> None: + if not write_json: + app.config.duration_write_json = None + app.build() + + expected = Path(app.outdir) / 'sphinx-reading-durations.json' + assert expected.is_file() == write_json + expected.unlink(missing_ok=not write_json) + + +@pytest.mark.sphinx( + 'dummy', + testroot='basic', + confoverrides={'extensions': ['sphinx.ext.duration']}, + freshenv=True, +) +def test_write_json_path(app: SphinxTestApp) -> None: + parent_name = 'durations' + file_name = 'durations.json' + app.config.duration_write_json = str(Path(parent_name, file_name)) + app.build() + + expected_path = app.outdir / parent_name / file_name + assert expected_path.parent.is_dir() + assert expected_path.is_file() + assert expected_path.parent.name == parent_name + assert expected_path.name == file_name + + +@pytest.mark.parametrize( + ('duration_limit', 'expect_warning'), [(0.0, True), (1.0, False)] +) +@pytest.mark.sphinx( + 'dummy', + testroot='basic', + confoverrides={'extensions': ['sphinx.ext.duration']}, + freshenv=True, +) +def test_duration_limit( + app: SphinxTestApp, duration_limit: float, expect_warning: bool +) -> None: + app.config.duration_limit = duration_limit + app.build() + + match = re.search( + r'index\.rst: WARNING: ' + r'Reading duration \d+\.\d{3}s exceeded the duration limit 0\.000s ' + r'\[duration\]', + app.warning.getvalue(), + ) + if expect_warning: + assert match is not None + else: + assert match is None