# Under the Hood: Building a Dynamic AST Test Runner and Subprocess Engine in Python

URL: https://hakangul.lovable.app/blog/locustpilot-part-2-dynamic-ast-test-runner-and-subprocess-engine
Author: Hakan Gül — Team Lead Senior Test Automation Engineer
Published: 2026-08-29
Updated: 2026-08-29
Category: test-automation
Tags: python, ast, locust, performance-testing, software-architecture, sdet

> How we used Python's Abstract Syntax Tree (AST) for safe static test discovery and built an unbuffered subprocess engine with real-time log streaming in LocustPilot.

## English

**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:

```python
# ❌ 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?
1. **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.
2. **Crash propagation:** A syntax error or missing optional dependency in one test file will crash the entire UI server.
3. **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:

```python
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.

---

![Terminal and Code Execution](https://images.unsplash.com/photo-1555066931-4365d14bab8c?auto=format&fit=crop&w=1200&q=80)
*Photo by [Kevin Ku](https://unsplash.com/@epicantus) 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:

```python
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 None
```

This 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.

```python
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, cmd
```

### Key Subprocess Design Choices:
* **`bufsize=1`:** Enables line-buffered I/O. Standard Python output buffering waits for hundreds of bytes before flushing; `bufsize=1` pushes log lines to Streamlit instantly.
* **`PYTHONPATH` Injection:** Automatically adds `libs/` and `utils/` 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:
1. **Safety:** Malicious or broken test scripts cannot crash the control plane.
2. **Responsiveness:** Heavy load generation runs in isolated processes without degrading web UI performance.
3. **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](https://github.com/hakanngul/LocustPilot)**

---

## 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.

## Türkçe

**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:

```python
# ❌ 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?
1. **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.
2. **Crash propagation:** A syntax error or missing optional dependency in one test file will crash the entire UI server.
3. **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:

```python
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.

---

![Terminal and Code Execution](https://images.unsplash.com/photo-1555066931-4365d14bab8c?auto=format&fit=crop&w=1200&q=80)
*Photo by [Kevin Ku](https://unsplash.com/@epicantus) 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:

```python
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 None
```

This 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.

```python
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, cmd
```

### Key Subprocess Design Choices:
* **`bufsize=1`:** Enables line-buffered I/O. Standard Python output buffering waits for hundreds of bytes before flushing; `bufsize=1` pushes log lines to Streamlit instantly.
* **`PYTHONPATH` Injection:** Automatically adds `libs/` and `utils/` 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:
1. **Safety:** Malicious or broken test scripts cannot crash the control plane.
2. **Responsiveness:** Heavy load generation runs in isolated processes without degrading web UI performance.
3. **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](https://github.com/hakanngul/LocustPilot)**

---

## 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.
