lyzr-cloudrift 0.1.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 (43) hide show
  1. lyzr_cloudrift-0.1.0/.claude/settings.local.json +9 -0
  2. lyzr_cloudrift-0.1.0/.github/workflows/develop.yml +34 -0
  3. lyzr_cloudrift-0.1.0/.github/workflows/publish.yml +42 -0
  4. lyzr_cloudrift-0.1.0/LICENSE +21 -0
  5. lyzr_cloudrift-0.1.0/PKG-INFO +362 -0
  6. lyzr_cloudrift-0.1.0/README.md +292 -0
  7. lyzr_cloudrift-0.1.0/cloudrift/__init__.py +16 -0
  8. lyzr_cloudrift-0.1.0/cloudrift/cache/__init__.py +43 -0
  9. lyzr_cloudrift-0.1.0/cloudrift/cache/base.py +270 -0
  10. lyzr_cloudrift-0.1.0/cloudrift/cache/redis_azure.py +162 -0
  11. lyzr_cloudrift-0.1.0/cloudrift/cache/redis_elasticache.py +229 -0
  12. lyzr_cloudrift-0.1.0/cloudrift/cache/redis_standalone.py +118 -0
  13. lyzr_cloudrift-0.1.0/cloudrift/core/__init__.py +0 -0
  14. lyzr_cloudrift-0.1.0/cloudrift/core/exceptions.py +84 -0
  15. lyzr_cloudrift-0.1.0/cloudrift/document/__init__.py +44 -0
  16. lyzr_cloudrift-0.1.0/cloudrift/document/base.py +86 -0
  17. lyzr_cloudrift-0.1.0/cloudrift/document/cosmos.py +371 -0
  18. lyzr_cloudrift-0.1.0/cloudrift/document/documentdb.py +242 -0
  19. lyzr_cloudrift-0.1.0/cloudrift/messaging/__init__.py +43 -0
  20. lyzr_cloudrift-0.1.0/cloudrift/messaging/azure_bus.py +263 -0
  21. lyzr_cloudrift-0.1.0/cloudrift/messaging/base.py +51 -0
  22. lyzr_cloudrift-0.1.0/cloudrift/messaging/sqs.py +206 -0
  23. lyzr_cloudrift-0.1.0/cloudrift/pubsub/__init__.py +46 -0
  24. lyzr_cloudrift-0.1.0/cloudrift/pubsub/azure_eventgrid.py +151 -0
  25. lyzr_cloudrift-0.1.0/cloudrift/pubsub/base.py +42 -0
  26. lyzr_cloudrift-0.1.0/cloudrift/pubsub/sns.py +185 -0
  27. lyzr_cloudrift-0.1.0/cloudrift/secrets/__init__.py +45 -0
  28. lyzr_cloudrift-0.1.0/cloudrift/secrets/aws_secrets_manager.py +180 -0
  29. lyzr_cloudrift-0.1.0/cloudrift/secrets/azure_keyvault.py +119 -0
  30. lyzr_cloudrift-0.1.0/cloudrift/secrets/base.py +46 -0
  31. lyzr_cloudrift-0.1.0/cloudrift/storage/__init__.py +48 -0
  32. lyzr_cloudrift-0.1.0/cloudrift/storage/azure_blob.py +255 -0
  33. lyzr_cloudrift-0.1.0/cloudrift/storage/base.py +76 -0
  34. lyzr_cloudrift-0.1.0/cloudrift/storage/s3.py +234 -0
  35. lyzr_cloudrift-0.1.0/pyproject.toml +81 -0
  36. lyzr_cloudrift-0.1.0/tests/__init__.py +0 -0
  37. lyzr_cloudrift-0.1.0/tests/test_cache.py +202 -0
  38. lyzr_cloudrift-0.1.0/tests/test_document.py +122 -0
  39. lyzr_cloudrift-0.1.0/tests/test_messaging.py +81 -0
  40. lyzr_cloudrift-0.1.0/tests/test_pubsub.py +101 -0
  41. lyzr_cloudrift-0.1.0/tests/test_secrets.py +111 -0
  42. lyzr_cloudrift-0.1.0/tests/test_storage.py +144 -0
  43. lyzr_cloudrift-0.1.0/uv.lock +2666 -0
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run:*)",
5
+ "Bash(uv sync:*)",
6
+ "Bash(.venv/bin/python -m pytest tests/test_document.py -v)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,34 @@
1
+ name: Develop — Test & Publish to TestPyPI
2
+
3
+ on:
4
+ push:
5
+ branches: ["develop"]
6
+
7
+ jobs:
8
+ test-and-publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+
13
+ - uses: astral-sh/setup-uv@v5
14
+ with:
15
+ python-version: "3.11"
16
+
17
+ - name: Install dependencies
18
+ run: uv sync --extra dev
19
+
20
+ - name: Run tests
21
+ run: uv run pytest tests/ -v
22
+
23
+ - name: Set dev version
24
+ run: |
25
+ VERSION=$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
26
+ sed -i "s/version = \"${VERSION}\"/version = \"${VERSION}.dev${{ github.run_number }}\"/" pyproject.toml
27
+
28
+ - name: Build
29
+ run: uv build
30
+
31
+ - name: Publish to TestPyPI
32
+ run: uv publish --publish-url https://test.pypi.org/legacy/
33
+ env:
34
+ UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_TOKEN }}
@@ -0,0 +1,42 @@
1
+ name: Main — Test & Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+
13
+ - uses: astral-sh/setup-uv@v5
14
+ with:
15
+ python-version: "3.11"
16
+
17
+ - name: Install dependencies
18
+ run: uv sync --extra dev
19
+
20
+ - name: Run tests
21
+ run: uv run pytest tests/ -v
22
+
23
+ publish:
24
+ needs: test
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+
32
+ - uses: astral-sh/setup-uv@v5
33
+ with:
34
+ python-version: "3.11"
35
+
36
+ - name: Build
37
+ run: uv build
38
+
39
+ - name: Publish to PyPI
40
+ run: uv publish
41
+ env:
42
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lyzr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,362 @@
1
+ Metadata-Version: 2.4
2
+ Name: lyzr-cloudrift
3
+ Version: 0.1.0
4
+ Summary: Cloud-agnostic abstraction for storage, messaging, document databases, cache, secrets, and pub/sub
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Lyzr
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ License-File: LICENSE
27
+ Keywords: aws,azure,blob,cache,cloud,cosmosdb,documentdb,elasticache,eventgrid,keyvault,pubsub,redis,s3,secrets,servicebus,sns,sqs
28
+ Classifier: Intended Audience :: Developers
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.11
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: Topic :: Software Development :: Libraries
34
+ Requires-Python: >=3.11
35
+ Provides-Extra: all
36
+ Requires-Dist: aioboto3>=13.0.0; extra == 'all'
37
+ Requires-Dist: azure-cosmos>=4.7.0; extra == 'all'
38
+ Requires-Dist: azure-eventgrid>=4.9.0; extra == 'all'
39
+ Requires-Dist: azure-identity>=1.15.0; extra == 'all'
40
+ Requires-Dist: azure-keyvault-secrets>=4.7.0; extra == 'all'
41
+ Requires-Dist: azure-servicebus>=7.11.0; extra == 'all'
42
+ Requires-Dist: azure-storage-blob>=12.19.0; extra == 'all'
43
+ Requires-Dist: motor>=3.3.0; extra == 'all'
44
+ Requires-Dist: redis[hiredis]>=5.0.0; extra == 'all'
45
+ Provides-Extra: aws
46
+ Requires-Dist: aioboto3>=13.0.0; extra == 'aws'
47
+ Requires-Dist: motor>=3.3.0; extra == 'aws'
48
+ Requires-Dist: redis[hiredis]>=5.0.0; extra == 'aws'
49
+ Provides-Extra: azure
50
+ Requires-Dist: azure-cosmos>=4.7.0; extra == 'azure'
51
+ Requires-Dist: azure-eventgrid>=4.9.0; extra == 'azure'
52
+ Requires-Dist: azure-identity>=1.15.0; extra == 'azure'
53
+ Requires-Dist: azure-keyvault-secrets>=4.7.0; extra == 'azure'
54
+ Requires-Dist: azure-servicebus>=7.11.0; extra == 'azure'
55
+ Requires-Dist: azure-storage-blob>=12.19.0; extra == 'azure'
56
+ Requires-Dist: motor>=3.3.0; extra == 'azure'
57
+ Requires-Dist: redis[hiredis]>=5.0.0; extra == 'azure'
58
+ Provides-Extra: cache
59
+ Requires-Dist: redis[hiredis]>=5.0.0; extra == 'cache'
60
+ Provides-Extra: dev
61
+ Requires-Dist: aioboto3>=13.0.0; extra == 'dev'
62
+ Requires-Dist: fakeredis>=2.20.0; extra == 'dev'
63
+ Requires-Dist: httpx>=0.25.0; extra == 'dev'
64
+ Requires-Dist: mongomock-motor>=0.0.21; extra == 'dev'
65
+ Requires-Dist: moto[s3,secretsmanager,server,sns,sqs]>=5.0; extra == 'dev'
66
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
67
+ Requires-Dist: pytest>=8.0; extra == 'dev'
68
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
69
+ Description-Content-Type: text/markdown
70
+
71
+ # cloudrift
72
+
73
+ Cloud-agnostic abstraction for **storage**, **messaging**, **document databases**, and **cache** — built for Lyzr microservices.
74
+
75
+ - **Async-first.** Every public method is `async def`. All four categories use native-async SDK clients (`aioboto3`, `azure.*.aio`, `motor`, `redis.asyncio`) — no thread-pool wrapping.
76
+ - **Drop-in providers.** Same interface across AWS, Azure, and self-hosted backends. Swap `s3` ↔ `azure_blob` (or `sqs` ↔ `azure_bus`, `documentdb` ↔ `cosmos`, `redis` ↔ `elasticache` ↔ `azure_redis`) by changing one string.
77
+ - **Multiple auth methods per provider.** Static keys, IAM roles, profiles, managed identity, service principals, SAS tokens, mTLS, IAM auth — pick what your microservice already has.
78
+
79
+ | Category | AWS | Azure | Self-hosted |
80
+ |---|---|---|---|
81
+ | Storage | S3 | Blob Storage | — |
82
+ | Messaging | SQS | Service Bus | — |
83
+ | Document DB | DocumentDB | Cosmos DB (Core/SQL) | — |
84
+ | Cache | ElastiCache | Azure Cache for Redis | Redis |
85
+
86
+ ---
87
+
88
+ ## Install
89
+
90
+ Pick the extras your service needs:
91
+
92
+ ```bash
93
+ pip install "cloudrift[aws]" # S3 + SQS + DocumentDB + Redis client
94
+ pip install "cloudrift[azure]" # Blob + Service Bus + Cosmos + Redis client
95
+ pip install "cloudrift[cache]" # Just Redis (any flavour)
96
+ pip install "cloudrift[all]" # Everything
97
+ ```
98
+
99
+ Python 3.11+.
100
+
101
+ ---
102
+
103
+ ## Quick start
104
+
105
+ Every backend is constructed via a factory function and held for the lifetime of the service. Reuse one instance per resource — the underlying client is connection-pooled.
106
+
107
+ ```python
108
+ from cloudrift.storage import get_storage
109
+
110
+ # Construct once at startup
111
+ storage = get_storage(
112
+ "s3",
113
+ bucket="my-bucket",
114
+ aws_access_key_id="AKIA...",
115
+ aws_secret_access_key="...",
116
+ region="us-east-1",
117
+ )
118
+
119
+ # Use anywhere
120
+ await storage.upload("docs/hello.txt", b"hello world", content_type="text/plain")
121
+ data = await storage.download("docs/hello.txt")
122
+ url = await storage.presigned_url("docs/hello.txt", expires_in=3600)
123
+
124
+ # Release sockets at shutdown
125
+ await storage.close()
126
+ ```
127
+
128
+ Or as an async context manager (auto-close):
129
+
130
+ ```python
131
+ async with get_storage("s3", bucket="b", region="us-east-1") as storage:
132
+ await storage.upload("k", b"v")
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Microservice integration
138
+
139
+ ### Configuration via env vars
140
+
141
+ Pick the provider per environment with a single env var:
142
+
143
+ ```python
144
+ import os
145
+ from cloudrift.storage import get_storage
146
+
147
+ storage = get_storage(
148
+ os.environ["STORAGE_PROVIDER"], # "s3" in prod, "azure_blob" in dev
149
+ **{
150
+ k.lower().removeprefix("storage_"): v
151
+ for k, v in os.environ.items()
152
+ if k.startswith("STORAGE_") and k != "STORAGE_PROVIDER"
153
+ },
154
+ )
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Storage
160
+
161
+ ```python
162
+ from cloudrift.storage import get_storage
163
+
164
+ # AWS S3
165
+ s3 = get_storage("s3", bucket="b", region="us-east-1") # IAM role
166
+ s3 = get_storage("s3", bucket="b", aws_access_key_id="...", # static keys
167
+ aws_secret_access_key="...", region="us-east-1")
168
+ s3 = get_storage("s3", bucket="b", profile_name="dev") # ~/.aws/credentials
169
+
170
+ # Azure Blob
171
+ blob = get_storage("azure_blob", connection_string="...", container="c")
172
+ blob = get_storage("azure_blob", account_url="https://acct.blob.core.windows.net",
173
+ account_key="...", container="c")
174
+ blob = get_storage("azure_blob", account_url="...", sas_token="...", container="c")
175
+ blob = get_storage("azure_blob", account_url="...", container="c") # managed identity
176
+ blob = get_storage("azure_blob", account_url="...", container="c",
177
+ tenant_id="...", client_id="...", client_secret="...") # service principal
178
+ ```
179
+
180
+ **Operations** — same on every backend:
181
+
182
+ ```python
183
+ await storage.upload(key, data, content_type="application/json")
184
+ data: bytes = await storage.download(key)
185
+ await storage.delete(key)
186
+ exists: bool = await storage.exists(key)
187
+ keys: list[str] = await storage.list(prefix="logs/")
188
+ url: str = await storage.presigned_url(key, expires_in=3600)
189
+ await storage.close()
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Messaging
195
+
196
+ ```python
197
+ from cloudrift.messaging import get_queue
198
+
199
+ # AWS SQS
200
+ sqs = get_queue("sqs", queue_url="https://sqs.us-east-1.amazonaws.com/.../q",
201
+ region="us-east-1")
202
+
203
+ # Azure Service Bus
204
+ bus = get_queue("azure_bus", connection_string="...", queue_name="my-queue")
205
+ bus = get_queue("azure_bus", fully_qualified_namespace="ns.servicebus.windows.net",
206
+ queue_name="my-queue") # managed identity
207
+ ```
208
+
209
+ **Operations**:
210
+
211
+ ```python
212
+ msg_id = await queue.send({"action": "process", "id": 42}, delay=0)
213
+ ids = await queue.send_batch([{"n": 1}, {"n": 2}])
214
+
215
+ messages = await queue.receive(max_messages=10, wait_time=20) # long-poll
216
+ for m in messages:
217
+ handle_job(m.body)
218
+ await queue.delete(m.receipt_handle) # ack (SQS only — see below)
219
+
220
+ await queue.purge()
221
+ await queue.close()
222
+ ```
223
+
224
+ > **Azure Service Bus note:** `delete(receipt_handle)` raises `NotImplementedError` because Service Bus completes messages via the receiver's lock token, not by handle. Until the abstraction is reworked, complete messages inside a custom receiver loop using `azure-servicebus` directly, or use the `purge()` helper.
225
+
226
+ ---
227
+
228
+ ## Document Database
229
+
230
+ ```python
231
+ from cloudrift.document import get_mongodb
232
+
233
+ # AWS DocumentDB (MongoDB-compatible)
234
+ db = get_mongodb(
235
+ "documentdb",
236
+ uri="mongodb://user:pass@cluster.docdb.amazonaws.com:27017/?tls=true",
237
+ database="lyzr",
238
+ tls_ca_file="/etc/ssl/rds-ca-bundle.pem",
239
+ max_pool_size=200,
240
+ )
241
+
242
+ # Azure Cosmos DB (Core/SQL API)
243
+ cdb = get_mongodb("cosmos", connection_string="...", database="lyzr")
244
+ cdb = get_mongodb("cosmos", url="https://acct.documents.azure.com:443/",
245
+ account_key="...", database="lyzr")
246
+ ```
247
+
248
+ **Operations** (MongoDB-style on both):
249
+
250
+ ```python
251
+ doc_id = await db.insert_one("users", {"name": "Alice", "age": 30})
252
+ ids = await db.insert_many("events", [{"v": 1}, {"v": 2}])
253
+
254
+ doc = await db.find_one("users", {"name": "Alice"})
255
+ docs = await db.find("events", {"v": {"$gte": 1}}, limit=100, skip=0)
256
+
257
+ modified = await db.update_one("users", {"_id": doc_id}, {"$set": {"age": 31}})
258
+ deleted = await db.delete_many("events", {"v": 1})
259
+ total = await db.count("users", {"age": {"$gte": 18}})
260
+
261
+ await db.close()
262
+ ```
263
+
264
+ ---
265
+
266
+ ## Cache
267
+
268
+ ```python
269
+ from cloudrift.cache import get_cache
270
+
271
+ # Self-hosted Redis
272
+ cache = get_cache("redis", "from_url", url="redis://localhost:6379/0")
273
+ cache = get_cache("redis", "from_credentials",
274
+ host="redis.internal", port=6379, password="...", db=0)
275
+
276
+ # AWS ElastiCache
277
+ cache = get_cache("elasticache", "from_auth_token",
278
+ host="my-cluster.cache.amazonaws.com", auth_token="...")
279
+ cache = get_cache("elasticache", "from_iam_auth",
280
+ host="my-cluster.cache.amazonaws.com",
281
+ username="lyzr-app", region="us-east-1") # SigV4 + auto-refresh
282
+
283
+ # Azure Cache for Redis
284
+ cache = get_cache("azure_redis", "from_access_key",
285
+ host="my-cache.redis.cache.windows.net", access_key="...")
286
+ cache = get_cache("azure_redis", "from_managed_identity",
287
+ host="my-cache.redis.cache.windows.net", username="lyzr-app")
288
+ ```
289
+
290
+ **Operations** — KV, hash, list, counters:
291
+
292
+ ```python
293
+ await cache.set("session:abc", b"data", ttl=3600)
294
+ value: bytes | None = await cache.get("session:abc")
295
+ await cache.delete("session:abc")
296
+
297
+ await cache.hset("user:1", "name", "Alice")
298
+ fields = await cache.hgetall("user:1")
299
+
300
+ await cache.lpush("jobs", "job-1", "job-2")
301
+ batch = await cache.lrange("jobs", 0, 99)
302
+
303
+ count = await cache.incr("hits:home")
304
+ ok = await cache.ping()
305
+ await cache.close()
306
+ ```
307
+
308
+ ---
309
+
310
+ ## Connection pooling & lifecycle
311
+
312
+ Every backend holds **one long-lived async client** that is reused across all operations. This is the single biggest perf knob:
313
+
314
+ - **Don't** call `get_storage(...)` inside a request handler.
315
+ - **Do** construct it once at app startup and share it (e.g. `app.state.storage`, FastAPI dependency, or module-level singleton).
316
+
317
+ Pool sizes are configurable per backend:
318
+
319
+ ```python
320
+ get_storage("s3", bucket="b", region="us-east-1",
321
+ max_pool_connections=100, connect_timeout=5.0, read_timeout=30.0)
322
+
323
+ get_mongodb("documentdb", uri="...", database="db",
324
+ max_pool_size=200, min_pool_size=10)
325
+ ```
326
+
327
+ Always release sockets on shutdown with `await backend.close()` — or wrap the whole lifetime in `async with`.
328
+
329
+ ---
330
+
331
+ ## Errors
332
+
333
+ All backends raise from a single hierarchy under `cloudrift.core.exceptions`:
334
+
335
+ ```python
336
+ from cloudrift.core.exceptions import (
337
+ ObjectNotFoundError, StoragePermissionError, StorageError,
338
+ QueueNotFoundError, MessageSendError, MessagingError,
339
+ DocumentNotFoundError, DocumentConnectionError, DocumentError,
340
+ CacheKeyNotFoundError, CacheConnectionError, CacheError,
341
+ )
342
+
343
+ try:
344
+ await storage.download("missing.txt")
345
+ except ObjectNotFoundError:
346
+ ...
347
+ ```
348
+
349
+ Provider-specific exceptions (e.g. `botocore.ClientError`, `azure.core.exceptions.HttpResponseError`) are translated to the cloudrift hierarchy at the boundary.
350
+
351
+ ---
352
+
353
+ ## Testing
354
+
355
+ The dev extra ships moto + a Motor mock so unit tests don't need real cloud credentials:
356
+
357
+ ```bash
358
+ pip install "cloudrift[dev]"
359
+ pytest
360
+ ```
361
+
362
+ For local integration testing of the AWS backends, the suite uses `ThreadedMotoServer` (LocalStack-style in-process mock) — see `tests/test_storage.py` for the pattern. Azure backends are tested against Azurite / Service Bus / Cosmos emulators (configure endpoint via the relevant `*_url` kwarg).