csvd2c 0.1.0__2-py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csvd2c/CSV.py +430 -0
- csvd2c/__init__.py +59 -0
- csvd2c/column/Error.py +16 -0
- csvd2c/column/Pattern.py +36 -0
- csvd2c/column/Validator.py +31 -0
- csvd2c/column/__init__.py +11 -0
- csvd2c/column/patterns/CaseFold.py +12 -0
- csvd2c/column/patterns/Choices.py +27 -0
- csvd2c/column/patterns/CollapseWhitespace.py +17 -0
- csvd2c/column/patterns/Currency.py +95 -0
- csvd2c/column/patterns/CurrencyData.py +3557 -0
- csvd2c/column/patterns/Date.py +72 -0
- csvd2c/column/patterns/Default.py +18 -0
- csvd2c/column/patterns/IfEmpty.py +19 -0
- csvd2c/column/patterns/IfMatch.py +26 -0
- csvd2c/column/patterns/IfUndecodable.py +23 -0
- csvd2c/column/patterns/Length.py +38 -0
- csvd2c/column/patterns/Lower.py +12 -0
- csvd2c/column/patterns/Map.py +38 -0
- csvd2c/column/patterns/Number.py +67 -0
- csvd2c/column/patterns/Range.py +69 -0
- csvd2c/column/patterns/Recode.py +46 -0
- csvd2c/column/patterns/RecoverEncoding.py +48 -0
- csvd2c/column/patterns/Regex.py +34 -0
- csvd2c/column/patterns/RegexReplace.py +32 -0
- csvd2c/column/patterns/RejectCharacters.py +32 -0
- csvd2c/column/patterns/RejectReplacementCharacter.py +24 -0
- csvd2c/column/patterns/RejectSurrogates.py +24 -0
- csvd2c/column/patterns/Replace.py +27 -0
- csvd2c/column/patterns/Required.py +24 -0
- csvd2c/column/patterns/Strip.py +19 -0
- csvd2c/column/patterns/TryRecoverEncoding.py +86 -0
- csvd2c/column/patterns/UnicodeNormalize.py +19 -0
- csvd2c/column/patterns/Upper.py +12 -0
- csvd2c/column/patterns/__init__.py +65 -0
- csvd2c/row/Error.py +17 -0
- csvd2c/row/Pattern.py +37 -0
- csvd2c/row/Validator.py +31 -0
- csvd2c/row/__init__.py +11 -0
- csvd2c/row/patterns/IfLength.py +45 -0
- csvd2c/row/patterns/Insert.py +31 -0
- csvd2c/row/patterns/Length.py +37 -0
- csvd2c/row/patterns/Merge.py +46 -0
- csvd2c/row/patterns/Pad.py +33 -0
- csvd2c/row/patterns/RejectEmpty.py +29 -0
- csvd2c/row/patterns/RejectHeader.py +36 -0
- csvd2c/row/patterns/Remove.py +35 -0
- csvd2c/row/patterns/Reorder.py +51 -0
- csvd2c/row/patterns/Replace.py +41 -0
- csvd2c/row/patterns/Slice.py +43 -0
- csvd2c/row/patterns/Split.py +44 -0
- csvd2c/row/patterns/Truncate.py +26 -0
- csvd2c/row/patterns/__init__.py +31 -0
- csvd2c-0.1.0.dist-info/METADATA +96 -0
- csvd2c-0.1.0.dist-info/RECORD +58 -0
- csvd2c-0.1.0.dist-info/WHEEL +6 -0
- csvd2c-0.1.0.dist-info/licenses/LICENSE +674 -0
- csvd2c-0.1.0.dist-info/top_level.txt +1 -0
csvd2c/CSV.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from contextlib import (
|
|
4
|
+
ExitStack,
|
|
5
|
+
contextmanager,
|
|
6
|
+
)
|
|
7
|
+
from csv import reader
|
|
8
|
+
from io import TextIOWrapper
|
|
9
|
+
from typing import (
|
|
10
|
+
Dict,
|
|
11
|
+
Generator,
|
|
12
|
+
Iterator,
|
|
13
|
+
List,
|
|
14
|
+
Optional,
|
|
15
|
+
Sequence,
|
|
16
|
+
Tuple,
|
|
17
|
+
Union,
|
|
18
|
+
overload,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .row import Validator as RowValidator
|
|
22
|
+
from .column import Validator as ColumnValidator
|
|
23
|
+
|
|
24
|
+
__all__ = (
|
|
25
|
+
'CSV',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CSV(object):
|
|
30
|
+
"""
|
|
31
|
+
Read CSV records incrementally and apply row and column validators.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
encoding: str = 'utf-8',
|
|
38
|
+
errors: str = 'strict',
|
|
39
|
+
newline: str = '',
|
|
40
|
+
delimiter: str = ',',
|
|
41
|
+
strict: bool = True,
|
|
42
|
+
row: Optional[RowValidator] = None,
|
|
43
|
+
cols: Optional[
|
|
44
|
+
Union[
|
|
45
|
+
Dict[str, ColumnValidator],
|
|
46
|
+
Tuple[ColumnValidator, ...],
|
|
47
|
+
]
|
|
48
|
+
] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.encoding = encoding
|
|
51
|
+
self.errors = errors
|
|
52
|
+
self.newline = newline
|
|
53
|
+
self.delimiter = delimiter
|
|
54
|
+
self.strict = strict
|
|
55
|
+
self.check_validators(row, cols)
|
|
56
|
+
self.row_validator = row
|
|
57
|
+
self.column_validators = cols
|
|
58
|
+
self.reader: Optional[Iterator[List[str]]] = None
|
|
59
|
+
self.text: Optional[TextIOWrapper] = None
|
|
60
|
+
self.header_data: Optional[List[str]] = None
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def open(
|
|
65
|
+
self,
|
|
66
|
+
path: str,
|
|
67
|
+
mode: str = 'rb',
|
|
68
|
+
) -> Generator['CSV', None, None]:
|
|
69
|
+
"""
|
|
70
|
+
Open a file for reading and close it on context exit.
|
|
71
|
+
|
|
72
|
+
Support rb (default), r, and rt. Binary mode uses TextIOWrapper;
|
|
73
|
+
text modes apply the same encoding, errors, and newline settings.
|
|
74
|
+
No record is consumed automatically. Nested opens on the same CSV
|
|
75
|
+
instance are rejected. File, decoding, and parsing errors propagate.
|
|
76
|
+
"""
|
|
77
|
+
if self.reader is not None:
|
|
78
|
+
raise RuntimeError('This CSV instance is already open')
|
|
79
|
+
if mode not in ('rb', 'r', 'rt'):
|
|
80
|
+
raise ValueError('mode must be rb, r, or rt')
|
|
81
|
+
|
|
82
|
+
with ExitStack() as stack:
|
|
83
|
+
if mode == 'rb':
|
|
84
|
+
binary = stack.enter_context(open(path, 'rb'))
|
|
85
|
+
text: TextIOWrapper = stack.enter_context(
|
|
86
|
+
TextIOWrapper(
|
|
87
|
+
binary,
|
|
88
|
+
encoding=self.encoding,
|
|
89
|
+
errors=self.errors,
|
|
90
|
+
newline=self.newline,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
text = stack.enter_context(
|
|
95
|
+
open(
|
|
96
|
+
path,
|
|
97
|
+
mode,
|
|
98
|
+
encoding=self.encoding,
|
|
99
|
+
errors=self.errors,
|
|
100
|
+
newline=self.newline,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
self.reader = reader(
|
|
104
|
+
text,
|
|
105
|
+
delimiter=self.delimiter,
|
|
106
|
+
strict=self.strict,
|
|
107
|
+
)
|
|
108
|
+
self.header_data = None
|
|
109
|
+
self.text = text
|
|
110
|
+
try:
|
|
111
|
+
yield self
|
|
112
|
+
finally:
|
|
113
|
+
if self.text is text:
|
|
114
|
+
self.close()
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Close the current stream and invalidate its readers and header.
|
|
120
|
+
|
|
121
|
+
Repeated calls are safe. Stream closing errors propagate, and context
|
|
122
|
+
exit also closes the stream automatically.
|
|
123
|
+
"""
|
|
124
|
+
text: Optional[TextIOWrapper] = self.text
|
|
125
|
+
try:
|
|
126
|
+
if text is not None:
|
|
127
|
+
text.close()
|
|
128
|
+
finally:
|
|
129
|
+
self.text = None
|
|
130
|
+
self.reader = None
|
|
131
|
+
self.header_data = None
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
def header(self) -> List[str]:
|
|
135
|
+
"""
|
|
136
|
+
Consume the next unvalidated record and use it as the current header.
|
|
137
|
+
|
|
138
|
+
Repeated calls consume additional records. Raise StopIteration at EOF.
|
|
139
|
+
The returned list is a copy so callers cannot change column mapping.
|
|
140
|
+
"""
|
|
141
|
+
source: Iterator[List[str]] = self.require_reader()
|
|
142
|
+
self.header_data = next(source)
|
|
143
|
+
return self.header_data.copy()
|
|
144
|
+
|
|
145
|
+
@overload
|
|
146
|
+
def row(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
header: None = None,
|
|
150
|
+
row: Optional[RowValidator] = None,
|
|
151
|
+
cols: Optional[Union[
|
|
152
|
+
Dict[str, ColumnValidator],
|
|
153
|
+
Tuple[ColumnValidator, ...],
|
|
154
|
+
]] = None,
|
|
155
|
+
) -> List[str]:
|
|
156
|
+
...
|
|
157
|
+
|
|
158
|
+
@overload
|
|
159
|
+
def row(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
header: Sequence[str],
|
|
163
|
+
row: Optional[RowValidator] = None,
|
|
164
|
+
cols: Optional[Union[
|
|
165
|
+
Dict[str, ColumnValidator],
|
|
166
|
+
Tuple[ColumnValidator, ...],
|
|
167
|
+
]] = None,
|
|
168
|
+
) -> Dict[str, str]:
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
@overload
|
|
172
|
+
def row(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
header: Optional[Sequence[str]],
|
|
176
|
+
row: Optional[RowValidator] = None,
|
|
177
|
+
cols: Optional[Union[
|
|
178
|
+
Dict[str, ColumnValidator],
|
|
179
|
+
Tuple[ColumnValidator, ...],
|
|
180
|
+
]] = None,
|
|
181
|
+
) -> Union[List[str], Dict[str, str]]:
|
|
182
|
+
...
|
|
183
|
+
|
|
184
|
+
def row(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
header: Optional[Sequence[str]] = None,
|
|
188
|
+
row: Optional[RowValidator] = None,
|
|
189
|
+
cols: Optional[
|
|
190
|
+
Union[
|
|
191
|
+
Dict[str, ColumnValidator],
|
|
192
|
+
Tuple[ColumnValidator, ...],
|
|
193
|
+
]
|
|
194
|
+
] = None,
|
|
195
|
+
) -> Union[List[str], Dict[str, str]]:
|
|
196
|
+
"""
|
|
197
|
+
Consume and validate one record, raising StopIteration at EOF.
|
|
198
|
+
|
|
199
|
+
None uses the constructor setting. Overrides apply only to this call.
|
|
200
|
+
Validation failures propagate after the record has been consumed.
|
|
201
|
+
An explicit header maps validated values to keys in their current order.
|
|
202
|
+
It does not consume a file header or change named validator mapping.
|
|
203
|
+
"""
|
|
204
|
+
source: Iterator[List[str]] = self.require_reader()
|
|
205
|
+
output_header: Optional[Tuple[str, ...]] = self.prepare_header(header)
|
|
206
|
+
row_validator, column_validators = self.resolve_validators(row, cols)
|
|
207
|
+
indices: List[Tuple[int, ColumnValidator]] = self.column_indices(column_validators)
|
|
208
|
+
record: List[str] = next(source)
|
|
209
|
+
record = self.validate(record, row_validator, indices)
|
|
210
|
+
return self.format_record(record, output_header)
|
|
211
|
+
|
|
212
|
+
@overload
|
|
213
|
+
def rows(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
header: None = None,
|
|
217
|
+
row: Optional[RowValidator] = None,
|
|
218
|
+
cols: Optional[Union[
|
|
219
|
+
Dict[str, ColumnValidator],
|
|
220
|
+
Tuple[ColumnValidator, ...],
|
|
221
|
+
]] = None,
|
|
222
|
+
) -> Generator[List[str], None, None]:
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
@overload
|
|
226
|
+
def rows(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
header: Sequence[str],
|
|
230
|
+
row: Optional[RowValidator] = None,
|
|
231
|
+
cols: Optional[Union[
|
|
232
|
+
Dict[str, ColumnValidator],
|
|
233
|
+
Tuple[ColumnValidator, ...],
|
|
234
|
+
]] = None,
|
|
235
|
+
) -> Generator[Dict[str, str], None, None]:
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
@overload
|
|
239
|
+
def rows(
|
|
240
|
+
self,
|
|
241
|
+
*,
|
|
242
|
+
header: Optional[Sequence[str]],
|
|
243
|
+
row: Optional[RowValidator] = None,
|
|
244
|
+
cols: Optional[Union[
|
|
245
|
+
Dict[str, ColumnValidator],
|
|
246
|
+
Tuple[ColumnValidator, ...],
|
|
247
|
+
]] = None,
|
|
248
|
+
) -> Generator[Union[List[str], Dict[str, str]], None, None]:
|
|
249
|
+
...
|
|
250
|
+
|
|
251
|
+
def rows(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
header: Optional[Sequence[str]] = None,
|
|
255
|
+
row: Optional[RowValidator] = None,
|
|
256
|
+
cols: Optional[
|
|
257
|
+
Union[
|
|
258
|
+
Dict[str, ColumnValidator],
|
|
259
|
+
Tuple[ColumnValidator, ...],
|
|
260
|
+
]
|
|
261
|
+
] = None,
|
|
262
|
+
) -> Generator[Union[List[str], Dict[str, str]], None, None]:
|
|
263
|
+
"""
|
|
264
|
+
Stream remaining records without accumulating results in memory.
|
|
265
|
+
|
|
266
|
+
Overrides and header mapping are captured when this method is called.
|
|
267
|
+
The iterator must be consumed inside the open context. It cannot be
|
|
268
|
+
reused after the file is closed, reopened, or the header is changed.
|
|
269
|
+
An explicit output header is copied at call time and maps validated
|
|
270
|
+
values to keys without rearranging columns.
|
|
271
|
+
"""
|
|
272
|
+
source: Iterator[List[str]] = self.require_reader()
|
|
273
|
+
output_header: Optional[Tuple[str, ...]] = self.prepare_header(header)
|
|
274
|
+
row_validator, column_validators = self.resolve_validators(row, cols)
|
|
275
|
+
indices: List[Tuple[int, ColumnValidator]] = self.column_indices(column_validators)
|
|
276
|
+
return self.iterate(source, self.header_data, row_validator, indices, output_header)
|
|
277
|
+
|
|
278
|
+
def prepare_header(
|
|
279
|
+
self,
|
|
280
|
+
header: Optional[Sequence[str]],
|
|
281
|
+
) -> Optional[Tuple[str, ...]]:
|
|
282
|
+
if header is None:
|
|
283
|
+
return None
|
|
284
|
+
if isinstance(header, str):
|
|
285
|
+
raise TypeError('header must be a sequence of column names, not a string')
|
|
286
|
+
names: Tuple[str, ...] = tuple(header)
|
|
287
|
+
for name in names:
|
|
288
|
+
if not isinstance(name, str):
|
|
289
|
+
raise TypeError('Output column names must be strings')
|
|
290
|
+
if len(set(names)) != len(names):
|
|
291
|
+
raise ValueError('Output column names must be unique')
|
|
292
|
+
return names
|
|
293
|
+
|
|
294
|
+
def format_record(
|
|
295
|
+
self,
|
|
296
|
+
record: List[str],
|
|
297
|
+
header: Optional[Tuple[str, ...]],
|
|
298
|
+
) -> Union[List[str], Dict[str, str]]:
|
|
299
|
+
if header is None:
|
|
300
|
+
return record
|
|
301
|
+
if len(record) != len(header):
|
|
302
|
+
raise ValueError(
|
|
303
|
+
'Output header has {} columns, but the validated record has {}'.format(
|
|
304
|
+
len(header),
|
|
305
|
+
len(record),
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
return dict(zip(header, record))
|
|
309
|
+
|
|
310
|
+
def require_reader(self) -> Iterator[List[str]]:
|
|
311
|
+
if self.reader is None:
|
|
312
|
+
raise RuntimeError('Open the CSV file before reading records')
|
|
313
|
+
return self.reader
|
|
314
|
+
|
|
315
|
+
def check_validators(
|
|
316
|
+
self,
|
|
317
|
+
row: Optional[RowValidator],
|
|
318
|
+
cols: Optional[
|
|
319
|
+
Union[
|
|
320
|
+
Dict[str, ColumnValidator],
|
|
321
|
+
Tuple[ColumnValidator, ...],
|
|
322
|
+
]
|
|
323
|
+
],
|
|
324
|
+
) -> None:
|
|
325
|
+
if row is not None and not isinstance(row, RowValidator):
|
|
326
|
+
raise TypeError('row must be a row Validator or None')
|
|
327
|
+
if cols is not None:
|
|
328
|
+
if isinstance(cols, dict):
|
|
329
|
+
for name, validator in cols.items():
|
|
330
|
+
if not isinstance(name, str):
|
|
331
|
+
raise TypeError('Column names must be strings')
|
|
332
|
+
if not isinstance(validator, ColumnValidator):
|
|
333
|
+
raise TypeError('Column validators must be column Validator instances')
|
|
334
|
+
elif isinstance(cols, tuple):
|
|
335
|
+
for validator in cols:
|
|
336
|
+
if not isinstance(validator, ColumnValidator):
|
|
337
|
+
raise TypeError('Column validators must be column Validator instances')
|
|
338
|
+
else:
|
|
339
|
+
raise TypeError('cols must be a dictionary, a tuple, or None')
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
def resolve_validators(
|
|
343
|
+
self,
|
|
344
|
+
row: Optional[RowValidator],
|
|
345
|
+
cols: Optional[
|
|
346
|
+
Union[
|
|
347
|
+
Dict[str, ColumnValidator],
|
|
348
|
+
Tuple[ColumnValidator, ...],
|
|
349
|
+
]
|
|
350
|
+
],
|
|
351
|
+
) -> Tuple[
|
|
352
|
+
Optional[RowValidator], Optional[
|
|
353
|
+
Union[
|
|
354
|
+
Dict[str, ColumnValidator],
|
|
355
|
+
Tuple[ColumnValidator, ...],
|
|
356
|
+
]
|
|
357
|
+
]
|
|
358
|
+
]:
|
|
359
|
+
self.check_validators(row, cols)
|
|
360
|
+
return (
|
|
361
|
+
self.row_validator if row is None else row,
|
|
362
|
+
self.column_validators if cols is None else cols,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def column_indices(
|
|
366
|
+
self,
|
|
367
|
+
cols: Optional[
|
|
368
|
+
Union[
|
|
369
|
+
Dict[str, ColumnValidator],
|
|
370
|
+
Tuple[ColumnValidator, ...],
|
|
371
|
+
]
|
|
372
|
+
],
|
|
373
|
+
) -> List[Tuple[int, ColumnValidator]]:
|
|
374
|
+
if cols is None:
|
|
375
|
+
return []
|
|
376
|
+
if isinstance(cols, tuple):
|
|
377
|
+
return list(enumerate(cols))
|
|
378
|
+
if not cols:
|
|
379
|
+
return []
|
|
380
|
+
if self.header_data is None:
|
|
381
|
+
raise RuntimeError('Call header() before using named column validators')
|
|
382
|
+
indices: List[Tuple[int, ColumnValidator]] = []
|
|
383
|
+
for name, validator in cols.items():
|
|
384
|
+
count: int = self.header_data.count(name)
|
|
385
|
+
if count != 1:
|
|
386
|
+
raise ValueError(
|
|
387
|
+
'Column {!r} must occur exactly once in the header; found {}'.format(
|
|
388
|
+
name,
|
|
389
|
+
count,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
indices.append((self.header_data.index(name), validator))
|
|
393
|
+
return indices
|
|
394
|
+
|
|
395
|
+
def validate(
|
|
396
|
+
self,
|
|
397
|
+
record: List[str],
|
|
398
|
+
row: Optional[RowValidator],
|
|
399
|
+
indices: List[Tuple[int, ColumnValidator]],
|
|
400
|
+
) -> List[str]:
|
|
401
|
+
if row is not None:
|
|
402
|
+
record = row(record)
|
|
403
|
+
for index, validator in indices:
|
|
404
|
+
if index >= len(record):
|
|
405
|
+
raise ValueError(
|
|
406
|
+
'Column index {} is missing from a record with {} columns'.format(
|
|
407
|
+
index,
|
|
408
|
+
len(record),
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
record[index] = validator(record[index])
|
|
412
|
+
return record
|
|
413
|
+
|
|
414
|
+
def iterate(
|
|
415
|
+
self,
|
|
416
|
+
source: Iterator[List[str]],
|
|
417
|
+
header: Optional[List[str]],
|
|
418
|
+
row: Optional[RowValidator],
|
|
419
|
+
indices: List[Tuple[int, ColumnValidator]],
|
|
420
|
+
output_header: Optional[Tuple[str, ...]],
|
|
421
|
+
) -> Generator[Union[List[str], Dict[str, str]], None, None]:
|
|
422
|
+
while True:
|
|
423
|
+
if self.reader is not source or self.header_data is not header:
|
|
424
|
+
raise RuntimeError('The CSV context or header has changed')
|
|
425
|
+
try:
|
|
426
|
+
record: List[str] = next(source)
|
|
427
|
+
except StopIteration:
|
|
428
|
+
return
|
|
429
|
+
record = self.validate(record, row, indices)
|
|
430
|
+
yield self.format_record(record, output_header)
|
csvd2c/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from codecs import getincrementaldecoder
|
|
4
|
+
from .CSV import CSV
|
|
5
|
+
|
|
6
|
+
__all__ = (
|
|
7
|
+
'detect_encoding',
|
|
8
|
+
'CSV',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def detect_encoding(
|
|
13
|
+
path: str,
|
|
14
|
+
*args: str,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Return the first encoding that decodes the initial sample of up to 1MiB.
|
|
18
|
+
|
|
19
|
+
Check candidates in order without automatic BOM-based selection.
|
|
20
|
+
Raise ValueError if no candidates are provided, the file is empty, or all
|
|
21
|
+
candidates fail. Propagate LookupError for unknown encodings and file
|
|
22
|
+
access errors unchanged. Success does not guarantee the actual encoding
|
|
23
|
+
or the validity of the entire file.
|
|
24
|
+
"""
|
|
25
|
+
if not args:
|
|
26
|
+
raise ValueError('At least one encoding candidate is required')
|
|
27
|
+
|
|
28
|
+
for encoding in args:
|
|
29
|
+
getincrementaldecoder(encoding)
|
|
30
|
+
|
|
31
|
+
sample_size = 1024 * 1024
|
|
32
|
+
with open(path, 'rb') as binary:
|
|
33
|
+
raw: bytes = binary.read(sample_size + 1)
|
|
34
|
+
|
|
35
|
+
if not raw:
|
|
36
|
+
raise ValueError('Cannot detect the encoding of an empty file')
|
|
37
|
+
|
|
38
|
+
sample: bytes = raw[:sample_size]
|
|
39
|
+
is_end: bool = len(raw) <= sample_size
|
|
40
|
+
failures: list[str] = []
|
|
41
|
+
for encoding in args:
|
|
42
|
+
decoder = getincrementaldecoder(encoding)(errors='strict')
|
|
43
|
+
try:
|
|
44
|
+
# Reject an incomplete trailing character only at the actual end of the file.
|
|
45
|
+
decoder.decode(sample, final=is_end)
|
|
46
|
+
except UnicodeDecodeError as error:
|
|
47
|
+
failures.append(
|
|
48
|
+
'{}: byte {}, {}'.format(
|
|
49
|
+
encoding,
|
|
50
|
+
error.start,
|
|
51
|
+
error.reason,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
return encoding
|
|
56
|
+
|
|
57
|
+
raise ValueError(
|
|
58
|
+
'Sample decoding failed for all encoding candidates: {}'.format('; '.join(failures))
|
|
59
|
+
)
|
csvd2c/column/Error.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from abc import (
|
|
4
|
+
ABC,
|
|
5
|
+
abstractmethod,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Error(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Handle rejected column data by returning a replacement or raising an exception.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def __call__(self, col: str) -> str:
|
|
16
|
+
raise NotImplementedError('Subclasses must implement this method')
|
csvd2c/column/Pattern.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from abc import (
|
|
4
|
+
ABC,
|
|
5
|
+
abstractmethod,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
'Pattern',
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Pattern(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Pattern Interface Class for Column Validator
|
|
16
|
+
|
|
17
|
+
Sample:
|
|
18
|
+
from operator import eq
|
|
19
|
+
|
|
20
|
+
class SamplePattern(Pattern):
|
|
21
|
+
def __init__(self, compare):
|
|
22
|
+
super(SamplePattern, self).__init__()
|
|
23
|
+
self.compare = compare
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
def __call__(self, col: str) -> str:
|
|
27
|
+
if not eq(col, self.compare):
|
|
28
|
+
raise ValueError('{} is not equal to {}'.format(col, self.compare))
|
|
29
|
+
return col # if true, return parameter
|
|
30
|
+
"""
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def __call__(self, col: str) -> str:
|
|
33
|
+
raise NotImplementedError('Subclasses must implement this method')
|
|
34
|
+
|
|
35
|
+
def __repr__(self):
|
|
36
|
+
return '{}()'.format(self.__class__.__name__)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from .Pattern import Pattern
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
|
|
6
|
+
__all__ = (
|
|
7
|
+
'Validator',
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Validator(object):
|
|
12
|
+
"""
|
|
13
|
+
Validator Class for Column Data
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self, *args: Pattern) -> None:
|
|
16
|
+
for pattern in args:
|
|
17
|
+
if not isinstance(pattern, Pattern):
|
|
18
|
+
raise RuntimeError('{} must be based {}'.format(pattern, Pattern))
|
|
19
|
+
self.patterns: Tuple[Pattern, ...] = args
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
def __call__(self, col: str) -> str:
|
|
23
|
+
for pattern in self.patterns:
|
|
24
|
+
col = pattern(col)
|
|
25
|
+
return col
|
|
26
|
+
|
|
27
|
+
def __repr__(self):
|
|
28
|
+
return 'Validator({})'.format(
|
|
29
|
+
', '.join([repr(p) for p in self.patterns])
|
|
30
|
+
)
|
|
31
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from ..Pattern import Pattern
|
|
5
|
+
from ..Error import Error
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Choices(Pattern):
|
|
9
|
+
"""
|
|
10
|
+
Require exact membership in the configured strings.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *values: str, error: Optional[Error] = None) -> None:
|
|
14
|
+
if any(not isinstance(value, str) for value in values):
|
|
15
|
+
raise TypeError('Choices must be strings')
|
|
16
|
+
if error is not None and not isinstance(error, Error):
|
|
17
|
+
raise TypeError('error must be a column Error instance or None')
|
|
18
|
+
self.values = frozenset(values)
|
|
19
|
+
self.error = error
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
def __call__(self, col: str) -> str:
|
|
23
|
+
if col not in self.values:
|
|
24
|
+
if self.error is not None:
|
|
25
|
+
return self.error(col)
|
|
26
|
+
raise ValueError('Value is not an allowed choice')
|
|
27
|
+
return col
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from re import compile
|
|
4
|
+
from ..Pattern import Pattern
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CollapseWhitespace(Pattern):
|
|
8
|
+
"""
|
|
9
|
+
Replace each Unicode whitespace run with one space without trimming.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.expression = compile(r'\s+')
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
def __call__(self, col: str) -> str:
|
|
17
|
+
return self.expression.sub(' ', col)
|