Skip to content

Commit

Permalink
Add support for custom functions on subcast. (#241)
Browse files Browse the repository at this point in the history
* Add support for custom functions on subcast.

- Allows users to define custom functions or extend mashmallow
fields.Field class to write custom subcasts for lists, dict keys and
dict values.
- Gives an alternative to solve #216

* Update changelog

* Minor naming and typing fixups

Co-authored-by: Steven Loria <[email protected]>
  • Loading branch information
bvanelli and sloria authored Jan 30, 2022
1 parent 59023e1 commit 9199ab5
Show file tree
Hide file tree
Showing 3 changed files with 57 additions and 11 deletions.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 9.5.0 (unreleased)

Features:

- Allow callables or custom marshmallow fields to be passed to `subcast`, `subcast_keys`, and `subcast_values`. ([#241](https://github.com/sloria/environs/pull/241)).
Thanks [bvanelli](https://github.com/bvanelli) for the PR.

## 9.4.0 (2022-01-04)

Bug fixes:
Expand Down Expand Up @@ -28,7 +35,7 @@ Bug fixes:

Bug fixes:

- Fix compatibility with marshmallow>=3.13.0
- Fix compatibility with marshmallow>=3.13.0
so that no DeprecationWarnings are raised ([#224](https://github.com/sloria/environs/issues/224)).

## 9.3.2 (2021-03-28)
Expand Down
38 changes: 28 additions & 10 deletions environs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import collections
import contextlib
import inspect
import functools
import inspect
import json as pyjson
import logging
import os
Expand All @@ -10,11 +10,11 @@
import warnings
from collections.abc import Mapping
from enum import Enum
from urllib.parse import urlparse, ParseResult
from pathlib import Path
from urllib.parse import ParseResult, urlparse

import marshmallow as ma
from dotenv.main import load_dotenv, _walk_to_root
from dotenv.main import _walk_to_root, load_dotenv

__version__ = "9.4.0"
__all__ = ["EnvError", "Env"]
Expand All @@ -28,7 +28,7 @@
ErrorMapping = typing.Mapping[str, typing.List[str]]
ErrorList = typing.List[str]
FieldFactory = typing.Callable[..., ma.fields.Field]
Subcast = typing.Union[typing.Type, typing.Callable[..., _T]]
Subcast = typing.Union[typing.Type, typing.Callable[..., _T], ma.fields.Field]
FieldType = typing.Type[ma.fields.Field]
FieldOrFactory = typing.Union[FieldType, FieldFactory]
ParserMethod = typing.Callable
Expand Down Expand Up @@ -104,7 +104,8 @@ def method(
# TODO: Remove `type: ignore` after https://github.com/python/mypy/issues/9676 is fixed
field = field_or_factory(**field_kwargs, **kwargs) # type: ignore
else:
field = field_or_factory(subcast=subcast, **field_kwargs)
parsed_subcast = _make_subcast_field(subcast)
field = field_or_factory(subcast=parsed_subcast, **field_kwargs)
parsed_key, value, proxied_key = self._get_from_environ(
name, field.load_default if _SUPPORTS_LOAD_DEFAULT else field.missing
)
Expand Down Expand Up @@ -174,8 +175,26 @@ def method(
return method


def _make_subcast_field(subcast: typing.Optional[Subcast]) -> typing.Type[ma.fields.Field]:
if isinstance(subcast, type) and subcast in ma.Schema.TYPE_MAPPING:
inner_field = ma.Schema.TYPE_MAPPING[subcast]
elif isinstance(subcast, type) and issubclass(subcast, ma.fields.Field):
inner_field = subcast
elif callable(subcast):

class SubcastField(ma.fields.Field):
def _deserialize(self, value, *args, **kwargs):
func = typing.cast(typing.Callable[..., _T], subcast)
return func(value)

inner_field = SubcastField
else:
inner_field = ma.fields.Field
return inner_field


def _make_list_field(*, subcast: typing.Optional[type], **kwargs) -> ma.fields.List:
inner_field = ma.Schema.TYPE_MAPPING[subcast] if subcast else ma.fields.Field
inner_field = _make_subcast_field(subcast)
return ma.fields.List(inner_field, **kwargs)


Expand All @@ -200,12 +219,11 @@ def _preprocess_dict(

if subcast_key:
warnings.warn("`subcast_key` is deprecated. Use `subcast_keys` instead.", DeprecationWarning)
subcast_keys = subcast_keys or subcast_key
subcast_keys_instance: ma.fields.Field = _make_subcast_field(subcast_keys or subcast_key)(**kwargs)
subcast_values_instance: ma.fields.Field = _make_subcast_field(subcast_values)(**kwargs)

return {
(subcast_keys(key.strip()) if subcast_keys else key.strip()): (
subcast_values(val.strip()) if subcast_values else val.strip()
)
subcast_keys_instance.deserialize(key.strip()): subcast_values_instance.deserialize(val.strip())
for key, val in (item.split("=", 1) for item in value.split(",") if value)
}

Expand Down
21 changes: 21 additions & 0 deletions tests/test_environs.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,27 @@ def test_dict_with_subcast_key_deprecated(self, set_env, env):
with pytest.warns(DeprecationWarning):
assert env.dict("DICT", subcast_key=int) == {1: "value1", 2: "value2"}

def test_custom_subcast_list(self, set_env, env):
class CustomTuple(ma.fields.Field):
def _deserialize(self, value: str, *args, **kwargs):
return tuple(value[1:-1].split(":"))

def custom_tuple(value: str):
return tuple(value[1:-1].split(":"))

set_env({"LIST": "(127.0.0.1:26380),(127.0.0.1:26379)"})
assert env.list("LIST", subcast=CustomTuple) == [("127.0.0.1", "26380"), ("127.0.0.1", "26379")]
assert env.list("LIST", subcast=custom_tuple) == [("127.0.0.1", "26380"), ("127.0.0.1", "26379")]

def test_custom_subcast_keys_values(self, set_env, env):
def custom_tuple(value: str):
return tuple(value.split(":"))

set_env({"DICT": "1:1=foo:bar"})
assert env.dict("DICT", subcast_keys=custom_tuple, subcast_values=custom_tuple) == {
("1", "1"): ("foo", "bar")
}

def test_dict_with_default_from_string(self, set_env, env):
assert env.dict("DICT", "key1=1,key2=2") == {"key1": "1", "key2": "2"}

Expand Down

0 comments on commit 9199ab5

Please sign in to comment.