Coverage for gws-app/gws/plugin/csv_helper/__init__.py: 84%
95 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"""CSV helper.
3The ``csv`` helper writes CSV data with configurable formatting, for example
4for the ALKIS export. ``writer`` creates a writer, which writes headers and
5rows either into memory or directly into a binary stream.
7Values are formatted according to their type:
9- ``None`` becomes an empty quoted string,
10- integers are written as they are; floats and decimals are formatted with
11 the number formatter of the locale. Numbers are only quoted if
12 ``quoteAll`` is set,
13- dates, datetimes and times are formatted in the short format of the locale
14 and quoted,
15- other values are converted to strings and quoted. If ``formulaHack`` is
16 set, digit-only strings are written as formulas (``="0123"``), so that
17 spreadsheet programs keep leading zeros.
19The helper is created with default settings if it is not configured.
21Example::
23 helpers+ {
24 type "csv"
25 format {
26 delimiter ";"
27 encoding "cp1252"
28 rowDelimiter "CRLF"
29 }
30 }
32Usage in Python::
34 helper = cast(gws.plugin.csv_helper.Object, root.app.helper('csv'))
35 w = helper.writer(gws.lib.intl.locale('de_DE'))
36 w.write_headers(['name', 'area'])
37 w.write_row(['Parcel 1', 123.4])
38 data = w.to_bytes()
39"""
41from typing import BinaryIO
43import decimal
44import datetime
46import gws
47import gws.lib.intl
50class FormatConfig(gws.Config):
51 """CSV format settings."""
53 delimiter: str = ','
54 """Field delimiter."""
55 encoding: str = 'utf8'
56 """Text encoding."""
57 formulaHack: bool = True
58 """Write digit-only strings as formulas."""
59 quote: str = '"'
60 """Quote character."""
61 quoteAll: bool = False
62 """Quote all fields."""
63 rowDelimiter: str = 'LF'
64 """Row delimiter."""
67@gws.ext.config.helper('csv')
68class Config(gws.Config):
69 """Format settings for CSV exports."""
71 format: FormatConfig
72 """CSV format settings."""
75class Format(gws.Data):
76 """CSV format settings used by the writer."""
78 delimiter: str
79 """Field delimiter."""
80 encoding: str
81 """Text encoding."""
82 formulaHack: bool
83 """Write digit-only strings as formulas."""
84 quote: str
85 """Quote character."""
86 quoteAll: bool
87 """Quote all fields, including numbers."""
88 rowDelimiter: str
89 """Row delimiter, with ``CR`` and ``LF`` replaced by the actual characters."""
92@gws.ext.object.helper('csv')
93class Object(gws.Node):
94 """CSV helper."""
96 format: Format
97 """Format settings."""
99 def configure(self) -> None:
100 self.format = Format(
101 delimiter=self.cfg('format.delimiter', default=','),
102 encoding=self.cfg('format.encoding', default='utf8'),
103 formulaHack=self.cfg('format.formulaHack', default=True),
104 quote=self.cfg('format.quote', default='"'),
105 quoteAll=self.cfg('format.quoteAll', default=False),
106 rowDelimiter=self.cfg('format.rowDelimiter', default='LF').replace('CR', '\r').replace('LF', '\n'),
107 )
109 def writer(self, locale: gws.Locale, stream_to: BinaryIO = None) -> '_Writer':
110 """Create a CSV writer.
112 Args:
113 locale: Locale for formatting numbers, dates and times.
114 stream_to: Binary stream to write to. If ``None``, the data is kept in memory.
116 Returns:
117 A new writer with the format settings of this helper.
118 """
120 return _Writer(self, locale, stream_to)
123class _Writer:
124 """CSV writer.
126 Writes headers and rows either directly into a binary stream or into
127 memory. Data kept in memory is returned by ``to_str`` and ``to_bytes``.
128 """
130 def __init__(self, helper: 'Object', locale: gws.Locale, stream_to: BinaryIO = None) -> None:
131 """Create a CSV writer.
133 Args:
134 helper: The CSV helper with the format settings.
135 locale: Locale for formatting numbers, dates and times.
136 stream_to: Binary stream to write to. If ``None``, the data is kept in memory.
137 """
138 self.helper: Object = helper
139 self.format = self.helper.format
140 self.stream_to = stream_to
141 self.eol = self.format.rowDelimiter.encode(self.format.encoding)
143 self.headers = []
144 self.str_rows = []
145 self.str_headers = ''
147 f = gws.lib.intl.formatters(locale)
148 self.dateFormatter = f[0]
149 self.timeFormatter = f[1]
150 self.numberFormatter = f[2]
152 def write_headers(self, headers: list[str]) -> '_Writer':
153 """Write the header row.
155 The headers also define the column order for ``write_dict``.
157 Args:
158 headers: Column names.
160 Returns:
161 The writer itself, for chaining.
162 """
164 self.headers = headers
165 self.str_headers = self.format.delimiter.join(self._quote(s) for s in headers)
166 if self.stream_to:
167 self.stream_to.write(self.str_headers.encode(self.format.encoding) + self.eol)
168 return self
170 def write_row(self, row: list) -> '_Writer':
171 """Write a data row.
173 Args:
174 row: Values of the row.
176 Returns:
177 The writer itself, for chaining.
178 """
180 s = self.format.delimiter.join(self._format(v) for v in row)
181 if self.stream_to:
182 self.stream_to.write(s.encode(self.format.encoding) + self.eol)
183 else:
184 self.str_rows.append(s)
185 return self
187 def write_dict(self, d: dict) -> '_Writer':
188 """Write a data row from a dict.
190 If no headers are written yet, the keys of the dict are written as
191 headers first. Values are taken in the order of the headers, missing
192 values are empty.
194 Args:
195 d: Values by column name.
197 Returns:
198 The writer itself, for chaining.
199 """
201 if not self.headers:
202 self.write_headers(list(d.keys()))
203 return self.write_row([d.get(h, '') for h in self.headers])
205 def to_str(self) -> str:
206 """Return the data kept in memory as a string.
208 Returns:
209 The header row and the data rows, joined with the row delimiter.
210 When writing into a stream, only the header row is kept in memory.
211 """
213 rows = []
214 if self.headers:
215 rows.append(self.str_headers)
216 rows.extend(self.str_rows)
217 return self.format.rowDelimiter.join(rows)
219 def to_bytes(self, encoding: str = None) -> bytes:
220 """Return the data kept in memory as bytes.
222 Characters that cannot be encoded are replaced.
224 Args:
225 encoding: Text encoding. If ``None``, the format encoding is used.
227 Returns:
228 The encoded CSV data.
229 """
231 return self.to_str().encode(encoding or self.format.encoding, errors='replace')
233 def _format(self, val) -> str:
234 """Format a value according to its type."""
235 if val is None:
236 return self._quote('')
238 if isinstance(val, (float, decimal.Decimal)):
239 s = self.numberFormatter.decimal(val)
240 return self._quote(s) if self.format.quoteAll else s
242 if isinstance(val, int):
243 s = str(val)
244 return self._quote(s) if self.format.quoteAll else s
246 if isinstance(val, (datetime.datetime, datetime.date)):
247 s = self.dateFormatter.short(val)
248 return self._quote(s)
250 if isinstance(val, datetime.time):
251 s = self.timeFormatter.short(val)
252 return self._quote(s)
254 val = gws.u.to_str(val)
256 if val and val.isdigit() and self.format.formulaHack:
257 val = '=' + self._quote(val)
259 return self._quote(val)
261 def _quote(self, val) -> str:
262 """Quote a value, doubling the quote characters in it."""
263 q = self.format.quote
264 s = gws.u.to_str(val).replace(q, q + q)
265 return q + s + q