tts-data-utils 0.7.3__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.
Files changed (72) hide show
  1. tts_data_utils/__init__.py +0 -0
  2. tts_data_utils/core/__init__.py +0 -0
  3. tts_data_utils/core/container_history.py +79 -0
  4. tts_data_utils/core/data_container.py +2102 -0
  5. tts_data_utils/core/data_item.py +487 -0
  6. tts_data_utils/core/diff.py +155 -0
  7. tts_data_utils/core/generic.py +78 -0
  8. tts_data_utils/core/lorem_utils.py +206 -0
  9. tts_data_utils/core/visual_diff.py +148 -0
  10. tts_data_utils/invulnerable_data_manager/__init__.py +0 -0
  11. tts_data_utils/invulnerable_data_manager/batch.py +176 -0
  12. tts_data_utils/invulnerable_data_manager/invulnerable_data_manager.py +171 -0
  13. tts_data_utils/invulnerable_data_manager/utilities.py +151 -0
  14. tts_data_utils/multimission/__init__.py +0 -0
  15. tts_data_utils/multimission/alarms.py +109 -0
  16. tts_data_utils/multimission/eha.py +253 -0
  17. tts_data_utils/multimission/evr.py +340 -0
  18. tts_data_utils/multimission/evr_gaps.py +148 -0
  19. tts_data_utils/multimission/expected_lad.py +161 -0
  20. tts_data_utils/multimission/planning_rule.py +106 -0
  21. tts_data_utils/multimission/taco.py +53 -0
  22. tts_data_utils/test/__init__.py +0 -0
  23. tts_data_utils/test/core/__init__.py +0 -0
  24. tts_data_utils/test/core/test_data_container.py +403 -0
  25. tts_data_utils/test/core/test_data_item.py +92 -0
  26. tts_data_utils/test/core/test_diff.py +173 -0
  27. tts_data_utils/test/core/test_files/diff/diff_dict/actual_diff.csv +5135 -0
  28. tts_data_utils/test/core/test_files/diff/diff_dict/expected_diff.csv +5135 -0
  29. tts_data_utils/test/core/test_files/diff/diff_dict/left.csv +637 -0
  30. tts_data_utils/test/core/test_files/diff/diff_dict/right.csv +637 -0
  31. tts_data_utils/test/core/test_files/diff/diff_list/actual_diff.csv +5135 -0
  32. tts_data_utils/test/core/test_files/diff/diff_list/expected_diff.csv +5135 -0
  33. tts_data_utils/test/core/test_files/diff/diff_list/left.csv +637 -0
  34. tts_data_utils/test/core/test_files/diff/diff_list/right.csv +637 -0
  35. tts_data_utils/test/core/test_files/diff/diff_list_size/actual_diff.csv +33 -0
  36. tts_data_utils/test/core/test_files/diff/diff_list_size/expected_diff.csv +33 -0
  37. tts_data_utils/test/core/test_files/diff/diff_list_size/left.csv +637 -0
  38. tts_data_utils/test/core/test_files/diff/diff_list_size/right.csv +637 -0
  39. tts_data_utils/test/core/test_files/diff/diff_simple_types/actual_diff.csv +17894 -0
  40. tts_data_utils/test/core/test_files/diff/diff_simple_types/expected_diff.csv +17894 -0
  41. tts_data_utils/test/core/test_files/diff/diff_simple_types/left.csv +637 -0
  42. tts_data_utils/test/core/test_files/diff/diff_simple_types/right.csv +637 -0
  43. tts_data_utils/test/core/test_files/diff/diff_type/actual_diff.csv +5135 -0
  44. tts_data_utils/test/core/test_files/diff/diff_type/expected_diff.csv +5135 -0
  45. tts_data_utils/test/core/test_files/diff/diff_type/left.csv +637 -0
  46. tts_data_utils/test/core/test_files/diff/diff_type/right.csv +637 -0
  47. tts_data_utils/test/core/test_files/diff/same_not_same/actual_diff.csv +5135 -0
  48. tts_data_utils/test/core/test_files/visdiff/adjacent_insert_delete.html +1581 -0
  49. tts_data_utils/test/core/test_files/visdiff/insert_and_delete.html +1581 -0
  50. tts_data_utils/test/core/test_files/visdiff/no_diff.html +1581 -0
  51. tts_data_utils/test/core/test_files/visdiff/overlapping_insert_delete.html +1541 -0
  52. tts_data_utils/test/core/test_files/visdiff/replace.html +1581 -0
  53. tts_data_utils/test/core/test_files/visdiff/replace_with_delete.html +1581 -0
  54. tts_data_utils/test/core/test_files/visdiff/simple_delete.html +1581 -0
  55. tts_data_utils/test/core/test_files/visdiff/simple_insert.html +1581 -0
  56. tts_data_utils/test/core/test_files/visdiff/stressing_case.html +2061 -0
  57. tts_data_utils/test/core/test_filters.py +319 -0
  58. tts_data_utils/test/core/test_generic_container.py +22 -0
  59. tts_data_utils/test/core/test_history.py +44 -0
  60. tts_data_utils/test/core/test_visdiff.py +193 -0
  61. tts_data_utils/test/invulnerable_data_manager/test_invulnerable_data_manager.py +150 -0
  62. tts_data_utils/test/multimission/__init__.py +0 -0
  63. tts_data_utils/test/multimission/evr/__init__.py +0 -0
  64. tts_data_utils/test/multimission/evr/test_evr.py +29 -0
  65. tts_data_utils/test/multimission/evr/test_files/actual_evr_gaps.csv +12 -0
  66. tts_data_utils/test/multimission/evr/test_files/evrs_with_gaps.csv +30 -0
  67. tts_data_utils/test/multimission/evr/test_files/expected_evr_gaps.csv +12 -0
  68. tts_data_utils/util.py +104 -0
  69. tts_data_utils-0.7.3.dist-info/METADATA +81 -0
  70. tts_data_utils-0.7.3.dist-info/RECORD +72 -0
  71. tts_data_utils-0.7.3.dist-info/WHEEL +5 -0
  72. tts_data_utils-0.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,487 @@
