python-dwca-reader 0.16.3__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/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 typing import List, Union, IO, Dict, Optional
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
- On initialization, an index of new lines is build. This may take time, but makes random access\
29
- faster.
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._file_stream = io.open(
42
- os.path.join(work_directory, self.file_descriptor.file_location),
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
- # On init, we parse the file once to build an index of newlines (including lines to ignore)
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
- if self.lines_to_ignore > 0:
66
- self._file_stream.readlines(self.lines_to_ignore)
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
- for position, row in enumerate(self):
112
- if self.file_descriptor.represents_corefile:
113
- tmp = CoreRow(row, position, self.file_descriptor)
114
- index.setdefault(tmp.id, array("L")).append(position)
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 a list of :class:`dwca.rows.ExtensionRow` whose Core Id field match `core_id`."""
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.seek(self._line_offsets[position + self.lines_to_ignore], 0)
147
- return self._file_stream.readline()
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
- def _get_all_line_offsets(f: IO, encoding: str) -> array:
158
- """Parse the file whose handler is given and return an array (long) containing the start offset\
159
- of each line.
160
-
161
- The values in the array are suitable for seek() operations.
162
-
163
- This function can take long for large files.
164
-
165
- It needs to know the file encoding to properly count the bytes in a given string.
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
- f.seek(0, 0)
168
-
169
- # We use an array of Longs instead of a list to store the index.
170
- # It's much more memory efficient, and a few tests w/ 1-4Gb uncompressed archives
171
- # didn't show any significant slowdown.
172
- #
173
- # See mini-benchmark in minibench.py
174
- line_offsets = array("L")
175
- offset = 0
176
- for line in f:
177
- line_offsets.append(offset)
178
- offset += len(line.encode(encoding))
179
-
180
- f.seek(0, 0)
181
- return line_offsets
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,11 +7,9 @@ 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
- from pandas.io.parsers import TextFileReader
14
-
15
13
  import dwca.vendor
16
14
  from dwca.descriptors import ArchiveDescriptor, DataFileDescriptor, shorten_term
17
15
  from dwca.exceptions import (
@@ -40,6 +38,10 @@ class DwCAReader(object):
40
38
  :param tmp_dir: temporary directory to use to uncompress the archive (if needed). If not provided, Python default \
41
39
  will be used.
42
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
43
45
 
44
46
  :raises: :class:`dwca.exceptions.InvalidArchive`
45
47
  :raises: :class:`dwca.exceptions.InvalidSimpleArchive`
@@ -102,6 +104,7 @@ class DwCAReader(object):
102
104
 
103
105
  #: The path to the Darwin Core Archive file, as passed to the constructor.
104
106
  self.archive_path = path # type: str
107
+ self._default_iterator = None # type: Optional[Iterator[CoreRow]]
105
108
 
106
109
  if os.path.isdir(
107
110
  self.archive_path
@@ -229,6 +232,7 @@ class DwCAReader(object):
229
232
  raise ImportError("Pandas is missing.")
230
233
 
231
234
  from pandas import read_csv
235
+ from pandas.io.parsers import TextFileReader
232
236
 
233
237
  kwargs["delimiter"] = datafile_descriptor.fields_terminated_by
234
238
  kwargs["skiprows"] = datafile_descriptor.lines_to_ignore
@@ -257,6 +261,30 @@ class DwCAReader(object):
257
261
 
258
262
  return df_or_textreader
259
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
+
260
288
  def orphaned_extension_rows(self) -> Dict[str, Dict[str, List[int]]]:
261
289
  """Return a dict of the orphaned extension rows.
262
290
 
@@ -297,10 +325,12 @@ class DwCAReader(object):
297
325
  return (self.descriptor is not None) and (len(self.descriptor.extensions) > 0)
298
326
 
299
327
  @property
300
- # TODO: decide, test and document what we guarantee about ordering
301
328
  def rows(self) -> List[CoreRow]:
302
329
  """A list of :class:`rows.CoreRow` objects representing the content of the archive.
303
330
 
331
+ The list is in order of appearance in the core data file, the same order produced by
332
+ iterating the reader.
333
+
304
334
  .. warning::
305
335
 
306
336
  All rows will be loaded in memory. In case of a large Darwin Core Archive, you may prefer using a for loop.
@@ -342,8 +372,8 @@ class DwCAReader(object):
342
372
 
343
373
  .. note::
344
374
 
345
- - If index is bigger than the length of the archive, None is returned
346
- - The position is often an appropriate way to unambiguously identify a core row in a DwCA.
375
+ The position is often an appropriate way to unambiguously identify a core row in
376
+ a DwCA.
347
377
 
348
378
  """
349
379
  for i, row in enumerate(self):
@@ -537,22 +567,39 @@ class DwCAReader(object):
537
567
  """Return `True` if the Core file of the archive contains the `term_url` term."""
538
568
  return term_url in self.core_file.file_descriptor.terms
539
569
 
540
- def __iter__(self) -> "DwCAReader":
541
- self._corefile_pointer = 0
542
- return self
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
543
584
 
544
585
  def __next__(self):
545
586
  return self.next()
546
587
 
547
588
  def next(self) -> CoreRow: # NOQA
548
- try:
549
- row = self.core_file.get_row_by_position(self._corefile_pointer)
550
-
551
- # Set up linked data so the CoreRow will know about them
552
- row.link_extension_files(self.extension_files)
553
- row.link_source_metadata(self.source_metadata)
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()
554
600
 
555
- self._corefile_pointer = self._corefile_pointer + 1
556
- return row
557
- except IndexError:
558
- raise StopIteration
601
+ try:
602
+ return next(self._default_iterator)
603
+ except StopIteration:
604
+ self._default_iterator = None
605
+ raise