python-corekit 0.1.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 (125) hide show
  1. corekit/__init__.py +0 -0
  2. corekit/api/__init__.py +9 -0
  3. corekit/api/handler.py +76 -0
  4. corekit/api/responses.py +40 -0
  5. corekit/api/routers.py +115 -0
  6. corekit/concurrency/__init__.py +9 -0
  7. corekit/concurrency/decorators.py +72 -0
  8. corekit/concurrency/thread_local.py +99 -0
  9. corekit/concurrency/worker.py +65 -0
  10. corekit/config/__init__.py +47 -0
  11. corekit/config/loader.py +153 -0
  12. corekit/config/settings.py +161 -0
  13. corekit/config/sources.py +125 -0
  14. corekit/connections/__init__.py +31 -0
  15. corekit/connections/connectable.py +212 -0
  16. corekit/connections/decorators.py +92 -0
  17. corekit/connections/redis/__init__.py +7 -0
  18. corekit/connections/redis/connection.py +239 -0
  19. corekit/connections/registry.py +80 -0
  20. corekit/connections/sql/__init__.py +10 -0
  21. corekit/connections/sql/connection.py +342 -0
  22. corekit/connections/sql/fields/__init__.py +7 -0
  23. corekit/connections/sql/fields/jsonb.py +67 -0
  24. corekit/connections/sql/migration/__init__.py +57 -0
  25. corekit/connections/sql/migration/base.py +40 -0
  26. corekit/connections/sql/migration/operations.py +416 -0
  27. corekit/connections/sql/migration/registry.py +166 -0
  28. corekit/connections/sql/migration/table.py +27 -0
  29. corekit/connections/sql/query.py +68 -0
  30. corekit/connections/sql/table.py +96 -0
  31. corekit/constants.py +45 -0
  32. corekit/crypto/__init__.py +1 -0
  33. corekit/crypto/constants.py +7 -0
  34. corekit/crypto/enum.py +11 -0
  35. corekit/crypto/hasher.py +89 -0
  36. corekit/data/__init__.py +81 -0
  37. corekit/data/dataset.py +340 -0
  38. corekit/data/expressions/__init__.py +46 -0
  39. corekit/data/expressions/comparison.py +252 -0
  40. corekit/data/expressions/expression.py +98 -0
  41. corekit/data/record.py +147 -0
  42. corekit/data/stats.py +157 -0
  43. corekit/decorators/__init__.py +2 -0
  44. corekit/decorators/exception_handling.py +43 -0
  45. corekit/decorators/warnings.py +35 -0
  46. corekit/docker/__init__.py +7 -0
  47. corekit/docker/watchdog.py +222 -0
  48. corekit/etl/__init__.py +44 -0
  49. corekit/etl/connection.py +44 -0
  50. corekit/etl/extract/__init__.py +0 -0
  51. corekit/etl/extract/extractor.py +48 -0
  52. corekit/etl/extract/schemas.py +18 -0
  53. corekit/etl/load/__init__.py +0 -0
  54. corekit/etl/load/loader.py +53 -0
  55. corekit/etl/load/schemas.py +33 -0
  56. corekit/etl/orchestrator.py +201 -0
  57. corekit/etl/schemas.py +22 -0
  58. corekit/etl/transform/__init__.py +0 -0
  59. corekit/etl/transform/schemas.py +15 -0
  60. corekit/etl/transform/transformer.py +28 -0
  61. corekit/events/__init__.py +38 -0
  62. corekit/events/enum.py +58 -0
  63. corekit/events/frames.py +51 -0
  64. corekit/events/models.py +23 -0
  65. corekit/events/publisher.py +75 -0
  66. corekit/events/reader.py +132 -0
  67. corekit/events/sse.py +109 -0
  68. corekit/events/websocket.py +97 -0
  69. corekit/exceptions/__init__.py +0 -0
  70. corekit/exceptions/base.py +45 -0
  71. corekit/exceptions/custom/__init__.py +0 -0
  72. corekit/exceptions/http/__init__.py +0 -0
  73. corekit/exceptions/http/exceptions.py +37 -0
  74. corekit/exceptions/types.py +17 -0
  75. corekit/files/__init__.py +25 -0
  76. corekit/files/base.py +117 -0
  77. corekit/files/enum.py +30 -0
  78. corekit/files/json.py +12 -0
  79. corekit/files/pickle.py +12 -0
  80. corekit/files/toml.py +43 -0
  81. corekit/http/__init__.py +0 -0
  82. corekit/http/client.py +176 -0
  83. corekit/http/exponential_backoff.py +100 -0
  84. corekit/http/response.py +12 -0
  85. corekit/log_monitor/__init__.py +23 -0
  86. corekit/log_monitor/constants.py +8 -0
  87. corekit/log_monitor/models.py +150 -0
  88. corekit/log_monitor/service.py +418 -0
  89. corekit/notifications/__init__.py +8 -0
  90. corekit/notifications/base.py +51 -0
  91. corekit/notifications/models.py +34 -0
  92. corekit/observability/__init__.py +21 -0
  93. corekit/observability/benchmarkable.py +12 -0
  94. corekit/observability/loggable.py +29 -0
  95. corekit/observability/timing/__init__.py +0 -0
  96. corekit/observability/timing/constants.py +1 -0
  97. corekit/observability/timing/split.py +20 -0
  98. corekit/observability/timing/timer.py +30 -0
  99. corekit/py.typed +0 -0
  100. corekit/registry/__init__.py +12 -0
  101. corekit/registry/registry.py +134 -0
  102. corekit/schemas/__init__.py +0 -0
  103. corekit/schemas/dataclasses/__init__.py +0 -0
  104. corekit/schemas/enum.py +49 -0
  105. corekit/schemas/models/__init__.py +0 -0
  106. corekit/schemas/models/arbitrary.py +11 -0
  107. corekit/schemas/models/date_models.py +18 -0
  108. corekit/schemas/pydantic/__init__.py +0 -0
  109. corekit/schemas/pydantic/fields.py +35 -0
  110. corekit/schemas/types.py +40 -0
  111. corekit/serialization/__init__.py +0 -0
  112. corekit/serialization/enum.py +21 -0
  113. corekit/serialization/serializable.py +42 -0
  114. corekit/serialization/serializer.py +179 -0
  115. corekit/utils/__init__.py +5 -0
  116. corekit/utils/ids.py +5 -0
  117. corekit/utils/raise_exc.py +8 -0
  118. corekit/utils/time.py +21 -0
  119. corekit/utils/validators.py +15 -0
  120. corekit/utils/void.py +8 -0
  121. python_corekit-0.1.0.dist-info/METADATA +417 -0
  122. python_corekit-0.1.0.dist-info/RECORD +125 -0
  123. python_corekit-0.1.0.dist-info/WHEEL +5 -0
  124. python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
  125. python_corekit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,12 @@
