summaryrefslogtreecommitdiffstats
path: root/sphinx/theming.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--sphinx/theming.py600
1 files changed, 463 insertions, 137 deletions
diff --git a/sphinx/theming.py b/sphinx/theming.py
index a8a3f83..097efa4 100644
--- a/sphinx/theming.py
+++ b/sphinx/theming.py
@@ -2,7 +2,10 @@
from __future__ import annotations
+__all__ = ('Theme', 'HTMLThemeFactory')
+
import configparser
+import contextlib
import os
import shutil
import sys
@@ -11,117 +14,138 @@ 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
from sphinx.locale import __
from sphinx.util import logging
from sphinx.util.osutil import ensuredir
+if sys.version_info >= (3, 11):
+ import tomllib
+else:
+ import tomli as tomllib
+
+if sys.version_info >= (3, 10):
+ from importlib.metadata import entry_points
+else:
+ from importlib_metadata import entry_points
+
if TYPE_CHECKING:
+ from collections.abc import Callable
+ from typing import TypedDict
+
+ from typing_extensions import Required
+
from sphinx.application import Sphinx
+ class _ThemeToml(TypedDict, total=False):
+ theme: Required[_ThemeTomlTheme]
+ options: dict[str, str]
-logger = logging.getLogger(__name__)
+ class _ThemeTomlTheme(TypedDict, total=False):
+ inherit: Required[str]
+ stylesheets: list[str]
+ sidebars: list[str]
+ pygments_style: _ThemeTomlThemePygments
-NODEFAULT = object()
-THEMECONF = 'theme.conf'
+ class _ThemeTomlThemePygments(TypedDict, total=False):
+ default: str
+ dark: str
-def extract_zip(filename: str, targetdir: str) -> None:
- """Extract zip file to target directory."""
- ensuredir(targetdir)
+logger = logging.getLogger(__name__)
- 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))
+_NO_DEFAULT = object()
+_THEME_TOML = 'theme.toml'
+_THEME_CONF = 'theme.conf'
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:
+ This class supports both theme directory and theme archive (zipped theme).
+ """
+
+ def __init__(
+ self,
+ name: str,
+ *,
+ configs: dict[str, _ConfigFile],
+ paths: list[str],
+ tmp_dirs: list[str],
+ ) -> 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
+ self._dirs = tuple(paths)
+ self._tmp_dirs = tmp_dirs
+
+ options: dict[str, Any] = {}
+ self.stylesheets: tuple[str, ...] = ()
+ self.sidebar_templates: tuple[str, ...] = ()
+ self.pygments_style_default: str | None = None
+ self.pygments_style_dark: str | None = None
+ for config in reversed(configs.values()):
+ options |= config.options
+ if config.stylesheets is not None:
+ self.stylesheets = config.stylesheets
+ if config.sidebar_templates is not None:
+ self.sidebar_templates = config.sidebar_templates
+ if config.pygments_style_default is not None:
+ self.pygments_style_default = config.pygments_style_default
+ if config.pygments_style_dark is not None:
+ self.pygments_style_dark = config.pygments_style_dark
+
+ self._options = options
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()
+ return list(self._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)
-
- if default is NODEFAULT:
- raise ThemeError(__('setting %s.%s occurs in none of the '
- 'searched theme configs') % (section, name)) from exc
- return default
+ if section == 'theme':
+ if name == 'stylesheet':
+ value = ', '.join(self.stylesheets) or default
+ elif name == 'sidebars':
+ value = ', '.join(self.sidebar_templates) or default
+ elif name == 'pygments_style':
+ value = self.pygments_style_default or default
+ elif name == 'pygments_dark_style':
+ value = self.pygments_style_dark or default
+ else:
+ value = default
+ elif section == 'options':
+ value = self._options.get(name, default)
+ else:
+ # https://github.com/sphinx-doc/sphinx/issues/12305
+ # For backwards compatibility when attempting to read a value
+ # from an unsupported configuration section.
+ # xref: RemovedInSphinx80Warning
+ msg = __(
+ 'Theme configuration sections other than [theme] and [options] '
+ 'are not supported, returning the default value instead '
+ '(tried to get a value from %r)'
+ )
+ logger.info(msg % section)
+ value = default
+ if value is _NO_DEFAULT:
+ msg = __('setting %s.%s occurs in none of the searched theme configs') % (
+ section,
+ name,
+ )
+ raise ThemeError(msg)
+ return value
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'))
-
+ options = self._options.copy()
for option, value in overrides.items():
if option not in options:
logger.warning(__('unsupported theme option %r given') % option)
@@ -130,77 +154,60 @@ class Theme:
return options
- def cleanup(self) -> None:
+ def _cleanup(self) -> None:
"""Remove temporary directories."""
- if self.rootdir:
+ for tmp_dir in self._tmp_dirs:
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
+ shutil.rmtree(tmp_dir)
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._entry_point_themes: dict[str, Callable[[], None]] = {}
+ 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)
+ self._load_entry_point_themes()
- 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
-
- 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')
+ self._themes[name] = theme
- def load_external_theme(self, name: str) -> None:
- """Try to load a theme using entry_points.
+ def _load_entry_point_themes(self) -> None:
+ """Try to load a theme with the specified name.
- Sphinx refers to ``sphinx_themes`` entry_points.
+ This uses the ``sphinx.html_themes`` entry point from package metadata.
"""
- # 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]:
+ for entry_point in entry_points(group='sphinx.html_themes'):
+ if entry_point.name in self._themes:
+ continue # don't overwrite loaded themes
+
+ def _load_theme_closure(
+ # bind variables in the function definition
+ app: Sphinx = self._app,
+ theme_module: str = entry_point.module,
+ ) -> None:
+ app.setup_extension(theme_module)
+ _config_post_init(app, app.config)
+
+ self._entry_point_themes[entry_point.name] = _load_theme_closure
+
+ @staticmethod
+ def _find_themes(theme_path: str) -> dict[str, str]:
"""Search themes from specified directory."""
themes: dict[str, str] = {}
if not path.isdir(theme_path):
@@ -209,24 +216,343 @@ 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)
+ 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)):
+ toml_path = path.join(pathname, _THEME_TOML)
+ conf_path = path.join(pathname, _THEME_CONF)
+ if path.isfile(toml_path) or path.isfile(conf_path):
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 in self._entry_point_themes:
+ # Load a deferred theme from an entry point
+ entry_point_loader = self._entry_point_themes[name]
+ entry_point_loader()
+ if name not in self._themes:
+ raise ThemeError(__('no theme named %r found (missing theme.toml?)') % name)
+
+ themes, theme_dirs, tmp_dirs = _load_theme_with_ancestors(
+ name,
+ self._themes,
+ self._entry_point_themes,
+ )
+ return Theme(name, configs=themes, paths=theme_dirs, tmp_dirs=tmp_dirs)
+
+
+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:
+ namelist = frozenset(f.namelist())
+ return _THEME_TOML in namelist or _THEME_CONF in namelist
+ except Exception:
+ return False
+
+
+def _load_theme_with_ancestors(
+ name: str,
+ theme_paths: dict[str, str],
+ entry_point_themes: dict[str, Callable[[], None]],
+ /,
+) -> tuple[dict[str, _ConfigFile], list[str], list[str]]:
+ themes: dict[str, _ConfigFile] = {}
+ theme_dirs: list[str] = []
+ tmp_dirs: list[str] = []
+
+ # having 10+ theme ancestors is ludicrous
+ for _ in range(10):
+ inherit, theme_dir, tmp_dir, config = _load_theme(name, theme_paths[name])
+ theme_dirs.append(theme_dir)
+ if tmp_dir is not None:
+ tmp_dirs.append(tmp_dir)
+ themes[name] = config
+ if inherit == 'none':
+ break
+ if inherit in themes:
+ msg = __('The %r theme has circular inheritance') % name
+ raise ThemeError(msg)
+ if inherit in entry_point_themes and inherit not in theme_paths:
+ # Load a deferred theme from an entry point
+ entry_point_loader = entry_point_themes[inherit]
+ entry_point_loader()
+ if inherit not in theme_paths:
+ msg = __(
+ 'The %r theme inherits from %r, which is not a loaded theme. '
+ 'Loaded themes are: %s'
+ ) % (name, inherit, ', '.join(sorted(theme_paths)))
+ raise ThemeError(msg)
+ name = inherit
+ else:
+ msg = __('The %r theme has too many ancestors') % name
+ raise ThemeError(msg)
+
+ return themes, theme_dirs, tmp_dirs
+
+
+def _load_theme(name: str, theme_path: str, /) -> tuple[str, str, str | None, _ConfigFile]:
+ if path.isdir(theme_path):
+ # already a directory, do nothing
+ tmp_dir = None
+ theme_dir = theme_path
+ else:
+ # extract the theme to a temp directory
+ tmp_dir = tempfile.mkdtemp('sxt')
+ theme_dir = path.join(tmp_dir, name)
+ _extract_zip(theme_path, theme_dir)
+
+ if path.isfile(toml_path := path.join(theme_dir, _THEME_TOML)):
+ _cfg_table = _load_theme_toml(toml_path)
+ inherit = _validate_theme_toml(_cfg_table, name)
+ config = _convert_theme_toml(_cfg_table)
+ elif path.isfile(conf_path := path.join(theme_dir, _THEME_CONF)):
+ _cfg_parser = _load_theme_conf(conf_path)
+ inherit = _validate_theme_conf(_cfg_parser, name)
+ config = _convert_theme_conf(_cfg_parser)
+ else:
+ raise ThemeError(__('no theme configuration file found in %r') % theme_dir)
+
+ return inherit, theme_dir, tmp_dir, config
+
+
+def _extract_zip(filename: str, target_dir: str, /) -> None:
+ """Extract zip file to target directory."""
+ ensuredir(target_dir)
+
+ with ZipFile(filename) as archive:
+ for name in archive.namelist():
+ if name.endswith('/'):
+ continue
+ entry = path.join(target_dir, name)
+ ensuredir(path.dirname(entry))
+ with open(path.join(entry), 'wb') as fp:
+ fp.write(archive.read(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)
+def _load_theme_toml(config_file_path: str, /) -> _ThemeToml:
+ with open(config_file_path, encoding='utf-8') as f:
+ config_text = f.read()
+ c = tomllib.loads(config_text)
+ return {s: c[s] for s in ('theme', 'options') if s in c} # type: ignore[return-value]
+
+
+def _validate_theme_toml(cfg: _ThemeToml, name: str) -> str:
+ if 'theme' not in cfg:
+ msg = __('theme %r doesn\'t have the "theme" table') % name
+ raise ThemeError(msg)
+ theme = cfg['theme']
+ if not isinstance(theme, dict):
+ msg = __('The %r theme "[theme]" table is not a table') % name
+ raise ThemeError(msg)
+ inherit = theme.get('inherit', '')
+ if not inherit:
+ msg = __('The %r theme must define the "theme.inherit" setting') % name
+ raise ThemeError(msg)
+ if 'options' in cfg:
+ if not isinstance(cfg['options'], dict):
+ msg = __('The %r theme "[options]" table is not a table') % name
+ raise ThemeError(msg)
+ return inherit
+
+
+def _convert_theme_toml(cfg: _ThemeToml, /) -> _ConfigFile:
+ theme = cfg['theme']
+ if 'stylesheets' in theme:
+ stylesheets: tuple[str, ...] | None = tuple(theme['stylesheets'])
+ else:
+ stylesheets = None
+ if 'sidebars' in theme:
+ sidebar_templates: tuple[str, ...] | None = tuple(theme['sidebars'])
+ else:
+ sidebar_templates = None
+ pygments_table = theme.get('pygments_style', {})
+ if isinstance(pygments_table, str):
+ hint = f'pygments_style = {{ default = "{pygments_table}" }}'
+ msg = __('The "theme.pygments_style" setting must be a table. Hint: "%s"') % hint
+ raise ThemeError(msg)
+ pygments_style_default: str | None = pygments_table.get('default')
+ pygments_style_dark: str | None = pygments_table.get('dark')
+ return _ConfigFile(
+ stylesheets=stylesheets,
+ sidebar_templates=sidebar_templates,
+ pygments_style_default=pygments_style_default,
+ pygments_style_dark=pygments_style_dark,
+ options=cfg.get('options', {}),
+ )
+
+
+def _load_theme_conf(config_file_path: str, /) -> configparser.RawConfigParser:
+ c = configparser.RawConfigParser()
+ c.read(config_file_path, encoding='utf-8')
+ return c
+
+
+def _validate_theme_conf(cfg: configparser.RawConfigParser, name: str) -> str:
+ if not cfg.has_section('theme'):
+ raise ThemeError(__('theme %r doesn\'t have the "theme" table') % name)
+ if inherit := cfg.get('theme', 'inherit', fallback=None):
+ return inherit
+ msg = __('The %r theme must define the "theme.inherit" setting') % name
+ raise ThemeError(msg)
+
+
+def _convert_theme_conf(cfg: configparser.RawConfigParser, /) -> _ConfigFile:
+ if stylesheet := cfg.get('theme', 'stylesheet', fallback=''):
+ stylesheets: tuple[str, ...] | None = tuple(map(str.strip, stylesheet.split(',')))
+ else:
+ stylesheets = None
+ if sidebar := cfg.get('theme', 'sidebars', fallback=''):
+ sidebar_templates: tuple[str, ...] | None = tuple(map(str.strip, sidebar.split(',')))
+ else:
+ sidebar_templates = None
+ pygments_style_default: str | None = cfg.get('theme', 'pygments_style', fallback=None)
+ pygments_style_dark: str | None = cfg.get('theme', 'pygments_dark_style', fallback=None)
+ options = dict(cfg.items('options')) if cfg.has_section('options') else {}
+ return _ConfigFile(
+ stylesheets=stylesheets,
+ sidebar_templates=sidebar_templates,
+ pygments_style_default=pygments_style_default,
+ pygments_style_dark=pygments_style_dark,
+ options=options,
+ )
+
+
+class _ConfigFile:
+ __slots__ = (
+ 'stylesheets',
+ 'sidebar_templates',
+ 'pygments_style_default',
+ 'pygments_style_dark',
+ 'options',
+ )
+
+ def __init__(
+ self,
+ stylesheets: tuple[str, ...] | None,
+ sidebar_templates: tuple[str, ...] | None,
+ pygments_style_default: str | None,
+ pygments_style_dark: str | None,
+ options: dict[str, str],
+ ) -> None:
+ self.stylesheets: tuple[str, ...] | None = stylesheets
+ self.sidebar_templates: tuple[str, ...] | None = sidebar_templates
+ self.pygments_style_default: str | None = pygments_style_default
+ self.pygments_style_dark: str | None = pygments_style_dark
+ self.options: dict[str, str] = options.copy()
+
+ def __repr__(self) -> str:
+ return (
+ f'{self.__class__.__qualname__}('
+ f'stylesheets={self.stylesheets!r}, '
+ f'sidebar_templates={self.sidebar_templates!r}, '
+ f'pygments_style_default={self.pygments_style_default!r}, '
+ f'pygments_style_dark={self.pygments_style_dark!r}, '
+ f'options={self.options!r})'
+ )
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, _ConfigFile):
+ return (
+ self.stylesheets == other.stylesheets
+ and self.sidebar_templates == other.sidebar_templates
+ and self.pygments_style_default == other.pygments_style_default
+ and self.pygments_style_dark == other.pygments_style_dark
+ and self.options == other.options
+ )
+ return NotImplemented
+
+ def __hash__(self) -> int:
+ return hash((
+ self.__class__.__qualname__,
+ self.stylesheets,
+ self.sidebar_templates,
+ self.pygments_style_default,
+ self.pygments_style_dark,
+ self.options,
+ ))
+
+
+def _migrate_conf_to_toml(argv: list[str]) -> int:
+ if argv[:1] != ['conf_to_toml']:
+ raise SystemExit(0)
+ argv = argv[1:]
+ if len(argv) != 1:
+ print('Usage: python -m sphinx.theming conf_to_toml <theme path>') # NoQA: T201
+ raise SystemExit(1)
+ theme_dir = path.realpath(argv[0])
+ conf_path = path.join(theme_dir, _THEME_CONF)
+ if not path.isdir(theme_dir) or not path.isfile(conf_path):
+ print( # NoQA: T201
+ f'{theme_dir!r} must be a path to a theme directory containing a "theme.conf" file'
+ )
+ return 1
+ _cfg_parser = _load_theme_conf(conf_path)
+ if not _cfg_parser.has_section('theme'):
+ print('The "theme" table is missing.') # NoQA: T201
+ return 1
+ inherit = _cfg_parser.get('theme', 'inherit', fallback=None)
+ if not inherit:
+ print('The "theme.inherit" setting is missing.') # NoQA: T201
+ return 1
+
+ toml_lines = [
+ '[theme]',
+ f'inherit = "{inherit}"',
+ ]
+
+ stylesheet = _cfg_parser.get('theme', 'stylesheet', fallback=...)
+ if stylesheet == '':
+ toml_lines.append('stylesheets = []')
+ elif stylesheet is not ...:
+ toml_lines.append('stylesheets = [')
+ toml_lines.extend(f' "{s}",' for s in map(str.strip, stylesheet.split(',')))
+ toml_lines.append(']')
+
+ sidebar = _cfg_parser.get('theme', 'sidebars', fallback=...)
+ if sidebar == '':
+ toml_lines.append('sidebars = []')
+ elif sidebar is not ...:
+ toml_lines.append('sidebars = [')
+ toml_lines += [f' "{s}",' for s in map(str.strip, sidebar.split(','))]
+ toml_lines.append(']')
+
+ styles = []
+ default = _cfg_parser.get('theme', 'pygments_style', fallback=...)
+ if default is not ...:
+ styles.append(f'default = "{default}"')
+ dark = _cfg_parser.get('theme', 'pygments_dark_style', fallback=...)
+ if dark is not ...:
+ styles.append(f'dark = "{dark}"')
+ if styles:
+ toml_lines.append('pygments_style = { ' + ', '.join(styles) + ' }')
+
+ if _cfg_parser.has_section('options'):
+ toml_lines.append('')
+ toml_lines.append('[options]')
+ toml_lines += [
+ f'{key} = "{d}"'
+ for key, default in _cfg_parser.items('options')
+ if (d := default.replace('"', r'\"')) or True
+ ]
+
+ toml_path = path.join(theme_dir, _THEME_TOML)
+ with open(toml_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(toml_lines) + '\n')
+ print(f'Written converted settings to {toml_path!r}') # NoQA: T201
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(_migrate_conf_to_toml(sys.argv[1:]))