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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.31
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any