Refactor sphinx.theming

This commit is contained in:
Takeshi KOMIYA 2017-04-20 14:04:49 +09:00
parent 637dd02f14
commit b405c0aaf5
3 changed files with 92 additions and 115 deletions

View File

@ -11,9 +11,9 @@
import os import os
import shutil import shutil
import zipfile
import tempfile import tempfile
from os import path from os import path
from zipfile import ZipFile
import pkg_resources import pkg_resources
from six import string_types, iteritems from six import string_types, iteritems
@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
if False: if False:
# For type annotation # For type annotation
from typing import Any, Callable, Dict, List, Tuple # NOQA from typing import Any, Dict, Iterator, List, Tuple # NOQA
NODEFAULT = object() NODEFAULT = object()
THEMECONF = 'theme.conf' THEMECONF = 'theme.conf'
@ -117,105 +117,115 @@ class _Theme(object):
self.base.cleanup() self.base.cleanup()
def is_archived_theme(filename):
# type: (unicode) -> bool
try:
with ZipFile(filename) as f: # type: ignore
return THEMECONF in f.namelist()
except:
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 Theme(object): class Theme(object):
""" """
Represents the theme chosen in the configuration. Represents the theme chosen in the configuration.
""" """
themes = {} # type: Dict[unicode, Tuple[unicode, zipfile.ZipFile]] themes = {} # type: Dict[unicode, unicode]
themepath = [] # type: List[unicode]
@classmethod @classmethod
def init_themes(cls, confdir, theme_path): def init_themes(cls, confdir, theme_path):
# type: (unicode, unicode) -> None # type: (unicode, unicode) -> None
"""Search all theme paths for available themes.""" """Search all theme paths for available themes."""
cls.themepath = list(theme_path) themepath = list(theme_path)
cls.themepath.append(path.join(package_dir, 'themes')) themepath.append(path.join(package_dir, 'themes'))
for themedir in cls.themepath[::-1]: # search themes from theme_paths
themedir = path.join(confdir, themedir) for themedir in themepath:
themedir = path.abspath(path.join(confdir, themedir))
if not path.isdir(themedir): if not path.isdir(themedir):
continue continue
for theme in os.listdir(themedir): else:
if theme.lower().endswith('.zip'): for name, theme in find_theme_entries(themedir):
try: cls.themes[name] = theme
zfile = zipfile.ZipFile(path.join(themedir, theme)) # type: ignore
if THEMECONF not in zfile.namelist():
continue
tname = theme[:-4]
tinfo = zfile
except Exception:
logger.warning('file %r on theme path is not a valid '
'zipfile or contains no theme', theme)
continue
else:
if not path.isfile(path.join(themedir, theme, THEMECONF)):
continue
tname = theme
tinfo = None
cls.themes[tname] = (path.join(themedir, theme), tinfo)
@classmethod @classmethod
def load_extra_theme(cls, name): def load_extra_theme(cls, name):
# type: (unicode) -> None if name == 'alabaster':
themes = ['alabaster'] cls.load_alabaster()
elif name == 'sphinx_rtd_theme':
cls.load_sphinx_rtd_theme()
else:
pass
@classmethod
def load_alabaster(cls):
import alabaster
cls.themes['alabaster'] = path.join(alabaster.get_path(), 'alabaster')
@classmethod
def load_sphinx_rtd_theme(cls):
try: try:
import sphinx_rtd_theme import sphinx_rtd_theme
themes.append('sphinx_rtd_theme') cls.themes['sphinx_rtd_theme'] = path.join(sphinx_rtd_theme.get_html_theme_path(),
'sphinx_rtd_theme')
except ImportError: except ImportError:
pass pass
if name in themes:
if name == 'alabaster':
import alabaster
themedir = alabaster.get_path()
# alabaster theme also requires 'alabaster' extension, it will be loaded
# at sphinx.application module.
elif name == 'sphinx_rtd_theme':
themedir = sphinx_rtd_theme.get_html_theme_path()
else:
raise NotImplementedError('Programming Error')
else: @classmethod
for themedir in load_theme_plugins(): def load_external_themes(cls, name):
if path.isfile(path.join(themedir, name, THEMECONF)): for entry_point in pkg_resources.iter_entry_points('sphinx_themes'):
break target = entry_point.load()
if callable(target):
themedir = target()
if not isinstance(path, string_types):
logger.warning(_('Theme extension %r does not response correctly.') %
entry_point.module_name)
else: else:
# specified theme is not found themedir = target
return
cls.themepath.append(themedir) for entry, theme in find_theme_entries(themedir):
cls.themes[name] = (path.join(themedir, name), None) if name == entry:
return cls.themes[entry] = theme
@classmethod @classmethod
def create(cls, name): def create(cls, name):
# type: (unicode) -> None # type: (unicode) -> None
if name not in cls.themes: if name not in cls.themes:
cls.load_extra_theme(name) cls.load_extra_theme(name)
if name not in cls.themes:
if name == 'sphinx_rtd_theme': if name not in cls.themes:
raise ThemeError('sphinx_rtd_theme is no longer a hard dependency ' if name == 'sphinx_rtd_theme':
'since version 1.4.0. Please install it manually.' raise ThemeError(_('sphinx_rtd_theme is no longer a hard dependency '
'(pip install sphinx_rtd_theme)') 'since version 1.4.0. Please install it manually.'
else: '(pip install sphinx_rtd_theme)'))
raise ThemeError('no theme named %r found ' else:
'(missing theme.conf?)' % name) raise ThemeError(_('no theme named %r found '
'(missing theme.conf?)') % name)
theme = _Theme() theme = _Theme()
theme.name = name theme.name = name
# Do not warn yet -- to be compatible with old Sphinxes, people *have* themedir = cls.themes[name]
# to use "default". if path.isdir(themedir):
# if name == 'default' and warn:
# warn("'default' html theme has been renamed to 'classic'. "
# "Please change your html_theme setting either to "
# "the new 'alabaster' default theme, or to 'classic' "
# "to keep using the old default.")
tdir, tinfo = cls.themes[name]
if tinfo is None:
# already a directory, do nothing # already a directory, do nothing
theme.rootdir = None theme.rootdir = None
theme.themedir = tdir theme.themedir = themedir
theme.themedir_created = False theme.themedir_created = False
else: else:
# extract the theme to a temp directory # extract the theme to a temp directory
@ -224,14 +234,14 @@ class Theme(object):
theme.themedir_created = True theme.themedir_created = True
ensuredir(theme.themedir) ensuredir(theme.themedir)
for name in tinfo.namelist(): with ZipFile(themedir) as archive: # type: ignore
if name.endswith('/'): for name in archive.namelist():
continue if name.endswith('/'):
dirname = path.dirname(name) continue
if not path.isdir(path.join(theme.themedir, dirname)): filename = path.join(theme.themedir, name)
os.makedirs(path.join(theme.themedir, dirname)) ensuredir(path.dirname(filename))
with open(path.join(theme.themedir, name), 'wb') as fp: with open(path.join(filename), 'wb') as fp:
fp.write(tinfo.read(name)) fp.write(archive.read(name))
theme.themeconf = configparser.RawConfigParser() theme.themeconf = configparser.RawConfigParser()
theme.themeconf.read(path.join(theme.themedir, THEMECONF)) # type: ignore theme.themeconf.read(path.join(theme.themedir, THEMECONF)) # type: ignore
@ -241,9 +251,6 @@ class Theme(object):
except configparser.NoOptionError: except configparser.NoOptionError:
raise ThemeError('theme %r doesn\'t have "inherit" setting' % name) raise ThemeError('theme %r doesn\'t have "inherit" setting' % name)
# load inherited theme automatically #1794, #1884, #1885
cls.load_extra_theme(inherit)
if inherit == 'none': if inherit == 'none':
theme.base = None theme.base = None
elif inherit not in cls.themes: elif inherit not in cls.themes:
@ -253,26 +260,3 @@ class Theme(object):
theme.base = cls.create(inherit) theme.base = cls.create(inherit)
return theme return theme
def load_theme_plugins():
# type: () -> List[unicode]
"""load plugins by using``sphinx_themes`` section in setuptools entry_points.
This API will return list of directory that contain some theme directory.
"""
theme_paths = [] # type: List[unicode]
for plugin in pkg_resources.iter_entry_points('sphinx_themes'):
func_or_path = plugin.load()
try:
path = func_or_path()
except Exception:
path = func_or_path
if isinstance(path, string_types):
theme_paths.append(path)
else:
raise ThemeError('Plugin %r does not response correctly.' %
plugin.module_name)
return theme_paths

