amsdal_storages 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- amsdal_storages/Third-Party Materials - AMSDAL Dependencies - License Notices.md +1223 -0
- amsdal_storages/__about__.py +1 -0
- amsdal_storages/__init__.py +0 -0
- amsdal_storages/py.typed +0 -0
- amsdal_storages/s3/__init__.py +0 -0
- amsdal_storages/s3/storage.py +201 -0
- amsdal_storages-0.1.0.dist-info/METADATA +60 -0
- amsdal_storages-0.1.0.dist-info/RECORD +9 -0
- amsdal_storages-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
File without changes
|
amsdal_storages/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import IO
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import BinaryIO
|
|
9
|
+
|
|
10
|
+
from amsdal_models.storage.backends.db import AsyncFileWrapper
|
|
11
|
+
from amsdal_models.storage.base import Storage
|
|
12
|
+
from amsdal_models.storage.errors import ConfigurationError
|
|
13
|
+
from amsdal_models.storage.errors import StorageError
|
|
14
|
+
from amsdal_models.storage.types import FileProtocol
|
|
15
|
+
from amsdal_utils.config.manager import AmsdalConfigManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class S3Storage(Storage):
|
|
19
|
+
"""S3-backed storage implementation.
|
|
20
|
+
|
|
21
|
+
Sync methods use boto3. Async methods use aioboto3.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
keeps_local_copy = False
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
object_prefix: str = '',
|
|
29
|
+
presign_ttl: int = 3600,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.bucket = os.environ.get('AWS_S3_BUCKET_NAME')
|
|
32
|
+
self.region_name = os.environ.get('AWS_S3_REGION_NAME') or os.environ.get('AWS_REGION')
|
|
33
|
+
self.endpoint_url = os.environ.get('AWS_S3_ENDPOINT_URL')
|
|
34
|
+
self.access_key_id = os.environ.get('AWS_S3_ACCESS_KEY_ID') or os.environ.get('AWS_ACCESS_KEY_ID')
|
|
35
|
+
self.secret_access_key = os.environ.get('AWS_S3_SECRET_ACCESS_KEY') or os.environ.get('AWS_SECRET_ACCESS_KEY')
|
|
36
|
+
self.security_token = os.environ.get('AWS_SESSION_TOKEN') or os.environ.get('AWS_SECURITY_TOKEN')
|
|
37
|
+
self.object_prefix = object_prefix.strip('/')
|
|
38
|
+
self.presign_ttl = presign_ttl
|
|
39
|
+
|
|
40
|
+
if not self.bucket:
|
|
41
|
+
msg = 'S3Storage requires a bucket name'
|
|
42
|
+
raise ConfigurationError(msg)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
if AmsdalConfigManager().get_config().async_mode:
|
|
46
|
+
import aioboto3 # noqa: F401
|
|
47
|
+
else:
|
|
48
|
+
import boto3 # noqa: F401
|
|
49
|
+
except ImportError:
|
|
50
|
+
msg = 'S3 dependencies are missing. Install it with `pip install amsdal_storages[s3]`.'
|
|
51
|
+
raise ConfigurationError(msg) from None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def _s3(self) -> Any:
|
|
55
|
+
import boto3
|
|
56
|
+
|
|
57
|
+
return boto3.client(
|
|
58
|
+
's3',
|
|
59
|
+
region_name=self.region_name,
|
|
60
|
+
endpoint_url=self.endpoint_url,
|
|
61
|
+
aws_access_key_id=self.access_key_id,
|
|
62
|
+
aws_secret_access_key=self.secret_access_key,
|
|
63
|
+
aws_session_token=self.security_token,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def _async_s3(self) -> Any:
|
|
68
|
+
import aioboto3
|
|
69
|
+
|
|
70
|
+
session = aioboto3.Session()
|
|
71
|
+
async with session.client(
|
|
72
|
+
's3',
|
|
73
|
+
region_name=self.region_name,
|
|
74
|
+
endpoint_url=self.endpoint_url,
|
|
75
|
+
aws_access_key_id=self.access_key_id,
|
|
76
|
+
aws_secret_access_key=self.secret_access_key,
|
|
77
|
+
aws_session_token=self.security_token,
|
|
78
|
+
) as client:
|
|
79
|
+
yield client
|
|
80
|
+
|
|
81
|
+
def _key(self, file: FileProtocol) -> str:
|
|
82
|
+
base = file.filename.lstrip('/')
|
|
83
|
+
return f'{self.object_prefix}/{base}' if self.object_prefix else base
|
|
84
|
+
|
|
85
|
+
def _export_kwargs(self) -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
'object_prefix': self.object_prefix,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def save(self, file: FileProtocol, content: BinaryIO) -> str:
|
|
91
|
+
import botocore.exceptions
|
|
92
|
+
|
|
93
|
+
key = self._key(file)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
self._s3.upload_fileobj(content, self.bucket, key)
|
|
97
|
+
except botocore.exceptions.ClientError as e:
|
|
98
|
+
msg = f'Failed to upload to s3://{self.bucket}/{key}: {e}'
|
|
99
|
+
raise StorageError(msg) from e
|
|
100
|
+
return key
|
|
101
|
+
|
|
102
|
+
def open(self, file: FileProtocol, mode: str = 'rb') -> IO[Any]: # noqa: ARG002
|
|
103
|
+
import botocore.exceptions
|
|
104
|
+
|
|
105
|
+
key = self._key(file)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
obj = self._s3.get_object(Bucket=self.bucket, Key=key)
|
|
109
|
+
except botocore.exceptions.ClientError as e:
|
|
110
|
+
msg = f'Failed to open s3://{self.bucket}/{key}: {e}'
|
|
111
|
+
raise StorageError(msg) from e
|
|
112
|
+
body = obj['Body']
|
|
113
|
+
|
|
114
|
+
return body # StreamingBody implements a file-like interface
|
|
115
|
+
|
|
116
|
+
def delete(self, file: FileProtocol) -> None:
|
|
117
|
+
import botocore.exceptions
|
|
118
|
+
|
|
119
|
+
key = self._key(file)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
self._s3.delete_object(Bucket=self.bucket, Key=key)
|
|
123
|
+
except botocore.exceptions.ClientError as e:
|
|
124
|
+
msg = f'Failed to delete s3://{self.bucket}/{key}: {e}'
|
|
125
|
+
raise StorageError(msg) from e
|
|
126
|
+
|
|
127
|
+
def exists(self, file: FileProtocol) -> bool:
|
|
128
|
+
import botocore.exceptions
|
|
129
|
+
|
|
130
|
+
key = self._key(file)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
self._s3.head_object(Bucket=self.bucket, Key=key)
|
|
134
|
+
return True
|
|
135
|
+
except botocore.exceptions.ClientError as e:
|
|
136
|
+
code = e.response.get('Error', {}).get('Code')
|
|
137
|
+
if code in {'404', 'NotFound', 'NoSuchKey'}:
|
|
138
|
+
return False
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
def url(self, file: FileProtocol) -> str:
|
|
142
|
+
key = self._key(file)
|
|
143
|
+
|
|
144
|
+
# fall back to presigned URL
|
|
145
|
+
return self._s3.generate_presigned_url(
|
|
146
|
+
'get_object',
|
|
147
|
+
Params={'Bucket': self.bucket, 'Key': key},
|
|
148
|
+
ExpiresIn=self.presign_ttl,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def asave(self, file: FileProtocol, content: BinaryIO) -> str:
|
|
152
|
+
key = self._key(file)
|
|
153
|
+
|
|
154
|
+
async with self._async_s3() as client:
|
|
155
|
+
try:
|
|
156
|
+
await client.upload_fileobj(content, self.bucket, key)
|
|
157
|
+
except Exception as e: # pragma: no cover - network dependent
|
|
158
|
+
msg = f'Failed to upload to s3://{self.bucket}/{key}: {e}'
|
|
159
|
+
raise StorageError(msg) from e
|
|
160
|
+
return key
|
|
161
|
+
|
|
162
|
+
async def aopen(self, file: FileProtocol, mode: str = 'rb') -> AsyncFileWrapper: # noqa: ARG002
|
|
163
|
+
key = self._key(file)
|
|
164
|
+
|
|
165
|
+
async with self._async_s3() as client:
|
|
166
|
+
try:
|
|
167
|
+
obj = await client.get_object(Bucket=self.bucket, Key=key)
|
|
168
|
+
body = await obj['Body'].read()
|
|
169
|
+
except Exception as e: # pragma: no cover
|
|
170
|
+
await client.__aexit__(None, None, None)
|
|
171
|
+
msg = f'Failed to open s3://{self.bucket}/{key}: {e}'
|
|
172
|
+
raise StorageError(msg) from e
|
|
173
|
+
return AsyncFileWrapper(io.BytesIO(body))
|
|
174
|
+
|
|
175
|
+
async def adelete(self, file: FileProtocol) -> None:
|
|
176
|
+
key = self._key(file)
|
|
177
|
+
|
|
178
|
+
async with self._async_s3() as client:
|
|
179
|
+
await client.delete_object(Bucket=self.bucket, Key=key)
|
|
180
|
+
|
|
181
|
+
async def aexists(self, file: FileProtocol) -> bool:
|
|
182
|
+
key = self._key(file)
|
|
183
|
+
|
|
184
|
+
async with self._async_s3() as client:
|
|
185
|
+
try:
|
|
186
|
+
await client.head_object(Bucket=self.bucket, Key=key)
|
|
187
|
+
return True
|
|
188
|
+
except Exception as e: # pragma: no cover
|
|
189
|
+
# aiobotocore raises ClientError similarly; detect 404-ish
|
|
190
|
+
msg = getattr(e, 'response', {}).get('Error', {}).get('Code') if hasattr(e, 'response') else None
|
|
191
|
+
if msg in {'404', 'NotFound', 'NoSuchKey'}:
|
|
192
|
+
return False
|
|
193
|
+
raise
|
|
194
|
+
|
|
195
|
+
async def aurl(self, file: FileProtocol) -> str:
|
|
196
|
+
key = self._key(file)
|
|
197
|
+
|
|
198
|
+
async with self._async_s3() as client:
|
|
199
|
+
return await client.generate_presigned_url(
|
|
200
|
+
'get_object', Params={'Bucket': self.bucket, 'Key': key}, ExpiresIn=self.presign_ttl
|
|
201
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amsdal_storages
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: amsdal_storages plugin for AMSDAL Framework
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: amsdal-models>=0.5.16
|
|
7
|
+
Requires-Dist: amsdal[cli]==0.5.10
|
|
8
|
+
Provides-Extra: s3
|
|
9
|
+
Requires-Dist: aioboto3>=11.0.0; extra == 's3'
|
|
10
|
+
Requires-Dist: boto3; extra == 's3'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# amsdal_storages
|
|
14
|
+
|
|
15
|
+
This plugin provides implementations of the Storage backends for AMSDAL.
|
|
16
|
+
|
|
17
|
+
## Plugin Structure
|
|
18
|
+
|
|
19
|
+
- `pyproject.toml` - Plugin configuration file
|
|
20
|
+
- `config.yml` - Configuration for connections
|
|
21
|
+
|
|
22
|
+
## Installing this Plugin
|
|
23
|
+
|
|
24
|
+
To use this plugin in an AMSDAL application:
|
|
25
|
+
|
|
26
|
+
1. Copy the plugin directory to your AMSDAL application
|
|
27
|
+
2. Import the models and transactions as needed
|
|
28
|
+
3. Register the plugin in your application configuration
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
This plugin uses sync mode.
|
|
33
|
+
|
|
34
|
+
### Adding Models
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
amsdal generate model ModelName --format py
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Adding Properties
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
amsdal generate property --model ModelName property_name
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Adding Transactions
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
amsdal generate transaction TransactionName
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Adding Hooks
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
amsdal generate hook --model ModelName on_create
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Testing
|
|
59
|
+
|
|
60
|
+
Test your plugin by integrating it with an AMSDAL application and running the application's test suite.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
amsdal_storages/Third-Party Materials - AMSDAL Dependencies - License Notices.md,sha256=uHJlGG0D4tbpUi8cq-497NNO9ltQ67a5448k-T14HTw,68241
|
|
2
|
+
amsdal_storages/__about__.py,sha256=IMjkMO3twhQzluVTo8Z6rE7Eg-9U79_LGKMcsWLKBkY,22
|
|
3
|
+
amsdal_storages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
amsdal_storages/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
amsdal_storages/s3/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
amsdal_storages/s3/storage.py,sha256=8rccyJOLdl6uhVb-VkRZ4eeZpWhm6jjo8SNY9exnHx4,7196
|
|
7
|
+
amsdal_storages-0.1.0.dist-info/METADATA,sha256=J66_rv0_gS_GI2S4sFOSL9C1nFgNQz9ARNudPAF8xv8,1286
|
|
8
|
+
amsdal_storages-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
amsdal_storages-0.1.0.dist-info/RECORD,,
|