On this page
- Before You Can Run a Batch, Someone Has to Parse the Input
- What You Need to Know Before Writing a Single Line
- The Post Office Mental Model: How Dispatch Tables Route Messages
- Building the Parser, One Chunk at a Time
- Chunk 1: The Entry Point and Dispatch Table
- Chunk 2: The Naive Approach vs. The Regex-Gated INT Parser
- Chunk 3: Floating-Point Ranges with Decimal Arithmetic
- Chunk 4: The Seed Parser — Composing Parsers
- Why Decimal Is Slower but Correct — A Look at Float Representation
- What Breaks This Parser and How the Code Defends Against It
- You Now Own the Dispatch Pattern — Here’s the Skill You Gained
Before You Can Run a Batch, Someone Has to Parse the Input
The Problem
You have a text box. A user types "10-30, 5" into it. That string needs to become the Python list [10, 15, 20, 25, 30]. But another user types "0.5-1.5, 0.25" into a different text box, and that needs to become [0.5, 0.75, 1.0, 1.25, 1.5]. A third types "euler, dpm_2, ddim" and expects a list of strings.
Same function signature. Five completely different types. Completely different parsing logic for each. The naive solution — a single function with a forest of if/elif branches — becomes unmaintainable the moment you add a sixth type. It is also untestable: you cannot isolate one parser from another.
The Solution
This tutorial dissects core/parameter_parser.py, a self-contained module that handles all five parameter types (INT, FLOAT, STRING, COMBO, SEED) through a clean 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.
We’ll build a parser that handles range syntax (10-30, 5), list syntax, and seed shorthand (random:5, increment:100:5) using only Python’s standard library — re, decimal, and random. No external parsing libraries.
What You Need to Know Before Writing a Single Line
Knowledge Prerequisites
- 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
dictmapping keys to callables)
Environment
Python >= 3.9 # Required for list[int] type hint syntax
Pillow >= 9.0.0 # Package dependency (not needed for this module)
Standard library only: re, decimal, random
To follow along, you only need a Python 3.9+ interpreter. No ComfyUI installation required — this module is fully standalone.
The Post Office Mental Model: How Dispatch Tables Route Messages
Architecture Diagram
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"]
The Analogy
Think of parse() as a post office sorting machine. Every piece of mail (raw string) comes in through one intake slot, and the machine reads the label (param_type) to send it down the correct chute. Each chute has its own 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.
This is the Command Dispatch pattern. The dictionary is the routing logic. Adding a new type means adding one key-value pair and one new function. Nothing else changes.
Building the Parser, One Chunk at a Time
Chunk 1: The Entry Point and Dispatch Table
Before writing any parsing logic, define the contract. The parse() function is the only public surface of this module. Everything else is implementation detail.
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."""
# Guard: empty or whitespace-only input returns an empty list,
# not an error. The caller decides if an empty list is a problem.
if not raw_string or not raw_string.strip():
return []
# The dispatch table maps type strings to parser functions.
# dict.get() returns None for unknown keys — cleaner than if/elif.
dispatch = {
"INT": parse_int,
"FLOAT": parse_float,
"STRING": parse_string,
"COMBO": parse_combo,
"SEED": parse_seed,
}
# .upper() makes the lookup case-insensitive ("int" == "INT")
parser = dispatch.get(param_type.upper())
if parser is None:
raise ValueError(f"Unknown parameter type: {param_type}")
# Strip before passing downstream — every sub-parser can assume
# it receives a clean string.
return parser(raw_string.strip())
🔵 Deep Dive: The dispatch table is a dict of references to functions, not calls to functions. parse_int (no parentheses) stores the function object itself. When you write parser(raw_string.strip()), you are calling it. This is first-class function behaviour — Python treats functions as values, just like integers or strings.
Chunk 2: The Naive Approach vs. The Regex-Gated INT Parser
Naive Approach (what most beginners write):
# ❌ 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.
Refined Solution (what the repo does):
def parse_int(raw: str) -> list[int]:
raw = raw.strip()
# re.match only checks from the start of the string.
# Pattern: optional negative sign, digits, dash, digits,
# then optionally a comma and a step value.
range_match = re.match(
r"^(-?\d+)\s*-\s*(-?\d+)(?:\s*,\s*(\d+))?$", raw
)
if range_match:
start = int(range_match.group(1)) # First capture group
end = int(range_match.group(2)) # Second capture group
# group(3) is None if the optional step was omitted — default to 1
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}")
# Handle both ascending (10-30) and descending (30-10) ranges
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 parsing.
# Loop instead of list comprehension so we can give the user the
# exact token that failed, not just a generic error.
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 key insight: the regex acts as a gate. If the input looks like a range, use range logic. If not, fall through to list logic. The two branches are mutually exclusive and independently testable.
Chunk 3: Floating-Point Ranges with Decimal Arithmetic
Floats introduce a classic problem. Try this in your Python REPL:
>>> 0.1 + 0.2
0.30000000000000004
If you build a range 0.5 → 1.5 with step 0.25 using native float arithmetic, you accumulate drift. By the fourth step you might have 1.0000000000000002, which prints fine but fails equality checks and produces ugly filenames. The repo solves this with decimal.Decimal:
from decimal import Decimal, ROUND_HALF_UP
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"):
# Determine decimal places from start and step, then quantize.
# ROUND_HALF_UP gives predictable rounding (no banker's rounding).
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
# ... comma list fallback omitted for brevity (same as parse_int pattern)
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 rounding at the boundary.
Chunk 4: The Seed Parser — Composing Parsers
The SEED parser is notable because it doesn’t reimplement integer list parsing — it delegates to parse_int:
def parse_seed(raw: str) -> list[int]:
raw = raw.strip()
# Syntax 1: "random:5" → five cryptographically random seeds.
# re.IGNORECASE lets users write "Random:5" too.
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)")
# 2^32 - 1 = 4294967295, the max seed value most samplers accept
return [random.randint(0, 2**32 - 1) for _ in range(count)]
# Syntax 2: "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))
# Syntax 3: Anything else — treat as a plain integer list.
# Delegate instead of duplicate.
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.
Why Decimal Is Slower but Correct — A Look at Float Representation
The Binary Fraction Problem
Python’s float is a 64-bit IEEE 754 double. Most decimal fractions cannot be represented exactly in binary. 0.1 in binary is the repeating fraction 0.0001100110011.... The CPU stores the closest approximation it can fit in 64 bits. Arithmetic on these approximations compounds the error.
decimal.Decimal stores numbers as coefficient × 10^exponent — the same representation humans use. Operations on Decimal values are exact within the precision you specify.
Performance Trade-off
Decimal arithmetic is roughly 10–100× slower than float arithmetic because it is implemented in software rather than using the CPU’s floating-point unit. For this use case — generating a list of at most a few hundred values once at job-submission time — this cost is completely negligible. The correctness benefit justifies the trade-off unambiguously.
Big-O Complexity
The range-generation loop runs in O(n) time where n is the number of values in the range. The comma-split list path is also O(n). Both are linear in output size, which is the theoretical minimum for any algorithm that must produce n values.
🔵 Deep Dive: Python’s int type has arbitrary precision — it can represent integers of any size exactly. This is why parse_int doesn’t need the Decimal workaround. The problem only arises for fractional values.
What Breaks This Parser and How the Code Defends Against It
Edge Case 1: The Ambiguous Negative Range
Consider the input "-5-5". Is this start=-5, end=5 or start=-5 followed by garbage? The regex ^(-?\d+)\s*-\s*(-?\d+) handles it correctly: the -? makes the leading minus optional, and the \s*-\s* in the middle is the separator. Test it: parse_int("-5-5") → [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5].
Edge Case 2: Empty Parts After Split
"10,,20" would produce ["10", "", "20"] after split(","). The if part: guard skips empty strings silently. This means "10,,20" and "10, 20" produce the same result — a forgiving parser that doesn’t punish trailing commas.
Edge Case 3: Seed Count Overflow
"random:100000" would generate 100,000 random ints. The parser caps this at 10,000 with an explicit error message. Without this guard, a user could accidentally queue a 100,000-image batch.
🔴 Danger: random.randint is not cryptographically secure. It uses Python’s Mersenne Twister PRNG. For generative AI seeds this is entirely fine — you want reproducible but varied seeds, not secrets. Never use this function for security-sensitive applications.
Concurrency
The parser is a set of pure functions with no shared state. They are trivially thread-safe: you can call parse() from 50 concurrent threads without a lock and get correct results from every one.
You Now Own the Dispatch Pattern — Here’s the Skill You Gained
You’ve seen how to build a type-aware dispatch parser that is:
- Extensible: add a new type by adding one dict entry and one function
- Testable: each sub-parser is a pure function with no dependencies
- Correct: Decimal arithmetic eliminates floating-point drift for ranges
- Forgiving: permissive input handling with informative error messages
The specific skill is dispatch table routing — replacing if/elif chains with a dictionary of callables. This pattern appears everywhere in production Python: Django’s URL router, Python’s own ast.NodeVisitor, and virtually every plugin system you will encounter. Recognizing when a branching problem is actually a routing problem is a sign of intermediate-to-senior Python fluency.