View File

@ -5,4 +5,5 @@ import sys, os
templates_path = ['_templates'] templates_path = ['_templates']
master_doc = 'index' master_doc = 'index'
html_theme = 'base_theme2' html_theme = 'base_theme2'
html_theme_path = ['base_themes_dir']
exclude_patterns = ['_build'] exclude_patterns = ['_build']

View File

@ -31,8 +31,8 @@ def test_theme_api(app, status, warning):
set(['basic', 'default', 'scrolls', 'agogo', 'sphinxdoc', 'haiku', set(['basic', 'default', 'scrolls', 'agogo', 'sphinxdoc', 'haiku',
'traditional', 'testtheme', 'ziptheme', 'epub', 'nature', 'traditional', 'testtheme', 'ziptheme', 'epub', 'nature',
'pyramid', 'bizstyle', 'classic', 'nonav']) 'pyramid', 'bizstyle', 'classic', 'nonav'])
assert Theme.themes['testtheme'][1] is None assert Theme.themes['testtheme'] == app.srcdir / 'testtheme'
assert isinstance(Theme.themes['ziptheme'][1], zipfile.ZipFile) assert Theme.themes['ziptheme'] == app.srcdir / 'ziptheme.zip'
# test Theme instance API # test Theme instance API
theme = app.builder.theme theme = app.builder.theme
@ -88,21 +88,13 @@ def test_js_source(app, status, warning):
@pytest.mark.sphinx(testroot='double-inheriting-theme') @pytest.mark.sphinx(testroot='double-inheriting-theme')
def test_double_inheriting_theme(make_app, app_params): def test_double_inheriting_theme(app, status, warning):
from sphinx.theming import load_theme_plugins # load original before patching assert app.builder.theme.name == 'base_theme2'
app.build() # => not raises TemplateNotFound
def load_themes():
roots = path(__file__).abspath().parent / 'roots'
yield roots / 'test-double-inheriting-theme' / 'base_themes_dir'
for t in load_theme_plugins():
yield t
with mock.patch('sphinx.theming.load_theme_plugins', side_effect=load_themes):
args, kwargs = app_params
make_app(*args, **kwargs)
@pytest.mark.sphinx(testroot='theming', @pytest.mark.sphinx(testroot='theming',
confoverrides={'html_theme': 'child'}) confoverrides={'html_theme': 'child'})
def test_nested_zipped_theme(app, status, warning): def test_nested_zipped_theme(app, status, warning):
assert app.builder.theme.name == 'child'
app.build() # => not raises TemplateNotFound app.build() # => not raises TemplateNotFound