python-corekit 0.1.1__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 (109) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +275 -0
  3. corekit/api/lifespan.py +233 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/__init__.py +2 -2
  7. corekit/concurrency/decorators.py +32 -5
  8. corekit/concurrency/thread_local.py +2 -2
  9. corekit/concurrency/worker.py +74 -65
  10. corekit/config/loader.py +42 -5
  11. corekit/config/settings.py +11 -1
  12. corekit/connections/__init__.py +7 -1
  13. corekit/connections/connectable.py +45 -4
  14. corekit/connections/redis/connection.py +53 -10
  15. corekit/connections/sql/__init__.py +33 -4
  16. corekit/connections/sql/connection.py +56 -3
  17. corekit/connections/sql/fields/__init__.py +2 -2
  18. corekit/connections/sql/fields/jsonb.py +13 -6
  19. corekit/connections/sql/migration/__init__.py +9 -5
  20. corekit/connections/sql/migration/base.py +3 -3
  21. corekit/connections/sql/migration/operations.py +135 -44
  22. corekit/connections/sql/migration/registry.py +2 -2
  23. corekit/connections/sql/operations/__init__.py +24 -0
  24. corekit/connections/sql/operations/base.py +111 -0
  25. corekit/connections/sql/operations/statements.py +170 -0
  26. corekit/connections/sql/query.py +4 -62
  27. corekit/connections/sql/table.py +33 -29
  28. corekit/crypto/__init__.py +3 -1
  29. corekit/crypto/constants.py +2 -2
  30. corekit/crypto/hasher.py +9 -4
  31. corekit/data/__init__.py +8 -0
  32. corekit/data/dataset.py +8 -2
  33. corekit/data/expressions/__init__.py +10 -2
  34. corekit/data/expressions/comparison.py +142 -123
  35. corekit/data/expressions/expression.py +71 -98
  36. corekit/data/expressions/operator.py +39 -0
  37. corekit/data/expressions/target.py +21 -0
  38. corekit/data/record.py +147 -147
  39. corekit/data/stats.py +162 -157
  40. corekit/decorators/__init__.py +2 -2
  41. corekit/decorators/exception_handling.py +38 -9
  42. corekit/docker/watchdog.py +50 -31
  43. corekit/etl/__init__.py +2 -1
  44. corekit/etl/connection.py +46 -44
  45. corekit/etl/extract/extractor.py +6 -13
  46. corekit/etl/orchestrator.py +19 -2
  47. corekit/etl/schemas.py +2 -2
  48. corekit/etl/transform/transformer.py +4 -1
  49. corekit/events/publisher.py +1 -1
  50. corekit/events/reader.py +26 -21
  51. corekit/events/sse.py +4 -1
  52. corekit/events/websocket.py +27 -13
  53. corekit/exceptions/__init__.py +33 -0
  54. corekit/exceptions/base.py +139 -10
  55. corekit/exceptions/enum.py +17 -0
  56. corekit/exceptions/types.py +6 -6
  57. corekit/files/__init__.py +2 -4
  58. corekit/files/base.py +15 -2
  59. corekit/files/enum.py +0 -5
  60. corekit/files/json.py +16 -2
  61. corekit/http/__init__.py +51 -0
  62. corekit/http/api.py +24 -0
  63. corekit/http/client.py +100 -73
  64. corekit/http/exceptions.py +140 -0
  65. corekit/http/response.py +50 -1
  66. corekit/http/status.py +89 -0
  67. corekit/jobs/__init__.py +26 -0
  68. corekit/jobs/registry.py +87 -0
  69. corekit/jobs/runner.py +80 -0
  70. corekit/jobs/task.py +173 -0
  71. corekit/log_monitor/models.py +8 -2
  72. corekit/log_monitor/service.py +77 -38
  73. corekit/notifications/base.py +18 -10
  74. corekit/observability/__init__.py +12 -3
  75. corekit/observability/benchmarkable.py +23 -5
  76. corekit/observability/loggable.py +21 -0
  77. corekit/observability/request_context.py +188 -0
  78. corekit/observability/timing/timer.py +4 -2
  79. corekit/registry/__init__.py +12 -7
  80. corekit/registry/ordered.py +86 -0
  81. corekit/registry/registry.py +55 -14
  82. corekit/schemas/__init__.py +10 -0
  83. corekit/schemas/enum.py +70 -49
  84. corekit/schemas/models/arbitrary.py +11 -11
  85. corekit/schemas/pydantic/fields.py +35 -35
  86. corekit/schemas/types.py +45 -40
  87. corekit/serialization/__init__.py +24 -0
  88. corekit/serialization/pickle_file.py +61 -0
  89. corekit/serialization/serializable.py +22 -2
  90. corekit/serialization/serializer.py +10 -3
  91. corekit/utils/__init__.py +59 -5
  92. corekit/utils/coercion.py +118 -0
  93. corekit/utils/collections.py +124 -0
  94. corekit/utils/ids.py +61 -5
  95. corekit/utils/payload.py +112 -0
  96. corekit/utils/raise_exc.py +8 -8
  97. corekit/utils/text.py +56 -0
  98. corekit/utils/time.py +74 -21
  99. corekit/utils/validators.py +15 -15
  100. corekit/utils/void.py +8 -8
  101. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
  102. python_corekit-0.3.0.dist-info/RECORD +145 -0
  103. corekit/constants.py +0 -45
  104. corekit/exceptions/http/exceptions.py +0 -37
  105. corekit/files/pickle.py +0 -12
  106. python_corekit-0.1.1.dist-info/RECORD +0 -125
  107. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  108. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  109. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
