fmtr.tools 1.1.1__py3-none-any.whl → 1.4.37__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 (97) hide show
  1. fmtr/tools/__init__.py +86 -52
  2. fmtr/tools/ai_tools/__init__.py +2 -2
  3. fmtr/tools/ai_tools/agentic_tools.py +151 -32
  4. fmtr/tools/ai_tools/inference_tools.py +2 -1
  5. fmtr/tools/api_tools.py +73 -12
  6. fmtr/tools/async_tools.py +4 -0
  7. fmtr/tools/av_tools.py +7 -0
  8. fmtr/tools/caching_tools.py +101 -3
  9. fmtr/tools/constants.py +41 -0
  10. fmtr/tools/context_tools.py +23 -0
  11. fmtr/tools/data_modelling_tools.py +227 -14
  12. fmtr/tools/database_tools/__init__.py +6 -0
  13. fmtr/tools/database_tools/document.py +51 -0
  14. fmtr/tools/datatype_tools.py +22 -2
  15. fmtr/tools/datetime_tools.py +12 -0
  16. fmtr/tools/debugging_tools.py +60 -1
  17. fmtr/tools/dns_tools/__init__.py +7 -0
  18. fmtr/tools/dns_tools/client.py +97 -0
  19. fmtr/tools/dns_tools/dm.py +257 -0
  20. fmtr/tools/dns_tools/proxy.py +66 -0
  21. fmtr/tools/dns_tools/server.py +138 -0
  22. fmtr/tools/docker_tools/__init__.py +6 -0
  23. fmtr/tools/entrypoints/__init__.py +0 -0
  24. fmtr/tools/entrypoints/cache_hfh.py +3 -0
  25. fmtr/tools/entrypoints/ep_test.py +2 -0
  26. fmtr/tools/entrypoints/install_yamlscript.py +8 -0
  27. fmtr/tools/{console_script_tools.py → entrypoints/remote_debug_test.py} +1 -6
  28. fmtr/tools/entrypoints/shell_debug.py +8 -0
  29. fmtr/tools/environment_tools.py +3 -2
  30. fmtr/tools/function_tools.py +77 -1
  31. fmtr/tools/google_api_tools.py +15 -4
  32. fmtr/tools/ha_tools/__init__.py +8 -0
  33. fmtr/tools/ha_tools/constants.py +9 -0
  34. fmtr/tools/ha_tools/core.py +16 -0
  35. fmtr/tools/ha_tools/supervisor.py +16 -0
  36. fmtr/tools/ha_tools/utils.py +46 -0
  37. fmtr/tools/http_tools.py +52 -0
  38. fmtr/tools/inherit_tools.py +27 -0
  39. fmtr/tools/interface_tools/__init__.py +8 -0
  40. fmtr/tools/interface_tools/context.py +13 -0
  41. fmtr/tools/interface_tools/controls.py +354 -0
  42. fmtr/tools/interface_tools/interface_tools.py +189 -0
  43. fmtr/tools/iterator_tools.py +122 -1
  44. fmtr/tools/logging_tools.py +99 -18
  45. fmtr/tools/mqtt_tools.py +89 -0
  46. fmtr/tools/networking_tools.py +73 -0
  47. fmtr/tools/packaging_tools.py +14 -0
  48. fmtr/tools/path_tools/__init__.py +12 -0
  49. fmtr/tools/path_tools/app_path_tools.py +40 -0
  50. fmtr/tools/{path_tools.py → path_tools/path_tools.py} +217 -14
  51. fmtr/tools/path_tools/type_path_tools.py +3 -0
  52. fmtr/tools/pattern_tools.py +277 -0
  53. fmtr/tools/pdf_tools.py +39 -1
  54. fmtr/tools/settings_tools.py +27 -6
  55. fmtr/tools/setup_tools/__init__.py +8 -0
  56. fmtr/tools/setup_tools/setup_tools.py +481 -0
  57. fmtr/tools/string_tools.py +92 -13
  58. fmtr/tools/tabular_tools.py +61 -0
  59. fmtr/tools/tools.py +27 -2
  60. fmtr/tools/version +1 -1
  61. fmtr/tools/version_tools/__init__.py +12 -0
  62. fmtr/tools/version_tools/version_tools.py +51 -0
  63. fmtr/tools/webhook_tools.py +17 -0
  64. fmtr/tools/yaml_tools.py +64 -5
  65. fmtr/tools/youtube_tools.py +128 -0
  66. fmtr_tools-1.4.37.data/scripts/add-service +14 -0
  67. fmtr_tools-1.4.37.data/scripts/add-user-path +8 -0
  68. fmtr_tools-1.4.37.data/scripts/apt-headless +23 -0
  69. fmtr_tools-1.4.37.data/scripts/compose-update +10 -0
  70. fmtr_tools-1.4.37.data/scripts/docker-sandbox +43 -0
  71. fmtr_tools-1.4.37.data/scripts/docker-sandbox-init +23 -0
  72. fmtr_tools-1.4.37.data/scripts/docs-deploy +6 -0
  73. fmtr_tools-1.4.37.data/scripts/docs-serve +5 -0
  74. fmtr_tools-1.4.37.data/scripts/download +9 -0
  75. fmtr_tools-1.4.37.data/scripts/fmtr-test-script +3 -0
  76. fmtr_tools-1.4.37.data/scripts/ftu +3 -0
  77. fmtr_tools-1.4.37.data/scripts/ha-addon-launch +16 -0
  78. fmtr_tools-1.4.37.data/scripts/install-browser +8 -0
  79. fmtr_tools-1.4.37.data/scripts/parse-args +43 -0
  80. fmtr_tools-1.4.37.data/scripts/set-password +5 -0
  81. fmtr_tools-1.4.37.data/scripts/snips-install +14 -0
  82. fmtr_tools-1.4.37.data/scripts/ssh-auth +28 -0
  83. fmtr_tools-1.4.37.data/scripts/ssh-serve +15 -0
  84. fmtr_tools-1.4.37.data/scripts/vlc-tn +10 -0
  85. fmtr_tools-1.4.37.data/scripts/vm-launch +17 -0
  86. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/METADATA +178 -54
  87. fmtr_tools-1.4.37.dist-info/RECORD +122 -0
  88. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/WHEEL +1 -1
  89. fmtr_tools-1.4.37.dist-info/entry_points.txt +6 -0
  90. fmtr_tools-1.4.37.dist-info/top_level.txt +1 -0
  91. fmtr/tools/docker_tools.py +0 -30
  92. fmtr/tools/interface_tools.py +0 -64
  93. fmtr/tools/version_tools.py +0 -62
  94. fmtr_tools-1.1.1.dist-info/RECORD +0 -65
  95. fmtr_tools-1.1.1.dist-info/entry_points.txt +0 -3
  96. fmtr_tools-1.1.1.dist-info/top_level.txt +0 -2
  97. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,481 @@
1
+ import sys
2
+ from datetime import datetime
3
+ from fnmatch import fnmatch
4
+ from functools import cached_property
5
+ from itertools import chain
6
+ from typing import List, Dict, Any, Callable, Optional
7
+
8
+ from fmtr.tools.constants import Constants
9
+ from fmtr.tools.path_tools import Path
10
+ from fmtr.tools.path_tools.path_tools import FromCallerMixin
11
+
12
+
13
+ class SetupPaths(FromCallerMixin):
14
+ """
15
+
16
+ Canonical paths for a repo.
17
+
18
+ """
19
+
20
+ def __init__(self, path=None, org=Constants.ORG_NAME):
21
+ """
22
+
23
+ Use calling module path as default path, if not otherwise specified.
24
+
25
+ """
26
+ if not path:
27
+ path = self.from_caller()
28
+
29
+ self.org_name = org
30
+ self.repo = Path(path)
31
+
32
+ @property
33
+ def readme(self) -> Path:
34
+ """
35
+
36
+ Path of the README file.
37
+
38
+ """
39
+ return self.repo / 'README.md'
40
+
41
+ @property
42
+ def version(self) -> Path:
43
+ """
44
+
45
+ Path of the version file
46
+
47
+ """
48
+ return self.path / Constants.FILENAME_VERSION
49
+
50
+ @cached_property
51
+ def path(self) -> Path:
52
+ """
53
+
54
+ Infer the package path. It should be the only non-excluded package in the repo/org Path.
55
+
56
+ """
57
+
58
+ if self.is_namespace:
59
+ base = self.org
60
+ else:
61
+ base = self.repo
62
+
63
+ packages = [
64
+ dir for dir in base.iterdir()
65
+ if (dir / Constants.INIT_FILENAME).is_file()
66
+ and not any(fnmatch(dir.name, pattern) for pattern in Constants.PACKAGE_EXCLUDE_DIRS) # todo add scripts dir
67
+ ]
68
+
69
+ if len(packages) != 1:
70
+ dirs_str = ', '.join([str(dir) for dir in packages])
71
+ msg = f'Expected exactly one package in {self.repo}, found {dirs_str}'
72
+ raise ValueError(msg)
73
+
74
+ package = next(iter(packages))
75
+ return package
76
+
77
+ @property
78
+ def org(self) -> bool | Path:
79
+ """
80
+
81
+ Get the org path, i.e. the namespace parent directory.
82
+
83
+ """
84
+ if not self.org_name:
85
+ return False
86
+ org = self.repo / self.org_name
87
+ if not org.is_dir():
88
+ return False
89
+ return org
90
+
91
+ @property
92
+ def entrypoint(self) -> Path:
93
+ """
94
+
95
+ Path of base entrypoint module.
96
+
97
+ """
98
+ return self.path / Constants.ENTRYPOINT_FILE
99
+
100
+ @property
101
+ def entrypoints(self) -> Path:
102
+ """
103
+
104
+ Path of entrypoints sub-package.
105
+
106
+ """
107
+ return self.path / Constants.ENTRYPOINTS_DIR
108
+
109
+ @property
110
+ def scripts(self) -> Path:
111
+ """
112
+
113
+ Paths of shell scripts
114
+
115
+ """
116
+
117
+ return self.repo / Constants.SCRIPTS_DIR
118
+
119
+ @property
120
+ def is_namespace(self) -> bool:
121
+ return bool(self.org)
122
+
123
+ @property
124
+ def name(self) -> str:
125
+ return self.path.stem
126
+
127
+
128
+ class Setup(FromCallerMixin):
129
+ """
130
+
131
+ Abstract canonical pacakge setup for setuptools.
132
+
133
+ """
134
+ AUTHOR = 'Frontmatter'
135
+ AUTHOR_EMAIL = 'innovative.fowler@mask.pro.fmtr.dev'
136
+
137
+ REQUIREMENTS_ARG = 'requirements'
138
+
139
+ ENTRYPOINT_COMMAND_SEP = '-'
140
+ ENTRYPOINT_FUNCTION_SEP = '_'
141
+ ENTRYPOINT_FUNC_NAME = 'main'
142
+
143
+ def __init__(self, dependencies, paths=None, org=Constants.ORG_NAME, client=None, do_setup=True, **kwargs):
144
+ """
145
+
146
+ First check if commandline arguments for requirements output exist. If so, print them and return early.
147
+ Otherwise, continue generating data to pass to setuptools.
148
+
149
+ """
150
+ self.kwargs = kwargs
151
+
152
+ if type(dependencies) is not Dependencies:
153
+ dependencies = Dependencies(**dependencies)
154
+ self.dependencies = dependencies
155
+
156
+ requirements_extras = self.get_requirements_extras()
157
+
158
+ if requirements_extras:
159
+ self.print_requirements()
160
+ return
161
+
162
+ self.org = org
163
+
164
+ if not paths:
165
+ paths = SetupPaths(path=self.from_caller(), org=self.org)
166
+ self.paths = paths
167
+
168
+ self.client = client
169
+
170
+ if do_setup:
171
+ self.setup()
172
+ self
173
+
174
+ def get_requirements_extras(self) -> Optional[List[str]]:
175
+ """
176
+
177
+ Get list of extras from command line arguments.
178
+
179
+ """
180
+ if self.REQUIREMENTS_ARG not in sys.argv:
181
+ return None
182
+
183
+ extras_str = sys.argv[-1]
184
+ extras = extras_str.split(',')
185
+ return extras
186
+
187
+ def print_requirements(self):
188
+ """
189
+
190
+ Output flat list of requirements for specified extras
191
+
192
+ """
193
+ reqs = []
194
+ reqs += self.dependencies.install
195
+
196
+ for extra in sys.argv[-1].split(','):
197
+ reqs += self.dependencies.extras[extra]
198
+ reqs = '\n'.join(reqs)
199
+ print(reqs)
200
+
201
+ @property
202
+ def console_scripts(self) -> List[str]:
203
+ """
204
+
205
+ Generate console scripts for the `entrypoint` module - and/or any modules in `entrypoints` sub-package.
206
+
207
+ """
208
+
209
+ if not self.paths.entrypoints.exists():
210
+ paths_mods = []
211
+ else:
212
+ paths_mods = list(self.paths.entrypoints.iterdir())
213
+
214
+ names_mods = [path.stem for path in paths_mods if path.is_file() and path.name != Constants.INIT_FILENAME]
215
+ command_suffixes = [name_mod.replace(self.ENTRYPOINT_FUNCTION_SEP, self.ENTRYPOINT_COMMAND_SEP) for name_mod in names_mods]
216
+ commands = [f'{self.name_command}-{command_suffix}' for command_suffix in command_suffixes]
217
+ paths = [f'{self.name}.{Constants.ENTRYPOINTS_DIR}.{name_mod}:{self.ENTRYPOINT_FUNC_NAME}' for name_mod in names_mods]
218
+
219
+ if self.paths.entrypoint.exists():
220
+ commands.append(self.name_command)
221
+ path = f'{self.name}.{self.paths.entrypoint.stem}:{self.ENTRYPOINT_FUNC_NAME}'
222
+ paths.append(path)
223
+
224
+ console_scripts = [f'{command} = {path}' for command, path in zip(commands, paths)]
225
+
226
+ return console_scripts
227
+
228
+ @property
229
+ def scripts(self) -> List[str]:
230
+ """
231
+
232
+ Generate list of shell scripts.
233
+
234
+ """
235
+
236
+ paths = []
237
+
238
+ if not self.paths.scripts.exists():
239
+ return paths
240
+
241
+ for path in self.paths.scripts.iterdir():
242
+ if path.is_dir():
243
+ continue
244
+
245
+ path_rel = path.relative_to(self.paths.repo)
246
+ paths.append(str(path_rel))
247
+
248
+ return paths
249
+
250
+ @cached_property
251
+ def name_command(self) -> str:
252
+ """
253
+
254
+ Name as a command, e.g. `fmtr-tools`
255
+
256
+ """
257
+ return self.name.replace('.', self.ENTRYPOINT_COMMAND_SEP)
258
+
259
+ @property
260
+ def name(self) -> str:
261
+ """
262
+
263
+ Full library name
264
+
265
+ """
266
+ if self.paths.is_namespace:
267
+ return f'{self.paths.org_name}.{self.paths.name}'
268
+ return self.paths.name
269
+
270
+ @property
271
+ def author(self) -> str:
272
+ """
273
+
274
+ Create appropriate author string
275
+
276
+ """
277
+ if self.client:
278
+ return f'{self.AUTHOR} on behalf of {self.client}'
279
+ return self.AUTHOR
280
+
281
+ @property
282
+ def copyright(self) -> str:
283
+ """
284
+
285
+ Create appropriate copyright string
286
+
287
+ """
288
+ if self.client:
289
+ return self.client
290
+ return self.AUTHOR
291
+
292
+ @property
293
+ def long_description(self) -> str:
294
+ """
295
+
296
+ Read in README.md
297
+
298
+ """
299
+ return self.paths.readme.read_text()
300
+
301
+ @property
302
+ def version(self) -> str:
303
+ """
304
+
305
+ Read in the version string from file
306
+
307
+ """
308
+ return self.paths.version.read_text().strip()
309
+
310
+ @property
311
+ def find(self) -> Callable:
312
+ """
313
+
314
+ Use the appropriate package finding function from setuptools
315
+
316
+ """
317
+ from fmtr.tools import setup
318
+
319
+ if self.paths.is_namespace:
320
+ return setup.find_namespace_packages
321
+ else:
322
+ return setup.find_packages
323
+
324
+ @property
325
+ def packages(self) -> List[str]:
326
+ """
327
+
328
+ Fetch list of packages excluding canonical paths
329
+
330
+ """
331
+ excludes = list(Constants.PACKAGE_EXCLUDE_DIRS) + [f'{name}.*' for name in Constants.PACKAGE_EXCLUDE_DIRS if '*' not in name]
332
+ packages = self.find(where=str(self.paths.repo), exclude=excludes)
333
+ return packages
334
+
335
+ @property
336
+ def package_dir(self):
337
+ """
338
+
339
+ Needs to be relative apparently as absolute paths break during packaging
340
+
341
+ """
342
+ if self.paths.is_namespace:
343
+ return {'': '.'}
344
+ else:
345
+ return None
346
+
347
+ @property
348
+ def package_data(self):
349
+ """
350
+
351
+ Default package data is just the version file
352
+
353
+ """
354
+ return {self.name: [Constants.FILENAME_VERSION]}
355
+
356
+ @property
357
+ def url(self) -> str:
358
+ """
359
+
360
+ Default to GitHub URL
361
+
362
+ """
363
+ return f'https://github.com/{self.org}/{self.name}'
364
+
365
+ @property
366
+ def data(self) -> Dict[str, Any]:
367
+ """
368
+
369
+ Generate data for use by setuptools
370
+
371
+ """
372
+ data = dict(
373
+ name=self.name,
374
+ version=self.version,
375
+ author=self.author,
376
+ author_email=self.AUTHOR_EMAIL,
377
+ url=self.url,
378
+ license=f'Copyright © {datetime.now().year} {self.copyright}. All rights reserved.',
379
+ long_description=self.long_description,
380
+ long_description_content_type='text/markdown',
381
+ packages=self.packages,
382
+ package_dir=self.package_dir,
383
+ package_data=self.package_data,
384
+ entry_points=dict(
385
+ console_scripts=self.console_scripts,
386
+ ),
387
+ install_requires=self.dependencies.install,
388
+ extras_require=self.dependencies.extras,
389
+ scripts=self.scripts,
390
+ ) | self.kwargs
391
+ return data
392
+
393
+ def setup(self):
394
+ """
395
+
396
+ Call setuptools.setup using generated data
397
+
398
+ """
399
+
400
+ from fmtr.tools import setup
401
+
402
+ return setup.setup_setuptools(**self.data)
403
+
404
+ def __repr__(self) -> str:
405
+ """
406
+
407
+ Show library name
408
+
409
+ """
410
+ return f'{self.__class__.__name__}("{self.name}")'
411
+
412
+ class Tools:
413
+ """
414
+
415
+ Helper for downstream libraries to specify lists of `fmtr.tools` extras
416
+
417
+ """
418
+ MASK = f'{Constants.LIBRARY_NAME}[{{extras}}]'
419
+
420
+ def __init__(self, *extras):
421
+ self.extras = extras
422
+
423
+ def __str__(self):
424
+ extras_str = ','.join(self.extras)
425
+ return self.MASK.format(extras=extras_str)
426
+
427
+
428
+
429
+ class Dependencies:
430
+ ALL = 'all'
431
+ INSTALL = 'install'
432
+
433
+ def __init__(self, **kwargs):
434
+ self.dependencies = kwargs
435
+
436
+ def resolve_values(self, key) -> List[str]:
437
+ """
438
+
439
+ Flatten a list of dependencies.
440
+
441
+ """
442
+ values_resolved = []
443
+ values = self.dependencies[key]
444
+
445
+ for value in values:
446
+ if value == key or value not in self.dependencies:
447
+ # Add the value directly if it references itself or is not a dependency key.
448
+ values_resolved.append(str(value))
449
+ else:
450
+ # Recurse into nested dependencies.
451
+ values_resolved += self.resolve_values(value)
452
+
453
+ return values_resolved
454
+
455
+ @cached_property
456
+ def extras(self) -> Dict[str, List[str]]:
457
+ """
458
+
459
+ Flatten dependencies.
460
+
461
+ """
462
+ resolved = {key: self.resolve_values(key) for key in self.dependencies.keys()}
463
+ resolved.pop(self.INSTALL, None)
464
+ resolved[self.ALL] = list(set(chain.from_iterable(resolved.values())))
465
+ return resolved
466
+
467
+ @cached_property
468
+ def install(self) -> List[str]:
469
+ """
470
+
471
+ Get install_requires
472
+
473
+ """
474
+ if self.INSTALL in self.dependencies:
475
+ return self.resolve_values(self.INSTALL)
476
+ else:
477
+ return []
478
+
479
+
480
+ if __name__ == '__main__':
481
+ ...
@@ -1,6 +1,6 @@
1
- from collections import namedtuple
2
-
3
1
  import re
2
+ from collections import namedtuple
3
+ from dataclasses import dataclass
4
4
  from string import Formatter
5
5
  from textwrap import dedent
6
6
  from typing import List
@@ -84,27 +84,80 @@ def sanitize(*strings, sep: str = '-') -> str:
84
84
  return string
85
85
 
86
86
 
87
- def truncate_mid(text, length=None, sep=ELLIPSIS):
87
+ @dataclass
88
+ class Truncation:
89
+ """
90
+
91
+ Result type for truncation functions
92
+
93
+ """
94
+ text: str
95
+ text_without_sep: str | None
96
+ original: str
97
+ remainder: str | None
98
+ sep: str
99
+
100
+
101
+ def truncate(text, length=None, sep=ELLIPSIS, return_type=str):
102
+ """
103
+
104
+ Truncate a string to length characters
105
+
106
+ """
107
+ text = flatten(text)
108
+ if len(text) <= length or not length:
109
+ return text if return_type is str else Truncation(text, text, text, None, sep)
110
+
111
+ cutoff = length - len(sep)
112
+ truncated = text[:cutoff] + sep
113
+
114
+ if return_type is str:
115
+ return truncated
116
+ else:
117
+ return Truncation(
118
+ text=truncated,
119
+ text_without_sep=text[:cutoff],
120
+ original=text,
121
+ remainder=text[cutoff:] or None,
122
+ sep=sep
123
+ )
124
+
125
+
126
+ def truncate_mid(text, length=None, sep=ELLIPSIS, return_type=str):
88
127
  """
89
128
 
90
- Truncate a string to `length` characters in the middle
129
+ Truncate a string to `length` characters in the middle.
91
130
 
92
131
  """
93
132
  text = flatten(text)
94
133
  if len(text) <= length or not length:
95
- return text
96
- half_length = (length - 3) // 2
97
- return text[:half_length] + sep + text[-half_length:]
134
+ return text if return_type is str else Truncation(text, text, text, '', sep)
135
+
136
+ half = (length - len(sep)) // 2
137
+ left = text[:half]
138
+ right = text[-half:]
139
+ truncated = left + sep + right
140
+
141
+ if return_type is str:
142
+ return truncated
143
+ else:
144
+ return Truncation(
145
+ text=truncated,
146
+ text_without_sep=None,
147
+ original=text,
148
+ remainder=None,
149
+ sep=sep
150
+ )
98
151
 
99
152
 
100
- def flatten(raw):
153
+ def flatten(raw, sep=' '):
101
154
  """
102
155
 
103
156
  Flatten a multiline string to a single line
104
157
 
105
158
  """
106
159
  lines = raw.splitlines()
107
- text = ' '.join(lines)
160
+ text = sep.join(lines)
108
161
  text = text.strip()
109
162
  return text
110
163
 
@@ -121,6 +174,23 @@ def join(strings, sep=' '):
121
174
  return text
122
175
 
123
176
 
177
+ def join_natural(items, sep=', ', conj='and'):
178
+ """
179
+
180
+ Natural language list
181
+
182
+ """
183
+
184
+ items = list(items)
185
+ if not items:
186
+ return ""
187
+ if len(items) == 1:
188
+ return items[0]
189
+ firsts, last = items[:-1], items[-1]
190
+ firsts_str = join(firsts, sep=sep)
191
+ text = f"{firsts_str} {conj} {last}"
192
+ return text
193
+
124
194
  class Mask:
125
195
  """
126
196
 
@@ -172,8 +242,17 @@ def trim(text: str) -> str:
172
242
  """
173
243
  return dedent(text).strip()
174
244
 
175
- if __name__ == '__main__':
176
- import numpy as np
177
245
 
178
- st = join([1, None, 'test', np.nan, 0, '', 'yeah'])
179
- st
246
+ ACRONYM_BOUNDARY = re.compile(r'([A-Z]+)([A-Z][a-z])')
247
+ CAMEL_BOUNDARY = re.compile(r'([a-z0-9])([A-Z])')
248
+
249
+
250
+ def camel_to_snake(name: str) -> str:
251
+ """
252
+
253
+ Camel case to snake case
254
+
255
+ """
256
+ name = ACRONYM_BOUNDARY.sub(r'\1_\2', name)
257
+ name = CAMEL_BOUNDARY.sub(r'\1_\2', name)
258
+ return name.lower()
@@ -1,7 +1,68 @@
1
+ import deepdiff
2
+ import numpy as np
1
3
  import pandas as pd
2
4
 
5
+ from fmtr.tools.iterator_tools import dedupe
6
+
3
7
  Table = DataFrame = pd.DataFrame
4
8
  Series = pd.Series
5
9
 
10
+ nan = np.nan
11
+
6
12
  CONCAT_HORIZONTALLY = 1
7
13
  CONCAT_VERTICALLY = 0
14
+
15
+
16
+ def normalize_nan(df, value=np.nan):
17
+ return df.replace({pd.NA: value, None: value, np.nan: value})
18
+
19
+
20
+ class Differ:
21
+ """
22
+
23
+ Diff two dataframes via DeepDiff, after shape normalization, datatype simplification, etc.
24
+
25
+ """
26
+
27
+ def __init__(self, left: Table, right: Table):
28
+
29
+ self.cols = dedupe(left.columns.tolist() + right.columns.tolist())
30
+ self.rows = dedupe(left.index.values.tolist() + right.index.values.tolist())
31
+ self.left = self.process(left)
32
+ self.right = self.process(right)
33
+ self.dfs = [self.left, self.right]
34
+
35
+ def process(self, df: Table) -> Table:
36
+ """
37
+
38
+ Ensure same rows/columns, plus simplify datatypes via JSON round-robin.
39
+
40
+ """
41
+
42
+ df_rows = set(df.index.values.tolist())
43
+ for row in self.rows:
44
+ if row in df_rows:
45
+ continue
46
+ df.loc[len(df)] = None
47
+
48
+ df_cols = set(df.columns.tolist())
49
+ for col in self.cols:
50
+ if col in df_cols:
51
+ continue
52
+ df[col] = None
53
+
54
+ df = pd.read_json(df.to_json(date_format='iso'))
55
+ df = normalize_nan(df, value=None)
56
+
57
+ return df
58
+
59
+ def get_diff(self) -> deepdiff.DeepDiff:
60
+ """
61
+
62
+ Cast to dicts and get diff
63
+
64
+ """
65
+
66
+ dicts = [df.to_dict(orient='index') for df in self.dfs]
67
+ diff = deepdiff.DeepDiff(*dicts, ignore_order=True)
68
+ return diff