diff --git a/.copier-answers.yml b/.copier-answers.yml
new file mode 100644
index 0000000..6f2111e
--- /dev/null
+++ b/.copier-answers.yml
@@ -0,0 +1,12 @@
+# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
+_commit: 2023.11.17
+_src_path: gh:scientific-python/cookie
+backend: setuptools621
+email: msclock@126.com
+full_name: msclock
+license: Apache
+org: msclock
+project_name: sphinx-deployment
+project_short_description: A versioned documentation deployment tool for sphinx.
+url: https://github.com/msclock/sphinx-deployment
+vcs: true
diff --git a/.git_archival.txt b/.git_archival.txt
new file mode 100644
index 0000000..8fb235d
--- /dev/null
+++ b/.git_archival.txt
@@ -0,0 +1,4 @@
+node: $Format:%H$
+node-date: $Format:%cI$
+describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$
+ref-names: $Format:%D$
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..00a7b00
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+.git_archival.txt export-subst
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..8a687ab
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,101 @@
+See the [Scientific Python Developer Guide][spc-dev-intro] for a detailed
+description of best practices for developing scientific packages.
+
+[spc-dev-intro]: https://learn.scientific-python.org/development/
+
+# Quick development
+
+The fastest way to start with development is to use nox. If you don't have nox,
+you can use `pipx run nox` to run it without installing, or `pipx install nox`.
+If you don't have pipx (pip for applications), then you can install with
+`pip install pipx` (the only case were installing an application with regular
+pip is reasonable). If you use macOS, then pipx and nox are both in brew, use
+`brew install pipx nox`.
+
+To use, run `nox`. This will lint and test using every installed version of
+Python on your system, skipping ones that are not installed. You can also run
+specific jobs:
+
+```console
+$ nox -s lint # Lint only
+$ nox -s tests # Python tests
+$ nox -s docs -- serve # Build and serve the docs
+$ nox -s build # Make an SDist and wheel
+```
+
+Nox handles everything for you, including setting up an temporary virtual
+environment for each run.
+
+# Setting up a development environment manually
+
+You can set up a development environment by running:
+
+```bash
+python3 -m venv .venv
+source ./.venv/bin/activate
+pip install -v -e .[dev]
+```
+
+If you have the
+[Python Launcher for Unix](https://github.com/brettcannon/python-launcher), you
+can instead do:
+
+```bash
+py -m venv .venv
+py -m install -v -e .[dev]
+```
+
+# Post setup
+
+You should prepare pre-commit, which will help you by checking that commits pass
+required checks:
+
+```bash
+pip install pre-commit # or brew install pre-commit on macOS
+pre-commit install # Will install a pre-commit hook into the git repo
+```
+
+You can also/alternatively run `pre-commit run` (changes only) or
+`pre-commit run --all-files` to check even without installing the hook.
+
+# Testing
+
+Use pytest to run the unit checks:
+
+```bash
+pytest
+```
+
+# Coverage
+
+Use pytest-cov to generate coverage reports:
+
+```bash
+pytest --cov=sphinx-deployment
+```
+
+# Building docs
+
+You can build the docs using:
+
+```bash
+nox -s docs
+```
+
+You can see a preview with:
+
+```bash
+nox -s docs -- serve
+```
+
+# Pre-commit
+
+This project uses pre-commit for all style checking. While you can run it with
+nox, this is such an important tool that it deserves to be installed on its own.
+Install pre-commit and run:
+
+```bash
+pre-commit run -a
+```
+
+to check all files.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..6fddca0
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+version: 2
+updates:
+ # Maintain dependencies for GitHub Actions
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/matchers/pylint.json b/.github/matchers/pylint.json
new file mode 100644
index 0000000..e3a6bd1
--- /dev/null
+++ b/.github/matchers/pylint.json
@@ -0,0 +1,32 @@
+{
+ "problemMatcher": [
+ {
+ "severity": "warning",
+ "pattern": [
+ {
+ "regexp": "^([^:]+):(\\d+):(\\d+): ([A-DF-Z]\\d+): \\033\\[[\\d;]+m([^\\033]+).*$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "code": 4,
+ "message": 5
+ }
+ ],
+ "owner": "pylint-warning"
+ },
+ {
+ "severity": "error",
+ "pattern": [
+ {
+ "regexp": "^([^:]+):(\\d+):(\\d+): (E\\d+): \\033\\[[\\d;]+m([^\\033]+).*$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "code": 4,
+ "message": 5
+ }
+ ],
+ "owner": "pylint-error"
+ }
+ ]
+}
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 0000000..1d1b952
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,90 @@
+name: CD
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - master
+ release:
+ types:
+ - published
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ FORCE_COLOR: 3
+
+jobs:
+ dist:
+ name: Distribution build
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: hynek/build-and-inspect-python-package@v2
+
+ publish-pypi:
+ needs: [dist]
+ name: Publish to PyPI
+ environment:
+ name: pypi
+ url: https://pypi.org/p/${{ github.event.repository.name }}
+ permissions:
+ id-token: write
+ runs-on: ubuntu-latest
+ if: github.event_name == 'release' && github.event.action == 'published'
+
+ steps:
+ - name: Download packages built by build-and-inspect-python-package
+ with:
+ name: Packages
+ path: dist
+ uses: actions/download-artifact@v4
+
+ - name: Upload package to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ if:
+ github.event_name == 'release' && github.event.action == 'published'
+ && env.PYPI_API_TOKEN != null
+ env:
+ PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
+ with:
+ password: ${{ secrets.PYPI_API_TOKEN }}
+
+ pages:
+ name: Deploy to GitHub Pages
+ runs-on: ubuntu-latest
+ if:
+ ${{ github.ref_name == github.event.repository.default_branch ||
+ (github.event_name == 'release' && github.event.action == 'published') }}
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ ref: ${{ github.head_ref }}
+
+ - uses: wntrblm/nox@2023.04.22
+ with:
+ python-versions: "3.11"
+
+ - name: Verify no changes required to API docs
+ run: |
+ nox -s build_api_docs
+ git diff --exit-code
+
+ - name: Generate docs
+ run: nox -s docs
+
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./docs/_build/html
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..936b674
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,106 @@
+name: CI
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - master
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ FORCE_COLOR: 3
+
+jobs:
+ lint:
+ name: Format
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-python@v4
+ with:
+ python-version: "3.x"
+ - uses: pre-commit/action@v3.0.0
+ with:
+ extra_args: --hook-stage manual --all-files
+ - name: Run PyLint
+ run: |
+ echo "::add-matcher::$GITHUB_WORKSPACE/.github/matchers/pylint.json"
+ pipx run nox -s pylint
+
+ checks:
+ name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }}
+ runs-on: ${{ matrix.runs-on }}
+ needs: [lint]
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.8", "3.12"]
+ runs-on: [ubuntu-latest, macos-latest, windows-latest]
+
+ include:
+ - python-version: pypy-3.10
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ allow-prereleases: true
+
+ - name: Install package
+ run: python -m pip install .[test]
+
+ - name: Test package
+ run: >-
+ python -m pytest -ra --cov --cov-report=xml --cov-report=term
+ --durations=20
+
+ - name: Upload coverage report
+ uses: codecov/codecov-action@v3.1.4
+
+ docs:
+ name: Check building docs
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ ref: ${{ github.head_ref }}
+
+ - uses: wntrblm/nox@2023.04.22
+ with:
+ python-versions: "3.11"
+
+ - name: Linkcheck
+ run: nox -s docs -- -b linkcheck
+
+ - name: Build docs with warnings as errors
+ run: nox -s docs -- -W
+
+ - name: Verify no changes required to API docs
+ run: |
+ nox -s build_api_docs
+ git diff --exit-code
+
+ pass:
+ if: always()
+ needs: [lint, checks, docs]
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - name: Decide whether the needed jobs succeeded or failed
+ uses: re-actors/alls-green@release/v1
+ with:
+ jobs: ${{ toJSON(needs) }}
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 0000000..8f73dd6
--- /dev/null
+++ b/.github/workflows/preview.yml
@@ -0,0 +1,39 @@
+name: Deploy PR previews
+
+on:
+ pull_request:
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - closed
+
+concurrency:
+ group: preview-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ pages-preview:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: wntrblm/nox@2023.04.22
+ with:
+ python-versions: "3.11"
+
+ - name: Verify no changes required to API docs
+ run: |
+ nox -s build_api_docs
+ git diff --exit-code
+
+ - name: Generate docs
+ run: nox -s docs
+
+ - name: Deploy preview
+ uses: rossjrw/pr-preview-action@v1
+ with:
+ source-dir: ./docs/_build/html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..25cf9a4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,158 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# setuptools_scm
+src/*/_version.py
+
+
+# ruff
+.ruff_cache/
+
+# OS specific stuff
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Common editor files
+*~
+*.swp
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..aa896f6
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,96 @@
+exclude: |
+ ^(
+ CHANGELOG.md
+ )
+
+repos:
+ - repo: https://github.com/psf/black-pre-commit-mirror
+ rev: "23.11.0"
+ hooks:
+ - id: black-jupyter
+
+ - repo: https://github.com/adamchainz/blacken-docs
+ rev: "1.16.0"
+ hooks:
+ - id: blacken-docs
+ additional_dependencies: [black==23.*]
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: "v4.5.0"
+ hooks:
+ - id: check-added-large-files
+ - id: check-case-conflict
+ - id: check-merge-conflict
+ - id: check-symlinks
+ - id: check-yaml
+ - id: debug-statements
+ - id: end-of-file-fixer
+ - id: mixed-line-ending
+ - id: name-tests-test
+ args: ["--pytest-test-first"]
+ - id: requirements-txt-fixer
+ - id: trailing-whitespace
+
+ - repo: https://github.com/pre-commit/pygrep-hooks
+ rev: "v1.10.0"
+ hooks:
+ - id: rst-backticks
+ - id: rst-directive-colons
+ - id: rst-inline-touching-normal
+
+ - repo: https://github.com/pre-commit/mirrors-prettier
+ rev: "v3.1.0"
+ hooks:
+ - id: prettier
+ types_or: [yaml, markdown, html, css, scss, javascript, json]
+ args: [--prose-wrap=always]
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: "v0.1.5"
+ hooks:
+ - id: ruff
+ args: ["--fix", "--show-fixes"]
+
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: "v1.7.0"
+ hooks:
+ - id: mypy
+ files: src|tests
+ args: []
+ additional_dependencies:
+ - click
+ - loguru
+ - pytest
+ - sphinx
+ - gitpython
+ - jinja2
+
+ - repo: https://github.com/codespell-project/codespell
+ rev: "v2.2.6"
+ hooks:
+ - id: codespell
+
+ - repo: https://github.com/shellcheck-py/shellcheck-py
+ rev: "v0.9.0.6"
+ hooks:
+ - id: shellcheck
+
+ - repo: local
+ hooks:
+ - id: disallow-caps
+ name: Disallow improper capitalization
+ language: pygrep
+ entry: PyBind|Numpy|Cmake|CCache|Github|PyTest
+ exclude: .pre-commit-config.yaml
+
+ - repo: https://github.com/abravalheri/validate-pyproject
+ rev: v0.15
+ hooks:
+ - id: validate-pyproject
+
+ - repo: https://github.com/python-jsonschema/check-jsonschema
+ rev: 0.27.0
+ hooks:
+ - id: check-dependabot
+ - id: check-github-workflows
+ - id: check-readthedocs
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 0000000..7e49657
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,18 @@
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.11"
+sphinx:
+ configuration: docs/conf.py
+
+python:
+ install:
+ - method: pip
+ path: .
+ extra_requirements:
+ - docs
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..b9e3aec
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,35 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "sphinx_deployment",
+ "type": "python",
+ "request": "launch",
+ "module": "sphinx_deployment",
+ "console": "internalConsole",
+ "python": "${command:python.interpreterPath}",
+ "args": ["create", "-V", "dev"],
+ "justMyCode": false
+ },
+ {
+ "name": "sphinx-build",
+ "type": "python",
+ "request": "launch",
+ "program": "/home/msclock/projects/sphinx-deployment/.venv/bin/sphinx-build",
+ "console": "internalConsole",
+ "python": "${command:python.interpreterPath}",
+ "args": [
+ "--keep-going",
+ "-n",
+ "-T",
+ "-b=html",
+ "docs",
+ "docs/_build/dev"
+ ],
+ "justMyCode": false
+ }
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..d969f96
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "python.testing.pytestArgs": ["tests"],
+ "python.testing.unittestEnabled": false,
+ "python.testing.pytestEnabled": true
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e69de29
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..51b18d5
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2023 msclock
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0189f37
--- /dev/null
+++ b/README.md
@@ -0,0 +1,19 @@
+# sphinx-deployment
+
+[![Actions Status][actions-badge]][actions-link]
+[![PyPI version][pypi-version]][pypi-link]
+[![PyPI platforms][pypi-platforms]][pypi-link]
+
+
+[actions-badge]: https://github.com/msclock/sphinx-deployment/actions/workflows/ci.yml/badge.svg
+[actions-link]: https://github.com/msclock/sphinx-deployment/actions
+[pypi-link]: https://pypi.org/project/sphinx-deployment/
+[pypi-platforms]: https://img.shields.io/pypi/pyversions/sphinx-deployment
+[pypi-version]: https://img.shields.io/pypi/v/sphinx-deployment
+
+
+
+
+A Sphinx plugin for deployment documentation.
+
+
diff --git a/docs/_templates/versioning.html b/docs/_templates/versioning.html
new file mode 100644
index 0000000..799444b
--- /dev/null
+++ b/docs/_templates/versioning.html
@@ -0,0 +1,43 @@
+
+ {{ _('Versions') }}
+
+
+
diff --git a/docs/api/sphinx_deployment.rst b/docs/api/sphinx_deployment.rst
new file mode 100644
index 0000000..a950e5a
--- /dev/null
+++ b/docs/api/sphinx_deployment.rst
@@ -0,0 +1,26 @@
+sphinx\_deployment package
+==========================
+
+.. automodule:: sphinx_deployment
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+Submodules
+----------
+
+sphinx\_deployment.cli module
+-----------------------------
+
+.. automodule:: sphinx_deployment.cli
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+sphinx\_deployment.sphinx\_ext module
+-------------------------------------
+
+.. automodule:: sphinx_deployment.sphinx_ext
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 0000000..2cc6760
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1,5 @@
+# Changelog
+
+```{include} ../CHANGELOG.md
+
+```
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..48c2711
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.append(Path("../src").resolve())
+
+project = "sphinx-deployment"
+copyright = "2023, msclock"
+author = "msclock"
+
+extensions = [
+ "myst_parser",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.mathjax",
+ "sphinx.ext.napoleon",
+ "sphinx.ext.viewcode",
+ "sphinx_autodoc_typehints",
+ "sphinx_copybutton",
+ "sphinx_inline_tabs",
+ "sphinx_deployment",
+]
+
+source_suffix = [".rst", ".md"]
+exclude_patterns = [
+ "_build",
+ "**.ipynb_checkpoints",
+ "Thumbs.db",
+ ".DS_Store",
+ ".env",
+ ".venv",
+]
+
+html_theme = "furo"
+
+myst_enable_extensions = [
+ "colon_fence",
+]
+
+intersphinx_mapping = {
+ "python": ("https://docs.python.org/3", None),
+ "sphinx": ("https://www.sphinx-doc.org/", None),
+ "jinja2": ("https://jinja.palletsprojects.com/", None),
+}
+
+nitpick_ignore = [
+ ("py:class", "_io.StringIO"),
+ ("py:class", "_io.BytesIO"),
+]
+
+always_document_param_types = True
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..a9986f4
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,41 @@
+# sphinx-deployment
+
+```{toctree}
+:maxdepth: 2
+:hidden:
+
+```
+
+
+
+```{include} ../README.md
+:start-after:
+```
+
+## Content
+
+```{toctree}
+:maxdepth: 2
+:titlesonly:
+:caption: Guide
+:glob:
+
+Overview
+changelog
+```
+
+
+
+```{toctree}
+:maxdepth: 1
+:titlesonly:
+:caption: API docs
+
+api/sphinx_deployment
+```
+
+## Indices and tables
+
+- {ref}`genindex`
+- {ref}`modindex`
+- {ref}`search`
diff --git a/noxfile.py b/noxfile.py
new file mode 100644
index 0000000..31f9325
--- /dev/null
+++ b/noxfile.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import argparse
+import shutil
+from pathlib import Path
+
+import nox
+
+DIR = Path(__file__).parent.resolve()
+
+nox.options.sessions = ["lint", "pylint", "tests"]
+
+
+@nox.session
+def lint(session: nox.Session) -> None:
+ """
+ Run the linter.
+ """
+ session.install("pre-commit")
+ session.run(
+ "pre-commit", "run", "--all-files", "--show-diff-on-failure", *session.posargs
+ )
+
+
+@nox.session
+def pylint(session: nox.Session) -> None:
+ """
+ Run PyLint.
+ """
+ # This needs to be installed into the package environment, and is slower
+ # than a pre-commit check
+ session.install(".", "pylint")
+ session.run("pylint", "sphinx_deployment", *session.posargs)
+
+
+@nox.session
+def tests(session: nox.Session) -> None:
+ """
+ Run the unit and regular tests.
+ """
+ session.install(".[test]")
+ session.run("pytest", *session.posargs)
+
+
+@nox.session(reuse_venv=True)
+def docs(session: nox.Session) -> None:
+ """
+ Build the docs. Pass "--serve" to serve. Pass "-b linkcheck" to check links.
+ """
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--serve", action="store_true", help="Serve after building")
+ parser.add_argument(
+ "-b", dest="builder", default="html", help="Build target (default: html)"
+ )
+ args, posargs = parser.parse_known_args(session.posargs)
+
+ if args.builder != "html" and args.serve:
+ session.error("Must not specify non-HTML builder with --serve")
+
+ extra_installs = ["sphinx-autobuild"] if args.serve else []
+
+ session.install("-e.[docs]", *extra_installs)
+ session.chdir("docs")
+
+ if args.builder == "linkcheck":
+ session.run(
+ "sphinx-build", "-b", "linkcheck", ".", "_build/linkcheck", *posargs
+ )
+ return
+
+ shared_args = (
+ "-n", # nitpicky mode
+ "-T", # full tracebacks
+ f"-b={args.builder}",
+ ".",
+ f"_build/{args.builder}",
+ *posargs,
+ )
+
+ if args.serve:
+ session.run("sphinx-autobuild", *shared_args)
+ else:
+ session.run("sphinx-build", "--keep-going", *shared_args)
+
+
+@nox.session
+def build_api_docs(session: nox.Session) -> None:
+ """
+ Build (regenerate) API docs.
+ """
+
+ session.install("sphinx")
+ session.chdir("docs")
+ session.run(
+ "sphinx-apidoc",
+ "-o",
+ "api/",
+ "--module-first",
+ "--no-toc",
+ "--force",
+ "../src/sphinx_deployment",
+ )
+
+
+@nox.session
+def build(session: nox.Session) -> None:
+ """
+ Build an SDist and wheel.
+ """
+
+ build_path = DIR.joinpath("build")
+ if build_path.exists():
+ shutil.rmtree(build_path)
+
+ session.install("build")
+ session.run("python", "-m", "build")
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f526868
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,142 @@
+[build-system]
+requires = ["setuptools>=61", "setuptools_scm[toml]>=7"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "sphinx-deployment"
+authors = [{ name = "msclock", email = "msclock@126.com" }]
+description = "A versioned documentation deployment tool for sphinx."
+dynamic = ["version"]
+readme = "README.md"
+requires-python = ">=3.8"
+classifiers = [
+ "Development Status :: 1 - Planning",
+ "Intended Audience :: Science/Research",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Apache Software License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Framework :: Sphinx :: Extension",
+ "Topic :: Scientific/Engineering",
+ "Typing :: Typed",
+]
+dependencies = ["click", "loguru", "sphinx>=7.0", "gitpython","jinja2"]
+
+[project.optional-dependencies]
+test = ["pytest >=6", "pytest-cov >=3"]
+docs = [
+ "sphinx>=7.0",
+ "furo>=2023.08.17",
+ "myst_parser>=0.13",
+ "sphinx_copybutton",
+ "sphinx_autodoc_typehints",
+ "sphinx-inline-tabs",
+]
+dev = ["sphinx-deployment[test,docs]", "build", "wheel"]
+
+[project.urls]
+Homepage = "https://github.com/msclock/sphinx-deployment"
+"Bug Tracker" = "https://github.com/msclock/sphinx-deployment/issues"
+Discussions = "https://github.com/msclock/sphinx-deployment/discussions"
+Changelog = "https://github.com/msclock/sphinx-deployment/releases"
+
+[project.scripts]
+sphinx_deployment = "sphinx_deployment.cli:commands"
+
+[tool.setuptools_scm]
+write_to = "src/sphinx_deployment/_version.py"
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
+xfail_strict = true
+filterwarnings = ["error"]
+log_cli_level = "INFO"
+testpaths = ["tests"]
+
+[tool.coverage]
+run.source = ["sphinx_deployment"]
+port.exclude_lines = ['pragma: no cover', '\.\.\.', 'if typing.TYPE_CHECKING:']
+
+[tool.mypy]
+files = ["src", "tests"]
+python_version = "3.8"
+warn_unused_configs = true
+strict = true
+enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
+warn_unreachable = true
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[[tool.mypy.overrides]]
+module = "sphinx_deployment.*"
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[tool.ruff]
+src = ["src"]
+
+[tool.ruff.lint]
+extend-select = [
+ "B", # flake8-bugbear
+ "I", # isort
+ "ARG", # flake8-unused-arguments
+ "C4", # flake8-comprehensions
+ "EM", # flake8-errmsg
+ "ICN", # flake8-import-conventions
+ "G", # flake8-logging-format
+ "PGH", # pygrep-hooks
+ "PIE", # flake8-pie
+ "PL", # pylint
+ "PT", # flake8-pytest-style
+ "PTH", # flake8-use-pathlib
+ "RET", # flake8-return
+ "RUF", # Ruff-specific
+ "SIM", # flake8-simplify
+ "T20", # flake8-print
+ "UP", # pyupgrade
+ "YTT", # flake8-2020
+ "EXE", # flake8-executable
+ "NPY", # NumPy specific rules
+ "PD", # pandas-vet
+]
+ignore = [
+ "PLR", # Design related pylint codes
+ "UP006", # Use `list` instead of `typing.List` for type annotation
+]
+isort.required-imports = ["from __future__ import annotations"]
+# Uncomment if using a _compat.typing backport
+# typing-modules = ["sphinx_deployment._compat.typing"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["T20"]
+"noxfile.py" = ["T20"]
+
+[tool.pylint]
+py-version = "3.8"
+ignore-paths = [".*/_version.py"]
+reports.output-format = "colorized"
+similarities.ignore-imports = "yes"
+messages_control.disable = [
+ "design",
+ "fixme",
+ "import-outside-toplevel",
+ "invalid-name",
+ "line-too-long",
+ "missing-class-docstring",
+ "missing-function-docstring",
+ "missing-function-docstring",
+ "missing-module-docstring",
+ "wrong-import-position",
+ "unnecessary-ellipsis", # Conflicts with Protocols
+ "broad-except",
+ "unused-argument", # Handled by Ruff
+ "redefined-builtin", # ExceptionGroup is a builtin
+]
diff --git a/src/sphinx_deployment/__init__.py b/src/sphinx_deployment/__init__.py
new file mode 100644
index 0000000..301e72a
--- /dev/null
+++ b/src/sphinx_deployment/__init__.py
@@ -0,0 +1,13 @@
+"""
+Copyright (c) 2023 msclock. All rights reserved.
+
+sphinx-deployment: A versioned documentation deployment tool for sphinx.
+"""
+
+
+from __future__ import annotations
+
+from ._version import version as __version__
+from .sphinx_ext import setup
+
+__all__ = ["__version__", "setup"]
diff --git a/src/sphinx_deployment/__main__.py b/src/sphinx_deployment/__main__.py
new file mode 100644
index 0000000..841736b
--- /dev/null
+++ b/src/sphinx_deployment/__main__.py
@@ -0,0 +1,9 @@
+"""
+Make the package executable with `python -m sphinx_deployment`.
+"""
+from __future__ import annotations
+
+from sphinx_deployment.cli import commands
+
+if __name__ == "__main__":
+ commands()
diff --git a/src/sphinx_deployment/_version.pyi b/src/sphinx_deployment/_version.pyi
new file mode 100644
index 0000000..91744f9
--- /dev/null
+++ b/src/sphinx_deployment/_version.pyi
@@ -0,0 +1,4 @@
+from __future__ import annotations
+
+version: str
+version_tuple: tuple[int, int, int] | tuple[int, int, int, str, str]
diff --git a/src/sphinx_deployment/cli.py b/src/sphinx_deployment/cli.py
new file mode 100644
index 0000000..0c6fe08
--- /dev/null
+++ b/src/sphinx_deployment/cli.py
@@ -0,0 +1,497 @@
+from __future__ import annotations
+
+import json
+import shutil
+import typing
+from contextlib import contextmanager
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+import click
+from git import Repo
+from jinja2 import Template
+from loguru import logger
+from sphinx.cmd.build import build_main
+
+from sphinx_deployment import __version__
+
+DIR = Path(__file__).parent.resolve()
+
+# click options
+opt_input = click.option(
+ "--input-path",
+ "-I",
+ show_default=True,
+ default="docs",
+ help="Path to input docs folder containing conf.py.",
+)
+opt_output = click.option(
+ "--output-path",
+ "-O",
+ show_default=True,
+ default="public",
+ help="Path to output docs.",
+)
+opt_version = click.option(
+ "--version",
+ "-V",
+ required=True,
+ help="Version to manage.",
+)
+opt_delete = click.option(
+ "--delete-version",
+ "-D",
+ required=True,
+ multiple=True,
+ help="Version to delete.",
+)
+opt_remote = click.option(
+ "--remote",
+ "-R",
+ show_default=True,
+ default="origin",
+ help="Origin to push changes.",
+)
+opt_branch = click.option(
+ "--branch",
+ "-b",
+ show_default=True,
+ default="pages",
+ help="Branch to push changes.",
+)
+opt_message = click.option(
+ "--message",
+ "-m",
+ default="",
+ help="Message to push changes.",
+)
+opt_push = click.option(
+ "--push",
+ "-P",
+ show_default=True,
+ is_flag=True,
+ default=False,
+ help="Push changes to remote.",
+)
+# click commands registry
+commands = click.Group(name="deployment", help="Sphinx Deployment Commands")
+
+# Temporary sections
+
+
+@dataclass()
+class Version:
+ version: str
+ """version of the deployment"""
+
+ title: str
+ """title of the deployment"""
+
+
+@dataclass
+class Versions:
+ default: str = field(default="")
+ """default version of the deployment"""
+
+ versions: typing.Dict[str, Version] = field(default_factory=dict)
+ """versions of the deployment"""
+
+ def __post_init__(self) -> None:
+ self.versions = {
+ k: Version(**v) if isinstance(v, dict) else v
+ for k, v in self.versions.items()
+ }
+
+ def add(self, version: str, title: str = "") -> Version:
+ """
+ Add a new version to the list of versions.
+
+ Parameters:
+ version (str): The version number of the new version.
+ title (str): The title of the new version.
+
+ Returns:
+ Version: The newly added Version object.
+ """
+ if title == "":
+ title = version
+ v = Version(version=version, title=title)
+ self.versions[version] = v
+ return v
+
+ def delete(self, version: str) -> bool:
+ """
+ Delete a version from the list of versions.
+
+ Parameters:
+ version (str): The version number of the version to delete.
+
+ Returns:
+ bool: True if the version was deleted successfully.
+ """
+ if version in self.versions:
+ del self.versions[version]
+ return True
+ return False
+
+
+def sync_remote(remote: str, branch: str) -> bool:
+ """
+ Synchronizes a remote repository with the local repository.
+
+ Args:
+ remote (str): The name of the remote repository to sync.
+ branch (str): The name of the branch to fetch from the remote repository.
+
+ Returns:
+ bool: True if the synchronization was successful.
+ """
+ try:
+ rp = Repo(".")
+ logger.debug(f"repo path: {rp.git_dir}")
+ rp.remote(remote).fetch(branch)
+ return True
+ except Exception as e:
+ logger.warning(f"unable to sync remote: \n{e}")
+ return False
+
+
+def list_versions(branch: str, version_path: str) -> Versions:
+ """
+ Retrieves a list of versions from a given branch and version path.
+
+ Args:
+ branch (str): The name of the branch to retrieve the versions from.
+ version_path (str): The path to the version file within the branch.
+
+ Returns:
+ Versions: An object containing the retrieved versions.
+
+ Raises:
+ Exception: If there is an error retrieving the versions.
+
+ Notes:
+ - This function assumes that the current working directory is the root of the repository.
+ - The `Versions` object is returned even if there is an error retrieving the versions, but it will be empty in that case.
+ """
+ try:
+ rp = Repo(".")
+ versions_json_content = rp.git.execute(
+ command=[
+ "git",
+ "show",
+ f"{branch}:{version_path}",
+ ],
+ with_extended_output=False,
+ as_process=False,
+ stdout_as_string=True,
+ )
+ version_dict = json.loads(versions_json_content)
+ return Versions(**version_dict)
+ except Exception as e:
+ logger.warning(f"unable to checkout branch: {branch} \n{e}")
+ return Versions()
+
+
+def dump_versions(version_path: str, deploy: Versions) -> None:
+ """
+ Write the versions to a JSON file.
+
+ Args:
+ version_path (str): The path to the JSON file.
+ deploy (Versions): The versions to be written.
+
+ Returns:
+ None
+ """
+ with Path(version_path).open("w", encoding="utf-8") as f:
+ json.dump(asdict(deploy), f, indent=4, separators=(",", ": "))
+ f.write("\n")
+
+
+def push_branch(remote: str, branch: str) -> None:
+ """
+ Pushes a branch to a remote repository.
+
+ Args:
+ remote (str): The name of the remote repository to push to.
+ branch (str): The name of the branch to push.
+
+ Returns:
+ None
+ """
+ rp = Repo(".")
+ rp.remote(remote).push(branch)
+ logger.debug(f"pushed branch: {branch} to remote: {remote}")
+
+
+def redirect_impl(template: Path) -> Template:
+ """
+ Returns a Template object by reading the contents of the file "redirect.html"
+ located at the given template path.
+
+ Args:
+ template (Path): The Path object representing the template path.
+
+ Returns:
+ Template: The Template object created from the contents of the file.
+ """
+ with template.joinpath("redirect.html").open("r", encoding="utf-8") as f:
+ return Template(f.read(), autoescape=True, keep_trailing_newline=True)
+
+
+@contextmanager
+def commit_changes(repo: str, message: str) -> typing.Any:
+ """
+ A context manager that commits changes in a Git repository.
+
+ Args:
+ repo (str): The path to the Git repository.
+ message (str): The commit message.
+
+ Yields:
+ typing.Any: The Git repository object.
+ """
+ rp = Repo(repo)
+ ref = rp.head.ref
+ try:
+ rp.git.execute(
+ command=[
+ "git",
+ "restore",
+ "--staged",
+ ".",
+ ]
+ )
+ rp.git.execute(
+ command=[
+ "git",
+ "stash",
+ ]
+ )
+ yield rp
+ finally:
+ rp.index.commit(message)
+ if ref is not None:
+ rp.heads[ref.name].checkout()
+ rp.git.execute(
+ command=[
+ "git",
+ "stash",
+ "pop",
+ ]
+ )
+
+
+# All commands go here
+
+
+@commands.command(name="create", help="Create a new deployment.")
+@opt_output
+@opt_input
+@opt_remote
+@opt_branch
+@opt_version
+@opt_message
+@opt_push
+def create(
+ input_path: str,
+ output_path: str,
+ version: str,
+ remote: str,
+ branch: str,
+ message: str,
+ push: bool,
+) -> None:
+ logger.debug(
+ f"create args: {input_path} {output_path} {push} {remote} {branch} {message} {version}"
+ )
+ _ = sync_remote(remote, branch)
+
+ version_path = Path(output_path) / "versions.json"
+ versions = list_versions(branch, str(version_path))
+ v = versions.add(version)
+ if versions.default == "":
+ versions.default = v.version
+
+ with TemporaryDirectory() as tmp:
+ result = build_main(["-b", "html", input_path, tmp])
+ if result == 2:
+ failed = "sphinx build failed"
+ raise RuntimeError(failed)
+
+ if message == "":
+ message = (
+ f'Deployed {Repo(".").head} to {output_path}/{version} '
+ f"with sphinx-deployment {__version__}"
+ )
+
+ t = redirect_impl(DIR / "templates")
+ redirect_render = t.render(href_to_ver=version + "/index.html")
+
+ with commit_changes(".", message) as repo:
+ rp: Repo = repo
+ if branch not in rp.heads:
+ rp.git.execute(command=["git", "checkout", "--orphan", branch])
+ rp.git.execute(command=["git", "rm", "-rf", "."])
+ else:
+ rp.heads[branch].checkout()
+
+ dest_dir = Path(output_path) / v.version
+ shutil.copytree(tmp, str(dest_dir), dirs_exist_ok=True)
+ rp.index.add([str(dest_dir)])
+
+ redirect_html = Path(output_path) / "index.html"
+ if not redirect_html.exists():
+ with redirect_html.open(
+ mode="w",
+ encoding="utf-8",
+ ) as f:
+ f.write(redirect_render)
+ rp.index.add([str(redirect_html)])
+
+ nojekyll = Path(output_path) / ".nojekyll"
+ if not nojekyll.exists():
+ nojekyll.touch()
+ rp.index.add([str(nojekyll)])
+
+ dump_versions(str(version_path), versions)
+ rp.index.add([str(version_path)])
+
+ if push:
+ push_branch(remote, branch)
+
+
+@commands.command(name="delete", help="Delete a deployment")
+@opt_output
+@opt_input
+@opt_remote
+@opt_branch
+@opt_delete
+@opt_push
+def delete(
+ input_path: str,
+ output_path: str,
+ remote: str,
+ branch: str,
+ delete_version: typing.Tuple[str],
+ push: bool,
+) -> None:
+ logger.debug(f"delete args: {input_path} {output_path} {remote} {branch} {push}")
+ if not sync_remote(remote, branch):
+ logger.warning(f"Sync failed in remote: {remote}")
+
+ version_path = Path(output_path) / "versions.json"
+ versions = list_versions(branch, str(version_path))
+
+ message = f"Deleted {delete_version} from {branch}"
+ with commit_changes(".", message) as repo:
+ rp: Repo = repo
+ rp.heads[branch].checkout()
+
+ for del_ver in delete_version:
+ versions.versions.pop(del_ver)
+ dest_dir = Path(output_path) / del_ver
+ shutil.rmtree(str(dest_dir), ignore_errors=True)
+ rp.index.add([str(dest_dir)])
+
+ dump_versions(str(version_path), versions)
+ rp.index.add([str(version_path)])
+
+ if push:
+ push_branch(remote, branch)
+
+
+@commands.command(name="default", help="Set the default deployment")
+@opt_output
+@opt_input
+@opt_remote
+@opt_branch
+@opt_version
+@opt_message
+@opt_push
+def default(
+ input_path: str,
+ output_path: str,
+ remote: str,
+ branch: str,
+ version: str,
+ message: str,
+ push: bool,
+) -> None:
+ logger.debug(
+ f"default args: {input_path} {output_path} {remote} {branch} {message} {push}"
+ )
+ if not sync_remote(remote, branch):
+ logger.warning(f"Sync failed in remote: {remote}")
+ return
+
+ version_path = Path(output_path) / "versions.json"
+ versions = list_versions(branch, str(version_path))
+
+ if version not in versions.versions:
+ logger.warning(f"Version not found: {version}")
+ return
+
+ versions.default = version
+
+ t = redirect_impl(DIR / "templates")
+
+ with commit_changes(".", message) as repo:
+ rp: Repo = repo
+ rp.heads[branch].checkout()
+
+ root_redirect = Path(output_path) / "index.html"
+ with root_redirect.open(
+ mode="w",
+ encoding="utf-8",
+ ) as f:
+ f.write(
+ t.render(
+ href_to_ver=version + "/index.html",
+ )
+ )
+ rp.index.add([str(root_redirect)])
+
+ dump_versions(str(version_path), versions)
+ rp.index.add([str(version_path)])
+
+ if push:
+ push_branch(remote, branch)
+
+
+@commands.command(name="rename", help="Rename a deployment")
+@opt_output
+@opt_input
+def rename() -> None:
+ logger.debug(__version__)
+
+
+@commands.command(name="list", help="List deployments")
+@opt_output
+@opt_input
+@opt_remote
+@opt_branch
+def list(input_path: str, output_path: str, remote: str, branch: str) -> None:
+ logger.debug(f"list args: {input_path} {output_path} {remote} {branch}")
+ if not sync_remote(remote, branch):
+ logger.warning(f"Sync failed in remote: {remote}")
+
+ version_path = Path(output_path) / "versions.json"
+ versions = list_versions(branch, str(version_path))
+ logger.debug(json.dumps(asdict(versions), indent=4, separators=(",", ": ")))
+
+
+@commands.command(name="serve", help="Serve a deployment")
+@opt_output
+@opt_input
+@opt_remote
+@opt_branch
+def serve(input_path: str, output_path: str, remote: str, branch: str) -> None:
+ logger.debug(f"serve args: {input_path} {output_path} {remote} {branch}")
+
+
+if __name__ == "__main__":
+ # Make the module executable
+ commands()
diff --git a/src/sphinx_deployment/py.typed b/src/sphinx_deployment/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/sphinx_deployment/sphinx_ext.py b/src/sphinx_deployment/sphinx_ext.py
new file mode 100644
index 0000000..2313c42
--- /dev/null
+++ b/src/sphinx_deployment/sphinx_ext.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Any
+
+from loguru import logger
+from sphinx.application import Sphinx
+from sphinx.config import Config
+from sphinx.util.fileutil import copy_asset
+
+from ._version import version
+
+
+def _copy_custom_files(app: Sphinx, exc: Any) -> None:
+ """
+ Copy custom files to the Sphinx output directory if the builder format is HTML and no exception occurred.
+
+ Parameters:
+ app (Sphinx): The Sphinx application object.
+ exc (Any): The exception object.
+
+ Returns:
+ None
+ """
+ if app.builder.format == "html" and not exc:
+ dest_static_dir = Path(app.builder.outdir, "_static")
+ rc_dir = Path(__file__).parent.resolve()
+ _copy_assset_dir_impl(
+ dest_asset_dir=dest_static_dir.joinpath("versioning"),
+ src_assets_dir=rc_dir.joinpath("versioning"),
+ )
+
+
+def _copy_assset_dir_impl(dest_asset_dir: Path, src_assets_dir: Path) -> None:
+ """
+ Copy the contents of the source assets directory to the destination assets directory.
+
+ Args:
+ dest_asset_dir (Path): The path to the destination assets directory.
+ src_assets_dir (Path): The path to the source assets directory.
+
+ Returns:
+ None: This function does not return anything.
+ """
+ if Path(dest_asset_dir).exists():
+ shutil.rmtree(dest_asset_dir)
+ copy_asset(src_assets_dir, dest_asset_dir)
+
+
+def _html_page_context(
+ app: Sphinx,
+ pagename: str,
+ templatename: str,
+ context: dict[str, Any],
+ doctree: object,
+) -> None:
+ """
+ A description of the entire function, its parameters, and its return types.
+
+ Parameters:
+ app (sphinx.application.Sphinx): The app to set up.
+ pagename (str): The name of the page.
+ templatename (str): The name of the template.
+ context (typing.Dict[str, typing.Any]): The context to set up.
+ doctree (object): The doctree to set up.
+
+ Returns:
+ None
+ """
+ _ = (pagename, templatename, context, doctree)
+ logger.debug(
+ f"html_page_context called for {pagename} from {templatename} template"
+ )
+ app.add_css_file("versioning/css/rtd.css", priority=100)
+ app.add_js_file("versioning/js/rtd.js")
+
+
+def _config_inited(app: Sphinx, config: Config) -> None:
+ """
+ A description of the entire function, its parameters, and its return types.
+
+ Parameters:
+ app (sphinx.application.Sphinx): The app to set up.
+ config (sphinx.config.Config): The config to set up.
+
+ Returns:
+ None
+ """
+
+ logger.debug(
+ f"sphinx_deployment meta: \nredirect_type: {config.redirect_type}"
+ f"\nversioning_default: {config.versioning_default}"
+ )
+ logger.debug("app srcdir: %s", app.srcdir)
+ app.connect("html-page-context", _html_page_context)
+
+
+def setup(app: Sphinx) -> dict[str, str | bool]:
+ """
+ Register the extension with Sphinx.
+
+ Parameters:
+ app (sphinx.application.Sphinx): The app to set up.
+
+ Returns:
+ dict[str, str | bool]: A dictionary metadata about the extension.
+ """
+
+ logger.info(f"sphinx_deployment setups docs {version} from {app.confdir}")
+
+ app.add_config_value("versioning_default", "latest", "html")
+ app.add_config_value("redirect_type", "symlink", "html")
+ app.connect("config-inited", _config_inited)
+ app.connect("build-finished", _copy_custom_files)
+
+ return {
+ "version": version,
+ "parallel_read_safe": True,
+ "parallel_write_safe": True,
+ }
diff --git a/src/sphinx_deployment/templates/redirect.html b/src/sphinx_deployment/templates/redirect.html
new file mode 100644
index 0000000..cd08e26
--- /dev/null
+++ b/src/sphinx_deployment/templates/redirect.html
@@ -0,0 +1,18 @@
+
+
+
+
+ Redirecting
+
+
+
+
+ Redirecting to {{href_to_ver}}...
+
+
diff --git a/src/sphinx_deployment/versioning/css/rtd.css b/src/sphinx_deployment/versioning/css/rtd.css
new file mode 100644
index 0000000..453626c
--- /dev/null
+++ b/src/sphinx_deployment/versioning/css/rtd.css
@@ -0,0 +1,145 @@
+.rst-other-versions dl {
+ margin: 0;
+}
+
+a .fa {
+ display: inline-block;
+ text-decoration: inherit;
+}
+
+.rst-versions {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ width: 300px;
+ color: #fcfcfc;
+ background: #1f1d1d;
+ font-family: "Lato", "proxima-nova", "Helvetica Neue", Arial, sans-serif;
+ z-index: 400;
+}
+
+.rst-versions a {
+ color: #2980b9;
+ text-decoration: none;
+}
+
+.rst-versions .rst-badge-small {
+ display: none;
+}
+
+.rst-versions .rst-current-version {
+ padding: 12px;
+ background-color: #272525;
+ display: block;
+ text-align: right;
+ font-size: 90%;
+ cursor: pointer;
+ color: #27ae60;
+}
+
+.rst-versions .rst-current-version:before,
+.rst-versions .rst-current-version:after {
+ display: table;
+ content: "";
+}
+
+.rst-versions .rst-current-version:after {
+ clear: both;
+}
+
+.rst-versions .rst-current-version .fa {
+ color: #fcfcfc;
+}
+
+.rst-versions .rst-current-version .fa-book {
+ float: left;
+}
+
+.rst-versions .rst-current-version.rst-out-of-date {
+ background-color: #e74c3c;
+ color: #fff;
+}
+
+.rst-versions .rst-current-version.rst-active-old-version {
+ background-color: #f1c40f;
+ color: #000;
+}
+
+.rst-versions.shift-up {
+ height: auto;
+ max-height: 100%;
+}
+
+/* display other versions part with block */
+.rst-versions.shift-up .rst-other-versions {
+ display: block;
+}
+
+.rst-versions .rst-other-versions {
+ font-size: 90%;
+ padding: 12px;
+ color: gray;
+ display: none;
+}
+
+.rst-versions .rst-other-versions hr {
+ display: block;
+ height: 1px;
+ border: 0;
+ margin: 20px 0;
+ padding: 0;
+ border-top: solid 1px #413d3d;
+}
+
+.rst-versions .rst-other-versions dd {
+ display: inline-block;
+ margin: 0;
+}
+
+.rst-versions .rst-other-versions dd a {
+ display: inline-block;
+ padding: 6px;
+ color: #fcfcfc;
+}
+
+.rst-versions.rst-badge {
+ width: auto;
+ bottom: 50px;
+ right: 20px;
+ left: auto;
+ border: none;
+ max-width: 300px;
+}
+
+.rst-versions.rst-badge .fa-book {
+ float: none;
+}
+
+.rst-versions.rst-badge.shift-up .rst-current-version {
+ text-align: right;
+}
+
+.rst-versions.rst-badge.shift-up .rst-current-version .fa-book {
+ float: left;
+ line-height: inherit;
+}
+
+.rst-versions.rst-badge .rst-current-version {
+ width: auto;
+ height: 30px;
+ line-height: 30px;
+ padding: 0 6px;
+ display: block;
+ text-align: center;
+}
+
+@media screen and (max-width: 768px) {
+ .rst-versions {
+ width: 85%;
+ display: none;
+ }
+
+ .rst-versions.shift {
+ display: block;
+ }
+}
diff --git a/src/sphinx_deployment/versioning/js/rtd.js b/src/sphinx_deployment/versioning/js/rtd.js
new file mode 100644
index 0000000..8b1d502
--- /dev/null
+++ b/src/sphinx_deployment/versioning/js/rtd.js
@@ -0,0 +1,157 @@
+/**
+ * Handles the click event.
+ *
+ * @param {Event} event - The click event object.
+ * @return {undefined} This function does not return a value.
+ */
+function handleClick(event) {
+ if (event.currentTarget.classList.contains("shift-up"))
+ event.currentTarget.classList.remove("shift-up");
+ else event.currentTarget.classList.add("shift-up");
+}
+
+/**
+ * Locates the URL of the versions.json file by recursively checking parent directories.
+ *
+ * @param {string} url - The URL of the current directory.
+ * @return {string} The URL of the versions.json file, or null if not found.
+ */
+async function locateVersionsJsonUrl(url) {
+ let checkUrl = url.endsWith("/") ? url.slice(0, -1) : url;
+ let response;
+
+ while (checkUrl !== "") {
+ try {
+ response = await fetch(checkUrl + "/versions.json");
+ if (response.ok) {
+ return checkUrl + "/versions.json";
+ }
+ } catch (error) {
+ console.warn("Failed to find versions.json at " + checkUrl + ":", error);
+ }
+
+ checkUrl = checkUrl.substring(0, checkUrl.lastIndexOf("/"));
+ }
+
+ return null;
+}
+
+window.addEventListener("DOMContentLoaded", async function () {
+ try {
+ var versionsJsonUrl = await locateVersionsJsonUrl(
+ this.window.location.href.substring(
+ 0,
+ this.window.location.href.lastIndexOf("/"),
+ ),
+ );
+ } catch (error) {
+ console.error("Failed to find versions.json");
+ }
+ if (versionsJsonUrl === null) {
+ console.error("Failed to find versions.json");
+ return;
+ }
+ var rootUrl = versionsJsonUrl.slice(0, versionsJsonUrl.lastIndexOf("/"));
+ var currentVersionPath = this.window.location.href.substring(rootUrl.length);
+ var currentVersion = currentVersionPath.match(/\/([^\/]+)\//)[1];
+
+ const response = await fetch(versionsJsonUrl);
+ if (!response.ok) {
+ throw new Error("Failed to fetch versions.json");
+ }
+ const versionsJson = await response.json();
+
+ var realVersion = versionsJson.versions.find(function (i) {
+ return i.version === currentVersion;
+ }).version;
+
+ // Create injected div
+ var injectedDiv = document.createElement("div");
+ injectedDiv.setAttribute("id", "injected");
+ document.body.appendChild(injectedDiv);
+
+ // Add style
+ const link1 = document.createElement("link");
+ link1.setAttribute("rel", "stylesheet");
+ link1.setAttribute(
+ "href",
+ "https://www.w3schools.cn/cdnjs/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css",
+ );
+ injectedDiv.appendChild(link1);
+
+ // Create and append the rstVersionsDiv
+ const rstVersionsDiv = document.createElement("div");
+ rstVersionsDiv.setAttribute("class", "rst-versions rst-badge");
+ rstVersionsDiv.addEventListener("click", handleClick);
+ injectedDiv.appendChild(rstVersionsDiv);
+
+ // Current version
+ const rstCurrentVersionSpan = document.createElement("span");
+ rstCurrentVersionSpan.setAttribute("class", "rst-current-version");
+ rstVersionsDiv.appendChild(rstCurrentVersionSpan);
+
+ // Append a book icon
+ const bookIconSpan = document.createElement("span");
+ bookIconSpan.setAttribute("class", "fa fa-book");
+ bookIconSpan.appendChild(document.createTextNode("\u00A0"));
+ rstCurrentVersionSpan.appendChild(bookIconSpan);
+
+ // Add a space
+ rstCurrentVersionSpan.appendChild(document.createTextNode("\u00A0"));
+
+ // Current version
+ const versionText = document.createTextNode(realVersion);
+ rstCurrentVersionSpan.appendChild(versionText);
+
+ // Add a space
+ rstCurrentVersionSpan.appendChild(document.createTextNode("\u00A0"));
+
+ // Append a caret icon
+ const caretSpan = document.createElement("span");
+ caretSpan.setAttribute("class", "fa fa-caret-down");
+ rstCurrentVersionSpan.appendChild(caretSpan);
+
+ // Other versions
+ const rstOtherVersionsDiv = document.createElement("div");
+ rstOtherVersionsDiv.setAttribute("class", "rst-other-versions");
+ rstVersionsDiv.appendChild(rstOtherVersionsDiv);
+
+ // Append a dl element in rstOtherVersionsDiv
+ const dl = document.createElement("dl");
+ rstOtherVersionsDiv.appendChild(dl);
+
+ // Append a dt element in dl
+ const dt = document.createElement("dt");
+ dt.appendChild(document.createTextNode("Versions"));
+ dl.appendChild(dt);
+
+ // Iterate over versions and append dd elements to dl
+ versionsJson.versions.forEach((versionEle) => {
+ const dd = document.createElement("dd");
+ dd.setAttribute("class", "rtd-current-item");
+ const link = document.createElement("a");
+ link.setAttribute("href", rootUrl + "/" + versionEle.version + "/");
+ link.appendChild(document.createTextNode(versionEle.version));
+ dd.appendChild(link);
+ dl.appendChild(dd);
+ });
+
+ // Append an hr element as a separator
+ rstOtherVersionsDiv.appendChild(document.createElement("hr"));
+
+ // Append a small element
+ const small = document.createElement("small");
+ rstOtherVersionsDiv.appendChild(small);
+
+ // Generator info
+ const generatorSpan = document.createElement("span");
+ small.appendChild(generatorSpan);
+ generatorSpan.appendChild(document.createTextNode("Generated by "));
+ const generatorLink = document.createElement("a");
+ generatorSpan.appendChild(generatorLink);
+ generatorLink.setAttribute(
+ "href",
+ "https://github.com/msclock/sphinx-deployment",
+ );
+ generatorLink.appendChild(document.createTextNode("sphinx-deployment"));
+});
diff --git a/tests/test_git.py b/tests/test_git.py
new file mode 100644
index 0000000..0320c5c
--- /dev/null
+++ b/tests/test_git.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from git import Repo
+
+
+def test_init():
+ repo: Repo = Repo.init(".")
+ repo_path = Path().resolve()
+ assert not repo.bare
+ assert repo.git_dir == str(repo_path.joinpath(".git"))
+ assert repo.working_tree_dir == str(repo_path)
diff --git a/tests/test_package.py b/tests/test_package.py
new file mode 100644
index 0000000..07a3138
--- /dev/null
+++ b/tests/test_package.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import importlib.metadata
+
+import sphinx_deployment as m
+
+
+def test_version():
+ assert importlib.metadata.version("sphinx_deployment") == m.__version__