@@ -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
@@ -0,0 +1,51 @@
1
+ """
2
+ HTTP client building blocks.
3
+
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
+ """
8
+
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
+ )
27
+ from corekit.http.exponential_backoff import ExponentialBackoff
28
+ from corekit.http.response import BaseApiResponse
29
+ from corekit.http.status import HTTPStatusCode
30
+
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")
corekit/http/client.py CHANGED
@@ -1,7 +1,10 @@
1
1
  """
2
2
  A small HTTP client with retries.
3
3
 
4
- Subclass and give it a base URL::
4
+ ``BaseHttpClient`` is the transport: relative paths, optional strict URL
5
+ checking, and retries for statuses the typed HTTP exceptions mark as
6
+ retryable. Subclass ``BaseApiClient`` when the client always talks to one
7
+ API and a foreign absolute URL should be a bug.
5
8
 
6
9
  class GithubClient(BaseApiClient):
7
10
  '''
@@ -17,46 +20,79 @@ Subclass and give it a base URL::
17
20
  return {"Authorization": f"Bearer {self.token}"}
18
21
 
19
22
  client = GithubClient()
20
- response = client.get("/user") # or await client.async_get("/user")
23
+ response = client.get("/user") # or await client.get("/user")
21
24
  response.data["login"]
22
25
 
23
26
  Every response comes back as a ``BaseApiResponse``, so callers see one shape
24
- regardless of what the endpoint returned. Requests that fail with a retryable
25
- status are retried with exponential backoff.
27
+ regardless of what the endpoint returned.
26
28
  """
27
29
 
28
30
  from http import HTTPMethod
29
31
  from typing import Any
30
- from urllib.parse import urljoin
32
+ from urllib.parse import urljoin, urlsplit, urlunsplit
31
33
 
32
34
  import httpx
33
35
 
36
+ from corekit.concurrency.decorators import allow_sync
37
+ from corekit.http.exceptions import RETRYABLE_STATUS_CODES, UnsupportedMethodError, URLMismatchError
34
38
  from corekit.http.exponential_backoff import ExponentialBackoff
35
39
  from corekit.http.response import BaseApiResponse
40
+ from corekit.http.status import HTTPStatusCode
36
41
  from corekit.observability.benchmarkable import Benchmarkable
37
42
 
38
- __all__ = ["BaseApiClient", "URLMismatchError"]
43
+ __all__ = ["BaseHttpClient"]
39
44
 
40
- # Statuses worth retrying: the server is busy or briefly unavailable, so the
41
- # same request may well succeed shortly. A 4xx other than 429 will not.
42
- RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
43
45
 
