python-corekit 0.1.0__py3-none-any.whl → 0.2.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 +237 -0
- corekit/api/lifespan.py +210 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/worker.py +65 -65
- corekit/config/settings.py +3 -3
- corekit/connections/sql/__init__.py +31 -3
- corekit/connections/sql/connection.py +19 -0
- corekit/connections/sql/migration/__init__.py +5 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +66 -42
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +102 -0
- corekit/connections/sql/operations/statements.py +150 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +30 -4
- corekit/constants.py +45 -45
- corekit/crypto/constants.py +4 -4
- corekit/data/__init__.py +8 -0
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +184 -104
- corekit/data/expressions/expression.py +103 -98
- corekit/data/expressions/operator.py +54 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +159 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +2 -1
- corekit/etl/connection.py +44 -44
- corekit/events/websocket.py +3 -2
- corekit/exceptions/__init__.py +18 -0
- corekit/http/__init__.py +13 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +69 -0
- corekit/jobs/task.py +152 -0
- corekit/observability/__init__.py +5 -3
- corekit/observability/request_context.py +135 -0
- corekit/registry/__init__.py +11 -6
- corekit/registry/ordered.py +86 -0
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +49 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +40 -40
- corekit/serialization/__init__.py +22 -0
- corekit/serialization/serializer.py +1 -1
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +115 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +100 -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.0.dist-info → python_corekit-0.2.0.dist-info}/METADATA +105 -100
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/RECORD +64 -46
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
|
-
from pydantic import Field
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def DefaultStringField(*args: Any, default: Any = "", **kwargs: Any) -> Field:
|
|
7
|
-
return Field(default=default, *args, **kwargs)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def DefaultIntField(*args: Any, default: Any = 0, **kwargs: Any) -> Field:
|
|
11
|
-
return Field(default=default, *args, **kwargs)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def DefaultFloatField(*args: Any, default: Any = 0.0, **kwargs: Any) -> Field:
|
|
15
|
-
return Field(default=default, *args, **kwargs)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def DefaultBoolField(*args: Any, default: Any = False, **kwargs: Any) -> Field:
|
|
19
|
-
return Field(default=default, *args, **kwargs)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def DefaultEmptyField(*args: Any, default: Any = None, **kwargs: Any) -> Field:
|
|
23
|
-
return Field(default=default, *args, **kwargs)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def DefaultListField(*args: Any, default_factory: Any = list, **kwargs: Any) -> Field:
|
|
27
|
-
return Field(default_factory=default_factory, *args, **kwargs)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def DefaultSetField(*args: Any, default_factory: Any = set, **kwargs: Any) -> Field:
|
|
31
|
-
return Field(default_factory=default_factory, *args, **kwargs)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def DefaultDictField(*args: Any, default_factory: Any = dict, **kwargs: Any) -> Field:
|
|
35
|
-
return Field(default_factory=default_factory, *args, **kwargs)
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def DefaultStringField(*args: Any, default: Any = "", **kwargs: Any) -> Field:
|
|
7
|
+
return Field(default=default, *args, **kwargs)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def DefaultIntField(*args: Any, default: Any = 0, **kwargs: Any) -> Field:
|
|
11
|
+
return Field(default=default, *args, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def DefaultFloatField(*args: Any, default: Any = 0.0, **kwargs: Any) -> Field:
|
|
15
|
+
return Field(default=default, *args, **kwargs)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def DefaultBoolField(*args: Any, default: Any = False, **kwargs: Any) -> Field:
|
|
19
|
+
return Field(default=default, *args, **kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def DefaultEmptyField(*args: Any, default: Any = None, **kwargs: Any) -> Field:
|
|
23
|
+
return Field(default=default, *args, **kwargs)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def DefaultListField(*args: Any, default_factory: Any = list, **kwargs: Any) -> Field:
|
|
27
|
+
return Field(default_factory=default_factory, *args, **kwargs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def DefaultSetField(*args: Any, default_factory: Any = set, **kwargs: Any) -> Field:
|
|
31
|
+
return Field(default_factory=default_factory, *args, **kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def DefaultDictField(*args: Any, default_factory: Any = dict, **kwargs: Any) -> Field:
|
|
35
|
+
return Field(default_factory=default_factory, *args, **kwargs)
|
corekit/schemas/types.py
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
from datetime import date, datetime
|
|
2
|
-
from typing import Any, Sequence
|
|
3
|
-
|
|
4
|
-
# ========== Primitive Types ==========
|
|
5
|
-
Number = int | float
|
|
6
|
-
AnyPrimitive = str | bool | Number
|
|
7
|
-
|
|
8
|
-
# ========== Dict Types ==========
|
|
9
|
-
StringDict = dict[str, str]
|
|
10
|
-
CounterDict = dict[str, int]
|
|
11
|
-
StatisticDict = dict[str, Number]
|
|
12
|
-
AnyDict = dict[str, Any]
|
|
13
|
-
UnknownDict = dict[Any, Any]
|
|
14
|
-
|
|
15
|
-
# ========== Sequence Types ==========
|
|
16
|
-
# Using the more broad "Sequence" type instead of "List" to allow for other sequence types like "tuple"
|
|
17
|
-
StringSequence = Sequence[str]
|
|
18
|
-
IntegerSequence = Sequence[int]
|
|
19
|
-
FloatSequence = Sequence[float]
|
|
20
|
-
NumberSequence = Sequence[Number]
|
|
21
|
-
BooleanSequence = Sequence[bool]
|
|
22
|
-
UnknownSequence = Sequence[Any]
|
|
23
|
-
|
|
24
|
-
# Using the more specific "list" and "set types allows for more rigid type checking
|
|
25
|
-
StringList = list[str]
|
|
26
|
-
IntegerList = list[int]
|
|
27
|
-
FloatList = list[float]
|
|
28
|
-
NumberList = list[Number]
|
|
29
|
-
BooleanList = list[bool]
|
|
30
|
-
UnknownList = list[Any]
|
|
31
|
-
|
|
32
|
-
StringSet = set[str]
|
|
33
|
-
IntegerSet = set[int]
|
|
34
|
-
FloatSet = set[float]
|
|
35
|
-
NumberSet = set[Number]
|
|
36
|
-
BooleanSet = set[bool]
|
|
37
|
-
UnknownSet = set[Any]
|
|
38
|
-
|
|
39
|
-
# ========== Date Types ==========
|
|
40
|
-
ArbitraryDate = date | datetime
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from typing import Any, Sequence
|
|
3
|
+
|
|
4
|
+
# ========== Primitive Types ==========
|
|
5
|
+
Number = int | float
|
|
6
|
+
AnyPrimitive = str | bool | Number
|
|
7
|
+
|
|
8
|
+
# ========== Dict Types ==========
|
|
9
|
+
StringDict = dict[str, str]
|
|
10
|
+
CounterDict = dict[str, int]
|
|
11
|
+
StatisticDict = dict[str, Number]
|
|
12
|
+
AnyDict = dict[str, Any]
|
|
13
|
+
UnknownDict = dict[Any, Any]
|
|
14
|
+
|
|
15
|
+
# ========== Sequence Types ==========
|
|
16
|
+
# Using the more broad "Sequence" type instead of "List" to allow for other sequence types like "tuple"
|
|
17
|
+
StringSequence = Sequence[str]
|
|
18
|
+
IntegerSequence = Sequence[int]
|
|
19
|
+
FloatSequence = Sequence[float]
|
|
20
|
+
NumberSequence = Sequence[Number]
|
|
21
|
+
BooleanSequence = Sequence[bool]
|
|
22
|
+
UnknownSequence = Sequence[Any]
|
|
23
|
+
|
|
24
|
+
# Using the more specific "list" and "set types allows for more rigid type checking
|
|
25
|
+
StringList = list[str]
|
|
26
|
+
IntegerList = list[int]
|
|
27
|
+
FloatList = list[float]
|
|
28
|
+
NumberList = list[Number]
|
|
29
|
+
BooleanList = list[bool]
|
|
30
|
+
UnknownList = list[Any]
|
|
31
|
+
|
|
32
|
+
StringSet = set[str]
|
|
33
|
+
IntegerSet = set[int]
|
|
34
|
+
FloatSet = set[float]
|
|
35
|
+
NumberSet = set[Number]
|
|
36
|
+
BooleanSet = set[bool]
|
|
37
|
+
UnknownSet = set[Any]
|
|
38
|
+
|
|
39
|
+
# ========== Date Types ==========
|
|
40
|
+
ArbitraryDate = date | datetime
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serialization with authenticated payloads.
|
|
3
|
+
|
|
4
|
+
``pickle`` and ``dill`` execute code while loading, so a payload must be proven
|
|
5
|
+
to come from a holder of the shared key *before* it is decoded, and the decoder
|
|
6
|
+
is chosen by the receiver rather than read out of the payload. JSON is the
|
|
7
|
+
default because it cannot execute code at all.
|
|
8
|
+
|
|
9
|
+
Set a key with ``COREKIT_SERIALIZATION__KEY`` to use an executing engine.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from corekit.serialization.enum import SerializerEngine
|
|
13
|
+
from corekit.serialization.serializable import Serializable
|
|
14
|
+
from corekit.serialization.serializer import Serializer, SignatureError, UnsafeEngineError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Serializable",
|
|
18
|
+
"Serializer",
|
|
19
|
+
"SerializerEngine",
|
|
20
|
+
"SignatureError",
|
|
21
|
+
"UnsafeEngineError",
|
|
22
|
+
]
|
|
@@ -69,7 +69,7 @@ class Serializer:
|
|
|
69
69
|
if self._engine in EXECUTING_ENGINES and self._key is None:
|
|
70
70
|
raise UnsafeEngineError(
|
|
71
71
|
f"{self._engine.value} executes code when loading, so it needs a key to authenticate "
|
|
72
|
-
f"payloads. Pass key=..., set
|
|
72
|
+
f"payloads. Pass key=..., set COREKIT_SERIALIZATION__KEY, or use SerializerEngine.JSON."
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
@staticmethod
|
corekit/utils/__init__.py
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
"""
|
|
2
|
+
Small, dependency-free helpers.
|
|
3
|
+
|
|
4
|
+
Four groups: total conversions that never raise (``safe_*``), string assembly
|
|
5
|
+
for human-readable output, identifier generation, and nested-dictionary lookup.
|
|
6
|
+
Plus timezone-aware time helpers and the placeholder callbacks (``void``,
|
|
7
|
+
``raise_exc``, ``true_validator``, ``false_validator``) that callbacks-by-
|
|
8
|
+
configuration need.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from corekit.utils.coercion import safe_dict, safe_float, safe_int, safe_list, safe_string, safe_tuple
|
|
12
|
+
from corekit.utils.collections import UNSET, MultiMatch, keygetter, repeated_get, split_list
|
|
13
|
+
from corekit.utils.ids import (
|
|
14
|
+
SHORTCODE_CHARS,
|
|
15
|
+
generate_session_token,
|
|
16
|
+
generate_shortcode,
|
|
17
|
+
generate_shortcodes,
|
|
18
|
+
generate_uuid,
|
|
19
|
+
)
|
|
20
|
+
from corekit.utils.payload import Payload, decode_payload, encode_payload
|
|
21
|
+
from corekit.utils.raise_exc import raise_exc
|
|
22
|
+
from corekit.utils.text import join_lines, list_to_english, sanitize_filename
|
|
23
|
+
from corekit.utils.time import isoformat_now, parse_docker_timestamp, time_now, timedelta_now, timestamp_now
|
|
24
|
+
from corekit.utils.validators import false_validator, true_validator
|
|
25
|
+
from corekit.utils.void import void
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"SHORTCODE_CHARS",
|
|
29
|
+
"UNSET",
|
|
30
|
+
"MultiMatch",
|
|
31
|
+
"Payload",
|
|
32
|
+
"decode_payload",
|
|
33
|
+
"encode_payload",
|
|
34
|
+
"false_validator",
|
|
35
|
+
"generate_session_token",
|
|
36
|
+
"generate_shortcode",
|
|
37
|
+
"generate_shortcodes",
|
|
38
|
+
"generate_uuid",
|
|
39
|
+
"isoformat_now",
|
|
40
|
+
"join_lines",
|
|
41
|
+
"keygetter",
|
|
42
|
+
"list_to_english",
|
|
43
|
+
"parse_docker_timestamp",
|
|
44
|
+
"raise_exc",
|
|
45
|
+
"repeated_get",
|
|
46
|
+
"safe_dict",
|
|
47
|
+
"safe_float",
|
|
48
|
+
"safe_int",
|
|
49
|
+
"safe_list",
|
|
50
|
+
"safe_string",
|
|
51
|
+
"safe_tuple",
|
|
52
|
+
"sanitize_filename",
|
|
53
|
+
"split_list",
|
|
54
|
+
"time_now",
|
|
55
|
+
"timedelta_now",
|
|
56
|
+
"timestamp_now",
|
|
57
|
+
"true_validator",
|
|
58
|
+
"void",
|
|
59
|
+
]
|
|
@@ -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,115 @@
|
|
|
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
|
+
for key, value in data.items():
|
|
61
|
+
if self.func(key):
|
|
62
|
+
self._keymap[key] = value
|
|
63
|
+
|
|
64
|
+
if not self._keymap:
|
|
65
|
+
return UNSET
|
|
66
|
+
|
|
67
|
+
if len(self._keymap) == 1:
|
|
68
|
+
return next(iter(self._keymap.values()))
|
|
69
|
+
|
|
70
|
+
return self._keymap
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def repeated_get(
|
|
74
|
+
data: dict[str, Any],
|
|
75
|
+
keys: list[str | keygetter] | None = None,
|
|
76
|
+
keypath: str | None = None,
|
|
77
|
+
separator: str = KEY_SEPARATOR,
|
|
78
|
+
fallback: Any = None,
|
|
79
|
+
) -> Any | MultiMatch:
|
|
80
|
+
"""
|
|
81
|
+
Follow a path into nested dictionaries, returning ``fallback`` if it breaks.
|
|
82
|
+
|
|
83
|
+
Give either ``keys`` (which may contain ``keygetter`` steps) or a dotted
|
|
84
|
+
``keypath``. Any level that is missing, or is not a mapping, ends the walk
|
|
85
|
+
at ``fallback`` rather than raising.
|
|
86
|
+
"""
|
|
87
|
+
if keys is None:
|
|
88
|
+
if keypath is None:
|
|
89
|
+
raise ValueError("repeated_get needs either keys or keypath")
|
|
90
|
+
keys = list(keypath.split(separator))
|
|
91
|
+
|
|
92
|
+
def _get_item(current: Any, key: str | keygetter) -> Any | MultiMatch:
|
|
93
|
+
if current is UNSET or not isinstance(current, dict):
|
|
94
|
+
return fallback
|
|
95
|
+
|
|
96
|
+
if isinstance(key, keygetter):
|
|
97
|
+
return key.func(current)
|
|
98
|
+
|
|
99
|
+
return current.get(key, UNSET)
|
|
100
|
+
|
|
101
|
+
# Copied so a keygetter's predicate cannot mutate the caller's data.
|
|
102
|
+
result = deepcopy(data)
|
|
103
|
+
for key in keys:
|
|
104
|
+
if isinstance(result, MultiMatch):
|
|
105
|
+
collected = MultiMatch()
|
|
106
|
+
for _parent_key, branch in result.items():
|
|
107
|
+
if not isinstance(branch, dict):
|
|
108
|
+
continue
|
|
109
|
+
for child_key, child_value in branch.items():
|
|
110
|
+
collected[child_key] = _get_item(child_value, key)
|
|
111
|
+
result = deepcopy(collected)
|
|
112
|
+
else:
|
|
113
|
+
result = _get_item(result, key)
|
|
114
|
+
|
|
115
|
+
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,100 @@
|
|
|
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 json
|
|
17
|
+
from datetime import date, datetime
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any, NamedTuple
|
|
20
|
+
|
|
21
|
+
__all__ = ["Payload", "decode_payload", "encode_payload"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Payload(NamedTuple):
|
|
25
|
+
"""
|
|
26
|
+
The positional and keyword arguments a call was made with.
|
|
27
|
+
|
|
28
|
+
A tuple, so it still unpacks as ``args, kwargs``, but the fields are named
|
|
29
|
+
at the point they are read.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
args: list[Any]
|
|
33
|
+
kwargs: dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encode_payload(args: tuple[Any, ...] | list[Any] = (), kwargs: dict[str, Any] | None = None) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Encode positional and keyword arguments as a JSON string.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
TypeError: If any argument is not JSON-serializable, naming the value.
|
|
42
|
+
Enums encode as their value and datetimes as ISO 8601, so those
|
|
43
|
+
cross as plain data; the receiver gets the encoded form, not the
|
|
44
|
+
original object.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
return json.dumps({"args": list(args), "kwargs": kwargs or {}}, default=_encode_extra)
|
|
48
|
+
except TypeError as exc:
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"Arguments must be JSON-serializable so they can cross as data: {exc}. "
|
|
51
|
+
f"Pass an id or another plain value and load the object on the other side."
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _encode_extra(value: Any) -> Any:
|
|
56
|
+
"""
|
|
57
|
+
Encode the few non-JSON types that appear in ordinary arguments.
|
|
58
|
+
|
|
59
|
+
The receiver gets what comes back out -- ``"GET"`` rather than
|
|
60
|
+
``HTTPMethod.GET`` -- so code wanting the original type reconstructs it.
|
|
61
|
+
Anything not handled here raises, which is the point: an object that cannot
|
|
62
|
+
cross as data has to be looked up on the other side instead.
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(value, Enum):
|
|
65
|
+
return value.value
|
|
66
|
+
if isinstance(value, (datetime, date)):
|
|
67
|
+
return value.isoformat()
|
|
68
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def decode_payload(payload: str | bytes | None) -> Payload:
|
|
72
|
+
"""
|
|
73
|
+
Decode a payload produced by ``encode_payload``.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The positional arguments and keyword arguments.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If the payload is not the expected JSON object.
|
|
80
|
+
"""
|
|
81
|
+
if not payload:
|
|
82
|
+
return Payload(args=[], kwargs={})
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
decoded = json.loads(payload)
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
raise ValueError(f"Payload is not valid JSON: {exc}") from exc
|
|
88
|
+
|
|
89
|
+
if not isinstance(decoded, dict):
|
|
90
|
+
raise ValueError(f"Payload must be a JSON object, got {type(decoded).__name__}")
|
|
91
|
+
|
|
92
|
+
args = decoded.get("args", [])
|
|
93
|
+
kwargs = decoded.get("kwargs", {})
|
|
94
|
+
|
|
95
|
+
if not isinstance(args, list):
|
|
96
|
+
raise ValueError(f"Payload 'args' must be a list, got {type(args).__name__}")
|
|
97
|
+
if not isinstance(kwargs, dict):
|
|
98
|
+
raise ValueError(f"Payload 'kwargs' must be an object, got {type(kwargs).__name__}")
|
|
99
|
+
|
|
100
|
+
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
|