Repository: Fatal1ty/mashumaro

Issue:
Allow for more complex logic for subclass Discrimnator field matching
**Is your feature request related to a problem? Please describe.**
In building a deserializer for a large complex configuration file, some subclasses have identical shapes, but it would still be useful to distinguish by a `type` field. Unfortunately, in our situation there are multiple values for `type` that map to the same subclass.

**Describe the solution you'd like**
I haven't dug into the existing code deep enough to know what's feasible, so there are probably a number of possible solutions (or maybe none, I suppose).

The least intrusive I could see would be having `variant_trigger_fn` return a `list[str]` instead of `str`.

A more involved solution might be adding an inverse to the `variant_trigger_fn` that takes a `str` and returns a `class`.

Finally, the workaround below could be improved upon if there was a hook in the base class that could accomplish the same thing.

**Describe alternatives you've considered**
I have been able to work around this using `__pre_deserialize__`:

```
@dataclass
class ClientEvent(DataClassDictMixin):
    client_ip: str
    type: str
    _type = "unknown"

@dataclass
class ClientConnectedEvent(ClientEvent):
    _type = "connected"

@dataclass
class ClientDisconnectedEvent(ClientEvent):
    _type = "disconnected"

def get_type(typ: str):
    if typ in ["disconnected", "connected"]:
        return typ
    if typ == "d/c":
        return "disconnected"
    return "unknown"

@dataclass
class AggregatedEvents(DataClassDictMixin):
    list: List[
        Annotated[
            ClientEvent, Discriminator(field="_type", include_subtypes=True, include_supertypes=True)
        ]
    ]
    @classmethod
    def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]:
        d["list"] = [dict({"_type": get_type(event["type"])}, **event) for event in d["list"]]
        return d

events = AggregatedEvents.from_dict(
    {
        "list": [
            {"type": "connected", "client_ip": "10.0.0.42"},
            {"type": "disconnected", "client_ip": "10.0.0.42"},
            {"type": "N/A", "client_ip": "10.0.0.42"},
            {"type": "d/c", "client_ip": "10.0.0.42"},
        ]
    }
)      

# Produces:
AggregatedEvents(list=[ClientConnectedEvent(client_ip='10.0.0.42', type='connected'), ClientDisconnectedEvent(client_ip='10.0.0.42', type='disconnected'), ClientEvent(client_ip='10.0.0.42', type='N/A'), ClientDisconnectedEvent(client_ip='10.0.0.42', type='d/c')])
 ```

This works reasonably well (and preserves the original `type` which can be useful). The major downside is that the `__pre_deserialize__` method needs to be implemented in each class that makes use of `ClientEvent`, and in our situation that ends up being several. It would be more convenient if there was a hook in the `ClientEvent` base class that could accomplish the same thing.

**Additional context**
I'm just getting started with this library and it's great so far. It's possible I'm missing something and this is already doable. Alternatively, the workaround, well, works, so feel free to close this if this is not a feature you're looking to add. Thanks!

Comments:

Patch:
diff --git a/README.md b/README.md
index e224048..3dc602a 100644
--- a/README.md
+++ b/README.md
@@ -2020,7 +2020,7 @@ It has the following parameters that affects class selection rules:
   by which all the variants can be distinguished
 * `include_subtypes` — allow to deserialize subclasses
 * `include_supertypes` — allow to deserialize superclasses
-* `variant_tagger_fn` — a custom function used to generate a tag value
+* `variant_tagger_fn` — a custom function used to generate tag values
   associated with a variant
 
 By default, each variant that you want to discriminate by tags should have a
@@ -2039,10 +2039,11 @@ following forms:
 > processed during serialization.
 
 However, it is possible to use discriminator without the class-level
-attribute. You can provide a custom function that generates a variant tag
-value. This function should take a class as the only argument and return a
-value of the basic type like `str` or `int`. The common practice is to use
-a class name as a tag value:
+attribute. You can provide a custom function that generates one or many variant
+tag values. This function should take a class as the only argument and return
+either a single value of the basic type like `str` or `int` or a list of them
+to associate multiple tags with a variant. The common practice is to use
+a class name as a single tag value:
 
 ```python
 variant_tagger_fn = lambda cls: cls.__name__
@@ -2466,6 +2467,15 @@ disconnected_event = ClientEvent.from_dict(
 assert disconnected_event == ClientDisconnectedEvent(IPv4Address("10.0.0.42"))
 ```
 
