Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TypeConversionDict.pop with type #2888

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Unreleased
- Add ``stale_while_revalidate`` and ``stale_if_error`` properties to
``ResponseCacheControl``. :issue:`2948`
- Add 421 ``MisdirectedRequest`` HTTP exception. :issue:`2850`
- Add ``TypeConversionDict.pop`` method. :issue:`2883`


Version 3.0.6
Expand Down
145 changes: 124 additions & 21 deletions src/werkzeug/datastructures/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ def __repr__(self):


class TypeConversionDict(dict):
"""Works like a regular dict but the :meth:`get` method can perform
type conversions. :class:`MultiDict` and :class:`CombinedMultiDict`
"""Works like a regular dict but the :meth:`get` and :meth:`pop` methods can
perform type conversions. :class:`MultiDict` and :class:`CombinedMultiDict`
are subclasses of this class and provide the same feature.

.. versionadded:: 0.5
Expand Down Expand Up @@ -88,6 +88,55 @@ def get(self, key, default=None, type=None):
rv = default
return rv

def pop(self, key, default=_missing, type=None):
"""Similar to :meth:`get` but removes the item, and does not default to
``None`` if the key doesn't exist. If type conversion fails and no
default is given, the item is not removed and the error is re-raised.

>>> d = TypeConversionDict(a="42", b="abc", c="def")
>>> d.pop("a", type=int)
42
>>> "a" in d
False
>>> d.pop("b", -1, type=int)
-1
>>> "b" in d
False
>>> d.pop("c", type=int)
ValueError: ...
>>> "c" in d
True

:param key: The key to be looked up.
:param default: The value to be returned if the key doesn't exist. If
not given, a ``KeyError`` is raised.
:param type: A callable that is used to convert the value.
If a ``ValueError`` or ``TypeError`` is raised, the default value is
returned if given, otherwise the error is raised.
"""

try:
# Don't remove the item yet, type conversion might fail.
rv = self[key]
except KeyError:
if default is _missing:
raise

return default

if type is not None:
try:
rv = type(rv)
except (ValueError, TypeError):
if default is _missing:
raise

return default

# Remove the item after type conversion succeeds.
del self[key]
return rv


class ImmutableTypeConversionDict(ImmutableDictMixin, TypeConversionDict):
"""Works like a :class:`TypeConversionDict` but does not support
Expand Down Expand Up @@ -373,62 +422,116 @@ def update(self, mapping):
for key, value in iter_multi_items(mapping):
MultiDict.add(self, key, value)

def pop(self, key, default=_missing):
"""Pop the first item for a list on the dict. Afterwards the
key is removed from the dict, so additional values are discarded:
def pop(self, key, default=_missing, type=None):
"""Pop the first item for a list on the dict. If there is more than one
value for the key, the remaining values are discarded.

>>> d = MultiDict({"foo": [1, 2, 3]})
>>> d.pop("foo")
1
>>> "foo" in d
False

:param key: the key to pop.
:param default: if provided the value to return if the key was
not in the dictionary.
:param key: The key to pop.
:param default: The value to be returned if the key doesn't exist. If
not given, a ``KeyError`` is raised.
:param type: A callable that is used to convert the value.
If a ``ValueError`` or ``TypeError`` is raised, the default value is
returned if given, otherwise the error is raised.
"""

try:
lst = dict.pop(self, key)
# Don't remove the item yet, type conversion might fail.
values = dict.__getitem__(self, key)

if len(lst) == 0:
raise exceptions.BadRequestKeyError(key)
if not values:
raise KeyError(key)

return lst[0]
value = values[0]
except KeyError:
if default is not _missing:
return default

raise exceptions.BadRequestKeyError(key) from None

def popitem(self):
if type is not None:
try:
value = type(value)
except (ValueError, TypeError):
if default is not _missing:
return default

raise

# Remove the item after type conversion succeeds.
del self[key]
return value

def popitem(self, type=None):
"""Pop an item from the dict."""
try:
item = dict.popitem(self)
# Let Python pick the item to pop.
key, values = dict.popitem(self)
# Put it back, type conversion might fail.
self.setlist(key, values)

if len(item[1]) == 0:
raise exceptions.BadRequestKeyError(item[0])
if len(values) == 0:
raise KeyError(key)

return (item[0], item[1][0])
value = values[0]
except KeyError as e:
raise exceptions.BadRequestKeyError(e.args[0]) from None

def poplist(self, key):
if type is not None:
value = type(value)

# Remove the item after type conversion succeeds.
del self[key]
return key, value

def poplist(self, key, type=None):
"""Pop the list for a key from the dict. If the key is not in the dict
an empty list is returned.

.. versionchanged:: 0.5
If the key does no longer exist a list is returned instead of
raising an error.
"""
return dict.pop(self, key, [])
values = dict.pop(self, key, [])

def popitemlist(self):
if type is None:
return values

out = []

for value in values:
try:
out.append(type(value))
except (ValueError, TypeError):
pass

return out

def popitemlist(self, type=None):
"""Pop a ``(key, list)`` tuple from the dict."""
try:
return dict.popitem(self)
key, values = dict.popitem(self)
except KeyError as e:
raise exceptions.BadRequestKeyError(e.args[0]) from None

if type is None:
return key, values

out = []

for value in values:
try:
out.append(type(value))
except (ValueError, TypeError):
pass

return key, out

def __copy__(self):
return self.copy()

