dv_schema_models 0.9.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.
- dv_schema_models/__init__.py +1 -0
- dv_schema_models/dataset_instance.py +305 -0
- dv_schema_models/dataverse_schema.py +109 -0
- dv_schema_models/file_instance.py +124 -0
- dv_schema_models/role_assignments.py +89 -0
- dv_schema_models/schema_driven_records.py +99 -0
- dv_schema_models/schema_spreadsheet.py +134 -0
- dv_schema_models-0.9.0.dist-info/METADATA +216 -0
- dv_schema_models-0.9.0.dist-info/RECORD +10 -0
- dv_schema_models-0.9.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""dv_schema_models."""
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Pydantic models for a Dataverse dataset export (`GET /api/datasets/:id` response).
|
|
2
|
+
|
|
3
|
+
This is the companion to dataverse_schema.py: that file models the *schema*
|
|
4
|
+
(what fields CAN exist and their rules), this file models actual *metadata
|
|
5
|
+
values* attached to one dataset. The shapes are different — here each field
|
|
6
|
+
is {typeName, multiple, typeClass, value}, and `value` itself is polymorphic
|
|
7
|
+
depending on typeClass/multiple:
|
|
8
|
+
|
|
9
|
+
- primitive / controlledVocabulary, multiple=False -> a plain string
|
|
10
|
+
- primitive / controlledVocabulary, multiple=True -> a list of strings
|
|
11
|
+
- compound, multiple=False -> a dict of nested fields
|
|
12
|
+
- compound, multiple=True -> a list of dicts of nested fields
|
|
13
|
+
"""
|
|
14
|
+
# ruff:file-ignore[mixed-case-variable-in-class-scope]
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
from dv_schema_models.file_instance import FileInstance
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DatasetFieldValue(BaseModel):
|
|
26
|
+
"""One metadata field value, as it appears inside metadataBlocks.<block>.fields."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="ignore")
|
|
29
|
+
|
|
30
|
+
typeName: str
|
|
31
|
+
multiple: bool
|
|
32
|
+
typeClass: str # 'primitive', 'compound', or 'controlledVocabulary'
|
|
33
|
+
value: str | list[str] | dict[str, DatasetFieldValue] | list[dict[str, DatasetFieldValue]]
|
|
34
|
+
|
|
35
|
+
def simple_value(self) -> Any:
|
|
36
|
+
"""Recursively strip away the typeName/multiple/typeClass wrapper, returning plain Python values.
|
|
37
|
+
|
|
38
|
+
Compound fields become plain dicts (or lists of dicts); primitive and
|
|
39
|
+
controlledVocabulary fields are already plain strings or lists of strings.
|
|
40
|
+
"""
|
|
41
|
+
if self.typeClass == "compound":
|
|
42
|
+
if isinstance(self.value, list):
|
|
43
|
+
return [{k: v.simple_value() for k, v in item.items()} for item in self.value]
|
|
44
|
+
if isinstance(self.value, dict):
|
|
45
|
+
return {k: v.simple_value() for k, v in self.value.items()}
|
|
46
|
+
return self.value
|
|
47
|
+
|
|
48
|
+
def get_fields(self) -> list[DatasetFieldValue]:
|
|
49
|
+
"""Return a flat list of all DatasetFieldValue objects nested inside this one."""
|
|
50
|
+
return [self.typeName for f in self.value]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Needed because DatasetFieldValue references itself by forward-reference string.
|
|
54
|
+
DatasetFieldValue.model_rebuild()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MetadataBlockInstance(BaseModel):
|
|
58
|
+
"""One populated metadata block (e.g. citation) within a dataset version."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(extra="ignore")
|
|
61
|
+
|
|
62
|
+
displayName: str
|
|
63
|
+
name: str
|
|
64
|
+
fields: list[DatasetFieldValue]
|
|
65
|
+
|
|
66
|
+
def field_names(self) -> list[str]:
|
|
67
|
+
"""Return the typeName of every field present in this block instance."""
|
|
68
|
+
return [f.typeName for f in self.fields]
|
|
69
|
+
|
|
70
|
+
def get_field(self, type_name: str) -> DatasetFieldValue | None:
|
|
71
|
+
"""Find a field in this block by its typeName (e.g. 'title', 'author')."""
|
|
72
|
+
return next((f for f in self.fields if f.typeName == type_name), None)
|
|
73
|
+
|
|
74
|
+
def get_value(self, type_name: str) -> Any:
|
|
75
|
+
"""Get the plain (unwrapped) value of a field in this block, or None if absent."""
|
|
76
|
+
field = self.get_field(type_name)
|
|
77
|
+
return field.simple_value() if field else None
|
|
78
|
+
|
|
79
|
+
def get_subfield_values(self, type_name: str, subfield: str) -> list[Any]:
|
|
80
|
+
"""Collect one subfield from a compound field, e.g. get_subfield_values('author', 'authorName') -> ['Author1', 'Author2']."""
|
|
81
|
+
value = self.get_value(type_name)
|
|
82
|
+
items = value if isinstance(value, list) else [value] if value else []
|
|
83
|
+
return [item[subfield] for item in items if subfield in item]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DatasetVersion(BaseModel):
|
|
87
|
+
"""One version of a dataset, holding the actual metadata block values and files."""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(extra="allow")
|
|
90
|
+
|
|
91
|
+
id: int
|
|
92
|
+
datasetId: int
|
|
93
|
+
versionState: str
|
|
94
|
+
datasetPersistentId: str
|
|
95
|
+
datasetType: (
|
|
96
|
+
str | None
|
|
97
|
+
) # backward compatibility: some datasets /older Dataverse versions don't have this field
|
|
98
|
+
storageIdentifier: str
|
|
99
|
+
versionNumber: int | None = None
|
|
100
|
+
internalVersionNumber: int
|
|
101
|
+
versionMinorNumber: int | None = None
|
|
102
|
+
latestVersionPublishingState: str
|
|
103
|
+
deaccessionLink: str | None = Field(None)
|
|
104
|
+
UNF: str | None = Field(None)
|
|
105
|
+
lastUpdateTime: str
|
|
106
|
+
releaseTime: str | None = Field(None)
|
|
107
|
+
createTime: str
|
|
108
|
+
termsOfUse: str | None = Field(None)
|
|
109
|
+
termsOfAccess: str | None = Field(None)
|
|
110
|
+
dataAccessPlace: str | None = Field(None)
|
|
111
|
+
fileAccessRequest: bool
|
|
112
|
+
|
|
113
|
+
metadataBlocks: dict[str, MetadataBlockInstance]
|
|
114
|
+
|
|
115
|
+
files: list[FileInstance] | None = None
|
|
116
|
+
|
|
117
|
+
def get_value(self, block_name: str, type_name: str) -> Any:
|
|
118
|
+
"""Get a field's plain value by block name (e.g. 'citation') and field typeName (e.g. 'title')."""
|
|
119
|
+
block = self.metadataBlocks.get(block_name)
|
|
120
|
+
return block.get_value(type_name) if block else None
|
|
121
|
+
|
|
122
|
+
def field_names(self, block_name: str) -> list[str]:
|
|
123
|
+
"""Get the typeNames of every field present in the given block (e.g. 'citation').
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
block_name
|
|
128
|
+
The name of the metadata block (e.g. 'citation', 'geospatial').
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
list[str]
|
|
133
|
+
A list of typeNames for the fields present in the specified block.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
block = self.metadataBlocks.get(block_name)
|
|
137
|
+
return block.field_names() if block else []
|
|
138
|
+
|
|
139
|
+
def get_raw(self, key: str) -> object | None:
|
|
140
|
+
"""Get a raw top-level field not covered by the schema.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
key
|
|
145
|
+
The name of the top-level field to retrieve.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
object | None
|
|
150
|
+
The value of the specified field, or None if not found.
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
return self.model_extra.get(key) if self.model_extra else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class IsPartOf(BaseModel):
|
|
158
|
+
"""The 'isPartOf' payload of a dataset export response.
|
|
159
|
+
|
|
160
|
+
See: https://github.com/IQSS/dataverse/blob/dd1859dde249df8b0612ca3899b5f00fcc6d082e/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java#L533-L562 (v6.11)
|
|
161
|
+
|
|
162
|
+
""" # ruff:ignore[doc-line-too-long]
|
|
163
|
+
|
|
164
|
+
model_config = ConfigDict(extra="allow")
|
|
165
|
+
|
|
166
|
+
type: str
|
|
167
|
+
identifier: str
|
|
168
|
+
isReleased: bool | None = None # backward compatibility
|
|
169
|
+
persistentIdentifier: str | None = None
|
|
170
|
+
displayName: str
|
|
171
|
+
isPartOf: IsPartOf | None = None # recursive
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def get_field_list(node: IsPartOf | None, field_name: str, *, from_root: bool = False) -> list:
|
|
175
|
+
"""Return the values of a field from a node to the root.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
node
|
|
180
|
+
The node to start from. May be None, in which case an empty list is returned.
|
|
181
|
+
|
|
182
|
+
field_name
|
|
183
|
+
The name of the field to retrieve (e.g. 'identifier', 'displayName').
|
|
184
|
+
|
|
185
|
+
from_root
|
|
186
|
+
If True, return the values from the root to this node; if False, return from this node to the root.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
list
|
|
191
|
+
A list of the values of the specified field.
|
|
192
|
+
|
|
193
|
+
""" # ruff:ignore[doc-line-too-long]
|
|
194
|
+
values = []
|
|
195
|
+
current = node
|
|
196
|
+
|
|
197
|
+
while current is not None:
|
|
198
|
+
values.append(getattr(current, field_name))
|
|
199
|
+
current = current.isPartOf
|
|
200
|
+
|
|
201
|
+
if not from_root:
|
|
202
|
+
values.reverse()
|
|
203
|
+
return values
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class DatasetData(BaseModel):
|
|
207
|
+
"""The 'data' payload of a dataset export response."""
|
|
208
|
+
|
|
209
|
+
model_config = ConfigDict(extra="allow")
|
|
210
|
+
|
|
211
|
+
id: int
|
|
212
|
+
identifier: str
|
|
213
|
+
persistentUrl: str
|
|
214
|
+
protocol: str
|
|
215
|
+
authority: str
|
|
216
|
+
separator: str
|
|
217
|
+
publisher: str
|
|
218
|
+
storageIdentifier: str
|
|
219
|
+
isPartOf: IsPartOf | None = None
|
|
220
|
+
datasetType: str | None = (
|
|
221
|
+
None # backward compatibility: some datasets /older Dataverse versions don't have this field
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
latestVersion: DatasetVersion | None = Field(
|
|
225
|
+
None, validation_alias=AliasChoices("datasetVersion", "latestVersion")
|
|
226
|
+
) # Deaccessioned dataset does not have this field.
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def datasetVersion(self) -> DatasetVersion | None:
|
|
230
|
+
"""Supports both export and Native JSON endpoints.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
DatasetVersion | None
|
|
235
|
+
The latest version of the dataset, or None if not present.
|
|
236
|
+
|
|
237
|
+
"""
|
|
238
|
+
return self.latestVersion
|
|
239
|
+
|
|
240
|
+
def get_raw(self, key: str) -> object | None:
|
|
241
|
+
"""Get a raw top-level field not covered by the schema.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
key
|
|
246
|
+
The name of the top-level field to retrieve.
|
|
247
|
+
|
|
248
|
+
Returns
|
|
249
|
+
-------
|
|
250
|
+
object | None
|
|
251
|
+
The value of the specified field, or None if not found.
|
|
252
|
+
|
|
253
|
+
"""
|
|
254
|
+
return self.model_extra.get(key) if self.model_extra else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class DatasetJson(BaseModel):
|
|
258
|
+
"""Top-level wrapper matching the raw JSON returned by GET /api/datasets/:id."""
|
|
259
|
+
|
|
260
|
+
model_config = ConfigDict(extra="ignore")
|
|
261
|
+
|
|
262
|
+
status: str
|
|
263
|
+
data: DatasetData
|
|
264
|
+
|
|
265
|
+
def get_value(
|
|
266
|
+
self, block_name: str, type_name: str
|
|
267
|
+
) -> str | list[str] | dict[str, Any] | list[dict[str, Any]] | None:
|
|
268
|
+
"""Get the value of a field by its block and type names.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
block_name: The name of the metadata block (e.g. 'citation', 'geospatial').
|
|
273
|
+
type_name: The typeName of the field within that block (e.g. 'title', 'author').
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
str | list[str] | dict[str, Any] | list[dict[str, Any]] | None: The value of the specified field, or None if not found.
|
|
278
|
+
|
|
279
|
+
""" # ruff:ignore[doc-line-too-long]
|
|
280
|
+
return self.data.latestVersion.get_value(block_name, type_name)
|
|
281
|
+
|
|
282
|
+
def field_names(self, block_name: str) -> list[str]:
|
|
283
|
+
"""Check what fields are present in a given block.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
block_name: The name of the metadata block (e.g. 'citation', 'geospatial').
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
list[str]: A list of typeNames for the fields present in the specified block.
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
return self.data.latestVersion.field_names(block_name)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def load_dataset(metadata: dict) -> DatasetJson:
|
|
298
|
+
"""Parse a dataset export JSON payload (already loaded as a dict) into a DatasetExport.
|
|
299
|
+
|
|
300
|
+
Accepts either the full `{status, data: {...}}` export envelope, or a bare
|
|
301
|
+
`data`-shaped payload (no envelope), by trying each model in turn.
|
|
302
|
+
"""
|
|
303
|
+
if "data" in metadata:
|
|
304
|
+
return DatasetJson.model_validate(metadata)
|
|
305
|
+
return DatasetJson(status="OK", data=DatasetData.model_validate(metadata))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Pydantic models for the Dataverse '/api/metadatablocks' schema response.
|
|
2
|
+
|
|
3
|
+
This models the *schema definition* JSON itself (block -> fields -> optional
|
|
4
|
+
recursive childFields), not an individual dataset's metadata values. Use it to
|
|
5
|
+
parse, validate, and query the schema (e.g. "what fields exist in the citation
|
|
6
|
+
block?", "is authorAffiliation required?", "what controlled vocab values does
|
|
7
|
+
subject accept?").
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# ruff: noqa: N815
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MetadataField(BaseModel):
|
|
18
|
+
"""A single metadata field definition.
|
|
19
|
+
|
|
20
|
+
For compound fields (typeClass == 'compound'), `childFields` holds the
|
|
21
|
+
nested sub-fields, keyed by field name, recursively using this same model.
|
|
22
|
+
Leaf fields (primitive or controlledVocabulary) have `childFields = None`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="ignore") # tolerate any schema fields we did not model
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
displayName: str
|
|
29
|
+
displayOnCreate: bool
|
|
30
|
+
title: str
|
|
31
|
+
type: str # e.g. 'TEXT', 'TEXTBOX', 'DATE', 'INT', 'FLOAT', 'URL', 'EMAIL', 'NONE'
|
|
32
|
+
typeClass: str # 'primitive', 'compound', or 'controlledVocabulary'
|
|
33
|
+
watermark: str = ""
|
|
34
|
+
description: str = ""
|
|
35
|
+
multiple: bool
|
|
36
|
+
isControlledVocabulary: bool
|
|
37
|
+
isAdvancedSearchFieldType: bool
|
|
38
|
+
displayFormat: str = ""
|
|
39
|
+
displayOrder: int
|
|
40
|
+
isRequired: bool
|
|
41
|
+
controlledVocabularyValues: list[str] | None = None
|
|
42
|
+
childFields: dict[str, MetadataField] | None = None
|
|
43
|
+
|
|
44
|
+
def is_compound(self) -> bool:
|
|
45
|
+
"""Return True if this field wraps nested childFields rather than holding a value directly."""
|
|
46
|
+
return self.childFields is not None
|
|
47
|
+
|
|
48
|
+
def iter_leaf_fields(self) -> list[MetadataField]:
|
|
49
|
+
"""Recursively collect every leaf (non-compound) field reachable from this field."""
|
|
50
|
+
if self.childFields:
|
|
51
|
+
leaves: list[MetadataField] = []
|
|
52
|
+
for child in self.childFields.values():
|
|
53
|
+
leaves.extend(child.iter_leaf_fields())
|
|
54
|
+
return leaves
|
|
55
|
+
return [self]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Needed because MetadataField references itself by forward-reference string.
|
|
59
|
+
MetadataField.model_rebuild()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MetadataBlock(BaseModel):
|
|
63
|
+
"""A single metadata block (e.g. citation, geospatial, astrophysics)."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(extra="ignore")
|
|
66
|
+
|
|
67
|
+
id: int
|
|
68
|
+
name: str
|
|
69
|
+
displayName: str
|
|
70
|
+
displayOnCreate: bool
|
|
71
|
+
fields: dict[str, MetadataField]
|
|
72
|
+
|
|
73
|
+
def get_field(self, field_name: str) -> MetadataField | None:
|
|
74
|
+
"""Look up a top-level field in this block by its key name."""
|
|
75
|
+
return self.fields.get(field_name)
|
|
76
|
+
|
|
77
|
+
def all_leaf_fields(self) -> dict[str, MetadataField]:
|
|
78
|
+
"""Flatten every leaf field in this block (including nested ones) into a single dict."""
|
|
79
|
+
flat: dict[str, MetadataField] = {}
|
|
80
|
+
for field in self.fields.values():
|
|
81
|
+
for leaf in field.iter_leaf_fields():
|
|
82
|
+
flat[leaf.name] = leaf
|
|
83
|
+
return flat
|
|
84
|
+
|
|
85
|
+
def required_fields(self) -> list[str]:
|
|
86
|
+
"""Return the names of all leaf fields marked isRequired = True."""
|
|
87
|
+
return [name for name, field in self.all_leaf_fields().items() if field.isRequired]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DataverseSchemaResponse(BaseModel):
|
|
91
|
+
"""Top-level wrapper matching the raw JSON returned by /api/metadatablocks."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(extra="ignore")
|
|
94
|
+
|
|
95
|
+
status: str
|
|
96
|
+
data: list[MetadataBlock]
|
|
97
|
+
|
|
98
|
+
def get_block(self, block_name: str) -> MetadataBlock | None:
|
|
99
|
+
"""Look up a metadata block by its short name (e.g. 'citation', 'geospatial')."""
|
|
100
|
+
return next((b for b in self.data if b.name == block_name), None)
|
|
101
|
+
|
|
102
|
+
def block_names(self) -> list[str]:
|
|
103
|
+
"""List the short names of every block in the response."""
|
|
104
|
+
return [b.name for b in self.data]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_schema(metadata: dict) -> DataverseSchemaResponse:
|
|
108
|
+
"""Parse a metadatablocks JSON payload (already loaded as a dict) into a DataverseSchemaResponse."""
|
|
109
|
+
return DataverseSchemaResponse.model_validate(metadata)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""File representation models."""
|
|
2
|
+
# ruff: noqa: N815
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DataFile(BaseModel):
|
|
15
|
+
"""The 'dataFile' object nested inside a FileInstance."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="allow")
|
|
18
|
+
|
|
19
|
+
id: int | None = None
|
|
20
|
+
persistentId: str | None = None
|
|
21
|
+
filename: str | None = None
|
|
22
|
+
contentType: str | None = None
|
|
23
|
+
friendlyType: str | None = None
|
|
24
|
+
filesize: int | None = None
|
|
25
|
+
description: str | None = None
|
|
26
|
+
storageIdentifier: str | None = None
|
|
27
|
+
rootDataFileId: int | None = None
|
|
28
|
+
md5: str | None = None
|
|
29
|
+
checksum: CheckSum | None = None
|
|
30
|
+
tabularData: bool | None = None
|
|
31
|
+
creationDate: str | None = None
|
|
32
|
+
directoryLabel: str | None = None
|
|
33
|
+
lastUpdateTime: str | None = None
|
|
34
|
+
fileAccessRequest: bool | None = None
|
|
35
|
+
|
|
36
|
+
def get_raw(self, key: str) -> object | None:
|
|
37
|
+
"""Get a raw top-level field not covered by the schema (requires extra='allow')."""
|
|
38
|
+
return self.model_extra.get(key) if self.model_extra else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CheckSum(BaseModel):
|
|
42
|
+
"""The 'checksum' object nested inside a DataFile."""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(extra="allow")
|
|
45
|
+
|
|
46
|
+
type: str
|
|
47
|
+
value: str
|
|
48
|
+
|
|
49
|
+
def get_raw(self, key: str) -> object | None:
|
|
50
|
+
"""Get a raw top-level field not covered by the schema (requires extra='allow')."""
|
|
51
|
+
return self.model_extra.get(key) if self.model_extra else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FileInstance(BaseModel):
|
|
55
|
+
"""One file, as it appears inside a dataset version."""
|
|
56
|
+
|
|
57
|
+
model_config = ConfigDict(extra="allow")
|
|
58
|
+
|
|
59
|
+
label: str | None = None
|
|
60
|
+
restricted: bool | None = None
|
|
61
|
+
directoryLabel: str | None = None
|
|
62
|
+
version: int | None = None
|
|
63
|
+
datasetVersionId: int | None = None
|
|
64
|
+
dataFile: DataFile | None = None
|
|
65
|
+
|
|
66
|
+
def get_raw(self, key: str) -> object | None:
|
|
67
|
+
"""Get a raw top-level field not covered by the schema (requires extra='allow')."""
|
|
68
|
+
return self.model_extra.get(key) if self.model_extra else None
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def sum_field(instances: list[FileInstance], field: str) -> float | None:
|
|
72
|
+
"""Sum a DataFile field across FileInstances, skipping entries with no dataFile or None values.
|
|
73
|
+
|
|
74
|
+
Returns None (and logs a warning) if any present value isn't int/float.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
instances
|
|
79
|
+
A list of FileInstance objects to sum over.
|
|
80
|
+
field
|
|
81
|
+
The name of the field to sum, e.g. "dataFile.filesize".
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
float | None
|
|
86
|
+
The sum of the field values, or None if any value is non-numeric.
|
|
87
|
+
|
|
88
|
+
""" # noqa: W505
|
|
89
|
+
values = [getattr(i.dataFile, field) for i in instances if i.dataFile is not None]
|
|
90
|
+
for v in values:
|
|
91
|
+
if v is not None and not isinstance(v, (int, float)):
|
|
92
|
+
logger.warning("sum_field: field %r has non-numeric value %r", field, v)
|
|
93
|
+
return None
|
|
94
|
+
return sum(v for v in values if v is not None)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def list_field(instances: list[FileInstance], field: str) -> list[Any]:
|
|
98
|
+
"""Get the values of a field across instances, e.g. field="dataFile.checksum.type".
|
|
99
|
+
|
|
100
|
+
Skips instances where the path is missing or resolves to None.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
instances
|
|
105
|
+
A list of FileInstance objects to extract values from.
|
|
106
|
+
|
|
107
|
+
field
|
|
108
|
+
The name of the field to extract, e.g. "dataFile.checksum.type".
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
list[Any]
|
|
113
|
+
A list of values for the specified field across the instances.
|
|
114
|
+
"""
|
|
115
|
+
result = []
|
|
116
|
+
for i in instances:
|
|
117
|
+
value: Any = i
|
|
118
|
+
for part in field.split("."):
|
|
119
|
+
value = getattr(value, part, None)
|
|
120
|
+
if value is None:
|
|
121
|
+
break
|
|
122
|
+
if value is not None:
|
|
123
|
+
result.append(value)
|
|
124
|
+
return result
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Role assignments model.
|
|
2
|
+
|
|
3
|
+
Corresponds to "List Role Assignments in a Dataset" endpoint: https://borealisdata.ca/guides/en/latest/api/native-api.html#list-role-assignments-in-a-dataset
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
# ruff: noqa: N815
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RoleAssignment(BaseModel):
|
|
12
|
+
"""A single role assignment."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
15
|
+
|
|
16
|
+
id: int | None = None
|
|
17
|
+
assignee: str | None = None
|
|
18
|
+
roleId: int | None = None
|
|
19
|
+
roleName: str | None = None
|
|
20
|
+
roleAlias: str | None = Field(default=None, alias="_roleAlias")
|
|
21
|
+
definitionPointId: int | None = None
|
|
22
|
+
|
|
23
|
+
def get_raw(self, key: str) -> object | None:
|
|
24
|
+
"""Get a raw top-level field not covered by the schema (requires extra='allow')."""
|
|
25
|
+
return self.model_extra.get(key) if self.model_extra else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RoleAssignments(BaseModel):
|
|
29
|
+
"""Role assignments model."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="allow")
|
|
32
|
+
|
|
33
|
+
status: str | None = None
|
|
34
|
+
data: list[RoleAssignment] | None = None
|
|
35
|
+
|
|
36
|
+
def count_field(self, field_name: str, value: str | int | None = None) -> int:
|
|
37
|
+
"""Count the number of occurrences of a field with a specific value. If value is None, counts all occurrences of the field regardless of value.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
field_name
|
|
42
|
+
The name of the field to count.
|
|
43
|
+
value
|
|
44
|
+
The value to look for and count. If None, counts all occurrences of the field regardless of value.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
int
|
|
49
|
+
The number of occurrences of the field with the specified value.
|
|
50
|
+
|
|
51
|
+
""" # noqa: W505
|
|
52
|
+
if not self.data:
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
if value is None:
|
|
56
|
+
return sum(1 for ra in self.data if hasattr(ra, field_name))
|
|
57
|
+
|
|
58
|
+
return sum(
|
|
59
|
+
1 for ra in self.data if hasattr(ra, field_name) and getattr(ra, field_name) == value
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def get_value(self, field_name: str) -> list[str | int | None]:
|
|
63
|
+
"""Get a list of all values for the given field name. Returns None for role assignments that don't have the field.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
field_name
|
|
68
|
+
The name of the field to retrieve values for.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
list[str | int | None]
|
|
73
|
+
A list of values for the specified field, or None for role assignments that don't have the field.
|
|
74
|
+
|
|
75
|
+
""" # noqa: W505
|
|
76
|
+
if not self.data:
|
|
77
|
+
return []
|
|
78
|
+
return [getattr(ra, field_name) if hasattr(ra, field_name) else None for ra in self.data]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_role_assignments(metadata: dict) -> RoleAssignments:
|
|
82
|
+
"""Parse a role assignments JSON payload (already loaded as a dict) into a RoleAssignments.
|
|
83
|
+
|
|
84
|
+
Accepts the full `{status, data: [...]}` export envelope (including error
|
|
85
|
+
responses with no `data`), or a bare single role assignment dict (no envelope).
|
|
86
|
+
"""
|
|
87
|
+
if "data" in metadata or "status" in metadata:
|
|
88
|
+
return RoleAssignments.model_validate(metadata)
|
|
89
|
+
return RoleAssignments(status="OK", data=[RoleAssignment.model_validate(metadata)])
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Build a Pydantic model FROM the Dataverse schema, then use it to validate real dataset values.
|
|
2
|
+
|
|
3
|
+
Workflow:
|
|
4
|
+
1. Load the metadatablocks schema (dataverse_schema.py) -> know what fields exist, their
|
|
5
|
+
types, whether they're required/multiple, and what compound sub-fields they contain.
|
|
6
|
+
2. Dynamically build a Pydantic model that mirrors that schema exactly, using pydantic.create_model.
|
|
7
|
+
3. Load a dataset export (dataset_instance.py) and flatten it to {typeName: value}.
|
|
8
|
+
4. Validate that flat dict against the schema-derived model, so the actual metadata is checked
|
|
9
|
+
against the same field names, required-ness, and structure the schema defines -- instead of
|
|
10
|
+
a generic model that would accept any field.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, create_model
|
|
18
|
+
|
|
19
|
+
from dv_schema_models.dataset_instance import MetadataBlockInstance
|
|
20
|
+
from dv_schema_models.dataverse_schema import MetadataBlock, MetadataField
|
|
21
|
+
|
|
22
|
+
# Maps a Dataverse field 'type' to a Python type. Anything not listed here (TEXT, TEXTBOX,
|
|
23
|
+
# DATE, URL, EMAIL, and controlled-vocabulary text) is treated as a plain str.
|
|
24
|
+
_PRIMITIVE_TYPE_MAP: dict[str, type] = {"INT": int, "FLOAT": float}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _python_type_for(field: MetadataField) -> type:
|
|
28
|
+
"""Map a leaf schema field's declared 'type' to a Python type.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
field: The schema MetadataField to map.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
type: The Python type corresponding to the Dataverse field's 'type'.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
return _PRIMITIVE_TYPE_MAP.get(field.type, str)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe_identifier(name: str) -> str:
|
|
43
|
+
"""Turn a Dataverse field name that may contain dots (e.g. 'resolution.Spatial') into a valid Python identifier."""
|
|
44
|
+
return name.replace(".", "_")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_record_model(
|
|
48
|
+
source: MetadataBlock | MetadataField, *, model_name: str | None = None
|
|
49
|
+
) -> type[BaseModel]:
|
|
50
|
+
"""Recursively build a Pydantic model matching a schema MetadataBlock (or a compound MetadataField).
|
|
51
|
+
|
|
52
|
+
The resulting model accepts exactly the fields the schema defines: correct nested structure
|
|
53
|
+
for compound fields, List[...] wrapping for multiple=True fields, and required vs Optional
|
|
54
|
+
matching isRequired.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(source, MetadataBlock):
|
|
57
|
+
fields = source.fields
|
|
58
|
+
default_name = f"{source.name.capitalize()}Record"
|
|
59
|
+
else:
|
|
60
|
+
fields = source.childFields or {}
|
|
61
|
+
default_name = f"{source.name.capitalize()}Record"
|
|
62
|
+
|
|
63
|
+
field_definitions: dict[str, Any] = {}
|
|
64
|
+
|
|
65
|
+
for field_name, field in fields.items():
|
|
66
|
+
py_name = _safe_identifier(field_name)
|
|
67
|
+
|
|
68
|
+
if field.is_compound():
|
|
69
|
+
base_type: Any = build_record_model(
|
|
70
|
+
field, model_name=f"{field_name.capitalize()}Record"
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
base_type = _python_type_for(field)
|
|
74
|
+
|
|
75
|
+
if field.multiple:
|
|
76
|
+
base_type = list[base_type]
|
|
77
|
+
|
|
78
|
+
if not field.isRequired:
|
|
79
|
+
base_type = Optional[base_type]
|
|
80
|
+
default = None
|
|
81
|
+
else:
|
|
82
|
+
default = ...
|
|
83
|
+
|
|
84
|
+
if py_name != field_name:
|
|
85
|
+
# Field name isn't a valid Python identifier as-is -- alias back to the original.
|
|
86
|
+
field_definitions[py_name] = (base_type, Field(default, alias=field_name))
|
|
87
|
+
else:
|
|
88
|
+
field_definitions[py_name] = (base_type, default)
|
|
89
|
+
|
|
90
|
+
return create_model(
|
|
91
|
+
model_name or default_name,
|
|
92
|
+
__config__=ConfigDict(populate_by_name=True, extra="forbid"),
|
|
93
|
+
**field_definitions,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def flatten_instance(block_instance: MetadataBlockInstance) -> dict[str, Any]:
|
|
98
|
+
"""Turn a dataset export's field list into a flat {typeName: value} dict, ready to validate."""
|
|
99
|
+
return {f.typeName: f.simple_value() for f in block_instance.fields}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Turns the dataverse schema into a spreadsheet."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import xlsxwriter
|
|
7
|
+
from xlsxwriter.format import Format
|
|
8
|
+
|
|
9
|
+
from dv_schema_models.dataverse_schema import DataverseSchemaResponse, MetadataBlock, MetadataField
|
|
10
|
+
|
|
11
|
+
# (title, definition, usage, repeatable, example, is_top_level, is_last_row_of_field)
|
|
12
|
+
Row = tuple[str, str, str, str, str, bool, bool]
|
|
13
|
+
|
|
14
|
+
COLUMN_WIDTHS = [30, 90, 10, 16, 60]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchemaSpreadsheet:
|
|
18
|
+
"""Turns the dataverse schema into a spreadsheet."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, schema: DataverseSchemaResponse) -> None:
|
|
21
|
+
"""Initialize the SchemaSpreadsheet with a DataverseSchemaResponse."""
|
|
22
|
+
self.schema = schema.data
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def _header_row() -> list[str]:
|
|
26
|
+
return ["Field", "Definition", "Usage", "Repeatable (Y/N)", "Example"]
|
|
27
|
+
|
|
28
|
+
def _title_fmt(self, workbook: xlsxwriter.Workbook):
|
|
29
|
+
return workbook.add_format({"bold": True})
|
|
30
|
+
|
|
31
|
+
def _block_fmt(self, workbook: xlsxwriter.Workbook):
|
|
32
|
+
return workbook.add_format({
|
|
33
|
+
"bold": True,
|
|
34
|
+
"align": "center",
|
|
35
|
+
"bg_color": f"#{random.randrange(0x1000000):06x}",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _field_rows(field: MetadataField) -> list[Row]:
|
|
40
|
+
"""Turn one top-level field (and its children) into spreadsheet rows."""
|
|
41
|
+
# ponytail: usage is RQ/O from isRequired; the schema has no "recommended" flag
|
|
42
|
+
usage = lambda f: "RQ" if f.isRequired else "O" # noqa: E731
|
|
43
|
+
repeatable = lambda f: "Y" if f.multiple else "N" # noqa: E731
|
|
44
|
+
|
|
45
|
+
if not field.childFields:
|
|
46
|
+
return [
|
|
47
|
+
(
|
|
48
|
+
field.title,
|
|
49
|
+
field.description,
|
|
50
|
+
usage(field),
|
|
51
|
+
repeatable(field),
|
|
52
|
+
field.watermark,
|
|
53
|
+
True,
|
|
54
|
+
True,
|
|
55
|
+
)
|
|
56
|
+
]
|
|
57
|
+
# Compound field: parent row carries only the definition, children the rest.
|
|
58
|
+
rows: list[Row] = [(field.title, field.description, "", "", "", True, False)]
|
|
59
|
+
children = sorted(field.childFields.values(), key=lambda f: f.displayOrder)
|
|
60
|
+
rows.extend(
|
|
61
|
+
(
|
|
62
|
+
child.title,
|
|
63
|
+
child.description,
|
|
64
|
+
usage(child),
|
|
65
|
+
# A child of a repeatable compound is effectively repeatable.
|
|
66
|
+
"Y" if (child.multiple or field.multiple) else "N",
|
|
67
|
+
child.watermark,
|
|
68
|
+
False,
|
|
69
|
+
child is children[-1],
|
|
70
|
+
)
|
|
71
|
+
for child in children
|
|
72
|
+
)
|
|
73
|
+
return rows
|
|
74
|
+
|
|
75
|
+
def _write_sheet(
|
|
76
|
+
self,
|
|
77
|
+
workbook: xlsxwriter.Workbook,
|
|
78
|
+
sheet_name: str,
|
|
79
|
+
blocks: list[MetadataBlock],
|
|
80
|
+
cell_fmts: dict[tuple[bool, bool], Format],
|
|
81
|
+
) -> None:
|
|
82
|
+
sheet = workbook.add_worksheet(sheet_name)
|
|
83
|
+
for col, width in enumerate(COLUMN_WIDTHS):
|
|
84
|
+
sheet.set_column(col, col, width)
|
|
85
|
+
|
|
86
|
+
sheet.write_row(0, 0, self._header_row(), self._title_fmt(workbook))
|
|
87
|
+
|
|
88
|
+
row_num = 1
|
|
89
|
+
for block in blocks:
|
|
90
|
+
sheet.merge_range(
|
|
91
|
+
row_num, 0, row_num, 4, f"{block.displayName} Block", self._block_fmt(workbook)
|
|
92
|
+
)
|
|
93
|
+
row_num += 1
|
|
94
|
+
for field in sorted(block.fields.values(), key=lambda f: f.displayOrder):
|
|
95
|
+
for title, *cells, bold, end in self._field_rows(field):
|
|
96
|
+
sheet.write(row_num, 0, title, cell_fmts[bold, end])
|
|
97
|
+
for col, value in enumerate(cells, start=1):
|
|
98
|
+
sheet.write(row_num, col, value, cell_fmts[False, end])
|
|
99
|
+
row_num += 1
|
|
100
|
+
|
|
101
|
+
def write(self, path: str | Path) -> Path:
|
|
102
|
+
"""Write an 'All' worksheet plus one per metadata block to an .xlsx file at `path`.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
path
|
|
107
|
+
The path to the .xlsx file to write. If it exists, it will be overwritten
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
Path
|
|
112
|
+
The path to the .xlsx file that was written.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
path = Path(path)
|
|
116
|
+
workbook = xlsxwriter.Workbook(str(path))
|
|
117
|
+
cell_fmts = {
|
|
118
|
+
(bold, end): workbook.add_format({
|
|
119
|
+
"bold": bold,
|
|
120
|
+
"bottom": 1 if end else 0,
|
|
121
|
+
"text_wrap": True,
|
|
122
|
+
"valign": "top",
|
|
123
|
+
})
|
|
124
|
+
for bold in (True, False)
|
|
125
|
+
for end in (True, False)
|
|
126
|
+
}
|
|
127
|
+
blocks = sorted(self.schema, key=lambda b: b.id)
|
|
128
|
+
self._write_sheet(workbook, "All", blocks, cell_fmts)
|
|
129
|
+
for block in blocks:
|
|
130
|
+
self._write_sheet(
|
|
131
|
+
workbook, block.displayName.removesuffix(" Metadata")[:31], [block], cell_fmts
|
|
132
|
+
)
|
|
133
|
+
workbook.close()
|
|
134
|
+
return path
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dv_schema_models
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Turns Harvard Dataverse Project metadatablocks schema and dataset JSON into Pydantic models.
|
|
5
|
+
Author: Ken Lui
|
|
6
|
+
Author-email: Ken Lui <kenlh.lui@utoronto.ca>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: pydantic>=2.0.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
10
|
+
Requires-Python: >=3.10, <4.0
|
|
11
|
+
Project-URL: homepage, https://github.com/kenlhlui/dv_schema_models
|
|
12
|
+
Project-URL: source, https://github.com/kenlhlui/dv_schema_models
|
|
13
|
+
Project-URL: changelog, https://github.com/kenlhlui/dv_schema_models/blob/main/CHANGELOG.md
|
|
14
|
+
Project-URL: releasenotes, https://github.com/kenlhlui/dv_schema_models/releases
|
|
15
|
+
Project-URL: documentation, https://kenlhlui.github.io/dv_schema_models
|
|
16
|
+
Project-URL: issues, https://github.com/kenlhlui/dv_schema_models/issues
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# dv_schema_models
|
|
20
|
+
|
|
21
|
+
Pydantic models for Dataverse metadata — parse the schema, load dataset
|
|
22
|
+
exports, and validate field values against the schema.
|
|
23
|
+
|
|
24
|
+
> [!CAUTION]
|
|
25
|
+
> This library is under active development and the API is not yet stable. Breaking changes may occur between releases. Please pin to a specific version in your `pyproject.toml` or `requirements.txt` if you want to avoid surprises.
|
|
26
|
+
|
|
27
|
+
## Pre-requisites
|
|
28
|
+
1. Python 3.10+
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
1. With `uv` (recommended):
|
|
33
|
+
```bash
|
|
34
|
+
uv add dv_schema_models
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
2. With `pip`:
|
|
38
|
+
```bash
|
|
39
|
+
pip install dv_schema_models
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
To export schemas to Excel (see [usage #5](#5-export-the-schema-to-a-spreadsheet)), install with the `spreadsheet` extra:
|
|
43
|
+
```bash
|
|
44
|
+
uv add "dv_schema_models[spreadsheet]" # or: pip install "dv_schema_models[spreadsheet]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Concepts
|
|
48
|
+
|
|
49
|
+
| Thing | What it is |
|
|
50
|
+
|---|---|
|
|
51
|
+
| **Schema** | `/api/metadatablocks` response — defines what fields *can* exist, their types, and rules |
|
|
52
|
+
| **Dataset instance** | `GET /api/datasets/:id` response — the actual metadata values for one dataset |
|
|
53
|
+
| **Record model** | A Pydantic model *generated from* the schema, used to validate instance values |
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### 1. Load and query the schema
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import json
|
|
61
|
+
from dv_schema_models.dataverse_schema import load_schema
|
|
62
|
+
|
|
63
|
+
schema = load_schema(json.load(open("dv_schema.json")))
|
|
64
|
+
|
|
65
|
+
schema.block_names() # ['citation', 'geospatial', ...]
|
|
66
|
+
block = schema.get_block("citation")
|
|
67
|
+
block.fields.keys() # top-level field names
|
|
68
|
+
block.required_fields() # leaf fields where isRequired=True
|
|
69
|
+
block.all_leaf_fields() # flattened, including nested compound fields
|
|
70
|
+
|
|
71
|
+
field = block.get_field("keyword")
|
|
72
|
+
field.is_compound() # True — has childFields
|
|
73
|
+
field.iter_leaf_fields() # [keywordValue, keywordVocabulary, ...]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Load a dataset and read values
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import json
|
|
80
|
+
from dv_schema_models.dataset_instance import IsPartOf, load_dataset
|
|
81
|
+
|
|
82
|
+
dataset = load_dataset(json.load(open("ds_metadata.json")))
|
|
83
|
+
|
|
84
|
+
# Load the possible typeNames for a given block
|
|
85
|
+
dataset.field_names("citation") # ['title', 'author', 'keyword', ...]
|
|
86
|
+
dataset.data.latestVersion.metadataBlocks.get("citation").field_names() # same
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Shortcut from the top level
|
|
90
|
+
dataset.get_value("citation", "title") # plain string
|
|
91
|
+
|
|
92
|
+
# Or drill down
|
|
93
|
+
block = dataset.data.latestVersion.metadataBlocks.get("citation")
|
|
94
|
+
block.get_value("keyword") # unwrapped Python value (str / list / dict)
|
|
95
|
+
block.get_field("author").simple_value() # [{'authorName': 'Author1', 'authorAffiliation': 'Author1Aff'...} ... {'authorName': 'Author2', 'authorAffiliation': 'Author2Aff'...}]
|
|
96
|
+
|
|
97
|
+
# Pull one subfield out of a compound field
|
|
98
|
+
block.get_subfield_values("author", "authorName") # ['Author1', 'Author2']
|
|
99
|
+
|
|
100
|
+
# Walk the isPartOf chain (dataset -> collection -> parent collection -> ...), possibly None
|
|
101
|
+
IsPartOf.get_field_list(dataset.data.isPartOf, "identifier") # ['sub-collection', 'top-collection']
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 3. Work with files
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from dv_schema_models.dataset_instance import load_dataset
|
|
108
|
+
from dv_schema_models.file_instance import FileInstance
|
|
109
|
+
|
|
110
|
+
dataset = load_dataset(json.load(open("ds_metadata.json")))
|
|
111
|
+
|
|
112
|
+
files = dataset.data.latestVersion.files or []
|
|
113
|
+
FileInstance.sum_field(files, "filesize") # sum a DataFile field across files, e.g. total filesize
|
|
114
|
+
FileInstance.list_field(files, "dataFile.filename") # list a field's values across files, dotted path for nested fields
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`sum_field` skips files with no `dataFile` or a `None` value for the field. Returns `None` (and logs a warning) if any present value isn't numeric.
|
|
118
|
+
|
|
119
|
+
`list_field` takes a dotted path (e.g. `"restricted"` for a top-level field, `"dataFile.checksum.type"` for a nested one) and skips entries where the path is missing or `None`.
|
|
120
|
+
|
|
121
|
+
### 4. Work with role assignments
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import json
|
|
125
|
+
from dv_schema_models.role_assignments import load_role_assignments
|
|
126
|
+
|
|
127
|
+
role_assignments = load_role_assignments(json.load(open("ds_role_assignments.json")))
|
|
128
|
+
|
|
129
|
+
role_assignments.count_field("assignee") # number of assignments with an "assignee" field
|
|
130
|
+
role_assignments.count_field("roleName", "Curator") # number of assignments where roleName == "Curator"
|
|
131
|
+
role_assignments.get_value("assignee") # ['@personA', '@personB', ...]
|
|
132
|
+
|
|
133
|
+
# Fields not on the schema (e.g. the `_roleAlias` Dataverse sends) are still reachable
|
|
134
|
+
role_assignments.data[0].get_raw("_roleAlias") # 'curator'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`load_role_assignments` also accepts the error envelope Dataverse returns when the request isn't permitted (`{"status": "ERROR", "message": "..."}`) — `data` is `None`, and `message` is reachable via `role_assignments.model_extra`.
|
|
138
|
+
|
|
139
|
+
### 5. Validate instance values against the schema
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import json
|
|
143
|
+
from dv_schema_models.dataverse_schema import load_schema
|
|
144
|
+
from dv_schema_models.dataset_instance import load_dataset
|
|
145
|
+
from dv_schema_models.schema_driven_records import build_record_model, flatten_instance
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
schema = load_schema(json.load(open("dv_schema.json")))
|
|
149
|
+
dataset = load_dataset(json.load(open("ds_metadata.json")))
|
|
150
|
+
|
|
151
|
+
citation_schema = schema.get_block("citation")
|
|
152
|
+
CitationRecord = build_record_model(citation_schema) # dynamic Pydantic model
|
|
153
|
+
|
|
154
|
+
block = dataset.data.latestVersion.metadataBlocks.get("citation")
|
|
155
|
+
raw = flatten_instance(block) # {typeName: value, ...}
|
|
156
|
+
record = CitationRecord.model_validate(raw)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The generated model enforces field names, required/optional status, list wrapping for `multiple=True` fields, and `int`/`float` types where declared by the schema.
|
|
160
|
+
|
|
161
|
+
### 6. Discover available fields
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# Fields actually present in this dataset instance
|
|
165
|
+
block = dataset.data.latestVersion.metadataBlocks.get("citation")
|
|
166
|
+
block.field_names() # e.g. ['title', 'author', 'keyword', ...]
|
|
167
|
+
|
|
168
|
+
# All fields the schema defines (including absent/optional ones)
|
|
169
|
+
schema.get_block("citation").all_leaf_fields().keys()
|
|
170
|
+
|
|
171
|
+
# After validation, access as typed attributes
|
|
172
|
+
record = CitationRecord.model_validate(flatten_instance(block))
|
|
173
|
+
record.title # str
|
|
174
|
+
record.author # list[...] for multiple=True compound fields
|
|
175
|
+
record.keyword # None if not present in this dataset (optional fields default to None)
|
|
176
|
+
# Note: field names with dots become underscores — e.g. 'resolution.Spatial' → record.resolution_Spatial
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### 7. Export the schema to a spreadsheet
|
|
180
|
+
|
|
181
|
+
Requires the `spreadsheet` extra (see [Installation](#installation)).
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
import json
|
|
185
|
+
from dv_schema_models.dataverse_schema import load_schema
|
|
186
|
+
from dv_schema_models.schema_spreadsheet import SchemaSpreadsheet
|
|
187
|
+
|
|
188
|
+
schema = load_schema(json.load(open("dv_schema.json")))
|
|
189
|
+
SchemaSpreadsheet(schema).write("dv_schema.xlsx")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Writes an `.xlsx` workbook with one formatted worksheet per metadata block plus a combined **All** sheet. See [docs/schema_spreadsheet/README.md](docs/schema_spreadsheet/README.md) for the output layout, column mapping, and architecture.
|
|
193
|
+
|
|
194
|
+
## Input file shapes
|
|
195
|
+
|
|
196
|
+
**Schema** — output of Dataverse `/api/metadatablocks`:
|
|
197
|
+
```json
|
|
198
|
+
{"status": "OK", "data": [{"id": 10, "name": "citation", "fields": {...}}]}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Dataset** — output of Dataverse `GET /api/datasets/:id`:
|
|
202
|
+
```json
|
|
203
|
+
{"status": "OK", "data": {"latestVersion": {"metadataBlocks": {"citation": {"fields": [...]}}}}}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Role assignments** — output of Dataverse `GET /api/datasets/:id/assignments`:
|
|
207
|
+
```json
|
|
208
|
+
{"status": "OK", "data": [{"id": 1, "assignee": "@user", "roleId": 7, "roleName": "Curator", "definitionPointId": 34847}]}
|
|
209
|
+
```
|
|
210
|
+
Error responses (e.g. `{"status": "ERROR", "message": "..."}`, no `data` key) are also accepted — see [usage #4](#4-work-with-role-assignments).
|
|
211
|
+
|
|
212
|
+
## Citation
|
|
213
|
+
If you use this library in your work, please cite according to [CITATION](CITATION.cff)
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
dv_schema_models/__init__.py,sha256=X3S5OpQrp39k1raDCGfNrvdAVInfNfuYmIb1GLjdYNc,24
|
|
2
|
+
dv_schema_models/dataset_instance.py,sha256=xXiWPw4Zd-rq2KAeCN50gXqABTPoiZtxh__BsjRcEOI,10575
|
|
3
|
+
dv_schema_models/dataverse_schema.py,sha256=KT9cHY0wHQUS5ig_v9wmwwXZBXT2GxmGObswCeDDRaM,4003
|
|
4
|
+
dv_schema_models/file_instance.py,sha256=UGZ6_s2HAFSkKormXJMEAmAz_sIK_C-knNddq9VjcWg,3992
|
|
5
|
+
dv_schema_models/role_assignments.py,sha256=-hYSLqIJkZl5EzonPDGCWKckgFwvUtFkQTZmT4rMdGI,3106
|
|
6
|
+
dv_schema_models/schema_driven_records.py,sha256=TNejnWfk-Yob-1oTxlvHSkYoEhhmU4JpR2tq2oCp2e0,3783
|
|
7
|
+
dv_schema_models/schema_spreadsheet.py,sha256=1pQ8Rjyoo09ux6hUn0Lf51FSgAfryqgTk4vYfo2cUPg,4756
|
|
8
|
+
dv_schema_models-0.9.0.dist-info/WHEEL,sha256=r-Se0i_n47Mj8pdnVuq7W628oP9YAKMaTjp4l3lQcns,81
|
|
9
|
+
dv_schema_models-0.9.0.dist-info/METADATA,sha256=yyohLs9IxJSNpRRAkAXzUAfBxSE5oXzgtPoEi1Gz-NQ,8699
|
|
10
|
+
dv_schema_models-0.9.0.dist-info/RECORD,,
|