Coverage for gws-app/gws/spec/reader.py: 41%
305 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
1"""Read and validate values according to spec types."""
3import re
5import gws
6import gws.lib.crs
7import gws.lib.datetimex
8import gws.lib.osx
9import gws.lib.uom
11from . import core
14class Reader:
15 atom = core.make_type({'c': core.c.ATOM})
17 def __init__(self, runtime, path, options):
18 self.runtime = runtime
19 self.path = path
21 options = set(options or [])
23 self.accept_extra_props = gws.SpecReadOption.acceptExtraProps in options
24 self.case_insensitive = gws.SpecReadOption.caseInsensitive in options
25 self.convert_values = gws.SpecReadOption.convertValues in options
26 self.ignore_extra_props = gws.SpecReadOption.ignoreExtraProps in options
27 self.allow_skip_required = gws.SpecReadOption.allowMissing in options
28 self.verbose_errors = gws.SpecReadOption.verboseErrors in options
30 self.stack = None
31 self.push = lambda _: ...
32 self.pop = lambda: ...
34 def read(self, value, type_uid):
35 if not self.verbose_errors:
36 return self.read2(value, type_uid)
38 self.stack = [('', value, type_uid)]
39 self.push = self.stack.append
40 self.pop = self.stack.pop
42 try:
43 return self.read2(value, type_uid)
44 except core.ReadError as exc:
45 raise self.add_error_details(exc)
47 def read2(self, value, type_uid):
48 typ = self.runtime.get_type(type_uid)
50 if type_uid in _READERS:
51 return _READERS[type_uid](self, value, typ or self.atom)
53 if not typ:
54 raise core.ReadError(f'unknown type {type_uid!r}', value)
56 if typ.c not in _READERS:
57 raise core.ReadError(f'unknown type category {typ.c!r}', value)
59 return _READERS[typ.c](self, value, typ)
61 def add_error_details(self, exc: Exception):
62 details = {
63 'formatted_value': _format_error_value(exc),
64 'path': self.path,
65 'formatted_stack': _format_error_stack(self.stack or []),
66 }
67 exc.args = (exc.args[0], exc.args[1], details)
68 return exc
71# atoms
74def _read_any(r: Reader, val, typ: core.Type):
75 return val
78def _read_bool(r: Reader, val, typ: core.Type):
79 if not r.convert_values:
80 return _ensure(val, bool)
81 try:
82 return bool(val)
83 except:
84 raise core.ReadError('must be true or false', val)
87def _read_bytes(r: Reader, val, typ: core.Type):
88 try:
89 if isinstance(val, str):
90 return val.encode('utf8', errors='strict')
91 return bytes(val)
92 except:
93 raise core.ReadError('must be a byte buffer', val)
96def _read_float(r: Reader, val, typ: core.Type):
97 if not r.convert_values:
98 if isinstance(val, int):
99 return float(val)
100 return _ensure(val, float)
101 try:
102 return float(val)
103 except:
104 raise core.ReadError('must be a float', val)
107def _read_int(r: Reader, val, typ: core.Type):
108 if isinstance(val, bool):
109 raise core.ReadError('must be an integer', val)
110 if not r.convert_values:
111 return _ensure(val, int)
112 try:
113 return int(val)
114 except:
115 raise core.ReadError('must be an integer', val)
118def _read_str(r: Reader, val, typ: core.Type):
119 if not r.convert_values:
120 return _ensure(val, str)
121 try:
122 return _to_string(val)
123 except:
124 raise core.ReadError('must be a string', val)
127# built-ins
130def _read_raw_dict(r: Reader, val, typ: core.Type):
131 return _ensure(val, dict)
134def _read_dict(r: Reader, val, typ: core.Type):
135 dct = {}
136 for k, v in _ensure(val, dict).items():
137 dct[k] = r.read2(v, typ.tValue)
138 return dct
141def _read_raw_list(r: Reader, val, typ: core.Type):
142 return _ensure(val, list)
145def _read_list(r: Reader, val, typ: core.Type):
146 lst = _read_any_list(r, val)
147 res = []
148 for n, v in enumerate(lst):
149 r.push((n, v, typ.tItem))
150 res.append(r.read2(v, typ.tItem))
151 r.pop()
152 return res
155def _read_set(r: Reader, val, typ: core.Type):
156 lst = _read_list(r, val, typ)
157 return set(lst)
160def _read_tuple(r: Reader, val, typ: core.Type):
161 lst = _read_any_list(r, val)
163 if len(lst) != len(typ.tItems):
164 raise core.ReadError(f'expected: {_comma(typ.tItems)}', val)
166 res = []
167 for n, v in enumerate(lst):
168 r.push((n, v, typ.tItems[n]))
169 res.append(r.read2(v, typ.tItems[n]))
170 r.pop()
171 return res
174def _read_any_list(r, val):
175 if r.convert_values and isinstance(val, str):
176 val = val.strip()
177 val = [v.strip() for v in val.split(',')] if val else []
178 return _ensure(val, list)
181def _read_literal(r: Reader, val, typ: core.Type):
182 s = _read_any(r, val, typ)
183 if s not in typ.literalValues:
184 raise core.ReadError(f'invalid value: {s!r}, expected: {_comma(typ.literalValues)}', val)
185 return s
188def _read_optional(r: Reader, val, typ: core.Type):
189 if val is None:
190 return val
191 return r.read2(val, typ.tTarget)
194def _read_union(r: Reader, val, typ: core.Type):
195 # @TODO no untyped unions yet
196 raise core.ReadError('unions are not supported yet', val)
199# our types
202def _read_type(r: Reader, val, typ: core.Type):
203 return r.read2(val, typ.tTarget)
206def _read_enum(r: Reader, val, typ: core.Type):
207 # NB: our Enums accept both names (for configs) and values (for api calls)
208 # this prevents silly things like Enum{foo=bar bar=123} but we don't care
209 #
210 # the comparison is also case-insensitive
211 #
212 # this reader returns a value, it's up to the caller to convert it to the actual enum
214 def _lower(s):
215 return s.lower() if isinstance(s, str) else s
217 lv = _lower(val)
219 for k, v in typ.enumValues.items():
220 if lv == _lower(k) or lv == _lower(v):
221 return v
222 raise core.ReadError(f'invalid value: {val!r}, expected: {_comma(typ.enumValues)}', val)
225def _read_object(r: Reader, val, typ: core.Type):
226 val = _ensure(val, dict)
228 if r.case_insensitive:
229 val = {k.lower(): v for k, v in val.items()}
230 else:
231 val = dict(val)
233 res = {}
235 for prop_name, prop_type_uid in typ.tProperties.items():
236 prop_val = val.pop(prop_name.lower() if r.case_insensitive else prop_name, None)
237 r.push((prop_name, prop_val, prop_type_uid))
238 res[prop_name] = r.read2(prop_val, prop_type_uid)
239 r.pop()
241 unknown = []
243 for k in val:
244 if k not in typ.tProperties:
245 if r.accept_extra_props:
246 res[k] = val[k]
247 elif r.ignore_extra_props:
248 continue
249 else:
250 unknown.append(k)
252 if unknown:
253 raise core.ReadError(f'unknown keys: {_comma(unknown)}, expected: {_comma(typ.tProperties)} for {typ.uid!r}', val)
255 return gws.Data(res)
258def _read_property(r: Reader, val, typ: core.Type):
259 if val is not None:
260 return r.read2(val, typ.tValue)
262 if not typ.hasDefault:
263 if r.allow_skip_required:
264 return None
265 raise core.ReadError(f'required property missing: {typ.ident!r} for {typ.tOwner!r}', None)
267 if typ.defaultValue is None:
268 return None
270 # the default, if given, must match the type
271 # NB, for Data objects, default={} will create an object with defaults
272 return r.read2(typ.defaultValue, typ.tValue)
275def _read_variant(r: Reader, val, typ: core.Type):
276 val = _ensure(val, dict)
277 if r.case_insensitive:
278 val = {k.lower(): v for k, v in val.items()}
280 type_name = val.get(core.v.VARIANT_TAG, core.v.DEFAULT_VARIANT_TAG)
281 target_type_uid = typ.tMembers.get(type_name)
282 if not target_type_uid:
283 raise core.ReadError(f'illegal type: {type_name!r}, expected: {_comma(typ.tMembers)}', val)
284 return r.read2(val, target_type_uid)
287# custom types
290def _read_acl_str(r: Reader, val, typ: core.Type):
291 try:
292 return gws.u.parse_acl(val)
293 except ValueError:
294 raise core.ReadError(f'invalid ACL', val)
297def _read_color(r: Reader, val, typ: core.Type):
298 # @TODO: parse color values
299 return _read_str(r, val, typ)
302def _read_crs(r: Reader, val, typ: core.Type):
303 crs = gws.lib.crs.get(val)
304 if not crs:
305 raise core.ReadError(f'invalid crs: {val!r}', val)
306 return crs.srid
309def _read_date(r: Reader, val, typ: core.Type):
310 try:
311 return gws.lib.datetimex.from_string(str(val))
312 except ValueError:
313 raise core.ReadError(f'invalid date: {val!r}', val)
316def _read_datetime(r: Reader, val, typ: core.Type):
317 try:
318 return gws.lib.datetimex.from_iso_string(str(val))
319 except ValueError:
320 raise core.ReadError(f'invalid date: {val!r}', val)
323def _read_dirpath(r: Reader, val, typ: core.Type):
324 path = gws.lib.osx.abs_path(val, r.path)
325 if not gws.u.is_dir(path):
326 raise core.ReadError(f'directory not found: {path!r}, base {r.path!r}', val)
327 return path
330def _read_duration(r: Reader, val, typ: core.Type):
331 try:
332 return gws.lib.datetimex.parse_duration(val)
333 except ValueError:
334 raise core.ReadError(f'invalid duration: {val!r}', val)
337def _read_filepath(r: Reader, val, typ: core.Type):
338 path = gws.lib.osx.abs_path(val, r.path)
339 if not gws.lib.osx.is_abs_path(val):
340 gws.log.warning(f'relative path, assuming {path!r} for {val!r}')
341 if not gws.u.is_file(path):
342 raise core.ReadError(f'file not found: {path!r}, base {r.path!r}', val)
343 return path
346def _read_formatstr(r: Reader, val, typ: core.Type):
347 # @TODO validate
348 return _read_str(r, val, typ)
351def _read_metadata(r: Reader, val, typ: core.Type):
352 rr = r.allow_skip_required
353 r.allow_skip_required = True
354 res = gws.u.compact(_read_object(r, val, typ))
355 r.allow_skip_required = rr
356 return res
359def _read_uom_value(r: Reader, val, typ: core.Type):
360 try:
361 return gws.lib.uom.parse(val)
362 except ValueError as e:
363 raise core.ReadError(f'invalid value: {val!r}: {e!r}', val)
366def _read_uom_point(r: Reader, val, typ: core.Type):
367 try:
368 return gws.lib.uom.parse_point(val)
369 except ValueError as e:
370 raise core.ReadError(f'invalid value: {val!r}: {e!r}', val)
373def _read_uom_extent(r: Reader, val, typ: core.Type):
374 try:
375 return gws.lib.uom.parse_extent(val)
376 except ValueError as e:
377 raise core.ReadError(f'invalid value: {val!r}: {e!r}', val)
380def _read_regex(r: Reader, val, typ: core.Type):
381 try:
382 re.compile(val)
383 return val
384 except re.error as e:
385 raise core.ReadError(f'invalid regular expression: {val!r}: {e!r}', val)
388def _read_url(r: Reader, val, typ: core.Type):
389 u = _read_str(r, val, typ)
390 if u.startswith(('http://', 'https://')):
391 return u
392 raise core.ReadError(f'invalid url: {val!r}', val)
395# utils
398def _ensure(val, cls):
399 if isinstance(val, cls):
400 return val
401 if cls == list and isinstance(val, tuple):
402 return list(val)
403 if cls == dict and gws.u.is_data_object(val):
404 return vars(val)
405 raise core.ReadError(f'wrong type: {_classname(type(val))!r}, expected: {_classname(cls)!r}', val)
408def _to_string(x):
409 if isinstance(x, str):
410 return x
411 if isinstance(x, (bytes, bytearray)):
412 return x.decode('utf8')
413 raise ValueError()
416def _classname(cls):
417 try:
418 return cls.__name__
419 except:
420 return str(cls)
423def _comma(ls):
424 return repr(', '.join(sorted(str(x) for x in ls)))
427##
430def _format_error_value(exc):
431 try:
432 val = exc.args[1]
433 except (AttributeError, IndexError):
434 return ''
436 s = repr(val)
437 if len(s) > 600:
438 s = s[:600] + '...'
439 return s
442def _format_error_stack(stack):
443 f = []
445 for name, value, type_uid in reversed(stack):
446 line = ''
448 if name:
449 name = repr(name)
450 line = f'item {name}' if name.isdigit() else name
452 obj = type_uid or 'object'
453 for p in 'uid', 'title', 'type':
454 try:
455 s = value.get(p)
456 if s is not None:
457 obj += f' {p}={s!r}'
458 break
459 except AttributeError:
460 pass
462 f.append(f'in {line} <{obj}>')
464 return f
467#
469_READERS = {
470 'any': _read_any,
471 'bool': _read_bool,
472 'bytes': _read_bytes,
473 'float': _read_float,
474 'int': _read_int,
475 'str': _read_str,
476 'list': _read_raw_list,
477 'dict': _read_raw_dict,
478 core.c.CLASS: _read_object,
479 core.c.DICT: _read_dict,
480 core.c.ENUM: _read_enum,
481 core.c.LIST: _read_list,
482 core.c.LITERAL: _read_literal,
483 core.c.OPTIONAL: _read_optional,
484 core.c.PROPERTY: _read_property,
485 core.c.SET: _read_set,
486 core.c.TUPLE: _read_tuple,
487 core.c.TYPE: _read_type,
488 core.c.UNION: _read_union,
489 core.c.VARIANT: _read_variant,
490 'gws.AclStr': _read_acl_str,
491 'gws.Color': _read_color,
492 'gws.CrsName': _read_crs,
493 'gws.DateStr': _read_date,
494 'gws.DateTimeStr': _read_datetime,
495 'gws.DirPath': _read_dirpath,
496 'gws.Duration': _read_duration,
497 'gws.FilePath': _read_filepath,
498 'gws.FormatStr': _read_formatstr,
499 'gws.UomValueStr': _read_uom_value,
500 'gws.UomPointStr': _read_uom_point,
501 'gws.UomSizeStr': _read_uom_point,
502 'gws.UomExtentStr': _read_uom_extent,
503 'gws.Metadata': _read_metadata,
504 'gws.Regex': _read_regex,
505 'gws.Url': _read_url,
506}