proxy-mock 2.10.1__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.
Files changed (42) hide show
  1. proxy_mock/__init__.py +10 -0
  2. proxy_mock/any_catcher.py +8 -0
  3. proxy_mock/api/__init__.py +0 -0
  4. proxy_mock/api/deps.py +5 -0
  5. proxy_mock/api/routes/__init__.py +0 -0
  6. proxy_mock/api/routes/configure.py +50 -0
  7. proxy_mock/api/routes/service.py +24 -0
  8. proxy_mock/api/routes/storage.py +51 -0
  9. proxy_mock/api/routes/traffic.py +79 -0
  10. proxy_mock/api/schemas.py +10 -0
  11. proxy_mock/app/__init__.py +3 -0
  12. proxy_mock/app/factory.py +97 -0
  13. proxy_mock/app/state.py +0 -0
  14. proxy_mock/client/__init__.py +4 -0
  15. proxy_mock/client/async_client.py +237 -0
  16. proxy_mock/client/client.py +174 -0
  17. proxy_mock/client/route.py +93 -0
  18. proxy_mock/client/service_endpoints.py +14 -0
  19. proxy_mock/core/__init__.py +0 -0
  20. proxy_mock/core/logging.py +16 -0
  21. proxy_mock/core/serializers.py +32 -0
  22. proxy_mock/core/settings.py +50 -0
  23. proxy_mock/core/time.py +6 -0
  24. proxy_mock/domain/__init__.py +20 -0
  25. proxy_mock/domain/constants.py +16 -0
  26. proxy_mock/domain/models.py +94 -0
  27. proxy_mock/repositories/__init__.py +0 -0
  28. proxy_mock/repositories/mock_storage.py +76 -0
  29. proxy_mock/repositories/traffic_store.py +69 -0
  30. proxy_mock/services/__init__.py +0 -0
  31. proxy_mock/services/cache_service.py +46 -0
  32. proxy_mock/services/mock_service.py +102 -0
  33. proxy_mock/services/proxy_service.py +104 -0
  34. proxy_mock/services/request_parser.py +30 -0
  35. proxy_mock/services/response_factory.py +25 -0
  36. proxy_mock/services/rule_engine.py +84 -0
  37. proxy_mock/services/traffic_service.py +26 -0
  38. proxy_mock/utils.py +145 -0
  39. proxy_mock-2.10.1.dist-info/METADATA +352 -0
  40. proxy_mock-2.10.1.dist-info/RECORD +42 -0
  41. proxy_mock-2.10.1.dist-info/WHEEL +4 -0
  42. proxy_mock-2.10.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,174 @@
