Coverage for gws-app/gws/base/exporter/util.py: 90%
93 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Helpers for exporters."""
3from typing import Optional
4import gws
5import gws.lib.gdalx
6import gws.lib.mime
7import gws.lib.osx
8import gws.lib.zipx
11class Group(gws.Data):
12 """Export-ready features of one model."""
14 title: str
15 """Group title: the model title, table name or model uid."""
16 records: list[gws.FeatureRecord]
17 """Records with the exported attributes and the shape."""
18 columns: dict[str, gws.AttributeType]
19 """Exported columns and their types."""
20 geomType: Optional[gws.GeometryType]
21 """Geometry type, taken from the first feature with a shape."""
22 crs: Optional[gws.Crs]
23 """CRS, taken from the first feature with a shape."""
26def group_features(ea: gws.ExportArgs, er: gws.ExportResult) -> list[Group]:
27 """Group features by model and determine the export columns and geometry type.
29 Columns are the model fields with an attribute type supported by the
30 exporter (or by GDAL, if the exporter does not restrict the types).
31 Features that do not fit their group are skipped and reported in
32 ``er.errors``, see ``gws.base.exporter``.
34 Args:
35 ea: Export arguments.
36 er: Export result, used to report errors.
38 Returns:
39 A list of groups, one per model that has exportable features.
40 """
42 if not ea.features:
43 return []
45 by_model: dict[str, list[gws.Feature]] = {}
46 for f in ea.features:
47 by_model.setdefault(f.model.uid, []).append(f)
49 ls = []
51 for _, features in by_model.items():
52 grp = _create_group(features, ea, er)
53 if grp:
54 ls.append(grp)
56 return ls
59def _create_group(features: list[gws.Feature], ea: gws.ExportArgs, er: gws.ExportResult) -> Optional[Group]:
60 """Create a group for the features of one model, or ``None`` if no feature fits."""
61 grp = Group(
62 records=[],
63 title='',
64 columns={},
65 geomType=None,
66 srid=0,
67 )
69 f = features[0]
71 grp.title = f.model.title
72 if not grp.title and hasattr(f.model, 'tableName'):
73 grp.title = getattr(f.model, 'tableName').split('.')[-1]
74 if not grp.title:
75 grp.title = f.model.uid
77 types = ea.exporter.supportedAttributeTypes or gws.lib.gdalx.supported_attribute_types()
78 for fld in f.model.fields:
79 if fld.attributeType in types:
80 grp.columns[fld.name] = fld.attributeType
82 for n, f in enumerate(features, 1):
83 if ea.notify:
84 ea.notify('')
85 rec = _feature_to_record(f, grp, ea, er)
86 if rec:
87 grp.records.append(rec)
89 return grp if grp.records else None
92def _feature_to_record(f: gws.Feature, grp: Group, ea: gws.ExportArgs, er: gws.ExportResult) -> Optional[gws.FeatureRecord]:
93 """Convert a feature to a record of the group, or report an error and return ``None``."""
94 uid = f.uid()
96 sh = f.shape()
98 if sh:
99 if grp.geomType and grp.geomType != sh.type and not ea.exporter.withMixedGeometry:
100 if len(er.errors) < ea.maxErrors:
101 er.errors.append(f'{uid}: inconsistent geometry: {grp.geomType} and {sh.type}')
102 return
104 if grp.crs and grp.crs != sh.crs and not ea.exporter.withMixedCrs:
105 if len(er.errors) < ea.maxErrors:
106 er.errors.append(f'{uid}: inconsistent CRS: {grp.crs} and {sh.crs}')
107 return
109 if not grp.geomType:
110 grp.geomType = sh.type
111 grp.crs = sh.crs
113 elif not ea.exporter.withNoGeometry:
114 if len(er.errors) < ea.maxErrors:
115 er.errors.append(f'{uid}: has no geometry')
116 return
118 return gws.FeatureRecord(
119 attributes={c: f.get(c) for c in grp.columns},
120 shape=sh,
121 )
124##
127def run_gdal_vector_export(driver_name: str, mime_type: str, ea: gws.ExportArgs, er: gws.ExportResult):
128 """Export features with a GDAL vector driver.
130 Writes one file per group, or a single file with one layer per group if
131 the exporter has ``withMultiLayer``. Several files are zipped. Sets the
132 path, mime type and counts in the export result. Does nothing if there
133 are no features to export.
135 Args:
136 driver_name: GDAL driver name, e.g. ``GeoJSON``.
137 mime_type: Mime type of the result file.
138 ea: Export arguments.
139 er: Export result, filled by this function.
141 Raises:
142 ``gws.Error``: If the driver is not supported.
143 """
145 di = gws.lib.gdalx.get_driver(driver_name)
146 if not di:
147 raise gws.Error(f'unsupported driver: {driver_name}')
149 groups = group_features(ea, er)
150 if not groups:
151 return
153 base_dir = gws.u.ephemeral_dir(gws.u.random_string(64))
154 ext = di.extensions[0] if di.extensions else 'dat'
156 if ea.exporter.withMultiLayer:
157 path = base_dir + f'/export.{ext}'
158 with gws.lib.gdalx.open_vector(path, 'w', driver=di.name, options=ea.exporter.options) as ds:
159 for grp in groups:
160 la = ds.create_layer(grp.title, grp.columns, grp.geomType, grp.crs)
161 fids = la.insert(grp.records)
162 er.numFeaturesExported += len(fids)
163 else:
164 for grp in groups:
165 path = base_dir + f'/{gws.u.to_uid(grp.title)}.{ext}'
166 with gws.lib.gdalx.open_vector(path, 'w', driver=di.name, options=ea.exporter.options) as ds:
167 la = ds.create_layer(grp.title, grp.columns, grp.geomType, grp.crs)
168 fids = la.insert(grp.records)
169 er.numFeaturesExported += len(fids)
171 paths = list(gws.lib.osx.find_files(base_dir, deep=False))
172 er.numFiles = len(paths)
173 if er.numFiles > 1:
174 er.path = base_dir + '/export.zip'
175 er.mimeType = gws.lib.mime.ZIP
176 gws.lib.zipx.zip_to_path(er.path, paths, flat=True)
177 else:
178 er.path = paths[0]
179 er.mimeType = mime_type