fmtr.tools 1.1.1__py3-none-any.whl → 1.3.81__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 (67) hide show
  1. fmtr/tools/__init__.py +68 -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 +8 -5
  6. fmtr/tools/caching_tools.py +101 -3
  7. fmtr/tools/constants.py +33 -0
  8. fmtr/tools/context_tools.py +23 -0
  9. fmtr/tools/data_modelling_tools.py +227 -14
  10. fmtr/tools/database_tools/__init__.py +6 -0
  11. fmtr/tools/database_tools/document.py +51 -0
  12. fmtr/tools/datatype_tools.py +21 -1
  13. fmtr/tools/datetime_tools.py +12 -0
  14. fmtr/tools/debugging_tools.py +60 -0
  15. fmtr/tools/dns_tools/__init__.py +7 -0
  16. fmtr/tools/dns_tools/client.py +97 -0
  17. fmtr/tools/dns_tools/dm.py +257 -0
  18. fmtr/tools/dns_tools/proxy.py +66 -0
  19. fmtr/tools/dns_tools/server.py +138 -0
  20. fmtr/tools/docker_tools/__init__.py +6 -0
  21. fmtr/tools/entrypoints/__init__.py +0 -0
  22. fmtr/tools/entrypoints/cache_hfh.py +3 -0
  23. fmtr/tools/entrypoints/ep_test.py +2 -0
  24. fmtr/tools/entrypoints/install_yamlscript.py +8 -0
  25. fmtr/tools/{console_script_tools.py → entrypoints/remote_debug_test.py} +1 -6
  26. fmtr/tools/entrypoints/shell_debug.py +8 -0
  27. fmtr/tools/environment_tools.py +2 -2
  28. fmtr/tools/function_tools.py +77 -1
  29. fmtr/tools/google_api_tools.py +15 -4
  30. fmtr/tools/http_tools.py +26 -0
  31. fmtr/tools/inherit_tools.py +27 -0
  32. fmtr/tools/interface_tools/__init__.py +8 -0
  33. fmtr/tools/interface_tools/context.py +13 -0
  34. fmtr/tools/interface_tools/controls.py +354 -0
  35. fmtr/tools/interface_tools/interface_tools.py +189 -0
  36. fmtr/tools/iterator_tools.py +29 -0
  37. fmtr/tools/logging_tools.py +43 -16
  38. fmtr/tools/packaging_tools.py +14 -0
  39. fmtr/tools/path_tools/__init__.py +12 -0
  40. fmtr/tools/path_tools/app_path_tools.py +40 -0
  41. fmtr/tools/{path_tools.py → path_tools/path_tools.py} +156 -12
  42. fmtr/tools/path_tools/type_path_tools.py +3 -0
  43. fmtr/tools/pattern_tools.py +260 -0
  44. fmtr/tools/pdf_tools.py +39 -1
  45. fmtr/tools/settings_tools.py +23 -4
  46. fmtr/tools/setup_tools/__init__.py +8 -0
  47. fmtr/tools/setup_tools/setup_tools.py +447 -0
  48. fmtr/tools/string_tools.py +92 -13
  49. fmtr/tools/tabular_tools.py +61 -0
  50. fmtr/tools/tools.py +27 -2
  51. fmtr/tools/version +1 -1
  52. fmtr/tools/version_tools/__init__.py +12 -0
  53. fmtr/tools/version_tools/version_tools.py +51 -0
  54. fmtr/tools/webhook_tools.py +17 -0
  55. fmtr/tools/yaml_tools.py +66 -5
  56. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/METADATA +136 -54
  57. fmtr_tools-1.3.81.dist-info/RECORD +93 -0
  58. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/WHEEL +1 -1
  59. fmtr_tools-1.3.81.dist-info/entry_points.txt +6 -0
  60. fmtr_tools-1.3.81.dist-info/top_level.txt +1 -0
  61. fmtr/tools/docker_tools.py +0 -30
  62. fmtr/tools/interface_tools.py +0 -64
  63. fmtr/tools/version_tools.py +0 -62
  64. fmtr_tools-1.1.1.dist-info/RECORD +0 -65
  65. fmtr_tools-1.1.1.dist-info/entry_points.txt +0 -3
  66. fmtr_tools-1.1.1.dist-info/top_level.txt +0 -2
  67. {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.3.81.dist-info}/licenses/LICENSE +0 -0
fmtr/tools/pdf_tools.py CHANGED
@@ -1,6 +1,7 @@
1
+ from typing import List, Tuple, Dict, Any, Self
2
+
1
3
  import pymupdf as pm
2
4
  import pymupdf4llm
3
- from typing import List, Tuple, Dict, Any, Self
4
5
 
