loaderup 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.
- agents/analyzer.py +78 -0
- agents/generator.py +110 -0
- agents/runner.py +40 -0
- loader/__init__.py +0 -0
- loader/demo_registry_target.py +25 -0
- loader/history.py +36 -0
- loader/main.py +150 -0
- loader/models.py +93 -0
- loader/pipeline.py +111 -0
- loader/settings.py +4 -0
- loader/store.py +43 -0
- loader/web/assets/app.js +773 -0
- loader/web/assets/styles.css +570 -0
- loader/web/index.html +16 -0
- loaderup/__init__.py +10 -0
- loaderup/autodiscovery.py +65 -0
- loaderup/cli.py +219 -0
- loaderup/collector.py +11 -0
- loaderup/decorators.py +34 -0
- loaderup/importer.py +120 -0
- loaderup/models.py +36 -0
- loaderup/registry.py +31 -0
- loaderup-0.1.0.dist-info/METADATA +78 -0
- loaderup-0.1.0.dist-info/RECORD +27 -0
- loaderup-0.1.0.dist-info/WHEEL +5 -0
- loaderup-0.1.0.dist-info/entry_points.txt +2 -0
- loaderup-0.1.0.dist-info/top_level.txt +3 -0
loaderup/cli.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# loaderup/cli.py
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import uvicorn
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
except ImportError: # pragma: no cover - fallback for minimal environments
|
|
14
|
+
Console = None
|
|
15
|
+
|
|
16
|
+
from loaderup.importer import discover_target_modules, import_target_modules
|
|
17
|
+
from loaderup.registry import get_registry, clear_registry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
console = Console() if Console else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ensure_working_dir_on_sys_path() -> None:
|
|
24
|
+
cwd = os.getcwd()
|
|
25
|
+
if cwd and cwd not in sys.path:
|
|
26
|
+
sys.path.insert(0, cwd)
|
|
27
|
+
|
|
28
|
+
current_pythonpath = os.environ.get("PYTHONPATH", "")
|
|
29
|
+
pythonpath_parts = [part for part in current_pythonpath.split(os.pathsep) if part]
|
|
30
|
+
if cwd and cwd not in pythonpath_parts:
|
|
31
|
+
os.environ["PYTHONPATH"] = os.pathsep.join([cwd, *pythonpath_parts])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_banner():
|
|
35
|
+
banner = r"""
|
|
36
|
+
██╗ ██████╗ █████╗ ██████╗ ███████╗██████╗ ██╗ ██╗██████╗
|
|
37
|
+
██║ ██╔═══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██║ ██║██╔══██╗
|
|
38
|
+
██║ ██║ ██║███████║██║ ██║█████╗ ██████╔╝██║ ██║██████╔╝
|
|
39
|
+
██║ ██║ ██║██╔══██║██║ ██║██╔══╝ ██╔══██╗██║ ██║██╔═══╝
|
|
40
|
+
███████╗╚██████╔╝██║ ██║██████╔╝███████╗██║ ██║╚██████╔╝██║
|
|
41
|
+
╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝
|
|
42
|
+
"""
|
|
43
|
+
if console:
|
|
44
|
+
console.print(f"[bold cyan]{banner}[/bold cyan]")
|
|
45
|
+
console.print("[bold white]AI-native load testing[/bold white]\n")
|
|
46
|
+
else:
|
|
47
|
+
print(banner)
|
|
48
|
+
print("AI-native load testing\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_app_import_string(app: str) -> str:
|
|
52
|
+
if ":" not in app:
|
|
53
|
+
return app
|
|
54
|
+
|
|
55
|
+
module_name, app_name = app.split(":", 1)
|
|
56
|
+
if "." in module_name:
|
|
57
|
+
return app
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
importlib.import_module(module_name)
|
|
61
|
+
return app
|
|
62
|
+
except ModuleNotFoundError as exc:
|
|
63
|
+
if exc.name != module_name:
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
fallback_module = f"loader.{module_name}"
|
|
67
|
+
importlib.import_module(fallback_module)
|
|
68
|
+
resolved = f"{fallback_module}:{app_name}"
|
|
69
|
+
print(f"[loaderup] Resolved app '{app}' -> '{resolved}'")
|
|
70
|
+
return resolved
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_up(args):
|
|
74
|
+
print_banner()
|
|
75
|
+
print("[loaderup] Discovering targets...")
|
|
76
|
+
|
|
77
|
+
clear_registry()
|
|
78
|
+
|
|
79
|
+
modules = []
|
|
80
|
+
if args.targets:
|
|
81
|
+
modules = [m.strip() for m in args.targets.split(",") if m.strip()]
|
|
82
|
+
|
|
83
|
+
if modules:
|
|
84
|
+
print(f"[loaderup] Importing target modules: {modules}")
|
|
85
|
+
import_target_modules(modules)
|
|
86
|
+
os.environ["LOADERUP_TARGET_MODULES"] = ",".join(modules)
|
|
87
|
+
else:
|
|
88
|
+
modules = discover_target_modules(".")
|
|
89
|
+
if modules:
|
|
90
|
+
print(f"[loaderup] Auto-discovered modules: {modules}")
|
|
91
|
+
import_target_modules(modules)
|
|
92
|
+
os.environ["LOADERUP_TARGET_MODULES"] = ",".join(modules)
|
|
93
|
+
else:
|
|
94
|
+
print("[loaderup] No target modules provided")
|
|
95
|
+
os.environ.pop("LOADERUP_TARGET_MODULES", None)
|
|
96
|
+
|
|
97
|
+
registry = get_registry()
|
|
98
|
+
print(f"[loaderup] Registered targets: {len(registry)}")
|
|
99
|
+
|
|
100
|
+
for i, target in enumerate(registry, start=1):
|
|
101
|
+
print(
|
|
102
|
+
f" {i}. {target['method']} {target['path']} "
|
|
103
|
+
f"(name={target['name']}, tags={target.get('tags', [])})"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
app_import = resolve_app_import_string(args.app)
|
|
107
|
+
print("[loaderup] Starting backend...")
|
|
108
|
+
print(f"[loaderup] Starting server: {app_import}")
|
|
109
|
+
|
|
110
|
+
if args.open_browser:
|
|
111
|
+
browser_host = "127.0.0.1" if args.host in {"0.0.0.0", "::"} else args.host
|
|
112
|
+
dashboard_url = f"http://{browser_host}:{args.port}/"
|
|
113
|
+
print("[loaderup] Opening dashboard...")
|
|
114
|
+
print(f"[loaderup] Opening dashboard: {dashboard_url}")
|
|
115
|
+
threading.Timer(1.0, lambda: webbrowser.open(dashboard_url)).start()
|
|
116
|
+
|
|
117
|
+
uvicorn.run(app_import, host=args.host, port=args.port, reload=args.reload)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_discover(args):
|
|
121
|
+
clear_registry()
|
|
122
|
+
|
|
123
|
+
modules = []
|
|
124
|
+
if args.targets:
|
|
125
|
+
modules = [m.strip() for m in args.targets.split(",") if m.strip()]
|
|
126
|
+
|
|
127
|
+
if modules:
|
|
128
|
+
print(f"[loaderup] Importing target modules: {modules}")
|
|
129
|
+
import_target_modules(modules)
|
|
130
|
+
else:
|
|
131
|
+
modules = discover_target_modules(args.path)
|
|
132
|
+
if modules:
|
|
133
|
+
print(f"[loaderup] Auto-discovered modules: {modules}")
|
|
134
|
+
loaded = []
|
|
135
|
+
skipped = []
|
|
136
|
+
for module_name in modules:
|
|
137
|
+
try:
|
|
138
|
+
import_target_modules([module_name])
|
|
139
|
+
loaded.append(module_name)
|
|
140
|
+
except ModuleNotFoundError as exc:
|
|
141
|
+
skipped.append((module_name, str(exc)))
|
|
142
|
+
if skipped:
|
|
143
|
+
for module_name, reason in skipped:
|
|
144
|
+
print(f"[loaderup] Skipping '{module_name}': {reason}")
|
|
145
|
+
if loaded:
|
|
146
|
+
print(f"[loaderup] Loaded modules: {loaded}")
|
|
147
|
+
else:
|
|
148
|
+
print("[loaderup] No target modules found by auto-discovery")
|
|
149
|
+
|
|
150
|
+
registry = get_registry()
|
|
151
|
+
print(f"[loaderup] Registered targets: {len(registry)}")
|
|
152
|
+
|
|
153
|
+
for i, target in enumerate(registry, start=1):
|
|
154
|
+
print(
|
|
155
|
+
f" {i}. {target['method']} {target['path']} "
|
|
156
|
+
f"(name={target['name']}, expected={target.get('expected_status', 200)}, "
|
|
157
|
+
f"tags={target.get('tags', [])})"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_parser():
|
|
162
|
+
parser = argparse.ArgumentParser(prog="loaderup")
|
|
163
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
164
|
+
|
|
165
|
+
up_parser = subparsers.add_parser("up", help="Start LoaderUp backend")
|
|
166
|
+
up_parser.add_argument(
|
|
167
|
+
"--app",
|
|
168
|
+
default="loader.main:app",
|
|
169
|
+
help="ASGI app import string, e.g. loader.main:app",
|
|
170
|
+
)
|
|
171
|
+
up_parser.add_argument(
|
|
172
|
+
"--targets",
|
|
173
|
+
default="",
|
|
174
|
+
help="Comma-separated Python modules to import for decorators, e.g. demo_registry_targets",
|
|
175
|
+
)
|
|
176
|
+
up_parser.add_argument("--host", default="127.0.0.1")
|
|
177
|
+
up_parser.add_argument("--port", type=int, default=8000)
|
|
178
|
+
up_parser.add_argument("--reload", action="store_true")
|
|
179
|
+
up_parser.add_argument(
|
|
180
|
+
"--open-browser",
|
|
181
|
+
action=argparse.BooleanOptionalAction,
|
|
182
|
+
default=True,
|
|
183
|
+
help="Open dashboard in your default browser (default: enabled)",
|
|
184
|
+
)
|
|
185
|
+
up_parser.set_defaults(func=cmd_up)
|
|
186
|
+
|
|
187
|
+
discover_parser = subparsers.add_parser(
|
|
188
|
+
"discover", help="Import modules and list registered targets"
|
|
189
|
+
)
|
|
190
|
+
discover_parser.add_argument(
|
|
191
|
+
"--targets",
|
|
192
|
+
default="",
|
|
193
|
+
help="Comma-separated Python modules to import for decorators",
|
|
194
|
+
)
|
|
195
|
+
discover_parser.add_argument(
|
|
196
|
+
"--path",
|
|
197
|
+
default=".",
|
|
198
|
+
help="Directory root for auto-discovery when --targets is not provided",
|
|
199
|
+
)
|
|
200
|
+
discover_parser.set_defaults(func=cmd_discover)
|
|
201
|
+
|
|
202
|
+
return parser
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main():
|
|
206
|
+
ensure_working_dir_on_sys_path()
|
|
207
|
+
|
|
208
|
+
parser = build_parser()
|
|
209
|
+
args = parser.parse_args()
|
|
210
|
+
|
|
211
|
+
if not hasattr(args, "func"):
|
|
212
|
+
parser.print_help()
|
|
213
|
+
sys.exit(1)
|
|
214
|
+
|
|
215
|
+
args.func(args)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|
loaderup/collector.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# loaderup/collector.py
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from loader.models import LoadTarget
|
|
6
|
+
from loaderup.registry import get_registry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def collect_load_targets() -> List[LoadTarget]:
|
|
10
|
+
raw_targets = get_registry()
|
|
11
|
+
return [LoadTarget(**target) for target in raw_targets]
|
loaderup/decorators.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# loaderup/decorators.py
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Dict, Any, List
|
|
4
|
+
from loaderup.registry import register_target
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_target(
|
|
8
|
+
name: str,
|
|
9
|
+
method: str,
|
|
10
|
+
path: str,
|
|
11
|
+
headers: Optional[Dict[str, str]] = None,
|
|
12
|
+
payload_example: Optional[Dict[str, Any]] = None,
|
|
13
|
+
weight: float = 1.0,
|
|
14
|
+
tags: Optional[List[str]] = None,
|
|
15
|
+
expected_status: int = 200,
|
|
16
|
+
):
|
|
17
|
+
def decorator(func):
|
|
18
|
+
target = {
|
|
19
|
+
"name": name,
|
|
20
|
+
"method": method.upper(),
|
|
21
|
+
"path": path,
|
|
22
|
+
"headers": headers or {},
|
|
23
|
+
"payload_example": payload_example,
|
|
24
|
+
"weight": weight,
|
|
25
|
+
"tags": tags or [],
|
|
26
|
+
"expected_status": expected_status,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
register_target(target)
|
|
30
|
+
|
|
31
|
+
func.__loaderup_target__ = target
|
|
32
|
+
return func
|
|
33
|
+
|
|
34
|
+
return decorator
|
loaderup/importer.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# loaderup/importer.py
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import importlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _module_candidates(module_name: str) -> list[str]:
|
|
10
|
+
candidates: list[str] = [module_name]
|
|
11
|
+
|
|
12
|
+
if module_name.endswith("s"):
|
|
13
|
+
candidates.append(module_name[:-1])
|
|
14
|
+
else:
|
|
15
|
+
candidates.append(f"{module_name}s")
|
|
16
|
+
|
|
17
|
+
if "." not in module_name:
|
|
18
|
+
candidates.extend([f"loader.{name}" for name in list(candidates)])
|
|
19
|
+
|
|
20
|
+
deduped: list[str] = []
|
|
21
|
+
seen: set[str] = set()
|
|
22
|
+
for candidate in candidates:
|
|
23
|
+
if candidate and candidate not in seen:
|
|
24
|
+
seen.add(candidate)
|
|
25
|
+
deduped.append(candidate)
|
|
26
|
+
|
|
27
|
+
return deduped
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _import_with_fallback(module_name: str) -> None:
|
|
31
|
+
candidates = _module_candidates(module_name)
|
|
32
|
+
|
|
33
|
+
for candidate in candidates:
|
|
34
|
+
try:
|
|
35
|
+
importlib.import_module(candidate)
|
|
36
|
+
return
|
|
37
|
+
except ModuleNotFoundError as exc:
|
|
38
|
+
if exc.name != candidate:
|
|
39
|
+
raise
|
|
40
|
+
|
|
41
|
+
tried = ", ".join(candidates)
|
|
42
|
+
raise ModuleNotFoundError(f"No module named '{module_name}'. Tried: {tried}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def import_target_modules(module_names: Iterable[str]) -> None:
|
|
46
|
+
for module_name in module_names:
|
|
47
|
+
module_name = module_name.strip()
|
|
48
|
+
if not module_name:
|
|
49
|
+
continue
|
|
50
|
+
_import_with_fallback(module_name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_target_file(path: Path) -> bool:
|
|
54
|
+
try:
|
|
55
|
+
source = path.read_text(encoding="utf-8")
|
|
56
|
+
except OSError:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
if "load_target" not in source:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
tree = ast.parse(source)
|
|
64
|
+
except SyntaxError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
for node in ast.walk(tree):
|
|
68
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
69
|
+
continue
|
|
70
|
+
for decorator in node.decorator_list:
|
|
71
|
+
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
72
|
+
if isinstance(target, ast.Name) and target.id == "load_target":
|
|
73
|
+
return True
|
|
74
|
+
if isinstance(target, ast.Attribute) and target.attr == "load_target":
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _path_to_module(root: Path, path: Path) -> str:
|
|
81
|
+
relative = path.relative_to(root)
|
|
82
|
+
parts = list(relative.parts)
|
|
83
|
+
|
|
84
|
+
if not parts:
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
if parts[-1].endswith(".py"):
|
|
88
|
+
parts[-1] = parts[-1][:-3]
|
|
89
|
+
|
|
90
|
+
if parts[-1] == "__init__":
|
|
91
|
+
parts = parts[:-1]
|
|
92
|
+
|
|
93
|
+
return ".".join(part for part in parts if part)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def discover_target_modules(search_root: str = ".") -> list[str]:
|
|
97
|
+
root = Path(search_root).resolve()
|
|
98
|
+
if not root.exists():
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
ignored_dirs = {".git", ".venv", "venv", "__pycache__", "artifacts", ".ruff_cache"}
|
|
102
|
+
modules: list[str] = []
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
|
|
105
|
+
for path in root.rglob("*.py"):
|
|
106
|
+
if any(part in ignored_dirs for part in path.parts):
|
|
107
|
+
continue
|
|
108
|
+
if path.name.startswith("test_"):
|
|
109
|
+
continue
|
|
110
|
+
if "tests" in path.parts:
|
|
111
|
+
continue
|
|
112
|
+
if not _is_target_file(path):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
module_name = _path_to_module(root, path)
|
|
116
|
+
if module_name and module_name not in seen:
|
|
117
|
+
seen.add(module_name)
|
|
118
|
+
modules.append(module_name)
|
|
119
|
+
|
|
120
|
+
return sorted(modules)
|
loaderup/models.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import BaseModel, Field, model_validator
|
|
3
|
+
from typing import Literal, Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LoadTarget(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
9
|
+
path: str
|
|
10
|
+
headers: Dict[str, str] = Field(default_factory=dict)
|
|
11
|
+
payload_example: Optional[Dict[str, Any]] = None
|
|
12
|
+
weight: float = 1.0
|
|
13
|
+
tags: List[str] = Field(default_factory=list)
|
|
14
|
+
expected_status: int = 200
|
|
15
|
+
|
|
16
|
+
@model_validator(mode="after")
|
|
17
|
+
def validate_target(self):
|
|
18
|
+
if not self.path.startswith("/"):
|
|
19
|
+
raise ValueError("path must start with '/'")
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunRegistryRequest(BaseModel):
|
|
24
|
+
base_url: str
|
|
25
|
+
vus: int = 10
|
|
26
|
+
duration_seconds: int = 30
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def validate_request(self):
|
|
30
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
31
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
32
|
+
if self.vus < 1:
|
|
33
|
+
raise ValueError("vus must be >= 1")
|
|
34
|
+
if self.duration_seconds < 1:
|
|
35
|
+
raise ValueError("duration_seconds must be >= 1")
|
|
36
|
+
return self
|
loaderup/registry.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# loaderup/registry.py
|
|
2
|
+
|
|
3
|
+
from typing import List, Dict, Any, Tuple
|
|
4
|
+
|
|
5
|
+
_REGISTRY: List[Dict[str, Any]] = []
|
|
6
|
+
_SEEN: set[Tuple[str, str, str]] = set()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_key(target: Dict[str, Any]) -> Tuple[str, str, str]:
|
|
10
|
+
return (
|
|
11
|
+
str(target.get("name", "")),
|
|
12
|
+
str(target.get("method", "")).upper(),
|
|
13
|
+
str(target.get("path", "")),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_target(target: Dict[str, Any]):
|
|
18
|
+
key = _make_key(target)
|
|
19
|
+
if key in _SEEN:
|
|
20
|
+
return
|
|
21
|
+
_SEEN.add(key)
|
|
22
|
+
_REGISTRY.append(target)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_registry() -> List[Dict[str, Any]]:
|
|
26
|
+
return list(_REGISTRY)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clear_registry():
|
|
30
|
+
_REGISTRY.clear()
|
|
31
|
+
_SEEN.clear()
|
|
@@ -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,27 @@
|
|
|
1
|
+
agents/analyzer.py,sha256=8O7Lv5miYeeiIzwc0fzZ8jKiaKw-dkySW4w4Sfw-6UE,2539
|
|
2
|
+
agents/generator.py,sha256=vDh32x4fdQdOJdRK2VE4Vggmaf9ABKfk0bnh621-0JA,2833
|
|
3
|
+
agents/runner.py,sha256=5m5KHhfI5GjW5jLYjFvKGDvtalnYFkaPpfaO8OfT8ow,1060
|
|
4
|
+
loader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
loader/demo_registry_target.py,sha256=aIY2DPWJpgvOMbOPSJ-XFuLklAwUtS33wu1d_g4TcWI,354
|
|
6
|
+
loader/history.py,sha256=TiCejCocJqhk3FvsgGYn4PwAF1chJkAhy2qWhdo78a8,964
|
|
7
|
+
loader/main.py,sha256=OH0uk_fNYVQCe62VZ8tsOelMdcQQ7kGfzLoVadCcOjs,4005
|
|
8
|
+
loader/models.py,sha256=_65c5lSiXuPK_m2_jbgnphGLlY7fzxzgPeC-fjb1qMw,2751
|
|
9
|
+
loader/pipeline.py,sha256=JqYqM1hX7yFUtYD8gk15Q0J7eD4ztM67yf62wazNe8s,3417
|
|
10
|
+
loader/settings.py,sha256=oSWO8hyRmZveBTo0ITqDMSOfJmOjeaHPCJ8aq5rDdSo,108
|
|
11
|
+
loader/store.py,sha256=3yAPn50eV-NGeLmBjAAbiz62wNH2M4q2AHG4mie0Z1g,864
|
|
12
|
+
loader/web/index.html,sha256=t4CaoP0_60niDvVIVAbw_PWTt_sCMbF9zfZg8G4W8i4,651
|
|
13
|
+
loader/web/assets/app.js,sha256=mbReOOF_xHYz4JnT0RiLBmRiAnsdmlxE-C80m99293Q,30106
|
|
14
|
+
loader/web/assets/styles.css,sha256=BY2MTh4CWyfsYgr6qc8_KFk4wMjT_nYcm8lEY6gCImg,9981
|
|
15
|
+
loaderup/__init__.py,sha256=rXWZxpd_NCOGjpuC7V2uoM3qGapXcPKEcX5V3kYIeY0,234
|
|
16
|
+
loaderup/autodiscovery.py,sha256=Dai7o7jd8PyWKAHQAl-Nm0wmLqUbbA8CXMZHPfiVXAY,1500
|
|
17
|
+
loaderup/cli.py,sha256=q1KvtNYerpgSdaRP5DTcXdPY-T609OwNxO4c5CcjGhs,7567
|
|
18
|
+
loaderup/collector.py,sha256=vgmWzZAY1VoUDugEkzw2TLrEUVPY9LqBdj-JxcWiwyU,273
|
|
19
|
+
loaderup/decorators.py,sha256=mjdvv75Y_OlbtfpYui-hBohEH_zpCOjGIuXg83LWndg,839
|
|
20
|
+
loaderup/importer.py,sha256=SWqoqQ5FnLTDpfuJPIWrbDf99NeazrO7aiMj7O3a00k,3269
|
|
21
|
+
loaderup/models.py,sha256=yeE6fKIhxZddd3qckKwf_JB2QJPSA6CrKUhuP-LzFzU,1160
|
|
22
|
+
loaderup/registry.py,sha256=XNMq8fWRgzAoWmh11nWvoBgpDpgn6z5BamzfUxYEeqU,645
|
|
23
|
+
loaderup-0.1.0.dist-info/METADATA,sha256=cqjhOZxdbyG0eAOugxO3Zx3SucCoulv7MDn-zKv9v-4,1772
|
|
24
|
+
loaderup-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
25
|
+
loaderup-0.1.0.dist-info/entry_points.txt,sha256=TaY9uODKo4jWT2SM63Cm_aW5Q4MLBdy6E3ADaxJqUPo,47
|
|
26
|
+
loaderup-0.1.0.dist-info/top_level.txt,sha256=jsXHKBeHgZQi9d24qixIifziH5MP9KWH4iNgXgxOG9Q,23
|
|
27
|
+
loaderup-0.1.0.dist-info/RECORD,,
|