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.
Files changed (64) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +237 -0
  3. corekit/api/lifespan.py +210 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/worker.py +65 -65
  7. corekit/config/settings.py +3 -3
  8. corekit/connections/sql/__init__.py +31 -3
  9. corekit/connections/sql/connection.py +19 -0
  10. corekit/connections/sql/migration/__init__.py +5 -5
  11. corekit/connections/sql/migration/base.py +3 -3
  12. corekit/connections/sql/migration/operations.py +66 -42
  13. corekit/connections/sql/migration/registry.py +2 -2
  14. corekit/connections/sql/operations/__init__.py +24 -0
  15. corekit/connections/sql/operations/base.py +102 -0
  16. corekit/connections/sql/operations/statements.py +150 -0
  17. corekit/connections/sql/query.py +4 -62
  18. corekit/connections/sql/table.py +30 -4
  19. corekit/constants.py +45 -45
  20. corekit/crypto/constants.py +4 -4
  21. corekit/data/__init__.py +8 -0
  22. corekit/data/expressions/__init__.py +10 -2
  23. corekit/data/expressions/comparison.py +184 -104
  24. corekit/data/expressions/expression.py +103 -98
  25. corekit/data/expressions/operator.py +54 -0
  26. corekit/data/expressions/target.py +21 -0
  27. corekit/data/record.py +147 -147
  28. corekit/data/stats.py +159 -157
  29. corekit/decorators/__init__.py +2 -2
  30. corekit/decorators/exception_handling.py +2 -1
  31. corekit/etl/connection.py +44 -44
  32. corekit/events/websocket.py +3 -2
  33. corekit/exceptions/__init__.py +18 -0
  34. corekit/http/__init__.py +13 -0
  35. corekit/jobs/__init__.py +26 -0
  36. corekit/jobs/registry.py +87 -0
  37. corekit/jobs/runner.py +69 -0
  38. corekit/jobs/task.py +152 -0
  39. corekit/observability/__init__.py +5 -3
  40. corekit/observability/request_context.py +135 -0
  41. corekit/registry/__init__.py +11 -6
  42. corekit/registry/ordered.py +86 -0
  43. corekit/schemas/__init__.py +10 -0
  44. corekit/schemas/enum.py +49 -49
  45. corekit/schemas/models/arbitrary.py +11 -11
  46. corekit/schemas/pydantic/fields.py +35 -35
  47. corekit/schemas/types.py +40 -40
  48. corekit/serialization/__init__.py +22 -0
  49. corekit/serialization/serializer.py +1 -1
  50. corekit/utils/__init__.py +59 -5
  51. corekit/utils/coercion.py +118 -0
  52. corekit/utils/collections.py +115 -0
  53. corekit/utils/ids.py +61 -5
  54. corekit/utils/payload.py +100 -0
  55. corekit/utils/raise_exc.py +8 -8
  56. corekit/utils/text.py +56 -0
  57. corekit/utils/time.py +74 -21
  58. corekit/utils/validators.py +15 -15
  59. corekit/utils/void.py +8 -8
  60. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/METADATA +105 -100
  61. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/RECORD +64 -46
  62. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
  63. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
  64. {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 COREKIT_SERIALIZATION_KEY, or use SerializerEngine.JSON."
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
- from .ids import generate_uuid
2
- from .raise_exc import raise_exc
3
- from .time import time_now, timedelta_now
4
- from .validators import false_validator, true_validator
5
- from .void import void
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
- import uuid
2
-
3
-
4
- def generate_uuid() -> str:
5
- return str(uuid.uuid4())
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)]
@@ -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)
@@ -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