From c54afff1b61baff7afe87e100f7f3396b2ab9a0b Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 23 Oct 2022 12:26:45 +0200 Subject: [PATCH 1/2] Inline style application logic into mpl.style.use. ... and use the shorter _rc_params_in_file. --- lib/matplotlib/style/core.py | 73 +++++++++++++++--------------------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py index 646b39dcc5df..5a510a337a31 100644 --- a/lib/matplotlib/style/core.py +++ b/lib/matplotlib/style/core.py @@ -18,7 +18,7 @@ import warnings 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 +64,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))) ) @@ -129,33 +112,38 @@ 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] + 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} not found in the style library and input is " + f"not a valid URL or path; see `style.available` for the " + f"list of available styles") 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 +193,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 From 24be7785cb19d6796171fc37955086d9728ec04a Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 23 Oct 2022 13:22:51 +0200 Subject: [PATCH 2/2] Load style files from third-party packages. --- .github/workflows/tests.yml | 4 +- .../next_api_changes/development/24257-AL.rst | 2 + doc/devel/dependencies.rst | 3 + .../next_whats_new/styles_from_packages.rst | 11 ++++ environment.yml | 1 + lib/matplotlib/style/core.py | 62 ++++++++++++++----- lib/matplotlib/tests/test_style.py | 15 +++++ requirements/testing/minver.txt | 1 + setup.py | 5 ++ tutorials/introductory/customizing.py | 17 ++++- 10 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 doc/api/next_api_changes/development/24257-AL.rst create mode 100644 doc/users/next_whats_new/styles_from_packages.rst 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 5a510a337a31..ed5cd4f63bc5 100644 --- a/lib/matplotlib/style/core.py +++ b/lib/matplotlib/style/core.py @@ -15,8 +15,16 @@ 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_in_file, rcParamsDefault @@ -82,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 ----- @@ -127,14 +143,28 @@ def use(style): 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: style = _rc_params_in_file(style) except IOError as err: raise IOError( - f"{style!r} not found in the style library and input is " - f"not a valid URL or path; see `style.available` for the " - f"list of available styles") 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: 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