Skip to content

Commit 6c679d5

Browse files
authored
Merge pull request #3 from adamghill/main
Sync with main official
2 parents ddea842 + 7b9f956 commit 6c679d5

File tree

23 files changed

+685
-146
lines changed

23 files changed

+685
-146
lines changed

.all-contributorsrc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,15 @@
212212
"contributions": [
213213
"doc"
214214
]
215+
},
216+
{
217+
"login": "bloodywing",
218+
"name": "Pierre",
219+
"avatar_url": "https://avatars.githubusercontent.com/u/1392097?v=4",
220+
"profile": "https://digitalpinup.art",
221+
"contributions": [
222+
"code"
223+
]
215224
}
216225
],
217226
"contributorsPerLine": 7,

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ Thanks to the following wonderful people ([emoji key](https://allcontributors.or
217217
</tr>
218218
<tr>
219219
<td align="center" valign="top" width="14.28%"><a href="https://marteydodoo.com"><img src="https://avatars.githubusercontent.com/u/49076?v=4?s=100" width="100px;" alt="Martey Dodoo"/><br /><sub><b>Martey Dodoo</b></sub></a><br /><a href="https://github.com/adamghill/django-unicorn/commits?author=martey" title="Documentation">📖</a></td>
220+
<td align="center" valign="top" width="14.28%"><a href="https://digitalpinup.art"><img src="https://avatars.githubusercontent.com/u/1392097?v=4?s=100" width="100px;" alt="Pierre"/><br /><sub><b>Pierre</b></sub></a><br /><a href="https://github.com/adamghill/django-unicorn/commits?author=bloodywing" title="Code">💻</a></td>
220221
</tr>
221222
</tbody>
222223
</table>

django_unicorn/call_method_parser.py

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,12 @@
33
from functools import lru_cache
44
from types import MappingProxyType
55
from typing import Any, Dict, List, Mapping, Tuple
6-
from uuid import UUID
76

8-
from django.utils.dateparse import (
9-
parse_date,
10-
parse_datetime,
11-
parse_duration,
12-
parse_time,
13-
)
7+
from django_unicorn.utils import CASTERS
148

159

1610
logger = logging.getLogger(__name__)
1711

18-
# Lambdas that attempt to convert something that failed while being parsed by `ast.literal_eval`.
19-
CASTERS = [
20-
lambda a: parse_datetime(a),
21-
lambda a: parse_time(a),
22-
lambda a: parse_date(a),
23-
lambda a: parse_duration(a),
24-
lambda a: UUID(a),
25-
]
26-
2712

2813
class InvalidKwarg(Exception):
2914
pass
@@ -64,6 +49,24 @@ def _get_expr_string(expr: ast.expr) -> str:
6449
return expr_str
6550

6651

52+
def _cast_value(value):
53+
"""
54+
Try to cast a value based on a list of casters.
55+
"""
56+
57+
for caster in CASTERS.values():
58+
try:
59+
casted_value = caster(value)
60+
61+
if casted_value:
62+
value = casted_value
63+
break
64+
except ValueError:
65+
pass
66+
67+
return value
68+
69+
6770
@lru_cache(maxsize=128, typed=True)
6871
def eval_value(value):
6972
"""
@@ -76,15 +79,7 @@ def eval_value(value):
7679
try:
7780
value = ast.literal_eval(value)
7881
except SyntaxError:
79-
for caster in CASTERS:
80-
try:
81-
casted_value = caster(value)
82-
83-
if casted_value:
84-
value = casted_value
85-
break
86-
except ValueError:
87-
pass
82+
value = _cast_value(value)
8883

8984
return value
9085

@@ -120,8 +115,9 @@ def parse_kwarg(kwarg: str, raise_if_unparseable=False) -> Dict[str, Any]:
120115
if raise_if_unparseable:
121116
raise
122117

123-
# The value can be a template variable that will get set from the context when
124-
# the templatetag is rendered, so just return the expr as a string.
118+
# The value can be a template variable that will get set from the
119+
# context when the templatetag is rendered, so just return the expr
120+
# as a string.
125121
value = _get_expr_string(assign.value)
126122
return {target.id: value}
127123
else:
@@ -135,7 +131,8 @@ def parse_call_method_name(
135131
call_method_name: str,
136132
) -> Tuple[str, Tuple[Any], Mapping[str, Any]]:
137133
"""
138-
Parses the method name from the request payload into a set of parameters to pass to a method.
134+
Parses the method name from the request payload into a set of parameters to pass to
135+
a method.
139136
140137
Args:
141138
param call_method_name: String representation of a method name with parameters,

django_unicorn/components/unicorn_template_response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def is_html_well_formed(html: str) -> bool:
5050

5151
for tag in tag_list:
5252
if "/" not in tag:
53-
cleaned_tag = re.sub(r"(<(\w+)[^>!]*>)", r"<\2>", tag)
53+
cleaned_tag = re.sub(r"(<([\w\-]+)[^>!]*>)", r"<\2>", tag)
5454

5555
if cleaned_tag not in EMPTY_ELEMENTS:
5656
stack.append(cleaned_tag)

django_unicorn/serializer.py

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from datetime import datetime, timedelta
2+
from datetime import timedelta
33
from decimal import Decimal
44
from functools import lru_cache
55
from typing import Any, Dict, List, Tuple
@@ -24,7 +24,7 @@
2424

2525
import orjson
2626

27-
from .utils import is_non_string_sequence
27+
from .utils import is_int, is_non_string_sequence
2828

2929

3030
try:
@@ -278,14 +278,14 @@ def _fix_floats(current: Dict, data: Dict = None, paths: List = None) -> None:
278278
_fix_floats(val, data, paths=paths)
279279
paths.pop()
280280
elif isinstance(current, list):
281-
for (idx, item) in enumerate(current):
281+
for idx, item in enumerate(current):
282282
paths.append(idx)
283283
_fix_floats(item, data, paths=paths)
284284
paths.pop()
285285
elif isinstance(current, float):
286286
_piece = data
287287

288-
for (idx, path) in enumerate(paths):
288+
for idx, path in enumerate(paths):
289289
if idx == len(paths) - 1:
290290
# `path` can be a dictionary key or list index,
291291
# but in either instance it is set the same way
@@ -294,20 +294,31 @@ def _fix_floats(current: Dict, data: Dict = None, paths: List = None) -> None:
294294
_piece = _piece[path]
295295

296296

297-
@lru_cache(maxsize=128, typed=True)
298-
def _dumps(
299-
serialized_data: bytes, exclude_field_attributes: Tuple[str] = None
300-
) -> bytes:
297+
def _sort_dict(data: Dict) -> Dict:
301298
"""
302-
Dump serialized data with custom massaging to fix floats and remove specific keys as needed.
299+
Recursively sort the dictionary keys so that JavaScript won't change the order
300+
and change the generated checksum.
301+
302+
Params:
303+
data: Dictionary to sort.
303304
"""
304305

305-
dict_data = orjson.loads(serialized_data)
306-
_fix_floats(dict_data)
307-
_exclude_field_attributes(dict_data, exclude_field_attributes)
306+
if type(data) is not dict:
307+
return data
308+
309+
items = [
310+
[k, v]
311+
for k, v in sorted(
312+
data.items(),
313+
key=lambda item: item[0] if not is_int(item[0]) else int(item[0]),
314+
)
315+
]
316+
317+
for item in items:
318+
if isinstance(item[1], dict):
319+
item[1] = _sort_dict(item[1])
308320

309-
dumped_data = orjson.dumps(dict_data)
310-
return dumped_data
321+
return dict(items)
311322

312323

313324
def _exclude_field_attributes(
@@ -346,8 +357,46 @@ def _exclude_field_attributes(
346357
del dict_data[field_name][field_attr]
347358

348359

360+
@lru_cache(maxsize=128, typed=True)
361+
def _dumps(
362+
serialized_data: bytes,
363+
fix_floats: bool = True,
364+
exclude_field_attributes: Tuple[str] = None,
365+
sort_dict: bool = True,
366+
) -> Dict:
367+
"""
368+
Dump serialized data with custom massaging.
369+
370+
Features:
371+
- fix floats
372+
- remove specific keys as needed
373+
- sort dictionary
374+
"""
375+
376+
data = orjson.loads(serialized_data)
377+
378+
if fix_floats:
379+
_fix_floats(data)
380+
381+
if exclude_field_attributes:
382+
# Excluding field attributes needs to de-serialize and then serialize again to
383+
# handle complex objects
384+
_exclude_field_attributes(data, exclude_field_attributes)
385+
386+
if sort_dict:
387+
# Sort dictionary manually because stringified integers don't get sorted
388+
# correctly with `orjson.OPT_SORT_KEYS` and JavaScript will sort the keys
389+
# as if they are integers
390+
data = _sort_dict(data)
391+
392+
return data
393+
394+
349395
def dumps(
350-
data: Dict, fix_floats: bool = True, exclude_field_attributes: Tuple[str] = None
396+
data: Dict,
397+
fix_floats: bool = True,
398+
exclude_field_attributes: Tuple[str] = None,
399+
sort_dict: bool = True,
351400
) -> str:
352401
"""
353402
Converts the passed-in dictionary to a string representation.
@@ -360,6 +409,8 @@ def dumps(
360409
but will be faster without it.
361410
param exclude_field_attributes: Tuple of strings with field attributes to remove, i.e. "1.2"
362411
to remove the key `2` from `{"1": {"2": "3"}}`
412+
param sort_dict: Whether the `dict` should be sorted. Defaults to `True`, but
413+
will be faster without it.
363414
364415
Returns a `str` instead of `bytes` (which deviates from `orjson.dumps`), but seems more useful.
365416
"""
@@ -368,17 +419,17 @@ def dumps(
368419
exclude_field_attributes
369420
), "exclude_field_attributes type needs to be a sequence"
370421

422+
# Call `dumps` to make sure that complex objects are serialized correctly
371423
serialized_data = orjson.dumps(data, default=_json_serializer)
372424

373-
if fix_floats:
374-
# Handle excluded field attributes in `_dumps` to reduce the amount of serialization/deserialization needed
375-
serialized_data = _dumps(
376-
serialized_data, exclude_field_attributes=exclude_field_attributes
377-
)
378-
elif exclude_field_attributes:
379-
dict_data = orjson.loads(serialized_data)
380-
_exclude_field_attributes(dict_data, exclude_field_attributes)
381-
serialized_data = orjson.dumps(dict_data)
425+
data = _dumps(
426+
serialized_data,
427+
fix_floats=fix_floats,
428+
exclude_field_attributes=exclude_field_attributes,
429+
sort_dict=sort_dict,
430+
)
431+
432+
serialized_data = orjson.dumps(data)
382433

383434
return serialized_data.decode("utf-8")
384435

0 commit comments

Comments
 (0)