Sphinx

# Source code for sphinx.builders.changes

    """Changelog builder."""

    from __future__ import annotations

    import html
    from typing import TYPE_CHECKING

    from sphinx import package_dir
    from sphinx._cli.util.colour import bold
    from sphinx.builders import Builder
    from sphinx.locale import _, __
    from sphinx.theming import HTMLThemeFactory
    from sphinx.util import logging
    from sphinx.util.fileutil import copy_asset_file

    if TYPE_CHECKING:
        from collections.abc import Set

        from sphinx.application import Sphinx
        from sphinx.util.typing import ExtensionMetadata

    logger = logging.getLogger(__name__)

    class ChangesBuilder(Builder):
        """Write a summary with all version-related directives."""

        name = 'changes'
        epilog = __('The overview file is in %(outdir)s.')

        def init(self) -> None:
            self.create_template_bridge()
            theme_factory = HTMLThemeFactory(
                confdir=self.confdir,
                app=self._app,
                config=self.config,
                registry=self._registry,
            )
            self.theme = theme_factory.create('default')
            self.templates.init(self, self.theme)

        def get_outdated_docs(self) -> str:
            return str(self.outdir)

        typemap = {
            'version-added': 'added',
            'versionadded': 'added',
            'version-changed': 'changed',
            'versionchanged': 'changed',
            'version-deprecated': 'deprecated',
            'deprecated': 'deprecated',
            'version-removed': 'removed',
            'versionremoved': 'removed',
        }

        def write_documents(self, _docnames: Set[str]) -> None:
            version = self.config.version
            domain = self.env.domains.changeset_domain
            libchanges: dict[str, list[tuple[str, str, int]]] = {}
            apichanges: list[tuple[str, str, int]] = []
            otherchanges: dict[tuple[str, str], list[tuple[str, str, int]]] = {}

            changesets = domain.get_changesets_for(version)
            if not changesets:
                logger.info(bold(__('no changes in version %s.')), version)
                return
            logger.info(bold(__('writing summary file...')))
            for changeset in changesets:
                descname = changeset.descname
                ttext = self.typemap[changeset.type]
                context = changeset.content.replace('\n', ' ')
                if descname and changeset.docname.startswith('c-api'):
                    if context:
                        entry = f'<b>{descname}</b>: <i>{ttext}:</i> {context}'
                    else:
                        entry = f'<b>{descname}</b>: <i>{ttext}</i>.'
                    apichanges.append((entry, changeset.docname, changeset.lineno))
                elif descname or changeset.module:
                    module = changeset.module or _('Builtins')
                    if not descname:
                        descname = _('Module level')
                    if context:
                        entry = f'<b>{descname}</b>: <i>{ttext}:</i> {context}'
                    else:
                        entry = f'<b>{descname}</b>: <i>{ttext}</i>.'
                    libchanges.setdefault(module, []).append((
                        entry,
                        changeset.docname,
                        changeset.lineno,
                    ))
                else:
                    if not context:
                        continue
                    entry = f'<i>{ttext.capitalize()}:</i> {context}'
                    title = self.env.titles[changeset.docname].astext()
                    otherchanges.setdefault((changeset.docname, title), []).append((
                        entry,
                        changeset.docname,
                        changeset.lineno,
                    ))

            ctx = {
                'project': self.config.project,
                'version': version,
                'docstitle': self.config.html_title,
                'shorttitle': self.config.html_short_title,
                'libchanges': sorted(libchanges.items()),
                'apichanges': sorted(apichanges),
                'otherchanges': sorted(otherchanges.items()),
                'show_copyright': self.config.html_show_copyright,
                'show_sphinx': self.config.html_show_sphinx,
            }
            with open(self.outdir / 'index.html', 'w', encoding='utf8') as f:
                f.write(self.templates.render('changes/frameset.html', ctx))
            with open(self.outdir / 'changes.html', 'w', encoding='utf8') as f:
                f.write(self.templates.render('changes/versionchanges.html', ctx))

            hltext = [
                f'.. version-added:: {version}',
                f'.. versionadded:: {version}',
                f'.. version-changed:: {version}',
                f'.. versionchanged:: {version}',
                f'.. version-deprecated:: {version}',
                f'.. deprecated:: {version}',
                f'.. version-removed:: {version}',
                f'.. versionremoved:: {version}',
            ]

            def hl(no: int, line: str) -> str:
                line = '<a name="L%s"> </a>' % no + html.escape(line)
                for x in hltext:
                    if x in line:
                        line = '<span class="hl">%s</span>' % line
                        break
                return line

            logger.info(bold(__('copying source files...')))
            for docname in self.env.all_docs:
                with open(
                    self.env.doc2path(docname), encoding=self.config.source_encoding
                ) as f:
                    try:
                        lines = f.readlines()
                    except UnicodeDecodeError:
                        logger.warning(
                            __('could not read %r for changelog creation'), docname
                        )
                        continue
                text = ''.join(hl(i + 1, line) for (i, line) in enumerate(lines))
                ctx = {
                    'filename': str(self.env.doc2path(docname, False)),
                    'text': text,
                }
                rendered = self.templates.render('changes/rstsource.html', ctx)
                targetfn = self.outdir / 'rst' / f'{docname}.html'
                targetfn.parent.mkdir(parents=True, exist_ok=True)
                with open(targetfn, 'w', encoding='utf-8') as f:
                    f.write(rendered)
            themectx = {
                'theme_' + key: val for (key, val) in self.theme.get_options({}).items()
            }
            copy_asset_file(
                package_dir.joinpath('themes', 'default', 'static', 'default.css.jinja'),
                self.outdir,
                context=themectx,
                renderer=self.templates,
                force=True,
            )
            copy_asset_file(
                package_dir.joinpath('themes', 'basic', 'static', 'basic.css'),
                self.outdir / 'basic.css',
                force=True,
            )

        def hl(self, text: str, version: str) -> str:
            text = html.escape(text)
            for directive in (
                'versionchanged',
                'versionadded',
                'deprecated',
                'versionremoved',
            ):
                text = text.replace(
                    f'.. {directive}:: {version}', f'<b>.. {directive}:: {version}</b>'
                )
            return text

        def finish(self) -> None:
            pass

    def setup(app: Sphinx) -> ExtensionMetadata:
        app.add_builder(ChangesBuilder)

        return {
            'version': 'builtin',
            'parallel_read_safe': True,
            'parallel_write_safe': True,
        }