local-web-services-python-sdk 0.1.0__tar.gz → 0.1.1__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.
Files changed (29) hide show
  1. local_web_services_python_sdk-0.1.1/.github/workflows/ci.yml +48 -0
  2. local_web_services_python_sdk-0.1.1/Makefile +36 -0
  3. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/PKG-INFO +1 -1
  4. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/pyproject.toml +2 -2
  5. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_builders/chaos.py +4 -4
  6. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_builders/iam.py +7 -7
  7. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_builders/mock.py +3 -3
  8. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_discovery/hcl.py +70 -55
  9. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_logs.py +7 -11
  10. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_resources/dynamodb.py +2 -1
  11. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_resources/sqs.py +1 -3
  12. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_transport/inprocess.py +161 -111
  13. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/fixtures.py +2 -1
  14. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/session.py +9 -9
  15. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/tests/unit/test_session_lifecycle.py +1 -0
  16. local_web_services_python_sdk-0.1.0/Makefile +0 -17
  17. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/.github/workflows/publish.yml +0 -0
  18. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/.gitignore +0 -0
  19. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/README.md +0 -0
  20. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/__init__.py +0 -0
  21. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_builders/__init__.py +0 -0
  22. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_discovery/__init__.py +0 -0
  23. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_discovery/cdk.py +0 -0
  24. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_resources/__init__.py +0 -0
  25. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_resources/s3.py +0 -0
  26. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/src/lws_testing/_transport/__init__.py +0 -0
  27. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/tests/__init__.py +0 -0
  28. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/tests/unit/__init__.py +0 -0
  29. {local_web_services_python_sdk-0.1.0 → local_web_services_python_sdk-0.1.1}/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.0
3
+ Version: 0.1.1
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.0"
3
+ version = "0.1.1"
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) -> "ChaosBuilder":
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) -> "ChaosBuilder":
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) -> "ChaosBuilder":
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) -> "ChaosBuilder":
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) -> "IamBuilder":
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) -> "IamBuilder":
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) -> "_IdentityBuilder":
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 = "*") -> "_IdentityBuilder":
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 = "*") -> "_IdentityBuilder":
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 = "*") -> "_IdentityBuilder":
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) -> "IamBuilder":
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) -> "_MockRuleBuilder":
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]) -> "MockBuilder":
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) -> "_MockRuleBuilder":
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'^\s*(?P<key>\w+)\s*=\s*(?P<value>\S+)')
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
- tables: list[dict[str, Any]] = []
39
- queues: list[dict[str, Any]] = []
40
- buckets: list[str] = []
41
- topics: list[dict[str, Any]] = []
42
- state_machines: list[dict[str, Any]] = []
43
- parameters: list[dict[str, Any]] = []
44
- secrets: list[dict[str, Any]] = []
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 == "aws_dynamodb_table":
48
- table = _build_table(attrs)
49
- if table:
50
- tables.append(table)
51
- elif resource_type == "aws_sqs_queue":
52
- queue = _build_queue(attrs)
53
- if queue:
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
- "tables": tables,
84
- "queues": queues,
85
- "buckets": buckets,
86
- "topics": topics,
87
- "state_machines": state_machines,
88
- "parameters": parameters,
89
- "secrets": secrets,
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
- opens = line.count("{")
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
+ }
@@ -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, but no matching log entry was found. "
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} to exist, but it was not found."
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
- async def start_services(
138
- spec: dict[str, Any],
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
- from lws.providers._shared.aws_chaos import AwsChaosConfig
150
- from lws.providers._shared.aws_operation_mock import AwsMockConfig
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
- dynamo_provider = SqliteDynamoProvider(data_dir=dynamo_data, tables=tables)
185
- sqs_provider = SqsProvider(queues=queues)
186
- s3_provider = S3Provider(data_dir=s3_data, buckets=buckets)
187
- sns_provider = SnsProvider(topics=topics)
188
- sf_provider = StepFunctionsProvider(state_machines=state_machines)
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
- # Allocate ephemeral ports
196
- ports: dict[str, int] = {s: _free_port() for s in service_names}
197
- mgmt_port = _free_port()
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
- # Start provider state (non-HTTP lifecycle)
238
- await dynamo_provider.start()
239
- await sqs_provider.start()
240
- await s3_provider.start()
241
- await sns_provider.start()
242
- await sf_provider.start()
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
 
@@ -26,7 +26,8 @@ Available fixtures
26
26
 
27
27
  from __future__ import annotations
28
28
 
29
- from typing import Any, Generator
29
+ from collections.abc import Generator
30
+ from typing import Any
30
31
 
31
32
  import pytest
32
33
 
@@ -7,11 +7,10 @@ import shutil
7
7
  import socket
8
8
  import tempfile
9
9
  import threading
10
+ from collections.abc import Generator
10
11
  from contextlib import contextmanager
11
12
  from pathlib import Path
12
- from typing import Any, Generator
13
-
14
- import httpx
13
+ from typing import Any
15
14
 
16
15
 
17
16
  def _free_port() -> int:
@@ -75,7 +74,7 @@ class LwsSession:
75
74
  # ── Constructors ──────────────────────────────────────────────────────────
76
75
 
77
76
  @classmethod
78
- def from_cdk(cls, project_dir: str = ".") -> "LwsSession":
77
+ def from_cdk(cls, project_dir: str = ".") -> LwsSession:
79
78
  """Create a session by discovering resources from a CDK project.
80
79
 
81
80
  Reads the synthesised cloud assembly in ``{project_dir}/cdk.out/``.
@@ -91,7 +90,7 @@ class LwsSession:
91
90
  return cls(**spec)
92
91
 
93
92
  @classmethod
94
- def from_hcl(cls, project_dir: str = ".") -> "LwsSession":
93
+ def from_hcl(cls, project_dir: str = ".") -> LwsSession:
95
94
  """Create a session by discovering resources from an HCL project.
96
95
 
97
96
  Reads ``.tf`` files in ``project_dir`` to discover tables, queues,
@@ -107,7 +106,7 @@ class LwsSession:
107
106
 
108
107
  # ── Context manager ───────────────────────────────────────────────────────
109
108
 
110
- def __enter__(self) -> "LwsSession":
109
+ def __enter__(self) -> LwsSession:
111
110
  self._start()
112
111
  return self
113
112
 
@@ -137,14 +136,15 @@ class LwsSession:
137
136
  ready.wait(timeout=30)
138
137
 
139
138
  if error_holder:
140
- raise RuntimeError(f"LwsSession failed to start: {error_holder[0]}") from error_holder[0]
139
+ exc = error_holder[0]
140
+ raise RuntimeError(f"LwsSession failed to start: {exc}") from exc
141
141
 
142
142
  async def _async_start(self, ready: threading.Event) -> None:
143
143
  """Create providers, build apps, start servers."""
144
144
  from lws_testing._transport.inprocess import start_services
145
145
 
146
- self._log_handler, self._ports, self._mgmt_port, self._servers = (
147
- await start_services(self._spec, self._data_dir)
146
+ self._log_handler, self._ports, self._mgmt_port, self._servers = await start_services(
147
+ self._spec, self._data_dir
148
148
  )
149
149
  ready.set()
150
150
 
@@ -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
 
@@ -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