jac-loadtest 0.2.2__tar.gz → 0.3.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.
Files changed (36) hide show
  1. {jac_loadtest-0.2.2 → jac_loadtest-0.3.0}/PKG-INFO +24 -14
  2. {jac_loadtest-0.2.2 → jac_loadtest-0.3.0}/README.md +6 -8
  3. jac_loadtest-0.3.0/_jac_build.py +19 -0
  4. jac_loadtest-0.3.0/jac.toml +41 -0
  5. jac_loadtest-0.3.0/jac_loadtest_cli/bridge/auth.jac +94 -0
  6. jac_loadtest-0.3.0/jac_loadtest_cli/bridge/topology.jac +165 -0
  7. jac_loadtest-0.3.0/jac_loadtest_cli/cli.jac +152 -0
  8. jac_loadtest-0.3.0/jac_loadtest_cli/config.jac +195 -0
  9. jac_loadtest-0.3.0/jac_loadtest_cli/core/engine.jac +352 -0
  10. jac_loadtest-0.3.0/jac_loadtest_cli/core/har_parser.jac +294 -0
  11. jac_loadtest-0.3.0/jac_loadtest_cli/core/metrics.jac +220 -0
  12. jac_loadtest-0.3.0/jac_loadtest_cli/core/process_runner.jac +207 -0
  13. jac_loadtest-0.3.0/jac_loadtest_cli/output/reporter.jac +359 -0
  14. jac_loadtest-0.2.2/jac_loadtest/plugin.py → jac_loadtest-0.3.0/jac_loadtest_cli/plugin.jac +12 -16
  15. jac_loadtest-0.3.0/jac_loadtest_cli/templates/reporter_template.html +271 -0
  16. jac_loadtest-0.3.0/pyproject.toml +35 -0
  17. jac_loadtest-0.2.2/jac_loadtest/bridge/auth.py +0 -114
  18. jac_loadtest-0.2.2/jac_loadtest/bridge/topology.py +0 -201
  19. jac_loadtest-0.2.2/jac_loadtest/cli.py +0 -86
  20. jac_loadtest-0.2.2/jac_loadtest/config.py +0 -149
  21. jac_loadtest-0.2.2/jac_loadtest/core/engine.py +0 -228
  22. jac_loadtest-0.2.2/jac_loadtest/core/har_parser.py +0 -279
  23. jac_loadtest-0.2.2/jac_loadtest/core/metrics.py +0 -170
  24. jac_loadtest-0.2.2/jac_loadtest/output/reporter.py +0 -115
  25. jac_loadtest-0.2.2/jac_loadtest.egg-info/PKG-INFO +0 -97
  26. jac_loadtest-0.2.2/jac_loadtest.egg-info/SOURCES.txt +0 -21
  27. jac_loadtest-0.2.2/jac_loadtest.egg-info/dependency_links.txt +0 -1
  28. jac_loadtest-0.2.2/jac_loadtest.egg-info/entry_points.txt +0 -2
  29. jac_loadtest-0.2.2/jac_loadtest.egg-info/requires.txt +0 -9
  30. jac_loadtest-0.2.2/jac_loadtest.egg-info/top_level.txt +0 -1
  31. jac_loadtest-0.2.2/pyproject.toml +0 -38
  32. jac_loadtest-0.2.2/setup.cfg +0 -4
  33. /jac_loadtest-0.2.2/jac_loadtest/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/__init__.jac +0 -0
  34. /jac_loadtest-0.2.2/jac_loadtest/bridge/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/bridge/__init__.jac +0 -0
  35. /jac_loadtest-0.2.2/jac_loadtest/core/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/core/__init__.jac +0 -0
  36. /jac_loadtest-0.2.2/jac_loadtest/output/__init__.py → /jac_loadtest-0.3.0/jac_loadtest_cli/output/__init__.jac +0 -0
