diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py
index e14b7c10e..b65e6b914 100644
--- a/sphinx/builders/html/__init__.py
+++ b/sphinx/builders/html/__init__.py
@@ -884,7 +884,7 @@ class StandaloneHTMLBuilder(Builder):
def cleanup(self) -> None:
# clean up theme stuff
if self.theme:
- self.theme.cleanup()
+ self.theme._cleanup()
def post_process_images(self, doctree: Node) -> None:
"""Pick the best candidate for an image and link down-scaled images to
diff --git a/sphinx/theming.py b/sphinx/theming.py
index 49bf22eb2..4172f1796 100644
--- a/sphinx/theming.py
+++ b/sphinx/theming.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import configparser
+import contextlib
import os
import shutil
import sys
@@ -11,13 +12,6 @@ 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.config import check_confval_types as _config_post_init
from sphinx.errors import ThemeError
@@ -25,25 +19,31 @@ from sphinx.locale import __
from sphinx.util import logging
from sphinx.util.osutil import ensuredir
+if sys.version_info >= (3, 10):
+ from importlib.metadata import entry_points
+else:
+ from importlib_metadata import entry_points
+
if TYPE_CHECKING:
from sphinx.application import Sphinx
+__all__ = 'Theme', 'HTMLThemeFactory'
logger = logging.getLogger(__name__)
-NODEFAULT = object()
-THEMECONF = 'theme.conf'
+_NO_DEFAULT = object()
+_THEME_CONF = 'theme.conf'
-def extract_zip(filename: str, targetdir: str) -> None:
+def _extract_zip(filename: str, target_dir: str, /) -> None:
"""Extract zip file to target directory."""
- ensuredir(targetdir)
+ ensuredir(target_dir)
with ZipFile(filename) as archive:
for name in archive.namelist():
if name.endswith('/'):
continue
- entry = path.join(targetdir, name)
+ entry = path.join(target_dir, name)
ensuredir(path.dirname(entry))
with open(path.join(entry), 'wb') as fp:
fp.write(archive.read(name))
@@ -55,23 +55,22 @@ class Theme:
This class supports both theme directory and theme archive (zipped theme).
"""
- def __init__(self, name: str, theme_path: str, factory: HTMLThemeFactory) -> None:
+ def __init__(self, name: str, theme_path: str, theme_factory: HTMLThemeFactory) -> None:
self.name = name
- self.base = None
- self.rootdir = None
+ self._base: Theme | None = None
if path.isdir(theme_path):
# already a directory, do nothing
- self.rootdir = None
- self.themedir = theme_path
+ self._root_dir = None
+ self._theme_dir = 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._root_dir = tempfile.mkdtemp('sxt')
+ self._theme_dir = path.join(self._root_dir, name)
+ _extract_zip(theme_path, self._theme_dir)
self.config = configparser.RawConfigParser()
- config_file_path = path.join(self.themedir, THEMECONF)
+ config_file_path = path.join(self._theme_dir, _THEME_CONF)
if not os.path.isfile(config_file_path):
raise ThemeError(__('theme configuration file %r not found') % config_file_path)
self.config.read(config_file_path, encoding='utf-8')
@@ -85,7 +84,7 @@ class Theme:
if inherit != 'none':
try:
- self.base = factory.create(inherit)
+ self._base = theme_factory.create(inherit)
except ThemeError as exc:
raise ThemeError(__('no theme named %r found, inherited by %r') %
(inherit, name)) from exc
@@ -94,24 +93,24 @@ class Theme:
"""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]
+ if self._base is None:
+ return [self._theme_dir]
else:
- return [self.themedir] + self.base.get_theme_dirs()
+ return [self._theme_dir] + self._base.get_theme_dirs()
- def get_config(self, section: str, name: str, default: Any = NODEFAULT) -> Any:
+ def get_config(self, section: str, name: str, default: Any = _NO_DEFAULT) -> 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)
+ except (configparser.NoOptionError, configparser.NoSectionError):
+ if self._base:
+ return self._base.get_config(section, name, default)
- if default is NODEFAULT:
+ if default is _NO_DEFAULT:
raise ThemeError(__('setting %s.%s occurs in none of the '
- 'searched theme configs') % (section, name)) from exc
+ 'searched theme configs') % (section, name)) from None
return default
def get_options(self, overrides: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -119,8 +118,8 @@ class Theme:
if overrides is None:
overrides = {}
- if self.base:
- options = self.base.get_options()
+ if self._base:
+ options = self._base.get_options()
else:
options = {}
@@ -135,21 +134,21 @@ class Theme:
return options
- def cleanup(self) -> None:
+ def _cleanup(self) -> None:
"""Remove temporary directories."""
- if self.rootdir:
+ if self._root_dir:
with contextlib.suppress(Exception):
- shutil.rmtree(self.rootdir)
+ shutil.rmtree(self._root_dir)
- if self.base:
- self.base.cleanup()
+ if self._base is not None:
+ self._base._cleanup()
-def is_archived_theme(filename: str) -> bool:
+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()
+ return _THEME_CONF in f.namelist()
except Exception:
return False
@@ -158,27 +157,27 @@ 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()
+ 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)
+ self._load_additional_themes(app.config.html_theme_path)
- def load_builtin_themes(self) -> None:
+ def _load_builtin_themes(self) -> None:
"""Load built-in themes."""
- themes = self.find_themes(path.join(package_dir, 'themes'))
+ themes = self._find_themes(path.join(package_dir, 'themes'))
for name, theme in themes.items():
- self.themes[name] = theme
+ self._themes[name] = theme
- def load_additional_themes(self, theme_paths: str) -> None:
+ 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)
+ 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
+ self._themes[name] = theme
- def load_extra_theme(self, name: str) -> None:
+ def _load_extra_theme(self, name: str) -> None:
"""Try to load a theme with the specified name.
This uses the ``sphinx.html_themes`` entry point from package metadata.
@@ -189,10 +188,10 @@ class HTMLThemeFactory:
except KeyError:
pass
else:
- self.app.registry.load_extension(self.app, entry_point.module)
- _config_post_init(None, self.app.config)
+ self._app.registry.load_extension(self._app, entry_point.module)
+ _config_post_init(None, self._app.config)
- def find_themes(self, theme_path: str) -> dict[str, str]:
+ 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):
@@ -201,24 +200,24 @@ class HTMLThemeFactory:
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):
+ 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)):
+ if path.isfile(path.join(pathname, _THEME_CONF)):
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:
+ self._load_extra_theme(name)
- if name not in self.themes:
+ 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)
+ return Theme(name, self._themes[name], self)
diff --git a/tests/test_theming/test_theming.py b/tests/test_theming/test_theming.py
index 0544c710c..2b1801f4a 100644
--- a/tests/test_theming/test_theming.py
+++ b/tests/test_theming/test_theming.py
@@ -13,8 +13,6 @@ from sphinx.theming import Theme, ThemeError
confoverrides={'html_theme': 'ziptheme',
'html_theme_options.testopt': 'foo'})
def test_theme_api(app, status, warning):
- cfg = app.config
-
themes = ['basic', 'default', 'scrolls', 'agogo', 'sphinxdoc', 'haiku',
'traditional', 'epub', 'nature', 'pyramid', 'bizstyle', 'classic', 'nonav',
'test-theme', 'ziptheme', 'staticfiles', 'parent', 'child', 'alabaster']
@@ -28,8 +26,8 @@ def test_theme_api(app, status, warning):
# test Theme instance API
theme = app.builder.theme
assert theme.name == 'ziptheme'
- themedir = theme.themedir
- assert theme.base.name == 'basic'
+ theme_dir = theme._theme_dir
+ assert theme._base.name == 'basic'
assert len(theme.get_theme_dirs()) == 2
# direct setting
@@ -46,13 +44,13 @@ def test_theme_api(app, status, warning):
options = theme.get_options({'nonexisting': 'foo'})
assert 'nonexisting' not in options
- options = theme.get_options(cfg.html_theme_options)
+ options = theme.get_options(app.config.html_theme_options)
assert options['testopt'] == 'foo'
assert options['nosidebar'] == 'false'
# cleanup temp directories
- theme.cleanup()
- assert not os.path.exists(themedir)
+ theme._cleanup()
+ assert not os.path.exists(theme_dir)
def test_nonexistent_theme_conf(tmp_path):