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.
Files changed (68) hide show
  1. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/PKG-INFO +27 -3
  2. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/README.md +25 -1
  3. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__init__.py +5 -0
  4. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/test_s3.py +172 -0
  5. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/storage.py +14 -0
  6. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/__init__.py +5 -0
  7. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/s3_compat.py +55 -20
  8. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/storage.py +11 -0
  9. mountaineer_cloud-0.2.1/mountaineer_cloud/providers_common/storage_errors.py +98 -0
  10. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/pyproject.toml +1 -1
  11. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.dockerignore +0 -0
  12. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.github/scripts/update_version.py +0 -0
  13. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.github/workflows/test.yml +0 -0
  14. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/.gitignore +0 -0
  15. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/Makefile +0 -0
  16. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/docs/Email.md +0 -0
  17. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/docs/ses_prod.png +0 -0
  18. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/__init__.py +0 -0
  19. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/conftest.py +0 -0
  20. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/dependencies/__init__.py +0 -0
  21. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/dependencies/test_core.py +0 -0
  22. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/aws/test_email.py +0 -0
  23. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/cloudflare/__init__.py +0 -0
  24. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/cloudflare/dependencies/__init__.py +0 -0
  25. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/cloudflare/dependencies/test_core.py +0 -0
  26. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/digitalocean/__init__.py +0 -0
  27. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/digitalocean/dependencies/__init__.py +0 -0
  28. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/digitalocean/dependencies/test_core.py +0 -0
  29. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/providers/__init__.py +0 -0
  30. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/providers/test_definition.py +0 -0
  31. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/__init__.py +0 -0
  32. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/dependencies/__init__.py +0 -0
  33. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/dependencies/test_core.py +0 -0
  34. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/resend/test_email.py +0 -0
  35. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/test_mountaineer_integration.py +0 -0
  36. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/test_utilities/__init__.py +0 -0
  37. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/__tests__/test_utilities/test_mock_aws.py +0 -0
  38. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/logging.py +0 -0
  39. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/mixin.py +0 -0
  40. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/__init__.py +0 -0
  41. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/base.py +0 -0
  42. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/primitives/email.py +0 -0
  43. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/__init__.py +0 -0
  44. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/__init__.py +0 -0
  45. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/config.py +0 -0
  46. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/core.py +0 -0
  47. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/aws/dependencies.py +0 -0
  48. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/base.py +0 -0
  49. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/__init__.py +0 -0
  50. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/config.py +0 -0
  51. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/core.py +0 -0
  52. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/cloudflare/dependencies.py +0 -0
  53. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/definition.py +0 -0
  54. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/__init__.py +0 -0
  55. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/config.py +0 -0
  56. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/core.py +0 -0
  57. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/digitalocean/dependencies.py +0 -0
  58. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/__init__.py +0 -0
  59. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/config.py +0 -0
  60. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/core.py +0 -0
  61. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers/resend/dependencies.py +0 -0
  62. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/providers_common/email.py +0 -0
  63. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/py.typed +0 -0
  64. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/test_utilities/__init__.py +0 -0
  65. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/test_utilities/fixtures.py +0 -0
  66. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/test_utilities/mock_aws.py +0 -0
  67. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/mountaineer_cloud/typing.py +0 -0
  68. {mountaineer_cloud-0.2.0.dev1 → mountaineer_cloud-0.2.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: mountaineer-cloud
3
- Version: 0.2.0.dev1
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 reading in one fell-swoop, in addition to streaming reads that are more efficient for large files stored remotely.
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 reading in one fell-swoop, in addition to streaming reads that are more efficient for large files stored remotely.
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
+ )
@@ -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
@@ -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
- try:
212
- raw_contents = await s3.get_object(
213
- Bucket=s3_parsed.netloc,
214
- Key=s3_parsed.path.strip("/"),
215
- )
216
- with self._get_output_io(s3_metadata) as output_file:
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
- output_file.seek(0)
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
- except ClientError as e:
231
- error(f"Error encountered when accessing {path}: {e}")
232
- raise
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
- try:
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mountaineer-cloud"
3
- version = "0.2.0.dev1"
3
+ version = "0.2.1"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"