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/test/test_datafile.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os
|
|
1
2
|
import unittest
|
|
2
3
|
import xml.etree.ElementTree as ET
|
|
3
4
|
from array import array
|
|
@@ -35,6 +36,9 @@ class TestCSVDataFile(unittest.TestCase):
|
|
|
35
36
|
description_txt = extension_files[0]
|
|
36
37
|
vernacular_txt = extension_files[1]
|
|
37
38
|
|
|
39
|
+
# coreid_index values are array("L") rather than lists. This is documented in the
|
|
40
|
+
# property docstring and DwCAReader.orphaned_extension_rows() relies on it via
|
|
41
|
+
# .tolist(), so it is part of the contract, not an implementation detail.
|
|
38
42
|
expected_core = {
|
|
39
43
|
"1": array("L", [0]),
|
|
40
44
|
"2": array("L", [1]),
|
|
@@ -49,9 +53,6 @@ class TestCSVDataFile(unittest.TestCase):
|
|
|
49
53
|
expected_description = {"1": array("L", [0, 1]), "4": array("L", [2])}
|
|
50
54
|
assert description_txt.coreid_index == expected_description
|
|
51
55
|
|
|
52
|
-
with pytest.raises(AttributeError):
|
|
53
|
-
dwca.corefile.coreid_index
|
|
54
|
-
|
|
55
56
|
def test_file_descriptor_attribute(self):
|
|
56
57
|
"""The instance of DataFileDescriptor passed to the constructor is available in .file_descriptor"""
|
|
57
58
|
|
|
@@ -160,3 +161,285 @@ class TestCSVDataFile(unittest.TestCase):
|
|
|
160
161
|
|
|
161
162
|
for row in data_file:
|
|
162
163
|
assert isinstance(row, str)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TestStreamingIteration(unittest.TestCase):
|
|
167
|
+
def test_iter_rows_yields_every_row_in_order(self):
|
|
168
|
+
with DwCAReader(sample_data_path("dwca-ids.zip")) as dwca:
|
|
169
|
+
rows = list(dwca.core_file.iter_rows())
|
|
170
|
+
|
|
171
|
+
# Row IDs appear in the core file in this order: 4-1-3-2
|
|
172
|
+
assert ["4", "1", "3", "2"] == [row.id for row in rows]
|
|
173
|
+
assert [0, 1, 2, 3] == [row.position for row in rows]
|
|
174
|
+
|
|
175
|
+
def test_iter_rows_agrees_with_random_access(self):
|
|
176
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
177
|
+
for data_file in [dwca.core_file] + dwca.extension_files:
|
|
178
|
+
streamed = list(data_file.iter_rows())
|
|
179
|
+
seeked = [
|
|
180
|
+
data_file.get_row_by_position(i) for i in range(len(streamed))
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
assert [r.data for r in streamed] == [r.data for r in seeked]
|
|
184
|
+
assert [r.raw_fields for r in streamed] == [
|
|
185
|
+
r.raw_fields for r in seeked
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
def test_iter_rows_can_be_nested(self):
|
|
189
|
+
"""Each call gets its own stream, so concurrent passes do not interfere."""
|
|
190
|
+
with DwCAReader(sample_data_path("dwca-ids.zip")) as dwca:
|
|
191
|
+
pairs = [
|
|
192
|
+
(outer.id, inner.id)
|
|
193
|
+
for outer in dwca.core_file.iter_rows()
|
|
194
|
+
for inner in dwca.core_file.iter_rows()
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
assert 16 == len(pairs)
|
|
198
|
+
|
|
199
|
+
def test_iter_rows_on_a_quoted_archive(self):
|
|
200
|
+
with DwCAReader(sample_data_path("dwca-csv-quote-dir")) as dwca:
|
|
201
|
+
rows = list(dwca.core_file.iter_rows())
|
|
202
|
+
|
|
203
|
+
assert 2 == len(rows)
|
|
204
|
+
|
|
205
|
+
def test_raw_line_iteration_skips_every_header_line(self):
|
|
206
|
+
"""readlines() takes a byte-size hint, not a line count, so with two header lines
|
|
207
|
+
the second used to leak through as data."""
|
|
208
|
+
from .archive_builder import build_archive, temp_archive_dir
|
|
209
|
+
|
|
210
|
+
path = build_archive(
|
|
211
|
+
temp_archive_dir(self),
|
|
212
|
+
rows=[["1", "Borneo"], ["2", "Mumbai"]],
|
|
213
|
+
ignore_header_lines=2,
|
|
214
|
+
header_rows=[["idA", "locA"], ["idB", "locB"]],
|
|
215
|
+
)
|
|
216
|
+
with DwCAReader(path) as dwca:
|
|
217
|
+
lines = list(dwca.core_file)
|
|
218
|
+
|
|
219
|
+
assert 2 == len(lines)
|
|
220
|
+
assert lines[0].startswith("1\t")
|
|
221
|
+
assert lines[1].startswith("2\t")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class TestLineOffsets(unittest.TestCase):
|
|
225
|
+
def test_opening_an_archive_does_not_build_the_index(self):
|
|
226
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
227
|
+
assert dwca.core_file._line_offsets is None
|
|
228
|
+
|
|
229
|
+
dwca.core_file.get_row_by_position(0)
|
|
230
|
+
|
|
231
|
+
assert dwca.core_file._line_offsets is not None
|
|
232
|
+
|
|
233
|
+
def test_offsets_survive_an_undecodable_byte(self):
|
|
234
|
+
from .archive_builder import build_archive, temp_archive_dir
|
|
235
|
+
|
|
236
|
+
path = build_archive(
|
|
237
|
+
temp_archive_dir(self),
|
|
238
|
+
rows=[],
|
|
239
|
+
columns=2,
|
|
240
|
+
raw_payload=b"1\tcaf\xe9\n2\tMumbai\n3\tBorneo\n",
|
|
241
|
+
)
|
|
242
|
+
with DwCAReader(path) as dwca:
|
|
243
|
+
term = "http://rs.tdwg.org/dwc/terms/term1"
|
|
244
|
+
|
|
245
|
+
assert "Mumbai" == dwca.core_file.get_row_by_position(1).data[term]
|
|
246
|
+
assert "Borneo" == dwca.core_file.get_row_by_position(2).data[term]
|
|
247
|
+
|
|
248
|
+
def test_offsets_with_dos_line_endings(self):
|
|
249
|
+
from .archive_builder import build_archive, temp_archive_dir
|
|
250
|
+
|
|
251
|
+
path = build_archive(
|
|
252
|
+
temp_archive_dir(self),
|
|
253
|
+
rows=[["1", "Borneo"], ["2", "Mumbai"]],
|
|
254
|
+
lines_terminated_by="\r\n",
|
|
255
|
+
)
|
|
256
|
+
with DwCAReader(path) as dwca:
|
|
257
|
+
term = "http://rs.tdwg.org/dwc/terms/term1"
|
|
258
|
+
|
|
259
|
+
assert "Borneo" == dwca.core_file.get_row_by_position(0).data[term]
|
|
260
|
+
assert "Mumbai" == dwca.core_file.get_row_by_position(1).data[term]
|
|
261
|
+
|
|
262
|
+
def test_offsets_are_correct_across_a_chunk_boundary(self):
|
|
263
|
+
"""The scanner reads in chunks, so a terminator can straddle two reads."""
|
|
264
|
+
from dwca.files import _build_line_offsets
|
|
265
|
+
from .archive_builder import build_archive, temp_archive_dir
|
|
266
|
+
|
|
267
|
+
rows = [[str(i), "locality-" + str(i)] for i in range(5000)]
|
|
268
|
+
path = build_archive(temp_archive_dir(self), rows=rows)
|
|
269
|
+
|
|
270
|
+
data_path = os.path.join(path, "occurrence.txt")
|
|
271
|
+
reference = _build_line_offsets(data_path, "utf-8", "\n")
|
|
272
|
+
chunked = _build_line_offsets(data_path, "utf-8", "\n", chunk_size=7)
|
|
273
|
+
|
|
274
|
+
assert 5000 == len(reference)
|
|
275
|
+
assert list(reference) == list(chunked)
|
|
276
|
+
|
|
277
|
+
def test_a_zero_byte_core_file_has_no_rows(self):
|
|
278
|
+
"""A zero-byte file must report no lines at all, not one spurious empty line."""
|
|
279
|
+
from .archive_builder import build_archive, temp_archive_dir
|
|
280
|
+
|
|
281
|
+
path = build_archive(
|
|
282
|
+
temp_archive_dir(self), rows=[], columns=2, raw_payload=b""
|
|
283
|
+
)
|
|
284
|
+
with DwCAReader(path) as dwca:
|
|
285
|
+
with pytest.raises(IndexError):
|
|
286
|
+
dwca.core_file.get_row_by_position(0)
|
|
287
|
+
|
|
288
|
+
def test_multibyte_utf8_and_an_embedded_newline_in_the_same_file(self):
|
|
289
|
+
"""_read_record() must use a byte-accurate read.
|
|
290
|
+
|
|
291
|
+
offsets are byte offsets, but a text stream's read(n) counts characters, so a
|
|
292
|
+
naive `self._file_stream.read(next_offset - start)` would desync as soon as the
|
|
293
|
+
file contains a character that takes more than one byte in UTF-8 - truncating or
|
|
294
|
+
over-reading the record. This archive puts multi-byte characters (which make
|
|
295
|
+
byte count and character count diverge) and a quoted embedded newline (which
|
|
296
|
+
makes a record span more than one physical line) in the same file, so both
|
|
297
|
+
failure modes would have to hold simultaneously to pass.
|
|
298
|
+
"""
|
|
299
|
+
from .archive_builder import build_archive, temp_archive_dir
|
|
300
|
+
|
|
301
|
+
# ACCENTED_E is a 2-byte UTF-8 character, SNOWMAN a 3-byte one: both make byte
|
|
302
|
+
# count and character count diverge. Row 2's third field is quoted and contains
|
|
303
|
+
# an embedded newline, so that record spans two physical lines.
|
|
304
|
+
ACCENTED_E = "\xe9"
|
|
305
|
+
SNOWMAN = "\u2603"
|
|
306
|
+
payload = (
|
|
307
|
+
"1,caf" + ACCENTED_E + "," + SNOWMAN + "\n"
|
|
308
|
+
'2,"multi\nline",' + SNOWMAN + ACCENTED_E + "\n"
|
|
309
|
+
"3," + ACCENTED_E * 3 + ",end\n"
|
|
310
|
+
).encode("utf-8")
|
|
311
|
+
path = build_archive(
|
|
312
|
+
temp_archive_dir(self),
|
|
313
|
+
rows=[],
|
|
314
|
+
columns=3,
|
|
315
|
+
raw_payload=payload,
|
|
316
|
+
fields_terminated_by=",",
|
|
317
|
+
fields_enclosed_by='"',
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
with DwCAReader(path) as dwca:
|
|
321
|
+
streamed = [row.raw_fields for row in dwca]
|
|
322
|
+
seeked = [
|
|
323
|
+
dwca.core_file.get_row_by_position(i).raw_fields
|
|
324
|
+
for i in range(len(streamed))
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
assert [
|
|
328
|
+
["1", "caf" + ACCENTED_E, SNOWMAN],
|
|
329
|
+
["2", "multi\nline", SNOWMAN + ACCENTED_E],
|
|
330
|
+
["3", ACCENTED_E * 3, "end"],
|
|
331
|
+
] == streamed
|
|
332
|
+
assert streamed == seeked
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class TestIterTerms(unittest.TestCase):
|
|
336
|
+
def test_matches_the_row_api(self):
|
|
337
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
338
|
+
terms = sorted(dwca.core_file.file_descriptor.terms)
|
|
339
|
+
|
|
340
|
+
via_rows = [tuple(row.data[t] for t in terms) for row in dwca]
|
|
341
|
+
via_terms = list(dwca.core_file.iter_terms(terms))
|
|
342
|
+
|
|
343
|
+
assert via_rows == via_terms
|
|
344
|
+
|
|
345
|
+
def test_works_on_an_extension_file(self):
|
|
346
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
347
|
+
extension = dwca.extension_files[0]
|
|
348
|
+
terms = sorted(extension.file_descriptor.terms)
|
|
349
|
+
|
|
350
|
+
via_rows = [tuple(r.data[t] for t in terms) for r in extension.iter_rows()]
|
|
351
|
+
via_terms = list(extension.iter_terms(terms))
|
|
352
|
+
|
|
353
|
+
assert via_rows == via_terms
|
|
354
|
+
|
|
355
|
+
def test_subset_of_columns(self):
|
|
356
|
+
with DwCAReader(sample_data_path("dwca-simple-test-archive.zip")) as dwca:
|
|
357
|
+
values = list(
|
|
358
|
+
dwca.core_file.iter_terms(["http://rs.tdwg.org/dwc/terms/locality"])
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
assert [("Borneo",), ("Mumbai",)] == values
|
|
362
|
+
|
|
363
|
+
def test_unknown_term_raises_before_reading_anything(self):
|
|
364
|
+
with DwCAReader(sample_data_path("dwca-simple-test-archive.zip")) as dwca:
|
|
365
|
+
with pytest.raises(ValueError):
|
|
366
|
+
dwca.core_file.iter_terms(["http://rs.tdwg.org/dwc/terms/nope"])
|
|
367
|
+
|
|
368
|
+
def test_default_only_term(self):
|
|
369
|
+
with DwCAReader(sample_data_path("dwca-test-default.zip")) as dwca:
|
|
370
|
+
values = list(
|
|
371
|
+
dwca.core_file.iter_terms(["http://rs.tdwg.org/dwc/terms/country"])
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
assert [("Belgium",), ("Belgium",)] == values
|
|
375
|
+
|
|
376
|
+
def test_can_be_nested(self):
|
|
377
|
+
with DwCAReader(sample_data_path("dwca-ids.zip")) as dwca:
|
|
378
|
+
term = ["http://rs.tdwg.org/dwc/terms/family"]
|
|
379
|
+
pairs = [
|
|
380
|
+
(a, b)
|
|
381
|
+
for a in dwca.core_file.iter_terms(term)
|
|
382
|
+
for b in dwca.core_file.iter_terms(term)
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
assert 16 == len(pairs)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class TestIterTermsKeyColumns(unittest.TestCase):
|
|
389
|
+
def test_core_id_is_reachable(self):
|
|
390
|
+
"""dwca-ids.zip declares no <field> for its id column."""
|
|
391
|
+
with DwCAReader(sample_data_path("dwca-ids.zip")) as dwca:
|
|
392
|
+
from_rows = [row.id for row in dwca]
|
|
393
|
+
from_terms = [values[0] for values in dwca.iter_terms(["id"])]
|
|
394
|
+
|
|
395
|
+
assert ["4", "1", "3", "2"] == from_rows
|
|
396
|
+
assert from_rows == from_terms
|
|
397
|
+
|
|
398
|
+
def test_extension_coreid_is_reachable(self):
|
|
399
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
400
|
+
extension = dwca.extension_files[0]
|
|
401
|
+
|
|
402
|
+
from_rows = [row.core_id for row in extension.iter_rows()]
|
|
403
|
+
from_terms = [values[0] for values in extension.iter_terms(["coreid"])]
|
|
404
|
+
|
|
405
|
+
assert from_rows == from_terms
|
|
406
|
+
|
|
407
|
+
def test_key_column_mixes_with_real_terms_in_the_requested_order(self):
|
|
408
|
+
order = "http://rs.tdwg.org/dwc/terms/order"
|
|
409
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
410
|
+
got = list(dwca.iter_terms([order, "id"]))
|
|
411
|
+
expected = [(row.data[order], row.id) for row in dwca]
|
|
412
|
+
|
|
413
|
+
assert expected == got
|
|
414
|
+
|
|
415
|
+
def test_id_is_still_unknown_when_the_file_has_no_id_column(self):
|
|
416
|
+
"""A metafile-less archive has no id column, so the name must not resolve."""
|
|
417
|
+
with DwCAReader(sample_data_path("dwca-simple-csv.zip")) as dwca:
|
|
418
|
+
with pytest.raises(ValueError):
|
|
419
|
+
dwca.iter_terms(["id"])
|
|
420
|
+
|
|
421
|
+
def test_coreid_is_unknown_on_a_core_file(self):
|
|
422
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
423
|
+
with pytest.raises(ValueError):
|
|
424
|
+
dwca.iter_terms(["coreid"])
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class TestClosedFileGuarantee(unittest.TestCase):
|
|
428
|
+
def test_iteration_after_close_raises(self):
|
|
429
|
+
"""close() documents that content is not accessible in any way afterwards."""
|
|
430
|
+
with DwCAReader(sample_data_path("dwca-simple-dir")) as dwca:
|
|
431
|
+
data_file = dwca.core_file
|
|
432
|
+
|
|
433
|
+
assert list(data_file.iter_rows()) # works while open
|
|
434
|
+
data_file.close()
|
|
435
|
+
|
|
436
|
+
with pytest.raises(ValueError):
|
|
437
|
+
list(data_file.iter_rows())
|
|
438
|
+
|
|
439
|
+
def test_reader_iteration_after_close_raises(self):
|
|
440
|
+
dwca = DwCAReader(sample_data_path("dwca-simple-dir"))
|
|
441
|
+
assert list(dwca)
|
|
442
|
+
dwca.close()
|
|
443
|
+
|
|
444
|
+
with pytest.raises(ValueError):
|
|
445
|
+
list(dwca)
|
dwca/test/test_descriptors.py
CHANGED
|
@@ -3,8 +3,11 @@ import unittest
|
|
|
3
3
|
import xml.etree.ElementTree as ET
|
|
4
4
|
import zipfile
|
|
5
5
|
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
6
8
|
from dwca.darwincore.utils import qualname as qn
|
|
7
9
|
from dwca.descriptors import DataFileDescriptor, ArchiveDescriptor
|
|
10
|
+
from dwca.exceptions import InvalidArchive
|
|
8
11
|
from dwca.read import DwCAReader
|
|
9
12
|
from .helpers import sample_data_path
|
|
10
13
|
|
|
@@ -455,6 +458,102 @@ class TestDataFileDescriptor(unittest.TestCase):
|
|
|
455
458
|
assert fields == descriptor.core.terms
|
|
456
459
|
|
|
457
460
|
|
|
461
|
+
class TestDataFileDescriptorEquality(unittest.TestCase):
|
|
462
|
+
"""Unit tests for DataFileDescriptor equality and hashing.
|
|
463
|
+
|
|
464
|
+
Descriptors compare by value (the data file layout they describe) rather than by object
|
|
465
|
+
identity, so descriptors built twice from the same archive - by two DwCAReader instances,
|
|
466
|
+
for example - compare equal.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
CORE_SECTION = """
|
|
470
|
+
<core encoding="utf-8" fieldsTerminatedBy="\t" linesTerminatedBy="\n" fieldsEnclosedBy=""
|
|
471
|
+
ignoreHeaderLines="0" rowType="http://rs.tdwg.org/dwc/terms/Occurrence">
|
|
472
|
+
<files>
|
|
473
|
+
<location>occurrence.txt</location>
|
|
474
|
+
</files>
|
|
475
|
+
<id index="0" />
|
|
476
|
+
<field index="1" term="http://rs.tdwg.org/dwc/terms/scientificName"/>
|
|
477
|
+
<field default="Belgium" term="http://rs.tdwg.org/dwc/terms/country"/>
|
|
478
|
+
</core>
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
def _make(self, section=None):
|
|
482
|
+
return DataFileDescriptor.make_from_metafile_section(
|
|
483
|
+
ET.fromstring(section if section is not None else self.CORE_SECTION)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def test_descriptors_from_identical_sections_are_equal(self):
|
|
487
|
+
one = self._make()
|
|
488
|
+
two = self._make()
|
|
489
|
+
|
|
490
|
+
assert one is not two
|
|
491
|
+
# raw_element is an ET.Element, which compares by identity: these two are distinct
|
|
492
|
+
# objects, so equality can only hold if raw_element is left out of the comparison.
|
|
493
|
+
assert one.raw_element is not two.raw_element
|
|
494
|
+
assert one == two
|
|
495
|
+
assert not (one != two)
|
|
496
|
+
assert hash(one) == hash(two)
|
|
497
|
+
assert len({one, two}) == 1
|
|
498
|
+
|
|
499
|
+
def test_descriptors_of_the_same_archive_read_twice_are_equal(self):
|
|
500
|
+
path = sample_data_path("dwca-2extensions.zip")
|
|
501
|
+
|
|
502
|
+
with DwCAReader(path) as one, DwCAReader(path) as two:
|
|
503
|
+
assert one.descriptor.core == two.descriptor.core
|
|
504
|
+
|
|
505
|
+
for ext_one, ext_two in zip(
|
|
506
|
+
one.descriptor.extensions, two.descriptor.extensions
|
|
507
|
+
):
|
|
508
|
+
assert ext_one == ext_two
|
|
509
|
+
|
|
510
|
+
def test_core_and_extension_descriptors_differ(self):
|
|
511
|
+
with DwCAReader(sample_data_path("dwca-2extensions.zip")) as dwca:
|
|
512
|
+
core = dwca.descriptor.core
|
|
513
|
+
|
|
514
|
+
for extension in dwca.descriptor.extensions:
|
|
515
|
+
assert core != extension
|
|
516
|
+
|
|
517
|
+
def test_descriptors_differing_by_ignored_header_lines_differ(self):
|
|
518
|
+
one = self._make()
|
|
519
|
+
two = self._make(
|
|
520
|
+
self.CORE_SECTION.replace('ignoreHeaderLines="0"', 'ignoreHeaderLines="1"')
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
# Everything but the number of header lines to skip is identical here. That number
|
|
524
|
+
# lives in raw_element, which is excluded from the comparison, so it's only taken into
|
|
525
|
+
# account through the lines_to_ignore property.
|
|
526
|
+
assert one.lines_to_ignore != two.lines_to_ignore
|
|
527
|
+
assert one != two
|
|
528
|
+
|
|
529
|
+
def test_descriptors_differing_by_fields_differ(self):
|
|
530
|
+
one = self._make()
|
|
531
|
+
two = self._make(
|
|
532
|
+
self.CORE_SECTION.replace(
|
|
533
|
+
'term="http://rs.tdwg.org/dwc/terms/scientificName"',
|
|
534
|
+
'term="http://rs.tdwg.org/dwc/terms/locality"',
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
assert one != two
|
|
539
|
+
|
|
540
|
+
def test_descriptors_differing_by_file_location_differ(self):
|
|
541
|
+
one = self._make()
|
|
542
|
+
two = self._make(
|
|
543
|
+
self.CORE_SECTION.replace("occurrence.txt", "other_occurrences.txt")
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
assert one != two
|
|
547
|
+
|
|
548
|
+
def test_comparison_with_other_types_returns_false(self):
|
|
549
|
+
descriptor = self._make()
|
|
550
|
+
|
|
551
|
+
assert descriptor != "not a descriptor"
|
|
552
|
+
assert descriptor != 42
|
|
553
|
+
assert descriptor != None # noqa: E711 - we're testing __eq__, not identity
|
|
554
|
+
assert not (descriptor == "not a descriptor")
|
|
555
|
+
|
|
556
|
+
|
|
458
557
|
class TestArchiveDescriptor(unittest.TestCase):
|
|
459
558
|
"""Unit tests for ArchiveDescriptor class."""
|
|
460
559
|
|
|
@@ -594,3 +693,235 @@ class TestArchiveDescriptor(unittest.TestCase):
|
|
|
594
693
|
descriptor = dwca.descriptor
|
|
595
694
|
|
|
596
695
|
assert descriptor.metadata_filename == "eml.xml"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class TestHeadersIndexZero(unittest.TestCase):
|
|
699
|
+
def test_column_at_index_zero_is_not_dropped(self):
|
|
700
|
+
"""A metafile-less archive has no id_index, so column 0 comes only from fields."""
|
|
701
|
+
with DwCAReader(sample_data_path("dwca-simple-csv.zip")) as dwca:
|
|
702
|
+
descriptor = dwca.core_file.file_descriptor
|
|
703
|
+
|
|
704
|
+
assert len(descriptor.fields) == len(descriptor.headers)
|
|
705
|
+
assert "gbifid" == descriptor.headers[0]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class TestFieldPlan(unittest.TestCase):
|
|
709
|
+
def _descriptor(self, fields_xml, tag="core", id_tag='<id index="0" />'):
|
|
710
|
+
section = """
|
|
711
|
+
<{tag} encoding="utf-8" fieldsTerminatedBy="\\t" linesTerminatedBy="\\n" \
|
|
712
|
+
fieldsEnclosedBy="" ignoreHeaderLines="0" rowType="http://rs.tdwg.org/dwc/terms/Occurrence">
|
|
713
|
+
<files><location>occurrence.txt</location></files>
|
|
714
|
+
{id_tag}
|
|
715
|
+
{fields}
|
|
716
|
+
</{tag}>
|
|
717
|
+
""".format(
|
|
718
|
+
tag=tag, id_tag=id_tag, fields=fields_xml
|
|
719
|
+
)
|
|
720
|
+
return DataFileDescriptor.make_from_metafile_section(ET.fromstring(section))
|
|
721
|
+
|
|
722
|
+
def test_contiguous_columns(self):
|
|
723
|
+
descriptor = self._descriptor(
|
|
724
|
+
'<field index="0" term="http://x/a"/>'
|
|
725
|
+
'<field index="1" term="http://x/b"/>'
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
assert {"http://x/a": "1", "http://x/b": "Borneo"} == (
|
|
729
|
+
descriptor.field_plan.build_data(["1", "Borneo"])
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def test_columns_out_of_order_and_with_gaps(self):
|
|
733
|
+
descriptor = self._descriptor(
|
|
734
|
+
'<field index="2" term="http://x/a"/>'
|
|
735
|
+
'<field index="0" term="http://x/b"/>'
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
assert {"http://x/a": "third", "http://x/b": "first"} == (
|
|
739
|
+
descriptor.field_plan.build_data(["first", "second", "third"])
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
def test_single_column(self):
|
|
743
|
+
descriptor = self._descriptor('<field index="1" term="http://x/a"/>')
|
|
744
|
+
|
|
745
|
+
assert {"http://x/a": "Borneo"} == descriptor.field_plan.build_data(
|
|
746
|
+
["1", "Borneo"]
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
def test_default_only_field_has_no_column(self):
|
|
750
|
+
descriptor = self._descriptor(
|
|
751
|
+
'<field index="0" term="http://x/a"/>'
|
|
752
|
+
'<field term="http://x/country" default="Belgium"/>'
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
assert {"http://x/a": "1", "http://x/country": "Belgium"} == (
|
|
756
|
+
descriptor.field_plan.build_data(["1"])
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def test_default_fills_an_empty_cell(self):
|
|
760
|
+
"""A field can have both a column and a default (issue #80)."""
|
|
761
|
+
descriptor = self._descriptor(
|
|
762
|
+
'<field index="0" term="http://x/a"/>'
|
|
763
|
+
'<field index="1" term="http://x/b" default="fallback"/>'
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
assert "Borneo" == descriptor.field_plan.build_data(["1", "Borneo"])["http://x/b"]
|
|
767
|
+
assert "fallback" == descriptor.field_plan.build_data(["1", ""])["http://x/b"]
|
|
768
|
+
|
|
769
|
+
def test_missing_value_without_default_becomes_empty_string(self):
|
|
770
|
+
descriptor = self._descriptor(
|
|
771
|
+
'<field index="0" term="http://x/a"/>'
|
|
772
|
+
'<field index="1" term="http://x/b"/>'
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
assert "" == descriptor.field_plan.build_data(["1", ""])["http://x/b"]
|
|
776
|
+
|
|
777
|
+
def test_key_order_follows_the_metafile(self):
|
|
778
|
+
"""Row.data key order is visible through str(row), so it must not drift."""
|
|
779
|
+
descriptor = self._descriptor(
|
|
780
|
+
'<field index="0" term="http://x/a"/>'
|
|
781
|
+
'<field term="http://x/country" default="Belgium"/>'
|
|
782
|
+
'<field index="1" term="http://x/b"/>'
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
assert ["http://x/a", "http://x/country", "http://x/b"] == list(
|
|
786
|
+
descriptor.field_plan.build_data(["1", "Borneo"])
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
def test_row_with_too_few_columns_raises(self):
|
|
790
|
+
descriptor = self._descriptor('<field index="3" term="http://x/a"/>')
|
|
791
|
+
|
|
792
|
+
with pytest.raises(InvalidArchive):
|
|
793
|
+
descriptor.field_plan.build_data(["1", "Borneo"])
|
|
794
|
+
|
|
795
|
+
def test_the_plan_is_cached(self):
|
|
796
|
+
descriptor = self._descriptor('<field index="0" term="http://x/a"/>')
|
|
797
|
+
|
|
798
|
+
assert descriptor.field_plan is descriptor.field_plan
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
class TestTermGetter(unittest.TestCase):
|
|
802
|
+
def _plan(self, fields_xml):
|
|
803
|
+
section = """
|
|
804
|
+
<core encoding="utf-8" fieldsTerminatedBy="\\t" linesTerminatedBy="\\n" fieldsEnclosedBy="" ignoreHeaderLines="0" rowType="http://rs.tdwg.org/dwc/terms/Occurrence">
|
|
805
|
+
<files><location>occurrence.txt</location></files>
|
|
806
|
+
<id index="0" />
|
|
807
|
+
{fields}
|
|
808
|
+
</core>
|
|
809
|
+
""".format(
|
|
810
|
+
fields=fields_xml
|
|
811
|
+
)
|
|
812
|
+
descriptor = DataFileDescriptor.make_from_metafile_section(
|
|
813
|
+
ET.fromstring(section)
|
|
814
|
+
)
|
|
815
|
+
return descriptor.field_plan
|
|
816
|
+
|
|
817
|
+
def test_returns_values_in_the_requested_order(self):
|
|
818
|
+
plan = self._plan(
|
|
819
|
+
'<field index="0" term="http://x/a"/>'
|
|
820
|
+
'<field index="1" term="http://x/b"/>'
|
|
821
|
+
'<field index="2" term="http://x/c"/>'
|
|
822
|
+
)
|
|
823
|
+
getter = plan.term_getter(["http://x/c", "http://x/a"])
|
|
824
|
+
|
|
825
|
+
assert ("third", "first") == getter(["first", "second", "third"])
|
|
826
|
+
|
|
827
|
+
def test_a_single_term_still_yields_a_tuple(self):
|
|
828
|
+
plan = self._plan('<field index="1" term="http://x/b"/>')
|
|
829
|
+
getter = plan.term_getter(["http://x/b"])
|
|
830
|
+
|
|
831
|
+
assert ("second",) == getter(["first", "second"])
|
|
832
|
+
|
|
833
|
+
def test_no_terms(self):
|
|
834
|
+
plan = self._plan('<field index="0" term="http://x/a"/>')
|
|
835
|
+
getter = plan.term_getter([])
|
|
836
|
+
|
|
837
|
+
assert () == getter(["first"])
|
|
838
|
+
|
|
839
|
+
def test_a_term_may_be_requested_twice(self):
|
|
840
|
+
plan = self._plan('<field index="0" term="http://x/a"/>')
|
|
841
|
+
getter = plan.term_getter(["http://x/a", "http://x/a"])
|
|
842
|
+
|
|
843
|
+
assert ("first", "first") == getter(["first"])
|
|
844
|
+
|
|
845
|
+
def test_default_only_term_yields_the_constant(self):
|
|
846
|
+
plan = self._plan(
|
|
847
|
+
'<field index="0" term="http://x/a"/>'
|
|
848
|
+
'<field term="http://x/country" default="Belgium"/>'
|
|
849
|
+
)
|
|
850
|
+
getter = plan.term_getter(["http://x/country", "http://x/a"])
|
|
851
|
+
|
|
852
|
+
assert ("Belgium", "first") == getter(["first"])
|
|
853
|
+
|
|
854
|
+
def test_default_fills_an_empty_cell(self):
|
|
855
|
+
plan = self._plan(
|
|
856
|
+
'<field index="0" term="http://x/a"/>'
|
|
857
|
+
'<field index="1" term="http://x/b" default="fallback"/>'
|
|
858
|
+
)
|
|
859
|
+
getter = plan.term_getter(["http://x/b"])
|
|
860
|
+
|
|
861
|
+
assert ("value",) == getter(["first", "value"])
|
|
862
|
+
assert ("fallback",) == getter(["first", ""])
|
|
863
|
+
|
|
864
|
+
def test_missing_value_without_default_becomes_empty_string(self):
|
|
865
|
+
plan = self._plan(
|
|
866
|
+
'<field index="0" term="http://x/a"/>'
|
|
867
|
+
'<field index="1" term="http://x/b" default=""/>'
|
|
868
|
+
)
|
|
869
|
+
getter = plan.term_getter(["http://x/b"])
|
|
870
|
+
|
|
871
|
+
assert ("",) == getter(["first", ""])
|
|
872
|
+
|
|
873
|
+
def test_unknown_terms_are_named_in_the_error(self):
|
|
874
|
+
plan = self._plan('<field index="0" term="http://x/a"/>')
|
|
875
|
+
|
|
876
|
+
with pytest.raises(ValueError) as excinfo:
|
|
877
|
+
plan.term_getter(["http://x/missing", "http://x/a", "http://x/gone"])
|
|
878
|
+
|
|
879
|
+
message = str(excinfo.value)
|
|
880
|
+
assert "http://x/missing" in message
|
|
881
|
+
assert "http://x/gone" in message
|
|
882
|
+
assert "http://x/a" not in message
|
|
883
|
+
|
|
884
|
+
def test_short_row_raises_invalid_archive(self):
|
|
885
|
+
plan = self._plan(
|
|
886
|
+
'<field index="0" term="http://x/a"/>'
|
|
887
|
+
'<field index="4" term="http://x/e"/>'
|
|
888
|
+
)
|
|
889
|
+
getter = plan.term_getter(["http://x/e"])
|
|
890
|
+
|
|
891
|
+
with pytest.raises(InvalidArchive):
|
|
892
|
+
getter(["first", "second"])
|
|
893
|
+
|
|
894
|
+
def test_terms_may_be_a_one_shot_iterable(self):
|
|
895
|
+
# term_getter() used to iterate `terms` three times internally (missing, indexes,
|
|
896
|
+
# defaults). A generator is exhausted after the first pass, which silently made
|
|
897
|
+
# every later pass - and therefore every returned tuple - empty.
|
|
898
|
+
plan = self._plan(
|
|
899
|
+
'<field index="0" term="http://x/a"/>'
|
|
900
|
+
'<field index="1" term="http://x/b"/>'
|
|
901
|
+
)
|
|
902
|
+
expected = ("second", "first")
|
|
903
|
+
|
|
904
|
+
terms_list = ["http://x/b", "http://x/a"]
|
|
905
|
+
assert expected == plan.term_getter(t for t in terms_list)(["first", "second"])
|
|
906
|
+
|
|
907
|
+
# A tuple and a set of a single term must still work (the normalisation must not
|
|
908
|
+
# break non-list iterables that already worked before).
|
|
909
|
+
assert expected == plan.term_getter(tuple(terms_list))(["first", "second"])
|
|
910
|
+
assert ("second",) == plan.term_getter({"http://x/b"})(["first", "second"])
|
|
911
|
+
|
|
912
|
+
def test_a_declared_term_named_id_wins_over_the_key_column(self):
|
|
913
|
+
"""Terms in metafile-less archives are raw header names, so one can be "id".
|
|
914
|
+
|
|
915
|
+
The declared term wins, which is what row.data["id"] already returns.
|
|
916
|
+
"""
|
|
917
|
+
section = """
|
|
918
|
+
<core encoding="utf-8" fieldsTerminatedBy="," linesTerminatedBy="\n" fieldsEnclosedBy="" ignoreHeaderLines="0" rowType="http://rs.tdwg.org/dwc/terms/Occurrence">
|
|
919
|
+
<files><location>occurrence.txt</location></files>
|
|
920
|
+
<id index="0" />
|
|
921
|
+
<field index="1" term="id"/>
|
|
922
|
+
</core>
|
|
923
|
+
"""
|
|
924
|
+
descriptor = DataFileDescriptor.make_from_metafile_section(ET.fromstring(section))
|
|
925
|
+
getter = descriptor.field_plan.term_getter(["id"])
|
|
926
|
+
|
|
927
|
+
assert ("declared",) == getter(["key", "declared"])
|