wsg 0.1.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.
wsg-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.3
2
+ Name: wsg
3
+ Version: 0.1.0
4
+ Summary: API load testing tool that produces latency/throughput graphs
5
+ Author: Owen Zeng
6
+ Author-email: Owen Zeng <owenzeng315@gmail.com>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: matplotlib>=3.11.1
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # wsg
13
+
14
+ API load testing tool designed for performance visualization. Sends concurrent
15
+ requests to an endpoint and produces a latency/throughput graph from the
16
+ results.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ uv tool install wsg
22
+ ```
23
+
24
+ Or, without a permanent install:
25
+
26
+ ```bash
27
+ uvx wsg <url> [options]
28
+ ```
29
+
30
+ `pipx install wsg` / `pip install wsg` also work if you don't use `uv`.
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ wsg <url> [options]
36
+ ```
37
+
38
+ ### Options
39
+
40
+ | Flag | Description | Default |
41
+ |------|-------------|---------|
42
+ | `-X`, `--method` | HTTP method to use | `GET` |
43
+ | `-n`, `--total_requests` | Total number of requests to send | `100` |
44
+ | `-c`, `--concurrency` | Number of concurrent requests | `10` |
45
+ | `-H`, `--headers` | HTTP headers, e.g. `-H "Authorization: Bearer xyz"` | none |
46
+ | `-d`, `--body` | Request body for POST/PUT requests | none |
47
+ | `--timeout` | Timeout per request in seconds | `10.0` |
48
+ | `-o`, `--output_path` | Path to save the output graph | `results.png` |
49
+
50
+ ### Example
51
+
52
+ ```bash
53
+ wsg https://api.example.com/ping -n 500 -c 20 -o load_test.png
54
+ ```
55
+
56
+ This sends 500 requests at a concurrency of 20 and saves a latency
57
+ distribution + throughput-over-time chart to `load_test.png` in the current
58
+ directory.
wsg-0.1.0/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # wsg
2
+
3
+ API load testing tool designed for performance visualization. Sends concurrent
4
+ requests to an endpoint and produces a latency/throughput graph from the
5
+ results.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ uv tool install wsg
11
+ ```
12
+
13
+ Or, without a permanent install:
14
+
15
+ ```bash
16
+ uvx wsg <url> [options]
17
+ ```
18
+
19
+ `pipx install wsg` / `pip install wsg` also work if you don't use `uv`.
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ wsg <url> [options]
25
+ ```
26
+
27
+ ### Options
28
+
29
+ | Flag | Description | Default |
30
+ |------|-------------|---------|
31
+ | `-X`, `--method` | HTTP method to use | `GET` |
32
+ | `-n`, `--total_requests` | Total number of requests to send | `100` |
33
+ | `-c`, `--concurrency` | Number of concurrent requests | `10` |
34
+ | `-H`, `--headers` | HTTP headers, e.g. `-H "Authorization: Bearer xyz"` | none |
35
+ | `-d`, `--body` | Request body for POST/PUT requests | none |
36
+ | `--timeout` | Timeout per request in seconds | `10.0` |
37
+ | `-o`, `--output_path` | Path to save the output graph | `results.png` |
38
+
39
+ ### Example
40
+
41
+ ```bash
42
+ wsg https://api.example.com/ping -n 500 -c 20 -o load_test.png
43
+ ```
44
+
45
+ This sends 500 requests at a concurrency of 20 and saves a latency
46
+ distribution + throughput-over-time chart to `load_test.png` in the current
47
+ directory.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "wsg"
3
+ version = "0.1.0"
4
+ description = "API load testing tool that produces latency/throughput graphs"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "httpx>=0.28.1",
9
+ "matplotlib>=3.11.1",
10
+ ]
11
+
12
+ [[project.authors]]
13
+ name = "Owen Zeng"
14
+ email = "owenzeng315@gmail.com"
15
+
16
+ [project.scripts]
17
+ wsg = "wsg:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.10,<0.13.0"]
21
+ build-backend = "uv_build"
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "wsg"
3
+ version = "0.1.0"
4
+ description = "API load testing tool that produces latency/throughput graphs"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Owen Zeng", email = "owenzeng315@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "httpx>=0.28.1",
12
+ "matplotlib>=3.11.1",
13
+ ]
14
+
15
+ [project.scripts]
16
+ wsg = "wsg:main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.12.10,<0.13.0"]
20
+ build-backend = "uv_build"
@@ -0,0 +1,15 @@
1
+ import asyncio
2
+
3
+ from wsg.cli import parse_args
4
+ from wsg.runner import run
5
+ from wsg.stats import summarize
6
+ from wsg.reports import print_report
7
+ from wsg.graph import render
8
+
9
+ def main():
10
+ config = parse_args()
11
+ results, _ = asyncio.run(run(config))
12
+ summary = summarize(results)
13
+ print_report(summary, config.url, config.method)
14
+ render(results, config.output_path)
15
+ print(f'Graph saved to {config.output_path}')
@@ -0,0 +1,40 @@
1
+ import argparse
2
+
3
+ from wsg.config import Config
4
+
5
+ def parse_args():
6
+ '''
7
+ Parse the command-line arguments for the API load-testing tool.
8
+ '''
9
+ parser = argparse.ArgumentParser(description="A simple API load-testing tool")
10
+ parser.add_argument("url", type=str, help="The URL of the API endpoint to test")
11
+ parser.add_argument("-X", "--method", type=str, default="GET", help="HTTP method to use (default: GET)")
12
+ parser.add_argument("-n", "--total_requests", type=int, default=100, help="Total number of requests to send (default: 100)")
13
+ parser.add_argument("-c", "--concurrency", type=int, default=10, help="Number of concurrent requests to send (default: 10)")
14
+ parser.add_argument("-H", "--headers", type=str, nargs='*', help="HTTP headers to include in the requests (format: 'Header-Name: Header-Value')")
15
+ parser.add_argument("-d", "--body", type=str, help="Request body for POST/PUT requests")
16
+ parser.add_argument("--timeout", type=float, default=10.0, help="Timeout for each request in seconds (default: 10.0)")
17
+ parser.add_argument("-o", "--output_path", type=str, default="results.png", help="Path to save the output results (default: results.png)")
18
+
19
+ args = parser.parse_args()
20
+
21
+ if args.total_requests <= 0:
22
+ parser.error("--requests must be a positive integer")
23
+ if args.concurrency <= 0:
24
+ parser.error("--concurrency must be a positive integer")
25
+ if args.timeout <= 0:
26
+ parser.error("--timeout must be a positive number")
27
+
28
+ return Config(
29
+ url=args.url,
30
+ method=args.method,
31
+ total_requests=args.total_requests,
32
+ concurrency=args.concurrency,
33
+ headers={header.split(":", 1)[0].strip(): header.split(":", 1)[1].strip() for header in args.headers} if args.headers else {},
34
+ body=args.body,
35
+ timeout=args.timeout,
36
+ output_path=args.output_path
37
+ )
38
+
39
+
40
+
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ @dataclass
4
+ class Config:
5
+ '''
6
+ Configuration class for the WSG application.
7
+ '''
8
+ url: str
9
+ method: str = "GET"
10
+ total_requests: int = 100
11
+ concurrency: int = 10
12
+ headers: dict[str, str] = field(default_factory=dict)
13
+ body: str | None = None
14
+ timeout: float = 10.0
15
+ output_path: str = "results.png"
@@ -0,0 +1,44 @@
1
+ import statistics
2
+
3
+ import matplotlib
4
+ matplotlib.use("Agg")
5
+ import matplotlib.pyplot as plt
6
+
7
+ from wsg.worker import Result
8
+ from wsg.stats import bucket_throughputs
9
+
10
+
11
+ def render(results: list[Result], output_path: str) -> None:
12
+ '''
13
+ Render a latency histogram and a throughput-over-time chart from the
14
+ results, side by side in one figure, and save it to output_path.
15
+ '''
16
+ latencies_ms = [r.elapsed * 1000 for r in results if r.status_code is not None]
17
+ throughputs = bucket_throughputs(results)
18
+
19
+ fig, (hist_ax, throughput_ax) = plt.subplots(1, 2, figsize=(12, 5))
20
+
21
+ bin_count = min(50, max(10, len(latencies_ms) // 2)) if latencies_ms else 10
22
+ hist_ax.hist(latencies_ms, bins=bin_count, color="#1f77b4", edgecolor="white")
23
+ if latencies_ms:
24
+ mean_ms = statistics.mean(latencies_ms)
25
+ hist_ax.axvline(mean_ms, color="black", linestyle="--", linewidth=1, label=f"Mean: {mean_ms:.0f} ms")
26
+ hist_ax.legend()
27
+ hist_ax.set_xlabel("Latency (ms)")
28
+ hist_ax.set_ylabel("Number of requests")
29
+ hist_ax.set_title("Latency Distribution")
30
+ hist_ax.grid(axis="y", alpha=0.3)
31
+
32
+ bucket_centers = [i + 0.5 for i in range(len(throughputs))]
33
+ bars = throughput_ax.bar(bucket_centers, throughputs, width=0.6, color="#ff7f0e", edgecolor="black", linewidth=0.5)
34
+ throughput_ax.bar_label(bars, fmt="%.0f")
35
+ throughput_ax.set_xticks(range(len(throughputs) + 1))
36
+ throughput_ax.set_ylim(0, max(throughputs, default=0) * 1.15)
37
+ throughput_ax.set_xlabel("Time since start (s)")
38
+ throughput_ax.set_ylabel("Requests/sec")
39
+ throughput_ax.set_title("Throughput Over Time")
40
+ throughput_ax.grid(axis="y", alpha=0.3)
41
+
42
+ fig.tight_layout()
43
+ fig.savefig(output_path, dpi=120)
44
+ plt.close(fig)
@@ -0,0 +1,27 @@
1
+ from wsg.stats import Summary
2
+
3
+ def print_report(summary: Summary, url: str, method: str) -> None:
4
+ '''
5
+ Print the summary report of the WSG application.
6
+ '''
7
+ print(f'Target: {method} {url}')
8
+ print("=== Load Test Results ===")
9
+ print(f'Total requests: {summary.count}')
10
+ print(f'Successful requests: {summary.success_count}')
11
+ print(f'Failed requests: {summary.error_count}')
12
+ print(f'Error rate: {summary.error_rate:.2%}')
13
+ print("=== Load Test Latency Metrics ===")
14
+ print(f'Min latency: {summary.latency_min * 1000:.2f} ms')
15
+ print(f'Max latency: {summary.latency_max * 1000:.2f} ms')
16
+ print(f'Mean latency: {summary.latency_mean * 1000:.2f} ms')
17
+ print(f'50th percentile latency: {summary.p50 * 1000:.2f} ms')
18
+ print(f'90th percentile latency: {summary.p90 * 1000:.2f} ms')
19
+ print(f'95th percentile latency: {summary.p95 * 1000:.2f} ms')
20
+ print(f'99th percentile latency: {summary.p99 * 1000:.2f} ms')
21
+ print("=== Load Test Throughput ===")
22
+ print(f'Throughput Min: {summary.throughput_min:.2f} requests/sec')
23
+ print(f'Throughput Max: {summary.throughput_max:.2f} requests/sec')
24
+ print(f'Throughput Mean: {summary.throughput_mean:.2f} requests/sec')
25
+ print("=== Status Codes ===")
26
+ for code, count in summary.status_codes.items():
27
+ print(f' {code}: {count}')
@@ -0,0 +1,26 @@
1
+ import asyncio
2
+ import httpx
3
+ import sys
4
+ import time
5
+
6
+ from wsg.config import Config
7
+ from wsg.worker import Result, send_request
8
+
9
+ async def run(config: Config) -> tuple[list[Result], float]:
10
+ '''
11
+ Run the load test using the provided configuration.
12
+ '''
13
+ async with httpx.AsyncClient() as client:
14
+ semaphore = asyncio.Semaphore(config.concurrency)
15
+ run_start = time.perf_counter()
16
+ tasks = [send_request(client, semaphore, config) for _ in range(config.total_requests)]
17
+
18
+ results = []
19
+ for coro in asyncio.as_completed(tasks):
20
+ results.append(await coro)
21
+ print(f"\rProgress: {len(results)}/{len(tasks)} ({len(results) / len(tasks):.0%})", end="", file=sys.stderr, flush=True)
22
+ print(file=sys.stderr)
23
+
24
+ for result in results:
25
+ result.start_time -= run_start
26
+ return (results, total_time := time.perf_counter() - run_start)
@@ -0,0 +1,105 @@
1
+ from dataclasses import dataclass
2
+ from wsg.worker import Result
3
+ from collections import Counter
4
+
5
+ import statistics
6
+
7
+
8
+ StatusCode = int
9
+
10
+ @dataclass
11
+ class Summary:
12
+ '''
13
+ Summary class to hold the results of the WSG application.
14
+ '''
15
+ count: int
16
+ success_count: int
17
+ error_count: int
18
+ error_rate: float
19
+ latency_min: float
20
+ latency_max: float
21
+ latency_mean: float
22
+ p50: float
23
+ p90: float
24
+ p95: float
25
+ p99: float
26
+ throughput_min: float
27
+ throughput_max: float
28
+ throughput_mean: float
29
+ status_codes: dict[StatusCode, int]
30
+
31
+ def bucket_throughputs(results: list[Result], bucket_size: float = 1.0) -> list[float]:
32
+ '''
33
+ Split successful results into fixed-size time buckets by completion
34
+ time (start_time + elapsed) and return the throughput (requests/sec)
35
+ measured in each bucket. The last bucket is dropped whenever there is
36
+ more than one, since it may cover only a partial slice of real time
37
+ and would understate its rate.
38
+ '''
39
+ completions = [r.start_time + r.elapsed for r in results if r.status_code is not None]
40
+ if not completions:
41
+ return []
42
+
43
+ bucket_counts = Counter(int(t // bucket_size) for t in completions)
44
+ buckets = [bucket_counts[i] / bucket_size for i in sorted(bucket_counts)]
45
+
46
+ if len(buckets) > 1:
47
+ buckets = buckets[:-1]
48
+
49
+ return buckets
50
+
51
+ def summarize(results: list[Result]) -> 'Summary':
52
+ '''
53
+ Summarize the results of the WSG application.
54
+ '''
55
+ success = []
56
+ failure = []
57
+
58
+ for i in results:
59
+ if i.status_code != None:
60
+ success.append(i)
61
+ else:
62
+ failure.append(i)
63
+
64
+ count = len(results)
65
+ success_count = len(success)
66
+ error_count = len(failure)
67
+ error_rate = error_count / count if count > 0 else 0.0
68
+
69
+ latency_list = []
70
+ for i in success:
71
+ latency_list.append(i.elapsed)
72
+
73
+ latency_min = min(latency_list) if latency_list else 0.0
74
+ latency_max = max(latency_list) if latency_list else 0.0
75
+ latency_mean = statistics.mean(latency_list) if latency_list else 0.0
76
+
77
+ p50 = statistics.median(latency_list) if latency_list else 0.0
78
+ p90 = statistics.quantiles(latency_list, n=10)[8] if latency_list else 0.0
79
+ p95 = statistics.quantiles(latency_list, n=20)[18] if latency_list else 0.0
80
+ p99 = statistics.quantiles(latency_list, n=100)[98] if latency_list else 0.0
81
+
82
+ throughput_buckets = bucket_throughputs(results)
83
+ throughput_min = min(throughput_buckets) if throughput_buckets else 0.0
84
+ throughput_max = max(throughput_buckets) if throughput_buckets else 0.0
85
+ throughput_mean = statistics.mean(throughput_buckets) if throughput_buckets else 0.0
86
+
87
+ status_codes = Counter(i.status_code for i in results if i.status_code is not None)
88
+
89
+ return Summary(
90
+ count=count,
91
+ success_count=success_count,
92
+ error_count=error_count,
93
+ error_rate=error_rate,
94
+ latency_min=latency_min,
95
+ latency_max=latency_max,
96
+ latency_mean=latency_mean,
97
+ p50=p50,
98
+ p90=p90,
99
+ p95=p95,
100
+ p99=p99,
101
+ throughput_min=throughput_min,
102
+ throughput_max=throughput_max,
103
+ throughput_mean=throughput_mean,
104
+ status_codes=dict(status_codes)
105
+ )
@@ -0,0 +1,34 @@
1
+ from dataclasses import dataclass
2
+ from wsg.config import Config
3
+ import time
4
+ import httpx
5
+ import asyncio
6
+
7
+ @dataclass
8
+ class Result:
9
+ '''
10
+ Result class to store the outcome of each request.
11
+ '''
12
+ status_code: int | None
13
+ elapsed: float
14
+ error: str | None
15
+ start_time: float
16
+
17
+ async def send_request(client: httpx.AsyncClient, semaphore: asyncio.Semaphore, config: Config) -> Result:
18
+ '''
19
+ Send a single HTTP request using the provided client and configuration.
20
+ '''
21
+ start = time.perf_counter()
22
+ try:
23
+ async with semaphore:
24
+ response = await client.request(
25
+ config.method, config.url,
26
+ headers=config.headers,
27
+ content=config.body,
28
+ timeout=config.timeout,
29
+ )
30
+ elapsed = time.perf_counter() - start
31
+ return Result(status_code=response.status_code, elapsed=elapsed, error=None, start_time=start)
32
+ except Exception as e:
33
+ elapsed = time.perf_counter() - start
34
+ return Result(status_code=None, elapsed=elapsed, error=str(e), start_time=start)