loaderup 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.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: loaderup
3
+ Version: 0.1.0
4
+ Summary: Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi>=0.135.3
8
+ Requires-Dist: httpx>=0.28.1
9
+ Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: python-multipart>=0.0.24
11
+ Requires-Dist: uvicorn>=0.44.0
12
+ Requires-Dist: rich>=13.10.0
13
+
14
+ # LoaderUp
15
+
16
+ Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
17
+
18
+ ## Requirements
19
+
20
+ - Python `>=3.12`
21
+ - `uv` (recommended package manager)
22
+ - `k6` (optional but recommended for real load runs)
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv pip install -e .
28
+ ```
29
+
30
+ ## Run API directly
31
+
32
+ ```bash
33
+ python -m loader.main
34
+ ```
35
+
36
+ Server starts on `http://127.0.0.1:8000` by default.
37
+
38
+ ## Run via CLI
39
+
40
+ ```bash
41
+ python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
42
+ ```
43
+
44
+ This command opens the dashboard automatically at `http://127.0.0.1:8000/`.
45
+ Use `--no-open-browser` if you want to disable auto-open.
46
+
47
+ You can also use fallback-friendly forms like:
48
+
49
+ ```bash
50
+ python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
51
+ ```
52
+
53
+ ## Health check
54
+
55
+ ```bash
56
+ curl http://127.0.0.1:8000/health
57
+ ```
58
+
59
+ ## Dashboard
60
+
61
+ Open:
62
+
63
+ ```bash
64
+ http://127.0.0.1:8000/
65
+ ```
66
+
67
+ Dashboard includes:
68
+
69
+ - live status + progress stream
70
+ - metrics cards + quick chart bars
71
+ - saved run history (persisted in `artifacts/history/runs.jsonl`)
72
+ - React-based multi-tab control center (`Run Now`, `Live`, `Registered Targets`, `History`)
73
+
74
+ ## Notes
75
+
76
+ - Registry targets are loaded when the module is imported.
77
+ - Use `/run/targets` to submit explicit targets.
78
+ - Use `/run/registry` to run all decorator-registered targets.
@@ -0,0 +1,65 @@
1
+ # LoaderUp
2
+
3
+ Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
4
+
5
+ ## Requirements
6
+
7
+ - Python `>=3.12`
8
+ - `uv` (recommended package manager)
9
+ - `k6` (optional but recommended for real load runs)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uv pip install -e .
15
+ ```
16
+
17
+ ## Run API directly
18
+
19
+ ```bash
20
+ python -m loader.main
21
+ ```
22
+
23
+ Server starts on `http://127.0.0.1:8000` by default.
24
+
25
+ ## Run via CLI
26
+
27
+ ```bash
28
+ python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
29
+ ```
30
+
31
+ This command opens the dashboard automatically at `http://127.0.0.1:8000/`.
32
+ Use `--no-open-browser` if you want to disable auto-open.
33
+
34
+ You can also use fallback-friendly forms like:
35
+
36
+ ```bash
37
+ python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
38
+ ```
39
+
40
+ ## Health check
41
+
42
+ ```bash
43
+ curl http://127.0.0.1:8000/health
44
+ ```
45
+
46
+ ## Dashboard
47
+
48
+ Open:
49
+
50
+ ```bash
51
+ http://127.0.0.1:8000/
52
+ ```
53
+
54
+ Dashboard includes:
55
+
56
+ - live status + progress stream
57
+ - metrics cards + quick chart bars
58
+ - saved run history (persisted in `artifacts/history/runs.jsonl`)
59
+ - React-based multi-tab control center (`Run Now`, `Live`, `Registered Targets`, `History`)
60
+
61
+ ## Notes
62
+
63
+ - Registry targets are loaded when the module is imported.
64
+ - Use `/run/targets` to submit explicit targets.
65
+ - Use `/run/registry` to run all decorator-registered targets.
@@ -0,0 +1,78 @@
1
+ import json
2
+ from typing import Any, Dict, Tuple
3
+ from loader.models import SummaryMetrics
4
+
5
+
6
+ def _metric_value(metrics: Dict[str, Any], metric_name: str, field: str):
7
+ metric = metrics.get(metric_name, {})
8
+ if not isinstance(metric, dict):
9
+ return None
10
+ return metric.get(field)
11
+
12
+
13
+ def _checks_pass_rate(metrics: Dict[str, Any]) -> float | None:
14
+ checks = metrics.get("checks", {})
15
+ if not isinstance(checks, dict):
16
+ return None
17
+
18
+ # Some k6 outputs provide "value"
19
+ if "value" in checks:
20
+ return checks.get("value")
21
+
22
+ passes = checks.get("passes")
23
+ fails = checks.get("fails")
24
+
25
+ if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
26
+ total = passes + fails
27
+ if total > 0:
28
+ return passes / total
29
+
30
+ return None
31
+
32
+
33
+ def _http_req_failed_rate(metrics: Dict[str, Any]) -> float | None:
34
+ failed = metrics.get("http_req_failed", {})
35
+ if not isinstance(failed, dict):
36
+ return None
37
+
38
+ # Best source in k6 summary output
39
+ value = failed.get("value")
40
+ if isinstance(value, (int, float)):
41
+ return float(value)
42
+
43
+ # Some formats may expose "rate"
44
+ rate = failed.get("rate")
45
+ if isinstance(rate, (int, float)):
46
+ return float(rate)
47
+
48
+ # Fallback: in k6 this metric is a Rate metric, and "passes"
49
+ # usually means failed requests, while "fails" means non-failed requests.
50
+ passes = failed.get("passes")
51
+ fails = failed.get("fails")
52
+
53
+ if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
54
+ total = passes + fails
55
+ if total > 0:
56
+ return float(passes) / float(total)
57
+
58
+ return None
59
+
60
+
61
+ def parse_k6_summary(summary_path: str) -> Tuple[SummaryMetrics, Dict[str, Any]]:
62
+ with open(summary_path, "r", encoding="utf-8") as f:
63
+ data = json.load(f)
64
+
65
+ metrics = data.get("metrics", {})
66
+
67
+ parsed = SummaryMetrics(
68
+ total_requests=_metric_value(metrics, "http_reqs", "count"),
69
+ avg_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "avg"),
70
+ med_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "med"),
71
+ p90_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(90)"),
72
+ p95_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(95)"),
73
+ max_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "max"),
74
+ http_req_failed_rate=_http_req_failed_rate(metrics),
75
+ checks_pass_rate=_checks_pass_rate(metrics),
76
+ )
77
+
78
+ return parsed, data
@@ -0,0 +1,110 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import List
4
+ from loader.models import LoadTarget
5
+ from loader.settings import ARTIFACTS_DIR
6
+
7
+
8
+ def _js_string(value: str) -> str:
9
+ return json.dumps(value)
10
+
11
+
12
+ def build_k6_script(
13
+ base_url: str,
14
+ targets: List[LoadTarget],
15
+ vus: int,
16
+ duration_seconds: int,
17
+ ) -> str:
18
+ target_entries = []
19
+
20
+ for target in targets:
21
+ entry = {
22
+ "name": target.name,
23
+ "method": target.method,
24
+ "path": target.path,
25
+ "headers": target.headers,
26
+ "payload": target.payload_example,
27
+ "expected_status": target.expected_status,
28
+ "weight": target.weight,
29
+ }
30
+ target_entries.append(entry)
31
+
32
+ targets_json = json.dumps(target_entries, indent=2)
33
+
34
+ script = f"""import http from 'k6/http';
35
+ import {{ check, sleep }} from 'k6';
36
+
37
+ export const options = {{
38
+ vus: {vus},
39
+ duration: '{duration_seconds}s',
40
+ }};
41
+
42
+ const BASE_URL = {_js_string(base_url.rstrip("/"))};
43
+ const TARGETS = {targets_json};
44
+
45
+ function weightedPick(targets) {{
46
+ const expanded = [];
47
+ for (const t of targets) {{
48
+ const weight = Math.max(1, Math.round(t.weight || 1));
49
+ for (let i = 0; i < weight; i++) {{
50
+ expanded.push(t);
51
+ }}
52
+ }}
53
+ const idx = Math.floor(Math.random() * expanded.length);
54
+ return expanded[idx];
55
+ }}
56
+
57
+ export default function () {{
58
+ const target = weightedPick(TARGETS);
59
+ const url = `${{BASE_URL}}${{target.path}}`;
60
+ const method = String(target.method || 'GET').toUpperCase();
61
+ const payloadMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
62
+
63
+ let res;
64
+
65
+ if (method === 'GET') {{
66
+ res = http.get(url, {{
67
+ headers: target.headers || {{}},
68
+ tags: {{
69
+ target_name: target.name,
70
+ method,
71
+ path: target.path,
72
+ }},
73
+ }});
74
+ }} else if (payloadMethods.has(method)) {{
75
+ const payload = target.payload ? JSON.stringify(target.payload) : null;
76
+ const headers = {{ ...(target.headers || {{}}) }};
77
+ if (payload !== null && !headers['Content-Type'] && !headers['content-type']) {{
78
+ headers['Content-Type'] = 'application/json';
79
+ }}
80
+
81
+ res = http.request(method, url, payload, {{
82
+ headers,
83
+ tags: {{
84
+ target_name: target.name,
85
+ method,
86
+ path: target.path,
87
+ }},
88
+ }});
89
+ }} else {{
90
+ throw new Error(`Unsupported method: ${{method}}`);
91
+ }}
92
+
93
+ check(res, {{
94
+ [`status is expected`]: (r) => r.status === target.expected_status,
95
+ [`status is < 500`]: (r) => r.status < 500,
96
+ }});
97
+
98
+ sleep(1);
99
+ }}
100
+ """
101
+ return script
102
+
103
+
104
+ def save_k6_script(job_id: str, script_text: str) -> str:
105
+ out_dir = ARTIFACTS_DIR / job_id
106
+ out_dir.mkdir(parents=True, exist_ok=True)
107
+
108
+ script_path = out_dir / "script.js"
109
+ script_path.write_text(script_text, encoding="utf-8")
110
+ return str(script_path)
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+ from loader.settings import ARTIFACTS_DIR
3
+
4
+
5
+ async def run_k6(script_path: str, job_id: str) -> dict:
6
+ out_dir = ARTIFACTS_DIR / job_id
7
+ out_dir.mkdir(parents=True, exist_ok=True)
8
+
9
+ summary_path = out_dir / "summary.json"
10
+
11
+ process = await asyncio.create_subprocess_exec(
12
+ "k6",
13
+ "run",
14
+ "--summary-export",
15
+ str(summary_path),
16
+ script_path,
17
+ stdout=asyncio.subprocess.PIPE,
18
+ stderr=asyncio.subprocess.PIPE,
19
+ )
20
+
21
+ stdout, stderr = await process.communicate()
22
+
23
+ stdout_text = stdout.decode(errors="replace")
24
+ stderr_text = stderr.decode(errors="replace")
25
+
26
+ if process.returncode != 0:
27
+ raise RuntimeError(
28
+ "k6 execution failed\n"
29
+ f"stdout:\n{stdout_text}\n"
30
+ f"stderr:\n{stderr_text}"
31
+ )
32
+
33
+ if not summary_path.exists():
34
+ raise RuntimeError("k6 completed but summary.json was not created")
35
+
36
+ return {
37
+ "summary_path": str(summary_path),
38
+ "stdout": stdout_text,
39
+ "stderr": stderr_text,
40
+ }
File without changes
@@ -0,0 +1,25 @@
1
+ # demo_registry_targets.py
2
+
3
+ from loaderup import load_target
4
+
5
+
6
+ @load_target(
7
+ name="home",
8
+ method="GET",
9
+ path="/",
10
+ expected_status=200,
11
+ tags=["public", "homepage"],
12
+ )
13
+ def home():
14
+ pass
15
+
16
+ @load_target(
17
+ name="home",
18
+ method="POST",
19
+ path="/gg",
20
+ expected_status=200,
21
+ tags=["public", "homepage"],
22
+ )
23
+ def gg():
24
+ pass
25
+
@@ -0,0 +1,36 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List
4
+
5
+ from loader.models import Job
6
+
7
+ HISTORY_FILE = (
8
+ Path(__file__).resolve().parents[1] / "artifacts" / "history" / "runs.jsonl"
9
+ )
10
+
11
+
12
+ def persist_job(job: Job) -> None:
13
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
14
+ with HISTORY_FILE.open("a", encoding="utf-8") as handle:
15
+ handle.write(json.dumps(job.model_dump(), ensure_ascii=True) + "\n")
16
+
17
+
18
+ def list_runs(limit: int = 20) -> List[Dict[str, Any]]:
19
+ if not HISTORY_FILE.exists():
20
+ return []
21
+
22
+ rows: List[Dict[str, Any]] = []
23
+ with HISTORY_FILE.open("r", encoding="utf-8") as handle:
24
+ for line in handle:
25
+ line = line.strip()
26
+ if not line:
27
+ continue
28
+ try:
29
+ rows.append(json.loads(line))
30
+ except json.JSONDecodeError:
31
+ continue
32
+
33
+ if limit < 1:
34
+ return []
35
+
36
+ return list(reversed(rows[-limit:]))
@@ -0,0 +1,150 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ import uuid
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.responses import FileResponse, StreamingResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from loader.models import Job, RunTargetsRequest, RunConfig
12
+ from loader.history import list_runs
13
+ from loaderup.importer import import_target_modules
14
+ from loaderup.models import RunRegistryRequest
15
+ from loader.store import create_job, get_job, get_queue, list_jobs
16
+ from loader.pipeline import run_targets_pipeline
17
+ from loader import demo_registry_target
18
+
19
+ app = FastAPI(title="LoadAgent")
20
+ WEB_DIR = Path(__file__).resolve().parent / "web"
21
+
22
+ app.mount("/assets", StaticFiles(directory=str(WEB_DIR / "assets")), name="assets")
23
+
24
+
25
+ @app.get("/", include_in_schema=False)
26
+ async def dashboard():
27
+ return FileResponse(str(WEB_DIR / "index.html"))
28
+
29
+
30
+ @app.get("/health")
31
+ async def health():
32
+ return {"status": "ok", "k6_installed": shutil.which("k6") is not None}
33
+
34
+
35
+ @app.on_event("startup")
36
+ async def load_cli_target_modules():
37
+ modules_csv = os.getenv("LOADERUP_TARGET_MODULES", "").strip()
38
+ if not modules_csv:
39
+ return
40
+
41
+ modules = [name.strip() for name in modules_csv.split(",") if name.strip()]
42
+ if modules:
43
+ import_target_modules(modules)
44
+
45
+
46
+ @app.post("/run/targets")
47
+ async def run_targets(payload: RunTargetsRequest):
48
+ job_id = str(uuid.uuid4())
49
+
50
+ job = Job(job_id=job_id)
51
+ create_job(job)
52
+
53
+ config = RunConfig(
54
+ base_url=payload.base_url.rstrip("/"),
55
+ vus=payload.vus,
56
+ duration_seconds=payload.duration_seconds,
57
+ targets=payload.targets,
58
+ )
59
+
60
+ asyncio.create_task(run_targets_pipeline(job_id, config))
61
+
62
+ return {
63
+ "job_id": job_id,
64
+ "status": job.status,
65
+ "stream_url": f"/run/{job_id}/stream",
66
+ "result_url": f"/run/{job_id}/result",
67
+ }
68
+
69
+
70
+ @app.get("/run/{job_id}/result")
71
+ async def get_result(job_id: str):
72
+ job = get_job(job_id)
73
+ if not job:
74
+ raise HTTPException(status_code=404, detail="Job not found")
75
+ return job
76
+
77
+
78
+ @app.get("/run/{job_id}/stream")
79
+ async def stream_run(job_id: str):
80
+ q = get_queue(job_id)
81
+ if not q:
82
+ raise HTTPException(status_code=404, detail="Job not found")
83
+
84
+ async def event_generator():
85
+ while True:
86
+ item = await q.get()
87
+ if item is None:
88
+ break
89
+ yield f"data: {json.dumps(item)}\n\n"
90
+
91
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
92
+
93
+
94
+ @app.post("/run/registry")
95
+ async def run_registry(payload: RunRegistryRequest):
96
+ from loaderup import collect_load_targets
97
+
98
+ registry_targets = collect_load_targets()
99
+ if not registry_targets:
100
+ raise HTTPException(status_code=400, detail="No registered targets found")
101
+
102
+ job_id = str(uuid.uuid4())
103
+
104
+ job = Job(job_id=job_id)
105
+ create_job(job)
106
+
107
+ config = RunConfig(
108
+ base_url=payload.base_url.rstrip("/"),
109
+ vus=payload.vus,
110
+ duration_seconds=payload.duration_seconds,
111
+ targets=registry_targets,
112
+ )
113
+
114
+ asyncio.create_task(run_targets_pipeline(job_id, config))
115
+
116
+ return {
117
+ "job_id": job_id,
118
+ "status": job.status,
119
+ "registered_targets": len(registry_targets),
120
+ "stream_url": f"/run/{job_id}/stream",
121
+ "result_url": f"/run/{job_id}/result",
122
+ }
123
+
124
+
125
+ @app.get("/registry/targets")
126
+ async def get_registry_targets():
127
+ from loaderup import collect_load_targets
128
+
129
+ targets = collect_load_targets()
130
+ return {
131
+ "count": len(targets),
132
+ "targets": [target.model_dump() for target in targets],
133
+ }
134
+
135
+
136
+ @app.get("/runs/active")
137
+ async def get_active_runs(limit: int = 20):
138
+ jobs = list_jobs(limit=limit)
139
+ return {"runs": [job.model_dump() for job in jobs]}
140
+
141
+
142
+ @app.get("/runs/history")
143
+ async def get_run_history(limit: int = 20):
144
+ return {"runs": list_runs(limit=limit)}
145
+
146
+
147
+ if __name__ == "__main__":
148
+ import uvicorn
149
+
150
+ uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -0,0 +1,93 @@
1
+ from enum import Enum
2
+ from typing import Optional, List, Dict, Any, Literal
3
+ from pydantic import BaseModel, Field, model_validator
4
+
5
+
6
+ class JobStatus(str, Enum):
7
+ pending = "pending"
8
+ generating = "generating"
9
+ running = "running"
10
+ analyzing = "analyzing"
11
+ done = "done"
12
+ failed = "failed"
13
+
14
+
15
+ class LoadTarget(BaseModel):
16
+ name: str
17
+ method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
18
+ path: str
19
+ headers: Dict[str, str] = Field(default_factory=dict)
20
+ payload_example: Optional[Dict[str, Any]] = None
21
+ weight: float = 1.0
22
+ tags: List[str] = Field(default_factory=list)
23
+ expected_status: int = 200
24
+
25
+ @model_validator(mode="after")
26
+ def validate_target(self):
27
+ if not self.path.startswith("/"):
28
+ raise ValueError("path must start with '/'")
29
+ return self
30
+
31
+
32
+ class RunTargetsRequest(BaseModel):
33
+ base_url: str
34
+ targets: List[LoadTarget]
35
+ vus: int = 10
36
+ duration_seconds: int = 30
37
+
38
+ @model_validator(mode="after")
39
+ def validate_request(self):
40
+ if not self.base_url.startswith(("http://", "https://")):
41
+ raise ValueError("base_url must start with http:// or https://")
42
+ if not self.targets:
43
+ raise ValueError("targets cannot be empty")
44
+ if self.vus < 1:
45
+ raise ValueError("vus must be >= 1")
46
+ if self.duration_seconds < 1:
47
+ raise ValueError("duration_seconds must be >= 1")
48
+ return self
49
+
50
+
51
+ class RunConfig(BaseModel):
52
+ base_url: str
53
+ vus: int
54
+ duration_seconds: int
55
+ targets: List[LoadTarget]
56
+
57
+
58
+ class SummaryMetrics(BaseModel):
59
+ total_requests: Optional[int] = None
60
+ avg_http_req_duration_ms: Optional[float] = None
61
+ med_http_req_duration_ms: Optional[float] = None
62
+ p90_http_req_duration_ms: Optional[float] = None
63
+ p95_http_req_duration_ms: Optional[float] = None
64
+ max_http_req_duration_ms: Optional[float] = None
65
+ http_req_failed_rate: Optional[float] = None
66
+ checks_pass_rate: Optional[float] = None
67
+
68
+
69
+ class TargetMetric(BaseModel):
70
+ name: str
71
+ method: str
72
+ path: str
73
+ expected_status: int
74
+ note: Optional[str] = None
75
+
76
+
77
+ class JobResult(BaseModel):
78
+ config: Optional[RunConfig] = None
79
+ k6_script_path: Optional[str] = None
80
+ k6_summary_path: Optional[str] = None
81
+ k6_stdout: Optional[str] = None
82
+ k6_stderr: Optional[str] = None
83
+ metrics: Optional[SummaryMetrics] = None
84
+ target_metrics: List[TargetMetric] = Field(default_factory=list)
85
+ raw_summary: Optional[Dict[str, Any]] = None
86
+
87
+
88
+ class Job(BaseModel):
89
+ job_id: str
90
+ status: JobStatus = JobStatus.pending
91
+ progress: List[str] = Field(default_factory=list)
92
+ error: Optional[str] = None
93
+ result: JobResult = Field(default_factory=JobResult)