021-python-clients 0.1.0__tar.gz → 0.2.0__tar.gz

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 (48) hide show
  1. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/CHANGELOG.md +26 -0
  2. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/CONTRACT.md +38 -10
  3. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/CONTRIBUTING.md +13 -8
  4. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/PKG-INFO +81 -13
  5. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/README.md +76 -11
  6. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/pyproject.toml +6 -2
  7. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/__init__.py +4 -3
  8. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/_observe.py +1 -1
  9. 021_python_clients-0.2.0/src/clients_021/cache/__init__.py +1 -0
  10. {021_python_clients-0.1.0/src/clients_021 → 021_python_clients-0.2.0/src/clients_021/cache}/redis.py +1 -1
  11. 021_python_clients-0.2.0/src/clients_021/crypto/__init__.py +1 -0
  12. {021_python_clients-0.1.0/src/clients_021 → 021_python_clients-0.2.0/src/clients_021/crypto}/kms.py +1 -1
  13. 021_python_clients-0.2.0/src/clients_021/kms.py +5 -0
  14. 021_python_clients-0.2.0/src/clients_021/redis.py +5 -0
  15. 021_python_clients-0.2.0/src/clients_021/s3.py +5 -0
  16. 021_python_clients-0.2.0/src/clients_021/storage/__init__.py +1 -0
  17. {021_python_clients-0.1.0/src/clients_021 → 021_python_clients-0.2.0/src/clients_021/storage}/s3.py +1 -1
  18. 021_python_clients-0.2.0/src/clients_021/storage/vercel_blob.py +169 -0
  19. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_contract.py +3 -3
  20. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_kms.py +1 -1
  21. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_redis.py +1 -1
  22. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_s3.py +1 -1
  23. 021_python_clients-0.2.0/tests/test_vercel_blob.py +347 -0
  24. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/wheel_minimal.py +12 -1
  25. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/wheel_smoke.py +14 -3
  26. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/.gitignore +0 -0
  27. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/LICENSE +0 -0
  28. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/_aws.py +0 -0
  29. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/email/__init__.py +0 -0
  30. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/email/_message.py +0 -0
  31. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/email/resend.py +0 -0
  32. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/email/ses.py +0 -0
  33. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/errors.py +0 -0
  34. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/http.py +0 -0
  35. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/httpx2.py +0 -0
  36. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/py.typed +0 -0
  37. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/queue/__init__.py +0 -0
  38. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/queue/qstash.py +0 -0
  39. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/src/clients_021/queue/sqs.py +0 -0
  40. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/__init__.py +0 -0
  41. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/conftest.py +0 -0
  42. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/qstash_local.py +0 -0
  43. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_email.py +0 -0
  44. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_http.py +0 -0
  45. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_httpx2.py +0 -0
  46. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_qstash.py +0 -0
  47. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_ses.py +0 -0
  48. {021_python_clients-0.1.0 → 021_python_clients-0.2.0}/tests/test_sqs.py +0 -0
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes are recorded here by release-please from Conventional Commits.
4
4
 
