diff --git a/pyproject.toml b/pyproject.toml
index 4571a41..dcafccc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,10 @@ classifiers = [
"Topic :: Documentation",
"Topic :: Software Development :: Documentation",
]
+dependencies = [
+ "httpx>=0.25",
+ 'tomli>=2; python_version < "3.11"',
+]
urls.Code = "https://github.com/python/python-docs-theme"
urls.Download = "https://pypi.org/project/python-docs-theme/"
urls.Homepage = "https://github.com/python/python-docs-theme/"
diff --git a/python_docs_theme/__init__.py b/python_docs_theme/__init__.py
index 7b9df30..fe7a030 100644
--- a/python_docs_theme/__init__.py
+++ b/python_docs_theme/__init__.py
@@ -2,16 +2,85 @@
import hashlib
import os
+import sys
from functools import lru_cache
from pathlib import Path
-from typing import Any
+from typing import Any, Literal
+import httpx
import sphinx.application
from sphinx.builders.html import StandaloneHTMLBuilder
+if sys.version_info[:2] >= (3, 11):
+ import tomllib
+else:
+ import tomli as tomllib
+
THEME_PATH = Path(__file__).parent.resolve()
+def _version_label(
+ version_name: str,
+ status: Literal["feature", "prerelease", "bugfix", "security", "end-of-life"],
+) -> str:
+ if status == "feature":
+ return f"dev ({version_name})"
+ if status == "prerelease":
+ return f"pre ({version_name})"
+ if status in {"end-of-life", "security", "bugfix"}:
+ return version_name
+ msg = f"Unknown status: {status}"
+ raise ValueError(msg)
+
+
+def _builder_inited(app):
+ html_context = app.config.html_context
+ language = app.config.language
+ release = app.config.release
+ if app.config.html_theme != "python_docs_theme":
+ return
+
+ # Get the current branch statuses
+ releases = httpx.get(
+ "https://raw.githubusercontent.com/python/devguide/main/include/release-cycle.json",
+ timeout=30,
+ ).json()
+ # Get appropriate version labels
+ release_labels = {
+ name: _version_label(name, release["status"])
+ for name, release in releases.items()
+ }
+ # Update the current version to be the full release string
+ if (short_version := ".".join(release.split(".", 2)[:2])) in release_labels:
+ release_labels[short_version] = release
+
+ # Store the versions in the context as a sorted list of tuples
+ html_context["switchers_versions"] = sorted(
+ release_labels.items(),
+ key=lambda release_label: tuple(map(int, release_label[0].split("."))),
+ reverse=True,
+ )
+
+ # Get the languages from the docsbuild-scripts config
+ docsbuild_config = httpx.get(
+ "https://raw.githubusercontent.com/python/docsbuild-scripts/main/config.toml",
+ timeout=30,
+ ).text
+ # Convert language tags and extract language names
+ languages = [
+ (iso639_tag.replace("_", "-").lower(), section["name"])
+ for iso639_tag, section in tomllib.loads(docsbuild_config)["languages"].items()
+ if section.get("in_prod", True)
+ ]
+
+ # If we are working on a language that is not in the list, add it
+ if language and language not in dict(languages):
+ languages.append((language, language))
+
+ # Store the versions in the context as a sorted list of tuples
+ html_context["switchers_languages"] = sorted(languages)
+
+
@lru_cache(maxsize=None)
def _asset_hash(path: str) -> str:
"""Append a `?digest=` to an url based on the file content."""
@@ -56,6 +125,7 @@ def setup(app):
current_dir = os.path.abspath(os.path.dirname(__file__))
app.add_html_theme("python_docs_theme", current_dir)
+ app.connect("builder-inited", _builder_inited)
app.connect("html-page-context", _html_page_context)
return {
diff --git a/python_docs_theme/layout.html b/python_docs_theme/layout.html
index 9762b06..0de99c5 100644
--- a/python_docs_theme/layout.html
+++ b/python_docs_theme/layout.html
@@ -17,8 +17,20 @@
{% if theme_root_include_title %}
@@ -74,6 +86,7 @@
{{ _('Navigation') }}
{%- if builder != "htmlhelp" %}
{%- if not embedded %}
+
diff --git a/python_docs_theme/static/switchers.js b/python_docs_theme/static/switchers.js
new file mode 100644
index 0000000..0eafd02
--- /dev/null
+++ b/python_docs_theme/static/switchers.js
@@ -0,0 +1,131 @@
+'use strict';
+
+const _is_file_uri = (uri) => uri.startsWith('file://');
+
+const _IS_LOCAL = _is_file_uri(window.location.href);
+const _CONTENT_ROOT = document.documentElement.dataset.content_root;
+const _CURRENT_PREFIX = _IS_LOCAL
+ ? null
+ : new URL(_CONTENT_ROOT, window.location).pathname;
+const _CURRENT_RELEASE = DOCUMENTATION_OPTIONS.VERSION || '';
+const _CURRENT_VERSION = _CURRENT_RELEASE.split('.').slice(0, 2).join('.');
+const _CURRENT_LANGUAGE = DOCUMENTATION_OPTIONS.LANGUAGE?.toLowerCase() || 'en';
+
+/**
+ * Change the current page to the first existing URL in the list.
+ * @param {Array} urls
+ * @private
+ */
+const _navigate_to_first_existing = async (urls) => {
+ // Navigate to the first existing URL of urls.
+ for (const url of urls) {
+ try {
+ const response = await fetch(url, { method: 'GET' })
+ if (response.ok) {
+ window.location.href = url;
+ return url; // Avoid race conditions with multiple redirects
+ }
+ } catch(err) {
+ console.error(`Error in: ${url}`);
+ console.error(err)
+ }
+ }
+
+ // if all else fails, redirect to the d.p.o root
+ window.location.href = '/';
+};
+
+/**
+ * Navigate to the selected version.
+ * @param {Event} event
+ * @returns {Promise}
+ */
+const on_version_switch = async (event) => {
+ if (_IS_LOCAL) return;
+
+ const selected_version = event.target.value;
+ // Special 'default' case for English.
+ const new_prefix =
+ _CURRENT_LANGUAGE === 'en'
+ ? `/${selected_version}/`
+ : `/${_CURRENT_LANGUAGE}/${selected_version}/`;
+ const new_prefix_en = `/${selected_version}/`;
+ if (_CURRENT_PREFIX !== new_prefix) {
+ // Try the following pages in order:
+ // 1. The current page in the current language with the new version
+ // 2. The current page in English with the new version
+ // 3. The documentation home in the current language with the new version
+ // 4. The documentation home in English with the new version
+ await _navigate_to_first_existing([
+ window.location.href.replace(_CURRENT_PREFIX, new_prefix),
+ window.location.href.replace(_CURRENT_PREFIX, new_prefix_en),
+ new_prefix,
+ new_prefix_en,
+ ]);
+ }
+};
+
+/**
+ * Navigate to the selected language.
+ * @param {Event} event
+ * @returns {Promise}
+ */
+const on_language_switch = async (event) => {
+ if (_IS_LOCAL) return;
+
+ const selected_language = event.target.value;
+ // Special 'default' case for English.
+ const new_prefix =
+ selected_language === 'en'
+ ? `/${_CURRENT_VERSION}/`
+ : `/${selected_language}/${_CURRENT_VERSION}/`;
+ if (_CURRENT_PREFIX !== new_prefix) {
+ // Try the following pages in order:
+ // 1. The current page in the new language with the current version
+ // 2. The documentation home in the new language with the current version
+ await _navigate_to_first_existing([
+ window.location.href.replace(_CURRENT_PREFIX, new_prefix),
+ new_prefix,
+ ]);
+ }
+};
+
+/**
+ * Set up the version and language switchers.
+ * @returns {Promise}
+ */
+const initialise_switchers = async () => {
+ try {
+ // Update the version select elements
+ document
+ .querySelectorAll('.version_switcher_placeholder select')
+ .forEach((select) => {
+ if (_IS_LOCAL) {
+ select.disabled = true;
+ select.title = 'Version switching is disabled in local builds';
+ }
+ select.addEventListener('change', on_version_switch);
+ select.parentElement.classList.remove('version_switcher_placeholder');
+ });
+
+ // Update the language select elements
+ document
+ .querySelectorAll('.language_switcher_placeholder select')
+ .forEach((select) => {
+ if (_IS_LOCAL) {
+ select.disabled = true;
+ select.title = 'Language switching is disabled in local builds';
+ }
+ select.addEventListener('change', on_language_switch);
+ select.parentElement.classList.remove('language_switcher_placeholder');
+ });
+ } catch (error) {
+ console.error(error);
+ }
+};
+
+if (document.readyState !== 'loading') {
+ initialise_switchers();
+} else {
+ document.addEventListener('DOMContentLoaded', initialise_switchers);
+}