Coverage for gws-app / gws / plugin / qgis / caps.py: 0%
417 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 10:12 +0100
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 10:12 +0100
1"""QGIS project xml parser."""
3from typing import Optional, Any
5import math
6import re
8import gws
9import gws.lib.bounds
10import gws.lib.extent
11import gws.lib.crs
12import gws.gis.source
13import gws.base.metadata
14import gws.lib.net
15import gws.lib.xmlx
18class PrintTemplateElement(gws.Data):
19 type: str
20 uuid: str
21 attributes: dict
22 position: gws.UomPoint
23 size: gws.UomSize
26class PrintTemplate(gws.Data):
27 title: str
28 index: int
29 attributes: dict
30 elements: list[PrintTemplateElement]
33class Caps(gws.Data):
34 metadata: gws.Metadata
35 printTemplates: list[PrintTemplate]
36 projectCrs: gws.Crs
37 projectBounds: Optional[gws.Bounds]
38 projectCanvasBounds: Optional[gws.Bounds]
39 properties: dict
40 sourceLayers: list[gws.SourceLayer]
41 version: str
42 visibilityPresets: dict[str, list[str]]
45def parse(xml: str) -> Caps:
46 el = gws.lib.xmlx.from_string(xml)
47 return parse_element(el)
50def parse_element(root_el: gws.XmlElement) -> Caps:
51 caps = Caps()
53 caps.version = str(root_el.get('version') or '')
54 caps.properties = _parse_properties(root_el.find('properties'))
55 caps.metadata = _project_metadata(root_el)
56 caps.printTemplates = _print_templates(root_el)
57 caps.visibilityPresets = _visibility_presets(root_el)
59 srid = root_el.textof('projectCrs/spatialrefsys/authid') or '4326'
60 crs = gws.lib.crs.get(srid)
61 if not crs:
62 raise gws.Error(f'invalid CRS {srid=} in qgis project')
63 caps.projectCrs = crs
65 ext = _extent_from_tag(root_el.find('properties/WMSExtent'))
66 if ext:
67 caps.projectBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs)
69 ext = _extent_from_tag(root_el.find('mapcanvas/extent'))
70 if ext:
71 caps.projectCanvasBounds = gws.lib.bounds.from_extent(ext, caps.projectCrs)
73 layers_dct = _map_layers(root_el, caps)
74 root_group = _layer_tree(root_el.find('layer-tree-group'), layers_dct)
75 caps.sourceLayers = gws.gis.source.check_layers(root_group.layers if root_group else [])
77 return caps
80##
83def _project_metadata(root_el) -> gws.Metadata:
84 md = gws.base.metadata.new()
86 el = root_el.find('projectMetadata')
87 if el:
88 _collect_metadata(el, md)
90 # @TODO supplementary metadata
91 return gws.base.metadata.normalize(md)
94_meta_mapping = [
95 ('authorityIdentifier', 'identifier'),
96 ('parentIdentifier', 'parentidentifier'),
97 ('language', 'language'),
98 ('type', 'type'),
99 ('title', 'title'),
100 ('abstract', 'abstract'),
101 ('dateCreated', 'creation'),
102 ('fees', 'fees'),
103]
105_contact_mapping = [
106 ('contactEmail', 'email'),
107 ('contactFax', 'fax'),
108 ('contactOrganization', 'organization'),
109 ('contactPerson', 'name'),
110 ('contactPhone', 'voice'),
111 ('contactPosition', 'position'),
112 ('contactRole', 'role'),
113 ('contactAddress', 'address'),
114 ('contactAddressType', 'type'),
115 ('contactArea', 'administrativearea'),
116 ('contactCity', 'city'),
117 ('contactCountry', 'country'),
118 ('contactZip', 'postalcode'),
119]
122def _add_dict(dst, src, mapping):
123 for dkey, skey in mapping:
124 if skey in src:
125 setattr(dst, dkey, src[skey])
128def _collect_metadata(el: gws.XmlElement, md: gws.Metadata):
129 # extract metadata from projectMetadata/resourceMetadata
131 _add_dict(md, el.textdict(), _meta_mapping)
133 md.keywords = []
134 for kw in el.findall('keywords'):
135 keywords = kw.textlist('keyword')
136 if kw.get('vocabulary') == 'gmd:topicCategory':
137 md.isoTopicCategories = keywords
138 else:
139 md.keywords.extend(keywords)
141 contact_el = el.find('contact')
142 if contact_el:
143 _add_dict(md, contact_el.textdict(), _contact_mapping)
144 for e in contact_el.findall('contactAddress'):
145 _add_dict(md, e.textdict(), _contact_mapping)
146 break # NB we only support one contact address
148 md.metaLinks = []
149 for e in el.findall('links/link'):
150 # @TODO clarify
151 md.metaLinks.append(
152 gws.MetadataLink(
153 url=e.get('url'),
154 description=e.get('description'),
155 mimeType=e.get('mimeType'),
156 format=e.get('format'),
157 title=e.get('name'),
158 scheme=e.get('type'),
159 )
160 )
162 for e in el.findall('constraints'):
163 md.accessConstraints = e.text
164 md.accessConstraintsType = e.get('type')
166 for e in el.findall('license'):
167 md.license = e.text
168 break
170 e = el.find('extent/temporal')
171 if e:
172 md.temporalBegin = e.textof('period/start')
173 md.temporalEnd = e.textof('period/end')
176# see QGIS/src/core/layout/qgslayoutitemregistry.h
178_QGraphicsItem_UserType = 65536 # https://doc.qt.io/qtforpython/PySide2/QtWidgets/QGraphicsItem.html
180_LT0 = _QGraphicsItem_UserType + 100
182_LAYOUT_TYPES = {
183 _LT0 + 0: 'item', # LayoutItem
184 _LT0 + 1: 'group', # LayoutGroup
185 _LT0 + 2: 'page', # LayoutPage
186 _LT0 + 3: 'map', # LayoutMap
187 _LT0 + 4: 'picture', # LayoutPicture
188 _LT0 + 5: 'label', # LayoutLabel
189 _LT0 + 6: 'legend', # LayoutLegend
190 _LT0 + 7: 'shape', # LayoutShape
191 _LT0 + 8: 'polygon', # LayoutPolygon
192 _LT0 + 9: 'polyline', # LayoutPolyline
193 _LT0 + 10: 'scalebar', # LayoutScaleBar
194 _LT0 + 11: 'frame', # LayoutFrame
195 _LT0 + 12: 'html', # LayoutHtml
196 _LT0 + 13: 'attributetable', # LayoutAttributeTable
197 _LT0 + 14: 'texttable', # LayoutTextTable
198 _LT0 + 15: '3dmap', # Layout3DMap
199 _LT0 + 16: 'manualtable', # LayoutManualTable
200 _LT0 + 17: 'marker', # LayoutMarker
201}
204# print templates in qgis-3:
205#
206# <Layouts>
207# <Layout name="..." <- template 1
208# <PageCollection
209# <LayoutItem <- pages
210# <LayoutItem type="<int, see below>" ...
211# <LayoutMultiFrame type="<int>" ...
212# <Layout??? <- evtl. other item tags
213#
214# <Layout name="..." <- template 2
215# etc
218def _print_templates(root_el: gws.XmlElement):
219 templates = []
221 for layout_el in root_el.findall('Layouts/Layout'):
222 tpl = PrintTemplate(
223 title=layout_el.get('name', ''),
224 attributes=layout_el.attrib,
225 index=len(templates),
226 elements=[],
227 )
229 pc_el = layout_el.find('PageCollection')
230 if pc_el:
231 tpl.elements.extend(gws.u.compact(_layout_element(c) for c in pc_el))
233 tpl.elements.extend(gws.u.compact(_layout_element(c) for c in layout_el))
235 templates.append(tpl)
237 return templates
240def _layout_element(item_el: gws.XmlElement):
241 type = _LAYOUT_TYPES.get(_parse_int(item_el.get('type')))
242 uuid = item_el.get('uuid')
243 if type and uuid:
244 return PrintTemplateElement(
245 type=type,
246 uuid=uuid,
247 attributes=item_el.attrib,
248 position=_parse_msize(item_el.get('position')),
249 size=_parse_msize(item_el.get('size')),
250 )
253##
256def _map_layers(root_el: gws.XmlElement, caps: Caps) -> dict[str, gws.SourceLayer]:
257 no_wms_layers = set(caps.properties.get('WMSRestrictedLayers', []))
258 use_layer_ids = caps.properties.get('WMSUseLayerIDs', False)
260 layers_dct = {}
262 for el in root_el.findall('projectlayers/maplayer'):
263 sl = _map_layer(el, caps, use_layer_ids)
264 if not sl:
265 continue
266 # no_wms_layers always contains titles, not ids (=names)
267 if sl.title in no_wms_layers:
268 continue
269 layers_dct[sl.sourceId] = sl
271 return layers_dct
274def _map_layer(layer_el: gws.XmlElement, caps: Caps, use_layer_ids: bool) -> gws.SourceLayer:
275 sl = gws.SourceLayer()
277 sl.metadata = _map_layer_metadata(layer_el)
279 if layer_el.get('hasScaleBasedVisibilityFlag') == '1':
280 # in qgis, maxScale < minScale
281 a = _parse_float(layer_el.get('maxScale'))
282 z = _parse_float(layer_el.get('minScale'))
283 if z > a:
284 sl.scaleRange = [a, z]
286 sl.dataSource = _map_layer_datasource(layer_el)
287 sl.opacity = _parse_float(layer_el.textof('layerOpacity') or '1')
288 sl.isQueryable = layer_el.textof('flags/Identifiable') == '1'
289 sl.properties = _parse_properties(layer_el.find('customproperties'))
291 uid = layer_el.textof('id')
292 if use_layer_ids:
293 layer_name = uid
294 else:
295 layer_name = layer_el.textof('shortname') or layer_el.textof('layername')
297 sl.title = layer_el.textof('layername') or sl.metadata.get('title') or ''
298 sl.name = layer_name
299 sl.sourceId = uid
301 crs = gws.lib.crs.get(layer_el.textof('srs/spatialrefsys/authid'))
302 if crs:
303 sl.supportedCrs = [crs]
305 ext = qgis_extent(layer_el, crs or caps.projectCrs)
306 if ext:
307 sl.wgsExtent = ext
309 return sl
312def _map_layer_metadata(layer_el) -> gws.Metadata:
313 # Layer metadata is either Layer->Properties->Metadata (stored in maplayer/resourceMetadata),
314 # or Layer->Properties->QGIS Server (stored directly under maplayer/abstract, maplayer/keywordList and so on.
316 md = gws.base.metadata.new()
318 el = layer_el.find('resourceMetadata')
319 if el:
320 _collect_metadata(el, md)
322 # fill in missing props from direct metadata
324 d = layer_el.textdict()
326 md.title = md.title or d.get('title') or d.get('shortname') or d.get('layername')
327 md.abstract = md.abstract or d.get('abstract')
329 if not md.attribution and d.get('attribution'):
330 md.attribution = d.get('attribution')
332 if not md.keywords:
333 md.keywords = layer_el.textlist('keywordList/value')
335 if not md.metaLinks:
336 md.metaLinks = []
337 for e in layer_el.findall('metadataUrls/metadataUrl'):
338 md.metaLinks.append(
339 gws.MetadataLink(
340 type=e.get('type'),
341 format=e.get('format'),
342 title=e.text,
343 )
344 )
346 return gws.base.metadata.normalize(md)
349def _map_layer_datasource(layer_el: gws.XmlElement) -> dict:
350 prov = layer_el.textof('provider')
351 ds_text = layer_el.textof('datasource')
353 if ds_text:
354 return parse_datasource((prov or '').lower(), ds_text)
355 if prov:
356 return {'provider': prov.lower()}
357 return {}
360def qgis_extent(layer_el: gws.XmlElement, layer_crs: gws.Crs):
361 uid = layer_el.textof('id')
363 # extent explicitly defined in metadata (Layer Props -> Metadata -> Extent)
364 el = layer_el.find('resourceMetadata/extent/spatial')
365 if el:
366 ext = _extent_from_tag(el)
367 crs = gws.lib.crs.get(el.get('crs'))
368 if ext and crs:
369 ext = gws.lib.extent.transform(ext, crs, gws.lib.crs.WGS84)
370 if gws.lib.extent.is_valid_wgs(ext):
371 gws.log.debug(f'qgis_extent: {uid}: spatial: {ext}')
372 return ext
374 # extent in <maplayer>/<wgs84extent>
375 el = layer_el.find('wgs84extent')
376 if el:
377 ext = _extent_from_tag(el)
378 if ext and gws.lib.extent.is_valid_wgs(ext):
379 gws.log.debug(f'qgis_extent: {uid}: wgs84extent: {ext}')
380 return ext
382 # extent in <maplayer>/<extent>, assume the layer CRS
383 el = layer_el.find('extent')
384 if el:
385 ext = _extent_from_tag(el)
386 if ext:
387 ext = gws.lib.extent.transform(ext, layer_crs, gws.lib.crs.WGS84)
388 if ext and gws.lib.extent.is_valid_wgs(ext):
389 gws.log.debug(f'qgis_extent: {uid}: extent: {ext}')
390 return ext
392 gws.log.warning(f'qgis_extent: {uid}: NOT FOUND')
395# layer trees:
397# <layer-tree-group>
398# <layer-tree-group checked="Qt::Checked" expanded="1" name="...">
399# <layer-tree-layer ... checked="Qt::Checked" expanded="1" id="...">
400# ...
403def _layer_tree(el: Optional[gws.XmlElement], layers_dct):
404 if not el:
405 return
407 visible = el.get('checked') != 'Qt::Unchecked'
408 expanded = el.get('expanded') == '1'
410 if el.tag == 'layer-tree-group':
411 title = el.get('name')
412 # qgis doesn't write 'id' for groups but our generators might
413 name = el.get('id') or title
415 return gws.SourceLayer(
416 title=title,
417 name=name,
418 metadata=gws.Metadata(title=title, name=name),
419 isVisible=visible,
420 isExpanded=expanded,
421 isGroup=True,
422 isQueryable=False,
423 isImage=False,
424 layers=gws.u.compact(_layer_tree(c, layers_dct) for c in el),
425 )
427 if el.tag == 'layer-tree-layer':
428 sl = layers_dct.get(el.get('id'))
429 if sl:
430 sl.isVisible = visible
431 sl.isExpanded = expanded
432 sl.isGroup = False
433 sl.isImage = True
434 return sl
437##
440def _visibility_presets(root_el: gws.XmlElement):
441 """Parse the global ``visibility-presets`` block.
443 We're only interested in which layers are visible.
445 Overall structure::
447 <visibility-presets>
448 <visibility-preset .... name="...">
449 <layer id="..." visible="1" ... />
450 <layer id="..." visible="1" ... />
451 <visibility-preset .... name="...">
452 <layer id="..." visible="1" ... />
453 <layer id="..." visible="1" ... />
455 """
457 d = {}
459 for el in root_el.findall('visibility-presets/visibility-preset'):
460 ls = []
461 for la in el.findall('layer'):
462 if la.attr('visible') == '1':
463 ls.append(la.attr('id'))
464 d[el.attr('name')] = ls
466 return d
469##
472def parse_datasource(prov, text):
473 ds = gws.u.to_lower_dict(_parse_datasource(text) or {})
474 ds['provider'] = (ds.get('provider') or prov).lower()
476 if ds['provider'] == 'wms' and 'tilematrixset' in ds:
477 ds['provider'] = 'wmts'
478 elif ds['provider'] == 'wms' and ds.get('type') == 'xyz':
479 ds['provider'] = 'xyz'
481 # @TODO classify ogr's based on a file extension
483 return ds
486def _parse_datasource(text):
487 # Datasources are very versatile and the format depends on the provider.
488 # For some hints see `decodedSource` in qgsvectorlayer.cpp/qgsrasterlayer.cpp.
489 # We don't have ambition to parse them all, just do some ad-hoc parsing
490 # of the most common flavors, and return the rest as `{'text': text}`.
492 text = text.strip()
494 if re.match(r'^\w+=[^&]*&', text):
495 # key=value, amp-separated, uri-encoded
496 # used for WMS, e.g.
497 # contextualWMSLegend=0&crs=EPSG:31468&...&url=...?SERVICE%3DWMTS%26REQUEST%3DGetCapabilities
498 return _datasource_amp_delimited(text)
500 if re.match(r'^\w+=\S+ ', text):
501 # key=value, space separated
502 # used for postgres/WFS, e.g.
503 # dbname='...' host=... port=...
504 # pagingEnabled='...' preferCoordinatesForWfsT11=...
505 return _datasource_space_delimited(text)
507 if text.startswith(('http://', 'https://')):
508 # just an url
509 return {'url': text}
511 if text.startswith(('.', '/')):
512 # path or path|options
513 # used for Geojson, GPKG, e.g.
514 # ../rel/path/test.gpkg|layername=name|subset=... etc
515 return _datasource_pipe_delimited(text)
517 return {'text': text}
520def _datasource_amp_delimited(text):
521 ds = {}
523 for p in text.split('&'):
524 if '=' not in p:
525 continue
526 k, v = p.split('=', maxsplit=1)
528 v = gws.lib.net.unquote(v)
530 if k in {'layers', 'styles'}:
531 ds.setdefault(k, []).append(v)
532 else:
533 ds[k] = v
535 if 'url' not in ds:
536 return ds
538 # extract params from the url
540 url, params = gws.lib.net.extract_params(ds['url'])
542 if 'typename' in params:
543 ds['typename'] = params.pop('typename')
544 if 'layers' in params:
545 ds.setdefault('layers', []).extend(params.pop('layers').split(','))
546 if 'styles' in params:
547 ds.setdefault('styles', []).extend(params.pop('styles').split(','))
549 params.pop('service', None)
550 params.pop('request', None)
552 ds['params'] = params
554 # {x} placeholders shouldn't be encoded
555 url = url.replace('%7B', '{')
556 url = url.replace('%7D', '}')
558 ds['url'] = url
560 return ds
563def _datasource_space_delimited(text):
564 key_re = r'^\w+\s*=\s*'
566 value_re = r"""(?x)
567 " (?: \\. | [^"])* " |
568 ' (?: \\. | [^'])* ' |
569 \S+
570 """
572 parens_re = r'\(.*?\)'
574 def _cut(u, rx):
575 m = re.match(rx, u)
576 if not m:
577 raise ValueError(f'datasource uri error, expected {rx!r}, found {u[:25]!r}')
578 v = m.group(0)
579 return v, u[len(v) :].strip()
581 def _unesc(s):
582 return re.sub(r'\\(.)', '\1', s)
584 def _mid(s):
585 return s[1:-1].strip()
587 def _value(v):
588 if v.startswith(("'", '"')):
589 return _unesc(_mid(v))
590 return v
592 ds = {}
594 while text:
595 # keyword=
596 key, text = _cut(text, key_re)
597 key = key.strip('= ')
599 if key == 'sql':
600 # 'sql=' is special and can contain whatever, it's always the last one
601 ds[key] = text
602 break
604 elif key == 'table':
605 # 'table=' is special, it can be `table="foo"` or `table="foo"."bar"` or table=`"foo"."bar" (geom)`
606 v, text = _cut(text, value_re)
607 ds['table'] = _value(v)
609 if text.startswith('.'):
610 v, text = _cut(text[1:], value_re)
611 ds['table'] += '.' + _value(v)
613 if text.startswith('('):
614 v, text = _cut(text, parens_re)
615 ds['geometryColumn'] = _mid(v)
617 else:
618 # just param=val
619 v, text = _cut(text, value_re)
620 ds[key] = _value(v)
622 return ds
625def _datasource_pipe_delimited(text):
626 if '|' not in text:
627 return {'path': text}
629 path, rest = text.split('|', maxsplit=1)
631 if '=' not in rest:
632 return {'path': path, 'options': rest}
634 ds = {'path': path}
636 for p in rest.split('|'):
637 k, v = p.split('=', maxsplit=1)
638 ds[k] = v
640 return ds
643##
646def _parse_properties(el: Optional[gws.XmlElement]) -> dict:
647 """Parse qgis property blocks.
649 There are following forms:
651 Scalar property::
653 <WMSContactPhone type="QString">...
655 Dict::
657 <QFieldSync>
658 <dirsToCopy type="QString">...
659 <exportDirectoryProject type="QString">...
660 </QFieldSync>
662 Option map::
664 <data-defined-properties>
665 <Option type="Map">
666 <Option type="QString" name="..." value="..."/>
667 <Option name="properties"/>
668 </Option>
669 </data-defined-properties>
672 """
674 if not el:
675 return {}
677 _, val = _parse_property_tag(el)
678 return val
681def _parse_property_tag(el: gws.XmlElement) -> tuple[str, Any]:
682 typ = el.get('type')
683 name = el.tag
684 is_opt = el.tag == 'Option'
686 if is_opt and el.attr('name'):
687 name = el.attr('name') or ''
689 if not typ or typ == 'Map':
690 d = {}
692 for c in el:
693 k, v = _parse_property_tag(c)
694 if k:
695 d[k] = v
697 if len(d) == 1 and 'Option' in d:
698 return name, d['Option']
700 return name, d
702 if typ == 'List':
703 ls = []
704 for c in el:
705 _, v = _parse_property_tag(c)
706 ls.append(v)
707 return name, ls
709 if typ == 'QStringList':
710 val = [c.text for c in el.findall('value')]
711 return name, val
713 if typ == 'QString':
714 val = el.attr('value') if is_opt else el.text
715 return name, val
717 if typ == 'bool':
718 val = el.attr('value') if is_opt else el.text
719 return name, (val or '').lower() == 'true'
721 if typ == 'int':
722 val = el.attr('value') if is_opt else el.text
723 return name, _parse_int(val)
725 if typ == 'double':
726 val = el.attr('value') if is_opt else el.text
727 return name, _parse_float(val)
729 return '', None
732##
735def _extent_from_tag(el: Optional[gws.XmlElement]) -> Optional[gws.Extent]:
736 if not el:
737 return
739 ext = _extent_from_tag2(el)
740 if not ext:
741 return
743 # work around qgis bug with absurdly high values in extent tags
744 if any(abs(c) > 1e10 for c in ext):
745 gws.log.warning(f'qgis_extent: ignoring invalid extent {ext}')
746 return
748 # ignore all-zero extents
749 if all(abs(c) < 1e-10 for c in ext):
750 return
752 # avoid single-point extents
753 min_size = 0.0001 # in metric projections, this is about 1cm
754 if ext[2] - ext[0] < min_size:
755 ext = gws.Extent([ext[0] - min_size, ext[1], ext[2] + min_size, ext[3]])
756 if ext[3] - ext[1] < min_size:
757 ext = gws.Extent([ext[0], ext[1] - min_size, ext[2], ext[3] + min_size])
759 return ext
762def _extent_from_tag2(el: gws.XmlElement):
763 # <spatial dimensions="2" miny="0" maxz="0" maxx="0" crs="EPSG:25832" minx="0" minz="0" maxy="0"/>
764 if el.get('minx'):
765 return _extent_from_list(
766 [
767 el.get('minx'),
768 el.get('miny'),
769 el.get('maxx'),
770 el.get('maxy'),
771 ]
772 )
774 # <WMSExtent type="QStringList">
775 # <value>1</value>
776 # <value>2</value>
777 # <value>3</value>
778 # <value>4</value>
779 # </WMSExtent>
780 #
781 if el.get('type') == 'QStringList':
782 return _extent_from_list([v.text for v in el.children()])
784 # <wgs84extent>
785 # <xmin>1</xmin>
786 # <ymin>2</ymin>
787 # <xmax>3</xmax>
788 # <ymax>4</ymax>
789 # </wgs84extent>
791 if el.find('xmin'):
792 return _extent_from_list(
793 [
794 el.textof('xmin'),
795 el.textof('ymin'),
796 el.textof('xmax'),
797 el.textof('ymax'),
798 ]
799 )
802def _extent_from_list(ls):
803 if len(ls) != 4:
804 return
805 try:
806 ext = [float(p) for p in ls]
807 except ValueError:
808 return
809 if not all(math.isfinite(p) for p in ext):
810 return
811 return gws.Extent([
812 min(ext[0], ext[2]),
813 min(ext[1], ext[3]),
814 max(ext[0], ext[2]),
815 max(ext[1], ext[3]),
816 ])
819def _parse_msize(s):
820 # e.g. 'position': '228.477,27.8455,mm'
821 try:
822 x, y, u = s.split(',')
823 return _parse_float(x), _parse_float(y), u
824 except Exception:
825 return None
828def _parse_int(s) -> int:
829 try:
830 return int(s)
831 except Exception:
832 return 0
835def _parse_float(s) -> float:
836 try:
837 x = float(s)
838 except Exception:
839 return 0
840 if math.isnan(x) or math.isinf(x):
841 return 0
842 return x