diff --git a/sphinx/application.py b/sphinx/application.py index 6f1418b2f..5058fa1b1 100644 --- a/sphinx/application.py +++ b/sphinx/application.py @@ -127,6 +127,7 @@ class Sphinx(object): self.env = None # type: BuildEnvironment self.enumerable_nodes = {} # type: Dict[nodes.Node, Tuple[unicode, Callable]] # NOQA self.post_transforms = [] # type: List[Transform] + self.html_themes = {} # type: Dict[unicode, unicode] self.srcdir = srcdir self.confdir = confdir diff --git a/sphinx/builders/changes.py b/sphinx/builders/changes.py index 582593311..5c3072059 100644 --- a/sphinx/builders/changes.py +++ b/sphinx/builders/changes.py @@ -16,7 +16,7 @@ from six import iteritems from sphinx import package_dir from sphinx.locale import _ -from sphinx.theming import Theme +from sphinx.theming import HTMLThemeFactory from sphinx.builders import Builder from sphinx.util import logging from sphinx.util.osutil import ensuredir, os_path @@ -42,8 +42,8 @@ class ChangesBuilder(Builder): def init(self): # type: () -> None self.create_template_bridge() - Theme.init_themes(self.confdir, self.config.html_theme_path) - self.theme = Theme.create('default') + theme_factory = HTMLThemeFactory(self.app) + self.theme = theme_factory.create('default') self.templates.init(self, self.theme) def get_outdated_docs(self): diff --git a/sphinx/builders/html.py b/sphinx/builders/html.py index cf5cee280..c1a1ae9d5 100644 --- a/sphinx/builders/html.py +++ b/sphinx/builders/html.py @@ -40,7 +40,7 @@ from sphinx.util.matching import patmatch, Matcher, DOTFILES from sphinx.config import string_classes from sphinx.locale import _, l_ from sphinx.search import js_index -from sphinx.theming import Theme +from sphinx.theming import HTMLThemeFactory from sphinx.builders import Builder from sphinx.application import ENV_PICKLE_FILENAME from sphinx.highlighting import PygmentsBridge @@ -196,9 +196,9 @@ class StandaloneHTMLBuilder(Builder): def init_templates(self): # type: () -> None - Theme.init_themes(self.confdir, self.config.html_theme_path) + theme_factory = HTMLThemeFactory(self.app) themename, themeoptions = self.get_theme_config() - self.theme = Theme.create(themename) + self.theme = theme_factory.create(themename) self.theme_options = themeoptions.copy() self.create_template_bridge() self.templates.init(self, self.theme) diff --git a/sphinx/theming.py b/sphinx/theming.py index abb99d1c8..fbd4dbb62 100644 --- a/sphinx/theming.py +++ b/sphinx/theming.py @@ -30,12 +30,13 @@ logger = logging.getLogger(__name__) if False: # For type annotation from typing import Any, Dict, Iterator, List, Tuple # NOQA + from sphinx.application import Sphinx # NOQA NODEFAULT = object() THEMECONF = 'theme.conf' -class _Theme(object): +class Theme(object): def __init__(self): # type: () -> None self.name = None @@ -126,69 +127,56 @@ def is_archived_theme(filename): return False -def find_theme_entries(themedir): - # type: (unicode) -> Iterator[Tuple[unicode, unicode]] - for entry in os.listdir(themedir): - pathname = path.join(themedir, entry) - if path.isfile(pathname) and entry.lower().endswith('.zip'): - if is_archived_theme(pathname): - name = entry[:-4] - yield 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)): - yield entry, pathname +class HTMLThemeFactory(object): + """A factory class for HTML Themes.""" + def __init__(self, app): + # type: (Sphinx) -> None + self.confdir = app.confdir + self.themes = app.html_themes + self.load_builtin_themes() + if getattr(app.config, 'html_theme_path', None): + self.load_additional_themes(app.config.html_theme_path) -class Theme(object): - """ - Represents the theme chosen in the configuration. - """ - themes = {} # type: Dict[unicode, unicode] + def load_builtin_themes(self): + # type: () -> None + themes = self.find_themes(path.join(package_dir, 'themes')) + for name, theme in iteritems(themes): + self.themes[name] = theme - @classmethod - def init_themes(cls, confdir, theme_path): - # type: (unicode, unicode) -> None - """Search all theme paths for available themes.""" - themepath = list(theme_path) - themepath.append(path.join(package_dir, 'themes')) + def load_additional_themes(self, theme_paths): + # type: (unicode) -> None + for theme_path in theme_paths: + abs_theme_path = path.abspath(path.join(self.confdir, theme_path)) + themes = self.find_themes(abs_theme_path) + for name, theme in iteritems(themes): + self.themes[name] = theme - # search themes from theme_paths - for themedir in themepath: - themedir = path.abspath(path.join(confdir, themedir)) - if not path.isdir(themedir): - continue - else: - for name, theme in find_theme_entries(themedir): - cls.themes[name] = theme - - @classmethod - def load_extra_theme(cls, name): + def load_extra_theme(self, name): + # type: (unicode) -> None if name == 'alabaster': - cls.load_alabaster() + self.load_alabaster_theme() elif name == 'sphinx_rtd_theme': - cls.load_sphinx_rtd_theme() + self.load_sphinx_rtd_theme() else: - pass + self.load_external_theme(name) - @classmethod - def load_alabaster(cls): + def load_alabaster_theme(self): + # type: () -> None import alabaster - cls.themes['alabaster'] = path.join(alabaster.get_path(), 'alabaster') + self.themes['alabaster'] = path.join(alabaster.get_path(), 'alabaster') - @classmethod - def load_sphinx_rtd_theme(cls): + def load_sphinx_rtd_theme(self): + # type: () -> None try: import sphinx_rtd_theme - cls.themes['sphinx_rtd_theme'] = path.join(sphinx_rtd_theme.get_html_theme_path(), - 'sphinx_rtd_theme') + theme_path = sphinx_rtd_theme.get_html_theme_path() + self.themes['sphinx_rtd_theme'] = path.join(theme_path, 'sphinx_rtd_theme') except ImportError: pass - @classmethod - def load_external_themes(cls, name): + def load_external_themes(self, name): + # type: (unicode) -> None for entry_point in pkg_resources.iter_entry_points('sphinx_themes'): target = entry_point.load() if callable(target): @@ -199,17 +187,38 @@ class Theme(object): else: themedir = target - for entry, theme in find_theme_entries(themedir): + themes = self.find_themes(themedir) + for entry, theme in iteritems(themes): if name == entry: - cls.themes[entry] = theme + self.themes[name] = theme - @classmethod - def create(cls, name): - # type: (unicode) -> None - if name not in cls.themes: - cls.load_extra_theme(name) + def find_themes(self, theme_path): + # type: (unicode) -> Dict[unicode, unicode] + themes = {} + if not path.isdir(theme_path): + return themes - if name not in cls.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): + # type: (unicode) -> Theme + if name not in self.themes: + self.load_extra_theme(name) + + if name not in self.themes: if name == 'sphinx_rtd_theme': raise ThemeError(_('sphinx_rtd_theme is no longer a hard dependency ' 'since version 1.4.0. Please install it manually.' @@ -218,10 +227,10 @@ class Theme(object): raise ThemeError(_('no theme named %r found ' '(missing theme.conf?)') % name) - theme = _Theme() + theme = Theme() theme.name = name - themedir = cls.themes[name] + themedir = self.themes[name] if path.isdir(themedir): # already a directory, do nothing theme.rootdir = None @@ -253,10 +262,10 @@ class Theme(object): if inherit == 'none': theme.base = None - elif inherit not in cls.themes: + elif inherit not in self.themes: raise ThemeError('no theme named %r found, inherited by %r' % (inherit, name)) else: - theme.base = cls.create(inherit) + theme.base = self.create(inherit) return theme diff --git a/tests/test_theming.py b/tests/test_theming.py index 29979ccf0..a9534a48e 100644 --- a/tests/test_theming.py +++ b/tests/test_theming.py @@ -10,14 +10,10 @@ """ import os -import zipfile -import mock import pytest -from sphinx.theming import Theme, ThemeError - -from util import path +from sphinx.theming import ThemeError @pytest.mark.sphinx( @@ -27,12 +23,12 @@ def test_theme_api(app, status, warning): cfg = app.config # test Theme class API - assert set(Theme.themes.keys()) == \ + assert set(app.html_themes.keys()) == \ set(['basic', 'default', 'scrolls', 'agogo', 'sphinxdoc', 'haiku', 'traditional', 'testtheme', 'ziptheme', 'epub', 'nature', 'pyramid', 'bizstyle', 'classic', 'nonav']) - assert Theme.themes['testtheme'] == app.srcdir / 'testtheme' - assert Theme.themes['ziptheme'] == app.srcdir / 'ziptheme.zip' + assert app.html_themes['testtheme'] == app.srcdir / 'testtheme' + assert app.html_themes['ziptheme'] == app.srcdir / 'ziptheme.zip' # test Theme instance API theme = app.builder.theme diff --git a/tests/util.py b/tests/util.py index 82c4b8d1b..52c317c3a 100644 --- a/tests/util.py +++ b/tests/util.py @@ -24,7 +24,6 @@ from docutils.parsers.rst import directives, roles from sphinx import application from sphinx.builders.latex import LaTeXBuilder -from sphinx.theming import Theme from sphinx.ext.autodoc import AutoDirective from sphinx.pycode import ModuleAnalyzer from sphinx.deprecation import RemovedInSphinx17Warning @@ -168,7 +167,6 @@ class SphinxTestApp(application.Sphinx): raise def cleanup(self, doctrees=False): - Theme.themes.clear() AutoDirective._registry.clear() ModuleAnalyzer.cache.clear() LaTeXBuilder.usepackages = []