Skip to content

Fix TypeError on Python 3.14 for union-typed fields - #70

Closed
devangpratap wants to merge 1 commit into
marin-community:mainfrom
devangpratap:fix/py314-union-type-argparse
Closed

devangpratap wants to merge 1 commit into
marin-community:mainfrom
devangpratap:fix/py314-union-type-argparse

Conversation

@devangpratap

Copy link
Copy Markdown

Fixes #69.

The bug

On Python 3.14, building a parser for any config containing a union or Optional field raises before a single argument is parsed:

import dataclasses, draccus
from typing import Optional

@dataclasses.dataclass
class C:
    path: Optional[str] = None

draccus.parse(config_class=C, args=[])
# TypeError: str | None is not callable

One Optional[str] field is enough to make draccus unusable on 3.14.

Root cause

Python 3.14 added a callability check to argparse.add_argument, which raises TypeError: {type_func!r} is not callable at registration time. It previously accepted any object and only failed if a conversion was actually attempted.

Union objects do not pass that check on 3.14, because typing.Union was reimplemented:

typing.Optional[str] callable(...)
3.13 typing.Optional[str] (_GenericAlias) True
3.14 str | None (Union) False

FieldWrapper.arg_options passes the canonicalized field type straight through as type, so every union field trips the new check.

Why the fix is safe

The type value is display-only. ArgumentParser.parse_known_args already replaces it before parsing:

# draccus/argparsing.py
if "--help" not in args and "-h" not in args:
    for action in self.parser._actions:
        action.default = argparse.SUPPRESS
        action.type = str  # In practice, we want all processing to happen with yaml

draccus decodes every value itself in _postprocessing via decoding.decode, so type is never used for conversion. It survives only when --help is requested, purely to render the type name, which is what the existing __name__ assignment was for.

This is also why the bug does not show up as a conversion error on older versions: with type=Optional[str] a plain argparse parser fails on 3.12 and 3.13 with invalid Optional value: 'foo', but draccus never lets argparse convert.

So the fix keeps the displayed name and wraps only non-callable types in a pass-through. Callable types are untouched.

Test results

Python before after
3.14 120 failed, 235 passed 356 passed
3.13 355 passed 356 passed
3.12 355 passed 356 passed
3.9 349 passed, 1 failed 350 passed, 1 failed

The single 3.9 failure is tests/test_literals.py::test_literal_help_text, which fails identically on unmodified master; I confirmed it by stashing the change and re-running. It is unrelated to this PR.

All 15 syrupy snapshots pass unchanged on every version, which is the check that matters most here since it confirms --help output is byte-identical.

What is in the diff

  • draccus/wrappers/field_wrapper.py: _display_type helper, used where type was previously assigned raw.
  • tests/test_union.py: test_union_field_builds_parser_and_parses. It fails with TypeError at argparse.py:1549 on 3.14 without the fix and passes with it.
  • .github/workflows/pytest.yml: adds 3.14 to the matrix, which stopped at 3.11. That gap is why this shipped. If adding it causes any friction in your CI, drop that line and I am happy to follow up separately.

Downstream

This currently takes out LeRobot entirely: PreTrainedConfig there has two dict[str, PolicyFeature] | None fields, so every Policy.from_pretrained raises. Filed as huggingface/lerobot#4509.

On Python 3.14, building a parser for any config containing a union or
Optional field raises before a single argument is parsed:

    TypeError: str | None is not callable

3.14 validates that argparse's `type` is callable inside `add_argument`,
and union objects are not callable there: `typing.Optional[str]` is a
`_GenericAlias` on 3.13 but a `Union` on 3.14. One `Optional[str]` field
is enough to make draccus unusable.

The value is display-only. `parse_known_args` overwrites `action.type`
with `str` before parsing whenever --help is absent, since draccus decodes
every value itself, so `type` only renders the type name in --help.

Wrap every field's type in a pass-through and keep the declared type
reachable through `__draccus_type__` so `get_metavar` still renders
`--path [str]` rather than `--path str | None`. Wrapping unconditionally
rather than only for non-callables keeps behaviour identical on every
version: it also fixes `prog --path foo --help`, which errored with
"invalid Optional[str] value" on 3.9-3.13, and avoids writing `__name__`
onto cached typing objects.

Adds 3.12, 3.13 and 3.14 to the CI matrix, which stopped at 3.11 and is
why this was not caught.
@devangpratap
devangpratap force-pushed the fix/py314-union-type-argparse branch from a40c8ee to 6f3f164 Compare August 25, 2026 16:28
@devangpratap

Copy link
Copy Markdown
Author

Pushed an update after putting this through a second review pass. Two real problems in my original patch, plus one scope decision:

1. The first version regressed --help on 3.14. The pass-through reached get_metavar() as a bare function, and that helper branches on is_union(t) / is_tuple(t) / get_type_arguments(t), falling back to __name__ only when all of those fail. So the help output diverged by version:

3.13:  --path [str]        --mapping [dict[str,int]]
3.14:  --path str | None   --mapping Dict[str, int] | None

My "snapshots unchanged" claim above was true but did not prove what I implied: no .ambr snapshot covers a union field's metavar. Fixed by keeping the declared type reachable via __draccus_type__, which get_metavar now unwraps. Both versions render --path [str] again, and there is a test for it.

2. The callable(tpe) branch left a version inconsistency. typing.Optional[str] is callable on <=3.13 but raises when called, and parse_known_args only swaps action.type for str when --help is absent. So prog --path foo --help failed with invalid Optional[str] value on 3.9-3.13 while newly working on 3.14. Now every field takes the same pass-through path, which makes behaviour identical everywhere, fixes that pre-existing --help failure, and stops writing __name__ onto cached typing objects.

That second point widens this slightly beyond "fix the 3.14 crash". I think it belongs here since it is the same code path and removes the inconsistency the fix would otherwise introduce, but say the word and I will split it out.

3. CI matrix now adds 3.12 and 3.13 alongside 3.14, since those are exactly where the old callable branch used to run. Still happy to drop any of these lines if they cause friction.

Updated results:

Python before after
3.14 120 failed, 235 passed 360 passed
3.13 355 passed 360 passed
3.12 355 passed 360 passed
3.9 349 passed, 1 failed 354 passed, 1 failed

The 3.9 failure is still only tests/test_literals.py::test_literal_help_text, which fails identically on unmodified master.

I left pyproject.toml's Python classifiers alone. They currently claim only 3.9 and 3.10, which understates reality even before this PR, but what the project publicly advertises on PyPI is your call rather than something to change inside a bug fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python 3.14: any Optional[X] / union field raises TypeError: ... is not callable at parser construction

1 participant