@@ -1,17 +1,29 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jac-loadtest
3
- Version: 0.2.2
4
- Summary: HAR-based load testing CLI for jac-scale applications
3
+ Version: 0.3.0
4
+ Summary: HAR-based load testing library for jac-scale applications
5
+ Author-email: Sahan Udayanga <sahanudayangaof@gmail.com>, Ravimal Ranathunga <ravimalranathunga@gmail.com>
6
+ Maintainer-email: Sahan Udayanga <sahanudayangaof@gmail.com>, Ravimal Ranathunga <ravimalranathunga@gmail.com>
7
+ License-Expression: MIT
8
+ Keywords: load-testing,har,jac,jaclang,jaseci,jac-scale,performance
5
9
  Requires-Python: >=3.12
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: jaclang==0.15.2
8
- Requires-Dist: jac-scale==0.2.16
10
+ Project-URL: Repository, https://github.com/SahanUday/jac-loadtest
11
+ Project-URL: Source, https://github.com/SahanUday/jac-loadtest/main/jac-loadtest-cli
12
+ Project-URL: Issues, https://github.com/SahanUday/jac-loadtest/issues
13
+ Requires-Dist: jac-scale>=0.2.16
14
+ Requires-Dist: aiohttp>=3.9.0,<4.0.0
9
15
  Requires-Dist: rich>=13.0.0
16
+ Requires-Dist: requests>=2.28.0
10
17
  Provides-Extra: test
11
18
  Requires-Dist: pytest>=8.0; extra == "test"
12
19
  Requires-Dist: pytest-asyncio>=0.23; extra == "test"
13
20
  Requires-Dist: pytest-mock>=3.12; extra == "test"
14
- Requires-Dist: aiohttp[speedups]>=3.9; extra == "test"
21
+ Requires-Dist: aiohttp[speedups]>=3.9.0,<4.0.0; extra == "test"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
25
+ Requires-Dist: pytest-mock>=3.12; extra == "dev"
26
+ Description-Content-Type: text/markdown
15
27
 
16
28
  # jac-loadtest
17
29
 
@@ -63,10 +75,10 @@ conda create -n load python=3.12
63
75
  conda activate load
64
76
 
65
77
  # 2. Install the package in editable mode (runtime deps only)
66
- pip install -e .
78
+ jac install -e .
67
79
 
68
80
  # 3. Also install test dependencies (when running tests)
69
- pip install -e ".[test]"
81
+ jac install -x test
70
82
 
71
83
  # 4. Verify the command is registered
72
84
  jac loadtest --help
@@ -75,17 +87,15 @@ jac loadtest --help
75
87
  ## Project Layout
76
88
 
77
89
  ```
78
- jac_loadtest/
79
- ├── plugin.py — registers `jac loadtest` via jaclang entry-points
80
- ├── cli.py — argument wiring; JacMetaImporter bootstrap
81
- ├── config.py — LoadTestConfig dataclass (three-layer resolution)
90
+ jac_loadtest_cli/
91
+ ├── plugin.jac — registers `jac loadtest` via jaclang entry-points
92
+ ├── cli.jac — argument wiring and run orchestration
93
+ ├── config.jac — LoadTestConfig dataclass (three-layer resolution)
82
94
  ├── core/ — HAR parser, load engine, metrics (no jac-scale knowledge)
83
95
  ├── bridge/ — jac-scale-aware adapters (auth, topology)
84
96
  └── output/ — console, JSON, HTML reporters
85
97
  ```
86
98
 
87
- The hard boundary between `core/` and `bridge/` is what keeps the eventual migration to `jac-scale[loadtest]` a file move rather than a rewrite.
88
-
89
99
  ## HAR Compatibility
90
100
 
91
101
  Tested with HAR **1.1** and **1.2** (the format exported by Chrome DevTools, Firefox, Postman, and Insomnia). Files from other versions are parsed with a warning — open an issue if something breaks.
@@ -48,10 +48,10 @@ conda create -n load python=3.12
48
48
  conda activate load
49
49
 
50
50
  # 2. Install the package in editable mode (runtime deps only)
51
- pip install -e .
51
+ jac install -e .
52
52
 
53
53
  # 3. Also install test dependencies (when running tests)
54
- pip install -e ".[test]"
54
+ jac install -x test
55
55
 
56
56
  # 4. Verify the command is registered
57
57
  jac loadtest --help
@@ -60,17 +60,15 @@ jac loadtest --help
60
60
  ## Project Layout
61
61
 
62
62
  ```
63
- jac_loadtest/
64
- ├── plugin.py — registers `jac loadtest` via jaclang entry-points
65
- ├── cli.py — argument wiring; JacMetaImporter bootstrap
66
- ├── config.py — LoadTestConfig dataclass (three-layer resolution)
63
+ jac_loadtest_cli/
64
+ ├── plugin.jac — registers `jac loadtest` via jaclang entry-points
65
+ ├── cli.jac — argument wiring and run orchestration
66
+ ├── config.jac — LoadTestConfig dataclass (three-layer resolution)
67
67
  ├── core/ — HAR parser, load engine, metrics (no jac-scale knowledge)
68
68
  ├── bridge/ — jac-scale-aware adapters (auth, topology)
69
69
  └── output/ — console, JSON, HTML reporters
70
70
  ```
71
71
 
72
- The hard boundary between `core/` and `bridge/` is what keeps the eventual migration to `jac-scale[loadtest]` a file move rather than a rewrite.
73
-
74
72
  ## HAR Compatibility
75
73
 
76
74
  Tested with HAR **1.1** and **1.2** (the format exported by Chrome DevTools, Firefox, Postman, and Insomnia). Files from other versions are parsed with a warning — open an issue if something breaks.
