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,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from mypy_boto3_dynamodb import DynamoDBClient
|
|
7
|
+
from mypy_boto3_dynamodb.service_resource import Table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DynamoTable:
|
|
11
|
+
"""
|
|
12
|
+
Thin wrapper around a DynamoDB table resource for use in pytest fixtures.
|
|
13
|
+
|
|
14
|
+
Uses the high-level boto3 resource API so item values are plain Python
|
|
15
|
+
types (str, int, list, dict) — not the low-level ``{"S": "val"}`` format.
|
|
16
|
+
|
|
17
|
+
The underlying boto3 resource is exposed via the ``table`` property, and
|
|
18
|
+
the low-level DynamoDB client is available via the ``client`` property.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, name: str, table: Table) -> None:
|
|
22
|
+
self._name = name
|
|
23
|
+
self._table = table
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
return self._name
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def table(self) -> Table:
|
|
31
|
+
"""The underlying boto3 DynamoDB Table resource."""
|
|
32
|
+
return self._table
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def client(self) -> DynamoDBClient:
|
|
36
|
+
"""The underlying low-level DynamoDB client for advanced operations."""
|
|
37
|
+
client: DynamoDBClient = self._table.meta.client
|
|
38
|
+
return client
|
|
39
|
+
|
|
40
|
+
def put_item(self, item: dict[str, Any]) -> None:
|
|
41
|
+
"""Write an item to the table using plain Python values."""
|
|
42
|
+
self._table.put_item(Item=item)
|
|
43
|
+
|
|
44
|
+
def get_item(self, key: dict[str, Any]) -> dict[str, Any] | None:
|
|
45
|
+
"""Fetch an item by key. Returns None if the item does not exist."""
|
|
46
|
+
resp = self._table.get_item(Key=key)
|
|
47
|
+
return resp.get("Item")
|
|
48
|
+
|
|
49
|
+
def delete_item(self, key: dict[str, Any]) -> None:
|
|
50
|
+
"""Delete an item by key."""
|
|
51
|
+
self._table.delete_item(Key=key)
|
|
52
|
+
|
|
53
|
+
def query(
|
|
54
|
+
self,
|
|
55
|
+
key_condition: str,
|
|
56
|
+
values: dict[str, Any],
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> list[dict[str, Any]]:
|
|
59
|
+
"""
|
|
60
|
+
Query items by key condition expression.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
key_condition: KeyConditionExpression string, e.g. ``"pk = :pk"``
|
|
64
|
+
values: ExpressionAttributeValues dict, e.g. ``{":pk": "val"}``
|
|
65
|
+
**kwargs: Extra args forwarded to boto3 (e.g. ``IndexName``)
|
|
66
|
+
"""
|
|
67
|
+
resp = self._table.query(
|
|
68
|
+
KeyConditionExpression=key_condition,
|
|
69
|
+
ExpressionAttributeValues=values,
|
|
70
|
+
**kwargs,
|
|
71
|
+
)
|
|
72
|
+
return resp["Items"]
|
|
73
|
+
|
|
74
|
+
def scan(self, **kwargs: Any) -> list[dict[str, Any]]:
|
|
75
|
+
"""Scan all items, with optional filter kwargs forwarded to boto3."""
|
|
76
|
+
resp = self._table.scan(**kwargs)
|
|
77
|
+
return resp["Items"]
|
samstack/resources/s3.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from mypy_boto3_s3 import S3Client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class S3Bucket:
|
|
11
|
+
"""Thin wrapper around an S3 bucket for use in pytest fixtures."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, name: str, client: S3Client) -> None:
|
|
14
|
+
self._name = name
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def name(self) -> str:
|
|
19
|
+
return self._name
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def client(self) -> S3Client:
|
|
23
|
+
return self._client
|
|
24
|
+
|
|
25
|
+
def put(self, key: str, data: bytes | str | dict[str, Any]) -> None:
|
|
26
|
+
"""Upload an object. Dicts are JSON-serialized; strings are UTF-8 encoded."""
|
|
27
|
+
if isinstance(data, dict):
|
|
28
|
+
body: bytes = json.dumps(data).encode()
|
|
29
|
+
elif isinstance(data, str):
|
|
30
|
+
body = data.encode()
|
|
31
|
+
else:
|
|
32
|
+
body = data
|
|
33
|
+
self._client.put_object(Bucket=self._name, Key=key, Body=body)
|
|
34
|
+
|
|
35
|
+
def get(self, key: str) -> bytes:
|
|
36
|
+
"""Download an object and return raw bytes."""
|
|
37
|
+
resp = self._client.get_object(Bucket=self._name, Key=key)
|
|
38
|
+
return resp["Body"].read()
|
|
39
|
+
|
|
40
|
+
def get_json(self, key: str) -> Any:
|
|
41
|
+
"""Download an object and deserialize as JSON."""
|
|
42
|
+
return json.loads(self.get(key))
|
|
43
|
+
|
|
44
|
+
def delete(self, key: str) -> None:
|
|
45
|
+
"""Delete an object."""
|
|
46
|
+
self._client.delete_object(Bucket=self._name, Key=key)
|
|
47
|
+
|
|
48
|
+
def list_keys(self, prefix: str = "") -> list[str]:
|
|
49
|
+
"""List object keys, optionally filtered by prefix."""
|
|
50
|
+
resp = self._client.list_objects_v2(Bucket=self._name, Prefix=prefix)
|
|
51
|
+
return [obj["Key"] for obj in resp.get("Contents", [])]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from mypy_boto3_sns import SNSClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SnsTopic:
|
|
11
|
+
"""Thin wrapper around an SNS topic for use in pytest fixtures."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, arn: str, client: SNSClient) -> None:
|
|
14
|
+
self._arn = arn
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def arn(self) -> str:
|
|
19
|
+
return self._arn
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def client(self) -> SNSClient:
|
|
23
|
+
return self._client
|
|
24
|
+
|
|
25
|
+
def publish(
|
|
26
|
+
self,
|
|
27
|
+
message: str | dict[str, Any],
|
|
28
|
+
subject: str | None = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Publish a message to the topic. Dicts are JSON-serialized.
|
|
32
|
+
Returns the message ID.
|
|
33
|
+
"""
|
|
34
|
+
body = json.dumps(message) if isinstance(message, dict) else message
|
|
35
|
+
kwargs: dict[str, Any] = {"TopicArn": self._arn, "Message": body}
|
|
36
|
+
if subject is not None:
|
|
37
|
+
kwargs["Subject"] = subject
|
|
38
|
+
resp = self._client.publish(**kwargs)
|
|
39
|
+
return resp["MessageId"]
|
|
40
|
+
|
|
41
|
+
def subscribe_sqs(self, queue_arn: str) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Subscribe an SQS queue to this topic.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
queue_arn: The ARN of the SQS queue to receive messages.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The subscription ARN.
|
|
50
|
+
"""
|
|
51
|
+
resp = self._client.subscribe(
|
|
52
|
+
TopicArn=self._arn,
|
|
53
|
+
Protocol="sqs",
|
|
54
|
+
Endpoint=queue_arn,
|
|
55
|
+
)
|
|
56
|
+
return resp["SubscriptionArn"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from mypy_boto3_sqs import SQSClient
|
|
8
|
+
from mypy_boto3_sqs.type_defs import MessageTypeDef
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SqsQueue:
|
|
12
|
+
"""Thin wrapper around an SQS queue for use in pytest fixtures."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, url: str, client: SQSClient) -> None:
|
|
15
|
+
self._url = url
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def url(self) -> str:
|
|
20
|
+
return self._url
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def client(self) -> SQSClient:
|
|
24
|
+
return self._client
|
|
25
|
+
|
|
26
|
+
def send(self, body: str | dict[str, Any], **kwargs: Any) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Send a message. Dicts are JSON-serialized. Returns the message ID.
|
|
29
|
+
Extra kwargs (e.g. ``DelaySeconds``) are forwarded to boto3.
|
|
30
|
+
"""
|
|
31
|
+
message_body = json.dumps(body) if isinstance(body, dict) else body
|
|
32
|
+
resp = self._client.send_message(
|
|
33
|
+
QueueUrl=self._url, MessageBody=message_body, **kwargs
|
|
34
|
+
)
|
|
35
|
+
return resp["MessageId"]
|
|
36
|
+
|
|
37
|
+
def receive(
|
|
38
|
+
self, max_messages: int = 1, wait_seconds: int = 0
|
|
39
|
+
) -> list[MessageTypeDef]:
|
|
40
|
+
"""
|
|
41
|
+
Receive messages from the queue.
|
|
42
|
+
|
|
43
|
+
Returns a list of message dicts (``MessageId``, ``Body``,
|
|
44
|
+
``ReceiptHandle``, etc). Returns an empty list when the queue is empty.
|
|
45
|
+
"""
|
|
46
|
+
resp = self._client.receive_message(
|
|
47
|
+
QueueUrl=self._url,
|
|
48
|
+
MaxNumberOfMessages=max_messages,
|
|
49
|
+
WaitTimeSeconds=wait_seconds,
|
|
50
|
+
)
|
|
51
|
+
return resp.get("Messages", [])
|
|
52
|
+
|
|
53
|
+
def purge(self) -> None:
|
|
54
|
+
"""Delete all messages from the queue."""
|
|
55
|
+
self._client.purge_queue(QueueUrl=self._url)
|
samstack/settings.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import platform
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _detect_architecture() -> Literal["arm64", "x86_64"]:
|
|
12
|
+
"""Return 'arm64' on Apple Silicon / Linux ARM hosts, 'x86_64' otherwise.
|
|
13
|
+
|
|
14
|
+
platform.machine() returns:
|
|
15
|
+
- 'arm64' on macOS ARM (Apple Silicon)
|
|
16
|
+
- 'aarch64' on Linux ARM64
|
|
17
|
+
- 'x86_64' on Intel/AMD (macOS and Linux)
|
|
18
|
+
"""
|
|
19
|
+
machine = platform.machine().lower()
|
|
20
|
+
if machine in ("arm64", "aarch64"):
|
|
21
|
+
return "arm64"
|
|
22
|
+
return "x86_64"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SamStackSettings:
|
|
27
|
+
sam_image: str
|
|
28
|
+
template: str = "template.yaml"
|
|
29
|
+
region: str = "us-east-1"
|
|
30
|
+
api_port: int = 3000
|
|
31
|
+
lambda_port: int = 3001
|
|
32
|
+
localstack_image: str = "localstack/localstack:4"
|
|
33
|
+
log_dir: str = "logs/sam"
|
|
34
|
+
build_args: list[str] = field(default_factory=list)
|
|
35
|
+
add_gitignore: bool = True
|
|
36
|
+
start_api_args: list[str] = field(default_factory=list)
|
|
37
|
+
start_lambda_args: list[str] = field(default_factory=list)
|
|
38
|
+
project_root: Path = field(default_factory=Path.cwd)
|
|
39
|
+
architecture: Literal["arm64", "x86_64"] = field(
|
|
40
|
+
default_factory=_detect_architecture
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def docker_platform(self) -> str:
|
|
45
|
+
return "linux/arm64" if self.architecture == "arm64" else "linux/amd64"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_settings(project_root: Path) -> SamStackSettings:
|
|
49
|
+
"""Parse [tool.samstack] from pyproject.toml in project_root."""
|
|
50
|
+
pyproject = project_root / "pyproject.toml"
|
|
51
|
+
with pyproject.open("rb") as f:
|
|
52
|
+
data = tomllib.load(f)
|
|
53
|
+
|
|
54
|
+
tool = data.get("tool", {})
|
|
55
|
+
if "samstack" not in tool:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"[tool.samstack] section not found in pyproject.toml. "
|
|
58
|
+
"Add it to configure samstack."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
cfg: dict = tool["samstack"]
|
|
62
|
+
|
|
63
|
+
if not cfg.get("sam_image"):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"sam_image is required in [tool.samstack]. "
|
|
66
|
+
'Example: sam_image = "public.ecr.aws/sam/build-python3.13"'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
arch = cfg.get("architecture")
|
|
70
|
+
if arch is not None and arch not in ("arm64", "x86_64"):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"architecture must be 'arm64' or 'x86_64', got '{arch}'. "
|
|
73
|
+
"Remove the field to auto-detect from the host machine."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Let the dataclass field defaults be the single source of truth.
|
|
77
|
+
# TOML already parses integers natively; list/bool/str fields pass through unchanged.
|
|
78
|
+
known = {f.name for f in dataclasses.fields(SamStackSettings)} - {
|
|
79
|
+
"sam_image",
|
|
80
|
+
"project_root",
|
|
81
|
+
}
|
|
82
|
+
filtered = {k: v for k, v in cfg.items() if k in known}
|
|
83
|
+
return SamStackSettings(
|
|
84
|
+
sam_image=cfg["sam_image"], project_root=project_root, **filtered
|
|
85
|
+
)
|