overload-cli 0.1.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.
Files changed (40) hide show
  1. overload/__init__.py +3 -0
  2. overload/__main__.py +5 -0
  3. overload/cli.py +393 -0
  4. overload/collection/__init__.py +1 -0
  5. overload/collection/environment.py +23 -0
  6. overload/collection/models.py +88 -0
  7. overload/collection/parser.py +220 -0
  8. overload/collection/variables.py +84 -0
  9. overload/config_file.py +73 -0
  10. overload/engine/__init__.py +1 -0
  11. overload/engine/assertions.py +151 -0
  12. overload/engine/auth.py +87 -0
  13. overload/engine/events.py +50 -0
  14. overload/engine/http_client.py +274 -0
  15. overload/engine/load_patterns.py +730 -0
  16. overload/engine/models.py +254 -0
  17. overload/engine/rate_limiter.py +124 -0
  18. overload/engine/runner.py +86 -0
  19. overload/report/__init__.py +1 -0
  20. overload/report/exporters.py +77 -0
  21. overload/report/generator.py +71 -0
  22. overload/report/templates/report.html +369 -0
  23. overload/utils/__init__.py +1 -0
  24. overload/utils/naming.py +26 -0
  25. overload/web/__init__.py +1 -0
  26. overload/web/app.py +38 -0
  27. overload/web/routes/__init__.py +1 -0
  28. overload/web/routes/api.py +461 -0
  29. overload/web/routes/ws.py +77 -0
  30. overload/web/static/css/app.css +242 -0
  31. overload/web/static/js/app.js +241 -0
  32. overload/web/static/js/charts.js +385 -0
  33. overload/web/static/js/collection.js +344 -0
  34. overload/web/static/js/runner.js +625 -0
  35. overload/web/templates/index.html +23 -0
  36. overload_cli-0.1.0.dist-info/METADATA +267 -0
  37. overload_cli-0.1.0.dist-info/RECORD +40 -0
  38. overload_cli-0.1.0.dist-info/WHEEL +4 -0
  39. overload_cli-0.1.0.dist-info/entry_points.txt +2 -0
  40. overload_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+
