crosscompute-macros 0.1.0__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.
- crosscompute_macros-0.1.0/.gitignore +20 -0
- crosscompute_macros-0.1.0/PKG-INFO +62 -0
- crosscompute_macros-0.1.0/README.md +3 -0
- crosscompute_macros-0.1.0/crosscompute_macros/__init__.py +0 -0
- crosscompute_macros-0.1.0/crosscompute_macros/abstract.py +22 -0
- crosscompute_macros-0.1.0/crosscompute_macros/datetime.py +25 -0
- crosscompute_macros-0.1.0/crosscompute_macros/disk.py +202 -0
- crosscompute_macros-0.1.0/crosscompute_macros/error.py +46 -0
- crosscompute_macros-0.1.0/crosscompute_macros/iterable.py +81 -0
- crosscompute_macros-0.1.0/crosscompute_macros/jinja.py +72 -0
- crosscompute_macros-0.1.0/crosscompute_macros/log.py +44 -0
- crosscompute_macros-0.1.0/crosscompute_macros/markdown.py +33 -0
- crosscompute_macros-0.1.0/crosscompute_macros/msgpack.py +19 -0
- crosscompute_macros-0.1.0/crosscompute_macros/package.py +40 -0
- crosscompute_macros-0.1.0/crosscompute_macros/process.py +21 -0
- crosscompute_macros-0.1.0/crosscompute_macros/pydantic.py +2 -0
- crosscompute_macros-0.1.0/crosscompute_macros/random.py +6 -0
- crosscompute_macros-0.1.0/crosscompute_macros/security.py +19 -0
- crosscompute_macros-0.1.0/crosscompute_macros/shapely.py +51 -0
- crosscompute_macros-0.1.0/crosscompute_macros/sqlalchemy.py +107 -0
- crosscompute_macros-0.1.0/crosscompute_macros/starlette.py +46 -0
- crosscompute_macros-0.1.0/crosscompute_macros/text.py +64 -0
- crosscompute_macros-0.1.0/crosscompute_macros/web.py +152 -0
- crosscompute_macros-0.1.0/crosscompute_macros/yaml.py +59 -0
- crosscompute_macros-0.1.0/pyproject.toml +52 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crosscompute-macros
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable classes and functions needed by crosscompute packages
|
|
5
|
+
Project-URL: Repository, https://github.com/crosscompute/crosscompute-macros
|
|
6
|
+
Project-URL: Issues, https://github.com/crosscompute/crosscompute-macros/issues
|
|
7
|
+
Author-email: Roy Hyunjin Han <rhh@crosscompute.com>
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Provides-Extra: all
|
|
14
|
+
Requires-Dist: aiofiles; extra == 'all'
|
|
15
|
+
Requires-Dist: aiofiles>=24.1.0; extra == 'all'
|
|
16
|
+
Requires-Dist: aiohttp[speedups]>=3.12.12; extra == 'all'
|
|
17
|
+
Requires-Dist: argon2-cffi; extra == 'all'
|
|
18
|
+
Requires-Dist: jinja2; extra == 'all'
|
|
19
|
+
Requires-Dist: markdown2; extra == 'all'
|
|
20
|
+
Requires-Dist: msgpack; extra == 'all'
|
|
21
|
+
Requires-Dist: ruamel-yaml; extra == 'all'
|
|
22
|
+
Requires-Dist: ruamel-yaml-bytes; extra == 'all'
|
|
23
|
+
Requires-Dist: shapely; extra == 'all'
|
|
24
|
+
Requires-Dist: sqlalchemy; extra == 'all'
|
|
25
|
+
Requires-Dist: starlette; extra == 'all'
|
|
26
|
+
Provides-Extra: development
|
|
27
|
+
Requires-Dist: pre-commit; extra == 'development'
|
|
28
|
+
Provides-Extra: disk
|
|
29
|
+
Requires-Dist: aiofiles>=24.1.0; extra == 'disk'
|
|
30
|
+
Provides-Extra: iterable
|
|
31
|
+
Provides-Extra: jinja
|
|
32
|
+
Requires-Dist: jinja2; extra == 'jinja'
|
|
33
|
+
Provides-Extra: log
|
|
34
|
+
Provides-Extra: markdown
|
|
35
|
+
Requires-Dist: markdown2; extra == 'markdown'
|
|
36
|
+
Provides-Extra: msgpack
|
|
37
|
+
Requires-Dist: msgpack; extra == 'msgpack'
|
|
38
|
+
Provides-Extra: security
|
|
39
|
+
Requires-Dist: argon2-cffi; extra == 'security'
|
|
40
|
+
Provides-Extra: shapely
|
|
41
|
+
Requires-Dist: aiofiles; extra == 'shapely'
|
|
42
|
+
Requires-Dist: shapely; extra == 'shapely'
|
|
43
|
+
Provides-Extra: sqlalchemy
|
|
44
|
+
Requires-Dist: sqlalchemy; extra == 'sqlalchemy'
|
|
45
|
+
Provides-Extra: starlette
|
|
46
|
+
Requires-Dist: starlette; extra == 'starlette'
|
|
47
|
+
Provides-Extra: test
|
|
48
|
+
Requires-Dist: aiofiles>=24.1.0; extra == 'test'
|
|
49
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
50
|
+
Requires-Dist: pytest-cov; extra == 'test'
|
|
51
|
+
Requires-Dist: pytest-xdist; extra == 'test'
|
|
52
|
+
Provides-Extra: web
|
|
53
|
+
Requires-Dist: aiofiles; extra == 'web'
|
|
54
|
+
Requires-Dist: aiohttp[speedups]>=3.12.12; extra == 'web'
|
|
55
|
+
Provides-Extra: yaml
|
|
56
|
+
Requires-Dist: ruamel-yaml; extra == 'yaml'
|
|
57
|
+
Requires-Dist: ruamel-yaml-bytes; extra == 'yaml'
|
|
58
|
+
Description-Content-Type: text/markdown
|
|
59
|
+
|
|
60
|
+
# CrossCompute Macros
|
|
61
|
+
|
|
62
|
+
Here are reusable functions and classes that are primarily used by the CrossCompute Software Development Kit and CrossCompute Platform.
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class Clay:
|
|
2
|
+
|
|
3
|
+
def __init__(self, instance=None, **kwargs):
|
|
4
|
+
d = self.__dict__
|
|
5
|
+
if instance:
|
|
6
|
+
d.update(instance.__dict__)
|
|
7
|
+
d.update(kwargs)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Mold:
|
|
11
|
+
|
|
12
|
+
def __init__(self, defaults=None):
|
|
13
|
+
self.defaults = defaults or {}
|
|
14
|
+
|
|
15
|
+
def set(self, k, d, f=None):
|
|
16
|
+
v = d.get(k)
|
|
17
|
+
if v is None:
|
|
18
|
+
x = self.defaults[k]
|
|
19
|
+
v = x() if callable(x) else x
|
|
20
|
+
if f:
|
|
21
|
+
v = f(v)
|
|
22
|
+
setattr(self, k, v)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
from datetime import datetime, UTC
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_longstamp(when=None):
|
|
6
|
+
return get_timestamp(when, template='%Y%m%d-%H%M%S-%f')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_datestamp(when=None, template='%Y%m%d'):
|
|
10
|
+
return get_timestamp(when, template)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_timestamp(when=None, template='%Y%m%d-%H%M', tz=None):
|
|
14
|
+
if when is None:
|
|
15
|
+
when = datetime.now(tz=tz)
|
|
16
|
+
return when.strftime(template)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_utc_now():
|
|
20
|
+
return datetime.now(UTC)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_day_count(year):
|
|
24
|
+
# https://stackoverflow.com/a/67395758/192092
|
|
25
|
+
return 365 + calendar.isleap(year)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from os import pathconf
|
|
5
|
+
from os.path import dirname, join, normpath
|
|
6
|
+
|
|
7
|
+
import aiofiles
|
|
8
|
+
import aiofiles.os
|
|
9
|
+
|
|
10
|
+
from .error import DiskError, ParsingError
|
|
11
|
+
from .iterable import LRUDict
|
|
12
|
+
from .random import make_random_string
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileCache(LRUDict):
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args, load, length: int, **kwargs):
|
|
18
|
+
super().__init__(*args, length=length, **kwargs)
|
|
19
|
+
self._load = load
|
|
20
|
+
|
|
21
|
+
async def set(self, path, d):
|
|
22
|
+
t = await get_modification_time(path)
|
|
23
|
+
value = t, d
|
|
24
|
+
super().__setitem__(str(path), value)
|
|
25
|
+
|
|
26
|
+
async def get(self, path):
|
|
27
|
+
path = str(path)
|
|
28
|
+
if path in self:
|
|
29
|
+
old_t, d = super().__getitem__(path)
|
|
30
|
+
new_t = await get_modification_time(path)
|
|
31
|
+
if old_t == new_t:
|
|
32
|
+
return d
|
|
33
|
+
x = await self._load(path)
|
|
34
|
+
await self.set(path, x)
|
|
35
|
+
return x
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def make_random_folder(
|
|
39
|
+
base_folder, name_length=16, with_fixed_length=False,
|
|
40
|
+
length_increment=8, retry_count=3):
|
|
41
|
+
retry_index = 0
|
|
42
|
+
while True:
|
|
43
|
+
name = chop_name(make_random_string(name_length))
|
|
44
|
+
folder = join(base_folder, name)
|
|
45
|
+
try:
|
|
46
|
+
await make_folder(folder, with_existing=False)
|
|
47
|
+
break
|
|
48
|
+
except FileExistsError as e:
|
|
49
|
+
if retry_index < retry_count:
|
|
50
|
+
retry_index += 1
|
|
51
|
+
L.debug(f'folder {retry_index=}', path=base_folder)
|
|
52
|
+
continue
|
|
53
|
+
if with_fixed_length:
|
|
54
|
+
x = (
|
|
55
|
+
'folder is nearing capacity and cannot support more '
|
|
56
|
+
'random folders')
|
|
57
|
+
raise DiskError(x, path=base_folder) from e
|
|
58
|
+
name_length += length_increment
|
|
59
|
+
L.debug(f'folder {name_length=}', path=base_folder)
|
|
60
|
+
return folder
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def make_folder(folder, with_existing=True):
|
|
64
|
+
await aiofiles.os.makedirs(folder, exist_ok=with_existing)
|
|
65
|
+
return folder
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def copy_path(target_path, source_path):
|
|
69
|
+
byte_count = await get_byte_count(source_path)
|
|
70
|
+
await make_folder(target_path.parent)
|
|
71
|
+
async with aiofiles.open(
|
|
72
|
+
target_path, mode='wb',
|
|
73
|
+
) as t, aiofiles.open(
|
|
74
|
+
source_path, mode='rb',
|
|
75
|
+
) as s:
|
|
76
|
+
await aiofiles.os.sendfile(t.fileno(), s.fileno(), 0, byte_count)
|
|
77
|
+
return target_path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def get_byte_count(path):
|
|
81
|
+
s = await aiofiles.os.stat(path)
|
|
82
|
+
return s.st_size
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def save_raw_text(path, text):
|
|
86
|
+
await make_folder(dirname(path))
|
|
87
|
+
async with aiofiles.open(path, mode='wt') as f:
|
|
88
|
+
await f.write(text)
|
|
89
|
+
return path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def load_raw_text(path):
|
|
93
|
+
try:
|
|
94
|
+
async with aiofiles.open(path, mode='rt') as f:
|
|
95
|
+
text = await f.read()
|
|
96
|
+
except OSError as e:
|
|
97
|
+
x = f'path is not accessible; {e}'
|
|
98
|
+
raise DiskError(x, path=path) from e
|
|
99
|
+
return text.rstrip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def save_raw_json(path, dictionary):
|
|
103
|
+
await make_folder(dirname(path))
|
|
104
|
+
async with aiofiles.open(path, mode='wt') as f:
|
|
105
|
+
await f.write(json.dumps(dictionary))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def load_raw_json(path):
|
|
109
|
+
try:
|
|
110
|
+
async with aiofiles.open(path, mode='rt') as f:
|
|
111
|
+
dictionary = json.loads(await f.read())
|
|
112
|
+
except OSError as e:
|
|
113
|
+
x = f'path is not accessible; {e}'
|
|
114
|
+
raise DiskError(x, path=path) from e
|
|
115
|
+
except json.JSONDecodeError as e:
|
|
116
|
+
x = f'file is not valid json; {e}'
|
|
117
|
+
raise ParsingError(x, path=path) from e
|
|
118
|
+
return dictionary
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def update_raw_json(path, dictionary):
|
|
122
|
+
if await is_existing_path(path):
|
|
123
|
+
async with aiofiles.open(path, mode='r+t') as f:
|
|
124
|
+
with suppress(json.JSONDecodeError):
|
|
125
|
+
dictionary = json.loads(await f.read()) | dictionary
|
|
126
|
+
await f.seek(0)
|
|
127
|
+
await f.write(json.dumps(dictionary))
|
|
128
|
+
await f.truncate()
|
|
129
|
+
else:
|
|
130
|
+
await save_raw_json(path, dictionary)
|
|
131
|
+
return dictionary
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def make_soft_link(target_path, source_path):
|
|
135
|
+
await aiofiles.os.symlink(source_path, target_path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def make_hard_link(target_path, source_path):
|
|
139
|
+
await aiofiles.os.link(source_path, target_path)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def get_real_path(path):
|
|
143
|
+
path = await get_absolute_path(path)
|
|
144
|
+
original_path = path
|
|
145
|
+
paths = [path]
|
|
146
|
+
while await is_link_path(path):
|
|
147
|
+
path = await get_absolute_path(join(
|
|
148
|
+
dirname(path), await aiofiles.os.readlink(path)))
|
|
149
|
+
if path in paths:
|
|
150
|
+
x = 'file is a circular symlink'
|
|
151
|
+
raise DiskError(x, path=original_path)
|
|
152
|
+
paths.append(path)
|
|
153
|
+
return path
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def get_absolute_path(path):
|
|
157
|
+
return await aiofiles.os.path.abspath(path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def is_path_in_folder(path, folder):
|
|
161
|
+
try:
|
|
162
|
+
path = await get_real_path(path)
|
|
163
|
+
folder = await get_real_path(folder)
|
|
164
|
+
except DiskError as e:
|
|
165
|
+
L.debug(e)
|
|
166
|
+
return False
|
|
167
|
+
return path.startswith(folder)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def is_contained_path(path):
|
|
171
|
+
folder = '_'
|
|
172
|
+
return normpath(join(folder, path)).startswith(folder)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def chop_name(name):
|
|
176
|
+
parts = []
|
|
177
|
+
name_length = len(name)
|
|
178
|
+
folder_count = name_length // MAXIMUM_FILE_NAME_LENGTH
|
|
179
|
+
for i in range(folder_count):
|
|
180
|
+
a = MAXIMUM_FILE_NAME_LENGTH * i
|
|
181
|
+
b = MAXIMUM_FILE_NAME_LENGTH * (i + 1)
|
|
182
|
+
parts.append(name[a:b])
|
|
183
|
+
parts.append(name[MAXIMUM_FILE_NAME_LENGTH * folder_count:])
|
|
184
|
+
return '/'.join(parts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_folder(path, relative_path):
|
|
188
|
+
return str(path).rsplit(relative_path)[0]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
get_modification_time = aiofiles.os.path.getmtime
|
|
192
|
+
is_existing_path = aiofiles.os.path.exists
|
|
193
|
+
is_file_path = aiofiles.os.path.isfile
|
|
194
|
+
is_folder_path = aiofiles.os.path.isdir
|
|
195
|
+
is_link_path = aiofiles.os.path.islink
|
|
196
|
+
is_same_path = aiofiles.os.path.samefile
|
|
197
|
+
list_paths = aiofiles.os.listdir
|
|
198
|
+
remove_path = aiofiles.os.unlink
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
L = getLogger(__name__)
|
|
202
|
+
MAXIMUM_FILE_NAME_LENGTH = pathconf('/', 'PC_NAME_MAX')
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from .log import redact_path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MacroError(Exception):
|
|
5
|
+
|
|
6
|
+
def __init__(self, *args, **kwargs):
|
|
7
|
+
super().__init__(*args)
|
|
8
|
+
self.__dict__.update(kwargs)
|
|
9
|
+
|
|
10
|
+
def __str__(self):
|
|
11
|
+
texts = [super().__str__()]
|
|
12
|
+
if hasattr(self, 'path'):
|
|
13
|
+
texts.append(f'path="{redact_path(self.path)}"')
|
|
14
|
+
return '; '.join(texts)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DiskError(MacroError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PackageError(MacroError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecurityError(MacroError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebError(MacroError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebConnectionError(WebError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebRequestError(WebError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ParsingError(MacroError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FormattingError(MacroError):
|
|
46
|
+
pass
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import operator
|
|
2
|
+
from collections import OrderedDict, defaultdict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LRUDict(OrderedDict):
|
|
6
|
+
# https://gist.github.com/davesteele/44793cd0348f59f8fadd49d7799bd306
|
|
7
|
+
|
|
8
|
+
def __init__(self, *args, length: int, **kwargs):
|
|
9
|
+
if length <= 0:
|
|
10
|
+
raise ValueError
|
|
11
|
+
self._length = length
|
|
12
|
+
super().__init__(*args, **kwargs)
|
|
13
|
+
|
|
14
|
+
def __setitem__(self, key, value):
|
|
15
|
+
super().__setitem__(key, value)
|
|
16
|
+
super().move_to_end(key)
|
|
17
|
+
while len(self) > self._length:
|
|
18
|
+
super().__delitem__(next(iter(self)))
|
|
19
|
+
|
|
20
|
+
def __getitem__(self, key):
|
|
21
|
+
value = super().__getitem__(key)
|
|
22
|
+
super().move_to_end(key)
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InfiniteDefaultDict(dict):
|
|
27
|
+
|
|
28
|
+
def __getitem__(self, key):
|
|
29
|
+
if key in self:
|
|
30
|
+
value = super().__getitem__(key)
|
|
31
|
+
else:
|
|
32
|
+
value = InfiniteDefaultDict()
|
|
33
|
+
super().__setitem__(key, value)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply_functions(value, function_names, function_by_name):
|
|
38
|
+
for function_name in function_names:
|
|
39
|
+
function_name = function_name.strip()
|
|
40
|
+
if not function_name:
|
|
41
|
+
continue
|
|
42
|
+
f = function_by_name[function_name]
|
|
43
|
+
value = f(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_unique_order(texts):
|
|
48
|
+
return list(dict.fromkeys([_.strip() for _ in texts]))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def group_by_attribute(items, name):
|
|
52
|
+
d = defaultdict(list)
|
|
53
|
+
for item in items:
|
|
54
|
+
d[getattr(item, name)].append(item)
|
|
55
|
+
return dict(d)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_item(
|
|
59
|
+
items, key, value, get_value=lambda item, key: getattr(item, key),
|
|
60
|
+
normalize=lambda _: _, compare=operator.eq):
|
|
61
|
+
normalized_value = normalize(value)
|
|
62
|
+
|
|
63
|
+
def is_match(item):
|
|
64
|
+
try:
|
|
65
|
+
v = get_value(item, key)
|
|
66
|
+
except KeyError:
|
|
67
|
+
is_match = False
|
|
68
|
+
else:
|
|
69
|
+
normalized_v = normalize(v)
|
|
70
|
+
is_match = compare(normalized_value, normalized_v)
|
|
71
|
+
return is_match
|
|
72
|
+
|
|
73
|
+
return next(filter(is_match, items))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def drop_null_values(d):
|
|
77
|
+
keys = list(d.keys())
|
|
78
|
+
for k in keys:
|
|
79
|
+
if d[k] is None:
|
|
80
|
+
del d[k]
|
|
81
|
+
return d
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from os.path import dirname, getmtime, join, normpath, realpath
|
|
2
|
+
from pathlib import PurePath
|
|
3
|
+
from string import Template as StringTemplate
|
|
4
|
+
|
|
5
|
+
from jinja2 import (
|
|
6
|
+
BaseLoader,
|
|
7
|
+
Environment,
|
|
8
|
+
Template as JinjaTemplate,
|
|
9
|
+
TemplateNotFound)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AssetStorage:
|
|
13
|
+
|
|
14
|
+
def __init__(self, folder):
|
|
15
|
+
self.folder = folder
|
|
16
|
+
|
|
17
|
+
def load_raw_text(self, file_name):
|
|
18
|
+
return (self.folder / file_name).read_text().strip()
|
|
19
|
+
|
|
20
|
+
def load_string_text(self, file_name):
|
|
21
|
+
return StringTemplate(self.load_raw_text(file_name))
|
|
22
|
+
|
|
23
|
+
def load_jinja_text(self, file_name):
|
|
24
|
+
return JinjaTemplate(self.load_raw_text(file_name), trim_blocks=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PathTemplateLoader(BaseLoader):
|
|
28
|
+
|
|
29
|
+
def __init__(self, encoding='utf-8'):
|
|
30
|
+
self.encoding = encoding
|
|
31
|
+
|
|
32
|
+
def get_source(self, environment, template): # noqa: ARG002
|
|
33
|
+
'Support absolute template paths.'
|
|
34
|
+
try:
|
|
35
|
+
modification_time = getmtime(template) # noqa: PTH204
|
|
36
|
+
except (OSError, TypeError) as e:
|
|
37
|
+
raise TemplateNotFound(template) from e
|
|
38
|
+
|
|
39
|
+
def is_latest():
|
|
40
|
+
try:
|
|
41
|
+
return modification_time == getmtime(template) # noqa: PTH204
|
|
42
|
+
except OSError:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
with open(template, mode='rt', encoding=self.encoding) as f: # noqa: PTH123
|
|
46
|
+
text = f.read()
|
|
47
|
+
return text, realpath(template), is_latest
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RelativeTemplateEnvironment(Environment):
|
|
51
|
+
|
|
52
|
+
def join_path(self, template, parent):
|
|
53
|
+
'Support relative template paths via extends, import, include.'
|
|
54
|
+
template = get_asset_path(template)
|
|
55
|
+
return normpath(join(dirname( # noqa: PTH118 PTH120
|
|
56
|
+
parent), template)) if template else template
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_asset_path(asset_uri):
|
|
60
|
+
asset_parts = asset_uri.split(':')
|
|
61
|
+
if len(asset_parts) > 1:
|
|
62
|
+
package_name, relative_path = asset_parts
|
|
63
|
+
package_name = package_name.strip()
|
|
64
|
+
else:
|
|
65
|
+
package_name, relative_path = '', asset_parts[0]
|
|
66
|
+
if package_name:
|
|
67
|
+
package = __import__(package_name)
|
|
68
|
+
package_folder = PurePath(package.__file__).parent
|
|
69
|
+
path = str(package_folder / relative_path)
|
|
70
|
+
else:
|
|
71
|
+
path = relative_path
|
|
72
|
+
return path
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from logging import (
|
|
3
|
+
basicConfig,
|
|
4
|
+
getLogger,
|
|
5
|
+
DEBUG,
|
|
6
|
+
INFO)
|
|
7
|
+
from os.path import expanduser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_argument_parser_for_logging(argument_parser):
|
|
11
|
+
argument_parser.add_argument(
|
|
12
|
+
'--debug', dest='with_debug', action='store_true', default=False,
|
|
13
|
+
help='show debugging messages')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def configure_logging_from(args, logging_level_by_package_name):
|
|
17
|
+
with_debug = args.with_debug
|
|
18
|
+
configure_logging(with_debug)
|
|
19
|
+
configure_logging_level_by_package_name(logging_level_by_package_name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configure_logging(with_debug, timestamp='%Y%m%d-%H%M'):
|
|
23
|
+
if with_debug:
|
|
24
|
+
logging_level = DEBUG
|
|
25
|
+
logging_format = (
|
|
26
|
+
'%(asctime)s %(levelname)s %(name)s:%(lineno)s %(message)s')
|
|
27
|
+
else:
|
|
28
|
+
logging_level = INFO
|
|
29
|
+
logging_format = '%(asctime)s %(levelname)s %(message)s'
|
|
30
|
+
basicConfig(format=logging_format, datefmt=timestamp, level=logging_level)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configure_logging_level_by_package_name(logging_level_by_package_name):
|
|
34
|
+
for package_name, logging_level in logging_level_by_package_name.items():
|
|
35
|
+
getLogger(package_name).setLevel(logging_level)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def redact_path(x):
|
|
39
|
+
return re.sub(r'^' + re.escape(expanduser('~')), '~', str(x))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_count_text(message_text, item_count, singular_name, plural_name):
|
|
43
|
+
item_name = singular_name if item_count == 1 else plural_name
|
|
44
|
+
return f'{message_text} {item_count} {item_name}'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from markdown2 import markdown
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
OUTER_PARAGRAPH_PATTERN = re.compile(r'^<p>((?:(?!<p>).)*)</p>$', re.DOTALL)
|
|
7
|
+
EXTRAS = [
|
|
8
|
+
'break-on-newline',
|
|
9
|
+
'code-friendly',
|
|
10
|
+
'cuddled-lists',
|
|
11
|
+
'fenced-code-blocks',
|
|
12
|
+
'footnotes',
|
|
13
|
+
'markdown-in-html',
|
|
14
|
+
'mermaid',
|
|
15
|
+
'strike',
|
|
16
|
+
'target-blank-links',
|
|
17
|
+
'tables',
|
|
18
|
+
'use-file-vars',
|
|
19
|
+
'task_list']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_html_from_markdown(text, extras=EXTRAS):
|
|
23
|
+
return markdown(text, extras=extras).strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def remove_outer_paragraph(html):
|
|
27
|
+
return OUTER_PARAGRAPH_PATTERN.sub(r'\g<1>', html)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def remove_inner_paragraphs(html, a, b):
|
|
31
|
+
html = re.sub(r'<p>(\s*(?:[^<X]*X))'.replace('X', a), r'\g<1>', html)
|
|
32
|
+
html = re.sub(r'((?:X[^>]*)\s*)</p>'.replace('X', b), r'\g<1>', html)
|
|
33
|
+
return html
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import msgpack
|
|
2
|
+
|
|
3
|
+
from .sqlalchemy import EncryptedBinary
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EncryptedMap(EncryptedBinary):
|
|
7
|
+
cache_ok = False
|
|
8
|
+
|
|
9
|
+
def process_bind_param(self, value, dialect):
|
|
10
|
+
if value is not None:
|
|
11
|
+
payload = msgpack.packb(value)
|
|
12
|
+
value = super().process_bind_param(payload, dialect)
|
|
13
|
+
return value
|
|
14
|
+
|
|
15
|
+
def process_result_value(self, value, dialect):
|
|
16
|
+
if value is not None:
|
|
17
|
+
payload = super().process_result_value(value, dialect)
|
|
18
|
+
value = msgpack.unpackb(payload)
|
|
19
|
+
return value
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from packaging.version import InvalidVersion, Version as PackageVersion
|
|
3
|
+
|
|
4
|
+
from .error import PackageError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Version(PackageVersion):
|
|
8
|
+
|
|
9
|
+
def __init__(self, version_text):
|
|
10
|
+
try:
|
|
11
|
+
super().__init__(version_text)
|
|
12
|
+
except InvalidVersion as e:
|
|
13
|
+
x = f'version "{version_text}" is not valid'
|
|
14
|
+
raise PackageError(x) from e
|
|
15
|
+
|
|
16
|
+
def is_equivalent(self, version, depth=None):
|
|
17
|
+
if self.epoch != version.epoch:
|
|
18
|
+
return False
|
|
19
|
+
this_release = self.release
|
|
20
|
+
that_release = version.release
|
|
21
|
+
if depth:
|
|
22
|
+
this_release = this_release[:depth]
|
|
23
|
+
that_release = that_release[:depth]
|
|
24
|
+
return this_release == that_release
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def import_attribute(attribute_string):
|
|
28
|
+
module_string, attribute_name = attribute_string.rsplit('.', maxsplit=1)
|
|
29
|
+
return getattr(import_module(module_string), attribute_name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_newer_version(new_version_text, old_version_text):
|
|
33
|
+
return Version(new_version_text) > Version(old_version_text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_equivalent_version(
|
|
37
|
+
new_version_text, old_version_text, version_depth=None):
|
|
38
|
+
new_version = Version(new_version_text)
|
|
39
|
+
old_version = Version(old_version_text)
|
|
40
|
+
return new_version.is_equivalent(old_version, depth=version_depth)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def run_process(args, cwd=None, env=None, text=None, check=False):
|
|
6
|
+
p = await asyncio.create_subprocess_exec(
|
|
7
|
+
*args,
|
|
8
|
+
cwd=cwd,
|
|
9
|
+
env=env,
|
|
10
|
+
stdout=subprocess.PIPE,
|
|
11
|
+
stderr=subprocess.PIPE)
|
|
12
|
+
return_code = await p.wait()
|
|
13
|
+
if check and return_code:
|
|
14
|
+
stdout = await p.stdout.read()
|
|
15
|
+
stderr = await p.stderr.read()
|
|
16
|
+
raise subprocess.CalledProcessError(
|
|
17
|
+
returncode=return_code,
|
|
18
|
+
cmd=args,
|
|
19
|
+
output=stdout.decode() if text else stdout,
|
|
20
|
+
stderr=stderr.decode() if text else stderr)
|
|
21
|
+
return p
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from argon2 import PasswordHasher
|
|
2
|
+
from argon2.exceptions import InvalidHash, VerifyMismatchError
|
|
3
|
+
|
|
4
|
+
from .error import SecurityError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def verify_text(encoded_hash, candidate_text):
|
|
8
|
+
try:
|
|
9
|
+
password_hasher.verify(encoded_hash, candidate_text)
|
|
10
|
+
except VerifyMismatchError as e:
|
|
11
|
+
x = 'text does not match hash'
|
|
12
|
+
raise SecurityError(x) from e
|
|
13
|
+
except InvalidHash as e:
|
|
14
|
+
x = 'hash is not valid'
|
|
15
|
+
raise SecurityError(x) from e
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
password_hasher = PasswordHasher()
|
|
19
|
+
hash_text = password_hasher.hash
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
from shapely.geometry import GeometryCollection, LineString, Point, shape
|
|
4
|
+
from shapely.ops import split, unary_union
|
|
5
|
+
|
|
6
|
+
from .disk import load_raw_json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def load_geometry_collection(geojson_path):
|
|
10
|
+
d = await load_raw_json(geojson_path)
|
|
11
|
+
return unary_union([shape(_) for _ in d['features']])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_random_points(source_geometry, target_count):
|
|
15
|
+
points = []
|
|
16
|
+
minimum_x, minimum_y, maximum_x, maximum_y = source_geometry.bounds
|
|
17
|
+
while len(points) < target_count:
|
|
18
|
+
# Generate random points inside bounds
|
|
19
|
+
random_points = [Point(
|
|
20
|
+
random.uniform(minimum_x, maximum_x), # noqa: S311
|
|
21
|
+
random.uniform(minimum_y, maximum_y), # noqa: S311
|
|
22
|
+
) for _ in range(target_count)]
|
|
23
|
+
# Retain points inside region
|
|
24
|
+
collection = unary_union(random_points + points)
|
|
25
|
+
intersection = collection.intersection(source_geometry)
|
|
26
|
+
if intersection.geom_type == 'Point':
|
|
27
|
+
points = [intersection]
|
|
28
|
+
else:
|
|
29
|
+
points = list(intersection.geoms)
|
|
30
|
+
# Trim if there are too many
|
|
31
|
+
return points[:target_count]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def slice_geometry(source_geometry, target_x_count=1, target_y_count=1):
|
|
35
|
+
minimum_x, minimum_y, maximum_x, maximum_y = source_geometry.bounds
|
|
36
|
+
dx = (maximum_x - minimum_x) / target_x_count
|
|
37
|
+
dy = (maximum_y - minimum_y) / target_y_count
|
|
38
|
+
x_lines = [LineString([
|
|
39
|
+
(minimum_x + i * dx, minimum_y),
|
|
40
|
+
(minimum_x + i * dx, maximum_y),
|
|
41
|
+
]) for i in range(1, target_x_count)]
|
|
42
|
+
y_lines = [LineString([
|
|
43
|
+
(minimum_x, minimum_y + i * dy),
|
|
44
|
+
(maximum_x, minimum_y + i * dy),
|
|
45
|
+
]) for i in range(1, target_y_count)]
|
|
46
|
+
geometry_collection = source_geometry
|
|
47
|
+
for line in x_lines + y_lines:
|
|
48
|
+
geometry_collection = split(geometry_collection, line)
|
|
49
|
+
if geometry_collection.geom_type != 'GeometryCollection':
|
|
50
|
+
geometry_collection = GeometryCollection(geometry_collection)
|
|
51
|
+
return geometry_collection
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from datetime import UTC
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
5
|
+
from sqlalchemy.ext.mutable import Mutable
|
|
6
|
+
from sqlalchemy.types import DateTime, LargeBinary, String, TypeDecorator
|
|
7
|
+
|
|
8
|
+
from .security import hash_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MutableMap(Mutable, dict):
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def coerce(cls, key, value):
|
|
15
|
+
if not isinstance(value, MutableMap):
|
|
16
|
+
if isinstance(value, dict):
|
|
17
|
+
return MutableMap(value)
|
|
18
|
+
return Mutable.coerce(key, value)
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
def __setitem__(self, key, value):
|
|
22
|
+
dict.__setitem__(self, key, value)
|
|
23
|
+
self.changed()
|
|
24
|
+
|
|
25
|
+
def __delitem__(self, key):
|
|
26
|
+
dict.__delitem__(self, key)
|
|
27
|
+
self.changed()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EncryptedBinary(TypeDecorator):
|
|
31
|
+
impl = LargeBinary
|
|
32
|
+
cache_ok = False
|
|
33
|
+
context = None
|
|
34
|
+
|
|
35
|
+
def process_bind_param(self, value, dialect):
|
|
36
|
+
if value is not None:
|
|
37
|
+
value = self.context.encrypt(value)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
def process_result_value(self, value, dialect):
|
|
41
|
+
if value is not None:
|
|
42
|
+
value = self.context.decrypt(value)
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EncryptedString(TypeDecorator):
|
|
47
|
+
impl = String
|
|
48
|
+
cache_ok = False
|
|
49
|
+
encoding = 'utf-8'
|
|
50
|
+
context = None
|
|
51
|
+
|
|
52
|
+
def process_bind_param(self, value, dialect):
|
|
53
|
+
if value is not None:
|
|
54
|
+
encoded_value = bytes(value, encoding=self.encoding)
|
|
55
|
+
value = self.context.encrypt(encoded_value)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
def process_result_value(self, value, dialect):
|
|
59
|
+
if value is not None:
|
|
60
|
+
encoded_value = self.context.decrypt(value)
|
|
61
|
+
value = bytes.decode(encoded_value, encoding=self.encoding)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HashedString(TypeDecorator):
|
|
66
|
+
impl = String
|
|
67
|
+
cache_ok = False
|
|
68
|
+
|
|
69
|
+
def process_bind_param(self, value, dialect):
|
|
70
|
+
if value is not None:
|
|
71
|
+
value = hash_text(value)
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class UTCDateTime(TypeDecorator):
|
|
76
|
+
impl = DateTime
|
|
77
|
+
cache_ok = True
|
|
78
|
+
|
|
79
|
+
def process_bind_param(self, value, dialect):
|
|
80
|
+
if value is not None:
|
|
81
|
+
value = value.astimezone(UTC).replace(tzinfo=None)
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
def process_result_value(self, value, dialect):
|
|
85
|
+
if value is not None:
|
|
86
|
+
value = value.replace(tzinfo=UTC)
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_database_engine(database_uri):
|
|
91
|
+
if database_uri.startswith('sqlite') and not database_uri.endswith('://'):
|
|
92
|
+
database_path = Path(database_uri.split(':///', maxsplit=1)[1])
|
|
93
|
+
database_folder = database_path.parent
|
|
94
|
+
database_folder.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
return create_async_engine(database_uri)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def define_get_database_session(database_engine):
|
|
99
|
+
return async_sessionmaker(
|
|
100
|
+
database_engine,
|
|
101
|
+
autoflush=False,
|
|
102
|
+
expire_on_commit=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def make_tables(database_engine, database_metadata):
|
|
106
|
+
async with database_engine.begin() as database_connection:
|
|
107
|
+
await database_connection.run_sync(database_metadata.create_all)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
|
|
5
|
+
from starlette.templating import Jinja2Templates
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TemplateResponseFactory(Jinja2Templates):
|
|
9
|
+
|
|
10
|
+
def __init__(self, environment, context_processors=None):
|
|
11
|
+
'Assume nothing about the template environment.'
|
|
12
|
+
self.env = environment
|
|
13
|
+
self.context_processors = context_processors or []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def yield_map_while_connected(websocket, timeout_in_seconds=1):
|
|
17
|
+
async for x in yield_packet_while_connected(websocket, timeout_in_seconds):
|
|
18
|
+
if x and 'text' in x:
|
|
19
|
+
try:
|
|
20
|
+
x = json.loads(x['text'])
|
|
21
|
+
except json.JSONDecodeError:
|
|
22
|
+
x = {}
|
|
23
|
+
else:
|
|
24
|
+
if not isinstance(x, dict):
|
|
25
|
+
x = {}
|
|
26
|
+
else:
|
|
27
|
+
x = {}
|
|
28
|
+
yield x
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def yield_packet_while_connected(websocket, timeout_in_seconds=1):
|
|
32
|
+
while True:
|
|
33
|
+
try:
|
|
34
|
+
packet = await asyncio.wait_for(
|
|
35
|
+
websocket.receive(), timeout=timeout_in_seconds)
|
|
36
|
+
except TimeoutError:
|
|
37
|
+
yield
|
|
38
|
+
except RuntimeError:
|
|
39
|
+
break
|
|
40
|
+
else:
|
|
41
|
+
if packet['type'] == 'websocket.disconnect':
|
|
42
|
+
break
|
|
43
|
+
yield packet
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
L = getLogger(__name__)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_name(text):
|
|
5
|
+
return normalize_key(
|
|
6
|
+
text,
|
|
7
|
+
word_separator=' ',
|
|
8
|
+
separate_camel_case=True,
|
|
9
|
+
separate_letter_digit=True,
|
|
10
|
+
process_text=title_conservatively)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_slug(text):
|
|
14
|
+
return normalize_key(
|
|
15
|
+
text,
|
|
16
|
+
word_separator='-')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_key(
|
|
20
|
+
key,
|
|
21
|
+
word_separator=' ',
|
|
22
|
+
separate_camel_case=False,
|
|
23
|
+
separate_letter_digit=False,
|
|
24
|
+
process_text=str.lower):
|
|
25
|
+
'''Normalize key using a variation of the method described in http://stackoverflow.com/a/1176023/192092.
|
|
26
|
+
|
|
27
|
+
ONETwo one two
|
|
28
|
+
OneTwo one two
|
|
29
|
+
one-two one two
|
|
30
|
+
one_two one two
|
|
31
|
+
one2 one 2
|
|
32
|
+
1two 1 two
|
|
33
|
+
'''
|
|
34
|
+
if separate_camel_case:
|
|
35
|
+
key = UPPER_LOWER_PATTERN.sub(r'\1 \2', key)
|
|
36
|
+
key = LOWER_UPPER_PATTERN.sub(r'\1 \2', key)
|
|
37
|
+
if separate_letter_digit:
|
|
38
|
+
key = LETTER_DIGIT_PATTERN.sub(r'\1 \2', key)
|
|
39
|
+
key = DIGIT_LETTER_PATTERN.sub(r'\1 \2', key)
|
|
40
|
+
word_separators = [r'\W_']
|
|
41
|
+
if word_separator not in word_separators:
|
|
42
|
+
word_separators.append(word_separator)
|
|
43
|
+
word_separator_expression = '[' + ''.join(word_separators) + ']'
|
|
44
|
+
word_separator_pattern = re.compile(word_separator_expression)
|
|
45
|
+
key = word_separator_pattern.sub(' ', key)
|
|
46
|
+
key = compact_whitespace(key)
|
|
47
|
+
key = key.replace(' ', word_separator)
|
|
48
|
+
return process_text(key)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compact_whitespace(string):
|
|
52
|
+
return WHITESPACE_PATTERN.sub(' ', string).strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def title_conservatively(text):
|
|
56
|
+
'Title camelCase as CamelCase.'
|
|
57
|
+
return ' '.join(_[0].upper() + _[1:] for _ in text.split(' '))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
UPPER_LOWER_PATTERN = re.compile(r'(.)([A-Z][a-z]+)')
|
|
61
|
+
LOWER_UPPER_PATTERN = re.compile(r'([a-z0-9])([A-Z])')
|
|
62
|
+
LETTER_DIGIT_PATTERN = re.compile(r'([A-Za-z])([0-9])')
|
|
63
|
+
DIGIT_LETTER_PATTERN = re.compile(r'([0-9])([A-Za-z])')
|
|
64
|
+
WHITESPACE_PATTERN = re.compile(r'\s+', re.MULTILINE)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from functools import partial
|
|
4
|
+
from os.path import dirname
|
|
5
|
+
from random import randint
|
|
6
|
+
|
|
7
|
+
import aiofiles
|
|
8
|
+
from aiohttp import request
|
|
9
|
+
from aiohttp.client_exceptions import ClientError
|
|
10
|
+
|
|
11
|
+
from .disk import (
|
|
12
|
+
make_folder)
|
|
13
|
+
from .error import (
|
|
14
|
+
DiskError,
|
|
15
|
+
WebConnectionError,
|
|
16
|
+
WebRequestError)
|
|
17
|
+
from .iterable import (
|
|
18
|
+
drop_null_values)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def upload(
|
|
22
|
+
target_uri, source_path, client_session=None, chunk_size=1024 * 1024,
|
|
23
|
+
method='PUT', headers=None, params=None):
|
|
24
|
+
fetch = _get_fetch(client_session, method)
|
|
25
|
+
if headers:
|
|
26
|
+
drop_null_values(headers)
|
|
27
|
+
try:
|
|
28
|
+
async with aiofiles.open(source_path, mode='rb') as f:
|
|
29
|
+
async def yield_chunk(chunk_size):
|
|
30
|
+
while (chunk := await f.read(chunk_size)):
|
|
31
|
+
yield chunk
|
|
32
|
+
async with fetch(
|
|
33
|
+
url=target_uri,
|
|
34
|
+
data=yield_chunk(chunk_size),
|
|
35
|
+
headers=headers,
|
|
36
|
+
params=params,
|
|
37
|
+
) as response:
|
|
38
|
+
response_status = response.status
|
|
39
|
+
response_text = await response.text()
|
|
40
|
+
if response_status != 200:
|
|
41
|
+
raise WebRequestError(
|
|
42
|
+
response_text, uri=target_uri, code=response_status)
|
|
43
|
+
except OSError as e:
|
|
44
|
+
x = 'path is not accessible'
|
|
45
|
+
raise DiskError(x, path=source_path) from e
|
|
46
|
+
except ClientError as e:
|
|
47
|
+
raise WebConnectionError(e, uri=target_uri) from e
|
|
48
|
+
return response_text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def download(
|
|
52
|
+
target_path, source_uri, client_session=None, chunk_size=1024 * 1024,
|
|
53
|
+
method='GET', headers=None, params=None):
|
|
54
|
+
fetch = _get_fetch(client_session, method)
|
|
55
|
+
if headers:
|
|
56
|
+
drop_null_values(headers)
|
|
57
|
+
try:
|
|
58
|
+
async with fetch(
|
|
59
|
+
url=source_uri, headers=headers, params=params,
|
|
60
|
+
) as response:
|
|
61
|
+
response_status = response.status
|
|
62
|
+
if response_status != 200:
|
|
63
|
+
raise WebRequestError(
|
|
64
|
+
await response.text(), uri=source_uri,
|
|
65
|
+
code=response_status)
|
|
66
|
+
await make_folder(dirname(target_path))
|
|
67
|
+
async with aiofiles.open(target_path, mode='wb') as f:
|
|
68
|
+
async for chunk in response.content.iter_chunked(chunk_size):
|
|
69
|
+
await f.write(chunk)
|
|
70
|
+
except ClientError as e:
|
|
71
|
+
raise WebConnectionError(e, uri=source_uri) from e
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def make_error(Error, message_text, response=None, error=None):
|
|
75
|
+
error_texts = [message_text]
|
|
76
|
+
kwargs = {}
|
|
77
|
+
if response:
|
|
78
|
+
response_text = (await response.text()).strip()
|
|
79
|
+
if response_text:
|
|
80
|
+
error_texts.append(response_text)
|
|
81
|
+
kwargs['uri'] = response.url
|
|
82
|
+
kwargs['code'] = response.status
|
|
83
|
+
elif error:
|
|
84
|
+
error_text = str(error).strip()
|
|
85
|
+
if error_text:
|
|
86
|
+
error_texts.append(error_text)
|
|
87
|
+
kwargs['uri'] = error.uri
|
|
88
|
+
if hasattr(error, 'code'):
|
|
89
|
+
kwargs['code'] = error.code
|
|
90
|
+
return Error('; '.join(error_texts), **kwargs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def escape_quotes_html(x):
|
|
94
|
+
with suppress(AttributeError):
|
|
95
|
+
x = x.replace('"', '"').replace("'", ''')
|
|
96
|
+
return x
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def escape_quotes_js(x):
|
|
100
|
+
with suppress(AttributeError):
|
|
101
|
+
x = x.replace('"', '\\"').replace("'", "\\'")
|
|
102
|
+
return x
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_open_port(
|
|
106
|
+
default_port=None,
|
|
107
|
+
minimum_port=1024,
|
|
108
|
+
maximum_port=65535):
|
|
109
|
+
|
|
110
|
+
def get_new_port():
|
|
111
|
+
return randint(minimum_port, maximum_port) # noqa: S311
|
|
112
|
+
|
|
113
|
+
port = default_port or get_new_port()
|
|
114
|
+
port_count = maximum_port - minimum_port + 1
|
|
115
|
+
closed_ports = set()
|
|
116
|
+
while True:
|
|
117
|
+
if not is_port_in_use(port):
|
|
118
|
+
break
|
|
119
|
+
closed_ports.add(port)
|
|
120
|
+
if len(closed_ports) == port_count:
|
|
121
|
+
x = (
|
|
122
|
+
'could not find an open port in '
|
|
123
|
+
f'[{minimum_port}, {maximum_port}]')
|
|
124
|
+
raise OSError(x)
|
|
125
|
+
port = get_new_port()
|
|
126
|
+
return port
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_port_in_use(port):
|
|
130
|
+
# https://stackoverflow.com/a/52872579
|
|
131
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
132
|
+
is_in_use = s.connect_ex(('127.0.0.1', int(port))) == 0
|
|
133
|
+
return is_in_use
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def replace_localhost(netloc):
|
|
137
|
+
domain_name = netloc.split(':')[0]
|
|
138
|
+
if domain_name == 'localhost':
|
|
139
|
+
netloc = netloc.replace('localhost', '127.0.0.1')
|
|
140
|
+
return netloc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_fetch(client_session, method_name):
|
|
144
|
+
method_name = method_name.lower()
|
|
145
|
+
if client_session:
|
|
146
|
+
fetch = getattr(client_session, method_name)
|
|
147
|
+
else:
|
|
148
|
+
fetch = partial(request, method=method_name)
|
|
149
|
+
return fetch
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ruff: noqa: PLR2004
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import aiofiles
|
|
2
|
+
from ruamel.yaml import YAML
|
|
3
|
+
from ruamel.yaml.error import YAMLError
|
|
4
|
+
|
|
5
|
+
from .error import DiskError, FormattingError, ParsingError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def load_raw_yaml(path, with_comments=False):
|
|
9
|
+
yaml = YAML(typ='rt' if with_comments else 'safe')
|
|
10
|
+
try:
|
|
11
|
+
async with aiofiles.open(path, mode='rt') as f:
|
|
12
|
+
dictionary = yaml.load(await f.read())
|
|
13
|
+
except OSError as e:
|
|
14
|
+
x = f'path is not accessible; {e}'
|
|
15
|
+
raise DiskError(x, path=path) from e
|
|
16
|
+
except YAMLError as e:
|
|
17
|
+
x = f'file is not yaml; {e}'
|
|
18
|
+
raise ParsingError(x, path=path) from e
|
|
19
|
+
return dictionary or {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def save_raw_yaml(path, x, with_comments=False):
|
|
23
|
+
yaml = YAML(typ=['rt' if with_comments else 'safe', 'bytes'])
|
|
24
|
+
try:
|
|
25
|
+
async with aiofiles.open(path, mode='wb') as f:
|
|
26
|
+
await f.write(yaml.dump_to_bytes(x))
|
|
27
|
+
except OSError as e:
|
|
28
|
+
x = f'path is not accessible; {e}'
|
|
29
|
+
raise DiskError(x, path=path) from e
|
|
30
|
+
except YAMLError as e:
|
|
31
|
+
x = f'value cannot be yaml; {e}'
|
|
32
|
+
raise FormattingError(x, path=path) from e
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sync_load_raw_yaml(path, with_comments=False):
|
|
36
|
+
yaml = YAML(typ='rt' if with_comments else 'safe')
|
|
37
|
+
try:
|
|
38
|
+
with path.open(mode='rt') as f:
|
|
39
|
+
dictionary = yaml.load(f.read())
|
|
40
|
+
except OSError as e:
|
|
41
|
+
x = f'path is not accessible; {e}'
|
|
42
|
+
raise DiskError(x, path=path) from e
|
|
43
|
+
except YAMLError as e:
|
|
44
|
+
x = f'file is not valid yaml; {e}'
|
|
45
|
+
raise ParsingError(x, path=path) from e
|
|
46
|
+
return dictionary or {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sync_save_raw_yaml(path, x, with_comments=False):
|
|
50
|
+
yaml = YAML(typ=['rt' if with_comments else 'safe', 'bytes'])
|
|
51
|
+
try:
|
|
52
|
+
with path.open(mode='wb') as f:
|
|
53
|
+
f.write(yaml.dump_to_bytes(x))
|
|
54
|
+
except OSError as e:
|
|
55
|
+
x = f'path is not accessible; {e}'
|
|
56
|
+
raise DiskError(x, path=path) from e
|
|
57
|
+
except YAMLError as e:
|
|
58
|
+
x = f'value cannot be yaml; {e}'
|
|
59
|
+
raise FormattingError(x, path=path) from e
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crosscompute-macros"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
dependencies = []
|
|
9
|
+
requires-python = ">= 3.10"
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "Roy Hyunjin Han", email = "rhh@crosscompute.com"},
|
|
12
|
+
]
|
|
13
|
+
maintainers = []
|
|
14
|
+
description = "Reusable classes and functions needed by crosscompute packages"
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
keywords = []
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
disk = ["aiofiles >= 24.1.0"]
|
|
26
|
+
iterable = []
|
|
27
|
+
jinja = ["jinja2"]
|
|
28
|
+
log = []
|
|
29
|
+
markdown = ["markdown2"]
|
|
30
|
+
msgpack = ["msgpack"]
|
|
31
|
+
security = ["argon2-cffi"]
|
|
32
|
+
shapely = ["aiofiles", "shapely"]
|
|
33
|
+
sqlalchemy = ["sqlalchemy"]
|
|
34
|
+
starlette = ["starlette"]
|
|
35
|
+
web = ["aiofiles", "aiohttp[speedups] >= 3.12.12"]
|
|
36
|
+
yaml = ["ruamel.yaml", "ruamel.yaml.bytes"]
|
|
37
|
+
development = ["pre-commit"]
|
|
38
|
+
test = ["crosscompute_macros[disk]", "pytest-asyncio", "pytest-cov", "pytest-xdist"]
|
|
39
|
+
all = ["crosscompute_macros[disk,iterable,jinja,log,markdown,msgpack,security,shapely,sqlalchemy,starlette,web,yaml]"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
# Homepage = ""
|
|
43
|
+
# Documentation = ""
|
|
44
|
+
Repository = "https://github.com/crosscompute/crosscompute-macros"
|
|
45
|
+
Issues = "https://github.com/crosscompute/crosscompute-macros/issues"
|
|
46
|
+
# Changelog = ""
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build]
|
|
49
|
+
packages = ["sources/crosscompute_macros"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
asyncio_default_fixture_loop_scope = "session"
|