44
-
45
- class URLMismatchError(Exception):
46
+ def _without_query(url: str) -> str:
46
47
  """
47
- Raised when an absolute URL is passed that does not share the base URL.
48
+ Drop the query string before a retry warning is logged.
48
49
  """
50
+ parts = urlsplit(url)
51
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
52
+
49
53
 
54
+ _DEFAULT_METHODS = frozenset(
55
+ {
56
+ HTTPMethod.GET,
57
+ HTTPMethod.POST,
58
+ HTTPMethod.PUT,
59
+ HTTPMethod.PATCH,
60
+ HTTPMethod.DELETE,
61
+ }
62
+ )
50
63
 
51
- class BaseApiClient(Benchmarkable):
64
+
65
+ class BaseHttpClient(Benchmarkable):
52
66
  """
53
- Base for API clients. Override ``base_url`` and, usually, ``headers``.
67
+ Base for HTTP clients. Override ``base_url`` and, usually, ``headers``.
68
+
69
+ ``strict=False`` accepts a foreign absolute URL as-is. ``BaseApiClient``
70
+ turns that into an error.
54
71
  """
55
72
 
56
- def __init__(self, retries: int = 3, timeout: float = 30.0) -> None:
73
+ def __init__(
74
+ self,
75
+ retries: int = 3,
76
+ timeout: float = 30.0,
77
+ strict: bool = False,
78
+ base_delay: float = 1,
79
+ http_client: httpx.AsyncClient | None = None,
80
+ ) -> None:
57
81
  super().__init__()
58
82
  self.retries = retries
59
83
  self.timeout = timeout
84
+ self.strict = strict
85
+ self.base_delay = base_delay
86
+ # Optional pool. Left unset by default: @allow_sync runs sync calls on a
87
+ # throwaway loop, and an AsyncClient cannot move between loops.
88
+ self._http_client = http_client
89
+
90
+ @property
91
+ def supported_methods(self) -> frozenset[HTTPMethod]:
92
+ """
93
+ HTTP methods this client will send. Override to narrow or extend the set.
94
+ """
95
+ return _DEFAULT_METHODS
60
96
 
61
97
  @property
62
98
  def headers(self) -> dict[str, str]:
@@ -77,16 +113,18 @@ class BaseApiClient(Benchmarkable):
77
113
  Resolve a path against the base URL, accepting an absolute URL that
78
114
  already matches it.
79
115
  """
80
- if url.startswith(self.base_url):
116
+ if self.base_url and url.startswith(self.base_url):
81
117
  return url
82
118
  if url.startswith("http"):
83
- raise URLMismatchError(f"URL {url} does not match base URL {self.base_url}")
119
+ if self.strict:
120
+ raise URLMismatchError(url, self.base_url)
121
+ return url
84
122
  return urljoin(self.base_url, url)
85
123
 
86
124
  @staticmethod
87
- def _to_response(raw: Any) -> BaseApiResponse:
125
+ def _to_response(raw: httpx.Response) -> BaseApiResponse:
88
126
  """
89
- Normalize an httpx response into a BaseApiResponse.
127
+ Normalize a httpx response into a BaseApiResponse.
90
128
 
91
129
  The body is decoded as JSON when it parses, and left as text otherwise,
92
130
  so a caller never has to guard against a non-JSON error page.
@@ -99,78 +137,67 @@ class BaseApiClient(Benchmarkable):
99
137
  data = {"data": data}
100
138
 
101
139
  return BaseApiResponse(
102
- status_code=raw.status_code,
140
+ status_code=HTTPStatusCode.coerce(raw.status_code),
103
141
  text=raw.text,
104
142
  data=data,
105
143
  headers=dict(raw.headers),
106
144
  cookies=dict(raw.cookies),
107
145
  )
108
146
 
109
- def _new_backoff(self) -> ExponentialBackoff:
110
- return ExponentialBackoff(retries=self.retries)
111
-
112
- def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
113
- """
114
- Send a request, retrying retryable statuses.
115
- """
116
-
117
- target = self._build_url(url)
118
- backoff = self._new_backoff()
119
-
120
- while True:
121
- with httpx.Client(timeout=self.timeout) as client:
122
- raw = client.request(str(method), target, headers=self.headers, **kwargs)
123
-
124
- if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
125
- return self._to_response(raw)
126
-
127
- self.warning(f"{method} {target} returned {raw.status_code}; retrying")
128
- backoff.wait()
129
-
130
- async def async_request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
147
+ async def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
131
148
  """
132
149
  Send a request asynchronously, retrying retryable statuses.
133
150
  """
