Tüm yazılar
test-automation

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.

29 Ağustos 20265 dk okuma
Kaputun Altı: Python AST ile Otomatik Locust Test Keşfi ve Asenkron 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:

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

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


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.

#python#ast#locust#performance-testing#software-architecture#sdet
Paylaş X LinkedIn

Yorumlar

  • Henüz yorum yok. İlk yorumu sen yaz.