Make various theme-related objects private

This commit is contained in:
Adam Turner 2024-01-09 00:19:28 +00:00 committed by Adam Turner
parent 2ccc9d315e
commit 0ca2ddf924
3 changed files with 66 additions and 69 deletions

View File

@ -884,7 +884,7 @@ class StandaloneHTMLBuilder(Builder):
def cleanup(self) -> None: def cleanup(self) -> None:
# clean up theme stuff # clean up theme stuff
if self.theme: if self.theme:
self.theme.cleanup() self.theme._cleanup()
def post_process_images(self, doctree: Node) -> None: def post_process_images(self, doctree: Node) -> None:
"""Pick the best candidate for an image and link down-scaled images to """Pick the best candidate for an image and link down-scaled images to

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import configparser import configparser
import contextlib
import os import os
import shutil import shutil
import sys import sys
@ -11,13 +12,6 @@ from os import path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from zipfile import ZipFile 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 import package_dir
from sphinx.config import check_confval_types as _config_post_init from sphinx.config import check_confval_types as _config_post_init
from sphinx.errors import ThemeError from sphinx.errors import ThemeError
@ -25,25 +19,31 @@ from sphinx.locale import __
from sphinx.util import logging from sphinx.util import logging
from sphinx.util.osutil import ensuredir 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: if TYPE_CHECKING:
from sphinx.application import Sphinx from sphinx.application import Sphinx
__all__ = 'Theme', 'HTMLThemeFactory'
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
NODEFAULT = object() _NO_DEFAULT = object()
THEMECONF = 'theme.conf' _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.""" """Extract zip file to target directory."""
ensuredir(targetdir) ensuredir(target_dir)
with ZipFile(filename) as archive: with ZipFile(filename) as archive:
for name in archive.namelist(): for name in archive.namelist():
if name.endswith('/'): if name.endswith('/'):
continue continue
entry = path.join(targetdir, name) entry = path.join(target_dir, name)
ensuredir(path.dirname(entry)) ensuredir(path.dirname(entry))
with open(path.join(entry), 'wb') as fp: with open(path.join(entry), 'wb') as fp:
fp.write(archive.read(name)) fp.write(archive.read(name))
@ -55,23 +55,22 @@ class Theme:
This class supports both theme directory and theme archive (zipped 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.name = name
self.base = None self._base: Theme | None = None
self.rootdir = None
if path.isdir(theme_path): if path.isdir(theme_path):
# already a directory, do nothing # already a directory, do nothing
self.rootdir = None self._root_dir = None
self.themedir = theme_path self._theme_dir = theme_path
else: else:
# extract the theme to a temp directory # extract the theme to a temp directory
self.rootdir = tempfile.mkdtemp('sxt') self._root_dir = tempfile.mkdtemp('sxt')
self.themedir = path.join(self.rootdir, name) self._theme_dir = path.join(self._root_dir, name)
extract_zip(theme_path, self.themedir) _extract_zip(theme_path, self._theme_dir)
self.config = configparser.RawConfigParser() 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): if not os.path.isfile(config_file_path):
raise ThemeError(__('theme configuration file %r not found') % config_file_path) raise ThemeError(__('theme configuration file %r not found') % config_file_path)
self.config.read(config_file_path, encoding='utf-8') self.config.read(config_file_path, encoding='utf-8')
@ -85,7 +84,7 @@ class Theme:
if inherit != 'none': if inherit != 'none':
try: try:
self.base = factory.create(inherit) self._base = theme_factory.create(inherit)
except ThemeError as exc: except ThemeError as exc:
raise ThemeError(__('no theme named %r found, inherited by %r') % raise ThemeError(__('no theme named %r found, inherited by %r') %
(inherit, name)) from exc (inherit, name)) from exc
@ -94,24 +93,24 @@ class Theme:
"""Return a list of theme directories, beginning with this theme's, """Return a list of theme directories, beginning with this theme's,
then the base theme's, then that one's base theme's, etc. then the base theme's, then that one's base theme's, etc.
""" """
if self.base is None: if self._base is None:
return [self.themedir] return [self._theme_dir]
else: 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 """Return the value for a theme configuration setting, searching the
base theme chain. base theme chain.
""" """
try: try:
return self.config.get(section, name) return self.config.get(section, name)
except (configparser.NoOptionError, configparser.NoSectionError) as exc: except (configparser.NoOptionError, configparser.NoSectionError):
if self.base: if self._base:
return self.base.get_config(section, name, default) 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 ' 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 return default
def get_options(self, overrides: dict[str, Any] | None = None) -> dict[str, Any]: def get_options(self, overrides: dict[str, Any] | None = None) -> dict[str, Any]:
@ -119,8 +118,8 @@ class Theme:
if overrides is None: if overrides is None:
overrides = {} overrides = {}
if self.base: if self._base:
options = self.base.get_options() options = self._base.get_options()
else: else:
options = {} options = {}
@ -135,21 +134,21 @@ class Theme:
return options return options
def cleanup(self) -> None: def _cleanup(self) -> None:
"""Remove temporary directories.""" """Remove temporary directories."""
if self.rootdir: if self._root_dir:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
shutil.rmtree(self.rootdir) shutil.rmtree(self._root_dir)
if self.base: if self._base is not None:
self.base.cleanup() 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.""" """Check whether the specified file is an archived theme file or not."""
try: try:
with ZipFile(filename) as f: with ZipFile(filename) as f:
return THEMECONF in f.namelist() return _THEME_CONF in f.namelist()
except Exception: except Exception:
return False return False
@ -158,27 +157,27 @@ class HTMLThemeFactory:
"""A factory class for HTML Themes.""" """A factory class for HTML Themes."""
def __init__(self, app: Sphinx) -> None: def __init__(self, app: Sphinx) -> None:
self.app = app self._app = app
self.themes = app.registry.html_themes self._themes = app.registry.html_themes
self.load_builtin_themes() self._load_builtin_themes()
if getattr(app.config, 'html_theme_path', None): 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.""" """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(): 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.""" """Load additional themes placed at specified directories."""
for theme_path in theme_paths: for theme_path in theme_paths:
abs_theme_path = path.abspath(path.join(self.app.confdir, theme_path)) abs_theme_path = path.abspath(path.join(self._app.confdir, theme_path))
themes = self.find_themes(abs_theme_path) themes = self._find_themes(abs_theme_path)
for name, theme in themes.items(): 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. """Try to load a theme with the specified name.
This uses the ``sphinx.html_themes`` entry point from package metadata. This uses the ``sphinx.html_themes`` entry point from package metadata.
@ -189,10 +188,10 @@ class HTMLThemeFactory:
except KeyError: except KeyError:
pass pass
else: else:
self.app.registry.load_extension(self.app, entry_point.module) self._app.registry.load_extension(self._app, entry_point.module)
_config_post_init(None, self.app.config) _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.""" """Search themes from specified directory."""
themes: dict[str, str] = {} themes: dict[str, str] = {}
if not path.isdir(theme_path): if not path.isdir(theme_path):
@ -201,24 +200,24 @@ class HTMLThemeFactory:
for entry in os.listdir(theme_path): for entry in os.listdir(theme_path):
pathname = path.join(theme_path, entry) pathname = path.join(theme_path, entry)
if path.isfile(pathname) and entry.lower().endswith('.zip'): if path.isfile(pathname) and entry.lower().endswith('.zip'):
if is_archived_theme(pathname): if _is_archived_theme(pathname):
name = entry[:-4] name = entry[:-4]
themes[name] = pathname themes[name] = pathname
else: else:
logger.warning(__('file %r on theme path is not a valid ' logger.warning(__('file %r on theme path is not a valid '
'zipfile or contains no theme'), entry) 'zipfile or contains no theme'), entry)
else: else:
if path.isfile(path.join(pathname, THEMECONF)): if path.isfile(path.join(pathname, _THEME_CONF)):
themes[entry] = pathname themes[entry] = pathname
return themes return themes
def create(self, name: str) -> Theme: def create(self, name: str) -> Theme:
"""Create an instance of theme.""" """Create an instance of theme."""
if name not in self.themes: if name not in self._themes:
self.load_extra_theme(name) 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) 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)

View File

@ -13,8 +13,6 @@ from sphinx.theming import Theme, ThemeError
confoverrides={'html_theme': 'ziptheme', confoverrides={'html_theme': 'ziptheme',
'html_theme_options.testopt': 'foo'}) 'html_theme_options.testopt': 'foo'})
def test_theme_api(app, status, warning): def test_theme_api(app, status, warning):
cfg = app.config
themes = ['basic', 'default', 'scrolls', 'agogo', 'sphinxdoc', 'haiku', themes = ['basic', 'default', 'scrolls', 'agogo', 'sphinxdoc', 'haiku',
'traditional', 'epub', 'nature', 'pyramid', 'bizstyle', 'classic', 'nonav', 'traditional', 'epub', 'nature', 'pyramid', 'bizstyle', 'classic', 'nonav',
'test-theme', 'ziptheme', 'staticfiles', 'parent', 'child', 'alabaster'] 'test-theme', 'ziptheme', 'staticfiles', 'parent', 'child', 'alabaster']
@ -28,8 +26,8 @@ def test_theme_api(app, status, warning):
# test Theme instance API # test Theme instance API
theme = app.builder.theme theme = app.builder.theme
assert theme.name == 'ziptheme' assert theme.name == 'ziptheme'
themedir = theme.themedir theme_dir = theme._theme_dir
assert theme.base.name == 'basic' assert theme._base.name == 'basic'
assert len(theme.get_theme_dirs()) == 2 assert len(theme.get_theme_dirs()) == 2
# direct setting # direct setting
@ -46,13 +44,13 @@ def test_theme_api(app, status, warning):
options = theme.get_options({'nonexisting': 'foo'}) options = theme.get_options({'nonexisting': 'foo'})
assert 'nonexisting' not in options 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['testopt'] == 'foo'
assert options['nosidebar'] == 'false' assert options['nosidebar'] == 'false'
# cleanup temp directories # cleanup temp directories
theme.cleanup() theme._cleanup()
assert not os.path.exists(themedir) assert not os.path.exists(theme_dir)
def test_nonexistent_theme_conf(tmp_path): def test_nonexistent_theme_conf(tmp_path):