local-web-services-python-sdk 0.1.0__tar.gz → 0.1.2__tar.gz
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.2/.github/workflows/ci.yml +48 -0
- local_web_services_python_sdk-0.1.2/Makefile +36 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/PKG-INFO +1 -1
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/pyproject.toml +2 -2
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_builders/chaos.py +4 -4
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_builders/iam.py +7 -7
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_builders/mock.py +3 -3
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_discovery/hcl.py +70 -55
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_logs.py +7 -11
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_resources/dynamodb.py +2 -1
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_resources/sqs.py +1 -3
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_transport/inprocess.py +161 -111
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/fixtures.py +2 -1
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/session.py +71 -9
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/tests/unit/test_session_lifecycle.py +45 -0
- local_web_services_python_sdk-0.1.0/Makefile +0 -17
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/.github/workflows/publish.yml +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/.gitignore +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/README.md +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/__init__.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_builders/__init__.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_discovery/__init__.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_discovery/cdk.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_resources/__init__.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_resources/s3.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_transport/__init__.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/tests/__init__.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/tests/unit/__init__.py +0 -0
- {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/tests/unit/test_session_imports.py +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
pull-requests: write
|
|
10
|
+
checks: write
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
lint:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: astral-sh/setup-uv@v4
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.11"
|
|
22
|
+
- run: make install
|
|
23
|
+
- run: make lint
|
|
24
|
+
- run: make format-check
|
|
25
|
+
|
|
26
|
+
analysis:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
- uses: astral-sh/setup-uv@v4
|
|
31
|
+
- uses: actions/setup-python@v5
|
|
32
|
+
with:
|
|
33
|
+
python-version: "3.11"
|
|
34
|
+
- run: make install
|
|
35
|
+
- run: make complexity
|
|
36
|
+
- run: make cpd
|
|
37
|
+
- run: make pylint
|
|
38
|
+
|
|
39
|
+
test:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v4
|
|
43
|
+
- uses: astral-sh/setup-uv@v4
|
|
44
|
+
- uses: actions/setup-python@v5
|
|
45
|
+
with:
|
|
46
|
+
python-version: "3.11"
|
|
47
|
+
- run: make install
|
|
48
|
+
- run: make test
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
.DEFAULT_GOAL := help
|
|
2
|
+
|
|
3
|
+
.PHONY: help install lint format format-check complexity cpd pylint test check
|
|
4
|
+
|
|
5
|
+
help: ## Show available targets
|
|
6
|
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
|
|
7
|
+
|
|
8
|
+
install: ## Install dependencies
|
|
9
|
+
uv sync --all-groups
|
|
10
|
+
|
|
11
|
+
lint: ## Run linter
|
|
12
|
+
uvx ruff check src tests
|
|
13
|
+
|
|
14
|
+
format: ## Auto-format code
|
|
15
|
+
uvx black src tests
|
|
16
|
+
|
|
17
|
+
format-check: ## Check formatting without changing files
|
|
18
|
+
uvx black --check src tests
|
|
19
|
+
|
|
20
|
+
complexity: ## Check cyclomatic complexity (all functions must be grade B or better)
|
|
21
|
+
@output=$$(uvx radon cc src -a -nc); \
|
|
22
|
+
echo "$$output"; \
|
|
23
|
+
echo "$$output" | grep -qE '^ +[A-Z]' && { echo "FAIL: complexity grade C or worse detected"; exit 1; } || true
|
|
24
|
+
|
|
25
|
+
cpd: ## Check for copy-pasted code (5+ similar lines)
|
|
26
|
+
@output=$$(uvx --from pylint symilar -d 5 --ignore-imports --ignore-docstrings --ignore-signatures $$(find src -name "*.py")); \
|
|
27
|
+
echo "$$output" | tail -1; \
|
|
28
|
+
echo "$$output" | tail -1 | grep -q "duplicates=0 " || { echo "$$output"; exit 1; }
|
|
29
|
+
|
|
30
|
+
pylint: ## Run pylint checks
|
|
31
|
+
uvx --from pylint pylint src/lws_testing --recursive=y
|
|
32
|
+
|
|
33
|
+
test: ## Run test suite
|
|
34
|
+
uv run pytest tests/ -v
|
|
35
|
+
|
|
36
|
+
check: lint format-check complexity cpd pylint test ## Run all checks (what CI runs)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: local-web-services-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Python testing SDK for local-web-services — in-process pytest fixtures and boto3 helpers
|
|
5
5
|
Project-URL: Homepage, https://local-web-services.github.io/www
|
|
6
6
|
Project-URL: Repository, https://github.com/local-web-services/local-web-services-sdk-python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "local-web-services-python-sdk"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Python testing SDK for local-web-services — in-process pytest fixtures and boto3 helpers"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -79,5 +79,5 @@ max-complexity = 10
|
|
|
79
79
|
disable = [
|
|
80
80
|
"E0401", "W0718", "R0801", "C0301", "C0302", "C0114", "C0115",
|
|
81
81
|
"R0903", "R0902", "R0913", "R0917", "R0904", "R0911", "R0914", "C0103",
|
|
82
|
-
"W0603", "W0621",
|
|
82
|
+
"W0603", "W0621", "C0415", "C0116", "W0212",
|
|
83
83
|
]
|
|
@@ -18,23 +18,23 @@ class ChaosBuilder:
|
|
|
18
18
|
self._mgmt_port = mgmt_port
|
|
19
19
|
self._config: dict = {"enabled": True}
|
|
20
20
|
|
|
21
|
-
def error_rate(self, rate: float) ->
|
|
21
|
+
def error_rate(self, rate: float) -> ChaosBuilder:
|
|
22
22
|
"""Set the fraction of requests that return an error (0.0–1.0)."""
|
|
23
23
|
self._config["error_rate"] = float(rate)
|
|
24
24
|
return self
|
|
25
25
|
|
|
26
|
-
def latency(self, min_ms: int = 0, max_ms: int = 0) ->
|
|
26
|
+
def latency(self, min_ms: int = 0, max_ms: int = 0) -> ChaosBuilder:
|
|
27
27
|
"""Inject artificial latency into responses."""
|
|
28
28
|
self._config["latency_min_ms"] = min_ms
|
|
29
29
|
self._config["latency_max_ms"] = max_ms
|
|
30
30
|
return self
|
|
31
31
|
|
|
32
|
-
def connection_reset_rate(self, rate: float) ->
|
|
32
|
+
def connection_reset_rate(self, rate: float) -> ChaosBuilder:
|
|
33
33
|
"""Set the fraction of requests that receive a connection reset (0.0–1.0)."""
|
|
34
34
|
self._config["connection_reset_rate"] = float(rate)
|
|
35
35
|
return self
|
|
36
36
|
|
|
37
|
-
def timeout_rate(self, rate: float) ->
|
|
37
|
+
def timeout_rate(self, rate: float) -> ChaosBuilder:
|
|
38
38
|
"""Set the fraction of requests that time out (0.0–1.0)."""
|
|
39
39
|
self._config["timeout_rate"] = float(rate)
|
|
40
40
|
return self
|
|
@@ -24,17 +24,17 @@ class IamBuilder:
|
|
|
24
24
|
self._updates: dict[str, Any] = {}
|
|
25
25
|
self._identities: dict[str, Any] = {}
|
|
26
26
|
|
|
27
|
-
def mode(self, mode: str) ->
|
|
27
|
+
def mode(self, mode: str) -> IamBuilder:
|
|
28
28
|
"""Set the global IAM auth mode (``"enforce"``, ``"audit"``, ``"disabled"``)."""
|
|
29
29
|
self._updates["mode"] = mode
|
|
30
30
|
return self
|
|
31
31
|
|
|
32
|
-
def default_identity(self, name: str) ->
|
|
32
|
+
def default_identity(self, name: str) -> IamBuilder:
|
|
33
33
|
"""Set the default identity used when no identity header is present."""
|
|
34
34
|
self._updates["default_identity"] = name
|
|
35
35
|
return self
|
|
36
36
|
|
|
37
|
-
def identity(self, name: str) ->
|
|
37
|
+
def identity(self, name: str) -> _IdentityBuilder:
|
|
38
38
|
"""Start configuring a named identity."""
|
|
39
39
|
return _IdentityBuilder(self, name)
|
|
40
40
|
|
|
@@ -64,7 +64,7 @@ class _IdentityBuilder:
|
|
|
64
64
|
self._inline_policies: list[dict[str, Any]] = []
|
|
65
65
|
self._boundary: dict[str, Any] | None = None
|
|
66
66
|
|
|
67
|
-
def allow(self, actions: list[str], resource: str = "*") ->
|
|
67
|
+
def allow(self, actions: list[str], resource: str = "*") -> _IdentityBuilder:
|
|
68
68
|
"""Add an Allow statement to this identity's inline policy."""
|
|
69
69
|
self._inline_policies.append(
|
|
70
70
|
{
|
|
@@ -83,7 +83,7 @@ class _IdentityBuilder:
|
|
|
83
83
|
)
|
|
84
84
|
return self
|
|
85
85
|
|
|
86
|
-
def deny(self, actions: list[str], resource: str = "*") ->
|
|
86
|
+
def deny(self, actions: list[str], resource: str = "*") -> _IdentityBuilder:
|
|
87
87
|
"""Add an explicit Deny statement to this identity's inline policy."""
|
|
88
88
|
self._inline_policies.append(
|
|
89
89
|
{
|
|
@@ -102,7 +102,7 @@ class _IdentityBuilder:
|
|
|
102
102
|
)
|
|
103
103
|
return self
|
|
104
104
|
|
|
105
|
-
def boundary(self, actions: list[str], resource: str = "*") ->
|
|
105
|
+
def boundary(self, actions: list[str], resource: str = "*") -> _IdentityBuilder:
|
|
106
106
|
"""Set a permissions boundary for this identity."""
|
|
107
107
|
self._boundary = {
|
|
108
108
|
"Version": "2012-10-17",
|
|
@@ -116,7 +116,7 @@ class _IdentityBuilder:
|
|
|
116
116
|
}
|
|
117
117
|
return self
|
|
118
118
|
|
|
119
|
-
def apply(self) ->
|
|
119
|
+
def apply(self) -> IamBuilder:
|
|
120
120
|
"""Register the identity and return the parent builder for chaining."""
|
|
121
121
|
config: dict[str, Any] = {"inline_policies": self._inline_policies}
|
|
122
122
|
if self._boundary is not None:
|
|
@@ -23,11 +23,11 @@ class MockBuilder:
|
|
|
23
23
|
self._mgmt_port = mgmt_port
|
|
24
24
|
self._rules: list[dict[str, Any]] = []
|
|
25
25
|
|
|
26
|
-
def operation(self, operation_name: str) ->
|
|
26
|
+
def operation(self, operation_name: str) -> _MockRuleBuilder:
|
|
27
27
|
"""Start building a mock rule for *operation_name*."""
|
|
28
28
|
return _MockRuleBuilder(self, operation_name)
|
|
29
29
|
|
|
30
|
-
def _add_rule(self, rule: dict[str, Any]) ->
|
|
30
|
+
def _add_rule(self, rule: dict[str, Any]) -> MockBuilder:
|
|
31
31
|
self._rules.append(rule)
|
|
32
32
|
self._apply()
|
|
33
33
|
return self
|
|
@@ -58,7 +58,7 @@ class _MockRuleBuilder:
|
|
|
58
58
|
self._operation = operation
|
|
59
59
|
self._match_headers: dict[str, str] = {}
|
|
60
60
|
|
|
61
|
-
def with_header(self, name: str, value: str) ->
|
|
61
|
+
def with_header(self, name: str, value: str) -> _MockRuleBuilder:
|
|
62
62
|
"""Add an additional header match constraint."""
|
|
63
63
|
self._match_headers[name] = value
|
|
64
64
|
return self
|
|
@@ -6,7 +6,6 @@ import re
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
|
|
10
9
|
# Matches: resource "aws_dynamodb_table" "logical_name" {
|
|
11
10
|
_RESOURCE_HEADER = re.compile(
|
|
12
11
|
r'resource\s+"(?P<type>[^"]+)"\s+"(?P<logical>[^"]+)"\s*\{',
|
|
@@ -14,7 +13,18 @@ _RESOURCE_HEADER = re.compile(
|
|
|
14
13
|
# Matches a simple key = "value" assignment
|
|
15
14
|
_ATTR_STR = re.compile(r'^\s*(?P<key>\w+)\s*=\s*"(?P<value>[^"]*)"')
|
|
16
15
|
# Matches a simple key = value (unquoted booleans / numbers)
|
|
17
|
-
_ATTR_BARE = re.compile(r
|
|
16
|
+
_ATTR_BARE = re.compile(r"^\s*(?P<key>\w+)\s*=\s*(?P<value>\S+)")
|
|
17
|
+
|
|
18
|
+
# Maps Terraform resource types to (collection_key, builder_function)
|
|
19
|
+
_DISPATCH: dict[str, tuple[str, Any]] = {
|
|
20
|
+
"aws_dynamodb_table": ("tables", "_build_table"),
|
|
21
|
+
"aws_sqs_queue": ("queues", "_build_queue"),
|
|
22
|
+
"aws_s3_bucket": ("buckets", "_build_bucket"),
|
|
23
|
+
"aws_sns_topic": ("topics", "_build_topic"),
|
|
24
|
+
"aws_sfn_state_machine": ("state_machines", "_build_state_machine"),
|
|
25
|
+
"aws_ssm_parameter": ("parameters", "_build_parameter"),
|
|
26
|
+
"aws_secretsmanager_secret": ("secrets", "_build_secret"),
|
|
27
|
+
}
|
|
18
28
|
|
|
19
29
|
|
|
20
30
|
def discover(project_dir: Path) -> dict[str, Any]:
|
|
@@ -35,58 +45,43 @@ def discover(project_dir: Path) -> dict[str, Any]:
|
|
|
35
45
|
for tf_file in tf_files:
|
|
36
46
|
resources.extend(_parse_tf_file(tf_file))
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
return _classify_resources(resources)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _classify_resources(
|
|
52
|
+
resources: list[tuple[str, str, dict[str, str]]],
|
|
53
|
+
) -> dict[str, list[Any]]:
|
|
54
|
+
"""Route parsed resources into typed collections."""
|
|
55
|
+
result: dict[str, list[Any]] = {
|
|
56
|
+
"tables": [],
|
|
57
|
+
"queues": [],
|
|
58
|
+
"buckets": [],
|
|
59
|
+
"topics": [],
|
|
60
|
+
"state_machines": [],
|
|
61
|
+
"parameters": [],
|
|
62
|
+
"secrets": [],
|
|
63
|
+
}
|
|
64
|
+
handlers = _get_handlers()
|
|
46
65
|
for resource_type, _logical, attrs in resources:
|
|
47
|
-
if resource_type
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
queues.append(queue)
|
|
55
|
-
elif resource_type == "aws_s3_bucket":
|
|
56
|
-
name = attrs.get("bucket") or attrs.get("name")
|
|
57
|
-
if name:
|
|
58
|
-
buckets.append(name)
|
|
59
|
-
elif resource_type == "aws_sns_topic":
|
|
60
|
-
name = attrs.get("name")
|
|
61
|
-
if name:
|
|
62
|
-
topics.append({"name": name})
|
|
63
|
-
elif resource_type == "aws_sfn_state_machine":
|
|
64
|
-
sm = _build_state_machine(attrs)
|
|
65
|
-
if sm:
|
|
66
|
-
state_machines.append(sm)
|
|
67
|
-
elif resource_type == "aws_ssm_parameter":
|
|
68
|
-
param = _build_parameter(attrs)
|
|
69
|
-
if param:
|
|
70
|
-
parameters.append(param)
|
|
71
|
-
elif resource_type == "aws_secretsmanager_secret":
|
|
72
|
-
name = attrs.get("name")
|
|
73
|
-
if name:
|
|
74
|
-
secrets.append(
|
|
75
|
-
{
|
|
76
|
-
"name": name,
|
|
77
|
-
"description": attrs.get("description", ""),
|
|
78
|
-
"secret_string": "",
|
|
79
|
-
}
|
|
80
|
-
)
|
|
66
|
+
if resource_type not in _DISPATCH:
|
|
67
|
+
continue
|
|
68
|
+
key, handler_name = _DISPATCH[resource_type]
|
|
69
|
+
item = handlers[handler_name](attrs)
|
|
70
|
+
if item is not None:
|
|
71
|
+
result[key].append(item)
|
|
72
|
+
return result
|
|
81
73
|
|
|
74
|
+
|
|
75
|
+
def _get_handlers() -> dict[str, Any]:
|
|
76
|
+
"""Return a map of handler name to callable."""
|
|
82
77
|
return {
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
78
|
+
"_build_table": _build_table,
|
|
79
|
+
"_build_queue": _build_queue,
|
|
80
|
+
"_build_bucket": _build_bucket,
|
|
81
|
+
"_build_topic": _build_topic,
|
|
82
|
+
"_build_state_machine": _build_state_machine,
|
|
83
|
+
"_build_parameter": _build_parameter,
|
|
84
|
+
"_build_secret": _build_secret,
|
|
90
85
|
}
|
|
91
86
|
|
|
92
87
|
|
|
@@ -115,11 +110,8 @@ def _collect_block(lines: list[str], start: int) -> tuple[dict[str, str], int]:
|
|
|
115
110
|
i = start
|
|
116
111
|
while i < len(lines) and depth > 0:
|
|
117
112
|
line = lines[i]
|
|
118
|
-
|
|
119
|
-
closes = line.count("}")
|
|
120
|
-
depth += opens - closes
|
|
113
|
+
depth += line.count("{") - line.count("}")
|
|
121
114
|
if depth > 1:
|
|
122
|
-
# Nested block — skip its contents
|
|
123
115
|
i += 1
|
|
124
116
|
continue
|
|
125
117
|
if depth == 0:
|
|
@@ -162,6 +154,17 @@ def _build_queue(attrs: dict[str, str]) -> dict[str, Any] | None:
|
|
|
162
154
|
return spec
|
|
163
155
|
|
|
164
156
|
|
|
157
|
+
def _build_bucket(attrs: dict[str, str]) -> str | None:
|
|
158
|
+
"""Build a bucket name from S3 resource attributes."""
|
|
159
|
+
return attrs.get("bucket") or attrs.get("name")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _build_topic(attrs: dict[str, str]) -> dict[str, str] | None:
|
|
163
|
+
"""Build a topic spec dict from SNS resource attributes."""
|
|
164
|
+
name = attrs.get("name")
|
|
165
|
+
return {"name": name} if name else None
|
|
166
|
+
|
|
167
|
+
|
|
165
168
|
def _build_state_machine(attrs: dict[str, str]) -> dict[str, Any] | None:
|
|
166
169
|
"""Build a state machine spec dict from Step Functions resource attributes."""
|
|
167
170
|
name = attrs.get("name")
|
|
@@ -185,3 +188,15 @@ def _build_parameter(attrs: dict[str, str]) -> dict[str, Any] | None:
|
|
|
185
188
|
"type": attrs.get("type", "String"),
|
|
186
189
|
"description": attrs.get("description", ""),
|
|
187
190
|
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _build_secret(attrs: dict[str, str]) -> dict[str, Any] | None:
|
|
194
|
+
"""Build a secret spec dict from Secrets Manager resource attributes."""
|
|
195
|
+
name = attrs.get("name")
|
|
196
|
+
if not name:
|
|
197
|
+
return None
|
|
198
|
+
return {
|
|
199
|
+
"name": name,
|
|
200
|
+
"description": attrs.get("description", ""),
|
|
201
|
+
"secret_string": "",
|
|
202
|
+
}
|
{local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/src/lws_testing/_logs.py
RENAMED
|
@@ -33,7 +33,7 @@ class LogCapture:
|
|
|
33
33
|
if self._handler is None:
|
|
34
34
|
return
|
|
35
35
|
full_backlog = self._handler.backlog()
|
|
36
|
-
self._entries = full_backlog[self._snapshot_len:]
|
|
36
|
+
self._entries = full_backlog[self._snapshot_len :]
|
|
37
37
|
|
|
38
38
|
# ── Access ────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
@@ -59,11 +59,11 @@ class LogCapture:
|
|
|
59
59
|
matching = [
|
|
60
60
|
e
|
|
61
61
|
for e in self._entries
|
|
62
|
-
if e.get("service", "").lower() == expected_service
|
|
63
|
-
and e.get("operation") == operation
|
|
62
|
+
if e.get("service", "").lower() == expected_service and e.get("operation") == operation
|
|
64
63
|
]
|
|
65
64
|
assert matching, (
|
|
66
|
-
f"Expected {service}.{operation} to have been called,
|
|
65
|
+
f"Expected {service}.{operation} to have been called, "
|
|
66
|
+
f"but no matching log entry was found. "
|
|
67
67
|
f"Captured entries: {self._entries}"
|
|
68
68
|
)
|
|
69
69
|
|
|
@@ -73,8 +73,7 @@ class LogCapture:
|
|
|
73
73
|
matching = [
|
|
74
74
|
e
|
|
75
75
|
for e in self._entries
|
|
76
|
-
if e.get("service", "").lower() == expected_service
|
|
77
|
-
and e.get("operation") == operation
|
|
76
|
+
if e.get("service", "").lower() == expected_service and e.get("operation") == operation
|
|
78
77
|
]
|
|
79
78
|
assert not matching, (
|
|
80
79
|
f"Expected {service}.{operation} NOT to have been called, "
|
|
@@ -84,9 +83,7 @@ class LogCapture:
|
|
|
84
83
|
def assert_no_errors(self) -> None:
|
|
85
84
|
"""Assert that no ERROR-level log entries were captured."""
|
|
86
85
|
errors = [e for e in self._entries if e.get("level") == "ERROR"]
|
|
87
|
-
assert not errors, (
|
|
88
|
-
f"Expected no ERROR log entries, but found {len(errors)}: {errors}"
|
|
89
|
-
)
|
|
86
|
+
assert not errors, f"Expected no ERROR log entries, but found {len(errors)}: {errors}"
|
|
90
87
|
|
|
91
88
|
def assert_call_count(self, service: str, operation: str, expected_count: int) -> None:
|
|
92
89
|
"""Assert that *service*.*operation* was called exactly *expected_count* times."""
|
|
@@ -94,8 +91,7 @@ class LogCapture:
|
|
|
94
91
|
actual_entries = [
|
|
95
92
|
e
|
|
96
93
|
for e in self._entries
|
|
97
|
-
if e.get("service", "").lower() == expected_service
|
|
98
|
-
and e.get("operation") == operation
|
|
94
|
+
if e.get("service", "").lower() == expected_service and e.get("operation") == operation
|
|
99
95
|
]
|
|
100
96
|
actual_count = len(actual_entries)
|
|
101
97
|
assert actual_count == expected_count, (
|
|
@@ -54,7 +54,8 @@ class DynamoDBHelper:
|
|
|
54
54
|
"""Assert that an item matching *key* exists; return the item."""
|
|
55
55
|
item = self.get(key)
|
|
56
56
|
assert item is not None, (
|
|
57
|
-
f"Expected item with key {key} in table {self._table_name!r}
|
|
57
|
+
f"Expected item with key {key} in table {self._table_name!r} "
|
|
58
|
+
"to exist, but it was not found."
|
|
58
59
|
)
|
|
59
60
|
return item
|
|
60
61
|
|
|
@@ -62,9 +62,7 @@ class SQSHelper:
|
|
|
62
62
|
QueueUrl=self._queue_url,
|
|
63
63
|
AttributeNames=["ApproximateNumberOfMessages"],
|
|
64
64
|
)
|
|
65
|
-
actual_count = int(
|
|
66
|
-
response["Attributes"].get("ApproximateNumberOfMessages", "0")
|
|
67
|
-
)
|
|
65
|
+
actual_count = int(response["Attributes"].get("ApproximateNumberOfMessages", "0"))
|
|
68
66
|
assert actual_count == expected_count, (
|
|
69
67
|
f"Expected {expected_count} message(s) in queue {self._queue_name!r}, "
|
|
70
68
|
f"but found approximately {actual_count}."
|
|
@@ -112,8 +112,8 @@ def _create_management_app(
|
|
|
112
112
|
) -> Any:
|
|
113
113
|
"""Build a FastAPI management app with reset, mock, and chaos endpoints."""
|
|
114
114
|
from fastapi import FastAPI
|
|
115
|
-
from lws.api.management import _handle_reset, create_management_router
|
|
116
115
|
from fastapi.responses import JSONResponse
|
|
116
|
+
from lws.api.management import _handle_reset, create_management_router
|
|
117
117
|
|
|
118
118
|
orchestrator = _StubOrchestrator(providers)
|
|
119
119
|
app = FastAPI(title="LWS Testing Management")
|
|
@@ -134,139 +134,189 @@ def _create_management_app(
|
|
|
134
134
|
return app
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
data_dir: Path,
|
|
140
|
-
) -> tuple[Any, dict[str, int], int, list[Any]]:
|
|
141
|
-
"""Start all LWS services in-process.
|
|
142
|
-
|
|
143
|
-
Returns:
|
|
144
|
-
Tuple of (log_handler, service_ports, management_port, servers_list).
|
|
145
|
-
``servers_list`` is a list of ``(Server, Task)`` pairs that can be
|
|
146
|
-
passed directly to :func:`stop_services`.
|
|
147
|
-
"""
|
|
137
|
+
def _setup_logging() -> Any:
|
|
138
|
+
"""Initialise WebSocketLogHandler and install it globally."""
|
|
148
139
|
from lws.logging.logger import WebSocketLogHandler, set_ws_handler
|
|
149
|
-
|
|
150
|
-
|
|
140
|
+
|
|
141
|
+
log_handler = WebSocketLogHandler()
|
|
142
|
+
set_ws_handler(log_handler)
|
|
143
|
+
return log_handler
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _convert_spec(spec: dict[str, Any]) -> dict[str, list[Any]]:
|
|
147
|
+
"""Convert raw spec dict to typed provider config lists."""
|
|
148
|
+
return {
|
|
149
|
+
"tables": [_make_table_config(t) for t in spec.get("tables", [])],
|
|
150
|
+
"queues": [_make_queue_config(q) for q in spec.get("queues", [])],
|
|
151
|
+
"buckets": [b if isinstance(b, str) else b["name"] for b in spec.get("buckets", [])],
|
|
152
|
+
"topics": [_make_topic_config(t) for t in spec.get("topics", [])],
|
|
153
|
+
"state_machines": [_make_state_machine_config(sm) for sm in spec.get("state_machines", [])],
|
|
154
|
+
"parameters": [_make_initial_parameter(p) for p in spec.get("parameters", [])],
|
|
155
|
+
"secrets": [_make_initial_secret(s) for s in spec.get("secrets", [])],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _create_providers(cfg: dict[str, list[Any]], data_dir: Path) -> dict[str, Any]:
|
|
160
|
+
"""Instantiate all service providers."""
|
|
151
161
|
from lws.providers.dynamodb.provider import SqliteDynamoProvider
|
|
152
|
-
from lws.providers.dynamodb.routes import create_dynamodb_app
|
|
153
|
-
from lws.providers.mockserver.provider import start_uvicorn_server
|
|
154
162
|
from lws.providers.s3.provider import S3Provider
|
|
155
|
-
from lws.providers.s3.routes import create_s3_app
|
|
156
|
-
from lws.providers.secretsmanager.routes import create_secretsmanager_app
|
|
157
163
|
from lws.providers.sns.provider import SnsProvider
|
|
158
|
-
from lws.providers.sns.routes import create_sns_app
|
|
159
164
|
from lws.providers.sqs.provider import SqsProvider
|
|
160
|
-
from lws.providers.sqs.routes import create_sqs_app
|
|
161
|
-
from lws.providers.ssm.routes import create_ssm_app
|
|
162
165
|
from lws.providers.stepfunctions.provider import StepFunctionsProvider
|
|
163
|
-
from lws.providers.stepfunctions.routes import create_stepfunctions_app
|
|
164
|
-
|
|
165
|
-
# Set up log handler
|
|
166
|
-
log_handler = WebSocketLogHandler()
|
|
167
|
-
set_ws_handler(log_handler)
|
|
168
|
-
|
|
169
|
-
# Convert spec to provider configs
|
|
170
|
-
tables = [_make_table_config(t) for t in spec.get("tables", [])]
|
|
171
|
-
queues = [_make_queue_config(q) for q in spec.get("queues", [])]
|
|
172
|
-
buckets = [b if isinstance(b, str) else b["name"] for b in spec.get("buckets", [])]
|
|
173
|
-
topics = [_make_topic_config(t) for t in spec.get("topics", [])]
|
|
174
|
-
state_machines = [_make_state_machine_config(sm) for sm in spec.get("state_machines", [])]
|
|
175
|
-
parameters = [_make_initial_parameter(p) for p in spec.get("parameters", [])]
|
|
176
|
-
secrets = [_make_initial_secret(s) for s in spec.get("secrets", [])]
|
|
177
166
|
|
|
178
|
-
# Create providers
|
|
179
167
|
dynamo_data = data_dir / "dynamodb"
|
|
180
168
|
dynamo_data.mkdir(parents=True, exist_ok=True)
|
|
181
169
|
s3_data = data_dir / "s3"
|
|
182
170
|
s3_data.mkdir(parents=True, exist_ok=True)
|
|
183
171
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
172
|
+
return {
|
|
173
|
+
"dynamodb": SqliteDynamoProvider(data_dir=dynamo_data, tables=cfg["tables"]),
|
|
174
|
+
"sqs": SqsProvider(queues=cfg["queues"]),
|
|
175
|
+
"s3": S3Provider(data_dir=s3_data, buckets=cfg["buckets"]),
|
|
176
|
+
"sns": SnsProvider(topics=cfg["topics"]),
|
|
177
|
+
"stepfunctions": StepFunctionsProvider(state_machines=cfg["state_machines"]),
|
|
178
|
+
}
|
|
189
179
|
|
|
190
|
-
# Create mutable chaos and mock configs per service
|
|
191
|
-
service_names = ["dynamodb", "sqs", "s3", "sns", "stepfunctions", "ssm", "secretsmanager"]
|
|
192
|
-
chaos_configs: dict[str, AwsChaosConfig] = {s: AwsChaosConfig() for s in service_names}
|
|
193
|
-
mock_configs: dict[str, AwsMockConfig] = {s: AwsMockConfig(service=s) for s in service_names}
|
|
194
180
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
181
|
+
def _build_service_apps(
|
|
182
|
+
providers: dict[str, Any],
|
|
183
|
+
ports: dict[str, int],
|
|
184
|
+
chaos_configs: dict[str, Any],
|
|
185
|
+
mock_configs: dict[str, Any],
|
|
186
|
+
cfg: dict[str, list[Any]],
|
|
187
|
+
) -> list[tuple[str, Any]]:
|
|
188
|
+
"""Build FastAPI apps for all services; return as (service_name, app) pairs."""
|
|
189
|
+
from lws.providers.dynamodb.routes import create_dynamodb_app
|
|
190
|
+
from lws.providers.s3.routes import create_s3_app
|
|
191
|
+
from lws.providers.secretsmanager.routes import create_secretsmanager_app
|
|
192
|
+
from lws.providers.sns.routes import create_sns_app
|
|
193
|
+
from lws.providers.sqs.routes import create_sqs_app
|
|
194
|
+
from lws.providers.ssm.routes import create_ssm_app
|
|
195
|
+
from lws.providers.stepfunctions.routes import create_stepfunctions_app
|
|
196
|
+
|
|
197
|
+
return [
|
|
198
|
+
(
|
|
199
|
+
"dynamodb",
|
|
200
|
+
create_dynamodb_app(
|
|
201
|
+
providers["dynamodb"],
|
|
202
|
+
chaos=chaos_configs["dynamodb"],
|
|
203
|
+
aws_mock=mock_configs["dynamodb"],
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
(
|
|
207
|
+
"sqs",
|
|
208
|
+
create_sqs_app(
|
|
209
|
+
providers["sqs"],
|
|
210
|
+
port=ports["sqs"],
|
|
211
|
+
chaos=chaos_configs["sqs"],
|
|
212
|
+
aws_mock=mock_configs["sqs"],
|
|
213
|
+
),
|
|
214
|
+
),
|
|
215
|
+
(
|
|
216
|
+
"s3",
|
|
217
|
+
create_s3_app(
|
|
218
|
+
providers["s3"],
|
|
219
|
+
chaos=chaos_configs["s3"],
|
|
220
|
+
aws_mock=mock_configs["s3"],
|
|
221
|
+
),
|
|
222
|
+
),
|
|
223
|
+
(
|
|
224
|
+
"sns",
|
|
225
|
+
create_sns_app(
|
|
226
|
+
providers["sns"],
|
|
227
|
+
chaos=chaos_configs["sns"],
|
|
228
|
+
aws_mock=mock_configs["sns"],
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
(
|
|
232
|
+
"stepfunctions",
|
|
233
|
+
create_stepfunctions_app(
|
|
234
|
+
providers["stepfunctions"],
|
|
235
|
+
chaos=chaos_configs["stepfunctions"],
|
|
236
|
+
aws_mock=mock_configs["stepfunctions"],
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
(
|
|
240
|
+
"ssm",
|
|
241
|
+
create_ssm_app(
|
|
242
|
+
initial_parameters=cfg["parameters"] or None,
|
|
243
|
+
chaos=chaos_configs["ssm"],
|
|
244
|
+
aws_mock=mock_configs["ssm"],
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
(
|
|
248
|
+
"secretsmanager",
|
|
249
|
+
create_secretsmanager_app(
|
|
250
|
+
initial_secrets=cfg["secrets"] or None,
|
|
251
|
+
chaos=chaos_configs["secretsmanager"],
|
|
252
|
+
aws_mock=mock_configs["secretsmanager"],
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def _start_providers(providers: dict[str, Any]) -> None:
|
|
259
|
+
"""Start the lifecycle of all service providers."""
|
|
260
|
+
for provider in providers.values():
|
|
261
|
+
await provider.start()
|
|
198
262
|
|
|
199
|
-
# Build FastAPI apps
|
|
200
|
-
dynamo_app = create_dynamodb_app(
|
|
201
|
-
dynamo_provider,
|
|
202
|
-
chaos=chaos_configs["dynamodb"],
|
|
203
|
-
aws_mock=mock_configs["dynamodb"],
|
|
204
|
-
)
|
|
205
|
-
sqs_app = create_sqs_app(
|
|
206
|
-
sqs_provider,
|
|
207
|
-
port=ports["sqs"],
|
|
208
|
-
chaos=chaos_configs["sqs"],
|
|
209
|
-
aws_mock=mock_configs["sqs"],
|
|
210
|
-
)
|
|
211
|
-
s3_app = create_s3_app(
|
|
212
|
-
s3_provider,
|
|
213
|
-
chaos=chaos_configs["s3"],
|
|
214
|
-
aws_mock=mock_configs["s3"],
|
|
215
|
-
)
|
|
216
|
-
sns_app = create_sns_app(
|
|
217
|
-
sns_provider,
|
|
218
|
-
chaos=chaos_configs["sns"],
|
|
219
|
-
aws_mock=mock_configs["sns"],
|
|
220
|
-
)
|
|
221
|
-
sf_app = create_stepfunctions_app(
|
|
222
|
-
sf_provider,
|
|
223
|
-
chaos=chaos_configs["stepfunctions"],
|
|
224
|
-
aws_mock=mock_configs["stepfunctions"],
|
|
225
|
-
)
|
|
226
|
-
ssm_app = create_ssm_app(
|
|
227
|
-
initial_parameters=parameters or None,
|
|
228
|
-
chaos=chaos_configs["ssm"],
|
|
229
|
-
aws_mock=mock_configs["ssm"],
|
|
230
|
-
)
|
|
231
|
-
secretsmanager_app = create_secretsmanager_app(
|
|
232
|
-
initial_secrets=secrets or None,
|
|
233
|
-
chaos=chaos_configs["secretsmanager"],
|
|
234
|
-
aws_mock=mock_configs["secretsmanager"],
|
|
235
|
-
)
|
|
236
263
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
264
|
+
async def _start_all_servers(
|
|
265
|
+
service_apps: list[tuple[str, Any]],
|
|
266
|
+
ports: dict[str, int],
|
|
267
|
+
mgmt_app: Any,
|
|
268
|
+
mgmt_port: int,
|
|
269
|
+
) -> list[Any]:
|
|
270
|
+
"""Start uvicorn servers for every service app plus the management app."""
|
|
271
|
+
from lws.providers.mockserver.provider import start_uvicorn_server
|
|
243
272
|
|
|
244
|
-
# Start uvicorn servers for each service
|
|
245
273
|
servers: list[Any] = []
|
|
246
|
-
service_apps = [
|
|
247
|
-
("dynamodb", dynamo_app),
|
|
248
|
-
("sqs", sqs_app),
|
|
249
|
-
("s3", s3_app),
|
|
250
|
-
("sns", sns_app),
|
|
251
|
-
("stepfunctions", sf_app),
|
|
252
|
-
("ssm", ssm_app),
|
|
253
|
-
("secretsmanager", secretsmanager_app),
|
|
254
|
-
]
|
|
255
274
|
for svc, app in service_apps:
|
|
256
275
|
server, task = await start_uvicorn_server(app, ports[svc], host="127.0.0.1")
|
|
257
276
|
servers.append((server, task))
|
|
258
|
-
|
|
259
|
-
# Start management app
|
|
260
|
-
all_providers: dict[str, Any] = {
|
|
261
|
-
"dynamodb": dynamo_provider,
|
|
262
|
-
"sqs": sqs_provider,
|
|
263
|
-
"s3": s3_provider,
|
|
264
|
-
"sns": sns_provider,
|
|
265
|
-
"stepfunctions": sf_provider,
|
|
266
|
-
}
|
|
267
|
-
mgmt_app = _create_management_app(all_providers, chaos_configs, mock_configs)
|
|
268
277
|
mgmt_server, mgmt_task = await start_uvicorn_server(mgmt_app, mgmt_port, host="127.0.0.1")
|
|
269
278
|
servers.append((mgmt_server, mgmt_task))
|
|
279
|
+
return servers
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
_SERVICE_NAMES = [
|
|
283
|
+
"dynamodb",
|
|
284
|
+
"sqs",
|
|
285
|
+
"s3",
|
|
286
|
+
"sns",
|
|
287
|
+
"stepfunctions",
|
|
288
|
+
"ssm",
|
|
289
|
+
"secretsmanager",
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def start_services(
|
|
294
|
+
spec: dict[str, Any],
|
|
295
|
+
data_dir: Path,
|
|
296
|
+
) -> tuple[Any, dict[str, int], int, list[Any]]:
|
|
297
|
+
"""Start all LWS services in-process.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Tuple of (log_handler, service_ports, management_port, servers_list).
|
|
301
|
+
``servers_list`` is a list of ``(Server, Task)`` pairs that can be
|
|
302
|
+
passed directly to :func:`stop_services`.
|
|
303
|
+
"""
|
|
304
|
+
from lws.providers._shared.aws_chaos import AwsChaosConfig
|
|
305
|
+
from lws.providers._shared.aws_operation_mock import AwsMockConfig
|
|
306
|
+
|
|
307
|
+
log_handler = _setup_logging()
|
|
308
|
+
cfg = _convert_spec(spec)
|
|
309
|
+
providers = _create_providers(cfg, data_dir)
|
|
310
|
+
|
|
311
|
+
chaos_configs: dict[str, Any] = {s: AwsChaosConfig() for s in _SERVICE_NAMES}
|
|
312
|
+
mock_configs: dict[str, Any] = {s: AwsMockConfig(service=s) for s in _SERVICE_NAMES}
|
|
313
|
+
ports: dict[str, int] = {s: _free_port() for s in _SERVICE_NAMES}
|
|
314
|
+
mgmt_port = _free_port()
|
|
315
|
+
|
|
316
|
+
service_apps = _build_service_apps(providers, ports, chaos_configs, mock_configs, cfg)
|
|
317
|
+
await _start_providers(providers)
|
|
318
|
+
mgmt_app = _create_management_app(providers, chaos_configs, mock_configs)
|
|
319
|
+
servers = await _start_all_servers(service_apps, ports, mgmt_app, mgmt_port)
|
|
270
320
|
|
|
271
321
|
return log_handler, ports, mgmt_port, servers
|
|
272
322
|
|
|
@@ -3,15 +3,35 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import os
|
|
6
7
|
import shutil
|
|
7
8
|
import socket
|
|
8
9
|
import tempfile
|
|
9
10
|
import threading
|
|
11
|
+
from collections.abc import Generator
|
|
10
12
|
from contextlib import contextmanager
|
|
11
13
|
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
# Maps boto3 service name → AWS SDK endpoint URL env var.
|
|
17
|
+
# Setting these redirects *any* boto3 client created in the process to the
|
|
18
|
+
# local LWS service — no production-code changes required.
|
|
19
|
+
_SERVICE_ENV_VARS: dict[str, str] = {
|
|
20
|
+
"dynamodb": "AWS_ENDPOINT_URL_DYNAMODB",
|
|
21
|
+
"sqs": "AWS_ENDPOINT_URL_SQS",
|
|
22
|
+
"s3": "AWS_ENDPOINT_URL_S3",
|
|
23
|
+
"sns": "AWS_ENDPOINT_URL_SNS",
|
|
24
|
+
"stepfunctions": "AWS_ENDPOINT_URL_STEPFUNCTIONS",
|
|
25
|
+
"ssm": "AWS_ENDPOINT_URL_SSM",
|
|
26
|
+
"secretsmanager": "AWS_ENDPOINT_URL_SECRETSMANAGER",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Credential / region overrides so boto3 never tries to contact IAM or STS.
|
|
30
|
+
_TEST_CREDENTIALS: dict[str, str] = {
|
|
31
|
+
"AWS_ACCESS_KEY_ID": "test",
|
|
32
|
+
"AWS_SECRET_ACCESS_KEY": "test",
|
|
33
|
+
"AWS_DEFAULT_REGION": "us-east-1",
|
|
34
|
+
}
|
|
15
35
|
|
|
16
36
|
|
|
17
37
|
def _free_port() -> int:
|
|
@@ -71,11 +91,12 @@ class LwsSession:
|
|
|
71
91
|
self._thread: threading.Thread | None = None
|
|
72
92
|
self._data_dir: Path | None = None
|
|
73
93
|
self._log_handler: Any = None
|
|
94
|
+
self._saved_env: dict[str, str | None] = {}
|
|
74
95
|
|
|
75
96
|
# ── Constructors ──────────────────────────────────────────────────────────
|
|
76
97
|
|
|
77
98
|
@classmethod
|
|
78
|
-
def from_cdk(cls, project_dir: str = ".") ->
|
|
99
|
+
def from_cdk(cls, project_dir: str = ".") -> LwsSession:
|
|
79
100
|
"""Create a session by discovering resources from a CDK project.
|
|
80
101
|
|
|
81
102
|
Reads the synthesised cloud assembly in ``{project_dir}/cdk.out/``.
|
|
@@ -91,7 +112,7 @@ class LwsSession:
|
|
|
91
112
|
return cls(**spec)
|
|
92
113
|
|
|
93
114
|
@classmethod
|
|
94
|
-
def from_hcl(cls, project_dir: str = ".") ->
|
|
115
|
+
def from_hcl(cls, project_dir: str = ".") -> LwsSession:
|
|
95
116
|
"""Create a session by discovering resources from an HCL project.
|
|
96
117
|
|
|
97
118
|
Reads ``.tf`` files in ``project_dir`` to discover tables, queues,
|
|
@@ -107,7 +128,7 @@ class LwsSession:
|
|
|
107
128
|
|
|
108
129
|
# ── Context manager ───────────────────────────────────────────────────────
|
|
109
130
|
|
|
110
|
-
def __enter__(self) ->
|
|
131
|
+
def __enter__(self) -> LwsSession:
|
|
111
132
|
self._start()
|
|
112
133
|
return self
|
|
113
134
|
|
|
@@ -137,19 +158,24 @@ class LwsSession:
|
|
|
137
158
|
ready.wait(timeout=30)
|
|
138
159
|
|
|
139
160
|
if error_holder:
|
|
140
|
-
|
|
161
|
+
exc = error_holder[0]
|
|
162
|
+
raise RuntimeError(f"LwsSession failed to start: {exc}") from exc
|
|
163
|
+
|
|
164
|
+
self._patch_env()
|
|
141
165
|
|
|
142
166
|
async def _async_start(self, ready: threading.Event) -> None:
|
|
143
167
|
"""Create providers, build apps, start servers."""
|
|
144
168
|
from lws_testing._transport.inprocess import start_services
|
|
145
169
|
|
|
146
|
-
self._log_handler, self._ports, self._mgmt_port, self._servers = (
|
|
147
|
-
|
|
170
|
+
self._log_handler, self._ports, self._mgmt_port, self._servers = await start_services(
|
|
171
|
+
self._spec, self._data_dir
|
|
148
172
|
)
|
|
149
173
|
ready.set()
|
|
150
174
|
|
|
151
175
|
def _stop(self) -> None:
|
|
152
176
|
"""Stop all servers and clean up temporary state."""
|
|
177
|
+
self._restore_env()
|
|
178
|
+
|
|
153
179
|
if self._loop and self._loop.is_running():
|
|
154
180
|
future = asyncio.run_coroutine_threadsafe(self._async_stop(), self._loop)
|
|
155
181
|
future.result(timeout=10)
|
|
@@ -167,6 +193,33 @@ class LwsSession:
|
|
|
167
193
|
|
|
168
194
|
await stop_services(self._servers)
|
|
169
195
|
|
|
196
|
+
# ── Environment patching ──────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def _patch_env(self) -> None:
|
|
199
|
+
"""Set AWS SDK endpoint env vars so any boto3 client hits local LWS.
|
|
200
|
+
|
|
201
|
+
This is the drop-in mechanism: production code that creates boto3
|
|
202
|
+
clients the normal way (``boto3.client("dynamodb")``) is automatically
|
|
203
|
+
redirected to the local services without any code changes.
|
|
204
|
+
"""
|
|
205
|
+
for service, env_var in _SERVICE_ENV_VARS.items():
|
|
206
|
+
port = self._ports.get(service)
|
|
207
|
+
if port:
|
|
208
|
+
self._saved_env[env_var] = os.environ.get(env_var)
|
|
209
|
+
os.environ[env_var] = f"http://127.0.0.1:{port}"
|
|
210
|
+
for key, val in _TEST_CREDENTIALS.items():
|
|
211
|
+
self._saved_env[key] = os.environ.get(key)
|
|
212
|
+
os.environ[key] = val
|
|
213
|
+
|
|
214
|
+
def _restore_env(self) -> None:
|
|
215
|
+
"""Restore all env vars that were overridden by :meth:`_patch_env`."""
|
|
216
|
+
for key, saved in self._saved_env.items():
|
|
217
|
+
if saved is None:
|
|
218
|
+
os.environ.pop(key, None)
|
|
219
|
+
else:
|
|
220
|
+
os.environ[key] = saved
|
|
221
|
+
self._saved_env = {}
|
|
222
|
+
|
|
170
223
|
# ── boto3 client factory ──────────────────────────────────────────────────
|
|
171
224
|
|
|
172
225
|
def client(self, service: str) -> Any:
|
|
@@ -279,6 +332,15 @@ class LwsSession:
|
|
|
279
332
|
|
|
280
333
|
return S3Helper(bucket_name, self.client("s3"))
|
|
281
334
|
|
|
335
|
+
def queue_url(self, queue_name: str) -> str:
|
|
336
|
+
"""Return the local SQS URL for *queue_name*.
|
|
337
|
+
|
|
338
|
+
Use this to set the queue URL env var that production code reads::
|
|
339
|
+
|
|
340
|
+
os.environ["ORDER_QUEUE_URL"] = session.queue_url("OrderQueue")
|
|
341
|
+
"""
|
|
342
|
+
return f"http://127.0.0.1:{self._ports['sqs']}/000000000000/{queue_name}"
|
|
343
|
+
|
|
282
344
|
# ── Mock / chaos / IAM builders ───────────────────────────────────────────
|
|
283
345
|
|
|
284
346
|
def mock(self, service: str) -> Any:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
|
+
|
|
6
7
|
from lws_testing import LwsSession
|
|
7
8
|
|
|
8
9
|
|
|
@@ -102,3 +103,47 @@ def test_all_services_available(session):
|
|
|
102
103
|
for svc in expected_services:
|
|
103
104
|
client = session.client(svc)
|
|
104
105
|
assert client is not None, f"Expected service {svc!r} to be available"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_vanilla_boto3_client_is_redirected_to_lws(session):
|
|
109
|
+
# Arrange — env vars are set by the session; a plain boto3 client should
|
|
110
|
+
# hit LWS without any explicit endpoint_url
|
|
111
|
+
import boto3
|
|
112
|
+
|
|
113
|
+
# Act
|
|
114
|
+
vanilla_client = boto3.client("dynamodb", region_name="us-east-1")
|
|
115
|
+
response = vanilla_client.list_tables()
|
|
116
|
+
|
|
117
|
+
# Assert — LWS responded (real AWS would also return this key, but if the
|
|
118
|
+
# env var wasn't set the call would fail with NoCredentialsError or timeout)
|
|
119
|
+
assert "TableNames" in response
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_env_vars_are_restored_after_session_closes():
|
|
123
|
+
# Arrange — capture state before any session starts
|
|
124
|
+
import os
|
|
125
|
+
|
|
126
|
+
expected_key = "AWS_ENDPOINT_URL_DYNAMODB"
|
|
127
|
+
value_before = os.environ.get(expected_key)
|
|
128
|
+
|
|
129
|
+
# Act
|
|
130
|
+
with LwsSession(tables=[{"name": "Tmp", "partition_key": "id"}]):
|
|
131
|
+
actual_during = os.environ.get(expected_key)
|
|
132
|
+
|
|
133
|
+
actual_after = os.environ.get(expected_key)
|
|
134
|
+
|
|
135
|
+
# Assert
|
|
136
|
+
assert actual_during is not None, "Expected env var to be set during session"
|
|
137
|
+
assert actual_after == value_before, "Expected env var to be restored after session"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_queue_url_returns_local_url(session):
|
|
141
|
+
# Arrange
|
|
142
|
+
expected_queue_name = "OrderQueue"
|
|
143
|
+
|
|
144
|
+
# Act
|
|
145
|
+
actual_url = session.queue_url(expected_queue_name)
|
|
146
|
+
|
|
147
|
+
# Assert
|
|
148
|
+
assert "127.0.0.1" in actual_url
|
|
149
|
+
assert expected_queue_name in actual_url
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
.PHONY: install check lint format test
|
|
2
|
-
|
|
3
|
-
install:
|
|
4
|
-
uv sync --all-groups
|
|
5
|
-
|
|
6
|
-
lint:
|
|
7
|
-
uv run ruff check src/ tests/
|
|
8
|
-
uv run pylint src/
|
|
9
|
-
|
|
10
|
-
format:
|
|
11
|
-
uv run black src/ tests/
|
|
12
|
-
uv run ruff check --fix src/ tests/
|
|
13
|
-
|
|
14
|
-
test:
|
|
15
|
-
uv run pytest tests/ -v
|
|
16
|
-
|
|
17
|
-
check: lint test
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/tests/__init__.py
RENAMED
|
File without changes
|
{local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.2}/tests/unit/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|