abstract-essentials 0.0.0.1__tar.gz
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.
- abstract_essentials-0.0.0.1/PKG-INFO +59 -0
- abstract_essentials-0.0.0.1/README.md +42 -0
- abstract_essentials-0.0.0.1/pyproject.toml +30 -0
- abstract_essentials-0.0.0.1/setup.cfg +4 -0
- abstract_essentials-0.0.0.1/src/abstract_essentials/__init__.py +8 -0
- abstract_essentials-0.0.0.1/src/abstract_essentials/utils.py +1087 -0
- abstract_essentials-0.0.0.1/src/abstract_essentials.egg-info/PKG-INFO +59 -0
- abstract_essentials-0.0.0.1/src/abstract_essentials.egg-info/SOURCES.txt +9 -0
- abstract_essentials-0.0.0.1/src/abstract_essentials.egg-info/dependency_links.txt +1 -0
- abstract_essentials-0.0.0.1/src/abstract_essentials.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abstract_essentials
|
|
3
|
+
Version: 0.0.0.1
|
|
4
|
+
Summary: The lean, dependency-free core of abstract_utilities: stdlib-only helpers (json/path/file/string/list/log) safe to import anywhere, including Termux/phone.
|
|
5
|
+
Author-email: putkoff <partners@abstractendeavors.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AbstractEndeavors/abstract_essentials
|
|
8
|
+
Keywords: utilities,stdlib,helpers,dependency-free
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# abstract_essentials
|
|
19
|
+
|
|
20
|
+
The lean, **dependency-free** core carved out of `abstract_utilities`.
|
|
21
|
+
|
|
22
|
+
Every symbol here depends only on the Python standard library — no third-party
|
|
23
|
+
imports, no `from .imports import *` chains, no submodule fan-out. It imports fast
|
|
24
|
+
and installs anywhere (desktop, server, **Termux/Android phone**), which makes it a
|
|
25
|
+
safe foundation for the rest of the `abstract_*` tree to build on without dragging
|
|
26
|
+
in the monolith.
|
|
27
|
+
|
|
28
|
+
## Why this exists
|
|
29
|
+
|
|
30
|
+
`abstract_utilities` is imported by nearly every `abstract_*` package, so its size
|
|
31
|
+
and entanglement (star-exports across a dozen submodules) ripple everywhere.
|
|
32
|
+
`abstract_essentials` is the ~50-function subset that is actually used in practice,
|
|
33
|
+
extracted as a clean, explicit API.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install abstract_essentials
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from abstract_essentials import make_list, get_any_value, safe_read_from_json, get_logFile
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The public API is whatever is listed in `abstract_essentials.__all__` (53 symbols:
|
|
48
|
+
json/path/file/string/list/type/log helpers).
|
|
49
|
+
|
|
50
|
+
## Migrating off abstract_utilities
|
|
51
|
+
|
|
52
|
+
`abstract_utilities` can become a thin compatibility shim that re-exports from here
|
|
53
|
+
(see `abstract_utilities_compat_shim.py` shipped alongside this scaffold), so existing
|
|
54
|
+
`from abstract_utilities import X` keeps working while new code imports from
|
|
55
|
+
`abstract_essentials`.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT — putkoff / Abstract Endeavors.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# abstract_essentials
|
|
2
|
+
|
|
3
|
+
The lean, **dependency-free** core carved out of `abstract_utilities`.
|
|
4
|
+
|
|
5
|
+
Every symbol here depends only on the Python standard library — no third-party
|
|
6
|
+
imports, no `from .imports import *` chains, no submodule fan-out. It imports fast
|
|
7
|
+
and installs anywhere (desktop, server, **Termux/Android phone**), which makes it a
|
|
8
|
+
safe foundation for the rest of the `abstract_*` tree to build on without dragging
|
|
9
|
+
in the monolith.
|
|
10
|
+
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
`abstract_utilities` is imported by nearly every `abstract_*` package, so its size
|
|
14
|
+
and entanglement (star-exports across a dozen submodules) ripple everywhere.
|
|
15
|
+
`abstract_essentials` is the ~50-function subset that is actually used in practice,
|
|
16
|
+
extracted as a clean, explicit API.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install abstract_essentials
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from abstract_essentials import make_list, get_any_value, safe_read_from_json, get_logFile
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The public API is whatever is listed in `abstract_essentials.__all__` (53 symbols:
|
|
31
|
+
json/path/file/string/list/type/log helpers).
|
|
32
|
+
|
|
33
|
+
## Migrating off abstract_utilities
|
|
34
|
+
|
|
35
|
+
`abstract_utilities` can become a thin compatibility shim that re-exports from here
|
|
36
|
+
(see `abstract_utilities_compat_shim.py` shipped alongside this scaffold), so existing
|
|
37
|
+
`from abstract_utilities import X` keeps working while new code imports from
|
|
38
|
+
`abstract_essentials`.
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT — putkoff / Abstract Endeavors.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "abstract_essentials"
|
|
7
|
+
version = "0.0.0.1"
|
|
8
|
+
description = "The lean, dependency-free core of abstract_utilities: stdlib-only helpers (json/path/file/string/list/log) safe to import anywhere, including Termux/phone."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
authors = [{ name = "putkoff", email = "partners@abstractendeavors.com" }]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
keywords = ["utilities", "stdlib", "helpers", "dependency-free"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
# Intentionally EMPTY — this package's whole reason for existing is to have no deps.
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/AbstractEndeavors/abstract_essentials"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
include = ["abstract_essentials*"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""abstract_essentials — the lean, stdlib-only core carved out of abstract_utilities.
|
|
2
|
+
|
|
3
|
+
Zero third-party dependencies; safe to import on any platform (incl. Termux/phone).
|
|
4
|
+
"""
|
|
5
|
+
from .utils import * # noqa: F401,F403
|
|
6
|
+
from .utils import __all__ # noqa: F401
|
|
7
|
+
|
|
8
|
+
__version__ = "0.0.0.1"
|
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
"""
|
|
2
|
+
standalone_utils.py
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Standalone, dependency-free extractions of selected ``abstract_utilities``
|
|
6
|
+
functions.
|
|
7
|
+
|
|
8
|
+
Every public function in this module was pulled out of the ``abstract_utilities``
|
|
9
|
+
package and rewritten / inlined so that it depends **only on the Python standard
|
|
10
|
+
library**. There are no ``from abstract_utilities ...`` imports and no
|
|
11
|
+
``from .imports import *`` style imports anywhere in this file, so it can be
|
|
12
|
+
dropped into any project and used on its own.
|
|
13
|
+
|
|
14
|
+
Functions exported here (with their original home in the package):
|
|
15
|
+
|
|
16
|
+
derive_media_type <- type_utils/mime_types.py
|
|
17
|
+
eatAll <- string_utils/eat_utils.py
|
|
18
|
+
get_any_value <- json_utils/json_utils.py
|
|
19
|
+
get_caller_dir <- class_utils/caller_utils.py
|
|
20
|
+
get_dirlist <- path_utils/path_utils.py
|
|
21
|
+
get_env_value <- env_utils/* (re-implemented, no abstractEnv class)
|
|
22
|
+
get_file_parts <- path_utils/path_utils.py
|
|
23
|
+
get_files <- path_utils/path_utils.py
|
|
24
|
+
get_files_and_dirs <- file_utils/.../find_collect.py (local-only rewrite)
|
|
25
|
+
get_home_dir <- path_utils/path_utils.py
|
|
26
|
+
get_inputs <- class_utils/abstract_classes.py
|
|
27
|
+
get_logFile <- log_utils/log_file.py
|
|
28
|
+
is_dir <- file_utils/.../classes.py (local-only)
|
|
29
|
+
is_file <- file_utils/.../classes.py (local-only)
|
|
30
|
+
is_number <- type_utils/is_type.py
|
|
31
|
+
join_path <- path_utils/path_utils.py
|
|
32
|
+
lazy_import <- import_utils/.../lazy_utils.py
|
|
33
|
+
make_list <- list_utils/list_utils.py
|
|
34
|
+
makedirs <- directory_utils/directory_utils.py
|
|
35
|
+
read_from_file <- read_write_utils/read_write_utils.py (local-only)
|
|
36
|
+
safe_dump_to_file <- json_utils/json_utils.py
|
|
37
|
+
safe_dump_to_json <- json_utils/json_utils.py
|
|
38
|
+
safe_load_from_json <- json_utils/json_utils.py
|
|
39
|
+
safe_read_from_json <- json_utils/json_utils.py
|
|
40
|
+
write_to_file <- read_write_utils/read_write_utils.py (local-only)
|
|
41
|
+
|
|
42
|
+
Notes on the local-only rewrites
|
|
43
|
+
---------------------------------
|
|
44
|
+
``read_from_file``, ``write_to_file``, ``get_files_and_dirs``, ``is_file`` and
|
|
45
|
+
``is_dir`` originally supported remote (SSH) execution through the
|
|
46
|
+
``abstract_utilities.ssh_utils`` machinery. Since that machinery is exactly the
|
|
47
|
+
"abstract_utilities module imports" we are removing, those code paths have been
|
|
48
|
+
dropped and these functions now operate purely on the local filesystem. They
|
|
49
|
+
still accept ``**kwargs`` so existing call sites that passed remote options will
|
|
50
|
+
not raise; the remote options are simply ignored.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import os
|
|
54
|
+
import sys
|
|
55
|
+
import json
|
|
56
|
+
import logging
|
|
57
|
+
import importlib
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from functools import lru_cache
|
|
60
|
+
from logging.handlers import RotatingFileHandler
|
|
61
|
+
from typing import Optional
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# type / number helpers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
def is_number(s):
|
|
68
|
+
"""Return True if ``s`` can be parsed as a float."""
|
|
69
|
+
try:
|
|
70
|
+
float(s)
|
|
71
|
+
return True
|
|
72
|
+
except Exception:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_list(obj, commaparse=True):
|
|
77
|
+
"""
|
|
78
|
+
Convert ``obj`` to a list.
|
|
79
|
+
|
|
80
|
+
- A comma-containing string is split on commas (unless ``commaparse`` is False).
|
|
81
|
+
- sets and tuples become lists.
|
|
82
|
+
- lists are returned unchanged.
|
|
83
|
+
- anything else is wrapped in a single-element list.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(obj, str):
|
|
86
|
+
if ',' in obj and commaparse is True:
|
|
87
|
+
obj = obj.split(',')
|
|
88
|
+
if isinstance(obj, (set, tuple)):
|
|
89
|
+
return list(obj)
|
|
90
|
+
if isinstance(obj, list):
|
|
91
|
+
return obj
|
|
92
|
+
return [obj]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# string helpers (eatAll + its inner/outer helpers)
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
def eatInner(string, list_objects):
|
|
99
|
+
"""Strip leading characters that appear in ``list_objects``."""
|
|
100
|
+
if not isinstance(list_objects, list):
|
|
101
|
+
list_objects = [list_objects]
|
|
102
|
+
if not isinstance(string, str):
|
|
103
|
+
string = str(string)
|
|
104
|
+
if string and list_objects:
|
|
105
|
+
for char in string:
|
|
106
|
+
if string:
|
|
107
|
+
if char not in list_objects:
|
|
108
|
+
return string
|
|
109
|
+
string = string[1:]
|
|
110
|
+
return string
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def eatOuter(string, list_objects):
|
|
114
|
+
"""Strip trailing characters that appear in ``list_objects``."""
|
|
115
|
+
if not isinstance(list_objects, list):
|
|
116
|
+
list_objects = [list_objects]
|
|
117
|
+
if not isinstance(string, str):
|
|
118
|
+
string = str(string)
|
|
119
|
+
if string and list_objects:
|
|
120
|
+
for _ in range(len(string)):
|
|
121
|
+
if string:
|
|
122
|
+
if string[-1] not in list_objects:
|
|
123
|
+
return string
|
|
124
|
+
string = string[:-1]
|
|
125
|
+
return string
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def eatAll(string, list_objects):
|
|
129
|
+
"""Strip leading and trailing characters that appear in ``list_objects``."""
|
|
130
|
+
if not isinstance(list_objects, list):
|
|
131
|
+
list_objects = [list_objects]
|
|
132
|
+
if not isinstance(string, str):
|
|
133
|
+
string = str(string)
|
|
134
|
+
if string and list_objects:
|
|
135
|
+
string = eatInner(string, list_objects)
|
|
136
|
+
if string and list_objects:
|
|
137
|
+
string = eatOuter(string, list_objects)
|
|
138
|
+
return string
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# basic path predicates / joins (local filesystem only)
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
def is_file(path, *args, **kwargs):
|
|
145
|
+
"""Local-only check: True if ``path`` is an existing file."""
|
|
146
|
+
if path:
|
|
147
|
+
return os.path.isfile(path)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_dir(path, *args, **kwargs):
|
|
152
|
+
"""Local-only check: True if ``path`` is an existing directory."""
|
|
153
|
+
if path:
|
|
154
|
+
return os.path.isdir(path)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def join_path(directory, basename):
|
|
159
|
+
"""Join two path components with ``os.path.join``."""
|
|
160
|
+
return os.path.join(directory, basename)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_files(directory):
|
|
164
|
+
"""Recursively collect every file path under ``directory``."""
|
|
165
|
+
file_list = []
|
|
166
|
+
for root, _dirs, files in os.walk(directory):
|
|
167
|
+
for file in files:
|
|
168
|
+
file_list.append(os.path.join(root, file))
|
|
169
|
+
return file_list
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_directory(directory):
|
|
173
|
+
"""Return ``directory``, creating it (and parents) if it does not exist."""
|
|
174
|
+
if not os.path.isdir(directory):
|
|
175
|
+
os.makedirs(directory, exist_ok=True)
|
|
176
|
+
return directory
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_dirlist(directory):
|
|
180
|
+
"""
|
|
181
|
+
Return the contents of ``directory``.
|
|
182
|
+
|
|
183
|
+
The directory is created if missing. If ``directory`` happens to be a file,
|
|
184
|
+
a single-element list with its basename is returned.
|
|
185
|
+
"""
|
|
186
|
+
path = get_directory(directory)
|
|
187
|
+
if not path:
|
|
188
|
+
return path
|
|
189
|
+
dir_list = []
|
|
190
|
+
if is_dir(path):
|
|
191
|
+
dir_list = os.listdir(path)
|
|
192
|
+
elif is_file(path):
|
|
193
|
+
dir_list = [os.path.basename(path)]
|
|
194
|
+
return dir_list
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# pathlib-aware helpers (get_home_dir / get_file_parts)
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
def is_pathlike(obj):
|
|
201
|
+
return bool(obj) and isinstance(obj, Path)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_pathlib_path(obj):
|
|
205
|
+
if obj and not is_pathlike(obj):
|
|
206
|
+
obj = Path(obj)
|
|
207
|
+
return obj
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_str_path(obj):
|
|
211
|
+
if is_pathlike(obj):
|
|
212
|
+
obj = str(obj)
|
|
213
|
+
return obj
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def return_path(path, isPath=False):
|
|
217
|
+
if isPath:
|
|
218
|
+
path = get_pathlib_path(path)
|
|
219
|
+
else:
|
|
220
|
+
path = get_str_path(path)
|
|
221
|
+
return path
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_home_dir(path=None):
|
|
225
|
+
"""Return the home directory, preserving str/Path type of ``path``."""
|
|
226
|
+
isPath = is_pathlike(path)
|
|
227
|
+
path = path or os.getcwd()
|
|
228
|
+
nupath = get_pathlib_path(path)
|
|
229
|
+
home_path = nupath.home()
|
|
230
|
+
return return_path(home_path, isPath=isPath)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_safe_basename(path=None):
|
|
234
|
+
if path:
|
|
235
|
+
return os.path.basename(str(path))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_safe_dirname(path=None):
|
|
239
|
+
if path:
|
|
240
|
+
isPath = is_pathlike(path)
|
|
241
|
+
dirname = os.path.dirname(str(path))
|
|
242
|
+
return return_path(dirname, isPath=isPath)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_safe_splitext(path=None, basename=None):
|
|
246
|
+
basename = basename or get_safe_basename(path=path)
|
|
247
|
+
if basename:
|
|
248
|
+
filename, ext = os.path.splitext(str(basename))
|
|
249
|
+
return filename, ext
|
|
250
|
+
return None, None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_file_parts(path):
|
|
254
|
+
"""
|
|
255
|
+
Decompose ``path`` into a dict of useful components: basename, filename,
|
|
256
|
+
extension, and the names of the parent / grandparent / great-grandparent
|
|
257
|
+
directories.
|
|
258
|
+
"""
|
|
259
|
+
if path:
|
|
260
|
+
path = str(path)
|
|
261
|
+
basename = get_safe_basename(path)
|
|
262
|
+
filename, ext = get_safe_splitext(basename=basename)
|
|
263
|
+
|
|
264
|
+
dirname = get_safe_dirname(path)
|
|
265
|
+
dirbase = get_safe_basename(dirname)
|
|
266
|
+
|
|
267
|
+
parent_dirname = get_safe_dirname(dirname)
|
|
268
|
+
parent_dirbase = get_safe_basename(parent_dirname)
|
|
269
|
+
|
|
270
|
+
super_dirname = get_safe_dirname(parent_dirname)
|
|
271
|
+
super_dirbase = get_safe_basename(super_dirname)
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"file_path": path,
|
|
275
|
+
"dirname": dirname,
|
|
276
|
+
"basename": basename,
|
|
277
|
+
"filename": filename,
|
|
278
|
+
"ext": ext,
|
|
279
|
+
"dirbase": dirbase,
|
|
280
|
+
"parent_dirname": parent_dirname,
|
|
281
|
+
"parent_dirbase": parent_dirbase,
|
|
282
|
+
"super_dirname": super_dirname,
|
|
283
|
+
"super_dirbase": super_dirbase,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# directory creation (makedirs)
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
def safe_join(*paths):
|
|
291
|
+
"""``os.path.join`` that silently drops falsy components."""
|
|
292
|
+
paths = [path for path in paths if path]
|
|
293
|
+
return os.path.join(*paths)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def raw_create_dirs(*paths):
|
|
297
|
+
"""Recursively create every directory along the given path and return it."""
|
|
298
|
+
full_path = os.path.abspath(safe_join(*paths))
|
|
299
|
+
sub_parts = [p for p in full_path.split(os.sep) if p]
|
|
300
|
+
|
|
301
|
+
current_path = "/" if full_path.startswith(os.sep) else ""
|
|
302
|
+
for part in sub_parts:
|
|
303
|
+
current_path = safe_join(current_path, part)
|
|
304
|
+
os.makedirs(current_path, exist_ok=True)
|
|
305
|
+
return full_path
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ``makedirs`` in the package is just an alias of ``raw_create_dirs``.
|
|
309
|
+
makedirs = raw_create_dirs
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# file collection (get_files_and_dirs) -- local-only rewrite
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
def get_files_and_dirs(*args, recursive=True, include_files=True, **kwargs):
|
|
316
|
+
"""
|
|
317
|
+
Return ``(dirs, files)`` collected from one or more directories.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
*args: directories to scan (defaults to the current working directory).
|
|
321
|
+
recursive: walk subdirectories when True, otherwise only the top level.
|
|
322
|
+
include_files: include files in the result when True.
|
|
323
|
+
|
|
324
|
+
Remote/SSH options accepted by the original implementation are ignored.
|
|
325
|
+
"""
|
|
326
|
+
directories = [a for a in args if a] or [os.getcwd()]
|
|
327
|
+
dirs, files = [], []
|
|
328
|
+
for directory in directories:
|
|
329
|
+
if not os.path.isdir(directory):
|
|
330
|
+
continue
|
|
331
|
+
if recursive:
|
|
332
|
+
for root, dnames, fnames in os.walk(directory):
|
|
333
|
+
for d in dnames:
|
|
334
|
+
dirs.append(os.path.join(root, d))
|
|
335
|
+
if include_files:
|
|
336
|
+
for fn in fnames:
|
|
337
|
+
files.append(os.path.join(root, fn))
|
|
338
|
+
else:
|
|
339
|
+
for name in os.listdir(directory):
|
|
340
|
+
full = os.path.join(directory, name)
|
|
341
|
+
if os.path.isdir(full):
|
|
342
|
+
dirs.append(full)
|
|
343
|
+
elif include_files and os.path.isfile(full):
|
|
344
|
+
files.append(full)
|
|
345
|
+
return dirs, files
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
# read / write (read_from_file / write_to_file) -- local-only rewrite
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
def _write_to_file(contents, file_path, **kwargs):
|
|
352
|
+
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
|
353
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
354
|
+
f.write(str(contents))
|
|
355
|
+
return file_path
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def write_to_file(*, contents, file_path, **kwargs):
|
|
359
|
+
"""Overwrite ``file_path`` with ``contents`` (creating parent dirs)."""
|
|
360
|
+
try:
|
|
361
|
+
return _write_to_file(contents=contents, file_path=file_path, **kwargs)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
print("WRITE ERROR:", e)
|
|
364
|
+
raise RuntimeError(f"Failed writing: {file_path}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def read_from_file(file_path=None, **kwargs):
|
|
368
|
+
"""Read and return the text contents of ``file_path``."""
|
|
369
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
370
|
+
return f.read()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
# JSON utilities
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
import re # noqa: E402 (kept next to the JSON helpers that use it)
|
|
377
|
+
|
|
378
|
+
_json_logger = logging.getLogger(__name__)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def try_json_loads(data):
|
|
382
|
+
try:
|
|
383
|
+
return json.loads(data)
|
|
384
|
+
except Exception:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def safe_json_loads(data):
|
|
389
|
+
if not isinstance(data, dict):
|
|
390
|
+
data = try_json_loads(data) or data
|
|
391
|
+
return data
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def clean_invalid_newlines(json_string, line_replacement_value=''):
|
|
395
|
+
"""Remove newlines that are not inside double-quoted strings."""
|
|
396
|
+
pattern = r'(?<!\\)\n(?!([^"]*"[^"]*")*[^"]*$)'
|
|
397
|
+
return re.sub(pattern, line_replacement_value, json_string)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def read_malformed_json(json_string, line_replacement_value="*n"):
|
|
401
|
+
"""Attempt to parse a (possibly malformed) JSON string after cleaning it."""
|
|
402
|
+
if isinstance(json_string, str):
|
|
403
|
+
json_string = clean_invalid_newlines(json_string, line_replacement_value=line_replacement_value)
|
|
404
|
+
return safe_json_loads(json_string)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def get_value_from_path(json_data, path, line_replacement_value='*n*'):
|
|
408
|
+
"""Traverse a nested JSON object along ``path`` and return the value found."""
|
|
409
|
+
current_data = safe_json_loads(json_data)
|
|
410
|
+
for step in path:
|
|
411
|
+
current_data = safe_json_loads(current_data[step])
|
|
412
|
+
if isinstance(current_data, str):
|
|
413
|
+
current_data = read_malformed_json(current_data, line_replacement_value=line_replacement_value)
|
|
414
|
+
return current_data
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def find_paths_to_key(json_data, key_to_find, line_replacement_value='*n*'):
|
|
418
|
+
"""Return every path (list of keys/indices) that leads to ``key_to_find``."""
|
|
419
|
+
def _search_path(data, current_path):
|
|
420
|
+
if isinstance(data, dict):
|
|
421
|
+
for key, value in data.items():
|
|
422
|
+
new_path = current_path + [key]
|
|
423
|
+
if key == key_to_find:
|
|
424
|
+
paths.append(new_path)
|
|
425
|
+
if isinstance(value, str):
|
|
426
|
+
try:
|
|
427
|
+
nested = read_malformed_json(value, line_replacement_value=line_replacement_value)
|
|
428
|
+
_search_path(nested, new_path)
|
|
429
|
+
except json.JSONDecodeError:
|
|
430
|
+
pass
|
|
431
|
+
_search_path(value, new_path)
|
|
432
|
+
elif isinstance(data, list):
|
|
433
|
+
for index, item in enumerate(data):
|
|
434
|
+
_search_path(item, current_path + [index])
|
|
435
|
+
|
|
436
|
+
paths = []
|
|
437
|
+
_search_path(json_data, [])
|
|
438
|
+
return paths
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_any_value(json_obj, key, line_replacement_value="*n*"):
|
|
442
|
+
"""
|
|
443
|
+
Fetch the value(s) associated with ``key`` from a JSON object, JSON string,
|
|
444
|
+
or a path to a JSON file.
|
|
445
|
+
"""
|
|
446
|
+
if isinstance(json_obj, str):
|
|
447
|
+
if os.path.isfile(json_obj):
|
|
448
|
+
with open(json_obj, 'r', encoding='UTF-8') as f:
|
|
449
|
+
json_obj = f.read()
|
|
450
|
+
json_data = read_malformed_json(json_obj)
|
|
451
|
+
paths_to_value = find_paths_to_key(json_data, key)
|
|
452
|
+
if not isinstance(paths_to_value, list):
|
|
453
|
+
paths_to_value = [paths_to_value]
|
|
454
|
+
for i, path_to_value in enumerate(paths_to_value):
|
|
455
|
+
paths_to_value[i] = get_value_from_path(json_data, path_to_value)
|
|
456
|
+
if isinstance(paths_to_value[i], str):
|
|
457
|
+
paths_to_value[i] = paths_to_value[i].replace(line_replacement_value, '\n')
|
|
458
|
+
if isinstance(paths_to_value, list):
|
|
459
|
+
if len(paths_to_value) == 0:
|
|
460
|
+
paths_to_value = None
|
|
461
|
+
elif len(paths_to_value) == 1:
|
|
462
|
+
paths_to_value = paths_to_value[0]
|
|
463
|
+
return paths_to_value
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def validate_file_path(file_path, is_read=False):
|
|
467
|
+
if file_path and isinstance(file_path, str):
|
|
468
|
+
if os.path.isfile(file_path) or os.path.isdir(file_path):
|
|
469
|
+
return file_path
|
|
470
|
+
if not is_read:
|
|
471
|
+
dirname = os.path.dirname(file_path)
|
|
472
|
+
if os.path.isdir(dirname):
|
|
473
|
+
return file_path
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def get_file_path(*args, is_read=False, **kwargs):
|
|
477
|
+
args = list(args)
|
|
478
|
+
for file_path in args:
|
|
479
|
+
if validate_file_path(file_path, is_read=is_read):
|
|
480
|
+
return file_path
|
|
481
|
+
for file_path in list(kwargs.values()):
|
|
482
|
+
if validate_file_path(file_path, is_read=is_read):
|
|
483
|
+
return file_path
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _write_file(data, file_path):
|
|
487
|
+
with open(file_path, 'w', encoding='utf-8') as file:
|
|
488
|
+
file.write(str(data))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _write_json(data, file_path, ensure_ascii=False, indent=4):
|
|
492
|
+
with open(file_path, 'w', encoding='utf-8') as file:
|
|
493
|
+
json.dump(data, file, ensure_ascii=ensure_ascii, indent=indent)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _safe_write_json(data, file_path, ensure_ascii=False, indent=4):
|
|
497
|
+
if isinstance(data, (dict, list, tuple)):
|
|
498
|
+
_write_json(data, file_path, ensure_ascii=ensure_ascii, indent=indent)
|
|
499
|
+
else:
|
|
500
|
+
_write_file(data, file_path)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _read_json(file_path):
|
|
504
|
+
with open(file_path, 'r', encoding='utf-8') as file:
|
|
505
|
+
return json.load(file)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _output_read_write_error(e, function_name, file_path, valid_file_path=None, data=None, is_read=False):
|
|
509
|
+
error_text = f"Error in {function_name};{e}\nFile path: {file_path} "
|
|
510
|
+
if valid_file_path is None:
|
|
511
|
+
error_text += f"\nValid File path: {valid_file_path} "
|
|
512
|
+
if not is_read:
|
|
513
|
+
error_text += f"\nData: {data} "
|
|
514
|
+
_json_logger.error(error_text)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def safe_dump_to_file(data, file_path=None, ensure_ascii=False, indent=4, *args, **kwargs):
|
|
518
|
+
"""Serialize ``data`` to ``file_path`` (JSON for dict/list/tuple, else text)."""
|
|
519
|
+
is_read = False
|
|
520
|
+
file_args = [file_path, data]
|
|
521
|
+
valid_file_path = get_file_path(*file_args, *args, is_read=is_read, **kwargs)
|
|
522
|
+
|
|
523
|
+
if valid_file_path:
|
|
524
|
+
file_path = valid_file_path
|
|
525
|
+
if file_path == file_args[-1]:
|
|
526
|
+
data = file_args[0]
|
|
527
|
+
if file_path is not None and data is not None:
|
|
528
|
+
try:
|
|
529
|
+
_safe_write_json(data, file_path, ensure_ascii=ensure_ascii, indent=indent)
|
|
530
|
+
except Exception as e:
|
|
531
|
+
_output_read_write_error(e, 'safe_dump_to_file', file_path, valid_file_path, is_read=is_read)
|
|
532
|
+
else:
|
|
533
|
+
_json_logger.error("file_path and data must be provided to safe_dump_to_file")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def safe_read_from_json(file_path, *args, **kwargs):
|
|
537
|
+
"""Read and return JSON content from ``file_path`` (None on failure)."""
|
|
538
|
+
is_read = True
|
|
539
|
+
valid_file_path = get_file_path(file_path, *args, is_read=is_read, **kwargs)
|
|
540
|
+
if valid_file_path:
|
|
541
|
+
file_path = valid_file_path
|
|
542
|
+
try:
|
|
543
|
+
return _read_json(file_path)
|
|
544
|
+
except Exception as e:
|
|
545
|
+
_output_read_write_error(e, 'safe_read_from_json', file_path, valid_file_path, is_read=is_read)
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def safe_load_from_json(*args, **kwargs):
|
|
550
|
+
"""Alias for :func:`safe_read_from_json`."""
|
|
551
|
+
return safe_read_from_json(*args, **kwargs)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def safe_dump_to_json(*args, **kwargs):
|
|
555
|
+
"""Alias for :func:`safe_dump_to_file`."""
|
|
556
|
+
return safe_dump_to_file(*args, **kwargs)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# ---------------------------------------------------------------------------
|
|
560
|
+
# media type detection (derive_media_type)
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
MIME_TYPES = {
|
|
563
|
+
'image': {
|
|
564
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
565
|
+
'.gif': 'image/gif', '.bmp': 'image/bmp', '.tiff': 'image/tiff',
|
|
566
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
567
|
+
'.heic': 'image/heic', '.psd': 'image/vnd.adobe.photoshop',
|
|
568
|
+
'.raw': 'image/x-raw', '.apng': 'image/apng', '.heif': 'image/heif',
|
|
569
|
+
'.jp2': 'image/jp2', '.jxl': 'image/jxl', '.eps': 'application/postscript',
|
|
570
|
+
'.ai': 'application/postscript',
|
|
571
|
+
},
|
|
572
|
+
'video': {
|
|
573
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
|
574
|
+
'.mov': 'video/quicktime', '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska',
|
|
575
|
+
'.flv': 'video/x-flv', '.wmv': 'video/x-ms-wmv', '.3gp': 'video/3gpp',
|
|
576
|
+
'.mpeg': 'video/mpeg', '.mpg': 'video/mpg', '.m4v': 'video/x-m4v',
|
|
577
|
+
'.f4v': 'video/x-f4v', '.asf': 'video/x-ms-asf', '.vob': 'video/dvd',
|
|
578
|
+
'.m2ts': 'video/mp2t', '.mts': 'video/mp2t',
|
|
579
|
+
},
|
|
580
|
+
'audio': {
|
|
581
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac',
|
|
582
|
+
'.aac': 'audio/aac', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4',
|
|
583
|
+
'.opus': 'audio/opus', '.aif': 'audio/x-aiff', '.aiff': 'audio/x-aiff',
|
|
584
|
+
'.amr': 'audio/amr', '.mid': 'audio/midi', '.midi': 'audio/midi',
|
|
585
|
+
'.wma': 'audio/x-ms-wma', '.mka': 'audio/x-matroska',
|
|
586
|
+
},
|
|
587
|
+
'document': {
|
|
588
|
+
'.pdf': 'application/pdf', '.doc': 'application/msword',
|
|
589
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
590
|
+
'.odt': 'application/vnd.oasis.opendocument.text', '.txt': 'text/plain',
|
|
591
|
+
'.rtf': 'application/rtf', '.md': 'text/markdown', '.markdown': 'text/markdown',
|
|
592
|
+
'.tex': 'application/x-tex', '.log': 'text/plain', '.json': 'application/json',
|
|
593
|
+
'.xml': 'application/xml', '.yaml': 'application/x-yaml', '.yml': 'application/x-yaml',
|
|
594
|
+
'.ini': 'text/plain', '.cfg': 'text/plain', '.toml': 'application/toml',
|
|
595
|
+
'.csv': 'text/csv', '.tsv': 'text/tab-separated-values', '.epub': 'application/epub+zip',
|
|
596
|
+
'.mobi': 'application/x-mobipocket-ebook', '.azw': 'application/vnd.amazon.ebook',
|
|
597
|
+
'.pages': 'application/x-iwork-pages-sffpages',
|
|
598
|
+
'.numbers': 'application/x-iwork-numbers-sffnumbers',
|
|
599
|
+
'.key': 'application/x-iwork-keynote-sffkey',
|
|
600
|
+
},
|
|
601
|
+
'presentation': {
|
|
602
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
603
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
604
|
+
'.odp': 'application/vnd.oasis.opendocument.presentation',
|
|
605
|
+
},
|
|
606
|
+
'spreadsheet': {
|
|
607
|
+
'.xls': 'application/vnd.ms-excel',
|
|
608
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
609
|
+
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
|
610
|
+
'.csv': 'text/csv', '.tsv': 'text/tab-separated-values',
|
|
611
|
+
},
|
|
612
|
+
'data_science': {
|
|
613
|
+
'.parquet': 'application/x-parquet', '.avro': 'application/avro',
|
|
614
|
+
'.hdf5': 'application/x-hdf5', '.h5': 'application/x-hdf5',
|
|
615
|
+
'.pickle': 'application/octet-stream', '.npy': 'application/x-npy',
|
|
616
|
+
'.npz': 'application/x-npz', '.ipynb': 'application/x-ipynb+json',
|
|
617
|
+
'.sqlite': 'application/x-sqlite3', '.db': 'application/x-sqlite3',
|
|
618
|
+
},
|
|
619
|
+
'code': {
|
|
620
|
+
'.py': 'text/x-python', '.java': 'text/x-java-source', '.c': 'text/x-c',
|
|
621
|
+
'.cpp': 'text/x-c++', '.h': 'text/x-c', '.hpp': 'text/x-c++',
|
|
622
|
+
'.js': 'application/javascript', '.cjs': 'application/javascript',
|
|
623
|
+
'.mjs': 'application/javascript', '.jsx': 'application/javascript',
|
|
624
|
+
'.ts': 'application/javascript', '.tsx': 'application/typescript',
|
|
625
|
+
'.rb': 'text/x-ruby', '.php': 'application/x-php', '.go': 'text/x-go',
|
|
626
|
+
'.rs': 'text/rust', '.swift': 'text/x-swift', '.kt': 'text/x-kotlin',
|
|
627
|
+
'.sh': 'application/x-shellscript', '.bash': 'application/x-shellscript',
|
|
628
|
+
'.ps1': 'application/x-powershell', '.sql': 'application/sql',
|
|
629
|
+
'.yml': 'application/x-yaml', '.coffee': 'text/coffeescript', '.lua': 'text/x-lua',
|
|
630
|
+
'.css': 'text/css', '.scss': 'text/x-scss', '.sass': 'text/x-sass',
|
|
631
|
+
'.less': 'text/x-less', '.html': 'text/html', '.htm': 'text/html',
|
|
632
|
+
'.vue': 'text/x-vue', '.svelte': 'text/x-svelte', '.graphql': 'application/graphql',
|
|
633
|
+
'.gql': 'application/graphql', '.dockerfile': 'text/x-dockerfile',
|
|
634
|
+
'.makefile': 'text/x-makefile', '.sol': 'text/x-solidity',
|
|
635
|
+
},
|
|
636
|
+
'three_d': {
|
|
637
|
+
'.obj': 'model/obj', '.stl': 'model/stl', '.glb': 'model/gltf-binary',
|
|
638
|
+
'.gltf': 'model/gltf+json', '.fbx': 'application/octet-stream',
|
|
639
|
+
'.usd': 'model/usd', '.usdz': 'model/vnd.usdz+zip', '.blend': 'application/x-blender',
|
|
640
|
+
},
|
|
641
|
+
'archive': {
|
|
642
|
+
'.zip': 'application/zip', '.tar': 'application/x-tar', '.gz': 'application/gzip',
|
|
643
|
+
'.tgz': 'application/gzip', '.bz2': 'application/x-bzip2', '.xz': 'application/x-xz',
|
|
644
|
+
'.rar': 'application/vnd.rar', '.7z': 'application/x-7z-compressed',
|
|
645
|
+
'.iso': 'application/x-iso9660-image', '.dmg': 'application/x-apple-diskimage',
|
|
646
|
+
'.jar': 'application/java-archive', '.war': 'application/java-archive',
|
|
647
|
+
'.whl': 'application/python-wheel', '.egg': 'application/python-egg',
|
|
648
|
+
'.zpaq': 'application/x-zpaq',
|
|
649
|
+
},
|
|
650
|
+
'font': {
|
|
651
|
+
'.ttf': 'font/ttf', '.otf': 'font/otf', '.woff': 'font/woff',
|
|
652
|
+
'.woff2': 'font/woff2', '.eot': 'application/vnd.ms-fontobject',
|
|
653
|
+
},
|
|
654
|
+
'executable': {
|
|
655
|
+
'.exe': 'application/vnd.microsoft.portable-executable',
|
|
656
|
+
'.dll': 'application/vnd.microsoft.portable-executable',
|
|
657
|
+
'.bin': 'application/octet-stream', '.deb': 'application/vnd.debian.binary-package',
|
|
658
|
+
'.rpm': 'application/x-rpm', '.app': 'application/x-executable',
|
|
659
|
+
'.ipa': 'application/x-itunes-ipa', '.apk': 'application/vnd.android.package-archive',
|
|
660
|
+
},
|
|
661
|
+
'geospatial': {
|
|
662
|
+
'.geojson': 'application/geo+json', '.kml': 'application/vnd.google-earth.kml+xml',
|
|
663
|
+
'.kmz': 'application/vnd.google-earth.kmz', '.shp': 'application/x-qgis-main-file',
|
|
664
|
+
'.shx': 'application/x-qgis-shape-index', '.dbf': 'application/x-dbf',
|
|
665
|
+
'.gpkg': 'application/geopackage+sqlite3', '.gpx': 'application/gpx+xml',
|
|
666
|
+
'.tif': 'image/tiff', '.tiff': 'image/tiff', '.osm': 'application/xml',
|
|
667
|
+
'.wkt': 'text/plain',
|
|
668
|
+
},
|
|
669
|
+
'pandas_data': {
|
|
670
|
+
'.parquet': 'application/x-parquet', '.feather': 'application/x-feather',
|
|
671
|
+
'.orc': 'application/x-orc', '.hdf': 'application/x-hdf', '.h5': 'application/x-hdf5',
|
|
672
|
+
'.pickle': 'application/octet-stream', '.pkl': 'application/octet-stream',
|
|
673
|
+
'.msgpack': 'application/x-msgpack', '.stata': 'application/x-stata',
|
|
674
|
+
'.dta': 'application/x-stata', '.sas7bdat': 'application/x-sas-data',
|
|
675
|
+
'.sav': 'application/x-spss-sav',
|
|
676
|
+
},
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
# Category -> set of extensions (equivalent to the package's MEDIA_TYPES).
|
|
680
|
+
MEDIA_TYPES = {category: set(mapping.keys()) for category, mapping in MIME_TYPES.items()}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def derive_media_type(obj):
|
|
684
|
+
"""Return the media category (e.g. 'image', 'video') for a path or extension."""
|
|
685
|
+
ext = os.path.splitext(str(obj))[-1] or obj
|
|
686
|
+
if ext:
|
|
687
|
+
for typ, exts in MEDIA_TYPES.items():
|
|
688
|
+
if ext in exts:
|
|
689
|
+
return typ
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# ---------------------------------------------------------------------------
|
|
693
|
+
# caller introspection (get_caller_dir)
|
|
694
|
+
# ---------------------------------------------------------------------------
|
|
695
|
+
import inspect # noqa: E402 (kept next to the caller helpers that use it)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def get_caller(i=None):
|
|
699
|
+
"""Return the filename of the calling frame ``i`` levels up (default 1)."""
|
|
700
|
+
depth = 1 if i is None else int(i)
|
|
701
|
+
stack = inspect.stack()
|
|
702
|
+
if depth >= len(stack):
|
|
703
|
+
depth = len(stack) - 1
|
|
704
|
+
return stack[depth].filename
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def get_caller_path(i=None):
|
|
708
|
+
"""Return the absolute, real path of the caller's file."""
|
|
709
|
+
depth = 1 if i is None else int(i)
|
|
710
|
+
file_path = get_caller(depth + 1)
|
|
711
|
+
return os.path.realpath(file_path)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def get_caller_dir(i=None):
|
|
715
|
+
"""Return the absolute directory of the caller's file."""
|
|
716
|
+
depth = 1 if i is None else int(i)
|
|
717
|
+
abspath = get_caller_path(depth + 1)
|
|
718
|
+
return os.path.dirname(abspath)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# ---------------------------------------------------------------------------
|
|
722
|
+
# environment values (get_env_value) -- standalone, no abstractEnv class
|
|
723
|
+
# ---------------------------------------------------------------------------
|
|
724
|
+
def _split_eq(line):
|
|
725
|
+
"""Split a ``KEY=VALUE`` line into a cleaned ``[key, value]`` pair."""
|
|
726
|
+
if '=' in line:
|
|
727
|
+
key_side = line.split('=')[0]
|
|
728
|
+
value_side = line[len(key_side + '='):]
|
|
729
|
+
return [eatOuter(key_side, [' ', '', '\t']),
|
|
730
|
+
eatAll(value_side, [' ', '', '\t', '\n'])]
|
|
731
|
+
return [line, None]
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _search_for_env_key(key, path, deep_scan=False):
|
|
735
|
+
"""Return the value for ``key`` within the env file at ``path`` (or None)."""
|
|
736
|
+
highest = [None, 0]
|
|
737
|
+
if path and os.path.isfile(path):
|
|
738
|
+
with open(path, "r") as f:
|
|
739
|
+
for line in f:
|
|
740
|
+
line_key, line_value = _split_eq(line)
|
|
741
|
+
if line_key == key:
|
|
742
|
+
return line_value
|
|
743
|
+
if deep_scan and key:
|
|
744
|
+
key_parts = 0
|
|
745
|
+
for key_part in key.split('_'):
|
|
746
|
+
if key_part and key_part in line_key:
|
|
747
|
+
key_parts += len(key_part)
|
|
748
|
+
if float(key_parts / len(key)) >= 0.5 and key_parts > highest[1]:
|
|
749
|
+
highest = [line_value, key_parts]
|
|
750
|
+
return line_value
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def get_env_value(key=None, path=None, file_name=None, deep_scan=False):
|
|
755
|
+
"""
|
|
756
|
+
Retrieve the value of an environment variable from a ``.env`` style file.
|
|
757
|
+
|
|
758
|
+
Searches, in order: the supplied ``path``, the current working directory,
|
|
759
|
+
the user's home directory, and ``~/.envy_all``.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
key: variable name to look up (defaults to ``MY_PASSWORD``).
|
|
763
|
+
path: a directory to search, or a direct path to an env file.
|
|
764
|
+
file_name: env file name (defaults to ``.env``).
|
|
765
|
+
deep_scan: when True, fall back to fuzzy key matching.
|
|
766
|
+
"""
|
|
767
|
+
key = key or 'MY_PASSWORD'
|
|
768
|
+
file_name = file_name or '.env'
|
|
769
|
+
current_folder = os.getcwd()
|
|
770
|
+
|
|
771
|
+
if path and os.path.isfile(path):
|
|
772
|
+
file_name = os.path.basename(path)
|
|
773
|
+
path = os.path.dirname(path)
|
|
774
|
+
else:
|
|
775
|
+
path = path or current_folder
|
|
776
|
+
|
|
777
|
+
home_folder = os.path.expanduser("~")
|
|
778
|
+
envy_all = os.path.join(home_folder, '.envy_all')
|
|
779
|
+
|
|
780
|
+
directories = []
|
|
781
|
+
for directory in [path, current_folder, home_folder, envy_all]:
|
|
782
|
+
if directory and os.path.isdir(directory) and directory not in directories:
|
|
783
|
+
directories.append(directory)
|
|
784
|
+
|
|
785
|
+
for directory in directories:
|
|
786
|
+
env_path = os.path.join(directory, file_name)
|
|
787
|
+
if os.path.isfile(env_path):
|
|
788
|
+
value = _search_for_env_key(key=key, path=env_path, deep_scan=deep_scan)
|
|
789
|
+
if value:
|
|
790
|
+
return value
|
|
791
|
+
return None
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# ---------------------------------------------------------------------------
|
|
795
|
+
# dataclass construction helper (get_inputs)
|
|
796
|
+
# ---------------------------------------------------------------------------
|
|
797
|
+
def get_inputs(cls, *args, **kwargs):
|
|
798
|
+
"""
|
|
799
|
+
Dynamically construct a (data)class instance from ``args``/``kwargs``,
|
|
800
|
+
filling any missing values from the class's own defaults.
|
|
801
|
+
"""
|
|
802
|
+
fields = list(cls.__annotations__.keys())
|
|
803
|
+
values = {}
|
|
804
|
+
|
|
805
|
+
args = list(args)
|
|
806
|
+
for field in fields:
|
|
807
|
+
if field in kwargs:
|
|
808
|
+
values[field] = kwargs[field]
|
|
809
|
+
elif args:
|
|
810
|
+
values[field] = args.pop(0)
|
|
811
|
+
else:
|
|
812
|
+
values[field] = getattr(cls(), field) # default from the class
|
|
813
|
+
|
|
814
|
+
return cls(**values)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# ---------------------------------------------------------------------------
|
|
818
|
+
# lazy import (lazy_import)
|
|
819
|
+
# ---------------------------------------------------------------------------
|
|
820
|
+
nullProxy_logger = logging.getLogger("abstract.lazy_import")
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class nullProxy:
|
|
824
|
+
"""Safe, chainable, callable placeholder returned for missing modules/attrs."""
|
|
825
|
+
|
|
826
|
+
def __init__(self, name, path=(), fallback=None):
|
|
827
|
+
self._name = name
|
|
828
|
+
self._path = path
|
|
829
|
+
self.fallback = fallback
|
|
830
|
+
|
|
831
|
+
def __getattr__(self, attr):
|
|
832
|
+
return nullProxy(self._name, self._path + (attr,))
|
|
833
|
+
|
|
834
|
+
def __call__(self, *args, **kwargs):
|
|
835
|
+
if self.fallback is not None:
|
|
836
|
+
try:
|
|
837
|
+
return self.fallback(*args, **kwargs)
|
|
838
|
+
except Exception as e:
|
|
839
|
+
nullProxy_logger.info("%s", e)
|
|
840
|
+
nullProxy_logger.warning(
|
|
841
|
+
"[lazy_import] Call to missing module/attr: %s.%s args=%s kwargs=%s",
|
|
842
|
+
self._name, ".".join(self._path), args, kwargs,
|
|
843
|
+
)
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
def __repr__(self):
|
|
847
|
+
full = ".".join((self._name, *self._path))
|
|
848
|
+
return f"<nullProxy {full}>"
|
|
849
|
+
|
|
850
|
+
def __bool__(self):
|
|
851
|
+
return False
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@lru_cache(maxsize=None)
|
|
855
|
+
def lazy_import_single(name, fallback=None):
|
|
856
|
+
"""Import ``name`` safely, returning a :class:`nullProxy` if unavailable."""
|
|
857
|
+
if name in sys.modules:
|
|
858
|
+
return sys.modules[name]
|
|
859
|
+
try:
|
|
860
|
+
return importlib.import_module(name)
|
|
861
|
+
except Exception as e:
|
|
862
|
+
nullProxy_logger.warning("[lazy_import] Failed to import '%s': %s", name, e)
|
|
863
|
+
return nullProxy(name, fallback=fallback)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def get_lazy_attr(module_name, *attrs, fallback=None):
|
|
867
|
+
obj = lazy_import(module_name, fallback=fallback)
|
|
868
|
+
for attr in attrs:
|
|
869
|
+
try:
|
|
870
|
+
obj = getattr(obj, attr)
|
|
871
|
+
except Exception:
|
|
872
|
+
return nullProxy(module_name, attrs, fallback=fallback)
|
|
873
|
+
return obj
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def lazy_import(name, *attrs, fallback=None):
|
|
877
|
+
"""
|
|
878
|
+
Import a module (and optionally walk into ``attrs``) safely.
|
|
879
|
+
|
|
880
|
+
Returns the module/attribute, or a :class:`nullProxy` placeholder if the
|
|
881
|
+
import fails — so the result is always safe to reference.
|
|
882
|
+
"""
|
|
883
|
+
if attrs:
|
|
884
|
+
return get_lazy_attr(name, *attrs, fallback=fallback)
|
|
885
|
+
return lazy_import_single(name, fallback=fallback)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
# ---------------------------------------------------------------------------
|
|
889
|
+
# logging (get_logFile)
|
|
890
|
+
# ---------------------------------------------------------------------------
|
|
891
|
+
_PACKAGE_NAME = "abstract_utilities"
|
|
892
|
+
|
|
893
|
+
LOG_FORMAT = (
|
|
894
|
+
"[%(asctime)s] "
|
|
895
|
+
"%(levelname)-8s "
|
|
896
|
+
"%(name)s:%(lineno)d | "
|
|
897
|
+
"%(message)s "
|
|
898
|
+
"[target=%(target_file)s:%(target_line)s]"
|
|
899
|
+
)
|
|
900
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class LevelFilter(logging.Filter):
|
|
904
|
+
"""Filter that allows selective level enablement/disablement."""
|
|
905
|
+
|
|
906
|
+
def __init__(self):
|
|
907
|
+
super().__init__()
|
|
908
|
+
self.enabled_levels = {
|
|
909
|
+
logging.DEBUG, logging.INFO, logging.WARNING,
|
|
910
|
+
logging.ERROR, logging.CRITICAL,
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
def filter(self, record):
|
|
914
|
+
return record.levelno in self.enabled_levels
|
|
915
|
+
|
|
916
|
+
def enable_level(self, level):
|
|
917
|
+
self.enabled_levels.add(level)
|
|
918
|
+
|
|
919
|
+
def disable_level(self, level):
|
|
920
|
+
self.enabled_levels.discard(level)
|
|
921
|
+
|
|
922
|
+
def is_enabled(self, level):
|
|
923
|
+
return level in self.enabled_levels
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class SafeFormatter(logging.Formatter):
|
|
927
|
+
"""Formatter that tolerates missing ``target_*`` extra fields."""
|
|
928
|
+
|
|
929
|
+
def format(self, record):
|
|
930
|
+
record.target_file = getattr(record, "target_file", "-")
|
|
931
|
+
record.target_line = getattr(record, "target_line", "-")
|
|
932
|
+
return super().format(record)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _resolve_log_root():
|
|
936
|
+
venv = os.getenv("VIRTUAL_ENV") or os.getenv("CONDA_PREFIX")
|
|
937
|
+
if venv:
|
|
938
|
+
p = Path(venv) / ".logs" / _PACKAGE_NAME
|
|
939
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
940
|
+
return p
|
|
941
|
+
|
|
942
|
+
home = Path.home() / ".cache" / _PACKAGE_NAME / "logs"
|
|
943
|
+
try:
|
|
944
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
945
|
+
return home
|
|
946
|
+
except PermissionError:
|
|
947
|
+
pass
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
syslog = Path("/var/log") / _PACKAGE_NAME
|
|
951
|
+
syslog.mkdir(parents=True, exist_ok=True)
|
|
952
|
+
return syslog
|
|
953
|
+
except PermissionError:
|
|
954
|
+
fallback = Path("/tmp") / _PACKAGE_NAME / "logs"
|
|
955
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
956
|
+
return fallback
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
LOG_ROOT = _resolve_log_root()
|
|
960
|
+
|
|
961
|
+
# Filters keyed by logger name for runtime control.
|
|
962
|
+
_level_filters = {}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def get_logFile(name, *, level=logging.INFO, console=True,
|
|
966
|
+
max_bytes=5 * 1024 * 1024, backup_count=5):
|
|
967
|
+
"""
|
|
968
|
+
Return a configured rotating-file logger named ``name``.
|
|
969
|
+
|
|
970
|
+
The logger writes to ``LOG_ROOT/<name>.log`` and (optionally) the console,
|
|
971
|
+
using :class:`SafeFormatter` and a per-logger :class:`LevelFilter`.
|
|
972
|
+
"""
|
|
973
|
+
logger = logging.getLogger(name)
|
|
974
|
+
|
|
975
|
+
# Skip re-initialization if already configured.
|
|
976
|
+
if logger.handlers:
|
|
977
|
+
return logger
|
|
978
|
+
|
|
979
|
+
logger.setLevel(logging.DEBUG) # Logger accepts everything.
|
|
980
|
+
|
|
981
|
+
formatter = SafeFormatter(LOG_FORMAT, DATE_FORMAT)
|
|
982
|
+
|
|
983
|
+
level_filter = LevelFilter()
|
|
984
|
+
if level > logging.DEBUG:
|
|
985
|
+
level_filter.disable_level(logging.DEBUG)
|
|
986
|
+
_level_filters[name] = level_filter
|
|
987
|
+
|
|
988
|
+
try:
|
|
989
|
+
file_handler = RotatingFileHandler(
|
|
990
|
+
LOG_ROOT / f"{name}.log",
|
|
991
|
+
maxBytes=max_bytes,
|
|
992
|
+
backupCount=backup_count,
|
|
993
|
+
encoding="utf-8",
|
|
994
|
+
)
|
|
995
|
+
file_handler.setFormatter(formatter)
|
|
996
|
+
file_handler.setLevel(logging.DEBUG)
|
|
997
|
+
file_handler.addFilter(level_filter)
|
|
998
|
+
logger.addHandler(file_handler)
|
|
999
|
+
except PermissionError:
|
|
1000
|
+
logger.addHandler(logging.NullHandler())
|
|
1001
|
+
|
|
1002
|
+
if console:
|
|
1003
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
1004
|
+
console_handler.setFormatter(formatter)
|
|
1005
|
+
console_handler.setLevel(logging.DEBUG)
|
|
1006
|
+
console_handler.addFilter(level_filter)
|
|
1007
|
+
logger.addHandler(console_handler)
|
|
1008
|
+
|
|
1009
|
+
logger.propagate = False
|
|
1010
|
+
return logger
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
# ---------------------------------------------------------------------------
|
|
1014
|
+
# SingletonMeta (abstract_utilities.SingletonMeta)
|
|
1015
|
+
# ---------------------------------------------------------------------------
|
|
1016
|
+
import threading as _threading # noqa: E402
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
class SingletonMeta(type):
|
|
1020
|
+
"""Thread-safe singleton metaclass: one instance per class."""
|
|
1021
|
+
|
|
1022
|
+
_instances: dict = {}
|
|
1023
|
+
_lock = _threading.Lock()
|
|
1024
|
+
|
|
1025
|
+
def __call__(cls, *args, **kwargs):
|
|
1026
|
+
with cls._lock:
|
|
1027
|
+
if cls not in cls._instances:
|
|
1028
|
+
cls._instances[cls] = super().__call__(*args, **kwargs)
|
|
1029
|
+
return cls._instances[cls]
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# ---------------------------------------------------------------------------
|
|
1033
|
+
# parse_url (abstract_utilities url helper)
|
|
1034
|
+
# ---------------------------------------------------------------------------
|
|
1035
|
+
from urllib.parse import urlparse as _urlparse # noqa: E402
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def parse_url(url):
|
|
1039
|
+
"""Decompose a URL into a dict of components.
|
|
1040
|
+
|
|
1041
|
+
``path`` is returned without its leading slash so ``path.split('/')[0]``
|
|
1042
|
+
yields the first path segment (e.g. the owner in an ``owner/repo`` URL),
|
|
1043
|
+
matching how the original abstract_utilities helper was consumed.
|
|
1044
|
+
"""
|
|
1045
|
+
p = _urlparse(str(url or ""))
|
|
1046
|
+
return {
|
|
1047
|
+
"scheme": p.scheme,
|
|
1048
|
+
"netloc": p.netloc,
|
|
1049
|
+
"host": p.hostname,
|
|
1050
|
+
"port": p.port,
|
|
1051
|
+
"path": (p.path or "").strip("/"),
|
|
1052
|
+
"params": p.params,
|
|
1053
|
+
"query": p.query,
|
|
1054
|
+
"fragment": p.fragment,
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
__all__ = [
|
|
1059
|
+
"derive_media_type",
|
|
1060
|
+
"eatAll",
|
|
1061
|
+
"get_any_value",
|
|
1062
|
+
"get_caller_dir",
|
|
1063
|
+
"get_dirlist",
|
|
1064
|
+
"get_env_value",
|
|
1065
|
+
"get_file_parts",
|
|
1066
|
+
"get_files",
|
|
1067
|
+
"get_files_and_dirs",
|
|
1068
|
+
"get_home_dir",
|
|
1069
|
+
"get_inputs",
|
|
1070
|
+
"get_logFile",
|
|
1071
|
+
"is_dir",
|
|
1072
|
+
"is_file",
|
|
1073
|
+
"is_number",
|
|
1074
|
+
"join_path",
|
|
1075
|
+
"lazy_import",
|
|
1076
|
+
"make_list",
|
|
1077
|
+
"makedirs",
|
|
1078
|
+
"nullProxy",
|
|
1079
|
+
"parse_url",
|
|
1080
|
+
"read_from_file",
|
|
1081
|
+
"safe_dump_to_file",
|
|
1082
|
+
"safe_dump_to_json",
|
|
1083
|
+
"safe_load_from_json",
|
|
1084
|
+
"safe_read_from_json",
|
|
1085
|
+
"SingletonMeta",
|
|
1086
|
+
"write_to_file",
|
|
1087
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abstract_essentials
|
|
3
|
+
Version: 0.0.0.1
|
|
4
|
+
Summary: The lean, dependency-free core of abstract_utilities: stdlib-only helpers (json/path/file/string/list/log) safe to import anywhere, including Termux/phone.
|
|
5
|
+
Author-email: putkoff <partners@abstractendeavors.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AbstractEndeavors/abstract_essentials
|
|
8
|
+
Keywords: utilities,stdlib,helpers,dependency-free
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# abstract_essentials
|
|
19
|
+
|
|
20
|
+
The lean, **dependency-free** core carved out of `abstract_utilities`.
|
|
21
|
+
|
|
22
|
+
Every symbol here depends only on the Python standard library — no third-party
|
|
23
|
+
imports, no `from .imports import *` chains, no submodule fan-out. It imports fast
|
|
24
|
+
and installs anywhere (desktop, server, **Termux/Android phone**), which makes it a
|
|
25
|
+
safe foundation for the rest of the `abstract_*` tree to build on without dragging
|
|
26
|
+
in the monolith.
|
|
27
|
+
|
|
28
|
+
## Why this exists
|
|
29
|
+
|
|
30
|
+
`abstract_utilities` is imported by nearly every `abstract_*` package, so its size
|
|
31
|
+
and entanglement (star-exports across a dozen submodules) ripple everywhere.
|
|
32
|
+
`abstract_essentials` is the ~50-function subset that is actually used in practice,
|
|
33
|
+
extracted as a clean, explicit API.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install abstract_essentials
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from abstract_essentials import make_list, get_any_value, safe_read_from_json, get_logFile
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The public API is whatever is listed in `abstract_essentials.__all__` (53 symbols:
|
|
48
|
+
json/path/file/string/list/type/log helpers).
|
|
49
|
+
|
|
50
|
+
## Migrating off abstract_utilities
|
|
51
|
+
|
|
52
|
+
`abstract_utilities` can become a thin compatibility shim that re-exports from here
|
|
53
|
+
(see `abstract_utilities_compat_shim.py` shipped alongside this scaffold), so existing
|
|
54
|
+
`from abstract_utilities import X` keeps working while new code imports from
|
|
55
|
+
`abstract_essentials`.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT — putkoff / Abstract Endeavors.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.cfg
|
|
4
|
+
src/abstract_essentials/__init__.py
|
|
5
|
+
src/abstract_essentials/utils.py
|
|
6
|
+
src/abstract_essentials.egg-info/PKG-INFO
|
|
7
|
+
src/abstract_essentials.egg-info/SOURCES.txt
|
|
8
|
+
src/abstract_essentials.egg-info/dependency_links.txt
|
|
9
|
+
src/abstract_essentials.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
abstract_essentials
|