Initial commit

This commit is contained in:
Cyrille GACHOT 2019-02-22 10:46:16 +01:00
commit 870e54ed8a
41 changed files with 3124 additions and 0 deletions

93
.gitignore vendored Normal file
View file

@ -0,0 +1,93 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# 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/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# pipenv
.venv/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Robpol86
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

16
Pipfile Normal file
View file

@ -0,0 +1,16 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
click = "*"
colorclass = "*"
sphinx = "*"
sphinx_rtd_theme = "*"
typing = {version = "*", markers="python_version >= '3.0' and python_version < '3.5'"}
[requires]
python_version = "3"

215
Pipfile.lock generated Normal file
View file

@ -0,0 +1,215 @@
{
"_meta": {
"hash": {
"sha256": "54db8168514bb292bfac3bdc169c417cea36001321d31e51752213ce5d3d5554"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"alabaster": {
"hashes": [
"sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
"sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
],
"version": "==0.7.12"
},
"babel": {
"hashes": [
"sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669",
"sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23"
],
"version": "==2.6.0"
},
"certifi": {
"hashes": [
"sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7",
"sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"
],
"version": "==2018.11.29"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"index": "pypi",
"version": "==7.0"
},
"colorclass": {
"hashes": [
"sha256:b05c2a348dfc1aff2d502527d78a5b7b7e2f85da94a96c5081210d8e9ee8e18b"
],
"index": "pypi",
"version": "==2.2.0"
},
"docutils": {
"hashes": [
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
],
"version": "==0.14"
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
"version": "==2.7"
},
"imagesize": {
"hashes": [
"sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
"sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"
],
"version": "==1.1.0"
},
"jinja2": {
"hashes": [
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
],
"version": "==2.10"
},
"markupsafe": {
"hashes": [
"sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432",
"sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b",
"sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9",
"sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af",
"sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834",
"sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd",
"sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d",
"sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7",
"sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b",
"sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3",
"sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c",
"sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2",
"sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7",
"sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36",
"sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1",
"sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e",
"sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1",
"sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c",
"sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856",
"sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550",
"sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492",
"sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672",
"sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401",
"sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6",
"sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6",
"sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c",
"sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd",
"sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1"
],
"version": "==1.1.0"
},
"packaging": {
"hashes": [
"sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807",
"sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9"
],
"version": "==18.0"
},
"pygments": {
"hashes": [
"sha256:6301ecb0997a52d2d31385e62d0a4a4cf18d2f2da7054a5ddad5c366cd39cee7",
"sha256:82666aac15622bd7bb685a4ee7f6625dd716da3ef7473620c192c0168aae64fc"
],
"version": "==2.3.0"
},
"pyparsing": {
"hashes": [
"sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b",
"sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592"
],
"version": "==2.3.0"
},
"pytz": {
"hashes": [
"sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca",
"sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6"
],
"version": "==2018.7"
},
"requests": {
"hashes": [
"sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54",
"sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263"
],
"version": "==2.20.1"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"snowballstemmer": {
"hashes": [
"sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128",
"sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"
],
"version": "==1.2.1"
},
"sphinx": {
"hashes": [
"sha256:120732cbddb1b2364471c3d9f8bfd4b0c5b550862f99a65736c77f970b142aea",
"sha256:b348790776490894e0424101af9c8413f2a86831524bd55c5f379d3e3e12ca64"
],
"index": "pypi",
"version": "==1.8.2"
},
"sphinx-rtd-theme": {
"hashes": [
"sha256:02f02a676d6baabb758a20c7a479d58648e0f64f13e07d1b388e9bb2afe86a09",
"sha256:d0f6bc70f98961145c5b0e26a992829363a197321ba571b31b24ea91879e0c96"
],
"index": "pypi",
"version": "==0.4.2"
},
"sphinxcontrib-websupport": {
"hashes": [
"sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
"sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9"
],
"version": "==1.1.0"
},
"typing": {
"hashes": [
"sha256:4027c5f6127a6267a435201981ba156de91ad0d1d98e9ddc2aa173453453492d",
"sha256:57dcf675a99b74d64dacf6fba08fb17cf7e3d5fdff53d4a30ea2a5e7e52543d4",
"sha256:a4c8473ce11a65999c8f59cb093e70686b6c84c98df58c1dae9b3b196089858a"
],
"index": "pypi",
"markers": "python_version >= '3.0' and python_version < '3.5'",
"version": "==3.6.6"
},
"urllib3": {
"hashes": [
"sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
"sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
],
"version": "==1.24.1"
}
},
"develop": {}
}

41
README.rst Normal file
View file

@ -0,0 +1,41 @@
===============
sphinx-versions
===============
Sphinx extension that allows building versioned docs for self-hosting.
* Python 3.4, and 3.5 supported on Linux and OS X.
* Python 3.4, and 3.5 supported on Windows (both 32 and 64 bit versions of Python).
Full documentation: https://sphinx-versions.readthedocs.io
This project is, for the most part, a fork of https://github.com/Robpol86/sphinxcontrib-versioning, with some additions and removals.
How to use
==========
Most basic usage:
.. code:: bash
sphinx-versions --help
sphinx-versions build --help
.. changelog-section-start
Changelog
=========
This project adheres to `Semantic Versioning <http://semver.org/>`_.
1.0.0 - 2018-12-08
------------------
Changes
* From *sphinxcontrib-versionning* *v2.2.1*, added compatibility with *Sphinx 1.8.2*.
* From *sphinxcontrib-versionning* *v2.2.1*, removed `push` commands, considered not core for our own usage.
* Migrates to ``pipenv`` as the recommanded installation process.
* Use `-s` option instead of `--no-patch` in `git show` (this is for git 1.8.3.1 compatibility).
.. changelog-section-end

19
docs/Makefile Normal file
View file

@ -0,0 +1,19 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

BIN
docs/_static/Favicon_logo_Smile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

34
docs/banner.rst Normal file
View file

@ -0,0 +1,34 @@
.. _banner:
==============
Banner Message
==============
Banner messages can be displayed at the top of every document informing users that they are currently viewing either old or the development version of the project's documentation, with the exception of the :option:`--banner-main-ref`. This feature is inspired by banner on the `Jinja2 documentation <http://jinja.pocoo.org/docs/dev/>`_.
The banner feature is disabled by default. It can be enabled with the :option:`--show-banner` setting (see the :ref:`settings <setting-show-banner>` for more configuration options).
From branch to `main-ref`
-------------------------
The message displayed when users are viewing docs from a branch and the :option:`--banner-main-ref` is a tag. The entire banner is a link that sends users to the latest version of the current page if it exists there.
.. figure:: screenshots/sphinx_rtd_theme_banner_dev.png
:target: _images/sphinx_rtd_theme_banner_dev.png
From old tag to `main-ref`
--------------------------
The message displayed when users are viewing docs from a tag and the :option:`--banner-main-ref` is a tag. Like the message above this one links users to the latest version of the current page.
.. figure:: screenshots/sphinx_rtd_theme_banner_old.png
:target: _images/sphinx_rtd_theme_banner_old.png
From a page not existing in the `main-ref`
------------------------------------------
An example of a banner message from a page that does not exist in the :option:`--banner-main-ref` version. Since there is no page to link to this is just text informing the user that they're viewing the development version of the docs.
.. figure:: screenshots/sphinx_rtd_theme_banner_nourl.png
:target: _images/sphinx_rtd_theme_banner_nourl.png

5
docs/changelog.rst Normal file
View file

@ -0,0 +1,5 @@
.. _changelog:
.. include:: ../README.rst
:start-after: changelog-section-start
:end-before: changelog-section-end

38
docs/conf.py Normal file
View file

@ -0,0 +1,38 @@
"""Sphinx configuration file."""
import os
import sys
import time
# -- Project information -----------------------------------------------------
copyright = '2018, Smile'
author = 'Smile'
# General configuration.
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
html_last_updated_fmt = '%c'
master_doc = 'index'
project = __import__('setup').NAME
pygments_style = 'friendly'
release = version = __import__('setup').VERSION
templates_path = ['_templates']
extensions = list()
# Options for HTML output.
html_context = dict(
conf_py_path='/docs/',
source_suffix='.rst',
)
html_copy_source = False
html_favicon='_static/Favicon_logo_Smile.png'
html_logo=''
html_theme = 'sphinx_rtd_theme'
html_title = project
# sphinx-versions
scv_banner_greatest_tag = True
scv_show_banner = True
scv_sort = ('semver', 'time')

166
docs/context.rst Normal file
View file

@ -0,0 +1,166 @@
.. _context:
================
HTML Context API
================
The following Jinja2_ context variables are exposed in `the Sphinx HTML builder context <sphinx_context_>`_ in all
versions.
Versions Iterable
=================
``versions`` is the main variable of interest. It yields names of other (and the current) versions and relative URLs to
them. You can iterate on it to get all branches and tags, or use special properties attached to it to yield just
branches or just tags.
.. attribute:: versions
An iterable that yields 2-item tuples of strings. The first item is the version (branch/tag) name while the second
item is the relative path to the documentation for that version. The path is URL safe and takes into account HTML
pages in sub directories.
.. code-block:: jinja
{%- for name, url in versions %}
<li><a href="{{ url }}">{{ name }}</a></li>
{%- endfor %}
.. attribute:: versions.branches
The ``versions`` iterable has a **branches** property that itself yields versions in branches (filtering out git
tags). The order is the same and it yields the same tuples.
.. code-block:: jinja
<dl>
<dt>Branches</dt>
{%- for name, url in versions.branches %}
<dd><a href="{{ url }}">{{ name }}</a></dd>
{%- endfor %}
</dl>
.. attribute:: versions.tags
The ``versions`` iterable also has a **tags** property that itself yields versions in tags (filtering out git
branches). Just as the **branches** property the order is maintained and the yielded tuples are the same.
.. code-block:: jinja
<dl>
<dt>Tags</dt>
{%- for name, url in versions.tags %}
<dd><a href="{{ url }}">{{ name }}</a></dd>
{%- endfor %}
</dl>
Functions
=========
.. function:: vhasdoc(other_version)
Similar to Sphinx's `hasdoc() <sphinx_hasdoc_>`_ function. Returns True if the current document exists in another
version.
.. code-block:: jinja
{% if vhasdoc('master') %}
This doc is available in <a href="../master/index.html">master</a>.
{% endif %}
.. function:: vpathto(other_version)
Similar to Sphinx's `pathto() <sphinx_pathto_>`_ function. Has two behaviors:
1. If the current document exists in the specified other version pathto() returns the relative URL to that document.
2. If the current document does not exist in the other version the relative URL to that version's
`master_doc <sphinx_master_doc_>`_ is returned instead.
.. code-block:: jinja
{% if vhasdoc('master') %}
This doc is available in <a href="{{ vpathto('master') }}">master</a>.
{% else %}
Go to <a href="{{ vpathto('master') }}">master</a> for the latest docs.
{% endif %}
Banner Variables
================
These variables are exposed in the Jinja2 context to facilitate displaying the banner message and deciding which message
to display.
.. attribute:: scv_banner_greatest_tag
A boolean set to True if :option:`--banner-greatest-tag` is used.
.. attribute:: scv_banner_main_ref_is_branch
A boolean set to True if the banner main ref is a branch.
.. attribute:: scv_banner_main_ref_is_tag
A boolean set to True if the banner main ref is a tag.
.. attribute:: scv_banner_main_version
A string, the value of :option:`--banner-main-ref`.
.. attribute:: scv_banner_recent_tag
A boolean set to True if :option:`--banner-recent-tag` is used.
.. attribute:: scv_show_banner
A boolean set to True if :option:`--show-banner` is used.
Other Variables
===============
.. attribute:: current_version
A string of the current version being built. This will be the git ref name (e.g. a branch name or tag name).
.. code-block:: jinja
<h3>Current Version: {{ current_version }}</h3>
.. attribute:: scv_is_branch
A boolean set to True if the current version being built is from a git branch.
.. attribute:: scv_is_greatest_tag
A boolean set to True if the current version being built is:
* From a git tag.
* A valid semver-formatted name (e.g. v1.2.3).
* The highest version number.
.. attribute:: scv_is_recent_branch
A boolean set to True if the current version being built is a git branch and is the most recent commit out of just
git branches.
.. attribute:: scv_is_recent_ref
A boolean set to True if the current version being built is the most recent git commit (branch or tag).
.. attribute:: scv_is_recent_tag
A boolean set to True if the current version being built is a git tag and is the most recent commit out of just git
tags.
.. attribute:: scv_is_root
A boolean set to True if the current version being built is in the web root (defined by :option:`--root-ref`).
.. attribute:: scv_is_tag
A boolean set to True if the current version being built is from a git tag.
.. _Jinja2: http://jinja.pocoo.org/
.. _sphinx_context: http://www.sphinx-doc.org/en/stable/config.html?highlight=context#confval-html_context
.. _sphinx_hasdoc: http://www.sphinx-doc.org/en/stable/templating.html#hasdoc
.. _sphinx_master_doc: http://www.sphinx-doc.org/en/stable/config.html#confval-master_doc
.. _sphinx_pathto: http://www.sphinx-doc.org/en/stable/templating.html#pathto

70
docs/contributing.rst Normal file
View file

@ -0,0 +1,70 @@
.. _contributing:
=================
How to contribute
=================
Make sure you meet the :ref:`requirements to use <requirements-to-use>` the project, first.
Requirements
============
Install the virtualenv dependencies
-----------------------------------
To install required dependencies, use the command:
.. code-block:: shell
$ pipenv install --dev
This will install in your local virtualenv all the required dependencies to contribute to this project.
The ``--dev`` option allows the installation of the *dev-packages* dependencies.
Install build and distribution tools
------------------------------------
* Install latest version of required tools
.. code:: bash
pip install --user -U setuptools wheel twine
Build and upload a new version of sphinx-versions
=================================================
Update the version
------------------
You need to update two different files :
* ``setup.py``: contains the `VERSION` constant, used to identify the version built and uploaded to the nexus.
* ``sphinxcontrib/versioning/__init__.py``: contains the ``__version__`` constant, used to identify the package version.
Update the README.rst
---------------------
You need to add a section in the ``README.rst`` for the newly created version (follow the pattern of other versions).
Generate package to distribute
------------------------------
This builds your python project and creates the `dist` directory (among other things).
.. code:: bash
python3 setup.py sdist bdist_wheel
Upload your package to nexus
----------------------------
.. code:: bash
twine upload dist/*
After this command, your package is available on https://pypi.org. Anyone can install it using `pip install sphinx-versions`.

51
docs/index.rst Normal file
View file

@ -0,0 +1,51 @@
=========================
sphinx-versions |version|
=========================
A Sphinx extension that lets you build Sphinx docs for all versions of your project without needing special hosting
services.
+--------------------+--------------------+
| **A Few Examples** |
+--------------------+--------------------+
| *alabaster* | *sphinx_rtd_theme* |
| | |
| |alabaster| | |sphinx_rtd_theme| |
+--------------------+--------------------+
| *classic* | *nature* |
| | |
| |classic| | |nature| |
+--------------------+--------------------+
.. |alabaster| image:: screenshots/alabaster.png
:target: _images/alabaster.png
.. |sphinx_rtd_theme| image:: screenshots/sphinx_rtd_theme.png
:target: _images/sphinx_rtd_theme.png
.. |classic| image:: screenshots/classic.png
:target: _images/classic.png
.. |nature| image:: screenshots/nature.png
:target: _images/nature.png
Project Links
=============
* Documentation: https://sphinx-versions.readthedocs.io
* Source code: https://github.com/Smile-SA/sphinx-versions
.. toctree::
:maxdepth: 2
:caption: General
install
tutorial
banner
settings
context
themes
.. toctree::
:maxdepth: 1
:caption: Appendix
contributing
changelog

54
docs/install.rst Normal file
View file

@ -0,0 +1,54 @@
.. _install:
============
Installation
============
.. _requirements-to-use:
Requirements
============
Install Python & PIP & pipenv
-----------------------------
First, you need to install Python 3 and PIP (if not already present on your system):
.. code-block:: shell
$ sudo apt-get install python3-pip
You might need to update PIP right away
.. code-block:: shell
$ pip3 install -U pip
Then, install ``pipenv``
.. code-block:: shell
$ pip install --user -U pipenv
Installation
============
`pipenv` install
----------------
The suggested way to get `sphinx-versions` is to use `pipenv <https://pipenv.readthedocs.io>`_. Simply run this command, from your current project:
.. code-block:: bash
pipenv install sphinx-versions
Clone and Install
-----------------
Lastly you can also just clone the repo and install from it. Usually you only need to do this if you plan on :ref:`contributing <contributing>` to the project.
.. code-block:: bash
git clone git@github.com:Smile-SA/sphinx-versions.git
cd sphinx-versions
python setup.py install

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
docs/screenshots/nature.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

272
docs/settings.rst Normal file
View file

@ -0,0 +1,272 @@
.. _settings:
========
Settings
========
.. code-block:: bash
sphinx-versioning [GLOBAL_OPTIONS] build [OPTIONS] REL_SOURCE... DESTINATION
sphinx-versions reads settings from two sources:
* Your Sphinx **conf.py** file.
* Command line arguments.
Command line arguments always override anything set in conf.py. You can specify the path to conf.py with the
:option:`--local-conf` argument or sphinx-versions will look at the first conf.py it finds in your :option:`REL_SOURCE`
directories. To completely disable using a conf.py file specify the :option:`--no-local-conf` command line argument.
Below are both the command line arguments available as well as the conf.py variable names sphinx-versions looks for. All
conf.py variable names are prefixed with ``scv_``.
An example:
.. code-block:: python
# conf.py
author = 'Your Name'
project = 'My Project'
scv_greatest_tag = True
Global Options
==============
These options apply to to :ref:`build <build-arguments>` sub commands. They must be specified before the build command or else you'll get an error.
.. option:: -c <directory>, --chdir <directory>
Change the current working directory of the program to this path.
.. option:: -g <directory>, --git-root <directory>
Path to directory in the local repo. Default is the current working directory.
.. option:: -l <file>, --local-conf <file>
Path to conf.py for sphinx-versions to read its config from. Does not affect conf.py loaded by sphinx-build.
If not specified the default behavior is to have sphinx-versions look for a conf.py file in any :option:`REL_SOURCE`
directory within the current working directory. Stops at the first conf.py found if any.
.. option:: -L, --no-local-conf
Disables searching for or loading a local conf.py for sphinx-versions settings. Does not affect conf.py loaded by
sphinx-build.
.. option:: -N, --no-colors
By default INFO, WARNING, and ERROR log/print statements use console colors. Use this argument to disable colors and
log/print plain text.
.. option:: -v, --verbose
Enable verbose/debug logging with timestamps and git command outputs. Implies :option:`--no-colors`. If specified
more than once excess options (number used - 1) will be passed to sphinx-build.
.. _common-positional-arguments:
Common Positional Arguments
===========================
The :ref:`build <build-arguments>` sub commands use these arguments.
.. option:: REL_SOURCE
The path to the docs directory relative to the git root. If the source directory has moved around between git tags
you can specify additional directories.
This cannot be an absolute path, it must be relative to the root of your git repository. Sometimes projects move
files around so documentation might not always have been in one place. To mitigate this you can specify additional
relative paths and the first one that has a **conf.py** will be selected for each branch/tag. Any branch/tag that
doesn't have a conf.py file in one of these REL_SOURCEs will be ignored.
.. option:: --, scv_overflow
It is possible to give the underlying ``sphinx-build`` program command line options. sphinx-versions passes everything
after ``--`` to it. For example if you changed the theme for your docs between versions and want docs for all
versions to have the same theme, you can run:
.. code-block:: bash
sphinx-versioning build docs docs/_build/html -- -A html_theme=sphinx_rtd_theme
This setting may also be specified in your conf.py file. It must be a tuple of strings:
.. code-block:: python
scv_overflow = ("-A", "html_theme=sphinx_rtd_theme")
.. _build-arguments:
Build Arguments
===============
The ``build`` sub command builds all versions locally. It always gets the latest branches and tags from origin and
builds those doc files.
Positional Arguments
--------------------
In addition to the :ref:`common arguments <common-positional-arguments>`:
.. option:: DESTINATION
The path to the directory that will hold all generated docs for all versions.
This is the local path on the file sytem that will hold HTML files. It can be relative to the current working
directory or an absolute directory path.
.. _build-options:
Options
-------
These options are available for the build sub command:
.. option:: -a, --banner-greatest-tag, scv_banner_greatest_tag
Override banner-main-ref to be the tag with the highest version number. If no tags have docs then this option is
ignored and :option:`--banner-main-ref` is used.
This setting may also be specified in your conf.py file. It must be a boolean:
.. code-block:: python
scv_banner_greatest_tag = True
.. option:: -A, --banner-recent-tag, scv_banner_recent_tag
Override banner-main-ref to be the most recent committed tag. If no tags have docs then this option is ignored and
:option:`--banner-main-ref` is used.
This setting may also be specified in your conf.py file. It must be a boolean:
.. code-block:: python
scv_banner_recent_tag = True
.. _setting-show-banner:
.. option:: -b, --show-banner, scv_show_banner
Show a warning banner. Enables the :ref:`banner` feature.
This setting may also be specified in your conf.py file. It must be a boolean:
.. code-block:: python
scv_show_banner = True
.. option:: -B <ref>, --banner-main-ref <ref>, scv_banner_main_ref
The branch/tag considered to be the latest/current version. The banner will not be displayed in this ref, only in
all others. Default is **master**.
If the banner-main-ref does not exist or does not have docs the banner will be disabled completely in all versions.
Docs will continue to be built.
This setting may also be specified in your conf.py file. It must be a string:
.. code-block:: python
scv_banner_main_ref = 'feature_branch'
.. option:: -i, --invert, scv_invert
Invert the order of branches/tags displayed in the sidebars in generated HTML documents. The default order is
whatever git prints when running "**git ls-remote --heads --tags**".
This setting may also be specified in your conf.py file. It must be a boolean:
.. code-block:: python
scv_invert = True
.. option:: -p <kind>, --priority <kind>, scv_priority
``kind`` may be either **branches** or **tags**. This argument is for themes that don't split up branches and tags
in the generated HTML (e.g. sphinx_rtd_theme). This argument groups branches and tags together and whichever is
selected for ``kind`` will be displayed first.
This setting may also be specified in your conf.py file. It must be a string:
.. code-block:: python
scv_priority = 'branches'
.. option:: -r <ref>, --root-ref <ref>, scv_root_ref
The branch/tag at the root of :option:`DESTINATION`. Will also be in subdirectories like the others. Default is
**master**.
If the root-ref does not exist or does not have docs, ``sphinx-versioning`` will fail and exit. The root-ref must
have docs.
This setting may also be specified in your conf.py file. It must be a string:
.. code-block:: python
scv_root_ref = 'feature_branch'
.. option:: -s <value>, --sort <value>, scv_sort
Sort versions by one or more certain kinds of values. Valid values are ``semver``, ``alpha``, and ``time``.
You can specify just one (e.g. "semver"), or more. The "semver" value sorts versions by
`Semantic Versioning <http://semver.org/>`_, with the highest version being first (e.g. 3.0.0, 2.10.0, 1.0.0).
Non-semver branches/tags will be sorted after all valid semver formats. This is where the multiple sort values come
in. You can specify "alpha" to sort the remainder alphabetically or "time" to sort chronologically (most recent
commit first).
This setting may also be specified in your conf.py file. It must be a tuple of strings:
.. code-block:: python
scv_sort = ('semver',)
.. option:: -t, --greatest-tag, scv_greatest_tag
Override root-ref to be the tag with the highest version number. If no tags have docs then this option is ignored
and :option:`--root-ref` is used.
This setting may also be specified in your conf.py file. It must be a boolean:
.. code-block:: python
scv_greatest_tag = True
.. option:: -T, --recent-tag, scv_recent_tag
Override root-ref to be the most recent committed tag. If no tags have docs then this option is ignored and
:option:`--root-ref` is used.
This setting may also be specified in your conf.py file. It must be a boolean:
.. code-block:: python
scv_recent_tag = True
.. option:: -w <pattern>, --whitelist-branches <pattern>, scv_whitelist_branches
Filter out branches not matching the pattern. Can be a simple string or a regex pattern. Specify multiple times to
include more patterns in the whitelist.
This setting may also be specified in your conf.py file. It must be a tuple of either strings or ``re.compile()``
objects:
.. code-block:: python
scv_whitelist_branches = ('master', 'latest')
.. option:: -W <pattern>, --whitelist-tags <pattern>, scv_whitelist_tags
Same as :option:`--whitelist-branches` but for git tags instead.
This setting may also be specified in your conf.py file. It must be a tuple of either strings or ``re.compile()``
objects:
.. code-block:: python
scv_whitelist_tags = (re.compile(r'^v\d+\.\d+\.\d+$'),)

56
docs/themes.rst Normal file
View file

@ -0,0 +1,56 @@
.. _themes:
================
Supported Themes
================
Below are screen shots of the supported built-in Sphinx themes. You can the "Versions" section in each screen shot on
sidebars.
sphinx_rtd_theme
----------------
.. figure:: screenshots/sphinx_rtd_theme.png
:target: _images/sphinx_rtd_theme.png
alabaster
---------
.. figure:: screenshots/alabaster.png
:target: _images/alabaster.png
classic
-------
.. figure:: screenshots/classic.png
:target: _images/classic.png
nature
------
.. figure:: screenshots/nature.png
:target: _images/nature.png
sphinxdoc
---------
.. figure:: screenshots/sphinxdoc.png
:target: _images/sphinxdoc.png
bizstyle
--------
.. figure:: screenshots/bizstyle.png
:target: _images/bizstyle.png
pyramid
-------
.. figure:: screenshots/pyramid.png
:target: _images/pyramid.png
traditional
-----------
.. figure:: screenshots/traditional.png
:target: _images/traditional.png

62
docs/tutorial.rst Normal file
View file

@ -0,0 +1,62 @@
.. _tutorial:
========
Tutorial
========
This guide will go over the basics of the project.
Make sure that you've already :ref:`installed <install>` it.
.. note::
If you have installed `sphinx-versions` with `pipenv` (which you should), you need to prefix your ``sphinx-versioning`` commands with ``pipenv run ...`` or execute them in your virtualenv (see `pipenv documentation <https://pipenv.readthedocs.io/>`_ for more information on this matter).
Building Docs Locally
=====================
Before we begin make sure you have some Sphinx docs already in your project. If not, read `First Steps with Sphinx <http://www.sphinx-doc.org/en/stable/tutorial.html>`_ first. If you just want something quick
and dirty you can do the following:
.. code-block:: bash
git checkout -b feature_branch master # Create new branch from master.
mkdir docs # All documentation will go here (optional, can be anywhere).
echo "master_doc = 'index'" > docs/conf.py # Create Sphinx config file.
echo -e "Test\n====\n\nSample Documentation" > docs/index.rst # Create one doc.
git add docs
git commit
git push origin feature_branch # Required.
.. note::
It is **required** to push doc files to origin. sphinx-versions only works with remote branches/tags and ignores any
local changes (committed, staged, unstaged, etc). If you don't push to origin sphinx-versions won't see them. This
eliminates race conditions when multiple CI jobs are building docs at the same time.
.. _build-all-versions:
Build All Versions
------------------
Now that you've got docs pushed to origin and they build fine with ``sphinx-build`` let's try building them with
sphinx-versions:
.. code-block:: bash
sphinx-versioning build -r feature_branch docs docs/_build/html
open docs/_build/html/index.html
More information about all of the options can be found at :ref:`settings` or by running with ``--help`` but just for
convenience:
* ``-r feature_branch`` tells the program to build our newly created/pushed branch at the root of the "html" directory.
We do this assuming there are no docs in master yet. Otherwise you can omit this argument.
* ``docs/_build/html`` is the destination directory that holds generated HTML files.
* The final ``docs`` argument is the directory where we put our RST files in, relative to the git root (e.g. if you
clone your repo to another directory, that would be the git root directory). You can add more relative paths if you've
moved the location of your RST files between different branches/tags.
The command should have worked and your docs should be available in `docs/_build/html/index.html` with a "Versions"
section in the sidebar.

105
setup.py Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env python
"""Setup script for the project."""
from __future__ import print_function
import codecs
import os
import re
from setuptools import Command, setup
IMPORT = 'sphinxcontrib.versioning'
INSTALL_REQUIRES = ['click', 'colorclass', 'sphinx']
LICENSE = 'MIT'
NAME = 'sphinx-versions'
VERSION = '1.0.0'
def readme(path='README.rst'):
"""Try to read README.rst or return empty string if failed.
:param str path: Path to README file.
:return: File contents.
:rtype: str
"""
path = os.path.realpath(os.path.join(os.path.dirname(__file__), path))
handle = None
try:
handle = codecs.open(path, encoding='utf-8')
return handle.read(131072)
except IOError:
return ''
finally:
getattr(handle, 'close', lambda: None)()
class CheckVersion(Command):
"""Make sure version strings and other metadata match here, in module/package, and other places."""
description = 'verify consistent version/etc strings in project'
user_options = []
@classmethod
def initialize_options(cls):
"""Required by distutils."""
pass
@classmethod
def finalize_options(cls):
"""Required by distutils."""
pass
@classmethod
def run(cls):
"""Check variables."""
project = __import__(IMPORT, fromlist=[''])
for expected, var in [(LICENSE, '__license__'), (VERSION, '__version__')]:
if getattr(project, var) != expected:
raise SystemExit('Mismatch: {0}'.format(var))
# Check changelog.
if not re.compile(r'^%s - \d{4}-\d{2}-\d{2}[\r\n]' % VERSION, re.MULTILINE).search(readme()):
raise SystemExit('Version not found in readme/changelog file.')
if __name__ == '__main__':
setup(
author='Smile',
author_email='cyrille.gachot@smile.fr',
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Framework :: Sphinx :: Extension',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: MacOS',
'Operating System :: POSIX :: Linux',
'Operating System :: POSIX',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Documentation :: Sphinx',
'Topic :: Software Development :: Documentation',
],
cmdclass=dict(check_version=CheckVersion),
description='Sphinx extension that allows building versioned docs for self-hosting.',
entry_points={'console_scripts': ['sphinx-versioning = sphinxcontrib.versioning.__main__:cli']},
install_requires=INSTALL_REQUIRES,
keywords='sphinx versioning versions version branches tags',
license=LICENSE,
long_description=readme(),
name=NAME,
package_data={'': [
os.path.join('_static', 'banner.css'),
os.path.join('_templates', 'banner.html'),
os.path.join('_templates', 'layout.html'),
os.path.join('_templates', 'versions.html'),
]},
packages=['sphinxcontrib', os.path.join('sphinxcontrib', 'versioning')],
url='https://github.com/Smile-SA/' + NAME,
version=VERSION,
zip_safe=False,
)

View file

@ -0,0 +1,3 @@
"""Declare namespace."""
__import__('pkg_resources').declare_namespace(__name__)

View file

@ -0,0 +1,7 @@
"""
Sphinx extension that allows building versioned docs for self-hosting.
"""
__author__ = 'Smile'
__license__ = 'MIT'
__version__ = '1.0.0'

View file

@ -0,0 +1,319 @@
"""Entry point of project via setuptools which calls cli()."""
import logging
import os
import shutil
import time
import click
from sphinxcontrib.versioning import __version__
from sphinxcontrib.versioning.git import clone, get_root, GitError
from sphinxcontrib.versioning.lib import Config, HandledError, TempDir
from sphinxcontrib.versioning.routines import build_all, gather_git_info, pre_build, read_local_conf
from sphinxcontrib.versioning.setup_logging import setup_logging
from sphinxcontrib.versioning.versions import multi_sort, Versions
IS_EXISTS_DIR = click.Path(exists=True, file_okay=False, dir_okay=True)
IS_EXISTS_FILE = click.Path(exists=True, file_okay=True, dir_okay=False)
NO_EXECUTE = False # Used in tests.
PUSH_RETRIES = 3
PUSH_SLEEP = 3 # Seconds.
class ClickGroup(click.Group):
"""Truncate docstrings at form-feed character and implement overflow arguments."""
def __init__(self, *args, **kwargs):
"""Constructor.
:param list args: Passed to super().
:param dict kwargs: Passed to super().
"""
self.overflow = None
if 'help' in kwargs and kwargs['help'] and '\f' in kwargs['help']:
kwargs['help'] = kwargs['help'].split('\f', 1)[0]
super(ClickGroup, self).__init__(*args, **kwargs)
@staticmethod
def custom_sort(param):
"""Custom Click(Command|Group).params sorter.
Case insensitive sort with capitals after lowercase. --version at the end since I can't sort --help.
:param click.core.Option param: Parameter to evaluate.
:return: Sort weight.
:rtype: int
"""
option = param.opts[0].lstrip('-')
if param.param_type_name != 'option':
return False,
return True, option == 'version', option.lower(), option.swapcase()
def get_params(self, ctx):
"""Sort order of options before displaying.
:param click.core.Context ctx: Click context.
:return: super() return value.
"""
self.params.sort(key=self.custom_sort)
return super(ClickGroup, self).get_params(ctx)
def main(self, *args, **kwargs):
"""Main function called by setuptools.
:param list args: Passed to super().
:param dict kwargs: Passed to super().
:return: super() return value.
"""
argv = kwargs.pop('args', click.get_os_args())
if '--' in argv:
pos = argv.index('--')
argv, self.overflow = argv[:pos], tuple(argv[pos + 1:])
else:
argv, self.overflow = argv, tuple()
return super(ClickGroup, self).main(args=argv, *args, **kwargs)
def invoke(self, ctx):
"""Inject overflow arguments into context state.
:param click.core.Context ctx: Click context.
:return: super() return value.
"""
if self.overflow:
ctx.ensure_object(Config).update(dict(overflow=self.overflow))
return super(ClickGroup, self).invoke(ctx)
class ClickCommand(click.Command):
"""Truncate docstrings at form-feed character for click.command()."""
def __init__(self, *args, **kwargs):
"""Constructor."""
if 'help' in kwargs and kwargs['help'] and '\f' in kwargs['help']:
kwargs['help'] = kwargs['help'].split('\f', 1)[0]
super(ClickCommand, self).__init__(*args, **kwargs)
def get_params(self, ctx):
"""Sort order of options before displaying.
:param click.core.Context ctx: Click context.
:return: super() return value.
"""
self.params.sort(key=ClickGroup.custom_sort)
return super(ClickCommand, self).get_params(ctx)
@click.group(cls=ClickGroup)
@click.option('-c', '--chdir', help='Make this the current working directory before running.', type=IS_EXISTS_DIR)
@click.option('-g', '--git-root', help='Path to directory in the local repo. Default is CWD.', type=IS_EXISTS_DIR)
@click.option('-l', '--local-conf', help='Path to conf.py for sphinx-versions to read config from.', type=IS_EXISTS_FILE)
@click.option('-L', '--no-local-conf', help="Don't attempt to search for nor load a local conf.py file.", is_flag=True)
@click.option('-N', '--no-colors', help='Disable colors in the terminal output.', is_flag=True)
@click.option('-v', '--verbose', help='Debug logging. Specify more than once for more logging.', count=True)
@click.version_option(version=__version__)
@click.make_pass_decorator(Config, ensure=True)
def cli(config, **options):
"""Build versioned Sphinx docs for every branch and tag pushed to origin.
Supports only building locally with the "build" sub command
For more information, run with its own --help.
The options below are global and must be specified before the sub command name (e.g. -N build ...).
\f
:param sphinxcontrib.versioning.lib.Config config: Runtime configuration.
:param dict options: Additional Click options.
"""
def pre(rel_source):
"""To be executed in a Click sub command.
Needed because if this code is in cli() it will be executed when the user runs: <command> <sub command> --help
:param tuple rel_source: Possible relative paths (to git root) of Sphinx directory containing conf.py.
"""
# Setup logging.
if not NO_EXECUTE:
setup_logging(verbose=config.verbose, colors=not config.no_colors)
log = logging.getLogger(__name__)
# Change current working directory.
if config.chdir:
os.chdir(config.chdir)
log.debug('Working directory: %s', os.getcwd())
else:
config.update(dict(chdir=os.getcwd()), overwrite=True)
# Get and verify git root.
try:
config.update(dict(git_root=get_root(config.git_root or os.getcwd())), overwrite=True)
except GitError as exc:
log.error(exc.message)
log.error(exc.output)
raise HandledError
# Look for local config.
if config.no_local_conf:
config.update(dict(local_conf=None), overwrite=True)
elif not config.local_conf:
candidates = [p for p in (os.path.join(s, 'conf.py') for s in rel_source) if os.path.isfile(p)]
if candidates:
config.update(dict(local_conf=candidates[0]), overwrite=True)
else:
log.debug("Didn't find a conf.py in any REL_SOURCE.")
elif os.path.basename(config.local_conf) != 'conf.py':
log.error('Path "%s" must end with conf.py.', config.local_conf)
raise HandledError
config['pre'] = pre # To be called by Click sub commands.
config.update(options)
def build_options(func):
"""Add "build" Click options to function.
:param function func: The function to wrap.
:return: The wrapped function.
:rtype: function
"""
func = click.option('-a', '--banner-greatest-tag', is_flag=True,
help='Override banner-main-ref to be the tag with the highest version number.')(func)
func = click.option('-A', '--banner-recent-tag', is_flag=True,
help='Override banner-main-ref to be the most recent committed tag.')(func)
func = click.option('-b', '--show-banner', help='Show a warning banner.', is_flag=True)(func)
func = click.option('-B', '--banner-main-ref',
help="Don't show banner on this ref and point banner URLs to this ref. Default master.")(func)
func = click.option('-i', '--invert', help='Invert/reverse order of versions.', is_flag=True)(func)
func = click.option('-p', '--priority', type=click.Choice(('branches', 'tags')),
help="Group these kinds of versions at the top (for themes that don't separate them).")(func)
func = click.option('-r', '--root-ref',
help='The branch/tag at the root of DESTINATION. Will also be in subdir. Default master.')(func)
func = click.option('-s', '--sort', multiple=True, type=click.Choice(('semver', 'alpha', 'time')),
help='Sort versions. Specify multiple times to sort equal values of one kind.')(func)
func = click.option('-t', '--greatest-tag', is_flag=True,
help='Override root-ref to be the tag with the highest version number.')(func)
func = click.option('-T', '--recent-tag', is_flag=True,
help='Override root-ref to be the most recent committed tag.')(func)
func = click.option('-w', '--whitelist-branches', multiple=True,
help='Whitelist branches that match the pattern. Can be specified more than once.')(func)
func = click.option('-W', '--whitelist-tags', multiple=True,
help='Whitelist tags that match the pattern. Can be specified more than once.')(func)
return func
def override_root_main_ref(config, remotes, banner):
"""Override root_ref or banner_main_ref with tags in config if user requested.
:param sphinxcontrib.versioning.lib.Config config: Runtime configuration.
:param iter remotes: List of dicts from Versions.remotes.
:param bool banner: Evaluate banner main ref instead of root ref.
:return: If root/main ref exists.
:rtype: bool
"""
log = logging.getLogger(__name__)
greatest_tag = config.banner_greatest_tag if banner else config.greatest_tag
recent_tag = config.banner_recent_tag if banner else config.recent_tag
if greatest_tag or recent_tag:
candidates = [r for r in remotes if r['kind'] == 'tags']
if candidates:
multi_sort(candidates, ['semver' if greatest_tag else 'time'])
config.update({'banner_main_ref' if banner else 'root_ref': candidates[0]['name']}, overwrite=True)
else:
flag = '--banner-main-ref' if banner else '--root-ref'
log.warning('No git tags with docs found in remote. Falling back to %s value.', flag)
ref = config.banner_main_ref if banner else config.root_ref
return ref in [r['name'] for r in remotes]
@cli.command(cls=ClickCommand)
@build_options
@click.argument('REL_SOURCE', nargs=-1, required=True)
@click.argument('DESTINATION', type=click.Path(file_okay=False, dir_okay=True))
@click.make_pass_decorator(Config)
def build(config, rel_source, destination, **options):
"""Fetch branches/tags and build all locally.
Just fetch all remote branches and tags, export them to a temporary directory, run
sphinx-build on each one, and then store all built documentation in DESTINATION.
REL_SOURCE is the path to the docs directory relative to the git root. If the source directory has moved around
between git tags you can specify additional directories.
DESTINATION is the path to the local directory that will hold all generated docs for all versions.
To pass options to sphinx-build (run for every branch/tag) use a double hyphen
(e.g. build docs docs/_build/html -- -D setting=value).
\f
:param sphinxcontrib.versioning.lib.Config config: Runtime configuration.
:param tuple rel_source: Possible relative paths (to git root) of Sphinx directory containing conf.py (e.g. docs).
:param str destination: Destination directory to copy/overwrite built docs to. Does not delete old files.
:param dict options: Additional Click options.
"""
if 'pre' in config:
config.pop('pre')(rel_source)
config.update({k: v for k, v in options.items() if v})
if config.local_conf:
config.update(read_local_conf(config.local_conf), ignore_set=True)
if NO_EXECUTE:
raise RuntimeError(config, rel_source, destination)
log = logging.getLogger(__name__)
# Gather git data.
log.info('Gathering info about the remote git repository...')
conf_rel_paths = [os.path.join(s, 'conf.py') for s in rel_source]
remotes = gather_git_info(config.git_root, conf_rel_paths, config.whitelist_branches, config.whitelist_tags)
if not remotes:
log.error('No docs found in any remote branch/tag. Nothing to do.')
raise HandledError
versions = Versions(
remotes,
sort=config.sort,
priority=config.priority,
invert=config.invert,
)
# Get root ref.
if not override_root_main_ref(config, versions.remotes, False):
log.error('Root ref %s not found in: %s', config.root_ref, ' '.join(r[1] for r in remotes))
raise HandledError
log.info('Root ref is: %s', config.root_ref)
# Get banner main ref.
if not config.show_banner:
config.update(dict(banner_greatest_tag=False, banner_main_ref=None, banner_recent_tag=False), overwrite=True)
elif not override_root_main_ref(config, versions.remotes, True):
log.warning('Banner main ref %s not found in: %s', config.banner_main_ref, ' '.join(r[1] for r in remotes))
log.warning('Disabling banner.')
config.update(dict(banner_greatest_tag=False, banner_main_ref=None, banner_recent_tag=False, show_banner=False),
overwrite=True)
else:
log.info('Banner main ref is: %s', config.banner_main_ref)
# Pre-build.
log.info("Pre-running Sphinx to collect versions' master_doc and other info.")
exported_root = pre_build(config.git_root, versions)
if config.banner_main_ref and config.banner_main_ref not in [r['name'] for r in versions.remotes]:
log.warning('Banner main ref %s failed during pre-run. Disabling banner.', config.banner_main_ref)
config.update(dict(banner_greatest_tag=False, banner_main_ref=None, banner_recent_tag=False, show_banner=False),
overwrite=True)
# Build.
build_all(exported_root, destination, versions)
# Cleanup.
log.debug('Removing: %s', exported_root)
shutil.rmtree(exported_root)
# Store versions in state for push().
config['versions'] = versions

View file

@ -0,0 +1,41 @@
.scv-banner {
padding: 3px;
border-radius: 2px;
font-size: 80%;
text-align: center;
color: white;
background: #d40 linear-gradient(-45deg,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.2) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.2) 75%,
transparent 75%,
transparent
);
background-size: 28px 28px;
}
.scv-banner > a {
color: white;
}
.scv-sphinx_rtd_theme {
background-color: #2980B9;
}
.scv-bizstyle {
background-color: #336699;
}
.scv-classic {
text-align: center !important;
}
.scv-traditional {
text-align: center !important;
}

View file

@ -0,0 +1,31 @@
{# Set banner color via CSS. #}
{%- set banner_classes = 'scv-banner' %}
{%- if html_theme in ('sphinx_rtd_theme', 'bizstyle', 'classic', 'traditional') %}
{%- set banner_classes = banner_classes + ' scv-' + html_theme %}
{%- endif %}
{# Set banner message. #}
{%- if scv_banner_main_version != current_version %}
{# Determine base message. #}
{%- if scv_is_branch %}
{%- set banner_message = '<b>Warning:</b> This document is for the development version of %s.'|format(project) %}
{%- else %}
{%- set banner_message = '<b>Warning:</b> This document is for an old version of %s.'|format(project) %}
{%- endif %}
{# Determine URL of main version. #}
{%- if vhasdoc(scv_banner_main_version) %}
{%- set banner_message = '<a href="%s">' + banner_message + ' The %s version is %s.</a>' %}
{%- if scv_banner_main_ref_is_tag %}
{%- set banner_message = banner_message|format(vpathto(scv_banner_main_version), 'latest', scv_banner_main_version) %}
{%- else %}
{%- set banner_message = banner_message|format(vpathto(scv_banner_main_version), 'main', scv_banner_main_version) %}
{%- endif %}
{%- endif %}
{%- endif %}
{# Display banner. #}
{% block banner %}
{%- if banner_message %}
<p class="{{ banner_classes }}">{{ banner_message }}</p>
{%- endif %}
{% endblock %}

View file

@ -0,0 +1,64 @@
{% if html_theme == 'sphinx_rtd_theme' %}
<div class="rst-versions" data-toggle="rst-versions" role="note" aria-label="versions">
<span class="rst-current-version" data-toggle="rst-current-version">
<span class="fa fa-book"> Other Versions</span>
v: {{ current_version }}
<span class="fa fa-caret-down"></span>
</span>
<div class="rst-other-versions">
{%- if versions.tags %}
<dl>
<dt>Tags</dt>
{%- for name, url in versions.tags %}
<dd><a href="{{ url }}">{{ name }}</a></dd>
{%- endfor %}
</dl>
{%- endif %}
{%- if versions.branches %}
<dl>
<dt>Branches</dt>
{%- for name, url in versions.branches %}
<dd><a href="{{ url }}">{{ name }}</a></dd>
{%- endfor %}
</dl>
{%- endif %}
</div>
</div>
{% elif html_theme == 'rasabaster' %}
<div class="versions">
<p class="caption">Versions</p>
<div class="versions-content">
<div>
<span class="current-version">
viewing: {{ current_version }}
</span>
</div>
<div class="other-versions">
{%- if versions.tags %}
<p>tags</p>
<div class="dropdown-content">
{%- for name, url in versions.tags %}
<a href="{{ url }}">{{ name }}</a>
{%- endfor %}
</div>
{%- endif %}
{%- if versions.branches %}
<p>branches</p>
<div class="dropdown-content">
{%- for name, url in versions.branches %}
<a href="{{ url }}">{{ name }}</a>
{%- endfor %}
</div>
{%- endif %}
</div>
</div>
</div>
{% else %}
<h3>{{ _('Versions') }}</h3>
<ul>
{%- for name, url in versions %}
<li><a href="{{ url }}">{{ name }}</a></li>
{%- endfor %}
</ul>
{%- endif %}

View file

@ -0,0 +1,393 @@
"""Interface with git locally and remotely."""
import glob
import json
import logging
import os
import re
import sys
import tarfile
import time
from datetime import datetime
from subprocess import CalledProcessError, PIPE, Popen, STDOUT
IS_WINDOWS = sys.platform == 'win32'
RE_ALL_REMOTES = re.compile(r'([\w./-]+)\t([A-Za-z0-9@:/\\._-]+) \((fetch|push)\)\n')
RE_REMOTE = re.compile(r'^(?P<sha>[0-9a-f]{5,40})\trefs/(?P<kind>heads|tags)/(?P<name>[\w./-]+(?:\^\{})?)$',
re.MULTILINE)
RE_UNIX_TIME = re.compile(r'^\d{10}$', re.MULTILINE)
WHITELIST_ENV_VARS = (
'APPVEYOR',
'APPVEYOR_ACCOUNT_NAME',
'APPVEYOR_BUILD_ID',
'APPVEYOR_BUILD_NUMBER',
'APPVEYOR_BUILD_VERSION',
'APPVEYOR_FORCED_BUILD',
'APPVEYOR_JOB_ID',
'APPVEYOR_JOB_NAME',
'APPVEYOR_PROJECT_ID',
'APPVEYOR_PROJECT_NAME',
'APPVEYOR_PROJECT_SLUG',
'APPVEYOR_PULL_REQUEST_NUMBER',
'APPVEYOR_PULL_REQUEST_TITLE',
'APPVEYOR_RE_BUILD',
'APPVEYOR_REPO_BRANCH',
'APPVEYOR_REPO_COMMIT',
'APPVEYOR_REPO_NAME',
'APPVEYOR_REPO_PROVIDER',
'APPVEYOR_REPO_TAG',
'APPVEYOR_REPO_TAG_NAME',
'APPVEYOR_SCHEDULED_BUILD',
'CI',
'CI_PULL_REQUEST',
'CI_PULL_REQUESTS',
'CIRCLE_BRANCH',
'CIRCLE_BUILD_IMAGE',
'CIRCLE_BUILD_NUM',
'CIRCLE_BUILD_URL',
'CIRCLE_COMPARE_URL',
'CIRCLE_PR_NUMBER',
'CIRCLE_PR_REPONAME',
'CIRCLE_PR_USERNAME',
'CIRCLE_PREVIOUS_BUILD_NUM',
'CIRCLE_PROJECT_REPONAME',
'CIRCLE_PROJECT_USERNAME',
'CIRCLE_REPOSITORY_URL',
'CIRCLE_SHA1',
'CIRCLE_TAG',
'CIRCLE_USERNAME',
'CIRCLECI',
'HOSTNAME',
'LANG',
'LC_ALL',
'PLATFORM',
'TRAVIS',
'TRAVIS_BRANCH',
'TRAVIS_BUILD_ID',
'TRAVIS_BUILD_NUMBER',
'TRAVIS_COMMIT',
'TRAVIS_COMMIT_RANGE',
'TRAVIS_EVENT_TYPE',
'TRAVIS_JOB_ID',
'TRAVIS_JOB_NUMBER',
'TRAVIS_OS_NAME',
'TRAVIS_PULL_REQUEST',
'TRAVIS_PYTHON_VERSION',
'TRAVIS_REPO_SLUG',
'TRAVIS_SECURE_ENV_VARS',
'TRAVIS_TAG',
'TRAVIS_TEST_RESULT',
'USER',
)
class GitError(Exception):
"""Raised if git exits non-zero."""
def __init__(self, message, output):
"""Constructor."""
self.message = message
self.output = output
super(GitError, self).__init__(message, output)
def chunk(iterator, max_size):
"""Chunk a list/set/etc.
:param iter iterator: The iterable object to chunk.
:param int max_size: Max size of each chunk. Remainder chunk may be smaller.
:return: Yield list of items.
:rtype: iter
"""
gen = iter(iterator)
while True:
chunked = list()
for i, item in enumerate(gen):
chunked.append(item)
if i >= max_size - 1:
break
if not chunked:
return
yield chunked
def run_command(local_root, command, env_var=True, pipeto=None, retry=0, environ=None):
"""Run a command and return the output.
:raise CalledProcessError: Command exits non-zero.
:param str local_root: Local path to git root directory.
:param iter command: Command to run.
:param dict environ: Environment variables to set/override in the command.
:param bool env_var: Define GIT_DIR environment variable (on non-Windows).
:param function pipeto: Pipe `command`'s stdout to this function (only parameter given).
:param int retry: Retry this many times on CalledProcessError after 0.1 seconds.
:return: Command output.
:rtype: str
"""
log = logging.getLogger(__name__)
# Setup env.
env = os.environ.copy()
if environ:
env.update(environ)
if env_var and not IS_WINDOWS:
env['GIT_DIR'] = os.path.join(local_root, '.git')
else:
env.pop('GIT_DIR', None)
# Run command.
with open(os.devnull) as null:
main = Popen(command, cwd=local_root, env=env, stdout=PIPE, stderr=PIPE if pipeto else STDOUT, stdin=null)
if pipeto:
pipeto(main.stdout)
main_output = main.communicate()[1].decode('utf-8') # Might deadlock if stderr is written to a lot.
else:
main_output = main.communicate()[0].decode('utf-8')
log.debug(json.dumps(dict(cwd=local_root, command=command, code=main.poll(), output=main_output)))
# Verify success.
if main.poll() != 0:
if retry < 1:
raise CalledProcessError(main.poll(), command, output=main_output)
time.sleep(0.1)
return run_command(local_root, command, env_var, pipeto, retry - 1)
return main_output
def get_root(directory):
"""Get root directory of the local git repo from any subdirectory within it.
:raise GitError: If git command fails (dir not a git repo?).
:param str directory: Subdirectory in the local repo.
:return: Root directory of repository.
:rtype: str
"""
command = ['git', 'rev-parse', '--show-toplevel']
try:
output = run_command(directory, command, env_var=False)
except CalledProcessError as exc:
raise GitError('Failed to find local git repository root in {}.'.format(repr(directory)), exc.output)
if IS_WINDOWS:
output = output.replace('/', '\\')
return output.strip()
def list_remote(local_root):
"""Get remote branch/tag latest SHAs.
:raise GitError: When git ls-remote fails.
:param str local_root: Local path to git root directory.
:return: List of tuples containing strings. Each tuple is sha, name, kind.
:rtype: list
"""
command = ['git', 'ls-remote', '--heads', '--tags']
try:
output = run_command(local_root, command)
except CalledProcessError as exc:
raise GitError('Git failed to list remote refs.', exc.output)
# Dereference annotated tags if any. No need to fetch annotations.
if '^{}' in output:
parsed = list()
for group in (m.groupdict() for m in RE_REMOTE.finditer(output)):
dereferenced, name, kind = group['name'].endswith('^{}'), group['name'][:-3], group['kind']
if dereferenced and parsed and kind == parsed[-1]['kind'] == 'tags' and name == parsed[-1]['name']:
parsed[-1]['sha'] = group['sha']
else:
parsed.append(group)
else:
parsed = [m.groupdict() for m in RE_REMOTE.finditer(output)]
return [[i['sha'], i['name'], i['kind']] for i in parsed]
def filter_and_date(local_root, conf_rel_paths, commits):
"""Get commit Unix timestamps and first matching conf.py path. Exclude commits with no conf.py file.
:raise CalledProcessError: Unhandled git command failure.
:raise GitError: A commit SHA has not been fetched.
:param str local_root: Local path to git root directory.
:param iter conf_rel_paths: List of possible relative paths (to git root) of Sphinx conf.py (e.g. docs/conf.py).
:param iter commits: List of commit SHAs.
:return: Commit time (seconds since Unix epoch) for each commit and conf.py path. SHA keys and [int, str] values.
:rtype: dict
"""
dates_paths = dict()
# Filter without docs.
for commit in commits:
if commit in dates_paths:
continue
command = ['git', 'ls-tree', '--name-only', '-r', commit] + conf_rel_paths
try:
output = run_command(local_root, command)
except CalledProcessError as exc:
raise GitError('Git ls-tree failed on {0}'.format(commit), exc.output)
if output:
dates_paths[commit] = [None, output.splitlines()[0].strip()]
# Get timestamps by groups of 50.
command_prefix = ['git', 'show', '-s', '--pretty=format:%ct']
for commits_group in chunk(dates_paths, 50):
command = command_prefix + commits_group
output = run_command(local_root, command)
timestamps = [int(i) for i in RE_UNIX_TIME.findall(output)]
for i, commit in enumerate(commits_group):
dates_paths[commit][0] = timestamps[i]
# Done.
return dates_paths
def fetch_commits(local_root, remotes):
"""Fetch from origin.
:raise CalledProcessError: Unhandled git command failure.
:param str local_root: Local path to git root directory.
:param iter remotes: Output of list_remote().
"""
# Fetch all known branches.
command = ['git', 'fetch', 'origin']
run_command(local_root, command)
# Fetch new branches/tags.
for sha, name, kind in remotes:
try:
run_command(local_root, ['git', 'reflog', sha])
except CalledProcessError:
run_command(local_root, command + ['refs/{0}/{1}'.format(kind, name)])
run_command(local_root, ['git', 'reflog', sha])
def export(local_root, commit, target):
"""Export git commit to directory. "Extracts" all files at the commit to the target directory.
Set mtime of RST files to last commit date.
:raise CalledProcessError: Unhandled git command failure.
:param str local_root: Local path to git root directory.
:param str commit: Git commit SHA to export.
:param str target: Directory to export to.
"""
log = logging.getLogger(__name__)
target = os.path.realpath(target)
mtimes = list()
# Define extract function.
def extract(stdout):
"""Extract tar archive from "git archive" stdout.
:param file stdout: Handle to git's stdout pipe.
"""
queued_links = list()
try:
with tarfile.open(fileobj=stdout, mode='r|') as tar:
for info in tar:
log.debug('name: %s; mode: %d; size: %s; type: %s', info.name, info.mode, info.size, info.type)
path = os.path.realpath(os.path.join(target, info.name))
if not path.startswith(target): # Handle bad paths.
log.warning('Ignoring tar object path %s outside of target directory.', info.name)
elif info.isdir(): # Handle directories.
if not os.path.exists(path):
os.makedirs(path, mode=info.mode)
elif info.issym() or info.islnk(): # Queue links.
queued_links.append(info)
else: # Handle files.
tar.extract(member=info, path=target)
if os.path.splitext(info.name)[1].lower() == '.rst':
mtimes.append(info.name)
for info in queued_links:
# There used to be a check for broken symlinks here, but it was buggy
tar.extract(member=info, path=target)
except tarfile.TarError as exc:
log.debug('Failed to extract output from "git archive" command: %s', str(exc))
# Run command.
run_command(local_root, ['git', 'archive', '--format=tar', commit], pipeto=extract)
# Set mtime.
for file_path in mtimes:
last_committed = int(run_command(local_root, ['git', 'log', '-n1', '--format=%at', commit, '--', file_path]))
os.utime(os.path.join(target, file_path), (last_committed, last_committed))
def clone(local_root, new_root, remote, branch, rel_dest, exclude):
"""Clone "local_root" origin into a new directory and check out a specific branch. Optionally run "git rm".
:raise CalledProcessError: Unhandled git command failure.
:raise GitError: Handled git failures.
:param str local_root: Local path to git root directory.
:param str new_root: Local path empty directory in which branch will be cloned into.
:param str remote: The git remote to clone from to.
:param str branch: Checkout this branch.
:param str rel_dest: Run "git rm" on this directory if exclude is truthy.
:param iter exclude: List of strings representing relative file paths to exclude from "git rm".
"""
log = logging.getLogger(__name__)
output = run_command(local_root, ['git', 'remote', '-v'])
remotes = dict()
for match in RE_ALL_REMOTES.findall(output):
remotes.setdefault(match[0], [None, None])
if match[2] == 'fetch':
remotes[match[0]][0] = match[1]
else:
remotes[match[0]][1] = match[1]
if not remotes:
raise GitError('Git repo has no remotes.', output)
if remote not in remotes:
raise GitError('Git repo missing remote "{}".'.format(remote), output)
# Clone.
try:
run_command(new_root, ['git', 'clone', remotes[remote][0], '--depth=1', '--branch', branch, '.'])
except CalledProcessError as exc:
raise GitError('Failed to clone from remote repo URL.', exc.output)
# Make sure user didn't select a tag as their DEST_BRANCH.
try:
run_command(new_root, ['git', 'symbolic-ref', 'HEAD'])
except CalledProcessError as exc:
raise GitError('Specified branch is not a real branch.', exc.output)
# Copy all remotes from original repo.
for name, (fetch, push) in remotes.items():
try:
run_command(new_root, ['git', 'remote', 'set-url' if name == 'origin' else 'add', name, fetch], retry=3)
run_command(new_root, ['git', 'remote', 'set-url', '--push', name, push], retry=3)
except CalledProcessError as exc:
raise GitError('Failed to set git remote URL.', exc.output)
# Done if no exclude.
if not exclude:
return
# Resolve exclude paths.
exclude_joined = [
os.path.relpath(p, new_root) for e in exclude for p in glob.glob(os.path.join(new_root, rel_dest, e))
]
log.debug('Expanded %s to %s', repr(exclude), repr(exclude_joined))
# Do "git rm".
try:
run_command(new_root, ['git', 'rm', '-rf', rel_dest])
except CalledProcessError as exc:
raise GitError('"git rm" failed to remove ' + rel_dest, exc.output)
# Restore files in exclude.
run_command(new_root, ['git', 'reset', 'HEAD'] + exclude_joined)
run_command(new_root, ['git', 'checkout', '--'] + exclude_joined)

View file

@ -0,0 +1,167 @@
"""Common objects used throughout the project."""
import atexit
import functools
import logging
import os
import shutil
import tempfile
import weakref
import click
class Config(object):
"""The global configuration and state of the running program."""
def __init__(self):
"""Constructor."""
self._already_set = set()
self._program_state = dict()
# Booleans.
self.banner_greatest_tag = False
self.banner_recent_tag = False
self.greatest_tag = False
self.invert = False
self.no_colors = False
self.no_local_conf = False
self.recent_tag = False
self.show_banner = False
# Strings.
self.banner_main_ref = 'master'
self.chdir = None
self.git_root = None
self.local_conf = None
self.priority = None
self.root_ref = 'master'
# Tuples.
self.overflow = tuple()
self.sort = tuple()
self.whitelist_branches = tuple()
self.whitelist_tags = tuple()
# Integers.
self.verbose = 0
def __contains__(self, item):
"""Implement 'key in Config'.
:param str item: Key to search for.
:return: If item in self._program_state.
:rtype: bool
"""
return item in self._program_state
def __iter__(self):
"""Yield names and current values of attributes that can be set from Sphinx config files."""
for name in (n for n in dir(self) if not n.startswith('_') and not callable(getattr(self, n))):
yield name, getattr(self, name)
def __repr__(self):
"""Class representation."""
attributes = ('_program_state', 'verbose', 'root_ref', 'overflow')
key_value_attrs = ', '.join('{}={}'.format(a, repr(getattr(self, a))) for a in attributes)
return '<{}.{} {}>'.format(self.__class__.__module__, self.__class__.__name__, key_value_attrs)
def __setitem__(self, key, value):
"""Implement Config[key] = value, updates self._program_state.
:param str key: Key to set in self._program_state.
:param value: Value to set in self._program_state.
"""
self._program_state[key] = value
@classmethod
def from_context(cls):
"""Retrieve this class' instance from the current Click context.
:return: Instance of this class.
:rtype: Config
"""
try:
ctx = click.get_current_context()
except RuntimeError:
return cls()
return ctx.find_object(cls)
def pop(self, *args):
"""Pop item from self._program_state.
:param iter args: Passed to self._program_state.
:return: Object from self._program_state.pop().
"""
return self._program_state.pop(*args)
def update(self, params, ignore_set=False, overwrite=False):
"""Set instance values from dictionary.
:param dict params: Click context params.
:param bool ignore_set: Skip already-set values instead of raising AttributeError.
:param bool overwrite: Allow overwriting already-set values.
"""
log = logging.getLogger(__name__)
valid = {i[0] for i in self}
for key, value in params.items():
if not hasattr(self, key):
raise AttributeError("'{}' object has no attribute '{}'".format(self.__class__.__name__, key))
if key not in valid:
message = "'{}' object does not support item assignment on '{}'"
raise AttributeError(message.format(self.__class__.__name__, key))
if key in self._already_set:
if ignore_set:
log.debug('%s already set in config, skipping.', key)
continue
if not overwrite:
message = "'{}' object does not support item re-assignment on '{}'"
raise AttributeError(message.format(self.__class__.__name__, key))
setattr(self, key, value)
self._already_set.add(key)
class HandledError(click.ClickException):
"""Abort the program."""
def __init__(self):
"""Constructor."""
super(HandledError, self).__init__(None)
def show(self, **_):
"""Error messages should be logged before raising this exception."""
logging.critical('Failure.')
class TempDir(object):
"""Similar to TemporaryDirectory in Python 3.x but with tuned weakref implementation."""
def __init__(self, defer_atexit=False):
"""Constructor.
:param bool defer_atexit: cleanup() to atexit instead of after garbage collection.
"""
self.name = tempfile.mkdtemp('sphinxcontrib_versioning')
if defer_atexit:
atexit.register(shutil.rmtree, self.name, True)
return
try:
weakref.finalize(self, shutil.rmtree, self.name, True)
except AttributeError:
weakref.proxy(self, functools.partial(shutil.rmtree, self.name, True))
def __enter__(self):
"""Return directory path."""
return self.name
def __exit__(self, *_):
"""Cleanup when exiting context."""
self.cleanup()
def cleanup(self):
"""Recursively delete directory."""
shutil.rmtree(self.name, onerror=lambda *a: os.chmod(a[1], __import__('stat').S_IWRITE) or os.unlink(a[1]))
if os.path.exists(self.name):
raise IOError(17, "File exists: '{}'".format(self.name))

View file

@ -0,0 +1,185 @@
"""Functions that perform main tasks. Code is here instead of in __main__.py."""
import json
import logging
import os
import re
import subprocess
from sphinxcontrib.versioning.git import export, fetch_commits, filter_and_date, GitError, list_remote
from sphinxcontrib.versioning.lib import Config, HandledError, TempDir
from sphinxcontrib.versioning.sphinx_ import build, read_config
RE_INVALID_FILENAME = re.compile(r'[^0-9A-Za-z.-]')
def read_local_conf(local_conf):
"""Search for conf.py in any rel_source directory in CWD and if found read it and return.
:param str local_conf: Path to conf.py to read.
:return: Loaded conf.py.
:rtype: dict
"""
log = logging.getLogger(__name__)
# Attempt to read.
log.info('Reading config from %s...', local_conf)
try:
config = read_config(os.path.dirname(local_conf), '<local>')
except HandledError:
log.warning('Unable to read file, continuing with only CLI args.')
return dict()
# Filter and return.
return {k[4:]: v for k, v in config.items() if k.startswith('scv_') and not k[4:].startswith('_')}
def gather_git_info(root, conf_rel_paths, whitelist_branches, whitelist_tags):
"""Gather info about the remote git repository. Get list of refs.
:raise HandledError: If function fails with a handled error. Will be logged before raising.
:param str root: Root directory of repository.
:param iter conf_rel_paths: List of possible relative paths (to git root) of Sphinx conf.py (e.g. docs/conf.py).
:param iter whitelist_branches: Optional list of patterns to filter branches by.
:param iter whitelist_tags: Optional list of patterns to filter tags by.
:return: Commits with docs. A list of tuples: (sha, name, kind, date, conf_rel_path).
:rtype: list
"""
log = logging.getLogger(__name__)
# List remote.
log.info('Getting list of all remote branches/tags...')
try:
remotes = list_remote(root)
except GitError as exc:
log.error(exc.message)
log.error(exc.output)
raise HandledError
log.info('Found: %s', ' '.join(i[1] for i in remotes))
# Filter and date.
try:
try:
dates_paths = filter_and_date(root, conf_rel_paths, (i[0] for i in remotes))
except GitError:
log.info('Need to fetch from remote...')
fetch_commits(root, remotes)
try:
dates_paths = filter_and_date(root, conf_rel_paths, (i[0] for i in remotes))
except GitError as exc:
log.error(exc.message)
log.error(exc.output)
raise HandledError
except subprocess.CalledProcessError as exc:
log.error(json.dumps(dict(command=exc.cmd, cwd=root, code=exc.returncode, output=exc.output)))
log.error('Failed to get dates for all remote commits.')
raise HandledError
filtered_remotes = [[i[0], i[1], i[2], ] + dates_paths[i[0]] for i in remotes if i[0] in dates_paths]
log.info('With docs: %s', ' '.join(i[1] for i in filtered_remotes))
if not whitelist_branches and not whitelist_tags:
return filtered_remotes
# Apply whitelist.
whitelisted_remotes = list()
for remote in filtered_remotes:
if remote[2] == 'heads' and whitelist_branches:
if not any(re.search(p, remote[1]) for p in whitelist_branches):
continue
if remote[2] == 'tags' and whitelist_tags:
if not any(re.search(p, remote[1]) for p in whitelist_tags):
continue
whitelisted_remotes.append(remote)
log.info('Passed whitelisting: %s', ' '.join(i[1] for i in whitelisted_remotes))
return whitelisted_remotes
def pre_build(local_root, versions):
"""Build docs for all versions to determine root directory and master_doc names.
Need to build docs to (a) avoid filename collision with files from root_ref and branch/tag names and (b) determine
master_doc config values for all versions (in case master_doc changes from e.g. contents.rst to index.rst between
versions).
Exports all commits into a temporary directory and returns the path to avoid re-exporting during the final build.
:param str local_root: Local path to git root directory.
:param sphinxcontrib.versioning.versions.Versions versions: Versions class instance.
:return: Tempdir path with exported commits as subdirectories.
:rtype: str
"""
log = logging.getLogger(__name__)
exported_root = TempDir(True).name
# Extract all.
for sha in {r['sha'] for r in versions.remotes}:
target = os.path.join(exported_root, sha)
log.debug('Exporting %s to temporary directory.', sha)
export(local_root, sha, target)
# Build root.
remote = versions[Config.from_context().root_ref]
with TempDir() as temp_dir:
log.debug('Building root (before setting root_dirs) in temporary directory: %s', temp_dir)
source = os.path.dirname(os.path.join(exported_root, remote['sha'], remote['conf_rel_path']))
build(source, temp_dir, versions, remote['name'], True)
existing = os.listdir(temp_dir)
# Define root_dir for all versions to avoid file name collisions.
for remote in versions.remotes:
root_dir = RE_INVALID_FILENAME.sub('_', remote['name'])
while root_dir in existing:
root_dir += '_'
remote['root_dir'] = root_dir
log.debug('%s root directory is %s', remote['name'], root_dir)
existing.append(root_dir)
# Get found_docs and master_doc values for all versions.
for remote in list(versions.remotes):
log.debug('Partially running sphinx-build to read configuration for: %s', remote['name'])
source = os.path.dirname(os.path.join(exported_root, remote['sha'], remote['conf_rel_path']))
try:
config = read_config(source, remote['name'])
except HandledError:
log.warning('Skipping. Will not be building: %s', remote['name'])
versions.remotes.pop(versions.remotes.index(remote))
continue
remote['found_docs'] = config['found_docs']
remote['master_doc'] = config['master_doc']
return exported_root
def build_all(exported_root, destination, versions):
"""Build all versions.
:param str exported_root: Tempdir path with exported commits as subdirectories.
:param str destination: Destination directory to copy/overwrite built docs to. Does not delete old files.
:param sphinxcontrib.versioning.versions.Versions versions: Versions class instance.
"""
log = logging.getLogger(__name__)
while True:
# Build root.
remote = versions[Config.from_context().root_ref]
log.info('Building root: %s', remote['name'])
source = os.path.dirname(os.path.join(exported_root, remote['sha'], remote['conf_rel_path']))
build(source, destination, versions, remote['name'], True)
# Build all refs.
for remote in list(versions.remotes):
log.info('Building ref: %s', remote['name'])
source = os.path.dirname(os.path.join(exported_root, remote['sha'], remote['conf_rel_path']))
target = os.path.join(destination, remote['root_dir'])
try:
build(source, target, versions, remote['name'], False)
except HandledError:
log.warning('Skipping. Will not be building %s. Rebuilding everything.', remote['name'])
versions.remotes.pop(versions.remotes.index(remote))
break # Break out of for loop.
else:
break # Break out of while loop if for loop didn't execute break statement above.

View file

@ -0,0 +1,77 @@
"""Code that handles logging for the project."""
import logging
import logging.handlers
import sys
import colorclass
class ColorFormatter(logging.Formatter):
"""Custom logging formatter that introduces console colors if not verbose."""
SPECIAL_SCOPE = __package__
def __init__(self, verbose, colors):
"""Constructor.
:param bool verbose: Enable verbose logging.
:param bool colors: Enable colored output for statements emitted from this project.
"""
self.verbose = verbose
self.colors = colors
if verbose:
fmt = '%(asctime)s %(process)-5d %(levelname)-8s %(name)-40s %(message)s'
else:
fmt = '%(message)s'
super(ColorFormatter, self).__init__(fmt)
def format(self, record):
"""Apply little arrow and colors to the record.
Arrow and colors are only applied to sphinxcontrib.versioning log statements.
:param logging.LogRecord record: The log record object to log.
"""
formatted = super(ColorFormatter, self).format(record)
if self.verbose or not record.name.startswith(self.SPECIAL_SCOPE):
return formatted
# Arrow.
formatted = '=> ' + formatted
# Colors.
if not self.colors:
return formatted
if record.levelno >= logging.ERROR:
formatted = str(colorclass.Color.red(formatted))
elif record.levelno >= logging.WARNING:
formatted = str(colorclass.Color.yellow(formatted))
else:
formatted = str(colorclass.Color.cyan(formatted))
return formatted
def setup_logging(verbose=0, colors=False, name=None):
"""Configure console logging. Info and below go to stdout, others go to stderr.
:param int verbose: Verbosity level. > 0 print debug statements. > 1 passed to sphinx-build.
:param bool colors: Print color text in non-verbose mode.
:param str name: Which logger name to set handlers to. Used for testing.
"""
root_logger = logging.getLogger(name)
root_logger.setLevel(logging.DEBUG if verbose > 0 else logging.INFO)
formatter = ColorFormatter(verbose > 0, colors)
if colors:
colorclass.Windows.enable()
handler_stdout = logging.StreamHandler(sys.stdout)
handler_stdout.setFormatter(formatter)
handler_stdout.setLevel(logging.DEBUG)
handler_stdout.addFilter(type('', (logging.Filter,), {'filter': staticmethod(lambda r: r.levelno <= logging.INFO)}))
root_logger.addHandler(handler_stdout)
handler_stderr = logging.StreamHandler(sys.stderr)
handler_stderr.setFormatter(formatter)
handler_stderr.setLevel(logging.WARNING)
root_logger.addHandler(handler_stderr)

View file

@ -0,0 +1,278 @@
"""Interface with Sphinx."""
import datetime
import logging
import multiprocessing
import os
import sys
from sphinx import application, locale
from sphinx.cmd.build import build_main
from sphinx.builders.html import StandaloneHTMLBuilder
from sphinx.config import Config as SphinxConfig
from sphinx.errors import SphinxError
from sphinx.jinja2glue import SphinxFileSystemLoader
from sphinx.util.i18n import format_date
from sphinxcontrib.versioning import __version__
from sphinxcontrib.versioning.lib import Config, HandledError, TempDir
from sphinxcontrib.versioning.versions import Versions
SC_VERSIONING_VERSIONS = list() # Updated after forking.
STATIC_DIR = os.path.join(os.path.dirname(__file__), '_static')
class EventHandlers(object):
"""Hold Sphinx event handlers as static or class methods.
:ivar multiprocessing.queues.Queue ABORT_AFTER_READ: Communication channel to parent process.
:ivar bool BANNER_GREATEST_TAG: Banner URLs point to greatest/highest (semver) tag.
:ivar str BANNER_MAIN_VERSION: Banner URLs point to this remote name (from Versions.__getitem__()).
:ivar bool BANNER_RECENT_TAG: Banner URLs point to most recently committed tag.
:ivar str CURRENT_VERSION: Current version being built.
:ivar bool IS_ROOT: Value for context['scv_is_root'].
:ivar bool SHOW_BANNER: Display the banner.
:ivar sphinxcontrib.versioning.versions.Versions VERSIONS: Versions class instance.
"""
ABORT_AFTER_READ = None
BANNER_GREATEST_TAG = False
BANNER_MAIN_VERSION = None
BANNER_RECENT_TAG = False
CURRENT_VERSION = None
IS_ROOT = False
SHOW_BANNER = False
VERSIONS = None
@staticmethod
def builder_inited(app):
"""Update the Sphinx builder.
:param sphinx.application.Sphinx app: Sphinx application object.
"""
# Add this extension's _templates directory to Sphinx.
templates_dir = os.path.join(os.path.dirname(__file__), '_templates')
app.builder.templates.pathchain.insert(0, templates_dir)
app.builder.templates.loaders.insert(0, SphinxFileSystemLoader(templates_dir))
app.builder.templates.templatepathlen += 1
# Add versions.html to sidebar.
if '**' not in app.config.html_sidebars:
# default_sidebars was deprecated in Sphinx 1.6+, so only use it if possible (to maintain
# backwards compatibility), else don't use it.
try:
app.config.html_sidebars['**'] = StandaloneHTMLBuilder.default_sidebars + ['versions.html']
except AttributeError:
app.config.html_sidebars['**'] = ['versions.html']
elif 'versions.html' not in app.config.html_sidebars['**']:
app.config.html_sidebars['**'].append('versions.html')
@classmethod
def env_updated(cls, app, env):
"""Abort Sphinx after initializing config and discovering all pages to build.
:param sphinx.application.Sphinx app: Sphinx application object.
:param sphinx.environment.BuildEnvironment env: Sphinx build environment.
"""
if cls.ABORT_AFTER_READ:
config = {n: getattr(app.config, n) for n in (a for a in dir(app.config) if a.startswith('scv_'))}
config['found_docs'] = tuple(str(d) for d in env.found_docs)
config['master_doc'] = str(app.config.master_doc)
cls.ABORT_AFTER_READ.put(config)
sys.exit(0)
@classmethod
def html_page_context(cls, app, pagename, templatename, context, doctree):
"""Update the Jinja2 HTML context, exposes the Versions class instance to it.
:param sphinx.application.Sphinx app: Sphinx application object.
:param str pagename: Name of the page being rendered (without .html or any file extension).
:param str templatename: Page name with .html.
:param dict context: Jinja2 HTML context.
:param docutils.nodes.document doctree: Tree of docutils nodes.
"""
assert templatename or doctree # Unused, for linting.
cls.VERSIONS.context = context
versions = cls.VERSIONS
this_remote = versions[cls.CURRENT_VERSION]
banner_main_remote = versions[cls.BANNER_MAIN_VERSION] if cls.SHOW_BANNER else None
# Update Jinja2 context.
context['bitbucket_version'] = cls.CURRENT_VERSION
context['current_version'] = cls.CURRENT_VERSION
context['github_version'] = cls.CURRENT_VERSION
context['html_theme'] = app.config.html_theme
context['scv_banner_greatest_tag'] = cls.BANNER_GREATEST_TAG
context['scv_banner_main_ref_is_branch'] = banner_main_remote['kind'] == 'heads' if cls.SHOW_BANNER else None
context['scv_banner_main_ref_is_tag'] = banner_main_remote['kind'] == 'tags' if cls.SHOW_BANNER else None
context['scv_banner_main_version'] = banner_main_remote['name'] if cls.SHOW_BANNER else None
context['scv_banner_recent_tag'] = cls.BANNER_RECENT_TAG
context['scv_is_branch'] = this_remote['kind'] == 'heads'
context['scv_is_greatest_tag'] = this_remote == versions.greatest_tag_remote
context['scv_is_recent_branch'] = this_remote == versions.recent_branch_remote
context['scv_is_recent_ref'] = this_remote == versions.recent_remote
context['scv_is_recent_tag'] = this_remote == versions.recent_tag_remote
context['scv_is_root'] = cls.IS_ROOT
context['scv_is_tag'] = this_remote['kind'] == 'tags'
context['scv_show_banner'] = cls.SHOW_BANNER
context['versions'] = versions
context['vhasdoc'] = versions.vhasdoc
context['vpathto'] = versions.vpathto
# Insert banner into body.
if cls.SHOW_BANNER and 'body' in context:
parsed = app.builder.templates.render('banner.html', context)
context['body'] = parsed + context['body']
# Handle overridden css_files.
css_files = context.setdefault('css_files', list())
if '_static/banner.css' not in css_files:
css_files.append('_static/banner.css')
# Handle overridden html_static_path.
if STATIC_DIR not in app.config.html_static_path:
app.config.html_static_path.append(STATIC_DIR)
# Reset last_updated with file's mtime (will be last git commit authored date).
if app.config.html_last_updated_fmt is not None:
file_path = app.env.doc2path(pagename)
if os.path.isfile(file_path):
lufmt = app.config.html_last_updated_fmt or getattr(locale, '_')('%b %d, %Y')
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
context['last_updated'] = format_date(lufmt, mtime, language=app.config.language)
def setup(app):
"""Called by Sphinx during phase 0 (initialization).
:param sphinx.application.Sphinx app: Sphinx application object.
:returns: Extension version.
:rtype: dict
"""
# Used internally. For rebuilding all pages when one or versions fail.
app.add_config_value('sphinxcontrib_versioning_versions', SC_VERSIONING_VERSIONS, 'html')
# Needed for banner.
app.config.html_static_path.append(STATIC_DIR)
app.add_stylesheet('banner.css')
# Tell Sphinx which config values can be set by the user.
for name, default in Config():
app.add_config_value('scv_{}'.format(name), default, 'html')
# Event handlers.
app.connect('builder-inited', EventHandlers.builder_inited)
app.connect('env-updated', EventHandlers.env_updated)
app.connect('html-page-context', EventHandlers.html_page_context)
return dict(version=__version__)
class ConfigInject(SphinxConfig):
"""Inject this extension info self.extensions. Append after user's extensions."""
def __init__(self, *args):
"""Constructor."""
super(ConfigInject, self).__init__(*args)
self.extensions.append('sphinxcontrib.versioning.sphinx_')
def _build(argv, config, versions, current_name, is_root):
"""Build Sphinx docs via multiprocessing for isolation.
:param tuple argv: Arguments to pass to Sphinx.
:param sphinxcontrib.versioning.lib.Config config: Runtime configuration.
:param sphinxcontrib.versioning.versions.Versions versions: Versions class instance.
:param str current_name: The ref name of the current version being built.
:param bool is_root: Is this build in the web root?
"""
# Patch.
application.Config = ConfigInject
if config.show_banner:
EventHandlers.BANNER_GREATEST_TAG = config.banner_greatest_tag
EventHandlers.BANNER_MAIN_VERSION = config.banner_main_ref
EventHandlers.BANNER_RECENT_TAG = config.banner_recent_tag
EventHandlers.SHOW_BANNER = True
EventHandlers.CURRENT_VERSION = current_name
EventHandlers.IS_ROOT = is_root
EventHandlers.VERSIONS = versions
SC_VERSIONING_VERSIONS[:] = [p for r in versions.remotes for p in sorted(r.items()) if p[0] not in ('sha', 'date')]
# Update argv.
if config.verbose > 1:
argv += ('-v',) * (config.verbose - 1)
if config.no_colors:
argv += ('-N',)
if config.overflow:
argv += config.overflow
# Build.
result = build_main(argv)
if result != 0:
raise SphinxError
def _read_config(argv, config, current_name, queue):
"""Read the Sphinx config via multiprocessing for isolation.
:param tuple argv: Arguments to pass to Sphinx.
:param sphinxcontrib.versioning.lib.Config config: Runtime configuration.
:param str current_name: The ref name of the current version being built.
:param multiprocessing.queues.Queue queue: Communication channel to parent process.
"""
# Patch.
EventHandlers.ABORT_AFTER_READ = queue
# Run.
_build(argv, config, Versions(list()), current_name, False)
def build(source, target, versions, current_name, is_root):
"""Build Sphinx docs for one version. Includes Versions class instance with names/urls in the HTML context.
:raise HandledError: If sphinx-build fails. Will be logged before raising.
:param str source: Source directory to pass to sphinx-build.
:param str target: Destination directory to write documentation to (passed to sphinx-build).
:param sphinxcontrib.versioning.versions.Versions versions: Versions class instance.
:param str current_name: The ref name of the current version being built.
:param bool is_root: Is this build in the web root?
"""
log = logging.getLogger(__name__)
argv = (source, target)
config = Config.from_context()
log.debug('Running sphinx-build for %s with args: %s', current_name, str(argv))
child = multiprocessing.Process(target=_build, args=(argv, config, versions, current_name, is_root))
child.start()
child.join() # Block.
if child.exitcode != 0:
log.error('sphinx-build failed for branch/tag: %s', current_name)
raise HandledError
def read_config(source, current_name):
"""Read the Sphinx config for one version.
:raise HandledError: If sphinx-build fails. Will be logged before raising.
:param str source: Source directory to pass to sphinx-build.
:param str current_name: The ref name of the current version being built.
:return: Specific Sphinx config values.
:rtype: dict
"""
log = logging.getLogger(__name__)
queue = multiprocessing.Queue()
config = Config.from_context()
with TempDir() as temp_dir:
argv = (source, temp_dir)
log.debug('Running sphinx-build for config values with args: %s', str(argv))
child = multiprocessing.Process(target=_read_config, args=(argv, config, current_name, queue))
child.start()
child.join() # Block.
if child.exitcode != 0:
log.error('sphinx-build failed for branch/tag while reading config: %s', current_name)
raise HandledError
config = queue.get()
return config

View file

@ -0,0 +1,241 @@
"""Collect and sort version strings."""
import re
RE_SEMVER = re.compile(r'^v?V?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?([\w.+-]*)$')
def semvers(names):
"""Parse versions into integers and convert non-integer meta indicators into integers with ord().
Each return list item has an indicator as the first item. 0 for valid versions and 1 for invalid. Can be used to
sort non-version names (e.g. master, feature_branch, etc) after valid versions. No sorting is done in this function
though.
Read multi_sort() docstring for reasoning behind inverted integers in version_ints variable.
:param iter names: List of strings representing versions/tags/branches.
:return: List of parsed versions. E.g. v1.10.0b3 -> [0, 1, 10, 0, ord('b'), ord('3')]
:rtype: list
"""
matches = [(RE_SEMVER.findall(n) or [[]])[0] for n in names]
max_len_ints = 0
max_len_str = 0
# Get max lens for padding.
for match in (m for m in matches if m):
max_len_ints = len(match) # Never changes.
max_len_str = max(max_len_str, len(match[-1]))
if not max_len_ints:
return matches # Nothing to do, all empty.
invalid_template = [1] + [0] * (max_len_ints + max_len_str - 1)
# Parse.
exploded_semver = list()
for match in matches:
if not match:
exploded_semver.append(invalid_template[:])
continue
version_ints = [-int(i or 0) for i in match[:-1]]
ints_of_str = [ord(i) for i in match[-1]] + [0] * (max_len_str - len(match[-1]))
exploded_semver.append([0] + version_ints + ints_of_str)
return exploded_semver
def multi_sort(remotes, sort):
"""Sort `remotes` in place. Allows sorting by multiple conditions.
This is needed because Python 3 no longer supports sorting lists of multiple types. Sort keys must all be of the
same type.
Problem: the user expects versions to be sorted latest first and timelogical to be most recent first (when viewing
the HTML documentation), yet expects alphabetical sorting to be A before Z.
Solution: invert integers (dates and parsed versions).
:param iter remotes: List of dicts from Versions().remotes.
:param iter sort: What to sort by. May be one or more of: alpha, time, semver
"""
exploded_alpha = list()
exploded_semver = list()
# Convert name to int if alpha is in sort.
if 'alpha' in sort:
alpha_max_len = max(len(r['name']) for r in remotes)
for name in (r['name'] for r in remotes):
exploded_alpha.append([ord(i) for i in name] + [0] * (alpha_max_len - len(name)))
# Parse versions if semver is in sort.
if 'semver' in sort:
exploded_semver = semvers(r['name'] for r in remotes)
# Build sort_mapping dict.
sort_mapping = dict()
for i, remote in enumerate(remotes):
key = list()
for sort_by in sort:
if sort_by == 'alpha':
key.extend(exploded_alpha[i])
elif sort_by == 'time':
key.append(-remote['date'])
elif sort_by == 'semver':
key.extend(exploded_semver[i])
sort_mapping[id(remote)] = key
# Sort.
remotes.sort(key=lambda k: sort_mapping.get(id(k)))
class Versions(object):
"""Iterable class that holds all versions and handles sorting and filtering. To be fed into Sphinx's Jinja2 env.
:ivar iter remotes: List of dicts for every branch/tag.
:ivar dict context: Current Jinja2 context, provided by Sphinx's html-page-context API hook.
:ivar dict greatest_tag_remote: Tag with the highest version number if it's a valid semver.
:ivar dict recent_branch_remote: Most recently committed branch.
:ivar dict recent_remote: Most recently committed branch/tag.
:ivar dict recent_tag_remote: Most recently committed tag.
"""
def __init__(self, remotes, sort=None, priority=None, invert=False):
"""Constructor.
:param iter remotes: Output of routines.gather_git_info(). Converted to list of dicts as instance variable.
:param iter sort: List of strings (order matters) to sort remotes by. Strings may be: alpha, time, semver
:param str priority: May be "branches" or "tags". Groups either before the other. Maintains order otherwise.
:param bool invert: Invert sorted/grouped remotes at the end of processing.
"""
self.remotes = [dict(
id='/'.join(r[2:0:-1]), # str; kind/name
sha=r[0], # str
name=r[1], # str
kind=r[2], # str
date=r[3], # int
conf_rel_path=r[4], # str
found_docs=tuple(), # tuple of str
master_doc='contents', # str
root_dir=r[1], # str
) for r in remotes]
self.context = dict()
self.greatest_tag_remote = None
self.recent_branch_remote = None
self.recent_remote = None
self.recent_tag_remote = None
# Sort one or more times.
if sort:
multi_sort(self.remotes, [s.strip().lower() for s in sort])
# Priority.
if priority == 'branches':
self.remotes.sort(key=lambda r: 1 if r['kind'] == 'tags' else 0)
elif priority == 'tags':
self.remotes.sort(key=lambda r: 0 if r['kind'] == 'tags' else 1)
# Invert.
if invert:
self.remotes.reverse()
# Get significant remotes.
if self.remotes:
remotes = self.remotes[:]
multi_sort(remotes, ('time',))
self.recent_remote = remotes[0]
self.recent_branch_remote = ([r for r in remotes if r['kind'] != 'tags'] or [None])[0]
self.recent_tag_remote = ([r for r in remotes if r['kind'] == 'tags'] or [None])[0]
if self.recent_tag_remote:
multi_sort(remotes, ('semver',))
greatest_tag_remote = [r for r in remotes if r['kind'] == 'tags'][0]
if RE_SEMVER.search(greatest_tag_remote['name']):
self.greatest_tag_remote = greatest_tag_remote
def __bool__(self):
"""True if self.remotes is not empty. Python 3.x."""
return bool(self.remotes)
def __nonzero__(self):
"""True if self.remotes is not empty. Python 2.x."""
return self.__bool__()
def __len__(self):
"""Length of self.remotes."""
return len(self.remotes)
def __getitem__(self, item):
"""Retrieve a version dict from self.remotes by any of its attributes."""
# First assume item is an attribute.
for key in ('id', 'sha', 'name', 'date'):
for remote in self.remotes:
if remote[key] == item:
return remote
# Next assume item is a substring of a sha.
try:
length = len(item)
except TypeError: # Not an int.
length = 0
if length >= 5:
for remote in self.remotes:
if item in remote['sha']:
return remote
# Finally assume it's an index. Raises IndexError if item is int.
try:
return self.remotes[item]
except TypeError:
pass
# Nothing found, IndexError not raised. item was probably a string, raising KeyError.
raise KeyError(item)
def __iter__(self):
"""Yield name and urls of branches and tags."""
for remote in self.remotes:
name = remote['name']
yield name, self.vpathto(name)
@property
def branches(self):
"""Return list of (name and urls) only branches."""
return [(r['name'], self.vpathto(r['name'])) for r in self.remotes if r['kind'] == 'heads']
@property
def tags(self):
"""Return list of (name and urls) only tags."""
return [(r['name'], self.vpathto(r['name'])) for r in self.remotes if r['kind'] == 'tags']
def vhasdoc(self, other_version):
"""Return True if the other version has the current document. Like Sphinx's hasdoc().
:raise KeyError: If other_version doesn't exist.
:param str other_version: Version to link to.
:return: If current document is in the other version.
:rtype: bool
"""
if self.context['current_version'] == other_version:
return True
return self.context['pagename'] in self[other_version]['found_docs']
def vpathto(self, other_version):
"""Return relative path to current document in another version. Like Sphinx's pathto().
If the current document doesn't exist in the other version its master_doc path is returned instead.
:raise KeyError: If other_version doesn't exist.
:param str other_version: Version to link to.
:return: Relative path.
:rtype: str
"""
is_root = self.context['scv_is_root']
pagename = self.context['pagename']
if self.context['current_version'] == other_version and not is_root:
return '{}.html'.format(pagename.split('/')[-1])
other_remote = self[other_version]
other_root_dir = other_remote['root_dir']
components = ['..'] * pagename.count('/')
components += [other_root_dir] if is_root else ['..', other_root_dir]
components += [pagename if self.vhasdoc(other_version) else other_remote['master_doc']]
return '{}.html'.format(__import__('posixpath').join(*components))