Repository: python-attrs/cattrs

Issue:
Literal[...] with more than one item raises exception
* cattrs version: 1.9.0 (unreleased, installed from GitHub)
* Python version: 3.8.10
* Operating System: Ubuntu running on WSL2 on Windows 10

### Description

The `Literal[...]` type allows for multiple values to be used, like `Literal["one", "two"]`, but `cattr` seems to only support the very first value, and throws an exception when structuring the others.

```py
from typing import Literal, Optional

import attr
import cattr


@attr.s(auto_attribs=True)
class A:
    foo: Optional[Literal["one", "two"]] = None


cattr.structure({}, A)
# A(foo=None)

cattr.structure({"foo": "one"}, A)
# A(foo='one')

cattr.structure({"foo": "two"}, A)
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "/venv/dev/lib/python3.8/site-packages/cattr/converters.py", line 295, in structure
#     return self._structure_func.dispatch(cl)(obj, cl)
#   File "<cattrs generated structure __main__.A>", line 5, in structure_A
#   File "/venv/dev/lib/python3.8/site-packages/cattr/converters.py", line 526, in _structure_optional
#     return self._structure_func.dispatch(other)(obj, other)
#   File "/venv/dev/lib/python3.8/site-packages/cattr/converters.py", line 404, in _structure_literal
#     raise Exception(f"Literal {type} not equal to {val}")
# Exception: Literal typing.Literal['one', 'two'] not equal to two
```

https://docs.python.org/3/library/typing.html#typing.Literal

This issue has also been reported on https://github.com/python-attrs/cattrs/issues/122#issuecomment-853771560, but since that issue is closed, I decided to open a new one for this.

What I'm currently doing as a work-around is using `Enum`s instead of `Literal`s, since `cattr` has some awesome support for `Enum`s.


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/python-attrs/cattrs/pull/182?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) Report
> Merging [#182](https://codecov.io/gh/python-attrs/cattrs/pull/182?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) (2990a82) into [main](https://codecov.io/gh/python-attrs/cattrs/commit/dbf6a8f33a9bd4beb972442f0667240e557d8071?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) (dbf6a8f) will **decrease** coverage by `7.40%`.
> The diff coverage is `0.00%`.

[![Impacted file tree graph](https://codecov.io/gh/python-attrs/cattrs/pull/182/graphs/tree.svg?width=650&height=150&src=pr&token=FUzhYOgsyy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs)](https://codecov.io/gh/python-attrs/cattrs/pull/182?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs)

```diff
@@            Coverage Diff             @@
##             main     #182      +/-   ##
==========================================
- Coverage   97.82%   90.41%   -7.41%     
==========================================
  Files          15       15              
  Lines         966      960       -6     
==========================================
- Hits          945      868      -77     
- Misses         21       92      +71     
```


| [Impacted Files](https://codecov.io/gh/python-attrs/cattrs/pull/182?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) | Coverage Δ | |
|---|---|---|
| [src/cattr/converters.py](https://codecov.io/gh/python-attrs/cattrs/pull/182/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs#diff-c3JjL2NhdHRyL2NvbnZlcnRlcnMucHk=) | `92.33% <0.00%> (-6.72%)` | :arrow_down: |
| [src/cattr/\_compat.py](https://codecov.io/gh/python-attrs/cattrs/pull/182/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs#diff-c3JjL2NhdHRyL19jb21wYXQucHk=) | `60.81% <0.00%> (-33.79%)` | :arrow_down: |
| [src/cattr/gen.py](https://codecov.io/gh/python-attrs/cattrs/pull/182/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs#diff-c3JjL2NhdHRyL2dlbi5weQ==) | `96.73% <0.00%> (-0.02%)` | :arrow_down: |
| [src/cattr/dispatch.py](https://codecov.io/gh/python-attrs/cattrs/pull/182/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs#diff-c3JjL2NhdHRyL2Rpc3BhdGNoLnB5) | `100.00% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-attrs/cattrs/pull/182?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-attrs/cattrs/pull/182?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs). Last update [af5b09f...2990a82](https://codecov.io/gh/python-attrs/cattrs/pull/182?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs).

MicaelJarniac: I have absolutely no idea how coverage went down. I even added tests for the things I changed.
Tinche: LGTM! I wouldn't worry too much about minute changes in coverage, a lot of the tests are Hypothesis tests which aren't strictly deterministic.
Tinche: Thanks for this!

Patch:
diff --git a/src/cattr/converters.py b/src/cattr/converters.py
index edb62a9..72b6eab 100644
--- a/src/cattr/converters.py
+++ b/src/cattr/converters.py
@@ -400,8 +400,8 @@ class Converter(object):
 
     @staticmethod
     def _structure_literal(val, type):
-        if val != type.__args__[0]:
-            raise Exception(f"Literal {type} not equal to {val}")
+        if val not in type.__args__:
+            raise Exception(f"{val} not in literal {type}")
         return val
 
     # Attrs classes.


Test patch:
diff --git a/tests/test_structure_attrs.py b/tests/test_structure_attrs.py
index 10224c2..dbd5a6c 100644
--- a/tests/test_structure_attrs.py
+++ b/tests/test_structure_attrs.py
@@ -164,6 +164,26 @@ def test_structure_literal(converter_cls):
     ) == ClassWithLiteral(4)
 
 
+@pytest.mark.skipif(is_py37, reason="Not supported on 3.7")
+@pytest.mark.parametrize("converter_cls", [Converter, GenConverter])
+def test_structure_literal_multiple(converter_cls):
+    """Structuring a class with a literal field works."""
+    from typing import Literal
+
+    converter = converter_cls()
+
+    @define
+    class ClassWithLiteral:
+        literal_field: Literal[4, 5] = 4
+
+    assert converter.structure(
+        {"literal_field": 4}, ClassWithLiteral
+    ) == ClassWithLiteral(4)
+    assert converter.structure(
+        {"literal_field": 5}, ClassWithLiteral
+    ) == ClassWithLiteral(5)
+
+
 @pytest.mark.skipif(is_py37, reason="Not supported on 3.7")
 @pytest.mark.parametrize("converter_cls", [Converter, GenConverter])
 def test_structure_literal_error(converter_cls):
@@ -180,6 +200,22 @@ def test_structure_literal_error(converter_cls):
         converter.structure({"literal_field": 3}, ClassWithLiteral)
 
 
+@pytest.mark.skipif(is_py37, reason="Not supported on 3.7")
+@pytest.mark.parametrize("converter_cls", [Converter, GenConverter])
+def test_structure_literal_multiple_error(converter_cls):
+    """Structuring a class with a literal field can raise an error."""
+    from typing import Literal
+
+    converter = converter_cls()
+
+    @define
+    class ClassWithLiteral:
+        literal_field: Literal[4, 5] = 4
+
+    with pytest.raises(Exception):
+        converter.structure({"literal_field": 3}, ClassWithLiteral)
+
+
 @pytest.mark.parametrize("converter_type", [Converter, GenConverter])
 def test_structure_fallback_to_attrib_converters(converter_type):
     attrib_converter = Mock()