prescale 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.
- prescale-0.1.0.dist-info/METADATA +68 -0
- prescale-0.1.0.dist-info/RECORD +12 -0
- prescale-0.1.0.dist-info/WHEEL +4 -0
- prescale-0.1.0.dist-info/entry_points.txt +2 -0
- prescale_cli/__init__.py +3 -0
- prescale_cli/audit.py +206 -0
- prescale_cli/commands/__init__.py +1 -0
- prescale_cli/commands/audit.py +75 -0
- prescale_cli/commands/run.py +315 -0
- prescale_cli/loadtest.py +509 -0
- prescale_cli/main.py +35 -0
- prescale_cli/report.py +283 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prescale
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Launch-readiness load testing for developers - find what breaks before your users do
|
|
5
|
+
Project-URL: Homepage, https://github.com/pyjeebz/prescale
|
|
6
|
+
Project-URL: Documentation, https://github.com/pyjeebz/prescale#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/pyjeebz/prescale
|
|
8
|
+
Author-email: Mujeeb Lawal-Saka <lawalsakamujeeb@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: cli,devtools,launch,load-testing,performance,stress-test
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing :: Traffic Generation
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: click>=8.0.0
|
|
24
|
+
Requires-Dist: httpx[http2]>=0.24.0
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Requires-Dist: rich>=13.0.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# PreScale
|
|
33
|
+
|
|
34
|
+
**Launch-readiness load testing for developers — find what breaks before your users do.**
|
|
35
|
+
|
|
36
|
+
Point it at a URL. It ramps simulated traffic until something gives, then tells you, in plain English, what failed first and at what load.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install prescale
|
|
40
|
+
prescale run https://staging.myapp.com
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Scale readiness: ⚠️ Survives ~90 concurrent users
|
|
45
|
+
First failure errors climb at ~150 users
|
|
46
|
+
Likely cause Server returned 5xx under load — likely DB pool exhaustion.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Why
|
|
50
|
+
|
|
51
|
+
- **Zero config** — no test scripts, no account, one command against a URL.
|
|
52
|
+
- **Stack-agnostic** — Vercel, Fly, Railway, a VPS, serverless… it just needs a URL.
|
|
53
|
+
- **An answer, not a histogram** — "you're good to ~90 users, your DB is the wall."
|
|
54
|
+
- **Safe by default** — won't hammer a non-local host until you confirm you own it.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
prescale run <url> [-u MAX_USERS] [-s STAGE_SECONDS] [--latency-wall S]
|
|
60
|
+
[--error-threshold R] [-m METHOD] [--timeout S]
|
|
61
|
+
[--i-own-this] [--json]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Python 3.10+. Full docs and source: https://github.com/pyjeebz/PreScale
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
Apache 2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
prescale_cli/__init__.py,sha256=Qtat7bIe3CdaxOaJCtg8an3qjby75ADbT5EW-Sj5XAs,86
|
|
2
|
+
prescale_cli/audit.py,sha256=4orY_sSSRW_S6KPOVKxHLECv6COiCQV1zQUFB-tQxfM,7785
|
|
3
|
+
prescale_cli/loadtest.py,sha256=314Z9S6UOROScjJKYXLP3IwtALIwFvkgNYbUVrX4VYc,18723
|
|
4
|
+
prescale_cli/main.py,sha256=kmaphvf__KyQcv4999lKLQv62WbDzcEb5LHpWCs-fPo,834
|
|
5
|
+
prescale_cli/report.py,sha256=L9uSJztlyOMOD0JfnmUqSjDjvyFXgFgLnMi4BB-yE1U,13188
|
|
6
|
+
prescale_cli/commands/__init__.py,sha256=eMuQR40Ye6ei4XbISb4dO1BwFk5RRqKIKGMxrztVu9Y,28
|
|
7
|
+
prescale_cli/commands/audit.py,sha256=SlfvERJEh7hXYIGsO7B3AEQEaF9dWWcpMBIrqoifCic,2334
|
|
8
|
+
prescale_cli/commands/run.py,sha256=1_PMnQt0KPFAONnh1e28KJGvRaPf0dTMhQhoS6-wlHY,12496
|
|
9
|
+
prescale-0.1.0.dist-info/METADATA,sha256=znnbXTdKIdNoM7jVN_Zp131scX5QK3N0FyDcxDxdkqA,2520
|
|
10
|
+
prescale-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
prescale-0.1.0.dist-info/entry_points.txt,sha256=zO_BktcyssCJPfVaT0Qc_qIifr1y00-I5I2N_xgP2rk,52
|
|
12
|
+
prescale-0.1.0.dist-info/RECORD,,
|
prescale_cli/__init__.py
ADDED
prescale_cli/audit.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Static scaling-hygiene audit for `prescale audit`.
|
|
2
|
+
|
|
3
|
+
Fetches a page and a few of its static assets and flags the HTTP-level footguns
|
|
4
|
+
that decide how a site handles traffic — compression, caching, CDN, HTTP
|
|
5
|
+
version, cookies on assets — in about a second, without generating load.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from html.parser import HTMLParser
|
|
13
|
+
from urllib.parse import urljoin, urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from prescale_cli import __version__
|
|
18
|
+
|
|
19
|
+
_USER_AGENT = f"prescale/{__version__}"
|
|
20
|
+
|
|
21
|
+
# Response/header markers that reveal a CDN or edge cache in front of the origin.
|
|
22
|
+
_CDN_MARKERS = {
|
|
23
|
+
"cf-cache-status": "Cloudflare",
|
|
24
|
+
"x-vercel-cache": "Vercel",
|
|
25
|
+
"x-amz-cf-id": "CloudFront",
|
|
26
|
+
"x-served-by": "Fastly/Varnish",
|
|
27
|
+
"x-fastly-request-id": "Fastly",
|
|
28
|
+
"fly-request-id": "Fly",
|
|
29
|
+
"x-cache": "edge cache",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuditError(Exception):
|
|
34
|
+
"""Raised when the target can't be reached at all."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Finding:
|
|
39
|
+
name: str
|
|
40
|
+
status: str # "pass" | "warn" | "fail" | "info"
|
|
41
|
+
detail: str
|
|
42
|
+
fix: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _AssetParser(HTMLParser):
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.urls: list[str] = []
|
|
49
|
+
|
|
50
|
+
def handle_starttag(self, tag: str, attrs) -> None:
|
|
51
|
+
a = dict(attrs)
|
|
52
|
+
rel = a.get("rel", "").lower()
|
|
53
|
+
if tag == "link" and rel in ("stylesheet", "preload") and a.get("href"):
|
|
54
|
+
self.urls.append(a["href"])
|
|
55
|
+
elif tag == "script" and a.get("src"):
|
|
56
|
+
self.urls.append(a["src"])
|
|
57
|
+
elif tag == "img" and a.get("src"):
|
|
58
|
+
self.urls.append(a["src"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_assets(html: str, base_url: str) -> list[str]:
|
|
62
|
+
"""Same-origin static asset URLs referenced by the page, de-duplicated."""
|
|
63
|
+
parser = _AssetParser()
|
|
64
|
+
try:
|
|
65
|
+
parser.feed(html)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
base = urlparse(base_url)
|
|
69
|
+
origin = (base.scheme, base.netloc)
|
|
70
|
+
out: list[str] = []
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
for raw in parser.urls:
|
|
73
|
+
full = urljoin(base_url, raw)
|
|
74
|
+
parsed = urlparse(full)
|
|
75
|
+
if parsed.scheme not in ("http", "https") or (parsed.scheme, parsed.netloc) != origin:
|
|
76
|
+
continue
|
|
77
|
+
if full not in seen:
|
|
78
|
+
seen.add(full)
|
|
79
|
+
out.append(full)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _lower(headers) -> dict[str, str]:
|
|
84
|
+
return {k.lower(): v for k, v in headers.items()}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _compression_finding(content_encoding: str | None) -> Finding:
|
|
88
|
+
if content_encoding and any(
|
|
89
|
+
e in content_encoding.lower() for e in ("gzip", "br", "deflate", "zstd")
|
|
90
|
+
):
|
|
91
|
+
return Finding("Compression", "pass", f"Responses are compressed ({content_encoding}).")
|
|
92
|
+
return Finding(
|
|
93
|
+
"Compression", "warn", "Responses aren't compressed.",
|
|
94
|
+
"Enable gzip or brotli at the server/CDN — smaller payloads, less bandwidth.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _http_version_finding(version: str | None) -> Finding:
|
|
99
|
+
if version is None:
|
|
100
|
+
return Finding("HTTP version", "info", "Couldn't determine (install httpx[http2]).")
|
|
101
|
+
if version.lower().startswith(("http/2", "http/3", "h2", "h3")):
|
|
102
|
+
return Finding("HTTP version", "pass", f"Negotiated {version}.")
|
|
103
|
+
return Finding(
|
|
104
|
+
"HTTP version", "warn", f"Served over {version}.",
|
|
105
|
+
"Enable HTTP/2 — better multiplexing and fewer connection limits under load.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cdn_finding(headers_list: list[dict[str, str]]) -> Finding:
|
|
110
|
+
for headers in headers_list:
|
|
111
|
+
for marker, label in _CDN_MARKERS.items():
|
|
112
|
+
if marker in headers:
|
|
113
|
+
return Finding("CDN / edge cache", "pass",
|
|
114
|
+
f"Detected ({label}: {headers[marker]}).")
|
|
115
|
+
if "cloudflare" in headers.get("server", "").lower():
|
|
116
|
+
return Finding("CDN / edge cache", "pass", "Detected (Cloudflare).")
|
|
117
|
+
return Finding(
|
|
118
|
+
"CDN / edge cache", "warn", "No CDN/edge-cache headers seen.",
|
|
119
|
+
"Put a CDN in front so the origin doesn't serve every request.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _asset_caching_finding(assets: list[tuple[str, dict[str, str]]]) -> Finding:
|
|
124
|
+
if not assets:
|
|
125
|
+
return Finding("Static asset caching", "info", "No static assets found to check.")
|
|
126
|
+
uncached = []
|
|
127
|
+
for url, headers in assets:
|
|
128
|
+
cc = headers.get("cache-control", "").lower()
|
|
129
|
+
cacheable = ("immutable" in cc) or (
|
|
130
|
+
"max-age" in cc and not any(x in cc for x in ("no-store", "no-cache", "max-age=0"))
|
|
131
|
+
)
|
|
132
|
+
validator = "etag" in headers or "last-modified" in headers
|
|
133
|
+
if not (cacheable or validator):
|
|
134
|
+
uncached.append(url)
|
|
135
|
+
if not uncached:
|
|
136
|
+
return Finding("Static asset caching", "pass",
|
|
137
|
+
f"All {len(assets)} sampled assets are cacheable.")
|
|
138
|
+
status = "fail" if len(uncached) == len(assets) else "warn"
|
|
139
|
+
return Finding(
|
|
140
|
+
"Static asset caching", status,
|
|
141
|
+
f"{len(uncached)} of {len(assets)} sampled assets have no caching headers.",
|
|
142
|
+
"Set Cache-Control: max-age (or an ETag) so clients and CDNs don't re-fetch them.",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _asset_cookie_finding(assets: list[tuple[str, dict[str, str]]]) -> Finding:
|
|
147
|
+
if not assets:
|
|
148
|
+
return Finding("Cookies on assets", "info", "No static assets found to check.")
|
|
149
|
+
cookied = [url for url, headers in assets if "set-cookie" in headers]
|
|
150
|
+
if not cookied:
|
|
151
|
+
return Finding("Cookies on assets", "pass", "No cookies set on sampled static assets.")
|
|
152
|
+
return Finding(
|
|
153
|
+
"Cookies on assets", "warn", f"{len(cookied)} asset(s) set cookies.",
|
|
154
|
+
"Don't set cookies on static assets — it defeats CDN and proxy caching.",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _baseline_finding(size_bytes: int, seconds: float) -> Finding:
|
|
159
|
+
kb = size_bytes / 1024
|
|
160
|
+
detail = f"HTML {kb:.0f} KB, responded in {seconds * 1000:.0f} ms."
|
|
161
|
+
if kb > 1024:
|
|
162
|
+
return Finding("Baseline", "warn", detail,
|
|
163
|
+
"Large HTML payload — trim, split, or stream it.")
|
|
164
|
+
if seconds > 1.0:
|
|
165
|
+
return Finding("Baseline", "warn", detail,
|
|
166
|
+
"Slow baseline response — investigate work on the main route.")
|
|
167
|
+
return Finding("Baseline", "info", detail)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def run_audit(url: str, *, timeout: float = 10.0, max_assets: int = 6) -> list[Finding]:
|
|
171
|
+
"""Fetch the page and a sample of its assets; return scaling-hygiene findings."""
|
|
172
|
+
headers = {"User-Agent": _USER_AGENT, "Accept-Encoding": "gzip, deflate, br"}
|
|
173
|
+
try:
|
|
174
|
+
client = httpx.AsyncClient(http2=True, timeout=timeout,
|
|
175
|
+
follow_redirects=True, headers=headers)
|
|
176
|
+
http2_capable = True
|
|
177
|
+
except ImportError: # h2 not installed
|
|
178
|
+
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers)
|
|
179
|
+
http2_capable = False
|
|
180
|
+
|
|
181
|
+
async with client:
|
|
182
|
+
start = time.perf_counter()
|
|
183
|
+
try:
|
|
184
|
+
resp = await client.get(url)
|
|
185
|
+
except httpx.HTTPError as exc:
|
|
186
|
+
raise AuditError(f"Couldn't reach {url}: {exc}") from exc
|
|
187
|
+
elapsed = time.perf_counter() - start
|
|
188
|
+
page_headers = _lower(resp.headers)
|
|
189
|
+
|
|
190
|
+
asset_results: list[tuple[str, dict[str, str]]] = []
|
|
191
|
+
for asset in extract_assets(resp.text, url)[:max_assets]:
|
|
192
|
+
try:
|
|
193
|
+
ar = await client.get(asset)
|
|
194
|
+
except httpx.HTTPError:
|
|
195
|
+
continue
|
|
196
|
+
asset_results.append((asset, _lower(ar.headers)))
|
|
197
|
+
|
|
198
|
+
all_headers = [page_headers] + [h for _, h in asset_results]
|
|
199
|
+
return [
|
|
200
|
+
_compression_finding(page_headers.get("content-encoding")),
|
|
201
|
+
_http_version_finding(resp.http_version if http2_capable else None),
|
|
202
|
+
_cdn_finding(all_headers),
|
|
203
|
+
_asset_caching_finding(asset_results),
|
|
204
|
+
_asset_cookie_finding(asset_results),
|
|
205
|
+
_baseline_finding(len(resp.content), elapsed),
|
|
206
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands package."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""`prescale audit` - fast static scaling-hygiene check for a URL (no load)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from prescale_cli.audit import AuditError, Finding, run_audit
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
_ICONS = {
|
|
17
|
+
"pass": "[green]✓[/green]",
|
|
18
|
+
"warn": "[yellow]⚠[/yellow]",
|
|
19
|
+
"fail": "[red]✗[/red]",
|
|
20
|
+
"info": "[dim]•[/dim]",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command()
|
|
25
|
+
@click.argument("url")
|
|
26
|
+
@click.option("--timeout", default=10.0, type=float, help="Per-request timeout in seconds.")
|
|
27
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit findings as JSON.")
|
|
28
|
+
def audit(url: str, timeout: float, as_json: bool) -> None:
|
|
29
|
+
"""Static scaling-hygiene check for URL (no load).
|
|
30
|
+
|
|
31
|
+
\b
|
|
32
|
+
Examples:
|
|
33
|
+
prescale audit https://myapp.com
|
|
34
|
+
prescale audit https://myapp.com --json
|
|
35
|
+
"""
|
|
36
|
+
parsed = urlparse(url)
|
|
37
|
+
if not parsed.scheme or not parsed.netloc:
|
|
38
|
+
console.print(f"[red]Error:[/red] '{url}' doesn't look like a URL "
|
|
39
|
+
"(expected e.g. https://myapp.com).")
|
|
40
|
+
raise SystemExit(1)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
if as_json:
|
|
44
|
+
findings = asyncio.run(run_audit(url, timeout=timeout))
|
|
45
|
+
else:
|
|
46
|
+
with console.status("[bold blue]Auditing…"):
|
|
47
|
+
findings = asyncio.run(run_audit(url, timeout=timeout))
|
|
48
|
+
except AuditError as exc:
|
|
49
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
50
|
+
raise SystemExit(1)
|
|
51
|
+
|
|
52
|
+
if as_json:
|
|
53
|
+
console.print(json.dumps(
|
|
54
|
+
[{"name": f.name, "status": f.status, "detail": f.detail, "fix": f.fix}
|
|
55
|
+
for f in findings],
|
|
56
|
+
indent=2,
|
|
57
|
+
))
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
_render(url, findings)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _render(url: str, findings: list[Finding]) -> None:
|
|
64
|
+
console.print(f"\n[bold]PreScale audit[/bold] — [cyan]{url}[/cyan]\n")
|
|
65
|
+
for f in findings:
|
|
66
|
+
console.print(f"{_ICONS.get(f.status, '•')} [bold]{f.name}[/bold] {f.detail}")
|
|
67
|
+
if f.fix:
|
|
68
|
+
console.print(f" [dim]{f.fix}[/dim]")
|
|
69
|
+
|
|
70
|
+
counts = {s: sum(1 for f in findings if f.status == s) for s in ("pass", "warn", "fail")}
|
|
71
|
+
console.print(
|
|
72
|
+
f"\n[green]{counts['pass']} passed[/green] · "
|
|
73
|
+
f"[yellow]{counts['warn']} warnings[/yellow] · "
|
|
74
|
+
f"[red]{counts['fail']} failed[/red]"
|
|
75
|
+
)
|