dv-schema-models 0.2.0a0__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.
File without changes
@@ -0,0 +1,165 @@
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: noqa: N815
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
21
+
22
+
23
+ class DatasetFieldValue(BaseModel):
24
+ """One metadata field value, as it appears inside metadataBlocks.<block>.fields."""
25
+
26
+ model_config = ConfigDict(extra="ignore")
27
+
28
+ typeName: str
29
+ multiple: bool
30
+ typeClass: str # 'primitive', 'compound', or 'controlledVocabulary'
31
+ value: (
32
+ str
33
+ | list[str]
34
+ | dict[str, DatasetFieldValue]
35
+ | list[dict[str, DatasetFieldValue]]
36
+ )
37
+
38
+ def simple_value(self) -> Any:
39
+ """Recursively strip away the typeName/multiple/typeClass wrapper, returning plain Python values.
40
+
41
+ Compound fields become plain dicts (or lists of dicts); primitive and
42
+ controlledVocabulary fields are already plain strings or lists of strings.
43
+ """
44
+ if self.typeClass == "compound":
45
+ if isinstance(self.value, list):
46
+ return [
47
+ {k: v.simple_value() for k, v in item.items()}
48
+ for item in self.value
49
+ ]
50
+ if isinstance(self.value, dict):
51
+ return {k: v.simple_value() for k, v in self.value.items()}
52
+ return self.value
53
+
54
+ def get_fields(self) -> list[DatasetFieldValue]:
55
+ return [self.typeName for f in self.value]
56
+
57
+
58
+ # Needed because DatasetFieldValue references itself by forward-reference string.
59
+ DatasetFieldValue.model_rebuild()
60
+
61
+
62
+ class MetadataBlockInstance(BaseModel):
63
+ """One populated metadata block (e.g. citation) within a dataset version."""
64
+
65
+ model_config = ConfigDict(extra="ignore")
66
+
67
+ displayName: str
68
+ name: str
69
+ fields: list[DatasetFieldValue]
70
+
71
+ def field_names(self) -> list[str]:
72
+ """Return the typeName of every field present in this block instance."""
73
+ return [f.typeName for f in self.fields]
74
+
75
+ def get_field(self, type_name: str) -> DatasetFieldValue | None:
76
+ """Find a field in this block by its typeName (e.g. 'title', 'author')."""
77
+ return next((f for f in self.fields if f.typeName == type_name), None)
78
+
79
+ def get_value(self, type_name: str) -> Any:
80
+ """Get the plain (unwrapped) value of a field in this block, or None if absent."""
81
+ field = self.get_field(type_name)
82
+ return field.simple_value() if field else None
83
+
84
+
85
+ class DatasetVersion(BaseModel):
86
+ """One version of a dataset, holding the actual metadata block values and files."""
87
+
88
+ model_config = ConfigDict(extra="ignore")
89
+
90
+ id: int
91
+ datasetId: int
92
+ versionState: str
93
+ datasetPersistentId: str
94
+ datasetType: (
95
+ str | None
96
+ ) # backward compatibility: some datasets /older Dataverse versions don't have this field
97
+ storageIdentifier: str
98
+ internalVersionNumber: int
99
+ versionState: str
100
+ latestVersionPublishingState: str
101
+ deaccessionLink: str | None
102
+ UNF: str | None
103
+ lastUpdateTime: str
104
+ createTime: str
105
+ termsOfUse: str | None
106
+ termsOfAccess: str | None
107
+ dataAccessPlace: str | None
108
+ fileAccessRequest: bool
109
+
110
+ metadataBlocks: dict[str, MetadataBlockInstance]
111
+
112
+ def get_value(self, block_name: str, type_name: str) -> Any:
113
+ """Get a field's plain value by block name (e.g. 'citation') and field typeName (e.g. 'title')."""
114
+ block = self.metadataBlocks.get(block_name)
115
+ return block.get_value(type_name) if block else None
116
+
117
+
118
+ class DatasetData(BaseModel):
119
+ """The 'data' payload of a dataset export response."""
120
+
121
+ model_config = ConfigDict(extra="ignore")
122
+
123
+ id: int
124
+ identifier: str
125
+ persistentUrl: str
126
+ protocol: str
127
+ authority: str
128
+ separator: str
129
+ publisher: str
130
+ storageIdentifier: str
131
+ datasetType: (
132
+ str | None
133
+ ) # backward compatibility: some datasets /older Dataverse versions don't have this field
134
+
135
+ latestVersion: DatasetVersion = Field(
136
+ alias="latestVersion",
137
+ validation_alias=AliasChoices(
138
+ "datasetVersion",
139
+ "latestVersion",
140
+ ),
141
+ )
142
+
143
+
144
+ class DatasetExport(BaseModel):
145
+ """Top-level wrapper matching the raw JSON returned by GET /api/datasets/:id."""
146
+
147
+ model_config = ConfigDict(extra="ignore")
148
+
149
+ status: str
150
+ data: DatasetData
151
+
152
+ def get_value(self, block_name: str, type_name: str) -> Any:
153
+ """Convenience shortcut: dataset.get_value('citation', 'title') straight from the top level."""
154
+ return self.data.latestVersion.get_value(block_name, type_name)
155
+
156
+
157
+ def load_dataset(metadata: dict) -> DatasetExport:
158
+ """Parse a dataset export JSON payload (already loaded as a dict) into a DatasetExport.
159
+
160
+ Accepts either the full `{status, data: {...}}` export envelope, or a bare
161
+ `data`-shaped payload (no envelope), by trying each model in turn.
162
+ """
163
+ if "data" in metadata:
164
+ return DatasetExport.model_validate(metadata)
165
+ return DatasetExport(status="OK", data=DatasetData.model_validate(metadata))
@@ -0,0 +1,113 @@
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(
26
+ extra="ignore"
27
+ ) # tolerate any schema fields we did not model
28
+
29
+ name: str
30
+ displayName: str
31
+ displayOnCreate: bool
32
+ title: str
33
+ type: str # e.g. 'TEXT', 'TEXTBOX', 'DATE', 'INT', 'FLOAT', 'URL', 'EMAIL', 'NONE'
34
+ typeClass: str # 'primitive', 'compound', or 'controlledVocabulary'
35
+ watermark: str = ""
36
+ description: str = ""
37
+ multiple: bool
38
+ isControlledVocabulary: bool
39
+ isAdvancedSearchFieldType: bool
40
+ displayFormat: str = ""
41
+ displayOrder: int
42
+ isRequired: bool
43
+ controlledVocabularyValues: list[str] | None = None
44
+ childFields: dict[str, MetadataField] | None = None
45
+
46
+ def is_compound(self) -> bool:
47
+ """Return True if this field wraps nested childFields rather than holding a value directly."""
48
+ return self.childFields is not None
49
+
50
+ def iter_leaf_fields(self) -> list[MetadataField]:
51
+ """Recursively collect every leaf (non-compound) field reachable from this field."""
52
+ if self.childFields:
53
+ leaves: list[MetadataField] = []
54
+ for child in self.childFields.values():
55
+ leaves.extend(child.iter_leaf_fields())
56
+ return leaves
57
+ return [self]
58
+
59
+
60
+ # Needed because MetadataField references itself by forward-reference string.
61
+ MetadataField.model_rebuild()
62
+
63
+
64
+ class MetadataBlock(BaseModel):
65
+ """A single metadata block (e.g. citation, geospatial, astrophysics)."""
66
+
67
+ model_config = ConfigDict(extra="ignore")
68
+
69
+ id: int
70
+ name: str
71
+ displayName: str
72
+ displayOnCreate: bool
73
+ fields: dict[str, MetadataField]
74
+
75
+ def get_field(self, field_name: str) -> MetadataField | None:
76
+ """Look up a top-level field in this block by its key name."""
77
+ return self.fields.get(field_name)
78
+
79
+ def all_leaf_fields(self) -> dict[str, MetadataField]:
80
+ """Flatten every leaf field in this block (including nested ones) into a single dict."""
81
+ flat: dict[str, MetadataField] = {}
82
+ for field in self.fields.values():
83
+ for leaf in field.iter_leaf_fields():
84
+ flat[leaf.name] = leaf
85
+ return flat
86
+
87
+ def required_fields(self) -> list[str]:
88
+ """Return the names of all leaf fields marked isRequired = True."""
89
+ return [
90
+ name for name, field in self.all_leaf_fields().items() if field.isRequired
91
+ ]
92
+
93
+
94
+ class DataverseSchemaResponse(BaseModel):
95
+ """Top-level wrapper matching the raw JSON returned by /api/metadatablocks."""
96
+
97
+ model_config = ConfigDict(extra="ignore")
98
+
99
+ status: str
100
+ data: list[MetadataBlock]
101
+
102
+ def get_block(self, block_name: str) -> MetadataBlock | None:
103
+ """Look up a metadata block by its short name (e.g. 'citation', 'geospatial')."""
104
+ return next((b for b in self.data if b.name == block_name), None)
105
+
106
+ def block_names(self) -> list[str]:
107
+ """List the short names of every block in the response."""
108
+ return [b.name for b in self.data]
109
+
110
+
111
+ def load_schema(metadata: dict) -> DataverseSchemaResponse:
112
+ """Parse a metadatablocks JSON payload (already loaded as a dict) into a DataverseSchemaResponse."""
113
+ return DataverseSchemaResponse.model_validate(metadata)
@@ -0,0 +1,94 @@
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 .dataset_instance import MetadataBlockInstance
20
+ from .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] = {
25
+ "INT": int,
26
+ "FLOAT": float,
27
+ }
28
+
29
+
30
+ def _python_type_for(field: MetadataField) -> type:
31
+ """Map a leaf schema field's declared 'type' to a Python type."""
32
+ return _PRIMITIVE_TYPE_MAP.get(field.type, str)
33
+
34
+
35
+ def _safe_identifier(name: str) -> str:
36
+ """Turn a Dataverse field name that may contain dots (e.g. 'resolution.Spatial') into a valid Python identifier."""
37
+ return name.replace(".", "_")
38
+
39
+
40
+ def build_record_model(
41
+ source: MetadataBlock | MetadataField,
42
+ *,
43
+ model_name: str | None = None,
44
+ ) -> type[BaseModel]:
45
+ """Recursively build a Pydantic model matching a schema MetadataBlock (or a compound MetadataField).
46
+
47
+ The resulting model accepts exactly the fields the schema defines: correct nested structure
48
+ for compound fields, List[...] wrapping for multiple=True fields, and required vs Optional
49
+ matching isRequired.
50
+ """
51
+ if isinstance(source, MetadataBlock):
52
+ fields = source.fields
53
+ default_name = f"{source.name.capitalize()}Record"
54
+ else:
55
+ fields = source.childFields or {}
56
+ default_name = f"{source.name.capitalize()}Record"
57
+
58
+ field_definitions: dict[str, Any] = {}
59
+
60
+ for field_name, field in fields.items():
61
+ py_name = _safe_identifier(field_name)
62
+
63
+ if field.is_compound():
64
+ base_type: Any = build_record_model(
65
+ field, model_name=f"{field_name.capitalize()}Record"
66
+ )
67
+ else:
68
+ base_type = _python_type_for(field)
69
+
70
+ if field.multiple:
71
+ base_type = list[base_type]
72
+
73
+ if not field.isRequired:
74
+ base_type = Optional[base_type]
75
+ default = None
76
+ else:
77
+ default = ...
78
+
79
+ if py_name != field_name:
80
+ # Field name isn't a valid Python identifier as-is -- alias back to the original.
81
+ field_definitions[py_name] = (base_type, Field(default, alias=field_name))
82
+ else:
83
+ field_definitions[py_name] = (base_type, default)
84
+
85
+ return create_model(
86
+ model_name or default_name,
87
+ __config__=ConfigDict(populate_by_name=True, extra="forbid"),
88
+ **field_definitions,
89
+ )
90
+
91
+
92
+ def flatten_instance(block_instance: MetadataBlockInstance) -> dict[str, Any]:
93
+ """Turn a dataset export's field list into a flat {typeName: value} dict, ready to validate."""
94
+ return {f.typeName: f.simple_value() for f in block_instance.fields}
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: dv-schema-models
3
+ Version: 0.2.0a0
4
+ Summary: Turns Harvard Dataverse Project metadatablocks schema and dataset JSON into Pydantic models.
5
+ Author-email: Ken Lui <kenlh.lui@utoronto.ca>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kenlhlui/dv_schema_models/
8
+ Project-URL: Documentation, https://github.com/kenlhlui/dv_schema_models/blob/main/README.md
9
+ Project-URL: Repository, https://github.com/kenlhlui/dv_schema_models.git
10
+ Project-URL: Issues, https://github.com/kenlhlui/dv_schema_models/issues
11
+ Project-URL: Changelog, https://github.com/kenlhlui/dv_schema_models/blob/main/CHANGELOG.md
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: pydantic>=2.13.4
16
+ Dynamic: license-file
17
+
18
+ # dv_schema_models
19
+
20
+ Pydantic models for Dataverse metadata — parse the schema, load dataset
21
+ exports, and validate field values against the schema.
22
+
23
+ > [!CAUTION]
24
+ > 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.
25
+
26
+ ## Pre-requisites
27
+ 1. Python 3.13+
28
+
29
+ ## Installation
30
+
31
+ 1. With `uv` (recommended):
32
+ ```bash
33
+ uv add dv_schema_models
34
+ ```
35
+
36
+ 2. With `pip`:
37
+ ```bash
38
+ pip install dv_schema_models
39
+ ```
40
+
41
+ ## Concepts
42
+
43
+ | Thing | What it is |
44
+ |---|---|
45
+ | **Schema** | `/api/metadatablocks` response — defines what fields *can* exist, their types, and rules |
46
+ | **Dataset instance** | `GET /api/datasets/:id` response — the actual metadata values for one dataset |
47
+ | **Record model** | A Pydantic model *generated from* the schema, used to validate instance values |
48
+
49
+ ## Usage
50
+
51
+ ### 1. Load and query the schema
52
+
53
+ ```python
54
+ import json
55
+ from dv_schema_models.dataverse_schema import load_schema
56
+
57
+ schema = load_schema(json.load(open("dv_schema.json")))
58
+
59
+ schema.block_names() # ['citation', 'geospatial', ...]
60
+ block = schema.get_block("citation")
61
+ block.fields.keys() # top-level field names
62
+ block.required_fields() # leaf fields where isRequired=True
63
+ block.all_leaf_fields() # flattened, including nested compound fields
64
+
65
+ field = block.get_field("keyword")
66
+ field.is_compound() # True — has childFields
67
+ field.iter_leaf_fields() # [keywordValue, keywordVocabulary, ...]
68
+ ```
69
+
70
+ ### 2. Load a dataset and read values
71
+
72
+ ```python
73
+ import json
74
+ from dv_schema_models.dataset_instance import load_dataset
75
+
76
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
77
+
78
+ # Shortcut from the top level
79
+ dataset.get_value("citation", "title") # plain string
80
+
81
+ # Or drill down
82
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
83
+ block.get_value("keyword") # unwrapped Python value (str / list / dict)
84
+ block.get_field("author").simple_value() # same, from the DatasetFieldValue directly
85
+ ```
86
+
87
+ ### 3. Validate instance values against the schema
88
+
89
+ ```python
90
+ import json
91
+ from dv_schema_models.dataverse_schema import load_schema
92
+ from dv_schema_models.dataset_instance import load_dataset
93
+ from dv_schema_models.schema_driven_records import build_record_model, flatten_instance
94
+
95
+
96
+ schema = load_schema(json.load(open("dv_schema.json")))
97
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
98
+
99
+ citation_schema = schema.get_block("citation")
100
+ CitationRecord = build_record_model(citation_schema) # dynamic Pydantic model
101
+
102
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
103
+ raw = flatten_instance(block) # {typeName: value, ...}
104
+ record = CitationRecord.model_validate(raw)
105
+ ```
106
+
107
+ The generated model enforces field names, required/optional status, list wrapping for `multiple=True` fields, and `int`/`float` types where declared by the schema.
108
+
109
+ ### 4. Discover available fields
110
+
111
+ ```python
112
+ # Fields actually present in this dataset instance
113
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
114
+ block.field_names() # e.g. ['title', 'author', 'keyword', ...]
115
+
116
+ # All fields the schema defines (including absent/optional ones)
117
+ schema.get_block("citation").all_leaf_fields().keys()
118
+
119
+ # After validation, access as typed attributes
120
+ record = CitationRecord.model_validate(flatten_instance(block))
121
+ record.title # str
122
+ record.author # list[...] for multiple=True compound fields
123
+ record.keyword # None if not present in this dataset (optional fields default to None)
124
+ # Note: field names with dots become underscores — e.g. 'resolution.Spatial' → record.resolution_Spatial
125
+ ```
126
+
127
+ ## Input file shapes
128
+
129
+ **Schema** — output of Dataverse `/api/metadatablocks`:
130
+ ```json
131
+ {"status": "OK", "data": [{"id": 10, "name": "citation", "fields": {...}}]}
132
+ ```
133
+
134
+ **Dataset** — output of Dataverse `GET /api/datasets/:id`:
135
+ ```json
136
+ {"status": "OK", "data": {"latestVersion": {"metadataBlocks": {"citation": {"fields": [...]}}}}}
137
+ ```
138
+
139
+ ## Citation
140
+ If you use this library in your work, please cite according to [CITATION](CITATION.cff)
141
+
142
+ ## License
143
+ [MIT](LICENSE)
@@ -0,0 +1,9 @@
1
+ dv_schema_models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dv_schema_models/dataset_instance.py,sha256=mRSrUYIH4yQHFO5lbU9Lg88UCsi2KXyrpN4pLNI0gVg,5707
3
+ dv_schema_models/dataverse_schema.py,sha256=1kl7rQg5ytau_9-v1J6SqPDVDw_7KpCdKyWqPNzmytY,4039
4
+ dv_schema_models/schema_driven_records.py,sha256=Gjs6_bNF1fdCfifxE4c6hIxvFyn8-bRmbaZk8q_t_34,3592
5
+ dv_schema_models-0.2.0a0.dist-info/licenses/LICENSE,sha256=rcRkUIujZwWhrbeUMrxbcnUMhy0p9zJsu4j6HxsHrgI,1068
6
+ dv_schema_models-0.2.0a0.dist-info/METADATA,sha256=ENkcnOveS31S1WBAQFQFP1IPJgmx_kujiFL9G7oizB8,5107
7
+ dv_schema_models-0.2.0a0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ dv_schema_models-0.2.0a0.dist-info/top_level.txt,sha256=exj3nMSY6lM22wuy0hEduAu6xVl59VR0FhsFnDNGjgw,17
9
+ dv_schema_models-0.2.0a0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lok Hei Lui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ dv_schema_models