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.
Files changed (25) hide show
  1. crosscompute_macros-0.1.0/.gitignore +20 -0
  2. crosscompute_macros-0.1.0/PKG-INFO +62 -0
  3. crosscompute_macros-0.1.0/README.md +3 -0
  4. crosscompute_macros-0.1.0/crosscompute_macros/__init__.py +0 -0
  5. crosscompute_macros-0.1.0/crosscompute_macros/abstract.py +22 -0
  6. crosscompute_macros-0.1.0/crosscompute_macros/datetime.py +25 -0
  7. crosscompute_macros-0.1.0/crosscompute_macros/disk.py +202 -0
  8. crosscompute_macros-0.1.0/crosscompute_macros/error.py +46 -0
  9. crosscompute_macros-0.1.0/crosscompute_macros/iterable.py +81 -0
  10. crosscompute_macros-0.1.0/crosscompute_macros/jinja.py +72 -0
  11. crosscompute_macros-0.1.0/crosscompute_macros/log.py +44 -0
  12. crosscompute_macros-0.1.0/crosscompute_macros/markdown.py +33 -0
  13. crosscompute_macros-0.1.0/crosscompute_macros/msgpack.py +19 -0
  14. crosscompute_macros-0.1.0/crosscompute_macros/package.py +40 -0
  15. crosscompute_macros-0.1.0/crosscompute_macros/process.py +21 -0
  16. crosscompute_macros-0.1.0/crosscompute_macros/pydantic.py +2 -0
  17. crosscompute_macros-0.1.0/crosscompute_macros/random.py +6 -0
  18. crosscompute_macros-0.1.0/crosscompute_macros/security.py +19 -0
  19. crosscompute_macros-0.1.0/crosscompute_macros/shapely.py +51 -0
  20. crosscompute_macros-0.1.0/crosscompute_macros/sqlalchemy.py +107 -0
  21. crosscompute_macros-0.1.0/crosscompute_macros/starlette.py +46 -0
  22. crosscompute_macros-0.1.0/crosscompute_macros/text.py +64 -0
  23. crosscompute_macros-0.1.0/crosscompute_macros/web.py +152 -0
  24. crosscompute_macros-0.1.0/crosscompute_macros/yaml.py +59 -0
  25. crosscompute_macros-0.1.0/pyproject.toml +52 -0
@@ -0,0 +1,20 @@
1
+ # vim
2
+ *.sw[op]
3
+ *~
4
+ # python
5
+ *.egg*
6
+ *.py[co]
7
+ .cache
8
+ .coverage
9
+ .ipynb_checkpoints/
10
+ .pytest_cache
11
+ .tox
12
+ __pycache__
13
+ build/
14
+ dist/
15
+ # transient
16
+ *.log
17
+ *.tmp
18
+ *_data
19
+ .DS_Store
20
+ .~lock.*
@@ -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.
@@ -0,0 +1,3 @@
1
+ # CrossCompute Macros
2
+
3
+ Here are reusable functions and classes that are primarily used by the CrossCompute Software Development Kit and CrossCompute Platform.
@@ -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,2 @@
1
+ def get_schema_map(schema):
2
+ return schema.model_dump(mode='json', exclude_defaults=True)
@@ -0,0 +1,6 @@
1
+ import secrets
2
+ from string import ascii_letters, digits
3
+
4
+
5
+ def make_random_string(length, alphabet=ascii_letters + digits):
6
+ return ''.join(secrets.choice(alphabet) for _ in range(length))
@@ -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('"', '&#34;').replace("'", '&#39;')
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"