Skip to content

Commit

Permalink
Apply review comments on exclude/enclude field arguments
Browse files Browse the repository at this point in the history
* Fix/simplify type annotations
* Allow both ``True`` and ``Ellipsis`` to be used to indicate full field
  exclusion
* Reenable hypothesis plugin (removed by mistake)
* Update advanced include/include docs to use ``True`` instead of ``...``
  • Loading branch information
daviskirk committed Feb 28, 2021
1 parent 78e9aa8 commit 54a9302
Show file tree
Hide file tree
Showing 11 changed files with 69 additions and 72 deletions.
4 changes: 2 additions & 2 deletions docs/examples/exporting_models_exclude1.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ class Transaction(BaseModel):
print(t.dict(exclude={'user', 'value'}))

# using a dict:
print(t.dict(exclude={'user': {'username', 'password'}, 'value': ...}))
print(t.dict(exclude={'user': {'username', 'password'}, 'value': True}))

print(t.dict(include={'id': ..., 'user': {'id'}}))
print(t.dict(include={'id': True, 'user': {'id'}}))
10 changes: 5 additions & 5 deletions docs/examples/exporting_models_exclude2.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,17 @@ class User(BaseModel):
)

exclude_keys = {
'second_name': ...,
'address': {'post_code': ..., 'country': {'phone_code'}},
'card_details': ...,
'second_name': True,
'address': {'post_code': True, 'country': {'phone_code'}},
'card_details': True,
# You can exclude fields from specific members of a tuple/list by index:
'hobbies': {-1: {'info'}},
}

include_keys = {
'first_name': ...,
'first_name': True,
'address': {'country': {'name'}},
'hobbies': {0: ..., -1: {'name'}},
'hobbies': {0: True, -1: {'name'}},
}

# would be the same as user.dict(exclude=exclude_keys) in this case:
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/exporting_models_exclude3.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
class User(BaseModel):
id: int
username: str
password: SecretStr = Field(..., exclude=...)
password: SecretStr = Field(..., exclude=True)


class Transaction(BaseModel):
Expand All @@ -13,7 +13,7 @@ class Transaction(BaseModel):
value: int

class Config:
fields = {'value': {'exclude': ...}}
fields = {'value': {'exclude': True}}


t = Transaction(
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/exporting_models_exclude4.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
class User(BaseModel):
id: int
username: str # overridden by explicit exclude
password: SecretStr = Field(exclude=...)
password: SecretStr = Field(exclude=True)


class Transaction(BaseModel):
Expand All @@ -23,4 +23,4 @@ class Transaction(BaseModel):
value=9876543210,
)

print(t.dict(exclude={'value': ..., 'user': {'username'}}))
print(t.dict(exclude={'value': True, 'user': {'username'}}))
6 changes: 3 additions & 3 deletions docs/examples/exporting_models_exclude5.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@


class User(BaseModel):
id: int = Field(..., include=...)
username: str = Field(..., include=...) # overridden by explicit include
id: int = Field(..., include=True)
username: str = Field(..., include=True) # overridden by explicit include
password: SecretStr


Expand All @@ -23,4 +23,4 @@ class Transaction(BaseModel):
value=9876543210,
)

