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/rows.py CHANGED
@@ -1,11 +1,9 @@
1
1
  """Objects that represents data rows coming from DarwinCore Archives."""
2
2
 
3
3
  import csv
4
- import sys
5
- from typing import Dict, Optional
4
+ from typing import Dict, List, Optional
6
5
 
7
6
  from dwca.descriptors import DataFileDescriptor
8
- from dwca.exceptions import InvalidArchive
9
7
 
10
8
 
11
9
  class Row(object):
@@ -14,6 +12,8 @@ class Row(object):
14
12
  This class is intended to be subclassed rather than used directly.
15
13
  """
16
14
 
15
+ __slots__ = ("descriptor", "position", "rowtype", "raw_fields", "data")
16
+
17
17
  # Common ground for __str__ between subclasses
18
18
  def _build_str(self, source_str, id_str):
19
19
  txt = (
@@ -52,33 +52,50 @@ class Row(object):
52
52
  def __init__(
53
53
  self, csv_line: str, position: int, datafile_descriptor: DataFileDescriptor
54
54
  ) -> None:
55
- #: An instance of :class:`dwca.descriptors.DataFileDescriptor` describing the originating
56
- #: data file.
55
+ self._populate(
56
+ csv_line_to_fields(
57
+ csv_line,
58
+ line_ending=datafile_descriptor.lines_terminated_by,
59
+ field_ending=datafile_descriptor.fields_terminated_by,
60
+ fields_enclosed_by=datafile_descriptor.fields_enclosed_by,
61
+ ),
62
+ position,
63
+ datafile_descriptor,
64
+ )
65
+
66
+ @classmethod
67
+ def from_fields(
68
+ cls,
69
+ raw_fields: List[str],
70
+ position: int,
71
+ datafile_descriptor: DataFileDescriptor,
72
+ ) -> "Row":
73
+ """Build a Row from an already-split data row.
74
+
75
+ This is the constructor used by the streaming engine. :meth:`__init__`, which takes a
76
+ raw CSV line, is kept for backwards compatibility.
77
+ """
78
+ row = cls.__new__(cls)
79
+ row._populate(raw_fields, position, datafile_descriptor)
80
+ return row
81
+
82
+ def _populate(self, raw_fields, position, datafile_descriptor) -> None:
83
+ #: An instance of :class:`dwca.descriptors.DataFileDescriptor` describing the
84
+ #: originating data file.
57
85
  self.descriptor = datafile_descriptor # type: DataFileDescriptor
58
86
 
59
- #: The row position/index (starting at 0) in the source data file. This can be used, for example with
60
- #: :meth:`dwca.read.DwCAReader.get_corerow_by_position` or :meth:`dwca.files.CSVDataFile.get_row_by_position`.
87
+ #: The row position/index (starting at 0) in the source data file. This can be used,
88
+ #: for example with :meth:`dwca.read.DwCAReader.get_corerow_by_position` or
89
+ #: :meth:`dwca.files.CSVDataFile.get_row_by_position`.
61
90
  self.position = position # type: int
62
91
 
63
- #: The csv line type as stated in the archive descriptor.
64
- #: (or None if the archive has no descriptor). Examples:
65
- #: http://rs.tdwg.org/dwc/terms/Occurrence,
92
+ #: The csv line type as stated in the archive descriptor (or None if the archive has
93
+ #: no descriptor). Examples: http://rs.tdwg.org/dwc/terms/Occurrence,
66
94
  #: http://rs.gbif.org/terms/1.0/VernacularName, ...
67
95
  self.rowtype = self.descriptor.type # type: Optional[str]
68
96
 
69
- # self.raw_fields is a list of the csv_line's content
70
- #:
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
- )
77
-
78
- # TODO: raw_fields is a new property: to test
79
-
80
- # TODO: Consistency check ?? self.raw_fields length should be :
81
- # num of self.raw_fields described in core_meta + 2 (id and \n)
97
+ #: A list of the row's raw (unmapped) field values.
98
+ self.raw_fields = raw_fields
82
99
 
83
100
  #: A dict containing the Row data, such as::
84
101
  #:
@@ -90,29 +107,11 @@ class Row(object):
90
107
  #:
91
108
  #: myrow.data['http://rs.tdwg.org/dwc/terms/locality'] # => "Brussels"
92
109
  #:
93
- #: .. note:: The :func:`dwca.darwincore.utils.qualname` helper is available to make such calls less verbose.
94
- self.data = {} # type: Dict[str, str]
95
-
96
- for field_descriptor in self.descriptor.fields:
97
- try:
98
- column_index = int(field_descriptor["index"])
99
- field_row_value = self.raw_fields[column_index]
100
- except TypeError:
101
- # int() argument must be a string... We don't have an index for this field
102
- field_row_value = None
103
- except IndexError:
104
- msg = (
105
- "The descriptor references a non-existent field (index={i})".format(
106
- i=column_index
107
- )
108
- )
109
- raise InvalidArchive(msg)
110
-
111
- field_default_value = field_descriptor["default"]
112
-
113
- self.data[field_descriptor["term"]] = (
114
- field_row_value or field_default_value or ""
115
- )
110
+ #: .. note:: The :func:`dwca.darwincore.utils.qualname` helper is available to make
111
+ #: such calls less verbose.
112
+ self.data = datafile_descriptor.field_plan.build_data(
113
+ raw_fields
114
+ ) # type: Dict[str, str]
116
115
 
117
116
 
118
117
  class CoreRow(Row):
@@ -127,14 +126,14 @@ class CoreRow(Row):
127
126
  id_str = "Row id: " + str(self.id)
128
127
  return super(CoreRow, self)._build_str("Core file", id_str)
129
128
 
130
- def __init__(
131
- self, csv_line: str, position: int, datafile_descriptor: DataFileDescriptor
132
- ) -> None:
133
- super(CoreRow, self).__init__(csv_line, position, datafile_descriptor)
129
+ __slots__ = ("id", "source_metadata", "extension_data_files", "_extensions")
134
130
 
135
- if self.descriptor.id_index is not None:
131
+ def _populate(self, raw_fields, position, datafile_descriptor) -> None:
132
+ super(CoreRow, self)._populate(raw_fields, position, datafile_descriptor)
133
+
134
+ if datafile_descriptor.id_index is not None:
136
135
  #: The row id
137
- self.id = self.raw_fields[self.descriptor.id_index]
136
+ self.id = raw_fields[datafile_descriptor.id_index]
138
137
  else:
139
138
  self.id = None
140
139
 
@@ -183,25 +182,42 @@ class CoreRow(Row):
183
182
  # Should these 3 be factorized ? How ? Mixin ? Parent class ?
184
183
  def __key(self):
185
184
  """Return a tuple representing the row. Common ground between equality and hash."""
185
+ # A CoreRow obtained without going through DwCAReader iteration (e.g.
186
+ # CSVDataFile.get_row_by_position() called directly) never had link_extension_files()
187
+ # / link_source_metadata() called on it, so self.extensions and self.source_metadata
188
+ # don't exist. Fall back to None for those rows instead of letting the attribute
189
+ # access raise AttributeError. This doesn't change equality for linked rows (both
190
+ # attributes are always present after DwCAReader.next() links them).
191
+ extensions = self.extensions if hasattr(self, "extension_data_files") else None
192
+ source_metadata = (
193
+ self.source_metadata if hasattr(self, "source_metadata") else None
194
+ )
195
+
186
196
  return (
187
197
  self.descriptor,
188
198
  self.id,
189
199
  self.data,
190
- self.extensions,
191
- self.source_metadata,
200
+ extensions,
201
+ source_metadata,
192
202
  self.rowtype,
193
203
  self.raw_fields,
194
204
  self.position,
195
205
  )
196
206
 
197
207
  def __eq__(self, other):
198
- return self.__key() == other.__key()
208
+ # The isinstance test is required, not just defensive: __key is name-mangled, so
209
+ # other.__key() means other._CoreRow__key(), which an ExtensionRow (or any non-row)
210
+ # doesn't have. Without it, comparing to anything else raises AttributeError.
211
+ if not isinstance(other, CoreRow):
212
+ return NotImplemented
199
213
 
200
- def __ne__(self, other):
201
- return not self.__eq__(other)
214
+ return self.__key() == other.__key()
202
215
 
203
216
  def __hash__(self):
204
- return hash(self.__key())
217
+ # __key() embeds the data dict, the raw field list and the lazily-loaded extensions,
218
+ # none of which are hashable. Equal rows still hash equally because this is a subset
219
+ # of the equality key.
220
+ return hash((self.descriptor, self.id, self.rowtype, self.position))
205
221
 
206
222
 
207
223
  class ExtensionRow(Row):
@@ -215,13 +231,13 @@ class ExtensionRow(Row):
215
231
  id_str = "Core row id: " + str(self.core_id)
216
232
  return super(ExtensionRow, self)._build_str("Extension file", id_str)
217
233
 
218
- def __init__(
219
- self, csv_line: str, position: int, datafile_descriptor: DataFileDescriptor
220
- ) -> None:
221
- super(ExtensionRow, self).__init__(csv_line, position, datafile_descriptor)
234
+ __slots__ = ("core_id",)
235
+
236
+ def _populate(self, raw_fields, position, datafile_descriptor) -> None:
237
+ super(ExtensionRow, self)._populate(raw_fields, position, datafile_descriptor)
222
238
 
223
239
  #: The id of the core row this extension row is referring to.
224
- self.core_id = self.raw_fields[datafile_descriptor.coreid_index]
240
+ self.core_id = raw_fields[datafile_descriptor.coreid_index]
225
241
 
226
242
  def __key(self):
227
243
  """Return a tuple representing the row. Common ground between equality and hash."""
@@ -235,13 +251,15 @@ class ExtensionRow(Row):
235
251
  )
236
252
 
237
253
  def __eq__(self, other):
238
- return self.__key() == other.__key()
254
+ # See the note on CoreRow.__eq__: __key is name-mangled, so this test is what keeps
255
+ # comparisons against a CoreRow (or anything else) from raising AttributeError.
256
+ if not isinstance(other, ExtensionRow):
257
+ return NotImplemented
239
258
 
240
- def __ne__(self, other):
241
- return not self.__eq__(other)
259
+ return self.__key() == other.__key()
242
260
 
243
261
  def __hash__(self):
244
- return hash(self.__key())
262
+ return hash((self.descriptor, self.core_id, self.rowtype, self.position))
245
263
 
246
264
 
247
265
  def csv_line_to_fields(csv_line, line_ending, field_ending, fields_enclosed_by):
@@ -249,16 +267,25 @@ def csv_line_to_fields(csv_line, line_ending, field_ending, fields_enclosed_by):
249
267
 
250
268
  Return a list of fields. Content is not trimmed.
251
269
  """
