featured image

Stop Guessing Types at Runtime: Building a Dispatch-Based Parameter Parser

Learn how to build a clean, maintainable parameter parser using the command dispatch pattern.

Published

Wed Aug 20 2025

Technologies Used

Python
Beginner 13 minutes

A Text Box That Needs to Mean Five Different Things

You have a text box. A user types "10-30, 5" and expects the Python list [10, 15, 20, 25, 30]. Another user types "0.5-1.5, 0.25" and expects [0.5, 0.75, 1.0, 1.25, 1.5]. A third types "euler, dpm_2, ddim" and expects a list of strings. A fourth types "random:5" and expects five randomly generated integers.

Same input widget. Five completely different types. Completely different parsing logic for each.

The naive solution — one function with a forest of if/elif branches — becomes unmaintainable the moment you add a sixth type. It’s also untestable: you can’t isolate one parser from another, which means a bug in float parsing can theoretically interfere with seed parsing if you’re not careful.

This tutorial dissects core/parameter_parser.py, a self-contained module that handles all five parameter types (INT, FLOAT, STRING, COMBO, SEED) through a dispatch table pattern. Every parser is a standalone function. The entry point is a single parse() function that routes to the right one based on a type key.

What You Need to Know First

  • Python functions, str.split(), str.strip()
  • Regular expressions at a basic level (re.match, capture groups)
  • Python type hints (list[int], list[float])
  • What a dispatch table is (a dict mapping keys to callables)
Python >= 3.9
Standard library only: re, decimal, random

You don’t need ComfyUI installed to follow this. The module is fully standalone.

A Post Office Sorting Machine

parse() is the intake slot. Every string comes through one entrance. The machine reads the label (param_type) and sends the string down the correct chute. Each chute has a specialist at the end. The specialists don’t know about each other; the sorting machine doesn’t know how to parse — it only knows how to route.

flowchart TD
    A["parse(raw_string, param_type)"] --> B{Lookup in dispatch dict}
    B -->|"INT"| C[parse_int]
    B -->|"FLOAT"| D[parse_float]
    B -->|"STRING"| E[parse_string]
    B -->|"COMBO"| F[parse_combo]
    B -->|"SEED"| G[parse_seed]
    B -->|"Unknown"| H["raise ValueError"]

    C --> C1{Range regex match?}
    C1 -->|Yes| C2["range(start, end+1, step)"]
    C1 -->|No| C3["split(',') → int()"]

    D --> D1{Range regex match?}
    D1 -->|Yes| D2["Decimal arithmetic loop"]
    D1 -->|No| D3["split(',') → float()"]

    G --> G1{Prefix match?}
    G1 -->|"random:N"| G2["[randint() × N]"]
    G1 -->|"increment:start:count"| G3["range(start, start+count)"]
    G1 -->|"Neither"| G4["delegate to parse_int"]

Adding a new type means adding one key-value pair to the dict and one new function. Nothing else changes.

Chunk 1: The Entry Point and Dispatch Table

import random
import re
from decimal import Decimal, ROUND_HALF_UP


def parse(raw_string: str, param_type: str) -> list:
    """Main entry point. Dispatches to type-specific parsers."""

    if not raw_string or not raw_string.strip():
        return []

    dispatch = {
        "INT":    parse_int,
        "FLOAT":  parse_float,
        "STRING": parse_string,
        "COMBO":  parse_combo,
        "SEED":   parse_seed,
    }

    parser = dispatch.get(param_type.upper())
    if parser is None:
        raise ValueError(f"Unknown parameter type: {param_type}")

    return parser(raw_string.strip())

The dispatch table stores references to functions, not calls to them — parse_int (no parentheses) stores the function object. When you write parser(raw_string.strip()), you’re calling it. This is first-class function behavior: Python treats functions as values, just like integers or strings. The .upper() call makes the lookup case-insensitive, so "int" and "INT" both work.

Chunk 2: The Regex-Gated INT Parser

Here’s what most beginners write first:

# Fragile: doesn't handle ranges, gives terrible error messages
def parse_int_naive(raw):
    return [int(x.strip()) for x in raw.split(",")]

This breaks on "10-30, 5" with a cryptic ValueError: invalid literal for int() with base 10: '10-30'. The user gets no guidance on what format is expected.

Here’s what the repo actually does:

def parse_int(raw: str) -> list[int]:
    raw = raw.strip()

    range_match = re.match(
        r"^(-?\d+)\s*-\s*(-?\d+)(?:\s*,\s*(\d+))?$", raw
    )

    if range_match:
        start = int(range_match.group(1))
        end   = int(range_match.group(2))
        step  = int(range_match.group(3)) if range_match.group(3) else 1

        if step <= 0:
            raise ValueError(f"Step must be positive, got {step}")

        if start <= end:
            return list(range(start, end + 1, step))
        else:
            return list(range(start, end - 1, -step))

    # Not a range: fall through to comma-separated list
    values = []
    for part in raw.split(","):
        part = part.strip()
        if part:
            try:
                values.append(int(part))
            except ValueError:
                raise ValueError(
                    f"Cannot parse '{part}' as integer. "
                    f"Use comma-separated values (10, 20, 30) "
                    f"or range syntax (10-30, 5)"
                )
    return values

