collate-data-diff 0.11.2__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 (54) hide show
  1. collate_data_diff-0.11.2.dist-info/LICENSE +18 -0
  2. collate_data_diff-0.11.2.dist-info/METADATA +77 -0
  3. collate_data_diff-0.11.2.dist-info/RECORD +54 -0
  4. collate_data_diff-0.11.2.dist-info/WHEEL +4 -0
  5. collate_data_diff-0.11.2.dist-info/entry_points.txt +3 -0
  6. data_diff/__init__.py +180 -0
  7. data_diff/__main__.py +618 -0
  8. data_diff/abcs/__init__.py +0 -0
  9. data_diff/abcs/compiler.py +13 -0
  10. data_diff/abcs/database_types.py +308 -0
  11. data_diff/cloud/__init__.py +2 -0
  12. data_diff/cloud/data_source.py +318 -0
  13. data_diff/cloud/datafold_api.py +304 -0
  14. data_diff/config.py +127 -0
  15. data_diff/databases/__init__.py +17 -0
  16. data_diff/databases/_connect.py +306 -0
  17. data_diff/databases/base.py +1291 -0
  18. data_diff/databases/bigquery.py +315 -0
  19. data_diff/databases/clickhouse.py +203 -0
  20. data_diff/databases/databricks.py +248 -0
  21. data_diff/databases/duckdb.py +192 -0
  22. data_diff/databases/mssql.py +229 -0
  23. data_diff/databases/mysql.py +159 -0
  24. data_diff/databases/oracle.py +195 -0
  25. data_diff/databases/postgresql.py +258 -0
  26. data_diff/databases/presto.py +197 -0
  27. data_diff/databases/redshift.py +217 -0
  28. data_diff/databases/snowflake.py +207 -0
  29. data_diff/databases/trino.py +50 -0
  30. data_diff/databases/vertica.py +160 -0
  31. data_diff/dbt.py +604 -0
  32. data_diff/dbt_config_validators.py +65 -0
  33. data_diff/dbt_parser.py +523 -0
  34. data_diff/diff_tables.py +416 -0
  35. data_diff/errors.py +74 -0
  36. data_diff/format.py +359 -0
  37. data_diff/hashdiff_tables.py +264 -0
  38. data_diff/info_tree.py +62 -0
  39. data_diff/joindiff_tables.py +399 -0
  40. data_diff/lexicographic_space.py +240 -0
  41. data_diff/parse_time.py +74 -0
  42. data_diff/py.typed +0 -0
  43. data_diff/queries/__init__.py +0 -0
  44. data_diff/queries/api.py +200 -0
  45. data_diff/queries/ast_classes.py +798 -0
  46. data_diff/queries/base.py +24 -0
  47. data_diff/queries/extras.py +29 -0
  48. data_diff/query_utils.py +56 -0
  49. data_diff/schema.py +52 -0
  50. data_diff/table_segment.py +286 -0
  51. data_diff/thread_utils.py +98 -0
  52. data_diff/tracking.py +237 -0
  53. data_diff/utils.py +625 -0
  54. data_diff/version.py +1 -0
