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,4 @@
1
+ from ._types import FaultConfig, Mock, MockRequest, MockResponse
2
+ from .container import MocklyContainer
3
+
4
+ __all__ = ["FaultConfig", "Mock", "MockRequest", "MockResponse", "MocklyContainer"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any