mockly-testcontainers 0.11.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,32 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class MockRequest:
|
|
7
|
+
method: str
|
|
8
|
+
path: str
|
|
9
|
+
headers: Optional[dict] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MockResponse:
|
|
14
|
+
status: int
|
|
15
|
+
body: Optional[str] = None
|
|
16
|
+
headers: Optional[dict] = None
|
|
17
|
+
delay: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Mock:
|
|
22
|
+
id: str
|
|
23
|
+
request: MockRequest
|
|
24
|
+
response: MockResponse
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FaultConfig:
|
|
29
|
+
enabled: bool
|
|
30
|
+
delay: Optional[str] = None
|
|
31
|
+
status_override: Optional[int] = None
|
|
32
|
+
error_rate: Optional[float] = None
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from testcontainers.core.container import DockerContainer
|
|
11
|
+
|
|
12
|
+
from ._types import FaultConfig, Mock
|
|
13
|
+
|
|
14
|
+
DEFAULT_IMAGE = "ghcr.io/dever-labs/mockly:latest"
|
|
15
|
+
HTTP_PORT = 8090
|
|
16
|
+
API_PORT = 9091
|
|
17
|
+
CONTAINER_CONFIG_PATH = "/config/mockly.yaml"
|
|
18
|
+
DEFAULT_CONFIG = """mockly:
|
|
19
|
+
api:
|
|
20
|
+
port: 9091
|
|
21
|
+
protocols:
|
|
22
|
+
http:
|
|
23
|
+
enabled: true
|
|
24
|
+
port: 8090
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _without_none(value: Any) -> Any:
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
return {
|
|
31
|
+
key: _without_none(item)
|
|
32
|
+
for key, item in value.items()
|
|
33
|
+
if item is not None
|
|
34
|
+
}
|
|
35
|
+
if isinstance(value, list):
|
|
36
|
+
return [_without_none(item) for item in value]
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MocklyContainer(DockerContainer):
|
|
41
|
+
def __init__(self, image: str = DEFAULT_IMAGE) -> None:
|
|
42
|
+
super().__init__(image)
|
|
43
|
+
self._config_yaml = DEFAULT_CONFIG
|
|
44
|
+
self._config_host_path: str | None = None
|
|
45
|
+
self.with_exposed_ports(HTTP_PORT, API_PORT)
|
|
46
|
+
|
|
47
|
+
def with_inline_config(self, yaml: str) -> "MocklyContainer":
|
|
48
|
+
self._config_yaml = yaml
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def _configure(self) -> None:
|
|
52
|
+
self._cleanup_config_file()
|
|
53
|
+
with tempfile.NamedTemporaryFile(
|
|
54
|
+
mode="w",
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
prefix=".mockly-tc-",
|
|
57
|
+
suffix=".yaml",
|
|
58
|
+
dir=os.getcwd(),
|
|
59
|
+
delete=False,
|
|
60
|
+
) as config_file:
|
|
61
|
+
config_file.write(self._config_yaml)
|
|
62
|
+
self._config_host_path = config_file.name
|
|
63
|
+
self.with_command(f"start -c {CONTAINER_CONFIG_PATH}")
|
|
64
|
+
self.with_volume_mapping(self._config_host_path, CONTAINER_CONFIG_PATH, "ro")
|
|
65
|
+
|
|
66
|
+
def _cleanup_config_file(self) -> None:
|
|
67
|
+
if self._config_host_path is None:
|
|
68
|
+
return
|
|
69
|
+
self.volumes.pop(self._config_host_path, None)
|
|
70
|
+
try:
|
|
71
|
+
os.unlink(self._config_host_path)
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
pass
|
|
74
|
+
self._config_host_path = None
|
|
75
|
+
|
|
76
|
+
def get_http_base(self) -> str:
|
|
77
|
+
host = self.get_container_host_ip()
|
|
78
|
+
port = self.get_exposed_port(HTTP_PORT)
|
|
79
|
+
return f"http://{host}:{port}"
|
|
80
|
+
|
|
81
|
+
def get_api_base(self) -> str:
|
|
82
|
+
host = self.get_container_host_ip()
|
|
83
|
+
port = self.get_exposed_port(API_PORT)
|
|
84
|
+
return f"http://{host}:{port}"
|
|
85
|
+
|
|
86
|
+
def _wait_ready(self, max_ms: int = 60000) -> None:
|
|
87
|
+
url = f"{self.get_api_base()}/api/protocols"
|
|
88
|
+
deadline = time.monotonic() + max_ms / 1000
|
|
89
|
+
while time.monotonic() < deadline:
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(url, timeout=2):
|
|
92
|
+
return
|
|
93
|
+
except Exception:
|
|
94
|
+
time.sleep(0.1)
|
|
95
|
+
raise TimeoutError(f"Mockly container did not become ready within {max_ms}ms")
|
|
96
|
+
|
|
97
|
+
def start(self) -> "MocklyContainer":
|
|
98
|
+
try:
|
|
99
|
+
self._configure()
|
|
100
|
+
super().start()
|
|
101
|
+
self._wait_ready()
|
|
102
|
+
return self
|
|
103
|
+
except Exception:
|
|
104
|
+
self.stop()
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
def stop(self, force: bool = True, delete_volume: bool = True) -> None:
|
|
108
|
+
try:
|
|
109
|
+
super().stop(force=force, delete_volume=delete_volume)
|
|
110
|
+
finally:
|
|
111
|
+
self._cleanup_config_file()
|
|
112
|
+
|
|
113
|
+
def add_mock(self, mock: Mock) -> None:
|
|
114
|
+
self._request(
|
|
115
|
+
"POST",
|
|
116
|
+
"/api/mocks/http",
|
|
117
|
+
_without_none(asdict(mock)),
|
|
118
|
+
expected=(200, 201),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def delete_mock(self, mock_id: str) -> None:
|
|
122
|
+
self._request("DELETE", f"/api/mocks/http/{mock_id}", expected=(204,))
|
|
123
|
+
|
|
124
|
+
def reset(self) -> None:
|
|
125
|
+
self._request("POST", "/api/reset", expected=(200,))
|
|
126
|
+
|
|
127
|
+
def activate_scenario(self, scenario_id: str) -> None:
|
|
128
|
+
self._request("POST", f"/api/scenarios/{scenario_id}/activate", expected=(200,))
|
|
129
|
+
|
|
130
|
+
def deactivate_scenario(self, scenario_id: str) -> None:
|
|
131
|
+
self._request("POST", f"/api/scenarios/{scenario_id}/deactivate", expected=(200,))
|
|
132
|
+
|
|
133
|
+
def set_fault(self, config: FaultConfig) -> None:
|
|
134
|
+
self._request(
|
|
135
|
+
"POST",
|
|
136
|
+
"/api/fault",
|
|
137
|
+
_without_none(asdict(config)),
|
|
138
|
+
expected=(200,),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def clear_fault(self) -> None:
|
|
142
|
+
self._request("DELETE", "/api/fault", expected=(200,))
|
|
143
|
+
|
|
144
|
+
def get_logs(self) -> str:
|
|
145
|
+
return self._request("GET", "/api/logs", expected=(200,)).decode()
|
|
146
|
+
|
|
147
|
+
def clear_logs(self) -> None:
|
|
148
|
+
self._request("DELETE", "/api/logs", expected=(200,))
|
|
149
|
+
|
|
150
|
+
def _request(
|
|
151
|
+
self,
|
|
152
|
+
method: str,
|
|
153
|
+
path: str,
|
|
154
|
+
body: dict | None = None,
|
|
155
|
+
expected: tuple[int, ...] = (200,),
|
|
156
|
+
) -> bytes:
|
|
157
|
+
url = f"{self.get_api_base()}{path}"
|
|
158
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
159
|
+
headers = {"Content-Type": "application/json"} if data is not None else {}
|
|
160
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(req) as resp:
|
|
163
|
+
if resp.status not in expected:
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"Unexpected status {resp.status} for {method} {path}"
|
|
166
|
+
)
|
|
167
|
+
return resp.read()
|
|
168
|
+
except urllib.error.HTTPError as exc:
|
|
169
|
+
if exc.code in expected:
|
|
170
|
+
return exc.read()
|
|
171
|
+
raise RuntimeError(
|
|
172
|
+
f"HTTP {exc.code} for {method} {path}: {exc.read().decode(errors='replace')}"
|
|
173
|
+
) from exc
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mockly-testcontainers
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Testcontainers module for Mockly — run Mockly as a Docker container in tests
|
|
5
|
+
Project-URL: Homepage, https://github.com/dever-labs/mockly/tree/main/clients/python-testcontainers
|
|
6
|
+
Project-URL: Repository, https://github.com/dever-labs/mockly
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: docker,http,integration-test,mock,mockly,testcontainers,testing
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: testcontainers>=4.0.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# mockly-testcontainers
|
|
14
|
+
|
|
15
|
+
Run Mockly in Docker-backed Python tests with `testcontainers-python`.
|
|
16
|
+
|
|
17
|
+
The package starts `ghcr.io/dever-labs/mockly:latest`, waits for the management API to be ready, and provides helpers for mocks, scenarios, faults, and logs.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python 3.10+
|
|
22
|
+
- Docker
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install mockly-testcontainers
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
from mockly_testcontainers import Mock, MockRequest, MockResponse, MocklyContainer
|
|
36
|
+
|
|
37
|
+
with MocklyContainer() as container:
|
|
38
|
+
container.add_mock(
|
|
39
|
+
Mock(
|
|
40
|
+
id="get-user",
|
|
41
|
+
request=MockRequest(method="GET", path="/users/1"),
|
|
42
|
+
response=MockResponse(status=200, body='{"id":1}'),
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
with urllib.request.urlopen(f"{container.get_http_base()}/users/1") as response:
|
|
47
|
+
assert response.status == 200
|
|
48
|
+
assert response.read().decode() == '{"id":1}'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## When to use the testcontainers module
|
|
52
|
+
|
|
53
|
+
Use `mockly-testcontainers` when you want Docker-managed lifecycle, no native binary download, or the same Mockly image in local tests and CI.
|
|
54
|
+
|
|
55
|
+
Use `mockly-driver` when you want to run the native Mockly binary directly from Python.
|
|
56
|
+
|
|
57
|
+
## Construction and configuration
|
|
58
|
+
|
|
59
|
+
`MocklyContainer` extends `testcontainers.core.container.DockerContainer`.
|
|
60
|
+
|
|
61
|
+
| API | Description |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `MocklyContainer(image=DEFAULT_IMAGE)` | Create a container using the default Mockly image, or override it with another image name. |
|
|
64
|
+
| `with_inline_config(yaml)` | Replace `/config/mockly.yaml` with inline YAML before startup. |
|
|
65
|
+
| `start()` | Start the container and wait for the management API. |
|
|
66
|
+
| `stop()` | Stop the container and clean up the generated config file. |
|
|
67
|
+
|
|
68
|
+
### Custom YAML config
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
with MocklyContainer().with_inline_config("""mockly:
|
|
72
|
+
api:
|
|
73
|
+
port: 9091
|
|
74
|
+
protocols:
|
|
75
|
+
http:
|
|
76
|
+
enabled: true
|
|
77
|
+
port: 8090
|
|
78
|
+
""") as container:
|
|
79
|
+
...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Management methods
|
|
83
|
+
|
|
84
|
+
| Method | Description |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `get_http_base()` | Base URL of the mock HTTP server. |
|
|
87
|
+
| `get_api_base()` | Base URL of the management API. |
|
|
88
|
+
| `add_mock(mock)` | Register a dynamic HTTP mock. |
|
|
89
|
+
| `delete_mock(mock_id)` | Delete a mock by ID. |
|
|
90
|
+
| `reset()` | Remove dynamic mocks, deactivate scenarios, and clear faults. |
|
|
91
|
+
| `activate_scenario(scenario_id)` | Activate a configured scenario. |
|
|
92
|
+
| `deactivate_scenario(scenario_id)` | Deactivate a configured scenario. |
|
|
93
|
+
| `set_fault(config)` | Apply a global HTTP fault. |
|
|
94
|
+
| `clear_fault()` | Remove the active fault. |
|
|
95
|
+
| `get_logs()` | Read request logs as JSON. |
|
|
96
|
+
| `clear_logs()` | Clear stored request logs. |
|
|
97
|
+
|
|
98
|
+
## Exported types
|
|
99
|
+
|
|
100
|
+
The package exports these dataclasses:
|
|
101
|
+
|
|
102
|
+
- `Mock`
|
|
103
|
+
- `MockRequest`
|
|
104
|
+
- `MockResponse`
|
|
105
|
+
- `FaultConfig`
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
mockly_testcontainers/__init__.py,sha256=ZLhoise8pYrdKkL5zSNXWNUUVYXHrhNf4WVg-n_nH5A,189
|
|
2
|
+
mockly_testcontainers/_types.py,sha256=diJ-6LPmFKRU5vuyes7Ikx3OPh5wgC6mIli3T5xgvA4,558
|
|
3
|
+
mockly_testcontainers/container.py,sha256=F8sUVYQULEw9EnKYBTNrRtdcrLgpK4Pul7K3izZB2Xs,5552
|
|
4
|
+
mockly_testcontainers-0.11.0.dist-info/METADATA,sha256=GxEz1FQ_B7H2IEE9r6SnR4sA5fYtZvp1tDHCvTceICk,3218
|
|
5
|
+
mockly_testcontainers-0.11.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
mockly_testcontainers-0.11.0.dist-info/RECORD,,
|