151
+ if method not in self.supported_methods:
152
+ raise UnsupportedMethodError(method)
134
153
 
135
154
  target = self._build_url(url)
136
- backoff = self._new_backoff()
137
-
155
+ headers = dict(self.headers)
156
+ extra_headers = kwargs.pop("headers", None)
157
+ if extra_headers:
158
+ headers.update(extra_headers)
159
+ # Fresh each request: ExponentialBackoff keeps its own attempt count.
160
+ backoff = ExponentialBackoff(retries=self.retries, base_delay=self.base_delay)
161
+
162
+ if self._http_client is not None:
163
+ return await self._exchange(self._http_client, method, target, headers, backoff, kwargs)
164
+
165
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
166
+ return await self._exchange(client, method, target, headers, backoff, kwargs)
167
+
168
+ async def _exchange(
169
+ self,
170
+ client: httpx.AsyncClient,
171
+ method: HTTPMethod,
172
+ target: str,
173
+ headers: dict[str, str],
174
+ backoff: ExponentialBackoff,
175
+ kwargs: dict[str, Any],
176
+ ) -> BaseApiResponse:
138
177
  while True:
139
- async with httpx.AsyncClient(timeout=self.timeout) as client:
140
- raw = await client.request(str(method), target, headers=self.headers, **kwargs)
141
-
178
+ raw = await client.request(str(method), target, headers=headers, **kwargs)
142
179
  if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
143
180
  return self._to_response(raw)
144
181
 
145
- self.warning(f"{method} {target} returned {raw.status_code}; retrying")
182
+ self.warning(f"{method} {_without_query(target)} returned {raw.status_code}; retrying")
146
183
  await backoff.async_wait()
147
184
 
148
- def get(self, url: str, **kwargs: Any) -> BaseApiResponse:
149
- return self.request(HTTPMethod.GET, url, **kwargs)
150
-
151
- def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
152
- return self.request(HTTPMethod.POST, url, **kwargs)
153
-
154
- def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
155
- return self.request(HTTPMethod.PUT, url, **kwargs)
156
-
157
- def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
158
- return self.request(HTTPMethod.PATCH, url, **kwargs)
159
-
160
- def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
161
- return self.request(HTTPMethod.DELETE, url, **kwargs)
162
-
163
- async def async_get(self, url: str, **kwargs: Any) -> BaseApiResponse:
164
- return await self.async_request(HTTPMethod.GET, url, **kwargs)
185
+ @allow_sync
186
+ async def get(self, url: str, **kwargs: Any) -> BaseApiResponse:
187
+ return await self.request(HTTPMethod.GET, url, **kwargs)
165
188
 
166
- async def async_post(self, url: str, **kwargs: Any) -> BaseApiResponse:
167
- return await self.async_request(HTTPMethod.POST, url, **kwargs)
189
+ @allow_sync
190
+ async def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
191
+ return await self.request(HTTPMethod.POST, url, **kwargs)
168
192
 
169
- async def async_put(self, url: str, **kwargs: Any) -> BaseApiResponse:
170
- return await self.async_request(HTTPMethod.PUT, url, **kwargs)
193
+ @allow_sync
194
+ async def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
195
+ return await self.request(HTTPMethod.PUT, url, **kwargs)
171
196
 
172
- async def async_patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
173
- return await self.async_request(HTTPMethod.PATCH, url, **kwargs)
197
+ @allow_sync
198
+ async def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
199
+ return await self.request(HTTPMethod.PATCH, url, **kwargs)
174
200
 
175
- async def async_delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
176
- return await self.async_request(HTTPMethod.DELETE, url, **kwargs)
201
+ @allow_sync
202
+ async def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
203
+ return await self.request(HTTPMethod.DELETE, url, **kwargs)