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.
Files changed (58) hide show
  1. csvd2c/CSV.py +430 -0
  2. csvd2c/__init__.py +59 -0
  3. csvd2c/column/Error.py +16 -0
  4. csvd2c/column/Pattern.py +36 -0
  5. csvd2c/column/Validator.py +31 -0
  6. csvd2c/column/__init__.py +11 -0
  7. csvd2c/column/patterns/CaseFold.py +12 -0
  8. csvd2c/column/patterns/Choices.py +27 -0
  9. csvd2c/column/patterns/CollapseWhitespace.py +17 -0
  10. csvd2c/column/patterns/Currency.py +95 -0
  11. csvd2c/column/patterns/CurrencyData.py +3557 -0
  12. csvd2c/column/patterns/Date.py +72 -0
  13. csvd2c/column/patterns/Default.py +18 -0
  14. csvd2c/column/patterns/IfEmpty.py +19 -0
  15. csvd2c/column/patterns/IfMatch.py +26 -0
  16. csvd2c/column/patterns/IfUndecodable.py +23 -0
  17. csvd2c/column/patterns/Length.py +38 -0
  18. csvd2c/column/patterns/Lower.py +12 -0
  19. csvd2c/column/patterns/Map.py +38 -0
  20. csvd2c/column/patterns/Number.py +67 -0
  21. csvd2c/column/patterns/Range.py +69 -0
  22. csvd2c/column/patterns/Recode.py +46 -0
  23. csvd2c/column/patterns/RecoverEncoding.py +48 -0
  24. csvd2c/column/patterns/Regex.py +34 -0
  25. csvd2c/column/patterns/RegexReplace.py +32 -0
  26. csvd2c/column/patterns/RejectCharacters.py +32 -0
  27. csvd2c/column/patterns/RejectReplacementCharacter.py +24 -0
  28. csvd2c/column/patterns/RejectSurrogates.py +24 -0
  29. csvd2c/column/patterns/Replace.py +27 -0
  30. csvd2c/column/patterns/Required.py +24 -0
  31. csvd2c/column/patterns/Strip.py +19 -0
  32. csvd2c/column/patterns/TryRecoverEncoding.py +86 -0
  33. csvd2c/column/patterns/UnicodeNormalize.py +19 -0
  34. csvd2c/column/patterns/Upper.py +12 -0
  35. csvd2c/column/patterns/__init__.py +65 -0
  36. csvd2c/row/Error.py +17 -0
  37. csvd2c/row/Pattern.py +37 -0
  38. csvd2c/row/Validator.py +31 -0
  39. csvd2c/row/__init__.py +11 -0
  40. csvd2c/row/patterns/IfLength.py +45 -0
  41. csvd2c/row/patterns/Insert.py +31 -0
  42. csvd2c/row/patterns/Length.py +37 -0
  43. csvd2c/row/patterns/Merge.py +46 -0
  44. csvd2c/row/patterns/Pad.py +33 -0
  45. csvd2c/row/patterns/RejectEmpty.py +29 -0
  46. csvd2c/row/patterns/RejectHeader.py +36 -0
  47. csvd2c/row/patterns/Remove.py +35 -0
  48. csvd2c/row/patterns/Reorder.py +51 -0
  49. csvd2c/row/patterns/Replace.py +41 -0
  50. csvd2c/row/patterns/Slice.py +43 -0
  51. csvd2c/row/patterns/Split.py +44 -0
  52. csvd2c/row/patterns/Truncate.py +26 -0
  53. csvd2c/row/patterns/__init__.py +31 -0
  54. csvd2c-0.1.0.dist-info/METADATA +96 -0
  55. csvd2c-0.1.0.dist-info/RECORD +58 -0
  56. csvd2c-0.1.0.dist-info/WHEEL +6 -0
  57. csvd2c-0.1.0.dist-info/licenses/LICENSE +674 -0
  58. 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')
@@ -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,11 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from .Pattern import Pattern
4
+ from .Validator import Validator
5
+ from .Error import Error
6
+
7
+ __all__ = (
8
+ 'Pattern',
9
+ 'Validator',
10
+ 'Error',
11
+ )
@@ -0,0 +1,12 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from ..Pattern import Pattern
4
+
5
+
6
+ class CaseFold(Pattern):
7
+ """
8
+ Apply Unicode case folding for caseless matching.
9
+ """
10
+
11
+ def __call__(self, col: str) -> str:
12
+ return col.casefold()
@@ -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)