samstack 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.
- samstack/__init__.py +18 -0
- samstack/_constants.py +6 -0
- samstack/_errors.py +38 -0
- samstack/_process.py +124 -0
- samstack/fixtures/__init__.py +0 -0
- samstack/fixtures/_sam_container.py +133 -0
- samstack/fixtures/localstack.py +92 -0
- samstack/fixtures/resources.py +376 -0
- samstack/fixtures/sam_api.py +47 -0
- samstack/fixtures/sam_build.py +95 -0
- samstack/fixtures/sam_lambda.py +74 -0
- samstack/plugin.py +94 -0
- samstack/py.typed +0 -0
- samstack/resources/__init__.py +6 -0
- samstack/resources/dynamodb.py +77 -0
- samstack/resources/s3.py +51 -0
- samstack/resources/sns.py +56 -0
- samstack/resources/sqs.py +55 -0
- samstack/settings.py +85 -0
- samstack-0.1.0.dist-info/METADATA +456 -0
- samstack-0.1.0.dist-info/RECORD +24 -0
- samstack-0.1.0.dist-info/WHEEL +4 -0
- samstack-0.1.0.dist-info/entry_points.txt +2 -0
- samstack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LocalStack resource fixtures for testing AWS services.
|
|
3
|
+
|
|
4
|
+
Provides session-scoped boto3 clients, session-scoped factory fixtures,
|
|
5
|
+
and function-scoped convenience fixtures for S3, DynamoDB, SQS, and SNS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
import boto3
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from samstack._constants import LOCALSTACK_ACCESS_KEY, LOCALSTACK_SECRET_KEY
|
|
20
|
+
from samstack.resources.dynamodb import DynamoTable
|
|
21
|
+
from samstack.resources.s3 import S3Bucket
|
|
22
|
+
from samstack.resources.sns import SnsTopic
|
|
23
|
+
from samstack.resources.sqs import SqsQueue
|
|
24
|
+
from samstack.settings import SamStackSettings
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def _safe_cleanup(description: str) -> Iterator[None]:
|
|
29
|
+
try:
|
|
30
|
+
yield
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
warnings.warn(
|
|
33
|
+
f"samstack: failed to clean up {description}: {exc}",
|
|
34
|
+
stacklevel=1,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from mypy_boto3_dynamodb import DynamoDBClient
|
|
40
|
+
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
|
|
41
|
+
from mypy_boto3_s3 import S3Client
|
|
42
|
+
from mypy_boto3_sns import SNSClient
|
|
43
|
+
from mypy_boto3_sqs import SQSClient
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# S3
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture(scope="session")
|
|
52
|
+
def s3_client(
|
|
53
|
+
localstack_endpoint: str,
|
|
54
|
+
samstack_settings: SamStackSettings,
|
|
55
|
+
) -> S3Client:
|
|
56
|
+
"""Session-scoped boto3 S3 client pointed at LocalStack."""
|
|
57
|
+
return boto3.client(
|
|
58
|
+
"s3",
|
|
59
|
+
endpoint_url=localstack_endpoint,
|
|
60
|
+
region_name=samstack_settings.region,
|
|
61
|
+
aws_access_key_id=LOCALSTACK_ACCESS_KEY,
|
|
62
|
+
aws_secret_access_key=LOCALSTACK_SECRET_KEY,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture(scope="session")
|
|
67
|
+
def s3_bucket_factory(
|
|
68
|
+
s3_client: S3Client,
|
|
69
|
+
) -> Iterator[Callable[[str], S3Bucket]]:
|
|
70
|
+
"""
|
|
71
|
+
Session-scoped factory that creates S3Bucket instances.
|
|
72
|
+
|
|
73
|
+
Each call creates a uniquely named bucket. All buckets are deleted
|
|
74
|
+
at end of session.
|
|
75
|
+
|
|
76
|
+
Usage::
|
|
77
|
+
|
|
78
|
+
def test_something(s3_bucket_factory):
|
|
79
|
+
bucket = s3_bucket_factory("my-data")
|
|
80
|
+
bucket.put("key.json", {"value": 1})
|
|
81
|
+
"""
|
|
82
|
+
created: list[S3Bucket] = []
|
|
83
|
+
|
|
84
|
+
def _create(name: str) -> S3Bucket:
|
|
85
|
+
actual = f"{name}-{uuid4().hex[:8]}"
|
|
86
|
+
s3_client.create_bucket(Bucket=actual)
|
|
87
|
+
bucket = S3Bucket(name=actual, client=s3_client)
|
|
88
|
+
created.append(bucket)
|
|
89
|
+
return bucket
|
|
90
|
+
|
|
91
|
+
yield _create
|
|
92
|
+
|
|
93
|
+
for bucket in created:
|
|
94
|
+
with _safe_cleanup(f"S3 bucket '{bucket.name}'"):
|
|
95
|
+
for key in bucket.list_keys():
|
|
96
|
+
bucket.delete(key)
|
|
97
|
+
s3_client.delete_bucket(Bucket=bucket.name)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.fixture
|
|
101
|
+
def s3_bucket(s3_client: S3Client) -> Iterator[S3Bucket]:
|
|
102
|
+
"""
|
|
103
|
+
Function-scoped S3Bucket fixture. Fresh bucket per test, deleted after.
|
|
104
|
+
|
|
105
|
+
Usage::
|
|
106
|
+
|
|
107
|
+
def test_upload(s3_bucket):
|
|
108
|
+
s3_bucket.put("file.txt", b"hello")
|
|
109
|
+
assert s3_bucket.get("file.txt") == b"hello"
|
|
110
|
+
"""
|
|
111
|
+
name = f"test-{uuid4().hex[:8]}"
|
|
112
|
+
s3_client.create_bucket(Bucket=name)
|
|
113
|
+
bucket = S3Bucket(name=name, client=s3_client)
|
|
114
|
+
yield bucket
|
|
115
|
+
with _safe_cleanup(f"S3 bucket '{name}'"):
|
|
116
|
+
for key in bucket.list_keys():
|
|
117
|
+
bucket.delete(key)
|
|
118
|
+
s3_client.delete_bucket(Bucket=name)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# DynamoDB
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.fixture(scope="session")
|
|
127
|
+
def dynamodb_client(
|
|
128
|
+
localstack_endpoint: str,
|
|
129
|
+
samstack_settings: SamStackSettings,
|
|
130
|
+
) -> DynamoDBClient:
|
|
131
|
+
"""Session-scoped boto3 DynamoDB low-level client pointed at LocalStack."""
|
|
132
|
+
return boto3.client(
|
|
133
|
+
"dynamodb",
|
|
134
|
+
endpoint_url=localstack_endpoint,
|
|
135
|
+
region_name=samstack_settings.region,
|
|
136
|
+
aws_access_key_id=LOCALSTACK_ACCESS_KEY,
|
|
137
|
+
aws_secret_access_key=LOCALSTACK_SECRET_KEY,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture(scope="session")
|
|
142
|
+
def _dynamodb_resource(
|
|
143
|
+
localstack_endpoint: str,
|
|
144
|
+
samstack_settings: SamStackSettings,
|
|
145
|
+
) -> DynamoDBServiceResource:
|
|
146
|
+
"""Session-scoped boto3 DynamoDB resource (high-level) for table wrappers."""
|
|
147
|
+
return boto3.resource(
|
|
148
|
+
"dynamodb",
|
|
149
|
+
endpoint_url=localstack_endpoint,
|
|
150
|
+
region_name=samstack_settings.region,
|
|
151
|
+
aws_access_key_id=LOCALSTACK_ACCESS_KEY,
|
|
152
|
+
aws_secret_access_key=LOCALSTACK_SECRET_KEY,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _create_dynamo_table(
|
|
157
|
+
dynamodb_client: DynamoDBClient,
|
|
158
|
+
resource: DynamoDBServiceResource,
|
|
159
|
+
name: str,
|
|
160
|
+
keys: dict[str, str],
|
|
161
|
+
) -> DynamoTable:
|
|
162
|
+
key_pairs = list(keys.items())
|
|
163
|
+
attr_defs = [{"AttributeName": k, "AttributeType": v} for k, v in key_pairs]
|
|
164
|
+
key_schema = [{"AttributeName": key_pairs[0][0], "KeyType": "HASH"}]
|
|
165
|
+
if len(key_pairs) > 1:
|
|
166
|
+
key_schema.append({"AttributeName": key_pairs[1][0], "KeyType": "RANGE"})
|
|
167
|
+
dynamodb_client.create_table(
|
|
168
|
+
TableName=name,
|
|
169
|
+
AttributeDefinitions=attr_defs,
|
|
170
|
+
KeySchema=key_schema,
|
|
171
|
+
BillingMode="PAY_PER_REQUEST",
|
|
172
|
+
)
|
|
173
|
+
table: Table = resource.Table(name)
|
|
174
|
+
return DynamoTable(name=name, table=table)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.fixture(scope="session")
|
|
178
|
+
def dynamodb_table_factory(
|
|
179
|
+
dynamodb_client: DynamoDBClient,
|
|
180
|
+
_dynamodb_resource: DynamoDBServiceResource,
|
|
181
|
+
) -> Iterator[Callable[[str, dict[str, str]], DynamoTable]]:
|
|
182
|
+
"""
|
|
183
|
+
Session-scoped factory that creates DynamoTable instances.
|
|
184
|
+
|
|
185
|
+
Each call creates a uniquely named table. All tables are deleted at end of session.
|
|
186
|
+
Uses the high-level resource API so item values are plain Python types.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
name: Base name for the table (UUID suffix appended).
|
|
190
|
+
keys: Mapping of attribute name to type (``"S"``, ``"N"``, or ``"B"``).
|
|
191
|
+
First entry is the HASH key; second (if present) is the RANGE key.
|
|
192
|
+
|
|
193
|
+
Usage::
|
|
194
|
+
|
|
195
|
+
def test_something(dynamodb_table_factory):
|
|
196
|
+
table = dynamodb_table_factory("orders", {"order_id": "S"})
|
|
197
|
+
table.put_item({"order_id": "1", "total": 99})
|
|
198
|
+
"""
|
|
199
|
+
created: list[str] = []
|
|
200
|
+
|
|
201
|
+
def _create(name: str, keys: dict[str, str]) -> DynamoTable:
|
|
202
|
+
actual = f"{name}-{uuid4().hex[:8]}"
|
|
203
|
+
table = _create_dynamo_table(dynamodb_client, _dynamodb_resource, actual, keys)
|
|
204
|
+
created.append(actual)
|
|
205
|
+
return table
|
|
206
|
+
|
|
207
|
+
yield _create
|
|
208
|
+
|
|
209
|
+
for table_name in created:
|
|
210
|
+
with _safe_cleanup(f"DynamoDB table '{table_name}'"):
|
|
211
|
+
dynamodb_client.delete_table(TableName=table_name)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.fixture
|
|
215
|
+
def dynamodb_table(
|
|
216
|
+
dynamodb_client: DynamoDBClient,
|
|
217
|
+
_dynamodb_resource: DynamoDBServiceResource,
|
|
218
|
+
) -> Iterator[DynamoTable]:
|
|
219
|
+
"""
|
|
220
|
+
Function-scoped DynamoTable fixture. Default key schema: ``{"id": "S"}``.
|
|
221
|
+
Fresh table per test, deleted after.
|
|
222
|
+
|
|
223
|
+
Usage::
|
|
224
|
+
|
|
225
|
+
def test_store(dynamodb_table):
|
|
226
|
+
dynamodb_table.put_item({"id": "1", "data": "x"})
|
|
227
|
+
assert dynamodb_table.get_item({"id": "1"})["data"] == "x"
|
|
228
|
+
"""
|
|
229
|
+
name = f"test-{uuid4().hex[:8]}"
|
|
230
|
+
table = _create_dynamo_table(dynamodb_client, _dynamodb_resource, name, {"id": "S"})
|
|
231
|
+
yield table
|
|
232
|
+
with _safe_cleanup(f"DynamoDB table '{name}'"):
|
|
233
|
+
dynamodb_client.delete_table(TableName=name)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# SQS
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.fixture(scope="session")
|
|
242
|
+
def sqs_client(
|
|
243
|
+
localstack_endpoint: str,
|
|
244
|
+
samstack_settings: SamStackSettings,
|
|
245
|
+
) -> SQSClient:
|
|
246
|
+
"""Session-scoped boto3 SQS client pointed at LocalStack."""
|
|
247
|
+
return boto3.client(
|
|
248
|
+
"sqs",
|
|
249
|
+
endpoint_url=localstack_endpoint,
|
|
250
|
+
region_name=samstack_settings.region,
|
|
251
|
+
aws_access_key_id=LOCALSTACK_ACCESS_KEY,
|
|
252
|
+
aws_secret_access_key=LOCALSTACK_SECRET_KEY,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.fixture(scope="session")
|
|
257
|
+
def sqs_queue_factory(
|
|
258
|
+
sqs_client: SQSClient,
|
|
259
|
+
) -> Iterator[Callable[[str], SqsQueue]]:
|
|
260
|
+
"""
|
|
261
|
+
Session-scoped factory that creates SqsQueue instances.
|
|
262
|
+
|
|
263
|
+
Each call creates a uniquely named queue. All queues are deleted at end of session.
|
|
264
|
+
|
|
265
|
+
Usage::
|
|
266
|
+
|
|
267
|
+
def test_something(sqs_queue_factory):
|
|
268
|
+
queue = sqs_queue_factory("jobs")
|
|
269
|
+
queue.send({"task": "process", "id": 1})
|
|
270
|
+
"""
|
|
271
|
+
created: list[SqsQueue] = []
|
|
272
|
+
|
|
273
|
+
def _create(name: str) -> SqsQueue:
|
|
274
|
+
actual = f"{name}-{uuid4().hex[:8]}"
|
|
275
|
+
resp = sqs_client.create_queue(QueueName=actual)
|
|
276
|
+
queue = SqsQueue(url=resp["QueueUrl"], client=sqs_client)
|
|
277
|
+
created.append(queue)
|
|
278
|
+
return queue
|
|
279
|
+
|
|
280
|
+
yield _create
|
|
281
|
+
|
|
282
|
+
for queue in created:
|
|
283
|
+
with _safe_cleanup(f"SQS queue '{queue.url}'"):
|
|
284
|
+
sqs_client.delete_queue(QueueUrl=queue.url)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@pytest.fixture
|
|
288
|
+
def sqs_queue(sqs_client: SQSClient) -> Iterator[SqsQueue]:
|
|
289
|
+
"""
|
|
290
|
+
Function-scoped SqsQueue fixture. Fresh queue per test, deleted after.
|
|
291
|
+
|
|
292
|
+
Usage::
|
|
293
|
+
|
|
294
|
+
def test_process(sqs_queue):
|
|
295
|
+
sqs_queue.send({"job": "run"})
|
|
296
|
+
messages = sqs_queue.receive()
|
|
297
|
+
assert len(messages) == 1
|
|
298
|
+
"""
|
|
299
|
+
name = f"test-{uuid4().hex[:8]}"
|
|
300
|
+
resp = sqs_client.create_queue(QueueName=name)
|
|
301
|
+
queue = SqsQueue(url=resp["QueueUrl"], client=sqs_client)
|
|
302
|
+
yield queue
|
|
303
|
+
with _safe_cleanup(f"SQS queue '{name}'"):
|
|
304
|
+
sqs_client.delete_queue(QueueUrl=queue.url)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# SNS
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@pytest.fixture(scope="session")
|
|
313
|
+
def sns_client(
|
|
314
|
+
localstack_endpoint: str,
|
|
315
|
+
samstack_settings: SamStackSettings,
|
|
316
|
+
) -> SNSClient:
|
|
317
|
+
"""Session-scoped boto3 SNS client pointed at LocalStack."""
|
|
318
|
+
return boto3.client(
|
|
319
|
+
"sns",
|
|
320
|
+
endpoint_url=localstack_endpoint,
|
|
321
|
+
region_name=samstack_settings.region,
|
|
322
|
+
aws_access_key_id=LOCALSTACK_ACCESS_KEY,
|
|
323
|
+
aws_secret_access_key=LOCALSTACK_SECRET_KEY,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@pytest.fixture(scope="session")
|
|
328
|
+
def sns_topic_factory(
|
|
329
|
+
sns_client: SNSClient,
|
|
330
|
+
) -> Iterator[Callable[[str], SnsTopic]]:
|
|
331
|
+
"""
|
|
332
|
+
Session-scoped factory that creates SnsTopic instances.
|
|
333
|
+
|
|
334
|
+
Each call creates a uniquely named topic. All topics are deleted at end of session.
|
|
335
|
+
|
|
336
|
+
Usage::
|
|
337
|
+
|
|
338
|
+
def test_something(sns_topic_factory, sqs_queue_factory):
|
|
339
|
+
topic = sns_topic_factory("notifications")
|
|
340
|
+
queue = sqs_queue_factory("inbox")
|
|
341
|
+
topic.subscribe_sqs(queue_arn)
|
|
342
|
+
topic.publish({"event": "created"})
|
|
343
|
+
"""
|
|
344
|
+
created: list[SnsTopic] = []
|
|
345
|
+
|
|
346
|
+
def _create(name: str) -> SnsTopic:
|
|
347
|
+
actual = f"{name}-{uuid4().hex[:8]}"
|
|
348
|
+
resp = sns_client.create_topic(Name=actual)
|
|
349
|
+
topic = SnsTopic(arn=resp["TopicArn"], client=sns_client)
|
|
350
|
+
created.append(topic)
|
|
351
|
+
return topic
|
|
352
|
+
|
|
353
|
+
yield _create
|
|
354
|
+
|
|
355
|
+
for topic in created:
|
|
356
|
+
with _safe_cleanup(f"SNS topic '{topic.arn}'"):
|
|
357
|
+
sns_client.delete_topic(TopicArn=topic.arn)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pytest.fixture
|
|
361
|
+
def sns_topic(sns_client: SNSClient) -> Iterator[SnsTopic]:
|
|
362
|
+
"""
|
|
363
|
+
Function-scoped SnsTopic fixture. Fresh topic per test, deleted after.
|
|
364
|
+
|
|
365
|
+
Usage::
|
|
366
|
+
|
|
367
|
+
def test_notify(sns_topic):
|
|
368
|
+
msg_id = sns_topic.publish("hello")
|
|
369
|
+
assert isinstance(msg_id, str)
|
|
370
|
+
"""
|
|
371
|
+
name = f"test-{uuid4().hex[:8]}"
|
|
372
|
+
resp = sns_client.create_topic(Name=name)
|
|
373
|
+
topic = SnsTopic(arn=resp["TopicArn"], client=sns_client)
|
|
374
|
+
yield topic
|
|
375
|
+
with _safe_cleanup(f"SNS topic '{name}'"):
|
|
376
|
+
sns_client.delete_topic(TopicArn=topic.arn)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from samstack.fixtures._sam_container import _run_sam_service
|
|
8
|
+
from samstack.settings import SamStackSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(scope="session")
|
|
12
|
+
def sam_api_extra_args() -> list[str]:
|
|
13
|
+
"""
|
|
14
|
+
Extra CLI args appended to `sam local start-api` after the defaults.
|
|
15
|
+
|
|
16
|
+
Override in your conftest.py:
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(scope="session")
|
|
19
|
+
def sam_api_extra_args() -> list[str]:
|
|
20
|
+
return ["--debug"]
|
|
21
|
+
"""
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session")
|
|
26
|
+
def sam_api(
|
|
27
|
+
samstack_settings: SamStackSettings,
|
|
28
|
+
sam_build: None,
|
|
29
|
+
docker_network: str,
|
|
30
|
+
sam_api_extra_args: list[str],
|
|
31
|
+
) -> Iterator[str]:
|
|
32
|
+
"""
|
|
33
|
+
Start `sam local start-api` in Docker. Yields base URL http://127.0.0.1:{api_port}.
|
|
34
|
+
Logs written to {log_dir}/start-api.log.
|
|
35
|
+
"""
|
|
36
|
+
with _run_sam_service(
|
|
37
|
+
settings=samstack_settings,
|
|
38
|
+
docker_network=docker_network,
|
|
39
|
+
subcommand="start-api",
|
|
40
|
+
port=samstack_settings.api_port,
|
|
41
|
+
warm_containers="LAZY",
|
|
42
|
+
settings_extra_args=samstack_settings.start_api_args,
|
|
43
|
+
fixture_extra_args=sam_api_extra_args,
|
|
44
|
+
log_filename="start-api.log",
|
|
45
|
+
wait_mode="http",
|
|
46
|
+
) as endpoint:
|
|
47
|
+
yield endpoint
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from samstack._constants import LOCALSTACK_ACCESS_KEY, LOCALSTACK_SECRET_KEY
|
|
9
|
+
from samstack._errors import SamBuildError
|
|
10
|
+
from samstack._process import run_one_shot_container
|
|
11
|
+
from samstack.fixtures._sam_container import DOCKER_SOCKET
|
|
12
|
+
from samstack.settings import SamStackSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _add_gitignore_entry(project_root: Path, log_dir: str) -> None:
|
|
16
|
+
gitignore = project_root / ".gitignore"
|
|
17
|
+
entry = f"{log_dir}/"
|
|
18
|
+
if gitignore.exists():
|
|
19
|
+
content = gitignore.read_text()
|
|
20
|
+
if entry in content.splitlines():
|
|
21
|
+
return
|
|
22
|
+
gitignore.write_text(content.rstrip("\n") + f"\n{entry}\n")
|
|
23
|
+
else:
|
|
24
|
+
gitignore.write_text(f"{entry}\n")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(scope="session")
|
|
28
|
+
def sam_env_vars(samstack_settings: SamStackSettings) -> dict[str, dict[str, str]]:
|
|
29
|
+
"""
|
|
30
|
+
Default environment variables injected into all Lambda functions at runtime.
|
|
31
|
+
|
|
32
|
+
Override in your conftest.py to add function-specific vars:
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(scope="session")
|
|
35
|
+
def sam_env_vars(sam_env_vars):
|
|
36
|
+
sam_env_vars["MyFunction"] = {"MY_VAR": "value"}
|
|
37
|
+
return sam_env_vars
|
|
38
|
+
"""
|
|
39
|
+
return {
|
|
40
|
+
"Parameters": {
|
|
41
|
+
"AWS_ENDPOINT_URL": "http://localstack:4566",
|
|
42
|
+
"AWS_DEFAULT_REGION": samstack_settings.region,
|
|
43
|
+
"AWS_ACCESS_KEY_ID": LOCALSTACK_ACCESS_KEY,
|
|
44
|
+
"AWS_SECRET_ACCESS_KEY": LOCALSTACK_SECRET_KEY,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture(scope="session")
|
|
50
|
+
def sam_build(
|
|
51
|
+
samstack_settings: SamStackSettings,
|
|
52
|
+
sam_env_vars: dict[str, dict[str, str]],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Run `sam build` in a one-shot Docker container. Runs once per test session.
|
|
56
|
+
|
|
57
|
+
The build output lands in {project_root}/.aws-sam/ and is reused by
|
|
58
|
+
sam_api and sam_lambda_endpoint fixtures.
|
|
59
|
+
"""
|
|
60
|
+
log_dir = samstack_settings.project_root / samstack_settings.log_dir
|
|
61
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
# Write env vars JSON to a path accessible inside the SAM container
|
|
64
|
+
env_vars_path = log_dir / "env_vars.json"
|
|
65
|
+
env_vars_path.write_text(json.dumps(sam_env_vars, indent=2))
|
|
66
|
+
|
|
67
|
+
# Mount the project at its real host path so that Lambda containers
|
|
68
|
+
# created by SAM via the Docker socket can also mount it — Docker Desktop
|
|
69
|
+
# only shares /Users (and similar host paths), not paths inside containers.
|
|
70
|
+
host_path = str(samstack_settings.project_root)
|
|
71
|
+
build_cmd = [
|
|
72
|
+
"sam",
|
|
73
|
+
"build",
|
|
74
|
+
"--skip-pull-image",
|
|
75
|
+
"--template",
|
|
76
|
+
samstack_settings.template,
|
|
77
|
+
*samstack_settings.build_args,
|
|
78
|
+
]
|
|
79
|
+
volumes = {
|
|
80
|
+
host_path: {"bind": host_path, "mode": "rw"},
|
|
81
|
+
DOCKER_SOCKET: {"bind": DOCKER_SOCKET, "mode": "rw"},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
logs, exit_code = run_one_shot_container(
|
|
85
|
+
image=samstack_settings.sam_image,
|
|
86
|
+
command=build_cmd,
|
|
87
|
+
volumes=volumes,
|
|
88
|
+
working_dir=host_path,
|
|
89
|
+
environment={"DOCKER_DEFAULT_PLATFORM": samstack_settings.docker_platform},
|
|
90
|
+
)
|
|
91
|
+
if exit_code != 0:
|
|
92
|
+
raise SamBuildError(logs=logs)
|
|
93
|
+
|
|
94
|
+
if samstack_settings.add_gitignore:
|
|
95
|
+
_add_gitignore_entry(samstack_settings.project_root, samstack_settings.log_dir)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from mypy_boto3_lambda import LambdaClient
|
|
11
|
+
|
|
12
|
+
from samstack._constants import LOCALSTACK_ACCESS_KEY, LOCALSTACK_SECRET_KEY
|
|
13
|
+
from samstack.fixtures._sam_container import _run_sam_service
|
|
14
|
+
from samstack.settings import SamStackSettings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(scope="session")
|
|
18
|
+
def sam_lambda_extra_args() -> list[str]:
|
|
19
|
+
"""
|
|
20
|
+
Extra CLI args appended to `sam local start-lambda` after the defaults.
|
|
21
|
+
|
|
22
|
+
Override in your conftest.py:
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(scope="session")
|
|
25
|
+
def sam_lambda_extra_args() -> list[str]:
|
|
26
|
+
return ["--debug"]
|
|
27
|
+
"""
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(scope="session")
|
|
32
|
+
def sam_lambda_endpoint(
|
|
33
|
+
samstack_settings: SamStackSettings,
|
|
34
|
+
sam_build: None,
|
|
35
|
+
docker_network: str,
|
|
36
|
+
sam_lambda_extra_args: list[str],
|
|
37
|
+
) -> Iterator[str]:
|
|
38
|
+
"""
|
|
39
|
+
Start `sam local start-lambda` in Docker. Yields the endpoint URL
|
|
40
|
+
http://127.0.0.1:{lambda_port} for use with boto3 Lambda client.
|
|
41
|
+
Logs written to {log_dir}/start-lambda.log.
|
|
42
|
+
"""
|
|
43
|
+
with _run_sam_service(
|
|
44
|
+
settings=samstack_settings,
|
|
45
|
+
docker_network=docker_network,
|
|
46
|
+
subcommand="start-lambda",
|
|
47
|
+
port=samstack_settings.lambda_port,
|
|
48
|
+
warm_containers="EAGER",
|
|
49
|
+
settings_extra_args=samstack_settings.start_lambda_args,
|
|
50
|
+
fixture_extra_args=sam_lambda_extra_args,
|
|
51
|
+
log_filename="start-lambda.log",
|
|
52
|
+
wait_mode="port",
|
|
53
|
+
) as endpoint:
|
|
54
|
+
yield endpoint
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture(scope="session")
|
|
58
|
+
def lambda_client(
|
|
59
|
+
samstack_settings: SamStackSettings,
|
|
60
|
+
sam_lambda_endpoint: str,
|
|
61
|
+
) -> LambdaClient:
|
|
62
|
+
"""
|
|
63
|
+
Boto3 Lambda client pointed at the local SAM Lambda endpoint.
|
|
64
|
+
|
|
65
|
+
Use this to invoke functions directly without HTTP:
|
|
66
|
+
result = lambda_client.invoke(FunctionName="MyFunction", Payload=b"{}")
|
|
67
|
+
"""
|
|
68
|
+
return boto3.client(
|
|
69
|
+
"lambda",
|
|
70
|
+
endpoint_url=sam_lambda_endpoint,
|
|
71
|
+
region_name=samstack_settings.region,
|
|
72
|
+
aws_access_key_id=LOCALSTACK_ACCESS_KEY,
|
|
73
|
+
aws_secret_access_key=LOCALSTACK_SECRET_KEY,
|
|
74
|
+
)
|
samstack/plugin.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pytest plugin entry point for samstack.
|
|
3
|
+
|
|
4
|
+
Registered via [project.entry-points."pytest11"] in pyproject.toml:
|
|
5
|
+
samstack = "samstack.plugin"
|
|
6
|
+
|
|
7
|
+
This module registers all fixtures and provides the samstack_settings fixture
|
|
8
|
+
by reading [tool.samstack] from the child project's pyproject.toml.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from samstack.fixtures.localstack import (
|
|
18
|
+
docker_network,
|
|
19
|
+
docker_network_name,
|
|
20
|
+
localstack_container,
|
|
21
|
+
localstack_endpoint,
|
|
22
|
+
)
|
|
23
|
+
from samstack.fixtures.resources import (
|
|
24
|
+
_dynamodb_resource, # noqa: F401 — internal fixture, must be registered for pytest discovery
|
|
25
|
+
dynamodb_client,
|
|
26
|
+
dynamodb_table,
|
|
27
|
+
dynamodb_table_factory,
|
|
28
|
+
s3_bucket,
|
|
29
|
+
s3_bucket_factory,
|
|
30
|
+
s3_client,
|
|
31
|
+
sns_client,
|
|
32
|
+
sns_topic,
|
|
33
|
+
sns_topic_factory,
|
|
34
|
+
sqs_client,
|
|
35
|
+
sqs_queue,
|
|
36
|
+
sqs_queue_factory,
|
|
37
|
+
)
|
|
38
|
+
from samstack.fixtures.sam_api import sam_api, sam_api_extra_args
|
|
39
|
+
from samstack.fixtures.sam_build import sam_build, sam_env_vars
|
|
40
|
+
from samstack.fixtures.sam_lambda import (
|
|
41
|
+
lambda_client,
|
|
42
|
+
sam_lambda_endpoint,
|
|
43
|
+
sam_lambda_extra_args,
|
|
44
|
+
)
|
|
45
|
+
from samstack.settings import SamStackSettings, load_settings
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"docker_network",
|
|
49
|
+
"docker_network_name",
|
|
50
|
+
"dynamodb_client",
|
|
51
|
+
"dynamodb_table",
|
|
52
|
+
"dynamodb_table_factory",
|
|
53
|
+
"lambda_client",
|
|
54
|
+
"localstack_container",
|
|
55
|
+
"localstack_endpoint",
|
|
56
|
+
"s3_bucket",
|
|
57
|
+
"s3_bucket_factory",
|
|
58
|
+
"s3_client",
|
|
59
|
+
"sam_api",
|
|
60
|
+
"sns_client",
|
|
61
|
+
"sns_topic",
|
|
62
|
+
"sns_topic_factory",
|
|
63
|
+
"sqs_client",
|
|
64
|
+
"sqs_queue",
|
|
65
|
+
"sqs_queue_factory",
|
|
66
|
+
"sam_api_extra_args",
|
|
67
|
+
"sam_build",
|
|
68
|
+
"sam_env_vars",
|
|
69
|
+
"sam_lambda_endpoint",
|
|
70
|
+
"sam_lambda_extra_args",
|
|
71
|
+
"samstack_settings",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture(scope="session")
|
|
76
|
+
def samstack_settings() -> SamStackSettings:
|
|
77
|
+
"""
|
|
78
|
+
Load [tool.samstack] from the child project's pyproject.toml.
|
|
79
|
+
|
|
80
|
+
samstack searches upward from the current working directory for pyproject.toml.
|
|
81
|
+
Override this fixture to supply settings programmatically:
|
|
82
|
+
|
|
83
|
+
@pytest.fixture(scope="session")
|
|
84
|
+
def samstack_settings() -> SamStackSettings:
|
|
85
|
+
return SamStackSettings(sam_image="public.ecr.aws/sam/build-python3.13")
|
|
86
|
+
"""
|
|
87
|
+
cwd = Path.cwd()
|
|
88
|
+
for parent in [cwd, *cwd.parents]:
|
|
89
|
+
candidate = parent / "pyproject.toml"
|
|
90
|
+
if candidate.exists():
|
|
91
|
+
return load_settings(parent)
|
|
92
|
+
raise FileNotFoundError(
|
|
93
|
+
"pyproject.toml not found. samstack requires [tool.samstack] in pyproject.toml."
|
|
94
|
+
)
|
samstack/py.typed
ADDED
|
File without changes
|