python-re3data-v2 0.1.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.
re3data/_parser.py ADDED
@@ -0,0 +1,443 @@
1
+ """Parse re3data API XML documents (schema 4.0) into typed models.
2
+
3
+ The re3data v40 API only serves ``application/xml``. This module converts the
4
+ XML into the typed dataclasses from :mod:`re3data._resources` using only the
5
+ standard library :mod:`xml.etree.ElementTree`.
6
+
7
+ Robustness rules:
8
+
9
+ - Unknown elements and attributes are ignored (forward compatibility).
10
+ - Missing optional elements become ``None`` / empty lists, never errors.
11
+ - Malformed dates degrade to ``None`` instead of failing the whole document
12
+ (upstream data has shown such inconsistencies).
13
+ - Vocabulary values are kept as plain strings even if unknown to the schema.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import xml.etree.ElementTree as ET
19
+ from datetime import date
20
+
21
+ from re3data._exceptions import Re3dataError
22
+ from re3data._resources.repository import (
23
+ AdditionalName,
24
+ Api,
25
+ Certificate,
26
+ ContentType,
27
+ DataAccess,
28
+ DatabaseAccess,
29
+ DatabaseLicense,
30
+ DataLicense,
31
+ DataUpload,
32
+ DataUploadLicense,
33
+ Description,
34
+ EndDate,
35
+ FunderIdentifier,
36
+ FundingInformation,
37
+ Identifiers,
38
+ Institution,
39
+ InstitutionAdditionalName,
40
+ InstitutionIdentifier,
41
+ InstitutionName,
42
+ MetadataStandard,
43
+ Policy,
44
+ Profile,
45
+ QualityManagement,
46
+ QualityManagementMeasure,
47
+ Re3Data,
48
+ RelatedRepository,
49
+ Repository,
50
+ RepositoryContact,
51
+ RepositoryIdentifier,
52
+ RepositoryName,
53
+ Subject,
54
+ Syndication,
55
+ )
56
+ from re3data._resources.repository_list import Link, RepositoryList, RepositorySummary
57
+
58
+ _RE3DATA_NS = "{http://www.re3data.org/schema/4-0}"
59
+
60
+
61
+ def _local_name(element: ET.Element) -> str:
62
+ """The local name of an element, ignoring any XML namespace prefix.
63
+
64
+ Detail documents use the ``r3d:`` prefix, index list documents are served
65
+ without a namespace; matching on local names covers both.
66
+ """
67
+ return element.tag.rsplit("}", 1)[-1]
68
+
69
+
70
+ def _children(element: ET.Element, name: str) -> list[ET.Element]:
71
+ """All direct children of ``element`` with the given local name."""
72
+ return [child for child in element if _local_name(child) == name]
73
+
74
+
75
+ def _child(element: ET.Element, name: str) -> ET.Element | None:
76
+ """The first direct child of ``element`` with the given local name, if any."""
77
+ for child in element:
78
+ if _local_name(child) == name:
79
+ return child
80
+ return None
81
+
82
+
83
+ def _text(element: ET.Element | None) -> str | None:
84
+ """The stripped text of an element, or None if absent/empty."""
85
+ if element is None:
86
+ return None
87
+ text = (element.text or "").strip()
88
+ return text or None
89
+
90
+
91
+ def _texts(element: ET.Element, name: str) -> list[str]:
92
+ """The stripped texts of all children with the given local name."""
93
+ return [text for child in _children(element, name) if (text := _text(child)) is not None]
94
+
95
+
96
+ def _attr(element: ET.Element | None, name: str) -> str | None:
97
+ if element is None:
98
+ return None
99
+ value = element.get(name)
100
+ return value if value else None
101
+
102
+
103
+ def _date_value(text: str | None) -> date | None:
104
+ if text is None:
105
+ return None
106
+ try:
107
+ return date.fromisoformat(text)
108
+ except ValueError:
109
+ # upstream data occasionally carries malformed dates; degrade gracefully
110
+ return None
111
+
112
+
113
+ def _parse_repository_name(element: ET.Element) -> RepositoryName:
114
+ return RepositoryName(value=_text(element) or "", language=_attr(element, "language"))
115
+
116
+
117
+ def _parse_additional_name(element: ET.Element) -> AdditionalName:
118
+ return AdditionalName(value=_text(element) or "", language=_attr(element, "language"))
119
+
120
+
121
+ def _parse_description(element: ET.Element) -> Description:
122
+ return Description(value=_text(element) or "", language=_attr(element, "language"))
123
+
124
+
125
+ def _parse_institution_name(element: ET.Element) -> InstitutionName:
126
+ return InstitutionName(value=_text(element) or "", language=_attr(element, "language"))
127
+
128
+
129
+ def _parse_institution_additional_name(element: ET.Element) -> InstitutionAdditionalName:
130
+ return InstitutionAdditionalName(value=_text(element) or "", language=_attr(element, "language"))
131
+
132
+
133
+ def _parse_repository_identifier(element: ET.Element) -> RepositoryIdentifier:
134
+ return RepositoryIdentifier(
135
+ repositoryIdentifierType=_text(_child(element, "repositoryIdentifierType")),
136
+ repositoryIdentifierValue=_text(_child(element, "repositoryIdentifierValue")),
137
+ )
138
+
139
+
140
+ def _parse_repository_contact(element: ET.Element) -> RepositoryContact:
141
+ return RepositoryContact(
142
+ repositoryContactInformation=_text(_child(element, "repositoryContactInformation")),
143
+ repositoryContactType=_text(_child(element, "repositoryContactType")),
144
+ )
145
+
146
+
147
+ def _parse_end_date(element: ET.Element) -> EndDate:
148
+ return EndDate(
149
+ closed=_child(element, "closed") is not None,
150
+ offline=_child(element, "offline") is not None,
151
+ )
152
+
153
+
154
+ def _parse_related_repository(element: ET.Element) -> RelatedRepository:
155
+ return RelatedRepository(
156
+ relatedRepositoryId=_text(_child(element, "relatedRepositoryId")),
157
+ relatedRepositoryType=_text(_child(element, "relatedRepositoryType")),
158
+ )
159
+
160
+
161
+ def _parse_subject(element: ET.Element) -> Subject:
162
+ return Subject(
163
+ subjectId=_text(_child(element, "subjectId")),
164
+ subjectName=_text(_child(element, "subjectName")),
165
+ subjectScheme=_attr(element, "subjectScheme"),
166
+ )
167
+
168
+
169
+ def _parse_content_type(element: ET.Element) -> ContentType:
170
+ return ContentType(
171
+ contentTypeId=_text(_child(element, "contentTypeId")),
172
+ contentTypeName=_text(_child(element, "contentTypeName")),
173
+ contentTypeScheme=_attr(element, "contentTypeScheme"),
174
+ )
175
+
176
+
177
+ def _parse_institution_identifier(element: ET.Element) -> InstitutionIdentifier:
178
+ return InstitutionIdentifier(
179
+ institutionIdentifierType=_text(_child(element, "institutionIdentifierType")),
180
+ institutionIdentifierUrl=_text(_child(element, "institutionIdentifierUrl")),
181
+ )
182
+
183
+
184
+ def _parse_institution(element: ET.Element) -> Institution:
185
+ name_element = _child(element, "institutionName")
186
+ return Institution(
187
+ institutionName=_parse_institution_name(name_element) if name_element is not None else None,
188
+ institutionAdditionalName=[
189
+ _parse_institution_additional_name(child) for child in _children(element, "institutionAdditionalName")
190
+ ],
191
+ institutionCountry=_text(_child(element, "institutionCountry")),
192
+ responsibilityType=_texts(element, "responsibilityType"),
193
+ institutionType=_text(_child(element, "institutionType")),
194
+ institutionUrl=_text(_child(element, "institutionUrl")),
195
+ institutionIdentifier=[_parse_institution_identifier(child) for child in _children(element, "institutionIdentifier")],
196
+ responsibilityStartDate=_date_value(_text(_child(element, "responsibilityStartDate"))),
197
+ responsibilityEndDate=_date_value(_text(_child(element, "responsibilityEndDate"))),
198
+ )
199
+
200
+
201
+ def _parse_policy(element: ET.Element) -> Policy:
202
+ return Policy(policyName=_text(_child(element, "policyName")), policyUrl=_text(_child(element, "policyUrl")))
203
+
204
+
205
+ def _parse_funder_identifier(element: ET.Element) -> FunderIdentifier:
206
+ return FunderIdentifier(
207
+ funderIdentifierType=_text(_child(element, "funderIdentifierType")),
208
+ funderIdentifierValue=_text(_child(element, "funderIdentifierValue")),
209
+ )
210
+
211
+
212
+ def _parse_funding_information(element: ET.Element) -> FundingInformation:
213
+ return FundingInformation(
214
+ funderName=_text(_child(element, "funderName")),
215
+ funderIdentifier=[_parse_funder_identifier(child) for child in _children(element, "funderIdentifier")],
216
+ grantIdentifier=_text(_child(element, "grantIdentifier")),
217
+ )
218
+
219
+
220
+ def _parse_database_access(element: ET.Element) -> DatabaseAccess:
221
+ return DatabaseAccess(
222
+ databaseAccessType=_text(_child(element, "databaseAccessType")),
223
+ databaseAccessRestriction=_texts(element, "databaseAccessRestriction"),
224
+ )
225
+
226
+
227
+ def _parse_database_license(element: ET.Element) -> DatabaseLicense:
228
+ return DatabaseLicense(
229
+ databaseLicenseName=_text(_child(element, "databaseLicenseName")),
230
+ databaseLicenseUrl=_text(_child(element, "databaseLicenseUrl")),
231
+ )
232
+
233
+
234
+ def _parse_data_access(element: ET.Element) -> DataAccess:
235
+ return DataAccess(
236
+ dataAccessType=_text(_child(element, "dataAccessType")),
237
+ dataAccessRestriction=_texts(element, "dataAccessRestriction"),
238
+ )
239
+
240
+
241
+ def _parse_data_license(element: ET.Element) -> DataLicense:
242
+ return DataLicense(
243
+ dataLicenseName=_text(_child(element, "dataLicenseName")),
244
+ dataLicenseUrl=_text(_child(element, "dataLicenseUrl")),
245
+ )
246
+
247
+
248
+ def _parse_data_upload(element: ET.Element) -> DataUpload:
249
+ return DataUpload(
250
+ dataUploadType=_text(_child(element, "dataUploadType")),
251
+ dataUploadRestriction=_texts(element, "dataUploadRestriction"),
252
+ )
253
+
254
+
255
+ def _parse_data_upload_license(element: ET.Element) -> DataUploadLicense:
256
+ return DataUploadLicense(
257
+ dataUploadLicenseName=_text(_child(element, "dataUploadLicenseName")),
258
+ dataUploadLicenseUrl=_text(_child(element, "dataUploadLicenseUrl")),
259
+ )
260
+
261
+
262
+ def _parse_api(element: ET.Element) -> Api:
263
+ return Api(
264
+ apiType=_text(_child(element, "apiType")),
265
+ apiUrl=_text(_child(element, "apiUrl")),
266
+ apiDocumentation=_text(_child(element, "apiDocumentation")),
267
+ )
268
+
269
+
270
+ def _parse_quality_management_measure(element: ET.Element) -> QualityManagementMeasure:
271
+ return QualityManagementMeasure(
272
+ qualityManagementMeasureName=_text(_child(element, "qualityManagementMeasureName")),
273
+ qualityManagementMeasureUrl=_text(_child(element, "qualityManagementMeasureUrl")),
274
+ )
275
+
276
+
277
+ def _parse_quality_management(element: ET.Element) -> QualityManagement:
278
+ return QualityManagement(
279
+ qualityManagementStatus=_text(_child(element, "qualityManagementStatus")),
280
+ qualityManagementMeasure=[
281
+ _parse_quality_management_measure(child) for child in _children(element, "qualityManagementMeasure")
282
+ ],
283
+ )
284
+
285
+
286
+ def _parse_certificate(element: ET.Element) -> Certificate:
287
+ return Certificate(
288
+ certificateName=_text(_child(element, "certificateName")),
289
+ certificateStartDate=_date_value(_text(_child(element, "certificateStartDate"))),
290
+ certificateEndDate=_date_value(_text(_child(element, "certificateEndDate"))),
291
+ certificateUrl=_text(_child(element, "certificateUrl")),
292
+ certificateWidget=_text(_child(element, "certificateWidget")),
293
+ )
294
+
295
+
296
+ def _parse_profile(element: ET.Element) -> Profile:
297
+ return Profile(profileName=_text(_child(element, "profileName")), profileUrl=_text(_child(element, "profileUrl")))
298
+
299
+
300
+ def _parse_metadata_standard(element: ET.Element) -> MetadataStandard:
301
+ return MetadataStandard(
302
+ metadataStandardName=_text(_child(element, "metadataStandardName")),
303
+ metadataStandardUrl=_text(_child(element, "metadataStandardUrl")),
304
+ )
305
+
306
+
307
+ def _parse_syndication(element: ET.Element) -> Syndication:
308
+ return Syndication(
309
+ syndicationType=_text(_child(element, "syndicationType")),
310
+ syndicationUrl=_text(_child(element, "syndicationUrl")),
311
+ )
312
+
313
+
314
+ def _parse_repository(element: ET.Element) -> Repository:
315
+ identifiers_element = _child(element, "identifiers")
316
+ end_date_element = _child(element, "endDate")
317
+ quality_management_element = _child(element, "qualityManagement")
318
+ database_access_element = _child(element, "databaseAccess")
319
+ return Repository(
320
+ identifiers=(
321
+ Identifiers(
322
+ re3data=_text(_child(identifiers_element, "re3data")),
323
+ doi=_text(_child(identifiers_element, "doi")),
324
+ )
325
+ if identifiers_element is not None
326
+ else None
327
+ ),
328
+ repositoryName=(
329
+ _parse_repository_name(name_element)
330
+ if (name_element := _child(element, "repositoryName")) is not None
331
+ else None
332
+ ),
333
+ additionalName=[_parse_additional_name(child) for child in _children(element, "additionalName")],
334
+ repositoryUrl=_text(_child(element, "repositoryUrl")),
335
+ repositoryIdentifier=[_parse_repository_identifier(child) for child in _children(element, "repositoryIdentifier")],
336
+ description=[_parse_description(child) for child in _children(element, "description")],
337
+ repositoryContact=[_parse_repository_contact(child) for child in _children(element, "repositoryContact")],
338
+ type=_texts(element, "type"),
339
+ size=_text(_child(element, "size")),
340
+ startDate=_date_value(_text(_child(element, "startDate"))),
341
+ endDate=_parse_end_date(end_date_element) if end_date_element is not None else None,
342
+ relatedRepository=[_parse_related_repository(child) for child in _children(element, "relatedRepository")],
343
+ repositoryLanguage=_texts(element, "repositoryLanguage"),
344
+ subject=[_parse_subject(child) for child in _children(element, "subject")],
345
+ missionStatementUrl=_text(_child(element, "missionStatementUrl")),
346
+ contentType=[_parse_content_type(child) for child in _children(element, "contentType")],
347
+ providerType=_texts(element, "providerType"),
348
+ keyword=_texts(element, "keyword"),
349
+ institution=[_parse_institution(child) for child in _children(element, "institution")],
350
+ policy=[_parse_policy(child) for child in _children(element, "policy")],
351
+ fundingInformation=[_parse_funding_information(child) for child in _children(element, "fundingInformation")],
352
+ databaseAccess=_parse_database_access(database_access_element) if database_access_element is not None else None,
353
+ databaseLicense=[_parse_database_license(child) for child in _children(element, "databaseLicense")],
354
+ dataAccess=[_parse_data_access(child) for child in _children(element, "dataAccess")],
355
+ dataLicense=[_parse_data_license(child) for child in _children(element, "dataLicense")],
356
+ dataUpload=[_parse_data_upload(child) for child in _children(element, "dataUpload")],
357
+ dataUploadLicense=[_parse_data_upload_license(child) for child in _children(element, "dataUploadLicense")],
358
+ software=_texts(element, "software"),
359
+ versioning=_text(_child(element, "versioning")),
360
+ api=[_parse_api(child) for child in _children(element, "api")],
361
+ pidSystem=_texts(element, "pidSystem"),
362
+ citationReference=_texts(element, "citationReference"),
363
+ metrics=_texts(element, "metrics"),
364
+ citationGuidelineUrl=_text(_child(element, "citationGuidelineUrl")),
365
+ aidSystem=_texts(element, "aidSystem"),
366
+ enhancedPublication=_text(_child(element, "enhancedPublication")),
367
+ qualityManagement=(
368
+ _parse_quality_management(quality_management_element) if quality_management_element is not None else None
369
+ ),
370
+ certificate=[_parse_certificate(child) for child in _children(element, "certificate")],
371
+ profile=[_parse_profile(child) for child in _children(element, "profile")],
372
+ metadataStandard=[_parse_metadata_standard(child) for child in _children(element, "metadataStandard")],
373
+ syndication=[_parse_syndication(child) for child in _children(element, "syndication")],
374
+ remarks=_text(_child(element, "remarks")),
375
+ entryDate=_date_value(_text(_child(element, "entryDate"))),
376
+ lastUpdate=_date_value(_text(_child(element, "lastUpdate"))),
377
+ )
378
+
379
+
380
+ def _parse_link(element: ET.Element) -> Link:
381
+ return Link(href=_attr(element, "href"), rel=_attr(element, "rel"))
382
+
383
+
384
+ def _parse_repository_summary(element: ET.Element) -> RepositorySummary:
385
+ link_element = _child(element, "link")
386
+ return RepositorySummary(
387
+ id=_text(_child(element, "id")),
388
+ doi=_text(_child(element, "doi")),
389
+ name=_text(_child(element, "name")),
390
+ link=_parse_link(link_element) if link_element is not None else None,
391
+ )
392
+
393
+
394
+ def parse_re3data(text: str) -> Re3Data:
395
+ """Parse a repository detail XML document into a :class:`Re3Data` object.
396
+
397
+ Args:
398
+ text: The XML document text as returned by ``GET /api/v40/repository/{id}``.
399
+
400
+ Returns:
401
+ The parsed document; repositories may be empty if the document carries none.
402
+
403
+ Raises:
404
+ Re3dataError: If the document is not well-formed XML.
405
+ """
406
+ try:
407
+ root = ET.fromstring(text)
408
+ except ET.ParseError as error:
409
+ raise Re3dataError(f"Unable to parse re3data XML response: {error}") from error
410
+ repositories = [_parse_repository(child) for child in _children(root, "repository")]
411
+ return Re3Data(repository=repositories)
412
+
413
+
414
+ def parse_repository(text: str) -> Repository:
415
+ """Parse a repository detail XML document and return its single repository.
416
+
417
+ Raises:
418
+ Re3dataError: If the document is not well-formed XML or contains no repository.
419
+ """
420
+ document = parse_re3data(text)
421
+ if not document.repository:
422
+ raise Re3dataError("re3data XML response contains no repository element")
423
+ return document.repository[0]
424
+
425
+
426
+ def parse_repository_list(text: str) -> RepositoryList:
427
+ """Parse a repository index list XML document into a :class:`RepositoryList`.
428
+
429
+ Args:
430
+ text: The XML document text as returned by ``GET /api/v40/repositories``.
431
+
432
+ Returns:
433
+ The parsed list; entries may be empty if the document carries none.
434
+
435
+ Raises:
436
+ Re3dataError: If the document is not well-formed XML.
437
+ """
438
+ try:
439
+ root = ET.fromstring(text)
440
+ except ET.ParseError as error:
441
+ raise Re3dataError(f"Unable to parse re3data XML response: {error}") from error
442
+ summaries = [_parse_repository_summary(child) for child in _children(root, "repository")]
443
+ return RepositoryList(repository=summaries)
@@ -0,0 +1,19 @@
1
+ """API resources mirroring the re3data schema 4.0 documents."""
2
+
3
+ from re3data._resources.repository import (
4
+ Re3Data,
5
+ Repository,
6
+ )
7
+ from re3data._resources.repository_list import (
8
+ Link,
9
+ RepositoryList,
10
+ RepositorySummary,
11
+ )
12
+
13
+ __all__ = (
14
+ "Link",
15
+ "Re3Data",
16
+ "Repository",
17
+ "RepositoryList",
18
+ "RepositorySummary",
19
+ )