dukit 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.
dukit/typing.py ADDED
@@ -0,0 +1,683 @@
1
+
2
+ import pandas as pd
3
+ import numpy as np
4
+ import re
5
+ import typing
6
+ import datetime
7
+ from pandas import isna
8
+
9
+
10
+ TYPES_INT: tuple[type, ...] = (
11
+ int,
12
+ np.int64,
13
+ np.int32,
14
+ np.int16,
15
+ np.int8,
16
+ pd.Int64Dtype,
17
+ pd.Int32Dtype,
18
+ pd.Int16Dtype,
19
+ pd.Int8Dtype,
20
+ )
21
+ TYPES_FLOAT: tuple[type, ...] = (
22
+ float,
23
+ np.float64,
24
+ np.float32,
25
+ np.float16,
26
+ pd.Float64Dtype,
27
+ pd.Float32Dtype,
28
+ )
29
+ TYPES_NUM: tuple[type, ...] = (
30
+ int,
31
+ float,
32
+ np.int64,
33
+ np.float64,
34
+ np.int32,
35
+ np.float32,
36
+ np.int16,
37
+ np.float16,
38
+ np.int8,
39
+ np.number,
40
+ pd.Int64Dtype,
41
+ pd.Float64Dtype,
42
+ pd.Int32Dtype,
43
+ pd.Float32Dtype,
44
+ pd.Int16Dtype,
45
+ pd.Int8Dtype,
46
+ )
47
+ TYPES_STR: tuple[type, ...] = (
48
+ str,
49
+ pd.StringDtype,
50
+ )
51
+ TYPES_BOOL: tuple[type, ...] = (
52
+ bool,
53
+ np.bool_,
54
+ pd.BooleanDtype,
55
+ )
56
+ TYPES_DATE: tuple[type, ...] = (
57
+ datetime.date,
58
+ pd.Timestamp,
59
+ )
60
+
61
+ VALUES_NA = (
62
+ '',
63
+ 'na',
64
+ '<na>',
65
+ 'n/a',
66
+ '<n/a>',
67
+ 'n.a',
68
+ 'n.a.',
69
+ 'na.',
70
+ 'n.a',
71
+ 'nan',
72
+ 'n.a.n',
73
+ 'n.a.n.',
74
+ 'not available',
75
+ 'not applicable',
76
+ 'not a number',
77
+ 'missing',
78
+ 'missing.',
79
+ 'null',
80
+ 'nil',
81
+ 'none',
82
+ 'void',
83
+ 'blank',
84
+ 'empty',
85
+ )
86
+ VALUES_NK = (
87
+ 'unk',
88
+ 'unknown',
89
+ 'not known',
90
+ 'not known.',
91
+ 'nk',
92
+ 'n.k.',
93
+ 'n.k',
94
+ 'n/k',
95
+ 'not specified',
96
+ 'not specified.',
97
+ )
98
+
99
+ DTYPES_ALLOWED = set([
100
+ 'string',
101
+ 'Int64',
102
+ 'Float64',
103
+ 'boolean',
104
+ 'datetime64[s]',
105
+ 'datetime64[us]',
106
+ 'object',
107
+ ])
108
+
109
+
110
+
111
+ def str_(x, spacer='\n ') -> str:
112
+ if isinstance(x, str):
113
+ return x
114
+ elif isinstance(x, tuple):
115
+ return _tuple_to_str(x, spacer)
116
+ elif isinstance(x, (list, pd.Series, pd.Index)):
117
+ return _list_to_str(x, spacer)
118
+ elif isinstance(x, dict):
119
+ return _dict_to_str(x, spacer)
120
+ else:
121
+ return str(x)
122
+
123
+
124
+
125
+ def _tuple_to_str(tpl: tuple, spacer='\n ') -> str:
126
+ if len(tpl) < 2:
127
+ return str(tpl)
128
+ str_tpl = (
129
+ '('
130
+ + spacer
131
+ + spacer.join(f'{item!r}' for item in tpl)
132
+ + '\n)'
133
+ )
134
+ return str_tpl
135
+
136
+
137
+
138
+ def _list_to_str(
139
+ lst: list | pd.Series | pd.Index,
140
+ spacer='\n ',
141
+ ) -> str:
142
+
143
+ if isinstance(lst, pd.Series):
144
+ lst = lst.to_list()
145
+ elif isinstance(lst, pd.Index):
146
+ lst = lst.to_list()
147
+
148
+ if len(lst) < 2:
149
+ return str(lst)
150
+ str_lst = (
151
+ '['
152
+ + spacer
153
+ + spacer.join(f'{item!r}' for item in lst)
154
+ + '\n]'
155
+ )
156
+
157
+ return str_lst
158
+
159
+
160
+
161
+ def _dict_to_str(d: dict, spacer='\n ') -> str:
162
+ if len(d) < 2:
163
+ return str(d)
164
+ kvs = (
165
+ f'{k!r}: {v!r}'
166
+ for k, v in
167
+ d.items()
168
+ )
169
+ str_d = (
170
+ '{'
171
+ + spacer
172
+ + spacer.join(kvs)
173
+ + '\n}'
174
+ )
175
+ return str_d
176
+
177
+
178
+
179
+ def int_(x, errors='coerce', na=np.nan) -> typing.Any:
180
+ if x is True or x is False:
181
+ return na
182
+ elif isinstance(x, TYPES_INT):
183
+ return x
184
+ elif isinstance(x, str):
185
+ x = x.replace(',', '.')
186
+ try:
187
+ return round(float(x)) #float first to handle strings like '1.0'
188
+ except Exception as e:
189
+ if errors == 'raise':
190
+ raise ValueError(
191
+ f'could not convert "{x}" to integer.\n'
192
+ 'Error handling:\n'
193
+ 'errors="raise": raises a ValueError\n'
194
+ 'errors="ignore": returns the original value\n'
195
+ 'errors="coerce": returns np.nan\n'
196
+ 'errors=<any other value>: returns <any other value>\n'
197
+ f'original error:\n{e}'
198
+ )
199
+ elif errors == 'ignore':
200
+ return x
201
+ elif errors == 'coerce':
202
+ return na
203
+ else:
204
+ return errors
205
+
206
+
207
+
208
+ def float_(x, errors='coerce', na=np.nan) -> typing.Any:
209
+ if isinstance(x, TYPES_FLOAT):
210
+ return x
211
+ elif isinstance(x, str):
212
+ x = x.replace(',', '.')
213
+ try:
214
+ return float(x)
215
+ except Exception as e:
216
+ if errors == 'raise':
217
+ raise ValueError(
218
+ f'could not convert "{x}" to float.\n'
219
+ 'Error handling:\n'
220
+ 'errors="raise": raises a ValueError\n'
221
+ 'errors="ignore": returns the original value\n'
222
+ 'errors="coerce": returns np.nan\n'
223
+ 'errors=<any other value>: returns <any other value>\n'
224
+ f'original error:\n{e}'
225
+ )
226
+ elif errors == 'ignore':
227
+ return x
228
+ elif errors == 'coerce':
229
+ return na
230
+ else:
231
+ return errors
232
+
233
+
234
+
235
+ def num_(x, errors='coerce', na=np.nan) -> typing.Any:
236
+ if isinstance(x, TYPES_NUM):
237
+ return x
238
+ try:
239
+ return pd.to_numeric(x)
240
+ except Exception as e:
241
+ if errors == 'raise':
242
+ raise ValueError(
243
+ f'could not convert "{x}" to numeric value.\n'
244
+ 'Error handling:\n'
245
+ 'errors="raise": raises a ValueError\n'
246
+ 'errors="ignore": returns the original value\n'
247
+ 'errors="coerce": returns np.nan\n'
248
+ 'errors=<any other value>: returns <any other value>\n'
249
+ f'original error:\n{e}'
250
+ )
251
+ elif errors == 'ignore':
252
+ return x
253
+ elif errors == 'coerce':
254
+ return na
255
+ else:
256
+ return errors
257
+
258
+
259
+
260
+ def bool_(x, errors='coerce', na=None) -> typing.Any:
261
+ if isinstance(x, TYPES_BOOL):
262
+ return x
263
+ elif str(x).lower() in ['y', 'yes', 'true', '1', '1.0', 'positive', 'pos']:
264
+ return True
265
+ elif str(x).lower() in ['n', 'no', 'false', '0', '0.0', 'negative', 'neg']:
266
+ return False
267
+ else:
268
+ if errors == 'raise':
269
+ raise ValueError(
270
+ f'could not convert "{x}" to numeric boolean.\n'
271
+ 'Error handling:\n'
272
+ 'errors="raise": raises a ValueError\n'
273
+ 'errors="ignore": returns the original value\n'
274
+ 'errors="coerce": returns np.nan\n'
275
+ 'errors=<any other value>: returns <any other value>\n'
276
+ )
277
+ elif errors == 'ignore':
278
+ return x
279
+ elif errors == 'coerce':
280
+ return na
281
+ else:
282
+ return errors
283
+
284
+
285
+
286
+ _months_txt = (
287
+ 'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|'
288
+ 'January|February|March|April|May|June|July|'
289
+ 'August|September|October|November|December'
290
+ )
291
+ def date_(x, errors='coerce', na=pd.NaT) -> typing.Any:
292
+ """
293
+ recognizes and converts potential dates
294
+ between 0001-01-01 and 2999-12-31 in various formats.
295
+ """
296
+ result = datetime_(x, errors=errors, na=na)
297
+ if result is na:
298
+ return na
299
+ elif isinstance(result, datetime.datetime) or isinstance(result, pd.Timestamp):
300
+ return result.date()
301
+
302
+ #raise and coerce are handled in _datetime and should not be reached here
303
+ elif errors == 'raise': #pragma: no cover
304
+ raise ValueError(
305
+ f'could not convert "{x}" to datetime.\n'
306
+ 'Error handling:\n'
307
+ 'errors="raise": raises a ValueError\n'
308
+ 'errors="ignore": returns the original value\n'
309
+ 'errors="coerce": returns np.nan\n'
310
+ 'errors=<any other value>: returns <any other value>\n'
311
+ )
312
+ elif errors == 'ignore':
313
+ return x
314
+ elif errors == 'coerce':
315
+ return na #pragma: no cover
316
+ else:
317
+ return errors
318
+
319
+
320
+
321
+ def datetime_(x, errors='coerce', na=pd.NaT) -> typing.Any:
322
+ if isinstance(x, datetime.datetime):
323
+ return x
324
+ elif isinstance(x, datetime.date):
325
+ return pd.to_datetime(x)
326
+ elif isinstance(x, str):
327
+ x = x.replace('.', '-')
328
+ x = x.replace('/', '-')
329
+ x = x.replace('\\', '-')
330
+ x = x.replace('_', '-')
331
+ try:
332
+
333
+ if len(str(x).strip()) < 8:
334
+ raise ValueError(f'could not convert "{x}" to date: too short to be a date')
335
+
336
+ elif re.fullmatch(r'[012]\d\d\d[-\d\s:]+.*', x):
337
+ result = pd.to_datetime(x, dayfirst=False)
338
+
339
+ elif re.match(
340
+ f'(\\d\\d\\d\\d)\\D?({_months_txt})\\D?(\\d\\d)',
341
+ x,
342
+ flags=re.IGNORECASE,
343
+ ):
344
+ x = re.sub(
345
+ f'(\\d\\d\\d\\d)\\D?({_months_txt})\\D?(\\d\\d)(.*)',
346
+ r'\3-\2-\1\4',
347
+ x,
348
+ flags=re.IGNORECASE,
349
+ )
350
+ result = pd.to_datetime(x, dayfirst=True)
351
+
352
+ elif re.match(
353
+ f'(\\d\\d)\\D?({_months_txt})\\D?(\\d\\d\\d\\d)',
354
+ x,
355
+ flags=re.IGNORECASE,
356
+ ):
357
+ x = re.sub(
358
+ f'(\\d\\d)\\D?({_months_txt})\\D?(\\d\\d\\d\\d)(.*)',
359
+ r'\1-\2-\3\4',
360
+ x,
361
+ flags=re.IGNORECASE,
362
+ )
363
+ result = pd.to_datetime(x, dayfirst=True)
364
+
365
+ elif re.match(
366
+ f'({_months_txt})\\D?(\\d\\d)\\D?(\\d\\d\\d\\d)',
367
+ x,
368
+ flags=re.IGNORECASE,
369
+ ):
370
+ x = re.sub(
371
+ f'({_months_txt})\\D?(\\d\\d)\\D?(\\d\\d\\d\\d)(.*)',
372
+ r'\2-\1-\3\4',
373
+ x,
374
+ flags=re.IGNORECASE,
375
+ )
376
+ result = pd.to_datetime(x, dayfirst=True)
377
+
378
+ else:
379
+ result = pd.to_datetime(x, dayfirst=True)
380
+
381
+ if result is pd.NaT:
382
+ raise ValueError(f'could not convert "{x}" to date')
383
+ else:
384
+ return result
385
+ except Exception as e:
386
+ if errors == 'raise':
387
+ raise ValueError(
388
+ f'could not convert "{x}" to datetime.\n'
389
+ 'Error handling:\n'
390
+ 'errors="raise": raises a ValueError\n'
391
+ 'errors="ignore": returns the original value\n'
392
+ 'errors="coerce": returns np.nan\n'
393
+ 'errors=<any other value>: returns <any other value>\n'
394
+ f'original error:\n{e}'
395
+ )
396
+ elif errors == 'ignore':
397
+ return x
398
+ elif errors == 'coerce':
399
+ return na
400
+ else:
401
+ return errors
402
+
403
+
404
+
405
+ def na_(x, errors='ignore', na=None) -> typing.Any:
406
+
407
+ if str(x).lower().strip() in VALUES_NA:
408
+ return na
409
+ elif isna(x):
410
+ return na
411
+ else:
412
+ if errors == 'raise':
413
+ raise ValueError(
414
+ f'could not convert "{x}" to "{na}".\n'
415
+ 'Error handling:\n'
416
+ 'errors="raise": raises a ValueError\n'
417
+ 'errors="ignore": returns the original value\n'
418
+ 'errors="coerce": returns np.nan\n'
419
+ 'errors=<any other value>: returns <any other value>\n'
420
+ )
421
+ elif errors == 'ignore':
422
+ return x
423
+ elif errors == 'coerce':
424
+ return None
425
+ else:
426
+ return errors
427
+
428
+
429
+
430
+ def nk_(x, errors='ignore', nk='unknown', na=None) -> typing.Any:
431
+ if str(x).lower().strip() in VALUES_NK:
432
+ return nk
433
+ else:
434
+ if errors == 'raise':
435
+ raise ValueError(
436
+ f'could not convert "{x}" to "{nk}".\n'
437
+ 'Error handling:\n'
438
+ 'errors="raise": raises a ValueError\n'
439
+ 'errors="ignore": returns the original value\n'
440
+ 'errors="coerce": returns np.nan\n'
441
+ 'errors=<any other value>: returns <any other value>\n'
442
+ )
443
+ elif errors == 'ignore':
444
+ return x
445
+ elif errors == 'coerce':
446
+ return na
447
+ else:
448
+ return errors
449
+
450
+
451
+
452
+ def yn_(x, errors='coerce', yes='yes', no='no', na=None) -> typing.Any:
453
+ if str(x).lower() in ['y', 'yes', 'true', '1', '1.0', 'positive', 'pos']:
454
+ return yes
455
+ elif str(x).lower() in ['n', 'no', 'false', '0', '0.0', 'negative', 'neg']:
456
+ return no
457
+ else:
458
+ if errors == 'raise':
459
+ raise ValueError(
460
+ f'could not convert "{x}" to "{yes}" or "{no}".\n'
461
+ 'Error handling:\n'
462
+ 'errors="raise": raises a ValueError\n'
463
+ 'errors="ignore": returns the original value\n'
464
+ 'errors="coerce": returns np.nan\n'
465
+ 'errors=<any other value>: returns <any other value>\n'
466
+ )
467
+ elif errors == 'ignore':
468
+ return x
469
+ elif errors == 'coerce':
470
+ return na
471
+ else:
472
+ return errors
473
+
474
+
475
+
476
+ def type_(x) -> str:
477
+ """
478
+ Returns what type something "should" be. e.g.: dk.type('1') == 'int'
479
+ """
480
+
481
+ if isinstance(x, bool):
482
+ return 'bool'
483
+ elif isinstance(x, TYPES_INT):
484
+ return 'int'
485
+ elif isinstance(x, TYPES_FLOAT):
486
+ return 'float'
487
+ elif datetime_(x) is not pd.NaT:
488
+ x = str(x).strip()
489
+ if re.fullmatch(r'\d*', x):
490
+ return 'int'
491
+ elif re.search(r'\d\d:\d\d', x):
492
+ return 'datetime'
493
+ else:
494
+ return 'date'
495
+
496
+ elif isinstance(x, str):
497
+ x = x.strip()
498
+ if re.fullmatch(r'(true|false)', x, re.IGNORECASE):
499
+ return 'bool'
500
+ elif re.fullmatch(r'-?\d+', x):
501
+ return 'int'
502
+ elif re.fullmatch(r'-?\d+[\.,]\d+', x):
503
+ return 'float'
504
+ elif str(x).lower() in VALUES_NA:
505
+ return 'na'
506
+ else:
507
+ try:
508
+ x = pd.to_numeric(x)
509
+ return 'num'
510
+ except Exception:
511
+ return 'str'
512
+
513
+ elif pd.isna(x):
514
+ return 'na'
515
+
516
+ else:
517
+ return type(x).__name__
518
+
519
+
520
+ _conversion_mapping = {
521
+ 'int': int_,
522
+ 'float': float_,
523
+ 'num': num_,
524
+ 'bool': bool_,
525
+ 'date': date_,
526
+ 'datetime': datetime_,
527
+ 'na': na_,
528
+ 'nk': nk_,
529
+ 'yn': yn_,
530
+ 'NoneType': lambda x, errors, na: na,
531
+ }
532
+ def convert_(value, errors='coerce', na=None) -> typing.Any:
533
+ """
534
+ Converts to the type something "should" be according to dk.type().
535
+ e.g.: dk.convert('1') == 1
536
+ """
537
+ type_name = type_(value)
538
+ if type_name == 'str':
539
+ result = str(value)
540
+ elif type_name in _conversion_mapping:
541
+ result = _conversion_mapping[type_name](value, errors, na)
542
+ else:
543
+ result = value
544
+ return result
545
+
546
+
547
+ def repr_(x) -> str:
548
+ txt = f'{x!r}'
549
+ return txt
550
+
551
+
552
+ def typeinfo_(x) -> str:
553
+ type_inferred = type_(x)
554
+ new = convert_(x)
555
+ #"<{type_inferred}>" would get interpreted as a html tag by the pandas styler
556
+ txt = f'{x!r} [{type_inferred}] {new!r}'
557
+ return txt
558
+
559
+
560
+ def typeinfostrict_(x) -> str:
561
+ type_strict = type(x).__name__
562
+ #"<{type_inferred}>" would get interpreted as a html tag by the pandas styler
563
+ txt = f'{x!r} [{type_strict}]'
564
+ return txt
565
+
566
+
567
+
568
+ def list_(x) -> list:
569
+ if x is None:
570
+ return []
571
+ elif isinstance(x, list):
572
+ return x
573
+ elif isinstance(x, str):
574
+ return [x]
575
+ elif hasattr(x, '__iter__'):
576
+ return list(x)
577
+ else:
578
+ return [x]
579
+
580
+
581
+
582
+ class Box:
583
+ """
584
+ - stores key-value pairs as attributes
585
+ - prevents collisions with class methods and reserved attributes
586
+ - provides dict-like introspection via keys(), values(), items()
587
+ - debug-friendly self referential representation
588
+ """
589
+
590
+ #essential methods
591
+
592
+ def __init__(
593
+ self,
594
+ **kwargs,
595
+ ):
596
+ for key, value in kwargs.items():
597
+ self.__setattr__(key, value)
598
+
599
+
600
+ def __setattr__(
601
+ self,
602
+ name: str,
603
+ value: typing.Any,
604
+ ):
605
+ if name in dir(Box):
606
+ msg = (
607
+ f'{name!r}'
608
+ ' is a reserved attribute/method name.'
609
+ )
610
+ raise AttributeError(msg)
611
+ super().__setattr__(name, value)
612
+
613
+
614
+ def __getitem__(
615
+ self,
616
+ key: str,
617
+ ):
618
+ return self.__dict__[key]
619
+
620
+
621
+ #dict-like interface
622
+
623
+ def keys(self):
624
+ return self.__dict__.keys()
625
+
626
+ def values(self):
627
+ return self.__dict__.values()
628
+
629
+ def items(self):
630
+ return self.__dict__.items()
631
+
632
+
633
+ #utility
634
+
635
+ def __repr__(self):
636
+ txt = (
637
+ f'<{self.__class__.__name__};'
638
+ f' {len(self.__dict__)} attributes>'
639
+ )
640
+ return txt
641
+
642
+ def __str__(self):
643
+ kwargs = [(k, v) for k, v in self.__dict__.items()]
644
+ if len(kwargs) == 0:
645
+ txt = f'{self.__class__.__name__}()'
646
+ elif len(kwargs) == 1:
647
+ content = f'{kwargs[0][0]}={kwargs[0][1]!r}'
648
+ txt = f'{self.__class__.__name__}({content})'
649
+ else:
650
+ content = []
651
+ for k, v in kwargs:
652
+ if isinstance(v, Box):
653
+ v = str(v).replace('\n', '\n ')
654
+ content.append(f' {k} = {v},')
655
+ elif isinstance(v, pd.DataFrame):
656
+ cols = len(v.columns)
657
+ rows = len(v.index)
658
+ v = f'<DataFrame; cols={cols}, rows={rows}>'
659
+ content.append(f' {k} = {v},')
660
+ else:
661
+ content.append(f' {k} = {v!r},')
662
+ content = '\n'.join(content)
663
+ txt = f'{self.__class__.__name__}(\n{content}\n )'
664
+ return txt
665
+
666
+ def print(self):
667
+ print(str(self))
668
+
669
+ def __eq__(self, value):
670
+ if isinstance(value, self.__class__):
671
+ return self.__dict__ == value.__dict__
672
+ else:
673
+ return False
674
+
675
+ def clear(self):
676
+ for key in list(self.__dict__.keys()):
677
+ del self.__dict__[key]
678
+
679
+ def copy(self):
680
+ return self.__class__(**self.__dict__)
681
+
682
+ def new(self, **kwargs):
683
+ return self.__class__(**kwargs)