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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy