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.
- fmtr/tools/__init__.py +86 -52
- fmtr/tools/ai_tools/__init__.py +2 -2
- fmtr/tools/ai_tools/agentic_tools.py +151 -32
- fmtr/tools/ai_tools/inference_tools.py +2 -1
- fmtr/tools/api_tools.py +73 -12
- fmtr/tools/async_tools.py +4 -0
- fmtr/tools/av_tools.py +7 -0
- fmtr/tools/caching_tools.py +101 -3
- fmtr/tools/constants.py +41 -0
- fmtr/tools/context_tools.py +23 -0
- fmtr/tools/data_modelling_tools.py +227 -14
- fmtr/tools/database_tools/__init__.py +6 -0
- fmtr/tools/database_tools/document.py +51 -0
- fmtr/tools/datatype_tools.py +22 -2
- fmtr/tools/datetime_tools.py +12 -0
- fmtr/tools/debugging_tools.py +60 -1
- fmtr/tools/dns_tools/__init__.py +7 -0
- fmtr/tools/dns_tools/client.py +97 -0
- fmtr/tools/dns_tools/dm.py +257 -0
- fmtr/tools/dns_tools/proxy.py +66 -0
- fmtr/tools/dns_tools/server.py +138 -0
- fmtr/tools/docker_tools/__init__.py +6 -0
- fmtr/tools/entrypoints/__init__.py +0 -0
- fmtr/tools/entrypoints/cache_hfh.py +3 -0
- fmtr/tools/entrypoints/ep_test.py +2 -0
- fmtr/tools/entrypoints/install_yamlscript.py +8 -0
- fmtr/tools/{console_script_tools.py → entrypoints/remote_debug_test.py} +1 -6
- fmtr/tools/entrypoints/shell_debug.py +8 -0
- fmtr/tools/environment_tools.py +3 -2
- fmtr/tools/function_tools.py +77 -1
- fmtr/tools/google_api_tools.py +15 -4
- fmtr/tools/ha_tools/__init__.py +8 -0
- fmtr/tools/ha_tools/constants.py +9 -0
- fmtr/tools/ha_tools/core.py +16 -0
- fmtr/tools/ha_tools/supervisor.py +16 -0
- fmtr/tools/ha_tools/utils.py +46 -0
- fmtr/tools/http_tools.py +52 -0
- fmtr/tools/inherit_tools.py +27 -0
- fmtr/tools/interface_tools/__init__.py +8 -0
- fmtr/tools/interface_tools/context.py +13 -0
- fmtr/tools/interface_tools/controls.py +354 -0
- fmtr/tools/interface_tools/interface_tools.py +189 -0
- fmtr/tools/iterator_tools.py +122 -1
- fmtr/tools/logging_tools.py +99 -18
- fmtr/tools/mqtt_tools.py +89 -0
- fmtr/tools/networking_tools.py +73 -0
- fmtr/tools/packaging_tools.py +14 -0
- fmtr/tools/path_tools/__init__.py +12 -0
- fmtr/tools/path_tools/app_path_tools.py +40 -0
- fmtr/tools/{path_tools.py → path_tools/path_tools.py} +217 -14
- fmtr/tools/path_tools/type_path_tools.py +3 -0
- fmtr/tools/pattern_tools.py +277 -0
- fmtr/tools/pdf_tools.py +39 -1
- fmtr/tools/settings_tools.py +27 -6
- fmtr/tools/setup_tools/__init__.py +8 -0
- fmtr/tools/setup_tools/setup_tools.py +481 -0
- fmtr/tools/string_tools.py +92 -13
- fmtr/tools/tabular_tools.py +61 -0
- fmtr/tools/tools.py +27 -2
- fmtr/tools/version +1 -1
- fmtr/tools/version_tools/__init__.py +12 -0
- fmtr/tools/version_tools/version_tools.py +51 -0
- fmtr/tools/webhook_tools.py +17 -0
- fmtr/tools/yaml_tools.py +64 -5
- fmtr/tools/youtube_tools.py +128 -0
- fmtr_tools-1.4.37.data/scripts/add-service +14 -0
- fmtr_tools-1.4.37.data/scripts/add-user-path +8 -0
- fmtr_tools-1.4.37.data/scripts/apt-headless +23 -0
- fmtr_tools-1.4.37.data/scripts/compose-update +10 -0
- fmtr_tools-1.4.37.data/scripts/docker-sandbox +43 -0
- fmtr_tools-1.4.37.data/scripts/docker-sandbox-init +23 -0
- fmtr_tools-1.4.37.data/scripts/docs-deploy +6 -0
- fmtr_tools-1.4.37.data/scripts/docs-serve +5 -0
- fmtr_tools-1.4.37.data/scripts/download +9 -0
- fmtr_tools-1.4.37.data/scripts/fmtr-test-script +3 -0
- fmtr_tools-1.4.37.data/scripts/ftu +3 -0
- fmtr_tools-1.4.37.data/scripts/ha-addon-launch +16 -0
- fmtr_tools-1.4.37.data/scripts/install-browser +8 -0
- fmtr_tools-1.4.37.data/scripts/parse-args +43 -0
- fmtr_tools-1.4.37.data/scripts/set-password +5 -0
- fmtr_tools-1.4.37.data/scripts/snips-install +14 -0
- fmtr_tools-1.4.37.data/scripts/ssh-auth +28 -0
- fmtr_tools-1.4.37.data/scripts/ssh-serve +15 -0
- fmtr_tools-1.4.37.data/scripts/vlc-tn +10 -0
- fmtr_tools-1.4.37.data/scripts/vm-launch +17 -0
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/METADATA +178 -54
- fmtr_tools-1.4.37.dist-info/RECORD +122 -0
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/WHEEL +1 -1
- fmtr_tools-1.4.37.dist-info/entry_points.txt +6 -0
- fmtr_tools-1.4.37.dist-info/top_level.txt +1 -0
- fmtr/tools/docker_tools.py +0 -30
- fmtr/tools/interface_tools.py +0 -64
- fmtr/tools/version_tools.py +0 -62
- fmtr_tools-1.1.1.dist-info/RECORD +0 -65
- fmtr_tools-1.1.1.dist-info/entry_points.txt +0 -3
- fmtr_tools-1.1.1.dist-info/top_level.txt +0 -2
- {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
|
+
...
|
fmtr/tools/string_tools.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 =
|
|
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
|
-
|
|
179
|
-
|
|
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()
|
fmtr/tools/tabular_tools.py
CHANGED
|
@@ -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
|