252
- csv_line = csv_line.rstrip(line_ending)
253
- raw_fields = []
270
+ # A carriage return is stripped alongside the declared terminator: archives routinely
271
+ # declare "\n" while the file itself has CRLF line endings. This matches both the csv
272
+ # module's own behavior and CSVDataFile._iter_field_lists, so the two access paths agree.
273
+ csv_line = csv_line.rstrip(line_ending + "\r")
254
274
 
255
275
  if fields_enclosed_by == "":
256
- opts = {"quoting": csv.QUOTE_NONE}
257
- else:
258
- opts = {"quoting": csv.QUOTE_ALL, "quotechar": fields_enclosed_by}
259
-
260
- for row in csv.reader([csv_line], delimiter=field_ending, **opts):
261
- for f in row:
262
- field = f.strip(fields_enclosed_by)
263
- raw_fields.append(field)
264
- return raw_fields
276
+ # No enclosure: the line is simply split on the separator. This also keeps any
277
+ # quote character that happens to appear in the content.
278
+ return csv_line.split(field_ending)
279
+
280
+ # The csv module unwraps the enclosure and un-doubles escaped quote characters itself.
281
+ # Stripping the quote character afterwards would eat a legitimate leading or trailing
282
+ # one from the field's own content.
283
+ for row in csv.reader(
284
+ [csv_line],
285
+ delimiter=field_ending,
286
+ quotechar=fields_enclosed_by,
287
+ quoting=csv.QUOTE_MINIMAL,
288
+ ):
289
+ return row
290
+
291
+ return []
dwca/star_record.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from dwca.files import CSVDataFile
2
- from typing import List
3
- from typing_extensions import Literal
2
+ from typing import List, Literal
4
3
  import itertools
