python-corekit 0.1.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- corekit/api/__init__.py +18 -3
- corekit/api/application.py +275 -0
- corekit/api/lifespan.py +233 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +74 -65
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +33 -4
- corekit/connections/sql/connection.py +56 -3
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +9 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +135 -44
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +111 -0
- corekit/connections/sql/operations/statements.py +170 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +33 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/__init__.py +8 -0
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +142 -123
- corekit/data/expressions/expression.py +71 -98
- corekit/data/expressions/operator.py +39 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +162 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +38 -9
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +46 -44
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +27 -13
- corekit/exceptions/__init__.py +33 -0
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +51 -0
- corekit/http/api.py +24 -0
- corekit/http/client.py +100 -73
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +80 -0
- corekit/jobs/task.py +173 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -3
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +188 -0
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +12 -7
- corekit/registry/ordered.py +86 -0
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +70 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +45 -40
- corekit/serialization/__init__.py +24 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +10 -3
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +124 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +112 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
- python_corekit-0.3.0.dist-info/RECORD +145 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.1.1.dist-info/RECORD +0 -125
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Total conversions.
|
|
3
|
+
|
|
4
|
+
Each helper turns anything into its target type, falling back to that type's
|
|
5
|
+
empty value rather than raising. Use them at boundaries where the input is
|
|
6
|
+
genuinely unknown -- an API payload, a config value, a scraped field -- and a
|
|
7
|
+
crash would be worse than a default.
|
|
8
|
+
|
|
9
|
+
The tradeoff is real: ``safe_int("banana")`` is ``0``, indistinguishable from a
|
|
10
|
+
genuine zero. Where telling those apart matters, parse explicitly instead.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from datetime import date, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from corekit.utils.text import list_to_english
|
|
18
|
+
|
|
19
|
+
__all__ = ["safe_dict", "safe_float", "safe_int", "safe_list", "safe_string", "safe_tuple"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def safe_string(item: Any, pretty: bool = False) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Convert anything to a string, or ``""`` if there is no sensible rendering.
|
|
25
|
+
|
|
26
|
+
Collections become JSON by default. With ``pretty`` they become an English
|
|
27
|
+
list instead -- ``"a, b, c"``, or ``"key: value, ..."`` for a mapping --
|
|
28
|
+
which reads better in a notification than raw JSON.
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(item, bytes):
|
|
31
|
+
item = item.decode("utf-8", errors="ignore")
|
|
32
|
+
if isinstance(item, str):
|
|
33
|
+
return item.strip()
|
|
34
|
+
# bool is a subclass of int, so both land here and render as "True"/"1".
|
|
35
|
+
if isinstance(item, (int, float)):
|
|
36
|
+
return str(item)
|
|
37
|
+
if isinstance(item, (list, tuple, set, dict)):
|
|
38
|
+
if pretty:
|
|
39
|
+
if isinstance(item, dict):
|
|
40
|
+
return list_to_english([f"{key}: {value}" for key, value in item.items()])
|
|
41
|
+
return list_to_english([safe_string(element) for element in item])
|
|
42
|
+
return json.dumps(item)
|
|
43
|
+
if isinstance(item, (date, datetime)):
|
|
44
|
+
return item.isoformat()
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def safe_int(item: Any) -> int:
|
|
49
|
+
"""
|
|
50
|
+
Convert anything to an int, or ``0`` if it will not convert.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(item, int):
|
|
53
|
+
return item
|
|
54
|
+
if isinstance(item, bytes):
|
|
55
|
+
item = item.decode("utf-8", errors="ignore")
|
|
56
|
+
if isinstance(item, (float, str)):
|
|
57
|
+
try:
|
|
58
|
+
return int(item)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return 0
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def safe_float(item: Any) -> float:
|
|
65
|
+
"""
|
|
66
|
+
Convert anything to a float, or ``0.0`` if it will not convert.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(item, float):
|
|
69
|
+
return item
|
|
70
|
+
if isinstance(item, bytes):
|
|
71
|
+
item = item.decode("utf-8", errors="ignore")
|
|
72
|
+
if isinstance(item, (int, str)):
|
|
73
|
+
try:
|
|
74
|
+
return float(item)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return 0.0
|
|
77
|
+
return 0.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def safe_list(item: Any) -> list[Any]:
|
|
81
|
+
"""
|
|
82
|
+
Convert a collection to a list, or ``[]`` for anything else.
|
|
83
|
+
|
|
84
|
+
A mapping yields its values. A string is *not* treated as a collection --
|
|
85
|
+
exploding it into characters is almost never what the caller meant.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(item, list):
|
|
88
|
+
return item
|
|
89
|
+
if isinstance(item, (tuple, set)):
|
|
90
|
+
return list(item)
|
|
91
|
+
if isinstance(item, dict):
|
|
92
|
+
return list(item.values())
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def safe_tuple(item: Any) -> tuple[Any, ...]:
|
|
97
|
+
"""
|
|
98
|
+
Convert a collection to a tuple, or ``()`` for anything else.
|
|
99
|
+
|
|
100
|
+
A mapping yields its values. A string is *not* treated as a collection --
|
|
101
|
+
exploding it into characters is almost never what the caller meant.
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(item, tuple):
|
|
104
|
+
return item
|
|
105
|
+
if isinstance(item, (list, set)):
|
|
106
|
+
return tuple(item)
|
|
107
|
+
if isinstance(item, dict):
|
|
108
|
+
return tuple(item.values())
|
|
109
|
+
return ()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def safe_dict(item: Any) -> dict[str, Any]:
|
|
113
|
+
"""
|
|
114
|
+
Return the mapping unchanged, or ``{}`` for anything else.
|
|
115
|
+
"""
|
|
116
|
+
if isinstance(item, dict):
|
|
117
|
+
return item
|
|
118
|
+
return {}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nested lookup and small collection helpers.
|
|
3
|
+
|
|
4
|
+
``repeated_get`` walks a path into nested dictionaries without a pile of
|
|
5
|
+
``.get()`` calls, and ``keygetter`` lets a step in that path match many keys at
|
|
6
|
+
once. Built for JSON from somewhere you do not control, where any level may be
|
|
7
|
+
missing or the wrong type.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
__all__ = ["MultiMatch", "keygetter", "repeated_get", "split_list"]
|
|
14
|
+
|
|
15
|
+
KEY_SEPARATOR = "."
|
|
16
|
+
|
|
17
|
+
# Distinct from None, which is a legitimate value to find at a key.
|
|
18
|
+
UNSET: Any = object()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def split_list(items: list[Any], index: int) -> tuple[list[Any], list[Any]]:
|
|
22
|
+
"""
|
|
23
|
+
Split a list in two at ``index``. Out-of-range indices yield an empty side.
|
|
24
|
+
"""
|
|
25
|
+
return items[:index], items[index:]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MultiMatch(dict[str, Any]):
|
|
29
|
+
"""
|
|
30
|
+
The result of a path step that matched more than one key.
|
|
31
|
+
|
|
32
|
+
A dict subclass, so it reads like the mapping it is, but a distinct type so
|
|
33
|
+
``repeated_get`` can tell "several matches" from "one match that happened to
|
|
34
|
+
be a dict".
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class keygetter:
|
|
39
|
+
"""
|
|
40
|
+
A path step that selects keys by predicate rather than by name.
|
|
41
|
+
|
|
42
|
+
Use where key names are not known ahead of time::
|
|
43
|
+
|
|
44
|
+
repeated_get(payload, keys=["items", keygetter(lambda k: k.startswith("id_"))])
|
|
45
|
+
|
|
46
|
+
One match returns that value; several return a ``MultiMatch``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, func: Callable[..., Any]) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Build a step from a predicate over key names.
|
|
52
|
+
"""
|
|
53
|
+
self.func = func
|
|
54
|
+
self._keymap: MultiMatch = MultiMatch()
|
|
55
|
+
|
|
56
|
+
def call(self, data: dict[str, Any]) -> Any | MultiMatch:
|
|
57
|
+
"""
|
|
58
|
+
Apply the predicate, returning the match, a ``MultiMatch``, or ``UNSET``.
|
|
59
|
+
|
|
60
|
+
The keymap is replaced on every call. The same instance can be a step
|
|
61
|
+
more than once in one path; keeping the previous matches would make
|
|
62
|
+
the second step see keys from the first.
|
|
63
|
+
"""
|
|
64
|
+
matches = MultiMatch()
|
|
65
|
+
for key, value in data.items():
|
|
66
|
+
if self.func(key):
|
|
67
|
+
matches[key] = value
|
|
68
|
+
self._keymap = matches
|
|
69
|
+
|
|
70
|
+
if not matches:
|
|
71
|
+
return UNSET
|
|
72
|
+
|
|
73
|
+
if len(matches) == 1:
|
|
74
|
+
return next(iter(matches.values()))
|
|
75
|
+
|
|
76
|
+
return matches
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def repeated_get(
|
|
80
|
+
data: dict[str, Any],
|
|
81
|
+
keys: list[str | keygetter] | None = None,
|
|
82
|
+
keypath: str | None = None,
|
|
83
|
+
separator: str = KEY_SEPARATOR,
|
|
84
|
+
fallback: Any = None,
|
|
85
|
+
) -> Any | MultiMatch:
|
|
86
|
+
"""
|
|
87
|
+
Follow a path into nested dictionaries, returning ``fallback`` if it breaks.
|
|
88
|
+
|
|
89
|
+
Give either ``keys`` (which may contain ``keygetter`` steps) or a dotted
|
|
90
|
+
``keypath``. Any level that is missing, or is not a mapping, ends the walk
|
|
91
|
+
at ``fallback`` rather than raising.
|
|
92
|
+
"""
|
|
93
|
+
if keys is None:
|
|
94
|
+
if keypath is None:
|
|
95
|
+
raise ValueError("repeated_get needs either keys or keypath")
|
|
96
|
+
keys = list(keypath.split(separator))
|
|
97
|
+
|
|
98
|
+
def _get_item(current: Any, key: str | keygetter) -> Any | MultiMatch:
|
|
99
|
+
if current is UNSET or not isinstance(current, dict):
|
|
100
|
+
return UNSET
|
|
101
|
+
|
|
102
|
+
if isinstance(key, keygetter):
|
|
103
|
+
return key.call(current)
|
|
104
|
+
if callable(key):
|
|
105
|
+
# A bare predicate is the same step as a keygetter. A reused
|
|
106
|
+
# instance still goes through call(), which owns the keymap.
|
|
107
|
+
return keygetter(key).call(current)
|
|
108
|
+
|
|
109
|
+
return current.get(key, UNSET)
|
|
110
|
+
|
|
111
|
+
# Copied so a keygetter's predicate cannot mutate the caller's data.
|
|
112
|
+
result = deepcopy(data)
|
|
113
|
+
for key in keys:
|
|
114
|
+
if isinstance(result, MultiMatch):
|
|
115
|
+
collected = MultiMatch()
|
|
116
|
+
for parent_key, branch in result.items():
|
|
117
|
+
child = _get_item(branch, key)
|
|
118
|
+
if child is not UNSET:
|
|
119
|
+
collected[parent_key] = child
|
|
120
|
+
result = collected if collected else UNSET
|
|
121
|
+
else:
|
|
122
|
+
result = _get_item(result, key)
|
|
123
|
+
|
|
124
|
+
return fallback if result is UNSET else result
|
corekit/utils/ids.py
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
"""
|
|
2
|
+
Identifier generation.
|
|
3
|
+
|
|
4
|
+
UUIDs for internal keys, URL-safe tokens for sessions, and short human-readable
|
|
5
|
+
codes for anything someone has to read aloud or type in by hand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import secrets
|
|
9
|
+
import string
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"SHORTCODE_CHARS",
|
|
14
|
+
"generate_session_token",
|
|
15
|
+
"generate_shortcode",
|
|
16
|
+
"generate_shortcodes",
|
|
17
|
+
"generate_uuid",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Uppercase alphanumerics minus the characters people confuse when reading a
|
|
21
|
+
# code off a screen: O/0 and I/1.
|
|
22
|
+
SHORTCODE_CHARS = string.ascii_uppercase.replace("O", "").replace("I", "") + string.digits.replace("0", "").replace(
|
|
23
|
+
"1", ""
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_uuid() -> str:
|
|
28
|
+
"""
|
|
29
|
+
Return a random UUID4 as a string.
|
|
30
|
+
"""
|
|
31
|
+
return str(uuid.uuid4())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_session_token(length: int = 32) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Return a cryptographically random URL-safe token.
|
|
37
|
+
|
|
38
|
+
``length`` is the number of random *bytes*; the returned string is longer,
|
|
39
|
+
since base64 encoding expands it by about a third.
|
|
40
|
+
"""
|
|
41
|
+
return secrets.token_urlsafe(length)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_shortcode(length: int, alphabet: str = SHORTCODE_CHARS) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Return a cryptographically random code of ``length`` characters.
|
|
47
|
+
|
|
48
|
+
Drawn from an unambiguous alphabet, so the result survives being read over
|
|
49
|
+
the phone or copied off a screen.
|
|
50
|
+
"""
|
|
51
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_shortcodes(length: int, count: int, alphabet: str = SHORTCODE_CHARS) -> list[str]:
|
|
55
|
+
"""
|
|
56
|
+
Return ``count`` shortcodes.
|
|
57
|
+
|
|
58
|
+
Codes are independent, so duplicates are possible; if they must be distinct,
|
|
59
|
+
check them against whatever store they are destined for.
|
|
60
|
+
"""
|
|
61
|
+
return [generate_shortcode(length, alphabet) for _ in range(count)]
|
corekit/utils/payload.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON argument payloads.
|
|
3
|
+
|
|
4
|
+
Encodes a call's arguments as JSON so they can cross a process boundary -- a
|
|
5
|
+
queue, a subprocess, a scheduler entry -- as data rather than as a serialized
|
|
6
|
+
object. ``pickle`` and ``dill`` execute code while loading, so a channel
|
|
7
|
+
carrying serialized objects turns "can write to the channel" into "can run code
|
|
8
|
+
on the other side". JSON cannot construct an arbitrary object, so whatever runs
|
|
9
|
+
always comes from the receiver's own code.
|
|
10
|
+
|
|
11
|
+
Arguments are validated when they are encoded rather than when they are used,
|
|
12
|
+
so a value that cannot cross fails at the call site where the offending
|
|
13
|
+
argument is still visible.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, NamedTuple
|
|
23
|
+
from uuid import UUID
|
|
24
|
+
|
|
25
|
+
__all__ = ["Payload", "decode_payload", "encode_payload"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Payload(NamedTuple):
|
|
29
|
+
"""
|
|
30
|
+
The positional and keyword arguments a call was made with.
|
|
31
|
+
|
|
32
|
+
A tuple, so it still unpacks as ``args, kwargs``, but the fields are named
|
|
33
|
+
at the point they are read.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
args: list[Any]
|
|
37
|
+
kwargs: dict[str, Any]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def encode_payload(args: tuple[Any, ...] | list[Any] = (), kwargs: dict[str, Any] | None = None) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Encode positional and keyword arguments as a JSON string.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
TypeError: If any argument is not JSON-serializable, naming the value.
|
|
46
|
+
Enums encode as their value and datetimes as ISO 8601, so those
|
|
47
|
+
cross as plain data; the receiver gets the encoded form, not the
|
|
48
|
+
original object.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
return json.dumps({"args": list(args), "kwargs": kwargs or {}}, default=_encode_extra)
|
|
52
|
+
except TypeError as exc:
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"Arguments must be JSON-serializable so they can cross as data: {exc}. "
|
|
55
|
+
f"Pass an id or another plain value and load the object on the other side."
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _encode_extra(value: Any) -> Any:
|
|
60
|
+
"""
|
|
61
|
+
Encode the few non-JSON types that appear in ordinary arguments.
|
|
62
|
+
|
|
63
|
+
The receiver gets what comes back out -- ``"GET"`` rather than
|
|
64
|
+
``HTTPMethod.GET`` -- so code wanting the original type reconstructs it.
|
|
65
|
+
Anything not handled here raises, which is the point: an object that cannot
|
|
66
|
+
cross as data has to be looked up on the other side instead.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(value, Enum):
|
|
69
|
+
return value.value
|
|
70
|
+
if isinstance(value, (datetime, date)):
|
|
71
|
+
return value.isoformat()
|
|
72
|
+
if isinstance(value, UUID):
|
|
73
|
+
return str(value)
|
|
74
|
+
if isinstance(value, Path):
|
|
75
|
+
return str(value)
|
|
76
|
+
if isinstance(value, Decimal):
|
|
77
|
+
return str(value)
|
|
78
|
+
if isinstance(value, bytes):
|
|
79
|
+
return base64.b64encode(value).decode("ascii")
|
|
80
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def decode_payload(payload: str | bytes | None) -> Payload:
|
|
84
|
+
"""
|
|
85
|
+
Decode a payload produced by ``encode_payload``.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The positional arguments and keyword arguments.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If the payload is not the expected JSON object.
|
|
92
|
+
"""
|
|
93
|
+
if not payload:
|
|
94
|
+
return Payload(args=[], kwargs={})
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
decoded = json.loads(payload)
|
|
98
|
+
except json.JSONDecodeError as exc:
|
|
99
|
+
raise ValueError(f"Payload is not valid JSON: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
if not isinstance(decoded, dict):
|
|
102
|
+
raise ValueError(f"Payload must be a JSON object, got {type(decoded).__name__}")
|
|
103
|
+
|
|
104
|
+
args = decoded.get("args", [])
|
|
105
|
+
kwargs = decoded.get("kwargs", {})
|
|
106
|
+
|
|
107
|
+
if not isinstance(args, list):
|
|
108
|
+
raise ValueError(f"Payload 'args' must be a list, got {type(args).__name__}")
|
|
109
|
+
if not isinstance(kwargs, dict):
|
|
110
|
+
raise ValueError(f"Payload 'kwargs' must be an object, got {type(kwargs).__name__}")
|
|
111
|
+
|
|
112
|
+
return Payload(args=args, kwargs=kwargs)
|
corekit/utils/raise_exc.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def raise_exc(ex: Exception, *args: Any, **kwargs: Any) -> None:
|
|
5
|
-
"""
|
|
6
|
-
Generic callback function that raises the exception
|
|
7
|
-
"""
|
|
8
|
-
raise ex
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def raise_exc(ex: Exception, *args: Any, **kwargs: Any) -> None:
|
|
5
|
+
"""
|
|
6
|
+
Generic callback function that raises the exception
|
|
7
|
+
"""
|
|
8
|
+
raise ex
|
corekit/utils/text.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
String assembly helpers.
|
|
3
|
+
|
|
4
|
+
Small formatters for turning collections into text a human will read -- log
|
|
5
|
+
lines, notification bodies, error messages -- plus filename sanitization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
__all__ = ["join_lines", "list_to_english", "sanitize_filename"]
|
|
12
|
+
|
|
13
|
+
FALLBACK_FILENAME = "unnamed"
|
|
14
|
+
_UNSAFE_CHARS = re.compile(r"[^a-z0-9\-]")
|
|
15
|
+
_REPEATED_HYPHENS = re.compile(r"-{2,}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def join_lines(*items: Any, prefix: str = "") -> str:
|
|
19
|
+
"""
|
|
20
|
+
Join items with newlines, optionally prefixing each one.
|
|
21
|
+
"""
|
|
22
|
+
return "\n".join(f"{prefix}{item}" for item in items)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_to_english(items: list[str], ending: Literal["and", "or"] | None = None) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Join items into a readable list: ``"a, b, and c"``.
|
|
28
|
+
|
|
29
|
+
Without ``ending`` the final item is joined with a plain comma, which suits
|
|
30
|
+
a bare enumeration; pass ``"and"`` or ``"or"`` for prose.
|
|
31
|
+
"""
|
|
32
|
+
if not items:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
if len(items) == 1:
|
|
36
|
+
return items[0]
|
|
37
|
+
|
|
38
|
+
buffer = ", ".join(items[:-1])
|
|
39
|
+
last = items[-1]
|
|
40
|
+
|
|
41
|
+
if ending is None:
|
|
42
|
+
return f"{buffer}, {last}"
|
|
43
|
+
return f"{buffer}, {ending} {last}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sanitize_filename(raw: str, fallback: str = FALLBACK_FILENAME) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Reduce a string to lowercase kebab-case safe for use as a filename stem.
|
|
49
|
+
|
|
50
|
+
Everything outside ``a-z0-9-`` becomes a hyphen, runs collapse, and the
|
|
51
|
+
result is trimmed. A string with nothing usable in it returns ``fallback``
|
|
52
|
+
rather than the empty string, which would produce a nameless file.
|
|
53
|
+
"""
|
|
54
|
+
cleaned = _UNSAFE_CHARS.sub("-", raw.strip().lower())
|
|
55
|
+
cleaned = _REPEATED_HYPHENS.sub("-", cleaned).strip("-")
|
|
56
|
+
return cleaned or fallback
|
corekit/utils/time.py
CHANGED
|
@@ -1,21 +1,74 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
"""
|
|
2
|
+
Timezone-aware time helpers.
|
|
3
|
+
|
|
4
|
+
Every function returns UTC. Naive datetimes are the usual source of "works on
|
|
5
|
+
my machine" time bugs, so nothing here produces one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__all__ = ["isoformat_now", "parse_docker_timestamp", "time_now", "timedelta_now", "timestamp_now"]
|
|
13
|
+
|
|
14
|
+
# Docker reports nanosecond precision; fromisoformat accepts at most microseconds.
|
|
15
|
+
_DOCKER_TS_PATTERN = re.compile(r"(\.\d{6})\d*")
|
|
16
|
+
|
|
17
|
+
# Docker's zero value for "this never happened".
|
|
18
|
+
_DOCKER_ZERO_TIME = "0001-01-01"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def time_now() -> datetime:
|
|
22
|
+
"""
|
|
23
|
+
Return the current UTC time, timezone-aware.
|
|
24
|
+
"""
|
|
25
|
+
return datetime.now(UTC)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def isoformat_now() -> str:
|
|
29
|
+
"""
|
|
30
|
+
Return the current UTC time as an ISO 8601 string.
|
|
31
|
+
"""
|
|
32
|
+
return time_now().isoformat()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def timestamp_now() -> float:
|
|
36
|
+
"""
|
|
37
|
+
Return the current UTC time as a Unix timestamp.
|
|
38
|
+
"""
|
|
39
|
+
return time_now().timestamp()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def timedelta_now(delta_seconds: int | None = None, add_time: bool = False, **kwargs: Any) -> datetime:
|
|
43
|
+
"""
|
|
44
|
+
Return a time offset from now, in the past by default.
|
|
45
|
+
|
|
46
|
+
Pass ``delta_seconds`` for the common case, or any ``timedelta`` keyword
|
|
47
|
+
(``days``, ``hours``, ...) for the rest. ``add_time`` moves forward instead
|
|
48
|
+
of back.
|
|
49
|
+
"""
|
|
50
|
+
if delta_seconds is not None:
|
|
51
|
+
kwargs = {"seconds": delta_seconds}
|
|
52
|
+
|
|
53
|
+
delta = timedelta(**kwargs)
|
|
54
|
+
if add_time:
|
|
55
|
+
return time_now() + delta
|
|
56
|
+
return time_now() - delta
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_docker_timestamp(value: str | None) -> float | None:
|
|
60
|
+
"""
|
|
61
|
+
Parse a docker API timestamp into a Unix timestamp, or ``None``.
|
|
62
|
+
|
|
63
|
+
Docker emits nanosecond-precision ISO 8601 (a container's ``State.StartedAt``,
|
|
64
|
+
for instance), which ``fromisoformat`` rejects. Returns ``None`` for empty
|
|
65
|
+
input, docker's zero timestamp, and anything unparseable, so a caller can
|
|
66
|
+
treat "no such time" and "bad time" the same way.
|
|
67
|
+
"""
|
|
68
|
+
if not value or value.startswith(_DOCKER_ZERO_TIME):
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
trimmed = _DOCKER_TS_PATTERN.sub(r"\1", value.replace("Z", "+00:00"))
|
|
72
|
+
return datetime.fromisoformat(trimmed).timestamp()
|
|
73
|
+
except ValueError:
|
|
74
|
+
return None
|
corekit/utils/validators.py
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def true_validator(*args: Any, **kwargs: Any) -> bool:
|
|
5
|
-
"""
|
|
6
|
-
Generic placeholder validator function that always returns True
|
|
7
|
-
"""
|
|
8
|
-
return True
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def false_validator(*args: Any, **kwargs: Any) -> bool:
|
|
12
|
-
"""
|
|
13
|
-
Generic placeholder validator function that always returns False
|
|
14
|
-
"""
|
|
15
|
-
return False
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def true_validator(*args: Any, **kwargs: Any) -> bool:
|
|
5
|
+
"""
|
|
6
|
+
Generic placeholder validator function that always returns True
|
|
7
|
+
"""
|
|
8
|
+
return True
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def false_validator(*args: Any, **kwargs: Any) -> bool:
|
|
12
|
+
"""
|
|
13
|
+
Generic placeholder validator function that always returns False
|
|
14
|
+
"""
|
|
15
|
+
return False
|
corekit/utils/void.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def void(ex: Exception, *args: Any, **kwargs: Any) -> None:
|
|
5
|
-
"""
|
|
6
|
-
Generic callback function that does nothing and returns nothing
|
|
7
|
-
"""
|
|
8
|
-
pass
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def void(ex: Exception, *args: Any, **kwargs: Any) -> None:
|
|
5
|
+
"""
|
|
6
|
+
Generic callback function that does nothing and returns nothing
|
|
7
|
+
"""
|
|
8
|
+
pass
|