+If we need to associate multiple tags with a single variant, we can return
+a list of tags:
+
+```python
+def client_event_tagger(cls):
+    name = cls.__name__[6:-5]
+    return [name.lower(), name.upper()]
+```
+
 ### Code generation options
 
 #### Add `omit_none` keyword argument
diff --git a/mashumaro/core/meta/types/unpack.py b/mashumaro/core/meta/types/unpack.py
index b979d2a..b6b1853 100644
--- a/mashumaro/core/meta/types/unpack.py
+++ b/mashumaro/core/meta/types/unpack.py
@@ -347,12 +347,17 @@ class DiscriminatedUnionUnpackerBuilder(AbstractUnpackerBuilder):
             with lines.indent("except (KeyError, AttributeError):"):
                 lines.append(f"variants_map = {variants_map}")
                 with lines.indent(f"for variant in {variants}:"):
-                    with lines.indent("try:"):
-                        lines.append(
-                            f"variants_map[{variant_tagger_expr}] = variant"
+                    if discriminator.variant_tagger_fn is not None:
+                        self._add_register_variant_tags(
+                            lines, variant_tagger_expr
                         )
-                    with lines.indent("except KeyError:"):
-                        lines.append("continue")
+                    else:
+                        with lines.indent("try:"):
+                            self._add_register_variant_tags(
+                                lines, variant_tagger_expr
+                            )
+                        with lines.indent("except KeyError:"):
+                            lines.append("continue")
                     self._add_build_variant_unpacker(
                         spec, lines, variant_method_name, variant_method_call
                     )
@@ -457,6 +462,19 @@ class DiscriminatedUnionUnpackerBuilder(AbstractUnpackerBuilder):
                     lines.append(f"return {attrs}.{variant_method_call}")
                 lines.append("except Exception: pass")
 
+    def _add_register_variant_tags(
+        self, lines: CodeLines, variant_tagger_expr: str
+    ) -> None:
+        if self.discriminator.variant_tagger_fn:
+            lines.append(f"variant_tags = {variant_tagger_expr}")
+            with lines.indent("if type(variant_tags) is list:"):
+                with lines.indent("for varint_tag in variant_tags:"):
+                    lines.append("variants_map[varint_tag] = variant")
+            with lines.indent("else:"):
+                lines.append("variants_map[variant_tags] = variant")
+        else:
+            lines.append(f"variants_map[{variant_tagger_expr}] = variant")
+
 
 class SubtypeUnpackerBuilder(DiscriminatedUnionUnpackerBuilder):
     def _get_variants_attr(self, spec: ValueSpec) -> str:


Test patch:
diff --git a/tests/test_discriminated_unions/test_parent_via_config.py b/tests/test_discriminated_unions/test_parent_via_config.py
index 6b9dd90..d73f4db 100644
--- a/tests/test_discriminated_unions/test_parent_via_config.py
+++ b/tests/test_discriminated_unions/test_parent_via_config.py
@@ -426,6 +426,52 @@ class _VariantWitCustomTaggerSub2(_VariantWitCustomTagger):
     pass
 
 
+@dataclass
+class VariantWithMultipleTags(DataClassDictMixin):
+    class Config(BaseConfig):
+        discriminator = Discriminator(
+            field="type",
+            include_subtypes=True,
+            variant_tagger_fn=lambda cls: [
+                cls.__name__.lower(),
+                cls.__name__.upper(),
+            ],
+        )
+
+
+@dataclass
+class VariantWithMultipleTagsOne(VariantWithMultipleTags):
+    pass
+
+
+@dataclass
+class VariantWithMultipleTagsTwo(VariantWithMultipleTags):
+    pass
+
+
+@dataclass
+class _VariantWithMultipleTags:
+    class Config(BaseConfig):
+        discriminator = Discriminator(
+            field="type",
+            include_subtypes=True,
+            variant_tagger_fn=lambda cls: [
+                cls.__name__.lower(),
+                cls.__name__.upper(),
+            ],
+        )
+
+
+@dataclass
+class _VariantWithMultipleTagsOne(_VariantWithMultipleTags):
+    pass
+
+
+@dataclass
+class _VariantWithMultipleTagsTwo(_VariantWithMultipleTags):
+    pass
+
+
 def test_by_subtypes():
     assert VariantBySubtypes.from_dict(X_1) == VariantBySubtypesSub1(x=DT_STR)
     assert decode(X_1, _VariantBySubtypes) == _VariantBySubtypesSub1(x=DT_STR)
@@ -749,3 +795,19 @@ def test_by_subtypes_with_custom_variant_tagger():
         VariantWitCustomTagger.from_dict({"type": "unknown"})
     with pytest.raises(SuitableVariantNotFoundError):
         decode({"type": "unknown"}, _VariantWitCustomTagger)
+
+
+def test_by_subtypes_with_custom_variant_tagger_and_multiple_tags():
+    for variant in (VariantWithMultipleTagsOne, VariantWithMultipleTagsTwo):
+        for tag in (variant.__name__.lower(), variant.__name__.upper()):
+            assert (
+                VariantWithMultipleTags.from_dict({"type": tag}) == variant()
+            )
+    for variant in (_VariantWithMultipleTagsOne, _VariantWithMultipleTagsTwo):
+        for tag in (variant.__name__.lower(), variant.__name__.upper()):
+            assert decode({"type": tag}, _VariantWithMultipleTags) == variant()
+
+    with pytest.raises(SuitableVariantNotFoundError):
+        VariantWithMultipleTags.from_dict({"type": "unknown"})
+    with pytest.raises(SuitableVariantNotFoundError):
+        decode({"type": "unknown"}, _VariantWithMultipleTags)
diff --git a/tests/test_discriminated_unions/test_union_by_field.py b/tests/test_discriminated_unions/test_union_by_field.py
index ad8db13..0ca230b 100644
--- a/tests/test_discriminated_unions/test_union_by_field.py
+++ b/tests/test_discriminated_unions/test_union_by_field.py
@@ -341,6 +341,28 @@ class VariantWitCustomTaggerOwner(
     pass
 
 
+@dataclass
+class _VariantWitCustomTaggerWithMultipleTagsOwner:
+    x: Annotated[
+        Union[VariantWitCustomTagger1, VariantWitCustomTagger2],
+        Discriminator(
+            field="type",
+            include_supertypes=True,
+            variant_tagger_fn=lambda cls: [
+                cls.__name__.lower(),
+                cls.__name__.upper(),
+            ],
+        ),
+    ]
+
+
+@dataclass
+class VariantWitCustomTaggerWithMultipleTagsOwner(
+    _VariantWitCustomTaggerWithMultipleTagsOwner, DataClassDictMixin
+):
+    pass
+
+
 def test_by_field_with_supertypes():
     decoder = BasicDecoder(_ByFieldWithSupertypes)
 
@@ -748,3 +770,29 @@ def test_by_field_with_subtypes_with_custom_variant_tagger():
         )
         with pytest.raises(InvalidFieldValue):
             func({"x": {"type": "unknown"}})
+
+
+def test_by_field_with_subtypes_with_custom_variant_tagger_and_multiple_tags():
+    decoder = BasicDecoder(_VariantWitCustomTaggerWithMultipleTagsOwner)
+
+    for func, cls in (
+        (
+            VariantWitCustomTaggerWithMultipleTagsOwner.from_dict,
+            VariantWitCustomTaggerWithMultipleTagsOwner,
+        ),
+        (decoder.decode, _VariantWitCustomTaggerWithMultipleTagsOwner),
+    ):
+        assert func({"x": {"type": "variantwitcustomtagger1"}}) == cls(
+            VariantWitCustomTagger1()
+        )
+        assert func({"x": {"type": "variantwitcustomtagger2"}}) == cls(
+            VariantWitCustomTagger2()
+        )
+        assert func({"x": {"type": "VARIANTWITCUSTOMTAGGER1"}}) == cls(
+            VariantWitCustomTagger1()
+        )
+        assert func({"x": {"type": "VARIANTWITCUSTOMTAGGER2"}}) == cls(
+            VariantWitCustomTagger2()
+        )
+        with pytest.raises(InvalidFieldValue):
+            func({"x": {"type": "unknown"}})