mountaineer-cloud 0.2.0.dev1__tar.gz → 0.2.1__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.
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/PKG-INFO +27 -3
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/README.md +25 -1
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__init__.py +5 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/test_s3.py +172 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/storage.py +14 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/__init__.py +5 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/s3_compat.py +55 -20
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/storage.py +11 -0
- mountaineer_cloud-0.2.1/mountaineer_cloud/providers_common/storage_errors.py +98 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/pyproject.toml +1 -1
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.dockerignore +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.github/scripts/update_version.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.github/workflows/test.yml +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.gitignore +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/Makefile +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/docs/Email.md +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/docs/ses_prod.png +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/conftest.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/dependencies/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/dependencies/test_core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/test_email.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/cloudflare/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/cloudflare/dependencies/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/cloudflare/dependencies/test_core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/digitalocean/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/digitalocean/dependencies/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/digitalocean/dependencies/test_core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/providers/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/providers/test_definition.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/dependencies/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/dependencies/test_core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/test_email.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/test_mountaineer_integration.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/test_utilities/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/test_utilities/test_mock_aws.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/logging.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/mixin.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/base.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/email.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/config.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/dependencies.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/base.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/config.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/dependencies.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/definition.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/config.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/dependencies.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/config.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/core.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/dependencies.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/email.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/py.typed +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/test_utilities/__init__.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/test_utilities/fixtures.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/test_utilities/mock_aws.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/typing.py +0 -0
- {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: mountaineer-cloud
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Author-email: Pierce Freeman <pierce@freeman.vc>
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: aioboto3>=14.0.0
|
|
@@ -71,6 +71,14 @@ async def get_asset_contents(
|
|
|
71
71
|
aws: AWSCore = Depends(AWSDependencies.get_aws_core),
|
|
72
72
|
) -> bytes:
|
|
73
73
|
return await asset.file_url.get_content(aws)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def delete_asset(
|
|
77
|
+
asset: Asset,
|
|
78
|
+
aws: AWSCore = Depends(AWSDependencies.get_aws_core),
|
|
79
|
+
) -> None:
|
|
80
|
+
# Removes the remote object and resets `asset.file_url` to an empty pointer
|
|
81
|
+
await asset.file_url.delete(aws)
|
|
74
82
|
```
|
|
75
83
|
|
|
76
84
|
If you later move that same model or endpoint to another provider, the primitive API stays the same. Thanks to the magic of typehinting, we'll proactively flag errors if you're trying to use a backend provider that doesn't support the functionality you expect. The main thing that changes is the core type:
|
|
@@ -79,7 +87,23 @@ If you later move that same model or endpoint to another provider, the primitive
|
|
|
79
87
|
- `CloudFile[CloudflareCore]`
|
|
80
88
|
- `CloudFile[DigitalOceanCore]`
|
|
81
89
|
|
|
82
|
-
CloudFile has support for writing and
|
|
90
|
+
CloudFile has support for writing, reading, and deleting in one fell-swoop, in addition to streaming reads that are more efficient for large files stored remotely.
|
|
91
|
+
|
|
92
|
+
Storage failures are raised as native Python exceptions where one fits, so remote files can be handled like local ones. Conditions without a native equivalent get their own types, which subclass botocore's `ClientError` so existing handlers still catch them. The original provider error is always available as `__cause__`.
|
|
93
|
+
|
|
94
|
+
| Condition | Raised |
|
|
95
|
+
|-----------|--------|
|
|
96
|
+
| Object does not exist | `FileNotFoundError` |
|
|
97
|
+
| Access denied | `PermissionError` |
|
|
98
|
+
| Bucket does not exist | `StorageBucketNotFoundError` |
|
|
99
|
+
| Request throttled | `StorageRateLimitError` |
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
try:
|
|
103
|
+
contents = await asset.file_url.get_content(aws)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
contents = None
|
|
106
|
+
```
|
|
83
107
|
|
|
84
108
|
### Email Primitive
|
|
85
109
|
|
|
@@ -57,6 +57,14 @@ async def get_asset_contents(
|
|
|
57
57
|
aws: AWSCore = Depends(AWSDependencies.get_aws_core),
|
|
58
58
|
) -> bytes:
|
|
59
59
|
return await asset.file_url.get_content(aws)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def delete_asset(
|
|
63
|
+
asset: Asset,
|
|
64
|
+
aws: AWSCore = Depends(AWSDependencies.get_aws_core),
|
|
65
|
+
) -> None:
|
|
66
|
+
# Removes the remote object and resets `asset.file_url` to an empty pointer
|
|
67
|
+
await asset.file_url.delete(aws)
|
|
60
68
|
```
|
|
61
69
|
|
|
62
70
|
If you later move that same model or endpoint to another provider, the primitive API stays the same. Thanks to the magic of typehinting, we'll proactively flag errors if you're trying to use a backend provider that doesn't support the functionality you expect. The main thing that changes is the core type:
|
|
@@ -65,7 +73,23 @@ If you later move that same model or endpoint to another provider, the primitive
|
|
|
65
73
|
- `CloudFile[CloudflareCore]`
|
|
66
74
|
- `CloudFile[DigitalOceanCore]`
|
|
67
75
|
|
|
68
|
-
CloudFile has support for writing and
|
|
76
|
+
CloudFile has support for writing, reading, and deleting in one fell-swoop, in addition to streaming reads that are more efficient for large files stored remotely.
|
|
77
|
+
|
|
78
|
+
Storage failures are raised as native Python exceptions where one fits, so remote files can be handled like local ones. Conditions without a native equivalent get their own types, which subclass botocore's `ClientError` so existing handlers still catch them. The original provider error is always available as `__cause__`.
|
|
79
|
+
|
|
80
|
+
| Condition | Raised |
|
|
81
|
+
|-----------|--------|
|
|
82
|
+
| Object does not exist | `FileNotFoundError` |
|
|
83
|
+
| Access denied | `PermissionError` |
|
|
84
|
+
| Bucket does not exist | `StorageBucketNotFoundError` |
|
|
85
|
+
| Request throttled | `StorageRateLimitError` |
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
try:
|
|
89
|
+
contents = await asset.file_url.get_content(aws)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
contents = None
|
|
92
|
+
```
|
|
69
93
|
|
|
70
94
|
### Email Primitive
|
|
71
95
|
|
|
@@ -29,3 +29,8 @@ from .providers.resend import (
|
|
|
29
29
|
ResendCore as ResendCore,
|
|
30
30
|
ResendDependencies as ResendDependencies,
|
|
31
31
|
)
|
|
32
|
+
from .providers_common.storage_errors import (
|
|
33
|
+
StorageBucketNotFoundError as StorageBucketNotFoundError,
|
|
34
|
+
StorageClientError as StorageClientError,
|
|
35
|
+
StorageRateLimitError as StorageRateLimitError,
|
|
36
|
+
)
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/test_s3.py
RENAMED
|
@@ -6,6 +6,7 @@ from unittest.mock import patch
|
|
|
6
6
|
import aioboto3
|
|
7
7
|
import pytest
|
|
8
8
|
import pytest_asyncio
|
|
9
|
+
from botocore.exceptions import ClientError
|
|
9
10
|
from iceaxe import Field as IceaxeField, TableBase
|
|
10
11
|
from iceaxe.schemas.db_memory_serializer import DatabaseHandler
|
|
11
12
|
from iceaxe.schemas.db_stubs import DBColumn
|
|
@@ -24,6 +25,11 @@ from mountaineer_cloud.primitives.storage import (
|
|
|
24
25
|
from mountaineer_cloud.providers.aws import AWSConfig, AWSCore
|
|
25
26
|
from mountaineer_cloud.providers.aws.dependencies import build_aws_core
|
|
26
27
|
from mountaineer_cloud.providers_common.s3_compat import S3CompatibleMetadataBase
|
|
28
|
+
from mountaineer_cloud.providers_common.storage_errors import (
|
|
29
|
+
StorageBucketNotFoundError,
|
|
30
|
+
StorageRateLimitError,
|
|
31
|
+
translate_storage_error,
|
|
32
|
+
)
|
|
27
33
|
from mountaineer_cloud.test_utilities import MockAWS
|
|
28
34
|
from mountaineer_cloud.test_utilities.fixtures import get_mock_aws
|
|
29
35
|
|
|
@@ -278,3 +284,169 @@ async def test_cloudfile_in_iceaxe_model(
|
|
|
278
284
|
)
|
|
279
285
|
assert await stored["Body"].read() == b"test data"
|
|
280
286
|
assert await asset.file_url.get_content(aws_core) == b"test data"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.asyncio
|
|
290
|
+
async def test_s3_delete(mock_aws: MockAWS, aws_core: AWSCore):
|
|
291
|
+
metadata = S3Metadata(bucket="mountaineer-test", prefix="test-prefix")
|
|
292
|
+
|
|
293
|
+
await mock_aws.mock_s3.put_object(
|
|
294
|
+
Bucket="mountaineer-test",
|
|
295
|
+
Key="test-prefix/test-uuid",
|
|
296
|
+
Body=b"test data",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
await aws_core.storage_delete(
|
|
300
|
+
path="s3://mountaineer-test/test-prefix/test-uuid",
|
|
301
|
+
metadata=metadata,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
listing = await mock_aws.mock_s3.list_objects_v2(
|
|
305
|
+
Bucket="mountaineer-test", Prefix="test-prefix/"
|
|
306
|
+
)
|
|
307
|
+
assert listing.get("KeyCount", 0) == 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@pytest.mark.asyncio
|
|
311
|
+
async def test_s3_delete_requires_path(aws_core: AWSCore):
|
|
312
|
+
metadata = S3Metadata(bucket="mountaineer-test", prefix="test-prefix")
|
|
313
|
+
|
|
314
|
+
with pytest.raises(ValueError):
|
|
315
|
+
await aws_core.storage_delete(path=None, metadata=metadata)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@pytest.mark.asyncio
|
|
319
|
+
async def test_cloudfile_delete_in_iceaxe_model(
|
|
320
|
+
mock_aws: MockAWS,
|
|
321
|
+
aws_core: AWSCore,
|
|
322
|
+
):
|
|
323
|
+
asset = ExampleAWSAsset(id=1, file_url=CloudFile(""))
|
|
324
|
+
assert asset.file_url is not None
|
|
325
|
+
|
|
326
|
+
with patch(
|
|
327
|
+
"mountaineer_cloud.providers_common.s3_compat.uuid4",
|
|
328
|
+
return_value="test-uuid",
|
|
329
|
+
):
|
|
330
|
+
await asset.file_url.put_content(aws_core, b"test data")
|
|
331
|
+
|
|
332
|
+
assert asset.file_url == "s3://mountaineer-test/test-prefix/test-uuid"
|
|
333
|
+
|
|
334
|
+
cleared = await asset.file_url.delete(aws_core)
|
|
335
|
+
|
|
336
|
+
assert cleared == ""
|
|
337
|
+
assert asset.file_url == ""
|
|
338
|
+
assert isinstance(asset.file_url, CloudFile)
|
|
339
|
+
assert asset.file_url._cloud_definition is not None
|
|
340
|
+
|
|
341
|
+
listing = await mock_aws.mock_s3.list_objects_v2(
|
|
342
|
+
Bucket="mountaineer-test", Prefix="test-prefix/"
|
|
343
|
+
)
|
|
344
|
+
assert listing.get("KeyCount", 0) == 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.asyncio
|
|
348
|
+
async def test_s3_download_missing_key_raises_file_not_found(aws_core: AWSCore):
|
|
349
|
+
metadata = S3Metadata(bucket="mountaineer-test", prefix="test-prefix")
|
|
350
|
+
path = "s3://mountaineer-test/test-prefix/missing"
|
|
351
|
+
|
|
352
|
+
with pytest.raises(FileNotFoundError) as exc_info:
|
|
353
|
+
async with aws_core.storage_read(path=path, metadata=metadata):
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
assert exc_info.value.filename == path
|
|
357
|
+
assert isinstance(exc_info.value.__cause__, ClientError)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pytest.mark.asyncio
|
|
361
|
+
async def test_cloudfile_get_content_missing_key_raises_file_not_found(
|
|
362
|
+
aws_core: AWSCore,
|
|
363
|
+
):
|
|
364
|
+
asset = ExampleAWSAsset(
|
|
365
|
+
id=1,
|
|
366
|
+
file_url=CloudFile("s3://mountaineer-test/test-prefix/missing"),
|
|
367
|
+
)
|
|
368
|
+
assert asset.file_url is not None
|
|
369
|
+
|
|
370
|
+
with pytest.raises(FileNotFoundError):
|
|
371
|
+
await asset.file_url.get_content(aws_core)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pytest.mark.asyncio
|
|
375
|
+
async def test_s3_download_missing_bucket_raises_bucket_not_found(
|
|
376
|
+
aws_core: AWSCore,
|
|
377
|
+
):
|
|
378
|
+
metadata = S3Metadata(bucket="missing-bucket", prefix="test-prefix")
|
|
379
|
+
path = "s3://missing-bucket/test-prefix/test-uuid"
|
|
380
|
+
|
|
381
|
+
with pytest.raises(StorageBucketNotFoundError) as exc_info:
|
|
382
|
+
async with aws_core.storage_read(path=path, metadata=metadata):
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
# Storage-specific errors remain catchable as the provider's ClientError
|
|
386
|
+
assert isinstance(exc_info.value, ClientError)
|
|
387
|
+
assert exc_info.value.path == path
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@pytest.mark.asyncio
|
|
391
|
+
async def test_s3_read_does_not_translate_caller_errors(
|
|
392
|
+
mock_aws: MockAWS, aws_core: AWSCore
|
|
393
|
+
):
|
|
394
|
+
metadata = S3Metadata(bucket="mountaineer-test", prefix="test-prefix")
|
|
395
|
+
await mock_aws.mock_s3.put_object(
|
|
396
|
+
Bucket="mountaineer-test",
|
|
397
|
+
Key="test-prefix/test-uuid",
|
|
398
|
+
Body=b"test data",
|
|
399
|
+
)
|
|
400
|
+
caller_error = ClientError({"Error": {"Code": "NoSuchKey"}}, "Other")
|
|
401
|
+
|
|
402
|
+
with pytest.raises(ClientError) as exc_info:
|
|
403
|
+
async with aws_core.storage_read(
|
|
404
|
+
path="s3://mountaineer-test/test-prefix/test-uuid",
|
|
405
|
+
metadata=metadata,
|
|
406
|
+
):
|
|
407
|
+
raise caller_error
|
|
408
|
+
|
|
409
|
+
assert exc_info.value is caller_error
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@pytest.mark.parametrize(
|
|
413
|
+
"error_response,expected_type",
|
|
414
|
+
[
|
|
415
|
+
({"Error": {"Code": "NoSuchKey"}}, FileNotFoundError),
|
|
416
|
+
(
|
|
417
|
+
{"Error": {"Code": "404"}, "ResponseMetadata": {"HTTPStatusCode": 404}},
|
|
418
|
+
FileNotFoundError,
|
|
419
|
+
),
|
|
420
|
+
({"Error": {"Code": "AccessDenied"}}, PermissionError),
|
|
421
|
+
(
|
|
422
|
+
{"Error": {"Code": "403"}, "ResponseMetadata": {"HTTPStatusCode": 403}},
|
|
423
|
+
PermissionError,
|
|
424
|
+
),
|
|
425
|
+
(
|
|
426
|
+
{
|
|
427
|
+
"Error": {"Code": "NoSuchBucket"},
|
|
428
|
+
"ResponseMetadata": {"HTTPStatusCode": 404},
|
|
429
|
+
},
|
|
430
|
+
StorageBucketNotFoundError,
|
|
431
|
+
),
|
|
432
|
+
({"Error": {"Code": "SlowDown"}}, StorageRateLimitError),
|
|
433
|
+
(
|
|
434
|
+
{"Error": {"Code": "Unknown"}, "ResponseMetadata": {"HTTPStatusCode": 429}},
|
|
435
|
+
StorageRateLimitError,
|
|
436
|
+
),
|
|
437
|
+
],
|
|
438
|
+
)
|
|
439
|
+
def test_translate_storage_error(
|
|
440
|
+
error_response: dict[str, Any], expected_type: type[Exception]
|
|
441
|
+
):
|
|
442
|
+
exc = ClientError(cast(Any, error_response), "GetObject")
|
|
443
|
+
translated = translate_storage_error(exc, path="s3://bucket/key")
|
|
444
|
+
assert type(translated) is expected_type
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_translate_storage_error_passes_through_unknown_codes():
|
|
448
|
+
exc = ClientError(
|
|
449
|
+
cast(Any, {"Error": {"Code": "InternalError"}}),
|
|
450
|
+
"GetObject",
|
|
451
|
+
)
|
|
452
|
+
assert translate_storage_error(exc, path="s3://bucket/key") is None
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/storage.py
RENAMED
|
@@ -156,6 +156,20 @@ class CloudFile(str, CloudValueBase[TStorageCore]):
|
|
|
156
156
|
async with self.get_contents(core) as file:
|
|
157
157
|
return file.read()
|
|
158
158
|
|
|
159
|
+
async def delete(self, core: TStorageCore) -> "CloudFile[TStorageCore]":
|
|
160
|
+
"""
|
|
161
|
+
Delete the remote object and clear this pointer. When bound to a model,
|
|
162
|
+
the owning field is reset to an empty `CloudFile` so the stale path is
|
|
163
|
+
no longer referenced.
|
|
164
|
+
"""
|
|
165
|
+
definition = self._require_definition()
|
|
166
|
+
await core.storage_delete(
|
|
167
|
+
path=str(self) or None,
|
|
168
|
+
metadata=definition.storage_metadata,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return self._apply_new_value("")
|
|
172
|
+
|
|
159
173
|
#
|
|
160
174
|
# Coerce from the string base class that's actually stored within the database
|
|
161
175
|
# level varchar column into the CloudFile primitive.
|
|
@@ -16,3 +16,8 @@ from .storage import (
|
|
|
16
16
|
StorageMetadata as StorageMetadata,
|
|
17
17
|
StorageProviderCore as StorageProviderCore,
|
|
18
18
|
)
|
|
19
|
+
from .storage_errors import (
|
|
20
|
+
StorageBucketNotFoundError as StorageBucketNotFoundError,
|
|
21
|
+
StorageClientError as StorageClientError,
|
|
22
|
+
StorageRateLimitError as StorageRateLimitError,
|
|
23
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import gzip
|
|
2
2
|
from abc import ABC
|
|
3
|
-
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
3
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
|
|
4
4
|
from contextlib import asynccontextmanager, contextmanager
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from datetime import datetime, timedelta, timezone
|
|
@@ -23,6 +23,9 @@ from mountaineer_cloud.providers_common.storage import (
|
|
|
23
23
|
StorageMetadata,
|
|
24
24
|
StorageProviderCore,
|
|
25
25
|
)
|
|
26
|
+
from mountaineer_cloud.providers_common.storage_errors import (
|
|
27
|
+
translate_storage_error,
|
|
28
|
+
)
|
|
26
29
|
|
|
27
30
|
if TYPE_CHECKING:
|
|
28
31
|
from types_aiobotocore_s3.client import S3Client
|
|
@@ -208,28 +211,27 @@ class S3CompatibleStorageCore(StorageProviderCore[TConfig], Generic[TConfig], AB
|
|
|
208
211
|
s3_parsed = urlparse(path)
|
|
209
212
|
|
|
210
213
|
async with self.get_storage_client() as s3:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
with self._get_output_io(s3_metadata) as output_file:
|
|
215
|
+
with _translate_client_errors(path, action="accessing"):
|
|
216
|
+
raw_contents = await s3.get_object(
|
|
217
|
+
Bucket=s3_parsed.netloc,
|
|
218
|
+
Key=s3_parsed.path.strip("/"),
|
|
219
|
+
)
|
|
217
220
|
buffer_size = 24 * 1024
|
|
218
221
|
while True:
|
|
219
222
|
chunk = await raw_contents["Body"].read(buffer_size)
|
|
220
223
|
if not chunk:
|
|
221
224
|
break
|
|
222
225
|
output_file.write(chunk)
|
|
223
|
-
|
|
224
|
-
with self._unwrap_compressed_file(
|
|
225
|
-
output_file,
|
|
226
|
-
s3_metadata,
|
|
227
|
-
) as decompressed_file:
|
|
228
|
-
yield decompressed_file
|
|
226
|
+
output_file.seek(0)
|
|
229
227
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
# Errors raised by the caller while consuming the file are
|
|
229
|
+
# theirs, so the yield sits outside of the translation scope.
|
|
230
|
+
with self._unwrap_compressed_file(
|
|
231
|
+
output_file,
|
|
232
|
+
s3_metadata,
|
|
233
|
+
) as decompressed_file:
|
|
234
|
+
yield decompressed_file
|
|
233
235
|
|
|
234
236
|
async def storage_write(
|
|
235
237
|
self,
|
|
@@ -290,7 +292,7 @@ class S3CompatibleStorageCore(StorageProviderCore[TConfig], Generic[TConfig], AB
|
|
|
290
292
|
s3_parsed = urlparse(s3_metadata_path)
|
|
291
293
|
|
|
292
294
|
async with self.get_storage_client() as s3:
|
|
293
|
-
|
|
295
|
+
with _translate_client_errors(s3_metadata_path, action="accessing"):
|
|
294
296
|
if content_type:
|
|
295
297
|
await s3.upload_fileobj(
|
|
296
298
|
Bucket=s3_parsed.netloc,
|
|
@@ -304,12 +306,29 @@ class S3CompatibleStorageCore(StorageProviderCore[TConfig], Generic[TConfig], AB
|
|
|
304
306
|
Key=s3_parsed.path.strip("/"),
|
|
305
307
|
Fileobj=payload,
|
|
306
308
|
)
|
|
307
|
-
except ClientError as e:
|
|
308
|
-
error(f"Error encountered when accessing {s3_metadata_path}: {e}")
|
|
309
|
-
raise
|
|
310
309
|
|
|
311
310
|
return s3_metadata_path
|
|
312
311
|
|
|
312
|
+
async def storage_delete(
|
|
313
|
+
self,
|
|
314
|
+
*,
|
|
315
|
+
path: str | None,
|
|
316
|
+
metadata: StorageMetadata,
|
|
317
|
+
) -> None:
|
|
318
|
+
del metadata
|
|
319
|
+
|
|
320
|
+
if not path:
|
|
321
|
+
raise ValueError("S3 object not found")
|
|
322
|
+
|
|
323
|
+
s3_parsed = urlparse(path)
|
|
324
|
+
|
|
325
|
+
async with self.get_storage_client() as s3:
|
|
326
|
+
with _translate_client_errors(path, action="deleting"):
|
|
327
|
+
await s3.delete_object(
|
|
328
|
+
Bucket=s3_parsed.netloc,
|
|
329
|
+
Key=s3_parsed.path.strip("/"),
|
|
330
|
+
)
|
|
331
|
+
|
|
313
332
|
@contextmanager
|
|
314
333
|
def _wrap_compressed_file(
|
|
315
334
|
self,
|
|
@@ -390,6 +409,22 @@ class S3CompatibleStorageCore(StorageProviderCore[TConfig], Generic[TConfig], AB
|
|
|
390
409
|
)
|
|
391
410
|
|
|
392
411
|
|
|
412
|
+
@contextmanager
|
|
413
|
+
def _translate_client_errors(path: str, *, action: str) -> Iterator[None]:
|
|
414
|
+
"""
|
|
415
|
+
Re-raise provider `ClientError`s as the typed errors defined in
|
|
416
|
+
`storage_errors`, keeping the original exception as the cause.
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
yield
|
|
420
|
+
except ClientError as e:
|
|
421
|
+
error(f"Error encountered when {action} {path}: {e}")
|
|
422
|
+
translated = translate_storage_error(e, path=path)
|
|
423
|
+
if translated is None:
|
|
424
|
+
raise
|
|
425
|
+
raise translated from e
|
|
426
|
+
|
|
427
|
+
|
|
393
428
|
def _coerce_s3_metadata(metadata: StorageMetadata) -> S3CompatibleMetadataBase:
|
|
394
429
|
if isinstance(metadata, S3CompatibleMetadataBase):
|
|
395
430
|
return metadata
|
|
@@ -68,3 +68,14 @@ class StorageProviderCore(ProviderCore[TConfig], Generic[TConfig], ABC):
|
|
|
68
68
|
"""
|
|
69
69
|
Persist payload data and return the resulting storage path.
|
|
70
70
|
"""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def storage_delete(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
path: str | None,
|
|
77
|
+
metadata: StorageMetadata,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Remove the payload stored at `path`.
|
|
81
|
+
"""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed errors raised by storage providers.
|
|
3
|
+
|
|
4
|
+
Where a failure has a native Python equivalent we raise that type directly, so
|
|
5
|
+
callers can handle remote objects the same way they would local files:
|
|
6
|
+
|
|
7
|
+
- Missing objects raise `FileNotFoundError`.
|
|
8
|
+
- Denied access raises `PermissionError`.
|
|
9
|
+
|
|
10
|
+
Failures without a native equivalent raise subclasses of botocore's
|
|
11
|
+
`ClientError`, so existing `except ClientError` handlers keep catching them
|
|
12
|
+
while new code can match on the specific condition.
|
|
13
|
+
|
|
14
|
+
In every case the original `ClientError` from the provider is preserved as the
|
|
15
|
+
`__cause__` of the raised exception.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import errno
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from botocore.exceptions import ClientError
|
|
23
|
+
|
|
24
|
+
# Error codes are matched before HTTP status codes, since some conditions share
|
|
25
|
+
# a status (S3 returns 404 for both a missing key and a missing bucket).
|
|
26
|
+
NOT_FOUND_ERROR_CODES = frozenset({"NoSuchKey", "NotFound", "404"})
|
|
27
|
+
PERMISSION_ERROR_CODES = frozenset(
|
|
28
|
+
{"AccessDenied", "AllAccessDisabled", "Forbidden", "403"}
|
|
29
|
+
)
|
|
30
|
+
BUCKET_NOT_FOUND_ERROR_CODES = frozenset({"NoSuchBucket"})
|
|
31
|
+
RATE_LIMIT_ERROR_CODES = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"SlowDown",
|
|
34
|
+
"Throttling",
|
|
35
|
+
"ThrottlingException",
|
|
36
|
+
"RequestThrottled",
|
|
37
|
+
"TooManyRequests",
|
|
38
|
+
"TooManyRequestsException",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StorageClientError(ClientError):
|
|
44
|
+
"""
|
|
45
|
+
Base class for storage failures that have no native Python equivalent.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
error_response: Any,
|
|
51
|
+
operation_name: str,
|
|
52
|
+
*,
|
|
53
|
+
path: str | None = None,
|
|
54
|
+
):
|
|
55
|
+
super().__init__(error_response, operation_name)
|
|
56
|
+
self.path = path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class StorageBucketNotFoundError(StorageClientError):
|
|
60
|
+
"""
|
|
61
|
+
The bucket referenced by the storage path does not exist.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class StorageRateLimitError(StorageClientError):
|
|
66
|
+
"""
|
|
67
|
+
The provider throttled the request. Retrying after a backoff may succeed.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def translate_storage_error(exc: ClientError, *, path: str) -> Exception | None:
|
|
72
|
+
"""
|
|
73
|
+
Map a provider `ClientError` onto a native or storage-specific exception.
|
|
74
|
+
|
|
75
|
+
Returns `None` when the error has no more specific type, in which case the
|
|
76
|
+
caller should re-raise the original `ClientError`.
|
|
77
|
+
"""
|
|
78
|
+
error = exc.response.get("Error", {})
|
|
79
|
+
code = str(error.get("Code", ""))
|
|
80
|
+
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
|
81
|
+
|
|
82
|
+
if code in BUCKET_NOT_FOUND_ERROR_CODES:
|
|
83
|
+
return StorageBucketNotFoundError(exc.response, exc.operation_name, path=path)
|
|
84
|
+
if code in RATE_LIMIT_ERROR_CODES:
|
|
85
|
+
return StorageRateLimitError(exc.response, exc.operation_name, path=path)
|
|
86
|
+
if code in NOT_FOUND_ERROR_CODES:
|
|
87
|
+
return FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
|
|
88
|
+
if code in PERMISSION_ERROR_CODES:
|
|
89
|
+
return PermissionError(errno.EACCES, os.strerror(errno.EACCES), path)
|
|
90
|
+
|
|
91
|
+
if status == 404:
|
|
92
|
+
return FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
|
|
93
|
+
if status == 403:
|
|
94
|
+
return PermissionError(errno.EACCES, os.strerror(errno.EACCES), path)
|
|
95
|
+
if status == 429:
|
|
96
|
+
return StorageRateLimitError(exc.response, exc.operation_name, path=path)
|
|
97
|
+
|
|
98
|
+
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/__init__.py
RENAMED
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/conftest.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/__init__.py
RENAMED
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/base.py
RENAMED
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/email.py
RENAMED
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/__init__.py
RENAMED
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/__init__.py
RENAMED
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/config.py
RENAMED
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/core.py
RENAMED
|
File without changes
|
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/definition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/core.py
RENAMED
|
File without changes
|
|
File without changes
|
{mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/email.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|