5
6
  from fmtr.tools import data_modelling_tools
6
7
 
@@ -179,6 +180,43 @@ class Document(pm.Document):
179
180
  """
180
181
  return pymupdf4llm.to_markdown(self, **kwargs)
181
182
 
183
+ def to_text_pages(self) -> List[str]:
184
+ """
185
+
186
+ Simple text output per-page.
187
+
188
+ """
189
+ lines = []
190
+ for page in self:
191
+ text = page.get_text()
192
+ lines.append(text)
193
+
194
+ return lines
195
+
196
+ def to_text(self) -> str:
197
+ """
198
+
199
+ Simple text output.
200
+
201
+ """
202
+
203
+ text = '\n'.join(self.to_text_pages())
204
+ return text
205
+
206
+ def split(self) -> List[Self]:
207
+ """
208
+
209
+ Split pages into individual documents.
210
+
211
+ """
212
+
213
+ documents = []
214
+ for i, page in enumerate(self, start=1):
215
+ document = self.__class__()
216
+ document.insert_pdf(self, from_page=i, to_page=i)
217
+ documents.append(document)
218
+
219
+ return documents
182
220
 
183
221
  if __name__ == '__main__':
184
222
  from fmtr.tools.path_tools import Path
@@ -1,10 +1,29 @@
1
+ from typing import ClassVar, Any
2
+
1
3
  from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, YamlConfigSettingsSource, EnvSettingsSource, CliSettingsSource
2
- from typing import ClassVar
3
4
 
4
- from fmtr.tools.path_tools import PackagePaths
5
+ from fmtr.tools.data_modelling_tools import CliRunMixin
6
+ from fmtr.tools.path_tools import PackagePaths, Path
7
+
8
+
9
+ class YamlScriptConfigSettingsSource(YamlConfigSettingsSource):
10
+ """
11
+
12
+ Customer source for reading YAML *Script* (as opposed to plain YAML) configuration files.
13
+
14
+ """
15
+
16
+ def _read_file(self, file_path: Path) -> dict[str, Any]:
17
+ """
18
+
19
+ Use our own Path class to read YAML Script.
20
+
21
+ """
22
+ data = Path(file_path).read_yaml() or {}
23
+ return data
5
24
 
6
25
 
7
- class Base(BaseSettings):
26
+ class Base(BaseSettings, CliRunMixin):
8
27
  """
9
28
 
10
29
  Base class for settings configuration using Pydantic BaseSettings.
@@ -34,7 +53,7 @@ class Base(BaseSettings):
34
53
  init_settings,
35
54
  CliSettingsSource(settings_cls, cli_parse_args=True),
36
55
  EnvSettingsSource(settings_cls, env_prefix=cls.get_env_prefix()),
37
- YamlConfigSettingsSource(settings_cls, yaml_file=cls.paths.settings),
56
+ YamlScriptConfigSettingsSource(settings_cls, yaml_file=cls.paths.settings),
38
57
  )
39
58
 
40
59
  return sources
