Skip to content
This repository was archived by the owner on Oct 8, 2021. It is now read-only.

Commit 2207dc3

Browse files
authored
Merge pull request #6 from UnitedIncome/property-tests
Property tests
2 parents 22c7fa4 + e361001 commit 2207dc3

File tree

17 files changed

+935
-134
lines changed

17 files changed

+935
-134
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/dist/
44
/.python-version
55
/.coverage
6+
/.hypothesis
67
/htmlcov
78
/pip-wheel-metadata
89
setup.py

README.md

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ structure using libraries like [attrs][].
2626
* The library has no dependencies of its own
2727
* It does not actually read or write JSON
2828

29-
At the time of writing, the library is in **alpha** and the API may move around or be
30-
renamed.
29+
At the time of writing, the library is in **beta** and the API is relatively stable but
30+
may change.
3131

3232
### Supported types
3333

@@ -219,7 +219,7 @@ During encoding, the reverse sequence takes place:
219219

220220
#### JSON type check hook
221221

222-
Type checks are only used in `json-syntax` to support `typing.Union`; in a nutshell, the
222+
Type checks are only used in _json-syntax_ to support `typing.Union`; in a nutshell, the
223223
`unions` rule will inspect some JSON to see which variant is present.
224224

225225
If a type-check hook is not defined, `__json_pre_decode__` will be called before the
@@ -249,9 +249,40 @@ encode_account = rules.lookup(typ=Union[AccountA, AccountB, AccountC],
249249

250250
See [the examples][] for details on custom rules.
251251

252+
### Debugging amibguous structures
253+
254+
(May need more docs and some test cases.)
255+
256+
As _json-syntax_ tries to directly translate your Python types to JSON, it is possible
257+
to write ambiguous structures. To avoid this, there is a handy `is_ambiguous` method:
258+
259+
```python
260+
# This is true because both are represented as an array of numbers in JSON.
261+
rules.is_ambiguous(typ=Union[List[int], Set[int]])
262+
263+
@dataclass
264+
class Account:
265+
user: str
266+
address: str
267+
268+
# This is true because such a dictionary would always match the contents of the account.
269+
rules.is_ambiguous(typ=Union[Dict[str, str], Account])
270+
```
271+
272+
The aim of this is to let you put a check in your unit tests to make sure data can be
273+
reliably expressed given your particular case.
274+
275+
Internally, this is using the `PATTERN` verb to represent the JSON pattern, so this may
276+
be helpful in understanding how _json-syntax_ is trying to represent your data:
277+
278+
```python
279+
print(rules.lookup(typ=MyAmbiguousClass, verb='show_pattern'))
280+
```
281+
252282
### Sharp edges
253283

254-
_Alpha release status._ This API may change, there are probably bugs!
284+
_Beta release status._ This API may change, there are probably bugs! In particular, the
285+
status of rules accepting subclasses is likely to change.
255286

256287
_The RuleSet caches encoders._ Construct a new ruleset if you want to change settings.
257288

@@ -265,28 +296,8 @@ _Everything to do with typing._ It's a bit magical and sort of wasn't designed f
265296
[We have a guide to it to try and help][types].
266297

267298
_Union types._ You can use `typing.Union` to allow a member to be one of some number of
268-
alternates, but there are some caveats. These are documented in code in `test_unions`,
269-
but in plain English:
270-
271-
When encoding Python to JSON:
272-
273-
* `Union[Super, Sub]` will never match Sub when converting from Python to JSON.
274-
275-
When decoding JSON to Python:
276-
277-
* `Union[str, Stringly]` will never construct an instance that is represented as a
278-
string in JSON.
279-
* This includes enums, dates and special float values (`Nan`, `-inf`, etc.) may be
280-
represented as strings.
281-
* `Union[datetime, date]` will never construct a date because `YYYY-MM-DD` is a valid
282-
datetime according to ISO8601.
283-
* `Union[Dict[str, Value], MyAttrs]` will never construct `MyAttrs` if all its
284-
attributes are `Value`.
285-
* `Union[List[X], Set[X], FrozenSet[X], Tuple[X, ...]]` will only ever construct
286-
`List[X]` because all the others are also represented as JSON lists.
287-
* `Union[MyClassA, MyClassB, MyClassC]` can be ambiguous if these classes all share
288-
common fields. Consider using the `__json_check__` hook to differentiate. Simply
289-
adding a field named `class` or something can be unambiguous and fast.
299+
alternates, but there are some caveats. You should use the `.is_ambiguous()` method of
300+
RuleSet to warn you of these.
290301

291302
_Rules accept subclasses._ If you subclass `int`, the atoms rule will match it, and then
292303
the converter will call `int` against your instance. I haven't taken the time to examine
@@ -306,7 +317,7 @@ This package is maintained via the [poetry][] tool. Some useful commands:
306317

307318
1. Setup: `poetry install`
308319
2. Run tests: `poetry run pytest tests/`
309-
3. Reformat: `poetry run black -N json_syntax/ tests/`
320+
3. Reformat: `poetry run black json_syntax/ tests/`
310321

311322
### Setting up tox
312323

@@ -322,6 +333,10 @@ You'll want pyenv, then install the pythons:
322333

323334
Once you install `tox` in your preferred python, running it is just `tox`.
324335

336+
(Caveat: `poetry install` is now breaking in `tox` because `pip` has changed: it now
337+
tries to create a dist in _pip-wheel-metadata_ each time. I'm nuking that directory, but
338+
most likely there's some new config variable to hunt down.)
339+
325340
### Notes
326341

327342
<b id="f1">1</b>: Writing the encoder is deceptively easy because the instances in

json_syntax/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from .attrs import attrs_classes, named_tuples, tuples
2424
from .unions import unions
25-
from .helpers import JSON2PY, PY2JSON, INSP_PY, INSP_JSON # noqa
25+
from .helpers import JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN # noqa
2626

2727

2828
def std_ruleset(
@@ -43,18 +43,18 @@ def std_ruleset(
4343
For example, to replace ``decimals`` with ``decimals_as_str`` just call ``std_ruleset(decimals=decimals_as_str)``
4444
"""
4545
return custom(
46+
enums,
4647
atoms,
4748
floats,
4849
decimals,
4950
dates,
5051
optional,
51-
enums,
5252
lists,
5353
attrs_classes,
5454
sets,
55-
dicts,
5655
named_tuples,
5756
tuples,
57+
dicts,
5858
unions,
5959
*extras,
6060
cache=cache,

json_syntax/action_v1.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from .helpers import ErrorContext, err_ctx
22

3-
from datetime import date, datetime, time
3+
from datetime import date, datetime, time, timedelta
4+
from decimal import InvalidOperation
45
import math
6+
import re
57

68

79
def check_parse_error(value, parser, error):
@@ -21,6 +23,13 @@ def check_has_type(value, typ):
2123
return type(value) == typ
2224

2325

26+
def convert_decimal_str(value):
27+
result = str(value)
28+
if result == "sNaN":
29+
raise InvalidOperation("Won't save signalling NaN")
30+
return result
31+
32+
2433
def convert_float(value):
2534
value = float(value)
2635
if math.isfinite(value):
@@ -74,6 +83,56 @@ def convert_str_enum(value, mapping):
7483
del instance
7584

7685

86+
def convert_timedelta_str(dur):
87+
"Barebones support for storing a timedelta as an ISO8601 duration."
88+
micro = ".{:06d}".format(dur.microseconds) if dur.microseconds else ""
89+
return "P{:d}DT{:d}{}S".format(dur.days, dur.seconds, micro)
90+
91+
92+
_iso8601_duration = re.compile(
93+
r"^P(?!$)([-+]?\d+(?:[.,]\d+)?Y)?"
94+
r"([-+]?\d+(?:[.,]\d+)?M)?"
95+
r"([-+]?\d+(?:[.,]\d+)?W)?"
96+
r"([-+]?\d+(?:[.,]\d+)?D)?"
97+
r"(?:(T)(?=[0-9+-])"
98+
r"([-+]?\d+(?:[.,]\d+)?H)?"
99+
r"([-+]?\d+(?:[.,]\d+)?M)?"
100+
r"([-+]?\d+(?:[.,]\d+)?S)?)?$"
101+
)
102+
_duration_args = {
103+
"PW": "weeks",
104+
"PD": "days",
105+
"TH": "hours",
106+
"TM": "minutes",
107+
"TS": "seconds",
108+
}
109+
110+
111+
def convert_str_timedelta(dur):
112+
if not isinstance(dur, str):
113+
raise ValueError("Value was not a string.")
114+
match = _iso8601_duration.match(dur.upper().replace(",", "."))
115+
section = "P"
116+
if not match:
117+
raise ValueError("Value was not an ISO8601 duration.")
118+
args = {}
119+
for elem in match.groups():
120+
if elem is None:
121+
continue
122+
if elem == "T":
123+
section = "T"
124+
continue
125+
part = section + elem[-1]
126+
value = float(elem[:-1])
127+
if not value:
128+
continue
129+
130+
if part in ("PY", "PM"):
131+
raise ValueError("Year and month durations not supported")
132+
args[_duration_args[part]] = value
133+
return timedelta(**args)
134+
135+
77136
def convert_optional(value, inner):
78137
if value is None:
79138
return None

json_syntax/attrs.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
PY2JSON,
44
INSP_JSON,
55
INSP_PY,
6+
PATTERN,
67
SENTINEL,
78
has_origin,
89
identity,
@@ -18,6 +19,7 @@
1819
convert_dict_to_attrs,
1920
convert_tuple_as_list,
2021
)
22+
from . import pattern as pat
2123

2224
from functools import partial
2325

@@ -33,7 +35,7 @@ def attrs_classes(
3335
"""
3436
Handle an ``@attr.s`` or ``@dataclass`` decorated class.
3537
"""
36-
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON):
38+
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN):
3739
return
3840
try:
3941
fields = typ.__attrs_attrs__
@@ -59,7 +61,7 @@ def attrs_classes(
5961
)
6062
if verb == PY2JSON:
6163
tup += (field.default,)
62-
elif verb == INSP_JSON:
64+
elif verb in (INSP_JSON, PATTERN):
6365
tup += (is_attrs_field_required(field),)
6466
inner_map.append(tup)
6567

@@ -82,6 +84,12 @@ def attrs_classes(
8284
return check
8385
pre_hook_method = getattr(typ, pre_hook, identity)
8486
return partial(check_dict, inner_map=inner_map, pre_hook=pre_hook_method)
87+
elif verb == PATTERN:
88+
return pat.Object.exact(
89+
(pat.String.exact(name), inner or pat.Unkown)
90+
for name, inner, req in inner_map
91+
if req
92+
)
8593

8694

8795
def named_tuples(verb, typ, ctx):
@@ -90,7 +98,9 @@ def named_tuples(verb, typ, ctx):
9098
9199
Also handles a ``collections.namedtuple`` if you have a fallback handler.
92100
"""
93-
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON) or not issub_safe(typ, tuple):
101+
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN) or not issub_safe(
102+
typ, tuple
103+
):
94104
return
95105
try:
96106
fields = typ._field_types
@@ -116,7 +126,7 @@ def named_tuples(verb, typ, ctx):
116126
)
117127
if verb == PY2JSON:
118128
tup += (defaults.get(name, SENTINEL),)
119-
elif verb == INSP_JSON:
129+
elif verb in (INSP_JSON, PATTERN):
120130
tup += (name not in defaults,)
121131
inner_map.append(tup)
122132

@@ -133,14 +143,20 @@ def named_tuples(verb, typ, ctx):
133143
)
134144
elif verb == INSP_JSON:
135145
return partial(check_dict, pre_hook=identity, inner_map=tuple(inner_map))
146+
elif verb == PATTERN:
147+
return pat.Object.exact(
148+
(pat.String.exact(name), inner) for name, inner, req in inner_map if req
149+
)
136150

137151

138152
def tuples(verb, typ, ctx):
139153
"""
140154
Handle a ``Tuple[type, type, type]`` product type. Use a ``NamedTuple`` if you don't
141155
want a list.
142156
"""
143-
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON) or not has_origin(typ, tuple):
157+
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN) or not has_origin(
158+
typ, tuple
159+
):
144160
return
145161
args = typ.__args__
146162
if Ellipsis in args:
@@ -155,3 +171,5 @@ def tuples(verb, typ, ctx):
155171
return partial(check_tuple_as_list, inner=inner, con=tuple)
156172
elif verb == INSP_JSON:
157173
return partial(check_tuple_as_list, inner=inner, con=list)
174+
elif verb == PATTERN:
175+
return pat.Array.exact(inner)

json_syntax/helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
PY2JSON = "python_to_json"
1010
INSP_JSON = "inspect_json"
1111
INSP_PY = "inspect_python"
12+
PATTERN = "show_pattern"
1213
NoneType = type(None)
1314
SENTINEL = object()
1415
python_minor = sys.version_info[:2]

0 commit comments

Comments
 (0)