local-web-services-python-sdk 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.
- local_web_services_python_sdk-0.1.0.dist-info/METADATA +96 -0
- local_web_services_python_sdk-0.1.0.dist-info/RECORD +21 -0
- local_web_services_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- local_web_services_python_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- lws_testing/__init__.py +26 -0
- lws_testing/_builders/__init__.py +1 -0
- lws_testing/_builders/chaos.py +56 -0
- lws_testing/_builders/iam.py +125 -0
- lws_testing/_builders/mock.py +107 -0
- lws_testing/_discovery/__init__.py +1 -0
- lws_testing/_discovery/cdk.py +115 -0
- lws_testing/_discovery/hcl.py +187 -0
- lws_testing/_logs.py +104 -0
- lws_testing/_resources/__init__.py +1 -0
- lws_testing/_resources/dynamodb.py +68 -0
- lws_testing/_resources/s3.py +69 -0
- lws_testing/_resources/sqs.py +76 -0
- lws_testing/_transport/__init__.py +1 -0
- lws_testing/_transport/inprocess.py +279 -0
- lws_testing/fixtures.py +99 -0
- lws_testing/session.py +330 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: local-web-services-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python testing SDK for local-web-services — in-process pytest fixtures and boto3 helpers
|
|
5
|
+
Project-URL: Homepage, https://local-web-services.github.io/www
|
|
6
|
+
Project-URL: Repository, https://github.com/local-web-services/local-web-services-sdk-python
|
|
7
|
+
Project-URL: Issues, https://github.com/local-web-services/local-web-services-sdk-python/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: aws,boto3,dynamodb,local,mocking,pytest,s3,sqs,testing
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: botocore>=1.34.0
|
|
21
|
+
Requires-Dist: httpx>=0.27.0
|
|
22
|
+
Requires-Dist: local-web-services>=0.17.2
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Provides-Extra: pytest
|
|
25
|
+
Requires-Dist: pytest>=8.0.0; extra == 'pytest'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# local-web-services-testing
|
|
29
|
+
|
|
30
|
+
Python testing SDK for [local-web-services](https://github.com/local-web-services/local-web-services) — in-process pytest fixtures and boto3 helpers for testing AWS applications without needing a running `ldk dev`.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install local-web-services-python-sdk
|
|
36
|
+
# or
|
|
37
|
+
uv add local-web-services-python-sdk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from lws_testing import LwsSession
|
|
44
|
+
|
|
45
|
+
# Auto-discover resources from a CDK project
|
|
46
|
+
with LwsSession.from_cdk("../my-cdk-project") as session:
|
|
47
|
+
dynamo = session.client("dynamodb")
|
|
48
|
+
dynamo.put_item(TableName="Orders", Item={"id": {"S": "1"}})
|
|
49
|
+
|
|
50
|
+
# Auto-discover resources from a Terraform project
|
|
51
|
+
with LwsSession.from_hcl("../my-terraform-project") as session:
|
|
52
|
+
s3 = session.client("s3")
|
|
53
|
+
s3.put_object(Bucket="my-bucket", Key="test.txt", Body=b"hello")
|
|
54
|
+
|
|
55
|
+
# Explicit resource declaration
|
|
56
|
+
with LwsSession(
|
|
57
|
+
tables=[{"name": "Orders", "partition_key": "id"}],
|
|
58
|
+
queues=["OrderQueue"],
|
|
59
|
+
buckets=["ReceiptsBucket"],
|
|
60
|
+
) as session:
|
|
61
|
+
table = session.dynamodb("Orders")
|
|
62
|
+
table.put({"id": {"S": "1"}, "status": {"S": "pending"}})
|
|
63
|
+
items = table.scan()
|
|
64
|
+
assert len(items) == 1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## pytest integration
|
|
68
|
+
|
|
69
|
+
The package registers pytest fixtures automatically via the `pytest11` entry point. Add a session fixture to your `conftest.py`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# conftest.py
|
|
73
|
+
import pytest
|
|
74
|
+
|
|
75
|
+
@pytest.fixture(scope="session")
|
|
76
|
+
def lws_session_spec():
|
|
77
|
+
return {
|
|
78
|
+
"tables": [{"name": "Orders", "partition_key": "id"}],
|
|
79
|
+
"queues": ["OrderQueue"],
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then use the `lws_session` fixture in your tests:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
def test_create_order(lws_session):
|
|
87
|
+
client = lws_session.client("dynamodb")
|
|
88
|
+
client.put_item(TableName="Orders", Item={"id": {"S": "42"}})
|
|
89
|
+
|
|
90
|
+
table = lws_session.dynamodb("Orders")
|
|
91
|
+
table.assert_item_exists({"id": {"S": "42"}})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
lws_testing/__init__.py,sha256=CF0kQuwZlj6Ab1bAuZWBBxV91ztYX9RqoBny8n9jPI4,715
|
|
2
|
+
lws_testing/_logs.py,sha256=RqIhgQeew06fjihCdFKTa-KtOC2g3ZHqDLVNqBaIi7c,4244
|
|
3
|
+
lws_testing/fixtures.py,sha256=ayht4LVBh_8V-hc1aFlYD7jJUVxK2PTlqSZjmHbkX-k,3042
|
|
4
|
+
lws_testing/session.py,sha256=jc6lHkhjtULFULWT8QZ-QMZ2uMobfJpPxpE61H3Lhf0,12827
|
|
5
|
+
lws_testing/_builders/__init__.py,sha256=HttkUvWQTrEoF9KZCc75qf86vtqynvQNZVrKdO6z9gc,62
|
|
6
|
+
lws_testing/_builders/chaos.py,sha256=OeWeyJ5nMrUkmO065Pzok-kycfAHDYIDyccT9MfxPkY,1899
|
|
7
|
+
lws_testing/_builders/iam.py,sha256=vuKUoiHlIcrABIivtS3p7vQOBC_mjQa3c-q_EizRZCA,4184
|
|
8
|
+
lws_testing/_builders/mock.py,sha256=MTfUR9rkK7FA1Wt34wSFelKIpng8GeJizCCOWjaOehE,3284
|
|
9
|
+
lws_testing/_discovery/__init__.py,sha256=cLKqSotYqZfhK6PImCtn9dx7DqYytJxYfxNElsUKJ-Y,52
|
|
10
|
+
lws_testing/_discovery/cdk.py,sha256=KoCDTHCh4qgpbiKrmlByhY3mW1UgSUm4Ks-yynSTNTw,3587
|
|
11
|
+
lws_testing/_discovery/hcl.py,sha256=-DrqJRbGgdFkeIMQvNxRiyBzpM88F80QMLwHKC0PZd8,6269
|
|
12
|
+
lws_testing/_resources/__init__.py,sha256=omtQNKUO7jAlH1GhwgqYMgkkYTDI_BXr5RvfCl4JDFs,68
|
|
13
|
+
lws_testing/_resources/dynamodb.py,sha256=_H_K7sQLAvilhcYJgvANETzBdogt-SMA0z89XImJdLY,2565
|
|
14
|
+
lws_testing/_resources/s3.py,sha256=d9EQAIQ6qPw1jdF5A9arjskwmj9SehkzdE9OCWHgzkQ,2670
|
|
15
|
+
lws_testing/_resources/sqs.py,sha256=N2CFWOHxQjjMzB3-_m1M1qczaBCzqbL8smr-E5TL8tU,2428
|
|
16
|
+
lws_testing/_transport/__init__.py,sha256=o87a1hyGHtaDNaR8y9BRRbe5RiQqXjLj0zATpaNowqs,62
|
|
17
|
+
lws_testing/_transport/inprocess.py,sha256=hFt-DokOREUqRbaHyYb8D3jeP8qLcVMtvg0o8uqUb5A,9821
|
|
18
|
+
local_web_services_python_sdk-0.1.0.dist-info/METADATA,sha256=ZwnWJkV0lzqH07mONnAJ_-S-tq7U17Bq4DBVIzZvI7Y,3082
|
|
19
|
+
local_web_services_python_sdk-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
20
|
+
local_web_services_python_sdk-0.1.0.dist-info/entry_points.txt,sha256=rL_6OHSdYlP7fXMZWJg1R_O_DfqSdbg2ZYaU6dLh4lM,46
|
|
21
|
+
local_web_services_python_sdk-0.1.0.dist-info/RECORD,,
|
lws_testing/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""local-web-services testing SDK.
|
|
2
|
+
|
|
3
|
+
In-process pytest fixtures and boto3 helpers for testing AWS applications
|
|
4
|
+
against real LWS service implementations without needing a running ldk dev.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from lws_testing import LwsSession
|
|
9
|
+
|
|
10
|
+
# Auto-detect CDK or HCL project in current directory
|
|
11
|
+
with LwsSession.from_cdk("../") as session: ...
|
|
12
|
+
with LwsSession.from_hcl("../") as session: ...
|
|
13
|
+
|
|
14
|
+
# Explicit resource declaration
|
|
15
|
+
with LwsSession(
|
|
16
|
+
tables=[{"name": "Orders", "partition_key": "id"}],
|
|
17
|
+
queues=["OrderQueue"],
|
|
18
|
+
buckets=["ReceiptsBucket"],
|
|
19
|
+
) as session: ...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from lws_testing.session import LwsSession
|
|
25
|
+
|
|
26
|
+
__all__ = ["LwsSession"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Fluent builders for mock, chaos, and IAM configuration."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Fluent builder for configuring chaos engineering rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChaosBuilder:
|
|
9
|
+
"""Fluent builder for AWS service chaos configuration.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
session.chaos("dynamodb").error_rate(0.3).latency(min_ms=50, max_ms=200).apply()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, service: str, mgmt_port: int) -> None:
|
|
17
|
+
self._service = service
|
|
18
|
+
self._mgmt_port = mgmt_port
|
|
19
|
+
self._config: dict = {"enabled": True}
|
|
20
|
+
|
|
21
|
+
def error_rate(self, rate: float) -> "ChaosBuilder":
|
|
22
|
+
"""Set the fraction of requests that return an error (0.0–1.0)."""
|
|
23
|
+
self._config["error_rate"] = float(rate)
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def latency(self, min_ms: int = 0, max_ms: int = 0) -> "ChaosBuilder":
|
|
27
|
+
"""Inject artificial latency into responses."""
|
|
28
|
+
self._config["latency_min_ms"] = min_ms
|
|
29
|
+
self._config["latency_max_ms"] = max_ms
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def connection_reset_rate(self, rate: float) -> "ChaosBuilder":
|
|
33
|
+
"""Set the fraction of requests that receive a connection reset (0.0–1.0)."""
|
|
34
|
+
self._config["connection_reset_rate"] = float(rate)
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def timeout_rate(self, rate: float) -> "ChaosBuilder":
|
|
38
|
+
"""Set the fraction of requests that time out (0.0–1.0)."""
|
|
39
|
+
self._config["timeout_rate"] = float(rate)
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def apply(self) -> None:
|
|
43
|
+
"""Push the chaos configuration to the management API."""
|
|
44
|
+
httpx.post(
|
|
45
|
+
f"http://127.0.0.1:{self._mgmt_port}/_ldk/chaos",
|
|
46
|
+
json={self._service: self._config},
|
|
47
|
+
timeout=5.0,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def clear(self) -> None:
|
|
51
|
+
"""Disable chaos for this service."""
|
|
52
|
+
httpx.post(
|
|
53
|
+
f"http://127.0.0.1:{self._mgmt_port}/_ldk/chaos",
|
|
54
|
+
json={self._service: {"enabled": False, "error_rate": 0.0}},
|
|
55
|
+
timeout=5.0,
|
|
56
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Fluent builder for configuring IAM authorization at runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IamBuilder:
|
|
11
|
+
"""Fluent builder for runtime IAM auth configuration.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
# Switch to enforce mode
|
|
16
|
+
session.iam.mode("enforce").apply()
|
|
17
|
+
|
|
18
|
+
# Register a read-only identity
|
|
19
|
+
session.iam.identity("readonly").allow(["dynamodb:GetItem"]).apply()
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, mgmt_port: int) -> None:
|
|
23
|
+
self._mgmt_port = mgmt_port
|
|
24
|
+
self._updates: dict[str, Any] = {}
|
|
25
|
+
self._identities: dict[str, Any] = {}
|
|
26
|
+
|
|
27
|
+
def mode(self, mode: str) -> "IamBuilder":
|
|
28
|
+
"""Set the global IAM auth mode (``"enforce"``, ``"audit"``, ``"disabled"``)."""
|
|
29
|
+
self._updates["mode"] = mode
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def default_identity(self, name: str) -> "IamBuilder":
|
|
33
|
+
"""Set the default identity used when no identity header is present."""
|
|
34
|
+
self._updates["default_identity"] = name
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def identity(self, name: str) -> "_IdentityBuilder":
|
|
38
|
+
"""Start configuring a named identity."""
|
|
39
|
+
return _IdentityBuilder(self, name)
|
|
40
|
+
|
|
41
|
+
def _register_identity(self, name: str, config: dict[str, Any]) -> None:
|
|
42
|
+
self._identities[name] = config
|
|
43
|
+
|
|
44
|
+
def apply(self) -> None:
|
|
45
|
+
"""Push all pending IAM configuration to the management API."""
|
|
46
|
+
payload: dict[str, Any] = dict(self._updates)
|
|
47
|
+
if self._identities:
|
|
48
|
+
payload["identities"] = self._identities
|
|
49
|
+
httpx.post(
|
|
50
|
+
f"http://127.0.0.1:{self._mgmt_port}/_ldk/iam-auth",
|
|
51
|
+
json=payload,
|
|
52
|
+
timeout=5.0,
|
|
53
|
+
)
|
|
54
|
+
self._updates = {}
|
|
55
|
+
self._identities = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _IdentityBuilder:
|
|
59
|
+
"""Configure a single named IAM identity."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, parent: IamBuilder, name: str) -> None:
|
|
62
|
+
self._parent = parent
|
|
63
|
+
self._name = name
|
|
64
|
+
self._inline_policies: list[dict[str, Any]] = []
|
|
65
|
+
self._boundary: dict[str, Any] | None = None
|
|
66
|
+
|
|
67
|
+
def allow(self, actions: list[str], resource: str = "*") -> "_IdentityBuilder":
|
|
68
|
+
"""Add an Allow statement to this identity's inline policy."""
|
|
69
|
+
self._inline_policies.append(
|
|
70
|
+
{
|
|
71
|
+
"name": f"inline-{len(self._inline_policies)}",
|
|
72
|
+
"document": {
|
|
73
|
+
"Version": "2012-10-17",
|
|
74
|
+
"Statement": [
|
|
75
|
+
{
|
|
76
|
+
"Effect": "Allow",
|
|
77
|
+
"Action": actions,
|
|
78
|
+
"Resource": resource,
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def deny(self, actions: list[str], resource: str = "*") -> "_IdentityBuilder":
|
|
87
|
+
"""Add an explicit Deny statement to this identity's inline policy."""
|
|
88
|
+
self._inline_policies.append(
|
|
89
|
+
{
|
|
90
|
+
"name": f"inline-deny-{len(self._inline_policies)}",
|
|
91
|
+
"document": {
|
|
92
|
+
"Version": "2012-10-17",
|
|
93
|
+
"Statement": [
|
|
94
|
+
{
|
|
95
|
+
"Effect": "Deny",
|
|
96
|
+
"Action": actions,
|
|
97
|
+
"Resource": resource,
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def boundary(self, actions: list[str], resource: str = "*") -> "_IdentityBuilder":
|
|
106
|
+
"""Set a permissions boundary for this identity."""
|
|
107
|
+
self._boundary = {
|
|
108
|
+
"Version": "2012-10-17",
|
|
109
|
+
"Statement": [
|
|
110
|
+
{
|
|
111
|
+
"Effect": "Allow",
|
|
112
|
+
"Action": actions,
|
|
113
|
+
"Resource": resource,
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def apply(self) -> "IamBuilder":
|
|
120
|
+
"""Register the identity and return the parent builder for chaining."""
|
|
121
|
+
config: dict[str, Any] = {"inline_policies": self._inline_policies}
|
|
122
|
+
if self._boundary is not None:
|
|
123
|
+
config["boundary_policy"] = self._boundary
|
|
124
|
+
self._parent._register_identity(self._name, config)
|
|
125
|
+
return self._parent
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Fluent builder for configuring AWS operation mocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MockBuilder:
|
|
11
|
+
"""Fluent builder for AWS service mock responses.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
session.mock("dynamodb").operation("PutItem").respond(
|
|
16
|
+
status=500,
|
|
17
|
+
body={"__type": "ProvisionedThroughputExceededException"},
|
|
18
|
+
)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, service: str, mgmt_port: int) -> None:
|
|
22
|
+
self._service = service
|
|
23
|
+
self._mgmt_port = mgmt_port
|
|
24
|
+
self._rules: list[dict[str, Any]] = []
|
|
25
|
+
|
|
26
|
+
def operation(self, operation_name: str) -> "_MockRuleBuilder":
|
|
27
|
+
"""Start building a mock rule for *operation_name*."""
|
|
28
|
+
return _MockRuleBuilder(self, operation_name)
|
|
29
|
+
|
|
30
|
+
def _add_rule(self, rule: dict[str, Any]) -> "MockBuilder":
|
|
31
|
+
self._rules.append(rule)
|
|
32
|
+
self._apply()
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def _apply(self) -> None:
|
|
36
|
+
"""Push the current rules to the management API."""
|
|
37
|
+
httpx.post(
|
|
38
|
+
f"http://127.0.0.1:{self._mgmt_port}/_ldk/aws-mock",
|
|
39
|
+
json={self._service: {"enabled": True, "rules": self._rules}},
|
|
40
|
+
timeout=5.0,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def clear(self) -> None:
|
|
44
|
+
"""Remove all mock rules for this service."""
|
|
45
|
+
self._rules = []
|
|
46
|
+
httpx.post(
|
|
47
|
+
f"http://127.0.0.1:{self._mgmt_port}/_ldk/aws-mock",
|
|
48
|
+
json={self._service: {"enabled": False, "rules": []}},
|
|
49
|
+
timeout=5.0,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _MockRuleBuilder:
|
|
54
|
+
"""Intermediate builder — configure a single mock rule."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, parent: MockBuilder, operation: str) -> None:
|
|
57
|
+
self._parent = parent
|
|
58
|
+
self._operation = operation
|
|
59
|
+
self._match_headers: dict[str, str] = {}
|
|
60
|
+
|
|
61
|
+
def with_header(self, name: str, value: str) -> "_MockRuleBuilder":
|
|
62
|
+
"""Add an additional header match constraint."""
|
|
63
|
+
self._match_headers[name] = value
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def respond(
|
|
67
|
+
self,
|
|
68
|
+
status: int = 200,
|
|
69
|
+
body: str | dict[str, Any] = "",
|
|
70
|
+
content_type: str = "application/json",
|
|
71
|
+
delay_ms: int = 0,
|
|
72
|
+
) -> MockBuilder:
|
|
73
|
+
"""Set the response and register the rule.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
status: HTTP status code to return.
|
|
77
|
+
body: Response body (dict will be JSON-encoded).
|
|
78
|
+
content_type: Response Content-Type header.
|
|
79
|
+
delay_ms: Artificial delay before responding.
|
|
80
|
+
"""
|
|
81
|
+
import json
|
|
82
|
+
|
|
83
|
+
if isinstance(body, dict):
|
|
84
|
+
body_str = json.dumps(body)
|
|
85
|
+
else:
|
|
86
|
+
body_str = body
|
|
87
|
+
|
|
88
|
+
rule: dict[str, Any] = {
|
|
89
|
+
"operation": self._operation,
|
|
90
|
+
"match_headers": self._match_headers,
|
|
91
|
+
"response": {
|
|
92
|
+
"status": status,
|
|
93
|
+
"content_type": content_type,
|
|
94
|
+
"delay_ms": delay_ms,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
if body_str:
|
|
98
|
+
rule["response"]["body"] = body_str
|
|
99
|
+
|
|
100
|
+
return self._parent._add_rule(rule)
|
|
101
|
+
|
|
102
|
+
def error(self, error_type: str, message: str = "", status: int = 400) -> MockBuilder:
|
|
103
|
+
"""Respond with a structured AWS error."""
|
|
104
|
+
import json
|
|
105
|
+
|
|
106
|
+
body = json.dumps({"__type": error_type, "message": message})
|
|
107
|
+
return self.respond(status=status, body=body)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Resource discovery from CDK and HCL projects."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Discover AWS resources from a CDK cloud assembly (cdk.out/)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def discover(project_dir: Path) -> dict[str, Any]:
|
|
10
|
+
"""Parse the CDK cloud assembly and return a resource spec dict.
|
|
11
|
+
|
|
12
|
+
Reads ``{project_dir}/cdk.out/`` (runs ``cdk synth`` first if absent is
|
|
13
|
+
acceptable — callers should run ``npx cdk synth`` beforehand).
|
|
14
|
+
|
|
15
|
+
Returns a dict compatible with :class:`~lws_testing.LwsSession` kwargs:
|
|
16
|
+
``tables``, ``queues``, ``buckets``, ``topics``, ``state_machines``,
|
|
17
|
+
``secrets``, ``parameters``.
|
|
18
|
+
"""
|
|
19
|
+
from lws.parser.assembly import parse_assembly
|
|
20
|
+
|
|
21
|
+
cdk_out = project_dir / "cdk.out"
|
|
22
|
+
if not cdk_out.exists():
|
|
23
|
+
raise FileNotFoundError(
|
|
24
|
+
f"CDK cloud assembly not found at {cdk_out}. "
|
|
25
|
+
"Run 'npx cdk synth' in your CDK project first."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
model = parse_assembly(cdk_out)
|
|
29
|
+
|
|
30
|
+
tables = _extract_tables(model.tables)
|
|
31
|
+
queues = _extract_queues(model.queues)
|
|
32
|
+
buckets = [b.name for b in model.buckets]
|
|
33
|
+
topics = [{"name": t.name, "arn": t.topic_arn} for t in model.topics]
|
|
34
|
+
state_machines = _extract_state_machines(model.state_machines)
|
|
35
|
+
parameters = _extract_parameters(model.ssm_parameters)
|
|
36
|
+
secrets = _extract_secrets(model.secrets)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"tables": tables,
|
|
40
|
+
"queues": queues,
|
|
41
|
+
"buckets": buckets,
|
|
42
|
+
"topics": topics,
|
|
43
|
+
"state_machines": state_machines,
|
|
44
|
+
"parameters": parameters,
|
|
45
|
+
"secrets": secrets,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_tables(dynamo_tables: list[Any]) -> list[dict[str, Any]]:
|
|
50
|
+
"""Convert DynamoTable models to table spec dicts."""
|
|
51
|
+
result = []
|
|
52
|
+
for table in dynamo_tables:
|
|
53
|
+
spec: dict[str, Any] = {"name": table.name}
|
|
54
|
+
for key in table.key_schema:
|
|
55
|
+
key_type = key.get("KeyType", "")
|
|
56
|
+
attr_name = key.get("AttributeName", "")
|
|
57
|
+
if key_type == "HASH":
|
|
58
|
+
spec["partition_key"] = attr_name
|
|
59
|
+
elif key_type == "RANGE":
|
|
60
|
+
spec["sort_key"] = attr_name
|
|
61
|
+
if "partition_key" not in spec:
|
|
62
|
+
# Skip tables without a parseable partition key
|
|
63
|
+
continue
|
|
64
|
+
result.append(spec)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_queues(sqs_queues: list[Any]) -> list[dict[str, Any]]:
|
|
69
|
+
"""Convert SqsQueue models to queue spec dicts."""
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
"name": q.name,
|
|
73
|
+
"is_fifo": q.is_fifo,
|
|
74
|
+
"visibility_timeout": q.visibility_timeout,
|
|
75
|
+
"content_based_dedup": q.content_based_dedup,
|
|
76
|
+
}
|
|
77
|
+
for q in sqs_queues
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_state_machines(state_machines: list[Any]) -> list[dict[str, Any]]:
|
|
82
|
+
"""Convert StateMachine models to state machine spec dicts."""
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
"name": sm.name,
|
|
86
|
+
"definition": sm.definition,
|
|
87
|
+
"role_arn": sm.role_arn,
|
|
88
|
+
}
|
|
89
|
+
for sm in state_machines
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _extract_parameters(ssm_parameters: list[Any]) -> list[dict[str, Any]]:
|
|
94
|
+
"""Convert SsmParameter models to parameter spec dicts."""
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
"name": p.name,
|
|
98
|
+
"value": p.value,
|
|
99
|
+
"type": p.type,
|
|
100
|
+
"description": p.description,
|
|
101
|
+
}
|
|
102
|
+
for p in ssm_parameters
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_secrets(secrets: list[Any]) -> list[dict[str, Any]]:
|
|
107
|
+
"""Convert SmSecret models to secret spec dicts."""
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
"name": s.name,
|
|
111
|
+
"description": s.description,
|
|
112
|
+
"secret_string": s.secret_string or "",
|
|
113
|
+
}
|
|
114
|
+
for s in secrets
|
|
115
|
+
]
|