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 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
- if f[
233
- "index"
234
- ]: # Some (default values for example) don't have a corresponding col.
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