5
4
 
6
5
 
@@ -0,0 +1,188 @@
1
+ """Build synthetic Darwin Core Archives for tests.
2
+
3
+ Produces a directory-based archive (DwCAReader reads directories directly, so nothing
4
+ needs zipping). Every parameter that the Metafile can express is explicit, which is what
5
+ lets the characterization tests cover encodings, terminators, quoting and header counts
6
+ without committing more binary sample files.
7
+ """
8
+
9
+ import os
10
+ import shutil
11
+ import tempfile
12
+ from xml.sax.saxutils import quoteattr
13
+
14
+ TERM_PREFIX = "http://rs.tdwg.org/dwc/terms/"
15
+
16
+
17
+ def temp_archive_dir(test_case):
18
+ """Return a temporary directory that is removed when `test_case` finishes.
19
+
20
+ Several tests in the suite count the entries in the system temporary directory, so
21
+ leaking one per test would eventually make them flaky.
22
+ """
23
+ directory = tempfile.mkdtemp()
24
+ test_case.addCleanup(shutil.rmtree, directory, ignore_errors=True)
25
+ return directory
26
+
27
+ METAFILE_TEMPLATE = """<archive xmlns="http://rs.tdwg.org/dwc/text/">
28
+ <core encoding={encoding} fieldsTerminatedBy={fields_terminated_by} \
29
+ linesTerminatedBy={lines_terminated_by} fieldsEnclosedBy={fields_enclosed_by} \
30
+ ignoreHeaderLines={ignore_header_lines} rowType="http://rs.tdwg.org/dwc/terms/Occurrence">
31
+ <files><location>occurrence.txt</location></files>
32
+ <id index="{id_index}" />
33
+ {fields}
34
+ </core>
35
+ {extension}
36
+ </archive>
37
+ """
38
+
39
+ EXTENSION_TEMPLATE = """ <extension encoding={encoding} fieldsTerminatedBy={fields_terminated_by} \
40
+ linesTerminatedBy={lines_terminated_by} fieldsEnclosedBy={fields_enclosed_by} \
41
+ ignoreHeaderLines={ignore_header_lines} rowType="http://rs.gbif.org/terms/1.0/VernacularName">
42
+ <files><location>extension.txt</location></files>
43
+ <coreid index="0" />
44
+ {fields}
45
+ </extension>"""
46
+
47
+
48
+ def _escape(raw):
49
+ # The Metafile stores separators escaped, e.g. the tab character as the two
50
+ # characters backslash-t. Mirror what real archives contain.
51
+ escaped = (
52
+ raw.replace("\\", "\\\\")
53
+ .replace("\t", "\\t")
54
+ .replace("\n", "\\n")
55
+ .replace("\r", "\\r")
56
+ )
57
+ return quoteattr(escaped)
58
+
59
+
60
+ def _field_tags(terms, defaults, indent=" "):
61
+ lines = []
62
+ for index, term in enumerate(terms):
63
+ default = (defaults or {}).get(term)
64
+ if default is None:
65
+ lines.append(
66
+ '{i}<field index="{n}" term="{t}"/>'.format(i=indent, n=index, t=term)
67
+ )
68
+ else:
69
+ lines.append(
70
+ '{i}<field index="{n}" term="{t}" default={d}/>'.format(
71
+ i=indent, n=index, t=term, d=quoteattr(default)
72
+ )
73
+ )
74
+ for term, default in (defaults or {}).items():
75
+ if term not in terms:
76
+ lines.append(
77
+ '{i}<field term="{t}" default={d}/>'.format(
78
+ i=indent, t=term, d=quoteattr(default)
79
+ )
80
+ )
81
+ return "\n".join(lines)
82
+
83
+
84
+ def _render_rows(rows, fields_terminated_by, lines_terminated_by, fields_enclosed_by):
85
+ out = []
86
+ for row in rows:
87
+ if fields_enclosed_by:
88
+ cells = [
89
+ fields_enclosed_by
90
+ + cell.replace(fields_enclosed_by, fields_enclosed_by * 2)
91
+ + fields_enclosed_by
92
+ for cell in row
93
+ ]
94
+ else:
95
+ cells = list(row)
96
+ out.append(fields_terminated_by.join(cells))
97
+ return lines_terminated_by.join(out) + (lines_terminated_by if out else "")
98
+
99
+
100
+ def build_archive(
101
+ directory,
102
+ rows,
103
+ columns=None,
104
+ encoding="utf-8",
105
+ lines_terminated_by="\n",
106
+ fields_terminated_by="\t",
107
+ fields_enclosed_by="",
108
+ ignore_header_lines=0,
109
+ header_rows=(),
110
+ id_index=0,
111
+ terms=None,
112
+ defaults=None,
113
+ extension=None,
114
+ trailing_newline=True,
115
+ raw_payload=None,
116
+ ):
117
+ """Write a Darwin Core Archive into `directory` and return its path.
118
+
119
+ :param rows: list of lists of str, the data rows.
120
+ :param terms: list of full term URIs, one per column. Defaults to term0, term1, ...
121
+ :param defaults: dict term -> default value. A term not present in `terms` becomes a
122
+ default-only field (no index attribute), as the standard allows.
123
+ :param extension: list of rows for an extension data file, or None. Column 0 is the coreid.
124
+ :param raw_payload: bytes written verbatim as the core data file instead of `rows`.
125
+ Used to build inputs no well-formed writer would produce (undecodable bytes, ragged
126
+ rows, blank lines).
127
+ """
128
+ if columns is None:
129
+ columns = max([len(r) for r in rows] + [1]) if rows else 1
130
+ if terms is None:
131
+ terms = [TERM_PREFIX + "term" + str(i) for i in range(columns)]
132
+
133
+ metafile = METAFILE_TEMPLATE.format(
134
+ encoding=quoteattr(encoding),
135
+ fields_terminated_by=_escape(fields_terminated_by),
136
+ lines_terminated_by=_escape(lines_terminated_by),
137
+ fields_enclosed_by=_escape(fields_enclosed_by),
138
+ ignore_header_lines=quoteattr(str(ignore_header_lines)),
139
+ id_index=id_index,
140
+ fields=_field_tags(terms, defaults),
141
+ extension=(
142
+ ""
143
+ if extension is None
144
+ else EXTENSION_TEMPLATE.format(
145
+ encoding=quoteattr(encoding),
146
+ fields_terminated_by=_escape(fields_terminated_by),
147
+ lines_terminated_by=_escape(lines_terminated_by),
148
+ fields_enclosed_by=_escape(fields_enclosed_by),
149
+ ignore_header_lines=quoteattr(str(ignore_header_lines)),
150
+ fields=_field_tags(
151
+ [TERM_PREFIX + "vernacularName"], None, indent=" "
152
+ ).replace('index="0"', 'index="1"'),
153
+ )
154
+ ),
155
+ )
156
+
157
+ with open(os.path.join(directory, "meta.xml"), "w", encoding="utf-8") as f:
158
+ f.write(metafile)
159
+
160
+ data_path = os.path.join(directory, "occurrence.txt")
161
+ if raw_payload is not None:
162
+ with open(data_path, "wb") as f:
163
+ f.write(raw_payload)
164
+ else:
165
+ payload = _render_rows(
166
+ list(header_rows) + list(rows),
167
+ fields_terminated_by,
168
+ lines_terminated_by,
169
+ fields_enclosed_by,
170
+ )
171
+ if not trailing_newline and payload.endswith(lines_terminated_by):
172
+ payload = payload[: -len(lines_terminated_by)]
173
+ with open(data_path, "w", encoding=encoding, newline="") as f:
174
+ f.write(payload)
175
+
176
+ if extension is not None:
177
+ payload = _render_rows(
178
+ list(header_rows) + list(extension),
179
+ fields_terminated_by,
180
+ lines_terminated_by,
181
+ fields_enclosed_by,
182
+ )
183
+ with open(
184
+ os.path.join(directory, "extension.txt"), "w", encoding=encoding, newline=""
185
+ ) as f:
186
+ f.write(payload)
187
+
188
+ return directory