1
+ import pdb
2
+ from datetime import datetime
3
+ from abc import ABC, abstractmethod
4
+ from copy import copy, deepcopy
5
+ from enum import Enum, auto
6
+ import json
7
+ try:
8
+ from tts_dexter.core.dispo import Disposition, get_dispo_joiner, DISPO_FORMAT, DISPO_SEVERITY, PALETTE
9
+ from tts_dexter.core.data import DISPO_CHOICE
10
+ DEXTER_PRESENT = True
11
+ except ModuleNotFoundError:
12
+ DEXTER_PRESENT = False
13
+ from tts_html_utils.core.components.text import Strong
14
+
15
+ class DataItem(ABC):
16
+ """
17
+ The fundamental atomic unit of a DataContainer, representing a single row.
18
+
19
+ **The Concept:**
20
+ A `DataItem` acts as a "Smart Dictionary" with a memory. It separates data into
21
+ two layers: **Source** and **Derived**.
22
+
23
+ Think of it like a piece of trace paper over an original document. The `source`
24
+ (the document) remains untouc hed for auditability. Any edits, calculations,
25
+ or augmentations are written on the `derived_values` (the trace paper).
26
+ When you ask for a value, the `DataItem` looks at the trace paper first;
27
+ if nothing is there, it reads from the original document.
28
+
29
+ **Traceability & Integrity:**
30
+ This architecture ensures that the original raw data is never destroyed or
31
+ altered by processing logic, which is critical for engineering applications
32
+ requiring data lineage.
33
+
34
+ :param source: Dictionary of raw data for the row.
35
+ :type source: dict
36
+ :param subcontainers: Nested containers attached to this row.
37
+ :type subcontainers: dict[str, DataContainer], optional
38
+ :param copy_data: If True, deep-copies the source data to prevent mutation.
39
+ :type copy_data: bool
40
+ :param cast_fields: If True, attempts to force fields into canonical datatypes.
41
+ :type cast_fields: bool
42
+ :param fill: If True, adds None for missing columns defined in DICT_VALID_KEYS.
43
+ :type fill: bool
44
+ :param is_django: Is the source coming from a Django model?
45
+ :type is_django: bool
46
+ :param validate: Should we raise an exception if fields are the wrong type?
47
+ :type validate: bool
48
+ :param default_dispo: Default disposition object (Dexter only).
49
+ :type default_dispo: str
50
+ """
51
+
52
+ DO_NOT_DIFF = []
53
+ """
54
+ Fields to ignore in diffs
55
+ """
56
+
57
+ NAME = 'data item'
58
+ """
59
+ Name of the data item to be printed in some situations
60
+ """
61
+
62
+ if DEXTER_PRESENT:
63
+ DICT_STAMP_KEY = 'disposition'
64
+ DEFAULT_DISPO = Disposition()
65
+ #TO DO: pull pallette into utils and call PURPLE instead of #AD44AD
66
+ DEFAULT_DISPO.populate('No Autodisposition', 'Manual Disposition Needed', DISPO_SEVERITY.NONE, color=PALETTE.PURPLE)
67
+ else:
68
+ DEFAULT_DISPO = None
69
+
70
+ def __init__(self, source, subcontainers=None, copy_data=False, cast_fields=False, fill=False, is_django=False, validate=True, default_dispo=None):
71
+ # Save off a copy of the source data
72
+ if is_django:
73
+ self.source = {self.DJANGO_KEY_MAP[k]: source.__dict__[k] for k in source.__dict__.keys() if not k.startswith('_')}
74
+ else:
75
+ self.source = deepcopy(source) if copy_data else source
76
+
77
+ self.subcontainers = subcontainers if subcontainers is not None else {}
78
+
79
+ self.derived_values = {}
80
+
81
+ if not validate: return
82
+
83
+ #TO DO: Look into if we can make this faster. List comprehension?
84
+
85
+ for k, t in deepcopy(self.DICT_VALID_KEYS):
86
+ if isinstance(t, type):
87
+ #allow developers to pass either a single
88
+ #type or a tuple of types
89
+ t = (t,)
90
+ if not isinstance(t, tuple):
91
+ raise Exception('Acceptable types must be either types or tuples of types')
92
+ for x in t:
93
+ if not isinstance(x, type) and x is not None:
94
+ raise Exception('Acceptable types must be either type or None')
95
+ t_with_none = copy(t)
96
+ if None in t:
97
+ #isinstance doesn't like none, but
98
+ #sometimes it's OK for something to be none
99
+ t = tuple(x for x in copy(t) if x is not None)
100
+ none_present = True
101
+ else:
102
+ none_present = False
103
+ if k not in self.source.keys():
104
+ #is this the smartest thing to do here?
105
+ #I'm not sure...
106
+ if fill: self.source[k] = None
107
+ elif isinstance(self.source[k], t):
108
+ #if it's already the right type, then move on to next field
109
+ continue
110
+ elif none_present and self.source[k] is None:
111
+ #this just extends the prior elif because
112
+ #isinstance doen't do None
113
+ continue
114
+ elif not cast_fields:
115
+ #if it's not the right data type and we're not casting, then fail
116
+ raise Exception(f'Key "{k}" of value "{self.source[k]}" is not type "{t}", and cast is False.')
117
+ elif t == tuple([datetime]):
118
+ #if we are casting and it's a datetime, use this
119
+ #data item's TIME_FORMATS
120
+ #
121
+ #the logical inthis elif is a little clunky, but
122
+ #I had to do it this way because I force all
123
+ #of these to be tuples for generalizing the way
124
+ #we use isinstance above
125
+ if k not in self.TIME_FORMATS.keys():
126
+ raise Exception(f'No time format for {self.source[k]} defined. Cannot cast to datetime')
127
+ try:
128
+ self.source[k] = datetime.strptime(self.source[k], self.TIME_FORMATS[k])
129
+ except:
130
+ raise Exception(f'Casting of value: "{self.source[k]}", key: "{k}" to datetime with format "{self.TIME_FORMATS[k]}" failed')
131
+ else:
132
+ #if it's not a datetime, then go one item through
133
+ #the types tuple to try casting it. Raise an exception
134
+ #if none of these work. This use case was originally
135
+ #forced by EHA, where DN can be int, float, or string,
136
+ #eu can be float or None, and state can be string or none.
137
+ cast_failed = True
138
+ for this_type in t_with_none:
139
+ #I hate this_type as a var name here, but I didn't
140
+ #want to overload t
141
+ if this_type is None and self.source[k] is None:
142
+ cast_failed = False
143
+ continue
144
+ elif this_type == dict:
145
+ if isinstance(self.source[k], str):
146
+ #This was put in here to get evr metadata, but I'm not
147
+ #sure it's a good idea because more complex json might
148
+ #be have quotes that need to be escaped. I hate json.
149
+ #Very unlikely that this will fail silently if that's the
150
+ #case, so I'm just going to leave it.
151
+ #If this causes problems, then the right solution will be
152
+ #to figure out how to get the user to know that can direct
153
+ #padas to df['Data'].apply(lambda x: json.dumps(x)) in the data
154
+ #container class. It doesn't have that funcitonalty right now
155
+ #so we'd have to write it, too.
156
+ #for now we're pushing ahead on risk.
157
+ self.source[k] = json.loads(self.source[k].replace("'",'"'))
158
+ cast_failed = False
159
+ else:
160
+ try:
161
+ self.source[k] = this_type(self.source[k])
162
+ cast_failed = False
163
+ break
164
+ except ValueError:
165
+ pass
166
+
167
+ if cast_failed:
168
+ failed_types = ','.join([x.__name__ for x in t])
169
+ raise Exception(f'Casting of value: {self.source[k]} for key: {k} to type(s): {failed_types} failed')
170
+
171
+
172
+ if fill:
173
+ #do we want to be able to separately call fill? Or is it OK that
174
+ #it's just in the cast_fields block?
175
+ pass
176
+
177
+ if not self.valid:
178
+ validation_errors = self.validate()
179
+ error_msg = 'Data not valid:\n' + '\n'.join(f' - {err}' for err in validation_errors)
180
+ raise Exception(error_msg)
181
+
182
+ ##################################################################################
183
+ # Dexter-specific attributes, consider reorganizing this so not every DataItem gets this
184
+ ##################################################################################
185
+ # Setup dispositions
186
+ self.dispositions = []
187
+
188
+ self.default_dispo = default_dispo if default_dispo is not None else self.DEFAULT_DISPO
189
+
190
+ # Initialize Batch pointers
191
+ self._batch_tags = []
192
+
193
+ # Optional sub-init hook
194
+ self.__sub_init()
195
+
196
+ def __sub_init(self):
197
+ """
198
+ Hook for extensions of this class. For DataItems that need some sort of special handling beyond
199
+ the default init behavior
200
+ """
201
+ pass
202
+
203
+ def __getitem__(self, key):
204
+ """
205
+ Returns values like a dictionary, choosing from derived_values if the key is present there,
206
+ and falling back to source.
207
+ """
208
+ if key in self.derived_values.keys():
209
+ return self.derived_values[key]
210
+ return self.source[key]
211
+
212
+ def __setitem__(self, key, value):
213
+ """
214
+ Sets item in derived_values only.
215
+ """
216
+ self.derived_values[key] = value
217
+
218
+ def __hash__(self):
219
+ """Returns a hash based on the unique signature of the row's data."""
220
+ return hash(self.row_signature())
221
+
222
+
223
+ def row_signature(self, ignore_cols=[]):
224
+ """
225
+ Generates a hashable representation of the row.
226
+
227
+ **Concept:**
228
+ Used to determine if two items are effectively identical. It recursively
229
+ converts mutable types (lists, dicts) into immutable tuples to ensure
230
+ the signature can be hashed.
231
+ """
232
+ def make_hashable(v):
233
+ if isinstance(v, list):
234
+ return tuple(make_hashable(x) for x in v)
235
+ elif isinstance(v, dict):
236
+ return tuple(sorted((k, make_hashable(val)) for k, val in v.items()))
237
+ elif isinstance(v, set):
238
+ return frozenset(make_hashable(x) for x in v)
239
+ return v # assume it's already hashable
240
+
241
+ sig = tuple(sorted(
242
+ (k, make_hashable(v))
243
+ for k, v in self.values.items()
244
+ if k not in ignore_cols
245
+ ))
246
+ return sig
247
+
248
+
249
+ def _copy(self):
250
+ """
251
+ Makes a copy of the object with a deep copy of derived_values only.
252
+
253
+ This was done to ensure that altering derived_values in downstream items generated from this one
254
+ does not change those upstream. The shallow copy of the rest of the object is for speed.
255
+ """
256
+ new_obj = copy(self)
257
+ new_obj.derived_values = deepcopy(self.derived_values)
258
+ return new_obj
259
+
260
+ @property
261
+ def values(self):
262
+ """
263
+ Property to return all values, be they from the original source, newly added, or altered.
264
+ """
265
+ return {**self.source, **self.derived_values}
266
+
267
+ # Define an empty FLOAT_FORMAT dictionary by default
268
+ # Subclasses can override this to specify custom float formatting
269
+ FLOAT_FORMAT = {}
270
+
271
+ # Define an empty TIME_FORMAT_PRECISION dictionary by default
272
+ # Subclasses can override this to specify decimal precision for timestamps with %f
273
+ # Example: {'Time': 2} will show only 2 decimal places for microseconds
274
+ TIME_FORMAT_PRECISION = {}
275
+
276
+ @property
277
+ def printable_values(self):
278
+ """
279
+ Same as values(), but with formatting applied:
280
+ - TIME_FORMATS used to convert datetimes to strings
281
+ - FLOAT_FORMAT used to format float values (if defined)
282
+ """
283
+ # Start with the original values
284
+ values = self.values.copy()
285
+
286
+ # Apply datetime formatting
287
+ for key, format_spec in self.TIME_FORMATS.items():
288
+ if key in values and isinstance(values[key], datetime) and values[key] is not None:
289
+ # Check if we need to limit decimal places in microseconds
290
+ if '%f' in format_spec and key in getattr(self, 'TIME_FORMAT_PRECISION', {}):
291
+ # Replace %f with a unique placeholder
292
+ placeholder = '___MICROSECONDS___'
293
+ base_format = format_spec.replace('%f', placeholder)
294
+ formatted_time = values[key].strftime(base_format)
295
+
296
+ # Format microseconds with specified precision
297
+ precision = self.TIME_FORMAT_PRECISION[key]
298
+ microseconds = values[key].microsecond / 1000000
299
+ # Format microseconds to the specified number of decimal places
300
+ microseconds_str = f"{{:.{precision}f}}".format(microseconds)[2:]
301
+
302
+ # Insert formatted microseconds where the placeholder is
303
+ values[key] = formatted_time.replace(placeholder, microseconds_str)
304
+ else:
305
+ # Use standard strftime if no precision is specified
306
+ values[key] = values[key].strftime(format_spec)
307
+
308
+ # Apply float formatting if FLOAT_FORMAT is defined
309
+ for key, format_spec in getattr(self, 'FLOAT_FORMAT', {}).items():
310
+ if key in values and isinstance(values[key], float):
311
+ values[key] = f"{values[key]:{format_spec}}"
312
+
313
+ return values
314
+
315
+ @property
316
+ @abstractmethod
317
+ def time(self):
318
+ """
319
+ Must define some way to time-tag each data item.
320
+ """
321
+ pass
322
+
323
+ def validate(self):
324
+ """
325
+ Compares values in each field to those provided in DICT_VALID_KEYS, which must be provided in each extension
326
+ of DataItem.
327
+ """
328
+ invalid_records = []
329
+ for key, acceptable_types in self.DICT_VALID_KEYS:
330
+ if key not in self.source:
331
+ invalid_records.append(f'Missing required column: "{key}"')
332
+ elif self.source[key] is None:
333
+ #is this the best way to handle this?
334
+ #this is here to go with the fill pattern above,
335
+ #but could cause issues?
336
+ continue
337
+ elif not isinstance(self.source[key], acceptable_types):
338
+ invalid_records.append(f'Invalid type for column "{key}": got {type(self.source[key]).__name__}, expected {acceptable_types}')
339
+ return invalid_records
340
+
341
+ @property
342
+ def valid(self):
343
+ """
344
+ Runs self.validate, but retunds a simple bool instead of a list of invalid records.
345
+ """
346
+ return self.validate() == []
347
+
348
+ @property
349
+ def default_html_row_style(self):
350
+ """Default CSS styles for an HTML table row."""
351
+ return {}
352
+
353
+ @property
354
+ def default_rich_text_row_style(self):
355
+ """Default styles for rich-text terminal output."""
356
+ return {}
357
+
358
+ @property
359
+ def default_html_cell_styles(self):
360
+ """A mapping of keys to CSS styles for individual HTML cells."""
361
+ return {k: {} for k in self.values.keys()}
362
+
363
+
364
+ @classmethod
365
+ def empty(cls, keys=[]):
366
+ """Returns an instance with all columns set to empty strings or None."""
367
+ # Returns an instance with all columns set to empty strings
368
+ return cls(source={k: None for k in keys}, validate=False)
369
+
370
+ ##################################################################################
371
+ # Dexter-specific methods, consider reorganizing this so not every DataItem gets this
372
+ ##################################################################################
373
+
374
+ def in_batch(self, batch):
375
+ """
376
+ Dexter only, and should be broken out into its own extension if possible.
377
+
378
+ TBD. Ask Nick Peper FMI
379
+
380
+ :param batch: Pointer to the data on which a disposition should be checked
381
+ :type batch: DataUtils Batch
382
+ """
383
+ return batch in self._batch_tags
384
+
385
+ def any_batches(self):
386
+ """
387
+ Dexter only, and should be broken out into its own extension if possible.
388
+
389
+ TBD. Ask Nick Peper FMI
390
+ """
391
+ return len(self._batch_tags) > 0
392
+
393
+ def tag_with_batch(self, batch):
394
+ """
395
+ Dexter only, and should be broken out into its own extension if possible.
396
+
397
+ TBD. Ask Nick Peper FMI
398
+
399
+ :param batch: Pointer to the data on which a disposition should be checked
400
+ :type batch: DataUtils Batch
401
+ """
402
+ if batch not in self._batch_tags:
403
+ self._batch_tags.append(batch)
404
+
405
+ def add_dispo(self, disposition):
406
+ """
407
+ Dexter only, and should be broken out into its own extension if possible.
408
+
409
+ Add a disposition to a dexter object.
410
+
411
+ TBD. Ask Nick Peper FMI
412
+
413
+ :param disposition: Disposition for whatever has happened to this DataItem
414
+ :type disposition: Dexter Disposition
415
+ """
416
+ self.dispositions.append(disposition)
417
+
418
+ def new_dispo(self):
419
+ """
420
+ Dexter only, and should be broken out into its own extension if possible.
421
+
422
+ Add a disposition to a dexter object. Like add_dispo, but just adds an empty disposition.
423
+
424
+ TBD. Ask Nick Peper FMI
425
+ """
426
+ new_dispo = Disposition()
427
+ self.dispositions.append(new_dispo)
428
+ return new_dispo
429
+
430
+ def choose_dispo(self, dispo_choice):
431
+ """
432
+ Dexter only, and should be broken out into its own extension if possible.
433
+
434
+ In Dexter, any DataItem can receive many dispositions. This method chooses which to present
435
+ to the user
436
+
437
+ :param dispo_choice: How would you like to roll up dispositions? FIST, LAST, ALL?
438
+ :type dispo_choice: DISPO_CHOICE
439
+ """
440
+ all_dispositions = [_ for _ in self.dispositions if _.populated]
441
+ if len(all_dispositions) == 0:
442
+ if self.default_dispo is None:
443
+ return
444
+ return [self.default_dispo]
445
+
446
+ if dispo_choice == DISPO_CHOICE.FIRST:
447
+ return [all_dispositions[0]]
448
+ elif dispo_choice == DISPO_CHOICE.LAST:
449
+ return [all_dispositions[-1]]
450
+ elif dispo_choice == DISPO_CHOICE.ALL:
451
+ return all_dispositions
452
+ else:
453
+ raise ValueError(f'Unhandled dispo choice value: {dispo_choice}')
454
+
455
+ def choose_and_stamp(self, dispo_choice, dispo_format):
456
+ """
457
+ Dexter only, and should be broken out into its own extension if possible.
458
+
459
+ Does the same as choose_dispo(), but also stamps the original DataItem with
460
+ either a string or a list of dispositions.
461
+
462
+ :param dispo_choice: FIRST, LAST, ALL
463
+ :type dispo_choice: DISPO_CHOICE
464
+
465
+ :param dispo_format: HTML, EXCEL, TEXT
466
+ :type dispo_format: DISPO_FORMATL
467
+ """
468
+ dispos = self.choose_dispo(dispo_choice)
469
+ if not dispos:
470
+ return
471
+ dispo_values = [_.format(dispo_format) for _ in dispos]
472
+ if dispo_format == DISPO_FORMAT.EXCEL:
473
+ #for Excel, this will be done in papertrail
474
+ dispo_full = dispo_values
475
+ else:
476
+ dispo_full = get_dispo_joiner(dispo_format).join(dispo_values)
477
+ self.stamp(dispo_full)
478
+
479
+ def stamp(self, dispo_value):
480
+ """
481
+ Dexter only, and should be broken out into its own extension if possible.
482
+
483
+ Adds a disposition value to source. Not used in isolation, and should probably be
484
+ made private in a future version
485
+ """
486
+ # TODO: Consider making this private
487
+ self.source[self.DICT_STAMP_KEY] = dispo_value
@@ -0,0 +1,155 @@
1
+ import pdb
2
+ from abc import ABC, abstractmethod
3
+
4
+ from tts_data_utils.core.data_container import DataContainer
5
+ from tts_data_utils.core.data_item import DataItem
6
+
7
+ from jpl_time import Time
8
+
9
+ class DiffItem(DataItem):
10
+ """
11
+ An individual record representing a field-level comparison between two objects.
12
+
13
+ **The Concept:**
14
+ Think of a `DiffItem` as a single row in a "Spot the Difference" report. It
15
+ doesn't just store a value; it stores the value from the "Left" side, the
16
+ value from the "Right" side, and a pre-calculated verdict on whether they
17
+ are identical.
18
+
19
+ **How it works:**
20
+ When two `DataContainers` are compared, each shared key is packed into a
21
+ `DiffItem`. If the data types differ between the sides (e.g., comparing a
22
+ string to an integer), the `Type` field is automatically set to "various"
23
+ to alert the user of a schema mismatch.
24
+
25
+ :param Key: Name of the attribute or field being compared.
26
+ :type Key: str
27
+ :param Same: A boolean flag indicating if Left and Right values match.
28
+ :type Same: bool
29
+ :param Type: The Python data type of the values (or "various").
30
+ :type Type: str
31
+ :param Left: The value found in the original (baseline) container.
32
+ :type Left: int | float | str | None
33
+ :param Right: The value found in the comparison (target) container.
34
+ :type Right: int | float | str | None
35
+ """
36
+ DICT_VALID_KEYS = [
37
+ ('Key', str),
38
+ ('Same', bool),
39
+ ('Type', str),
40
+ ('Left', (int, float, str, None)),
41
+ ('Right', (int, float, str, None))
42
+ ]
43
+
44
+ TIME_FORMATS = {}
45
+
46
+ # Metadata mapping to prevent infinite recursion during diff operations
47
+ DO_NOT_DIFF = {'source': ['left', 'right']}
48
+
49
+ def __sub_init(self):
50
+ """
51
+ Internal initialization hook.
52
+ """
53
+ return
54
+
55
+ def time(self):
56
+ """
57
+ [Disabled]
58
+
59
+ DiffItems represent logical differences rather than temporal events,
60
+ so time parsing is disabled by default.
61
+ """
62
+ return
63
+
64
+
65
+ class DiffContainer(DataContainer):
66
+ """
67
+ The results object generated by a `DataContainer.diff()` operation.
68
+
69
+ **The Concept:**
70
+ A `DiffContainer` is a specialized collection that holds `DiffItems`. Its
71
+ primary purpose is to act as a "Filterable Report." By default, when you
72
+ print or view this container, it filters itself to show only the
73
+ differences (`Same == False`), allowing you to ignore identical data
74
+ and focus on what changed.
75
+
76
+ **Usage Note:**
77
+ You generally do not instantiate this class manually. It is returned
78
+ automatically when you call `.diff()` on any standard `DataContainer`.
79
+
80
+ :param name: The identifier for this diff report.
81
+ :type name: str
82
+ :param metadata: Contextual information about the two containers being compared.
83
+ :type metadata: dict
84
+ :param csv_path: Optional path to load/save the diff results.
85
+ :type csv_path: str, optional
86
+ """
87
+ NAME = 'diff'
88
+ DATA_ITEM_CLS = DiffItem
89
+ DO_NOT_DIFF = ['left', 'right']
90
+
91
+ def __init__(self, name, metadata, csv_path=None):
92
+ super().__init__(csv_path=csv_path)
93
+ self.name = name
94
+ self.metadata = metadata
95
+ self._repr_cols = [x for x, _ in self.DATA_ITEM_CLS.DICT_VALID_KEYS]
96
+ self._csv_cols = [x for x, _ in self.DATA_ITEM_CLS.DICT_VALID_KEYS]
97
+
98
+ # Default view: Only show rows where a difference was detected
99
+ self._repr_filters = [{'operator': 'eq', 'key': 'Same', 'value': False}]
100
+
101
+
102
+ def _find_row_by_key(self, key):
103
+ """
104
+ Internal helper to locate a specific comparison row.
105
+ """
106
+ record = [r for r in self.records if r['Key'] == key]
107
+ if len(record) != 1: raise Exception(f'No key "{key}" found.')
108
+ return record[0]
109
+
110
+ def left(self, key):
111
+ """
112
+ Retrieves the baseline value for a specific key.
113
+
114
+ :param key: The field name to look up.
115
+ :type key: str
116
+ :return: The value from the 'Left' side of the comparison.
117
+ """
118
+ return self._find_row_by_key(key)['left']
119
+
120
+ def right(self, key):
121
+ """
122
+ Retrieves the comparison value for a specific key.
123
+
124
+ :param key: The field name to look up.
125
+ :type key: str
126
+ :return: The value from the 'Right' side of the comparison.
127
+ """
128
+ return self._find_row_by_key(key)['right']
129
+
130
+ def is_same(self):
131
+ """
132
+ Filters the report to show only matching records.
133
+
134
+ **Concept:**
135
+ Useful for "sanity checking" to confirm which values were verified
136
+ as identical.
137
+ """
138
+ return self.eq('Same', True)
139
+
140
+ def not_same(self):
141
+ """
142
+ Filters the report to show only mismatched records.
143
+
144
+ **Concept:**
145
+ This is the primary troubleshooting view, isolating only the
146
+ discrepancies between datasets.
147
+ """
148
+ return self.eq('Same', False)
149
+
150
+ @property
151
+ def repr_cols(self):
152
+ """
153
+ Returns the standard list of columns for display.
154
+ """
155
+ return self._repr_cols