The regex acts as a gate. If the input looks like a range (10-30, 5), use range logic. If not, fall through to list logic. The two branches are mutually exclusive and independently testable.

The error message in the except clause is the important part — it tells the user exactly what formats are accepted. Generic ValueError messages are useless to users who don’t know your parsing rules.

Chunk 3: Float Ranges With Decimal Arithmetic

Try this in your REPL:

>>> 0.1 + 0.2
0.30000000000000004

If you build a float range using native arithmetic, you accumulate drift. By the fourth step, 1.0000000000000002 appears in your list, which breaks equality checks and produces ugly filenames for generated images.

def parse_float(raw: str) -> list[float]:
    raw = raw.strip()

    range_match = re.match(
        r"^(-?[\d.]+)\s*-\s*(-?[\d.]+)(?:\s*,\s*([\d.]+))?$", raw
    )

    if range_match:
        # Construct Decimal from string, NOT from float.
        # Decimal(0.1) inherits float's imprecision.
        # Decimal("0.1") is exact.
        start = Decimal(range_match.group(1))
        end   = Decimal(range_match.group(2))
        step  = Decimal(range_match.group(3)) if range_match.group(3) else Decimal("1")

        values = []
        current = start
        while current <= end + step / Decimal("1000"):
            precision = max(
                -start.as_tuple().exponent if start.as_tuple().exponent < 0 else 0,
                -step.as_tuple().exponent  if step.as_tuple().exponent  < 0 else 0,
            )
            rounded = float(
                current.quantize(
                    Decimal("0." + "0" * precision),
                    rounding=ROUND_HALF_UP,
                )
            )
            values.append(rounded)
            current += step  # Decimal addition: exact

        return values

Decimal stores numbers as coefficient × 10^exponent — the same representation humans use. Decimal arithmetic is 10–100x slower than float, but this parser runs once at job-submission time to generate a list of at most a few hundred values. The performance cost is completely negligible; the correctness benefit is not.

The step / Decimal("1000") tolerance in the while condition prevents off-by-one errors where the loop exits one step too early due to accumulated boundary rounding.

Chunk 4: The Seed Parser Delegates Instead of Duplicating

The SEED parser is notable because it doesn’t reimplement integer list parsing — it calls parse_int when neither seed shorthand syntax matches:

def parse_seed(raw: str) -> list[int]:
    raw = raw.strip()

    # "random:5" → five random seeds
    random_match = re.match(r"^random\s*:\s*(\d+)$", raw, re.IGNORECASE)
    if random_match:
        count = int(random_match.group(1))
        if count <= 0:
            raise ValueError("Random seed count must be positive")
        if count > 10000:
            raise ValueError("Random seed count too large (max 10000)")
        return [random.randint(0, 2**32 - 1) for _ in range(count)]

    # "increment:100:5" → [100, 101, 102, 103, 104]
    inc_match = re.match(
        r"^increment\s*:\s*(\d+)\s*:\s*(\d+)$", raw, re.IGNORECASE
    )
    if inc_match:
        start = int(inc_match.group(1))
        count = int(inc_match.group(2))
        if count <= 0:
            raise ValueError("Increment count must be positive")
        return list(range(start, start + count))

    # Fallback: treat as plain integer list
    return parse_int(raw)

This is parser composition: parse_seed is built on top of parse_int. The shared fallback path eliminates duplicated comma-list logic. If parse_int gets a bug fix, parse_seed benefits automatically.

The seed count cap at 10,000 exists because "random:100000" would queue 100,000 images. That’s a mistake, not a feature. The error message makes the limit explicit.

One thing worth noting: random.randint uses Python’s Mersenne Twister PRNG, which is not cryptographically secure. For generative AI seeds, that’s completely fine — you want varied but reproducible-ish seeds, not secrets. Don’t use this function for anything security-sensitive.

Edge Cases the Parser Handles Without Crashing

"-5-5" is ambiguous. Is it start=-5, end=5 or something else? The regex ^(-?\d+)\s*-\s*(-?\d+) handles it correctly: parse_int("-5-5")[-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5]. The optional leading minus on the first capture group and the whitespace-tolerant separator in the middle make this unambiguous.

"10,,20" produces ["10", "", "20"] after split(","). The if part: guard skips empty strings. So "10,,20" and "10, 20" produce the same result — the parser doesn’t punish typos with cryptic errors.

The parser functions have no shared state and no side effects beyond their return values. They’re trivially thread-safe: calling parse() from 50 concurrent threads produces correct results from all 50 without any locking.

The dispatch table pattern replaces if/elif chains with a dictionary of callables. You’ll see it everywhere in production Python: Django’s URL router, Python’s ast.NodeVisitor, virtually every plugin system. Recognizing when a branching problem is actually a routing problem is a sign of intermediate-to-senior Python fluency.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!