Skip to content

Commit d16a930

Browse files
committed
Update serializer with recursive cleaning and removing null values
1 parent 6c239a3 commit d16a930

File tree

7 files changed

+79
-44
lines changed

7 files changed

+79
-44
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.0.10
2+
3+
* Update serializer with recursive cleaning and removing null values
4+
15
## 2.0.9
26

37
* Update serializer with removing empty dicts/lists and transforming empty dicts into nulls in lists

fhirpy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from .lib import AsyncFHIRClient, SyncFHIRClient
22

33
__title__ = "fhir-py"
4-
__version__ = "2.0.9"
4+
__version__ = "2.0.10"
55
__author__ = "beda.software"
66
__license__ = "None"
77
__copyright__ = "Copyright 2024 beda.software"

fhirpy/base/lib_async.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ async def save(
111111
# _as_dict is a private api used internally
112112
_as_dict: bool = False,
113113
) -> Union[TResource, Any]:
114-
data = serialize(self.dump(resource), remove_nulls=fields is None)
114+
data = serialize(self.dump(resource), drop_nulls_from_dicts=fields is None)
115115
if fields:
116116
if not resource.id:
117117
raise TypeError("Resource `id` is required for update operation")
@@ -171,7 +171,7 @@ async def patch(
171171
response_data = await self._do_request(
172172
"patch",
173173
f"{resource_type}/{resource_id}",
174-
data=serialize(self.dump(kwargs), remove_nulls=False),
174+
data=serialize(self.dump(kwargs), drop_nulls_from_dicts=False),
175175
)
176176

177177
if custom_resource_class:
@@ -473,7 +473,7 @@ async def patch(self, _resource: Any = None, **kwargs) -> TResource:
473473
)
474474
data = serialize(
475475
self.client.dump(_resource if _resource is not None else kwargs),
476-
remove_nulls=False,
476+
drop_nulls_from_dicts=False,
477477
)
478478
response_data = await self.client._do_request(
479479
"PATCH", self.resource_type, data, self.params

fhirpy/base/lib_sync.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def save(
111111
# _as_dict is a private api used internally
112112
_as_dict: bool = False,
113113
) -> Union[TResource, Any]:
114-
data = serialize(self.dump(resource), remove_nulls=fields is None)
114+
data = serialize(self.dump(resource), drop_nulls_from_dicts=fields is None)
115115
if fields:
116116
if not resource.id:
117117
raise TypeError("Resource `id` is required for update operation")
@@ -167,7 +167,7 @@ def patch(
167167
response_data = self._do_request(
168168
"patch",
169169
f"{resource_type}/{resource_id}",
170-
data=serialize(self.dump(kwargs), remove_nulls=False),
170+
data=serialize(self.dump(kwargs), drop_nulls_from_dicts=False),
171171
)
172172

173173
if custom_resource_class:
@@ -473,7 +473,7 @@ def patch(self, _resource: Any = None, **kwargs) -> TResource:
473473

474474
data = serialize(
475475
self.client.dump(_resource if _resource is not None else kwargs),
476-
remove_nulls=False,
476+
drop_nulls_from_dicts=False,
477477
)
478478
response_data = self.client._do_request("patch", self.resource_type, data, self.params)
479479
return self._dict_to_resource(response_data)

fhirpy/base/resource.py

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
from fhirpy.base.client import TClient
66
from fhirpy.base.exceptions import ResourceNotFound
77
from fhirpy.base.resource_protocol import TReference, TResource, get_resource_path
8-
from fhirpy.base.utils import convert_values, get_by_path, parse_path
8+
from fhirpy.base.utils import (
9+
clean_empty_values,
10+
convert_values,
11+
get_by_path,
12+
parse_path,
13+
remove_nulls_from_dicts,
14+
)
915

1016

1117
class AbstractResource(Generic[TClient], dict, ABC):
@@ -251,13 +257,7 @@ def is_local(self):
251257
pass
252258

253259

254-
def serialize(resource: Any, remove_nulls=True) -> dict:
255-
"""
256-
* empty dicts/lists are always removed
257-
* nulls are removed only for dicts if `remove_nulls` is set
258-
* in lists empty dicts are transformed into nulls because nulls are used for alignment
259-
"""
260-
260+
def serialize(resource: Any, drop_nulls_from_dicts=True) -> dict:
261261
def convert_fn(item):
262262
if isinstance(item, BaseResource):
263263
return serialize(item.to_reference()), True
@@ -267,40 +267,16 @@ def convert_fn(item):
267267

268268
if _is_serializable_dict_like(item):
269269
# Handle dict-serializable structures like pydantic Model
270-
item = _remove_dict_empty_values(dict(item))
271-
272-
if remove_nulls:
273-
return _remove_nulls(item), False
274-
return item, False
275-
276-
if isinstance(item, list):
277-
return _transform_list_empty_values_to_null(item), False
270+
return dict(item), False
278271

279272
return item, False
280273

281-
return convert_values(dict(resource), convert_fn)
282-
283-
284-
def _remove_dict_empty_values(d: dict):
285-
return {key: value for key, value in d.items() if not _is_empty(value)}
286-
287-
288-
def _transform_list_empty_values_to_null(d: list):
289-
return [None if _is_empty(value) else value for value in d]
290-
291-
292-
def _remove_nulls(d: dict):
293-
return {key: value for key, value in d.items() if not _is_null(value)}
294-
295-
296-
def _is_empty(d: Any):
297-
if isinstance(d, (dict, list)):
298-
return not d
299-
return False
274+
converted_values = convert_values(dict(resource), convert_fn)
300275

276+
if drop_nulls_from_dicts:
277+
converted_values = remove_nulls_from_dicts(converted_values)
301278

302-
def _is_null(d: Any):
303-
return d is None
279+
return clean_empty_values(converted_values)
304280

305281

306282
def _is_serializable_dict_like(item):

fhirpy/base/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import reprlib
2+
from typing import Any
23
from urllib.parse import parse_qs, quote, urlencode, urlparse
34

45
from yarl import URL
@@ -218,3 +219,34 @@ def set_by_path(obj, path, value):
218219

219220
def remove_prefix(s, prefix):
220221
return s[len(prefix) :] if s.startswith(prefix) else s
222+
223+
224+
def clean_empty_values(data: Any):
225+
if isinstance(data, dict):
226+
cleaned_dict = {k: clean_empty_values(v) for k, v in data.items()}
227+
return {k: v for k, v in cleaned_dict.items() if not _is_empty(v)}
228+
229+
if isinstance(data, list):
230+
return [clean_empty_values(item) if not _is_empty(item) else None for item in data]
231+
232+
return data
233+
234+
235+
def _is_empty(d: Any):
236+
if isinstance(d, (dict, list)):
237+
return not d
238+
return False
239+
240+
241+
def remove_nulls_from_dicts(data: Any):
242+
if isinstance(data, dict):
243+
return {k: remove_nulls_from_dicts(v) for k, v in data.items() if not _is_null(v)}
244+
245+
if isinstance(data, list):
246+
return [remove_nulls_from_dicts(item) for item in data]
247+
248+
return data
249+
250+
251+
def _is_null(d: Any):
252+
return d is None

tests/test_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pydantic import BaseModel
44

55
from fhirpy.base.resource_protocol import get_resource_type_from_class
6+
from fhirpy.base.utils import clean_empty_values, remove_nulls_from_dicts
67

78

89
def test_get_resource_type_from_class_for_pydantic_model_value():
@@ -17,3 +18,25 @@ class Patient(BaseModel):
1718
resourceType: Literal["Patient"] # noqa: N815
1819

1920
assert get_resource_type_from_class(Patient) == "Patient"
21+
22+
23+
def test_remove_nulls_from_dicts():
24+
assert remove_nulls_from_dicts({}) == {}
25+
assert remove_nulls_from_dicts({"item": []}) == {"item": []}
26+
assert remove_nulls_from_dicts({"item": [None]}) == {"item": [None]}
27+
assert remove_nulls_from_dicts({"item": [None, {"item": None}]}) == {"item": [None, {}]}
28+
assert remove_nulls_from_dicts({"item": [None, {"item": None}, {}]}) == {"item": [None, {}, {}]}
29+
30+
31+
def test_clean_empty_values():
32+
assert clean_empty_values({}) == {}
33+
assert clean_empty_values({"str": ""}) == {"str": ""}
34+
assert clean_empty_values({"nested": {"nested2": [{}]}}) == {"nested": {"nested2": [None]}}
35+
assert clean_empty_values({"nested": {"nested2": {}}}) == {}
36+
assert clean_empty_values({"item": []}) == {}
37+
assert clean_empty_values({"item": []}) == {}
38+
assert clean_empty_values({"item": [None]}) == {"item": [None]}
39+
assert clean_empty_values({"item": [None, {"item": None}]}) == {"item": [None, {"item": None}]}
40+
assert clean_empty_values({"item": [None, {"item": None}, {}]}) == {
41+
"item": [None, {"item": None}, None]
42+
}

0 commit comments

Comments
 (0)