From 4a80f8a66381bd992ca69ad941e2ef41f80341ab Mon Sep 17 00:00:00 2001 From: msclock Date: Thu, 28 Dec 2023 08:03:16 +0800 Subject: [PATCH] init repo Signed-off-by: msclock --- .copier-answers.yml | 12 + .git_archival.txt | 4 + .gitattributes | 1 + .github/CONTRIBUTING.md | 101 ++++ .github/dependabot.yml | 7 + .github/matchers/pylint.json | 32 + .github/workflows/cd.yml | 90 +++ .github/workflows/ci.yml | 106 ++++ .github/workflows/preview.yml | 39 ++ .gitignore | 158 +++++ .pre-commit-config.yaml | 96 +++ .readthedocs.yml | 18 + .vscode/launch.json | 35 ++ .vscode/settings.json | 5 + CHANGELOG.md | 0 LICENSE | 202 +++++++ README.md | 19 + docs/_templates/versioning.html | 43 ++ docs/api/sphinx_deployment.rst | 26 + docs/changelog.md | 5 + docs/conf.py | 52 ++ docs/index.md | 41 ++ noxfile.py | 117 ++++ pyproject.toml | 142 +++++ src/sphinx_deployment/__init__.py | 13 + src/sphinx_deployment/__main__.py | 9 + src/sphinx_deployment/_version.pyi | 4 + src/sphinx_deployment/cli.py | 558 ++++++++++++++++++ src/sphinx_deployment/py.typed | 0 src/sphinx_deployment/sphinx_ext.py | 121 ++++ src/sphinx_deployment/templates/redirect.html | 18 + src/sphinx_deployment/versioning/css/rtd.css | 145 +++++ src/sphinx_deployment/versioning/js/rtd.js | 157 +++++ tests/test_git.py | 13 + tests/test_package.py | 9 + 35 files changed, 2398 insertions(+) create mode 100644 .copier-answers.yml create mode 100644 .git_archival.txt create mode 100644 .gitattributes create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/dependabot.yml create mode 100644 .github/matchers/pylint.json create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/preview.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .readthedocs.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/_templates/versioning.html create mode 100644 docs/api/sphinx_deployment.rst create mode 100644 docs/changelog.md create mode 100644 docs/conf.py create mode 100644 docs/index.md create mode 100644 noxfile.py create mode 100644 pyproject.toml create mode 100644 src/sphinx_deployment/__init__.py create mode 100644 src/sphinx_deployment/__main__.py create mode 100644 src/sphinx_deployment/_version.pyi create mode 100644 src/sphinx_deployment/cli.py create mode 100644 src/sphinx_deployment/py.typed create mode 100644 src/sphinx_deployment/sphinx_ext.py create mode 100644 src/sphinx_deployment/templates/redirect.html create mode 100644 src/sphinx_deployment/versioning/css/rtd.css create mode 100644 src/sphinx_deployment/versioning/js/rtd.js create mode 100644 tests/test_git.py create mode 100644 tests/test_package.py 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..aaf0412 --- /dev/null +++ b/src/sphinx_deployment/cli.py @@ -0,0 +1,558 @@ +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", + "-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(".") + rp.remote(remote).fetch(branch) + return True + except Exception: + logger.warning(f"Sync failed with {remote}/{branch}") + 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 (pathlib.Path): The Path object representing the template path. + + Returns: + jinja2.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_command( + 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_message +@opt_push +def delete_command( + input_path: str, + output_path: str, + remote: str, + branch: str, + delete: typing.Tuple[str], + message: str, + push: bool, +) -> None: + logger.debug(f"delete args: {input_path} {output_path} {remote} {branch} {push}") + _ = sync_remote(remote, branch) + + version_path = Path(output_path) / "versions.json" + versions = list_versions(branch, str(version_path)) + + if message == "": + message = ( + f"Deleted {delete} from {branch} " f"with sphinx-deployment {__version__}" + ) + with commit_changes(".", message) as repo: + rp: Repo = repo + rp.heads[branch].checkout() + + for del_ver in delete: + 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)]) + if del_ver == versions.default: + shutil.rmtree(str(dest_dir / "index.html"), ignore_errors=True) + rp.index.add([str(dest_dir / "index.html")]) + + 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_command( + 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}" + ) + _ = sync_remote(remote, branch) + + 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") + redirect_render = t.render(href_to_ver=version + "/index.html") + + if message == "": + message = ( + f'Defaulted {Repo(".").head} to {output_path}/{version} ' + f"with sphinx-deployment {__version__}" + ) + + 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(redirect_render) + if rp.is_dirty(untracked_files=True, path=root_redirect): + 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 +@opt_remote +@opt_branch +@opt_message +@opt_push +@click.argument("src", nargs=1) +@click.argument("dst", nargs=1) +def rename_command( + input_path: str, + output_path: str, + remote: str, + branch: str, + message: str, + push: bool, + src: str, + dst: str, +) -> None: + logger.debug( + f"rename args: {input_path} {output_path} {remote} {branch} {push} {src} {dst}" + ) + + if src == dst: + logger.warning(f"Source and destination are the same: {src}") + return + + _ = sync_remote(remote, branch) + + version_path = Path(output_path) / "versions.json" + versions = list_versions(branch, str(version_path)) + + if src not in versions.versions: + logger.warning(f"Version not found: {src}") + return + + if dst in versions.versions: + logger.warning(f"Version already exists: {dst}") + return + + if message == "": + message = ( + f"Renamed {src} to {dst} in {branch} " + f"with sphinx-deployment {__version__}" + ) + + with commit_changes(".", message) as repo: + rp: Repo = repo + rp.heads[branch].checkout() + + pop_v = versions.versions.pop(src) + pop_v.version = dst + versions.versions[dst] = pop_v + rp.index.move(output_path + src, output_path + dst) + rp.index.add([output_path + src, output_path + dst]) + dump_versions(str(version_path), versions) + rp.index.add([str(version_path)]) + + if push: + push_branch(remote, branch) + + +@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}") + _ = sync_remote(remote, branch) + + 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__