diff --git a/docs/changelog.rst b/docs/changelog.rst index 646b83b..32724e2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,17 @@ Change Log Change log here. +Version 2.x +=========== + +.. version:: 2.0 + :break: + + - Integrated with :external+render:doc:`sphinxnotes-render ` + - Drop the ``.. recentupdate::`` directive, use ``load_extra('recentupdate')`` + in :rst:dir:`data.render`'s template + - Drop the ``recentupdate_date_format`` confval + Version 1.x =========== diff --git a/docs/conf.py b/docs/conf.py index ad04a5f..eb94b98 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -126,3 +126,7 @@ extensions.append('sphinxnotes.recentupdate') # CUSTOM CONFIGURATION + +intersphinx_mapping['render'] = ('https://sphinx.silverrainz.me/render', None) + +extensions.append('sphinxnotes.render.ext') diff --git a/docs/conf.rst b/docs/conf.rst index db1eddd..a1341f7 100644 --- a/docs/conf.rst +++ b/docs/conf.rst @@ -4,53 +4,10 @@ Configuration The extension provides the following configuration: -.. confval:: recentupdate_count - :type: int - :default: 10 +.. autoconfval:: recentupdate_exclude_path - The default count of recent revisions. See :doc:`usage`. + A list of path that should be excluded when looking for file changes. -.. confval:: recentupdate_template - :type: str - :default: see below - - The default Jinja template of update information. See :doc:`usage`. - - Here is the default value: - - .. code:: jinja - - {% for r in revisions %} - {{ r.date | strftime }} - :Author: {{ r.author }} - :Message: {{ r.message }} - - {% if r.modification %} - - Modified {{ r.modification | roles("doc") | join(", ") }} - {% endif %} - {% if r.addition %} - - Added {{ r.addition | roles("doc") | join(", ") }} - {% endif %} - {% if r.deletion %} - - Deleted {{ r.deletion | join(", ") }} - {% endif %} - {% endfor %} - -.. confval:: recentupdate_date_format - :type: str - :default: "%Y-%m-%dT" - - The default date format of :ref:`strftime` filter. - -.. confval:: recentupdate_exclude_path - :type: List[str] - :default: [] - - A list of path that should be excluded when looking for file changes. - -.. confval:: recentupdate_exclude_commit - :type: List[str] - :default: ["skip-recentupdate"] - - A list of commit message pattern that should be excluded when looking for file changes. +.. autoconfval:: recentupdate_exclude_commit + A list of commit message pattern that should be excluded when looking for file changes. diff --git a/docs/index.rst b/docs/index.rst index 8b51fdb..d8b69ab 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,12 +27,14 @@ Introduction .. INTRODUCTION START -Get the document update information from git and display it in Sphinx documentation. +Get the Sphinx document update information from Git repository. -This extensions provides a :doc:`recentupdate ` directive, which can show recent document update of current Sphinx documentation. The update information is read from Git_ repository (So you must use Git to manage your documentation). You can customize the update information through generating reStructuredText from Jinja_ template. +This extension integrates with :external+render:doc:`sphinxnotes-render ` +by providing an extra context ``recentupdate``. The recent document update +information is read from a Git_ repository. You can customize the presentation +via ``data.render`` template. .. _Git: https://git-scm.com/ -.. _Jinja: https://jinja.palletsprojects.com/en/3.0.x/templates/ .. INTRODUCTION END @@ -58,22 +60,33 @@ Then, add the extension name to ``extensions`` configuration item in your .. code-block:: python extensions = [ - # … - 'sphinxnotes.recentupdate', - # … - ] + # … + 'sphinxnotes.render.ext', + 'sphinxnotes.recentupdate', + # … + ] .. _Getting Started with Sphinx: https://www.sphinx-doc.org/en/master/usage/quickstart.html .. _conf.py: https://www.sphinx-doc.org/en/master/usage/configuration.html .. ADDITIONAL CONTENT START -Add ``recentupdate`` directive to your document, build your document, the directive will be rendered to: +Now you can use the :rst:dir:`data.render` directive (provided by +``sphinxnotes.render.ext``) with ``recentupdate`` extra context to render +a revision list: .. example:: - :style: grid - .. recentupdate:: + .. data.render:: + + The most recent 3 commits: + + {% for r in load_extra('recentupdate', 3) %} + ``{{ r.date }}`` + {{ r.message[0] }} + {% endfor %} + +Please refer to :doc:`usage` for more details. .. ADDITIONAL CONTENT END diff --git a/docs/requirements.txt b/docs/requirements.txt index 85c7d97..f67fe7b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -12,4 +12,5 @@ sphinxnotes-project sphinxnotes-comboroles # CUSTOM DOCS DEPENDENCIES START +sphinxnotes-render # CUSTOM DOCS DEPENDENCIES END diff --git a/docs/usage.rst b/docs/usage.rst index a8ec91f..0c21cfb 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -2,64 +2,63 @@ Usage ===== -The extension provides a ``recentupdate`` directive: +The extension provides an extra context ``recentupdate`` usable via +:external+render:term:`load_extra` function in ``sphinxnotes-render`` template: -.. code:: rst +.. example:: - .. recentupdate:: [count] + .. data.render:: - [jinja template] + {% for r in load_extra('recentupdate', 3) %} + ``📅 {{ r.date }}`` | ``👤{{ r.author }}`` -count - The optional argument of directive is the count of recent "revisions" you want to show. Revision is a git commit which contains document changes. + {{ r.message[0] }} - If no count given, value of :confval:`recentupdate_count` is used. + {% if r.changed_docs %} + - Modified {{ r.changed_docs | roles("doc") | join(", ") }} + {% endif %} + {% if r.added_docs %} + - Added {{ r.added_docs | roles("doc") | join(", ") }} + {% endif %} + {% if r.removed_docs %} + - Deleted {{ r.removed_docs | join(", ") }} + {% endif %} -template - The optional content of directive is a jinja template for generating reStructuredText, in the template you can access Variables_ named `{{ revisions }}`_. + {% endfor %} - Beside, You can use `Builtin Filters`_ and Filters_ provided by extensions. +The ``load_extra('recentupdate', count=3)`` returns a list of +:py:class:`~sphinxnotes.recentupdate.Revision` objects from recent Git +commits that touched document files, see below. - If no template given, value of :confval:`recentupdate_template` is used. +The :external+render:term:`roles` filter is provided by ``sphinxnotes-render`` +too. -.. _Builtin Filters: https://jinja.palletsprojects.com/en/3.0.x/templates/#builtin-filters +.. seealso:: -Variables -========= + :external+render:doc:`sphinxnotes-render: Templating ` + How to write ``data.render`` templates. + :external+render:doc:`sphinxnotes-render: Templating ` + How extra context and filters work. -All available variables_: +The "recentupdate" extra context +================================= -.. _variables: https://jinja.palletsprojects.com/en/3.0.x/templates/#variables +``load_extra('recentupdate', count=3)`` returns a list of +:py:class:`~sphinxnotes.recentupdate.Revision` objects from recent Git +commits that touched document files. -{{ revisions }} ---------------- +- ``count`` (*int*) — Number of recent revisions to return (default ``10``). -``{{ revisions }}`` is an an array of revisions. The length of array is determined by the argument of ``recentupdate`` directive. +.. py:class:: sphinxnotes.recentupdate.Revision -Here is the schema of array element: + .. autoattribute:: message -.. autoclass:: recentupdate.Revision - :members: + .. autoattribute:: author -Filters -======= + .. autoattribute:: date -.. _strftime: + .. autoattribute:: added_docs -strftime --------- + .. autoattribute:: changed_docs -Convert a :py:class:`datetime.datetime` to string in given format. - -If no format given, use value of :confval:`recentupdate_date_format`. - -It is used in :confval:`default template `. - -roles ------ - -Convert a list of string to list of reStructuredText roles. - -``{{ ['foo', 'bar'] | roles("doc") }}`` produces ``[':doc:`foo`', ':doc:`bar`']``. - -It is used in :confval:`default template `. + .. autoattribute:: removed_docs diff --git a/pyproject.toml b/pyproject.toml index ae263d4..e93dd31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ # CUSTOM DEPENDENCIES START "GitPython", - "Jinja2", + "sphinxnotes-render", # CUSTOM DEPENDENCIES END ] diff --git a/src/sphinxnotes/recentupdate/__init__.py b/src/sphinxnotes/recentupdate/__init__.py index 109564e..29d8c9a 100644 --- a/src/sphinxnotes/recentupdate/__init__.py +++ b/src/sphinxnotes/recentupdate/__init__.py @@ -2,80 +2,44 @@ sphinxnotes.recentupdate ~~~~~~~~~~~~~~~~~~~~~~~~ -Get the document update information from git and display it in Sphinx documentation. +Get recent document revision info from git, exposed as render extra context. :copyright: Copyright 2021 Shengyu Zhang :license: BSD, see LICENSE for details. """ from __future__ import annotations -from typing import Iterable, TYPE_CHECKING -from textwrap import dedent +from typing import TYPE_CHECKING, ClassVar from datetime import datetime from dataclasses import dataclass from os import path from pathlib import Path -from docutils import nodes -from docutils.statemachine import StringList -from docutils.parsers.rst import directives +from git import Repo from sphinx.util import logging -from sphinx.util.docutils import SphinxDirective -from sphinx.util.nodes import nested_parse_with_titles from sphinx.util.matching import Matcher -if TYPE_CHECKING: - from sphinx.application import Sphinx - -from git import Repo -import jinja2 +from sphinxnotes.render import ( + extra_context, + ExtraContext, + ExtraContextRequest, +) from . import meta +if TYPE_CHECKING: + from typing import Any + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment logger = logging.getLogger(__name__) -class Environment(jinja2.Environment): - datefmt: str - - def __init__(self, datefmt: str, *args, **kwargs): - super().__init__(*args, **kwargs) - self.datefmt = datefmt - self.filters['strftime'] = self._strftime_filter - self.filters['roles'] = self._roles_filter - - def _strftime_filter(self, value, format=None) -> str: - """ - Filter for stringify datetime given format. - if no format given, use confval "recentupdate_date_format". - """ - if format is None: - format = self.datefmt - return value.strftime(format) - - def _roles_filter(self, value: Iterable[str], role: str) -> Iterable[str]: - """ - A heplfer filter for converting list of string to list of role. - - For example:: - - {{ ["foo", "bar"] | roles("doc") }} - - Produces ``[":doc:`foo`", ":doc:`bar`"]``. - """ - return map(lambda x: ':%s:`%s`' % (role, x), value) - - @dataclass -class Revision(object): - """ - Revision represents a git commit which contains document changes. - """ - - #: Git commit message - message: str +class Revision: + #: Git commit message, split by lines + message: list[str] #: Git commit author author: str #: Git commit author date @@ -91,90 +55,81 @@ class Revision(object): # :U: file is unmerged (you must complete the merge before it can be committed) # :X: "unknown" change type (most probably a bug, please report it) - #: List of docname, corresponding to files which are modified - addition: list[str] #: List of docname, corresponding to files which are newly added - modification: list[str] + added_docs: list[str] + #: List of docname, corresponding to files which are modified + changed_docs: list[str] #: List of docname, corresponding to files which are deleted - deletion: list[str] + removed_docs: list[str] -class RecentUpdateDirective(SphinxDirective): - """Directive for displaying recent update.""" +@extra_context('recentupdate') +class RecentUpdateExtraContext(ExtraContext): + """Extra context providing recent document revisions from Git.""" - # Member of parent - has_content: bool = True - required_arguments: int = 0 - optional_arguments: int = 1 - final_argument_whitespace: bool = False - option_spec = {} + repo: ClassVar[Repo] - #: Repo info - repo: Repo = None + def generate(self, req: ExtraContextRequest, *args, **kwargs) -> Any: + count = args[0] if args else kwargs.get('count', 10) + return self._revisions(req.env, count) - def _get_docname(self, relfn_to_repo: str) -> str | None: - relsrcdir_to_repo = path.relpath(self.env.srcdir, self.repo.working_dir) - relfn_to_srcdir = path.relpath(relfn_to_repo, relsrcdir_to_repo) - absfn = Path(self.repo.working_dir, relfn_to_repo) - if not absfn.is_relative_to(self.env.srcdir): - logger.debug(f'Skip {relfn_to_repo}: out of srcdir') + def _get_docname(self, env: BuildEnvironment, file_path: str) -> str | None: + """Convert a repo-relative file path to a Sphinx docname.""" + relsrcdir_to_repo = path.relpath(env.srcdir, self.repo.working_dir) + relfn_to_srcdir = path.relpath(file_path, relsrcdir_to_repo) + absfn = Path(self.repo.working_dir, file_path) + if not absfn.is_relative_to(env.srcdir): + logger.debug(f'Skip {file_path}: out of srcdir') return None - excluded = Matcher(self.config.exclude_patterns) + excluded = Matcher(env.config.exclude_patterns) if excluded(relfn_to_srcdir): - logger.debug(f'Skip {relfn_to_repo}: excluded by exclude_patterns confval') + logger.debug(f'Skip {file_path}: excluded by exclude_patterns') return None docname, ext = path.splitext(relfn_to_srcdir) - source_suffix = list(self.config.source_suffix.keys()) + source_suffix = list(env.config.source_suffix.keys()) if not ext or ext not in source_suffix: - logger.debug(f'Skip {relfn_to_repo}: not {source_suffix} files') + logger.debug(f'Skip {file_path}: not {source_suffix} files') return None - for p in self.config.recentupdate_exclude_path: - exclude_path = Path(self.env.srcdir, p) + for p in env.config.recentupdate_exclude_path: + exclude_path = Path(env.srcdir, p) if absfn.is_relative_to(exclude_path): - logger.debug( - f'Skip {relfn_to_repo}: excluded by path {exclude_path}' - ) + logger.debug(f'Skip {file_path}: excluded by path {exclude_path}') return None logger.debug(f'Get docname: {docname}') return docname - def _context(self, count: int) -> dict[str, any]: - revisions = [] - res = {'revisions': revisions} + def _revisions(self, env: BuildEnvironment, count: int) -> list[Revision]: + revisions: list[Revision] = [] cur = self.repo.head.commit if cur is None: - return res + return revisions - # Get recent N commits which contain document changes (N = count) n = 0 while n < count: prev = cur.parents[0] if len(cur.parents) != 0 else None if prev is None: break - matches = [ - x in cur.message for x in self.config.recentupdate_exclude_commit - ] + matches = [x in cur.message for x in env.config.recentupdate_exclude_commit] if any(matches): logger.debug( - f'Skip commit {cur.hexsha}: excluded by recentupdate_exclude_commit confval' + f'Skip commit {cur.hexsha}: excluded by recentupdate_exclude_commit' ) cur = prev continue - m = [] - a = [] - d = [] + m, a, d = [], [], [] diff_idx = prev.tree.diff(cur) for diff in diff_idx: - docname = self._get_docname(diff.a_path) + if diff.a_path is None: + continue + docname = self._get_docname(env, diff.a_path) if docname is None: - # Skip files out of srcdir continue if diff.change_type == 'M': @@ -184,93 +139,46 @@ def _context(self, count: int) -> dict[str, any]: elif diff.change_type == 'D': d.append(docname) else: - logger.warning( - f'Skip {diff.a_path}: unsupport change type {diff.change_type}' + logger.info( + f'Skip {diff.a_path}: ' + f'unsupported change type {diff.change_type}' ) if len(m) + len(a) + len(d) == 0: - # Dont create revisions when no document changes logger.debug(f'Skip commit {cur.hexsha}: no document changes') cur = prev continue revisions.append( Revision( - message=cur.message, - author=cur.author, + message=cur.message.splitlines(), + author=str(cur.author or ''), date=datetime.utcfromtimestamp(cur.authored_date), - modification=m, - addition=a, - deletion=d, + changed_docs=m, + added_docs=a, + removed_docs=d, ) ) cur = prev n += 1 - logger.warning( + logger.info( f'[recentupdate] Intend to get recent {count} commits, eventually get {n}' ) - return res - - def run(self) -> list[nodes.Node]: - if len(self.arguments) >= 1: - count = directives.nonnegative_int(self.arguments[0]) - else: - count = self.config.recentupdate_count - - # Render reST from Jinja template, then parse it in to document - env = Environment(self.config.recentupdate_date_format) - - try: - template = env.from_string( - '\n'.join(list(self.content)) or self.config.recentupdate_template - ) - lines = template.render(self._context(count)).split('\n') - except Exception as e: - msg = f'failed to render recentupdate template: {e}' - logger.warning(msg, location=self.state.parent) - sm = nodes.system_message( - msg, type='WARNING', level=2, backrefs=[], source='' - ) - return [sm] - else: - nested_parse_with_titles(self.state, StringList(lines), self.state.parent) - return [] - - -DEFAULT_TEMPLATE = dedent(""" - {% for r in revisions %} - {{ r.date | strftime }} - :Author: {{ r.author }} - :Message: {{ r.message }} - - {% if r.modification %} - - Modified {{ r.modification | roles("doc") | join(", ") }} - {% endif %} - {% if r.addition %} - - Added {{ r.addition | roles("doc") | join(", ") }} - {% endif %} - {% if r.deletion %} - - Deleted {{ r.deletion | join(", ") }} - {% endif %} - {% endfor %} - """) + return revisions def setup(app: Sphinx): - """Sphinx extension entrypoint.""" meta.pre_setup(app) - # Set current git repo - RecentUpdateDirective.repo = Repo(app.srcdir, search_parent_directories=True) + RecentUpdateExtraContext.repo = Repo(app.srcdir, search_parent_directories=True) - app.add_directive('recentupdate', RecentUpdateDirective) + app.setup_extension('sphinxnotes.render') - app.add_config_value('recentupdate_count', 10, 'env') - app.add_config_value('recentupdate_template', DEFAULT_TEMPLATE, 'env') - app.add_config_value('recentupdate_date_format', '%Y-%m-%d', 'env') - app.add_config_value('recentupdate_exclude_path', [], 'env') - app.add_config_value('recentupdate_exclude_commit', ['skip-recentupdate'], 'env') + app.add_config_value('recentupdate_exclude_path', [], 'env', types=list[str]) + app.add_config_value( + 'recentupdate_exclude_commit', ['skip-recentupdate'], 'env', types=list[str] + ) return meta.post_setup(app)