python-dwca-reader 0.16.2__py3-none-any.whl → 0.16.4__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/darwincore/build_dc_terms_list.py +8 -6
- dwca/darwincore/terms.py +171 -1
- dwca/darwincore/utils.py +1 -1
- dwca/descriptors.py +104 -81
- dwca/files.py +5 -5
- dwca/minibench.py +8 -6
- dwca/read.py +82 -41
- dwca/rows.py +85 -47
- dwca/star_record.py +27 -19
- dwca/test/helpers.py +1 -1
- dwca/test/test_datafile.py +8 -3
- dwca/test/test_descriptors.py +144 -90
- dwca/test/test_dwcareader.py +95 -43
- dwca/test/test_rows.py +8 -4
- dwca/test/test_star_record.py +117 -27
- dwca/vendor.py +1 -0
- dwca/version.py +1 -1
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.4.dist-info}/METADATA +1 -1
- python_dwca_reader-0.16.4.dist-info/RECORD +28 -0
- python_dwca_reader-0.16.2.dist-info/RECORD +0 -28
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.4.dist-info}/AUTHORS +0 -0
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.4.dist-info}/LICENSE.txt +0 -0
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.4.dist-info}/WHEEL +0 -0
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.4.dist-info}/top_level.txt +0 -0
dwca/read.py
CHANGED
|
@@ -10,11 +10,14 @@ from tempfile import mkdtemp
|
|
|
10
10
|
from typing import 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
|
-
from dwca.exceptions import
|
|
15
|
+
from dwca.exceptions import (
|
|
16
|
+
RowNotFound,
|
|
17
|
+
InvalidArchive,
|
|
18
|
+
InvalidSimpleArchive,
|
|
19
|
+
NotADataFile,
|
|
20
|
+
)
|
|
18
21
|
from dwca.files import CSVDataFile
|
|
19
22
|
from dwca.helpers import remove_tree
|
|
20
23
|
from dwca.rows import CoreRow
|
|
@@ -72,7 +75,7 @@ class DwCAReader(object):
|
|
|
72
75
|
|
|
73
76
|
default_metadata_filename = "EML.xml"
|
|
74
77
|
default_metafile_name = "meta.xml"
|
|
75
|
-
source_metadata_directory =
|
|
78
|
+
source_metadata_directory = "dataset"
|
|
76
79
|
|
|
77
80
|
def __enter__(self):
|
|
78
81
|
return self
|
|
@@ -80,7 +83,13 @@ class DwCAReader(object):
|
|
|
80
83
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
81
84
|
self.close()
|
|
82
85
|
|
|
83
|
-
def __init__(
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
path: str,
|
|
89
|
+
extensions_to_ignore: List[str] = None,
|
|
90
|
+
tmp_dir: str = None,
|
|
91
|
+
skip_metadata: bool = False,
|
|
92
|
+
) -> None:
|
|
84
93
|
"""Open the Darwin Core Archive."""
|
|
85
94
|
if extensions_to_ignore is None:
|
|
86
95
|
extensions_to_ignore = []
|
|
@@ -92,7 +101,9 @@ class DwCAReader(object):
|
|
|
92
101
|
#: The path to the Darwin Core Archive file, as passed to the constructor.
|
|
93
102
|
self.archive_path = path # type: str
|
|
94
103
|
|
|
95
|
-
if os.path.isdir(
|
|
104
|
+
if os.path.isdir(
|
|
105
|
+
self.archive_path
|
|
106
|
+
): # Archive is a (directly readable) directory
|
|
96
107
|
self._working_directory_path = self.archive_path
|
|
97
108
|
self._directory_to_clean = None # type: Optional[str]
|
|
98
109
|
else: # Archive is zipped/tgzipped, we have to extract it first.
|
|
@@ -104,15 +115,18 @@ class DwCAReader(object):
|
|
|
104
115
|
self._metafile_handle = None
|
|
105
116
|
try:
|
|
106
117
|
self._metafile_handle = self.open_included_file(self.default_metafile_name)
|
|
107
|
-
self.descriptor = ArchiveDescriptor(
|
|
108
|
-
|
|
118
|
+
self.descriptor = ArchiveDescriptor(
|
|
119
|
+
self._metafile_handle.read(), files_to_ignore=extensions_to_ignore
|
|
120
|
+
)
|
|
109
121
|
except IOError as exc:
|
|
110
122
|
if exc.errno == ENOENT:
|
|
111
123
|
pass
|
|
112
124
|
|
|
113
125
|
#: A :class:`xml.etree.ElementTree.Element` instance containing the (scientific) metadata
|
|
114
|
-
#: of the archive, or `None` if the archive has no metadata.
|
|
115
|
-
self.metadata =
|
|
126
|
+
#: of the archive, or `None` if the archive has no metadata or if the `skip_metadata` parameter is True.
|
|
127
|
+
self.metadata = None # type: Optional[Element]
|
|
128
|
+
if not skip_metadata:
|
|
129
|
+
self.metadata = self._parse_metadata_file() # type: Optional[Element]
|
|
116
130
|
|
|
117
131
|
#: If the archive contains source-level metadata (typically, GBIF downloads), this is a dict such as::
|
|
118
132
|
#:
|
|
@@ -122,23 +136,33 @@ class DwCAReader(object):
|
|
|
122
136
|
#: See :doc:`gbif_results` for more details.
|
|
123
137
|
self.source_metadata = self._get_source_metadata() # type: Dict[str, Element]
|
|
124
138
|
|
|
125
|
-
if
|
|
139
|
+
if (
|
|
140
|
+
self.descriptor
|
|
141
|
+
): # We have an Archive descriptor that we can use to access data files.
|
|
126
142
|
#: An instance of :class:`dwca.files.CSVDataFile` for the core data file.
|
|
127
|
-
self.core_file = CSVDataFile(
|
|
143
|
+
self.core_file = CSVDataFile(
|
|
144
|
+
self._working_directory_path, self.descriptor.core
|
|
145
|
+
) # type: CSVDataFile
|
|
128
146
|
|
|
129
147
|
#: A list of :class:`dwca.files.CSVDataFile`, one entry for each extension data file , sorted by order of
|
|
130
148
|
#: appearance in the Metafile (or an empty list if the archive doesn't use extensions).
|
|
131
|
-
self.extension_files = [
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
self.extension_files = [
|
|
150
|
+
CSVDataFile(
|
|
151
|
+
work_directory=self._working_directory_path, file_descriptor=d
|
|
152
|
+
)
|
|
153
|
+
for d in self.descriptor.extensions
|
|
154
|
+
] # type: List[CSVDataFile]
|
|
134
155
|
else: # Archive without descriptor, we'll have to find and inspect the data file
|
|
135
156
|
try:
|
|
136
157
|
datafile_name = self._is_valid_simple_archive()
|
|
137
158
|
descriptor = DataFileDescriptor.make_from_file(
|
|
138
|
-
os.path.join(self._working_directory_path, datafile_name)
|
|
159
|
+
os.path.join(self._working_directory_path, datafile_name)
|
|
160
|
+
)
|
|
139
161
|
|
|
140
|
-
self.core_file = CSVDataFile(
|
|
141
|
-
|
|
162
|
+
self.core_file = CSVDataFile(
|
|
163
|
+
work_directory=self._working_directory_path,
|
|
164
|
+
file_descriptor=descriptor,
|
|
165
|
+
)
|
|
142
166
|
self.extension_files = []
|
|
143
167
|
except InvalidSimpleArchive:
|
|
144
168
|
msg = "No Metafile was found, but the archive contains multiple files/directories."
|
|
@@ -146,14 +170,17 @@ class DwCAReader(object):
|
|
|
146
170
|
|
|
147
171
|
def _get_source_metadata(self) -> Dict[str, Element]:
|
|
148
172
|
source_metadata = {} # type: Dict[str, Element]
|
|
149
|
-
source_metadata_dir = os.path.join(
|
|
173
|
+
source_metadata_dir = os.path.join(
|
|
174
|
+
self._working_directory_path, self.source_metadata_directory
|
|
175
|
+
)
|
|
150
176
|
|
|
151
177
|
if os.path.isdir(source_metadata_dir):
|
|
152
178
|
for f in os.listdir(source_metadata_dir):
|
|
153
179
|
if os.path.isfile(os.path.join(source_metadata_dir, f)):
|
|
154
180
|
dataset_key = os.path.splitext(f)[0]
|
|
155
181
|
source_metadata[dataset_key] = self._parse_xml_included_file(
|
|
156
|
-
os.path.join(self.source_metadata_directory, f)
|
|
182
|
+
os.path.join(self.source_metadata_directory, f)
|
|
183
|
+
)
|
|
157
184
|
|
|
158
185
|
return source_metadata
|
|
159
186
|
|
|
@@ -192,23 +219,28 @@ class DwCAReader(object):
|
|
|
192
219
|
not supported in case the value returned by `pandas.read_csv()` is a `TextFileReader` (e.g. when using
|
|
193
220
|
`chunksize` or `iterator=True`).
|
|
194
221
|
"""
|
|
195
|
-
datafile_descriptor = self.get_descriptor_for(
|
|
222
|
+
datafile_descriptor = self.get_descriptor_for(
|
|
223
|
+
relative_path
|
|
224
|
+
) # type: DataFileDescriptor
|
|
196
225
|
|
|
197
226
|
if not dwca.vendor._has_pandas:
|
|
198
227
|
raise ImportError("Pandas is missing.")
|
|
199
228
|
|
|
200
229
|
from pandas import read_csv
|
|
230
|
+
from pandas.io.parsers import TextFileReader
|
|
201
231
|
|
|
202
|
-
kwargs[
|
|
203
|
-
kwargs[
|
|
204
|
-
kwargs[
|
|
205
|
-
kwargs[
|
|
232
|
+
kwargs["delimiter"] = datafile_descriptor.fields_terminated_by
|
|
233
|
+
kwargs["skiprows"] = datafile_descriptor.lines_to_ignore
|
|
234
|
+
kwargs["header"] = None
|
|
235
|
+
kwargs["names"] = datafile_descriptor.short_headers
|
|
206
236
|
|
|
207
|
-
df_or_textreader = read_csv(
|
|
237
|
+
df_or_textreader = read_csv(
|
|
238
|
+
self.absolute_temporary_path(relative_path), **kwargs
|
|
239
|
+
)
|
|
208
240
|
|
|
209
241
|
# Add a column for default values, if present in the file descriptor
|
|
210
242
|
for field in datafile_descriptor.fields:
|
|
211
|
-
field_default_value = field[
|
|
243
|
+
field_default_value = field["default"]
|
|
212
244
|
if field_default_value is not None:
|
|
213
245
|
if isinstance(df_or_textreader, TextFileReader):
|
|
214
246
|
# I don't see how to assign default values to a TextFileReader, so
|
|
@@ -220,7 +252,7 @@ class DwCAReader(object):
|
|
|
220
252
|
"the archive."
|
|
221
253
|
)
|
|
222
254
|
|
|
223
|
-
df_or_textreader[shorten_term(field[
|
|
255
|
+
df_or_textreader[shorten_term(field["term"])] = field_default_value
|
|
224
256
|
|
|
225
257
|
return df_or_textreader
|
|
226
258
|
|
|
@@ -249,7 +281,9 @@ class DwCAReader(object):
|
|
|
249
281
|
ids = temp_ids.keys()
|
|
250
282
|
|
|
251
283
|
for extension in self.extension_files:
|
|
252
|
-
coreid_index = dict(
|
|
284
|
+
coreid_index = dict(
|
|
285
|
+
(k, v.tolist()) for k, v in extension.coreid_index.items()
|
|
286
|
+
)
|
|
253
287
|
for id in ids:
|
|
254
288
|
coreid_index.pop(id, None)
|
|
255
289
|
indexes[extension.file_descriptor.file_location] = coreid_index
|
|
@@ -311,7 +345,7 @@ class DwCAReader(object):
|
|
|
311
345
|
- The position is often an appropriate way to unambiguously identify a core row in a DwCA.
|
|
312
346
|
|
|
313
347
|
"""
|
|
314
|
-
for
|
|
348
|
+
for i, row in enumerate(self):
|
|
315
349
|
if i == position:
|
|
316
350
|
return row
|
|
317
351
|
|
|
@@ -340,7 +374,9 @@ class DwCAReader(object):
|
|
|
340
374
|
File existence is not tested.
|
|
341
375
|
|
|
342
376
|
"""
|
|
343
|
-
return os.path.abspath(
|
|
377
|
+
return os.path.abspath(
|
|
378
|
+
os.path.join(self._working_directory_path, relative_path)
|
|
379
|
+
)
|
|
344
380
|
|
|
345
381
|
def get_descriptor_for(self, relative_path: str) -> DataFileDescriptor:
|
|
346
382
|
"""Return a descriptor for the data file located at relative_path.
|
|
@@ -365,7 +401,6 @@ class DwCAReader(object):
|
|
|
365
401
|
raise NotADataFile("{fn} is not a data file".format(fn=relative_path))
|
|
366
402
|
|
|
367
403
|
def _is_valid_simple_archive(self) -> str:
|
|
368
|
-
|
|
369
404
|
# If the working dir appear to contains a valid simple darwin core archive
|
|
370
405
|
# (one single data file + possibly some metadata), returns the name of the data file.
|
|
371
406
|
#
|
|
@@ -396,7 +431,7 @@ class DwCAReader(object):
|
|
|
396
431
|
"""Load the archive (scientific) Metadata file, parse it with\
|
|
397
432
|
ElementTree and return its content (or `None` if the archive has no metadata).
|
|
398
433
|
|
|
399
|
-
:raises: :class:`dwca.exceptions.InvalidArchive` if the archive references
|
|
434
|
+
:raises: :class:`dwca.exceptions.InvalidArchive` if the archive references a non-existent
|
|
400
435
|
metadata file.
|
|
401
436
|
"""
|
|
402
437
|
# If the archive has descriptor, look for the metadata filename there.
|
|
@@ -407,14 +442,18 @@ class DwCAReader(object):
|
|
|
407
442
|
return self._parse_xml_included_file(filename)
|
|
408
443
|
except IOError as exc:
|
|
409
444
|
if exc.errno == ENOENT: # File not found
|
|
410
|
-
msg = "{} is referenced in the archive descriptor but missing.".format(
|
|
445
|
+
msg = "{} is referenced in the archive descriptor but missing.".format(
|
|
446
|
+
filename
|
|
447
|
+
)
|
|
411
448
|
raise InvalidArchive(msg)
|
|
412
449
|
|
|
413
450
|
else: # Otherwise, the metadata file has to be named 'EML.xml'
|
|
414
451
|
try:
|
|
415
452
|
return self._parse_xml_included_file(self.default_metadata_filename)
|
|
416
453
|
except IOError as exc:
|
|
417
|
-
if
|
|
454
|
+
if (
|
|
455
|
+
exc.errno == ENOENT
|
|
456
|
+
): # File not found, this is an archive without metadata
|
|
418
457
|
return None
|
|
419
458
|
|
|
420
459
|
assert False # For MyPy, see: https://github.com/python/mypy/issues/4223#issuecomment-342865133
|
|
@@ -428,7 +467,7 @@ class DwCAReader(object):
|
|
|
428
467
|
# In that case, we work with a stripped string instead.
|
|
429
468
|
# Note that this method cannot be generalized because it won't work well with encoding specified in the xml
|
|
430
469
|
# tag. This is why we're only choosing it in case of error
|
|
431
|
-
data_as_string = self.open_included_file(relative_path,
|
|
470
|
+
data_as_string = self.open_included_file(relative_path, "r").read()
|
|
432
471
|
return ET.fromstring(data_as_string.strip())
|
|
433
472
|
|
|
434
473
|
def _unzip_or_untar(self) -> str:
|
|
@@ -444,14 +483,16 @@ class DwCAReader(object):
|
|
|
444
483
|
try:
|
|
445
484
|
# Security note: with Python < 2.7.4, a zip file may be able to write outside of the
|
|
446
485
|
# directory using absolute paths, parent (..) path, ... See note in ZipFile.extract doc
|
|
447
|
-
zipfile.ZipFile(self.archive_path,
|
|
486
|
+
zipfile.ZipFile(self.archive_path, "r").extractall(tmp_dir)
|
|
448
487
|
except zipfile.BadZipfile:
|
|
449
488
|
# Doesn't look like a valid zip, let's see if it's a tar archive (possibly compressed)
|
|
450
489
|
try:
|
|
451
490
|
# TODO: Once we only support Python 3.12+, we should pass the filter="data" argument to extractall()
|
|
452
|
-
tarfile.open(self.archive_path,
|
|
491
|
+
tarfile.open(self.archive_path, "r:*").extractall(tmp_dir)
|
|
453
492
|
except tarfile.ReadError:
|
|
454
|
-
raise InvalidArchive(
|
|
493
|
+
raise InvalidArchive(
|
|
494
|
+
"The archive cannot be read. Is it a .zip or .tgz file?"
|
|
495
|
+
)
|
|
455
496
|
|
|
456
497
|
return tmp_dir
|
|
457
498
|
|
|
@@ -487,7 +528,7 @@ class DwCAReader(object):
|
|
|
487
528
|
extension_file.close()
|
|
488
529
|
if self._metafile_handle:
|
|
489
530
|
self._metafile_handle.close()
|
|
490
|
-
|
|
531
|
+
|
|
491
532
|
if self._directory_to_clean:
|
|
492
533
|
remove_tree(self._directory_to_clean)
|
|
493
534
|
|
|
@@ -495,7 +536,7 @@ class DwCAReader(object):
|
|
|
495
536
|
"""Return `True` if the Core file of the archive contains the `term_url` term."""
|
|
496
537
|
return term_url in self.core_file.file_descriptor.terms
|
|
497
538
|
|
|
498
|
-
def __iter__(self) ->
|
|
539
|
+
def __iter__(self) -> "DwCAReader":
|
|
499
540
|
self._corefile_pointer = 0
|
|
500
541
|
return self
|
|
501
542
|
|
dwca/rows.py
CHANGED
|
@@ -16,36 +16,45 @@ class Row(object):
|
|
|
16
16
|
|
|
17
17
|
# Common ground for __str__ between subclasses
|
|
18
18
|
def _build_str(self, source_str, id_str):
|
|
19
|
-
txt = (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
txt = (
|
|
20
|
+
"--\n"
|
|
21
|
+
"Rowtype: {rowtype}\n"
|
|
22
|
+
"Position: {position}\n"
|
|
23
|
+
"Source: {source_str}\n"
|
|
24
|
+
"{id_row}\n"
|
|
25
|
+
"Reference extension rows: {extension_flag}\n"
|
|
26
|
+
"Reference source metadata: {source_metadata_flag}\n"
|
|
27
|
+
"Data: {data}\n"
|
|
28
|
+
"--\n"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
extension_flag = (
|
|
32
|
+
"Yes"
|
|
33
|
+
if (hasattr(self, "extensions") and (len(self.extensions) > 0))
|
|
34
|
+
else "No"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if hasattr(self, "source_metadata") and (self.source_metadata is not None):
|
|
38
|
+
source_metadata_flag = "Yes"
|
|
33
39
|
else:
|
|
34
|
-
source_metadata_flag =
|
|
35
|
-
|
|
36
|
-
return txt.format(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
source_metadata_flag = "No"
|
|
41
|
+
|
|
42
|
+
return txt.format(
|
|
43
|
+
rowtype=self.rowtype,
|
|
44
|
+
position=self.position,
|
|
45
|
+
source_str=source_str,
|
|
46
|
+
data=self.data,
|
|
47
|
+
id_row=id_str,
|
|
48
|
+
extension_flag=extension_flag,
|
|
49
|
+
source_metadata_flag=source_metadata_flag,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, csv_line: str, position: int, datafile_descriptor: DataFileDescriptor
|
|
54
|
+
) -> None:
|
|
46
55
|
#: An instance of :class:`dwca.descriptors.DataFileDescriptor` describing the originating
|
|
47
56
|
#: data file.
|
|
48
|
-
self.descriptor = datafile_descriptor # type: DataFileDescriptor
|
|
57
|
+
self.descriptor = datafile_descriptor # type: DataFileDescriptor
|
|
49
58
|
|
|
50
59
|
#: The row position/index (starting at 0) in the source data file. This can be used, for example with
|
|
51
60
|
#: :meth:`dwca.read.DwCAReader.get_corerow_by_position` or :meth:`dwca.files.CSVDataFile.get_row_by_position`.
|
|
@@ -59,10 +68,12 @@ class Row(object):
|
|
|
59
68
|
|
|
60
69
|
# self.raw_fields is a list of the csv_line's content
|
|
61
70
|
#:
|
|
62
|
-
self.raw_fields = csv_line_to_fields(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
self.raw_fields = csv_line_to_fields(
|
|
72
|
+
csv_line,
|
|
73
|
+
line_ending=self.descriptor.lines_terminated_by,
|
|
74
|
+
field_ending=self.descriptor.fields_terminated_by,
|
|
75
|
+
fields_enclosed_by=self.descriptor.fields_enclosed_by,
|
|
76
|
+
)
|
|
66
77
|
|
|
67
78
|
# TODO: raw_fields is a new property: to test
|
|
68
79
|
|
|
@@ -84,18 +95,24 @@ class Row(object):
|
|
|
84
95
|
|
|
85
96
|
for field_descriptor in self.descriptor.fields:
|
|
86
97
|
try:
|
|
87
|
-
column_index = int(field_descriptor[
|
|
98
|
+
column_index = int(field_descriptor["index"])
|
|
88
99
|
field_row_value = self.raw_fields[column_index]
|
|
89
100
|
except TypeError:
|
|
90
101
|
# int() argument must be a string... We don't have an index for this field
|
|
91
102
|
field_row_value = None
|
|
92
103
|
except IndexError:
|
|
93
|
-
msg =
|
|
104
|
+
msg = (
|
|
105
|
+
"The descriptor references a non-existent field (index={i})".format(
|
|
106
|
+
i=column_index
|
|
107
|
+
)
|
|
108
|
+
)
|
|
94
109
|
raise InvalidArchive(msg)
|
|
95
110
|
|
|
96
|
-
field_default_value = field_descriptor[
|
|
111
|
+
field_default_value = field_descriptor["default"]
|
|
97
112
|
|
|
98
|
-
self.data[field_descriptor[
|
|
113
|
+
self.data[field_descriptor["term"]] = (
|
|
114
|
+
field_row_value or field_default_value or ""
|
|
115
|
+
)
|
|
99
116
|
|
|
100
117
|
|
|
101
118
|
class CoreRow(Row):
|
|
@@ -110,7 +127,9 @@ class CoreRow(Row):
|
|
|
110
127
|
id_str = "Row id: " + str(self.id)
|
|
111
128
|
return super(CoreRow, self)._build_str("Core file", id_str)
|
|
112
129
|
|
|
113
|
-
def __init__(
|
|
130
|
+
def __init__(
|
|
131
|
+
self, csv_line: str, position: int, datafile_descriptor: DataFileDescriptor
|
|
132
|
+
) -> None:
|
|
114
133
|
super(CoreRow, self).__init__(csv_line, position, datafile_descriptor)
|
|
115
134
|
|
|
116
135
|
if self.descriptor.id_index is not None:
|
|
@@ -129,14 +148,14 @@ class CoreRow(Row):
|
|
|
129
148
|
# provide some, but not for this row, it will be set to None).
|
|
130
149
|
#
|
|
131
150
|
# If this method is never called, the source_metadata attribute will not exist
|
|
132
|
-
field_name =
|
|
151
|
+
field_name = "http://rs.tdwg.org/dwc/terms/datasetID"
|
|
133
152
|
|
|
134
153
|
#: Row-level metadata (if provided by the archive).
|
|
135
154
|
#: This is a non-standard DwCA feature currently that we can sometimes encounter (in downloads from GBIF.org
|
|
136
155
|
#: for example).
|
|
137
156
|
self.source_metadata = None
|
|
138
157
|
|
|
139
|
-
if
|
|
158
|
+
if archive_source_metadata and (field_name in self.data):
|
|
140
159
|
try:
|
|
141
160
|
self.source_metadata = archive_source_metadata[self.data[field_name]]
|
|
142
161
|
except KeyError:
|
|
@@ -150,10 +169,13 @@ class CoreRow(Row):
|
|
|
150
169
|
# type () -> List[ExtensionRow]
|
|
151
170
|
"""A list of :class:`.ExtensionRow` instances that relates to this Core row."""
|
|
152
171
|
# We use lazy loading
|
|
153
|
-
if not hasattr(self,
|
|
172
|
+
if not hasattr(self, "_extensions"):
|
|
154
173
|
self._extensions = []
|
|
155
174
|
for csv_file in self.extension_data_files:
|
|
156
|
-
[
|
|
175
|
+
[
|
|
176
|
+
self._extensions.append(r)
|
|
177
|
+
for r in csv_file.get_all_rows_by_coreid(self.id)
|
|
178
|
+
]
|
|
157
179
|
|
|
158
180
|
return self._extensions
|
|
159
181
|
|
|
@@ -161,8 +183,16 @@ class CoreRow(Row):
|
|
|
161
183
|
# Should these 3 be factorized ? How ? Mixin ? Parent class ?
|
|
162
184
|
def __key(self):
|
|
163
185
|
"""Return a tuple representing the row. Common ground between equality and hash."""
|
|
164
|
-
return (
|
|
165
|
-
|
|
186
|
+
return (
|
|
187
|
+
self.descriptor,
|
|
188
|
+
self.id,
|
|
189
|
+
self.data,
|
|
190
|
+
self.extensions,
|
|
191
|
+
self.source_metadata,
|
|
192
|
+
self.rowtype,
|
|
193
|
+
self.raw_fields,
|
|
194
|
+
self.position,
|
|
195
|
+
)
|
|
166
196
|
|
|
167
197
|
def __eq__(self, other):
|
|
168
198
|
return self.__key() == other.__key()
|
|
@@ -185,7 +215,9 @@ class ExtensionRow(Row):
|
|
|
185
215
|
id_str = "Core row id: " + str(self.core_id)
|
|
186
216
|
return super(ExtensionRow, self)._build_str("Extension file", id_str)
|
|
187
217
|
|
|
188
|
-
def __init__(
|
|
218
|
+
def __init__(
|
|
219
|
+
self, csv_line: str, position: int, datafile_descriptor: DataFileDescriptor
|
|
220
|
+
) -> None:
|
|
189
221
|
super(ExtensionRow, self).__init__(csv_line, position, datafile_descriptor)
|
|
190
222
|
|
|
191
223
|
#: The id of the core row this extension row is referring to.
|
|
@@ -193,7 +225,14 @@ class ExtensionRow(Row):
|
|
|
193
225
|
|
|
194
226
|
def __key(self):
|
|
195
227
|
"""Return a tuple representing the row. Common ground between equality and hash."""
|
|
196
|
-
return (
|
|
228
|
+
return (
|
|
229
|
+
self.descriptor,
|
|
230
|
+
self.core_id,
|
|
231
|
+
self.data,
|
|
232
|
+
self.rowtype,
|
|
233
|
+
self.raw_fields,
|
|
234
|
+
self.position,
|
|
235
|
+
)
|
|
197
236
|
|
|
198
237
|
def __eq__(self, other):
|
|
199
238
|
return self.__key() == other.__key()
|
|
@@ -214,10 +253,9 @@ def csv_line_to_fields(csv_line, line_ending, field_ending, fields_enclosed_by):
|
|
|
214
253
|
raw_fields = []
|
|
215
254
|
|
|
216
255
|
if fields_enclosed_by == "":
|
|
217
|
-
opts = {
|
|
256
|
+
opts = {"quoting": csv.QUOTE_NONE}
|
|
218
257
|
else:
|
|
219
|
-
opts = {
|
|
220
|
-
'quotechar': fields_enclosed_by}
|
|
258
|
+
opts = {"quoting": csv.QUOTE_ALL, "quotechar": fields_enclosed_by}
|
|
221
259
|
|
|
222
260
|
for row in csv.reader([csv_line], delimiter=field_ending, **opts):
|
|
223
261
|
for f in row:
|
dwca/star_record.py
CHANGED
|
@@ -5,16 +5,19 @@ import itertools
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class StarRecordIterator(object):
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
"""Object used to iterate over multiple DWCA-files joined on the coreid
|
|
9
|
+
|
|
10
|
+
:param files_to_join: a list of the `dwca.files.CSVDataFile`s we'd like to join.
|
|
11
|
+
May or may not include the core file (the core is not treated in a special way)
|
|
12
|
+
:param how: indicates the type of join. "inner" and "outer" correspond vaguely to
|
|
13
|
+
inner and full joins. The outer join includes rows that don't match on all files,
|
|
14
|
+
however, it doesn't create null fields to fill in when rows are missing in files.
|
|
15
|
+
Attempts to conform to pandas.DataFrame.merge API.
|
|
16
16
|
"""
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, files_to_join: List[CSVDataFile], how: Literal["inner", "outer"] = "inner"
|
|
20
|
+
):
|
|
18
21
|
self.files_to_join = files_to_join
|
|
19
22
|
|
|
20
23
|
# gather the coreids we want to join over.
|
|
@@ -26,12 +29,11 @@ class StarRecordIterator(object):
|
|
|
26
29
|
# outer join: coreid may be in any files
|
|
27
30
|
elif how == "outer":
|
|
28
31
|
self.common_core |= set(data_file.coreid_index)
|
|
29
|
-
|
|
32
|
+
|
|
30
33
|
# initialize iterator variables
|
|
31
34
|
self._common_core_iterator = iter(self.common_core)
|
|
32
35
|
self._cross_product_iterator = iter([])
|
|
33
36
|
|
|
34
|
-
|
|
35
37
|
def __next__(self):
|
|
36
38
|
# the next combination of rows matching this coreid
|
|
37
39
|
next_positions = next(self._cross_product_iterator, None)
|
|
@@ -40,20 +42,26 @@ class StarRecordIterator(object):
|
|
|
40
42
|
# get the next coreid
|
|
41
43
|
self._current_coreid = next(self._common_core_iterator)
|
|
42
44
|
self._files_with_current_coreid = [
|
|
43
|
-
csv_file
|
|
44
|
-
|
|
45
|
+
csv_file
|
|
46
|
+
for csv_file in self.files_to_join
|
|
47
|
+
if self._current_coreid in csv_file.coreid_index
|
|
48
|
+
]
|
|
45
49
|
# this iterates over all combinations of rows matching a coreid from all files
|
|
46
50
|
self._cross_product_iterator = itertools.product(
|
|
47
51
|
*(
|
|
48
|
-
csv_file.coreid_index[self._current_coreid]
|
|
49
|
-
|
|
50
|
-
)
|
|
52
|
+
csv_file.coreid_index[self._current_coreid]
|
|
53
|
+
for csv_file in self._files_with_current_coreid
|
|
54
|
+
)
|
|
55
|
+
)
|
|
51
56
|
# go back and try to iterate over the rows for the new coreid
|
|
52
57
|
return next(self)
|
|
53
58
|
# zip up this combination of rows from all of the files.
|
|
54
59
|
return (
|
|
55
|
-
csv_file.get_row_by_position(position)
|
|
60
|
+
csv_file.get_row_by_position(position)
|
|
61
|
+
for position, csv_file in zip(
|
|
62
|
+
next_positions, self._files_with_current_coreid
|
|
63
|
+
)
|
|
56
64
|
)
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
def __iter__(self):
|
|
67
|
+
return self
|
dwca/test/helpers.py
CHANGED
dwca/test/test_datafile.py
CHANGED
|
@@ -35,13 +35,18 @@ class TestCSVDataFile(unittest.TestCase):
|
|
|
35
35
|
description_txt = extension_files[0]
|
|
36
36
|
vernacular_txt = extension_files[1]
|
|
37
37
|
|
|
38
|
-
expected_core = {
|
|
38
|
+
expected_core = {
|
|
39
|
+
"1": array("L", [0]),
|
|
40
|
+
"2": array("L", [1]),
|
|
41
|
+
"3": array("L", [2]),
|
|
42
|
+
"4": array("L", [3]),
|
|
43
|
+
}
|
|
39
44
|
assert core_txt.coreid_index == expected_core
|
|
40
45
|
|
|
41
|
-
expected_vernacular = {"1": array(
|
|
46
|
+
expected_vernacular = {"1": array("L", [0, 1, 2]), "2": array("L", [3])}
|
|
42
47
|
assert vernacular_txt.coreid_index == expected_vernacular
|
|
43
48
|
|
|
44
|
-
expected_description = {"1": array(
|
|
49
|
+
expected_description = {"1": array("L", [0, 1]), "4": array("L", [2])}
|
|
45
50
|
assert description_txt.coreid_index == expected_description
|
|
46
51
|
|
|
47
52
|
with pytest.raises(AttributeError):
|