Bug 2343916

Summary: python-cattrs fails to build with Python 3.14: TypeError: <class 'tests.strategies.test_include_subclasses.GrandChild'> has no usable non-default attributes
Product: [Fedora] Fedora Reporter: Karolina Surma <ksurma>
Component: python-cattrsAssignee: Ankur Sinha (FranciscoD) <sanjay.ankur>
Status: CLOSED RAWHIDE QA Contact: Fedora Extras Quality Assurance <extras-qa>
Severity: unspecified Docs Contact:
Priority: unspecified    
Version: rawhideCC: code, ksurma, mhroncok, neuro-sig, sanjay.ankur
Target Milestone: ---   
Target Release: ---   
Hardware: Unspecified   
OS: Unspecified   
Whiteboard:
Fixed In Version: Doc Type: If docs needed, set a value
Doc Text:
Story Points: ---
Clone Of: Environment:
Last Closed: 2025-06-05 11:48:45 UTC Type: Bug
Regression: --- Mount Type: ---
Documentation: --- CRM:
Verified Versions: Category: ---
oVirt Team: --- RHEL 7.3 requirements from Atomic Host:
Cloudforms Team: --- Target Upstream Version:
Embargoed:
Bug Depends On:    
Bug Blocks: 2322407    

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.

Comment 3 Ben Beasley 2025-06-05 11:48:45 UTC
Based on testing with the Python 3.14 COPR, this should be fixed in Rawhide with the 25.x releases.