Kaputun Altı: Python AST ile Otomatik Locust Test Keşfi ve Asenkron Log Akışı Mimarisi
LocustPilot'ta test dosyalarını çalıştırmadan statik analizle tespit eden Python AST tarayıcısı ve unbuffered subprocess ile anlık log akış mimarisi.
TL;DR: Dynamically discovering test classes at runtime by executing importlib is dangerous because module-level code runs immediately. In LocustPilot, we solved this by building an Abstract Syntax Tree (AST) static scanner that identifies HttpUser and FastHttpUser classes without executing Python code, paired with an unbuffered subprocess.Popen engine for real-time log streaming.
The Hidden Trap of Dynamic Python Discovery
When building a web control plane for load testing, the first requirement is simple: automatically discover and list all test files in the UI dropdown.
Most developers jump straight to dynamic importing:
# ❌ The Naive Way: Dangerous runtime import
import importlib.util
spec = importlib.util.spec_from_file_location("module", file_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # ⚠️ Executes all top-level code!Why is this dangerous?
- Module-level side effects: If a test file establishes database connections, reads external credentials, or executes setup logic at the module level, simply browsing the UI triggers those actions.
- Crash propagation: A syntax error or missing optional dependency in one test file will crash the entire UI server.
- High memory footprint: Importing dozens of heavy test modules pollutes the server process memory space.
We needed a way to inspect Python test files statically and safely before execution.
The Solution: Static Analysis via Python's AST
In LocustPilot (app/core/runner.py), we use Python's built-in ast (Abstract Syntax Tree) module. We parse the source code into a syntax tree and walk the nodes without executing a single line of code.
Here is the exact heuristic implemented in LocustPilot:
import ast
import re
from pathlib import Path
def _has_locust_user_class(path: Path) -> bool:
"""Heuristic: returns True if file defines a Locust User class.
Detects subclasses of HttpUser/FastHttpUser via AST; falls back to regex.
"""
try:
src = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return False
try:
tree = ast.parse(src)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
for base in node.bases:
# Matches class MyTest(HttpUser)
if isinstance(base, ast.Name) and base.id in {"HttpUser", "FastHttpUser"}:
return True
# Matches class MyTest(locust.HttpUser)
if isinstance(base, ast.Attribute) and base.attr in {"HttpUser", "FastHttpUser"}:
return True
except Exception:
pass
# Fallback regex check for custom decorators
return (
re.search(r"class\s+\w+\(.*?(HttpUser|FastHttpUser).*?\)", src) is not None
or "@task" in src
)Why This Approach Wins:
- ⚡ Ultra-Fast: Parsing 100 Python files takes less than 15 milliseconds.
- 🛡️ Zero Side-Effects: No network sockets, database pools, or environment lookups are triggered.
- 🔍 Smart Filtering: Automatically excludes
__pycache__,__init__.py, and helper utility files unless they contain runnable user classes.
Photo by Kevin Ku on Unsplash
Extracting Target Hosts Without Hardcoding
In real-world projects, different test files target different microservices or staging environments. LocustPilot uses an intelligent fallback chain to determine the default target host:
def extract_locustfile_host(path: Path) -> Optional[str]:
# 1. Environment variable override takes highest precedence
env_host = os.getenv("LOCUST_TARGET_HOST")
if env_host:
return env_host
# 2. Extract host attribute directly from test file
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return None
m = re.search(r"^\s*host\s*=\s*['\"]([^'\"]+)['\"]", text, flags=re.MULTILINE)
if m:
return m.group(1).strip()
# 3. Fallback to centralized BaseUser target host
if re.search(r"^\s*host\s*=\s*TARGET_HOST", text, flags=re.MULTILINE):
base_user = BASE_DIR / "locustfiles" / "utils" / "base_user.py"
if base_user.exists():
base_text = base_user.read_text(encoding="utf-8", errors="ignore")
m_target = re.search(r"TARGET_HOST\s*=\s*['\"]([^'\"]+)['\"]", base_text)
if m_target:
return m_target.group(1).strip()
return NoneThis allows engineers to either set hosts per test file, use a shared BaseLocustUser, or dynamically override them via the Streamlit UI.
Subprocess Execution and Unbuffered Log Streaming
Running load tests inside the main web server process would freeze the UI. LocustPilot spawns an isolated subprocess.Popen running headless Locust while streaming logs in real time.
def run_locust(
locustfile: Path,
host: Optional[str],
users: int,
spawn_rate: float,
run_time: str,
run_dir: Path,
csv_prefix: str = "stats",
html_report: bool = True,
csv_full_history: bool = True,
stream_logs: bool = True,
) -> Tuple[subprocess.Popen, Path, Path, str, List[str]]:
cmd = [
"locust",
"-f", str(locustfile),
"--headless",
"-u", str(users),
"-r", str(spawn_rate),
"--run-time", str(run_time),
"--csv", str(run_dir / csv_prefix),
"--logfile", str(run_dir / "locust.log"),
"--only-summary",
]
if host:
cmd += ["--host", str(host)]
if html_report:
cmd += ["--html", str(run_dir / "report.html")]
if csv_full_history:
cmd += ["--csv-full-history"]
# Dynamic PYTHONPATH injection so test libraries import cleanly
env = os.environ.copy()
py_paths = [str(LOCUSTFILES_DIR), str(LOCUSTFILES_DIR / "libs"), str(BASE_DIR)]
env["PYTHONPATH"] = os.pathsep.join(py_paths)
proc = subprocess.Popen(
cmd,
stdout=(subprocess.PIPE if stream_logs else subprocess.DEVNULL),
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # Line-buffered output for instant streaming
cwd=str(BASE_DIR),
env=env,
)
return proc, logfile, html_path, start, cmdKey Subprocess Design Choices:
bufsize=1: Enables line-buffered I/O. Standard Python output buffering waits for hundreds of bytes before flushing;bufsize=1pushes log lines to Streamlit instantly.PYTHONPATHInjection: Automatically addslibs/andutils/to Python path, allowing test scripts to use shared utilities without complex packaging.- Non-Blocking Polling: The Streamlit UI polls
proc.stdout.readline()without blocking the event loop.
Architectural Summary
By decoupling test discovery (AST) from test execution (Subprocess), LocustPilot achieves:
- Safety: Malicious or broken test scripts cannot crash the control plane.
- Responsiveness: Heavy load generation runs in isolated processes without degrading web UI performance.
- Clean Code: Test authors write standard Python without needing framework boilerplate.
What Is Next?
In Part 3, we will dive into Enterprise Observability: how LocustPilot leverages gevent greenlets to stream real-time P95/P99 latency percentiles and deduplicated error alerts to ReportPortal.
👉 Explore the LocustPilot Source Code on GitHub
FAQ
Why not use regex alone to find Locust classes?
Regex can produce false positives on comments or strings (e.g. inside docstrings). AST guarantees that we are inspecting actual Python class inheritance syntax.
Does subprocess.Popen limit load test throughput?
No. Headless Locust runs in its own OS process utilizing gevent coroutines. The Streamlit UI only reads lightweight summary lines, keeping CPU overhead below 1%.
Can I run tests with custom Python dependencies?
Yes. By configuring PYTHONPATH dynamically, any custom library inside locustfiles/libs/ or virtualenv is immediately accessible.