mgplot 0.1.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.
@@ -0,0 +1,460 @@
1
+ """
2
+ kw_type_checking.py
3
+ - report_kwargs()
4
+ - validate_kwargs()
5
+ - validate_expected()
6
+ - limit_kwargs()
7
+
8
+ Private functions used for validating the arguments passed
9
+ to the major functions as **kwargs keyword arguments. This
10
+ allows us to warn when an unexpected argument appears or
11
+ when the value is not of the expected type.
12
+
13
+ This module is not intended to be used directly by the user.
14
+
15
+ The assumption is that most keyword arguments are one of the
16
+ following types:
17
+ - simple types (such as str, int, float, bool, complex, NoneType)
18
+ - Sequences (such as list, tuple, but excluding strings, and without
19
+ being infinitely recursive, like a list of lists of lists ...)
20
+ - Sets (such as set, frozenset)
21
+ - Mappings (such as dict)
22
+
23
+ Note: this means some Python types are only partially supported.
24
+ Others are unsupported, such as: generators, iterators, and
25
+ coroutines.
26
+
27
+ In order to check the **kwargs dictionary, we need to construct
28
+ a dictionary of expected keywords and their expected types.
29
+ An example follows.
30
+
31
+ expected = {
32
+ "arg1": str, # arg1 is expected to be a string
33
+ "arg2": (int, float), # arg2 is an int or a float
34
+ "arg3": (list, (bool,)), # arg3 is a list of Booleans
35
+ "arg4": (list, (float, int)), # arg4 is a list of floats or ints
36
+ "arg5": (Sequence, (float, int)), # a sequence of floats or ints
37
+ "arg6": (dict, (str, int)), # a dictionary with str keys and int values
38
+ )
39
+
40
+ Parsing Rules:
41
+ - If the type is a single type, it is used as is.
42
+ - if the type is a tuple of simple types, it is treated as a union.
43
+ - if the type of non-String Sequence, the subsequent tuple is a
44
+ union of Sequence member types.
45
+ - eg, (list, (float, int)) is a list of floats or ints.
46
+ - eg, (int, float, list, (int, float)) is an int, a float or
47
+ a list of ints or floats.
48
+ - if the type of a Mapping, the subsequent 2-part tuple is treated
49
+ as the types of the keys and values of the Mapping.
50
+ - eg, (dict, (str, int)) is a dictionary with str keys and int values.
51
+ - eg, (dict, (str, (int, float))) is a dictionary with str keys and
52
+ an int or float values.
53
+ - eg, (dict, (str, list, (int, float)), (list, (int, float))) is a
54
+ dictionary with str keys and a list of ints or floats as values.
55
+ - Sets are treated like Sequences.
56
+
57
+ Limitations:
58
+ - cannot specify multiple types of Sequence as a type - for example
59
+ ((list, tuple), int) - but you can specify (Sequence, int) which
60
+ will match list and tuple types. or you might do it as follows:
61
+ (list, (int, float), tuple, (int, float)).
62
+ - strings, bytearrays, bytes are treated as simple types, not Sequences.
63
+ - You cannot use generators or iterators as types, they would be
64
+ consumed in the testing.
65
+ - Sequence, Set and Mapping must be imported from collections.abc
66
+ and not from the older typing module. A world of pain awaits
67
+ if you do.
68
+ """
69
+
70
+ # --- imports
71
+ from typing import Any, Final, Union, Optional
72
+ from typing import Sequence as TypingSequence
73
+ from typing import Set as TypingSet
74
+ from typing import Iterable as TypingIterable
75
+ from typing import Mapping as TypingMapping
76
+
77
+ from collections.abc import Sequence, Set # Iterable and Sized
78
+ from collections.abc import Mapping
79
+ from collections.abc import Iterable, Sized, Container, Callable, Generator, Iterator
80
+
81
+ import textwrap
82
+
83
+
84
+ # --- constants
85
+ type NestedTypeTuple = tuple[type | NestedTypeTuple, ...] # recursive type
86
+ type ExpectedTypeDict = dict[str, type | NestedTypeTuple]
87
+
88
+ NOT_SEQUENCE: Final[tuple[type, ...]] = (str, bytearray, bytes, memoryview)
89
+ REPORT_KWARGS: Final[str] = "report_kwargs" # special case
90
+
91
+
92
+ # --- module-scoped global variable
93
+ module_testing: bool = False
94
+
95
+
96
+ # --- functions
97
+
98
+ # === keyword argument reporting ===
99
+
100
+
101
+ def report_kwargs(
102
+ called_from: str,
103
+ **kwargs,
104
+ ) -> None:
105
+ """
106
+ Dump the received keyword arguments to the console.
107
+ Useful for debugging purposes.
108
+
109
+ Arguments:
110
+ - called_from: str - the name of the function that called this
111
+ function, used for debugging.
112
+ - **kwargs - the keyword arguments to be reported, but only if
113
+ the REPORT_KWARGS key is present and set to True.
114
+ """
115
+
116
+ if kwargs.get(REPORT_KWARGS, False):
117
+ wrapped = textwrap.fill(str(kwargs), width=79)
118
+ print(f"{called_from} kwargs:\n{wrapped}\n".strip())
119
+
120
+
121
+ # === limit kwargs to those in an approved list
122
+
123
+
124
+ def limit_kwargs(
125
+ expected: ExpectedTypeDict,
126
+ **kwargs,
127
+ ) -> dict[str, Any]:
128
+ """
129
+ Limit the keyword arguments to those in the expected dict.
130
+ """
131
+
132
+ return {k: v for k, v in kwargs.items() if k in expected or k == REPORT_KWARGS}
133
+
134
+
135
+ # === Keyword expectation validation ===
136
+
137
+
138
+ def _check_expected_tuple(
139
+ t: NestedTypeTuple,
140
+ ) -> bool:
141
+
142
+ post_mapping = post_sequence = False
143
+ empty = True
144
+ for element in t:
145
+ empty = False
146
+
147
+ if isinstance(element, type):
148
+ if post_mapping or post_sequence:
149
+ return False
150
+ if issubclass(element, NOT_SEQUENCE):
151
+ post_mapping = post_sequence = False
152
+ continue
153
+ if issubclass(element, (Sequence, Set)):
154
+ post_sequence = True
155
+ continue
156
+ if issubclass(element, Mapping):
157
+ post_mapping = True
158
+ continue
159
+ post_mapping = post_sequence = False
160
+ continue
161
+
162
+ if isinstance(element, tuple):
163
+ if not (post_mapping or post_sequence):
164
+ return False
165
+ if post_sequence:
166
+ check = _check_expectations(element)
167
+ if not check:
168
+ return False
169
+ post_sequence = False
170
+ if post_mapping:
171
+ if len(element) != 2:
172
+ return False
173
+ check = _check_expectations(element[0]) and _check_expectations(
174
+ element[1]
175
+ )
176
+ if not check:
177
+ return False
178
+ post_mapping = False
179
+ if empty:
180
+ return False
181
+ return True
182
+
183
+
184
+ def _check_expected_type(t: type) -> bool:
185
+ """
186
+ Check t is an acceptable stand alone type
187
+ """
188
+
189
+ if issubclass(t, NOT_SEQUENCE):
190
+ return True
191
+ if issubclass(t, (Sequence, Set, Mapping)):
192
+ return False
193
+ return True
194
+
195
+
196
+ def _check_expectations(
197
+ t: type | NestedTypeTuple,
198
+ ) -> bool:
199
+ """
200
+ Check t is a type or a tuple of types.
201
+
202
+ Where a Sequence or Mapping type is found, check that
203
+ the subsequent tuple contains valid member types.
204
+ """
205
+
206
+ # --- simple case
207
+ if isinstance(t, type):
208
+ return _check_expected_type(t)
209
+
210
+ # --- more challenging case
211
+ if isinstance(t, tuple):
212
+ return _check_expected_tuple(t)
213
+
214
+ return False
215
+
216
+
217
+ def validate_expected(
218
+ expected: ExpectedTypeDict,
219
+ called_from: str,
220
+ ) -> None:
221
+ """
222
+ Check the expected types dictionary is properly formed.
223
+ This function should be used on all the expected types
224
+ dictionaries in the module.
225
+
226
+ It is not intended to be used by the user.
227
+
228
+ This function raises an ValueError exception if the expected
229
+ types dictionary is malformed.
230
+ """
231
+
232
+ def check_members(key: str, t: type | NestedTypeTuple) -> str:
233
+ """
234
+ Recursively check each element of the NestedTypeTuple.
235
+ to ensure it is a type or a tuple of types. Returns a string
236
+ description of any problems found.
237
+ """
238
+
239
+ problems = ""
240
+ # --- start with the things that are types
241
+ if t in (Iterable, Sized, Container, Callable, Generator, Iterator):
242
+ # note: these collections.abc types *are* types
243
+ problems += f"{key}: the collections.abc type {t} in {called_from} is unsupported.\n"
244
+ elif t in (Any,):
245
+ # Any is also an instance of type
246
+ problems += f"{key}: please use 'object' rather than 'typing.Any'.\n"
247
+ elif isinstance(t, type):
248
+ pass # Fantastic!
249
+ # --- then the things that are not types
250
+ elif isinstance(t, tuple):
251
+ for element in t:
252
+ problems += check_members(key, element)
253
+ elif t in (
254
+ # note: these typing types *are not* types
255
+ TypingSequence,
256
+ TypingSet,
257
+ TypingMapping,
258
+ TypingIterable,
259
+ Union,
260
+ Optional,
261
+ ):
262
+ problems += (
263
+ f"{key}: Only use the collection.abc types: {t} in {called_from}.\n"
264
+ )
265
+ else:
266
+ problems += f"{key}: Malformed typing '{t}' in {called_from}.\n"
267
+ return problems
268
+
269
+ problems = ""
270
+ for key, value in expected.items():
271
+ if not isinstance(key, str):
272
+ problems += f"Key '{key}' is not a string - {called_from=}.\n"
273
+ continue
274
+ problems += check_members(key, value)
275
+ if not _check_expectations(value):
276
+ problems += f"{key}: Malformed '{value}' in {called_from}.\n"
277
+ if problems:
278
+ # Other than testing, we want to raise an exception here
279
+ statement = (
280
+ "Expected types validation failed "
281
+ + f"(this is an internal package error):\n{problems}"
282
+ )
283
+ if not module_testing:
284
+ raise ValueError(statement)
285
+ print(statement)
286
+
287
+
288
+ # === keyword validation: (1) if expected, (2) of the right type ===
289
+
290
+
291
+ def _check_tuple(
292
+ value: Any,
293
+ typeinfo: NestedTypeTuple, # we know this is a tuple
294
+ ) -> bool:
295
+ """
296
+ Check the value against the expected tuple type.
297
+ """
298
+
299
+ check_sequence = check_mapping = False
300
+ for thistype in typeinfo:
301
+
302
+ if check_mapping or check_sequence: # the guard-rail
303
+ if not isinstance(thistype, tuple):
304
+ return False
305
+
306
+ if check_sequence and isinstance(thistype, tuple):
307
+ for v in value:
308
+ check = _type_check_kwargs(v, thistype)
309
+ if not check:
310
+ check_sequence = False
311
+ continue
312
+ return True
313
+
314
+ if check_mapping and isinstance(thistype, tuple):
315
+ for k, v in value.items():
316
+ check = _type_check_kwargs(k, thistype[0]) and _type_check_kwargs(
317
+ v, thistype[1]
318
+ )
319
+ if not check:
320
+ check_mapping = False
321
+ continue
322
+ return True
323
+
324
+ if isinstance(thistype, type) and isinstance(value, thistype):
325
+ if thistype in NOT_SEQUENCE:
326
+ return True
327
+ if issubclass(thistype, (Sequence, Set)):
328
+ check_sequence = True
329
+ continue
330
+ if issubclass(thistype, Mapping):
331
+ check_mapping = True
332
+ continue
333
+ return True
334
+
335
+ return False
336
+
337
+
338
+ def _type_check_kwargs(
339
+ value: Any,
340
+ typeinfo: type | NestedTypeTuple,
341
+ ) -> bool:
342
+ """
343
+ Check the type of the value against the expected type.
344
+ """
345
+
346
+ # --- the simple case
347
+ if isinstance(typeinfo, type):
348
+ return isinstance(value, typeinfo)
349
+
350
+ # --- complex
351
+ if isinstance(typeinfo, tuple):
352
+ return _check_tuple(value, typeinfo)
353
+
354
+ return False
355
+
356
+
357
+ def validate_kwargs(
358
+ expected: ExpectedTypeDict,
359
+ called_from: str,
360
+ **kwargs,
361
+ ) -> None:
362
+ """
363
+ This function is used to validate the keyword arguments.
364
+ To check we don't have unexpected keyword arguments, and
365
+ to check that the values are of the expected type.
366
+
367
+ Arguments
368
+ - expected: ExpectedTypeDict - the expected keyword arguments and their types.
369
+ - called_from: str - the name of the function that called this function,
370
+ - **kwargs - the keyword arguments to be validated.
371
+
372
+ It is not intended to be used by the user.
373
+ """
374
+
375
+ problems = ""
376
+ for key, value in kwargs.items():
377
+ if key == REPORT_KWARGS and isinstance(value, bool):
378
+ # This is a special case - and always okay if the value is boolean
379
+ continue
380
+ if key not in expected:
381
+ problems += (
382
+ f"{key}: unexpected keyword argument with {value=}in {called_from}.\n"
383
+ )
384
+ continue
385
+ if not _type_check_kwargs(value, expected[key]):
386
+ problems += (
387
+ f"{key}: with {value=} had the type "
388
+ f"'{type(value)}' in {called_from}. Expected: {expected[key]}\n"
389
+ )
390
+
391
+ if problems:
392
+ # don't raise an exception - just warn instead
393
+ statement = f"Keyword argument validation issues:\n{problems}"
394
+ print(statement)
395
+
396
+
397
+ # --- test code
398
+ if __name__ == "__main__":
399
+ # Test the type_check_kwargs function
400
+ module_testing = True # pylint: disable=invalid-name
401
+
402
+ # --- test the validate_expected() function
403
+ expected_gb: ExpectedTypeDict = {
404
+ # - these ones should pass
405
+ "good1": str,
406
+ "good2": (int, float),
407
+ "good3": bool,
408
+ "good4": (list, (float, int)),
409
+ "good5": (Sequence, (float, int)),
410
+ "good6": (dict, (str, int)),
411
+ "good7": (int, float, list, (int, float)),
412
+ "good8": (dict, (str, (int, float))),
413
+ "good9": (set, (str,)),
414
+ "good10": (frozenset, (str,), int, complex),
415
+ "good11": (dict, ((str, int), (int, float))),
416
+ "good12": (list, (dict, ((str, int), (list, (complex,))))),
417
+ "good13": (Sequence, (int, float), Set, (int, float)),
418
+ "good14": (Sequence, (str,)),
419
+ # - these ones should fail
420
+ "bad1": list,
421
+ "bad2": (int, (str, bool)),
422
+ "bad3": tuple(),
423
+ "bad4": (int, float, set, bool, float),
424
+ "bad5": (list, float),
425
+ "bad6": ((list, tuple), (int, float)),
426
+ "bad7": (dict, (str, int), (int, float)),
427
+ "bad8": (TypingSequence, (int, float)),
428
+ # "bad9": (list, [int, float]),
429
+ "bad10": (dict, (str,)),
430
+ "bad11": (Iterable, (int, float)),
431
+ # "bad12": Any,
432
+ }
433
+ validate_expected(expected_gb, "testing")
434
+
435
+ # --- test the validate_kwargs() function
436
+ # bad means the KWARGS are not of the expected type
437
+
438
+ expected_kw: ExpectedTypeDict = {
439
+ "good_1": str,
440
+ "good_2": (Sequence, (int, float), int, float),
441
+ "good_3": (int, float, Sequence, (int, float)),
442
+ "good_4": (Sequence, (str,)),
443
+ "bad_1": str,
444
+ "bad_2": (int, float),
445
+ }
446
+ validate_expected(expected_kw, "test")
447
+
448
+ kwargs_test = {
449
+ # - these ones should pass
450
+ "good_1": "hello",
451
+ "good_2": [1, 2, 3],
452
+ "good_3": (),
453
+ "good_4": ["fred", "bill", "janice"],
454
+ "report_kwargs": True, # special case
455
+ # - these ones should fail
456
+ "missing": "hello",
457
+ "bad_1": 3.14,
458
+ "bad_2": (3, 4),
459
+ }
460
+ validate_kwargs(expected_kw, "test", **kwargs_test)
mgplot/line_plot.py ADDED
@@ -0,0 +1,178 @@
1
+ """
2
+ line_plot.py:
3
+ Plot a series or a dataframe with lines.
4
+ """
5
+
6
+ # --- imports
7
+ from typing import Any
8
+ from collections.abc import Sequence
9
+ import matplotlib.pyplot as plt
10
+ from pandas import DataFrame, Period
11
+
12
+ from mgplot.settings import DataT, get_setting
13
+ from mgplot.kw_type_checking import (
14
+ report_kwargs,
15
+ validate_kwargs,
16
+ validate_expected,
17
+ ExpectedTypeDict,
18
+ )
19
+ from mgplot.utilities import (
20
+ apply_defaults,
21
+ get_color_list,
22
+ get_axes,
23
+ annotate_series,
24
+ constrain_data,
25
+ check_clean_timeseries,
26
+ )
27
+
28
+
29
+ # --- constants
30
+ DATA = "data"
31
+ AX = "ax"
32
+ STYLE, WIDTH, COLOR, ALPHA = "style", "width", "color", "alpha"
33
+ ANNOTATE = "annotate"
34
+ ROUNDING = "rounding"
35
+ FONTSIZE = "fontsize"
36
+ DROPNA = "dropna"
37
+ DRAWSTYLE, MARKER, MARKERSIZE = "drawstyle", "marker", "markersize"
38
+ PLOT_FROM = "plot_from" # used to constrain the data to a starting point
39
+ LEGEND = "legend"
40
+
41
+ LP_KW_TYPES: ExpectedTypeDict = {
42
+ AX: (plt.Axes, type(None)),
43
+ STYLE: (str, Sequence, (str,)),
44
+ WIDTH: (float, int, Sequence, (float, int)),
45
+ COLOR: (str, Sequence, (str,)),
46
+ ALPHA: (float, Sequence, (float,)),
47
+ DRAWSTYLE: (str, Sequence, (str,), type(None)),
48
+ MARKER: (str, Sequence, (str,), type(None)),
49
+ MARKERSIZE: (float, Sequence, (float,), int, type(None)),
50
+ DROPNA: (bool, Sequence, (bool,)),
51
+ ANNOTATE: (bool, Sequence, (bool,)),
52
+ ROUNDING: (Sequence, (bool, int), int, bool, type(None)),
53
+ FONTSIZE: (Sequence, (str, int), str, int, type(None)),
54
+ PLOT_FROM: (int, Period, type(None)),
55
+ LEGEND: (dict, (str, object), bool, type(None)),
56
+ }
57
+ validate_expected(LP_KW_TYPES, "line_plot")
58
+
59
+
60
+ # --- functions
61
+ def _get_style_width_color_etc(
62
+ item_count, num_data_points, **kwargs
63
+ ) -> tuple[dict[str, list | tuple], dict[str, Any]]:
64
+ """
65
+ Get the plot-line attributes arguemnts.
66
+ Returns a dictionary of lists of attributes for each line, and
67
+ a modified kwargs dictionary.
68
+ """
69
+
70
+ data_point_thresh = 151
71
+ defaults: dict[str, Any] = {
72
+ STYLE: "-",
73
+ WIDTH: (
74
+ get_setting("line_normal")
75
+ if num_data_points > data_point_thresh
76
+ else get_setting("line_wide")
77
+ ),
78
+ COLOR: kwargs.get(COLOR, get_color_list(item_count)),
79
+ ALPHA: 1.0,
80
+ DRAWSTYLE: None,
81
+ MARKER: None,
82
+ MARKERSIZE: 10,
83
+ DROPNA: True,
84
+ ANNOTATE: False,
85
+ ROUNDING: True,
86
+ FONTSIZE: "small",
87
+ }
88
+
89
+ return apply_defaults(item_count, defaults, kwargs)
90
+
91
+
92
+ def line_plot(data: DataT, **kwargs) -> plt.Axes:
93
+ """
94
+ Build a single plot from the data passed in.
95
+ This can be a single- or multiple-line plot.
96
+ Return the axes object for the build.
97
+
98
+ Agruments:
99
+ - data: DataFrame | Series - data to plot
100
+ - kwargs:
101
+ - ax: plt.Axes | None - axes to plot on (optional)
102
+ - dropna: bool | list[bool] - whether to delete NAs frm the
103
+ data before plotting [optional]
104
+ - color: str | list[str] - line colors.
105
+ - width: float | list[float] - line widths [optional].
106
+ - style: str | list[str] - line styles [optional].
107
+ - alpha: float | list[float] - line transparencies [optional].
108
+ - marker: str | list[str] - line markers [optional].
109
+ - marker_size: float | list[float] - line marker sizes [optional].
110
+ - annotate: bool | list[bool] - whether to annotate a series.
111
+ - rounding: int | bool | list[int | bool] - number of decimal places
112
+ to round an annotation. If True, a default between 0 and 2 is
113
+ used.
114
+ - fontsize: int | str | list[int | str] - font size for the
115
+ annotation.
116
+ - drawstyle: str | list[str] - matplotlib line draw styles.
117
+
118
+ Returns:
119
+ - axes: plt.Axes - the axes object for the plot
120
+ """
121
+
122
+ # sanity checks
123
+ report_kwargs(called_from="line_plot", **kwargs)
124
+ data = check_clean_timeseries(data)
125
+ validate_kwargs(LP_KW_TYPES, called_from="line_plot", **kwargs)
126
+
127
+ # the data to be plotted:
128
+ df = DataFrame(data) # really we are only plotting DataFrames
129
+ df, kwargs = constrain_data(df, **kwargs)
130
+ if df.empty:
131
+ print("Warning: No data to plot.")
132
+
133
+ # get the arguments for each line we will plot ...
134
+ item_count = len(df.columns)
135
+ num_data_points = len(df)
136
+ swce, kwargs = _get_style_width_color_etc(item_count, num_data_points, **kwargs)
137
+
138
+ # Let's plot
139
+ axes, kwargs = get_axes(**kwargs) # get the axes to plot on
140
+
141
+ for i, column in enumerate(df.columns):
142
+ series = df[column]
143
+ series = series.dropna() if DROPNA in swce and swce[DROPNA][i] else series
144
+ if series.empty or series.isna().all():
145
+ continue
146
+
147
+ axes = series.plot(
148
+ ls=swce[STYLE][i],
149
+ lw=swce[WIDTH][i],
150
+ color=swce[COLOR][i],
151
+ alpha=swce[ALPHA][i],
152
+ marker=swce[MARKER][i],
153
+ ms=swce[MARKERSIZE][i],
154
+ drawstyle=swce[DRAWSTYLE][i],
155
+ ax=axes,
156
+ )
157
+
158
+ if swce[ANNOTATE][i] is None or not swce[ANNOTATE][i]:
159
+ continue
160
+
161
+ annotate_series(
162
+ series,
163
+ axes,
164
+ rounding=swce[ROUNDING][i],
165
+ color=swce[COLOR][i],
166
+ fontsize=swce[FONTSIZE][i],
167
+ )
168
+
169
+ # add a legend if requested
170
+ if len(df.columns) > 1:
171
+ kwargs[LEGEND] = kwargs.get(LEGEND, get_setting("legend"))
172
+ if LEGEND in kwargs and kwargs[LEGEND] is not None:
173
+ legend = kwargs[LEGEND]
174
+ if isinstance(legend, bool):
175
+ legend = get_setting("legend")
176
+ axes.legend(**legend)
177
+
178
+ return axes