Skip to content

Commit 3193b91

Browse files
authored
Improve response output (#220)
This PR introduces a way to remove `None` values from serialised responses so they are more compact (and so that they mirror the REST API response). Additionally, it changes how the data is returned to improve clarity for users (by using clearer/more appropriate names): - Remove the `.json` property. The rationale is that we weren't actually returning a json string but a dictionary. Also, given that it was a property, we couldn't take additional parameters to specify whether a 'compact' version should be returned. - Add a `.to_dict()` method, which returns a dictionary. It takes a parameter `exclude_none` (which defaults to `True`) which excludes any keys which are None or empty lists*. I decided to call it `exclude_none` rather than `compact` to mirror how Pydantic deals with the same issue (model_dump optionally takes `exclude_none=True` in order to remove all `None` values). This prepares us for a future move to Pydantic (instead of Python dataclasses), as we've discussed. - Add a `.to_json()` method, which returns a json string (via `json.dumps`). It takes the same `exclude_none` parameter described above. Those two methods are implemented via a mixin class.
1 parent a3cdc9d commit 3193b91

2 files changed

Lines changed: 232 additions & 30 deletions

File tree

datacommons_client/endpoints/response.py

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import asdict
22
from dataclasses import dataclass
33
from dataclasses import field
4+
import json
45
from typing import Any, Dict, List
56

67
from datacommons_client.models.node import Arcs
@@ -14,11 +15,46 @@
1415
from datacommons_client.models.resolve import Entity
1516
from datacommons_client.utils.data_processing import flatten_properties
1617
from datacommons_client.utils.data_processing import observations_as_records
17-
from datacommons_client.utils.data_processing import unpack_arcs
18+
19+
20+
class SerializableMixin:
21+
"""Provides serialization methods for the Response dataclasses."""
22+
23+
def to_dict(self, exclude_none: bool = True) -> Dict[str, Any]:
24+
"""Converts the instance to a dictionary.
25+
26+
Args:
27+
exclude_none: If True, only include non-empty values in the response.
28+
29+
Returns:
30+
Dict[str, Any]: The dictionary representation of the instance.
31+
"""
32+
33+
def _remove_none(data: Any) -> Any:
34+
"""Recursively removes None or empty values from a dictionary or list."""
35+
if isinstance(data, dict):
36+
return {k: _remove_none(v) for k, v in data.items() if v is not None}
37+
elif isinstance(data, list):
38+
return [_remove_none(item) for item in data]
39+
return data
40+
41+
result = asdict(self)
42+
return _remove_none(result) if exclude_none else result
43+
44+
def to_json(self, exclude_none: bool = True) -> str:
45+
"""Converts the instance to a JSON string.
46+
47+
Args:
48+
exclude_none: If True, only include non-empty values in the response.
49+
50+
Returns:
51+
str: The JSON string representation of the instance.
52+
"""
53+
return json.dumps(self.to_dict(exclude_none=exclude_none), indent=2)
1854

1955

2056
@dataclass
21-
class DCResponse:
57+
class DCResponse(SerializableMixin):
2258
"""Represents a structured response from the Data Commons API."""
2359

2460
json: Dict[str, Any] = field(default_factory=dict)
@@ -33,7 +69,7 @@ def next_token(self):
3369

3470

3571
@dataclass
36-
class NodeResponse:
72+
class NodeResponse(SerializableMixin):
3773
"""Represents a response from the Node endpoint of the Data Commons API.
3874
3975
Attributes:
@@ -69,13 +105,9 @@ def parse_data(data: Dict[str, Any]) -> Arcs | Properties:
69105
def get_properties(self) -> Dict:
70106
return flatten_properties(self.data)
71107

72-
@property
73-
def json(self):
74-
return asdict(self)
75-
76108

77109
@dataclass
78-
class ObservationResponse:
110+
class ObservationResponse(SerializableMixin):
79111
"""Represents a response from the Observation endpoint of the Data Commons API.
80112
81113
Attributes:
@@ -100,10 +132,6 @@ def from_json(cls, json_data: Dict[str, Any]) -> "ObservationResponse":
100132
},
101133
)
102134

