021-python-clients 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.
- 021_python_clients-0.1.0.dist-info/METADATA +327 -0
- 021_python_clients-0.1.0.dist-info/RECORD +21 -0
- 021_python_clients-0.1.0.dist-info/WHEEL +4 -0
- 021_python_clients-0.1.0.dist-info/licenses/LICENSE +202 -0
- clients_021/__init__.py +22 -0
- clients_021/_aws.py +55 -0
- clients_021/_observe.py +106 -0
- clients_021/email/__init__.py +16 -0
- clients_021/email/_message.py +77 -0
- clients_021/email/resend.py +143 -0
- clients_021/email/ses.py +119 -0
- clients_021/errors.py +20 -0
- clients_021/http.py +131 -0
- clients_021/httpx2.py +93 -0
- clients_021/kms.py +178 -0
- clients_021/py.typed +0 -0
- clients_021/queue/__init__.py +6 -0
- clients_021/queue/qstash.py +219 -0
- clients_021/queue/sqs.py +177 -0
- clients_021/redis.py +124 -0
- clients_021/s3.py +164 -0
clients_021/redis.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Redis, observed: JSON values with a TTL, one-time reads, windowed counters and a ping.
|
|
2
|
+
|
|
3
|
+
from clients_021.redis import RedisClient
|
|
4
|
+
|
|
5
|
+
redis = RedisClient.from_url(obs, settings.redis_url, dependency="redis")
|
|
6
|
+
await redis.set_json("session:abc", {"user": "ana"}, ttl_seconds=1800)
|
|
7
|
+
value = await redis.get_json("session:abc")
|
|
8
|
+
|
|
9
|
+
Needs the `redis` extra. Calls record `db.client.operation.*` with `dependency` and
|
|
10
|
+
`operation` (`get_json`, `set_json`, `take_json`, `delete`, `incr_window`, `ping`).
|
|
11
|
+
Timeouts and connection failures raise DependencyUnavailable; a wrong command or type
|
|
12
|
+
(`ResponseError`) is a bug and keeps its type.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Awaitable
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
|
|
21
|
+
from observability_021 import Observability
|
|
22
|
+
from observability_021.config import identifier
|
|
23
|
+
from redis.asyncio import Redis
|
|
24
|
+
from redis.exceptions import AuthenticationError, AuthorizationError
|
|
25
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
26
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
27
|
+
|
|
28
|
+
from clients_021._observe import observed
|
|
29
|
+
from clients_021.errors import DependencyUnavailable
|
|
30
|
+
|
|
31
|
+
SYSTEM = {"db.system.name": "redis"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _classify(error: BaseException) -> str | None:
|
|
35
|
+
# Both inherit ConnectionError, but retrying cannot fix invalid credentials.
|
|
36
|
+
if isinstance(error, AuthenticationError | AuthorizationError):
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(error, RedisTimeoutError):
|
|
39
|
+
return "timeout"
|
|
40
|
+
if isinstance(error, RedisConnectionError):
|
|
41
|
+
return "connection_error"
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RedisClient:
|
|
46
|
+
def __init__(self, obs: Observability, redis: Redis, *, dependency: str = "redis") -> None:
|
|
47
|
+
self._obs = obs
|
|
48
|
+
self._redis = redis
|
|
49
|
+
self.dependency = identifier(dependency)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_url(
|
|
53
|
+
cls,
|
|
54
|
+
obs: Observability,
|
|
55
|
+
url: str,
|
|
56
|
+
*,
|
|
57
|
+
dependency: str = "redis",
|
|
58
|
+
timeout_seconds: float = 1.0,
|
|
59
|
+
) -> RedisClient:
|
|
60
|
+
# redis-py's from_url is only partially typed.
|
|
61
|
+
redis: Redis = Redis.from_url( # pyright: ignore[reportUnknownMemberType]
|
|
62
|
+
url, socket_timeout=timeout_seconds, socket_connect_timeout=timeout_seconds
|
|
63
|
+
)
|
|
64
|
+
return cls(obs, redis, dependency=dependency)
|
|
65
|
+
|
|
66
|
+
def _observed(self, operation: str): # noqa: ANN202 - an async context manager
|
|
67
|
+
return observed(
|
|
68
|
+
self._obs,
|
|
69
|
+
family="db",
|
|
70
|
+
system=SYSTEM,
|
|
71
|
+
dependency=self.dependency,
|
|
72
|
+
operation=operation,
|
|
73
|
+
classify=_classify,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def get_json(self, key: str) -> Any:
|
|
77
|
+
"""The decoded value, or None when the key is missing or expired."""
|
|
78
|
+
async with self._observed("get_json"):
|
|
79
|
+
raw = await self._redis.get(key)
|
|
80
|
+
return None if raw is None else json.loads(raw)
|
|
81
|
+
|
|
82
|
+
async def set_json(self, key: str, value: Any, *, ttl_seconds: int) -> None:
|
|
83
|
+
"""Every value expires: there is no way to store one forever by mistake."""
|
|
84
|
+
if ttl_seconds < 1:
|
|
85
|
+
raise ValueError("ttl_seconds must be at least 1")
|
|
86
|
+
data = json.dumps(value, separators=(",", ":"))
|
|
87
|
+
async with self._observed("set_json"):
|
|
88
|
+
await self._redis.set(key, data, ex=ttl_seconds)
|
|
89
|
+
|
|
90
|
+
async def take_json(self, key: str) -> Any:
|
|
91
|
+
"""Reads and deletes in one step (GETDEL): a second caller gets None."""
|
|
92
|
+
async with self._observed("take_json"):
|
|
93
|
+
raw = await cast(Awaitable[Any], self._redis.getdel(key))
|
|
94
|
+
return None if raw is None else json.loads(raw)
|
|
95
|
+
|
|
96
|
+
async def delete(self, key: str) -> None:
|
|
97
|
+
async with self._observed("delete"):
|
|
98
|
+
await self._redis.delete(key)
|
|
99
|
+
|
|
100
|
+
async def incr_window(self, key: str, *, by: int = 1, ttl_seconds: int) -> int:
|
|
101
|
+
"""Adds `by` and returns the total; the key expires `ttl_seconds` after its first hit.
|
|
102
|
+
|
|
103
|
+
INCRBY and EXPIRE NX run in one transaction, so a crash between them cannot leave
|
|
104
|
+
a counter that never expires.
|
|
105
|
+
"""
|
|
106
|
+
if ttl_seconds < 1:
|
|
107
|
+
raise ValueError("ttl_seconds must be at least 1")
|
|
108
|
+
async with self._observed("incr_window"), self._redis.pipeline(transaction=True) as pipe:
|
|
109
|
+
pipe.incrby(key, by)
|
|
110
|
+
pipe.expire(key, ttl_seconds, nx=True)
|
|
111
|
+
total, _ = await pipe.execute()
|
|
112
|
+
return int(total)
|
|
113
|
+
|
|
114
|
+
async def ping(self) -> bool:
|
|
115
|
+
"""True when Redis answers; False (recorded as an error) when it does not."""
|
|
116
|
+
try:
|
|
117
|
+
async with self._observed("ping"):
|
|
118
|
+
# redis-py types ping() for both clients; the asyncio one returns an awaitable.
|
|
119
|
+
return bool(await self._redis.ping()) # pyright: ignore[reportUnknownMemberType]
|
|
120
|
+
except DependencyUnavailable:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
async def aclose(self) -> None:
|
|
124
|
+
await self._redis.aclose()
|
clients_021/s3.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""S3 (or any S3-compatible store), observed: put, get, head, delete and presigned URLs.
|
|
2
|
+
|
|
3
|
+
from clients_021.s3 import S3Client
|
|
4
|
+
|
|
5
|
+
files = S3Client.create(obs, bucket="service-uploads", region="us-east-1")
|
|
6
|
+
await files.put("reports/2026-10.pdf", data, content_type="application/pdf")
|
|
7
|
+
data = await files.get("reports/2026-10.pdf") # None when missing
|
|
8
|
+
|
|
9
|
+
Needs the `aws` extra. Calls record `storage.client.operation.*` with `dependency` and
|
|
10
|
+
`operation` (`put`, `get`, `head`, `delete`). A missing object is not an error: `get` and
|
|
11
|
+
`head` return None. Timeouts, connection failures, throttling (`SlowDown`) and 5xx raise
|
|
12
|
+
DependencyUnavailable; access denied or a missing bucket keep their botocore type.
|
|
13
|
+
Presigning is local (no request), so it records nothing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import boto3
|
|
24
|
+
from botocore.exceptions import ClientError
|
|
25
|
+
from observability_021 import Observability
|
|
26
|
+
from observability_021.config import identifier
|
|
27
|
+
|
|
28
|
+
from clients_021 import _aws
|
|
29
|
+
from clients_021._observe import observed
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from mypy_boto3_s3 import S3Client as Boto3S3Client
|
|
33
|
+
else:
|
|
34
|
+
Boto3S3Client = Any
|
|
35
|
+
|
|
36
|
+
SYSTEM = {"rpc.system": "aws-api", "rpc.service": "S3"}
|
|
37
|
+
_MISSING = frozenset({"NoSuchKey", "404", "NotFound"})
|
|
38
|
+
MAX_PRESIGN_SECONDS = 7 * 24 * 3600
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ObjectInfo:
|
|
43
|
+
key: str
|
|
44
|
+
size: int
|
|
45
|
+
content_type: str | None
|
|
46
|
+
etag: str | None
|
|
47
|
+
last_modified: datetime | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class S3Client:
|
|
51
|
+
def __init__(
|
|
52
|
+
self, obs: Observability, client: Boto3S3Client, bucket: str, *, dependency: str = "s3"
|
|
53
|
+
) -> None:
|
|
54
|
+
self._obs = obs
|
|
55
|
+
self._client = client
|
|
56
|
+
self.bucket = bucket
|
|
57
|
+
self.dependency = identifier(dependency)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def create(
|
|
61
|
+
cls,
|
|
62
|
+
obs: Observability,
|
|
63
|
+
bucket: str,
|
|
64
|
+
*,
|
|
65
|
+
region: str | None = None,
|
|
66
|
+
endpoint_url: str | None = None,
|
|
67
|
+
dependency: str = "s3",
|
|
68
|
+
timeout_seconds: float = 10.0,
|
|
69
|
+
) -> S3Client:
|
|
70
|
+
client: Boto3S3Client = boto3.client( # pyright: ignore[reportUnknownMemberType]
|
|
71
|
+
"s3",
|
|
72
|
+
region_name=region,
|
|
73
|
+
endpoint_url=endpoint_url,
|
|
74
|
+
config=_aws.config(
|
|
75
|
+
connect_seconds=min(timeout_seconds, 3.0), read_seconds=timeout_seconds
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
return cls(obs, client, bucket, dependency=dependency)
|
|
79
|
+
|
|
80
|
+
def _observed(self, operation: str): # noqa: ANN202 - an async context manager
|
|
81
|
+
return observed(
|
|
82
|
+
self._obs,
|
|
83
|
+
family="storage",
|
|
84
|
+
system=SYSTEM,
|
|
85
|
+
dependency=self.dependency,
|
|
86
|
+
operation=operation,
|
|
87
|
+
classify=_aws.classify,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def put(
|
|
91
|
+
self, key: str, body: bytes, *, content_type: str = "application/octet-stream"
|
|
92
|
+
) -> str | None:
|
|
93
|
+
"""Stores the object and returns its ETag."""
|
|
94
|
+
async with self._observed("put"):
|
|
95
|
+
response = await asyncio.to_thread(
|
|
96
|
+
self._client.put_object,
|
|
97
|
+
Bucket=self.bucket,
|
|
98
|
+
Key=key,
|
|
99
|
+
Body=body,
|
|
100
|
+
ContentType=content_type,
|
|
101
|
+
)
|
|
102
|
+
return response.get("ETag")
|
|
103
|
+
|
|
104
|
+
async def get(self, key: str) -> bytes | None:
|
|
105
|
+
"""The object's bytes, or None when it does not exist."""
|
|
106
|
+
async with self._observed("get"):
|
|
107
|
+
return await asyncio.to_thread(self._read, key)
|
|
108
|
+
|
|
109
|
+
def _read(self, key: str) -> bytes | None:
|
|
110
|
+
try:
|
|
111
|
+
response = self._client.get_object(Bucket=self.bucket, Key=key)
|
|
112
|
+
except ClientError as error:
|
|
113
|
+
if _aws.error_code(error) in _MISSING:
|
|
114
|
+
return None
|
|
115
|
+
raise
|
|
116
|
+
with response["Body"] as stream:
|
|
117
|
+
return stream.read()
|
|
118
|
+
|
|
119
|
+
async def head(self, key: str) -> ObjectInfo | None:
|
|
120
|
+
"""The object's metadata, or None when it does not exist."""
|
|
121
|
+
async with self._observed("head"):
|
|
122
|
+
response = await asyncio.to_thread(self._head, key)
|
|
123
|
+
if response is None:
|
|
124
|
+
return None
|
|
125
|
+
return ObjectInfo(
|
|
126
|
+
key=key,
|
|
127
|
+
size=int(response.get("ContentLength", 0)),
|
|
128
|
+
content_type=response.get("ContentType"),
|
|
129
|
+
etag=response.get("ETag"),
|
|
130
|
+
last_modified=response.get("LastModified"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _head(self, key: str) -> dict[str, Any] | None:
|
|
134
|
+
try:
|
|
135
|
+
return dict(self._client.head_object(Bucket=self.bucket, Key=key))
|
|
136
|
+
except ClientError as error:
|
|
137
|
+
if _aws.error_code(error) in _MISSING:
|
|
138
|
+
return None
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
async def delete(self, key: str) -> None:
|
|
142
|
+
"""Deleting a missing object succeeds, as in S3."""
|
|
143
|
+
async with self._observed("delete"):
|
|
144
|
+
await asyncio.to_thread(self._client.delete_object, Bucket=self.bucket, Key=key)
|
|
145
|
+
|
|
146
|
+
async def aclose(self) -> None:
|
|
147
|
+
"""Release the underlying SDK connection pools after in-flight calls finish."""
|
|
148
|
+
await asyncio.to_thread(self._client.close)
|
|
149
|
+
|
|
150
|
+
def presign_get(self, key: str, *, expires_seconds: int = 900) -> str:
|
|
151
|
+
return self._presign("get_object", key, expires_seconds, {})
|
|
152
|
+
|
|
153
|
+
def presign_put(self, key: str, *, content_type: str, expires_seconds: int = 900) -> str:
|
|
154
|
+
"""The uploader must send the same Content-Type header."""
|
|
155
|
+
return self._presign("put_object", key, expires_seconds, {"ContentType": content_type})
|
|
156
|
+
|
|
157
|
+
def _presign(self, method: str, key: str, expires: int, extra: dict[str, str]) -> str:
|
|
158
|
+
if not 1 <= expires <= MAX_PRESIGN_SECONDS:
|
|
159
|
+
raise ValueError(f"expires_seconds must be between 1 and {MAX_PRESIGN_SECONDS}")
|
|
160
|
+
return self._client.generate_presigned_url(
|
|
161
|
+
method,
|
|
162
|
+
Params={"Bucket": self.bucket, "Key": key, **extra},
|
|
163
|
+
ExpiresIn=expires,
|
|
164
|
+
)
|