Repository: marshmallow-code/marshmallow

Issue:
RFC: Remove FormattedString field
`fields.FormattedString` only exists because it was [ported from Flask-RESTful](https://github.com/flask-restful/flask-restful/blob/b30a408b64acd167cba3a09e649d6c97e415af6b/flask_restful/fields.py#L250). It has a couple peculiarities:

1. It [converts the object to a dict](https://github.com/marshmallow-code/marshmallow/blob/cfb09de0c3048e1c416ba7f34b7b703c6098003f/src/marshmallow/fields.py#L1034) in a way that is error-prone and inefficient.
2. Its deserialization behavior is no-op, making it asymettrical with its serialization behavior.

`fields.Function` and `fields.Method` achieve the same use case in a more performant way.

```python
from dataclasses import dataclass
from marshmallow import Schema, fields


class MySchema(Schema):
    formatted = fields.FormattedString("{first_name} {last_name}")
    function = fields.Function(lambda u: f"{u.first_name} {u.last_name}")


@dataclass
class User:
    first_name: str
    last_name: str


u = User(first_name="Steve", last_name="Loria")
data = MySchema().dump(u)
assert data["formatted"] == data["function"]
```

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e3c2c121..ba309a43 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,11 @@ Features:
 - Allow input value to be included in error messages
   for a number of fields (:pr:`1129`). Thanks :user:`hdoupe` for the PR.
 
+Deprecations/Removals:
+
+- Remove ``fields.FormattedString`` (:issue:`1141`). Use
+  ``fields.Function`` or ``fields.Method`` instead.
+
 3.0.0rc4 (2019-02-08)
 *********************
 
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
index 197dfa8c..5044eca6 100644
--- a/docs/upgrading.rst
+++ b/docs/upgrading.rst
@@ -744,6 +744,25 @@ The ``prefix`` parameter of ``Schema`` is removed. The same feature can be achie
     MySchema().dump({'f1': 'one', 'f2': 'two'})
     # {'pre_f1': 'one', '_pre_f2': 'two'}
 
+
+``fields.FormattedString`` is removed
+*************************************
+
+``fields.FormattedString`` field is removed. Use `fields.Function
+<marshmallow.fields.Function>` or
+`fields.Method <marshmallow.fields.Method>` instead.
+
+.. code-block:: python
+
+    # 2.x
+    class MySchema(Schema):
+        full_name = fields.FormattedString("{first_name} {last_name}")
+
+    # 3.x
+    class MySchema(Schema):
+        full_name = fields.Function(lambda u: f"{u.first_name} {u.last_name}")
+
+
 ``attribute`` or ``data_key`` collision triggers an exception
 *************************************************************
 
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 4c28518a..4c459837 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -14,7 +14,7 @@ import math
 from marshmallow import validate, utils, class_registry
 from marshmallow.base import FieldABC, SchemaABC
 from marshmallow.utils import is_collection, missing as missing_, resolve_field_instance
-from marshmallow.compat import basestring, text_type, Mapping as _Mapping, iteritems
+from marshmallow.compat import basestring, Mapping as _Mapping, iteritems
 from marshmallow.exceptions import (
     ValidationError, StringNotCollectionError, FieldInstanceResolutionError,
 )
@@ -34,7 +34,6 @@ __all__ = [
     'Integer',
     'Decimal',
     'Boolean',
-    'FormattedString',
     'Float',
     'DateTime',
     'LocalDateTime',
@@ -1006,37 +1005,6 @@ class Boolean(Field):
         self.fail('invalid', input=value)
 
 
-class FormattedString(Field):
-    """Interpolate other values from the object into this field. The syntax for
-    the source string is the same as the string `str.format` method
-    from the python stdlib.
-    ::
-
-        class UserSchema(Schema):
-            name = fields.String()
-            greeting = fields.FormattedString('Hello {name}')
-
-        ser = UserSchema()
-        res = ser.dump(user)
-        res.data  # => {'name': 'Monty', 'greeting': 'Hello Monty'}
-    """
-    default_error_messages = {
-        'format': 'Cannot format string with given data.',
-    }
-    _CHECK_ATTRIBUTE = False
-
-    def __init__(self, src_str, *args, **kwargs):
-        Field.__init__(self, *args, **kwargs)
-        self.src_str = text_type(src_str)
-
-    def _serialize(self, value, attr, obj, **kwargs):
-        try:
-            data = utils.to_marshallable_type(obj)
-            return self.src_str.format(**data)
-        except (TypeError, IndexError):
-            self.fail('format')
-
-
 class DateTime(Field):
     """A formatted datetime string in UTC.
 
diff --git a/src/marshmallow/utils.py b/src/marshmallow/utils.py
index a7e32a87..ceb7012e 100644
--- a/src/marshmallow/utils.py
+++ b/src/marshmallow/utils.py
@@ -9,7 +9,6 @@ import inspect
 import json
 import re
 import time
-import types
 from calendar import timegm
 from email.utils import formatdate, parsedate
 from pprint import pprint as py_pprint
@@ -84,29 +83,6 @@ def is_keyed_tuple(obj):
     return isinstance(obj, tuple) and hasattr(obj, '_fields')
 
 
-def to_marshallable_type(obj, field_names=None):
-    """Helper for converting an object to a dictionary only if it is not
-    dictionary already or an indexable object nor a simple type"""
-    if obj is None:
-        return None  # make it idempotent for None
-
-    if hasattr(obj, '__marshallable__'):
-        return obj.__marshallable__()
-
-    if hasattr(obj, '__getitem__') and not is_keyed_tuple(obj):
-        return obj  # it is indexable it is ok
-
-    if isinstance(obj, types.GeneratorType):
-        return list(obj)
-    if field_names:
-        # exclude field names that aren't actual attributes of the object
-        attrs = set(dir(obj)) & set(field_names)
-    else:
-        attrs = set(dir(obj))
-    return dict([(attr, getattr(obj, attr, None)) for attr in attrs
-                  if not attr.startswith('__') and not attr.endswith('__')])
-
-
 def pprint(obj, *args, **kwargs):
     """Pretty-printing function that can pretty-print OrderedDicts
     like regular dictionaries. Useful for printing the output of


Test patch:
diff --git a/tests/base.py b/tests/base.py
index cad3ea71..51095872 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -28,7 +28,6 @@ ALL_FIELDS = [
     fields.Dict,
     fields.Url,
     fields.Email,
-    fields.FormattedString,
     fields.UUID,
     fields.Decimal,
 ]
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index 43475967..431b53b0 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -24,20 +24,13 @@ class TestDeserializingNone:
 
     @pytest.mark.parametrize('FieldClass', ALL_FIELDS)
     def test_fields_allow_none_deserialize_to_none(self, FieldClass):
-        if FieldClass == fields.FormattedString:
-            field = FieldClass(src_str='foo', allow_none=True)
-        else:
-            field = FieldClass(allow_none=True)
+        field = FieldClass(allow_none=True)
         field.deserialize(None) is None
 
     # https://github.com/marshmallow-code/marshmallow/issues/111
     @pytest.mark.parametrize('FieldClass', ALL_FIELDS)
     def test_fields_dont_allow_none_by_default(self, FieldClass):
-        # by default, allow_none=False
-        if FieldClass == fields.FormattedString:
-            field = FieldClass(src_str='foo')
-        else:
-            field = FieldClass()
+        field = FieldClass()
         with pytest.raises(ValidationError) as excinfo:
             field.deserialize(None)
         assert 'Field may not be null.' in str(excinfo)
@@ -1809,8 +1802,7 @@ class TestValidation:
             SchemaB().load(b_dict, partial=('z.y',))
 
 
-FIELDS_TO_TEST = [f for f in ALL_FIELDS if f not in [fields.FormattedString]]
-@pytest.mark.parametrize('FieldClass', FIELDS_TO_TEST)
+@pytest.mark.parametrize('FieldClass', ALL_FIELDS)
 def test_required_field_failure(FieldClass):  # noqa
     class RequireSchema(Schema):
         age = FieldClass(required=True)
diff --git a/tests/test_fields.py b/tests/test_fields.py
index dc4ec065..5e386b7e 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -165,12 +165,7 @@ class TestParentAndName:
 
 class TestMetadata:
 
-    FIELDS_TO_TEST = [
-        field for field in ALL_FIELDS
-        if field not in [fields.FormattedString]
-    ]
-
-    @pytest.mark.parametrize('FieldClass', FIELDS_TO_TEST)
+    @pytest.mark.parametrize('FieldClass', ALL_FIELDS)
     def test_extra_metadata_may_be_added_to_field(self, FieldClass):  # noqa
         field = FieldClass(description='Just a normal field.')
         assert field.metadata['description'] == 'Just a normal field.'
@@ -180,10 +175,6 @@ class TestMetadata:
         )
         assert field.metadata == {'description': 'foo', 'widget': 'select'}
 
-    def test_metadata_may_be_added_to_formatted_string_field(self):
-        field = fields.FormattedString('hello {name}', description='a greeting')
-        assert field.metadata == {'description': 'a greeting'}
-
 
 class TestErrorMessages:
 
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 23828f6b..81a523ca 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -562,18 +562,6 @@ class TestFieldSerialization:
         user.name = None
         assert field.serialize('name', user) is None
 
-    def test_formattedstring_field(self):
-        field = fields.FormattedString('Hello {name}')
-        user = User(name='Monty')
-        assert field.serialize('name', user) == 'Hello Monty'
-
-    # Regression test for https://github.com/marshmallow-code/marshmallow/issues/348
-    def test_formattedstring_field_on_schema(self):
-        class MySchema(Schema):
-            greeting = fields.FormattedString('Hello {name}')
-        user = User(name='Monty')
-        assert MySchema().dump(user)['greeting'] == 'Hello Monty'
-
     def test_string_field_default_to_empty_string(self, user):
         field = fields.String(default='')
         assert field.serialize('notfound', {}) == ''
@@ -886,16 +874,9 @@ class TestFieldSerialization:
 
     @pytest.mark.parametrize('FieldClass', ALL_FIELDS)
     def test_all_fields_serialize_none_to_none(self, FieldClass):
-        if FieldClass == fields.FormattedString:
-            field = FieldClass('{foo}', allow_none=True)
-        else:
-            field = FieldClass(allow_none=True)
-
+        field = FieldClass(allow_none=True)
         res = field.serialize('foo', {'foo': None})
-        if FieldClass == fields.FormattedString:
-            assert res == 'None'
-        else:
-            assert res is None
+        assert res is None
 
 class TestSchemaSerialization:
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 653a45c7..e6862d0e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -19,34 +19,9 @@ def test_missing_singleton_copy():
     assert copy(utils.missing) is utils.missing
     assert deepcopy(utils.missing) is utils.missing
 
-def test_to_marshallable_type():
-    class Foo(object):
-        CLASS_VAR = 'bar'
-
-        def __init__(self):
-            self.attribute = 'baz'
-
-        @property
-        def prop(self):
-            return 42
-
-    obj = Foo()
-    u_dict = utils.to_marshallable_type(obj)
-    assert u_dict['CLASS_VAR'] == Foo.CLASS_VAR
-    assert u_dict['attribute'] == obj.attribute
-    assert u_dict['prop'] == obj.prop
-
-def test_to_marshallable_type_none():
-    assert utils.to_marshallable_type(None) is None
-
 
 PointNT = namedtuple('Point', ['x', 'y'])
 
-def test_to_marshallable_type_with_namedtuple():
-    p = PointNT(24, 42)
-    result = utils.to_marshallable_type(p)
-    assert result['x'] == p.x
-    assert result['y'] == p.y
 
 class PointClass(object):
     def __init__(self, x, y):
@@ -137,22 +112,6 @@ def test_is_keyed_tuple():
     lst = [24, 42]
     assert utils.is_keyed_tuple(lst) is False
 
-def test_to_marshallable_type_list():
-    assert utils.to_marshallable_type(['foo', 'bar']) == ['foo', 'bar']
-
-def test_to_marshallable_type_generator():
-    gen = (e for e in ['foo', 'bar'])
-    assert utils.to_marshallable_type(gen) == ['foo', 'bar']
-
-def test_marshallable():
-    class ObjContainer(object):
-        contained = {'foo': 1}
-
-        def __marshallable__(self):
-            return self.contained
-
-    obj = ObjContainer()
-    assert utils.to_marshallable_type(obj) == {'foo': 1}
 
 def test_is_collection():
     assert utils.is_collection([1, 'foo', {}]) is True