@@ -0,0 +1,19 @@
1
+ """Bundled PEP 517 build backend — generated by jac bundle, do not edit."""
2
+
3
+
4
+ def get_requires_for_build_wheel(config_settings=None):
5
+ return []
6
+
7
+
8
+ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
9
+ from pathlib import Path
10
+ from jaclang.project.config import get_config
11
+ from jaclang.publish.builder import WheelBuilder
12
+
13
+ proj_root = Path.cwd()
14
+ config = get_config(start_path=proj_root, force_discover=True)
15
+ if config is None:
16
+ raise RuntimeError(f"No jac.toml found in {proj_root}.")
17
+ return WheelBuilder(config=config, project_root=proj_root).build_to(
18
+ Path(wheel_directory)
19
+ ).name
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "jac-loadtest"
3
+ version = "0.3.0"
4
+ description = "HAR-based load testing library for jac-scale applications"
5
+ authors = [{ name = "Sahan Udayanga", email = "sahanudayangaof@gmail.com" }, { name = "Ravimal Ranathunga", email = "ravimalranathunga@gmail.com" }]
6
+ maintainers = [{ name = "Sahan Udayanga", email = "sahanudayangaof@gmail.com" }, { name = "Ravimal Ranathunga", email = "ravimalranathunga@gmail.com" }]
7
+ license = "MIT"
8
+ readme = "README.md"
9
+ keywords = ["load-testing", "har", "jac", "jaclang", "jaseci", "jac-scale", "performance"]
10
+ requires-python = ">=3.12"
11
+
12
+ [project.urls]
13
+ repository = "https://github.com/SahanUday/jac-loadtest"
14
+ source = "https://github.com/SahanUday/jac-loadtest/main/jac-loadtest-cli"
15
+ issues = "https://github.com/SahanUday/jac-loadtest/issues"
16
+
17
+ [project.include]
18
+ packages = ["jac_loadtest_cli"]
19
+
20
+ [project.include.data]
21
+ jac_loadtest_cli = ["templates/*.html"]
22
+
23
+ [dependencies]
24
+ jac-scale = ">=0.2.16"
25
+ aiohttp = ">=3.9.0,<4.0.0"
26
+ rich = ">=13.0.0"
27
+ requests = ">=2.28.0"
28
+
29
+ [dev-dependencies]
30
+ pytest = ">=8.0"
31
+ pytest-asyncio = ">=0.23"
32
+ pytest-mock = ">=3.12"
33
+
34
+ [optional-dependencies.test]
35
+ pytest = ">=8.0"
36
+ pytest-asyncio = ">=0.23"
37
+ pytest-mock = ">=3.12"
38
+ "aiohttp[speedups]" = ">=3.9.0,<4.0.0"
39
+
40
+ [entrypoints.jac]
41
+ loadtest = "jac_loadtest_cli.plugin:loadtest"
@@ -0,0 +1,94 @@
1
+ import csv;
2
+ import aiohttp;
3
+ import from ..config { LoadTestConfig }
4
+
5
+ class AuthenticationError(Exception) { }
6
+
7
+ obj Credential {
8
+ has username: str;
9
+ has password: str;
10
+ }
11
+
12
+ obj AuthProvider {
13
+ has _credentials: list[Credential];
14
+ has _login_path: str = "/user/login";
15
+
16
+ class def from_config(config: LoadTestConfig) -> AuthProvider | None {
17
+ if config.credentials_file {
18
+ creds = _load_csv(config.credentials_file);
19
+ } elif config.username and config.password {
20
+ creds = [Credential(username=config.username, password=config.password)];
21
+ } else {
22
+ return None;
23
+ }
24
+ return AuthProvider(_credentials=creds, _login_path=config.login_path);
25
+ }
26
+
27
+ def get_credential(vu_id: int) -> Credential {
28
+ return self._credentials[vu_id % len(self._credentials)];
29
+ }
30
+
31
+ async def authenticate(vu_id: int, session: aiohttp.ClientSession, base_url: str) -> str {
32
+ cred = self.get_credential(vu_id);
33
+ identity_type = "email" if "@" in cred.username else "username";
34
+ payload = {
35
+ "identity": {"type": identity_type, "value": cred.username},
36
+ "credential": {"type": "password", "password": cred.password},
37
+ };
38
+ url = base_url.rstrip("/") + self._login_path;
39
+ try {
40
+ async with session.post(url, json=payload) as resp {
41
+ if resp.status == 401 {
42
+ raise AuthenticationError(
43
+ f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
44
+ "401 Unauthorized — check credentials."
45
+ );
46
+ }
47
+ if not resp.ok {
48
+ raise AuthenticationError(
49
+ f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
50
+ f"server returned {resp.status}."
51
+ );
52
+ }
53
+ body = await resp.json();
54
+ }
55
+ } except aiohttp.ClientConnectorError as exc {
56
+ raise AuthenticationError(
57
+ f"Cannot reach login endpoint {url!r}: {exc}"
58
+ ) from exc;
59
+ }
60
+ try {
61
+ return body["data"]["token"];
62
+ } except (KeyError, TypeError) as exc {
63
+ raise AuthenticationError(
64
+ f"Unexpected login response shape from {url!r}: {body!r}"
65
+ ) from exc;
66
+ }
67
+ }
68
+ }
69
+
70
+ def _load_csv(path: str) -> list[Credential] {
71
+ credentials = [];
72
+ with open(path, newline="") as f {
73
+ reader = csv.reader(f);
74
+ i = 0;
75
+ for row in reader {
76
+ if len(row) < 2 {
77
+ i += 1;
78
+ continue;
79
+ }
80
+ username = row[0].strip();
81
+ password = row[1].strip();
82
+ if i == 0 and username.lower() == "username" {
83
+ i += 1;
84
+ continue;
85
+ }
86
+ credentials.append(Credential(username=username, password=password));
87
+ i += 1;
88
+ }
89
+ }
90
+ if not credentials {
91
+ raise ValueError(f"No credentials found in {path!r}");
92
+ }
93
+ return credentials;
94
+ }
@@ -0,0 +1,165 @@
1
+ import json;
2
+ import os;
3
+ import from urllib.parse { urlparse, urlunparse }
4
+ import from ..config { LoadTestConfig }
5
+
6
+ obj ServiceRoute {
7
+ has name: str;
8
+ has prefix: str;
9
+ has url: str;
10
+ }
11
+
12
+ obj TopologyRouter {
13
+ has _routes: list;
14
+ has _fallback_url: str | None = None;
15
+
16
+ def __post_init__() {
17
+ self._routes = sorted(self._routes, key=lambda r: len(r.prefix), reverse=True);
18
+ }
19
+
20
+ def resolve(entry_url: str) -> tuple {
21
+ parsed = urlparse(entry_url);
22
+ path = parsed.path;
23
+
24
+ for route in self._routes {
25
+ if _prefix_matches(path, route.prefix) {
26
+ t = urlparse(route.url);
27
+ if route.prefix {
28
+ service_path = path[len(route.prefix):] or "/";
29
+ } else {
30
+ service_path = path;
31
+ }
32
+ base_path = t.path.rstrip("/") if t.path else "";
33
+ routed_path = f"{base_path}{service_path}" if base_path else service_path;
34
+ routed = urlunparse((
35
+ t.scheme, t.netloc,
36
+ routed_path, parsed.params, parsed.query, "",
37
+ ));
38
+ return (routed, route.name);
39
+ }
40
+ }
41
+
42
+ if self._fallback_url {
43
+ t = urlparse(self._fallback_url);
44
+ base_path = t.path.rstrip("/") if t.path else "";
45
+ routed_path = f"{base_path}{path}" if base_path else path;
46
+ routed = urlunparse((
47
+ t.scheme, t.netloc,
48
+ routed_path, parsed.params, parsed.query, "",
49
+ ));
50
+ return (routed, "gateway");
51
+ }
52
+
53
+ raise ValueError(
54
+ f"No route for path '{path}' and no --url fallback provided. "
55
+ "Use --services-map or add [plugins.scale.microservices.routes] to jac.toml."
56
+ );
57
+ }
58
+
59
+ class def from_config(config: LoadTestConfig) -> TopologyRouter {
60
+ if config.mode != "microservice" {
61
+ return TopologyRouter(
62
+ _routes=[ServiceRoute(name="monolith", prefix="", url=config.url or "")],
63
+ _fallback_url=None,
64
+ );
65
+ }
66
+ return _build_microservice_topology(config);
67
+ }
68
+ }
69
+
70
+ def _build_microservice_topology(config: LoadTestConfig) -> TopologyRouter {
71
+ toml_routes = _load_toml_routes();
72
+
73
+ if config.services_map {
74
+ try {
75
+ services_json = json.loads(config.services_map);
76
+ } except json.JSONDecodeError as exc {
77
+ raise ValueError(f"--services-map is not valid JSON: {exc}") from exc;
78
+ }
79
+
80
+ routes = [];
81
+ for item in services_json.items() {
82
+ name = item[0];
83
+ url = item[1];
84
+ prefix = toml_routes.get(name);
85
+ if prefix is None {
86
+ if name.startswith("/") {
87
+ prefix = name;
88
+ } else {
89
+ raise ValueError(
90
+ f"--services-map key '{name}' has no matching route in jac.toml "
91
+ "and does not start with '/'. "
92
+ "Use a path prefix as the key or add '[plugins.scale.microservices.routes]' to jac.toml."
93
+ );
94
+ }
95
+ }
96
+ routes.append(ServiceRoute(
97
+ name=name.lstrip("/") if name.startswith("/") else name,
98
+ prefix=prefix,
99
+ url=url.rstrip("/"),
100
+ ));
101
+ }
102
+ return TopologyRouter(_routes=routes, _fallback_url=config.url);
103
+ }
104
+
105
+ if not toml_routes {
106
+ raise ValueError(
107
+ "Microservice mode requires either --services-map or "
108
+ "[plugins.scale.microservices.routes] in jac.toml. Neither was found."
109
+ );
110
+ }
111
+
112
+ routes = [];
113
+ missing = [];
114
+ for item in toml_routes.items() {
115
+ name = item[0];
116
+ prefix = item[1];
117
+ env_var = f"JAC_SV_{name.upper()}_URL";
118
+ url = os.environ.get(env_var);
119
+ if not url {
120
+ missing.append(env_var);
121
+ } else {
122
+ routes.append(ServiceRoute(name=name, prefix=prefix, url=url.rstrip("/")));
123
+ }
124
+ }
125
+
126
+ if missing {
127
+ raise ValueError(
128
+ f"Microservice mode: missing environment variable(s): {', '.join(missing)}. "
129
+ "Expected format: JAC_SV_<SERVICE_NAME>_URL=http://host:port"
130
+ );
131
+ }
132
+
133
+ return TopologyRouter(_routes=routes, _fallback_url=config.url);
134
+ }
135
+
136
+ def _prefix_matches(path: str, prefix: str) -> bool {
137
+ if not prefix {
138
+ return True;
139
+ }
140
+ return path == prefix or path.startswith(prefix + "/");
141
+ }
142
+
143
+ def _load_toml_routes -> dict {
144
+ try {
145
+ import from pathlib { Path }
146
+ import from jac_scale.config_loader { get_scale_config, reset_scale_config }
147
+ reset_scale_config();
148
+ ms_config = get_scale_config(project_dir=Path.cwd()).get_microservices_config();
149
+ return dict(ms_config.get("routes", {}));
150
+ } except Exception {
151
+ return {};
152
+ }
153
+ }
154
+
155
+ def resolve_url(path: str, routing_table: dict, fallback_url: str | None) -> str {
156
+ for prefix in sorted(routing_table, key=len, reverse=True) {
157
+ if path.startswith(prefix) {
158
+ return routing_table[prefix];
159
+ }
160
+ }
161
+ if fallback_url {
162
+ return fallback_url;
163
+ }
164
+ raise ValueError(f"No route for path '{path}' and no --url fallback provided.");
165
+ }
@@ -0,0 +1,152 @@
1
+ import sys;
2
+ import asyncio;
3
+ import time;
4
+ import from .config { from_args, LoadTestConfig }
5
+ import from .core.har_parser { parse_har, HarEntry }
6
+ import from .core.engine { run_all_vus }
7
+ import from .core.metrics { MetricsCollector }
8
+ import from .core.process_runner { run_multiprocess }
9
+ import from .output.reporter { render_console, render_json, render_html }
10
+ import from .bridge.auth { AuthProvider, AuthenticationError }
11
+ import from .bridge.topology { TopologyRouter }
12
+ import from rich.console { Console as _Console }
13
+
14
+ def run(args: object) {
15
+ config: LoadTestConfig = from_args(args);
16
+
17
+ if config.mode == "monolith" and not config.url {
18
+ print("Error: --url is required for monolith mode", file=sys.stderr);
19
+ sys.exit(2);
20
+ }
21
+
22
+ if not config.har_file {
23
+ print("Error: har_file positional argument is required", file=sys.stderr);
24
+ sys.exit(2);
25
+ }
26
+
27
+ entries: list[HarEntry] = [];
28
+ try {
29
+ entries = parse_har(
30
+ config.har_file,
31
+ target_url=config.url if config.mode == "monolith" else None,
32
+ include_static=config.include_static,
33
+ login_path=config.login_path,
34
+ );
35
+ } except (FileNotFoundError, ValueError) as exc {
36
+ print(f"Error: {exc}", file=sys.stderr);
37
+ sys.exit(2);
38
+ }
39
+
40
+ if not entries {
41
+ print(
42
+ "Error: no API entries found in HAR file after filtering. "
43
+ "Use --include-static to include static assets.",
44
+ file=sys.stderr,
45
+ );
46
+ sys.exit(2);
47
+ }
48
+
49
+ auth_provider: AuthProvider | None = AuthProvider.from_config(config);
50
+
51
+ if config.mode == "microservice" and auth_provider is not None and not config.url {
52
+ print(
53
+ "Error: --url (gateway URL) is required when using authentication "
54
+ "in microservice mode",
55
+ file=sys.stderr,
56
+ );
57
+ sys.exit(2);
58
+ }
59
+
60
+ try {
61
+ topology = TopologyRouter.from_config(config);
62
+ } except ValueError as exc {
63
+ print(f"Error: {exc}", file=sys.stderr);
64
+ sys.exit(2);
65
+ }
66
+
67
+ _console = _Console(stderr=True);
68
+
69
+ t_start = time.time();
70
+
71
+ try {
72
+ with _console.status(f"Running load test — {config.vus} VUs...") {
73
+ if config.workers > 1 {
74
+ metrics = run_multiprocess(entries, config, topology, auth_provider);
75
+ } else {
76
+ metrics = MetricsCollector(max_samples=config.max_samples);
77
+ asyncio.run(
78
+ run_all_vus(
79
+ entries, config, metrics,
80
+ topology=topology,
81
+ auth_provider=auth_provider,
82
+ )
83
+ );
84
+ }
85
+ }
86
+ } except AuthenticationError as exc {
87
+ print(f"Error: {exc}", file=sys.stderr);
88
+ sys.exit(2);
89
+ }
90
+
91
+ duration_s = time.time() - t_start;
92
+ stats = metrics.compute_endpoint_stats(t_start=t_start);
93
+ snapshots = metrics.generate_timeseries(t_start);
94
+ _comp = metrics.completion_percentiles(t_start);
95
+ completion_p50 = _comp[0];
96
+ completion_p95 = _comp[1];
97
+ completion_p99 = _comp[2];
98
+
99
+ fmt = config.report_format;
100
+
101
+ if fmt == "json" {
102
+ output = render_json(
103
+ stats, config,
104
+ actual_duration_s=duration_s,
105
+ total_rps=metrics.global_rps(duration_s),
106
+ snapshots=snapshots,
107
+ completion_p50_s=completion_p50,
108
+ completion_p95_s=completion_p95,
109
+ completion_p99_s=completion_p99,
110
+ );
111
+ if config.report_out {
112
+ with open(config.report_out, "w", encoding="utf-8") as fh {
113
+ fh.write(output);
114
+ }
115
+ print(f"JSON report written to {config.report_out}");
116
+ } else {
117
+ print(output);
118
+ }
119
+ } elif fmt == "html" {
120
+ if not config.report_out {
121
+ print(
122
+ "Error: --report-out <path> is required for --report-format html",
123
+ file=sys.stderr,
124
+ );
125
+ sys.exit(2);
126
+ }
127
+ output = render_html(
128
+ stats, config,
129
+ actual_duration_s=duration_s,
130
+ total_rps=metrics.global_rps(duration_s),
131
+ snapshots=snapshots,
132
+ completion_p50_s=completion_p50,
133
+ completion_p95_s=completion_p95,
134
+ completion_p99_s=completion_p99,
135
+ );
136
+ with open(config.report_out, "w", encoding="utf-8") as fh {
137
+ fh.write(output);
138
+ }
139
+ print(f"HTML report written to {config.report_out}", file=sys.stderr);
140
+ } else {
141
+ render_console(
142
+ stats, config,
143
+ actual_duration_s=duration_s,
144
+ total_rps=metrics.global_rps(duration_s),
145
+ completion_p50_s=completion_p50,
146
+ completion_p95_s=completion_p95,
147
+ completion_p99_s=completion_p99,
148
+ );
149
+ }
150
+ }
151
+
152
+