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.
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
+ )