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.
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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loaderup = loaderup.cli:main
@@ -0,0 +1,3 @@
1
+ agents
2
+ loader
3
+ loaderup