python-table-processor 0.2.29__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,601 @@
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
+ FilterConfig,
32
+ GlobalStatus,
33
+ JoinConfig,
34
+ OmitConfig,
35
+ ParseConfig,
36
+ PickConfig,
37
+ SplitConfig,
38
+ Row,
39
+ )
40
+
41
+ from . functions.assign_id import assign_id
42
+ from . functions.flatten_row import flatten_row
43
+ from . functions.nest_row import nest_row
44
+ from . functions.search_column_value import search_column_value
45
+ from . functions.set_flat_field_value import set_flat_field_value
46
+ from . functions.set_row_value import (
47
+ set_row_staging_value,
48
+ )
49
+ from . functions.set_nested_field_value import set_nested_field_value
50
+
51
+ def setup_actions_with_args(
52
+ config: Config,
53
+ list_actions: list[str],
54
+ action_delimiter: str = ':',
55
+ ):
56
+ ic(list_actions)
57
+ for str_action in list_actions:
58
+ fields = str_action.split(action_delimiter)
59
+ if len(fields) >= 1:
60
+ action_name = fields[0].strip()
61
+ if action_name == 'assign-format':
62
+ setup_assign_format_action(config, str_action, action_delimiter)
63
+ continue
64
+ if action_name == 'filter':
65
+ setup_filter_action(config, str_action, action_delimiter)
66
+ continue
67
+ if len(fields) not in [2,3]:
68
+ raise ValueError(
69
+ 'Action must have 2 or 3 delimiter-separated fields: ' +
70
+ f'delimiter:{action_delimiter!r}, action string: {str_action!r}'
71
+ )
72
+ str_fields = fields[1].strip()
73
+ if len(fields) == 3:
74
+ str_options = fields[2].strip()
75
+ else:
76
+ str_options = ''
77
+ options = OrderedDict()
78
+ if str_options:
79
+ for str_option in str_options.split(','):
80
+ if '=' in str_option:
81
+ key, value = str_option.split('=')
82
+ options[key.strip()] = value.strip()
83
+ else:
84
+ options[str_option.strip()] = True
85
+ fields = str_fields.split(',')
86
+ for field in fields:
87
+ if '=' in field:
88
+ target, source = field.split('=')
89
+ target = target.strip()
90
+ source = source.strip()
91
+ else:
92
+ target = field.strip()
93
+ source = field.strip()
94
+ if action_name == 'assign':
95
+ assign_default = False
96
+ default_value = None
97
+ if 'default' in options:
98
+ assign_default = True
99
+ default_value = options['default']
100
+ if default_value in ['None', 'none', 'Null', 'null']:
101
+ default_value = None
102
+ required = options.get('required', False)
103
+ config.actions.append(AssignConfig(
104
+ target = target,
105
+ source = source,
106
+ assign_default = assign_default,
107
+ default_value = default_value,
108
+ required = required,
109
+ ))
110
+ continue
111
+ if action_name == 'assign-constant':
112
+ str_type = options.get('type', 'str')
113
+ if str_type in ['str', 'string']:
114
+ value = source
115
+ elif str_type in ['int', 'integer']:
116
+ value = int(source)
117
+ elif str_type == 'float':
118
+ value = float(source)
119
+ elif str_type in ['bool', 'boolean']:
120
+ value = bool(source)
121
+ else:
122
+ raise ValueError(
123
+ f'Unsupported type: {str_type}'
124
+ )
125
+ config.actions.append(AssignConstantConfig(
126
+ target = target,
127
+ value = value,
128
+ ))
129
+ continue
130
+ if action_name == 'assign-id':
131
+ context = options.get('context', None)
132
+ if context:
133
+ context = context.split(',')
134
+ config.actions.append(AssignIdConfig(
135
+ target = target,
136
+ primary = [source],
137
+ context = context,
138
+ ))
139
+ continue
140
+ if action_name == 'filter-empty':
141
+ config.actions.append(FilterConfig(
142
+ field = target,
143
+ operator = 'empty',
144
+ value = '',
145
+ ))
146
+ continue
147
+ if action_name == 'filter-not-empty':
148
+ config.actions.append(FilterConfig(
149
+ field = target,
150
+ operator = 'not-empty',
151
+ value = '',
152
+ ))
153
+ continue
154
+ if action_name == 'join':
155
+ delimiter = options.get('delimiter', None)
156
+ config.actions.append(JoinConfig(
157
+ target = target,
158
+ source = source,
159
+ delimiter = delimiter,
160
+ ))
161
+ continue
162
+ if action_name == 'omit':
163
+ config.actions.append(OmitConfig(
164
+ field = target,
165
+ ))
166
+ continue
167
+ if action_name == 'parse':
168
+ as_type = options.get('as', 'literal')
169
+ required = options.get('required', False)
170
+ if as_type not in ['json', 'literal']:
171
+ raise ValueError(
172
+ f'Unsupported as type: {as_type}'
173
+ )
174
+ config.actions.append(ParseConfig(
175
+ target = target,
176
+ source = source,
177
+ as_type = as_type,
178
+ required = required,
179
+ ))
180
+ continue
181
+ if action_name == 'parse-json':
182
+ required = options.get('required', False)
183
+ config.actions.append(ParseConfig(
184
+ target = target,
185
+ source = source,
186
+ as_type = 'json',
187
+ required = required,
188
+ ))
189
+ continue
190
+ if action_name == 'split':
191
+ delimiter = options.get('delimiter', None)
192
+ if delimiter == '\\n':
193
+ delimiter = '\n'
194
+ config.actions.append(SplitConfig(
195
+ target = target,
196
+ source = source,
197
+ delimiter = delimiter,
198
+ ))
199
+ continue
200
+ raise ValueError(
201
+ f'Unsupported action: {action_name}'
202
+ )
203
+ return config
204
+
205
+ def setup_assign_format_action(
206
+ config: Config,
207
+ str_action: str,
208
+ delimiter: str = ':',
209
+ ):
210
+ action_fields = str_action.split(delimiter, 1)
211
+ if len(action_fields) != 2:
212
+ raise ValueError(
213
+ f'Expected 2 fields separated by ":": {str_action}'
214
+ )
215
+ action_name = action_fields[0].strip()
216
+ assert action_name == 'assign-format'
217
+ assignment_fields = action_fields[1].split('=')
218
+ if len(assignment_fields) != 2:
219
+ raise ValueError(
220
+ f'Expected 2 fields separated by "=": {action_fields[1]}'
221
+ )
222
+ target = assignment_fields[0].strip()
223
+ format = assignment_fields[1].strip()
224
+ config.actions.append(AssignFormatConfig(
225
+ target = target,
226
+ format = format,
227
+ ))
228
+ return config
229
+
230
+ def setup_filter_action(
231
+ config: Config,
232
+ str_action: str,
233
+ delimiter: str = ':',
234
+ ):
235
+ action_fields = str_action.split(delimiter, 1)
236
+ if len(action_fields) != 2:
237
+ raise ValueError(
238
+ f'Expected 2 fields separated by ":": {str_action}'
239
+ )
240
+ action_name = action_fields[0].strip()
241
+ assert action_name == 'filter'
242
+ str_filter = action_fields[1].strip()
243
+ if '==' in str_filter:
244
+ field, value = str_filter.split('==')
245
+ config.actions.append(FilterConfig(
246
+ field = field.strip(),
247
+ operator = '==',
248
+ value = value.strip(),
249
+ ))
250
+ return config
251
+ if '!=' in str_filter:
252
+ field, value = str_filter.split('!=')
253
+ config.actions.append(FilterConfig(
254
+ field = field.strip(),
255
+ operator = '!=',
256
+ value = value.strip(),
257
+ ))
258
+ return config
259
+ if '=~' in str_filter:
260
+ field, value = str_filter.split('=~')
261
+ config.actions.append(FilterConfig(
262
+ field = field.strip(),
263
+ operator = '=~',
264
+ value = value.strip(),
265
+ ))
266
+ return config
267
+ raise ValueError(
268
+ f'Unsupported filter: {str_filter}'
269
+ )
270
+
271
+ def do_actions(
272
+ status: GlobalStatus,
273
+ row: Row,
274
+ actions: list[AssignConstantConfig],
275
+ ):
276
+ for action in actions:
277
+ row = do_action(status, row, action)
278
+ if row is None:
279
+ return None
280
+ return row
281
+
282
+ def do_action(
283
+ status: GlobalStatus,
284
+ row: Row,
285
+ action: AssignConstantConfig,
286
+ ):
287
+ if isinstance(action, AssignConfig):
288
+ return assign(row, action)
289
+ if isinstance(action, AssignConstantConfig):
290
+ return assign_constant(row, action)
291
+ if isinstance(action, AssignFormatConfig):
292
+ return assign_format(row, action)
293
+ if isinstance(action, AssignIdConfig):
294
+ return assign_id(status.id_context_map, row, action)
295
+ if isinstance(action, FilterConfig):
296
+ if filter_row(row, action):
297
+ return row
298
+ return None
299
+ if isinstance(action, JoinConfig):
300
+ return join_field(row, action)
301
+ if isinstance(action, ParseConfig):
302
+ return parse(row, action)
303
+ if isinstance(action, OmitConfig):
304
+ return omit_field(row, action)
305
+ if isinstance(action, SplitConfig):
306
+ return split_field(row, action)
307
+ raise ValueError(
308
+ f'Unsupported action: {action}'
309
+ )
310
+
311
+ def prepare_row(
312
+ flat_row: OrderedDict | None = None,
313
+ ):
314
+ if flat_row is None:
315
+ flat_row = OrderedDict()
316
+ try:
317
+ nested_row = nest_row(flat_row)
318
+ except:
319
+ ic(flat_row)
320
+ raise
321
+ return Row(
322
+ flat = OrderedDict(flat_row),
323
+ nested = nested_row,
324
+ )
325
+
326
+ def delete_flat_row_value(
327
+ flat_row: OrderedDict,
328
+ target: str,
329
+ ):
330
+ prefix = f'{target}.'
331
+ for key in list(flat_row.keys()):
332
+ if key == target or key.startswith(prefix):
333
+ del flat_row[key]
334
+
335
+ def pop_nested_row_value(
336
+ nested_row: OrderedDict,
337
+ key: str,
338
+ default: Any = None,
339
+ ):
340
+ keys = key.split('.')
341
+ for key in keys[:-1]:
342
+ if key not in nested_row:
343
+ return default, False
344
+ nested_row = nested_row[key]
345
+ return nested_row.pop(keys[-1], default), True
346
+
347
+ def pop_row_value(
348
+ row: Row,
349
+ key: str,
350
+ default: Any = None,
351
+ ):
352
+ delete_flat_row_value(row.flat, key)
353
+ return pop_nested_row_value(row.nested, key, default)
354
+
355
+ def pop_row_staging(
356
+ row: Row,
357
+ default: Any = None,
358
+ ):
359
+ return pop_row_value(row, STAGING_FIELD, default)
360
+
361
+ def assign_constant(
362
+ row: Row,
363
+ config: AssignConstantConfig,
364
+ ):
365
+ set_row_staging_value(row, config.target, config.value)
366
+ return row
367
+
368
+ def split_field(
369
+ row: Row,
370
+ config: SplitConfig,
371
+ ):
372
+ value, found = search_column_value(row.flat, config.source)
373
+ if found:
374
+ if isinstance(value, str):
375
+ new_value = value.split(config.delimiter)
376
+ new_value = map(str.strip, new_value)
377
+ new_value = list(filter(None, new_value))
378
+ value = new_value
379
+ set_row_staging_value(row, config.target, value)
380
+ return row
381
+
382
+ def remap_columns(
383
+ row: Row,
384
+ list_config: list[PickConfig],
385
+ ):
386
+ if not list_config:
387
+ list_config = []
388
+ for key in row.nested[STAGING_FIELD][INPUT_FIELD].keys():
389
+ list_config.append(PickConfig(
390
+ source = key,
391
+ target = key,
392
+ ))
393
+ new_flat_row = OrderedDict()
394
+ picked = []
395
+ for config in list_config:
396
+ value, key = search_column_value(row.nested, config.source)
397
+ if key:
398
+ set_flat_field_value(new_flat_row, config.target, value)
399
+ picked.append(key)
400
+ for key in row.flat.keys():
401
+ if key in picked:
402
+ if not key.startswith(f'{STAGING_FIELD}.{INPUT_FIELD}.'):
403
+ continue
404
+ if key in new_flat_row:
405
+ continue
406
+ if key.startswith(f'{STAGING_FIELD}.'):
407
+ # NOTE: Skip staging fields
408
+ new_flat_row[key] = row.flat[key]
409
+ else:
410
+ input_key = f'{STAGING_FIELD}.{INPUT_FIELD}.{key}'
411
+ if input_key in row.flat:
412
+ value = row.flat[key]
413
+ input_value = row.flat[input_key]
414
+ if value == input_value:
415
+ # NOTE: Skip if the same value in the input field
416
+ continue
417
+ # NOTE: Set the unused value to the staging field
418
+ new_flat_row[f'{STAGING_FIELD}.{key}'] = row.flat[key]
419
+ row.flat = new_flat_row
420
+ row.nested = nest_row(new_flat_row)
421
+ return row
422
+
423
+
424
+ def search_with_operator(
425
+ row: Row,
426
+ source: str,
427
+ ):
428
+ or_operator = '\|\|'
429
+ null_or_operator = '\?\?'
430
+ operator_group = f'{or_operator}|{null_or_operator}'
431
+ matched = re.split(f'({operator_group})', source, 1)
432
+ #ic(source, matched)
433
+ if len(matched) == 1:
434
+ return search_column_value(row.nested, source)
435
+ matched = map(str.strip, matched)
436
+ left, operator, rest = matched
437
+ value, found = search_column_value(row.nested, left)
438
+ if operator == '||':
439
+ if bool(value):
440
+ return value, found
441
+ if operator == '??':
442
+ if found and value is not None:
443
+ return value, found
444
+ return search_with_operator(row, rest)
445
+
446
+ def assign(
447
+ row: Row,
448
+ config: AssignConfig,
449
+ ):
450
+ value, found = search_with_operator(row, config.source)
451
+ if config.required:
452
+ if not found or bool(value) == False:
453
+ raise ValueError(
454
+ 'Required field not found or empty, ' +
455
+ f'field: {config.source}, found: {found}, value: {value}'
456
+ )
457
+ if found:
458
+ set_row_staging_value(row, config.target, value)
459
+ else:
460
+ if config.assign_default:
461
+ set_row_staging_value(row, config.target, config.default_value)
462
+ return row
463
+
464
+ def assign_format(
465
+ row: Row,
466
+ config: AssignFormatConfig,
467
+ ):
468
+ template = config.format
469
+ params = {}
470
+ for key, value in row.flat.items():
471
+ for prefix in [
472
+ f'{STAGING_FIELD}.{INPUT_FIELD}.',
473
+ f'{STAGING_FIELD}.',
474
+ ]:
475
+ if key.startswith(prefix):
476
+ rest = key[len(prefix):]
477
+ params[rest] = value
478
+ params.update(row.flat)
479
+ formatted = None
480
+ while formatted is None:
481
+ try:
482
+ formatted = template.format(**params)
483
+ except KeyError as e:
484
+ #ic(e)
485
+ #ic(e.args)
486
+ #ic(e.args[0])
487
+ key = e.args[0]
488
+ params[key] = f'__{key}__undefined__'
489
+ except:
490
+ #ic(params)
491
+ ic(params.keys())
492
+ raise
493
+ set_row_staging_value(row, config.target, formatted)
494
+ return row
495
+
496
+ def check_empty(
497
+ value: Any,
498
+ found: str | None,
499
+ ):
500
+ if not found:
501
+ return True
502
+ return not bool(value)
503
+
504
+ def filter_row(
505
+ row: Row,
506
+ config: list[FilterConfig],
507
+ ):
508
+ value, found = search_column_value(row.nested, config.field)
509
+ #ic(config, value, found)
510
+ if config.operator == '==':
511
+ if not found:
512
+ return False
513
+ if value != config.value and str(value) != str(config.value):
514
+ return False
515
+ elif config.operator == '!=':
516
+ if str(value) == str(config.value) or value == config.value:
517
+ return False
518
+ elif config.operator == '=~':
519
+ if not found:
520
+ return False
521
+ if not re.search(config.value, value):
522
+ return False
523
+ elif config.operator == 'not-in':
524
+ if isinstance(config.value, list):
525
+ if value in config.value:
526
+ return False
527
+ if str(value) in config.value:
528
+ return False
529
+ else:
530
+ raise ValueError(f'Unsupported filter value type: type{config.value}')
531
+ elif config.operator == 'empty':
532
+ if not check_empty(value, found):
533
+ return False
534
+ elif config.operator == 'not-empty':
535
+ if check_empty(value, found):
536
+ return False
537
+ else:
538
+ raise ValueError(f'Unsupported operator: {config.operator}')
539
+ return True
540
+
541
+ def omit_field(
542
+ row: Row,
543
+ config: OmitConfig,
544
+ ):
545
+ value, found = pop_row_value(row, config.field)
546
+ if not found:
547
+ return row
548
+ if f'{STAGING_FIELD}.{config.field}' not in row.flat:
549
+ set_row_staging_value(row, config.field, value)
550
+ return row
551
+
552
+ def join_field(
553
+ row: Row,
554
+ config: JoinConfig,
555
+ ):
556
+ value, found = search_column_value(row.nested, config.source)
557
+ if found:
558
+ delimiter = config.delimiter
559
+ if delimiter is None:
560
+ delimiter = ';'
561
+ if delimiter == '\\n':
562
+ delimiter = '\n'
563
+ if isinstance(value, list):
564
+ value = delimiter.join(value)
565
+ set_row_staging_value(row, config.target, value)
566
+ return row
567
+
568
+ def parse(
569
+ row: Row,
570
+ config: AssignConfig,
571
+ ):
572
+ value, found = search_column_value(row.nested, config.source)
573
+ if config.required:
574
+ if not found:
575
+ raise ValueError(
576
+ f'Required field not found, field: {config.source}'
577
+ )
578
+ if found:
579
+ if type(value) == str:
580
+ if config.as_type == 'literal':
581
+ try:
582
+ parsed = ast.literal_eval(value)
583
+ except:
584
+ raise ValueError(
585
+ f'Failed to parse literal: {value}'
586
+ )
587
+ elif config.as_type == 'json':
588
+ try:
589
+ parsed = json.loads(value)
590
+ except:
591
+ raise ValueError(
592
+ f'Failed to parse JSON: {value}'
593
+ )
594
+ else:
595
+ raise ValueError(
596
+ f'Unsupported as type: {config.as_type}'
597
+ )
598
+ else:
599
+ parsed = value
600
+ set_row_staging_value(row, config.target, parsed)
601
+ return row