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.
Files changed (84) hide show
  1. corekit/api/application.py +47 -9
  2. corekit/api/lifespan.py +26 -3
  3. corekit/concurrency/__init__.py +2 -2
  4. corekit/concurrency/decorators.py +32 -5
  5. corekit/concurrency/thread_local.py +2 -2
  6. corekit/concurrency/worker.py +9 -0
  7. corekit/config/loader.py +42 -5
  8. corekit/config/settings.py +11 -1
  9. corekit/connections/__init__.py +7 -1
  10. corekit/connections/connectable.py +45 -4
  11. corekit/connections/redis/connection.py +53 -10
  12. corekit/connections/sql/__init__.py +2 -1
  13. corekit/connections/sql/connection.py +39 -5
  14. corekit/connections/sql/fields/__init__.py +2 -2
  15. corekit/connections/sql/fields/jsonb.py +13 -6
  16. corekit/connections/sql/migration/__init__.py +4 -0
  17. corekit/connections/sql/migration/operations.py +69 -2
  18. corekit/connections/sql/operations/base.py +11 -2
  19. corekit/connections/sql/operations/statements.py +25 -5
  20. corekit/connections/sql/table.py +7 -29
  21. corekit/crypto/__init__.py +3 -1
  22. corekit/crypto/constants.py +2 -2
  23. corekit/crypto/hasher.py +9 -4
  24. corekit/data/dataset.py +8 -2
  25. corekit/data/expressions/__init__.py +3 -3
  26. corekit/data/expressions/comparison.py +19 -80
  27. corekit/data/expressions/expression.py +0 -32
  28. corekit/data/expressions/operator.py +13 -28
  29. corekit/data/stats.py +3 -0
  30. corekit/decorators/exception_handling.py +36 -8
  31. corekit/docker/watchdog.py +50 -31
  32. corekit/etl/__init__.py +2 -1
  33. corekit/etl/connection.py +14 -12
  34. corekit/etl/extract/extractor.py +6 -13
  35. corekit/etl/orchestrator.py +19 -2
  36. corekit/etl/schemas.py +2 -2
  37. corekit/etl/transform/transformer.py +4 -1
  38. corekit/events/publisher.py +1 -1
  39. corekit/events/reader.py +26 -21
  40. corekit/events/sse.py +4 -1
  41. corekit/events/websocket.py +24 -11
  42. corekit/exceptions/__init__.py +24 -9
  43. corekit/exceptions/base.py +139 -10
  44. corekit/exceptions/enum.py +17 -0
  45. corekit/exceptions/types.py +6 -6
  46. corekit/files/__init__.py +2 -4
  47. corekit/files/base.py +15 -2
  48. corekit/files/enum.py +0 -5
  49. corekit/files/json.py +16 -2
  50. corekit/http/__init__.py +43 -5
  51. corekit/http/api.py +24 -0
  52. corekit/http/client.py +100 -73
  53. corekit/http/exceptions.py +140 -0
  54. corekit/http/response.py +50 -1
  55. corekit/http/status.py +89 -0
  56. corekit/jobs/runner.py +12 -1
  57. corekit/jobs/task.py +23 -2
  58. corekit/log_monitor/models.py +8 -2
  59. corekit/log_monitor/service.py +77 -38
  60. corekit/notifications/base.py +18 -10
  61. corekit/observability/__init__.py +9 -2
  62. corekit/observability/benchmarkable.py +23 -5
  63. corekit/observability/loggable.py +21 -0
  64. corekit/observability/request_context.py +55 -2
  65. corekit/observability/timing/timer.py +4 -2
  66. corekit/registry/__init__.py +2 -2
  67. corekit/registry/registry.py +55 -14
  68. corekit/schemas/enum.py +22 -1
  69. corekit/schemas/types.py +6 -1
  70. corekit/serialization/__init__.py +2 -0
  71. corekit/serialization/pickle_file.py +61 -0
  72. corekit/serialization/serializable.py +22 -2
  73. corekit/serialization/serializer.py +9 -2
  74. corekit/utils/collections.py +22 -13
  75. corekit/utils/payload.py +12 -0
  76. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/METADATA +7 -7
  77. python_corekit-0.3.0.dist-info/RECORD +145 -0
  78. corekit/constants.py +0 -45
  79. corekit/exceptions/http/exceptions.py +0 -37
  80. corekit/files/pickle.py +0 -12
  81. python_corekit-0.2.0.dist-info/RECORD +0 -143
  82. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_corekit-0.2.0.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  84. {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 BaseConnection
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: BaseConnection
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
- Primary method to run the transformer.
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
@@ -11,7 +11,7 @@ constant::
11
11
  EventPublisher.for_resource("minecraft", "server", "survival")
12
12
  # -> channel "minecraft:server:survival"
13
13
 
14
- Requires the ``redis`` extra.
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``, so a caller
92
- can act on the silence -- sending a keepalive, say -- rather than
93
- blocking forever.
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
- try:
96
- message = await asyncio.wait_for(
97
- self._pubsub.get_message(ignore_subscribe_messages=True),
98
- timeout=timeout,
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
- except asyncio.TimeoutError:
101
- return None
102
-
103
- if message is None:
104
- # Nothing ready. Yield rather than spin.
105
- await asyncio.sleep(IDLE_SLEEP_SECONDS)
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
- if message.get(PubSubField.TYPE.value) not in (None, PubSubMessageType.MESSAGE.value):
109
- return None
113
+ if message.get(PubSubField.TYPE.value) not in (None, PubSubMessageType.MESSAGE.value):
114
+ continue
110
115
 
111
- return self._decode(message.get(PubSubField.DATA.value))
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
- yield SSEFrame.comment()
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
 
@@ -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 False
69
- return asyncio.get_event_loop().time() - started > self.timeout
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.get_event_loop().time()
85
+ started = asyncio.get_running_loop().time()
79
86
  try:
80
87
  while True:
81
- payload = await self._reader.next_payload(timeout=self.timeout)
82
-
83
- if payload is not None:
84
- await self.websocket.send_json(payload)
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
- if self._expired(started):
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:
@@ -1,18 +1,33 @@
1
1
  """
2
2
  Exception base classes.
3
3
 
4
- ``CustomException`` carries a message and an optional underlying error;
5
- ``CustomHTTPException`` is its FastAPI-facing counterpart.
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 CustomException, CustomHTTPException, ExponentialBackoffTimeoutException
9
- from corekit.exceptions.types import ArbitraryCustomExceptionType, CustomExceptionType, CustomHTTPExceptionType
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
- "ArbitraryCustomExceptionType",
13
- "CustomException",
14
- "CustomExceptionType",
15
- "CustomHTTPException",
16
- "CustomHTTPExceptionType",
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
  ]
@@ -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
- __all__ = ["CustomException", "CustomHTTPException", "ExponentialBackoffTimeoutException"]
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 CustomException(Exception):
40
+ class CoreException(Exception, ABC):
13
41
  """
14
- Base for corekit exceptions, carrying optional error context.
42
+ Abstract base carrying the fields every corekit exception shares.
15
43
  """
16
44
 
17
- def __init__(self, message: str, error: str | None = None) -> None:
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(CustomException):
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 CustomHTTPException(HTTPException):
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
- FastAPI HTTPException that also accepts ``message=`` as an alias for ``detail=``.
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
- message = kwargs.pop("message", None)
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"
@@ -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: CustomExceptionType) -> None``.
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 CustomException, CustomHTTPException
11
+ from corekit.exceptions.base import CoreException, CoreHTTPException
12
12
 
13
- __all__ = ["ArbitraryCustomExceptionType", "CustomExceptionType", "CustomHTTPExceptionType"]
13
+ __all__ = ["ArbitraryCoreExceptionType", "CoreExceptionType", "CoreHTTPExceptionType"]
14
14
 
15
- CustomExceptionType = type[CustomException]
16
- CustomHTTPExceptionType = type[CustomHTTPException]
17
- ArbitraryCustomExceptionType = CustomExceptionType | CustomHTTPExceptionType
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 FileError, FileMode
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.files.enum import FileError, FileMode
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 ValueError(FileError.NOT_OPEN if should_be_open else FileError.OPEN) # FIXME: raise custom exception
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
@@ -23,8 +23,3 @@ class FileMode(StringEnum):
23
23
  if self in {FileMode.READ, FileMode.READ_BINARY}:
24
24
  return False
25
25
  return True
26
-
27
-
28
- class FileError(StringEnum):
29
- NOT_OPEN = "File is not open"
30
- OPEN = "File is open"
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
- ``BaseApiClient`` wraps httpx with retries; ``ExponentialBackoff`` is the retry
5
- policy behind it and is useful on its own for any operation that should back off
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.client import BaseApiClient, URLMismatchError
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__ = ["BaseApiClient", "BaseApiResponse", "ExponentialBackoff", "URLMismatchError"]
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")