python-dwca-reader 0.16.4__py3-none-any.whl → 0.17.0__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.
- dwca/descriptors.py +239 -3
- dwca/files.py +381 -55
- dwca/read.py +65 -17
- dwca/rows.py +105 -78
- dwca/star_record.py +1 -2
- dwca/test/archive_builder.py +188 -0
- dwca/test/test_characterization.py +617 -0
- dwca/test/test_datafile.py +286 -3
- dwca/test/test_descriptors.py +331 -0
- dwca/test/test_dwcareader.py +74 -12
- dwca/test/test_rows.py +173 -0
- dwca/version.py +1 -1
- {python_dwca_reader-0.16.4.dist-info → python_dwca_reader-0.17.0.dist-info}/METADATA +4 -2
- python_dwca_reader-0.17.0.dist-info/RECORD +30 -0
- python_dwca_reader-0.16.4.dist-info/RECORD +0 -28
- {python_dwca_reader-0.16.4.dist-info → python_dwca_reader-0.17.0.dist-info}/AUTHORS +0 -0
- {python_dwca_reader-0.16.4.dist-info → python_dwca_reader-0.17.0.dist-info}/LICENSE.txt +0 -0
- {python_dwca_reader-0.16.4.dist-info → python_dwca_reader-0.17.0.dist-info}/WHEEL +0 -0
- {python_dwca_reader-0.16.4.dist-info → python_dwca_reader-0.17.0.dist-info}/top_level.txt +0 -0
dwca/files.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""File-related classes and functions."""
|
|
2
2
|
|
|
3
|
+
import csv
|
|
3
4
|
import io
|
|
4
5
|
import os
|
|
5
6
|
from array import array
|
|
6
|
-
from
|
|
7
|
+
from itertools import islice
|
|
8
|
+
from typing import Iterator, List, Tuple, Union, IO, Dict, Optional
|
|
7
9
|
|
|
8
10
|
from dwca.descriptors import DataFileDescriptor
|
|
9
11
|
from dwca.rows import CoreRow, ExtensionRow, Row
|
|
@@ -25,11 +27,11 @@ class CSVDataFile(object):
|
|
|
25
27
|
* For an extension data file, with :meth:`get_all_rows_by_coreid` (A :class:`dwca.rows.CoreRow` or \
|
|
26
28
|
:class:`dwca.rows.ExtensionRow` object is returned)
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
An index of line offsets is built on first random access (:meth:`get_row_by_position` or\
|
|
31
|
+
:meth:`get_all_rows_by_coreid`). This may take time for large files, but makes further random\
|
|
32
|
+
access faster.
|
|
30
33
|
"""
|
|
31
34
|
|
|
32
|
-
# TODO: More tests for this class
|
|
33
35
|
def __init__(
|
|
34
36
|
self, work_directory: str, file_descriptor: DataFileDescriptor
|
|
35
37
|
) -> None:
|
|
@@ -38,32 +40,142 @@ class CSVDataFile(object):
|
|
|
38
40
|
#: constructor.
|
|
39
41
|
self.file_descriptor = file_descriptor # type: DataFileDescriptor
|
|
40
42
|
|
|
41
|
-
self.
|
|
42
|
-
|
|
43
|
-
mode="r",
|
|
44
|
-
encoding=self.file_descriptor.file_encoding,
|
|
45
|
-
newline=self.file_descriptor.lines_terminated_by,
|
|
46
|
-
errors="replace",
|
|
43
|
+
self._file_path = os.path.join(
|
|
44
|
+
work_directory, self.file_descriptor.file_location
|
|
47
45
|
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# that will make random access faster later on...
|
|
51
|
-
self._line_offsets = _get_all_line_offsets(
|
|
52
|
-
self._file_stream, self.file_descriptor.file_encoding
|
|
46
|
+
self._file_stream = self._open_stream(
|
|
47
|
+
newline=self.file_descriptor.lines_terminated_by
|
|
53
48
|
)
|
|
54
49
|
|
|
50
|
+
# Opened lazily, on first random-access read: a dedicated binary stream used to
|
|
51
|
+
# fetch the exact byte range of a record (see _read_record).
|
|
52
|
+
self._binary_stream = None # type: Optional[IO]
|
|
53
|
+
|
|
54
|
+
# The index of line offsets is only needed for random access, so it is built on
|
|
55
|
+
# first use rather than here: opening an archive stays O(1) whatever its size.
|
|
56
|
+
self._line_offsets = None # type: Optional[array]
|
|
57
|
+
|
|
55
58
|
#: Number of lines to ignore (header lines) in the CSV file.
|
|
56
59
|
self.lines_to_ignore = self.file_descriptor.lines_to_ignore # type: int
|
|
57
60
|
|
|
58
61
|
self._coreid_index = None # type: Optional[Dict[str, List[int]]]
|
|
59
62
|
|
|
63
|
+
def iter_terms(self, terms: List[str]) -> Iterator[Tuple[str, ...]]:
|
|
64
|
+
"""Yield one tuple of values per data row, holding `terms` in the order given.
|
|
65
|
+
|
|
66
|
+
This is a faster alternative to iterating over rows for consumers that only need a
|
|
67
|
+
few of the file's columns: neither a :class:`dwca.rows.Row` object nor its term to
|
|
68
|
+
value dict is built. A single term still yields a one-element tuple.
|
|
69
|
+
|
|
70
|
+
Usage::
|
|
71
|
+
|
|
72
|
+
for locality, latitude in data_file.iter_terms([qn('locality'),
|
|
73
|
+
qn('decimalLatitude')]):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
The special names "id" and "coreid" request the file's key column - the same names
|
|
77
|
+
used by :attr:`dwca.descriptors.DataFileDescriptor.headers`. "id" resolves for a core
|
|
78
|
+
file, "coreid" for an extension file; the other one raises, as does either name when
|
|
79
|
+
the file has no such column (metafile-less archives have neither). If the file
|
|
80
|
+
declares an actual term of the same name, that declared term takes precedence, which
|
|
81
|
+
matches what `Row.data['id']` already returns.
|
|
82
|
+
|
|
83
|
+
:param terms: a list of full term identifiers.
|
|
84
|
+
:raises ValueError: if any of `terms` is not present in this data file.
|
|
85
|
+
"""
|
|
86
|
+
# The getter is resolved eagerly so an unknown term is reported by this call rather
|
|
87
|
+
# than on first iteration.
|
|
88
|
+
getter = self.file_descriptor.field_plan.term_getter(terms)
|
|
89
|
+
|
|
90
|
+
return self._iter_terms(getter)
|
|
91
|
+
|
|
92
|
+
def _iter_terms(self, getter) -> Iterator[tuple]:
|
|
93
|
+
for fields in self._iter_field_lists():
|
|
94
|
+
yield getter(fields)
|
|
95
|
+
|
|
60
96
|
def __str__(self) -> str:
|
|
61
97
|
return self.file_descriptor.file_location
|
|
62
98
|
|
|
99
|
+
def _open_stream(self, newline: str) -> IO:
|
|
100
|
+
return io.open(
|
|
101
|
+
self._file_path,
|
|
102
|
+
mode="r",
|
|
103
|
+
encoding=self.file_descriptor.file_encoding,
|
|
104
|
+
newline=newline,
|
|
105
|
+
errors="replace",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _get_line_offsets(self) -> array:
|
|
109
|
+
if self._line_offsets is None:
|
|
110
|
+
descriptor = self.file_descriptor
|
|
111
|
+
self._line_offsets = _build_line_offsets(
|
|
112
|
+
self._file_path,
|
|
113
|
+
descriptor.file_encoding,
|
|
114
|
+
descriptor.lines_terminated_by,
|
|
115
|
+
descriptor.fields_enclosed_by,
|
|
116
|
+
descriptor.fields_terminated_by,
|
|
117
|
+
)
|
|
118
|
+
return self._line_offsets
|
|
119
|
+
|
|
120
|
+
def _iter_field_lists(self) -> Iterator[List[str]]:
|
|
121
|
+
"""Yield each data row of the file as a list of raw field values.
|
|
122
|
+
|
|
123
|
+
Header lines are skipped. This is a single forward pass over a dedicated stream, so
|
|
124
|
+
it is safe to run several of these concurrently and alongside random access.
|
|
125
|
+
"""
|
|
126
|
+
if self._file_stream.closed:
|
|
127
|
+
raise ValueError("The data file has been closed.")
|
|
128
|
+
|
|
129
|
+
descriptor = self.file_descriptor
|
|
130
|
+
quoted = descriptor.fields_enclosed_by != ""
|
|
131
|
+
|
|
132
|
+
# The csv module refuses a stream that can hand it an embedded carriage return, so
|
|
133
|
+
# the quoted path has to let Python handle newlines. The unquoted path keeps the
|
|
134
|
+
# archive's own terminator, which is what stops U+0085 from being treated as a line
|
|
135
|
+
# break (issue #20).
|
|
136
|
+
stream = self._open_stream(
|
|
137
|
+
newline="" if quoted else descriptor.lines_terminated_by
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
if quoted:
|
|
141
|
+
source = csv.reader(
|
|
142
|
+
stream,
|
|
143
|
+
delimiter=descriptor.fields_terminated_by,
|
|
144
|
+
quotechar=descriptor.fields_enclosed_by,
|
|
145
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
146
|
+
) # type: Iterator[List[str]]
|
|
147
|
+
else:
|
|
148
|
+
# A carriage return is stripped alongside the declared terminator: archives
|
|
149
|
+
# routinely declare "\n" while the file itself has CRLF line endings, and the
|
|
150
|
+
# csv module this replaced dropped the stray CR for us. Without this, the last
|
|
151
|
+
# field of every row would keep it.
|
|
152
|
+
line_ending = descriptor.lines_terminated_by + "\r"
|
|
153
|
+
separator = descriptor.fields_terminated_by
|
|
154
|
+
source = (line.rstrip(line_ending).split(separator) for line in stream)
|
|
155
|
+
|
|
156
|
+
for fields in islice(source, self.lines_to_ignore, None):
|
|
157
|
+
yield fields
|
|
158
|
+
finally:
|
|
159
|
+
stream.close()
|
|
160
|
+
|
|
161
|
+
def iter_rows(self) -> Iterator[Union[CoreRow, ExtensionRow]]:
|
|
162
|
+
"""Yield every data row of the file, in order of appearance.
|
|
163
|
+
|
|
164
|
+
Unlike repeated :meth:`get_row_by_position` calls this is a single forward pass and
|
|
165
|
+
never touches the line offset index.
|
|
166
|
+
"""
|
|
167
|
+
descriptor = self.file_descriptor
|
|
168
|
+
row_class = CoreRow if descriptor.represents_corefile else ExtensionRow
|
|
169
|
+
|
|
170
|
+
for position, fields in enumerate(self._iter_field_lists()):
|
|
171
|
+
yield row_class.from_fields(fields, position, descriptor)
|
|
172
|
+
|
|
63
173
|
def _position_file_after_header(self) -> None:
|
|
64
174
|
self._file_stream.seek(0, 0)
|
|
65
|
-
|
|
66
|
-
|
|
175
|
+
# NOTE: readlines() takes a byte-size hint, not a line count, so it cannot be used
|
|
176
|
+
# here. With ignoreHeaderLines="2" it would read a single line.
|
|
177
|
+
for _ in range(self.lines_to_ignore):
|
|
178
|
+
self._file_stream.readline()
|
|
67
179
|
|
|
68
180
|
def __iter__(self) -> "CSVDataFile":
|
|
69
181
|
self._position_file_after_header()
|
|
@@ -73,6 +185,12 @@ class CSVDataFile(object):
|
|
|
73
185
|
return self.next()
|
|
74
186
|
|
|
75
187
|
def next(self) -> str: # NOQA
|
|
188
|
+
"""Return the next raw line of the data file, including its line terminator.
|
|
189
|
+
|
|
190
|
+
Header lines are skipped. Raises `StopIteration` once the file is exhausted. This is
|
|
191
|
+
the iterator protocol behind `for line in data_file`, described in the class
|
|
192
|
+
docstring.
|
|
193
|
+
"""
|
|
76
194
|
for line in self._file_stream:
|
|
77
195
|
return line
|
|
78
196
|
|
|
@@ -108,20 +226,22 @@ class CSVDataFile(object):
|
|
|
108
226
|
"""Build and return an index of Core Rows IDs suitable for `CSVDataFile.coreid_index`."""
|
|
109
227
|
index = {} # type: Dict[str, array[int]]
|
|
110
228
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
else:
|
|
116
|
-
tmp = ExtensionRow(row, position, self.file_descriptor)
|
|
117
|
-
index.setdefault(tmp.core_id, array("L")).append(position)
|
|
229
|
+
represents_corefile = self.file_descriptor.represents_corefile
|
|
230
|
+
for row in self.iter_rows():
|
|
231
|
+
key = row.id if represents_corefile else row.core_id
|
|
232
|
+
index.setdefault(key, array("L")).append(row.position)
|
|
118
233
|
|
|
119
234
|
return index
|
|
120
235
|
|
|
121
|
-
# TODO: For ExtensionRow and a specific field only, generalize ?
|
|
122
|
-
# TODO: What happens if called on a Core Row?
|
|
123
236
|
def get_all_rows_by_coreid(self, core_id: int) -> List[Row]:
|
|
124
|
-
"""Return
|
|
237
|
+
"""Return the rows whose linking id matches `core_id`.
|
|
238
|
+
|
|
239
|
+
For an extension file, this is the row's `coreid` field. For a core file, it is the
|
|
240
|
+
row's own `id`, since `coreid_index` then maps each row's id to its position. The
|
|
241
|
+
return type is `List[Row]` to cover both :class:`dwca.rows.CoreRow` (core file) and
|
|
242
|
+
:class:`dwca.rows.ExtensionRow` (extension file). Returns an empty list if `core_id`
|
|
243
|
+
is not found.
|
|
244
|
+
"""
|
|
125
245
|
if core_id not in self.coreid_index:
|
|
126
246
|
return []
|
|
127
247
|
|
|
@@ -143,8 +263,48 @@ class CSVDataFile(object):
|
|
|
143
263
|
|
|
144
264
|
# Raises IndexError if position is incorrect
|
|
145
265
|
def _get_line_by_position(self, position: int) -> str:
|
|
146
|
-
self._file_stream.
|
|
147
|
-
|
|
266
|
+
if self._file_stream.closed:
|
|
267
|
+
raise ValueError("The data file has been closed.")
|
|
268
|
+
|
|
269
|
+
offsets = self._get_line_offsets()
|
|
270
|
+
index = position + self.lines_to_ignore
|
|
271
|
+
return self._read_record(offsets, index)
|
|
272
|
+
|
|
273
|
+
def _get_binary_stream(self) -> IO:
|
|
274
|
+
# A dedicated handle, separate from self._file_stream: random access reads the
|
|
275
|
+
# exact byte range of a record (see _read_record), and must not disturb the
|
|
276
|
+
# position of the text stream used for sequential iteration.
|
|
277
|
+
if self._binary_stream is None:
|
|
278
|
+
self._binary_stream = io.open(self._file_path, mode="rb")
|
|
279
|
+
return self._binary_stream
|
|
280
|
+
|
|
281
|
+
def _read_record(self, offsets: array, index: int) -> str:
|
|
282
|
+
"""Return the full text of the record starting at offsets[index].
|
|
283
|
+
|
|
284
|
+
offsets holds byte offsets, computed by scanning the file as bytes (see
|
|
285
|
+
_build_line_offsets). A text stream's read(n) counts characters, not bytes, so
|
|
286
|
+
turning a byte delta into a character count would silently truncate or over-read
|
|
287
|
+
wherever a character takes more than one byte in the file's encoding (e.g.
|
|
288
|
+
multi-byte UTF-8). Reading the exact byte range through a dedicated binary stream
|
|
289
|
+
and decoding it afterwards keeps this correct regardless of encoding, and also
|
|
290
|
+
regardless of how many physical lines the record spans (a quoted field may
|
|
291
|
+
legally contain the line terminator).
|
|
292
|
+
|
|
293
|
+
`index` supports the same negative-indexing quirk as a plain list/array
|
|
294
|
+
(get_row_by_position(-1) is pinned by TestNegativePosition), so `offsets[index]`
|
|
295
|
+
is used rather than any manual wraparound arithmetic.
|
|
296
|
+
"""
|
|
297
|
+
start = offsets[index]
|
|
298
|
+
stream = self._get_binary_stream()
|
|
299
|
+
stream.seek(start, 0)
|
|
300
|
+
|
|
301
|
+
following = index + 1
|
|
302
|
+
if following < len(offsets) and offsets[following] > start:
|
|
303
|
+
raw = stream.read(offsets[following] - start)
|
|
304
|
+
else:
|
|
305
|
+
raw = stream.read()
|
|
306
|
+
|
|
307
|
+
return raw.decode(self.file_descriptor.file_encoding, errors="replace")
|
|
148
308
|
|
|
149
309
|
def close(self) -> None:
|
|
150
310
|
"""Close the file.
|
|
@@ -152,30 +312,196 @@ class CSVDataFile(object):
|
|
|
152
312
|
The content of the file will not be accessible in any way afterwards.
|
|
153
313
|
"""
|
|
154
314
|
self._file_stream.close()
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
315
|
+
if self._binary_stream is not None:
|
|
316
|
+
self._binary_stream.close()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _build_line_offsets(
|
|
320
|
+
path: str,
|
|
321
|
+
encoding: str,
|
|
322
|
+
lines_terminated_by: str,
|
|
323
|
+
fields_enclosed_by: str = "",
|
|
324
|
+
fields_terminated_by: str = "\t",
|
|
325
|
+
chunk_size: int = 1024 * 1024,
|
|
326
|
+
) -> array:
|
|
327
|
+
"""Return an array of the byte offset of every CSV record in the file at `path`.
|
|
328
|
+
|
|
329
|
+
The values are suitable for seek() on a stream opened with the same encoding.
|
|
330
|
+
|
|
331
|
+
Without an enclosure character every terminator ends a record, so this is a plain scan.
|
|
332
|
+
With one, a terminator inside an enclosed field is data rather than a record boundary
|
|
333
|
+
(a quoted field may legally contain the line terminator), so the scan tracks whether it
|
|
334
|
+
is inside an enclosure.
|
|
335
|
+
|
|
336
|
+
The file is scanned as bytes rather than as decoded text: decoding with
|
|
337
|
+
errors="replace" turns an undecodable byte into U+FFFD, which does not re-encode to the
|
|
338
|
+
same length, so computing offsets from decoded text desynchronises the index from the
|
|
339
|
+
file.
|
|
340
|
+
|
|
341
|
+
An array of Longs is used instead of a list. It is much more memory efficient, and a few
|
|
342
|
+
tests with 1-4Gb uncompressed archives didn't show any significant slowdown.
|
|
166
343
|
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
344
|
+
terminator = lines_terminated_by.encode(encoding)
|
|
345
|
+
tlen = len(terminator)
|
|
346
|
+
|
|
347
|
+
if not fields_enclosed_by:
|
|
348
|
+
return _scan_plain(path, terminator, tlen, chunk_size)
|
|
349
|
+
|
|
350
|
+
quote = fields_enclosed_by.encode(encoding)
|
|
351
|
+
|
|
352
|
+
# Fast screen: if no physical line ends with an unbalanced number of quote characters,
|
|
353
|
+
# then no record spans a line break and the plain scan is already correct. This is the
|
|
354
|
+
# overwhelmingly common case - including files where EVERY field is quoted - and it costs
|
|
355
|
+
# one C-level count() per line instead of a Python step per quote character.
|
|
356
|
+
offsets = _scan_screened(path, terminator, tlen, quote, chunk_size)
|
|
357
|
+
if offsets is not None:
|
|
358
|
+
return offsets
|
|
359
|
+
|
|
360
|
+
return _scan_enclosed(
|
|
361
|
+
path,
|
|
362
|
+
terminator,
|
|
363
|
+
tlen,
|
|
364
|
+
quote,
|
|
365
|
+
fields_terminated_by.encode(encoding),
|
|
366
|
+
chunk_size,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _scan_plain(path, terminator, tlen, chunk_size):
|
|
371
|
+
offsets = array("L", [0])
|
|
372
|
+
buffer = b""
|
|
373
|
+
base = 0
|
|
374
|
+
with io.open(path, "rb") as f:
|
|
375
|
+
while True:
|
|
376
|
+
chunk = f.read(chunk_size)
|
|
377
|
+
if not chunk:
|
|
378
|
+
break
|
|
379
|
+
buffer += chunk
|
|
380
|
+
consumed = 0
|
|
381
|
+
while True:
|
|
382
|
+
found = buffer.find(terminator, consumed)
|
|
383
|
+
if found == -1:
|
|
384
|
+
break
|
|
385
|
+
consumed = found + tlen
|
|
386
|
+
offsets.append(base + consumed)
|
|
387
|
+
base += consumed
|
|
388
|
+
buffer = buffer[consumed:]
|
|
389
|
+
_trim(offsets, path)
|
|
390
|
+
return offsets
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _scan_screened(path, terminator, tlen, quote, chunk_size):
|
|
394
|
+
"""Return record offsets if every physical line has balanced quotes, else None."""
|
|
395
|
+
offsets = array("L", [0])
|
|
396
|
+
buffer = b""
|
|
397
|
+
base = 0
|
|
398
|
+
line_start = 0 # index within buffer of the current line's first byte
|
|
399
|
+
with io.open(path, "rb") as f:
|
|
400
|
+
while True:
|
|
401
|
+
chunk = f.read(chunk_size)
|
|
402
|
+
if not chunk:
|
|
403
|
+
break
|
|
404
|
+
buffer += chunk
|
|
405
|
+
consumed = 0
|
|
406
|
+
while True:
|
|
407
|
+
found = buffer.find(terminator, consumed)
|
|
408
|
+
if found == -1:
|
|
409
|
+
break
|
|
410
|
+
if buffer.count(quote, line_start, found) % 2:
|
|
411
|
+
return None # a quoted field spans this line break; needs the exact scan
|
|
412
|
+
consumed = found + tlen
|
|
413
|
+
line_start = consumed
|
|
414
|
+
offsets.append(base + consumed)
|
|
415
|
+
base += consumed
|
|
416
|
+
buffer = buffer[consumed:]
|
|
417
|
+
line_start = 0
|
|
418
|
+
_trim(offsets, path)
|
|
419
|
+
return offsets
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _scan_enclosed(path, terminator, tlen, quote, delimiter, chunk_size):
|
|
423
|
+
qlen = len(quote)
|
|
424
|
+
dlen = len(delimiter)
|
|
425
|
+
# A quote opens a field only at the very start of a record or immediately after a
|
|
426
|
+
# delimiter; anywhere else it is literal content, which is what csv.reader does under
|
|
427
|
+
# QUOTE_MINIMAL. Deciding that needs to look back at the bytes before the quote, and
|
|
428
|
+
# deciding whether a quote is escaped needs to look ahead, so the buffer keeps a margin
|
|
429
|
+
# of context on both sides of the cursor rather than being trimmed flush to it.
|
|
430
|
+
margin = max(dlen, tlen, 2 * qlen)
|
|
431
|
+
|
|
432
|
+
offsets = array("L", [0])
|
|
433
|
+
buffer = b""
|
|
434
|
+
base = 0 # absolute offset of buffer[0]
|
|
435
|
+
pos = 0 # cursor within buffer
|
|
436
|
+
in_quotes = False
|
|
437
|
+
record_start = 0 # absolute offset of the current record
|
|
438
|
+
|
|
439
|
+
with io.open(path, "rb") as f:
|
|
440
|
+
while True:
|
|
441
|
+
chunk = f.read(chunk_size)
|
|
442
|
+
eof = not chunk
|
|
443
|
+
if not eof:
|
|
444
|
+
buffer += chunk
|
|
445
|
+
|
|
446
|
+
# Without more bytes coming, patterns can be resolved right up to the end.
|
|
447
|
+
limit = len(buffer) if eof else len(buffer) - margin
|
|
448
|
+
|
|
449
|
+
while pos < len(buffer):
|
|
450
|
+
if in_quotes:
|
|
451
|
+
found = buffer.find(quote, pos)
|
|
452
|
+
if found == -1 or (not eof and found > limit):
|
|
453
|
+
pos = max(pos, limit)
|
|
454
|
+
break
|
|
455
|
+
if not eof and found + 2 * qlen > len(buffer):
|
|
456
|
+
break # cannot yet tell an escaped quote from a closing one
|
|
457
|
+
if buffer.startswith(quote, found + qlen):
|
|
458
|
+
pos = found + 2 * qlen
|
|
459
|
+
continue
|
|
460
|
+
in_quotes = False
|
|
461
|
+
pos = found + qlen
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
nq = buffer.find(quote, pos)
|
|
465
|
+
nt = buffer.find(terminator, pos)
|
|
466
|
+
if nt == -1 and nq == -1:
|
|
467
|
+
pos = max(pos, limit)
|
|
468
|
+
break
|
|
469
|
+
if not eof and min(x for x in (nq, nt) if x != -1) > limit:
|
|
470
|
+
pos = max(pos, limit)
|
|
471
|
+
break
|
|
472
|
+
if nt != -1 and (nq == -1 or nt < nq):
|
|
473
|
+
pos = nt + tlen
|
|
474
|
+
offsets.append(base + pos)
|
|
475
|
+
record_start = base + pos
|
|
476
|
+
continue
|
|
477
|
+
if base + nq == record_start:
|
|
478
|
+
opens = True
|
|
479
|
+
else:
|
|
480
|
+
opens = (
|
|
481
|
+
(nq >= dlen and buffer.startswith(delimiter, nq - dlen))
|
|
482
|
+
or (nq >= tlen and buffer.startswith(terminator, nq - tlen))
|
|
483
|
+
)
|
|
484
|
+
if opens:
|
|
485
|
+
in_quotes = True
|
|
486
|
+
pos = nq + qlen
|
|
487
|
+
|
|
488
|
+
if eof:
|
|
489
|
+
break
|
|
490
|
+
|
|
491
|
+
# Keep `margin` bytes behind the cursor so the look-back above still works after
|
|
492
|
+
# the buffer is trimmed.
|
|
493
|
+
drop = max(0, pos - margin)
|
|
494
|
+
buffer = buffer[drop:]
|
|
495
|
+
base += drop
|
|
496
|
+
pos -= drop
|
|
497
|
+
|
|
498
|
+
_trim(offsets, path)
|
|
499
|
+
return offsets
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _trim(offsets: array, path: str) -> None:
|
|
503
|
+
# A file ending with the terminator has no extra empty line after it, so the offset
|
|
504
|
+
# pointing at EOF is spurious. This also empties the index for a zero-byte file, which
|
|
505
|
+
# must report no lines at all rather than one empty one.
|
|
506
|
+
if offsets and offsets[-1] == os.path.getsize(path):
|
|
507
|
+
offsets.pop()
|
dwca/read.py
CHANGED
|
@@ -7,7 +7,7 @@ import xml.etree.ElementTree as ET
|
|
|
7
7
|
import zipfile
|
|
8
8
|
from errno import ENOENT
|
|
9
9
|
from tempfile import mkdtemp
|
|
10
|
-
from typing import List, Optional, Dict, Any, IO, Tuple
|
|
10
|
+
from typing import Iterator, List, Optional, Dict, Any, IO, Tuple
|
|
11
11
|
from xml.etree.ElementTree import Element
|
|
12
12
|
|
|
13
13
|
import dwca.vendor
|
|
@@ -38,6 +38,10 @@ class DwCAReader(object):
|
|
|
38
38
|
:param tmp_dir: temporary directory to use to uncompress the archive (if needed). If not provided, Python default \
|
|
39
39
|
will be used.
|
|
40
40
|
:type tmp_dir: str
|
|
41
|
+
:param skip_metadata: if `True`, the archive's scientific metadata file is not parsed and the `metadata` \
|
|
42
|
+
attribute stays `None`. This does not affect `source_metadata`, which is still populated either way. Use this \
|
|
43
|
+
to avoid the parsing cost when only the data rows are needed.
|
|
44
|
+
:type skip_metadata: bool
|
|
41
45
|
|
|
42
46
|
:raises: :class:`dwca.exceptions.InvalidArchive`
|
|
43
47
|
:raises: :class:`dwca.exceptions.InvalidSimpleArchive`
|
|
@@ -100,6 +104,7 @@ class DwCAReader(object):
|
|
|
100
104
|
|
|
101
105
|
#: The path to the Darwin Core Archive file, as passed to the constructor.
|
|
102
106
|
self.archive_path = path # type: str
|
|
107
|
+
self._default_iterator = None # type: Optional[Iterator[CoreRow]]
|
|
103
108
|
|
|
104
109
|
if os.path.isdir(
|
|
105
110
|
self.archive_path
|
|
@@ -256,6 +261,30 @@ class DwCAReader(object):
|
|
|
256
261
|
|
|
257
262
|
return df_or_textreader
|
|
258
263
|
|
|
264
|
+
def iter_terms(self, terms: List[str]) -> Iterator[Tuple[str, ...]]:
|
|
265
|
+
"""Yield one tuple of values per core row, holding `terms` in the order given.
|
|
266
|
+
|
|
267
|
+
A faster alternative to iterating over the reader when only a few terms are needed.
|
|
268
|
+
See :meth:`dwca.files.CSVDataFile.iter_terms`.
|
|
269
|
+
|
|
270
|
+
Usage::
|
|
271
|
+
|
|
272
|
+
for identifier, latitude, longitude in dwca.iter_terms(
|
|
273
|
+
[qn('occurrenceID'), qn('decimalLatitude'), qn('decimalLongitude')]):
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
The special name "id" requests the core file's id column - the same name used by
|
|
277
|
+
:attr:`dwca.descriptors.DataFileDescriptor.headers`. It resolves even when the
|
|
278
|
+
Metafile declares no ``<field>`` for that column, which is the common case. If the
|
|
279
|
+
core file declares an actual term named "id" (possible in metafile-less archives,
|
|
280
|
+
where terms are raw CSV header names), that declared term takes precedence, which
|
|
281
|
+
matches what `CoreRow.data['id']` already returns.
|
|
282
|
+
|
|
283
|
+
:param terms: a list of full term identifiers.
|
|
284
|
+
:raises ValueError: if any of `terms` is not present in the core data file.
|
|
285
|
+
"""
|
|
286
|
+
return self.core_file.iter_terms(terms)
|
|
287
|
+
|
|
259
288
|
def orphaned_extension_rows(self) -> Dict[str, Dict[str, List[int]]]:
|
|
260
289
|
"""Return a dict of the orphaned extension rows.
|
|
261
290
|
|
|
@@ -296,10 +325,12 @@ class DwCAReader(object):
|
|
|
296
325
|
return (self.descriptor is not None) and (len(self.descriptor.extensions) > 0)
|
|
297
326
|
|
|
298
327
|
@property
|
|
299
|
-
# TODO: decide, test and document what we guarantee about ordering
|
|
300
328
|
def rows(self) -> List[CoreRow]:
|
|
301
329
|
"""A list of :class:`rows.CoreRow` objects representing the content of the archive.
|
|
302
330
|
|
|
331
|
+
The list is in order of appearance in the core data file, the same order produced by
|
|
332
|
+
iterating the reader.
|
|
333
|
+
|
|
303
334
|
.. warning::
|
|
304
335
|
|
|
305
336
|
All rows will be loaded in memory. In case of a large Darwin Core Archive, you may prefer using a for loop.
|
|
@@ -341,8 +372,8 @@ class DwCAReader(object):
|
|
|
341
372
|
|
|
342
373
|
.. note::
|
|
343
374
|
|
|
344
|
-
|
|
345
|
-
|
|
375
|
+
The position is often an appropriate way to unambiguously identify a core row in
|
|
376
|
+
a DwCA.
|
|
346
377
|
|
|
347
378
|
"""
|
|
348
379
|
for i, row in enumerate(self):
|
|
@@ -536,22 +567,39 @@ class DwCAReader(object):
|
|
|
536
567
|
"""Return `True` if the Core file of the archive contains the `term_url` term."""
|
|
537
568
|
return term_url in self.core_file.file_descriptor.terms
|
|
538
569
|
|
|
539
|
-
def __iter__(self) ->
|
|
540
|
-
|
|
541
|
-
|
|
570
|
+
def __iter__(self) -> Iterator[CoreRow]:
|
|
571
|
+
# A fresh iterator each time, so nesting loops (or calling get_corerow_by_id() from
|
|
572
|
+
# inside one) behaves as expected.
|
|
573
|
+
return self._iter_core_rows()
|
|
574
|
+
|
|
575
|
+
def _iter_core_rows(self) -> Iterator[CoreRow]:
|
|
576
|
+
extension_files = self.extension_files
|
|
577
|
+
source_metadata = self.source_metadata
|
|
578
|
+
|
|
579
|
+
for row in self.core_file.iter_rows():
|
|
580
|
+
# Set up linked data so the CoreRow will know about them
|
|
581
|
+
row.link_extension_files(extension_files)
|
|
582
|
+
row.link_source_metadata(source_metadata)
|
|
583
|
+
yield row
|
|
542
584
|
|
|
543
585
|
def __next__(self):
|
|
544
586
|
return self.next()
|
|
545
587
|
|
|
546
588
|
def next(self) -> CoreRow: # NOQA
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
row
|
|
552
|
-
|
|
589
|
+
"""Return the next core row.
|
|
590
|
+
|
|
591
|
+
.. deprecated::
|
|
592
|
+
Iterate over the reader instead. This method keeps its own implicit iterator,
|
|
593
|
+
which is independent of any `for row in reader:` loop: interleaving the two makes
|
|
594
|
+
each of them scan the archive separately. Once this iterator is exhausted it
|
|
595
|
+
raises StopIteration, and the call after that starts a fresh pass from the first
|
|
596
|
+
row rather than raising again.
|
|
597
|
+
"""
|
|
598
|
+
if self._default_iterator is None:
|
|
599
|
+
self._default_iterator = self._iter_core_rows()
|
|
553
600
|
|
|
554
|
-
|
|
555
|
-
return
|
|
556
|
-
except
|
|
557
|
-
|
|
601
|
+
try:
|
|
602
|
+
return next(self._default_iterator)
|
|
603
|
+
except StopIteration:
|
|
604
|
+
self._default_iterator = None
|
|
605
|
+
raise
|