6
+ import httpx
7
+
8
+ from overload.collection.models import AuthConfig, ParsedRequest, RequestBody
9
+ from overload.collection.variables import VariableContext
10
+ from overload.engine.models import RequestResult
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class HttpClient:
16
+ def __init__(
17
+ self,
18
+ timeout: float = 30.0,
19
+ verify_ssl: bool = True,
20
+ follow_redirects: bool = True,
21
+ max_connections: int = 100,
22
+ save_responses: bool = False,
23
+ ) -> None:
24
+ self._timeout = timeout
25
+ self._verify_ssl = verify_ssl
26
+ self._follow_redirects = follow_redirects
27
+ self._max_connections = max_connections
28
+ self._save_responses = save_responses
29
+ self._client: httpx.AsyncClient | None = None
30
+
31
+ async def __aenter__(self) -> HttpClient:
32
+ self._client = httpx.AsyncClient(
33
+ timeout=httpx.Timeout(self._timeout),
34
+ verify=self._verify_ssl,
35
+ follow_redirects=self._follow_redirects,
36
+ limits=httpx.Limits(
37
+ max_connections=self._max_connections,
38
+ max_keepalive_connections=self._max_connections // 2,
39
+ ),
40
+ )
41
+ return self
42
+
43
+ async def __aexit__(self, *args: object) -> None:
44
+ if self._client:
45
+ await self._client.aclose()
46
+ self._client = None
47
+
48
+ async def execute(
49
+ self,
50
+ request: ParsedRequest,
51
+ variables: VariableContext | None = None,
52
+ ) -> RequestResult:
53
+ if self._client is None:
54
+ raise RuntimeError("HttpClient must be used as an async context manager")
55
+
56
+ ctx = variables or VariableContext()
57
+
58
+ url = ctx.resolve_url(request.url_raw)
59
+ method = request.method
60
+ headers = ctx.resolve_dict(request.headers)
61
+ request_name = request.name
62
+
63
+ query_params = {
64
+ ctx.resolve(q.key): ctx.resolve(q.value)
65
+ for q in request.query_params
66
+ if not q.disabled
67
+ }
68
+
69
+ self._apply_auth(request.auth, headers, ctx, query_params)
70
+
71
+ content, content_headers = self._prepare_body(request.body, ctx)
72
+ headers.update(content_headers)
73
+
74
+ logger.debug(
75
+ "Executing %s %s (name=%s)", method, url, request_name,
76
+ )
77
+
78
+ timestamp = time.time()
79
+ t0 = time.monotonic()
80
+
81
+ try:
82
+ response = await self._client.request(
83
+ method=method,
84
+ url=url,
85
+ headers=headers,
86
+ params=query_params if query_params else None,
87
+ **content,
88
+ )
89
+ latency_ms = (time.monotonic() - t0) * 1000
90
+
91
+ logger.debug(
92
+ "%s %s -> %d (%.1fms)",
93
+ method, url, response.status_code, latency_ms,
94
+ )
95
+
96
+ resp_body = None
97
+ if self._save_responses:
98
+ try:
99
+ resp_body = response.text[:10_000]
100
+ except Exception:
101
+ resp_body = f"<binary {len(response.content)} bytes>"
102
+
103
+ return RequestResult(
104
+ request_name=request_name,
105
+ method=method,
106
+ url=url,
107
+ status_code=response.status_code,
108
+ latency_ms=latency_ms,
109
+ timestamp=timestamp,
110
+ headers_sent=dict(headers),
111
+ headers_received=dict(response.headers),
112
+ body_size_bytes=len(response.content),
113
+ response_body=resp_body,
114
+ )
115
+
116
+ except httpx.TimeoutException:
117
+ latency_ms = (time.monotonic() - t0) * 1000
118
+ logger.warning("Timeout: %s %s after %.1fms", method, url, latency_ms)
119
+ return RequestResult(
120
+ request_name=request_name,
121
+ method=method,
122
+ url=url,
123
+ status_code=0,
124
+ latency_ms=latency_ms,
125
+ timestamp=timestamp,
126
+ error="timeout",
127
+ )
128
+
129
+ except httpx.ConnectError as exc:
130
+ latency_ms = (time.monotonic() - t0) * 1000
131
+ logger.error("Connection error: %s %s - %s", method, url, exc)
132
+ return RequestResult(
133
+ request_name=request_name,
134
+ method=method,
135
+ url=url,
136
+ status_code=-1,
137
+ latency_ms=latency_ms,
138
+ timestamp=timestamp,
139
+ error=f"connection_error: {exc}",
140
+ )
141
+
142
+ except httpx.HTTPError as exc:
143
+ latency_ms = (time.monotonic() - t0) * 1000
144
+ logger.error("HTTP error: %s %s - %s", method, url, exc)
145
+ return RequestResult(
146
+ request_name=request_name,
147
+ method=method,
148
+ url=url,
149
+ status_code=-1,
150
+ latency_ms=latency_ms,
151
+ timestamp=timestamp,
152
+ error=str(exc),
153
+ )
154
+
155
+ async def prepare_collection_auth(
156
+ self,
157
+ auth: AuthConfig | None,
158
+ ctx: VariableContext,
159
+ ) -> None:
160
+ if not auth or auth.type != "oauth2":
161
+ return
162
+ if self._client is None:
163
+ raise RuntimeError("HttpClient must be used as an async context manager")
164
+ from overload.engine.auth import fetch_oauth2_token
165
+ token = await fetch_oauth2_token(auth, ctx, self._client)
166
+ ctx.set_variable("_oauth2_access_token", token)
167
+ logger.info("OAuth2: access token ready, injected into variable context")
168
+
169
+ def _apply_auth(
170
+ self,
171
+ auth: AuthConfig | None,
172
+ headers: dict[str, str],
173
+ ctx: VariableContext,
174
+ query_params: dict[str, str] | None = None,
175
+ ) -> None:
176
+ if not auth:
177
+ return
178
+
179
+ if auth.type == "bearer":
180
+ token = ctx.resolve(auth.params.get("token", ""))
181
+ if token:
182
+ headers["Authorization"] = f"Bearer {token}"
183
+
184
+ elif auth.type == "basic":
185
+ import base64
186
+ username = ctx.resolve(auth.params.get("username", ""))
187
+ password = ctx.resolve(auth.params.get("password", ""))
188
+ credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
189
+ headers["Authorization"] = f"Basic {credentials}"
190
+
191
+ elif auth.type == "apikey":
192
+ key = ctx.resolve(auth.params.get("key", ""))
193
+ value = ctx.resolve(auth.params.get("value", ""))
194
+ location = auth.params.get("in", "header")
195
+ if location == "header" and key:
196
+ headers[key] = value
197
+ elif location == "query" and key and query_params is not None:
198
+ query_params[key] = value
199
+
200
+ elif auth.type == "oauth2":
201
+ token = ctx.resolve("{{_oauth2_access_token}}")
202
+ if token and token != "{{_oauth2_access_token}}":
203
+ headers["Authorization"] = f"Bearer {token}"
204
+ else:
205
+ logger.warning(
206
+ "OAuth2: no access token found in context — "
207
+ "prepare_collection_auth() may not have been called"
208
+ )
209
+
210
+ else:
211
+ logger.warning(
212
+ "Unsupported auth type: %s — request will be sent without authentication",
213
+ auth.type,
214
+ )
215
+
216
+ def _prepare_body(
217
+ self,
218
+ body: RequestBody,
219
+ ctx: VariableContext,
220
+ ) -> tuple[dict, dict[str, str]]:
221
+ extra_headers: dict[str, str] = {}
222
+
223
+ if body.mode == "none":
224
+ return {}, extra_headers
225
+
226
+ if body.mode == "raw":
227
+ content_str = ctx.resolve(str(body.content))
228
+ if body.content_type:
229
+ extra_headers["Content-Type"] = body.content_type
230
+ return {"content": content_str}, extra_headers
231
+
232
+ if body.mode == "urlencoded":
233
+ fields = body.content if isinstance(body.content, list) else []
234
+ data = {
235
+ ctx.resolve(f["key"]): ctx.resolve(f.get("value", ""))
236
+ for f in fields
237
+ if isinstance(f, dict)
238
+ }
239
+ return {"data": data}, extra_headers
240
+
241
+ if body.mode == "formdata":
242
+ fields = body.content if isinstance(body.content, list) else []
243
+ files = {}
244
+ data = {}
245
+ for f in fields:
246
+ if not isinstance(f, dict):
247
+ continue
248
+ key = ctx.resolve(f["key"])
249
+ if f.get("type") == "file":
250
+ file_path = f.get("value", "") or f.get("src", "")
251
+ try:
252
+ files[key] = open(file_path, "rb")
253
+ except (OSError, FileNotFoundError):
254
+ logger.warning("Cannot open file: %s", file_path)
255
+ else:
256
+ data[key] = ctx.resolve(f.get("value", ""))
257
+ kwargs: dict = {}
258
+ if data:
259
+ kwargs["data"] = data
260
+ if files:
261
+ kwargs["files"] = files
262
+ return kwargs, extra_headers
263
+
264
+ if body.mode == "graphql":
265
+ import json
266
+ gql = body.content if isinstance(body.content, dict) else {}
267
+ payload = {
268
+ "query": ctx.resolve(gql.get("query", "")),
269
+ "variables": ctx.resolve(gql.get("variables", "{}")),
270
+ }
271
+ extra_headers["Content-Type"] = "application/json"
272
+ return {"content": json.dumps(payload)}, extra_headers
273
+
274
+ return {}, extra_headers