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.
- tts_data_utils/__init__.py +0 -0
- tts_data_utils/core/__init__.py +0 -0
- tts_data_utils/core/container_history.py +79 -0
- tts_data_utils/core/data_container.py +2102 -0
- tts_data_utils/core/data_item.py +487 -0
- tts_data_utils/core/diff.py +155 -0
- tts_data_utils/core/generic.py +78 -0
- tts_data_utils/core/lorem_utils.py +206 -0
- tts_data_utils/core/visual_diff.py +148 -0
- tts_data_utils/invulnerable_data_manager/__init__.py +0 -0
- tts_data_utils/invulnerable_data_manager/batch.py +176 -0
- tts_data_utils/invulnerable_data_manager/invulnerable_data_manager.py +171 -0
- tts_data_utils/invulnerable_data_manager/utilities.py +151 -0
- tts_data_utils/multimission/__init__.py +0 -0
- tts_data_utils/multimission/alarms.py +109 -0
- tts_data_utils/multimission/eha.py +253 -0
- tts_data_utils/multimission/evr.py +340 -0
- tts_data_utils/multimission/evr_gaps.py +148 -0
- tts_data_utils/multimission/expected_lad.py +161 -0
- tts_data_utils/multimission/planning_rule.py +106 -0
- tts_data_utils/multimission/taco.py +53 -0
- tts_data_utils/test/__init__.py +0 -0
- tts_data_utils/test/core/__init__.py +0 -0
- tts_data_utils/test/core/test_data_container.py +403 -0
- tts_data_utils/test/core/test_data_item.py +92 -0
- tts_data_utils/test/core/test_diff.py +173 -0
- tts_data_utils/test/core/test_files/diff/diff_dict/actual_diff.csv +5135 -0
- tts_data_utils/test/core/test_files/diff/diff_dict/expected_diff.csv +5135 -0
- tts_data_utils/test/core/test_files/diff/diff_dict/left.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_dict/right.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_list/actual_diff.csv +5135 -0
- tts_data_utils/test/core/test_files/diff/diff_list/expected_diff.csv +5135 -0
- tts_data_utils/test/core/test_files/diff/diff_list/left.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_list/right.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_list_size/actual_diff.csv +33 -0
- tts_data_utils/test/core/test_files/diff/diff_list_size/expected_diff.csv +33 -0
- tts_data_utils/test/core/test_files/diff/diff_list_size/left.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_list_size/right.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_simple_types/actual_diff.csv +17894 -0
- tts_data_utils/test/core/test_files/diff/diff_simple_types/expected_diff.csv +17894 -0
- tts_data_utils/test/core/test_files/diff/diff_simple_types/left.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_simple_types/right.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_type/actual_diff.csv +5135 -0
- tts_data_utils/test/core/test_files/diff/diff_type/expected_diff.csv +5135 -0
- tts_data_utils/test/core/test_files/diff/diff_type/left.csv +637 -0
- tts_data_utils/test/core/test_files/diff/diff_type/right.csv +637 -0
- tts_data_utils/test/core/test_files/diff/same_not_same/actual_diff.csv +5135 -0
- tts_data_utils/test/core/test_files/visdiff/adjacent_insert_delete.html +1581 -0
- tts_data_utils/test/core/test_files/visdiff/insert_and_delete.html +1581 -0
- tts_data_utils/test/core/test_files/visdiff/no_diff.html +1581 -0
- tts_data_utils/test/core/test_files/visdiff/overlapping_insert_delete.html +1541 -0
- tts_data_utils/test/core/test_files/visdiff/replace.html +1581 -0
- tts_data_utils/test/core/test_files/visdiff/replace_with_delete.html +1581 -0
- tts_data_utils/test/core/test_files/visdiff/simple_delete.html +1581 -0
- tts_data_utils/test/core/test_files/visdiff/simple_insert.html +1581 -0
- tts_data_utils/test/core/test_files/visdiff/stressing_case.html +2061 -0
- tts_data_utils/test/core/test_filters.py +319 -0
- tts_data_utils/test/core/test_generic_container.py +22 -0
- tts_data_utils/test/core/test_history.py +44 -0
- tts_data_utils/test/core/test_visdiff.py +193 -0
- tts_data_utils/test/invulnerable_data_manager/test_invulnerable_data_manager.py +150 -0
- tts_data_utils/test/multimission/__init__.py +0 -0
- tts_data_utils/test/multimission/evr/__init__.py +0 -0
- tts_data_utils/test/multimission/evr/test_evr.py +29 -0
- tts_data_utils/test/multimission/evr/test_files/actual_evr_gaps.csv +12 -0
- tts_data_utils/test/multimission/evr/test_files/evrs_with_gaps.csv +30 -0
- tts_data_utils/test/multimission/evr/test_files/expected_evr_gaps.csv +12 -0
- tts_data_utils/util.py +104 -0
- tts_data_utils-0.7.3.dist-info/METADATA +81 -0
- tts_data_utils-0.7.3.dist-info/RECORD +72 -0
- tts_data_utils-0.7.3.dist-info/WHEEL +5 -0
- 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
|