dv-schema-models 0.2.0__tar.gz

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,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,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: dv-schema-models
3
+ Version: 0.2.0
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.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: pydantic>=2.13.4
16
+ Provides-Extra: spreadsheet
17
+ Requires-Dist: xlsxwriter>=3.2.9; extra == "spreadsheet"
18
+ Dynamic: license-file
19
+
20
+ # dv_schema_models
21
+
22
+ Pydantic models for Dataverse metadata — parse the schema, load dataset
23
+ exports, and validate field values against the schema.
24
+
25
+ > [!CAUTION]
26
+ > 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.
27
+
28
+ ## Pre-requisites
29
+ 1. Python 3.13+
30
+
31
+ ## Installation
32
+
33
+ 1. With `uv` (recommended):
34
+ ```bash
35
+ uv add dv_schema_models
36
+ ```
37
+
38
+ 2. With `pip`:
39
+ ```bash
40
+ pip install dv_schema_models
41
+ ```
42
+
43
+ To export schemas to Excel (see [usage #5](#5-export-the-schema-to-a-spreadsheet)), install with the `spreadsheet` extra:
44
+ ```bash
45
+ uv add "dv_schema_models[spreadsheet]" # or: pip install "dv_schema_models[spreadsheet]"
46
+ ```
47
+
48
+ ## Concepts
49
+
50
+ | Thing | What it is |
51
+ |---|---|
52
+ | **Schema** | `/api/metadatablocks` response — defines what fields *can* exist, their types, and rules |
53
+ | **Dataset instance** | `GET /api/datasets/:id` response — the actual metadata values for one dataset |
54
+ | **Record model** | A Pydantic model *generated from* the schema, used to validate instance values |
55
+
56
+ ## Usage
57
+
58
+ ### 1. Load and query the schema
59
+
60
+ ```python
61
+ import json
62
+ from dv_schema_models.dataverse_schema import load_schema
63
+
64
+ schema = load_schema(json.load(open("dv_schema.json")))
65
+
66
+ schema.block_names() # ['citation', 'geospatial', ...]
67
+ block = schema.get_block("citation")
68
+ block.fields.keys() # top-level field names
69
+ block.required_fields() # leaf fields where isRequired=True
70
+ block.all_leaf_fields() # flattened, including nested compound fields
71
+
72
+ field = block.get_field("keyword")
73
+ field.is_compound() # True — has childFields
74
+ field.iter_leaf_fields() # [keywordValue, keywordVocabulary, ...]
75
+ ```
76
+
77
+ ### 2. Load a dataset and read values
78
+
79
+ ```python
80
+ import json
81
+ from dv_schema_models.dataset_instance import load_dataset
82
+
83
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
84
+
85
+ # Load the possible typeNames for a given block
86
+ dataset.field_names("citation") # ['title', 'author', 'keyword', ...]
87
+ dataset.data.latestVersion.metadataBlocks.get("citation").field_names() # same
88
+
89
+
90
+ # Shortcut from the top level
91
+ dataset.get_value("citation", "title") # plain string
92
+
93
+ # Or drill down
94
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
95
+ block.get_value("keyword") # unwrapped Python value (str / list / dict)
96
+ block.get_field("author").simple_value() # [{'authorName': 'Author1', 'authorAffiliation': 'Author1Aff'...} ... {'authorName': 'Author2', 'authorAffiliation': 'Author2Aff'...}]
97
+ ```
98
+
99
+ ### 3. Validate instance values against the schema
100
+
101
+ ```python
102
+ import json
103
+ from dv_schema_models.dataverse_schema import load_schema
104
+ from dv_schema_models.dataset_instance import load_dataset
105
+ from dv_schema_models.schema_driven_records import build_record_model, flatten_instance
106
+
107
+
108
+ schema = load_schema(json.load(open("dv_schema.json")))
109
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
110
+
111
+ citation_schema = schema.get_block("citation")
112
+ CitationRecord = build_record_model(citation_schema) # dynamic Pydantic model
113
+
114
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
115
+ raw = flatten_instance(block) # {typeName: value, ...}
116
+ record = CitationRecord.model_validate(raw)
117
+ ```
118
+
119
+ The generated model enforces field names, required/optional status, list wrapping for `multiple=True` fields, and `int`/`float` types where declared by the schema.
120
+
121
+ ### 4. Discover available fields
122
+
123
+ ```python
124
+ # Fields actually present in this dataset instance
125
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
126
+ block.field_names() # e.g. ['title', 'author', 'keyword', ...]
127
+
128
+ # All fields the schema defines (including absent/optional ones)
129
+ schema.get_block("citation").all_leaf_fields().keys()
130
+
131
+ # After validation, access as typed attributes
132
+ record = CitationRecord.model_validate(flatten_instance(block))
133
+ record.title # str
134
+ record.author # list[...] for multiple=True compound fields
135
+ record.keyword # None if not present in this dataset (optional fields default to None)
136
+ # Note: field names with dots become underscores — e.g. 'resolution.Spatial' → record.resolution_Spatial
137
+ ```
138
+
139
+ ### 5. Export the schema to a spreadsheet
140
+
141
+ Requires the `spreadsheet` extra (see [Installation](#installation)).
142
+
143
+ ```python
144
+ import json
145
+ from dv_schema_models.dataverse_schema import load_schema
146
+ from dv_schema_models.schema_spreadsheet import SchemaSpreadsheet
147
+
148
+ schema = load_schema(json.load(open("dv_schema.json")))
149
+ SchemaSpreadsheet(schema).write("dv_schema.xlsx")
150
+ ```
151
+
152
+ 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.
153
+
154
+ ## Input file shapes
155
+
156
+ **Schema** — output of Dataverse `/api/metadatablocks`:
157
+ ```json
158
+ {"status": "OK", "data": [{"id": 10, "name": "citation", "fields": {...}}]}
159
+ ```
160
+
161
+ **Dataset** — output of Dataverse `GET /api/datasets/:id`:
162
+ ```json
163
+ {"status": "OK", "data": {"latestVersion": {"metadataBlocks": {"citation": {"fields": [...]}}}}}
164
+ ```
165
+
166
+ ## Citation
167
+ If you use this library in your work, please cite according to [CITATION](CITATION.cff)
168
+
169
+ ## License
170
+ [MIT](LICENSE)
@@ -0,0 +1,151 @@
1
+ # dv_schema_models
2
+
3
+ Pydantic models for Dataverse metadata — parse the schema, load dataset
4
+ exports, and validate field values against the schema.
5
+
6
+ > [!CAUTION]
7
+ > 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.
8
+
9
+ ## Pre-requisites
10
+ 1. Python 3.13+
11
+
12
+ ## Installation
13
+
14
+ 1. With `uv` (recommended):
15
+ ```bash
16
+ uv add dv_schema_models
17
+ ```
18
+
19
+ 2. With `pip`:
20
+ ```bash
21
+ pip install dv_schema_models
22
+ ```
23
+
24
+ To export schemas to Excel (see [usage #5](#5-export-the-schema-to-a-spreadsheet)), install with the `spreadsheet` extra:
25
+ ```bash
26
+ uv add "dv_schema_models[spreadsheet]" # or: pip install "dv_schema_models[spreadsheet]"
27
+ ```
28
+
29
+ ## Concepts
30
+
31
+ | Thing | What it is |
32
+ |---|---|
33
+ | **Schema** | `/api/metadatablocks` response — defines what fields *can* exist, their types, and rules |
34
+ | **Dataset instance** | `GET /api/datasets/:id` response — the actual metadata values for one dataset |
35
+ | **Record model** | A Pydantic model *generated from* the schema, used to validate instance values |
36
+
37
+ ## Usage
38
+
39
+ ### 1. Load and query the schema
40
+
41
+ ```python
42
+ import json
43
+ from dv_schema_models.dataverse_schema import load_schema
44
+
45
+ schema = load_schema(json.load(open("dv_schema.json")))
46
+
47
+ schema.block_names() # ['citation', 'geospatial', ...]
48
+ block = schema.get_block("citation")
49
+ block.fields.keys() # top-level field names
50
+ block.required_fields() # leaf fields where isRequired=True
51
+ block.all_leaf_fields() # flattened, including nested compound fields
52
+
53
+ field = block.get_field("keyword")
54
+ field.is_compound() # True — has childFields
55
+ field.iter_leaf_fields() # [keywordValue, keywordVocabulary, ...]
56
+ ```
57
+
58
+ ### 2. Load a dataset and read values
59
+
60
+ ```python
61
+ import json
62
+ from dv_schema_models.dataset_instance import load_dataset
63
+
64
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
65
+
66
+ # Load the possible typeNames for a given block
67
+ dataset.field_names("citation") # ['title', 'author', 'keyword', ...]
68
+ dataset.data.latestVersion.metadataBlocks.get("citation").field_names() # same
69
+
70
+
71
+ # Shortcut from the top level
72
+ dataset.get_value("citation", "title") # plain string
73
+
74
+ # Or drill down
75
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
76
+ block.get_value("keyword") # unwrapped Python value (str / list / dict)
77
+ block.get_field("author").simple_value() # [{'authorName': 'Author1', 'authorAffiliation': 'Author1Aff'...} ... {'authorName': 'Author2', 'authorAffiliation': 'Author2Aff'...}]
78
+ ```
79
+
80
+ ### 3. Validate instance values against the schema
81
+
82
+ ```python
83
+ import json
84
+ from dv_schema_models.dataverse_schema import load_schema
85
+ from dv_schema_models.dataset_instance import load_dataset
86
+ from dv_schema_models.schema_driven_records import build_record_model, flatten_instance
87
+
88
+
89
+ schema = load_schema(json.load(open("dv_schema.json")))
90
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
91
+
92
+ citation_schema = schema.get_block("citation")
93
+ CitationRecord = build_record_model(citation_schema) # dynamic Pydantic model
94
+
95
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
96
+ raw = flatten_instance(block) # {typeName: value, ...}
97
+ record = CitationRecord.model_validate(raw)
98
+ ```
99
+
100
+ The generated model enforces field names, required/optional status, list wrapping for `multiple=True` fields, and `int`/`float` types where declared by the schema.
101
+
102
+ ### 4. Discover available fields
103
+
104
+ ```python
105
+ # Fields actually present in this dataset instance
106
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
107
+ block.field_names() # e.g. ['title', 'author', 'keyword', ...]
108
+
109
+ # All fields the schema defines (including absent/optional ones)
110
+ schema.get_block("citation").all_leaf_fields().keys()
111
+
112
+ # After validation, access as typed attributes
113
+ record = CitationRecord.model_validate(flatten_instance(block))
114
+ record.title # str
115
+ record.author # list[...] for multiple=True compound fields
116
+ record.keyword # None if not present in this dataset (optional fields default to None)
117
+ # Note: field names with dots become underscores — e.g. 'resolution.Spatial' → record.resolution_Spatial
118
+ ```
119
+
120
+ ### 5. Export the schema to a spreadsheet
121
+
122
+ Requires the `spreadsheet` extra (see [Installation](#installation)).
123
+
124
+ ```python
125
+ import json
126
+ from dv_schema_models.dataverse_schema import load_schema
127
+ from dv_schema_models.schema_spreadsheet import SchemaSpreadsheet
128
+
129
+ schema = load_schema(json.load(open("dv_schema.json")))
130
+ SchemaSpreadsheet(schema).write("dv_schema.xlsx")
131
+ ```
132
+
133
+ 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.
134
+
135
+ ## Input file shapes
136
+
137
+ **Schema** — output of Dataverse `/api/metadatablocks`:
138
+ ```json
139
+ {"status": "OK", "data": [{"id": 10, "name": "citation", "fields": {...}}]}
140
+ ```
141
+
142
+ **Dataset** — output of Dataverse `GET /api/datasets/:id`:
143
+ ```json
144
+ {"status": "OK", "data": {"latestVersion": {"metadataBlocks": {"citation": {"fields": [...]}}}}}
145
+ ```
146
+
147
+ ## Citation
148
+ If you use this library in your work, please cite according to [CITATION](CITATION.cff)
149
+
150
+ ## License
151
+ [MIT](LICENSE)
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "dv-schema-models"
3
+ version = "0.2.0"
4
+ description = "Turns Harvard Dataverse Project metadatablocks schema and dataset JSON into Pydantic models."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Ken Lui", email = "kenlh.lui@utoronto.ca"}
8
+ ]
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "pydantic>=2.13.4",
12
+ ]
13
+ license = "MIT"
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/kenlhlui/dv_schema_models/"
17
+ Documentation = "https://github.com/kenlhlui/dv_schema_models/blob/main/README.md"
18
+ Repository = "https://github.com/kenlhlui/dv_schema_models.git"
19
+ Issues = "https://github.com/kenlhlui/dv_schema_models/issues"
20
+ Changelog = "https://github.com/kenlhlui/dv_schema_models/blob/main/CHANGELOG.md"
21
+
22
+ [project.optional-dependencies]
23
+ spreadsheet = [
24
+ "xlsxwriter>=3.2.9",
25
+ ]
26
+
27
+
28
+ [tool.cff-from-621.static]
29
+ message = "If you use this software, please cite it."
30
+
31
+
32
+ [tool.commitizen]
33
+ name = "cz_conventional_commits"
34
+ tag_format = "v$version"
35
+ version_scheme = "pep440"
36
+ version_provider = "uv"
37
+ update_changelog_on_bump = true
38
+ major_version_zero = true
39
+ [dependency-groups]
40
+ dev = [
41
+ "httpx2>=2.5.0",
42
+ "pytest>=9.1.1",
43
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,196 @@
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, N802
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="allow")
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
+ latestVersionPublishingState: str
100
+ deaccessionLink: str | None
101
+ UNF: str | None = Field(None)
102
+ lastUpdateTime: str
103
+ createTime: str
104
+ termsOfUse: str | None
105
+ termsOfAccess: str | None
106
+ dataAccessPlace: str | None = Field(None)
107
+ fileAccessRequest: bool
108
+
109
+ metadataBlocks: dict[str, MetadataBlockInstance]
110
+
111
+ def get_value(self, block_name: str, type_name: str) -> Any:
112
+ """Get a field's plain value by block name (e.g. 'citation') and field typeName (e.g. 'title')."""
113
+ block = self.metadataBlocks.get(block_name)
114
+ return block.get_value(type_name) if block else None
115
+
116
+ def field_names(self, block_name: str) -> list[str]:
117
+ """Get the typeNames of every field present in the given block (e.g. 'citation')."""
118
+ block = self.metadataBlocks.get(block_name)
119
+ return block.field_names() if block else []
120
+
121
+
122
+ class DatasetData(BaseModel):
123
+ """The 'data' payload of a dataset export response."""
124
+
125
+ model_config = ConfigDict(extra="ignore")
126
+
127
+ id: int
128
+ identifier: str
129
+ persistentUrl: str
130
+ protocol: str
131
+ authority: str
132
+ separator: str
133
+ publisher: str
134
+ storageIdentifier: str
135
+ datasetType: (
136
+ str | None
137
+ ) # backward compatibility: some datasets /older Dataverse versions don't have this field
138
+
139
+ latestVersion: DatasetVersion = Field(
140
+ validation_alias=AliasChoices(
141
+ "datasetVersion",
142
+ "latestVersion",
143
+ ),
144
+ )
145
+
146
+ @property
147
+ def datasetVersion(self) -> DatasetVersion:
148
+ """Supports both export and Native JSON endpoints."""
149
+ return self.latestVersion
150
+
151
+
152
+ class DatasetExport(BaseModel):
153
+ """Top-level wrapper matching the raw JSON returned by GET /api/datasets/:id."""
154
+
155
+ model_config = ConfigDict(extra="ignore")
156
+
157
+ status: str
158
+ data: DatasetData
159
+
160
+ def get_value(
161
+ self, block_name: str, type_name: str
162
+ ) -> str | list[str] | dict[str, Any] | list[dict[str, Any]] | None:
163
+ """Get the value of a field by its block and type names.
164
+
165
+ Args:
166
+ block_name: The name of the metadata block (e.g. 'citation', 'geospatial').
167
+ type_name: The typeName of the field within that block (e.g. 'title', 'author').
168
+
169
+ Returns:
170
+ str | list[str] | dict[str, Any] | list[dict[str, Any]] | None: The value of the specified field, or None if not found.
171
+
172
+ """
173
+ return self.data.latestVersion.get_value(block_name, type_name)
174
+
175
+ def field_names(self, block_name: str) -> list[str]:
176
+ """Check what fields are present in a given block.
177
+
178
+ Args:
179
+ block_name: The name of the metadata block (e.g. 'citation', 'geospatial').
180
+
181
+ Returns:
182
+ list[str]: A list of typeNames for the fields present in the specified block.
183
+
184
+ """
185
+ return self.data.latestVersion.field_names(block_name)
186
+
187
+
188
+ def load_dataset(metadata: dict) -> DatasetExport:
189
+ """Parse a dataset export JSON payload (already loaded as a dict) into a DatasetExport.
190
+
191
+ Accepts either the full `{status, data: {...}}` export envelope, or a bare
192
+ `data`-shaped payload (no envelope), by trying each model in turn.
193
+ """
194
+ if "data" in metadata:
195
+ return DatasetExport.model_validate(metadata)
196
+ 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,142 @@
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 (
10
+ DataverseSchemaResponse,
11
+ MetadataBlock,
12
+ MetadataField,
13
+ )
14
+
15
+ # (title, definition, usage, repeatable, example, is_top_level, is_last_row_of_field)
16
+ Row = tuple[str, str, str, str, str, bool, bool]
17
+
18
+ COLUMN_WIDTHS = [30, 90, 10, 16, 60]
19
+
20
+
21
+ class SchemaSpreadsheet:
22
+ """Turns the dataverse schema into a spreadsheet."""
23
+
24
+ def __init__(self, schema: DataverseSchemaResponse) -> None:
25
+ """Initialize the SchemaSpreadsheet with a DataverseSchemaResponse."""
26
+ self.schema = schema.data
27
+
28
+ @staticmethod
29
+ def _header_row() -> list[str]:
30
+ return ["Field", "Definition", "Usage", "Repeatable (Y/N)", "Example"]
31
+
32
+ def _title_fmt(self, workbook: xlsxwriter.Workbook):
33
+ return workbook.add_format(
34
+ {
35
+ "bold": True,
36
+ }
37
+ )
38
+
39
+ def _block_fmt(self, workbook: xlsxwriter.Workbook):
40
+ return workbook.add_format(
41
+ {
42
+ "bold": True,
43
+ "align": "center",
44
+ "bg_color": f"#{random.randrange(0x1000000):06x}",
45
+ }
46
+ )
47
+
48
+ @staticmethod
49
+ def _field_rows(field: MetadataField) -> list[Row]:
50
+ """Turn one top-level field (and its children) into spreadsheet rows."""
51
+ # ponytail: usage is RQ/O from isRequired; the schema has no "recommended" flag
52
+ usage = lambda f: "RQ" if f.isRequired else "O" # noqa: E731
53
+ repeatable = lambda f: "Y" if f.multiple else "N" # noqa: E731
54
+
55
+ if not field.childFields:
56
+ return [
57
+ (
58
+ field.title,
59
+ field.description,
60
+ usage(field),
61
+ repeatable(field),
62
+ field.watermark,
63
+ True,
64
+ True,
65
+ )
66
+ ]
67
+ # Compound field: parent row carries only the definition, children the rest.
68
+ rows: list[Row] = [(field.title, field.description, "", "", "", True, False)]
69
+ children = sorted(field.childFields.values(), key=lambda f: f.displayOrder)
70
+ rows.extend(
71
+ (
72
+ child.title,
73
+ child.description,
74
+ usage(child),
75
+ # A child of a repeatable compound is effectively repeatable.
76
+ "Y" if (child.multiple or field.multiple) else "N",
77
+ child.watermark,
78
+ False,
79
+ child is children[-1],
80
+ )
81
+ for child in children
82
+ )
83
+ return rows
84
+
85
+ def _write_sheet(
86
+ self,
87
+ workbook: xlsxwriter.Workbook,
88
+ sheet_name: str,
89
+ blocks: list[MetadataBlock],
90
+ cell_fmts: dict[tuple[bool, bool], Format],
91
+ ) -> None:
92
+ sheet = workbook.add_worksheet(sheet_name)
93
+ for col, width in enumerate(COLUMN_WIDTHS):
94
+ sheet.set_column(col, col, width)
95
+
96
+ sheet.write_row(0, 0, self._header_row(), self._title_fmt(workbook))
97
+
98
+ row_num = 1
99
+ for block in blocks:
100
+ sheet.merge_range(
101
+ row_num,
102
+ 0,
103
+ row_num,
104
+ 4,
105
+ f"{block.displayName} Block",
106
+ self._block_fmt(workbook),
107
+ )
108
+ row_num += 1
109
+ for field in sorted(block.fields.values(), key=lambda f: f.displayOrder):
110
+ for title, *cells, bold, end in self._field_rows(field):
111
+ sheet.write(row_num, 0, title, cell_fmts[bold, end])
112
+ for col, value in enumerate(cells, start=1):
113
+ sheet.write(row_num, col, value, cell_fmts[False, end])
114
+ row_num += 1
115
+
116
+ def write(self, path: str | Path) -> Path:
117
+ """Write an 'All' worksheet plus one per metadata block to an .xlsx file at `path`."""
118
+ path = Path(path)
119
+ workbook = xlsxwriter.Workbook(str(path))
120
+ cell_fmts = {
121
+ (bold, end): workbook.add_format(
122
+ {
123
+ "bold": bold,
124
+ "bottom": 1 if end else 0,
125
+ "text_wrap": True,
126
+ "valign": "top",
127
+ }
128
+ )
129
+ for bold in (True, False)
130
+ for end in (True, False)
131
+ }
132
+ blocks = sorted(self.schema, key=lambda b: b.id)
133
+ self._write_sheet(workbook, "All", blocks, cell_fmts)
134
+ for block in blocks:
135
+ self._write_sheet(
136
+ workbook,
137
+ block.displayName.removesuffix(" Metadata")[:31],
138
+ [block],
139
+ cell_fmts,
140
+ )
141
+ workbook.close()
142
+ return path
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: dv-schema-models
3
+ Version: 0.2.0
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.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: pydantic>=2.13.4
16
+ Provides-Extra: spreadsheet
17
+ Requires-Dist: xlsxwriter>=3.2.9; extra == "spreadsheet"
18
+ Dynamic: license-file
19
+
20
+ # dv_schema_models
21
+
22
+ Pydantic models for Dataverse metadata — parse the schema, load dataset
23
+ exports, and validate field values against the schema.
24
+
25
+ > [!CAUTION]
26
+ > 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.
27
+
28
+ ## Pre-requisites
29
+ 1. Python 3.13+
30
+
31
+ ## Installation
32
+
33
+ 1. With `uv` (recommended):
34
+ ```bash
35
+ uv add dv_schema_models
36
+ ```
37
+
38
+ 2. With `pip`:
39
+ ```bash
40
+ pip install dv_schema_models
41
+ ```
42
+
43
+ To export schemas to Excel (see [usage #5](#5-export-the-schema-to-a-spreadsheet)), install with the `spreadsheet` extra:
44
+ ```bash
45
+ uv add "dv_schema_models[spreadsheet]" # or: pip install "dv_schema_models[spreadsheet]"
46
+ ```
47
+
48
+ ## Concepts
49
+
50
+ | Thing | What it is |
51
+ |---|---|
52
+ | **Schema** | `/api/metadatablocks` response — defines what fields *can* exist, their types, and rules |
53
+ | **Dataset instance** | `GET /api/datasets/:id` response — the actual metadata values for one dataset |
54
+ | **Record model** | A Pydantic model *generated from* the schema, used to validate instance values |
55
+
56
+ ## Usage
57
+
58
+ ### 1. Load and query the schema
59
+
60
+ ```python
61
+ import json
62
+ from dv_schema_models.dataverse_schema import load_schema
63
+
64
+ schema = load_schema(json.load(open("dv_schema.json")))
65
+
66
+ schema.block_names() # ['citation', 'geospatial', ...]
67
+ block = schema.get_block("citation")
68
+ block.fields.keys() # top-level field names
69
+ block.required_fields() # leaf fields where isRequired=True
70
+ block.all_leaf_fields() # flattened, including nested compound fields
71
+
72
+ field = block.get_field("keyword")
73
+ field.is_compound() # True — has childFields
74
+ field.iter_leaf_fields() # [keywordValue, keywordVocabulary, ...]
75
+ ```
76
+
77
+ ### 2. Load a dataset and read values
78
+
79
+ ```python
80
+ import json
81
+ from dv_schema_models.dataset_instance import load_dataset
82
+
83
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
84
+
85
+ # Load the possible typeNames for a given block
86
+ dataset.field_names("citation") # ['title', 'author', 'keyword', ...]
87
+ dataset.data.latestVersion.metadataBlocks.get("citation").field_names() # same
88
+
89
+
90
+ # Shortcut from the top level
91
+ dataset.get_value("citation", "title") # plain string
92
+
93
+ # Or drill down
94
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
95
+ block.get_value("keyword") # unwrapped Python value (str / list / dict)
96
+ block.get_field("author").simple_value() # [{'authorName': 'Author1', 'authorAffiliation': 'Author1Aff'...} ... {'authorName': 'Author2', 'authorAffiliation': 'Author2Aff'...}]
97
+ ```
98
+
99
+ ### 3. Validate instance values against the schema
100
+
101
+ ```python
102
+ import json
103
+ from dv_schema_models.dataverse_schema import load_schema
104
+ from dv_schema_models.dataset_instance import load_dataset
105
+ from dv_schema_models.schema_driven_records import build_record_model, flatten_instance
106
+
107
+
108
+ schema = load_schema(json.load(open("dv_schema.json")))
109
+ dataset = load_dataset(json.load(open("ds_metadata.json")))
110
+
111
+ citation_schema = schema.get_block("citation")
112
+ CitationRecord = build_record_model(citation_schema) # dynamic Pydantic model
113
+
114
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
115
+ raw = flatten_instance(block) # {typeName: value, ...}
116
+ record = CitationRecord.model_validate(raw)
117
+ ```
118
+
119
+ The generated model enforces field names, required/optional status, list wrapping for `multiple=True` fields, and `int`/`float` types where declared by the schema.
120
+
121
+ ### 4. Discover available fields
122
+
123
+ ```python
124
+ # Fields actually present in this dataset instance
125
+ block = dataset.data.latestVersion.metadataBlocks.get("citation")
126
+ block.field_names() # e.g. ['title', 'author', 'keyword', ...]
127
+
128
+ # All fields the schema defines (including absent/optional ones)
129
+ schema.get_block("citation").all_leaf_fields().keys()
130
+
131
+ # After validation, access as typed attributes
132
+ record = CitationRecord.model_validate(flatten_instance(block))
133
+ record.title # str
134
+ record.author # list[...] for multiple=True compound fields
135
+ record.keyword # None if not present in this dataset (optional fields default to None)
136
+ # Note: field names with dots become underscores — e.g. 'resolution.Spatial' → record.resolution_Spatial
137
+ ```
138
+
139
+ ### 5. Export the schema to a spreadsheet
140
+
141
+ Requires the `spreadsheet` extra (see [Installation](#installation)).
142
+
143
+ ```python
144
+ import json
145
+ from dv_schema_models.dataverse_schema import load_schema
146
+ from dv_schema_models.schema_spreadsheet import SchemaSpreadsheet
147
+
148
+ schema = load_schema(json.load(open("dv_schema.json")))
149
+ SchemaSpreadsheet(schema).write("dv_schema.xlsx")
150
+ ```
151
+
152
+ 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.
153
+
154
+ ## Input file shapes
155
+
156
+ **Schema** — output of Dataverse `/api/metadatablocks`:
157
+ ```json
158
+ {"status": "OK", "data": [{"id": 10, "name": "citation", "fields": {...}}]}
159
+ ```
160
+
161
+ **Dataset** — output of Dataverse `GET /api/datasets/:id`:
162
+ ```json
163
+ {"status": "OK", "data": {"latestVersion": {"metadataBlocks": {"citation": {"fields": [...]}}}}}
164
+ ```
165
+
166
+ ## Citation
167
+ If you use this library in your work, please cite according to [CITATION](CITATION.cff)
168
+
169
+ ## License
170
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/dv_schema_models/__init__.py
5
+ src/dv_schema_models/dataset_instance.py
6
+ src/dv_schema_models/dataverse_schema.py
7
+ src/dv_schema_models/schema_driven_records.py
8
+ src/dv_schema_models/schema_spreadsheet.py
9
+ src/dv_schema_models.egg-info/PKG-INFO
10
+ src/dv_schema_models.egg-info/SOURCES.txt
11
+ src/dv_schema_models.egg-info/dependency_links.txt
12
+ src/dv_schema_models.egg-info/requires.txt
13
+ src/dv_schema_models.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ pydantic>=2.13.4
2
+
3
+ [spreadsheet]
4
+ xlsxwriter>=3.2.9
@@ -0,0 +1 @@
1
+ dv_schema_models