dv-schema-models 0.2.0a0__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.
- dv_schema_models-0.2.0a0/LICENSE +21 -0
- dv_schema_models-0.2.0a0/PKG-INFO +143 -0
- dv_schema_models-0.2.0a0/README.md +126 -0
- dv_schema_models-0.2.0a0/pyproject.toml +29 -0
- dv_schema_models-0.2.0a0/setup.cfg +4 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models/__init__.py +0 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models/dataset_instance.py +165 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models/dataverse_schema.py +113 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models/schema_driven_records.py +94 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models.egg-info/PKG-INFO +143 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models.egg-info/SOURCES.txt +12 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models.egg-info/dependency_links.txt +1 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models.egg-info/requires.txt +1 -0
- dv_schema_models-0.2.0a0/src/dv_schema_models.egg-info/top_level.txt +1 -0
|
@@ -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,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,126 @@
|
|
|
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
|
+
## Concepts
|
|
25
|
+
|
|
26
|
+
| Thing | What it is |
|
|
27
|
+
|---|---|
|
|
28
|
+
| **Schema** | `/api/metadatablocks` response — defines what fields *can* exist, their types, and rules |
|
|
29
|
+
| **Dataset instance** | `GET /api/datasets/:id` response — the actual metadata values for one dataset |
|
|
30
|
+
| **Record model** | A Pydantic model *generated from* the schema, used to validate instance values |
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### 1. Load and query the schema
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import json
|
|
38
|
+
from dv_schema_models.dataverse_schema import load_schema
|
|
39
|
+
|
|
40
|
+
schema = load_schema(json.load(open("dv_schema.json")))
|
|
41
|
+
|
|
42
|
+
schema.block_names() # ['citation', 'geospatial', ...]
|
|
43
|
+
block = schema.get_block("citation")
|
|
44
|
+
block.fields.keys() # top-level field names
|
|
45
|
+
block.required_fields() # leaf fields where isRequired=True
|
|
46
|
+
block.all_leaf_fields() # flattened, including nested compound fields
|
|
47
|
+
|
|
48
|
+
field = block.get_field("keyword")
|
|
49
|
+
field.is_compound() # True — has childFields
|
|
50
|
+
field.iter_leaf_fields() # [keywordValue, keywordVocabulary, ...]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 2. Load a dataset and read values
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import json
|
|
57
|
+
from dv_schema_models.dataset_instance import load_dataset
|
|
58
|
+
|
|
59
|
+
dataset = load_dataset(json.load(open("ds_metadata.json")))
|
|
60
|
+
|
|
61
|
+
# Shortcut from the top level
|
|
62
|
+
dataset.get_value("citation", "title") # plain string
|
|
63
|
+
|
|
64
|
+
# Or drill down
|
|
65
|
+
block = dataset.data.latestVersion.metadataBlocks.get("citation")
|
|
66
|
+
block.get_value("keyword") # unwrapped Python value (str / list / dict)
|
|
67
|
+
block.get_field("author").simple_value() # same, from the DatasetFieldValue directly
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3. Validate instance values against the schema
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import json
|
|
74
|
+
from dv_schema_models.dataverse_schema import load_schema
|
|
75
|
+
from dv_schema_models.dataset_instance import load_dataset
|
|
76
|
+
from dv_schema_models.schema_driven_records import build_record_model, flatten_instance
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
schema = load_schema(json.load(open("dv_schema.json")))
|
|
80
|
+
dataset = load_dataset(json.load(open("ds_metadata.json")))
|
|
81
|
+
|
|
82
|
+
citation_schema = schema.get_block("citation")
|
|
83
|
+
CitationRecord = build_record_model(citation_schema) # dynamic Pydantic model
|
|
84
|
+
|
|
85
|
+
block = dataset.data.latestVersion.metadataBlocks.get("citation")
|
|
86
|
+
raw = flatten_instance(block) # {typeName: value, ...}
|
|
87
|
+
record = CitationRecord.model_validate(raw)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The generated model enforces field names, required/optional status, list wrapping for `multiple=True` fields, and `int`/`float` types where declared by the schema.
|
|
91
|
+
|
|
92
|
+
### 4. Discover available fields
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Fields actually present in this dataset instance
|
|
96
|
+
block = dataset.data.latestVersion.metadataBlocks.get("citation")
|
|
97
|
+
block.field_names() # e.g. ['title', 'author', 'keyword', ...]
|
|
98
|
+
|
|
99
|
+
# All fields the schema defines (including absent/optional ones)
|
|
100
|
+
schema.get_block("citation").all_leaf_fields().keys()
|
|
101
|
+
|
|
102
|
+
# After validation, access as typed attributes
|
|
103
|
+
record = CitationRecord.model_validate(flatten_instance(block))
|
|
104
|
+
record.title # str
|
|
105
|
+
record.author # list[...] for multiple=True compound fields
|
|
106
|
+
record.keyword # None if not present in this dataset (optional fields default to None)
|
|
107
|
+
# Note: field names with dots become underscores — e.g. 'resolution.Spatial' → record.resolution_Spatial
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Input file shapes
|
|
111
|
+
|
|
112
|
+
**Schema** — output of Dataverse `/api/metadatablocks`:
|
|
113
|
+
```json
|
|
114
|
+
{"status": "OK", "data": [{"id": 10, "name": "citation", "fields": {...}}]}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Dataset** — output of Dataverse `GET /api/datasets/:id`:
|
|
118
|
+
```json
|
|
119
|
+
{"status": "OK", "data": {"latestVersion": {"metadataBlocks": {"citation": {"fields": [...]}}}}}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Citation
|
|
123
|
+
If you use this library in your work, please cite according to [CITATION](CITATION.cff)
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dv-schema-models"
|
|
3
|
+
version = "0.2.0-a"
|
|
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.13"
|
|
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
|
+
|
|
23
|
+
[tool.cff-from-621.static]
|
|
24
|
+
message = "If you use this software, please cite it."
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=9.1.1",
|
|
29
|
+
]
|
|
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,12 @@
|
|
|
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.egg-info/PKG-INFO
|
|
9
|
+
src/dv_schema_models.egg-info/SOURCES.txt
|
|
10
|
+
src/dv_schema_models.egg-info/dependency_links.txt
|
|
11
|
+
src/dv_schema_models.egg-info/requires.txt
|
|
12
|
+
src/dv_schema_models.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pydantic>=2.13.4
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dv_schema_models
|