From 675ffbdf985484574a9a4ffb6ab23909b6bfca84 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Fri, 15 Oct 2021 07:18:01 -0400 Subject: [PATCH] Change form encoder to default to stringify. This matches what urlencode would do and is will remove surprises where a value is supported by the other transcoders but not the form encoder. --- sprockets/mixins/mediatype/transcoders.py | 47 +++++++++++------------ sprockets/mixins/mediatype/type_info.py | 5 ++- tests.py | 28 +++++++------- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py index cd66df5..879b705 100644 --- a/sprockets/mixins/mediatype/transcoders.py +++ b/sprockets/mixins/mediatype/transcoders.py @@ -282,9 +282,7 @@ class FormUrlEncodedTranscoder: This transcoder implements transcoding according to the current W3C documentation. The encoding interface takes mappings or sequences of pairs and encodes both the name and value. The - following list describes how each supported type is encoded. - Any value type that is not on the list will result in a - :exc:`TypeError`. + following table describes how each supported type is encoded. +----------------------------+---------------------------------------+ | Value / Type | Encoding | @@ -315,14 +313,16 @@ class FormUrlEncodedTranscoder: .. warning:: Types that are not explicitly mentioned above will result in - :meth:`to_bytes` raising a :exc:`TypeError`. This transcoder - differs slightly from others in that it does not include - support for encoding values that are nested collections without - explicit configuration. + :meth:`to_bytes` simply calling ``str(value)`` and encoding + the result. This causes nested sequences to be encoded as + their ``repr``. For example, encoding ``{'a': [1, 2]}`` will + result in ``a=%5B1%2C%202%5D``. This matches what + :func:`urllib.parse.urlencode` does by default. - Support for sequence values can be enabled by setting the - :attr:`~FormUrlEncodingOptions.encode_sequences` attribute of - :attr:`.options`. + Better support for sequence values can be enabled by setting + the :attr:`~FormUrlEncodingOptions.encode_sequences` attribute + of :attr:`.options`. This mimics the ``doseq`` parameter of + :func:`urllib,parse.urlencode`. .. attribute:: options :type: FormUrlEncodingOptions @@ -332,7 +332,7 @@ class FormUrlEncodedTranscoder: """ content_type = 'application/x-www-formurlencoded' - def __init__(self, **encoding_options) -> None: + def __init__(self, **encoding_options: typing.Any) -> None: self.options = FormUrlEncodingOptions(**encoding_options) def to_bytes( @@ -436,25 +436,23 @@ class FormUrlEncodedTranscoder: elif (isinstance(datum, (float, int, str, uuid.UUID)) and not isinstance(datum, bool)): datum = str(datum) - elif datum in self.options.literal_mapping: - datum = self.options.literal_mapping[datum] + elif (isinstance(datum, collections.abc.Hashable) + and datum in self.options.literal_mapping): + # the isinstance Hashable check confuses mypy + datum = self.options.literal_mapping[datum] # type: ignore elif isinstance(datum, (bytearray, bytes, memoryview)): return ''.join(char_map[c] for c in datum) - elif datum is None or isinstance(datum, bool): - # This could happen if the user modifies the literal mapping - # and MUST be before the isinstance(datum, int) check since - # Boolean literals are integers instances - raise TypeError(f'{datum.__class__.__name__} is not serializable') - elif hasattr(datum, 'isoformat'): + elif isinstance(datum, type_info.DefinesIsoFormat): datum = datum.isoformat() else: - raise TypeError(f'{datum.__class__.__name__} is not serializable') + datum = str(datum) return ''.join(char_map[c] for c in datum.encode(encoding)) def _convert_to_tuple_sequence( self, value: type_info.Serializable ) -> typing.Iterable[typing.Tuple[typing.Any, typing.Any]]: + tuples: typing.Iterable[typing.Tuple[typing.Any, typing.Any]] if isinstance(value, collections.abc.Mapping): tuples = value.items() else: @@ -464,13 +462,14 @@ class FormUrlEncodedTranscoder: raise TypeError('Cannot convert value to sequence of tuples') if self.options.encode_sequences: - tuples, in_tuples = [], tuples - for a, b in in_tuples: + out_tuples = [] + for a, b in tuples: if (not isinstance(b, (bytes, bytearray, memoryview, str)) and isinstance(b, collections.abc.Iterable)): for value in b: - tuples.append((a, value)) + out_tuples.append((a, value)) else: - tuples.append((a, b)) + out_tuples.append((a, b)) + tuples = out_tuples return tuples diff --git a/sprockets/mixins/mediatype/type_info.py b/sprockets/mixins/mediatype/type_info.py index 4b63a3a..53e6f22 100644 --- a/sprockets/mixins/mediatype/type_info.py +++ b/sprockets/mixins/mediatype/type_info.py @@ -4,13 +4,14 @@ import typing import uuid try: - from typing import Protocol + from typing import Protocol, runtime_checkable except ImportError: # "ignore" is required to avoid an incompatible import # error due to different bindings of _SpecialForm - from typing_extensions import Protocol # type: ignore + from typing_extensions import Protocol, runtime_checkable # type: ignore +@runtime_checkable class DefinesIsoFormat(Protocol): """An object that has an isoformat method.""" def isoformat(self) -> str: diff --git a/tests.py b/tests.py index 33dd0f5..550bb2d 100644 --- a/tests.py +++ b/tests.py @@ -634,9 +634,13 @@ class FormUrlEncodingTranscoderTests(unittest.TestCase): _, result = self.transcoder.to_bytes({'value': 'with space'}) self.assertEqual(b'value=with%20space', result) - def test_that_serializing_unsupported_types_fails(self): - with self.assertRaises(TypeError): - self.transcoder.to_bytes({'unsupported': object()}) + def test_that_serializing_unsupported_types_stringifies(self): + obj = object() + # quick & dirty URL encoding + expected = str(obj).translate({0x20: '%20', 0x3C: '%3C', 0x3E: '%3E'}) + + _, result = self.transcoder.to_bytes({'unsupported': obj}) + self.assertEqual(f'unsupported={expected}'.encode(), result) def test_that_required_octets_are_encoded(self): # build the set of all characters required to be encoded by @@ -675,26 +679,20 @@ class FormUrlEncodingTranscoderTests(unittest.TestCase): self.transcoder: transcoders.FormUrlEncodedTranscoder self.transcoder.options.literal_mapping.clear() for value in {None, True, False}: - with self.assertRaises(TypeError): - self.transcoder.to_bytes(value) + _, result = self.transcoder.to_bytes(value) + self.assertEqual(str(value).encode(), result) def test_serialization_of_sequences(self): self.transcoder: transcoders.FormUrlEncodedTranscoder - always_illegal = [[1, 2, 3], {1, 2, 3}, (1, 2, 3)] + value = {'list': [1, 2], 'tuple': (1, 2), 'set': {1, 2}, 'str': 'val'} self.transcoder.options.encode_sequences = False - for value in always_illegal: - with self.assertRaises(TypeError): - self.transcoder.to_bytes(value) + _, result = self.transcoder.to_bytes(value) + self.assertEqual((b'list=%5B1%2C%202%5D&tuple=%281%2C%202%29' + b'&set=%7B1%2C%202%7D&str=val'), result) self.transcoder.options.encode_sequences = True - for value in always_illegal: - with self.assertRaises(TypeError): - self.transcoder.to_bytes(value) - - self.transcoder.options.encode_sequences = True - value = {'list': [1, 2], 'tuple': (1, 2), 'set': {1, 2}, 'str': 'val'} _, result = self.transcoder.to_bytes(value) self.assertEqual(b'list=1&list=2&tuple=1&tuple=2&set=1&set=2&str=val', result)