├── .coveragerc
├── .github
├── dependabot.yml
└── workflows
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .gitmodules
├── LICENSE
├── MANIFEST.in
├── NEWS.rst
├── README.rst
├── pyproject.toml
├── pytest.ini
├── setup.py
├── src
└── sphinx_last_updated_by_git.py
└── tests
├── requirements.txt
├── test_example_repo.py
├── test_singlehtml.py
├── test_untracked.py
└── update_submodules.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | source=sphinx_last_updated_by_git
3 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish to PyPI
2 | on: push
3 | jobs:
4 | build:
5 | name: Build distribution
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/checkout@v4
9 | - name: Set up Python
10 | uses: actions/setup-python@v5
11 | with:
12 | python-version: "3"
13 | - name: Install "build"
14 | run: |
15 | python -m pip install build
16 | - name: Build a binary wheel and a source tarball
17 | run: python3 -m build
18 | - name: Store the distribution packages
19 | uses: actions/upload-artifact@v4
20 | with:
21 | name: dist
22 | path: dist
23 | publish:
24 | name: Upload release to PyPI
25 | if: startsWith(github.ref, 'refs/tags/')
26 | needs:
27 | - build
28 | runs-on: ubuntu-latest
29 | environment:
30 | name: pypi
31 | url: https://pypi.org/p/sphinx-last-updated-by-git
32 | permissions:
33 | id-token: write
34 | steps:
35 | - name: Get the artifacts
36 | uses: actions/download-artifact@v4
37 | with:
38 | name: dist
39 | path: dist
40 | - name: Publish package distributions to PyPI
41 | uses: pypa/gh-action-pypi-publish@release/v1
42 | with:
43 | print-hash: true
44 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 | on: [push, pull_request]
3 | env:
4 | PYTEST_ADDOPTS: "--color=yes"
5 | PYTHONWARNINGS: error
6 | jobs:
7 | run_pytest:
8 | strategy:
9 | matrix:
10 | os: [macos-latest, windows-latest, ubuntu-latest]
11 | python-version: ["3.8", "3.12", "3.13-dev"]
12 | runs-on: ${{ matrix.os }}
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Set up Python ${{ matrix.python-version }}
16 | uses: actions/setup-python@v5
17 | # TODO: remove this once the warning is fixed in Python 3.13-dev:
18 | env:
19 | PYTHONWARNINGS: error,default::DeprecationWarning
20 | with:
21 | python-version: ${{ matrix.python-version }}
22 | - name: Double-check Python version
23 | run: |
24 | python --version
25 | - name: Checkout submodules
26 | run: |
27 | python tests/update_submodules.py
28 | - name: Upgrade pip
29 | env:
30 | PYTHONWARNINGS: error,default::DeprecationWarning
31 | run: |
32 | python -m pip install pip --upgrade
33 | - name: Install Python package
34 | env:
35 | PYTHONWARNINGS: error,default::DeprecationWarning
36 | run: |
37 | python -m pip install .
38 | - name: Install test dependencies
39 | env:
40 | PYTHONWARNINGS: error,default::DeprecationWarning
41 | run: |
42 | python -m pip install -r tests/requirements.txt
43 | - name: Run pytest
44 | run: |
45 | python -m pytest
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | /build/
3 | /dist/
4 | /src/sphinx_last_updated_by_git.egg-info/
5 | /htmlcov/
6 | /.coverage
7 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "tests/repo_full"]
2 | path = tests/repo_full
3 | url = https://github.com/mgeier/test-repo-for-sphinx-last-updated-by-git.git
4 | [submodule "tests/repo_shallow"]
5 | path = tests/repo_shallow
6 | url = https://github.com/mgeier/test-repo-for-sphinx-last-updated-by-git.git
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020-2024, Matthias Geier
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are met:
5 |
6 | 1. Redistributions of source code must retain the above copyright notice, this
7 | list of conditions and the following disclaimer.
8 |
9 | 2. Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 |
13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include NEWS.rst
2 |
--------------------------------------------------------------------------------
/NEWS.rst:
--------------------------------------------------------------------------------
1 | Version 0.3.8 (2024-08-11):
2 | * Missing dependencies are ignored (but a warning is shown)
3 |
4 | Version 0.3.7 (2024-05-04):
5 | * Only documentation and CI updates
6 |
7 | Version 0.3.6 (2023-08-26):
8 | * Support for changed behavior of ``source-read`` event in Sphinx 7.2
9 |
10 | Version 0.3.5 (2023-05-18):
11 | * A few build system and test updates
12 |
13 | Version 0.3.4 (2022-09-02):
14 | * Add ``git_exclude_patterns`` and ``git_exclude_commits`` features
15 |
16 | Version 0.3.3 (2022-08-21):
17 | * Remove ``page_source_suffix`` from ``context`` if there is no source link
18 |
19 | Version 0.3.2 (2022-04-30):
20 | * Use ``--no-show-signature`` to avoid error when ``log.showSignature`` is on
21 | * Properly stop ``git log`` subprocess even on error (to avoid warnings)
22 |
23 | Version 0.3.1 (2022-03-04):
24 | * Handle "added" but not yet "committed" files
25 |
26 | Version 0.3.0 (2021-02-09):
27 | * Refactor to make ``git`` calls per directory instead of per file,
28 | which makes this extension usable on big repositories
29 | * Raise warnings instead of errors when ``git`` is not available
30 | and when source files are not in a Git repo
31 | * Add warning subtypes ``git.command_not_found`` and ``git.subprocess_error``
32 | * Drop support for Python 3.5
33 |
34 | Version 0.2.4 (2021-01-21):
35 | * ``srcdir`` can now be different from ``confdir``
36 |
37 | Version 0.2.3 (2020-12-19):
38 | * Add timestamp as a ```` tag
39 |
40 | Version 0.2.2 (2020-07-20):
41 | * Add option ``git_last_updated_timezone``
42 |
43 | Version 0.2.1 (2020-04-21):
44 | * Set ``last_updated`` to ``None`` when using a ``singlehtml`` builder
45 |
46 | Version 0.2.0 (2020-04-25):
47 | * Change Git errors from warnings to proper errors
48 | * Change "too shallow" message to proper warning
49 | (with the ability to suppress with ``git.too_shallow``)
50 | * Explicitly use the local time zone
51 | * Support for Python 3.5
52 |
53 | Version 0.1.1 (2020-04-20):
54 | * Don't add times for too shallow Git clones
55 | * Handle untracked source files, add configuration options
56 | ``git_untracked_check_dependencies`` and ``git_untracked_show_sourcelink``
57 |
58 | Version 0.1.0 (2020-04-08):
59 | Initial release
60 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Get the "last updated" time for each Sphinx page from Git
2 | =========================================================
3 |
4 | This is a little Sphinx_ extension that does exactly that.
5 | It also checks for included files and other dependencies and
6 | uses their "last updated" time if it's more recent.
7 | For each file, the "author date" of the Git commit where it was last changed
8 | is taken to be its "last updated" time. Uncommitted changes are ignored.
9 |
10 | If a page doesn't have a source file, its last_updated_ time is set to ``None``.
11 |
12 | The default value for html_last_updated_fmt_ is changed
13 | from ``None`` to the empty string.
14 |
15 | Usage
16 | #. Make sure that you use a Sphinx theme that shows the "last updated"
17 | information (or use a custom template with last_updated_)
18 | #. Install the Python package ``sphinx-last-updated-by-git``
19 | #. Add ``'sphinx_last_updated_by_git'`` to ``extensions`` in your ``conf.py``
20 | #. Run Sphinx!
21 |
22 | Options
23 | * If a source file is not tracked by Git (e.g. because it has been
24 | auto-generated on demand by autosummary_generate_) but its dependencies
25 | are, the last_updated_ time is taken from them. If you don't want this
26 | to happen, use ``git_untracked_check_dependencies = False``.
27 |
28 | * If a source file is not tracked by Git, its HTML page doesn't get a
29 | source link. If you do want those pages to have a sourcelink, set
30 | ``git_untracked_show_sourcelink = True``. Of course, in this case
31 | html_copy_source_ and html_show_sourcelink_ must also be ``True``, and
32 | the theme you are using must support source links in the first place.
33 |
34 | * By default, timestamps are displayed using the local time zone.
35 | You can specify a datetime.timezone_ object (or any ``tzinfo`` subclass
36 | instance) with the configuration option ``git_last_updated_timezone``.
37 | You can also use any string recognized by babel_,
38 | e.g. ``git_last_updated_timezone = 'Pacific/Auckland'``.
39 |
40 | * By default, the "last updated" timestamp is added as an HTML ````
41 | tag. This can be disabled by setting the configuration option
42 | ``git_last_updated_metatags`` to ``False``.
43 |
44 | * Files can be excluded from the last updated date calculation by passing
45 | a list of exclusion patterns to the configuration option
46 | ``git_exclude_patterns``.
47 | These patterns are checked on both source files and dependencies
48 | and are treated the same way as Sphinx's exclude_patterns_.
49 |
50 | * Individual commits can be excluded from the last updated date
51 | calculation by passing a list of commit hashes to the configuration
52 | option ``git_exclude_commits``.
53 |
54 | Caveats
55 | * When using a "Git shallow clone" (with the ``--depth`` option),
56 | the "last updated" commit for a long-unchanged file
57 | might not have been checked out.
58 | In this case, the last_updated_ time is set to ``None``
59 | (and a warning is shown during the build).
60 |
61 | This might happen on https://readthedocs.org/
62 | because they use shallow clones by default.
63 | To avoid this problem, you can edit your config file ``.readthedocs.yml``:
64 |
65 | .. code:: yaml
66 |
67 | version: 2
68 | build:
69 | os: "ubuntu-22.04"
70 | tools:
71 | python: "3"
72 | jobs:
73 | post_checkout:
74 | - git fetch --unshallow || true
75 |
76 | For more details, `read the docs`__.
77 |
78 | __ https://docs.readthedocs.com/platform/stable/build-customization.html#unshallow-git-clone
79 |
80 | This might also happen when using Github Actions,
81 | because `actions/checkout`__ also uses shallow clones by default.
82 | This can be changed by using ``fetch-depth: 0``:
83 |
84 | .. code:: yaml
85 |
86 | steps:
87 | - uses: actions/checkout@v3
88 | with:
89 | fetch-depth: 0
90 |
91 | __ https://github.com/actions/checkout
92 |
93 | If you only want to get rid of the warning (without actually fixing the problem),
94 | use this in your ``conf.py``::
95 |
96 | suppress_warnings = ['git.too_shallow']
97 |
98 | * If depedency file does not exist, a warning is being emitted.
99 |
100 | If you only want to get rid of the warning (without actually fixing the problem),
101 | use this in your ``conf.py``::
102 |
103 | suppress_warnings = ['git.dependency_not_found']
104 |
105 | * When a project on https://readthedocs.org/ using their default theme
106 | ``sphinx_rtd_theme`` was created before October 20th 2020,
107 | the date will not be displayed in the footer.
108 |
109 | An outdated work-around is to enable the (undocumented) feature flag
110 | ``USE_SPHINX_LATEST``.
111 |
112 | A better work-around is to override the defaults
113 | by means of a ``requirements.txt`` file containing something like this::
114 |
115 | sphinx>=2
116 | sphinx_rtd_theme>=0.5
117 |
118 | See also `issue #1`_.
119 |
120 | * In Sphinx versions 5.0 and 5.1, there has been
121 | a regression in how dependencies are determined.
122 | This could lead to spurious dependencies
123 | which means that some "last changed" dates were wrong.
124 | This has been fixed in Sphinx version 5.2 and above.
125 |
126 | See also `issue #40`_.
127 |
128 | License
129 | BSD-2-Clause (same as Sphinx itself),
130 | for more information take a look at the ``LICENSE`` file.
131 |
132 | Similar stuff
133 | | https://github.com/jdillard/sphinx-gitstamp
134 | | https://github.com/OddBloke/sphinx-git
135 | | https://github.com/MestreLion/git-tools (``git-restore-mtime``)
136 | | https://github.com/TYPO3-Documentation/sphinxcontrib-gitloginfo
137 |
138 | .. _Sphinx: https://www.sphinx-doc.org/
139 | .. _last_updated: https://www.sphinx-doc.org/en/master/
140 | development/html_themes/templating.html#last_updated
141 | .. _exclude_patterns: https://www.sphinx-doc.org/en/master/usage/
142 | configuration.html#confval-exclude_patterns
143 | .. _autosummary_generate: https://www.sphinx-doc.org/en/master/
144 | usage/extensions/autosummary.html#confval-autosummary_generate
145 | .. _html_copy_source: https://www.sphinx-doc.org/en/master/
146 | usage/configuration.html#confval-html_copy_source
147 | .. _html_show_sourcelink: https://www.sphinx-doc.org/en/master/
148 | usage/configuration.html#confval-html_show_sourcelink
149 | .. _html_last_updated_fmt: https://www.sphinx-doc.org/en/master/
150 | usage/configuration.html#confval-html_last_updated_fmt
151 | .. _datetime.timezone: https://docs.python.org/3/library/
152 | datetime.html#timezone-objects
153 | .. _babel: https://babel.pocoo.org/
154 | .. _issue #1: https://github.com/mgeier/sphinx-last-updated-by-git/issues/1
155 | .. _issue #40: https://github.com/mgeier/sphinx-last-updated-by-git/issues/40
156 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools >= 40.8.0"]
3 | build-backend = "setuptools.build_meta"
4 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | addopts = --cov --cov-report term --cov-report html
3 | filterwarnings =
4 | error
5 | default:unable to set time zone:UserWarning
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from setuptools import setup
4 |
5 |
6 | # "import" __version__
7 | __version__ = 'unknown'
8 | with Path('src/sphinx_last_updated_by_git.py').open() as f:
9 | for line in f:
10 | if line.startswith('__version__'):
11 | exec(line)
12 | break
13 |
14 | setup(
15 | name='sphinx-last-updated-by-git',
16 | version=__version__,
17 | package_dir={'': 'src'},
18 | py_modules=['sphinx_last_updated_by_git'],
19 | python_requires='>=3.7',
20 | install_requires=[
21 | 'sphinx>=1.8',
22 | ],
23 | author='Matthias Geier',
24 | author_email='Matthias.Geier@gmail.com',
25 | description='Get the "last updated" time for each Sphinx page from Git',
26 | long_description=Path('README.rst').read_text(),
27 | license='BSD-2-Clause',
28 | keywords='Sphinx Git'.split(),
29 | url='https://github.com/mgeier/sphinx-last-updated-by-git/',
30 | platforms='any',
31 | classifiers=[
32 | 'Framework :: Sphinx',
33 | 'Framework :: Sphinx :: Extension',
34 | 'Operating System :: OS Independent',
35 | 'Programming Language :: Python',
36 | 'Programming Language :: Python :: 3',
37 | 'Topic :: Documentation :: Sphinx',
38 | ],
39 | zip_safe=True,
40 | )
41 |
--------------------------------------------------------------------------------
/src/sphinx_last_updated_by_git.py:
--------------------------------------------------------------------------------
1 | """Get the "last updated" time for each Sphinx page from Git."""
2 | from collections import defaultdict
3 | from contextlib import suppress
4 | from datetime import datetime, timezone
5 | from pathlib import Path
6 | import subprocess
7 |
8 | from sphinx.locale import _
9 | from sphinx.util.i18n import format_date
10 | from sphinx.util.logging import getLogger
11 | from sphinx.util.matching import Matcher
12 | try:
13 | from sphinx.util.display import status_iterator
14 | except ImportError:
15 | # For older Sphinx versions, will be removed in Sphinx 8:
16 | from sphinx.util import status_iterator
17 |
18 |
19 | __version__ = '0.3.8'
20 |
21 |
22 | logger = getLogger(__name__)
23 |
24 |
25 | def update_file_dates(git_dir, exclude_commits, file_dates):
26 | """Ask Git for "author date" of given files in given directory.
27 |
28 | A git subprocess is executed at most three times:
29 |
30 | * First, to check which of the files are even managed by Git.
31 | * With only those files (if any), a "git log" is created and parsed
32 | until all requested files have been found.
33 | * If the root commit is reached (i.e. there is at least one of the
34 | requested files that has never been edited since the root commit),
35 | git is called again to check whether the repo is "shallow".
36 |
37 | """
38 | requested_files = set(file_dates)
39 | assert requested_files
40 |
41 | existing_files = subprocess.check_output(
42 | [
43 | 'git', 'ls-tree', '--name-only', '-z', 'HEAD',
44 | '--', *requested_files
45 | ],
46 | cwd=git_dir,
47 | stderr=subprocess.PIPE,
48 | ).rstrip().rstrip(b'\0')
49 | if not existing_files:
50 | return # None of the requested files are under version control
51 | existing_files = existing_files.decode('utf-8').split('\0')
52 | requested_files.intersection_update(existing_files)
53 | assert requested_files
54 |
55 | process = subprocess.Popen(
56 | [
57 | 'git', 'log', '--pretty=format:%n%at%x00%H%x00%P',
58 | '--author-date-order', '--relative', '--name-only',
59 | '--no-show-signature', '-z', '-m', '--', *requested_files
60 | ],
61 | cwd=git_dir,
62 | stdout=subprocess.PIPE,
63 | # NB: We ignore stderr to avoid deadlocks when reading stdout
64 | )
65 | with process:
66 | parse_log(process.stdout, requested_files,
67 | git_dir, exclude_commits, file_dates)
68 | # We don't need the rest of the log if there's something left:
69 | process.terminate()
70 |
71 |
72 | def parse_log(stream, requested_files, git_dir, exclude_commits, file_dates):
73 | requested_files = set(f.encode('utf-8') for f in requested_files)
74 |
75 | line0 = stream.readline()
76 |
77 | # First line is blank
78 | assert not line0.rstrip(), 'unexpected git output in {}: {}'.format(
79 | git_dir, line0)
80 |
81 | while requested_files:
82 | line1 = stream.readline()
83 | if not line1:
84 | msg = 'end of git log in {}, unhandled files: {}'
85 | assert exclude_commits, msg.format(
86 | git_dir, requested_files)
87 | msg = 'unhandled files in {}: {}, due to excluded commits: {}'
88 | logger.warning(
89 | msg.format(git_dir, requested_files, exclude_commits),
90 | type='git', subtype='unhandled_files')
91 | break
92 | pieces = line1.rstrip().split(b'\0')
93 | assert len(pieces) == 3, 'invalid git info in {}: {}'.format(
94 | git_dir, line1)
95 | timestamp, commit, parent_commits = pieces
96 | line2 = stream.readline().rstrip()
97 | assert line2.endswith(b'\0'), 'unexpected file list in {}: {}'.format(
98 | git_dir, line2)
99 | line2 = line2.rstrip(b'\0')
100 | assert line2, 'no changed files in {} (parent commit(s): {})'.format(
101 | git_dir, parent_commits)
102 | changed_files = line2.split(b'\0')
103 |
104 | if commit in exclude_commits:
105 | continue
106 |
107 | too_shallow = False
108 | if not parent_commits:
109 | is_shallow = subprocess.check_output(
110 | # --is-shallow-repository is available since Git 2.15.
111 | ['git', 'rev-parse', '--is-shallow-repository'],
112 | cwd=git_dir,
113 | stderr=subprocess.PIPE,
114 | ).rstrip()
115 | if is_shallow == b'true':
116 | too_shallow = True
117 |
118 | for file in changed_files:
119 | try:
120 | requested_files.remove(file)
121 | except KeyError:
122 | continue
123 | else:
124 | file_dates[file.decode('utf-8')] = timestamp, too_shallow
125 |
126 |
127 | def _env_updated(app, env):
128 | # NB: We call git once per sub-directory, because each one could
129 | # potentially be a separate Git repo (or at least a submodule)!
130 |
131 | def to_relpath(f: Path) -> str:
132 | with suppress(ValueError):
133 | f = f.relative_to(app.srcdir)
134 | return str(f)
135 |
136 | src_paths = {}
137 | src_dates = defaultdict(dict)
138 | excluded = Matcher(app.config.git_exclude_patterns)
139 | exclude_commits = set(
140 | map(lambda h: h.encode('utf-8'), app.config.git_exclude_commits))
141 |
142 | for docname, data in env.git_last_updated.items():
143 | if data is not None:
144 | continue # No need to update this source file
145 | if excluded(env.doc2path(docname, False)):
146 | continue
147 | srcfile = Path(env.doc2path(docname)).resolve()
148 | src_dates[srcfile.parent][srcfile.name] = None
149 | src_paths[docname] = srcfile.parent, srcfile.name
150 |
151 | srcdir_iter = status_iterator(
152 | src_dates, 'getting Git timestamps for source files... ',
153 | 'fuchsia', len(src_dates), app.verbosity, stringify_func=to_relpath)
154 | for git_dir in srcdir_iter:
155 | try:
156 | update_file_dates(git_dir, exclude_commits, src_dates[git_dir])
157 | except subprocess.CalledProcessError as e:
158 | msg = 'Error getting data from Git'
159 | msg += ' (no "last updated" dates will be shown'
160 | msg += ' for source files from {})'.format(git_dir)
161 | if e.stderr:
162 | msg += ':\n' + e.stderr.decode('utf-8')
163 | logger.warning(msg, type='git', subtype='subprocess_error')
164 | except FileNotFoundError as e:
165 | logger.warning(
166 | '"git" command not found, '
167 | 'no "last updated" dates will be shown',
168 | type='git', subtype='command_not_found')
169 | return
170 |
171 | dep_paths = defaultdict(list)
172 | dep_dates = defaultdict(dict)
173 |
174 | candi_dates = defaultdict(list)
175 | show_sourcelink = {}
176 |
177 | for docname, (src_dir, filename) in src_paths.items():
178 | show_sourcelink[docname] = True
179 | date = src_dates[src_dir][filename]
180 | if date is None:
181 | if not app.config.git_untracked_show_sourcelink:
182 | show_sourcelink[docname] = False
183 | if not app.config.git_untracked_check_dependencies:
184 | continue
185 | else:
186 | candi_dates[docname].append(date)
187 | for dep in env.dependencies[docname]:
188 | # NB: dependencies are relative to srcdir and may contain ".."!
189 | if excluded(dep):
190 | continue
191 | depfile = Path(env.srcdir, dep).resolve()
192 | if not depfile.exists():
193 | logger.warning(
194 | "Dependency file %r, doesn't exist, skipping",
195 | depfile,
196 | location=docname,
197 | type='git',
198 | subtype='dependency_not_found',
199 | )
200 | continue
201 | dep_dates[depfile.parent][depfile.name] = None
202 | dep_paths[docname].append((depfile.parent, depfile.name))
203 |
204 | depdir_iter = status_iterator(
205 | dep_dates, 'getting Git timestamps for dependencies... ',
206 | 'turquoise', len(dep_dates), app.verbosity, stringify_func=to_relpath)
207 | for git_dir in depdir_iter:
208 | try:
209 | update_file_dates(git_dir, exclude_commits, dep_dates[git_dir])
210 | except subprocess.CalledProcessError as e:
211 | pass # We ignore errors in dependencies
212 |
213 | for docname, deps in dep_paths.items():
214 | for dep_dir, filename in deps:
215 | date = dep_dates[dep_dir][filename]
216 | if date is None:
217 | continue
218 | candi_dates[docname].append(date)
219 |
220 | for docname in src_paths:
221 | timestamps = candi_dates[docname]
222 | if timestamps:
223 | # NB: too_shallow is only relevant if it affects the latest date.
224 | timestamp, too_shallow = max(timestamps)
225 | if too_shallow:
226 | timestamp = None
227 | logger.warning(
228 | 'Git clone too shallow', location=docname,
229 | type='git', subtype='too_shallow')
230 | else:
231 | timestamp = None
232 | env.git_last_updated[docname] = timestamp, show_sourcelink[docname]
233 |
234 |
235 | def _html_page_context(app, pagename, templatename, context, doctree):
236 | context['last_updated'] = None
237 | lufmt = app.config.html_last_updated_fmt
238 | if lufmt is None or 'sourcename' not in context:
239 | return
240 | if 'page_source_suffix' not in context:
241 | # This happens in 'singlehtml' builders
242 | assert context['sourcename'] == ''
243 | return
244 |
245 | data = app.env.git_last_updated[pagename]
246 | if data is None:
247 | # There was a problem with git, a warning has already been issued
248 | timestamp = None
249 | show_sourcelink = False
250 | else:
251 | timestamp, show_sourcelink = data
252 | if not show_sourcelink:
253 | del context['sourcename']
254 | del context['page_source_suffix']
255 | if timestamp is None:
256 | return
257 |
258 | utc_date = datetime.fromtimestamp(int(timestamp), timezone.utc)
259 | date = utc_date.astimezone(app.config.git_last_updated_timezone)
260 | context['last_updated'] = format_date(
261 | lufmt or _('%b %d, %Y'),
262 | date=date,
263 | language=app.config.language)
264 |
265 | if app.config.git_last_updated_metatags:
266 | context['metatags'] += """
267 | """.format(
268 | date.isoformat())
269 |
270 |
271 | def _config_inited(app, config):
272 | if config.html_last_updated_fmt is None:
273 | config.html_last_updated_fmt = ''
274 | if isinstance(config.git_last_updated_timezone, str):
275 | from babel.dates import get_timezone
276 | config.git_last_updated_timezone = get_timezone(
277 | config.git_last_updated_timezone)
278 |
279 |
280 | def _builder_inited(app):
281 | env = app.env
282 | if not hasattr(env, 'git_last_updated'):
283 | env.git_last_updated = {}
284 |
285 |
286 | def _source_read(app, docname, source):
287 | env = app.env
288 | if docname not in env.found_docs:
289 | # Since Sphinx 7.2, "docname" can be None or a relative path
290 | # to a file included with the "include" directive.
291 | # We are only interested in actual source documents.
292 | return
293 | if docname in env.git_last_updated:
294 | # Again since Sphinx 7.2, the source-read hook can be called
295 | # multiple times when using the "include" directive.
296 | return
297 | env.git_last_updated[docname] = None
298 |
299 |
300 | def _env_merge_info(app, env, docnames, other):
301 | env.git_last_updated.update(other.git_last_updated)
302 |
303 |
304 | def _env_purge_doc(app, env, docname):
305 | try:
306 | del env.git_last_updated[docname]
307 | except KeyError:
308 | pass
309 |
310 |
311 | def setup(app):
312 | """Sphinx extension entry point."""
313 | app.require_sphinx('1.8') # For "config-inited" event
314 | app.connect('html-page-context', _html_page_context)
315 | app.connect('config-inited', _config_inited)
316 | app.connect('env-updated', _env_updated)
317 | app.connect('builder-inited', _builder_inited)
318 | app.connect('source-read', _source_read)
319 | app.connect('env-merge-info', _env_merge_info)
320 | app.connect('env-purge-doc', _env_purge_doc)
321 | app.add_config_value(
322 | 'git_untracked_check_dependencies', True, rebuild='env')
323 | app.add_config_value(
324 | 'git_untracked_show_sourcelink', False, rebuild='env')
325 | app.add_config_value(
326 | 'git_last_updated_timezone', None, rebuild='env')
327 | app.add_config_value(
328 | 'git_last_updated_metatags', True, rebuild='html')
329 | app.add_config_value('git_exclude_patterns', [], rebuild='env')
330 | app.add_config_value(
331 | 'git_exclude_commits', [], rebuild='env')
332 | return {
333 | 'version': __version__,
334 | 'parallel_read_safe': True,
335 | 'env_version': 1,
336 | }
337 |
--------------------------------------------------------------------------------
/tests/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx != 5.0.*, != 5.1.*
2 | pytest
3 | pytest-cov
4 | tzdata; sys_platform == "win32" and python_version >= "3.9"
5 |
--------------------------------------------------------------------------------
/tests/test_example_repo.py:
--------------------------------------------------------------------------------
1 | import os
2 | from pathlib import Path
3 | import tempfile
4 | import time
5 |
6 | import pytest
7 | from sphinx.cmd.build import build_main
8 |
9 |
10 | # Set timezone to make tests reproducible
11 | os.environ['TZ'] = 'Factory'
12 | try:
13 | time.tzset()
14 | except AttributeError:
15 | # time.tzset() is only available on Unix systems
16 | import warnings
17 | warnings.warn('unable to set time zone')
18 |
19 | time1 = '2020-04-22 10:41:20'
20 | time2 = '2020-04-23 07:24:08'
21 | time3 = '2021-01-31 20:52:36'
22 |
23 | expected_results = {
24 | 'index': [time1, 'defined'],
25 | 'I 🖤 Unicode': [time3, 'defined'],
26 | 'api': [time2, 'defined'],
27 | 'example_module.example_function': [time2, 'undefined'],
28 | 'search': ['None', 'undefined'],
29 | }
30 |
31 |
32 | def run_sphinx(subdir, **kwargs):
33 | srcdir = Path(__file__).parent / subdir
34 | with tempfile.TemporaryDirectory() as outdir:
35 | args = [str(srcdir), outdir, '-W', '-v']
36 | args.extend('-D{}={}'.format(k, v) for k, v in kwargs.items())
37 | result = build_main(args)
38 | assert result == 0
39 | data = {}
40 | for name in expected_results:
41 | path = Path(outdir) / (name + '.html')
42 | data[name] = path.read_text().splitlines()
43 | return data
44 |
45 |
46 | def test_repo_full():
47 | data = run_sphinx('repo_full')
48 | assert data == expected_results
49 |
50 |
51 | def test_untracked_no_dependencies():
52 | data = run_sphinx(
53 | 'repo_full',
54 | git_untracked_check_dependencies=0,
55 | )
56 | assert data == {
57 | **expected_results,
58 | 'example_module.example_function': ['None', 'undefined'],
59 | }
60 |
61 |
62 | def test_untracked_show_sourcelink():
63 | data = run_sphinx(
64 | 'repo_full',
65 | git_untracked_show_sourcelink=1,
66 | )
67 | assert data == {
68 | **expected_results,
69 | 'example_module.example_function': [time2, 'defined'],
70 | }
71 |
72 |
73 | def test_untracked_no_dependencies_and_show_sourcelink():
74 | data = run_sphinx(
75 | 'repo_full',
76 | git_untracked_check_dependencies=0,
77 | git_untracked_show_sourcelink=1,
78 | )
79 | assert data == {
80 | **expected_results,
81 | 'example_module.example_function': ['None', 'defined'],
82 | }
83 |
84 |
85 | def test_repo_shallow(capsys):
86 | with pytest.raises(AssertionError):
87 | run_sphinx('repo_shallow')
88 | assert 'too shallow' in capsys.readouterr().err
89 |
90 |
91 | def test_repo_shallow_without_warning():
92 | data = run_sphinx(
93 | 'repo_shallow',
94 | suppress_warnings='git.too_shallow,',
95 | )
96 | assert data == {
97 | **expected_results,
98 | 'index': ['None', 'defined'],
99 | }
100 |
101 |
102 | def test_custom_timezone():
103 | data = run_sphinx(
104 | 'repo_full',
105 | git_last_updated_timezone='Africa/Ouagadougou',
106 | )
107 | assert data == expected_results
108 |
109 |
110 | def test_no_git(capsys):
111 | path_backup = os.environ['PATH']
112 | os.environ['PATH'] = ''
113 | try:
114 | with pytest.raises(AssertionError):
115 | run_sphinx('repo_full')
116 | assert '"git" command not found' in capsys.readouterr().err
117 | finally:
118 | os.environ['PATH'] = path_backup
119 |
120 |
121 | def test_no_git_no_warning(capsys):
122 | path_backup = os.environ['PATH']
123 | os.environ['PATH'] = ''
124 | try:
125 | data = run_sphinx(
126 | 'repo_full',
127 | suppress_warnings='git.command_not_found')
128 | finally:
129 | os.environ['PATH'] = path_backup
130 | for k, v in data.items():
131 | assert v == ['None', 'undefined']
132 |
133 |
134 | def test_exclude_patterns_srcdir_relative():
135 | data = run_sphinx(
136 | 'repo_full',
137 | git_exclude_patterns='I 🖤 Unicode.rst',
138 | )
139 | assert data == {
140 | **expected_results,
141 | 'I 🖤 Unicode': ['None', 'undefined'],
142 | }
143 |
144 |
145 | def test_exclude_patterns_glob():
146 | data = run_sphinx(
147 | 'repo_full',
148 | git_exclude_patterns='*.rst',
149 | )
150 | assert data == {
151 | **expected_results,
152 | 'index': ['None', 'undefined'],
153 | 'I 🖤 Unicode': ['None', 'undefined'],
154 | 'api': ['None', 'undefined'],
155 | 'example_module.example_function': ['None', 'undefined'],
156 | }
157 |
158 |
159 | def test_exclude_patterns_deps_dates():
160 | data = run_sphinx(
161 | 'repo_full',
162 | git_exclude_patterns='example_module.py',
163 | )
164 | assert data == {
165 | **expected_results,
166 | 'api': [time1, 'defined'],
167 | 'example_module.example_function': ['None', 'undefined'],
168 | }
169 |
170 |
171 | def test_exclude_commits_dates():
172 | data = run_sphinx(
173 | 'repo_full',
174 | git_exclude_commits='6bb90c6027c3788d3891f833f017dbf8d229e432')
175 | assert data == {
176 | **expected_results,
177 | 'api': [time1, 'defined'],
178 | 'example_module.example_function': [time1, 'undefined'],
179 | }
180 |
181 |
182 | def test_exclude_commits_warning(capsys):
183 | with pytest.raises(AssertionError):
184 | run_sphinx(
185 | 'repo_full',
186 | git_exclude_commits='23d25d0b7ac4604b7a9545420b2f9de84daabe73')
187 | assert 'unhandled files' in capsys.readouterr().err
188 |
189 |
190 | def test_exclude_commits_without_warning():
191 | data = run_sphinx(
192 | 'repo_full',
193 | suppress_warnings='git.unhandled_files',
194 | git_exclude_commits='23d25d0b7ac4604b7a9545420b2f9de84daabe73')
195 | assert data == {
196 | **expected_results,
197 | 'I 🖤 Unicode': ['None', 'undefined'],
198 | }
199 |
--------------------------------------------------------------------------------
/tests/test_singlehtml.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | import tempfile
3 |
4 | from sphinx.cmd.build import build_main
5 |
6 |
7 | def test_singlehtml():
8 | srcdir = Path(__file__).parent / 'repo_full'
9 | with tempfile.TemporaryDirectory() as outdir:
10 | args = [str(srcdir), outdir, '-W', '-v', '-b', 'singlehtml']
11 | result = build_main(args)
12 | assert result == 0
13 | path = Path(outdir) / 'index.html'
14 | data = path.read_text().splitlines()
15 | # NB: sourcefile is defined but empty in this case
16 | assert data == ['None', 'defined']
17 |
--------------------------------------------------------------------------------
/tests/test_untracked.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | import tempfile
3 |
4 | from sphinx.cmd.build import build_main
5 |
6 |
7 | def create_and_run(srcdir, **kwargs):
8 | srcdir = Path(srcdir)
9 | srcdir.joinpath('conf.py').write_text("""
10 | extensions = [
11 | 'sphinx_last_updated_by_git',
12 | ]
13 | templates_path = ['_templates']
14 | """)
15 | srcdir.joinpath('index.rst').write_text("""
16 | .. include:: another-file.txt
17 | """)
18 | srcdir.joinpath('another-file.txt').write_text("""
19 | This will be an untracked dependency.
20 | """)
21 | srcdir.joinpath('_templates').mkdir()
22 | srcdir.joinpath('_templates', 'layout.html').write_text("""\
23 | {{ last_updated }}
24 | {% if sourcename is not defined %}un{% endif %}defined
25 | """)
26 | outdir = srcdir / '_build'
27 | args = [str(srcdir), str(outdir), '-W', '-v']
28 | args.extend('-D{}={}'.format(k, v) for k, v in kwargs.items())
29 | result = build_main(args)
30 | if result != 0:
31 | return None
32 | path = outdir / 'index.html'
33 | return path.read_text().splitlines()
34 |
35 |
36 | def test_without_git_repo(capsys):
37 | with tempfile.TemporaryDirectory() as srcdir:
38 | assert create_and_run(srcdir) is None
39 | assert 'Error getting data from Git' in capsys.readouterr().err
40 |
41 |
42 | def test_without_git_repo_without_warning():
43 | with tempfile.TemporaryDirectory() as srcdir:
44 | data = create_and_run(srcdir, suppress_warnings='git.subprocess_error')
45 | assert data == ['None', 'undefined']
46 |
47 |
48 | def test_untracked_source_files():
49 | test_dir = Path(__file__).parent
50 | with tempfile.TemporaryDirectory(dir=str(test_dir)) as srcdir:
51 | data = create_and_run(srcdir)
52 | assert data == ['None', 'undefined']
53 |
--------------------------------------------------------------------------------
/tests/update_submodules.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from pathlib import Path
3 | import subprocess
4 |
5 |
6 | path = Path(__file__).parent
7 |
8 |
9 | def update_submodule(name, depth):
10 | subprocess.run(
11 | ['git', 'submodule', 'update', '--init', '--depth', str(depth), name],
12 | cwd=path,
13 | check=True,
14 | )
15 |
16 |
17 | if __name__ == '__main__':
18 | update_submodule('repo_full', 0x7fffffff)
19 | update_submodule('repo_shallow', 4)
20 |
--------------------------------------------------------------------------------