Bug 2343916 - python-cattrs fails to build with Python 3.14: TypeError: <class 'tests.strategies.test_include_subclasses.GrandChild'> has no usable non-default attributes
Summary: python-cattrs fails to build with Python 3.14: TypeError: <class 'tests.strat...
Keywords:
Status: NEW
Alias: None
Product: Fedora
Classification: Fedora
Component: python-cattrs
Version: rawhide
Hardware: Unspecified
OS: Unspecified
unspecified
unspecified
Target Milestone: ---
Assignee: Ankur Sinha (FranciscoD)
QA Contact: Fedora Extras Quality Assurance
URL:
Whiteboard:
Depends On:
Blocks: PYTHON3.14
TreeView+ depends on / blocked
 
Reported: 2025-02-05 11:27 UTC by Karolina Surma
Modified: 2025-03-31 11:01 UTC (History)
5 users (show)

Fixed In Version:
Clone Of:
Environment:
Last Closed:
Type: Bug
Embargoed:


Attachments (Terms of Use)


Links
System ID Private Priority Status Summary Last Updated
Github python-attrs cattrs issues 626 0 None open Python 3.14.0a4: TypeError: <class '…'> has no usable non-default attributes 2025-02-09 20:46:10 UTC

Description Karolina Surma 2025-02-05 11:27:40 UTC
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.

Comment 1 Ben Beasley 2025-02-09 20:46:10 UTC
Reported upstream as https://github.com/python-attrs/cattrs/issues/626. I don’t really have any insight into the root cause.

Comment 2 Miro Hrončok 2025-03-31 11:01:09 UTC
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.


Note You need to log in before you can comment on or make changes to this bug.