Coverage for gws-app/gws/spec/reader.py: 43%
295 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +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_config_error_info(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_config_error_info(self, exc: Exception):
62 cei = gws.ConfigErrorInfo(
63 value=_format_error_value(exc),
64 path=self.path,
65 stack=_prepare_error_stack(self.stack or []),
66 )
67 exc.args = (exc.args[0], exc.args[1], cei)
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 # accept 'uid' for all objects
246 if k == 'uid':
247 res[k] = val[k]
248 elif r.accept_extra_props:
249 res[k] = val[k]
250 elif r.ignore_extra_props:
251 continue
252 else:
253 unknown.append(k)
255 if unknown:
256 raise core.ReadError(f'unknown keys: {_comma(unknown)}, expected: {_comma(typ.tProperties)} for {typ.uid!r}', val)
258 return gws.Data(res)
261def _read_property(r: Reader, val, typ: core.Type):
262 if val is not None:
263 return r.read2(val, typ.tValue)
265 if not typ.hasDefault:
266 if r.allow_skip_required:
267 return None
268 raise core.ReadError(f'required property missing: {typ.ident!r} for {typ.tOwner!r}', None)
270 if typ.defaultValue is None:
271 return None
273 # the default, if given, must match the type
274 # NB, for Data objects, default={} will create an object with defaults
275 return r.read2(typ.defaultValue, typ.tValue)
278def _read_variant(r: Reader, val, typ: core.Type):
279 val = _ensure(val, dict)
280 if r.case_insensitive:
281 val = {k.lower(): v for k, v in val.items()}
283 type_name = val.get(core.v.VARIANT_TAG, core.v.DEFAULT_VARIANT_TAG)
284 target_type_uid = typ.tMembers.get(type_name)
285 if not target_type_uid:
286 raise core.ReadError(f'illegal type: {type_name!r}, expected: {_comma(typ.tMembers)}', val)
287 return r.read2(val, target_type_uid)
290# custom types
293def _read_acl_str(r: Reader, val, typ: core.Type):
294 try:
295 return gws.u.parse_acl(val)
296 except ValueError:
297 raise core.ReadError(f'invalid ACL', val)
300def _read_color(r: Reader, val, typ: core.Type):
301 # @TODO: parse color values
302 return _read_str(r, val, typ)
305def _read_crs(r: Reader, val, typ: core.Type):
306 crs = gws.lib.crs.get(val)
307 if not crs:
308 raise core.ReadError(f'invalid crs: {val!r}', val)
309 return crs.srid
312def _read_date(r: Reader, val, typ: core.Type):
313 try:
314 return gws.lib.datetimex.from_string(str(val))
315 except ValueError:
316 raise core.ReadError(f'invalid date: {val!r}', val)
319def _read_datetime(r: Reader, val, typ: core.Type):
320 try:
321 return gws.lib.datetimex.from_iso_string(str(val))
322 except ValueError:
323 raise core.ReadError(f'invalid date: {val!r}', val)
326def _read_dirpath(r: Reader, val, typ: core.Type):
327 path = gws.lib.osx.abs_path(val, r.path)
328 if not gws.u.is_dir(path):
329 raise core.ReadError(f'directory not found: {path!r}', val)
330 return path
333def _read_duration(r: Reader, val, typ: core.Type):
334 try:
335 return gws.lib.datetimex.parse_duration(val)
336 except ValueError:
337 raise core.ReadError(f'invalid duration: {val!r}', val)
340def _read_filepath(r: Reader, val, typ: core.Type):
341 path = gws.lib.osx.abs_path(val, r.path)
342 if not gws.lib.osx.is_abs_path(val):
343 gws.log.warning(f'relative path, assuming {path!r} for {val!r}')
344 if not gws.u.is_file(path):
345 raise core.ReadError(f'file not found: {path!r}', val)
346 return path
349def _read_formatstr(r: Reader, val, typ: core.Type):
350 # @TODO validate
351 return _read_str(r, val, typ)
354def _read_metadata(r: Reader, val, typ: core.Type):
355 rr = r.allow_skip_required
356 r.allow_skip_required = True
357 res = gws.u.compact(_read_object(r, val, typ))
358 r.allow_skip_required = rr
359 return res
362def _read_uom_value(r: Reader, val, typ: core.Type):
363 try:
364 return gws.lib.uom.parse(val)
365 except ValueError as e:
366 raise core.ReadError(f'invalid value: {val!r}: {e!r}', val)
369def _read_uom_point(r: Reader, val, typ: core.Type):
370 try:
371 return gws.lib.uom.parse_point(val)
372 except ValueError as e:
373 raise core.ReadError(f'invalid value: {val!r}: {e!r}', val)
376def _read_uom_extent(r: Reader, val, typ: core.Type):
377 try:
378 return gws.lib.uom.parse_extent(val)
379 except ValueError as e:
380 raise core.ReadError(f'invalid value: {val!r}: {e!r}', val)
383def _read_regex(r: Reader, val, typ: core.Type):
384 try:
385 re.compile(val)
386 return val
387 except re.error as e:
388 raise core.ReadError(f'invalid regular expression: {val!r}: {e!r}', val)
391def _read_url(r: Reader, val, typ: core.Type):
392 u = _read_str(r, val, typ)
393 if u.startswith(('http://', 'https://')):
394 return u
395 raise core.ReadError(f'invalid url: {val!r}', val)
398# utils
401def _ensure(val, cls):
402 if isinstance(val, cls):
403 return val
404 if cls == list and isinstance(val, tuple):
405 return list(val)
406 if cls == dict and gws.u.is_data_object(val):
407 return vars(val)
408 raise core.ReadError(f'wrong type: {_classname(type(val))!r}, expected: {_classname(cls)!r}', val)
411def _to_string(x):
412 if isinstance(x, str):
413 return x
414 if isinstance(x, (bytes, bytearray)):
415 return x.decode('utf8')
416 raise ValueError()
419def _classname(cls):
420 try:
421 return cls.__name__
422 except:
423 return str(cls)
426def _comma(ls):
427 return repr(', '.join(sorted(str(x) for x in ls)))
430##
433def _format_error_value(exc):
434 try:
435 val = exc.args[1]
436 except Exception:
437 return ''
439 s = repr(val)
440 if len(s) > 600:
441 s = s[:600] + '...'
442 return s
445def _prepare_error_stack(stack):
446 ls = []
448 for name, value, type_uid in reversed(stack):
449 obj_name = gws.u.get(value, 'name') or gws.u.get(value, 'title')
450 ls.append(
451 gws.ConfigLocation(
452 objectUid=gws.u.get(value, 'uid'),
453 objectType=type_uid or gws.u.get(value, 'type'),
454 objectName=obj_name if isinstance(obj_name, str) else '',
455 propName=str(name) if name is not None else '',
456 )
457 )
459 return ls
462#
464_READERS = {
465 'any': _read_any,
466 'bool': _read_bool,
467 'bytes': _read_bytes,
468 'float': _read_float,
469 'int': _read_int,
470 'str': _read_str,
471 'list': _read_raw_list,
472 'dict': _read_raw_dict,
473 core.c.CLASS: _read_object,
474 core.c.DICT: _read_dict,
475 core.c.ENUM: _read_enum,
476 core.c.LIST: _read_list,
477 core.c.LITERAL: _read_literal,
478 core.c.OPTIONAL: _read_optional,
479 core.c.PROPERTY: _read_property,
480 core.c.SET: _read_set,
481 core.c.TUPLE: _read_tuple,
482 core.c.TYPE: _read_type,
483 core.c.UNION: _read_union,
484 core.c.VARIANT: _read_variant,
485 'gws.AclStr': _read_acl_str,
486 'gws.Color': _read_color,
487 'gws.CrsName': _read_crs,
488 'gws.DateStr': _read_date,
489 'gws.DateTimeStr': _read_datetime,
490 'gws.DirPath': _read_dirpath,
491 'gws.Duration': _read_duration,
492 'gws.FilePath': _read_filepath,
493 'gws.FormatStr': _read_formatstr,
494 'gws.UomValueStr': _read_uom_value,
495 'gws.UomPointStr': _read_uom_point,
496 'gws.UomSizeStr': _read_uom_point,
497 'gws.UomExtentStr': _read_uom_extent,
498 'gws.Metadata': _read_metadata,
499 'gws.Regex': _read_regex,
500 'gws.Url': _read_url,
501}