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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ prescale = prescale_cli.main:main
@@ -0,0 +1,3 @@
1
+ """PreScale - launch-readiness load testing for developers."""
2
+
3
+ __version__ = "0.1.0"
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
+ )