Skip to content

Commit e35b3b0

Browse files
committed
Prototype serialization feature
1 parent 361a1d5 commit e35b3b0

6 files changed

Lines changed: 406 additions & 2 deletions

File tree

docs/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,39 @@ Example:
5757
'{"a": 1}\n'
5858
```
5959

60+
### Object serialization
61+
62+
The `Formatter.serialize` method is used to serialize a python object to formatted JSON.
63+
64+
``` python
65+
>>> from fractured_json import Formatter
66+
>>> obj = {'a': 1, 'b': [7, 8, 9], 'c': {'x': 0, 'y': 0}}
67+
>>> formatter = Formatter()
68+
>>> formatter.serialize(obj)
69+
'{ "a": 1, "b": [7, 8, 9], "c": {"x": 0, "y": 0} }\n'
70+
```
71+
72+
Optional parameters for the starting depth of formatting and for options for the serialization into JSON are available but the serialization options are not Python but instead a [.NET JsonSerializerOptions Class](https://docs.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions). You must use `pythonnet` to construct this class and then interact with it. For example to enable escaping of `'` in the JSON output:
73+
74+
``` python
75+
import clr
76+
77+
clr.AddReference("System.Text.Json")
78+
clr.AddReference("System.Text.Encodings.Web")
79+
80+
from System.Text.Encodings.Web import JavaScriptEncoder
81+
from System.Text.Json import JsonSerializerOptions
82+
83+
json_options = JsonSerializerOptions()
84+
json_options.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
85+
json_options.WriteIndented = True
86+
87+
formatter = Formatter()
88+
result = formatter.serialize(obj, 0, json_options)
89+
```
90+
91+
92+
6093
### Options
6194

