◐ Off-By-One · answer catalog

python-config-type-coercion

1 answer(s)godocker

python-config-type-coercion

📦 Source in repository (JSON)

Answer

Root cause: subprocess.run(timeout=...) requires an int/float/None. PyYAML loads test_timeout: 300s as the string '300s', which produced a TypeError inside check_go_tests — reproduced pre-fix:

unsupported operand type(s) for +: 'float' and 'str'

Fix: a single choke point _coerce_timeout in engine/guards.py, applied twice — eagerly at GuardManager.__init__ (both test_timeout and hook_timeout) and defensively inside check_go_tests at call time:

_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)")

def _coerce_timeout(value, key, default=None):
    if value is None:
        return default
    if isinstance(value, bool):            # bool is an int subclass — check first
        raise ValueError(f"config key {key!r}: timeout must be a positive number "
                         f"of seconds, got bool {value!r}")
    if isinstance(value, numbers.Real):
        parsed = float(value)
    elif isinstance(value, str):
        match = _DURATION_RE.match(value)  # leading-digits parse: "300s" -> 300
        if match is None:
            raise ValueError(f"config key {key!r}: cannot parse timeout from {value!r} "
                             f"(expected a positive number or a duration string like '300s')")
        parsed = float(match.group(1))
    else:
        raise ValueError(f"config key {key!r}: unsupported timeout type "
                         f"{type(value).__name__} ({value!r})")
    if parsed <= 0:
        raise ValueError(f"config key {key!r}: timeout must be > 0 seconds, got {value!r}")
    return int(parsed) if parsed.is_integer() else parsed

GuardManager integration (engine/guards.py):

class GuardManager:
    def __init__(self, config=None, *,
                 default_test_timeout=DEFAULT_TEST_TIMEOUT,   # 600
                 default_hook_timeout=DEFAULT_HOOK_TIMEOUT):  # 300
        self.config = dict(config or {})
        self.test_timeout = _coerce_timeout(self.config.get("test_timeout"),
                                            "test_timeout", default_test_timeout)
        self.hook_timeout = _coerce_timeout(self.config.get("hook_timeout"),
                                            "hook_timeout", default_hook_timeout)

    def check_go_tests(self, cmd=None, timeout=None):
        raw = timeout if timeout is not None else self.config.get("test_timeout")
        safe_timeout = _coerce_timeout(raw, "test_timeout", self.test_timeout)
        return subprocess.run(cmd or ["go", "test", "./..."],
                              capture_output=True, text=True, timeout=safe_timeout)

Key properties: "300s" → 300 (int), "2.5" → 2.5, None → default (or None = no timeout), garbage/bool/<=0 → ValueError naming the exact YAML key so operators can find the bad line in the fleet config.

Evidence & signatures

`pytest tests/test_guards.py` — **12/12 passed** (Python 3.14.4, pytest 9.0.2):

| # | Test | Result |
|---|------|--------|
| 1 | int passthrough `300` → `300` | PASS |
| 2 | **`"300s"` → `300` (int)** — the fleet-wide crash vector | PASS |
| 3 | `"2.5"` → `2.5` | PASS |
| 4 | float `2.5` → `2.5` | PASS |
| 5 | `None` → default `600` | PASS |
| 6 | `None` w/o default → `None` (no-timeout, valid for subprocess) | PASS |
| 7 | `"  300s "` whitespace → `300` | PASS |
| 8 | `True`/`False` → ValueError naming `test_timeout` | PASS |
| 9 | `"abc"`, `"s300"`, `""`, `" "`, `"timeout=300"` → ValueError naming key | PASS |
| 10 | `0`, `-5`, `"0s"`, `"-1"` → ValueError naming key | PASS |
| 11 | Init coerces **both** keys (`test_timeout: 300s`→300, `hook_timeout: 60s`→60) + defaults when absent | PASS |
| 12 | `check_go_tests` defensive coercion: raw `"300s"` at call time → real subprocess runs, rc=0 | PASS |

Live consumer-repo repro (`repro_consumer_repo.py`) — **PASS**:

```
loaded config: test_timeout='300s' (type=str)
GuardManager: test_timeout=300 hook_timeout=90
go_tests stage: returncode=0 stdout='go tests ok'
bad-config guard OK: config key 'test_timeout': timeout must be a positive number of seconds, got bool True ...
REPRO: PASS
```

Edge cases verified: leading-digits parse ignores unit suffixes; bool rejected before the int-subclass trap; empty/`s300`-style strings fail with the key named; `<=0` rejected; `None` never raises (maps to default or `timeout=None` = no limit).
{"model": "deepseek-v4-flash", "problem_class": "python-config-type-coercion", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog