Skip to content

Commit bdd5d8c

Browse files
Fix dict representation not being JSON serializable (#1632)
1 parent 4d6a83a commit bdd5d8c

File tree

3 files changed

+141
-8
lines changed

3 files changed

+141
-8
lines changed

fsspec/json.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import json
22
from contextlib import suppress
33
from pathlib import PurePath
4-
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
4+
from typing import (
5+
Any,
6+
Callable,
7+
ClassVar,
8+
Dict,
9+
List,
10+
Mapping,
11+
Optional,
12+
Sequence,
13+
Tuple,
14+
)
515

616
from .registry import _import_class, get_filesystem_class
717
from .spec import AbstractFileSystem
@@ -19,6 +29,21 @@ def default(self, o: Any) -> Any:
1929

2030
return super().default(o)
2131

32+
def make_serializable(self, obj: Any) -> Any:
33+
"""
34+
Recursively converts an object so that it can be JSON serialized via
35+
:func:`json.dumps` and :func:`json.dump`, without actually calling
36+
said functions.
37+
"""
38+
if isinstance(obj, (str, int, float, bool)):
39+
return obj
40+
if isinstance(obj, Mapping):
41+
return {k: self.make_serializable(v) for k, v in obj.items()}
42+
if isinstance(obj, Sequence):
43+
return [self.make_serializable(v) for v in obj]
44+
45+
return self.default(obj)
46+
2247

2348
class FilesystemJSONDecoder(json.JSONDecoder):
2449
def __init__(
@@ -81,3 +106,16 @@ def custom_object_hook(self, dct: Dict[str, Any]):
81106
return self.original_object_hook(dct)
82107

83108
return dct
109+
110+
def unmake_serializable(self, obj: Any) -> Any:
111+
"""
112+
Inverse function of :meth:`FilesystemJSONEncoder.make_serializable`.
113+
"""
114+
if isinstance(obj, dict):
115+
obj = self.custom_object_hook(obj)
116+
if isinstance(obj, dict):
117+
return {k: self.unmake_serializable(v) for k, v in obj.items()}
118+
if isinstance(obj, (list, tuple)):
119+
return [self.unmake_serializable(v) for v in obj]
120+
121+
return obj

fsspec/spec.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,6 +1466,10 @@ def to_dict(self, *, include_password: bool = True) -> Dict[str, Any]:
14661466
passed to the constructor, such as passwords and tokens. Make sure you
14671467
store and send them in a secure environment!
14681468
"""
1469+
from .json import FilesystemJSONEncoder
1470+
1471+
json_encoder = FilesystemJSONEncoder()
1472+
14691473
cls = type(self)
14701474
proto = self.protocol
14711475

@@ -1476,8 +1480,8 @@ def to_dict(self, *, include_password: bool = True) -> Dict[str, Any]:
14761480
return dict(
14771481
cls=f"{cls.__module__}:{cls.__name__}",
14781482
protocol=proto[0] if isinstance(proto, (tuple, list)) else proto,
1479-
args=self.storage_args,
1480-
**storage_options,
1483+
args=json_encoder.make_serializable(self.storage_args),
1484+
**json_encoder.make_serializable(storage_options),
14811485
)
14821486

14831487
@staticmethod
@@ -1503,6 +1507,8 @@ def from_dict(dct: Dict[str, Any]) -> AbstractFileSystem:
15031507
"""
15041508
from .json import FilesystemJSONDecoder
15051509

1510+
json_decoder = FilesystemJSONDecoder()
1511+
15061512
dct = dict(dct) # Defensive copy
15071513

15081514
cls = FilesystemJSONDecoder.try_resolve_fs_cls(dct)
@@ -1512,7 +1518,10 @@ def from_dict(dct: Dict[str, Any]) -> AbstractFileSystem:
15121518
dct.pop("cls", None)
15131519
dct.pop("protocol", None)
15141520

1515-
return cls(*dct.pop("args", ()), **dct)
1521+
return cls(
1522+
*json_decoder.unmake_serializable(dct.pop("args", ())),
1523+
**json_decoder.unmake_serializable(dct),
1524+
)
15161525

15171526
def _get_pyarrow_filesystem(self):
15181527
"""

fsspec/tests/test_spec.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -875,17 +875,38 @@ def test_json_path_attr():
875875

876876
def test_json_fs_attr():
877877
a = DummyTestFS(1)
878-
b = DummyTestFS(2, bar=a)
878+
b = DummyTestFS(2, bar=Path("baz"))
879+
c = DummyTestFS(3, baz=b)
879880

880881
outa = a.to_json()
881882
outb = b.to_json()
883+
outc = c.to_json()
882884

883-
assert json.loads(outb) # is valid JSON
884-
assert a != b
885-
assert "bar" in outb
885+
assert json.loads(outc) # is valid JSON
886+
assert b != c
887+
assert "baz" in outc
888+
889+
assert DummyTestFS.from_json(outa) is a
890+
assert DummyTestFS.from_json(outb) is b
891+
assert DummyTestFS.from_json(outc) is c
892+
893+
894+
def test_json_dict_attr():
895+
a = DummyTestFS(1)
896+
b = DummyTestFS(2, bar=Path("baz"))
897+
c = DummyTestFS(3, baz={"key": b})
898+
899+
outa = a.to_json()
900+
outb = b.to_json()
901+
outc = c.to_json()
902+
903+
assert json.loads(outc) # is valid JSON
904+
assert b != c
905+
assert "baz" in outc
886906

887907
assert DummyTestFS.from_json(outa) is a
888908
assert DummyTestFS.from_json(outb) is b
909+
assert DummyTestFS.from_json(outc) is c
889910

890911

891912
def test_dict():
@@ -903,6 +924,57 @@ def test_dict():
903924
assert DummyTestFS.from_dict(outb) is b
904925

905926

927+
def test_dict_path_attr():
928+
a = DummyTestFS(1)
929+
b = DummyTestFS(2, bar=Path("baz"))
930+
931+
outa = a.to_dict()
932+
outb = b.to_dict()
933+
934+
assert isinstance(outa, dict)
935+
assert a != b
936+
assert outb["bar"]["str"] == "baz"
937+
938+
assert DummyTestFS.from_dict(outa) is a
939+
assert DummyTestFS.from_dict(outb) is b
940+
941+
942+
def test_dict_fs_attr():
943+
a = DummyTestFS(1)
944+
b = DummyTestFS(2, bar=Path("baz"))
945+
c = DummyTestFS(3, baz=b)
946+
947+
outa = a.to_dict()
948+
outb = b.to_dict()
949+
outc = c.to_dict()
950+
951+
assert isinstance(outc, dict)
952+
assert b != c
953+
assert outc["baz"] == outb
954+
955+
assert DummyTestFS.from_dict(outa) is a
956+
assert DummyTestFS.from_dict(outb) is b
957+
assert DummyTestFS.from_dict(outc) is c
958+
959+
960+
def test_dict_dict_attr():
961+
a = DummyTestFS(1)
962+
b = DummyTestFS(2, bar=Path("baz"))
963+
c = DummyTestFS(3, baz={"key": b})
964+
965+
outa = a.to_dict()
966+
outb = b.to_dict()
967+
outc = c.to_dict()
968+
969+
assert isinstance(outc, dict)
970+
assert b != c
971+
assert outc["baz"]["key"] == outb
972+
973+
assert DummyTestFS.from_dict(outa) is a
974+
assert DummyTestFS.from_dict(outb) is b
975+
assert DummyTestFS.from_dict(outc) is c
976+
977+
906978
def test_dict_idempotent():
907979
a = DummyTestFS(1)
908980

@@ -912,6 +984,20 @@ def test_dict_idempotent():
912984
assert DummyTestFS.from_dict(outa) is a
913985

914986

987+
def test_dict_json_serializable():
988+
a = DummyTestFS(1)
989+
b = DummyTestFS(2, bar=Path("baz"))
990+
c = DummyTestFS(3, baz=b)
991+
992+
outa = a.to_dict()
993+
outb = b.to_dict()
994+
outc = c.to_dict()
995+
996+
json.dumps(outa)
997+
json.dumps(outb)
998+
json.dumps(outc)
999+
1000+
9151001
def test_serialize_no_password():
9161002
fs = DummyTestFS(1, password="admin")
9171003

0 commit comments

Comments
 (0)