@@ -0,0 +1,8 @@
1
+ from fmtr.tools.import_tools import MissingExtraMockModule
2
+
3
+ from fmtr.tools.setup_tools.setup_tools import Setup, SetupPaths, Dependencies, Tools
4
+
5
+ try:
6
+ from setuptools import find_namespace_packages, find_packages, setup as setup_setuptools
7
+ except ModuleNotFoundError as exception:
8
+ find_namespace_packages = find_packages = setup_setuptools = MissingExtraMockModule('setup', exception)
@@ -0,0 +1,447 @@
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)
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 is_namespace(self) -> bool:
111
+ return bool(self.org)
112
+
113
+ @property
114
+ def name(self) -> str:
115
+ return self.path.stem
116
+
117
+
118
+ class Setup(FromCallerMixin):
119
+ """
120
+
121
+ Abstract canonical pacakge setup for setuptools.
122
+
123
+ """
124
+ AUTHOR = 'Frontmatter'
125
+ AUTHOR_EMAIL = 'innovative.fowler@mask.pro.fmtr.dev'
126
+
127
+ REQUIREMENTS_ARG = 'requirements'
128
+
129
+ ENTRYPOINT_COMMAND_SEP = '-'
130
+ ENTRYPOINT_FUNCTION_SEP = '_'
131
+ ENTRYPOINT_FUNC_NAME = 'main'
132
+
133
+ def __init__(self, dependencies, paths=None, org=Constants.ORG_NAME, client=None, do_setup=True, **kwargs):
134
+ """
135
+
136
+ First check if commandline arguments for requirements output exist. If so, print them and return early.
137
+ Otherwise, continue generating data to pass to setuptools.
138
+
139
+ """
140
+ self.kwargs = kwargs
141
+
142
+ if type(dependencies) is not Dependencies:
143
+ dependencies = Dependencies(**dependencies)
144
+ self.dependencies = dependencies
145
+
146
+ requirements_extras = self.get_requirements_extras()
147
+
148
+ if requirements_extras:
149
+ self.print_requirements()
150
+ return
151
+
152
+ self.org = org
153
+
154
+ if not paths:
155
+ paths = SetupPaths(path=self.from_caller(), org=self.org)
156
+ self.paths = paths
157
+
158
+ self.client = client
159
+
160
+ if do_setup:
161
+ self.setup()
162
+
163
+ def get_requirements_extras(self) -> Optional[List[str]]:
164
+ """
165
+
166
+ Get list of extras from command line arguments.
167
+
168
+ """
169
+ if self.REQUIREMENTS_ARG not in sys.argv:
170
+ return None
171
+
172
+ extras_str = sys.argv[-1]
173
+ extras = extras_str.split(',')
174
+ return extras
175
+
176
+ def print_requirements(self):
177
+ """
178
+
179
+ Output flat list of requirements for specified extras
180
+
181
+ """
182
+ reqs = []
183
+ reqs += self.dependencies.install
184
+
185
+ for extra in sys.argv[-1].split(','):
186
+ reqs += self.dependencies.extras[extra]
187
+ reqs = '\n'.join(reqs)
188
+ print(reqs)
189
+
190
+ @property
191
+ def console_scripts(self) -> List[str]:
192
+ """
193
+
194
+ Generate console scripts for the `entrypoint` module - and/or any modules in `entrypoints` sub-package.
195
+
196
+ """
197
+
198
+ if not self.paths.entrypoints.exists():
199
+ paths_mods = []
200
+ else:
201
+ paths_mods = list(self.paths.entrypoints.iterdir())
202
+
203
+ names_mods = [path.stem for path in paths_mods if path.is_file() and path.name != Constants.INIT_FILENAME]
204
+ command_suffixes = [name_mod.replace(self.ENTRYPOINT_FUNCTION_SEP, self.ENTRYPOINT_COMMAND_SEP) for name_mod in names_mods]
205
+ commands = [f'{self.name_command}-{command_suffix}' for command_suffix in command_suffixes]
206
+ paths = [f'{self.name}.{Constants.ENTRYPOINTS_DIR}.{name_mod}:{self.ENTRYPOINT_FUNC_NAME}' for name_mod in names_mods]
207
+
208
+ if self.paths.entrypoint.exists():
209
+ commands.append(self.name_command)
210
+ path = f'{self.name}.{self.paths.entrypoint.stem}:{self.ENTRYPOINT_FUNC_NAME}'
211
+ paths.append(path)
212
+
213
+ console_scripts = [f'{command} = {path}' for command, path in zip(commands, paths)]
214
+
215
+ return console_scripts
216
+
217
+ @cached_property
218
+ def name_command(self) -> str:
219
+ """
220
+
221
+ Name as a command, e.g. `fmtr-tools`
222
+
223
+ """
224
+ return self.name.replace('.', self.ENTRYPOINT_COMMAND_SEP)
225
+
226
+ @property
227
+ def name(self) -> str:
228
+ """
229
+
230
+ Full library name
231
+
232
+ """
233
+ if self.paths.is_namespace:
234
+ return f'{self.paths.org_name}.{self.paths.name}'
235
+ return self.paths.name
236
+
237
+ @property
238
+ def author(self) -> str:
239
+ """
240
+
241
+ Create appropriate author string
242
+
243
+ """
244
+ if self.client:
245
+ return f'{self.AUTHOR} on behalf of {self.client}'
246
+ return self.AUTHOR
247
+
248
+ @property
249
+ def copyright(self) -> str:
250
+ """
251
+
252
+ Create appropriate copyright string
253
+
254
+ """
255
+ if self.client:
256
+ return self.client
257
+ return self.AUTHOR
258
+
259
+ @property
260
+ def long_description(self) -> str:
261
+ """
262
+
263
+ Read in README.md
264
+
265
+ """
266
+ return self.paths.readme.read_text()
267
+
268
+ @property
269
+ def version(self) -> str:
270
+ """
271
+
272
+ Read in the version string from file
273
+
274
+ """
275
+ return self.paths.version.read_text().strip()
276
+
277
+ @property
278
+ def find(self) -> Callable:
279
+ """
280
+
281
+ Use the appropriate package finding function from setuptools
282
+
283
+ """
284
+ from fmtr.tools import setup
285
+
286
+ if self.paths.is_namespace:
287
+ return setup.find_namespace_packages
288
+ else:
289
+ return setup.find_packages
290
+
291
+ @property
292
+ def packages(self) -> List[str]:
293
+ """
294
+
295
+ Fetch list of packages excluding canonical paths
296
+
297
+ """
298
+ excludes = list(Constants.PACKAGE_EXCLUDE_DIRS) + [f'{name}.*' for name in Constants.PACKAGE_EXCLUDE_DIRS if '*' not in name]
299
+ packages = self.find(where=str(self.paths.repo), exclude=excludes)
300
+ return packages
301
+
302
+ @property
303
+ def package_dir(self):
304
+ """
305
+
306
+ Needs to be relative apparently as absolute paths break during packaging
307
+
308
+ """
309
+ if self.paths.is_namespace:
310
+ return {'': '.'}
311
+ else:
312
+ return None
313
+
314
+ @property
315
+ def package_data(self):
316
+ """
317
+
318
+ Default package data is just the version file
319
+
320
+ """
321
+ return {self.name: [Constants.FILENAME_VERSION]}
322
+
323
+ @property
324
+ def url(self) -> str:
325
+ """
326
+
327
+ Default to GitHub URL
328
+
329
+ """
330
+ return f'https://github.com/{self.org}/{self.name}'
331
+
332
+ @property
333
+ def data(self) -> Dict[str, Any]:
334
+ """
335
+
336
+ Generate data for use by setuptools
337
+
338
+ """
339
+ data = dict(
340
+ name=self.name,
341
+ version=self.version,
342
+ author=self.author,
343
+ author_email=self.AUTHOR_EMAIL,
344
+ url=self.url,
345
+ license=f'Copyright © {datetime.now().year} {self.copyright}. All rights reserved.',
346
+ long_description=self.long_description,
347
+ long_description_content_type='text/markdown',
348
+ packages=self.packages,
349
+ package_dir=self.package_dir,
350
+ package_data=self.package_data,
351
+ entry_points=dict(
352
+ console_scripts=self.console_scripts,
353
+ ),
354
+ install_requires=self.dependencies.install,
355
+ extras_require=self.dependencies.extras,
356
+ ) | self.kwargs
357
+ return data
358
+
359
+ def setup(self):
360
+ """
361
+
362
+ Call setuptools.setup using generated data
363
+
364
+ """
365
+
366
+ from fmtr.tools import setup
367
+
368
+ return setup.setup_setuptools(**self.data)
369
+
370
+ def __repr__(self) -> str:
371
+ """
372
+
373
+ Show library name
374
+
375
+ """
376
+ return f'{self.__class__.__name__}("{self.name}")'
377
+
378
+ class Tools:
379
+ """
380
+
381
+ Helper for downstream libraries to specify lists of `fmtr.tools` extras
382
+
383
+ """
384
+ MASK = f'{Constants.LIBRARY_NAME}[{{extras}}]'
385
+
386
+ def __init__(self, *extras):
387
+ self.extras = extras
388
+
389
+ def __str__(self):
390
+ extras_str = ','.join(self.extras)
391
+ return self.MASK.format(extras=extras_str)
392
+
393
+
394
+
395
+ class Dependencies:
396
+ ALL = 'all'
397
+ INSTALL = 'install'
398
+
399
+ def __init__(self, **kwargs):
400
+ self.dependencies = kwargs
401
+
402
+ def resolve_values(self, key) -> List[str]:
403
+ """
404
+
405
+ Flatten a list of dependencies.
406
+
407
+ """
408
+ values_resolved = []
409
+ values = self.dependencies[key]
410
+
411
+ for value in values:
412
+ if value == key or value not in self.dependencies:
413
+ # Add the value directly if it references itself or is not a dependency key.
414
+ values_resolved.append(str(value))
415
+ else:
416
+ # Recurse into nested dependencies.
417
+ values_resolved += self.resolve_values(value)
418
+
419
+ return values_resolved
420
+
421
+ @cached_property
422
+ def extras(self) -> Dict[str, List[str]]:
423
+ """
424
+
425
+ Flatten dependencies.
426
+
427
+ """
428
+ resolved = {key: self.resolve_values(key) for key in self.dependencies.keys()}
429
+ resolved.pop(self.INSTALL, None)
430
+ resolved[self.ALL] = list(set(chain.from_iterable(resolved.values())))
431
+ return resolved
432
+
433
+ @cached_property
434
+ def install(self) -> List[str]:
435
+ """
436
+
437
+ Get install_requires
438
+
439
+ """
440
+ if self.INSTALL in self.dependencies:
441
+ return self.resolve_values(self.INSTALL)
442
+ else:
443
+ return []
444
+
445
+
446
+ if __name__ == '__main__':
447
+ ...
@@ -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()