103-
@property
104-
def json(self):
105-
return asdict(self)
106-
107135
def get_data_by_entity(self) -> Dict:
108136
"""Unpacks the data for each entity, for each variable.
109137
@@ -125,7 +153,7 @@ def get_observations_as_records(self) -> List[Dict[str, Any]]:
125153

126154

127155
@dataclass
128-
class ResolveResponse:
156+
class ResolveResponse(SerializableMixin):
129157
"""Represents a response from the Resolve endpoint of the Data Commons API.
130158
131159
Attributes:
@@ -149,15 +177,3 @@ def from_json(cls, json_data: Dict[str, Any]) -> "ResolveResponse":
149177
return cls(entities=[
150178
Entity.from_json(entity) for entity in json_data.get("entities", [])
151179
])
152-
153-
@property
154-
def json(self):
155-
"""Converts the ResolveResponse instance to a dictionary.
156-
157-
This is useful for serializing the response data back into a JSON-compatible
158-
format.
159-
160-
Returns:
161-
Dict[str, Any]: The dictionary representation of the ResolveResponse instance.
162-
"""
163-
return asdict(self)

datacommons_client/tests/endpoints/test_response.py

Lines changed: 191 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
13
from datacommons_client.endpoints.response import DCResponse
24
from datacommons_client.endpoints.response import NodeResponse
35
from datacommons_client.endpoints.response import ObservationResponse
@@ -78,7 +80,48 @@ def test_node_as_dict():
7880
}
7981

8082
response = NodeResponse.from_json(json_data)
81-
result = response.json
83+
result = response.to_dict()
84+
85+
assert result == json_data
86+
87+
88+
def test_node_as_dict_exclude_none():
89+
"""Test that the NodeResponse.json property returns the correct dictionary."""
90+
json_data = {
91+
"data": {
92+
"geoId/06": {
93+
"properties": None
94+
}
95+
},
96+
"nextToken": "token123",
97+
}
98+
99+
expected = {
100+
"data": {
101+
"geoId/06": {}
102+
},
103+
"nextToken": "token123",
104+
}
105+
106+
response = NodeResponse.from_json(json_data)
107+
result = response.to_dict(exclude_none=True)
108+
109+
assert result == expected
110+
111+
112+
def test_node_as_dict_include_none():
113+
"""Test that the NodeResponse.json property returns the correct dictionary."""
114+
json_data = {
115+
"data": {
116+
"geoId/06": {
117+
"properties": None
118+
}
119+
},
120+
"nextToken": "token123",
121+
}
122+
123+
response = NodeResponse.from_json(json_data)
124+
result = response.to_dict(exclude_none=False)
82125

83126
assert result == json_data
84127

@@ -238,14 +281,46 @@ def test_observation_as_dict():
238281
response = ObservationResponse.from_json(json_data)
239282

240283
# Getting it back as a dictionary
241-
result = response.json
284+
result = response.to_dict()
242285

243286
assert "byVariable" in result
244287
assert "facets" in result
245288
assert "var1" in result["byVariable"]
246289
assert "entity1" in result["byVariable"]["var1"]["byEntity"]
247290

248291