6295
A full description of the options available can be found in the [FracturedJson Wiki](https://github.com/j-brooke/FracturedJson/wiki/Options) and these are dynamically created from the .NET library so will always match the .NET implementation.

src/fractured_json/__init__.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# pyright: reportMissingImports=false
12
import os
23
import re
34
from collections.abc import Callable
@@ -31,16 +32,27 @@ def load_runtime() -> None:
3132
load_runtime()
3233

3334
import clr # noqa: E402
34-
from System import ( # noqa: E402 # pyright: ignore[reportMissingImports]
35+
36+
# .NET types are imported late because we need to load the .NET runtime first
37+
38+
clr.AddReference("System.Text.Json")
39+
clr.AddReference("System.Text.Encodings.Web")
40+
41+
from System import ( # noqa: E402
3542
Activator,
3643
ArgumentException,
3744
Boolean,
45+
Double,
3846
Enum,
3947
Int32,
48+
Object,
4049
String,
4150
Type,
4251
)
43-
from System.Reflection import BindingFlags # pyright: ignore[reportMissingImports] # noqa: E402
52+
from System.Collections import ArrayList # noqa: E402
53+
from System.Collections.Generic import Dictionary # noqa: E402
54+
from System.Reflection import BindingFlags # noqa: E402
55+
from System.Text.Json import JsonSerializerOptions # noqa: E402
4456

4557

4658
def get_object_types() -> dict[str, "System.RuntimeType"]:
@@ -156,6 +168,7 @@ def values_fn(_cls: type) -> list[int]:
156168
FormatterType = types["Formatter"]
157169
FracturedJsonOptionsType = types["FracturedJsonOptions"]
158170

171+
159172
__all__ = [
160173
"Formatter",
161174
"FracturedJsonOptions",
@@ -292,6 +305,18 @@ def __init__(self, options: FracturedJsonOptions | None = None) -> None:
292305
options_property = FormatterType.GetProperty("Options")
293306
options_property.SetValue(self._dotnet_instance, options._dotnet_instance) # noqa: SLF001
294307

308+
# Python.NET is unable to find a matching method when we call Formatter.Serialize
309+
# directly so we need to coerce it
310+
self._serialize_method = None
311+
methods = self._dotnet_instance.GetType().GetMethods(
312+
BindingFlags.Public | BindingFlags.Instance,
313+
)
314+
serialize_methods = [m for m in methods if m.Name == "Serialize"]
315+
for m in serialize_methods:
316+
params = m.GetParameters()
317+
if len(params) == 3 and params[2].Name == "serOpts": # noqa: PLR2004
318+
self._serialize_method = m
319+
295320
@property
296321
def options(self) -> FracturedJsonOptions:
297322
"""Gets/sets the formatting options (FracturedJsonOptions)."""
@@ -342,3 +367,48 @@ def dotnet_wrapper(s_dotnet: String) -> Int32:
342367
return Int32(result)
343368

344369
self._dotnet_instance.StringLengthFunc = Func[String, Int32](dotnet_wrapper)
370+
371+
@staticmethod
372+
def to_dotnet(obj: object) -> object: # noqa: PLR0911
373+
"""Recursively converts a JSON-serializable Python object to a .NET object."""
374+
if isinstance(obj, dict):
375+
dotnet_dict = Dictionary[String, Object]()
376+
for k, v in obj.items():
377+
# Keys must be strings
378+
key = str(k)
379+
dotnet_dict[key] = Formatter.to_dotnet(v)
380+
return dotnet_dict
381+
382+
if isinstance(obj, (list, tuple)):
383+
dotnet_list = ArrayList()
384+
for item in obj:
385+
dotnet_list.Add(Formatter.to_dotnet(item))
386+
return dotnet_list
387+
388+
if isinstance(obj, bool):
389+
return Boolean(obj)
390+
if isinstance(obj, int):
391+
return Int32(obj)
392+
if isinstance(obj, float):
393+
return Double(obj)
394+
if isinstance(obj, str):
395+
return String(obj)
396+
if obj is None:
397+
return None
398+
399+
msg = f"Type '{type(obj).__name__}' not supported for serialization"
400+
raise TypeError(msg)
401+
402+
def serialize(
403+
self,
404+
obj: object,
405+
starting_depth: int = 0,
406+
ser_opts: JsonSerializerOptions = None,
407+
) -> str:
408+
"""Format a JSON-serializable Python object into a string."""
409+
dotnet_obj = Formatter.to_dotnet(obj)
410+
generic_method = self._serialize_method.MakeGenericMethod(type(dotnet_obj))
411+
return generic_method.Invoke(
412+
self._dotnet_instance,
413+
[dotnet_obj, Int32(starting_depth), ser_opts],
414+
)

src/fractured_json/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ class Formatter:
4747
def string_length_func(self) -> Callable[[str], int]: ...
4848
@string_length_func.setter
4949
def string_length_func(self, func: Callable[[str], int]) -> None: ...
50+
def serialize(self, obj: object, starting_depth: int = 0, ser_opts: object = None) -> str: ...

tests/data/test-serialize.json

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
{
2+
"BannerText": [
3+
"Sometimes you will have to protect multiple enemy goals."
4+
],
5+
"Terrain": {
6+
"TileTypes": [
7+
{
8+
"BlocksMovement": false,
9+
"BlocksVision": false,
10+
"Appearance": " ",
11+
"Name": "Open"
12+
},
13+
{
14+
"BlocksMovement": true,
15+
"BlocksVision": true,
16+
"Appearance": "*",
17+
"Name": "Stone"
18+
},
19+
{
20+
"BlocksMovement": true,
21+
"BlocksVision": false,
22+
"Appearance": "~",
23+
"Name": "Water"
24+
},
25+
{
26+
"BlocksMovement": false,
27+
"BlocksVision": true,
28+
"Appearance": "@",
29+
"Name": "Fog"
30+
}
31+
],
32+
"Width": 45,
33+
"Height": 20,
34+
"Tiles": [
35+
" *** *** ******** ",
36+
" ******* **** ",
37+
"*********** **** ",
38+
"*********** *******",
39+
"********* *********** ",
40+
" ****** ******** ",
41+
" *** ****",
42+
" ",
43+
" ",
44+
" ",
45+
" * ****",
46+
" * ***** ** ",
47+
"**** ****** **** ",
48+
"***** **** ** ",
49+
"****** *** ** ",
50+
"****** * ",
51+
" ** ****** ****",
52+
" * * ",
53+
" **** ",
54+
" ** "
55+
],
56+
"SpawnPointsMap": {
57+
"1": [
58+
[
59+
1,
60+
8
61+
]
62+
]
63+
},
64+
"GoalPointsMap": {
65+
"1": [
66+
[
67+
43,
68+
8
69+
],
70+
[
71+
43,
72+
18
73+
]
74+
]
75+
}
76+
},
77+
"AttackPlans": [
78+
{
79+
"TeamId": 1,
80+
"Spawns": [
81+
{
82+
"Time": 0.0,
83+
"UnitType": "Grunt",
84+
"SpawnPointIndex": 0
85+
},
86+
{
87+
"Time": 0.0,
88+
"UnitType": "Grunt",
89+
"SpawnPointIndex": 0
90+
},
91+
{
92+
"Time": 0.0,
93+
"UnitType": "Grunt",
94+
"SpawnPointIndex": 0
95+
},
96+
{
97+
"Time": 0.0,
98+
"UnitType": "Grunt",
99+
"SpawnPointIndex": 0
100+
},
101+
{
102+
"Time": 0.0,
103+
"UnitType": "Grunt",
104+
"SpawnPointIndex": 0
105+
}
106+
]
107+
},
108+
{
109+
"TeamId": 2,
110+
"Spawns": []
111+
}
112+
],
113+
"DefensePlans": [
114+
{
115+
"TeamId": 2,
116+
"Placements": [
117+
{
118+
"UnitType": "Archer",
119+
"Position": [
120+
41,
121+
7
122+
]
123+
},
124+
{
125+
"UnitType": "Archer",
126+
"Position": [
127+
41,
128+
8
129+
]
130+
},
131+
{
132+
"UnitType": "Archer",
133+
"Position": [
134+
41,
135+
9
136+
]
137+
},
138+
{
139+
"UnitType": "Pikeman",
140+
"Position": [
141+
40,
142+
9
143+
]
144+
},
145+
{
146+
"UnitType": "Pikeman",
147+
"Position": [
148+
40,
149+
8
150+
]
151+
},
152+
{
153+
"UnitType": "Pikeman",
154+
"Position": [
155+
40,
156+
7
157+
]
158+
},
159+
{
160+
"UnitType": "Barricade",
161+
"Position": [
162+
39,
163+
7
164+
]
165+
},
166+
{
167+
"UnitType": "Barricade",
168+
"Position": [
169+
39,
170+
8
171+
]
172+
},
173+
{
174+
"UnitType": "Barricade",
175+
"Position": [
176+
39,
177+
9
178+
]
179+
},
180+
{
181+
"UnitType": "Archer",
182+
"Position": [
183+
41,
184+
18
185+
]
186+
}
187+
]
188+
}
189+
],
190+
"Challenges": [
191+
{
192+
"Name": "*",
193+
"PlayerTeamId": 2,
194+
"AttackersMustNotReachGoal": false,
195+
"MaximumUnitTypeCount": {}
196+
},
197+
{
198+
"Name": "**",
199+
"PlayerTeamId": 2,
200+
"AttackersMustNotReachGoal": true,
201+
"MaximumUnitTypeCount": {}
202+
}
203+
]
204+
}

0 commit comments

Comments
 (0)