├── .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 | --------------------------------------------------------------------------------