print(t.dict(include={'id': ..., 'user': {'id'}}))
print(t.dict(include={'id': True, 'user': {'id'}}))
10 changes: 5 additions & 5 deletions docs/usage/exporting_models.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ sets or dictionaries. This allows nested selection of which fields to export:
{!.tmp_examples/exporting_models_exclude1.py!}
```

The ellipsis (``...``) indicates that we want to exclude or include an entire key, just as if we included it in a set.
The ``True`` indicates that we want to exclude or include an entire key, just as if we included it in a set.
Of course, the same can be done at any depth level.

Special care must be taken when including or excluding fields from a list or tuple of submodels or dictionaries. In this scenario,
Expand All @@ -177,20 +177,20 @@ The same holds for the `json` and `copy` methods.

### Model and field level include and exclude

In addition to the explicit arguments `exclude` and `include` passed to `dict`, `json` and `copy` methods, we can also pass the `include`/`exclude` arguments directly to the `Field` constructor or the equivilant `field` entry in the models `Config` class:
In addition to the explicit arguments `exclude` and `include` passed to `dict`, `json` and `copy` methods, we can also pass the `include`/`exclude` arguments directly to the `Field` constructor or the equivalent `field` entry in the models `Config` class:

```py
{!.tmp_examples/exporting_models_exclude3.py!}
```

In the case where multiple strategies are used, `exclude`/`include` fields are merged according to the following rules:

* First, model config level settings (via `"fields"` entry) are merged per field with the field constructor settings (i.e. `Field(..., exclude=...)`), with the field constructor taking priority.
* The resulting settings are merged per class with the excplicit settings on `dict`, `json`, `copy` calls with the exclicit settings taking priority.
* First, model config level settings (via `"fields"` entry) are merged per field with the field constructor settings (i.e. `Field(..., exclude=True)`), with the field constructor taking priority.
* The resulting settings are merged per class with the explicit settings on `dict`, `json`, `copy` calls with the explicit settings taking priority.

Note that while merging settings, `exclude` entries are merged by computing the "union" of keys, while `include` entries are merged by computing the "intersection" of keys.

The resulting merged exlude settings:
The resulting merged exclude settings:

```py
{!.tmp_examples/exporting_models_exclude4.py!}
Expand Down
6 changes: 4 additions & 2 deletions pydantic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,9 @@ def _iter(
if include is not None or self.__include_fields__ is not None:
include = ValueItems.merge(self.__include_fields__, include, intersect=True)

allowed_keys = self._calculate_keys(include=include, exclude=exclude, exclude_unset=exclude_unset)
allowed_keys = self._calculate_keys(
include=include, exclude=exclude, exclude_unset=exclude_unset # type: ignore
)
if allowed_keys is None and not (to_dict or by_alias or exclude_unset or exclude_defaults or exclude_none):
# huge boost for plain _iter()
yield from self.__dict__.items()
Expand Down Expand Up @@ -914,7 +916,7 @@ def _calculate_keys(
keys -= update.keys()

if exclude:
keys -= {k for k, v in exclude.items() if v is ...}
keys -= {k for k, v in exclude.items() if ValueItems.is_true(v)}

return keys

Expand Down
73 changes: 23 additions & 50 deletions pydantic/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
Type,
TypeVar,
Union,
overload,
)

from .typing import GenericAlias, NoneType, display_as_type
Expand Down Expand Up @@ -423,25 +422,20 @@ class ValueItems(Representation):
__slots__ = ('_items', '_type')

def __init__(self, value: Any, items: Union['AbstractSetIntStr', 'MappingIntStrAny']) -> None:
if TYPE_CHECKING:
self._items: 'MappingIntStrAny'

items = self._coerce_items(items)

if isinstance(value, (list, tuple)):
items = self._normalize_indexes(items, len(value))

self._items = items
self._items: 'MappingIntStrAny' = items

def is_excluded(self, item: Any) -> bool:
"""
Check if item is fully excluded
(value considered excluded if self._type is set and item contained in self._items
or self._type is dict and self._items.get(item) is ...
Check if item is fully excluded.
:param item: key or index of a value
"""
return self._items.get(item) is ...
return self.is_true(self._items.get(item))

def is_included(self, item: Any) -> bool:
"""
Expand All @@ -458,25 +452,23 @@ def for_element(self, e: 'IntStr') -> Optional[Union['AbstractSetIntStr', 'Mappi
"""

item = self._items.get(e)
return item if item is not ... else None
return item if not self.is_true(item) else None

def _normalize_indexes(self, items: 'MappingIntStrAny', v_length: int) -> 'DictIntStrAny':
"""
:param items: dict or set of indexes which will be normalized
:param v_length: length of sequence indexes of which will be
>>> self._normalize_indexes({0, -2, -1}, 4)
{0, 2, 3}
>>> self._normalize_indexes({'__all__'}, 4)
{0, 1, 2, 3}
>>> self._normalize_indexes({0: True, -2: True, -1: True}, 4)
{0: True, 2: True, 3: True}
>>> self._normalize_indexes({'__all__': True}, 4)
{0: True, 1: True, 2: True, 3: True}
"""

if TYPE_CHECKING:
normalized_items: 'DictIntStrAny'
normalized_items: 'DictIntStrAny' = {}
all_items = None
normalized_items = {}
for i, v in items.items():
if not (isinstance(v, Mapping) or isinstance(v, AbstractSet) or v is ...):
if not (isinstance(v, Mapping) or isinstance(v, AbstractSet) or self.is_true(v)):
raise TypeError(f'Unexpected type of exclude value for index "{i}" {v.__class__}')
if i == '__all__':
all_items = self._coerce_value(v)
Expand All @@ -491,40 +483,16 @@ def _normalize_indexes(self, items: 'MappingIntStrAny', v_length: int) -> 'DictI

if not all_items:
return normalized_items
if all_items is ...:
if self.is_true(all_items):
for i in range(v_length):
normalized_items.setdefault(i, ...)
return normalized_items
for i in range(v_length):
normalized_item = normalized_items.setdefault(i, {})
if normalized_item is not ...:
if not self.is_true(normalized_item):
normalized_items[i] = self.merge(all_items, normalized_item)
return normalized_items

@overload
@classmethod
def merge(
cls, base: Union['AbstractSetIntStr', 'MappingIntStrAny'], override: Optional[Any], intersect: bool = False
) -> 'MappingIntStrAny':
...

@overload
@classmethod
def merge(
cls, base: Optional[Any], override: Union['AbstractSetIntStr', 'MappingIntStrAny'], intersect: bool = False
) -> 'MappingIntStrAny':
...

@overload
@classmethod
def merge(cls, base: None, override: None, intersect: bool = False) -> None:
...

@overload
@classmethod
def merge(cls, base: Optional[Any], override: Optional[Any], intersect: bool = False) -> Optional[Any]:
...

@classmethod
def merge(cls, base: Any, override: Any, intersect: bool = False) -> Any:
"""
Expand All @@ -545,17 +513,18 @@ def merge(cls, base: Any, override: Any, intersect: bool = False) -> Any:
base = cls._coerce_value(base)
if override is None:
return base
if base is ... or base is None:
if cls.is_true(base) or base is None:
return override
elif override is ...:
if cls.is_true(override):
return base if intersect else override

# intersection or union of keys while preserving ordering:
if intersect:
merge_keys = override.keys() & base.keys()
merge_keys = [k for k in base if k in override] + [k for k in override if k in base]
else:
merge_keys = override.keys() | base.keys()
merge_keys = list(base) + [k for k in override if k not in base]

merged = {}
merged: 'DictIntStrAny' = {}
for k in merge_keys:
merged_item = cls.merge(base.get(k), override.get(k), intersect=intersect)
if merged_item is not None:
Expand All @@ -575,10 +544,14 @@ def _coerce_items(items: Union['AbstractSetIntStr', 'MappingIntStrAny']) -> 'Map

@classmethod
def _coerce_value(cls, value: Any) -> Any:
if value is None or value is ...:
if value is None or cls.is_true(value):
return value
return cls._coerce_items(value)

@staticmethod
def is_true(v: Any) -> bool:
return v is True or v is ...

def __repr_args__(self) -> 'ReprArgs':
return [(None, self._items)]

Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
except ImportError:
pytest_plugins = []
else:
pytest_plugins = []
pytest_plugins = ['hypothesis.extra.pytestplugin']


def _extract_source_code_from_function(function):
Expand Down
17 changes: 17 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,23 @@ class Config:
assert actual == expected, 'Unexpected model export result'


def test_model_export_with_true_instead_of_ellipsis():
class Sub(BaseModel):
s1: int = 1

class Model(BaseModel):
a: int = 2
b: int = Field(3, exclude=True)
c: int = Field(4)
s: Sub = Sub()

class Config:
fields = {"c": {"exclude": True}}

m = Model()
assert m.dict(exclude={'s': True}) == {'a': 2}


def test_model_export_inclusion():
class Sub(BaseModel):
s1: str = 'v1'
Expand Down
5 changes: 5 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ def test_value_items():
({'a': ...}, {'b': {'c'}}, True, {}),
({'a': ...}, {'a': {'c'}}, True, {'a': {'c': ...}}),
({'a': {'c': ...}, 'b': {'d'}}, {'a': ...}, True, {'a': {'c': ...}}),
# Check usage of `True` instead of `...`
(..., True, False, True),
(True, ..., False, ...),
(True, None, False, True),
({'a': {'c': True}, 'b': {'d'}}, {'a': True}, False, {'a': True, 'b': {'d': ...}}),
],
)
def test_value_items_merge(base, override, intersect, expected):
Expand Down

0 comments on commit 54a9302

Please sign in to comment.