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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ lws_testing = lws_testing.fixtures
@@ -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
+ ]