python-cattrs fails to build with Python 3.14.0a4. There are multiple errors and failures of the test suite, some similar to this: ___________________________ test_structure_as_union ____________________________ [gw0] linux -- Python 3.14.0 /usr/bin/python3 def test_structure_as_union(): converter = Converter() > include_subclasses(Parent, converter) converter = <cattrs.converters.Converter object at 0x7f4d91ad8c10> tests/strategies/test_include_subclasses.py:184: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ../BUILDROOT/usr/lib/python3.14/site-packages/cattrs/strategies/_subclasses.py:75: in include_subclasses _include_subclasses_without_union_strategy( cl = <class 'tests.strategies.test_include_subclasses.Parent'> converter = <cattrs.converters.Converter object at 0x7f4d91ad8c10> overrides = None parent_subclass_tree = (<class 'tests.strategies.test_include_subclasses.Parent'>, <class 'tests.strategies.test_include_subclasses.Child1'>,...'tests.strategies.test_include_subclasses.GrandChild'>, <class 'tests.strategies.test_include_subclasses.Child2'>, ...) subclasses = None union_strategy = None ../BUILDROOT/usr/lib/python3.14/site-packages/cattrs/strategies/_subclasses.py:117: in _include_subclasses_without_union_strategy dis_fn = converter._get_dis_func(subclass_union, overrides=overrides) base_struct_hook = <function structure_Parent at 0x7f4d91c2f060> base_unstruct_hook = <function unstructure_Parent at 0x7f4d91c2e6c0> cl = <class 'tests.strategies.test_include_subclasses.Parent'> cls_is_cl = <function _include_subclasses_without_union_strategy.<locals>.cls_is_cl at 0x7f4d91c2f740> converter = <cattrs.converters.Converter object at 0x7f4d91ad8c10> overrides = None parent_subclass_tree = (<class 'tests.strategies.test_include_subclasses.Parent'>, <class 'tests.strategies.test_include_subclasses.Child1'>,...'tests.strategies.test_include_subclasses.GrandChild'>, <class 'tests.strategies.test_include_subclasses.Child2'>, ...) subclass_union = typing.Union[tests.strategies.test_include_subclasses.Child2, tests.strategies.test_include_subclasses.Child1, tests.s...ubclasses.Child1, tests.strategies.test_include_subclasses.Parent, tests.strategies.test_include_subclasses.GrandChild] ../BUILDROOT/usr/lib/python3.14/site-packages/cattrs/converters.py:981: in _get_dis_func return create_default_dis_func( overrides = None self = <cattrs.converters.Converter object at 0x7f4d91ad8c10> union = typing.Union[tests.strategies.test_include_subclasses.Child2, tests.strategies.test_include_subclasses.Child1, tests.s...ubclasses.Child1, tests.strategies.test_include_subclasses.Parent, tests.strategies.test_include_subclasses.GrandChild] union_types = (<class 'tests.strategies.test_include_subclasses.Child2'>, <class 'tests.strategies.test_include_subclasses.Child1'>,...ass 'tests.strategies.test_include_subclasses.Child1'>, <class 'tests.strategies.test_include_subclasses.Parent'>, ...) use_literals = True _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ converter = <cattrs.converters.Converter object at 0x7f4d91ad8c10> use_literals = True, overrides = [{}, {}, {}, {}, {}, {}, ...] classes = (<class 'tests.strategies.test_include_subclasses.Child2'>, <class 'tests.strategies.test_include_subclasses.Child1'>,...ass 'tests.strategies.test_include_subclasses.Child1'>, <class 'tests.strategies.test_include_subclasses.Parent'>, ...) def create_default_dis_func( converter: BaseConverter, *classes: type[AttrsInstance], use_literals: bool = True, overrides: ( dict[str, AttributeOverride] | Literal["from_converter"] ) = "from_converter", ) -> Callable[[Mapping[Any, Any]], type[Any] | None]: """Given attrs classes or dataclasses, generate a disambiguation function. The function is based on unique fields without defaults or unique values. :param use_literals: Whether to try using fields annotated as literals for disambiguation. :param overrides: Attribute overrides to apply. .. versionchanged:: 24.1.0 Dataclasses are now supported. """ if len(classes) < 2: raise ValueError("At least two classes required.") if overrides == "from_converter": overrides = [ getattr(converter.get_structure_hook(c), "overrides", {}) for c in classes ] else: overrides = [overrides for _ in classes] # first, attempt for unique values if use_literals: # requirements for a discriminator field: # (... TODO: a single fallback is OK) # - it must always be enumerated cls_candidates = [ { at.name for at in adapted_fields(get_origin(cl) or cl) if is_literal(at.type) } for cl in classes ] # literal field names common to all members discriminators: set[str] = cls_candidates[0] for possible_discriminators in cls_candidates: discriminators &= possible_discriminators best_result = None best_discriminator = None for discriminator in discriminators: # maps Literal values (strings, ints...) to classes mapping = defaultdict(list) for cl in classes: for key in get_args( fields_dict(get_origin(cl) or cl)[discriminator].type ): mapping[key].append(cl) if best_result is None or max(len(v) for v in mapping.values()) <= max( len(v) for v in best_result.values() ): best_result = mapping best_discriminator = discriminator if ( best_result and best_discriminator and max(len(v) for v in best_result.values()) != len(classes) ): final_mapping = { k: v[0] if len(v) == 1 else Union[tuple(v)] for k, v in best_result.items() } def dis_func(data: Mapping[Any, Any]) -> type | None: if not isinstance(data, Mapping): raise ValueError("Only input mappings are supported.") return final_mapping[data[best_discriminator]] return dis_func # next, attempt for unique keys # NOTE: This could just as well work with just field availability and not # uniqueness, returning Unions ... it doesn't do that right now. cls_and_attrs = [ (cl, *_usable_attribute_names(cl, override)) for cl, override in zip(classes, overrides) ] # For each class, attempt to generate a single unique required field. uniq_attrs_dict: dict[str, type] = {} # We start from classes with the largest number of unique fields # so we can do easy picks first, making later picks easier. cls_and_attrs.sort(key=lambda c_a: len(c_a[1]), reverse=True) fallback = None # If none match, try this. for cl, cl_reqs, back_map in cls_and_attrs: # We do not have to consider classes we've already processed, since # they will have been eliminated by the match dictionary already. other_classes = [ c_and_a for c_and_a in cls_and_attrs if c_and_a[0] is not cl and c_and_a[0] not in uniq_attrs_dict.values() ] other_reqs = reduce(or_, (c_a[1] for c_a in other_classes), set()) uniq = cl_reqs - other_reqs # We want a unique attribute with no default. cl_fields = fields_dict(get_origin(cl) or cl) for maybe_renamed_attr_name in uniq: orig_name = back_map[maybe_renamed_attr_name] if cl_fields[orig_name].default in (NOTHING, MISSING): break else: if fallback is None: fallback = cl continue > raise TypeError(f"{cl} has no usable non-default attributes") E TypeError: <class 'tests.strategies.test_include_subclasses.GrandChild'> has no usable non-default attributes back_map = {'c1': 'c1', 'p': 'p'} best_discriminator = None best_result = None cl = <class 'tests.strategies.test_include_subclasses.GrandChild'> cl_fields = {'c1': Attribute(name='c1', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=No...adata=mappingproxy({}), type=<class 'int'>, converter=None, kw_only=False, inherited=True, on_setattr=None, alias='p')} cl_reqs = {'c1', 'p'} classes = (<class 'tests.strategies.test_include_subclasses.Child2'>, <class 'tests.strategies.test_include_subclasses.Child1'>,...ass 'tests.strategies.test_include_subclasses.Child1'>, <class 'tests.strategies.test_include_subclasses.Parent'>, ...) cls_and_attrs = [(<class 'tests.strategies.test_include_subclasses.GrandChild'>, {'c1', 'g', 'p'}, {'c1': 'c1', 'g': 'g', 'p': 'p'}), ...sses.Child2'>, {'p'}, {'p': 'p'}), (<class 'tests.strategies.test_include_subclasses.Child1'>, {'p'}, {'p': 'p'}), ...] cls_candidates = [set(), set(), set(), set(), set(), set(), ...] converter = <cattrs.converters.Converter object at 0x7f4d91ad8c10> discriminators = set() fallback = <class 'tests.strategies.test_include_subclasses.Child1'> maybe_renamed_attr_name = 'c2' orig_name = 'c2' other_classes = [(<class 'tests.strategies.test_include_subclasses.Child1'>, {'c1', 'p'}, {'c1': 'c1', 'p': 'p'}), (<class 'tests.stra...ubclasses.Child1'>, {'p'}, {'p': 'p'}), (<class 'tests.strategies.test_include_subclasses.Parent'>, {'p'}, {'p': 'p'})] other_reqs = {'c1', 'p'} overrides = [{}, {}, {}, {}, {}, {}, ...] possible_discriminators = set() uniq = set() uniq_attrs_dict = {'c2': <class 'tests.strategies.test_include_subclasses.Child2'>, 'g': <class 'tests.strategies.test_include_subclasses.GrandChild'>} use_literals = True https://docs.python.org/3.14/whatsnew/3.14.html For the build logs, see: https://copr-be.cloud.fedoraproject.org/results/@python/python3.14/fedora-rawhide-x86_64/08604793-python-cattrs/ For all our attempts to build python-cattrs with Python 3.14, see: https://copr.fedorainfracloud.org/coprs/g/python/python3.14/package/python-cattrs/ Testing and mass rebuild of packages is happening in copr. You can follow these instructions to test locally in mock if your package builds with Python 3.14: https://copr.fedorainfracloud.org/coprs/g/python/python3.14/ Let us know here if you have any questions. Python 3.14 is planned to be included in Fedora 43. To make that update smoother, we're building Fedora packages with all pre-releases of Python 3.14. A build failure prevents us from testing all dependent packages (transitive [Build]Requires), so if this package is required a lot, it's important for us to get it fixed soon. We'd appreciate help from the people who know this package best, but if you don't want to work on this now, let us know so we can try to work around it on our side.
Reported upstream as https://github.com/python-attrs/cattrs/issues/626. I don’t really have any insight into the root cause.
I updated the upstream ticket with new failures in a6. I also reported a related regression to Python upstream: https://github.com/python/cpython/issues/131933 -- if they decide the change is intentional (as they often unfortunately do), adjusting the tests to compare typing Unions for equality rather than identity should be easy.