From 06dbb2087afd43dbf381b105ee46067f021059a3 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 8 Jun 2023 01:41:28 +0200 Subject: [PATCH] Define the supported rcParams as code Until now, the parameter definition was dispersed: valid names and defaults are loaded from the canonical `matplotlibrc` data file. Docs are only available as comments in there. Validators are attached ad-hoc in `rcsetup.py`. This makes for a more formal definition of parameters, including meta-information, like validators, docs in a single place. It will simplify the rcParams related code in `matplotlib.__init__.py` and `matplotlib.rcsetup.py`. It will also enable us to generate sphinx documentation on the parameters. --- lib/matplotlib/rcsetup.py | 131 ++++++++++++++++++++++++++ lib/matplotlib/tests/test_rcparams.py | 81 +++++++++++++++- 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 663ff4b70536..cd07687d873b 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -14,11 +14,14 @@ """ import ast +from dataclasses import dataclass from functools import lru_cache, reduce from numbers import Real import operator import os import re +import textwrap +from typing import Any, Callable import numpy as np @@ -1290,3 +1293,131 @@ def _convert_validator_spec(key, conv): } _validators = {k: _convert_validator_spec(k, conv) for k, conv in _validators.items()} + + +@dataclass +class Param: + name: str + default: Any + validator: Callable[[Any], Any] + desc: str = None + + def to_matplotlibrc(self): + return f"{self.name}: {self.default} # {self.desc}\n" + + +class Comment: + def __init__(self, text): + self.text = textwrap.dedent(text).strip("\n") + + def to_matplotlibrc(self): + return self.text + "\n" + + +class RcParamsDefinition: + """ + Definition of config parameters. + + Parameters + ---------- + content: list of Param and Comment objects + This contains: + + - Param objects specifying config parameters + - Comment objects specifying comments to be included in the generated + matplotlibrc file + """ + + def __init__(self, content): + self._content = content + self._params = {item.name: item for item in content if isinstance(item, Param)} + + def create_default_rcparams(self): + """ + Return a RcParams object with the default values. + + Note: The result is equivalent to ``matplotlib.rcParamsDefault``, but + generated from this definition and not from a matplotlibrc file. + """ + from matplotlib import RcParams # TODO: avoid circular import + return RcParams({ + name: param.default for name, param in self._params.items() + }) + + def write_default_matplotlibrc(self, filename): + """ + Write the default matplotlibrc file. + + Note: This aspires to fully generate lib/matplotlib/mpl-data/matplotlibrc. + """ + with open(filename, "w") as f: + for item in self._content: + f.write(item.to_matplotlibrc()) + + def read_matplotlibrc(self, filename): + """ + Read a matplotlibrc file and return a dict with the values. + + Note: This is the core of file-reading. + ``RcParams(read_matplotlibrc(filename))`` is equivalent to + `matplotlib._rc_params_in_file` (modulo handling of the + additional parameters. + + Also note that we have the validation in here, and currently + again in RcParams itself. The validators are currently necessary + to transform the string representation of the values into their + actual type. We may split transformation and validation into + separate steps in the future. + """ + params = {} + with open(filename) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + name, value = line.split(":", 1) + name = name.strip() + value = value.split("#", 1)[0].strip() + try: + param_def = self._params[name] + except KeyError: + raise ValueError(f"Unknown rcParam {name!r} in {filename!r}") + else: + params[name] = param_def.validator(value) + return params + + +# This is how the valid rcParams will be defined in the future. +# The Comment sections are a bit ad-hoc, but are necessary for now if we want the +# definition to be the leading source of truth and also be able to generate the +# current matplotlibrc file from it. +rc_def = RcParamsDefinition([ + Comment(""" + # *************************************************************************** + # * LINES * + # *************************************************************************** + # See https://matplotlib.org/stable/api/artist_api.html#module-matplotlib.lines + # for more information on line properties. + """), + Param("lines.linewidth", 1.5, validate_float, "line width in points"), + Param("lines.linestyle", "-", validate_string, "solid line"), + Param("lines.color", "C0", validate_string, "has no affect on plot(); see axes.prop_cycle"), + Param("lines.marker", "None", validate_string, "the default marker"), + Param("lines.markerfacecolor", "auto", validate_string, "the default marker face color"), + Param("lines.markeredgecolor", "auto", validate_string, "the default marker edge color"), + Param("lines.markeredgewidth", 1.0, validate_float, "the line width around the marker symbol"), + Param("lines.markersize", 6, validate_float, "marker size, in points"), + Param("lines.dash_joinstyle", "round", validate_string, "{miter, round, bevel}"), + Param("lines.dash_capstyle", "butt", validate_string, "{butt, round, projecting}"), + Param("lines.solid_joinstyle", "round", validate_string, "{miter, round, bevel}"), + Param("lines.solid_capstyle", "projecting", validate_string, "{butt, round, projecting}"), + Param("lines.antialiased", True, validate_bool, "render lines in antialiased (no jaggies)"), + Comment(""" + + # The three standard dash patterns. These are scaled by the linewidth. + """), + Param("lines.dashed_pattern", [3.7, 1.6], validate_floatlist), + Param("lines.dashdot_pattern", [6.4, 1.6, 1, 1.6], validate_floatlist), + Param("lines.dotted_pattern", [1, 1.65], validate_floatlist), + Param("lines.scale_dashes", True, validate_bool), +]) diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index ce20f57ef665..32537696ae6f 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -1,5 +1,6 @@ import copy import os +import textwrap from pathlib import Path import re import subprocess @@ -27,9 +28,13 @@ validate_hist_bins, validate_int, validate_markevery, + validate_string, validate_stringlist, _validate_linestyle, - _listify_validator) + _listify_validator, + RcParamsDefinition, + Param, Comment, +) def test_rcparams(tmpdir): @@ -604,3 +609,77 @@ def test_rcparams_legend_loc(): match_str = f"{value} is not a valid value for legend.loc;" with pytest.raises(ValueError, match=re.escape(match_str)): mpl.RcParams({'legend.loc': value}) + + +class TestRcParamsDefinition: + + # sample definition for testing + rc_def = RcParamsDefinition([ + Comment("""\ + # *************************************************************************** + # * LINES * + # *************************************************************************** + # See https://matplotlib.org/stable/api/artist_api.html#module-matplotlib.lines + # for more information on line properties. + """), + Param("lines.linewidth", 1.5, validate_float, "line width in points"), + Param("lines.linestyle", "-", validate_string, "solid line"), + Param("lines.antialiased", True, validate_bool, "antialiasing on lines"), + ]) + + @staticmethod + def unindented(text): + """Helper function to be able to use indented multi-line strings.""" + return textwrap.dedent(text).lstrip() + + def test_create_default_rcparams(self): + params = self.rc_def.create_default_rcparams() + assert type(params) is mpl.RcParams + # TODO: check why rcParams.items() returns the elements in an order + # different from the order given at setup - for now, just sort + assert sorted(params.items()) == sorted([ + ("lines.linewidth", 1.5), + ("lines.linestyle", "-"), + ("lines.antialiased", True), + ]) + + def test_write_default_matplotlibrc(self, tmp_path): + self.rc_def.write_default_matplotlibrc(tmp_path / "matplotlibrc") + + lines = (tmp_path / "matplotlibrc").read_text() + assert lines == self.unindented(""" + # *************************************************************************** + # * LINES * + # *************************************************************************** + # See https://matplotlib.org/stable/api/artist_api.html#module-matplotlib.lines + # for more information on line properties. + lines.linewidth: 1.5 # line width in points + lines.linestyle: - # solid line + lines.antialiased: True # antialiasing on lines + """) + + def test_read_matplotlibrc(self, tmp_path): + (tmp_path / "matplotlibrc").write_text(self.unindented(""" + lines.linewidth: 2 # line width in points + lines.linestyle: -- + """)) + params = self.rc_def.read_matplotlibrc(tmp_path / "matplotlibrc") + assert params == { + "lines.linewidth": 2, + "lines.linestyle": "--", + } + + def test_read_matplotlibrc_unknown_key(self, tmp_path): + (tmp_path / "matplotlibrc").write_text(self.unindented(""" + lines.linewidth: 2 # line width in points + lines.unkown_param: "fail" + """)) + with pytest.raises(ValueError, match="Unknown rcParam 'lines.unkown_param'"): + self.rc_def.read_matplotlibrc(tmp_path / "matplotlibrc") + + def test_read_matplotlibrc_invalid_value(self, tmp_path): + (tmp_path / "matplotlibrc").write_text(self.unindented(""" + lines.linewidth: two # line width in points + """)) + with pytest.raises(ValueError, match="Could not convert 'two' to float"): + self.rc_def.read_matplotlibrc(tmp_path / "matplotlibrc")