diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 020f09df4010..eddb14be0bc6 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -162,8 +162,8 @@ jobs:
# Install dependencies from PyPI.
python -m pip install --upgrade $PRE \
- 'contourpy>=1.0.1' cycler fonttools kiwisolver numpy packaging \
- pillow pyparsing python-dateutil setuptools-scm \
+ 'contourpy>=1.0.1' cycler fonttools kiwisolver importlib_resources \
+ numpy packaging pillow pyparsing python-dateutil setuptools-scm \
-r requirements/testing/all.txt \
${{ matrix.extra-requirements }}
diff --git a/doc/api/next_api_changes/development/24257-AL.rst b/doc/api/next_api_changes/development/24257-AL.rst
new file mode 100644
index 000000000000..584420df8fd7
--- /dev/null
+++ b/doc/api/next_api_changes/development/24257-AL.rst
@@ -0,0 +1,2 @@
+importlib_resources>=2.3.0 is now required on Python<3.10
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/doc/devel/dependencies.rst b/doc/devel/dependencies.rst
index fb76857898a4..365c062364e2 100644
--- a/doc/devel/dependencies.rst
+++ b/doc/devel/dependencies.rst
@@ -26,6 +26,9 @@ reference.
* `Pillow `_ (>= 6.2)
* `pyparsing `_ (>= 2.3.1)
* `setuptools `_
+* `pyparsing `_ (>= 2.3.1)
+* `importlib-resources `_
+ (>= 3.2.0; only required on Python < 3.10)
.. _optional_dependencies:
diff --git a/doc/users/next_whats_new/styles_from_packages.rst b/doc/users/next_whats_new/styles_from_packages.rst
new file mode 100644
index 000000000000..d129bb356fa7
--- /dev/null
+++ b/doc/users/next_whats_new/styles_from_packages.rst
@@ -0,0 +1,11 @@
+Style files can be imported from third-party packages
+-----------------------------------------------------
+
+Third-party packages can now distribute style files that are globally available
+as follows. Assume that a package is importable as ``import mypackage``, with
+a ``mypackage/__init__.py`` module. Then a ``mypackage/presentation.mplstyle``
+style sheet can be used as ``plt.style.use("mypackage.presentation")``.
+
+The implementation does not actually import ``mypackage``, making this process
+safe against possible import-time side effects. Subpackages (e.g.
+``dotted.package.name``) are also supported.
diff --git a/environment.yml b/environment.yml
index 28ff3a1b2c34..c9b7aa610720 100644
--- a/environment.yml
+++ b/environment.yml
@@ -12,6 +12,7 @@ dependencies:
- contourpy>=1.0.1
- cycler>=0.10.0
- fonttools>=4.22.0
+ - importlib-resources>=3.2.0
- kiwisolver>=1.0.1
- numpy>=1.19
- pillow>=6.2
diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py
index 646b39dcc5df..ed5cd4f63bc5 100644
--- a/lib/matplotlib/style/core.py
+++ b/lib/matplotlib/style/core.py
@@ -15,10 +15,18 @@
import logging
import os
from pathlib import Path
+import sys
import warnings
+if sys.version_info >= (3, 10):
+ import importlib.resources as importlib_resources
+else:
+ # Even though Py3.9 has importlib.resources, it doesn't properly handle
+ # modules added in sys.path.
+ import importlib_resources
+
import matplotlib as mpl
-from matplotlib import _api, _docstring, rc_params_from_file, rcParamsDefault
+from matplotlib import _api, _docstring, _rc_params_in_file, rcParamsDefault
_log = logging.getLogger(__name__)
@@ -64,23 +72,6 @@
"directly use the seaborn API instead.")
-def _remove_blacklisted_style_params(d, warn=True):
- o = {}
- for key in d: # prevent triggering RcParams.__getitem__('backend')
- if key in STYLE_BLACKLIST:
- if warn:
- _api.warn_external(
- f"Style includes a parameter, {key!r}, that is not "
- "related to style. Ignoring this parameter.")
- else:
- o[key] = d[key]
- return o
-
-
-def _apply_style(d, warn=True):
- mpl.rcParams.update(_remove_blacklisted_style_params(d, warn=warn))
-
-
@_docstring.Substitution(
"\n".join(map("- {}".format, sorted(STYLE_BLACKLIST, key=str.lower)))
)
@@ -99,20 +90,28 @@ def use(style):
Parameters
----------
style : str, dict, Path or list
- A style specification. Valid options are:
- +------+-------------------------------------------------------------+
- | str | The name of a style or a path/URL to a style file. For a |
- | | list of available style names, see `.style.available`. |
- +------+-------------------------------------------------------------+
- | dict | Dictionary with valid key/value pairs for |
- | | `matplotlib.rcParams`. |
- +------+-------------------------------------------------------------+
- | Path | A path-like object which is a path to a style file. |
- +------+-------------------------------------------------------------+
- | list | A list of style specifiers (str, Path or dict) applied from |
- | | first to last in the list. |
- +------+-------------------------------------------------------------+
+ A style specification.
+
+ - If a str, this can be one of the style names in `.style.available`
+ (a builtin style or a style installed in the user library path).
+
+ This can also be a dotted name of the form "package.style_name"; in
+ that case, "package" should be an importable Python package name,
+ e.g. at ``/path/to/package/__init__.py``; the loaded style file is
+ ``/path/to/package/style_name.mplstyle``. (Style files in
+ subpackages are likewise supported.)
+
+ This can also be the path or URL to a style file, which gets loaded
+ by `.rc_params_from_file`.
+
+ - If a dict, this is a mapping of key/value pairs for `.rcParams`.
+
+ - If a Path, this is the path to a style file, which gets loaded by
+ `.rc_params_from_file`.
+
+ - If a list, this is a list of style specifiers (str, Path or dict),
+ which get applied from first to last in the list.
Notes
-----
@@ -129,33 +128,52 @@ def use(style):
style_alias = {'mpl20': 'default', 'mpl15': 'classic'}
- def fix_style(s):
- if isinstance(s, str):
- s = style_alias.get(s, s)
- if s in _DEPRECATED_SEABORN_STYLES:
+ for style in styles:
+ if isinstance(style, str):
+ style = style_alias.get(style, style)
+ if style in _DEPRECATED_SEABORN_STYLES:
_api.warn_deprecated("3.6", message=_DEPRECATED_SEABORN_MSG)
- s = _DEPRECATED_SEABORN_STYLES[s]
- return s
-
- for style in map(fix_style, styles):
- if not isinstance(style, (str, Path)):
- _apply_style(style)
- elif style == 'default':
- # Deprecation warnings were already handled when creating
- # rcParamsDefault, no need to reemit them here.
- with _api.suppress_matplotlib_deprecation_warning():
- _apply_style(rcParamsDefault, warn=False)
- elif style in library:
- _apply_style(library[style])
- else:
+ style = _DEPRECATED_SEABORN_STYLES[style]
+ if style == "default":
+ # Deprecation warnings were already handled when creating
+ # rcParamsDefault, no need to reemit them here.
+ with _api.suppress_matplotlib_deprecation_warning():
+ # don't trigger RcParams.__getitem__('backend')
+ style = {k: rcParamsDefault[k] for k in rcParamsDefault
+ if k not in STYLE_BLACKLIST}
+ elif style in library:
+ style = library[style]
+ elif "." in style:
+ pkg, _, name = style.rpartition(".")
+ try:
+ path = (importlib_resources.files(pkg)
+ / f"{name}.{STYLE_EXTENSION}")
+ style = _rc_params_in_file(path)
+ except (ModuleNotFoundError, IOError) as exc:
+ # There is an ambiguity whether a dotted name refers to a
+ # package.style_name or to a dotted file path. Currently,
+ # we silently try the first form and then the second one;
+ # in the future, we may consider forcing file paths to
+ # either use Path objects or be prepended with "./" and use
+ # the slash as marker for file paths.
+ pass
+ if isinstance(style, (str, Path)):
try:
- rc = rc_params_from_file(style, use_default_template=False)
- _apply_style(rc)
+ style = _rc_params_in_file(style)
except IOError as err:
raise IOError(
- "{!r} not found in the style library and input is not a "
- "valid URL or path; see `style.available` for list of "
- "available styles".format(style)) from err
+ f"{style!r} is not a valid package style, path of style "
+ f"file, URL of style file, or library style name (library "
+ f"styles are listed in `style.available`)") from err
+ filtered = {}
+ for k in style: # don't trigger RcParams.__getitem__('backend')
+ if k in STYLE_BLACKLIST:
+ _api.warn_external(
+ f"Style includes a parameter, {k!r}, that is not "
+ f"related to style. Ignoring this parameter.")
+ else:
+ filtered[k] = style[k]
+ mpl.rcParams.update(filtered)
@contextlib.contextmanager
@@ -205,8 +223,7 @@ def read_style_directory(style_dir):
styles = dict()
for path in Path(style_dir).glob(f"*.{STYLE_EXTENSION}"):
with warnings.catch_warnings(record=True) as warns:
- styles[path.stem] = rc_params_from_file(
- path, use_default_template=False)
+ styles[path.stem] = _rc_params_in_file(path)
for w in warns:
_log.warning('In %s: %s', path, w.message)
return styles
diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py
index c788c45920ae..7d1ed94ea236 100644
--- a/lib/matplotlib/tests/test_style.py
+++ b/lib/matplotlib/tests/test_style.py
@@ -190,3 +190,18 @@ def test_deprecated_seaborn_styles():
def test_up_to_date_blacklist():
assert mpl.style.core.STYLE_BLACKLIST <= {*mpl.rcsetup._validators}
+
+
+def test_style_from_module(tmp_path, monkeypatch):
+ monkeypatch.syspath_prepend(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ pkg_path = tmp_path / "mpl_test_style_pkg"
+ pkg_path.mkdir()
+ (pkg_path / "test_style.mplstyle").write_text(
+ "lines.linewidth: 42", encoding="utf-8")
+ pkg_path.with_suffix(".mplstyle").write_text(
+ "lines.linewidth: 84", encoding="utf-8")
+ mpl.style.use("mpl_test_style_pkg.test_style")
+ assert mpl.rcParams["lines.linewidth"] == 42
+ mpl.style.use("mpl_test_style_pkg.mplstyle")
+ assert mpl.rcParams["lines.linewidth"] == 84
diff --git a/requirements/testing/minver.txt b/requirements/testing/minver.txt
index d932b0aa34e7..82301e900f52 100644
--- a/requirements/testing/minver.txt
+++ b/requirements/testing/minver.txt
@@ -3,6 +3,7 @@
contourpy==1.0.1
cycler==0.10
kiwisolver==1.0.1
+importlib-resources==3.2.0
numpy==1.19.0
packaging==20.0
pillow==6.2.1
diff --git a/setup.py b/setup.py
index 365de0c0b5a2..2f1fb84f8cc6 100644
--- a/setup.py
+++ b/setup.py
@@ -334,6 +334,11 @@ def make_release_tree(self, base_dir, files):
os.environ.get("CIBUILDWHEEL", "0") != "1"
) else []
),
+ extras_require={
+ ':python_version<"3.10"': [
+ "importlib-resources>=3.2.0",
+ ],
+ },
use_scm_version={
"version_scheme": "release-branch-semver",
"local_scheme": "node-and-date",
diff --git a/tutorials/introductory/customizing.py b/tutorials/introductory/customizing.py
index ea6b501e99ea..10fc21d2187b 100644
--- a/tutorials/introductory/customizing.py
+++ b/tutorials/introductory/customizing.py
@@ -9,9 +9,9 @@
There are three ways to customize Matplotlib:
- 1. :ref:`Setting rcParams at runtime`.
- 2. :ref:`Using style sheets`.
- 3. :ref:`Changing your matplotlibrc file`.
+1. :ref:`Setting rcParams at runtime`.
+2. :ref:`Using style sheets`.
+3. :ref:`Changing your matplotlibrc file`.
Setting rcParams at runtime takes precedence over style sheets, style
sheets take precedence over :file:`matplotlibrc` files.
@@ -137,6 +137,17 @@ def plotting_function():
# >>> import matplotlib.pyplot as plt
# >>> plt.style.use('./images/presentation.mplstyle')
#
+#
+# Distributing styles
+# -------------------
+#
+# You can include style sheets into standard importable Python packages (which
+# can be e.g. distributed on PyPI). If your package is importable as
+# ``import mypackage``, with a ``mypackage/__init__.py`` module, and you add
+# a ``mypackage/presentation.mplstyle`` style sheet, then it can be used as
+# ``plt.style.use("mypackage.presentation")``. Subpackages (e.g.
+# ``dotted.package.name``) are also supported.
+#
# Alternatively, you can make your style known to Matplotlib by placing
# your ``.mplstyle`` file into ``mpl_configdir/stylelib``. You
# can then load your custom style sheet with a call to