plot-io 0.12.0__py3-none-win_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-arm64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/states.py ADDED
@@ -0,0 +1,216 @@
1
+ """The render's states of a figure's areas: each plot resolved (calls, cycle, axes and
2
+ their limits, legend, colorbars), before the layout measures and places them."""
3
+
4
+ from typing import Any
5
+
6
+ from .axis import AxisState
7
+ from .layers.pipeline import plot_calls, train_colors, z_of
8
+ from .settings import cascade
9
+
10
+ __all__ = ["PlotState", "build", "occupy", "z_key", "label_content", "offset_content"]
11
+
12
+
13
+ def z_key(item: Any) -> float:
14
+ """Drawing order key of a call or a decoration (see :func:`~plot_io.layers.pipeline.z_of`)."""
15
+ return z_of(getattr(item, "series", item))
16
+
17
+
18
+ def occupy(call: Any, ctx: Any, occ: Any) -> None:
19
+ """Mark the cells a call's layers cover (legend placement)."""
20
+ c = ctx.for_call(call)
21
+ for layer in call.layers:
22
+ if not layer.occupy or layer.overlay:
23
+ continue
24
+ if layer.occupy == "all":
25
+ x0, y0, x1, y1 = c.rect
26
+ occ.rects([x0], [y0], [x1], [y1])
27
+ continue
28
+ seg = layer.options.get("segment")
29
+ if seg is None:
30
+ _occupy(c, layer, occ)
31
+ else:
32
+ with seg.scope(c) as cs:
33
+ _occupy(cs, layer, occ)
34
+
35
+
36
+ def _occupy(c: Any, layer: Any, occ: Any) -> None:
37
+ lay = layer.columns(c)
38
+ aes = layer.options.get("occupy_aes")
39
+ if aes:
40
+ lay = _with_aes(lay, aes)
41
+ lay.geom.occupy(c, lay, occ)
42
+
43
+
44
+ def _with_aes(layer: Any, aes: dict[str, str]) -> Any:
45
+ from .layers.core import Layer
46
+ out = Layer.__new__(Layer)
47
+ for k in Layer.__slots__:
48
+ setattr(out, k, getattr(layer, k))
49
+ out.aes = {**layer.aes, **aes}
50
+ return out
51
+
52
+
53
+ def label_content(state: AxisState) -> Any:
54
+ """The axis title: its label, or the label it takes from a data column (on a genome
55
+ axis, the chromosome and unit); ``None`` for ``""`` and under ``hide_title``."""
56
+ if state.hide_title:
57
+ return None
58
+ label = state.axis.resolve("label")
59
+ if state.genome:
60
+ from .genome.scale import axis_label
61
+ label = axis_label(state, label)
62
+ label = state.auto_label if label is None else label
63
+ return None if label == "" else label
64
+
65
+
66
+ def offset_content(state: AxisState) -> Any:
67
+ """What is written at the end of the axis, as its formatter gave it: the common ×10ᵏ
68
+ factor of the tick labels and the value added to them (``+1×10⁹``), or the common date
69
+ part of a time axis; ``None`` when there is none."""
70
+ return state.offset_label
71
+
72
+
73
+ class PlotState:
74
+ """A plot while its figure is drawn."""
75
+
76
+ def __init__(self, plot: Any, cycle: Any = None) -> None:
77
+ self.plot = plot
78
+ self.x = AxisState(plot.x_axis)
79
+ self.y = AxisState(plot.y_axis)
80
+ calls = plot_calls(plot)
81
+ #: the plot's calls, in drawing order
82
+ self.calls = sorted(calls, key=z_key)
83
+ twins = list(getattr(plot, "twins", []))
84
+ twin_calls = [c for t in twins for c in plot_calls(t)]
85
+ self.cycle = train_colors(plot, calls, twin_calls, cycle)
86
+ cycle = self.cycle
87
+ kinds = {c.axes for c in calls}
88
+ if len(kinds) > 1:
89
+ raise ValueError(f"{plot!r} mixes series drawn without axes (pie) and with axes")
90
+ self.axes_on = plot.resolve("axes") and kinds != {False}
91
+ decos = list(plot.decorations)
92
+ self.decorations = decos
93
+ limit_items = list(calls) + [d for d in decos if d.resolve("extend_limits")]
94
+ self.limit_items, self.padded = limit_items, self.axes_on
95
+ for role, st in (("x", self.x), ("y", self.y)):
96
+ st.auto_label = next((t for t in (c.axis_title(role) for c in calls)
97
+ if t is not None), None)
98
+ self.twins = [PlotState(t, cycle) for t in twins]
99
+ self.role_items = {"x": list(limit_items), "y": list(limit_items)}
100
+ for t in self.twins: # a twin shares the other axis
101
+ shared = "x" if t.plot.twin_of[1] == "y" else "y"
102
+ self.role_items[shared] += t.limit_items
103
+ self.x.resolve(self.role_items["x"], self.padded)
104
+ self.y.resolve(self.role_items["y"], self.padded)
105
+ #: roles whose axis is not drawn: hidden by every call on it, unless set visible
106
+ self.hidden: set[str] = set()
107
+ for role, st in (("x", self.x), ("y", self.y)):
108
+ on = [c for c in calls if c.has_axis(role)]
109
+ st.call_leaves = any(role in c.leaves for c in on)
110
+ if on and all(role in c.ticks for c in on):
111
+ st.call_ticks = [t for c in on for t in c.ticks[role]]
112
+ st.call_thin = any(role in c.thin for c in on)
113
+ if on and all(role in c.hide for c in on) \
114
+ and cascade(st.axis, "visible")[2] not in ("own", "figure"):
115
+ self.hidden.add(role)
116
+ for t in self.twins:
117
+ shared = "x" if t.plot.twin_of[1] == "y" else "y"
118
+ mine, theirs = getattr(self, shared), getattr(t, shared)
119
+ theirs.copy_limits(mine)
120
+ theirs.shared = mine
121
+ from .guides import GuideState
122
+ bars = list(enumerate(GuideState.for_colorbar(cb)
123
+ for cb in [*plot.colorbars, *size_keys(plot)]))
124
+ #: the attached colorbars, each side's from the axes outward (``order``, else
125
+ #: creation order); those a figure colorbar shows instead are left out (layout)
126
+ self.bars = [b for _, b in sorted(bars, key=lambda e: (
127
+ e[0] if e[1].order is None else e[1].order, e[0]))]
128
+ self.legend = GuideState.for_plot(self)
129
+ #: in a figure of given width / height, the plot's box width / height as last
130
+ #: placed (``None``: not given, or not placed yet): an outside legend wraps
131
+ #: within it (layout)
132
+ self.box_room: tuple[float | None, float | None] = (None, None)
133
+ #: the bands last measured (see :func:`plot_io.boxes.measure`), with the row
134
+ #: label and group bands (:func:`plot_io.rows.bands`)
135
+ self.bands: Any = None
136
+ #: the nodes of its box tree with their bands, as last measured (see
137
+ #: :mod:`plot_io.boxes`)
138
+ self.tree: list[Any] = []
139
+
140
+ def shows(self, role: str) -> bool:
141
+ """Whether the axis ``role`` is drawn (ticks, labels)."""
142
+ return role not in self.hidden and getattr(self.plot, f"{role}_axis").resolve("visible")
143
+
144
+ def aspect_want(self) -> float | None:
145
+ """Height / width the axes need for the `data_ratio` (1 without axes), else
146
+ ``None``."""
147
+ aspect = self.plot.resolve("data_ratio") or (None if self.axes_on else 1.0)
148
+ if not aspect:
149
+ return None
150
+ dx = abs(self.x.fhi - self.x.flo) or 1.0
151
+ dy = abs(self.y.fhi - self.y.flo) or 1.0
152
+ return aspect * dy / dx
153
+
154
+ def aspect_rect(self, rect: tuple[float, float, float, float]) -> tuple:
155
+ want = self.aspect_want() # h / w
156
+ if not want:
157
+ return rect
158
+ x0, y0, x1, y1 = rect
159
+ w, h = x1 - x0, y1 - y0
160
+ if h > w * want:
161
+ nh = w * want
162
+ y0 += (h - nh) / 2
163
+ y1 = y0 + nh
164
+ else:
165
+ nw = h / want
166
+ x0 += (w - nw) / 2
167
+ x1 = x0 + nw
168
+ return (x0, y0, x1, y1)
169
+
170
+
171
+ def build(fig: Any, areas: list[Any]) -> None:
172
+ """The areas' states: the color and size axes trained first (:func:`plot_io.mapped.
173
+ train`), then each plot, colorbar and size legend, then the shared x and y axes."""
174
+ from .guides import GuideState
175
+ from .legend import SizeLegend
176
+ from .mapped import train
177
+ from .plot import Plot
178
+ train(fig, areas)
179
+ for a in areas:
180
+ if isinstance(a.obj, Plot):
181
+ a.state = PlotState(a.obj)
182
+ elif isinstance(a.obj, SizeLegend):
183
+ a.state = GuideState.for_size_legend(a.obj)
184
+ else:
185
+ a.state = GuideState.for_colorbar(a.obj)
186
+ from .sharing import apply
187
+ apply(fig, areas)
188
+
189
+
190
+ def size_keys(plot: Any) -> list[Any]:
191
+ """The size keys a plot's series ask for (``size_key="key"`` or a list of values):
192
+ one per size axis of the plot and its twins, unless a colorbar of the plot or a size
193
+ legend area (``figure.size_legend``) shows it."""
194
+ from .colorbar import Colorbar
195
+ from .legend import SizeLegend
196
+ from .mapped import _size_key, size_axis_of
197
+ shown = {id(cb.source) for cb in plot.colorbars if cb.sizes}
198
+ fig = plot.figure
199
+ areas = {_size_key(o.size_axis) for o in (fig._areas.values() if fig is not None else ())
200
+ if isinstance(o, SizeLegend)}
201
+ areas.discard(None)
202
+ out = []
203
+ for p in [plot, *plot.twins]:
204
+ for s in p.series:
205
+ v = s.resolve("size_key") if "size_key" in s.SETTINGS else False
206
+ if v is False or v == "legend":
207
+ continue
208
+ axis = size_axis_of(s)
209
+ if axis is None or id(axis) in shown or (areas and _size_key(axis) in areas):
210
+ continue
211
+ shown.add(id(axis))
212
+ cb = Colorbar(axis, plot=plot)
213
+ if isinstance(v, list):
214
+ cb.marks = v
215
+ out.append(cb)
216
+ return out
@@ -0,0 +1,307 @@
1
+ """Styles: named presets of settings.
2
+
3
+ A style is a dict of settings: setting names for inherited settings (``"font_size"``),
4
+ ``"Class.name"`` for any setting of a class (``"Axis.grid"``) and ``"Group.member"`` for
5
+ a group (``"Marker.size"``). The key ``"base"`` names the styles it builds on, one or a
6
+ list (a later base over an earlier one). Keys starting with ``--`` are custom variables,
7
+ read by ``var(--name)``.
8
+
9
+ A figure's style chain is its style, then that style's bases (depth first, each once),
10
+ then ``"default"`` (:data:`~plot_io.defaults.DEFAULTS`). The nearer style wins; within a
11
+ style, the most specific key wins.
12
+ """
13
+
14
+ import contextlib
15
+ import contextvars
16
+ import difflib
17
+ from collections.abc import Generator
18
+ from typing import Any
19
+
20
+ from .. import hints
21
+ from ..defaults import DEFAULTS
22
+ from ..units import given
23
+ from ..settings import ALIASES, BASE_THEME, Setting, Theme, check_value, custom_vars
24
+
25
+ __all__ = ["add_style", "names", "settings", "set_style", "use_style",
26
+ "check_style", "effective", "current"]
27
+
28
+ #: ``"default"``: no keys of its own, as ``DEFAULTS`` ends every chain.
29
+ _STYLES: dict[str, dict[str, Any]] = {"default": {}}
30
+ _version = 0
31
+ _session: Any = None
32
+ #: the styles of the enclosing ``use_style`` blocks of this thread or task
33
+ _stack: contextvars.ContextVar[tuple[Any, ...]] = contextvars.ContextVar("styles", default=())
34
+
35
+
36
+ #: Style classes registered by the modules that define them (the genome track preset).
37
+ _REGISTERED: dict[str, Any] = {}
38
+
39
+
40
+ def register_class(cls: Any, name: str | None = None) -> None:
41
+ """Make ``cls`` (a ``Settings`` class) a style class: ``"Name.setting"`` style keys and
42
+ defaults apply to it. Plot types added with ``registry.layers.add``
43
+ are style classes without this."""
44
+ _REGISTERED[name or cls.__name__] = cls
45
+
46
+
47
+ def _classes() -> dict[str, Any]:
48
+ from ..axis import Axis
49
+ from ..colorbar import Colorbar, FigureColorbar
50
+ from ..figure import Figure, FigureTitle, MainTitle
51
+ from ..plot import FigureLegend, Legend, Plot, Title
52
+ from .. import decorations as d
53
+ from ..layers import Series, layer_types
54
+ out = {"Series": Series, "Decoration": d.Decoration,
55
+ "Figure": Figure, "FigureTitle": FigureTitle, "MainTitle": MainTitle, "Plot": Plot,
56
+ "Title": Title,
57
+ "Legend": Legend, "FigureLegend": FigureLegend, "Axis": Axis, "Colorbar": Colorbar,
58
+ "FigureColorbar": FigureColorbar}
59
+ for f in layer_types().values():
60
+ if isinstance(f, type) and issubclass(f, Series):
61
+ out[f.__name__] = f
62
+ out.update({"HLine": d.HLine, "VLine": d.VLine, "AxLine": d.AxLine, "HSpan": d.HSpan,
63
+ "VSpan": d.VSpan, "Text": d.Text, "Annotate": d.Annotate})
64
+ from .. import rows
65
+ out.update({"FigureVLine": rows.FigureVLine, "FigureVSpan": rows.FigureVSpan,
66
+ "FigureGroup": rows.FigureGroup})
67
+ out.update(_REGISTERED)
68
+ return out
69
+
70
+
71
+ def _subclass_setting(cls: Any, name: str) -> Setting | None:
72
+ """Setting ``name`` of ``cls`` or of a subclass (keys of a base class)."""
73
+ todo = [cls]
74
+ while todo:
75
+ c = todo.pop()
76
+ s = c.SETTINGS.get(name)
77
+ if s is not None:
78
+ return s
79
+ todo.extend(c.__subclasses__())
80
+ return None
81
+
82
+
83
+ def _setting(key: str) -> Setting:
84
+ """The setting a style key refers to: ``"name"`` (inherited), ``"Class.name"`` (of a
85
+ class and its subclasses) or ``"Group.member"``."""
86
+ from ..groups import GROUPS
87
+ if key.rpartition(".")[2] in ("color_map", "cmap"):
88
+ from ..color.maps import COLOR_MAP_HINT
89
+ raise ValueError(f"style key {key!r}: {COLOR_MAP_HINT}")
90
+ classes = _classes()
91
+ if "." in key:
92
+ cls_name, _, name = key.partition(".")
93
+ group = GROUPS.get(cls_name)
94
+ if group is not None and cls_name not in classes:
95
+ if name not in group.MEMBERS:
96
+ near = difflib.get_close_matches(name, group.MEMBERS, n=1)
97
+ hint = f" (did you mean {cls_name}.{near[0]}?)" if near else ""
98
+ raise ValueError(f"unknown member {key!r} in style{hint}")
99
+ return group.member_setting(name)
100
+ cls = classes.get(cls_name)
101
+ if cls_name in ("XAxis", "YAxis"): # the x or y axis of plots only
102
+ cls = classes["Axis"]
103
+ if cls is None:
104
+ near = difflib.get_close_matches(cls_name, list(classes) + list(GROUPS), n=1)
105
+ hint = f" (did you mean {near[0]!r}?)" if near else ""
106
+ raise ValueError(f"unknown class {cls_name!r} in style key {key!r}{hint}")
107
+ s = _subclass_setting(cls, name)
108
+ if s is None and cls_name == "Colorbar": # a setting of colorbar axes only
109
+ s = classes["Axis"].SETTINGS.get(name)
110
+ if s is None:
111
+ use = hints.setting(name, cls.SETTINGS)
112
+ if use is not None:
113
+ raise ValueError(f"unknown setting {key!r} in style: use {use}")
114
+ near = difflib.get_close_matches(name, cls.SETTINGS, n=1)
115
+ hint = f" (did you mean {cls_name}.{near[0]}?)" if near else ""
116
+ raise ValueError(f"unknown setting {key!r} in style{hint}")
117
+ return s
118
+ names: dict[str, Setting] = {}
119
+ for cls in classes.values():
120
+ for n, s in cls.SETTINGS.items():
121
+ if s.inherited:
122
+ names.setdefault(n, s)
123
+ if key not in names:
124
+ use = hints.setting(key, names)
125
+ if use is not None:
126
+ raise ValueError(f"unknown style key {key!r}: use {use}")
127
+ near = difflib.get_close_matches(key, names, n=1)
128
+ hint = f" (did you mean {near[0]!r}?)" if near else ""
129
+ raise ValueError(f"unknown style key {key!r}: not an inherited setting; use "
130
+ f"\"Class.{key}\" for a setting of one class{hint}")
131
+ return names[key]
132
+
133
+
134
+ def _bases(v: Any) -> tuple[str, ...]:
135
+ names = (v,) if isinstance(v, str) else tuple(v)
136
+ for n in names:
137
+ if not isinstance(n, str):
138
+ raise ValueError(f"a style base is a style name, got {n!r}")
139
+ _get(n)
140
+ return names
141
+
142
+
143
+ def _validate(style: dict[str, Any]) -> dict[str, Any]:
144
+ out: dict[str, Any] = {}
145
+ given_as: dict[str, str] = {}
146
+ for key, value in style.items():
147
+ if key == "base":
148
+ out["base"] = _bases(value)
149
+ continue
150
+ if key.startswith("--"): # a custom variable, read by var(--name)
151
+ if value is None or not key[2:]:
152
+ raise ValueError(f"style key {key!r}: a custom variable needs a name and "
153
+ f"a value")
154
+ out[key] = value
155
+ continue
156
+ cls_part, dot, name = key.rpartition(".")
157
+ given_key = key
158
+ if name in ALIASES:
159
+ key = f"{cls_part}{dot}{ALIASES[name]}"
160
+ if key in out:
161
+ raise ValueError(f"style keys {given_as[key]!r} and {given_key!r} are the same "
162
+ f"setting; give one")
163
+ given_as[key] = given_key
164
+ s = _setting(key)
165
+ if s.check is custom_vars:
166
+ raise ValueError(f"style key {key!r}: a style sets custom variables as "
167
+ f"\"--name\" keys")
168
+ if s.name == "style" and s is _classes()["Figure"].SETTINGS["style"]:
169
+ raise ValueError(f"style key {key!r}: a style builds on others with \"base\"")
170
+ if value is None:
171
+ raise ValueError(f"style key {key!r}: None is not a value")
172
+ try:
173
+ value = check_value(s, value)
174
+ except (TypeError, ValueError) as e:
175
+ raise ValueError(f"style key {key!r}: {e}") from None
176
+ out[key] = value
177
+ return out
178
+
179
+
180
+ _loaded = False
181
+
182
+
183
+ def _load() -> None:
184
+ """Register the bundled styles (on first use: they refer to every settings class)."""
185
+ global _loaded
186
+ if not _loaded:
187
+ _loaded = True
188
+ from . import library # noqa: F401
189
+
190
+
191
+ def _get(name: str) -> dict[str, Any]:
192
+ _load()
193
+ key = name.strip().lower()
194
+ if key not in _STYLES:
195
+ near = difflib.get_close_matches(key, _STYLES, n=1)
196
+ hint = f" (did you mean {near[0]!r}?)" if near else ""
197
+ known = ", ".join(repr(n) for n in sorted(_STYLES) if n != "solid")
198
+ raise ValueError(f"unknown style {name!r}{hint}; the styles: {known}, or a dict")
199
+ return _STYLES[key]
200
+
201
+
202
+ def add_style(name: str, style: dict[str, Any]) -> None:
203
+ """Register ``style`` (a dict of settings, with an optional ``"base"``) as ``name``."""
204
+ global _version
205
+ _load()
206
+ if name.strip().lower() == "default":
207
+ raise ValueError('the "default" style is DEFAULTS and cannot be replaced; register '
208
+ 'a style under another name and set it with set_style')
209
+ if not isinstance(style, dict):
210
+ raise ValueError("a style is a dict of settings")
211
+ key = name.strip().lower()
212
+ old = _STYLES.get(key)
213
+ _STYLES[key] = _validate(style)
214
+ try:
215
+ chain(key) # refuses a cycle of bases
216
+ except ValueError:
217
+ if old is None:
218
+ del _STYLES[key]
219
+ else:
220
+ _STYLES[key] = old
221
+ raise
222
+ _version += 1
223
+
224
+
225
+ def names() -> list[str]:
226
+ """Names of the registered styles."""
227
+ _load()
228
+ return sorted(_STYLES)
229
+
230
+
231
+ def check_style(v: Any) -> Any:
232
+ """Validator of the ``style`` setting: a registered name or a dict."""
233
+ if isinstance(v, str):
234
+ _get(v)
235
+ return v.strip().lower()
236
+ if isinstance(v, dict):
237
+ return _validate(v)
238
+ raise ValueError(f"a style is a style name or a dict of settings, got {v!r}")
239
+
240
+
241
+ def chain(style: Any) -> list[tuple[str, dict[str, Any]]]:
242
+ """The styles of ``style``'s chain other than ``"default"``, nearest first, as
243
+ ``(label, keys)``."""
244
+ out: list[tuple[str, dict[str, Any]]] = []
245
+ done: set[str] = set()
246
+
247
+ def walk(st: Any, path: tuple[str, ...], of: str | None) -> None:
248
+ if isinstance(st, str):
249
+ key = st.strip().lower()
250
+ if key in path:
251
+ raise ValueError(f"style {key!r} is its own base "
252
+ f"({' → '.join(path + (key,))})")
253
+ if key in done or key == "default":
254
+ return
255
+ done.add(key)
256
+ d, path, name = _get(key), path + (key,), f'"{key}"'
257
+ else:
258
+ d, name = check_style(st), "(a dict)"
259
+ label = name if of is None else f"{name} (base of {of})"
260
+ out.append((label, {k: v for k, v in d.items() if k != "base"}))
261
+ for b in reversed(d.get("base", ())):
262
+ walk(b, path, name)
263
+
264
+ walk(style, (), None)
265
+ return out
266
+
267
+
268
+ def settings(style: Any) -> dict[str, Any]:
269
+ """Every key of ``style``'s chain, ``DEFAULTS`` included, as the nearest style gives
270
+ it (values as given), to inspect or copy."""
271
+ out = dict(DEFAULTS)
272
+ for _, keys in reversed(chain(style)):
273
+ out.update(keys)
274
+ return {k: given(v) for k, v in out.items()}
275
+
276
+
277
+ def effective(style: Any) -> Theme:
278
+ """The theme of a figure with ``style`` (``None``: ``DEFAULTS`` only)."""
279
+ return BASE_THEME if style is None else Theme(chain(style))
280
+
281
+
282
+ def version() -> int:
283
+ """Changes whenever a style is registered (for caches of effective themes)."""
284
+ _load()
285
+ return _version
286
+
287
+
288
+ def current() -> Any:
289
+ """The style new figures take: the innermost ``use_style``, else ``set_style``'s."""
290
+ stack = _stack.get()
291
+ return stack[-1] if stack else _session
292
+
293
+
294
+ def set_style(style: Any) -> None:
295
+ """Default style of figures created afterwards (``None``: back to ``"default"``)."""
296
+ global _session
297
+ _session = None if style is None else check_style(style)
298
+
299
+
300
+ @contextlib.contextmanager
301
+ def use_style(style: Any) -> Generator[None]:
302
+ """Default style of figures created inside the block."""
303
+ token = _stack.set((*_stack.get(), check_style(style)))
304
+ try:
305
+ yield
306
+ finally:
307
+ _stack.reset(token)