292+
def test_observation_as_dict_exclude_none():
293+
"""Test that the ObservationResponse.json property returns the correct dictionary."""
294+
json_data = {
295+
"byVariable": {
296+
"var1": {
297+
"byEntity": {
298+
"entity1": {
299+
"orderedFacets": [{
300+
"facetId": "facet1"
301+
}]
302+
}
303+
}
304+
}
305+
},
306+
"facets": {
307+
"facet1": {
308+
"unit": "GTQ",
309+
"importName": "Import Name",
310+
}
311+
},
312+
}
313+
314+
# Parsing JSON data
315+
response = ObservationResponse.from_json(json_data)
316+
317+
# Getting it back as a dictionary
318+
result = response.to_dict(exclude_none=True)
319+
320+
assert ("latestDate" not in result["byVariable"]["var1"]["byEntity"]
321+
["entity1"]["orderedFacets"][0])
322+
323+
249324
def test_observation_response_from_json():
250325
"""Test that the ObservationResponse.from_json method parses JSON correctly."""
251326
json_data = {
@@ -483,8 +558,8 @@ def test_resolve_response_from_json():
483558
assert entity2.candidates[0].dominantType == "Type2"
484559

485560

486-
def test_resolve_response_json():
487-
"""Test that ResolveResponse.from_json and json are consistent."""
561+
def test_resolve_response_dict():
562+
"""Test that ResolveResponse.to_dict and json are consistent."""
488563
# Input dictionary
489564
input_data = {
490565
"entities": [
@@ -516,7 +591,118 @@ def test_resolve_response_json():
516591
response = ResolveResponse.from_json(input_data)
517592

518593
# Convert back to dictionary using the json property
519-
result = response.json
594+
result = response.to_dict(exclude_none=False)
520595

521596
# Assert that the resulting dictionary matches the original input
522597
assert result == input_data
598+
599+
600+
def test_resolve_response_dict_exclude_none():
601+
"""Test that ResolveResponse.to_dict and json are consistent."""
602+
# Input dictionary
603+
input_data = {
604+
"entities": [{
605+
"node":
606+
"entity1",
607+
"candidates": [
608+
{
609+
"dcid": "dcid1",
610+
"dominantType": "Type1"
611+
},
612+
{
613+
"dcid": "dcid2",
614+
"dominantType": None
615+
},
616+
],
617+
}, {
618+
"node": "entity2",
619+
"candidates": [{
620+
"dcid": "dcid3",
621+
"dominantType": "Type2"
622+
},],
623+
}, {
624+
"node": "entity3",
625+
"candidates": [],
626+
}]
627+
}
628+
629+
# Expected data
630+
expected_data = {
631+
"entities": [{
632+
"node":
633+
"entity1",
634+
"candidates": [
635+
{
636+
"dcid": "dcid1",
637+
"dominantType": "Type1"
638+
},
639+
{
640+
"dcid": "dcid2"
641+
},
642+
],
643+
}, {
644+
"node": "entity2",
645+
"candidates": [{
646+
"dcid": "dcid3",
647+
"dominantType": "Type2"
648+
},],
649+
}, {
650+
"node": "entity3",
651+
"candidates": [],
652+
}]
653+
}
654+
655+
# Create ResolveResponse from the dictionary
656+
response = ResolveResponse.from_json(input_data)
657+
658+
# Convert back to dictionary using the json property
659+
result = response.to_dict(exclude_none=True)
660+
661+
# Assert that the resulting dictionary matches the original input
662+
assert result == expected_data
663+
664+
665+
def test_resolve_response_json_string_exclude_none():
666+
"""Test that ResolveResponse.to_dict and json are consistent."""
667+
# Input dictionary
668+
input_data = {
669+
"entities": [
670+
{
671+
"node":
672+
"entity1",
673+
"candidates": [
674+
{
675+
"dcid": "dcid1",
676+
"dominantType": "Type1"
677+
},
678+
{
679+
"dcid": "dcid2",
680+
"dominantType": None
681+
},
682+
],
683+
},
684+
{
685+
"node": "entity2",
686+
"candidates": [{
687+
"dcid": "dcid3",
688+
"dominantType": "Type2"
689+
},],
690+
},
691+
{
692+
"node": "entity3",
693+
"candidates": [],
694+
},
695+
]
696+
}
697+
698+
# Expected data
699+
expected = json.dumps(input_data, indent=2)
700+
701+
# Create ResolveResponse from the dictionary
702+
response = ResolveResponse.from_json(input_data)
703+
704+
# Convert back to dictionary using the json property
705+
result = response.to_json(exclude_none=False)
706+
707+
# Assert that the resulting dictionary matches the original input
708+
assert result == expected

0 commit comments

Comments
 (0)