Expand Down
34 changes: 34 additions & 0 deletions src/werkzeug/datastructures/structures.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,28 @@ class TypeConversionDict(dict[K, V]):
def get(self, key: K, default: D, type: Callable[[V], T]) -> D | T: ...
@overload
def get(self, key: K, type: Callable[[V], T]) -> T | None: ...
@overload
def pop(self, key: K) -> V: ...
@overload
def pop(self, key: K, default: V) -> V: ...
@overload
def pop(self, key: K, default: D) -> V | D: ...
@overload
def pop(self, key: K, default: D, type: Callable[[V], T]) -> D | T: ...
@overload
def pop(self, key: K, type: Callable[[V], T]) -> T | None: ...

class ImmutableTypeConversionDict(ImmutableDictMixin[K, V], TypeConversionDict[K, V]):
def copy(self) -> TypeConversionDict[K, V]: ...
def __copy__(self) -> ImmutableTypeConversionDict[K, V]: ...
@overload
def pop(self, key: K, default: V | None = ...) -> NoReturn: ...
@overload
def pop(self, key: K, default: D) -> NoReturn: ...
@overload
def pop(self, key: K, default: D, type: Callable[[V], T]) -> NoReturn: ...
@overload
def pop(self, key: K, type: Callable[[V], T]) -> NoReturn: ...

class MultiDict(TypeConversionDict[K, V]):
def __init__(
Expand Down Expand Up @@ -74,6 +92,14 @@ class MultiDict(TypeConversionDict[K, V]):
@overload
def pop(self, key: K) -> V: ...
@overload
def pop(self, key: K, default: V) -> V: ...
@overload
def pop(self, key: K, default: D) -> V | D: ...
@overload
def pop(self, key: K, default: D, type: Callable[[V], T]) -> D | T: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't valid unless the implementation of MultiDict.pop is changed to have a type parameter.

Similarly, poplist should probably have a type parameter.

@overload
def pop(self, key: K, type: Callable[[V], T]) -> T | None: ...
@overload
def pop(self, key: K, default: V | T = ...) -> V | T: ...
def popitem(self) -> tuple[K, V]: ...
def poplist(self, key: K) -> list[V]: ...
Expand Down Expand Up @@ -119,6 +145,14 @@ class OrderedMultiDict(MultiDict[K, V]):
@overload
def pop(self, key: K) -> V: ...
@overload
def pop(self, key: K, default: V) -> V: ...
@overload
def pop(self, key: K, default: D) -> V | D: ...
@overload
def pop(self, key: K, default: D, type: Callable[[V], T]) -> D | T: ...
@overload
def pop(self, key: K, type: Callable[[V], T]) -> T | None: ...
@overload
def pop(self, key: K, default: V | T = ...) -> V | T: ...
def popitem(self) -> tuple[K, V]: ...
def popitemlist(self) -> tuple[K, list[V]]: ...
Expand Down
64 changes: 59 additions & 5 deletions tests/test_datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,19 @@ def test_dict_is_hashable(self):
assert immutable in x
assert immutable2 in x

def test_get_does_not_raise(self):
cls = self.storage_class
immutable = cls({"a": 1})
assert immutable.get("a") == 1

def test_pop_raises(self):
cls = self.storage_class
immutable = cls({"a": 1})
with pytest.raises(TypeError):
immutable.pop("a")
with pytest.raises(TypeError):
immutable.popitem()


class TestImmutableTypeConversionDict(_ImmutableDictTests):
storage_class = ds.ImmutableTypeConversionDict
Expand Down Expand Up @@ -545,20 +558,61 @@ def test_get_description(self):
class TestTypeConversionDict:
storage_class = ds.TypeConversionDict

def test_value_conversion(self):
class MyException(Exception):
def raize(self, *args, **kwargs):
raise self

def test_get_value_conversion(self):
d = self.storage_class(foo="1")
assert d.get("foo", type=int) == 1

def test_return_default_when_conversion_is_not_possible(self):
def test_get_return_default_when_conversion_is_not_possible(self):
d = self.storage_class(foo="bar", baz=None)
assert d.get("foo", default=-1, type=int) == -1
assert d.get("baz", default=-1, type=int) == -1

def test_propagate_exceptions_in_conversion(self):
def test_get_propagate_exceptions_in_conversion(self):
d = self.storage_class(foo="bar")
with pytest.raises(self.MyException):
d.get("foo", type=lambda x: self.MyException().raize())

def test_get_error_in_conversion(self):
d = self.storage_class(foo="bar")
assert d.get("foo", type=int) is None

def test_pop_value_conversion(self):
d = self.storage_class(foo="1")
assert d.pop("foo", type=int) == 1
assert "foo" not in d

@pytest.mark.parametrize(
("value", "exc_type"), [("a", ValueError), (None, TypeError)]
)
def test_pop_conversion_fails(self, value, exc_type):
d = self.storage_class(a=value)

with pytest.raises(exc_type):
d.pop("a", type=int)

assert "a" in d

@pytest.mark.parametrize(
("value", "exc_type"), [("a", ValueError), (None, TypeError)]
)
def test_pop_conversion_fails_default(self, value, exc_type):
d = self.storage_class(a=value)
assert d.pop("a", default=None, type=int) is None
assert "a" in d

def test_pop_propagate_exceptions_in_conversion(self):
d = self.storage_class(foo="bar")
switch = {"a": 1}
with pytest.raises(self.MyException):
d.pop("foo", type=lambda x: self.MyException().raize())

def test_pop_key_error(self):
d = self.storage_class()
with pytest.raises(KeyError):
d.get("foo", type=lambda x: switch[x])
d.pop("foo")


class TestCombinedMultiDict:
Expand Down