tabpro 0.4.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.
tabpro/core/actions.py ADDED
@@ -0,0 +1,752 @@
1
+ '''
2
+ Actions are used to transform the data in the table.
3
+ '''
4
+
5
+ import ast
6
+ import json
7
+ import re
8
+
9
+ from collections import OrderedDict
10
+ from dataclasses import dataclass
11
+ from typing import (
12
+ Any,
13
+ )
14
+
15
+ from icecream import ic
16
+
17
+ from . config import (
18
+ Config,
19
+ )
20
+
21
+ from . constants import (
22
+ INPUT_FIELD,
23
+ STAGING_FIELD,
24
+ )
25
+
26
+ from . types import (
27
+ AssignConfig,
28
+ AssignConstantConfig,
29
+ AssignFormatConfig,
30
+ AssignIdConfig,
31
+ AssignLengthConfig,
32
+ CastConfig,
33
+ FilterConfig,
34
+ GlobalStatus,
35
+ JoinConfig,
36
+ OmitConfig,
37
+ ParseConfig,
38
+ PickConfig,
39
+ PushConfig,
40
+ SplitConfig,
41
+ Row,
42
+ )
43
+
44
+ from . functions.assign_id import assign_id
45
+ from . functions.flatten_row import flatten_row
46
+ from . functions.nest_row import nest_row
47
+ from . functions.search_column_value import search_column_value
48
+ from . functions.set_flat_field_value import set_flat_field_value
49
+ from . functions.set_row_value import (
50
+ set_row_staging_value,
51
+ )
52
+ from . functions.set_nested_field_value import set_nested_field_value
53
+
54
+ def setup_actions_with_args(
55
+ config: Config,
56
+ list_actions: list[str],
57
+ action_delimiter: str = ':',
58
+ ):
59
+ ic(list_actions)
60
+ for str_action in list_actions:
61
+ fields = str_action.split(action_delimiter)
62
+ if len(fields) >= 1:
63
+ action_name = fields[0].strip()
64
+ if action_name == 'assign-format':
65
+ setup_assign_format_action(config, str_action, action_delimiter)
66
+ continue
67
+ if action_name == 'filter':
68
+ setup_filter_action(config, str_action, action_delimiter)
69
+ continue
70
+ if len(fields) not in [2,3]:
71
+ raise ValueError(
72
+ 'Action must have 2 or 3 delimiter-separated fields: ' +
73
+ f'delimiter:{action_delimiter!r}, action string: {str_action!r}'
74
+ )
75
+ str_fields = fields[1].strip()
76
+ if len(fields) == 3:
77
+ str_options = fields[2].strip()
78
+ else:
79
+ str_options = ''
80
+ options = OrderedDict()
81
+ if str_options:
82
+ for str_option in str_options.split(','):
83
+ if '=' in str_option:
84
+ key, value = str_option.split('=')
85
+ options[key.strip()] = value.strip()
86
+ else:
87
+ options[str_option.strip()] = True
88
+ fields = str_fields.split(',')
89
+ for field in fields:
90
+ if '=' in field:
91
+ target, source = field.split('=')
92
+ target = target.strip()
93
+ source = source.strip()
94
+ else:
95
+ target = field.strip()
96
+ source = field.strip()
97
+ if action_name == 'assign':
98
+ assign_default = False
99
+ default_value = None
100
+ if 'default' in options:
101
+ assign_default = True
102
+ default_value = options['default']
103
+ if default_value in ['None', 'none', 'Null', 'null']:
104
+ default_value = None
105
+ required = options.get('required', False)
106
+ config.actions.append(AssignConfig(
107
+ target = target,
108
+ source = source,
109
+ assign_default = assign_default,
110
+ default_value = default_value,
111
+ required = required,
112
+ ))
113
+ continue
114
+ if action_name == 'assign-constant':
115
+ str_type = options.get('type', 'str')
116
+ if str_type in ['str', 'string']:
117
+ value = source
118
+ elif str_type in ['int', 'integer']:
119
+ value = int(source)
120
+ elif str_type == 'float':
121
+ value = float(source)
122
+ elif str_type in ['bool', 'boolean']:
123
+ value = bool(source)
124
+ else:
125
+ raise ValueError(
126
+ f'Unsupported type: {str_type}'
127
+ )
128
+ config.actions.append(AssignConstantConfig(
129
+ target = target,
130
+ value = value,
131
+ ))
132
+ continue
133
+ if action_name == 'assign-id':
134
+ context = options.get('context', None)
135
+ if context:
136
+ context = context.split(',')
137
+ reverse = options.get('reverse', False)
138
+ config.actions.append(AssignIdConfig(
139
+ target = target,
140
+ primary = [source],
141
+ context = context,
142
+ reverse = reverse,
143
+ ))
144
+ continue
145
+ if action_name == 'assign-length':
146
+ config.actions.append(AssignLengthConfig(
147
+ target = target,
148
+ source = source,
149
+ ))
150
+ continue
151
+ if action_name == 'cast':
152
+ required = options.get('required', False)
153
+ as_type = options.get('as', 'literal')
154
+ if as_type in ['boolean']:
155
+ as_type = 'bool'
156
+ if as_type not in ['bool', 'int', 'float', 'str']:
157
+ raise ValueError(
158
+ f'Unsupported as type: {as_type}'
159
+ )
160
+ assign_default = False
161
+ default_value = None
162
+ if 'default' in options:
163
+ assign_default = True
164
+ default_value = options['default']
165
+ if default_value in ['None', 'none', 'Null', 'null']:
166
+ default_value = None
167
+ config.actions.append(CastConfig(
168
+ target = target,
169
+ source = source,
170
+ as_type = as_type,
171
+ required = required,
172
+ assign_default = assign_default,
173
+ default_value = default_value,
174
+ ))
175
+ continue
176
+ if action_name == 'filter-empty':
177
+ config.actions.append(FilterConfig(
178
+ field = target,
179
+ operator = 'empty',
180
+ value = '',
181
+ ))
182
+ continue
183
+ if action_name == 'filter-not-empty':
184
+ config.actions.append(FilterConfig(
185
+ field = target,
186
+ operator = 'not-empty',
187
+ value = '',
188
+ ))
189
+ continue
190
+ if action_name == 'join':
191
+ delimiter = options.get('delimiter', None)
192
+ config.actions.append(JoinConfig(
193
+ target = target,
194
+ source = source,
195
+ delimiter = delimiter,
196
+ ))
197
+ continue
198
+ if action_name == 'omit':
199
+ purge = options.get('purge', False)
200
+ config.actions.append(OmitConfig(
201
+ field = target,
202
+ purge = purge,
203
+ ))
204
+ continue
205
+ if action_name == 'parse':
206
+ as_type = options.get('as', 'literal')
207
+ required = options.get('required', False)
208
+ if as_type in ['boolean']:
209
+ as_type = 'bool'
210
+ if as_type not in ['bool', 'json', 'literal']:
211
+ raise ValueError(
212
+ f'Unsupported as type: {as_type}'
213
+ )
214
+ assign_default = False
215
+ default_value = None
216
+ if 'default' in options:
217
+ assign_default = True
218
+ default_value = options['default']
219
+ if default_value in ['None', 'none', 'Null', 'null']:
220
+ default_value = None
221
+ config.actions.append(ParseConfig(
222
+ target = target,
223
+ source = source,
224
+ as_type = as_type,
225
+ required = required,
226
+ assign_default = assign_default,
227
+ default_value = default_value,
228
+ ))
229
+ continue
230
+ if action_name == 'parse-json':
231
+ required = options.get('required', False)
232
+ config.actions.append(ParseConfig(
233
+ target = target,
234
+ source = source,
235
+ as_type = 'json',
236
+ required = required,
237
+ ))
238
+ continue
239
+ if action_name == 'push':
240
+ condition = options.get('condition', None)
241
+ config.actions.append(PushConfig(
242
+ target = target,
243
+ source = source,
244
+ condition = condition,
245
+ ))
246
+ continue
247
+ if action_name == 'split':
248
+ delimiter = options.get('delimiter', None)
249
+ if delimiter == '\\n':
250
+ delimiter = '\n'
251
+ config.actions.append(SplitConfig(
252
+ target = target,
253
+ source = source,
254
+ delimiter = delimiter,
255
+ ))
256
+ continue
257
+ raise ValueError(
258
+ f'Unsupported action: {action_name}'
259
+ )
260
+ return config
261
+
262
+ def setup_assign_format_action(
263
+ config: Config,
264
+ str_action: str,
265
+ delimiter: str = ':',
266
+ ):
267
+ action_fields = str_action.split(delimiter, 1)
268
+ if len(action_fields) != 2:
269
+ raise ValueError(
270
+ f'Expected 2 fields separated by ":": {str_action}'
271
+ )
272
+ action_name = action_fields[0].strip()
273
+ assert action_name == 'assign-format'
274
+ assignment_fields = action_fields[1].split('=')
275
+ if len(assignment_fields) != 2:
276
+ raise ValueError(
277
+ f'Expected 2 fields separated by "=": {action_fields[1]}'
278
+ )
279
+ target = assignment_fields[0].strip()
280
+ format = assignment_fields[1].strip()
281
+ config.actions.append(AssignFormatConfig(
282
+ target = target,
283
+ format = format,
284
+ ))
285
+ return config
286
+
287
+ def setup_filter_action(
288
+ config: Config,
289
+ str_action: str,
290
+ delimiter: str = ':',
291
+ ):
292
+ action_fields = str_action.split(delimiter, 1)
293
+ if len(action_fields) != 2:
294
+ raise ValueError(
295
+ f'Expected 2 fields separated by ":": {str_action}'
296
+ )
297
+ action_name = action_fields[0].strip()
298
+ assert action_name == 'filter'
299
+ str_filter = action_fields[1].strip()
300
+ if '==' in str_filter:
301
+ field, value = str_filter.split('==')
302
+ config.actions.append(FilterConfig(
303
+ field = field.strip(),
304
+ operator = '==',
305
+ value = value.strip(),
306
+ ))
307
+ return config
308
+ if '!=' in str_filter:
309
+ field, value = str_filter.split('!=')
310
+ config.actions.append(FilterConfig(
311
+ field = field.strip(),
312
+ operator = '!=',
313
+ value = value.strip(),
314
+ ))
315
+ return config
316
+ if '=~' in str_filter:
317
+ field, value = str_filter.split('=~')
318
+ config.actions.append(FilterConfig(
319
+ field = field.strip(),
320
+ operator = '=~',
321
+ value = value.strip(),
322
+ ))
323
+ return config
324
+ raise ValueError(
325
+ f'Unsupported filter: {str_filter}'
326
+ )
327
+
328
+ def do_actions(
329
+ status: GlobalStatus,
330
+ row: Row,
331
+ actions: list[AssignConstantConfig],
332
+ ):
333
+ for action in actions:
334
+ row = do_action(status, row, action)
335
+ if row is None:
336
+ return None
337
+ return row
338
+
339
+ def do_action(
340
+ status: GlobalStatus,
341
+ row: Row,
342
+ action: AssignConstantConfig,
343
+ ):
344
+ if isinstance(action, AssignConfig):
345
+ return assign(row, action)
346
+ if isinstance(action, AssignConstantConfig):
347
+ return assign_constant(row, action)
348
+ if isinstance(action, AssignFormatConfig):
349
+ return assign_format(row, action)
350
+ if isinstance(action, AssignIdConfig):
351
+ return assign_id(status.id_context_map, row, action)
352
+ if isinstance(action, AssignLengthConfig):
353
+ return assign_length(row, action)
354
+ if isinstance(action, CastConfig):
355
+ return cast(row, action)
356
+ if isinstance(action, FilterConfig):
357
+ if filter_row(row, action):
358
+ return row
359
+ return None
360
+ if isinstance(action, JoinConfig):
361
+ return join_field(row, action)
362
+ if isinstance(action, ParseConfig):
363
+ return parse(row, action)
364
+ if isinstance(action, OmitConfig):
365
+ return omit_field(row, action)
366
+ if isinstance(action, PushConfig):
367
+ return push_field(row, action)
368
+ if isinstance(action, SplitConfig):
369
+ return split_field(row, action)
370
+ raise ValueError(
371
+ f'Unsupported action: {action}'
372
+ )
373
+
374
+ def prepare_row(
375
+ flat_row: OrderedDict | None = None,
376
+ ):
377
+ if flat_row is None:
378
+ flat_row = OrderedDict()
379
+ try:
380
+ nested_row = nest_row(flat_row)
381
+ except:
382
+ ic(flat_row)
383
+ raise
384
+ return Row(
385
+ flat = OrderedDict(flat_row),
386
+ nested = nested_row,
387
+ )
388
+
389
+ def delete_flat_row_value(
390
+ flat_row: OrderedDict,
391
+ target: str,
392
+ ):
393
+ prefix = f'{target}.'
394
+ for key in list(flat_row.keys()):
395
+ if key == target or key.startswith(prefix):
396
+ del flat_row[key]
397
+
398
+ def pop_nested_row_value(
399
+ nested_row: OrderedDict,
400
+ key: str,
401
+ default: Any = None,
402
+ ):
403
+ keys = key.split('.')
404
+ for key in keys[:-1]:
405
+ if key not in nested_row:
406
+ return default, False
407
+ nested_row = nested_row[key]
408
+ return nested_row.pop(keys[-1], default), True
409
+
410
+ def pop_row_value(
411
+ row: Row,
412
+ key: str,
413
+ default: Any = None,
414
+ ):
415
+ delete_flat_row_value(row.flat, key)
416
+ return pop_nested_row_value(row.nested, key, default)
417
+
418
+ def pop_row_staging(
419
+ row: Row,
420
+ default: Any = None,
421
+ ):
422
+ return pop_row_value(row, STAGING_FIELD, default)
423
+
424
+ def assign_constant(
425
+ row: Row,
426
+ config: AssignConstantConfig,
427
+ ):
428
+ set_row_staging_value(row, config.target, config.value)
429
+ return row
430
+
431
+ def split_field(
432
+ row: Row,
433
+ config: SplitConfig,
434
+ ):
435
+ value, found = search_column_value(row.flat, config.source)
436
+ if found:
437
+ if isinstance(value, str):
438
+ new_value = value.split(config.delimiter)
439
+ new_value = map(str.strip, new_value)
440
+ new_value = list(filter(None, new_value))
441
+ value = new_value
442
+ set_row_staging_value(row, config.target, value)
443
+ return row
444
+
445
+ def remap_columns(
446
+ row: Row,
447
+ list_config: list[PickConfig],
448
+ ):
449
+ if not list_config:
450
+ list_config = []
451
+ for key in row.nested[STAGING_FIELD][INPUT_FIELD].keys():
452
+ list_config.append(PickConfig(
453
+ source = key,
454
+ target = key,
455
+ ))
456
+ new_flat_row = OrderedDict()
457
+ picked = []
458
+ for config in list_config:
459
+ value, key = search_column_value(row.nested, config.source)
460
+ if key:
461
+ set_flat_field_value(new_flat_row, config.target, value)
462
+ picked.append(key)
463
+ for key in row.flat.keys():
464
+ if key in picked:
465
+ if not key.startswith(f'{STAGING_FIELD}.{INPUT_FIELD}.'):
466
+ continue
467
+ if key in new_flat_row:
468
+ continue
469
+ if key.startswith(f'{STAGING_FIELD}.'):
470
+ # NOTE: Skip staging fields
471
+ new_flat_row[key] = row.flat[key]
472
+ else:
473
+ input_key = f'{STAGING_FIELD}.{INPUT_FIELD}.{key}'
474
+ if input_key in row.flat:
475
+ value = row.flat[key]
476
+ input_value = row.flat[input_key]
477
+ if value == input_value:
478
+ # NOTE: Skip if the same value in the input field
479
+ continue
480
+ # NOTE: Set the unused value to the staging field
481
+ new_flat_row[f'{STAGING_FIELD}.{key}'] = row.flat[key]
482
+ row.flat = new_flat_row
483
+ row.nested = nest_row(new_flat_row)
484
+ return row
485
+
486
+
487
+ def search_with_operator(
488
+ row: Row,
489
+ source: str,
490
+ ):
491
+ or_operator = '||'
492
+ null_or_operator = '??'
493
+ operator_group = f'{re.escape(or_operator)}|{re.escape(null_or_operator)}'
494
+ matched = re.split(f'({operator_group})', source, 1)
495
+ #ic(source, matched)
496
+ if len(matched) == 1:
497
+ return search_column_value(row.nested, source)
498
+ matched = map(str.strip, matched)
499
+ left, operator, rest = matched
500
+ value, found = search_column_value(row.nested, left)
501
+ if operator == or_operator:
502
+ if bool(value):
503
+ return value, found
504
+ if operator == null_or_operator:
505
+ if found and value is not None:
506
+ return value, found
507
+ return search_with_operator(row, rest)
508
+
509
+ def assign(
510
+ row: Row,
511
+ config: AssignConfig,
512
+ ):
513
+ value, found = search_with_operator(row, config.source)
514
+ if config.required:
515
+ if not found or bool(value) == False:
516
+ raise ValueError(
517
+ 'Required field not found or empty, ' +
518
+ f'field: {config.source}, found: {found}, value: {value}'
519
+ )
520
+ if found:
521
+ set_row_staging_value(row, config.target, value)
522
+ else:
523
+ if config.assign_default:
524
+ set_row_staging_value(row, config.target, config.default_value)
525
+ return row
526
+
527
+ def assign_format(
528
+ row: Row,
529
+ config: AssignFormatConfig,
530
+ ):
531
+ template = config.format
532
+ params = {}
533
+ for key, value in row.flat.items():
534
+ for prefix in [
535
+ f'{STAGING_FIELD}.{INPUT_FIELD}.',
536
+ f'{STAGING_FIELD}.',
537
+ ]:
538
+ if key.startswith(prefix):
539
+ rest = key[len(prefix):]
540
+ params[rest] = value
541
+ params.update(row.flat)
542
+ formatted = None
543
+ while formatted is None:
544
+ try:
545
+ formatted = template.format(**params)
546
+ except KeyError as e:
547
+ #ic(e)
548
+ #ic(e.args)
549
+ #ic(e.args[0])
550
+ key = e.args[0]
551
+ params[key] = f'__{key}__undefined__'
552
+ except:
553
+ #ic(params)
554
+ #ic(params.keys())
555
+ ic(row.flat)
556
+ raise
557
+ set_row_staging_value(row, config.target, formatted)
558
+ return row
559
+
560
+ def check_empty(
561
+ value: Any,
562
+ found: str | None,
563
+ ):
564
+ if not found:
565
+ return True
566
+ return not bool(value)
567
+
568
+ def filter_row(
569
+ row: Row,
570
+ config: list[FilterConfig],
571
+ ):
572
+ value, found = search_column_value(row.nested, config.field)
573
+ #ic(config, value, found)
574
+ if config.operator == '==':
575
+ if not found:
576
+ return False
577
+ if value != config.value and str(value) != str(config.value):
578
+ return False
579
+ elif config.operator == '!=':
580
+ if str(value) == str(config.value) or value == config.value:
581
+ return False
582
+ elif config.operator == '=~':
583
+ if not found:
584
+ return False
585
+ if not re.search(config.value, value):
586
+ return False
587
+ elif config.operator == 'not-in':
588
+ if isinstance(config.value, list):
589
+ if value in config.value:
590
+ return False
591
+ if str(value) in config.value:
592
+ return False
593
+ else:
594
+ raise ValueError(f'Unsupported filter value type: type{config.value}')
595
+ elif config.operator == 'empty':
596
+ if not check_empty(value, found):
597
+ return False
598
+ elif config.operator == 'not-empty':
599
+ if check_empty(value, found):
600
+ return False
601
+ else:
602
+ raise ValueError(f'Unsupported operator: {config.operator}')
603
+ return True
604
+
605
+ def omit_field(
606
+ row: Row,
607
+ config: OmitConfig,
608
+ ):
609
+ value, found = pop_row_value(row, config.field)
610
+ if not found:
611
+ return row
612
+ if not config.purge:
613
+ if f'{STAGING_FIELD}.{config.field}' not in row.flat:
614
+ set_row_staging_value(row, config.field, value)
615
+ return row
616
+
617
+ def join_field(
618
+ row: Row,
619
+ config: JoinConfig,
620
+ ):
621
+ value, found = search_column_value(row.nested, config.source)
622
+ if found:
623
+ delimiter = config.delimiter
624
+ if delimiter is None:
625
+ delimiter = ';'
626
+ if delimiter == '\\n':
627
+ delimiter = '\n'
628
+ if isinstance(value, list):
629
+ value = delimiter.join(value)
630
+ set_row_staging_value(row, config.target, value)
631
+ return row
632
+
633
+ def parse(
634
+ row: Row,
635
+ config: AssignConfig,
636
+ ):
637
+ value, found = search_column_value(row.nested, config.source)
638
+ if config.required:
639
+ if not found:
640
+ raise ValueError(
641
+ f'Required field not found, field: {config.source}'
642
+ )
643
+ if found:
644
+ if config.as_type == 'literal':
645
+ try:
646
+ if type(value) is str:
647
+ parsed = ast.literal_eval(value)
648
+ else:
649
+ parsed = value
650
+ except:
651
+ raise ValueError(
652
+ f'Failed to parse literal: {value}'
653
+ )
654
+ elif config.as_type == 'json':
655
+ try:
656
+ if type(value) is str:
657
+ parsed = json.loads(value)
658
+ else:
659
+ parsed = value
660
+ except:
661
+ raise ValueError(
662
+ f'Failed to parse JSON: {value}'
663
+ )
664
+ elif config.as_type == 'bool':
665
+ if config.assign_default and value in [None, '']:
666
+ value = config.default_value
667
+ if type(value) is bool:
668
+ parsed = value
669
+ elif type(value) is str:
670
+ if value.lower() in ['true', 'yes', 'on', '1']:
671
+ parsed = True
672
+ elif value.lower() in ['false', 'no', 'off', '0']:
673
+ parsed = False
674
+ else:
675
+ raise ValueError(
676
+ f'Failed to parse bool: {value}'
677
+ )
678
+ else:
679
+ raise ValueError(
680
+ f'Failed to parse bool: {value}'
681
+ )
682
+ else:
683
+ raise ValueError(
684
+ f'Unsupported as type: {config.as_type}'
685
+ )
686
+ set_row_staging_value(row, config.target, parsed)
687
+ return row
688
+
689
+ def push_field(
690
+ row: Row,
691
+ config: PushConfig,
692
+ ):
693
+ source_value, found = search_column_value(row.nested, config.source)
694
+ do_append = False
695
+ if config.condition is None:
696
+ do_append = True
697
+ else:
698
+ condition_value, found = search_column_value(row.nested, config.condition)
699
+ if condition_value:
700
+ do_append = True
701
+ if do_append:
702
+ target_value, found = search_column_value(row.nested, config.target)
703
+ if found:
704
+ array = target_value
705
+ else:
706
+ array = []
707
+ set_row_staging_value(row, config.target, array)
708
+ array.append(source_value)
709
+ return row
710
+
711
+ def assign_length(
712
+ row: Row,
713
+ config: AssignLengthConfig,
714
+ ):
715
+ value, found = search_column_value(row.nested, config.source)
716
+ if found:
717
+ set_row_staging_value(row, config.target, len(value))
718
+ return row
719
+
720
+ def cast(
721
+ row: Row,
722
+ config: CastConfig,
723
+ ):
724
+ value, found = search_column_value(row.nested, config.source)
725
+ if config.required:
726
+ if not found:
727
+ raise ValueError(
728
+ f'Required field not found, field: {config.source}'
729
+ )
730
+ if config.as_type == 'bool':
731
+ cast_func = bool
732
+ elif config.as_type == 'int':
733
+ cast_func = int
734
+ elif config.as_type == 'float':
735
+ cast_func = float
736
+ elif config.as_type == 'str':
737
+ cast_func = str
738
+ else:
739
+ raise ValueError(
740
+ f'Unsupported as type: {config.as_type}'
741
+ )
742
+ try:
743
+ casted = cast_func(value)
744
+ except:
745
+ if config.assign_default:
746
+ casted = config.default_value
747
+ else:
748
+ raise ValueError(
749
+ f'Failed to cast: {value}'
750
+ )
751
+ set_row_staging_value(row, config.target, casted)
752
+ return row