mockly-testcontainers 0.11.0__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.
@@ -0,0 +1,72 @@
1
+ # Binaries
2
+ /mockly
3
+ /mockly.exe
4
+ dist/
5
+
6
+ # UI build
7
+ ui/dist/
8
+ ui/node_modules/
9
+
10
+ # Assets (built by make)
11
+ assets/dist/
12
+
13
+ # Go
14
+ *.test
15
+ *.out
16
+ vendor/
17
+ coverage.txt
18
+
19
+ # IDE
20
+ .idea/
21
+ .vscode/
22
+ *.swp
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # Config (user-specific, not example)
29
+ mockly.yaml
30
+
31
+ # Node client
32
+ clients/node/node_modules/
33
+ clients/node/dist/
34
+ clients/node/bin/
35
+
36
+ # Node testcontainers
37
+ clients/node-testcontainers/node_modules/
38
+ clients/node-testcontainers/dist/
39
+
40
+ # Python client
41
+ clients/python/.pytest_cache/
42
+ clients/python/__pycache__/
43
+ clients/python/src/**/__pycache__/
44
+ clients/python/*.egg-info/
45
+ clients/python/dist/
46
+ clients/python/.venv/
47
+
48
+ # Python testcontainers
49
+ clients/python-testcontainers/.venv/
50
+ clients/python-testcontainers/dist/
51
+ clients/python-testcontainers/src/**/__pycache__/
52
+ clients/python-testcontainers/*.egg-info/
53
+
54
+ # Python cache (all locations)
55
+ **/__pycache__/
56
+ **/*.pyc
57
+
58
+ # Java client
59
+ clients/java/target/
60
+
61
+ # Java testcontainers
62
+ clients/java/testcontainers/target/
63
+
64
+ # .NET client
65
+ clients/dotnet/**/bin/
66
+ clients/dotnet/**/obj/
67
+
68
+ # Rust client
69
+ clients/rust/target/
70
+
71
+ # Rust testcontainers
72
+ clients/rust-testcontainers/target/
@@ -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,93 @@
1
+ # mockly-testcontainers
2
+
3
+ Run Mockly in Docker-backed Python tests with `testcontainers-python`.
4
+
5
+ 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.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.10+
10
+ - Docker
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ pip install mockly-testcontainers
16
+ ```
17
+
18
+ ## Quickstart
19
+
20
+ ```python
21
+ import urllib.request
22
+
23
+ from mockly_testcontainers import Mock, MockRequest, MockResponse, MocklyContainer
24
+
25
+ with MocklyContainer() as container:
26
+ container.add_mock(
27
+ Mock(
28
+ id="get-user",
29
+ request=MockRequest(method="GET", path="/users/1"),
30
+ response=MockResponse(status=200, body='{"id":1}'),
31
+ )
32
+ )
33
+
34
+ with urllib.request.urlopen(f"{container.get_http_base()}/users/1") as response:
35
+ assert response.status == 200
36
+ assert response.read().decode() == '{"id":1}'
37
+ ```
38
+
39
+ ## When to use the testcontainers module
40
+
41
+ Use `mockly-testcontainers` when you want Docker-managed lifecycle, no native binary download, or the same Mockly image in local tests and CI.
42
+
43
+ Use `mockly-driver` when you want to run the native Mockly binary directly from Python.
44
+
45
+ ## Construction and configuration
46
+
47
+ `MocklyContainer` extends `testcontainers.core.container.DockerContainer`.
48
+
49
+ | API | Description |
50
+ |---|---|
51
+ | `MocklyContainer(image=DEFAULT_IMAGE)` | Create a container using the default Mockly image, or override it with another image name. |
52
+ | `with_inline_config(yaml)` | Replace `/config/mockly.yaml` with inline YAML before startup. |
53
+ | `start()` | Start the container and wait for the management API. |
54
+ | `stop()` | Stop the container and clean up the generated config file. |
55
+
56
+ ### Custom YAML config
57
+
58
+ ```python
59
+ with MocklyContainer().with_inline_config("""mockly:
60
+ api:
61
+ port: 9091
62
+ protocols:
63
+ http:
64
+ enabled: true
65
+ port: 8090
66
+ """) as container:
67
+ ...
68
+ ```
69
+
70
+ ## Management methods
71
+
72
+ | Method | Description |
73
+ |---|---|
74
+ | `get_http_base()` | Base URL of the mock HTTP server. |
75
+ | `get_api_base()` | Base URL of the management API. |
76
+ | `add_mock(mock)` | Register a dynamic HTTP mock. |
77
+ | `delete_mock(mock_id)` | Delete a mock by ID. |
78
+ | `reset()` | Remove dynamic mocks, deactivate scenarios, and clear faults. |
79
+ | `activate_scenario(scenario_id)` | Activate a configured scenario. |
80
+ | `deactivate_scenario(scenario_id)` | Deactivate a configured scenario. |
81
+ | `set_fault(config)` | Apply a global HTTP fault. |
82
+ | `clear_fault()` | Remove the active fault. |
83
+ | `get_logs()` | Read request logs as JSON. |
84
+ | `clear_logs()` | Clear stored request logs. |
85
+
86
+ ## Exported types
87
+
88
+ The package exports these dataclasses:
89
+
90
+ - `Mock`
91
+ - `MockRequest`
92
+ - `MockResponse`
93
+ - `FaultConfig`
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mockly-testcontainers"
7
+ version = "0.11.0"
8
+ description = "Testcontainers module for Mockly — run Mockly as a Docker container in tests"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ keywords = ["mock", "testing", "http", "integration-test", "mockly", "testcontainers", "docker"]
13
+ dependencies = ["testcontainers>=4.0.0"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/dever-labs/mockly/tree/main/clients/python-testcontainers"
17
+ Repository = "https://github.com/dever-labs/mockly"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/mockly_testcontainers"]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ addopts = "-m 'not integration'"
25
+ markers = [
26
+ "integration: marks tests that require a running Docker daemon (deselect with '-m not integration')",
27
+ ]
@@ -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,113 @@
1
+ from dataclasses import asdict
2
+ from pathlib import Path
3
+ from unittest.mock import MagicMock
4
+
5
+ from mockly_testcontainers import MocklyContainer
6
+ from mockly_testcontainers._types import FaultConfig, Mock, MockRequest, MockResponse
7
+ from mockly_testcontainers.container import (
8
+ API_PORT,
9
+ CONTAINER_CONFIG_PATH,
10
+ DEFAULT_CONFIG,
11
+ DEFAULT_IMAGE,
12
+ HTTP_PORT,
13
+ )
14
+
15
+
16
+ def test_default_image():
17
+ assert DEFAULT_IMAGE == "ghcr.io/dever-labs/mockly:latest"
18
+
19
+
20
+ def test_default_ports():
21
+ assert HTTP_PORT == 8090
22
+ assert API_PORT == 9091
23
+
24
+
25
+ def test_with_inline_config():
26
+ container = MocklyContainer()
27
+ yaml = "protocols:\n http:\n enabled: true"
28
+ result = container.with_inline_config(yaml)
29
+ assert result is container
30
+ assert container._config_yaml == yaml
31
+
32
+
33
+ def test_default_config_contains_api_port():
34
+ container = MocklyContainer()
35
+ assert container._config_yaml == DEFAULT_CONFIG
36
+ assert "api:\n port: 9091" in container._config_yaml
37
+
38
+
39
+ def test_configure_sets_command_and_mounts_config():
40
+ container = MocklyContainer().with_inline_config("mockly:\n api:\n port: 9091\n")
41
+
42
+ try:
43
+ container._configure()
44
+
45
+ assert container._command == f"start -c {CONTAINER_CONFIG_PATH}"
46
+ assert container._config_host_path is not None
47
+ assert Path(container._config_host_path).read_text() == container._config_yaml
48
+ assert container.volumes[container._config_host_path] == {
49
+ "bind": CONTAINER_CONFIG_PATH,
50
+ "mode": "ro",
51
+ }
52
+ finally:
53
+ container._cleanup_config_file()
54
+
55
+
56
+ def test_mock_json_serialization():
57
+ mock = Mock(
58
+ id="test",
59
+ request=MockRequest(method="GET", path="/hello"),
60
+ response=MockResponse(status=200, body="world"),
61
+ )
62
+ data = asdict(mock)
63
+ assert data["id"] == "test"
64
+ assert data["request"]["method"] == "GET"
65
+ assert data["response"]["status"] == 200
66
+
67
+
68
+ def test_add_mock_posts_serialized_body():
69
+ container = MocklyContainer()
70
+ container._request = MagicMock()
71
+
72
+ mock = Mock(
73
+ id="test",
74
+ request=MockRequest(method="GET", path="/hello"),
75
+ response=MockResponse(status=200, body="world"),
76
+ )
77
+
78
+ container.add_mock(mock)
79
+
80
+ container._request.assert_called_once_with(
81
+ "POST",
82
+ "/api/mocks/http",
83
+ {
84
+ "id": "test",
85
+ "request": {"method": "GET", "path": "/hello"},
86
+ "response": {"status": 200, "body": "world"},
87
+ },
88
+ expected=(200, 201),
89
+ )
90
+
91
+
92
+ def test_set_fault_posts_config():
93
+ container = MocklyContainer()
94
+ container._request = MagicMock()
95
+
96
+ container.set_fault(FaultConfig(enabled=True, delay="50ms", error_rate=0.1))
97
+
98
+ container._request.assert_called_once_with(
99
+ "POST",
100
+ "/api/fault",
101
+ {"enabled": True, "delay": "50ms", "error_rate": 0.1},
102
+ expected=(200,),
103
+ )
104
+
105
+
106
+ def test_get_logs_returns_decoded_body():
107
+ container = MocklyContainer()
108
+ container._request = MagicMock(return_value=b'[{"matched_id":"users"}]')
109
+
110
+ logs = container.get_logs()
111
+
112
+ assert logs == '[{"matched_id":"users"}]'
113
+ container._request.assert_called_once_with("GET", "/api/logs", expected=(200,))
@@ -0,0 +1,66 @@
1
+ """Integration tests — require a running Docker daemon.
2
+
3
+ Run with:
4
+ pytest -m integration tests/
5
+ """
6
+
7
+ import json
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ import pytest
12
+
13
+ from mockly_testcontainers import Mock, MocklyContainer, MockRequest, MockResponse
14
+
15
+
16
+ pytestmark = pytest.mark.integration
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def container():
21
+ c = MocklyContainer()
22
+ c.start()
23
+ yield c
24
+ c.stop()
25
+
26
+
27
+ def test_smoke(container):
28
+ assert container.get_http_base().startswith("http://")
29
+ assert container.get_api_base().startswith("http://")
30
+
31
+ with urllib.request.urlopen(f"{container.get_api_base()}/api/protocols") as response:
32
+ assert response.status == 200
33
+
34
+
35
+ def test_add_mock_and_reset(container):
36
+ container.add_mock(
37
+ Mock(
38
+ id="hello-mock",
39
+ request=MockRequest(method="GET", path="/hello"),
40
+ response=MockResponse(status=200, body="world"),
41
+ )
42
+ )
43
+
44
+ with urllib.request.urlopen(f"{container.get_http_base()}/hello") as response:
45
+ assert response.status == 200
46
+ assert response.read() == b"world"
47
+
48
+ container.reset()
49
+
50
+ with pytest.raises(urllib.error.HTTPError) as exc_info:
51
+ urllib.request.urlopen(f"{container.get_http_base()}/hello")
52
+
53
+ assert exc_info.value.code != 200
54
+
55
+
56
+ def test_get_logs(container):
57
+ container.clear_logs()
58
+
59
+ try:
60
+ urllib.request.urlopen(f"{container.get_http_base()}/logging-path")
61
+ except urllib.error.HTTPError:
62
+ pass
63
+
64
+ logs = container.get_logs()
65
+ assert len(logs) > 0
66
+ json.loads(logs)