5
+ ## [0.2.0](https://github.com/from021-io/python-clients/compare/v0.1.0...v0.2.0) (2026-10-21)
6
+
7
+
8
+ ### Features
9
+
10
+ * **storage:** add observed Vercel Blob client ([9850d03](https://github.com/from021-io/python-clients/commit/9850d0381eed264c5d2962dec1340eb110621480))
11
+ * **storage:** add observed Vercel Blob client ([2cd982d](https://github.com/from021-io/python-clients/commit/2cd982d79b5ebe7e3c5f473c41403803f2803a72))
12
+ * **storage:** add observed Vercel Blob client ([#7](https://github.com/from021-io/python-clients/issues/7)) ([9850d03](https://github.com/from021-io/python-clients/commit/9850d0381eed264c5d2962dec1340eb110621480))
13
+
14
+
15
+ ### Refactors
16
+
17
+ * **clients:** group providers by capability ([56d4d89](https://github.com/from021-io/python-clients/commit/56d4d89ac69f757a059fed5d19a636a16ce417ed))
18
+ * **clients:** group providers by capability ([3bac4c1](https://github.com/from021-io/python-clients/commit/3bac4c14cf1c9b7157e1dec42c6a827d0fc46890))
19
+ * **clients:** group providers by capability ([#6](https://github.com/from021-io/python-clients/issues/6)) ([56d4d89](https://github.com/from021-io/python-clients/commit/56d4d89ac69f757a059fed5d19a636a16ce417ed))
20
+
21
+ ## [Unreleased]
22
+
23
+ ### Added
24
+
25
+ - Add an optional Vercel Blob client with observed CRUD, explicit public/private access, bounded operations and SDK-native metadata/errors.
26
+
27
+ ### Changed
28
+
29
+ - Group Redis, S3 and KMS under cache, storage and crypto, retaining the 0.1.0 imports as compatibility aliases.
30
+
5
31
  ## 0.1.0 (2026-10-21)
6
32
 
7
33
 
@@ -8,13 +8,19 @@ semantics below is a breaking change.
8
8
  Queue providers live in `clients_021.queue.sqs` (`SqsClient`, `ReceivedMessage`) and
9
9
  `clients_021.queue.qstash` (`QStashClient`, `PublishedMessage`, `QStashResponseError`).
10
10
  Use these imports in place of the initial draft's root-level `clients_021.sqs` and
11
- `clients_021.qstash` paths. This organization is part of the first unreleased API.
11
+ `clients_021.qstash` paths. These capability paths shipped in 0.1.0.
12
12
  Importing `clients_021.queue` or its QStash module does not require the `aws` extra.
13
13
 
14
14
  The queue package groups providers by purpose. SQS owns polling, receipts and visibility;
15
15
  QStash owns HTTP delivery. Their public methods and observability contracts remain distinct.
16
16
  Shared email types and the interchangeable `EmailSender` protocol live in `clients_021.email`.
17
17
 
18
+ Redis lives in `clients_021.cache.redis`, S3 in `clients_021.storage.s3` and KMS in
19
+ `clients_021.crypto.kms`. The 0.1.0 root modules (`redis`, `s3`, `kms`) continue to re-export
20
+ the same clients and result types (`ObjectInfo`, `DataKey`). Existing call signatures,
21
+ metric/span names and error semantics are unchanged. Capability package imports do not
22
+ load optional SDKs. HTTP transports remain in `clients_021.http` and `clients_021.httpx2`.
23
+
18
24
  ## Metrics
19
25
 
20
26
  All on the service's `Observability` meter, so they carry its resource (service name,
@@ -26,8 +32,8 @@ version, environment). Durations use 021-python-observability's buckets.
26
32
  | `db.client.operation.duration` | Histogram / `s` | same | Redis |
27
33
  | `messaging.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | SQS, QStash, email (Resend, SES) |
28
34
  | `messaging.client.operation.duration` | Histogram / `s` | same | SQS, QStash, email (Resend, SES) |
29
- | `storage.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | S3 |
30
- | `storage.client.operation.duration` | Histogram / `s` | same | S3 |
35
+ | `storage.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | S3, Vercel Blob |
36
+ | `storage.client.operation.duration` | Histogram / `s` | same | S3, Vercel Blob |
31
37
  | `crypto.client.operation.count` | Counter / `{operation}` | dependency, operation, status, error.type | KMS |
32
38
  | `crypto.client.operation.duration` | Histogram / `s` | same | KMS |
33
39
  | `http.client.request.count` | Counter / `{request}` | dependency, http.request.method, http.response.status_code, error.type | HTTP, httpx2, QStash transport, Resend (its HTTP request) |
@@ -36,10 +42,11 @@ version, environment). Durations use 021-python-observability's buckets.
36
42
  - `status`: `ok` or `error`. `error.type` only on errors.
37
43
  - `operation`: a fixed set per client. Redis: `get_json`, `set_json`, `take_json`,
38
44
  `delete`, `incr_window`, `ping`. SQS: `send`, `receive`, `delete`, `change_visibility`.
39
- S3: `put`, `get`, `head`, `delete`. QStash: `publish`. Email (Resend, SES): `send`. KMS:
45
+ S3 and Vercel Blob: `put`, `get`, `head`, `delete`. QStash: `publish`.
46
+ Email (Resend, SES): `send`. KMS:
40
47
  `encrypt`, `decrypt`, `generate_data_key`; `seal` and `unseal` record the KMS call they make
41
48
  (`generate_data_key`, `decrypt`), not the local AES work.
42
- - Redis/SQS/S3/QStash/email/KMS `error.type`: `timeout`, `connection_error`, `throttled`,
49
+ - Redis/SQS/S3/Vercel Blob/QStash/email/KMS `error.type`: `timeout`, `connection_error`, `throttled`,
43
50
  `status_<5xx>`, `cancelled`, or the exception class name for errors that are not outages
44
51
  (`EmailRejected` for every email rejection; botocore's class, such as `ClientError` or
45
52
  `NotFoundException`, for KMS).
@@ -50,7 +57,7 @@ version, environment). Durations use 021-python-observability's buckets.
50
57
  `DependencyUnavailable.reason` describes the failure returned to the caller.
51
58
  - httpx2 uses `timeout`, `connection_error`, `cancelled`, or the exception class name.
52
59
  HTTP status errors set the span's ERROR status and status-code label, with no `error.type`.
53
- - A missing S3 object or Redis key is `status=ok`.
60
+ - A missing S3/Blob object or Redis key is `status=ok`.
54
61
  - `dependency` is a static identifier, validated consistently with 021-python-observability.
55
62
  User ids, tenant ids and request-derived values are forbidden even if they match its syntax.
56
63
 
@@ -59,8 +66,8 @@ version, environment). Durations use 021-python-observability's buckets.
59
66
  CLIENT spans named `<dependency> <operation>` (`HTTP <dependency>` for HTTP), with the
60
67
  metric labels plus `db.system.name=redis`, `messaging.system=aws_sqs`,
61
68
  `messaging.system=upstash_qstash`, `messaging.system=resend`, `messaging.system=aws_ses`, or
62
- `rpc.system=aws-api` + `rpc.service=S3` / `rpc.service=KMS`. Resend's `HTTP <dependency>`
63
- span is a child of its `<dependency> send` span. Status ERROR on failure (and on HTTP
69
+ `rpc.system=aws-api` + `rpc.service=S3` / `rpc.service=KMS`, or `rpc.system=vercel-blob`.
70
+ Resend's `HTTP <dependency>` span is a child of its `<dependency> send` span. Status ERROR on failure (and on HTTP
64
71
  status ≥ 400).
65
72
  Cancellation propagates unchanged and records ERROR with `error.type=cancelled`.
66
73
  HTTP transports measure through response headers; they do not measure body consumption.
@@ -78,7 +85,7 @@ Local input validation occurs before either operation is recorded.
78
85
  When enabled, both caller-supplied trace headers are replaced with the current context.
79
86
  - Resend: nothing is sent. The `traceparent`, `tracestate` and `x-request-id` headers that
80
87
  021-python-observability's transport adds are removed before the request leaves.
81
- - SES and KMS: nothing is sent.
88
+ - SES, KMS and Vercel Blob: nothing is sent by the default transports.
82
89
  - SQS: `traceparent` and `tracestate` are message attributes; `ReceivedMessage.trace_context()`
83
90
  returns the producer's context. Caller-supplied values for both trace attributes are
84
91
  replaced with the current context.
@@ -127,6 +134,16 @@ tampered ciphertext (`InvalidCiphertextException`) and a ciphertext from another
127
134
  Every call requires a non-empty encryption context. `unseal` raises `ValueError` for a
128
135
  malformed envelope and `cryptography.exceptions.InvalidTag` when the payload was altered.
129
136
 
137
+ Vercel Blob (`clients_021.storage.vercel_blob`): HTTP 429 maps to `throttled`; 5xx to
138
+ `status_<code>`; HTTP timeouts and total deadlines to `timeout`; network/remote protocol
139
+ failures to `connection_error`. Known transport causes wrapped by the SDK are classified
140
+ without inspecting provider messages. Other errors retain their SDK type, including
141
+ `BlobAccessError`, `BlobStoreNotFoundError`, `BlobPreconditionFailedError`, and
142
+ `BlobStreamError` for malformed acknowledgements. Only `BlobNotFoundError` becomes `None`
143
+ for `get`/`head`, counted as success. `put` returns the SDK's `PutResult` (exported as
144
+ `BlobUpload`); `head` returns its `HeadResult` (`BlobInfo`). These types are provider-specific.
145
+ SDK validation and credential failures can produce an error span but perform no network I/O.
146
+
130
147
  ## Sealed envelope format
131
148
 
132
149
  `KmsClient.seal` output is stored data, so its layout is part of this contract: version
@@ -138,7 +155,7 @@ A new data key is generated for every envelope.
138
155
 
139
156
  ## Lifecycle and retry limits
140
157
 
141
- HTTP, Redis, SQS, QStash, S3, Resend, SES and KMS expose `aclose()`. Close them after
158
+ HTTP, Redis, SQS, QStash, S3, Vercel Blob, Resend, SES and KMS expose `aclose()`. Close them after
142
159
  in-flight calls complete; the wrapper owns the underlying client, including one supplied
143
160
  through the constructor.
144
161
  AWS factories configure three total attempts; injected clients retain their own policy.
@@ -155,9 +172,20 @@ publish again.
155
172
  `HttpClient.request` accepts at most one of `json`, `form` or raw byte `content`. Passing
156
173
  more than one non-None body format raises `ValueError` before sending a request.
157
174
 
175
+ Vercel Blob uses the optional `vercel-blob>=0.5,<0.6` SDK and explicit read-write credentials,
176
+ store ID and access mode. It owns a shared transport; each operation borrows it in a separate
177
+ SDK session. No environment credentials or global SDK client perform requests. The total
178
+ operation deadline defaults to 10 seconds and includes buffered downloads. Timeout and
179
+ cancellation do not imply a remote write was undone. Neither this wrapper nor the SDK
180
+ retries operations; an injected transport must respect this policy. Overwrite and random
181
+ suffixes default to false. Callers decide whether a repeated write is safe. A closed client
182
+ cannot reopen its pool. No additional HTTP metrics are emitted by this client.
183
+
158
184
  ## Never recorded
159
185
 
160
186
  Keys, URLs, query strings, bodies, message ids, receipts, object names, bucket or queue
161
187
  names, provider error messages. Email: sender, recipient and reply-to addresses, subjects,
162
188
  text and HTML bodies, tags, idempotency keys, API keys, configuration set names. KMS: key
163
189
  ids, ARNs and aliases, encryption contexts, plaintext, ciphertext and data keys.
190
+
191
+ Vercel Blob: read-write tokens, store IDs, pathnames, delivery URLs, content and ETags.
@@ -7,25 +7,30 @@ not need to be reimplemented here.
7
7
 
8
8
  ## Layout and public API
9
9
 
10
- Group providers by capability when more than one serves the same purpose, with a module
11
- per provider inside that package. Keep standalone clients at the root until a second
12
- provider makes grouping useful. Tests stay in `tests/test_<provider>.py`.
10
+ Group domain clients by capability, with a module per provider even when the package
11
+ currently has one provider. HTTP transports are cross-cutting infrastructure and stay at
12
+ the root. Tests stay in `tests/test_<provider>.py`.
13
13
 
14
14
  ```text
15
15
  clients_021/
16
16
  email/ # _message.py (shared protocol/types), resend.py, ses.py
17
17
  queue/ # sqs.py, qstash.py
18
18
  http.py
19
- redis.py
20
- s3.py
21
- kms.py
19
+ httpx2.py
20
+ cache/ # redis.py
21
+ storage/ # s3.py, vercel_blob.py
22
+ crypto/ # kms.py
22
23
  ```
23
24
 
25
+ The original `clients_021.redis`, `clients_021.s3` and `clients_021.kms` imports remain
26
+ compatibility re-exports of the same classes and result types. New code uses the capability
27
+ paths. Do not remove compatibility modules without a breaking release.
28
+
24
29
  Public result and exception types live beside their provider unless they are truly shared.
25
30
  A common protocol requires interchangeable operations and semantics: `EmailSender.send`
26
31
  qualifies. SQS polling/receipts and QStash HTTP delivery do not share a consumer API, so
27
- `queue/` groups them without inventing one. Provider imports are explicit, and importing
28
- the capability package must not load optional SDKs.
32
+ `queue/` groups them without inventing one. S3 and Blob also keep provider-specific metadata
33
+ and write options. Provider imports are explicit, and importing the capability package must not load optional SDKs.
29
34
 
30
35
  Use private helpers for actual shared behavior: `_observe.py` owns instrumentation and
31
36
  `_aws.py` owns AWS configuration and error classification. Avoid a universal base class:
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 021-python-clients
3
- Version: 0.1.0
4
- Summary: Observed clients for Redis, SQS, QStash, S3, HTTP, email and KMS, built on 021-python-observability
3
+ Version: 0.2.0
4
+ Summary: Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email and KMS, built on 021-python-observability
5
5
  Project-URL: Repository, https://github.com/from021-io/python-clients
6
6
  Project-URL: Changelog, https://github.com/from021-io/python-clients/blob/main/CHANGELOG.md
7
7
  Project-URL: Contract, https://github.com/from021-io/python-clients/blob/main/CONTRACT.md
@@ -29,6 +29,9 @@ Provides-Extra: httpx2
29
29
  Requires-Dist: httpx2<3,>=2; extra == 'httpx2'
30
30
  Provides-Extra: redis
31
31
  Requires-Dist: redis<9,>=6; extra == 'redis'
32
+ Provides-Extra: vercel-blob
33
+ Requires-Dist: httpx2<3,>=2; extra == 'vercel-blob'
34
+ Requires-Dist: vercel-blob<0.6,>=0.5; extra == 'vercel-blob'
32
35
  Description-Content-Type: text/markdown
33
36
 
34
37
  # 021-python-clients
@@ -40,20 +43,21 @@ dependency call gets the same span, the same metrics under a stable dependency n
40
43
  the same failure type.
41
44
 
42
45
  ```bash
43
- uv add "021-python-clients[redis,aws,httpx2]" # pick the extras you use
46
+ uv add "021-python-clients[redis,aws,httpx2,vercel-blob]" # pick the extras you use
44
47
  ```
45
48
 
46
49
  | Module | Extra | What it gives |
47
50
  | --- | --- | --- |
48
51
  | `clients_021.http` | none | `HttpClient`: one httpx client per dependency, timeouts, retries on idempotent methods |
49
52
  | `clients_021.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
50
- | `clients_021.redis` | `redis` | `RedisClient`: JSON with TTL, one-time reads, windowed counters, ping |
53
+ | `clients_021.cache.redis` | `redis` | `RedisClient`: JSON with TTL, one-time reads, windowed counters, ping |
51
54
  | `clients_021.queue.qstash` | none | `QStashClient`: JSON publishing, deduplication, delivery options and forwarded trace context |
52
55
  | `clients_021.queue.sqs` | `aws` | `SqsClient`: send, receive, delete, visibility, trace carried in the message |
53
- | `clients_021.s3` | `aws` | `S3Client`: put, get, head, delete, presigned URLs |
56
+ | `clients_021.storage.s3` | `aws` | `S3Client`: put, get, head, delete, presigned URLs |
57
+ | `clients_021.storage.vercel_blob` | `vercel-blob` | `VercelBlobClient`: put, get, head and conditional delete for public/private stores |
54
58
  | `clients_021.email` | none | `EmailMessage`, the `EmailSender` protocol, `ResendSender` (Resend's HTTP API) |
55
59
  | `clients_021.email.ses` | `aws` | `SesSender`: the same protocol over Amazon SES v2 |
56
- | `clients_021.kms` | `aws` | `KmsClient`: encrypt, decrypt and data keys bound to an encryption context; AES-256-GCM envelopes |
60
+ | `clients_021.crypto.kms` | `aws` | `KmsClient`: encrypt, decrypt and data keys bound to an encryption context; AES-256-GCM envelopes |
57
61
 
58
62
  Every client takes the service's `Observability` (from `setup_observability`) and a
59
63
  `dependency` name, which becomes the `dependency` label of its metrics.
@@ -61,11 +65,16 @@ Every client takes the service's `Observability` (from `setup_observability`) an
61
65
  ## Organization
62
66
 
63
67
  Providers with the same purpose live under one capability package: `email/` contains
64
- Resend and SES; `queue/` contains SQS and QStash. Import queue clients explicitly:
68
+ Resend and SES; `queue/` contains SQS and QStash; `storage/` contains S3 and Vercel Blob; `cache/`
69
+ contains Redis; `crypto/` contains KMS. HTTP transports remain at the root. Import providers
70
+ explicitly:
65
71
 
66
72
  ```python
67
73
  from clients_021.queue.sqs import SqsClient
68
74
  from clients_021.queue.qstash import QStashClient
75
+ from clients_021.storage.s3 import S3Client
76
+ from clients_021.cache.redis import RedisClient
77
+ from clients_021.crypto.kms import KmsClient
69
78
  ```
70
79
 
71
80
  SQS supports polling, receipts and visibility changes; QStash delivers to an HTTP endpoint.
@@ -74,16 +83,20 @@ because both implement the same send operation. A shared package does not imply
74
83
  provider implements an interchangeable consumption protocol. Importing `queue` or QStash
75
84
  does not load the optional AWS SDK.
76
85
 
86
+ The 0.1.0 imports `clients_021.redis`, `clients_021.s3` and `clients_021.kms` remain
87
+ compatible aliases for the same classes and result types. Importing a capability package
88
+ does not load its optional SDKs.
89
+
77
90
  ## What every call gets
78
91
 
79
92
  - A CLIENT span named `<dependency> <operation>` (`HTTP <dependency>` for HTTP), with the
80
93
  system attribute (`db.system.name=redis`, `messaging.system=aws_sqs|resend|aws_ses`,
81
- `rpc.service=S3|KMS`).
94
+ `rpc.service=S3|KMS`, `rpc.system=vercel-blob`).
82
95
  - A counter and a duration histogram: `db.client.operation.*` (Redis, shared with SQL),
83
- `messaging.client.operation.*` (SQS, QStash, email), `storage.client.operation.*` (S3),
96
+ `messaging.client.operation.*` (SQS, QStash, email), `storage.client.operation.*` (S3, Vercel Blob),
84
97
  `crypto.client.operation.*` (KMS), `http.client.request.*` (HTTP, and the HTTP request
85
98
  under QStash and Resend). Labels are bounded; see [CONTRACT.md](CONTRACT.md).
86
- - Redis, SQS, QStash, S3, email and KMS emit a `dependency.call_failed` warning on failure,
99
+ - Redis, SQS, QStash, S3, Vercel Blob, email and KMS emit a `dependency.call_failed` warning on failure,
87
100
  with dependency, operation and error type only. HTTP transports emit spans and metrics.
88
101
  - `DependencyUnavailable(dependency, operation, reason)` when the service timed out,
89
102
  refused the connection, throttled or failed with a 5xx. Map it to a 503. Errors that mean
@@ -100,8 +113,8 @@ them from a tenant, user or request. All clients apply observability's identifie
100
113
 
101
114
  ```python
102
115
  from clients_021 import DependencyUnavailable, HttpClient
103
- from clients_021.redis import RedisClient
104
- from clients_021.s3 import S3Client
116
+ from clients_021.cache.redis import RedisClient
117
+ from clients_021.storage.s3 import S3Client
105
118
  from clients_021.queue.sqs import SqsClient
106
119
 
107
120
  redis = RedisClient.from_url(obs, settings.redis_url)
@@ -123,6 +136,59 @@ http = HttpClient(obs, timeout_seconds=10)
123
136
  response = await http.request("GET", jwks_url, dependency="identity-provider")
124
137
  ```
125
138
 
139
+ ### Vercel Blob
140
+
141
+ ```python
142
+ from clients_021.storage.vercel_blob import VercelBlobClient
143
+
144
+ files = VercelBlobClient(
145
+ obs,
146
+ token=settings.blob_token,
147
+ store_id=settings.blob_store_id,
148
+ access="private", # Required; must match the store. Public is also supported.
149
+ dependency="documents",
150
+ timeout_seconds=10,
151
+ )
152
+ try:
153
+ uploaded = await files.put("reports/report.pdf", pdf_bytes, content_type="application/pdf")
154
+ content = await files.get(uploaded.url) # bytes, or None for a missing object
155
+ info = await files.head(uploaded.url) # metadata, or None for a missing object
156
+ if info is not None:
157
+ await files.delete(uploaded.url, if_match=info.etag)
158
+ finally:
159
+ await files.aclose()
160
+ ```
161
+
162
+ Uses the [official Python Blob SDK](https://vercel.com/docs/vercel-blob/python-sdk-reference),
163
+ `vercel-blob>=0.5,<0.6`, installed with the `vercel-blob` extra. This is the current functional
164
+ SDK API; it does not depend on the earlier `AsyncBlobClient` API or the full `vercel` bundle.
165
+ Credentials and store access are explicit; no environment or default SDK session is used
166
+ for requests. A read-write token's store must match `store_id` (the `store_` prefix is allowed).
167
+
168
+ `put` returns `BlobUpload` (the SDK's `PutResult`); `head` returns `BlobInfo` (its `HeadResult`).
169
+ These are provider-specific types, not a common S3 protocol. Uploads require a pathname;
170
+ reads, metadata and deletes accept a pathname or a Vercel delivery URL. The SDK validates
171
+ those URLs and does not follow redirects. `get` buffers the entire body in memory.
172
+
173
+ Each operation has one attempt and a finite total deadline, including body consumption.
174
+ The client reuses its connection pool across calls and closes it through `aclose()`,
175
+ including an injected `httpx2.AsyncBaseTransport`. Call `aclose()` only after in-flight calls
176
+ finish. Calls after close fail locally. SDK sessions are scoped to each operation so
177
+ concurrent clients keep their credentials separate. No trace headers are sent by default.
178
+
179
+ `allow_overwrite=False` and `add_random_suffix=False` by default. Both can be explicitly
180
+ enabled, along with `cache_control_max_age`. A timeout may follow a successful upload:
181
+ retrying with a random suffix can create another object; overwriting a stable pathname can
182
+ race with other writers. Deleting a missing object succeeds. `if_match` guards a delete
183
+ against a changed ETag. CDN caches can retain deleted content briefly.
184
+
185
+ 429, 5xx, transport failures and deadlines become `DependencyUnavailable`. Authentication,
186
+ missing stores, precondition failures, invalid inputs and malformed responses retain SDK
187
+ exceptions; only a missing object becomes `None` for `get`/`head`. Cancellation propagates.
188
+ The storage operation includes the complete request and response body; this client does
189
+ not emit an additional HTTP metric. Signed URLs, multipart and streaming APIs stay outside
190
+ this initial client.
191
+
126
192
  ### QStash
127
193
 
128
194
  ```python
@@ -207,7 +273,7 @@ The encryption context is required on every call, so a ciphertext only decrypts
207
273
  purpose it was made for:
208
274
 
209
275
  ```python
210
- from clients_021.kms import KmsClient
276
+ from clients_021.crypto.kms import KmsClient
211
277
 
212
278
  keys = KmsClient.create(obs, settings.session_key_arn, region="us-east-1")
213
279
  context = {"purpose": "refresh-token", "tenant": tenant_id}
@@ -292,6 +358,8 @@ Retry policies are explicit:
292
358
  `DependencyUnavailable`; other rejected statuses and invalid acknowledgements raise
293
359
  `QStashResponseError` with a status code and no provider body. Delivery timeouts and
294
360
  delays use integer seconds and remain subject to the provider plan's limits.
361
+ - Vercel Blob sends each operation once, with a finite total deadline (10 seconds by default).
362
+ An injected transport must also avoid automatic retries of uncertain writes.
295
363
  - httpx2 SDKs own their retries; `ObservedTransport` adds no extra retry loop.
296
364
 
297
365
  `DependencyUnavailable` signals a transient dependency failure, **not permission to replay
@@ -7,20 +7,21 @@ dependency call gets the same span, the same metrics under a stable dependency n
7
7
  the same failure type.
8
8
 
9
9
  ```bash
10
- uv add "021-python-clients[redis,aws,httpx2]" # pick the extras you use
10
+ uv add "021-python-clients[redis,aws,httpx2,vercel-blob]" # pick the extras you use
11
11
  ```
12
12
 
13
13
  | Module | Extra | What it gives |
14
14
  | --- | --- | --- |
15
15
  | `clients_021.http` | none | `HttpClient`: one httpx client per dependency, timeouts, retries on idempotent methods |
16
16
  | `clients_021.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
17
- | `clients_021.redis` | `redis` | `RedisClient`: JSON with TTL, one-time reads, windowed counters, ping |
17
+ | `clients_021.cache.redis` | `redis` | `RedisClient`: JSON with TTL, one-time reads, windowed counters, ping |
18
18
  | `clients_021.queue.qstash` | none | `QStashClient`: JSON publishing, deduplication, delivery options and forwarded trace context |
19
19
  | `clients_021.queue.sqs` | `aws` | `SqsClient`: send, receive, delete, visibility, trace carried in the message |
20
- | `clients_021.s3` | `aws` | `S3Client`: put, get, head, delete, presigned URLs |
20
+ | `clients_021.storage.s3` | `aws` | `S3Client`: put, get, head, delete, presigned URLs |
21
+ | `clients_021.storage.vercel_blob` | `vercel-blob` | `VercelBlobClient`: put, get, head and conditional delete for public/private stores |
21
22
  | `clients_021.email` | none | `EmailMessage`, the `EmailSender` protocol, `ResendSender` (Resend's HTTP API) |
22
23
  | `clients_021.email.ses` | `aws` | `SesSender`: the same protocol over Amazon SES v2 |
23
- | `clients_021.kms` | `aws` | `KmsClient`: encrypt, decrypt and data keys bound to an encryption context; AES-256-GCM envelopes |
24
+ | `clients_021.crypto.kms` | `aws` | `KmsClient`: encrypt, decrypt and data keys bound to an encryption context; AES-256-GCM envelopes |
24
25
 
25
26
  Every client takes the service's `Observability` (from `setup_observability`) and a
26
27
  `dependency` name, which becomes the `dependency` label of its metrics.
@@ -28,11 +29,16 @@ Every client takes the service's `Observability` (from `setup_observability`) an
28
29
  ## Organization
29
30
 
30
31
  Providers with the same purpose live under one capability package: `email/` contains
31
- Resend and SES; `queue/` contains SQS and QStash. Import queue clients explicitly:
32
+ Resend and SES; `queue/` contains SQS and QStash; `storage/` contains S3 and Vercel Blob; `cache/`
33
+ contains Redis; `crypto/` contains KMS. HTTP transports remain at the root. Import providers
34
+ explicitly:
32
35
 
33
36
  ```python
34
37
  from clients_021.queue.sqs import SqsClient
35
38
  from clients_021.queue.qstash import QStashClient
39
+ from clients_021.storage.s3 import S3Client
40
+ from clients_021.cache.redis import RedisClient
41
+ from clients_021.crypto.kms import KmsClient
36
42
  ```
37
43
 
38
44
  SQS supports polling, receipts and visibility changes; QStash delivers to an HTTP endpoint.
@@ -41,16 +47,20 @@ because both implement the same send operation. A shared package does not imply
41
47
  provider implements an interchangeable consumption protocol. Importing `queue` or QStash
42
48
  does not load the optional AWS SDK.
43
49
 
50
+ The 0.1.0 imports `clients_021.redis`, `clients_021.s3` and `clients_021.kms` remain
51
+ compatible aliases for the same classes and result types. Importing a capability package
52
+ does not load its optional SDKs.
53
+
44
54
  ## What every call gets
45
55
 
46
56
  - A CLIENT span named `<dependency> <operation>` (`HTTP <dependency>` for HTTP), with the
47
57
  system attribute (`db.system.name=redis`, `messaging.system=aws_sqs|resend|aws_ses`,
48
- `rpc.service=S3|KMS`).
58
+ `rpc.service=S3|KMS`, `rpc.system=vercel-blob`).
49
59
  - A counter and a duration histogram: `db.client.operation.*` (Redis, shared with SQL),
50
- `messaging.client.operation.*` (SQS, QStash, email), `storage.client.operation.*` (S3),
60
+ `messaging.client.operation.*` (SQS, QStash, email), `storage.client.operation.*` (S3, Vercel Blob),
51
61
  `crypto.client.operation.*` (KMS), `http.client.request.*` (HTTP, and the HTTP request
52
62
  under QStash and Resend). Labels are bounded; see [CONTRACT.md](CONTRACT.md).
53
- - Redis, SQS, QStash, S3, email and KMS emit a `dependency.call_failed` warning on failure,
63
+ - Redis, SQS, QStash, S3, Vercel Blob, email and KMS emit a `dependency.call_failed` warning on failure,
54
64
  with dependency, operation and error type only. HTTP transports emit spans and metrics.
55
65
  - `DependencyUnavailable(dependency, operation, reason)` when the service timed out,
56
66
  refused the connection, throttled or failed with a 5xx. Map it to a 503. Errors that mean
@@ -67,8 +77,8 @@ them from a tenant, user or request. All clients apply observability's identifie
67
77
 
68
78
  ```python
69
79
  from clients_021 import DependencyUnavailable, HttpClient
70
- from clients_021.redis import RedisClient
71
- from clients_021.s3 import S3Client
80
+ from clients_021.cache.redis import RedisClient
81
+ from clients_021.storage.s3 import S3Client
72
82
  from clients_021.queue.sqs import SqsClient
73
83
 
74
84
  redis = RedisClient.from_url(obs, settings.redis_url)
@@ -90,6 +100,59 @@ http = HttpClient(obs, timeout_seconds=10)
90
100
  response = await http.request("GET", jwks_url, dependency="identity-provider")
91
101
  ```
92
102
 
103
+ ### Vercel Blob
104
+
105
+ ```python
106
+ from clients_021.storage.vercel_blob import VercelBlobClient
107
+
108
+ files = VercelBlobClient(
109
+ obs,
110
+ token=settings.blob_token,
111
+ store_id=settings.blob_store_id,
112
+ access="private", # Required; must match the store. Public is also supported.
113
+ dependency="documents",
114
+ timeout_seconds=10,
115
+ )
116
+ try:
117
+ uploaded = await files.put("reports/report.pdf", pdf_bytes, content_type="application/pdf")
118
+ content = await files.get(uploaded.url) # bytes, or None for a missing object
119
+ info = await files.head(uploaded.url) # metadata, or None for a missing object
120
+ if info is not None:
121
+ await files.delete(uploaded.url, if_match=info.etag)
122
+ finally:
123
+ await files.aclose()
124
+ ```
125
+
126
+ Uses the [official Python Blob SDK](https://vercel.com/docs/vercel-blob/python-sdk-reference),
127
+ `vercel-blob>=0.5,<0.6`, installed with the `vercel-blob` extra. This is the current functional
128
+ SDK API; it does not depend on the earlier `AsyncBlobClient` API or the full `vercel` bundle.
129
+ Credentials and store access are explicit; no environment or default SDK session is used
130
+ for requests. A read-write token's store must match `store_id` (the `store_` prefix is allowed).
131
+
132
+ `put` returns `BlobUpload` (the SDK's `PutResult`); `head` returns `BlobInfo` (its `HeadResult`).
133
+ These are provider-specific types, not a common S3 protocol. Uploads require a pathname;
134
+ reads, metadata and deletes accept a pathname or a Vercel delivery URL. The SDK validates
135
+ those URLs and does not follow redirects. `get` buffers the entire body in memory.
136
+
137
+ Each operation has one attempt and a finite total deadline, including body consumption.
138
+ The client reuses its connection pool across calls and closes it through `aclose()`,
139
+ including an injected `httpx2.AsyncBaseTransport`. Call `aclose()` only after in-flight calls
140
+ finish. Calls after close fail locally. SDK sessions are scoped to each operation so
141
+ concurrent clients keep their credentials separate. No trace headers are sent by default.
142
+
143
+ `allow_overwrite=False` and `add_random_suffix=False` by default. Both can be explicitly
144
+ enabled, along with `cache_control_max_age`. A timeout may follow a successful upload:
145
+ retrying with a random suffix can create another object; overwriting a stable pathname can
146
+ race with other writers. Deleting a missing object succeeds. `if_match` guards a delete
147
+ against a changed ETag. CDN caches can retain deleted content briefly.
148
+
149
+ 429, 5xx, transport failures and deadlines become `DependencyUnavailable`. Authentication,
150
+ missing stores, precondition failures, invalid inputs and malformed responses retain SDK
151
+ exceptions; only a missing object becomes `None` for `get`/`head`. Cancellation propagates.
152
+ The storage operation includes the complete request and response body; this client does
153
+ not emit an additional HTTP metric. Signed URLs, multipart and streaming APIs stay outside
154
+ this initial client.
155
+
93
156
  ### QStash
94
157
 
95
158
  ```python
@@ -174,7 +237,7 @@ The encryption context is required on every call, so a ciphertext only decrypts
174
237
  purpose it was made for:
175
238
 
176
239
  ```python
177
- from clients_021.kms import KmsClient
240
+ from clients_021.crypto.kms import KmsClient
178
241
 
179
242
  keys = KmsClient.create(obs, settings.session_key_arn, region="us-east-1")
180
243
  context = {"purpose": "refresh-token", "tenant": tenant_id}
@@ -259,6 +322,8 @@ Retry policies are explicit:
259
322
  `DependencyUnavailable`; other rejected statuses and invalid acknowledgements raise
260
323
  `QStashResponseError` with a status code and no provider body. Delivery timeouts and
261
324
  delays use integer seconds and remain subject to the provider plan's limits.
325
+ - Vercel Blob sends each operation once, with a finite total deadline (10 seconds by default).
326
+ An injected transport must also avoid automatic retries of uncertain writes.
262
327
  - httpx2 SDKs own their retries; `ObservedTransport` adds no extra retry loop.
263
328
 
264
329
  `DependencyUnavailable` signals a transient dependency failure, **not permission to replay
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "021-python-clients"
7
- version = "0.1.0"
8
- description = "Observed clients for Redis, SQS, QStash, S3, HTTP, email and KMS, built on 021-python-observability"
7
+ version = "0.2.0"
8
+ description = "Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email and KMS, built on 021-python-observability"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
11
  license-files = ["LICENSE"]
@@ -34,6 +34,10 @@ redis = ["redis>=6,<9"]
34
34
  # cryptography is for KmsClient.seal / unseal (AES-256-GCM envelopes).
35
35
  aws = ["boto3>=1.40,<2", "cryptography>=44"]
36
36
  httpx2 = ["httpx2>=2,<3"]
37
+ vercel-blob = [
38
+ "httpx2>=2,<3",
39
+ "vercel-blob>=0.5,<0.6",
40
+ ]
37
41
 
38
42
  [project.urls]
39
43
  Repository = "https://github.com/from021-io/python-clients"
@@ -1,9 +1,10 @@
1
1
  """Observed clients for the external services Python services call.
2
2
 
3
3
  `clients_021.http`, `clients_021.queue.qstash` and `clients_021.email` (Resend) need no extras;
4
- `clients_021.redis` needs the `redis` extra; `clients_021.queue.sqs`, `clients_021.s3`,
5
- `clients_021.email.ses` and `clients_021.kms` the `aws` extra; `clients_021.httpx2` the
6
- `httpx2` extra.
4
+ `clients_021.cache.redis` needs the `redis` extra; `clients_021.queue.sqs`,
5
+ `clients_021.storage.s3`, `clients_021.email.ses` and `clients_021.crypto.kms` the `aws` extra;
6
+ `clients_021.httpx2` the `httpx2` extra.
7
+ `clients_021.storage.vercel_blob` needs the `vercel-blob` extra.
7
8
  """
8
9
 
9
10
  from importlib.metadata import version
@@ -2,7 +2,7 @@
2
2
 
3
3
  The `db` family reuses the instruments 021-python-observability already creates for SQL
4
4
  (`db.client.operation.*`), so Redis and Postgres share dashboards. `messaging` (SQS, email),
5
- `storage` (S3) and `crypto` (KMS) get their own pair on the same meter. Attributes are
5
+ `storage` (S3, Vercel Blob) and `crypto` (KMS) get their own pair on the same meter. Attributes are
6
6
  bounded: dependency, operation, status and error.type; never keys, URLs, bodies or ids.
7
7
  """
8
8
 
@@ -0,0 +1 @@
1
+ """Cache clients. Import providers explicitly; SDKs are optional extras."""
@@ -1,6 +1,6 @@
1
1
  """Redis, observed: JSON values with a TTL, one-time reads, windowed counters and a ping.
2
2
 
3
- from clients_021.redis import RedisClient
3
+ from clients_021.cache.redis import RedisClient
4
4
 
5
5
  redis = RedisClient.from_url(obs, settings.redis_url, dependency="redis")
6
6
  await redis.set_json("session:abc", {"user": "ana"}, ttl_seconds=1800)
@@ -0,0 +1 @@
1
+ """Crypto clients. Import providers explicitly; SDKs are optional extras."""
@@ -1,6 +1,6 @@
1
1
  """AWS KMS, observed: encrypt, decrypt and data keys, always bound to an encryption context.
2
2
 
3
- from clients_021.kms import KmsClient
3
+ from clients_021.crypto.kms import KmsClient
4
4
 
5
5
  keys = KmsClient.create(obs, settings.session_key_id, region="us-east-1")
6
6
  context = {"purpose": "session", "tenant": tenant_id}
@@ -0,0 +1,5 @@
1
+ """Compatibility imports for 0.1.0; prefer ``clients_021.crypto.kms``."""
2
+
3
+ from clients_021.crypto.kms import SYSTEM, DataKey, KmsClient
4
+
5
+ __all__ = ["KmsClient", "DataKey", "SYSTEM"]
@@ -0,0 +1,5 @@
1
+ """Compatibility imports for 0.1.0; prefer ``clients_021.cache.redis``."""
2
+
3
+ from clients_021.cache.redis import SYSTEM, RedisClient
4
+
5
+ __all__ = ["RedisClient", "SYSTEM"]
@@ -0,0 +1,5 @@
1
+ """Compatibility imports for 0.1.0; prefer ``clients_021.storage.s3``."""
2
+
3
+ from clients_021.storage.s3 import MAX_PRESIGN_SECONDS, SYSTEM, ObjectInfo, S3Client
4
+
5
+ __all__ = ["S3Client", "ObjectInfo", "MAX_PRESIGN_SECONDS", "SYSTEM"]
@@ -0,0 +1 @@
1
+ """Storage clients. Import providers explicitly; SDKs are optional extras."""
@@ -1,6 +1,6 @@
1
1
  """S3 (or any S3-compatible store), observed: put, get, head, delete and presigned URLs.
2
2
 
3
- from clients_021.s3 import S3Client
3
+ from clients_021.storage.s3 import S3Client
4
4
 
5
5
  files = S3Client.create(obs, bucket="service-uploads", region="us-east-1")
6
6
  await files.put("reports/2026-10.pdf", data, content_type="application/pdf")