1
+ from corekit.observability.loggable import Loggable
2
+ from corekit.observability.timing.timer import Timer
3
+
4
+
5
+ class Benchmarkable(Loggable):
6
+ def __init__(self) -> None:
7
+ super().__init__()
8
+ self._timing = Timer()
9
+
10
+ def timing(self, split_name: str | None = None) -> None:
11
+ split_message = str(self._timing.split(split_name=split_name))
12
+ self.info(split_message)
@@ -0,0 +1,29 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+
5
+ class Loggable:
6
+ """
7
+ Base class with a built-in logger named after the concrete class.
8
+
9
+ Accepts and ignores ``*args``/``**kwargs`` so it can sit anywhere in a
10
+ cooperative ``super().__init__()`` chain.
11
+ """
12
+
13
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
14
+ self.logger = logging.getLogger(self.__class__.__name__)
15
+
16
+ def debug(self, message: str, **kwargs: Any) -> None:
17
+ self.logger.debug(message, **kwargs)
18
+
19
+ def info(self, message: str, **kwargs: Any) -> None:
20
+ self.logger.info(message, **kwargs)
21
+
22
+ def warning(self, message: str, **kwargs: Any) -> None:
23
+ self.logger.warning(message, **kwargs)
24
+
25
+ def error(self, message: str, **kwargs: Any) -> None:
26
+ self.logger.error(message, **kwargs)
27
+
28
+ def exception(self, message: str, **kwargs: Any) -> None:
29
+ self.logger.exception(message, **kwargs)
File without changes
@@ -0,0 +1 @@
1
+ DEFAULT_PRECISION = 4
@@ -0,0 +1,20 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Split(BaseModel):
5
+ num: int
6
+ total: float
7
+ latest: float
8
+ name: str | None = None
9
+ split_name: str | None = None
10
+
11
+ def __str__(self) -> str:
12
+ identifier = ""
13
+ if self.name:
14
+ identifier += f" {self.name}"
15
+ if self.split_name:
16
+ identifier += f" {self.split_name}"
17
+ return f"{identifier} Split {self.num}: total={self.total}, latest_split={self.latest}".strip()
18
+
19
+ def __repr__(self) -> str:
20
+ return self.__str__()
@@ -0,0 +1,30 @@
1
+ import time
2
+
3
+ from corekit.observability.timing.constants import DEFAULT_PRECISION
4
+ from corekit.observability.timing.split import Split
5
+
6
+
7
+ class Timer:
8
+ def __init__(self, precision: int = DEFAULT_PRECISION) -> None:
9
+ _time = time.time()
10
+ self.start = _time
11
+ self.latest = _time
12
+ self.num = 0
13
+ self.name = type(self).__name__
14
+ self.precision = precision if precision > 0 else DEFAULT_PRECISION
15
+
16
+ def _round(self, value: float) -> float:
17
+ return round(value, self.precision)
18
+
19
+ def split(self, split_name: str | None = None) -> Split:
20
+ _time = time.time()
21
+ self.num += 1
22
+ split = Split(
23
+ num=self.num,
24
+ total=self._round(_time - self.start),
25
+ latest=self._round(_time - self.latest),
26
+ name=self.name,
27
+ split_name=split_name,
28
+ )
29
+ self.latest = _time
30
+ return split
corekit/py.typed ADDED
File without changes
@@ -0,0 +1,12 @@
1
+ """
2
+ Key-normalizing registry.
3
+
4
+ ``SmartRegistry`` folds case, trims whitespace and treats spaces and underscores
5
+ as hyphens, so ``"AI Conversation Handler"``, ``"ai_conversation_handler"`` and
6
+ ``"ai-conversation-handler"`` all address the same entry. That forgiveness
7
+ matters wherever a human -- or a language model -- supplies the key.
8
+ """
9
+
10
+ from corekit.registry.registry import SmartRegistry
11
+
12
+ __all__ = ["SmartRegistry"]
@@ -0,0 +1,134 @@
1
+ import re
2
+ from typing import Any, Iterator
3
+
4
+ REPLACEMENT_CHAR = "-"
5
+ NORMALIZATION_PATTERN = re.compile(r"[\s_-]+")
6
+ # Split CamelCase into words: "HTTPServerError" -> "HTTP-Server-Error".
7
+ CAMEL_BOUNDARY_PATTERN = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
8
+
9
+
10
+ class SmartRegistry:
11
+ """
12
+ A specialized registry object that handles key normalization and
13
+ provides a unified interface for discovery across different layers.
14
+ """
15
+
16
+ __slots__ = ("__registry__",)
17
+
18
+ def __init__(self) -> None:
19
+ """
20
+ Initialize an empty registry.
21
+ """
22
+ self.__registry__ = {}
23
+
24
+ def __len__(self) -> int:
25
+ """
26
+ Return the number of items in the registry.
27
+ """
28
+ return len(self.__registry__)
29
+
30
+ def __getitem__(self, key: str) -> Any:
31
+ """
32
+ Retrieve an item from the registry using a normalized key.
33
+ """
34
+ return self.__registry__[self.__normalize_key__(key)]
35
+
36
+ def __setitem__(self, key: str, value: Any) -> None:
37
+ """
38
+ Store an item in the registry with a normalized key.
39
+ """
40
+ self.__registry__[self.__normalize_key__(key)] = value
41
+
42
+ def __delitem__(self, key: str) -> None:
43
+ """
44
+ Remove an item from the registry.
45
+ """
46
+ del self.__registry__[self.__normalize_key__(key)]
47
+
48
+ def __iter__(self) -> Iterator[str]:
49
+ """
50
+ Return an iterator over the normalized keys in the registry.
51
+ """
52
+ return iter(self.__registry__)
53
+
54
+ def items(self) -> Any:
55
+ """
56
+ Return the items in the registry as (key, value) pairs.
57
+ """
58
+ return self.__registry__.items()
59
+
60
+ def keys(self) -> Any:
61
+ """
62
+ Return the normalized keys in the registry.
63
+ """
64
+ return self.__registry__.keys()
65
+
66
+ def values(self) -> Any:
67
+ """
68
+ Return the values stored in the registry.
69
+ """
70
+ return self.__registry__.values()
71
+
72
+ def __contains__(self, key: str) -> bool:
73
+ """
74
+ Check if a normalized key exists in the registry.
75
+ """
76
+ return self.__normalize_key__(key) in self.__registry__
77
+
78
+ def __str__(self) -> str:
79
+ """
80
+ Return a string representation of the underlying registry.
81
+ """
82
+ return str(self.__registry__)
83
+
84
+ def __repr__(self) -> str:
85
+ """
86
+ Return a formal representation of the SmartRegistry.
87
+ """
88
+ return f"{self.__class__.__name__}({self.__registry__})"
89
+
90
+ def __eq__(self, other: Any) -> bool:
91
+ """
92
+ Compare this registry with another object for equality.
93
+ """
94
+ if not isinstance(other, SmartRegistry):
95
+ return False
96
+ return self.__registry__ == other.__registry__
97
+
98
+ def __ne__(self, other: Any) -> bool:
99
+ """
100
+ Compare this registry with another object for inequality.
101
+ """
102
+ return not self == other
103
+
104
+ def __getstate__(self) -> dict[str, Any]:
105
+ """
106
+ Return the internal state for serialization.
107
+ """
108
+ return self.__registry__
109
+
110
+ def __setstate__(self, state: dict[str, Any]) -> None:
111
+ """
112
+ Restore the internal state from a serialized dictionary.
113
+ """
114
+ self.__registry__ = state
115
+
116
+ @staticmethod
117
+ def __normalize_key__(key: str) -> str:
118
+ """
119
+ Reduce a key to a canonical hyphenated form.
120
+
121
+ Word boundaries are taken from CamelCase as well as from whitespace,
122
+ underscores and hyphens, so ``"GreetingHandler"``, ``"greeting_handler"``
123
+ and ``"Greeting Handler"`` all normalize to ``"greeting-handler"``.
124
+ Without the CamelCase step a class registered under its ``__name__``
125
+ could not be found by the snake_case name a caller would naturally type.
126
+ """
127
+ spaced = CAMEL_BOUNDARY_PATTERN.sub(REPLACEMENT_CHAR, key.strip())
128
+ return NORMALIZATION_PATTERN.sub(REPLACEMENT_CHAR, spaced.lower()).strip(REPLACEMENT_CHAR)
129
+
130
+ def get(self, key: str, fallback: Any = None) -> Any:
131
+ """
132
+ Safely retrieve an item from the registry with an optional fallback.
133
+ """
134
+ return self.__registry__.get(self.__normalize_key__(key), fallback)
File without changes
File without changes
@@ -0,0 +1,49 @@
1
+ from enum import Enum
2
+ from typing import Any
3
+
4
+
5
+ class ValidatingEnum(Enum):
6
+ """
7
+ A base class for enums that validates values against a set of allowed values.
8
+ """
9
+
10
+ @classmethod
11
+ def is_member(cls, value: Any) -> bool:
12
+ """
13
+ Check if the value is a member of the enum.
14
+ """
15
+ return value in cls._value2member_map_
16
+
17
+ @classmethod
18
+ def validate_and_create(cls, value: Any) -> "ValidatingEnum":
19
+ if cls.is_member(value):
20
+ return cls(value)
21
+ raise ValueError(f"{cls.__name__} does not contain {cls}")
22
+
23
+ @classmethod
24
+ def get_all_members(cls) -> list["ValidatingEnum"]:
25
+ """
26
+ Get all members of the enum.
27
+ """
28
+ return list(cls._value2member_map_.values()) # type: ignore[return-value]
29
+
30
+ def display_name(self) -> str:
31
+ return self.name.replace("_", " ").title()
32
+
33
+
34
+ class StringEnum(str, ValidatingEnum):
35
+ @classmethod
36
+ def from_string(cls, value: str) -> "StringEnum":
37
+ """
38
+ Convert a string to the corresponding enum member.
39
+ """
40
+ return cls.validate_and_create(value)
41
+
42
+
43
+ class IntegerEnum(int, ValidatingEnum):
44
+ @classmethod
45
+ def from_int(cls, value: int) -> "IntegerEnum":
46
+ """
47
+ Convert an integer to the corresponding enum member.
48
+ """
49
+ return cls.validate_and_create(value)
File without changes
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class ArbitraryModel(BaseModel):
5
+ """
6
+ Base model for arbitrary data. This model is used to take unstructured data and convert it into
7
+ a structured format that can be easily mapped to a model. This model is capable of parsing any type of data
8
+ and converting it into a structured format that can be easily accessed and manipulated.
9
+ """
10
+
11
+ pass
@@ -0,0 +1,18 @@
1
+ from datetime import timedelta
2
+ from typing import NamedTuple
3
+
4
+ from corekit.schemas.types import ArbitraryDate
5
+
6
+
7
+ class DateRange(NamedTuple):
8
+ start: ArbitraryDate
9
+ end: ArbitraryDate
10
+
11
+ def __str__(self) -> str:
12
+ return f"{self.start} to {self.end}"
13
+
14
+ def __repr__(self) -> str:
15
+ return self.__str__()
16
+
17
+ def delta(self) -> timedelta:
18
+ return self.end - self.start
File without changes
@@ -0,0 +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)
@@ -0,0 +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
File without changes
@@ -0,0 +1,21 @@
1
+ from corekit.schemas.enum import StringEnum
2
+
3
+
4
+ class SerializerEngine(StringEnum):
5
+ DILL = "dill"
6
+ PICKLE = "pickle"
7
+ JSON = "json"
8
+
9
+ @classmethod
10
+ def get_default(cls) -> "SerializerEngine":
11
+ """
12
+ JSON, because it cannot execute code while loading.
13
+ """
14
+ return cls.JSON
15
+
16
+ @classmethod
17
+ def from_bytes(cls, name_in_bytes: bytes) -> "SerializerEngine":
18
+ return cls(name_in_bytes.decode("utf-8"))
19
+
20
+ def is_valid_for_class(self) -> bool:
21
+ return self in {SerializerEngine.DILL, SerializerEngine.PICKLE}
@@ -0,0 +1,42 @@
1
+ """
2
+ Mixin for objects that can serialize themselves.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ from corekit.serialization.serializer import Serializer
8
+
9
+ __all__ = ["Serializable"]
10
+
11
+
12
+ class Serializable:
13
+ """
14
+ Gives a class ``serialize`` and ``from_serialized``.
15
+
16
+ Serializing an arbitrary object needs pickle or dill, both of which execute
17
+ code on load, so a key is required -- see ``Serializer``.
18
+ """
19
+
20
+ def __init__(self, **kwargs: Any) -> None:
21
+ self._serializer = Serializer(**kwargs)
22
+
23
+ @classmethod
24
+ def from_serialized(cls, serialized: bytes, **kwargs: Any) -> Any:
25
+ """
26
+ Rebuild an instance from bytes produced by ``serialize``.
27
+
28
+ The engine and key are supplied by the caller rather than read from the
29
+ payload, so the receiver decides how the bytes are decoded.
30
+ """
31
+ return Serializer(**kwargs).deserialize(serialized)
32
+
33
+ def serialize(self) -> bytes:
34
+ """
35
+ Encode this object.
36
+ """
37
+ if not self._serializer.is_valid_for_class():
38
+ raise TypeError(
39
+ f"{type(self).__name__} needs an engine that can encode arbitrary objects "
40
+ f"(pickle or dill), which requires a key."
41
+ )
42
+ return self._serializer.serialize(self)
@@ -0,0 +1,179 @@
1
+ """
2
+ Serialization with authenticated payloads.
3
+
4
+ ``pickle`` and ``dill`` execute code while loading. Anything that reaches
5
+ ``loads`` can run arbitrary code, so a payload must be proven to come from a
6
+ holder of the shared key **before** it is decoded, and the decoder must be
7
+ chosen by the receiver rather than read out of the payload.
8
+
9
+ serializer = Serializer(SerializerEngine.JSON, key=b"shared-secret")
10
+ blob = serializer.serialize({"a": 1})
11
+ serializer.deserialize(blob)
12
+
13
+ JSON is the default because it cannot execute code. Choosing ``pickle`` or
14
+ ``dill`` requires a key, and payloads carrying them are rejected unless their
15
+ HMAC verifies.
16
+ """
17
+
18
+ import hashlib
19
+ import hmac
20
+ import json
21
+ import pickle
22
+ from typing import Any
23
+
24
+ import dill
25
+
26
+ from corekit.config import get_settings
27
+ from corekit.serialization.enum import SerializerEngine
28
+
29
+ __all__ = ["Serializer", "SignatureError", "UnsafeEngineError"]
30
+
31
+ DELIMITER = b":"
32
+ DIGEST = hashlib.sha256
33
+
34
+ # Engines that execute code while loading. A payload naming one of these is
35
+ # only decoded after its signature verifies.
36
+ EXECUTING_ENGINES = frozenset({SerializerEngine.PICKLE, SerializerEngine.DILL})
37
+
38
+ _ENGINE_MODULES = {
39
+ SerializerEngine.JSON: json,
40
+ SerializerEngine.PICKLE: pickle,
41
+ SerializerEngine.DILL: dill,
42
+ }
43
+
44
+
45
+ class SignatureError(Exception):
46
+ """
47
+ Raised when a payload's signature is missing or does not verify.
48
+ """
49
+
50
+
51
+ class UnsafeEngineError(Exception):
52
+ """
53
+ Raised when a code-executing engine is requested without a key.
54
+ """
55
+
56
+
57
+ class Serializer:
58
+ """
59
+ Serializes to bytes, optionally authenticated with an HMAC.
60
+
61
+ Without a key only JSON is available. With one, every payload is signed on
62
+ the way out and verified on the way in.
63
+ """
64
+
65
+ def __init__(self, engine: SerializerEngine | None = None, key: bytes | str | None = None) -> None:
66
+ self._engine = engine or SerializerEngine.get_default()
67
+ self._key = self._resolve_key(key)
68
+
69
+ if self._engine in EXECUTING_ENGINES and self._key is None:
70
+ raise UnsafeEngineError(
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."
73
+ )
74
+
75
+ @staticmethod
76
+ def _resolve_key(key: bytes | str | None) -> bytes | None:
77
+ """
78
+ Take the supplied key, else the configured one, else none.
79
+ """
80
+ if key is None:
81
+ key = get_settings().serialization.key
82
+ if key is None:
83
+ return None
84
+ return key.encode("utf-8") if isinstance(key, str) else key
85
+
86
+ @property
87
+ def encoding(self) -> str:
88
+ return "utf-8"
89
+
90
+ @property
91
+ def engine(self) -> Any:
92
+ """
93
+ The module implementing the selected engine.
94
+ """
95
+ return _ENGINE_MODULES[self._engine]
96
+
97
+ @property
98
+ def is_signed(self) -> bool:
99
+ """
100
+ Whether this serializer authenticates its payloads.
101
+ """
102
+ return self._key is not None
103
+
104
+ def is_valid_for_class(self) -> bool:
105
+ """
106
+ Whether the engine can serialize an arbitrary object graph.
107
+ """
108
+ return self._engine.is_valid_for_class()
109
+
110
+ def _dumps(self, value: Any) -> bytes:
111
+ """
112
+ Encode a value, normalizing JSON's str output to bytes.
113
+ """
114
+ encoded = self.engine.dumps(value)
115
+ return encoded.encode(self.encoding) if isinstance(encoded, str) else encoded
116
+
117
+ def _compute_mac(self, engine_bytes: bytes, payload: bytes) -> bytes:
118
+ """
119
+ Authenticate the engine name together with the payload.
120
+
121
+ Covering the engine name prevents an attacker from taking a valid JSON
122
+ payload and re-labelling it as pickle.
123
+ """
124
+ assert self._key is not None
125
+ return hmac.new(self._key, engine_bytes + DELIMITER + payload, DIGEST).hexdigest().encode("ascii")
126
+
127
+ def serialize(self, value: Any) -> bytes:
128
+ """
129
+ Encode a value, signing it when a key is configured.
130
+
131
+ Produces ``engine:payload``, or ``engine:mac:payload`` when signed.
132
+ """
133
+ engine_bytes = self._engine.value.encode(self.encoding)
134
+ payload = self._dumps(value)
135
+
136
+ if self._key is None:
137
+ return engine_bytes + DELIMITER + payload
138
+
139
+ mac = self._compute_mac(engine_bytes, payload)
140
+ return engine_bytes + DELIMITER + mac + DELIMITER + payload
141
+
142
+ def deserialize(self, value: bytes) -> Any:
143
+ """
144
+ Decode a payload produced by ``serialize``.
145
+
146
+ The engine named in the payload must match this serializer's, so the
147
+ receiver decides how bytes are decoded. A signed serializer verifies the
148
+ HMAC before decoding anything.
149
+ """
150
+ engine_bytes, _, remainder = value.partition(DELIMITER)
151
+ if not remainder:
152
+ raise ValueError("Malformed payload: expected engine:payload")
153
+
154
+ try:
155
+ engine = SerializerEngine.from_bytes(engine_bytes)
156
+ except ValueError as exc:
157
+ raise ValueError(f"Unknown serializer engine {engine_bytes!r}") from exc
158
+
159
+ # The receiver's configuration decides the decoder. Honouring the
160
+ # payload's choice would let a caller select pickle and execute code.
161
+ if engine is not self._engine:
162
+ raise SignatureError(
163
+ f"Payload was serialized with {engine.value}, but this serializer uses {self._engine.value}. "
164
+ f"Construct a Serializer for that engine explicitly if the source is trusted."
165
+ )
166
+
167
+ if self._key is None:
168
+ if engine in EXECUTING_ENGINES:
169
+ raise UnsafeEngineError(f"Refusing to load an unsigned {engine.value} payload.")
170
+ return self.engine.loads(remainder)
171
+
172
+ mac, _, payload = remainder.partition(DELIMITER)
173
+ if not payload:
174
+ raise SignatureError("Payload is unsigned, but this serializer requires a signature.")
175
+
176
+ if not hmac.compare_digest(mac, self._compute_mac(engine_bytes, payload)):
177
+ raise SignatureError("Payload signature does not verify; refusing to deserialize.")
178
+
179
+ return self.engine.loads(payload)
@@ -0,0 +1,5 @@
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
corekit/utils/ids.py ADDED
@@ -0,0 +1,5 @@
1
+ import uuid
2
+
3
+
4
+ def generate_uuid() -> str:
5
+ return str(uuid.uuid4())