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.
@@ -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)
@@ -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"])