1
+ from http import HTTPMethod
2
+ from typing import Any
3
+
4
+ import msgpack
5
+ from yarl import URL
6
+
7
+ from proxy_mock.client.route import Route
8
+ from proxy_mock.client.service_endpoints import Endpoints
9
+
10
+ CONFIGURE_CONTENT_TYPE = "application/octet-stream"
11
+
12
+
13
+ def _build_traffic_settings_payload(record_unknown_traffic: bool | None, max_items: int | None) -> dict:
14
+ """The endpoint accepts a partial update, so fields that were not supplied are left out."""
15
+ payload = {}
16
+ if record_unknown_traffic is not None:
17
+ payload["record_unknown_traffic"] = record_unknown_traffic
18
+ if max_items is not None:
19
+ payload["max_items"] = max_items
20
+ return payload
21
+
22
+
23
+ class ProxyMock(Route):
24
+ def get_proxy_mock(self):
25
+ return super().execute_request_and_get_response_body(HTTPMethod.GET, Endpoints.PROXY_MOCK)
26
+
27
+ def _build_mock_request_data(
28
+ self,
29
+ path: str,
30
+ body: Any = None,
31
+ headers: dict | None = None,
32
+ status_code: int | None = None,
33
+ extra_info: dict | None = None,
34
+ proxy_host: str | None = None,
35
+ timeout: float | None = None,
36
+ rules: list[dict] | None = None,
37
+ methods: list[str] | None = None,
38
+ cache_time: int | None = None,
39
+ include_body_if_none: bool = True,
40
+ **kwargs,
41
+ ) -> dict:
42
+ mock_data = {}
43
+ if include_body_if_none or body is not None:
44
+ mock_data["body"] = body
45
+ if status_code is not None:
46
+ mock_data["status_code"] = status_code
47
+ if headers is not None:
48
+ mock_data["headers"] = headers
49
+
50
+ request_data = {"path": path}
51
+ if methods is not None:
52
+ request_data["methods"] = methods
53
+ if mock_data:
54
+ request_data["mock_data"] = mock_data
55
+ if extra_info is not None:
56
+ request_data["extra_info"] = extra_info
57
+ if proxy_host is not None:
58
+ request_data["proxy_host"] = proxy_host
59
+ if timeout is not None:
60
+ request_data["timeout"] = timeout
61
+ if rules is not None:
62
+ request_data["rules"] = rules
63
+ if cache_time is not None:
64
+ request_data["cache_time"] = cache_time
65
+
66
+ request_data.update(kwargs)
67
+ return request_data
68
+
69
+ def _send_configure(self, method: HTTPMethod, payload: dict):
70
+ return super().execute_request_and_get_response_body(
71
+ method=method,
72
+ route=Endpoints.CONFIGURE_MOCK,
73
+ data=msgpack.packb(payload),
74
+ headers={"Content-Type": CONFIGURE_CONTENT_TYPE},
75
+ )
76
+
77
+ def configure_mock(
78
+ self,
79
+ path: str,
80
+ body: Any = None,
81
+ headers: dict | None = None,
82
+ status_code: int | None = None,
83
+ extra_info: dict | None = None,
84
+ proxy_host: str | None = None,
85
+ timeout: float | None = None,
86
+ rules: list[dict] | None = None,
87
+ methods: list[str] | None = None,
88
+ cache_time: int | None = None,
89
+ **kwargs,
90
+ ):
91
+ payload = self._build_mock_request_data(
92
+ path=path,
93
+ body=body,
94
+ headers=headers,
95
+ status_code=status_code,
96
+ extra_info=extra_info,
97
+ proxy_host=proxy_host,
98
+ timeout=timeout,
99
+ rules=rules,
100
+ methods=methods,
101
+ cache_time=cache_time,
102
+ include_body_if_none=True, # for configure the body may explicitly be None
103
+ **kwargs,
104
+ )
105
+ return self._send_configure(HTTPMethod.POST, payload)
106
+
107
+ def patch_mock(
108
+ self,
109
+ path: str,
110
+ body: Any = None,
111
+ headers: dict | None = None,
112
+ status_code: int | None = None,
113
+ extra_info: dict | None = None,
114
+ proxy_host: str | None = None,
115
+ timeout: float | None = None,
116
+ rules: list[dict] | None = None,
117
+ methods: list[str] | None = None,
118
+ cache_time: int | None = None,
119
+ **kwargs,
120
+ ):
121
+ payload = self._build_mock_request_data(
122
+ path=path,
123
+ body=body,
124
+ headers=headers,
125
+ status_code=status_code,
126
+ extra_info=extra_info,
127
+ proxy_host=proxy_host,
128
+ timeout=timeout,
129
+ rules=rules,
130
+ methods=methods,
131
+ cache_time=cache_time,
132
+ include_body_if_none=False, # for patch we do not send a body unless one is given
133
+ **kwargs,
134
+ )
135
+ return self._send_configure(HTTPMethod.PATCH, payload)
136
+
137
+ def get_traffic(self, path: str | None = None, method: str | None = None, limit: int | None = None):
138
+ query_params = {
139
+ **({"path": path} if path else {}),
140
+ **({"method": method} if method else {}),
141
+ **({"limit": limit} if limit is not None else {}),
142
+ }
143
+ full_path = URL().with_path(Endpoints.TRAFFIC).with_query(query_params).human_repr()
144
+ return super().execute_request_and_get_response_body(HTTPMethod.GET, full_path)
145
+
146
+ def get_storage(self, path: str | None = None):
147
+ query_params = {**({"path": path} if path else {})}
148
+ full_path = URL().with_path(Endpoints.STORAGE).with_query(query_params).human_repr()
149
+ return super().execute_request_and_get_response_body(HTTPMethod.GET, full_path)
150
+
151
+ def clean_storage(self, path: str | None = None):
152
+ query_params = {**({"path": path} if path else {})}
153
+ full_path = URL().with_path(Endpoints.STORAGE_CLEAN).with_query(query_params).human_repr()
154
+ return super().execute_request_and_get_response_body(HTTPMethod.POST, full_path)
155
+
156
+ def delete_mock(self, path: str):
157
+ full_path = URL().with_path(Endpoints.STORAGE).with_query({"path": path}).human_repr()
158
+ return super().execute_request_and_get_response_body(HTTPMethod.DELETE, full_path)
159
+
160
+ def clean_traffic(self):
161
+ return super().execute_request_and_get_response_body(HTTPMethod.POST, Endpoints.TRAFFIC_CLEAN)
162
+
163
+ def get_traffic_settings(self):
164
+ return super().execute_request_and_get_response_body(HTTPMethod.GET, Endpoints.TRAFFIC_SETTINGS)
165
+
166
+ def set_traffic_settings(self, record_unknown_traffic: bool | None = None, max_items: int | None = None):
167
+ return super().execute_request_and_get_response_body(
168
+ HTTPMethod.POST,
169
+ Endpoints.TRAFFIC_SETTINGS,
170
+ json=_build_traffic_settings_payload(record_unknown_traffic, max_items),
171
+ )
172
+
173
+ def clean_cache(self):
174
+ return super().execute_request_and_get_response_body(HTTPMethod.POST, Endpoints.CACHE_CLEAN)
@@ -0,0 +1,93 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ import requests
5
+
6
+
7
+ class ProxyMockRequestError(Exception):
8
+ """Error while performing a request to the proxy-mock server."""
9
+
10
+
11
+ class ProxyMockResponseError(Exception):
12
+ """HTTP error returned by the proxy-mock server."""
13
+
14
+
15
+ class Route:
16
+ def __init__(self, host: str, timeout: float = 10.0) -> None:
17
+ self.host = host.rstrip("/")
18
+ self.timeout = timeout
19
+ self.session = requests.Session()
20
+
21
+ def _build_url(self, command: str) -> str:
22
+ command = command.strip("/")
23
+ return f"{self.host}/{command}"
24
+
25
+ def _request(
26
+ self,
27
+ method: str,
28
+ url: str,
29
+ params: dict[str, Any] | None = None,
30
+ data: bytes | str | None = None,
31
+ json_data: dict[str, Any] | None = None,
32
+ headers: dict[str, str] | None = None,
33
+ **kwargs,
34
+ ) -> requests.Response:
35
+ try:
36
+ return self.session.request(
37
+ method=method,
38
+ url=url,
39
+ params=params,
40
+ data=data,
41
+ json=json_data,
42
+ headers=headers,
43
+ timeout=kwargs.pop("timeout", self.timeout),
44
+ **kwargs,
45
+ )
46
+ except (requests.ConnectionError, requests.Timeout) as err:
47
+ raise ProxyMockRequestError(f"Proxy-mock request error:\n{err}") from err
48
+
49
+ @staticmethod
50
+ def _parse_response_body(response: requests.Response) -> dict | str | list | bytes | None:
51
+ if not response.content:
52
+ return None
53
+
54
+ content_type = response.headers.get("Content-Type", "")
55
+ try:
56
+ if "json" in content_type:
57
+ return response.json()
58
+ if "text" in content_type:
59
+ return response.text
60
+ return response.content
61
+ except json.JSONDecodeError:
62
+ return response.text
63
+
64
+ def execute_request(self, method: str, route: str, raise_for_status: bool = False, **kwargs) -> requests.Response:
65
+ url = self._build_url(route)
66
+
67
+ # Normalise the json argument: kwargs["json"] is accepted but passed to _request as json_data
68
+ json_payload = kwargs.pop("json", None)
69
+
70
+ response = self._request(method=method, url=url, json_data=json_payload, **kwargs)
71
+
72
+ if raise_for_status and response.status_code >= 400:
73
+ raise ProxyMockResponseError(f"Proxy-mock returned HTTP {response.status_code}: {response.text}")
74
+
75
+ return response
76
+
77
+ def execute_request_and_get_response_body(
78
+ self,
79
+ method: str,
80
+ route: str,
81
+ **kwargs,
82
+ ) -> dict | str | list | bytes | None:
83
+ response = self.execute_request(method=method, route=route, **kwargs)
84
+ return self._parse_response_body(response)
85
+
86
+ def close(self) -> None:
87
+ self.session.close()
88
+
89
+ def __enter__(self):
90
+ return self
91
+
92
+ def __exit__(self, exc_type, exc, tb):
93
+ self.close()
@@ -0,0 +1,14 @@
1
+ """Routes of the proxy-mock API."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class Endpoints(StrEnum):
7
+ PROXY_MOCK = "/proxy_mock"
8
+ CONFIGURE_MOCK = "/configure_mock"
9
+ STORAGE_CLEAN = "/storage/clean"
10
+ TRAFFIC_CLEAN = "/traffic/clean"
11
+ CACHE_CLEAN = "/cache/clean"
12
+ TRAFFIC = "/traffic"
13
+ TRAFFIC_SETTINGS = "/traffic/settings"
14
+ STORAGE = "/storage"
File without changes
@@ -0,0 +1,16 @@
1
+ import logging
2
+
3
+
4
+ def configure_logger():
5
+ uvicorn_logger = logging.getLogger("uvicorn")
6
+ logger = uvicorn_logger.getChild("proxy_mock")
7
+
8
+ formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
9
+ for handler in uvicorn_logger.handlers:
10
+ handler.setFormatter(formatter)
11
+
12
+ logger.setLevel(logging.DEBUG)
13
+ return logger
14
+
15
+
16
+ app_logger = configure_logger()
@@ -0,0 +1,32 @@
1
+ import json
2
+ from typing import Any
3
+
4
+
5
+ def formatted_data(data: str | bytes):
6
+ if not data:
7
+ return None
8
+
9
+ if isinstance(data, str):
10
+ try:
11
+ return json.loads(data)
12
+ except json.JSONDecodeError:
13
+ return data
14
+
15
+ if isinstance(data, bytes):
16
+ try:
17
+ return json.loads(data)
18
+ except (json.JSONDecodeError, UnicodeDecodeError):
19
+ # latin-1 decodes any byte sequence, so no further fallback is needed.
20
+ return data.decode("latin-1")
21
+
22
+ return data
23
+
24
+
25
+ def convert_bytes_to_str(data: Any):
26
+ if isinstance(data, dict):
27
+ return {key: convert_bytes_to_str(value) for key, value in data.items()}
28
+ if isinstance(data, list):
29
+ return [convert_bytes_to_str(item) for item in data]
30
+ if isinstance(data, bytes):
31
+ return formatted_data(data)
32
+ return data
@@ -0,0 +1,50 @@
1
+ import os
2
+ import tomllib
3
+ from importlib import metadata
4
+
5
+ PACKAGE_NAME = "proxy_mock"
6
+
7
+ RECORD_UNKNOWN_TRAFFIC_ENV = "PROXY_MOCK_RECORD_UNKNOWN_TRAFFIC"
8
+ DEFAULT_RECORD_UNKNOWN_TRAFFIC = True
9
+
10
+ _TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
11
+ _FALSE_VALUES = frozenset({"0", "false", "no", "off"})
12
+
13
+
14
+ def record_unknown_traffic_default() -> bool:
15
+ """Whether to record requests that matched no mock (the 404 response) in traffic.
16
+
17
+ An unrecognised value falls back to the default, so a typo cannot silently change behaviour.
18
+ """
19
+ raw = os.getenv(RECORD_UNKNOWN_TRAFFIC_ENV, "").strip().lower()
20
+ if raw in _TRUE_VALUES:
21
+ return True
22
+ if raw in _FALSE_VALUES:
23
+ return False
24
+ return DEFAULT_RECORD_UNKNOWN_TRAFFIC
25
+
26
+
27
+ def get_version_from_pyproject(logger) -> str:
28
+ # Dev checkout or Docker image: the working directory holds proxy_mock's own pyproject.toml.
29
+ # We check the project name so that another project's pyproject.toml is not picked up
30
+ # when proxy_mock is installed as a library.
31
+ try:
32
+ with open("pyproject.toml", "rb") as file:
33
+ pyproject_data = tomllib.load(file)
34
+ project = pyproject_data.get("project") or {}
35
+ if project.get("name") == PACKAGE_NAME:
36
+ version = project["version"]
37
+ logger.info(f"Proxy-Mock version is {version}")
38
+ return version
39
+ except (OSError, tomllib.TOMLDecodeError, KeyError):
40
+ pass
41
+
42
+ # Installed as a package: take the version from the distribution metadata
43
+ try:
44
+ version = metadata.version(PACKAGE_NAME)
45
+ except metadata.PackageNotFoundError:
46
+ logger.warning("Could not determine the proxy_mock version")
47
+ return "unknown"
48
+
49
+ logger.info(f"Proxy-Mock version is {version}")
50
+ return version
@@ -0,0 +1,6 @@
1
+ from datetime import datetime
2
+ from zoneinfo import ZoneInfo
3
+
4
+
5
+ def now_msk_iso() -> str:
6
+ return datetime.now(ZoneInfo("Europe/Moscow")).isoformat()
@@ -0,0 +1,20 @@
1
+ from proxy_mock.domain.constants import ConfigureContentTypes, ParseError
2
+ from proxy_mock.domain.models import (
3
+ ConfigureMockRequestSchema,
4
+ InputRequestSchema,
5
+ MockDataSchema,
6
+ MockPathSchema,
7
+ MockRulesSchema,
8
+ RulesInputDataSchema,
9
+ )
10
+
11
+ __all__ = [
12
+ "MockDataSchema",
13
+ "RulesInputDataSchema",
14
+ "MockRulesSchema",
15
+ "MockPathSchema",
16
+ "ConfigureMockRequestSchema",
17
+ "InputRequestSchema",
18
+ "ConfigureContentTypes",
19
+ "ParseError",
20
+ ]
@@ -0,0 +1,16 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ConfigureContentTypes(str, Enum):
5
+ JSON = "application/json"
6
+ BINARY = "application/octet-stream"
7
+
8
+ @classmethod
9
+ def array(cls) -> list[str]:
10
+ return [item.value for item in cls]
11
+
12
+
13
+ class ParseError(Exception):
14
+ def __init__(self, detail: str, code: int):
15
+ self.detail = detail
16
+ self.code = code
@@ -0,0 +1,94 @@
1
+ import time
2
+ from http import HTTPMethod
3
+
4
+ from pydantic import BaseModel, Field, field_serializer, field_validator
5
+ from yarl import URL
6
+
7
+
8
+ class MockDataSchema(BaseModel):
9
+ body: dict | str | bytes | list | None = Field(None)
10
+ status_code: int = Field(200)
11
+ headers: dict | None = Field(None)
12
+
13
+ @field_serializer("headers")
14
+ def serialize_headers(self, headers: dict) -> dict:
15
+ return {str(key): str(value) for key, value in headers.items()} if headers else {}
16
+
17
+
18
+ class RulesInputDataSchema(BaseModel):
19
+ methods: list[HTTPMethod] | None = Field(None)
20
+ body: dict | bytes | None = Field(None)
21
+ headers: dict | None = Field(None)
22
+ query: dict | None = Field(None)
23
+ proxy_host: str | None = Field(None)
24
+ timeout: float | None = Field(None)
25
+
26
+ @field_serializer("headers")
27
+ def serialize_headers(self, headers: dict) -> dict:
28
+ return {str(key).lower(): str(value) for key, value in headers.items()} if headers else {}
29
+
30
+ @field_serializer("query")
31
+ def serialize_query(self, query: dict) -> dict:
32
+ return {str(key): str(value) for key, value in query.items()} if query else {}
33
+
34
+ @field_validator("proxy_host")
35
+ def check_proxy_host(cls, value):
36
+ if value and not URL(value).is_absolute():
37
+ raise ValueError("Parameter 'proxy_host' must be an absolute URL")
38
+ return value
39
+
40
+
41
+ class MockRulesSchema(BaseModel):
42
+ input_data: RulesInputDataSchema | dict = Field(RulesInputDataSchema().model_dump())
43
+ output_data: MockDataSchema | dict = Field(MockDataSchema().model_dump())
44
+ extra_info: dict | None = Field(None)
45
+ priority: int = Field(0)
46
+ timestamp: float = Field(default_factory=lambda: time.time())
47
+
48
+
49
+ class MockPathSchema(BaseModel):
50
+ methods: list[HTTPMethod] | None = Field(None)
51
+ mock_data: MockDataSchema | dict = Field(MockDataSchema().model_dump())
52
+ extra_info: dict | None = Field(None)
53
+ proxy_host: str | None = Field(None)
54
+ timeout: float | None = Field(None)
55
+ rules: list[MockRulesSchema] | None = Field(None)
56
+ cache_time: int | None = Field(None)
57
+
58
+
59
+ class ConfigureMockRequestSchema(MockPathSchema):
60
+ path: str = Field(...)
61
+
62
+ @field_validator("proxy_host")
63
+ def check_proxy_host(cls, value):
64
+ if value and not URL(value).is_absolute():
65
+ raise ValueError("Parameter 'proxy_host' must be an absolute URL")
66
+ return value
67
+
68
+ @field_serializer("path")
69
+ def serialize_path(self, path: str):
70
+ path = path.split("?")[0]
71
+ path = path.replace(" ", "")
72
+ return f"/{path}" if path and path[0] != "/" else path
73
+
74
+ @field_serializer("methods")
75
+ def serialize_methods(self, methods: list[str]):
76
+ if not methods:
77
+ return [m.value for m in HTTPMethod]
78
+ return methods
79
+
80
+
81
+ class TrafficSettingsSchema(BaseModel):
82
+ """Partial update: only the explicitly supplied fields are applied."""
83
+
84
+ record_unknown_traffic: bool | None = Field(None)
85
+ max_items: int | None = Field(None, gt=0)
86
+
87
+
88
+ class InputRequestSchema(BaseModel):
89
+ request_method: str = Field(...)
90
+ request_body: dict | list | str | None = Field(None)
91
+ request_headers: dict | None = Field(None)
92
+ request_path: str | None = Field(None)
93
+ request_query: dict | None = Field(None)
94
+ extra_info: dict | None = Field(None)
File without changes
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from copy import deepcopy
5
+
6
+ from proxy_mock.domain.models import MockDataSchema, MockPathSchema
7
+
8
+
9
+ def _normalize_path(path: str) -> str:
10
+ path = (path or "").split("?")[0].strip()
11
+ if not path.startswith("/"):
12
+ path = "/" + path
13
+ if path != "/" and path.endswith("/"):
14
+ path = path.rstrip("/")
15
+ return path
16
+
17
+
18
+ class MockStorage:
19
+ def __init__(self) -> None:
20
+ self._storage: dict[str, dict] = {}
21
+ self._lock = asyncio.Lock()
22
+
23
+ async def set_mock_data(
24
+ self,
25
+ path: str,
26
+ methods: list[str],
27
+ mock_data: dict,
28
+ extra_info: dict,
29
+ proxy_host: str | None,
30
+ timeout: float,
31
+ rules: list[dict],
32
+ cache_time: int,
33
+ ) -> dict:
34
+ normalized_path = _normalize_path(path)
35
+
36
+ payload = MockPathSchema(
37
+ methods=methods,
38
+ mock_data=MockDataSchema(**mock_data).model_dump(),
39
+ extra_info=extra_info,
40
+ proxy_host=proxy_host,
41
+ timeout=timeout,
42
+ rules=rules,
43
+ cache_time=cache_time,
44
+ ).model_dump()
45
+ payload["path"] = normalized_path
46
+
47
+ async with self._lock:
48
+ self._storage[normalized_path] = payload
49
+ return deepcopy(payload)
50
+
51
+ async def get_mock_data(self, path: str) -> dict | None:
52
+ normalized_path = _normalize_path(path)
53
+ async with self._lock:
54
+ data = self._storage.get(normalized_path)
55
+ return deepcopy(data) if data else None
56
+
57
+ async def get_storage(self) -> dict:
58
+ async with self._lock:
59
+ return deepcopy(self._storage)
60
+
61
+ async def count(self) -> int:
62
+ async with self._lock:
63
+ return len(self._storage)
64
+
65
+ async def clean_storage(self) -> bool:
66
+ async with self._lock:
67
+ self._storage.clear()
68
+ return True
69
+
70
+ async def delete_mock_data(self, path: str) -> bool:
71
+ normalized_path = _normalize_path(path)
72
+ async with self._lock:
73
+ return self._storage.pop(normalized_path, None) is not None
74
+
75
+
76
+ mock_storage = MockStorage()
@@ -0,0 +1,69 @@
1
+ import asyncio
2
+ import os
3
+ from collections import deque
4
+ from copy import deepcopy
5
+ from typing import Any
6
+
7
+ DEFAULT_TRAFFIC_MAX = 1000
8
+
9
+
10
+ def _default_max_items() -> int:
11
+ raw = os.getenv("PROXY_MOCK_TRAFFIC_MAX", "").strip()
12
+ if raw.isdigit() and int(raw) > 0:
13
+ return int(raw)
14
+ return DEFAULT_TRAFFIC_MAX
15
+
16
+
17
+ class TrafficStore:
18
+ def __init__(self, max_items: int | None = None):
19
+ self._data = deque(maxlen=max_items or _default_max_items())
20
+ self._lock = asyncio.Lock()
21
+
22
+ @property
23
+ def max_items(self) -> int:
24
+ return self._data.maxlen
25
+
26
+ async def set_max_items(self, value: int) -> None:
27
+ """Change the storage limit. Excess records are dropped, the most recent ones are kept."""
28
+ async with self._lock:
29
+ if value != self._data.maxlen:
30
+ self._data = deque(self._data, maxlen=value)
31
+
32
+ async def count(self) -> int:
33
+ async with self._lock:
34
+ return len(self._data)
35
+
36
+ async def add(self, item: dict[str, Any]) -> None:
37
+ async with self._lock:
38
+ self._data.append(item)
39
+
40
+ async def patch_last(self, patch: dict[str, Any]) -> None:
41
+ async with self._lock:
42
+ if not self._data:
43
+ return
44
+ last = dict(self._data[-1])
45
+ last.update(patch)
46
+ self._data[-1] = last
47
+
48
+ async def list(
49
+ self,
50
+ path: str | None = None,
51
+ method: str | None = None,
52
+ limit: int | None = None,
53
+ ) -> list[dict[str, Any]]:
54
+ async with self._lock:
55
+ items = list(self._data)
56
+
57
+ if path:
58
+ items = [x for x in items if x.get("request_path") == path]
59
+ if method:
60
+ method_u = method.upper()
61
+ items = [x for x in items if str(x.get("request_method", "")).upper() == method_u]
62
+ if limit is not None:
63
+ items = items[-limit:]
64
+
65
+ return deepcopy(items)
66
+
67
+ async def clear(self) -> None:
68
+ async with self._lock:
69
+ self._data.clear()
File without changes