python-corekit 0.2.0__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/application.py +47 -9
- corekit/api/lifespan.py +26 -3
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +9 -0
- 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 +2 -1
- corekit/connections/sql/connection.py +39 -5
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +4 -0
- corekit/connections/sql/migration/operations.py +69 -2
- corekit/connections/sql/operations/base.py +11 -2
- corekit/connections/sql/operations/statements.py +25 -5
- corekit/connections/sql/table.py +7 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +3 -3
- corekit/data/expressions/comparison.py +19 -80
- corekit/data/expressions/expression.py +0 -32
- corekit/data/expressions/operator.py +13 -28
- corekit/data/stats.py +3 -0
- corekit/decorators/exception_handling.py +36 -8
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +14 -12
- 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 +24 -11
- corekit/exceptions/__init__.py +24 -9
- 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 +43 -5
- 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/runner.py +12 -1
- corekit/jobs/task.py +23 -2
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +9 -2
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +55 -2
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +2 -2
- corekit/registry/registry.py +55 -14
- corekit/schemas/enum.py +22 -1
- corekit/schemas/types.py +6 -1
- corekit/serialization/__init__.py +2 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +9 -2
- corekit/utils/collections.py +22 -13
- corekit/utils/payload.py +12 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/METADATA +7 -7
- 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.2.0.dist-info/RECORD +0 -143
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
corekit/etl/schemas.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
|
|
3
|
-
from corekit.etl.connection import
|
|
3
|
+
from corekit.etl.connection import ETLSource
|
|
4
4
|
from corekit.schemas.types import UnknownDict
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ class BaseItem(BaseModel):
|
|
|
9
9
|
Base model for all ETL items
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
connection:
|
|
12
|
+
connection: ETLSource
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class RawAPIResponse(BaseItem):
|
|
@@ -18,7 +18,10 @@ class BaseETLTransformer(Loggable, ABC):
|
|
|
18
18
|
|
|
19
19
|
def transform(self, item: ExtractedItem) -> TransformedItem:
|
|
20
20
|
"""
|
|
21
|
-
|
|
21
|
+
Turn one extracted item into a loadable one.
|
|
22
|
+
|
|
23
|
+
Synchronous on purpose. The orchestrator calls this directly and does
|
|
24
|
+
not await it.
|
|
22
25
|
"""
|
|
23
26
|
data_type = item.get_data_type()
|
|
24
27
|
mapping = self._extracted_item_type_to_method
|
corekit/events/publisher.py
CHANGED
|
@@ -11,7 +11,7 @@ constant::
|
|
|
11
11
|
EventPublisher.for_resource("minecraft", "server", "survival")
|
|
12
12
|
# -> channel "minecraft:server:survival"
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Redis is a hard dependency of corekit; no extra install is required.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from typing import Any
|
corekit/events/reader.py
CHANGED
|
@@ -8,7 +8,9 @@ disappears -- so no consumer has to repeat it.
|
|
|
8
8
|
Everything here uses Redis's async client. The synchronous client's
|
|
9
9
|
``get_message`` blocks the thread it is called on, which in an async server is
|
|
10
10
|
the event loop, stalling every other request in the process while one client
|
|
11
|
-
waits for an event that may never come.
|
|
11
|
+
waits for an event that may never come. The async client's ``get_message``
|
|
12
|
+
returns immediately unless it is given a timeout, so the wait has to be
|
|
13
|
+
asked for there rather than wrapped around a call that already finished.
|
|
12
14
|
"""
|
|
13
15
|
|
|
14
16
|
import asyncio
|
|
@@ -21,9 +23,6 @@ from corekit.observability.loggable import Loggable
|
|
|
21
23
|
|
|
22
24
|
__all__ = ["RedisChannelReader"]
|
|
23
25
|
|
|
24
|
-
# How long to yield to the event loop when the socket has nothing ready.
|
|
25
|
-
IDLE_SLEEP_SECONDS = 0.01
|
|
26
|
-
|
|
27
26
|
|
|
28
27
|
class RedisChannelReader(Loggable):
|
|
29
28
|
"""
|
|
@@ -88,27 +87,33 @@ class RedisChannelReader(Loggable):
|
|
|
88
87
|
"""
|
|
89
88
|
Return the next decoded payload.
|
|
90
89
|
|
|
91
|
-
Returns ``None`` when nothing arrived before ``timeout
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
Returns ``None`` only when nothing arrived before ``timeout``. The wait
|
|
91
|
+
happens inside Redis ``get_message``: calling it with no timeout
|
|
92
|
+
returns immediately, and wrapping that in ``asyncio.wait_for`` never
|
|
93
|
+
blocks. ``timeout=None`` waits until a payload arrives.
|
|
94
94
|
"""
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
loop = asyncio.get_running_loop()
|
|
96
|
+
deadline = None if timeout is None else loop.time() + timeout
|
|
97
|
+
while True:
|
|
98
|
+
remaining = None if deadline is None else deadline - loop.time()
|
|
99
|
+
if remaining is not None and remaining <= 0:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
message = await self._pubsub.get_message(
|
|
103
|
+
ignore_subscribe_messages=True,
|
|
104
|
+
timeout=remaining,
|
|
99
105
|
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return None
|
|
106
|
+
if message is None:
|
|
107
|
+
# None before the deadline is an ignored subscribe ack, not
|
|
108
|
+
# silence. Only the expired wait is a keepalive.
|
|
109
|
+
if deadline is None or loop.time() < deadline:
|
|
110
|
+
continue
|
|
111
|
+
return None
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
if message.get(PubSubField.TYPE.value) not in (None, PubSubMessageType.MESSAGE.value):
|
|
114
|
+
continue
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
return self._decode(message.get(PubSubField.DATA.value))
|
|
112
117
|
|
|
113
118
|
def _decode(self, raw: Any) -> Any:
|
|
114
119
|
"""
|
corekit/events/sse.py
CHANGED
|
@@ -93,7 +93,10 @@ class SSEStream(Loggable):
|
|
|
93
93
|
while True:
|
|
94
94
|
payload = await self._reader.next_payload(timeout=self.keepalive_interval)
|
|
95
95
|
if payload is None:
|
|
96
|
-
|
|
96
|
+
# None means the wait expired. A disabled keepalive never
|
|
97
|
+
# asks for a timeout, so it must not emit a comment either.
|
|
98
|
+
if self.keepalive_interval is not None:
|
|
99
|
+
yield SSEFrame.comment()
|
|
97
100
|
continue
|
|
98
101
|
yield self._frame_for(payload)
|
|
99
102
|
|
corekit/events/websocket.py
CHANGED
|
@@ -64,9 +64,16 @@ class WebSocketBridge(Loggable):
|
|
|
64
64
|
"""
|
|
65
65
|
Whether the bridge has outlived its timeout.
|
|
66
66
|
"""
|
|
67
|
+
remaining = self._remaining(started)
|
|
68
|
+
return remaining is not None and remaining <= 0
|
|
69
|
+
|
|
70
|
+
def _remaining(self, started: float) -> float | None:
|
|
71
|
+
"""
|
|
72
|
+
Seconds left before the bridge times out, or None if it never does.
|
|
73
|
+
"""
|
|
67
74
|
if self.timeout is None:
|
|
68
|
-
return
|
|
69
|
-
return asyncio.
|
|
75
|
+
return None
|
|
76
|
+
return self.timeout - (asyncio.get_running_loop().time() - started)
|
|
70
77
|
|
|
71
78
|
async def run(self) -> None:
|
|
72
79
|
"""
|
|
@@ -75,21 +82,27 @@ class WebSocketBridge(Loggable):
|
|
|
75
82
|
if not await self._reader.open():
|
|
76
83
|
return
|
|
77
84
|
|
|
78
|
-
started = asyncio.
|
|
85
|
+
started = asyncio.get_running_loop().time()
|
|
79
86
|
try:
|
|
80
87
|
while True:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if self._is_terminal(payload):
|
|
86
|
-
self.info(f"Bridge on {self.channel} reached a terminal status")
|
|
87
|
-
break
|
|
88
|
+
remaining = self._remaining(started)
|
|
89
|
+
if remaining is not None and remaining <= 0:
|
|
90
|
+
self.warning(f"Bridge on {self.channel} timed out")
|
|
91
|
+
break
|
|
88
92
|
|
|
89
|
-
|
|
93
|
+
payload = await self._reader.next_payload(timeout=remaining)
|
|
94
|
+
if payload is None:
|
|
95
|
+
# The wait expired. That is the timeout, not a reason to poll again.
|
|
96
|
+
if remaining is None:
|
|
97
|
+
continue
|
|
90
98
|
self.warning(f"Bridge on {self.channel} timed out")
|
|
91
99
|
break
|
|
92
100
|
|
|
101
|
+
await self.websocket.send_json(payload)
|
|
102
|
+
if self._is_terminal(payload):
|
|
103
|
+
self.info(f"Bridge on {self.channel} reached a terminal status")
|
|
104
|
+
break
|
|
105
|
+
|
|
93
106
|
except asyncio.CancelledError:
|
|
94
107
|
raise
|
|
95
108
|
except Exception as exc:
|
corekit/exceptions/__init__.py
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Exception base classes.
|
|
3
3
|
|
|
4
|
-
``
|
|
5
|
-
``
|
|
4
|
+
``CoreException`` is abstract. Raise ``InternalCoreException`` for backend-only
|
|
5
|
+
failures, or ``PublicCoreException`` when there is copy that is safe to show
|
|
6
|
+
a user. ``CoreHTTPException`` is the FastAPI-facing public exception.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
|
-
from corekit.exceptions.base import
|
|
9
|
-
|
|
9
|
+
from corekit.exceptions.base import (
|
|
10
|
+
CoreException,
|
|
11
|
+
CoreHTTPException,
|
|
12
|
+
ExponentialBackoffTimeoutException,
|
|
13
|
+
InternalCoreException,
|
|
14
|
+
NonRetryableCoreHTTPException,
|
|
15
|
+
PublicCoreException,
|
|
16
|
+
RetryableCoreHTTPException,
|
|
17
|
+
)
|
|
18
|
+
from corekit.exceptions.enum import Retryability
|
|
19
|
+
from corekit.exceptions.types import ArbitraryCoreExceptionType, CoreExceptionType, CoreHTTPExceptionType
|
|
10
20
|
|
|
11
21
|
__all__ = [
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
22
|
+
"ArbitraryCoreExceptionType",
|
|
23
|
+
"CoreException",
|
|
24
|
+
"CoreExceptionType",
|
|
25
|
+
"CoreHTTPException",
|
|
26
|
+
"CoreHTTPExceptionType",
|
|
17
27
|
"ExponentialBackoffTimeoutException",
|
|
28
|
+
"InternalCoreException",
|
|
29
|
+
"NonRetryableCoreHTTPException",
|
|
30
|
+
"PublicCoreException",
|
|
31
|
+
"Retryability",
|
|
32
|
+
"RetryableCoreHTTPException",
|
|
18
33
|
]
|
corekit/exceptions/base.py
CHANGED
|
@@ -1,26 +1,104 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Base exception types.
|
|
3
|
+
|
|
4
|
+
Every corekit exception is one of two shapes:
|
|
5
|
+
|
|
6
|
+
* ``InternalCoreException`` -- backend-only. ``message``/``error`` are for
|
|
7
|
+
logs and debugging and must never reach an end user; there is no field to
|
|
8
|
+
put user-facing copy in, so there is nothing to leak by accident.
|
|
9
|
+
* ``PublicCoreException`` -- also carries ``user_message``, pre-written copy
|
|
10
|
+
that *is* safe to show externally. ``message``/``error`` stay backend-only
|
|
11
|
+
even here; only ``user_message`` should ever be serialized to a client.
|
|
12
|
+
|
|
13
|
+
Both carry ``retryable``, so a caller (or whatever eventually surfaces the
|
|
14
|
+
error) can tell whether trying again is worth it without re-deriving that
|
|
15
|
+
from a status code every time.
|
|
16
|
+
|
|
17
|
+
``CoreException`` itself is abstract -- it exists to hold the shared fields,
|
|
18
|
+
not to be raised. Raise one of the two subclasses above (or a subclass of
|
|
19
|
+
those, like ``CoreHTTPException`` and the typed HTTP errors in ``corekit.http``).
|
|
3
20
|
"""
|
|
4
21
|
|
|
22
|
+
from abc import ABC
|
|
5
23
|
from typing import Any
|
|
6
24
|
|
|
7
25
|
from fastapi import HTTPException
|
|
8
26
|
|
|
9
|
-
|
|
27
|
+
from corekit.exceptions.enum import Retryability
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CoreException",
|
|
31
|
+
"CoreHTTPException",
|
|
32
|
+
"RetryableCoreHTTPException",
|
|
33
|
+
"NonRetryableCoreHTTPException",
|
|
34
|
+
"ExponentialBackoffTimeoutException",
|
|
35
|
+
"InternalCoreException",
|
|
36
|
+
"PublicCoreException",
|
|
37
|
+
]
|
|
10
38
|
|
|
11
39
|
|
|
12
|
-
class
|
|
40
|
+
class CoreException(Exception, ABC):
|
|
13
41
|
"""
|
|
14
|
-
|
|
42
|
+
Abstract base carrying the fields every corekit exception shares.
|
|
15
43
|
"""
|
|
16
44
|
|
|
17
|
-
def __init__(
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
message: str,
|
|
48
|
+
*,
|
|
49
|
+
retryable: Retryability = Retryability.UNKNOWN,
|
|
50
|
+
error: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if type(self) is CoreException:
|
|
53
|
+
raise TypeError("CoreException is abstract -- raise InternalCoreException or PublicCoreException")
|
|
18
54
|
super().__init__(message)
|
|
19
55
|
self.message = message
|
|
20
56
|
self.error = error
|
|
57
|
+
self.retryable = retryable
|
|
58
|
+
|
|
59
|
+
def for_log(self) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Backend text for logs.
|
|
62
|
+
|
|
63
|
+
``str(self)`` on a public exception is the user-facing copy, so a log
|
|
64
|
+
line of ``str(exc)`` drops ``message``. This always returns the
|
|
65
|
+
backend message, and the separate error detail when one was given.
|
|
66
|
+
"""
|
|
67
|
+
if self.error:
|
|
68
|
+
return f"{self.message} ({self.error})"
|
|
69
|
+
return self.message
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InternalCoreException(CoreException):
|
|
73
|
+
"""
|
|
74
|
+
Backend-only exception. Never expose ``message`` or ``error`` to a user.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PublicCoreException(CoreException):
|
|
79
|
+
"""
|
|
80
|
+
Exception with pre-written copy that is safe to show an end user.
|
|
81
|
+
|
|
82
|
+
``message``/``error`` remain backend-only, for logs; ``user_message`` is
|
|
83
|
+
the only field on this exception meant to leave the backend.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
message: str,
|
|
89
|
+
user_message: str,
|
|
90
|
+
*,
|
|
91
|
+
retryable: Retryability = Retryability.UNKNOWN,
|
|
92
|
+
error: str | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
super().__init__(message, retryable=retryable, error=error)
|
|
95
|
+
self.user_message = user_message
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str:
|
|
98
|
+
return self.user_message
|
|
21
99
|
|
|
22
100
|
|
|
23
|
-
class ExponentialBackoffTimeoutException(
|
|
101
|
+
class ExponentialBackoffTimeoutException(InternalCoreException):
|
|
24
102
|
"""
|
|
25
103
|
Raised when a retry loop exhausts its attempts without succeeding.
|
|
26
104
|
"""
|
|
@@ -28,18 +106,69 @@ class ExponentialBackoffTimeoutException(CustomException):
|
|
|
28
106
|
def __init__(self, attempts: int, error: str | None = None) -> None:
|
|
29
107
|
super().__init__(
|
|
30
108
|
message=f"Exponential Backoff timed out after {attempts} attempts",
|
|
109
|
+
retryable=Retryability.NON_RETRYABLE,
|
|
31
110
|
error=error,
|
|
32
111
|
)
|
|
33
112
|
self.attempts = attempts
|
|
34
113
|
|
|
35
114
|
|
|
36
|
-
class
|
|
115
|
+
class CoreHTTPException(HTTPException, PublicCoreException):
|
|
116
|
+
"""
|
|
117
|
+
FastAPI HTTPException that is also a PublicCoreException.
|
|
118
|
+
|
|
119
|
+
``detail`` (FastAPI's client-facing field) and ``user_message`` are kept
|
|
120
|
+
in sync -- pass either one. ``message``/``error`` default to ``detail``
|
|
121
|
+
when omitted, since most call sites raising this directly have nothing
|
|
122
|
+
more specific to log.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
status_code: int,
|
|
129
|
+
detail: str | None = None,
|
|
130
|
+
message: str | None = None,
|
|
131
|
+
user_message: str | None = None,
|
|
132
|
+
retryable: Retryability = Retryability.UNKNOWN,
|
|
133
|
+
error: str | None = None,
|
|
134
|
+
**kwargs: Any,
|
|
135
|
+
) -> None:
|
|
136
|
+
resolved_user_message = user_message or detail
|
|
137
|
+
if not resolved_user_message:
|
|
138
|
+
raise ValueError("CoreHTTPException requires detail= or user_message=")
|
|
139
|
+
|
|
140
|
+
resolved_message = message or resolved_user_message
|
|
141
|
+
HTTPException.__init__(self, status_code=status_code, detail=resolved_user_message, **kwargs)
|
|
142
|
+
PublicCoreException.__init__(
|
|
143
|
+
self,
|
|
144
|
+
resolved_message,
|
|
145
|
+
resolved_user_message,
|
|
146
|
+
retryable=retryable,
|
|
147
|
+
error=error,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def __str__(self) -> str:
|
|
151
|
+
# HTTPException.__str__ is "404: detail" and sits ahead of
|
|
152
|
+
# PublicCoreException in the MRO. The public contract is that only
|
|
153
|
+
# user_message leaves the backend.
|
|
154
|
+
return self.user_message
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class RetryableCoreHTTPException(CoreHTTPException):
|
|
158
|
+
"""
|
|
159
|
+
HTTP error where trying the same request again may succeed.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
163
|
+
kwargs.setdefault("retryable", Retryability.RETRYABLE)
|
|
164
|
+
super().__init__(**kwargs)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class NonRetryableCoreHTTPException(CoreHTTPException):
|
|
37
168
|
"""
|
|
38
|
-
|
|
169
|
+
HTTP error where the same request will fail again until something else changes.
|
|
39
170
|
"""
|
|
40
171
|
|
|
41
172
|
def __init__(self, **kwargs: Any) -> None:
|
|
42
|
-
|
|
43
|
-
if message:
|
|
44
|
-
kwargs["detail"] = message
|
|
173
|
+
kwargs.setdefault("retryable", Retryability.NON_RETRYABLE)
|
|
45
174
|
super().__init__(**kwargs)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enums used by corekit exceptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.schemas.enum import StringEnum
|
|
6
|
+
|
|
7
|
+
__all__ = ["Retryability"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Retryability(StringEnum):
|
|
11
|
+
"""
|
|
12
|
+
Whether retrying the operation that raised an exception can help.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
RETRYABLE = "retryable"
|
|
16
|
+
NON_RETRYABLE = "non_retryable"
|
|
17
|
+
UNKNOWN = "unknown"
|
corekit/exceptions/types.py
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
Type aliases for exception classes.
|
|
3
3
|
|
|
4
4
|
These name the *class*, for signatures that accept an exception type rather than
|
|
5
|
-
an instance -- ``def handle(exc:
|
|
5
|
+
an instance -- ``def handle(exc: CoreExceptionType) -> None``.
|
|
6
6
|
|
|
7
7
|
``type(X)`` returns X's metaclass, which is ``type`` for an ordinary class and
|
|
8
8
|
carries no information about X. ``type[X]`` is the subscript form these need.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from corekit.exceptions.base import
|
|
11
|
+
from corekit.exceptions.base import CoreException, CoreHTTPException
|
|
12
12
|
|
|
13
|
-
__all__ = ["
|
|
13
|
+
__all__ = ["ArbitraryCoreExceptionType", "CoreExceptionType", "CoreHTTPExceptionType"]
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
CoreExceptionType = type[CoreException]
|
|
16
|
+
CoreHTTPExceptionType = type[CoreHTTPException]
|
|
17
|
+
ArbitraryCoreExceptionType = CoreExceptionType | CoreHTTPExceptionType
|
corekit/files/__init__.py
CHANGED
|
@@ -8,10 +8,9 @@ File reading and writing, with format handled by the manager.
|
|
|
8
8
|
``_serialize`` and ``_deserialize`` for its format.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from corekit.files.base import FileContent, FileManager
|
|
12
|
-
from corekit.files.enum import
|
|
11
|
+
from corekit.files.base import FileContent, FileError, FileManager
|
|
12
|
+
from corekit.files.enum import FileMode
|
|
13
13
|
from corekit.files.json import JsonFileManager
|
|
14
|
-
from corekit.files.pickle import PickleFileManager
|
|
15
14
|
from corekit.files.toml import TomlFileManager
|
|
16
15
|
|
|
17
16
|
__all__ = [
|
|
@@ -20,6 +19,5 @@ __all__ = [
|
|
|
20
19
|
"FileManager",
|
|
21
20
|
"FileMode",
|
|
22
21
|
"JsonFileManager",
|
|
23
|
-
"PickleFileManager",
|
|
24
22
|
"TomlFileManager",
|
|
25
23
|
]
|
corekit/files/base.py
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
from typing import Any, BinaryIO, Iterator, TextIO, Union
|
|
2
2
|
|
|
3
|
-
from corekit.
|
|
3
|
+
from corekit.exceptions import InternalCoreException, Retryability
|
|
4
|
+
from corekit.files.enum import FileMode
|
|
4
5
|
|
|
5
6
|
FileContent: type[str | bytes] = Union[str, bytes]
|
|
6
7
|
|
|
7
8
|
|
|
9
|
+
class FileError(InternalCoreException):
|
|
10
|
+
"""
|
|
11
|
+
Raised when a file manager is used in the wrong open/closed state.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
NOT_OPEN = "File is not open"
|
|
15
|
+
OPEN = "File is open"
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, error: str | None = None) -> None:
|
|
18
|
+
super().__init__(message, retryable=Retryability.NON_RETRYABLE, error=error)
|
|
19
|
+
|
|
20
|
+
|
|
8
21
|
class FileManager:
|
|
9
22
|
def __init__(
|
|
10
23
|
self,
|
|
@@ -36,7 +49,7 @@ class FileManager:
|
|
|
36
49
|
|
|
37
50
|
def _verify_file_status(self, should_be_open: bool) -> None:
|
|
38
51
|
if self.is_open != should_be_open:
|
|
39
|
-
raise
|
|
52
|
+
raise FileError(FileError.NOT_OPEN if should_be_open else FileError.OPEN)
|
|
40
53
|
|
|
41
54
|
# ===== File Opening/Closing Methods =====
|
|
42
55
|
def _open(self) -> None:
|
corekit/files/enum.py
CHANGED
corekit/files/json.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json as _json
|
|
2
|
-
from typing import Any
|
|
2
|
+
from typing import Any, Iterator
|
|
3
3
|
|
|
4
|
-
from corekit.files.base import FileContent, FileManager
|
|
4
|
+
from corekit.files.base import FileContent, FileError, FileManager
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class JsonFileManager(FileManager):
|
|
@@ -10,3 +10,17 @@ class JsonFileManager(FileManager):
|
|
|
10
10
|
|
|
11
11
|
def _deserialize(self, content: FileContent) -> Any:
|
|
12
12
|
return _json.loads(content)
|
|
13
|
+
|
|
14
|
+
def read(self, size: int = -1) -> Any:
|
|
15
|
+
"""
|
|
16
|
+
Read and parse the whole document. Partial reads are refused.
|
|
17
|
+
"""
|
|
18
|
+
if size != -1:
|
|
19
|
+
raise FileError("JsonFileManager refuses partial reads; JSON must be parsed as a whole document.")
|
|
20
|
+
return super().read(size)
|
|
21
|
+
|
|
22
|
+
def readline(self) -> Any:
|
|
23
|
+
raise FileError("JsonFileManager cannot readline; JSON is not line-oriented.")
|
|
24
|
+
|
|
25
|
+
def stream(self) -> Iterator[Any]:
|
|
26
|
+
raise FileError("JsonFileManager cannot stream; JSON must be parsed as a whole document.")
|
corekit/http/__init__.py
CHANGED
|
@@ -1,13 +1,51 @@
|
|
|
1
1
|
"""
|
|
2
2
|
HTTP client building blocks.
|
|
3
3
|
|
|
4
|
-
``
|
|
5
|
-
|
|
6
|
-
rather than hammer.
|
|
4
|
+
``BaseHttpClient`` is the transport; ``BaseApiClient`` is the strict API
|
|
5
|
+
wrapper. ``ExponentialBackoff`` is the retry policy behind the client and is
|
|
6
|
+
useful on its own for any operation that should back off rather than hammer.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from corekit.http.
|
|
9
|
+
from corekit.http.api import BaseApiClient
|
|
10
|
+
from corekit.http.client import BaseHttpClient
|
|
11
|
+
from corekit.http.exceptions import (
|
|
12
|
+
BadGatewayException,
|
|
13
|
+
BadRequestException,
|
|
14
|
+
ConflictErrorException,
|
|
15
|
+
ForbiddenException,
|
|
16
|
+
GatewayTimeoutException,
|
|
17
|
+
InternalServerErrorException,
|
|
18
|
+
NotFoundException,
|
|
19
|
+
ServiceUnavailableException,
|
|
20
|
+
TooManyRequestsException,
|
|
21
|
+
UnauthorizedException,
|
|
22
|
+
UnprocessableEntityException,
|
|
23
|
+
UnsupportedMediaTypeException,
|
|
24
|
+
UnsupportedMethodError,
|
|
25
|
+
URLMismatchError,
|
|
26
|
+
)
|
|
10
27
|
from corekit.http.exponential_backoff import ExponentialBackoff
|
|
11
28
|
from corekit.http.response import BaseApiResponse
|
|
29
|
+
from corekit.http.status import HTTPStatusCode
|
|
12
30
|
|
|
13
|
-
__all__ = [
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BadGatewayException",
|
|
33
|
+
"BadRequestException",
|
|
34
|
+
"BaseApiClient",
|
|
35
|
+
"BaseApiResponse",
|
|
36
|
+
"BaseHttpClient",
|
|
37
|
+
"ConflictErrorException",
|
|
38
|
+
"ExponentialBackoff",
|
|
39
|
+
"ForbiddenException",
|
|
40
|
+
"GatewayTimeoutException",
|
|
41
|
+
"HTTPStatusCode",
|
|
42
|
+
"InternalServerErrorException",
|
|
43
|
+
"NotFoundException",
|
|
44
|
+
"ServiceUnavailableException",
|
|
45
|
+
"TooManyRequestsException",
|
|
46
|
+
"UnauthorizedException",
|
|
47
|
+
"UnprocessableEntityException",
|
|
48
|
+
"UnsupportedMediaTypeException",
|
|
49
|
+
"UnsupportedMethodError",
|
|
50
|
+
"URLMismatchError",
|
|
51
|
+
]
|
corekit/http/api.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API clients: a ``BaseHttpClient`` that always talks to one origin.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from corekit.http.client import BaseHttpClient
|
|
8
|
+
|
|
9
|
+
__all__ = ["BaseApiClient"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseApiClient(BaseHttpClient):
|
|
13
|
+
"""
|
|
14
|
+
Base for API clients. Override ``base_url`` and, usually, ``headers``.
|
|
15
|
+
|
|
16
|
+
Strict by default, so an absolute URL that does not share ``base_url`` is
|
|
17
|
+
a bug rather than a request to somewhere else. ``base_url`` must be
|
|
18
|
+
available during ``__init__`` — a property on the subclass is the usual way.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args: Any, strict: bool = True, **kwargs: Any) -> None:
|
|
22
|
+
super().__init__(*args, strict=strict, **kwargs)
|
|
23
|
+
if not self.base_url:
|
|
24
|
+
raise ValueError("BaseApiClient requires a non-empty base_url")
|