Opened 4 years ago

Last modified 12 days ago

#34156 assigned Bug

TypedChoiceField.to_python() always returns a string

Reported by: Yoshio Hasegawa Owned by: Django Sprints
Component: Forms Version: 4.1
Severity: Normal Keywords: Form, TypedChoiceField, IntegerChoices, Coercion
Cc: Triage Stage: Accepted
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

Coercion always fails when using IntegerChoices with a TypedChoiceField in a Django form.

When a value is cleaned in a TypedChoiceField, the inherited ChoiceField's to_python() method will return the value casted as a string. This means that when coercion with an IntegerChoices object is attempted, it will always fail as IntegerChoices will expect an int type when instantiated.

Example

class SomeIntegerChoice(models.IntegerChoices):
    VAL_2350 = (2350, "value 2350")
    VAL_4100 = (4100, "value 4100")
    VAL_8760 = (8760, "value 8760")
form.fields["Integer_choice"] = forms.TypedChoiceField(
    required=required,
    choices=SomeIntegerChoice.choices,
    coerce=SomeIntegerChoice,
)

This field will never pass validation since ChoiceField (inherited by TypedChoiceField) has a to_python() method that casts the provided value as a string:

# class ChoiceField:
# ...
def to_python(self, value):
    """Return a string."""
    if value in self.empty_values:
        return ""
    return str(value)

To explain further... ChoiceField.to_python() will be called when TypedChoiceField attempts to clean() a value. After the value is cleaned, it is coerced using the provided class definition via the coerce property.

Here is example code from Django to show how this happens:

# class TypedChoiceField:
# ...
def _coerce(self, value):
    """
    Validate that the value can be coerced to the right type (if not empty).
    """
    if value == self.empty_value or value in self.empty_values:
        return self.empty_value
    try:
        value = self.coerce(value)
    except (ValueError, TypeError, ValidationError):
        raise ValidationError(
            self.error_messages["invalid_choice"],
            code="invalid_choice",
            params={"value": value},
        )
    return value

def clean(self, value):
    value = super().clean(value)
    return self._coerce(value)

Change History (4)

comment:1 by Mariusz Felisiak, 4 years ago

Resolution: invalid
Status: newclosed

As far as I'm aware, it's an issue in your code. For IntegerChoices you should pass coerce=int and everything works fine.

in reply to:  1 comment:2 by Jacob Walls, 2 weeks ago

Resolution: invalid
Status: closednew
Summary: TypedChoiceField not compatible with IntegerChoicesTypedChoiceField.to_python() always returns a string

Replying to Mariusz Felisiak:

As far as I'm aware, it's an issue in your code. For IntegerChoices you should pass coerce=int and everything works fine.

Mariusz is right, coerce=int is enough to make the error go away, but the OP might have wanted to be able to use enum values in their code, e.g.:

>>> TypedChoiceField(choices=Suit, coerce=Suit).clean(4)
Suit.CLUB

Also, although it's handy that int can handle strings just as well as ints, decimal.Decimal would surely produce interesting differences:

>>> decimal.Decimal(4.0)
Decimal('4')
>>> decimal.Decimal(str(4.0))  # to_python does str()
Decimal('4.0')

I'm suggesting we take another look, because the state of play after #21397 is not quite ideal -- it leaves to_python() to return str, whereas it's documented to return the proper python type. Breaking this expectation led to 500s in the admin changelist in #36865/4cecf3039586ea738afafb9a28c946bff42c37c1 (crash report in #37263).

Tests pass with the following starting suggestion (maybe also worth looking into self.empty_values):

  • django/forms/fields.py

    diff --git a/django/forms/fields.py b/django/forms/fields.py
    index ab3f6876df..4e5735db16 100644
    a b class TypedChoiceField(ChoiceField):  
    938938            )
    939939        return value
    940940
    941     def clean(self, value):
    942         value = super().clean(value)
     941    def to_python(self, value):
    943942        return self._coerce(value)
    944943
    945944
    class TypedMultipleChoiceField(MultipleChoiceField):  
    10151014                )
    10161015        return new_value
    10171016
    1018     def clean(self, value):
    1019         value = super().clean(value)
     1017    def to_python(self, value):
    10201018        return self._coerce(value)
    10211019
    10221020    def validate(self, value):
  • tests/forms_tests/field_tests/test_typedchoicefield.py

    diff --git a/tests/forms_tests/field_tests/test_typedchoicefield.py b/tests/forms_tests/field_tests/test_typedchoicefield.py
    index fe5cbe12ee..0391513c89 100644
    a b class TypedChoiceFieldTest(SimpleTestCase):  
    8989        f = TypedChoiceField(
    9090            choices=[(1, "1"), (2, "2")], coerce=coerce_func, required=True
    9191        )
    92         self.assertEqual(decimal.Decimal("1.2"), f.clean("2"))
     92        self.assertEqual(decimal.Decimal("1.2"), f.to_python("2"))
    9393        with self.assertRaisesMessage(ValidationError, "'This field is required.'"):
    9494            f.clean("")
    95         msg = "'Select a valid choice. 3 is not one of the available choices.'"
     95        msg = "'Select a valid choice. 1.3 is not one of the available choices.'"
    9696        with self.assertRaisesMessage(ValidationError, msg):
    9797            f.clean("3")
  • tests/forms_tests/field_tests/test_typedmultiplechoicefield.py

    diff --git a/tests/forms_tests/field_tests/test_typedmultiplechoicefield.py b/tests/forms_tests/field_tests/test_typedmultiplechoicefield.py
    index e874e192d8..a634be564f 100644
    a b class TypedMultipleChoiceFieldTest(SimpleTestCase):  
    8080        f = TypedMultipleChoiceField(
    8181            choices=[(1, "1"), (2, "2")], coerce=coerce_func, required=True
    8282        )
    83         self.assertEqual([decimal.Decimal("1.2")], f.clean(["2"]))
     83        self.assertEqual([decimal.Decimal("1.2")], f.to_python(["2"]))
    8484        with self.assertRaisesMessage(ValidationError, "'This field is required.'"):
    8585            f.clean([])
    86         msg = "'Select a valid choice. 3 is not one of the available choices.'"
     86        msg = "'Select a valid choice. 1.3 is not one of the available choices.'"
    8787        with self.assertRaisesMessage(ValidationError, msg):
    8888            f.clean(["3"])

Since the solve is the same as the original request here, I figured to reopen.

comment:3 by Jacob Walls, 2 weeks ago

Owner: changed from nobody to Django Sprints
Status: newassigned

comment:4 by Sarah Boyce, 12 days ago

Triage Stage: UnreviewedAccepted
Note: See TracTickets for help on using tickets.
Back to Top