summaryrefslogtreecommitdiffstats
path: root/sphinx/theming.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/theming.py')
-rw-r--r--sphinx/theming.py232
1 files changed, 232 insertions, 0 deletions
diff --git a/sphinx/theming.py b/sphinx/theming.py
new file mode 100644
index 0000000..a8a3f83
--- /dev/null
+++ b/sphinx/theming.py
@@ -0,0 +1,232 @@
+"""Theming support for HTML builders."""
+
+from __future__ import annotations
+
+import configparser
+import os
+import shutil
+import sys
+import tempfile
+from os import path
+from typing import TYPE_CHECKING, Any
+from zipfile import ZipFile
+
+if sys.version_info >= (3, 10):
+ from importlib.metadata import entry_points
+else:
+ from importlib_metadata import entry_points
+
+import contextlib
+
+from sphinx import package_dir
+from sphinx.errors import ThemeError
+from sphinx.locale import __
+from sphinx.util import logging
+from sphinx.util.osutil import ensuredir
+
+if TYPE_CHECKING:
+ from sphinx.application import Sphinx
+
+
+logger = logging.getLogger(__name__)
+
+NODEFAULT = object()
+THEMECONF = 'theme.conf'
+
+
+def extract_zip(filename: str, targetdir: str) -> None:
+ """Extract zip file to target directory."""
+ ensuredir(targetdir)
+
+ with ZipFile(filename) as archive:
+ for name in archive.namelist():
+ if name.endswith('/'):
+ continue
+ entry = path.join(targetdir, name)
+ ensuredir(path.dirname(entry))
+ with open(path.join(entry), 'wb') as fp:
+ fp.write(archive.read(name))
+
+
+class Theme:
+ """A Theme is a set of HTML templates and configurations.
+
+ This class supports both theme directory and theme archive (zipped theme)."""
+
+ def __init__(self, name: str, theme_path: str, factory: HTMLThemeFactory) -> None:
+ self.name = name
+ self.base = None
+ self.rootdir = None
+
+ if path.isdir(theme_path):
+ # already a directory, do nothing
+ self.rootdir = None
+ self.themedir = theme_path
+ else:
+ # extract the theme to a temp directory
+ self.rootdir = tempfile.mkdtemp('sxt')
+ self.themedir = path.join(self.rootdir, name)
+ extract_zip(theme_path, self.themedir)
+
+ self.config = configparser.RawConfigParser()
+ self.config.read(path.join(self.themedir, THEMECONF), encoding='utf-8')
+
+ try:
+ inherit = self.config.get('theme', 'inherit')
+ except configparser.NoSectionError as exc:
+ raise ThemeError(__('theme %r doesn\'t have "theme" setting') % name) from exc
+ except configparser.NoOptionError as exc:
+ raise ThemeError(__('theme %r doesn\'t have "inherit" setting') % name) from exc
+
+ if inherit != 'none':
+ try:
+ self.base = factory.create(inherit)
+ except ThemeError as exc:
+ raise ThemeError(__('no theme named %r found, inherited by %r') %
+ (inherit, name)) from exc
+
+ def get_theme_dirs(self) -> list[str]:
+ """Return a list of theme directories, beginning with this theme's,
+ then the base theme's, then that one's base theme's, etc.
+ """
+ if self.base is None:
+ return [self.themedir]
+ else:
+ return [self.themedir] + self.base.get_theme_dirs()
+
+ def get_config(self, section: str, name: str, default: Any = NODEFAULT) -> Any:
+ """Return the value for a theme configuration setting, searching the
+ base theme chain.
+ """
+ try:
+ return self.config.get(section, name)
+ except (configparser.NoOptionError, configparser.NoSectionError) as exc:
+ if self.base:
+ return self.base.get_config(section, name, default)
+
+ if default is NODEFAULT:
+ raise ThemeError(__('setting %s.%s occurs in none of the '
+ 'searched theme configs') % (section, name)) from exc
+ return default
+
+ def get_options(self, overrides: dict[str, Any] | None = None) -> dict[str, Any]:
+ """Return a dictionary of theme options and their values."""
+ if overrides is None:
+ overrides = {}
+
+ if self.base:
+ options = self.base.get_options()
+ else:
+ options = {}
+
+ with contextlib.suppress(configparser.NoSectionError):
+ options.update(self.config.items('options'))
+
+ for option, value in overrides.items():
+ if option not in options:
+ logger.warning(__('unsupported theme option %r given') % option)
+ else:
+ options[option] = value
+
+ return options
+
+ def cleanup(self) -> None:
+ """Remove temporary directories."""
+ if self.rootdir:
+ with contextlib.suppress(Exception):
+ shutil.rmtree(self.rootdir)
+
+ if self.base:
+ self.base.cleanup()
+
+
+def is_archived_theme(filename: str) -> bool:
+ """Check whether the specified file is an archived theme file or not."""
+ try:
+ with ZipFile(filename) as f:
+ return THEMECONF in f.namelist()
+ except Exception:
+ return False
+
+
+class HTMLThemeFactory:
+ """A factory class for HTML Themes."""
+
+ def __init__(self, app: Sphinx) -> None:
+ self.app = app
+ self.themes = app.registry.html_themes
+ self.load_builtin_themes()
+ if getattr(app.config, 'html_theme_path', None):
+ self.load_additional_themes(app.config.html_theme_path)
+
+ def load_builtin_themes(self) -> None:
+ """Load built-in themes."""
+ themes = self.find_themes(path.join(package_dir, 'themes'))
+ for name, theme in themes.items():
+ self.themes[name] = theme
+
+ def load_additional_themes(self, theme_paths: str) -> None:
+ """Load additional themes placed at specified directories."""
+ for theme_path in theme_paths:
+ abs_theme_path = path.abspath(path.join(self.app.confdir, theme_path))
+ themes = self.find_themes(abs_theme_path)
+ for name, theme in themes.items():
+ self.themes[name] = theme
+
+ def load_extra_theme(self, name: str) -> None:
+ """Try to load a theme with the specified name."""
+ if name == 'alabaster':
+ self.load_alabaster_theme()
+ else:
+ self.load_external_theme(name)
+
+ def load_alabaster_theme(self) -> None:
+ """Load alabaster theme."""
+ import alabaster
+ self.themes['alabaster'] = path.join(alabaster.get_path(), 'alabaster')
+
+ def load_external_theme(self, name: str) -> None:
+ """Try to load a theme using entry_points.
+
+ Sphinx refers to ``sphinx_themes`` entry_points.
+ """
+ # look up for new styled entry_points at first
+ theme_entry_points = entry_points(group='sphinx.html_themes')
+ try:
+ entry_point = theme_entry_points[name]
+ self.app.registry.load_extension(self.app, entry_point.module)
+ self.app.config.post_init_values()
+ return
+ except KeyError:
+ pass
+
+ def find_themes(self, theme_path: str) -> dict[str, str]:
+ """Search themes from specified directory."""
+ themes: dict[str, str] = {}
+ if not path.isdir(theme_path):
+ return themes
+
+ for entry in os.listdir(theme_path):
+ pathname = path.join(theme_path, entry)
+ if path.isfile(pathname) and entry.lower().endswith('.zip'):
+ if is_archived_theme(pathname):
+ name = entry[:-4]
+ themes[name] = pathname
+ else:
+ logger.warning(__('file %r on theme path is not a valid '
+ 'zipfile or contains no theme'), entry)
+ else:
+ if path.isfile(path.join(pathname, THEMECONF)):
+ themes[entry] = pathname
+
+ return themes
+
+ def create(self, name: str) -> Theme:
+ """Create an instance of theme."""
+ if name not in self.themes:
+ self.load_extra_theme(name)
+
+ if name not in self.themes:
+ raise ThemeError(__('no theme named %r found (missing theme.conf?)') % name)
+
+ return Theme(name, self.themes[name], factory=self)