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/descriptors.py +239 -3
- dwca/files.py +381 -55
- dwca/read.py +66 -19
- 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.3.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.3.dist-info/RECORD +0 -28
- {python_dwca_reader-0.16.3.dist-info → python_dwca_reader-0.17.0.dist-info}/AUTHORS +0 -0
- {python_dwca_reader-0.16.3.dist-info → python_dwca_reader-0.17.0.dist-info}/LICENSE.txt +0 -0
- {python_dwca_reader-0.16.3.dist-info → python_dwca_reader-0.17.0.dist-info}/WHEEL +0 -0
- {python_dwca_reader-0.16.3.dist-info → python_dwca_reader-0.17.0.dist-info}/top_level.txt +0 -0
dwca/descriptors.py
CHANGED
|
@@ -12,6 +12,7 @@ import csv
|
|
|
12
12
|
import io
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
|
+
from operator import itemgetter
|
|
15
16
|
import xml.etree.ElementTree as ET
|
|
16
17
|
from typing import Optional, List, Dict, Set
|
|
17
18
|
from xml.etree.ElementTree import Element
|
|
@@ -210,6 +211,66 @@ class DataFileDescriptor(object):
|
|
|
210
211
|
fields_terminated_by=fields_terminated_by,
|
|
211
212
|
)
|
|
212
213
|
|
|
214
|
+
def __key(self):
|
|
215
|
+
"""Return a tuple describing the data file layout. Common ground between equality and hash.
|
|
216
|
+
|
|
217
|
+
Deliberately excludes `raw_element`: it's an xml.etree.ElementTree.Element, which
|
|
218
|
+
compares by identity, so including it would make two descriptors built from the same
|
|
219
|
+
metafile section never compare equal. `lines_to_ignore` stands in for the only part of
|
|
220
|
+
`raw_element` that affects parsing (the ignoreHeaderLines attribute).
|
|
221
|
+
|
|
222
|
+
`created_from_file` is excluded too: it records where the descriptor came from, not how
|
|
223
|
+
the file is laid out, and its only behavioral effect is already covered by
|
|
224
|
+
`lines_to_ignore`. `represents_extension` is excluded as it's just the negation of
|
|
225
|
+
`represents_corefile`.
|
|
226
|
+
"""
|
|
227
|
+
return (
|
|
228
|
+
self.file_location,
|
|
229
|
+
self.file_encoding,
|
|
230
|
+
self.type,
|
|
231
|
+
self.id_index,
|
|
232
|
+
self.coreid_index,
|
|
233
|
+
self.represents_corefile,
|
|
234
|
+
self.fields,
|
|
235
|
+
self.lines_terminated_by,
|
|
236
|
+
self.fields_enclosed_by,
|
|
237
|
+
self.fields_terminated_by,
|
|
238
|
+
self.lines_to_ignore,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def __eq__(self, other):
|
|
242
|
+
if not isinstance(other, DataFileDescriptor):
|
|
243
|
+
return NotImplemented
|
|
244
|
+
|
|
245
|
+
return self.__key() == other.__key()
|
|
246
|
+
|
|
247
|
+
def __hash__(self):
|
|
248
|
+
# __key() embeds `fields`, a list of dicts, which isn't hashable. Equal descriptors
|
|
249
|
+
# still hash equally because this is a subset of the equality key.
|
|
250
|
+
return hash(
|
|
251
|
+
(
|
|
252
|
+
self.file_location,
|
|
253
|
+
self.file_encoding,
|
|
254
|
+
self.type,
|
|
255
|
+
self.id_index,
|
|
256
|
+
self.coreid_index,
|
|
257
|
+
self.represents_corefile,
|
|
258
|
+
self.lines_terminated_by,
|
|
259
|
+
self.fields_enclosed_by,
|
|
260
|
+
self.fields_terminated_by,
|
|
261
|
+
self.lines_to_ignore,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def field_plan(self) -> "FieldPlan":
|
|
267
|
+
"""A cached :class:`FieldPlan` turning a split data row into a term -> value dict."""
|
|
268
|
+
plan = self.__dict__.get("_field_plan")
|
|
269
|
+
if plan is None:
|
|
270
|
+
plan = FieldPlan(self.fields, self.id_index, self.coreid_index)
|
|
271
|
+
self.__dict__["_field_plan"] = plan
|
|
272
|
+
return plan
|
|
273
|
+
|
|
213
274
|
@property
|
|
214
275
|
def terms(self) -> Set[str]:
|
|
215
276
|
"""Return a Python set containing all the Darwin Core terms appearing in file."""
|
|
@@ -229,9 +290,9 @@ class DataFileDescriptor(object):
|
|
|
229
290
|
columns = {}
|
|
230
291
|
|
|
231
292
|
for f in self.fields:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
]
|
|
293
|
+
# Some fields (those carrying only a default value) have no column. Note the
|
|
294
|
+
# explicit None test: index 0 is a valid column and must not be dropped.
|
|
295
|
+
if f["index"] is not None:
|
|
235
296
|
columns[f["index"]] = f["term"]
|
|
236
297
|
|
|
237
298
|
# In addition to DwC terms, we may also have id (Core) or core_id (Extensions) columns
|
|
@@ -264,6 +325,181 @@ class DataFileDescriptor(object):
|
|
|
264
325
|
return int(self.raw_element.get("ignoreHeaderLines", 0))
|
|
265
326
|
|
|
266
327
|
|
|
328
|
+
class FieldPlan(object):
|
|
329
|
+
"""Precomputed mapping from a split CSV line to a term -> value dict.
|
|
330
|
+
|
|
331
|
+
Built once per :class:`DataFileDescriptor` (see its ``field_plan`` property) so that
|
|
332
|
+
parsing a row costs one C-level ``dict(zip(...))`` instead of a Python loop over the
|
|
333
|
+
field descriptors.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
__slots__ = (
|
|
337
|
+
"_fields",
|
|
338
|
+
"_id_index",
|
|
339
|
+
"_coreid_index",
|
|
340
|
+
"_contiguous_terms",
|
|
341
|
+
"_terms",
|
|
342
|
+
"_getter",
|
|
343
|
+
"_single_column",
|
|
344
|
+
"_defaults",
|
|
345
|
+
"_constants",
|
|
346
|
+
"_ordered",
|
|
347
|
+
"required_columns",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def __init__(self, fields, id_index=None, coreid_index=None):
|
|
351
|
+
self._fields = fields
|
|
352
|
+
self._id_index = id_index
|
|
353
|
+
self._coreid_index = coreid_index
|
|
354
|
+
|
|
355
|
+
indexed = [f for f in fields if f["index"] is not None]
|
|
356
|
+
indexes = [f["index"] for f in indexed]
|
|
357
|
+
|
|
358
|
+
#: Number of columns a data row must have for this plan to apply.
|
|
359
|
+
self.required_columns = max(indexes) + 1 if indexes else 0
|
|
360
|
+
|
|
361
|
+
# Terms without a column: the value is always the default.
|
|
362
|
+
self._constants = tuple(
|
|
363
|
+
(f["term"], f["default"] or "") for f in fields if f["index"] is None
|
|
364
|
+
)
|
|
365
|
+
# Indexed terms that also carry a default, used when the cell is empty (issue #80).
|
|
366
|
+
self._defaults = tuple(
|
|
367
|
+
(f["term"], f["default"]) for f in indexed if f["default"]
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# When some terms have no column, the fast paths below would append them after the
|
|
371
|
+
# mapped ones and change the key order of Row.data (which is user-visible through
|
|
372
|
+
# str(row)). Those archives use an ordered path instead; they are rare, and the
|
|
373
|
+
# ordered path is still free of the per-field try/except and int() it replaces.
|
|
374
|
+
self._ordered = (
|
|
375
|
+
tuple((f["term"], f["index"], f["default"]) for f in fields)
|
|
376
|
+
if self._constants
|
|
377
|
+
else None
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if indexes and sorted(indexes) == list(range(len(indexes))):
|
|
381
|
+
# Fast path: the indexed fields cover columns 0..n-1 exactly once.
|
|
382
|
+
ordered = sorted(indexed, key=lambda f: f["index"])
|
|
383
|
+
self._contiguous_terms = tuple(f["term"] for f in ordered)
|
|
384
|
+
self._terms = None
|
|
385
|
+
self._getter = None
|
|
386
|
+
self._single_column = False
|
|
387
|
+
else:
|
|
388
|
+
self._contiguous_terms = None
|
|
389
|
+
self._terms = tuple(f["term"] for f in indexed)
|
|
390
|
+
self._getter = itemgetter(*indexes) if indexes else None
|
|
391
|
+
# itemgetter with a single argument returns a scalar, not a tuple.
|
|
392
|
+
self._single_column = len(indexes) == 1
|
|
393
|
+
|
|
394
|
+
def build_data(self, raw_fields):
|
|
395
|
+
"""Return the term -> value dict for an already-split data row."""
|
|
396
|
+
if len(raw_fields) < self.required_columns:
|
|
397
|
+
self._raise_missing_column(raw_fields)
|
|
398
|
+
|
|
399
|
+
if self._ordered is not None:
|
|
400
|
+
data = {}
|
|
401
|
+
for term, index, default in self._ordered:
|
|
402
|
+
value = raw_fields[index] if index is not None else None
|
|
403
|
+
data[term] = value or default or ""
|
|
404
|
+
return data
|
|
405
|
+
|
|
406
|
+
if self._contiguous_terms is not None:
|
|
407
|
+
data = dict(zip(self._contiguous_terms, raw_fields))
|
|
408
|
+
elif self._getter is not None:
|
|
409
|
+
values = self._getter(raw_fields)
|
|
410
|
+
if self._single_column:
|
|
411
|
+
values = (values,)
|
|
412
|
+
data = dict(zip(self._terms, values))
|
|
413
|
+
else:
|
|
414
|
+
data = {}
|
|
415
|
+
|
|
416
|
+
for term, default in self._defaults:
|
|
417
|
+
if not data[term]:
|
|
418
|
+
data[term] = default
|
|
419
|
+
for term, constant in self._constants:
|
|
420
|
+
data[term] = constant
|
|
421
|
+
|
|
422
|
+
return data
|
|
423
|
+
|
|
424
|
+
def term_getter(self, terms):
|
|
425
|
+
"""Return a callable mapping a split data row to a tuple of values for `terms`.
|
|
426
|
+
|
|
427
|
+
:raises ValueError: if any requested term is absent from the data file.
|
|
428
|
+
"""
|
|
429
|
+
# `terms` is iterated three times below (missing, indexes, defaults). A generator or
|
|
430
|
+
# other one-shot iterable would be exhausted after the first pass, silently turning
|
|
431
|
+
# every later pass empty rather than raising - so normalise to a list once up front.
|
|
432
|
+
terms = list(terms)
|
|
433
|
+
|
|
434
|
+
by_term = {f["term"]: f for f in self._fields}
|
|
435
|
+
|
|
436
|
+
# "id" and "coreid" name the archive's key columns. These are the names this library
|
|
437
|
+
# already uses for them in headers(), short_headers() and pd_read(). A declared term of
|
|
438
|
+
# the same name wins, which matters for metafile-less archives whose terms are raw CSV
|
|
439
|
+
# header names, and keeps this consistent with row.data.
|
|
440
|
+
if "id" not in by_term and self._id_index is not None:
|
|
441
|
+
by_term["id"] = {"term": "id", "index": self._id_index, "default": None}
|
|
442
|
+
if "coreid" not in by_term and self._coreid_index is not None:
|
|
443
|
+
by_term["coreid"] = {"term": "coreid", "index": self._coreid_index, "default": None}
|
|
444
|
+
|
|
445
|
+
missing = [term for term in terms if term not in by_term]
|
|
446
|
+
if missing:
|
|
447
|
+
raise ValueError(
|
|
448
|
+
"These terms are not in this data file: {t}".format(
|
|
449
|
+
t=", ".join(sorted(missing))
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
indexes = tuple(by_term[term]["index"] for term in terms)
|
|
454
|
+
defaults = tuple(by_term[term]["default"] for term in terms)
|
|
455
|
+
|
|
456
|
+
if indexes and all(i is not None for i in indexes) and not any(defaults):
|
|
457
|
+
# Fast path: every term maps to a column and none has a default, so the whole
|
|
458
|
+
# tuple comes out of a single C-level call.
|
|
459
|
+
getter = itemgetter(*indexes)
|
|
460
|
+
required = max(indexes) + 1
|
|
461
|
+
|
|
462
|
+
if len(indexes) == 1:
|
|
463
|
+
# itemgetter with a single argument returns a scalar, not a tuple.
|
|
464
|
+
def get_one(raw_fields):
|
|
465
|
+
if len(raw_fields) < required:
|
|
466
|
+
self._raise_missing_column(raw_fields)
|
|
467
|
+
return (getter(raw_fields),)
|
|
468
|
+
|
|
469
|
+
return get_one
|
|
470
|
+
|
|
471
|
+
def get_many(raw_fields):
|
|
472
|
+
if len(raw_fields) < required:
|
|
473
|
+
self._raise_missing_column(raw_fields)
|
|
474
|
+
return getter(raw_fields)
|
|
475
|
+
|
|
476
|
+
return get_many
|
|
477
|
+
|
|
478
|
+
required = max([i for i in indexes if i is not None] or [-1]) + 1
|
|
479
|
+
|
|
480
|
+
def get_general(raw_fields):
|
|
481
|
+
if len(raw_fields) < required:
|
|
482
|
+
self._raise_missing_column(raw_fields)
|
|
483
|
+
return tuple(
|
|
484
|
+
(raw_fields[index] if index is not None else None) or default or ""
|
|
485
|
+
for index, default in zip(indexes, defaults)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
return get_general
|
|
489
|
+
|
|
490
|
+
def _raise_missing_column(self, raw_fields):
|
|
491
|
+
# Slow path: report the same index the old per-field loop would have reported.
|
|
492
|
+
for field in self._fields:
|
|
493
|
+
index = field["index"]
|
|
494
|
+
if index is not None and index >= len(raw_fields):
|
|
495
|
+
raise InvalidArchive(
|
|
496
|
+
"The descriptor references a non-existent field (index={i})".format(
|
|
497
|
+
i=index
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
raise InvalidArchive("The descriptor references a non-existent field")
|
|
501
|
+
|
|
502
|
+
|
|
267
503
|
class ArchiveDescriptor(object):
|
|
268
504
|
"""Class used to encapsulate the whole Metafile (`meta.xml`)."""
|
|
269
505
|
|