Coverage for gws-app/gws/plugin/qgis/caps.py: 72%
418 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"""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'])
541 params = gws.u.to_lower_dict(params)
543 if 'typename' in params:
544 ds['typename'] = params.pop('typename')
545 if 'layers' in params:
546 ds.setdefault('layers', []).extend(params.pop('layers').split(','))
547 if 'styles' in params:
548 ds.setdefault('styles', []).extend(params.pop('styles').split(','))
550 params.pop('service', None)
551 params.pop('request', None)
553 ds['params'] = params
555 # {x} placeholders shouldn't be encoded
556 url = url.replace('%7B', '{')
557 url = url.replace('%7D', '}')
559 ds['url'] = url
561 return ds
564def _datasource_space_delimited(text):
565 key_re = r'^\w+\s*=\s*'
567 value_re = r"""(?x)
568 " (?: \\. | [^"])* " |
569 ' (?: \\. | [^'])* ' |
570 \S+
571 """
573 parens_re = r'\(.*?\)'
575 def _cut(u, rx):
576 m = re.match(rx, u)
577 if not m:
578 raise ValueError(f'datasource uri error, expected {rx!r}, found {u[:25]!r}')
579 v = m.group(0)
580 return v, u[len(v) :].strip()
582 def _unesc(s):
583 return re.sub(r'\\(.)', '\1', s)
585 def _mid(s):
586 return s[1:-1].strip()
588 def _value(v):
589 if v.startswith(("'", '"')):
590 return _unesc(_mid(v))
591 return v
593 ds = {}
595 while text:
596 # keyword=
597 key, text = _cut(text, key_re)
598 key = key.strip('= ')
600 if key == 'sql':
601 # 'sql=' is special and can contain whatever, it's always the last one
602 ds[key] = text
603 break
605 elif key == 'table':
606 # 'table=' is special, it can be `table="foo"` or `table="foo"."bar"` or table=`"foo"."bar" (geom)`
607 v, text = _cut(text, value_re)
608 ds['table'] = _value(v)
610 if text.startswith('.'):
611 v, text = _cut(text[1:], value_re)
612 ds['table'] += '.' + _value(v)
614 if text.startswith('('):
615 v, text = _cut(text, parens_re)
616 ds['geometryColumn'] = _mid(v)
618 else:
619 # just param=val
620 v, text = _cut(text, value_re)
621 ds[key] = _value(v)
623 return ds
626def _datasource_pipe_delimited(text):
627 if '|' not in text:
628 return {'path': text}
630 path, rest = text.split('|', maxsplit=1)
632 if '=' not in rest:
633 return {'path': path, 'options': rest}
635 ds = {'path': path}
637 for p in rest.split('|'):
638 k, v = p.split('=', maxsplit=1)
639 ds[k] = v
641 return ds
644##
647def _parse_properties(el: Optional[gws.XmlElement]) -> dict:
648 """Parse qgis property blocks.
650 There are following forms:
652 Scalar property::
654 <WMSContactPhone type="QString">...
656 Dict::
658 <QFieldSync>
659 <dirsToCopy type="QString">...
660 <exportDirectoryProject type="QString">...
661 </QFieldSync>
663 Option map::
665 <data-defined-properties>
666 <Option type="Map">
667 <Option type="QString" name="..." value="..."/>
668 <Option name="properties"/>
669 </Option>
670 </data-defined-properties>
673 """
675 if not el:
676 return {}
678 _, val = _parse_property_tag(el)
679 return val
682def _parse_property_tag(el: gws.XmlElement) -> tuple[str, Any]:
683 typ = el.get('type')
684 name = el.tag
685 is_opt = el.tag == 'Option'
687 if is_opt and el.attr('name'):
688 name = el.attr('name') or ''
690 if not typ or typ == 'Map':
691 d = {}
693 for c in el:
694 k, v = _parse_property_tag(c)
695 if k:
696 d[k] = v
698 if len(d) == 1 and 'Option' in d:
699 return name, d['Option']
701 return name, d
703 if typ == 'List':
704 ls = []
705 for c in el:
706 _, v = _parse_property_tag(c)
707 ls.append(v)
708 return name, ls
710 if typ == 'QStringList':
711 val = [c.text for c in el.findall('value')]
712 return name, val
714 if typ == 'QString':
715 val = el.attr('value') if is_opt else el.text
716 return name, val
718 if typ == 'bool':
719 val = el.attr('value') if is_opt else el.text
720 return name, (val or '').lower() == 'true'
722 if typ == 'int':
723 val = el.attr('value') if is_opt else el.text
724 return name, _parse_int(val)
726 if typ == 'double':
727 val = el.attr('value') if is_opt else el.text
728 return name, _parse_float(val)
730 return '', None
733##
736def _extent_from_tag(el: Optional[gws.XmlElement]) -> Optional[gws.Extent]:
737 if not el:
738 return
740 ext = _extent_from_tag2(el)
741 if not ext:
742 return
744 # work around qgis bug with absurdly high values in extent tags
745 if any(abs(c) > 1e10 for c in ext):
746 gws.log.warning(f'qgis_extent: ignoring invalid extent {ext}')
747 return
749 # ignore all-zero extents
750 if all(abs(c) < 1e-10 for c in ext):
751 return
753 # avoid single-point extents
754 min_size = 0.0001 # in metric projections, this is about 1cm
755 if ext[2] - ext[0] < min_size:
756 ext = gws.Extent([ext[0] - min_size, ext[1], ext[2] + min_size, ext[3]])
757 if ext[3] - ext[1] < min_size:
758 ext = gws.Extent([ext[0], ext[1] - min_size, ext[2], ext[3] + min_size])
760 return ext
763def _extent_from_tag2(el: gws.XmlElement):
764 # <spatial dimensions="2" miny="0" maxz="0" maxx="0" crs="EPSG:25832" minx="0" minz="0" maxy="0"/>
765 if el.get('minx'):
766 return _extent_from_list(
767 [
768 el.get('minx'),
769 el.get('miny'),
770 el.get('maxx'),
771 el.get('maxy'),
772 ]
773 )
775 # <WMSExtent type="QStringList">
776 # <value>1</value>
777 # <value>2</value>
778 # <value>3</value>
779 # <value>4</value>
780 # </WMSExtent>
781 #
782 if el.get('type') == 'QStringList':
783 return _extent_from_list([v.text for v in el.children()])
785 # <wgs84extent>
786 # <xmin>1</xmin>
787 # <ymin>2</ymin>
788 # <xmax>3</xmax>
789 # <ymax>4</ymax>
790 # </wgs84extent>
792 if el.find('xmin'):
793 return _extent_from_list(
794 [
795 el.textof('xmin'),
796 el.textof('ymin'),
797 el.textof('xmax'),
798 el.textof('ymax'),
799 ]
800 )
803def _extent_from_list(ls):
804 if len(ls) != 4:
805 return
806 try:
807 ext = [float(p) for p in ls]
808 except ValueError:
809 return
810 if not all(math.isfinite(p) for p in ext):
811 return
812 return gws.Extent([
813 min(ext[0], ext[2]),
814 min(ext[1], ext[3]),
815 max(ext[0], ext[2]),
816 max(ext[1], ext[3]),
817 ])
820def _parse_msize(s):
821 # e.g. 'position': '228.477,27.8455,mm'
822 try:
823 x, y, u = s.split(',')
824 return _parse_float(x), _parse_float(y), u
825 except Exception:
826 return None
829def _parse_int(s) -> int:
830 try:
831 return int(s)
832 except Exception:
833 return 0
836def _parse_float(s) -> float:
837 try:
838 x = float(s)
839 except Exception:
840 return 0
841 if math.isnan(x) or math.isinf(x):
842 return 0
843 return x