iplotx 0.3.1__py3-none-any.whl → 0.5.0__py3-none-any.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.
@@ -1,155 +1,33 @@
1
1
  from typing import (
2
2
  Any,
3
+ Iterable,
3
4
  Optional,
4
5
  Sequence,
5
6
  )
7
+ from collections import defaultdict
6
8
  from collections.abc import Hashable
7
- import copy
8
9
  from contextlib import contextmanager
9
10
  import numpy as np
10
11
  import pandas as pd
11
12
 
12
-
13
- style_leaves = (
14
- "cmap",
15
- "color",
16
- "size",
17
- "edgecolor",
18
- "facecolor",
19
- "linewidth",
20
- "linestyle",
21
- "alpha",
22
- "zorder",
23
- "tension",
24
- "looptension",
25
- "loopmaxangle",
26
- "paralleloffset",
27
- "offset",
28
- "rotate",
29
- "marker",
30
- "waypoints",
31
- "horizontalalignment",
32
- "verticalalignment",
33
- "boxstyle",
34
- "hpadding",
35
- "vpadding",
36
- "hmargin",
37
- "vmargin",
38
- "ports",
39
- "extend",
40
- )
41
-
42
- # These properties are not allowed to be rotated (global throughout the graph).
43
- # This might change in the future as the API improves.
44
- nonrotating_leaves = (
45
- "paralleloffset",
46
- "looptension",
47
- "loopmaxangle",
48
- "vertexpadding",
49
- "extend",
13
+ from ..utils.style import copy_with_deep_values
14
+ from .library import style_library
15
+ from .leaf_info import (
16
+ style_leaves,
17
+ nonrotating_leaves,
50
18
  )
51
19
 
52
20
 
53
- default = {
54
- "vertex": {
55
- "size": 20,
56
- "facecolor": "black",
57
- "marker": "o",
58
- "label": {
59
- "color": "white",
60
- "horizontalalignment": "center",
61
- "verticalalignment": "center",
62
- "hpadding": 18,
63
- "vpadding": 12,
64
- },
65
- },
66
- "edge": {
67
- "linewidth": 1.5,
68
- "linestyle": "-",
69
- "color": "black",
70
- "curved": False,
71
- "paralleloffset": 3,
72
- "tension": 1,
73
- "looptension": 4,
74
- "loopmaxangle": 60,
75
- "label": {
76
- "horizontalalignment": "center",
77
- "verticalalignment": "center",
78
- "rotate": False,
79
- "bbox": {
80
- "boxstyle": "round",
81
- "facecolor": "white",
82
- "edgecolor": "none",
83
- },
84
- },
85
- "arrow": {
86
- "marker": "|>",
87
- "width": 8,
88
- },
89
- },
90
- "grouping": {
91
- "facecolor": ["grey", "steelblue", "tomato"],
92
- "edgecolor": "black",
93
- "linewidth": 1.5,
94
- "alpha": 0.5,
95
- "vertexpadding": 18,
96
- },
97
- }
98
-
99
-
100
- def copy_with_deep_values(style):
101
- """Make a deep copy of the style dict but do not create copies of the keys."""
102
- newdict = {}
103
- for key, value in style.items():
104
- if isinstance(value, dict):
105
- newdict[key] = copy_with_deep_values(value)
106
- else:
107
- newdict[key] = copy.copy(value)
108
- return newdict
109
-
110
-
111
- hollow = copy_with_deep_values(default)
112
- hollow["vertex"]["color"] = None
113
- hollow["vertex"]["facecolor"] = "none"
114
- hollow["vertex"]["edgecolor"] = "black"
115
- hollow["vertex"]["linewidth"] = 1.5
116
- hollow["vertex"]["marker"] = "r"
117
- hollow["vertex"]["size"] = "label"
118
- hollow["vertex"]["label"]["color"] = "black"
119
-
120
- tree = copy_with_deep_values(default)
121
- tree["vertex"]["size"] = 0
122
- tree["vertex"]["alpha"] = 0
123
- tree["edge"]["linewidth"] = 2.5
124
- tree["vertex"]["label"]["bbox"] = {
125
- "boxstyle": "square,pad=0.5",
126
- "facecolor": "white",
127
- "edgecolor": "none",
128
- }
129
- tree["vertex"]["label"]["color"] = "black"
130
- tree["vertex"]["label"]["size"] = 12
131
- tree["vertex"]["label"]["verticalalignment"] = "center"
132
- tree["vertex"]["label"]["hmargin"] = 10
133
-
134
-
21
+ # Prepopulate default style, it's used later as a backbone for everything else
22
+ default = style_library["default"]
135
23
  styles = {
136
24
  "default": default,
137
- "hollow": hollow,
138
- "tree": tree,
139
25
  }
140
26
 
141
27
 
142
- stylename = "default"
143
-
144
-
145
28
  current = copy_with_deep_values(styles["default"])
146
29
 
147
30
 
148
- def get_stylename():
149
- """Return the name of the current iplotx style."""
150
- return str(stylename)
151
-
152
-
153
31
  def get_style(name: str = "", *args) -> dict[str, Any]:
154
32
  """Get a *deep copy* of the chosen style.
155
33
 
@@ -194,36 +72,33 @@ def get_style(name: str = "", *args) -> dict[str, Any]:
194
72
  return style
195
73
 
196
74
 
197
- # The following is inspired by matplotlib's style library
198
- # https://github.com/matplotlib/matplotlib/blob/v3.10.3/lib/matplotlib/style/core.py#L45
199
- def use(style: Optional[str | dict | Sequence] = None, **kwargs):
200
- """Use iplotx style setting for a style specification.
201
-
202
- The style name of 'default' is reserved for reverting back to
203
- the default style settings.
75
+ def merge_styles(
76
+ styles: Sequence[str | dict[str, Any]] | Iterable[str | dict[str, Any]],
77
+ ) -> dict[str, Any]:
78
+ """Merge a sequence of styles into a single one.
204
79
 
205
80
  Parameters:
206
- style: A style specification, currently either a name of an existing style
207
- or a dict with specific parts of the style to override. The string
208
- "default" resets the style to the default one. If this is a sequence,
209
- each style is applied in order.
210
- **kwargs: Additional style changes to be applied at the end of any style.
81
+ styles: Sequence (list, tuple, etc.) of styles, each either the name of an internal
82
+ style or a dict-like with custom properties.
83
+ Returns:
84
+ The composite style as a dict.
211
85
  """
212
86
  try:
213
87
  import networkx as nx
214
88
  except ImportError:
215
89
  nx = None
216
90
 
217
- global current
218
-
219
91
  def _sanitize_leaves(style: dict):
220
92
  for key, value in style.items():
221
93
  if key in style_leaves:
94
+ # Networkx has a few lazy data structures
95
+ # TODO: move this code to provider
222
96
  if nx is not None:
223
97
  if isinstance(value, nx.classes.reportviews.NodeView):
224
98
  style[key] = dict(value)
225
99
  elif isinstance(value, nx.classes.reportviews.EdgeViewABC):
226
100
  style[key] = [v for *e, v in value]
101
+
227
102
  elif isinstance(value, dict):
228
103
  _sanitize_leaves(value)
229
104
 
@@ -233,37 +108,97 @@ def use(style: Optional[str | dict | Sequence] = None, **kwargs):
233
108
  current[key] = value
234
109
  continue
235
110
 
236
- # Style leaves are by definition not to be recurred into
237
- if isinstance(value, dict) and (key not in style_leaves):
238
- _update(value, current[key])
239
- elif value is None:
240
- del current[key]
111
+ # Style non-leaves are either recurred into or deleted
112
+ if key not in style_leaves:
113
+ if isinstance(value, dict):
114
+ _update(value, current[key])
115
+ elif value is None:
116
+ del current[key]
117
+ else:
118
+ raise ValueError(
119
+ f"Setting non-leaf style value to a non-dict: {key}, {value}",
120
+ )
241
121
  else:
242
- current[key] = value
122
+ # Style leaves could be incomplete, ensure a sensible default
123
+ if value is None:
124
+ del current[key]
125
+ continue
243
126
 
244
- old_style = copy_with_deep_values(current)
127
+ if not isinstance(value, dict):
128
+ current[key] = value
129
+ continue
245
130
 
246
- try:
247
- if style is None:
248
- styles = []
249
- elif isinstance(style, (dict, str)):
250
- styles = [style]
131
+ if hasattr(value, "default_factory"):
132
+ current[key] = value
133
+ continue
134
+
135
+ if hasattr(current[key], "default_factory"):
136
+ default_value = current[key].default_factory()
137
+ else:
138
+ default_value = current[key]
139
+ current[key] = defaultdict(
140
+ lambda: default_value,
141
+ value,
142
+ )
143
+
144
+ merged = {}
145
+ for style in styles:
146
+ if isinstance(style, str):
147
+ style = get_style(style)
251
148
  else:
252
- styles = list(style)
149
+ _sanitize_leaves(style)
150
+ unflatten_style(style)
151
+ _update(style, merged)
253
152
 
254
- if kwargs:
255
- styles.append(kwargs)
153
+ return merged
256
154
 
257
- for style in styles:
258
- if style == "default":
259
- reset()
260
- else:
261
- if isinstance(style, str):
262
- current = get_style(style)
263
- else:
264
- _sanitize_leaves(style)
265
- unflatten_style(style)
266
- _update(style, current)
155
+
156
+ # The following is inspired by matplotlib's style library
157
+ # https://github.com/matplotlib/matplotlib/blob/v3.10.3/lib/matplotlib/style/core.py#L45
158
+ def use(
159
+ style: Optional[
160
+ str | dict[str, Any] | Sequence[str | dict[str, Any]] | Iterable[str | dict[str, Any]]
161
+ ] = None,
162
+ **kwargs,
163
+ ):
164
+ """Use iplotx style setting for a style specification.
165
+
166
+ The style name of 'default' is reserved for reverting back to
167
+ the default style settings.
168
+
169
+ Parameters:
170
+ style: A style specification, currently either a name of an existing style
171
+ or a dict with specific parts of the style to override. The string
172
+ "default" resets the style to the default one. If this is a sequence,
173
+ each style is applied in order.
174
+ **kwargs: Additional style changes to be applied at the end of any style.
175
+ """
176
+ global current
177
+
178
+ styles = []
179
+ if isinstance(style, (dict, str)):
180
+ styles.append(style)
181
+ elif style is not None:
182
+ styles.extend(list(style))
183
+ if kwargs:
184
+ styles.append(kwargs)
185
+
186
+ # Discard empty styles for speed
187
+ styles = [style for style in styles if style]
188
+
189
+ if len(styles) == 0:
190
+ return
191
+
192
+ # If the first style is a string (internal style), apply it cold. If it's a
193
+ # dict, apply it hot (on top of the current style(. Any style after the first
194
+ # is always applied hot - otherwise it would invalidate previous styles.
195
+ if not isinstance(styles[0], str):
196
+ # hot insertion on top of current
197
+ styles.insert(0, current)
198
+
199
+ old_style = copy_with_deep_values(current)
200
+ try:
201
+ current = merge_styles(styles)
267
202
  except:
268
203
  current = old_style
269
204
  raise
@@ -276,7 +211,12 @@ def reset() -> None:
276
211
 
277
212
 
278
213
  @contextmanager
279
- def context(style: Optional[str | dict | Sequence] = None, **kwargs):
214
+ def context(
215
+ style: Optional[
216
+ str | dict[str, Any] | Sequence[str | dict[str, Any]] | Iterable[str | dict[str, Any]]
217
+ ] = None,
218
+ **kwargs,
219
+ ):
280
220
  """Create a style context for iplotx.
281
221
 
282
222
  Parameters:
@@ -336,9 +276,7 @@ def unflatten_style(
336
276
 
337
277
  # top-level adjustments
338
278
  if "zorder" in style_flat:
339
- style_flat["network_zorder"] = style_flat["grouping_zorder"] = style_flat.pop(
340
- "zorder"
341
- )
279
+ style_flat["network_zorder"] = style_flat["grouping_zorder"] = style_flat.pop("zorder")
342
280
 
343
281
  # Begin recursion
344
282
  _inner(style_flat)
@@ -348,6 +286,7 @@ def rotate_style(
348
286
  style: dict[str, Any],
349
287
  index: Optional[int] = None,
350
288
  key: Optional[Hashable] = None,
289
+ key2: Optional[Hashable] = None,
351
290
  props: Optional[Sequence[str]] = None,
352
291
  ) -> dict[str, Any]:
353
292
  """Rotate leaves of a style for a certain index or key.
@@ -357,6 +296,9 @@ def rotate_style(
357
296
  index: The integer to rotate the style leaves into.
358
297
  key: For dict-like leaves (e.g. vertex properties specified as a dict-like object over the
359
298
  vertices themselves), the key to use for rotation (e.g. the vertex itself).
299
+ key2: For dict-like leaves, a backup key in case the first key fails. If this is None
300
+ or also a failure (i.e. KeyError), default to the empty type constructor for the
301
+ first value of the dict-like style leaf.
360
302
  props: The properties to rotate, usually all leaf properties.
361
303
 
362
304
  Returns:
@@ -369,9 +311,7 @@ def rotate_style(
369
311
  {'vertex': {'size': 10}}
370
312
  """
371
313
  if (index is None) and (key is None):
372
- raise ValueError(
373
- "At least one of 'index' or 'key' must be provided to rotate_style."
374
- )
314
+ raise ValueError("At least one of 'index' or 'key' must be provided to rotate_style.")
375
315
 
376
316
  if props is None:
377
317
  props = tuple(prop for prop in style_leaves if prop not in nonrotating_leaves)
@@ -383,23 +323,46 @@ def rotate_style(
383
323
  if val is None:
384
324
  continue
385
325
  # Try integer indexing for ordered types
386
- if (index is not None) and isinstance(
387
- val, (tuple, list, np.ndarray, pd.Index, pd.Series)
388
- ):
389
- style[prop] = np.asarray(val)[index % len(val)]
326
+ if (index is not None) and isinstance(val, (tuple, list, np.ndarray, pd.Index, pd.Series)):
327
+ # NOTE: cannot cast to ndarray because rotation might involve
328
+ # cross-type lists (e.g. ["none", False])
329
+ style[prop] = list(val)[index % len(val)]
390
330
  # Try key indexing for unordered, dict-like types
391
331
  if (
392
332
  (key is not None)
393
333
  and (not isinstance(val, (str, tuple, list, np.ndarray)))
394
334
  and hasattr(val, "__getitem__")
395
335
  ):
396
- # If only a subset of keys is provided, default the other ones
397
- # to the empty type constructor (e.g. 0 for ints, 0.0 for floats,
398
- # empty strings).
336
+ # If only a subset of keys is provided, try the default value a la
337
+ # defaultdict. If that fails, use an empty constructor
399
338
  if key in val:
400
- style[prop] = val[key]
339
+ newval = val[key]
340
+ elif key2 is not None and (key2 in val):
341
+ newval = val[key2]
401
342
  else:
402
- valtype = type(next(iter(val.values())))
403
- style[prop] = valtype()
343
+ try:
344
+ newval = val[key]
345
+ except KeyError:
346
+ valtype = type(next(iter(val.values())))
347
+ newval = valtype()
348
+ style[prop] = newval
404
349
 
405
350
  return style
351
+
352
+
353
+ def add_style(name: str, style: dict[str, Any]) -> None:
354
+ """Add a style to the default dictionary of styles.
355
+
356
+ Parameters:
357
+ name: The name of the style to add.
358
+ style: A dictionary with the style properties to add.
359
+ """
360
+ with context(["default", style]):
361
+ styles[name] = get_style()
362
+
363
+
364
+ # Populate style library (default is already there)
365
+ for name, style in style_library.items():
366
+ if name != "default":
367
+ add_style(name, style)
368
+ del name, style
@@ -0,0 +1,44 @@
1
+ # These properties are at the bottom of a style dictionary. Values corresponding
2
+ # to these keys are rotatable.
3
+ rotating_leaves = (
4
+ "cmap",
5
+ "color",
6
+ "size",
7
+ "edgecolor",
8
+ "facecolor",
9
+ "linewidth",
10
+ "linestyle",
11
+ "alpha",
12
+ "zorder",
13
+ "tension",
14
+ "offset",
15
+ "rotate",
16
+ "marker",
17
+ "waypoints",
18
+ "horizontalalignment",
19
+ "verticalalignment",
20
+ "boxstyle",
21
+ "hpadding",
22
+ "vpadding",
23
+ "hmargin",
24
+ "vmargin",
25
+ "ports",
26
+ "width",
27
+ "height",
28
+ )
29
+
30
+ # These properties are also terminal style properties, but they cannot be rotated.
31
+ # This might change in the future as the API improves.
32
+ nonrotating_leaves = (
33
+ "paralleloffset",
34
+ "looptension",
35
+ "loopmaxangle",
36
+ "vertexpadding",
37
+ "extend",
38
+ "deep",
39
+ "angular",
40
+ "curved",
41
+ )
42
+
43
+ # Union of all style leaves (rotating and nonrotating)
44
+ style_leaves = tuple(list(rotating_leaves) + list(nonrotating_leaves))