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

Commit 62760da

Browse files
authored
v2.0.0: Add support for DynamoDB and TypedDict (#9)
* Add an extra for DynamoDB translation. - Add RLockCache for experimental caching that uses a re-entrant lock. - Refactor product type support - Refactor exception contexts. - Run flake8 and black * enums and optionals in Dynamo ruleset. - Fix bug in RLockCache - Vastly improve error messages in product types - Correct RuleSet.lookup's fallback semantics
1 parent b7f0c71 commit 62760da

25 files changed

+973
-225
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ encode_account = rules.lookup(typ=Union[AccountA, AccountB, AccountC],
249249

250250
### Adding custom rules
251251

252-
See [the examples][] for details on custom rules, but generally a rule is just a
252+
See [the extras][] for details on custom rules, but generally a rule is just a
253253
function. Say, for instance, your type has class methods that encode and decode, this
254254
would be sufficient for many cases:
255255

@@ -365,7 +365,7 @@ union. [↩](#a2)
365365

366366
[poetry]: https://poetry.eustace.io/docs/#installation
367367
[gradual typing]: https://www.python.org/dev/peps/pep-0483/#summary-of-gradual-typing
368-
[the examples]: https://github.com/UnitedIncome/json-syntax/tree/master/json_syntax/examples
368+
[the extras]: https://github.com/UnitedIncome/json-syntax/tree/master/json_syntax/extras
369369
[typing]: https://docs.python.org/3/library/typing.html
370370
[types]: https://github.com/UnitedIncome/json-syntax/blob/master/TYPES.md
371371
[attrs]: https://attrs.readthedocs.io/en/stable/

json_syntax/action_v1.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -178,42 +178,43 @@ def check_mapping(value, key, val, con):
178178
def convert_dict_to_attrs(value, pre_hook, inner_map, con):
179179
value = pre_hook(value)
180180
args = {}
181-
for name, inner in inner_map:
182-
with ErrorContext("[{!r}]".format(name)):
181+
for attr in inner_map:
182+
with ErrorContext("[{!r}]".format(attr.name)):
183183
try:
184-
arg = value[name]
184+
arg = value[attr.name]
185185
except KeyError:
186-
pass
186+
if attr.is_required:
187+
raise KeyError("Missing key") from None
187188
else:
188-
args[name] = inner(arg)
189+
args[attr.name] = attr.inner(arg)
189190
return con(**args)
190191

191192

192193
def check_dict(value, inner_map, pre_hook):
193194
value = pre_hook(value)
194195
if not isinstance(value, dict):
195196
return False
196-
for name, inner, required in inner_map:
197-
with ErrorContext("[{!r}]".format(name)):
197+
for attr in inner_map:
198+
with ErrorContext("[{!r}]".format(attr.name)):
198199
try:
199-
arg = value[name]
200+
arg = value[attr.name]
200201
except KeyError:
201-
if required:
202+
if attr.is_required:
202203
return False
203204
else:
204-
if not inner(arg):
205+
if not attr.inner(arg):
205206
return False
206207
return True
207208

208209

209210
def convert_attrs_to_dict(value, post_hook, inner_map):
210211
out = {}
211-
for name, inner, default in inner_map:
212-
with ErrorContext("." + name):
213-
field = getattr(value, name)
214-
if field == default:
212+
for attr in inner_map:
213+
with ErrorContext("." + attr.name):
214+
field = getattr(value, attr.name)
215+
if field == attr.default:
215216
continue
216-
out[name] = inner(field)
217+
out[attr.name] = attr.inner(field)
217218
if post_hook is not None:
218219
out = getattr(value, post_hook)(out)
219220
return out

json_syntax/attrs.py

Lines changed: 64 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,4 @@
1-
from .helpers import (
2-
JSON2PY,
3-
PY2JSON,
4-
INSP_JSON,
5-
INSP_PY,
6-
PATTERN,
7-
SENTINEL,
8-
has_origin,
9-
identity,
10-
is_attrs_field_required,
11-
issub_safe,
12-
resolve_fwd_ref,
13-
)
1+
from .helpers import JSON2PY, PY2JSON, INSP_JSON, INSP_PY, PATTERN, has_origin, identity
142
from .action_v1 import (
153
check_dict,
164
check_isinst,
@@ -20,9 +8,12 @@
208
convert_tuple_as_list,
219
)
2210
from . import pattern as pat
11+
from .product import build_attribute_map, build_named_tuple_map, build_typed_dict_map
2312

2413
from functools import partial
2514

15+
_SUPPORTED_VERBS = (JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN)
16+
2617

2718
def attrs_classes(
2819
verb,
@@ -53,49 +44,26 @@ def attrs_classes(
5344
`__json_check__` may be used to completely override the `inspect_json` check generated
5445
for this class.
5546
"""
56-
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN):
47+
if verb not in _SUPPORTED_VERBS:
48+
return
49+
inner_map = build_attribute_map(verb, typ, ctx, read_all=verb == PY2JSON)
50+
if inner_map is None:
5751
return
58-
try:
59-
fields = typ.__attrs_attrs__
60-
except AttributeError:
61-
try:
62-
fields = typ.__dataclass_fields__
63-
except AttributeError:
64-
return
65-
else:
66-
fields = fields.values()
6752

6853
if verb == INSP_PY:
6954
return partial(check_isinst, typ=typ)
7055

71-
inner_map = []
72-
for field in fields:
73-
if field.init or verb == PY2JSON:
74-
tup = (
75-
field.name,
76-
ctx.lookup(
77-
verb=verb, typ=resolve_fwd_ref(field.type, typ), accept_missing=True
78-
),
79-
)
80-
if verb == PY2JSON:
81-
tup += (field.default,)
82-
elif verb in (INSP_JSON, PATTERN):
83-
tup += (is_attrs_field_required(field),)
84-
inner_map.append(tup)
85-
8656
if verb == JSON2PY:
8757
pre_hook_method = getattr(typ, pre_hook, identity)
8858
return partial(
8959
convert_dict_to_attrs,
9060
pre_hook=pre_hook_method,
91-
inner_map=tuple(inner_map),
61+
inner_map=inner_map,
9262
con=typ,
9363
)
9464
elif verb == PY2JSON:
9565
post_hook = post_hook if hasattr(typ, post_hook) else None
96-
return partial(
97-
convert_attrs_to_dict, post_hook=post_hook, inner_map=tuple(inner_map)
98-
)
66+
return partial(convert_attrs_to_dict, post_hook=post_hook, inner_map=inner_map)
9967
elif verb == INSP_JSON:
10068
check = getattr(typ, check, None)
10169
if check:
@@ -104,9 +72,26 @@ def attrs_classes(
10472
return partial(check_dict, inner_map=inner_map, pre_hook=pre_hook_method)
10573
elif verb == PATTERN:
10674
return pat.Object.exact(
107-
(pat.String.exact(name), inner or pat.Unkown)
108-
for name, inner, req in inner_map
109-
if req
75+
(pat.String.exact(attr.name), attr.inner or pat.Unkown)
76+
for attr in inner_map
77+
if attr.is_required
78+
)
79+
80+
81+
def _simple_product(inner_map, verb, typ, ctx):
82+
if verb == JSON2PY:
83+
return partial(
84+
convert_dict_to_attrs, pre_hook=identity, inner_map=inner_map, con=typ
85+
)
86+
elif verb == PY2JSON:
87+
return partial(convert_attrs_to_dict, post_hook=None, inner_map=inner_map)
88+
elif verb == INSP_JSON:
89+
return partial(check_dict, pre_hook=identity, inner_map=inner_map)
90+
elif verb == PATTERN:
91+
return pat.Object.exact(
92+
(pat.String.exact(attr.name), attr.inner)
93+
for attr in inner_map
94+
if attr.is_required
11095
)
11196

11297

@@ -115,67 +100,53 @@ def named_tuples(verb, typ, ctx):
115100
Handle a ``NamedTuple(name, [('field', type), ('field', type)])`` type.
116101
117102
Also handles a ``collections.namedtuple`` if you have a fallback handler.
103+
104+
Warning: there's no clear runtime marker that something is a namedtuple; it's just
105+
a subclass of ``tuple`` that has some special fields.
118106
"""
119-
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN) or not issub_safe(
120-
typ, tuple
121-
):
107+
if verb not in _SUPPORTED_VERBS:
122108
return
123-
try:
124-
fields = typ._field_types
125-
except AttributeError:
126-
try:
127-
fields = typ._fields
128-
except AttributeError:
129-
return
130-
fields = [(name, None) for name in fields]
131-
else:
132-
fields = fields.items()
109+
110+
inner_map = build_named_tuple_map(verb, typ, ctx)
111+
if inner_map is None:
112+
return
113+
133114
if verb == INSP_PY:
134115
return partial(check_isinst, typ=typ)
135116

136-
defaults = {}
137-
defaults.update(getattr(typ, "_fields_defaults", ()))
138-
defaults.update(getattr(typ, "_field_defaults", ()))
139-
inner_map = []
140-
for name, inner in fields:
141-
tup = (
142-
name,
143-
ctx.lookup(verb=verb, typ=resolve_fwd_ref(inner, typ), accept_missing=True),
144-
)
145-
if verb == PY2JSON:
146-
tup += (defaults.get(name, SENTINEL),)
147-
elif verb in (INSP_JSON, PATTERN):
148-
tup += (name not in defaults,)
149-
inner_map.append(tup)
117+
return _simple_product(inner_map, verb, typ, ctx)
150118

151-
if verb == JSON2PY:
152-
return partial(
153-
convert_dict_to_attrs,
154-
pre_hook=identity,
155-
inner_map=tuple(inner_map),
156-
con=typ,
157-
)
158-
elif verb == PY2JSON:
159-
return partial(
160-
convert_attrs_to_dict, post_hook=None, inner_map=tuple(inner_map)
161-
)
162-
elif verb == INSP_JSON:
163-
return partial(check_dict, pre_hook=identity, inner_map=tuple(inner_map))
164-
elif verb == PATTERN:
165-
return pat.Object.exact(
166-
(pat.String.exact(name), inner) for name, inner, req in inner_map if req
167-
)
119+
120+
def typed_dicts(verb, typ, ctx):
121+
"""
122+
Handle the TypedDict product type. This allows you to construct a dict with specific (string) keys, which
123+
is often how people really use dicts.
124+
125+
Both the class form and the functional form, ``TypedDict('Name', {'field': type, 'field': type})`` are
126+
supported.
127+
"""
128+
if verb not in _SUPPORTED_VERBS:
129+
return
130+
131+
inner_map = build_typed_dict_map(verb, typ, ctx)
132+
if inner_map is None:
133+
return
134+
135+
if verb == INSP_PY:
136+
return partial(check_dict, inner_map=inner_map, pre_hook=identity)
137+
138+
# Note: we pass `dict` as the typ here because it's the correct constructor.
139+
return _simple_product(inner_map, verb, dict, ctx)
168140

169141

170142
def tuples(verb, typ, ctx):
171143
"""
172144
Handle a ``Tuple[type, type, type]`` product type. Use a ``NamedTuple`` if you don't
173-
want a list.
145+
want a list. Though, if possible, prefer ``attrs`` or ``dataclass``.
174146
"""
175-
if verb not in (JSON2PY, PY2JSON, INSP_PY, INSP_JSON, PATTERN) or not has_origin(
176-
typ, tuple
177-
):
147+
if verb not in _SUPPORTED_VERBS or not has_origin(typ, tuple):
178148
return
149+
179150
args = typ.__args__
180151
if Ellipsis in args:
181152
# This is a homogeneous tuple, use the lists rule.

json_syntax/cache.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ class SimpleCache:
2626
def __init__(self):
2727
self.cache = {}
2828

29+
def access(self):
30+
"""Requests a context manager to access the cache."""
31+
return self
32+
33+
def __enter__(self):
34+
"""Stub implementation; see subclasses."""
35+
return self
36+
37+
def __exit__(self, e_typ, e_val, e_tb):
38+
"""Stub implementation; see subclasses."""
39+
return
40+
2941
def get(self, verb, typ):
3042
result = self._lookup(verb, typ)
3143
return result if result is not NotImplemented else None
@@ -107,3 +119,22 @@ def cache(self):
107119
except AttributeError:
108120
_cache = local.cache = {}
109121
return _cache
122+
123+
124+
class RLockCache(SimpleCache):
125+
"""
126+
Uses a re-entrant lock to ensure only one thread is touching rules at a time.
127+
"""
128+
129+
def __init__(self, timeout=-1):
130+
self._rlock = threading.RLock()
131+
self._timeout = -1
132+
self.cache = {}
133+
134+
def __enter__(self):
135+
if not self._rlock.acquire(timeout=self._timeout):
136+
raise TypeError("acquire failed to acquire a lock")
137+
return self
138+
139+
def __exit__(self, e_typ, e_val, e_tb):
140+
self._rlock.release()
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)