data_diff/utils.py ADDED
@@ -0,0 +1,625 @@
1
+ import json
2
+ import logging
3
+ import math
4
+ import re
5
+ import string
6
+ from abc import abstractmethod
7
+ from typing import Any, Dict, Iterable, Iterator, List, MutableMapping, Optional, Sequence, TypeVar, Union
8
+ from urllib.parse import urlparse
9
+ import operator
10
+ import threading
11
+ from datetime import datetime
12
+ from uuid import UUID
13
+
14
+ import attrs
15
+ from packaging.version import parse as parse_version
16
+ import requests
17
+ from tabulate import tabulate
18
+ from typing_extensions import Self
19
+
20
+ from data_diff.version import __version__
21
+ from rich.status import Status
22
+
23
+
24
+ # -- Common --
25
+
26
+
27
+ def join_iter(joiner: Any, iterable: Iterable) -> Iterable:
28
+ it = iter(iterable)
29
+ try:
30
+ yield next(it)
31
+ except StopIteration:
32
+ return
33
+ for i in it:
34
+ yield joiner
35
+ yield i
36
+
37
+
38
+ def safezip(*args):
39
+ "zip but makes sure all sequences are the same length"
40
+ lens = list(map(len, args))
41
+ if len(set(lens)) != 1:
42
+ raise ValueError(f"Mismatching lengths in arguments to safezip: {lens}")
43
+ return zip(*args)
44
+
45
+
46
+ UUID_PATTERN = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
47
+
48
+
49
+ def is_uuid(u: str) -> bool:
50
+ # E.g., hashlib.md5(b'hello') is a 32-letter hex number, but not an UUID.
51
+ # It would fail UUID-like comparison (< & >) because of casing and dashes.
52
+ if not UUID_PATTERN.fullmatch(u):
53
+ return False
54
+ try:
55
+ UUID(u)
56
+ except ValueError:
57
+ return False
58
+ return True
59
+
60
+
61
+ def match_regexps(regexps: Dict[str, Any], s: str) -> Sequence[tuple]:
62
+ for regexp, v in regexps.items():
63
+ m = re.match(regexp + "$", s)
64
+ if m:
65
+ yield m, v
66
+
67
+
68
+ # -- Schema --
69
+
70
+ V = TypeVar("V")
71
+
72
+
73
+ class CaseAwareMapping(MutableMapping[str, V]):
74
+ @abstractmethod
75
+ def get_key(self, key: str) -> str: ...
76
+
77
+ def new(self, initial=()) -> Self:
78
+ return type(self)(initial)
79
+
80
+
81
+ class CaseInsensitiveDict(CaseAwareMapping):
82
+ def __init__(self, initial) -> None:
83
+ super().__init__()
84
+ self._dict = {k.lower(): (k, v) for k, v in dict(initial).items()}
85
+
86
+ def __getitem__(self, key: str) -> V:
87
+ return self._dict[key.lower()][1]
88
+
89
+ def __iter__(self) -> Iterator[V]:
90
+ return iter(self._dict)
91
+
92
+ def __len__(self) -> int:
93
+ return len(self._dict)
94
+
95
+ def __setitem__(self, key: str, value) -> None:
96
+ k = key.lower()
97
+ if k in self._dict:
98
+ key = self._dict[k][0]
99
+ self._dict[k] = key, value
100
+
101
+ def __delitem__(self, key: str) -> None:
102
+ del self._dict[key.lower()]
103
+
104
+ def get_key(self, key: str) -> str:
105
+ return self._dict[key.lower()][0]
106
+
107
+ def __repr__(self) -> str:
108
+ return repr(dict(self.items()))
109
+
110
+
111
+ class CaseSensitiveDict(dict, CaseAwareMapping):
112
+ def get_key(self, key):
113
+ self[key] # Throw KeyError if key doesn't exist
114
+ return key
115
+
116
+ def as_insensitive(self):
117
+ return CaseInsensitiveDict(self)
118
+
119
+
120
+ # -- Alphanumerics --
121
+
122
+ alphanums = " -" + string.digits + string.ascii_uppercase + "_" + string.ascii_lowercase
123
+
124
+
125
+ @attrs.define(frozen=True)
126
+ class ArithString:
127
+ @classmethod
128
+ def new(cls, *args, **kw) -> Self:
129
+ return cls(*args, **kw)
130
+
131
+ def range(self, other: "ArithString", count: int) -> List[Self]:
132
+ assert isinstance(other, ArithString)
133
+ checkpoints = split_space(self.int, other.int, count)
134
+ return [self.new(int=i) for i in checkpoints]
135
+
136
+
137
+ def _any_to_uuid(v: Union[str, int, UUID, "ArithUUID"]) -> UUID:
138
+ if isinstance(v, ArithUUID):
139
+ return v.uuid
140
+ elif isinstance(v, UUID):
141
+ return v
142
+ elif isinstance(v, str):
143
+ return UUID(v)
144
+ elif isinstance(v, int):
145
+ return UUID(int=v)
146
+ else:
147
+ raise ValueError(f"Cannot convert a value to UUID: {v!r}")
148
+
149
+
150
+ @attrs.define(frozen=True, eq=False, order=False)
151
+ class ArithUUID(ArithString):
152
+ "A UUID that supports basic arithmetic (add, sub)"
153
+
154
+ uuid: UUID = attrs.field(converter=_any_to_uuid)
155
+ lowercase: Optional[bool] = None
156
+ uppercase: Optional[bool] = None
157
+
158
+ def range(self, other: "ArithUUID", count: int) -> List[Self]:
159
+ assert isinstance(other, ArithUUID)
160
+ checkpoints = split_space(self.uuid.int, other.uuid.int, count)
161
+ return [attrs.evolve(self, uuid=i) for i in checkpoints]
162
+
163
+ def __int__(self) -> int:
164
+ return self.uuid.int
165
+
166
+ def __add__(self, other: int) -> Self:
167
+ if isinstance(other, int):
168
+ return attrs.evolve(self, uuid=self.uuid.int + other)
169
+ return NotImplemented
170
+
171
+ def __sub__(self, other: Union["ArithUUID", int]):
172
+ if isinstance(other, int):
173
+ return attrs.evolve(self, uuid=self.uuid.int - other)
174
+ elif isinstance(other, ArithUUID):
175
+ return self.uuid.int - other.uuid.int
176
+ return NotImplemented
177
+
178
+ def __eq__(self, other: object) -> bool:
179
+ if isinstance(other, ArithUUID):
180
+ return self.uuid == other.uuid
181
+ return NotImplemented
182
+
183
+ def __ne__(self, other: object) -> bool:
184
+ if isinstance(other, ArithUUID):
185
+ return self.uuid != other.uuid
186
+ return NotImplemented
187
+
188
+ def __gt__(self, other: object) -> bool:
189
+ if isinstance(other, ArithUUID):
190
+ return self.uuid > other.uuid
191
+ return NotImplemented
192
+
193
+ def __lt__(self, other: object) -> bool:
194
+ if isinstance(other, ArithUUID):
195
+ return self.uuid < other.uuid
196
+ return NotImplemented
197
+
198
+ def __ge__(self, other: object) -> bool:
199
+ if isinstance(other, ArithUUID):
200
+ return self.uuid >= other.uuid
201
+ return NotImplemented
202
+
203
+ def __le__(self, other: object) -> bool:
204
+ if isinstance(other, ArithUUID):
205
+ return self.uuid <= other.uuid
206
+ return NotImplemented
207
+
208
+
209
+ def numberToAlphanum(num: int, base: str = alphanums) -> str:
210
+ digits = []
211
+ while num > 0:
212
+ num, remainder = divmod(num, len(base))
213
+ digits.append(remainder)
214
+ return "".join(base[i] for i in digits[::-1])
215
+
216
+
217
+ def alphanumToNumber(alphanum: str, base: str = alphanums) -> int:
218
+ num = 0
219
+ for c in alphanum:
220
+ num = num * len(base) + base.index(c)
221
+ return num
222
+
223
+
224
+ def justify_alphanums(s1: str, s2: str):
225
+ max_len = max(len(s1), len(s2))
226
+ s1 = s1.ljust(max_len)
227
+ s2 = s2.ljust(max_len)
228
+ return s1, s2
229
+
230
+
231
+ def alphanums_to_numbers(s1: str, s2: str):
232
+ s1, s2 = justify_alphanums(s1, s2)
233
+ n1 = alphanumToNumber(s1)
234
+ n2 = alphanumToNumber(s2)
235
+ return n1, n2
236
+
237
+
238
+ @attrs.define(frozen=True, eq=False, order=False, repr=False)
239
+ class ArithAlphanumeric(ArithString):
240
+ _str: str
241
+ _max_len: Optional[int] = None
242
+
243
+ def __attrs_post_init__(self) -> None:
244
+ if self._str is None:
245
+ raise ValueError("Alphanum string cannot be None")
246
+ if self._max_len and len(self._str) > self._max_len:
247
+ raise ValueError(f"Length of alphanum value '{str}' is longer than the expected {self._max_len}")
248
+
249
+ for ch in self._str:
250
+ if ch not in alphanums:
251
+ raise ValueError(f"Unexpected character {ch} in alphanum string")
252
+
253
+ # @property
254
+ # def int(self):
255
+ # return alphanumToNumber(self._str, alphanums)
256
+
257
+ def __str__(self) -> str:
258
+ s = self._str
259
+ if self._max_len:
260
+ s = s.rjust(self._max_len, alphanums[0])
261
+ return s
262
+
263
+ def __len__(self) -> int:
264
+ return len(self._str)
265
+
266
+ def __repr__(self) -> str:
267
+ return f'alphanum"{self._str}"'
268
+
269
+ def __add__(self, other: "Union[ArithAlphanumeric, int]") -> Self:
270
+ if isinstance(other, int):
271
+ if other != 1:
272
+ raise NotImplementedError("not implemented for arbitrary numbers")
273
+ num = alphanumToNumber(self._str)
274
+ return self.new(numberToAlphanum(num + 1))
275
+
276
+ return NotImplemented
277
+
278
+ def range(self, other: "ArithAlphanumeric", count: int) -> List[Self]:
279
+ assert isinstance(other, ArithAlphanumeric)
280
+ n1, n2 = alphanums_to_numbers(self._str, other._str)
281
+ split = split_space(n1, n2, count)
282
+ return [self.new(numberToAlphanum(s)) for s in split]
283
+
284
+ def __sub__(self, other: "Union[ArithAlphanumeric, int]") -> float:
285
+ if isinstance(other, ArithAlphanumeric):
286
+ n1, n2 = alphanums_to_numbers(self._str, other._str)
287
+ return n1 - n2
288
+
289
+ return NotImplemented
290
+
291
+ def __ge__(self, other) -> bool:
292
+ if not isinstance(other, type(self)):
293
+ return NotImplemented
294
+ return self._str >= other._str
295
+
296
+ def __lt__(self, other) -> bool:
297
+ if not isinstance(other, type(self)):
298
+ return NotImplemented
299
+ return self._str < other._str
300
+
301
+ def __eq__(self, other) -> bool:
302
+ if not isinstance(other, type(self)):
303
+ return NotImplemented
304
+ return self._str == other._str
305
+
306
+ def new(self, *args, **kw) -> Self:
307
+ return type(self)(*args, **kw, max_len=self._max_len)
308
+
309
+
310
+ def number_to_human(n):
311
+ millnames = ["", "k", "m", "b"]
312
+ n = float(n)
313
+ millidx = max(
314
+ 0,
315
+ min(len(millnames) - 1, int(math.floor(0 if n == 0 else math.log10(abs(n)) / 3))),
316
+ )
317
+
318
+ return "{:.0f}{}".format(n / 10 ** (3 * millidx), millnames[millidx])
319
+
320
+
321
+ def split_space(start, end, count) -> List[int]:
322
+ size = end - start
323
+ assert count <= size, (count, size)
324
+ return list(range(start, end, (size + 1) // (count + 1)))[1 : count + 1]
325
+
326
+
327
+ def remove_passwords_in_dict(d: dict, replace_with: str = "***"):
328
+ for k, v in d.items():
329
+ if k == "password":
330
+ d[k] = replace_with
331
+ elif k == "filepath":
332
+ if "motherduck_token=" in v:
333
+ d[k] = v.split("motherduck_token=")[0] + f"motherduck_token={replace_with}"
334
+ elif isinstance(v, dict):
335
+ remove_passwords_in_dict(v, replace_with)
336
+ elif k.startswith("database"):
337
+ d[k] = remove_password_from_url(v, replace_with)
338
+
339
+
340
+ def _join_if_any(sym, args):
341
+ args = list(args)
342
+ if not args:
343
+ return ""
344
+ return sym.join(str(a) for a in args if a)
345
+
346
+
347
+ def remove_password_from_url(url: str, replace_with: str = "***") -> str:
348
+ if "motherduck_token=" in url:
349
+ replace_token_url = url.split("motherduck_token=")[0] + f"motherduck_token={replace_with}"
350
+ return replace_token_url
351
+ else:
352
+ parsed = urlparse(url)
353
+ account = parsed.username or ""
354
+ if parsed.password:
355
+ account += ":" + replace_with
356
+ host = _join_if_any(":", filter(None, [parsed.hostname, parsed.port]))
357
+ netloc = _join_if_any("@", filter(None, [account, host]))
358
+ replaced = parsed._replace(netloc=netloc)
359
+ return replaced.geturl()
360
+
361
+
362
+ def match_like(pattern: str, strs: Sequence[str]) -> Iterable[str]:
363
+ reo = re.compile(pattern.replace("%", ".*").replace("?", ".") + "$")
364
+ for s in strs:
365
+ if reo.match(s):
366
+ yield s
367
+
368
+
369
+ def accumulate(iterable, func=operator.add, *, initial=None):
370
+ "Return running totals"
371
+ # Taken from https://docs.python.org/3/library/itertools.html#itertools.accumulate, to backport 'initial' to 3.7
372
+ it = iter(iterable)
373
+ total = initial
374
+ if initial is None:
375
+ try:
376
+ total = next(it)
377
+ except StopIteration:
378
+ return
379
+ yield total
380
+ for element in it:
381
+ total = func(total, element)
382
+ yield total
383
+
384
+
385
+ def run_as_daemon(threadfunc, *args):
386
+ th = threading.Thread(target=threadfunc, args=args)
387
+ th.daemon = True
388
+ th.start()
389
+ return th
390
+
391
+
392
+ def getLogger(name):
393
+ return logging.getLogger(name.rsplit(".", 1)[-1])
394
+
395
+
396
+ def eval_name_template(name):
397
+ def get_timestamp(_match):
398
+ return datetime.now().isoformat("_", "seconds").replace(":", "_")
399
+
400
+ return re.sub("%t", get_timestamp, name)
401
+
402
+
403
+ def truncate_error(error: str):
404
+ first_line = error.split("\n", 1)[0]
405
+ return re.sub("'(.*?)'", "'***'", first_line)
406
+
407
+
408
+ def get_from_dict_with_raise(dictionary: Dict, key: str, exception: Exception):
409
+ if dictionary is None:
410
+ raise exception
411
+ result = dictionary.get(key)
412
+ if result is None:
413
+ raise exception
414
+ return result
415
+
416
+
417
+ class Vector(tuple):
418
+ """Immutable implementation of a regular vector over any arithmetic value
419
+
420
+ Implements a product order - https://en.wikipedia.org/wiki/Product_order
421
+
422
+ Partial implementation: Only the needed functionality is implemented
423
+ """
424
+
425
+ def __lt__(self, other: "Vector") -> bool:
426
+ if isinstance(other, Vector):
427
+ return all(a < b for a, b in safezip(self, other))
428
+ return NotImplemented
429
+
430
+ def __le__(self, other: "Vector") -> bool:
431
+ if isinstance(other, Vector):
432
+ return all(a <= b for a, b in safezip(self, other))
433
+ return NotImplemented
434
+
435
+ def __gt__(self, other: "Vector") -> bool:
436
+ if isinstance(other, Vector):
437
+ return all(a > b for a, b in safezip(self, other))
438
+ return NotImplemented
439
+
440
+ def __ge__(self, other: "Vector") -> bool:
441
+ if isinstance(other, Vector):
442
+ return all(a >= b for a, b in safezip(self, other))
443
+ return NotImplemented
444
+
445
+ def __eq__(self, other: "Vector") -> bool:
446
+ if isinstance(other, Vector):
447
+ return all(a == b for a, b in safezip(self, other))
448
+ return NotImplemented
449
+
450
+ def __sub__(self, other: "Vector") -> "Vector":
451
+ if isinstance(other, Vector):
452
+ return Vector((a - b) for a, b in safezip(self, other))
453
+ raise NotImplementedError()
454
+
455
+ def __repr__(self) -> str:
456
+ return "(%s)" % ", ".join(str(k) for k in self)
457
+
458
+
459
+ def dbt_diff_string_template(
460
+ total_rows_table1: int,
461
+ total_rows_table2: int,
462
+ total_rows_diff: int,
463
+ rows_added: int,
464
+ rows_removed: int,
465
+ rows_updated: int,
466
+ rows_unchanged: int,
467
+ extra_info_dict: Dict,
468
+ extra_info_str: str,
469
+ is_cloud: Optional[bool] = False,
470
+ deps_impacts: Optional[Dict] = None,
471
+ ) -> str:
472
+ # main table
473
+ main_rows = [
474
+ ["Total", total_rows_table1, "", f"{total_rows_table2} [{diff_int_dynamic_color_template(total_rows_diff)}]"],
475
+ ["Added", "", diff_int_dynamic_color_template(rows_added), ""],
476
+ ["Removed", "", diff_int_dynamic_color_template(-rows_removed), ""],
477
+ ["Different", "", rows_updated, ""],
478
+ ["Unchanged", "", rows_unchanged, ""],
479
+ ]
480
+
481
+ main_headers = ["rows", "PROD", "<>", "DEV"]
482
+ main_table = tabulate(main_rows, headers=main_headers)
483
+
484
+ # diffs table
485
+ diffs_rows = sorted(list(extra_info_dict.items()))
486
+
487
+ diffs_headers = ["columns", "% diff values" if is_cloud else "# diff values"]
488
+ diffs_table = tabulate(diffs_rows, headers=diffs_headers)
489
+
490
+ # deps impacts table
491
+ deps_impacts_table = ""
492
+ if deps_impacts:
493
+ deps_impacts_rows = list(deps_impacts.items())
494
+ deps_impacts_headers = ["deps", "# data assets"]
495
+ deps_impacts_table = f"\n\n{tabulate(deps_impacts_rows, headers=deps_impacts_headers)}"
496
+
497
+ # combine all tables
498
+ string_output = f"\n{main_table}\n\n{diffs_table}{deps_impacts_table}"
499
+
500
+ return string_output
501
+
502
+
503
+ def diff_int_dynamic_color_template(diff_value: int) -> str:
504
+ if not isinstance(diff_value, int):
505
+ return diff_value
506
+
507
+ if diff_value > 0:
508
+ return f"[green]+{diff_value}[/]"
509
+ elif diff_value < 0:
510
+ return f"[red]{diff_value}[/]"
511
+ else:
512
+ return "0"
513
+
514
+
515
+ def _jsons_equiv(a: str, b: str):
516
+ try:
517
+ return json.loads(a) == json.loads(b)
518
+ except (ValueError, TypeError, json.decoder.JSONDecodeError): # not valid jsons
519
+ return False
520
+
521
+
522
+ def diffs_are_equiv_jsons(diff: list, json_cols: dict):
523
+ overriden_diff_cols = set()
524
+ if (len(diff) != 2) or ({diff[0][0], diff[1][0]} != {"+", "-"}):
525
+ return False, overriden_diff_cols
526
+ match = True
527
+ for i, (col_a, col_b) in enumerate(safezip(diff[0][1][1:], diff[1][1][1:])): # index 0 is extra_columns first elem
528
+ # we only attempt to parse columns of JSON type, but we still need to check if non-json columns don't match
529
+ match = col_a == col_b
530
+ if not match and (i in json_cols):
531
+ if _jsons_equiv(col_a, col_b):
532
+ overriden_diff_cols.add(json_cols[i])
533
+ match = True
534
+ if not match:
535
+ break
536
+ return match, overriden_diff_cols
537
+
538
+
539
+ def columns_removed_template(columns_removed: set) -> str:
540
+ columns_removed_str = f"[red]Columns removed [-{len(columns_removed)}]:[/] [blue]{columns_removed}[/]\n"
541
+ return columns_removed_str
542
+
543
+
544
+ def columns_added_template(columns_added: set) -> str:
545
+ columns_added_str = f"[green]Columns added [+{len(columns_added)}]: {columns_added}[/]\n"
546
+ return columns_added_str
547
+
548
+
549
+ def columns_type_changed_template(columns_type_changed) -> str:
550
+ columns_type_changed_str = f"Type changed [{len(columns_type_changed)}]: [green]{columns_type_changed}[/]\n"
551
+ return columns_type_changed_str
552
+
553
+
554
+ def no_differences_template() -> str:
555
+ return "[bold][green]No row differences[/][/]\n"
556
+
557
+
558
+ def print_version_info() -> None:
559
+ base_version_string = f"Running with data-diff={__version__}"
560
+ logger = getLogger(__name__)
561
+ latest_version = None
562
+ try:
563
+ response = requests.get(url="https://pypi.org/pypi/data-diff/json", timeout=3)
564
+ response.raise_for_status()
565
+ response_json = response.json()
566
+ latest_version = response_json["info"]["version"]
567
+ except Exception as ex:
568
+ logger.debug(f"Failed checking version: {ex}")
569
+
570
+ if latest_version and parse_version(__version__) < parse_version(latest_version):
571
+ print(f"{base_version_string} (Update {latest_version} is available!)")
572
+ else:
573
+ print(base_version_string)
574
+
575
+
576
+ class LogStatusHandler(logging.Handler):
577
+ """
578
+ This log handler can be used to update a rich.status every time a log is emitted.
579
+ """
580
+
581
+ def __init__(self) -> None:
582
+ super().__init__()
583
+ self.status = Status("")
584
+ self.prefix = ""
585
+ self.diff_status = {}
586
+
587
+ def emit(self, record):
588
+ log_entry = self.format(record)
589
+ if self.diff_status:
590
+ self._update_diff_status(log_entry)
591
+ else:
592
+ self.status.update(self.prefix + log_entry)
593
+
594
+ def set_prefix(self, prefix_string):
595
+ self.prefix = prefix_string
596
+
597
+ def diff_started(self, model_name):
598
+ self.diff_status[model_name] = "[yellow]In Progress[/]"
599
+ self._update_diff_status()
600
+
601
+ def diff_finished(self, model_name):
602
+ self.diff_status[model_name] = "[green]Finished [/]"
603
+ self._update_diff_status()
604
+
605
+ def _update_diff_status(self, log=None):
606
+ status_string = "\n"
607
+ for model_name, status in self.diff_status.items():
608
+ status_string += f"{status} {model_name}\n"
609
+ self.status.update(f"{status_string}{log or ''}")
610
+
611
+
612
+ class UnknownMeta(type):
613
+ def __instancecheck__(self, instance):
614
+ return instance is Unknown
615
+
616
+ def __repr__(self) -> str:
617
+ return "Unknown"
618
+
619
+
620
+ class Unknown(metaclass=UnknownMeta):
621
+ def __bool__(self) -> bool:
622
+ raise TypeError()
623
+
624
+ def __new__(class_, *args, **kwargs):
625
+ raise RuntimeError("Unknown is a singleton")
data_diff/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.11.1"