python-attrs / python-attrs/cattrs

Allow disambiguating union types by unique literal types

Open
#273 9 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
1.1k
Forks
159
Avg merge
12h 21m
Merged PRs (30d)
6

Description

Description

Right now, it seems like union types are only supported if (a) the options are all attrs classes or None and (b) the attrs classes have unique attributes.

I use a ton of "tagged unions", so it would be great there was support for those. (They have one attribute that has an unambiguous value). Here's an example:

@attrs.define
class MatchedThing:
    matched: Literal[True] = True
    xxxxxx: str


@attrs.define
class UnmatchedThing:
    matched: Literal[False] = False
    yyyyyy: str


@attrs.define
class MaybeSomething:
    something: MatchedThing | UnmatchedThing

In this example, I'm using a literal boolean but much more common is to use a literal string, for example type: Literal["request", "response", "error"]. Other possibilities are literal integers (covered by the below code) and literal enum values (not sure if those work with the below code)

This pattern is very common in JSON files.

Here's an implementation that seems to work for me (attrs classes only though the same should work with typing.get_type_hints(anything))`:

def create_literal_field_disambiguator_function(*classes: type) -> Callable[..., Any]:
    """Given attr classes, generate a disambiguation function.
    The function is based a common field with unique literal value types."""
    common_attrs: set[str] = set.intersection(
        *(set(at.name for at in attrs.fields(get_origin(cl) or cl)) for cl in classes)
    )

    def check_attr_is_unique(attr: str) -> dict[str, type] | None:
        attr_value_to_class = {}
        for cl in classes:
            field_type = getattr(attrs.fields(get_origin(cl) or cl), attr).type
            if not cattrs.converters.is_literal(field_type):
                # type is not a literal
                return None
            for value in typing.get_args(field_type):
                if value in attr_value_to_class:
                    # this value is ambiguous -> attribute not usable for disambiguation
                    return None
                attr_value_to_class[value] = cl
        return attr_value_to_class

    unique_attr_value_to_class = None
    # try each attribute to see if the types are Literal and the values are unique
    for attr in common_attrs:
        unique_attr_value_to_class = check_attr_is_unique(attr)
        if unique_attr_value_to_class:
            # found unique attribute
            break
    else:
        raise ValueError(
            f"{classes} cannot be disambiguated with a common literal attribute. checked types of common fields: {common_attrs}"
        )

    def dis_func(data: Mapping, type: Any) -> Optional[type]:
        if not isinstance(data, Mapping):
            raise ValueError("Only input mappings are supported.")
        return unique_attr_value_to_class[data[attr]]

    return dis_func

...
def get_converter():
    ...
    converter.register_structure_hook_factory(
        lambda t: cattrs.converters.is_attrs_union(t),
        lambda t: create_literal_field_disambiguator_function(*typing.get_args(t)),
    )
...

It should probably be added to the existing disambiguation.py machinery, but those parts aren't publicly exposed

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the existing disambiguation.py machinery and the get_converter() registration path, then compare the proposed literal-field approach with current union disambiguation behavior. Done means tagged unions with unique Literal values can be structured without requiring unique attribute names, while existing union handling remains intact.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.