python-dwca-reader 0.16.2__py3-none-any.whl → 0.16.3__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 +81 -39
- 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.3.dist-info}/METADATA +1 -1
- python_dwca_reader-0.16.3.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.3.dist-info}/AUTHORS +0 -0
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.3.dist-info}/LICENSE.txt +0 -0
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.3.dist-info}/WHEEL +0 -0
- {python_dwca_reader-0.16.2.dist-info → python_dwca_reader-0.16.3.dist-info}/top_level.txt +0 -0
dwca/read.py
CHANGED
|
@@ -14,7 +14,12 @@ from pandas.io.parsers import TextFileReader
|
|
|
14
14
|
|
|
15
15
|
import dwca.vendor
|
|
16
16
|
from dwca.descriptors import ArchiveDescriptor, DataFileDescriptor, shorten_term
|
|
17
|
-
from dwca.exceptions import
|
|
17
|
+
from dwca.exceptions import (
|
|
18
|
+
RowNotFound,
|
|
19
|
+
InvalidArchive,
|
|
20
|
+
InvalidSimpleArchive,
|
|
21
|
+
NotADataFile,
|
|
22
|
+
)
|
|
18
23
|
from dwca.files import CSVDataFile
|
|
19
24
|
from dwca.helpers import remove_tree
|
|
20
25
|
from dwca.rows import CoreRow
|
|
@@ -72,7 +77,7 @@ class DwCAReader(object):
|
|
|
72
77
|
|
|
73
78
|
default_metadata_filename = "EML.xml"
|
|
74
79
|
default_metafile_name = "meta.xml"
|
|
75
|
-
source_metadata_directory =
|
|
80
|
+
source_metadata_directory = "dataset"
|
|
76
81
|
|
|
77
82
|
def __enter__(self):
|
|
78
83
|
return self
|
|
@@ -80,7 +85,13 @@ class DwCAReader(object):
|
|
|
80
85
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
81
86
|
self.close()
|
|
82
87
|
|
|
83
|
-
def __init__(
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
path: str,
|
|
91
|
+
extensions_to_ignore: List[str] = None,
|
|
92
|
+
tmp_dir: str = None,
|
|
93
|
+
skip_metadata: bool = False,
|
|
94
|
+
) -> None:
|
|
84
95
|
"""Open the Darwin Core Archive."""
|
|
85
96
|
if extensions_to_ignore is None:
|
|
86
97
|
extensions_to_ignore = []
|
|
@@ -92,7 +103,9 @@ class DwCAReader(object):
|
|
|
92
103
|
#: The path to the Darwin Core Archive file, as passed to the constructor.
|
|
93
104
|
self.archive_path = path # type: str
|
|
94
105
|
|
|
95
|
-
if os.path.isdir(
|
|
106
|
+
if os.path.isdir(
|
|
107
|
+
self.archive_path
|
|
108
|
+
): # Archive is a (directly readable) directory
|
|
96
109
|
self._working_directory_path = self.archive_path
|
|
97
110
|
self._directory_to_clean = None # type: Optional[str]
|
|
98
111
|
else: # Archive is zipped/tgzipped, we have to extract it first.
|
|
@@ -104,15 +117,18 @@ class DwCAReader(object):
|
|
|
104
117
|
self._metafile_handle = None
|
|
105
118
|
try:
|
|
106
119
|
self._metafile_handle = self.open_included_file(self.default_metafile_name)
|
|
107
|
-
self.descriptor = ArchiveDescriptor(
|
|
108
|
-
|
|
120
|
+
self.descriptor = ArchiveDescriptor(
|
|
121
|
+
self._metafile_handle.read(), files_to_ignore=extensions_to_ignore
|
|
122
|
+
)
|
|
109
123
|
except IOError as exc:
|
|
110
124
|
if exc.errno == ENOENT:
|
|
111
125
|
pass
|
|
112
126
|
|
|
113
127
|
#: 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 =
|
|
128
|
+
#: of the archive, or `None` if the archive has no metadata or if the `skip_metadata` parameter is True.
|
|
129
|
+
self.metadata = None # type: Optional[Element]
|
|
130
|
+
if not skip_metadata:
|
|
131
|
+
self.metadata = self._parse_metadata_file() # type: Optional[Element]
|
|
116
132
|
|
|
117
133
|
#: If the archive contains source-level metadata (typically, GBIF downloads), this is a dict such as::
|
|
118
134
|
#:
|
|
@@ -122,23 +138,33 @@ class DwCAReader(object):
|
|
|
122
138
|
#: See :doc:`gbif_results` for more details.
|
|
123
139
|
self.source_metadata = self._get_source_metadata() # type: Dict[str, Element]
|
|
124
140
|
|
|
125
|
-
if
|
|
141
|
+
if (
|
|
142
|
+
self.descriptor
|
|
143
|
+
): # We have an Archive descriptor that we can use to access data files.
|
|
126
144
|
#: An instance of :class:`dwca.files.CSVDataFile` for the core data file.
|
|
127
|
-
self.core_file = CSVDataFile(
|
|
145
|
+
self.core_file = CSVDataFile(
|
|
146
|
+
self._working_directory_path, self.descriptor.core
|
|
147
|
+
) # type: CSVDataFile
|
|
128
148
|
|
|
129
149
|
#: A list of :class:`dwca.files.CSVDataFile`, one entry for each extension data file , sorted by order of
|
|
130
150
|
#: appearance in the Metafile (or an empty list if the archive doesn't use extensions).
|
|
131
|
-
self.extension_files = [
|
|
132
|
-
|
|
133
|
-
|
|
151
|
+
self.extension_files = [
|
|
152
|
+
CSVDataFile(
|
|
153
|
+
work_directory=self._working_directory_path, file_descriptor=d
|
|
154
|
+
)
|
|
155
|
+
for d in self.descriptor.extensions
|
|
156
|
+
] # type: List[CSVDataFile]
|
|
134
157
|
else: # Archive without descriptor, we'll have to find and inspect the data file
|
|
135
158
|
try:
|
|
136
159
|
datafile_name = self._is_valid_simple_archive()
|
|
137
160
|
descriptor = DataFileDescriptor.make_from_file(
|
|
138
|
-
os.path.join(self._working_directory_path, datafile_name)
|
|
161
|
+
os.path.join(self._working_directory_path, datafile_name)
|
|
162
|
+
)
|
|
139
163
|
|
|
140
|
-
self.core_file = CSVDataFile(
|
|
141
|
-
|
|
164
|
+
self.core_file = CSVDataFile(
|
|
165
|
+
work_directory=self._working_directory_path,
|
|
166
|
+
file_descriptor=descriptor,
|
|
167
|
+
)
|
|
142
168
|
self.extension_files = []
|
|
143
169
|
except InvalidSimpleArchive:
|
|
144
170
|
msg = "No Metafile was found, but the archive contains multiple files/directories."
|
|
@@ -146,14 +172,17 @@ class DwCAReader(object):
|
|
|
146
172
|
|
|
147
173
|
def _get_source_metadata(self) -> Dict[str, Element]:
|
|
148
174
|
source_metadata = {} # type: Dict[str, Element]
|
|
149
|
-
source_metadata_dir = os.path.join(
|
|
175
|
+
source_metadata_dir = os.path.join(
|
|
176
|
+
self._working_directory_path, self.source_metadata_directory
|
|
177
|
+
)
|
|
150
178
|
|
|
151
179
|
if os.path.isdir(source_metadata_dir):
|
|
152
180
|
for f in os.listdir(source_metadata_dir):
|
|
153
181
|
if os.path.isfile(os.path.join(source_metadata_dir, f)):
|
|
154
182
|
dataset_key = os.path.splitext(f)[0]
|
|
155
183
|
source_metadata[dataset_key] = self._parse_xml_included_file(
|
|
156
|
-
os.path.join(self.source_metadata_directory, f)
|
|
184
|
+
os.path.join(self.source_metadata_directory, f)
|
|
185
|
+
)
|
|
157
186
|
|
|
158
187
|
return source_metadata
|
|
159
188
|
|
|
@@ -192,23 +221,27 @@ class DwCAReader(object):
|
|
|
192
221
|
not supported in case the value returned by `pandas.read_csv()` is a `TextFileReader` (e.g. when using
|
|
193
222
|
`chunksize` or `iterator=True`).
|
|
194
223
|
"""
|
|
195
|
-
datafile_descriptor = self.get_descriptor_for(
|
|
224
|
+
datafile_descriptor = self.get_descriptor_for(
|
|
225
|
+
relative_path
|
|
226
|
+
) # type: DataFileDescriptor
|
|
196
227
|
|
|
197
228
|
if not dwca.vendor._has_pandas:
|
|
198
229
|
raise ImportError("Pandas is missing.")
|
|
199
230
|
|
|
200
231
|
from pandas import read_csv
|
|
201
232
|
|
|
202
|
-
kwargs[
|
|
203
|
-
kwargs[
|
|
204
|
-
kwargs[
|
|
205
|
-
kwargs[
|
|
233
|
+
kwargs["delimiter"] = datafile_descriptor.fields_terminated_by
|
|
234
|
+
kwargs["skiprows"] = datafile_descriptor.lines_to_ignore
|
|
235
|
+
kwargs["header"] = None
|
|
236
|
+
kwargs["names"] = datafile_descriptor.short_headers
|
|
206
237
|
|
|
207
|
-
df_or_textreader = read_csv(
|
|
238
|
+
df_or_textreader = read_csv(
|
|
239
|
+
self.absolute_temporary_path(relative_path), **kwargs
|
|
240
|
+
)
|
|
208
241
|
|
|
209
242
|
# Add a column for default values, if present in the file descriptor
|
|
210
243
|
for field in datafile_descriptor.fields:
|
|
211
|
-
field_default_value = field[
|
|
244
|
+
field_default_value = field["default"]
|
|
212
245
|
if field_default_value is not None:
|
|
213
246
|
if isinstance(df_or_textreader, TextFileReader):
|
|
214
247
|
# I don't see how to assign default values to a TextFileReader, so
|
|
@@ -220,7 +253,7 @@ class DwCAReader(object):
|
|
|
220
253
|
"the archive."
|
|
221
254
|
)
|
|
222
255
|
|
|
223
|
-
df_or_textreader[shorten_term(field[
|
|
256
|
+
df_or_textreader[shorten_term(field["term"])] = field_default_value
|
|
224
257
|
|
|
225
258
|
return df_or_textreader
|
|
226
259
|
|
|
@@ -249,7 +282,9 @@ class DwCAReader(object):
|
|
|
249
282
|
ids = temp_ids.keys()
|
|
250
283
|
|
|
251
284
|
for extension in self.extension_files:
|
|
252
|
-
coreid_index = dict(
|
|
285
|
+
coreid_index = dict(
|
|
286
|
+
(k, v.tolist()) for k, v in extension.coreid_index.items()
|
|
287
|
+
)
|
|
253
288
|
for id in ids:
|
|
254
289
|
coreid_index.pop(id, None)
|
|
255
290
|
indexes[extension.file_descriptor.file_location] = coreid_index
|
|
@@ -311,7 +346,7 @@ class DwCAReader(object):
|
|
|
311
346
|
- The position is often an appropriate way to unambiguously identify a core row in a DwCA.
|
|
312
347
|
|
|
313
348
|
"""
|
|
314
|
-
for
|
|
349
|
+
for i, row in enumerate(self):
|
|
315
350
|
if i == position:
|
|
316
351
|
return row
|
|
317
352
|
|
|
@@ -340,7 +375,9 @@ class DwCAReader(object):
|
|
|
340
375
|
File existence is not tested.
|
|
341
376
|
|
|
342
377
|
"""
|
|
343
|
-
return os.path.abspath(
|
|
378
|
+
return os.path.abspath(
|
|
379
|
+
os.path.join(self._working_directory_path, relative_path)
|
|
380
|
+
)
|
|
344
381
|
|
|
345
382
|
def get_descriptor_for(self, relative_path: str) -> DataFileDescriptor:
|
|
346
383
|
"""Return a descriptor for the data file located at relative_path.
|
|
@@ -365,7 +402,6 @@ class DwCAReader(object):
|
|
|
365
402
|
raise NotADataFile("{fn} is not a data file".format(fn=relative_path))
|
|
366
403
|
|
|
367
404
|
def _is_valid_simple_archive(self) -> str:
|
|
368
|
-
|
|
369
405
|
# If the working dir appear to contains a valid simple darwin core archive
|
|
370
406
|
# (one single data file + possibly some metadata), returns the name of the data file.
|
|
371
407
|
#
|
|
@@ -396,7 +432,7 @@ class DwCAReader(object):
|
|
|
396
432
|
"""Load the archive (scientific) Metadata file, parse it with\
|
|
397
433
|
ElementTree and return its content (or `None` if the archive has no metadata).
|
|
398
434
|
|
|
399
|
-
:raises: :class:`dwca.exceptions.InvalidArchive` if the archive references
|
|
435
|
+
:raises: :class:`dwca.exceptions.InvalidArchive` if the archive references a non-existent
|
|
400
436
|
metadata file.
|
|
401
437
|
"""
|
|
402
438
|
# If the archive has descriptor, look for the metadata filename there.
|
|
@@ -407,14 +443,18 @@ class DwCAReader(object):
|
|
|
407
443
|
return self._parse_xml_included_file(filename)
|
|
408
444
|
except IOError as exc:
|
|
409
445
|
if exc.errno == ENOENT: # File not found
|
|
410
|
-
msg = "{} is referenced in the archive descriptor but missing.".format(
|
|
446
|
+
msg = "{} is referenced in the archive descriptor but missing.".format(
|
|
447
|
+
filename
|
|
448
|
+
)
|
|
411
449
|
raise InvalidArchive(msg)
|
|
412
450
|
|
|
413
451
|
else: # Otherwise, the metadata file has to be named 'EML.xml'
|
|
414
452
|
try:
|
|
415
453
|
return self._parse_xml_included_file(self.default_metadata_filename)
|
|
416
454
|
except IOError as exc:
|
|
417
|
-
if
|
|
455
|
+
if (
|
|
456
|
+
exc.errno == ENOENT
|
|
457
|
+
): # File not found, this is an archive without metadata
|
|
418
458
|
return None
|
|
419
459
|
|
|
420
460
|
assert False # For MyPy, see: https://github.com/python/mypy/issues/4223#issuecomment-342865133
|
|
@@ -428,7 +468,7 @@ class DwCAReader(object):
|
|
|
428
468
|
# In that case, we work with a stripped string instead.
|
|
429
469
|
# Note that this method cannot be generalized because it won't work well with encoding specified in the xml
|
|
430
470
|
# tag. This is why we're only choosing it in case of error
|
|
431
|
-
data_as_string = self.open_included_file(relative_path,
|
|
471
|
+
data_as_string = self.open_included_file(relative_path, "r").read()
|
|
432
472
|
return ET.fromstring(data_as_string.strip())
|
|
433
473
|
|
|
434
474
|
def _unzip_or_untar(self) -> str:
|
|
@@ -444,14 +484,16 @@ class DwCAReader(object):
|
|
|
444
484
|
try:
|
|
445
485
|
# Security note: with Python < 2.7.4, a zip file may be able to write outside of the
|
|
446
486
|
# directory using absolute paths, parent (..) path, ... See note in ZipFile.extract doc
|
|
447
|
-
zipfile.ZipFile(self.archive_path,
|
|
487
|
+
zipfile.ZipFile(self.archive_path, "r").extractall(tmp_dir)
|
|
448
488
|
except zipfile.BadZipfile:
|
|
449
489
|
# Doesn't look like a valid zip, let's see if it's a tar archive (possibly compressed)
|
|
450
490
|
try:
|
|
451
491
|
# TODO: Once we only support Python 3.12+, we should pass the filter="data" argument to extractall()
|
|
452
|
-
tarfile.open(self.archive_path,
|
|
492
|
+
tarfile.open(self.archive_path, "r:*").extractall(tmp_dir)
|
|
453
493
|
except tarfile.ReadError:
|
|
454
|
-
raise InvalidArchive(
|
|
494
|
+
raise InvalidArchive(
|
|
495
|
+
"The archive cannot be read. Is it a .zip or .tgz file?"
|
|
496
|
+
)
|
|
455
497
|
|
|
456
498
|
return tmp_dir
|
|
457
499
|
|
|
@@ -487,7 +529,7 @@ class DwCAReader(object):
|
|
|
487
529
|
extension_file.close()
|
|
488
530
|
if self._metafile_handle:
|
|
489
531
|
self._metafile_handle.close()
|
|
490
|
-
|
|
532
|
+
|
|
491
533
|
if self._directory_to_clean:
|
|
492
534
|
remove_tree(self._directory_to_clean)
|
|
493
535
|
|
|
@@ -495,7 +537,7 @@ class DwCAReader(object):
|
|
|
495
537
|
"""Return `True` if the Core file of the archive contains the `term_url` term."""
|
|
496
538
|
return term_url in self.core_file.file_descriptor.terms
|
|
497
539
|
|
|
498
|
-
def __iter__(self) ->
|
|
540
|
+
def __iter__(self) -> "DwCAReader":
|
|
499
541
|
self._corefile_pointer = 0
|
|
500
542
|
return self
|
|
501
543
|
|
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):
|