schematalog-core 0.1.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.
- schematalog_core-0.1.0/.gitignore +234 -0
- schematalog_core-0.1.0/LICENSE +21 -0
- schematalog_core-0.1.0/PKG-INFO +69 -0
- schematalog_core-0.1.0/README.md +45 -0
- schematalog_core-0.1.0/pyproject.toml +50 -0
- schematalog_core-0.1.0/schematalog/common/__init__.py +1 -0
- schematalog_core-0.1.0/schematalog/common/avroform/__init__.py +15 -0
- schematalog_core-0.1.0/schematalog/common/avroform/exceptions.py +2 -0
- schematalog_core-0.1.0/schematalog/common/avroform/to_avro.py +208 -0
- schematalog_core-0.1.0/schematalog/common/avroform/to_json_schema.py +124 -0
- schematalog_core-0.1.0/schematalog/common/logging.py +141 -0
- schematalog_core-0.1.0/schematalog/common/models.py +12 -0
- schematalog_core-0.1.0/schematalog/common/validation.py +80 -0
- schematalog_core-0.1.0/schematalog/domain/__init__.py +8 -0
- schematalog_core-0.1.0/schematalog/domain/exceptions.py +13 -0
- schematalog_core-0.1.0/schematalog/domain/schema.py +465 -0
- schematalog_core-0.1.0/schematalog/testing/__init__.py +12 -0
- schematalog_core-0.1.0/schematalog/testing/conformance.py +259 -0
- schematalog_core-0.1.0/schematalog/testing/example_schema.json +66 -0
- schematalog_core-0.1.0/schematalog/testing/samples.py +28 -0
- schematalog_core-0.1.0/tests/__init__.py +0 -0
- schematalog_core-0.1.0/tests/conftest.py +20 -0
- schematalog_core-0.1.0/tests/unit/__init__.py +0 -0
- schematalog_core-0.1.0/tests/unit/avroform/__init__.py +0 -0
- schematalog_core-0.1.0/tests/unit/avroform/test_roundtrip.py +76 -0
- schematalog_core-0.1.0/tests/unit/avroform/test_to_avro.py +169 -0
- schematalog_core-0.1.0/tests/unit/avroform/test_to_json_schema.py +94 -0
- schematalog_core-0.1.0/tests/unit/domain/__init__.py +0 -0
- schematalog_core-0.1.0/tests/unit/domain/test_schema.py +241 -0
- schematalog_core-0.1.0/tests/unit/test_validation.py +58 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
BUILD_NUMBER/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
pip-wheel-metadata/
|
|
25
|
+
share/python-wheels/
|
|
26
|
+
*.egg-info/
|
|
27
|
+
.installed.cfg
|
|
28
|
+
*.egg
|
|
29
|
+
MANIFEST
|
|
30
|
+
|
|
31
|
+
# PyInstaller
|
|
32
|
+
# Usually these files are written by a python script from a template
|
|
33
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
34
|
+
*.manifest
|
|
35
|
+
*.spec
|
|
36
|
+
|
|
37
|
+
# Installer logs
|
|
38
|
+
pip-log.txt
|
|
39
|
+
pip-delete-this-directory.txt
|
|
40
|
+
|
|
41
|
+
# Unit test / coverage reports
|
|
42
|
+
htmlcov/
|
|
43
|
+
.tox/
|
|
44
|
+
.nox/
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage.*
|
|
47
|
+
.cache
|
|
48
|
+
nosetests.xml
|
|
49
|
+
coverage.xml
|
|
50
|
+
*-coverage.xml
|
|
51
|
+
*.cover
|
|
52
|
+
*.py,cover
|
|
53
|
+
.hypothesis/
|
|
54
|
+
.pytest_cache/
|
|
55
|
+
test-reports/
|
|
56
|
+
|
|
57
|
+
# Various testing files
|
|
58
|
+
.mutmut-cache
|
|
59
|
+
|
|
60
|
+
# Translations
|
|
61
|
+
*.mo
|
|
62
|
+
*.pot
|
|
63
|
+
|
|
64
|
+
# Django stuff:
|
|
65
|
+
*.log
|
|
66
|
+
local_settings.py
|
|
67
|
+
db.sqlite3
|
|
68
|
+
db.sqlite3-journal
|
|
69
|
+
|
|
70
|
+
# Flask stuff:
|
|
71
|
+
instance/
|
|
72
|
+
.webassets-cache
|
|
73
|
+
|
|
74
|
+
# Scrapy stuff:
|
|
75
|
+
.scrapy
|
|
76
|
+
|
|
77
|
+
# Sphinx documentation
|
|
78
|
+
docs/_build/
|
|
79
|
+
|
|
80
|
+
# PyBuilder
|
|
81
|
+
target/
|
|
82
|
+
|
|
83
|
+
# Jupyter Notebook
|
|
84
|
+
.ipynb_checkpoints
|
|
85
|
+
.ipynb
|
|
86
|
+
|
|
87
|
+
# IPython
|
|
88
|
+
profile_default/
|
|
89
|
+
ipython_config.py
|
|
90
|
+
|
|
91
|
+
# pyenv
|
|
92
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
93
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
94
|
+
.python-version
|
|
95
|
+
|
|
96
|
+
# lock files
|
|
97
|
+
# According to pypa/pipenv#598, it is recommended to include the lock file in version control.
|
|
98
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
99
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
100
|
+
# install all needed dependencies.
|
|
101
|
+
Pipfile.lock
|
|
102
|
+
poetry.lock
|
|
103
|
+
requirements.txt
|
|
104
|
+
requirements.in
|
|
105
|
+
pdm.lock
|
|
106
|
+
# uv.lock
|
|
107
|
+
|
|
108
|
+
# other tools
|
|
109
|
+
.pdm-python
|
|
110
|
+
.pdm-build
|
|
111
|
+
|
|
112
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
113
|
+
__pypackages__/
|
|
114
|
+
|
|
115
|
+
# Celery stuff
|
|
116
|
+
celerybeat-schedule
|
|
117
|
+
celerybeat.pid
|
|
118
|
+
|
|
119
|
+
# SageMath parsed files
|
|
120
|
+
*.sage.py
|
|
121
|
+
|
|
122
|
+
# Environments
|
|
123
|
+
.env
|
|
124
|
+
.envrc
|
|
125
|
+
.venv
|
|
126
|
+
env/
|
|
127
|
+
venv/
|
|
128
|
+
ENV/
|
|
129
|
+
env.bak/
|
|
130
|
+
venv.bak/
|
|
131
|
+
|
|
132
|
+
# Spyder project settings
|
|
133
|
+
.spyderproject
|
|
134
|
+
.spyproject
|
|
135
|
+
|
|
136
|
+
# Rope project settings
|
|
137
|
+
.ropeproject
|
|
138
|
+
|
|
139
|
+
# mkdocs documentation
|
|
140
|
+
/site
|
|
141
|
+
|
|
142
|
+
# mypy
|
|
143
|
+
.mypy_cache/
|
|
144
|
+
.dmypy.json
|
|
145
|
+
dmypy.json
|
|
146
|
+
|
|
147
|
+
# Pyre type checker
|
|
148
|
+
.pyre/
|
|
149
|
+
|
|
150
|
+
# pytype static type analyzer
|
|
151
|
+
.pytype/
|
|
152
|
+
|
|
153
|
+
# PyCharm stuff
|
|
154
|
+
.idea
|
|
155
|
+
# User-specific stuff
|
|
156
|
+
.idea/**/workspace.xml
|
|
157
|
+
.idea/**/tasks.xml
|
|
158
|
+
.idea/**/usage.statistics.xml
|
|
159
|
+
.idea/**/dictionaries
|
|
160
|
+
.idea/**/shelf
|
|
161
|
+
|
|
162
|
+
# Generated files
|
|
163
|
+
.idea/**/contentModel.xml
|
|
164
|
+
|
|
165
|
+
# Sensitive or high-churn files
|
|
166
|
+
.idea/**/dataSources/
|
|
167
|
+
.idea/**/dataSources.ids
|
|
168
|
+
.idea/**/dataSources.local.xml
|
|
169
|
+
.idea/**/sqlDataSources.xml
|
|
170
|
+
.idea/**/dynamic.xml
|
|
171
|
+
.idea/**/uiDesigner.xml
|
|
172
|
+
.idea/**/dbnavigator.xml
|
|
173
|
+
|
|
174
|
+
# Editor workspace files
|
|
175
|
+
.vscode
|
|
176
|
+
|
|
177
|
+
.testmondata
|
|
178
|
+
|
|
179
|
+
# macOS
|
|
180
|
+
.DS_Store
|
|
181
|
+
|
|
182
|
+
# Docker stuff for local dev
|
|
183
|
+
docker-compose.override.yml
|
|
184
|
+
|
|
185
|
+
.skip-hooks
|
|
186
|
+
|
|
187
|
+
tmp/
|
|
188
|
+
.ruff_cache
|
|
189
|
+
|
|
190
|
+
# run configurations
|
|
191
|
+
.run/
|
|
192
|
+
/.run/
|
|
193
|
+
|
|
194
|
+
# kube manifest
|
|
195
|
+
helm/baked_manifest.yaml
|
|
196
|
+
|
|
197
|
+
# local storage
|
|
198
|
+
_storage
|
|
199
|
+
storage_
|
|
200
|
+
database.json
|
|
201
|
+
|
|
202
|
+
# localstack files
|
|
203
|
+
volume/
|
|
204
|
+
|
|
205
|
+
# HTML rendering files
|
|
206
|
+
schematalog/presentation/webapp/static/pico/
|
|
207
|
+
|
|
208
|
+
# frontend toolchain
|
|
209
|
+
# Path-independent: a `frontend/` prefix stopped matching when the frontend moved
|
|
210
|
+
# into its package, and 1816 files went into the index before anyone noticed.
|
|
211
|
+
**/node_modules/
|
|
212
|
+
# Vite build output (regenerated by `just build-fe`; built fresh in the Docker image)
|
|
213
|
+
packages/schematalog-app/schematalog/app/presentation/webapp/static/dist/
|
|
214
|
+
|
|
215
|
+
# depscan report
|
|
216
|
+
reports/
|
|
217
|
+
|
|
218
|
+
# complexipy
|
|
219
|
+
.complexipy_cache/
|
|
220
|
+
|
|
221
|
+
profile.stats
|
|
222
|
+
|
|
223
|
+
# logfire
|
|
224
|
+
.logfire/
|
|
225
|
+
|
|
226
|
+
# AI files
|
|
227
|
+
CLAUDE.md
|
|
228
|
+
.claude/
|
|
229
|
+
# Point-in-time analysis written for the 2026-08-14 strategy conversation, at commit
|
|
230
|
+
# e511be0. Describes the pre-pivot world in the present tense, so it is kept local
|
|
231
|
+
# rather than published; what outlived it lives in DECISIONS.md and ROADMAP.md.
|
|
232
|
+
DIRECTION-ANALYSIS.md
|
|
233
|
+
|
|
234
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Berislav Lopac
|
|
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,69 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: schematalog-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Schematalog domain contract and its conformance suite.
|
|
5
|
+
Project-URL: Homepage, https://schematalog.com
|
|
6
|
+
Project-URL: Documentation, https://schematalog.com
|
|
7
|
+
Project-URL: Source, https://github.com/berislavlopac/schematalog
|
|
8
|
+
Author-email: Berislav Lopac <berislav@lopac.net>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: ~=3.14.0
|
|
17
|
+
Requires-Dist: jsonschema>=4.23.0
|
|
18
|
+
Requires-Dist: pydantic>=2.9.0
|
|
19
|
+
Requires-Dist: sanitary>=0.2.1
|
|
20
|
+
Requires-Dist: unclogger>=0.2.2
|
|
21
|
+
Provides-Extra: testing
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'testing'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# schematalog-core
|
|
26
|
+
|
|
27
|
+
The domain contract for [Schematalog](https://schematalog.com), a registry and catalog
|
|
28
|
+
for JSON Schema specifications — and the conformance suite that goes with it.
|
|
29
|
+
|
|
30
|
+
**This is what a storage backend codes against.** It deliberately does not depend on the
|
|
31
|
+
registry application, so implementing a backend does not oblige you to install a web
|
|
32
|
+
framework.
|
|
33
|
+
|
|
34
|
+
```shell
|
|
35
|
+
pip install schematalog-core[testing]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Writing a storage backend
|
|
39
|
+
|
|
40
|
+
A backend implements five methods — `add`, `get`, `set_metadata`, `list_versions` and
|
|
41
|
+
`list_names`. Three more (`get_latest`, `list_latest`, `list_predecessors`) are derived
|
|
42
|
+
for you by `SchemaRepository`, including the rule for which version counts as *latest*,
|
|
43
|
+
so you inherit that rather than reimplementing it. Override them only if your store can
|
|
44
|
+
answer them better.
|
|
45
|
+
|
|
46
|
+
The contract is written as tests. Subclass it, supply one fixture yielding an empty
|
|
47
|
+
repository, and it tells you whether your backend is correct:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import pytest
|
|
51
|
+
from schematalog.testing import SchemaRepositoryConformance
|
|
52
|
+
|
|
53
|
+
class TestMyBackend(SchemaRepositoryConformance):
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def repository(self):
|
|
56
|
+
return MyRepository(...)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Registration is a `schematalog.storage` entry point naming the URL scheme you answer to;
|
|
60
|
+
nothing in the registry needs changing. See
|
|
61
|
+
[`schematalog-s3`](https://pypi.org/project/schematalog-s3/) for a complete worked
|
|
62
|
+
example.
|
|
63
|
+
|
|
64
|
+
## What it contains
|
|
65
|
+
|
|
66
|
+
- `schematalog.domain` — `Schema`, `SchemaRepository`, the value objects and errors.
|
|
67
|
+
- `schematalog.testing` — the conformance suite and a sample schema to test with.
|
|
68
|
+
- `schematalog.common` — layer-neutral helpers, including a dependency-free JSON Schema
|
|
69
|
+
↔ Avro converter.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# schematalog-core
|
|
2
|
+
|
|
3
|
+
The domain contract for [Schematalog](https://schematalog.com), a registry and catalog
|
|
4
|
+
for JSON Schema specifications — and the conformance suite that goes with it.
|
|
5
|
+
|
|
6
|
+
**This is what a storage backend codes against.** It deliberately does not depend on the
|
|
7
|
+
registry application, so implementing a backend does not oblige you to install a web
|
|
8
|
+
framework.
|
|
9
|
+
|
|
10
|
+
```shell
|
|
11
|
+
pip install schematalog-core[testing]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Writing a storage backend
|
|
15
|
+
|
|
16
|
+
A backend implements five methods — `add`, `get`, `set_metadata`, `list_versions` and
|
|
17
|
+
`list_names`. Three more (`get_latest`, `list_latest`, `list_predecessors`) are derived
|
|
18
|
+
for you by `SchemaRepository`, including the rule for which version counts as *latest*,
|
|
19
|
+
so you inherit that rather than reimplementing it. Override them only if your store can
|
|
20
|
+
answer them better.
|
|
21
|
+
|
|
22
|
+
The contract is written as tests. Subclass it, supply one fixture yielding an empty
|
|
23
|
+
repository, and it tells you whether your backend is correct:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import pytest
|
|
27
|
+
from schematalog.testing import SchemaRepositoryConformance
|
|
28
|
+
|
|
29
|
+
class TestMyBackend(SchemaRepositoryConformance):
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def repository(self):
|
|
32
|
+
return MyRepository(...)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Registration is a `schematalog.storage` entry point naming the URL scheme you answer to;
|
|
36
|
+
nothing in the registry needs changing. See
|
|
37
|
+
[`schematalog-s3`](https://pypi.org/project/schematalog-s3/) for a complete worked
|
|
38
|
+
example.
|
|
39
|
+
|
|
40
|
+
## What it contains
|
|
41
|
+
|
|
42
|
+
- `schematalog.domain` — `Schema`, `SchemaRepository`, the value objects and errors.
|
|
43
|
+
- `schematalog.testing` — the conformance suite and a sample schema to test with.
|
|
44
|
+
- `schematalog.common` — layer-neutral helpers, including a dependency-free JSON Schema
|
|
45
|
+
↔ Avro converter.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "schematalog-core"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "The Schematalog domain contract and its conformance suite."
|
|
5
|
+
authors = [{ name = "Berislav Lopac", email = "berislav@lopac.net" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3.14",
|
|
13
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
14
|
+
"Topic :: Software Development :: Testing",
|
|
15
|
+
]
|
|
16
|
+
requires-python = "~=3.14.0"
|
|
17
|
+
# Deliberately small. This is what a storage backend codes against, so anything added
|
|
18
|
+
# here is added to every backend author's environment - which is why the SQLAlchemy
|
|
19
|
+
# column types live with the backend that uses them rather than here.
|
|
20
|
+
dependencies = [
|
|
21
|
+
"jsonschema>=4.23.0",
|
|
22
|
+
"pydantic>=2.9.0",
|
|
23
|
+
"sanitary>=0.2.1",
|
|
24
|
+
"unclogger>=0.2.2",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
# The conformance suite is importable test code; pytest is needed only by those running it.
|
|
29
|
+
testing = ["pytest>=8.0"]
|
|
30
|
+
|
|
31
|
+
[tool.deptry]
|
|
32
|
+
known_first_party = ["schematalog"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://schematalog.com"
|
|
36
|
+
Documentation = "https://schematalog.com"
|
|
37
|
+
Source = "https://github.com/berislavlopac/schematalog"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "schematalog/domain/__init__.py"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["schematalog"]
|
|
48
|
+
# The sample schema is data the published test support reads at runtime, not a fixture
|
|
49
|
+
# left behind in the source tree.
|
|
50
|
+
artifacts = ["schematalog/testing/example_schema.json"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Layer-neutral utilities shared across the application (no dependency on other layers)."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Convert between JSON Schema and Apache Avro schemas.
|
|
2
|
+
|
|
3
|
+
A small, dependency-free converter covering the common subset of JSON Schema:
|
|
4
|
+
objects/records, primitives, arrays, enums, nullable fields (unions), nested
|
|
5
|
+
records, string formats that map to Avro logical types, and internal ``$ref``
|
|
6
|
+
pointers (``#/$defs/...``, inlined before conversion). Constructs with no clean Avro
|
|
7
|
+
equivalent (``oneOf``/``anyOf``/``allOf``, external or recursive ``$ref``) raise
|
|
8
|
+
:class:`AvroConversionError` rather than emitting an invalid schema.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .exceptions import AvroConversionError
|
|
12
|
+
from .to_avro import to_avro
|
|
13
|
+
from .to_json_schema import to_json_schema
|
|
14
|
+
|
|
15
|
+
__all__ = ["AvroConversionError", "to_avro", "to_json_schema"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""JSON Schema -> Avro schema."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .exceptions import AvroConversionError
|
|
7
|
+
|
|
8
|
+
AvroType = str | dict[str, Any] | list[Any]
|
|
9
|
+
|
|
10
|
+
# JSON Schema primitive `type` -> Avro primitive type.
|
|
11
|
+
_PRIMITIVES = {
|
|
12
|
+
"boolean": "boolean",
|
|
13
|
+
"integer": "long",
|
|
14
|
+
"number": "double",
|
|
15
|
+
"null": "null",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# JSON Schema string `format` -> (Avro base type, Avro logicalType).
|
|
19
|
+
_STRING_FORMATS = {
|
|
20
|
+
"date": ("int", "date"),
|
|
21
|
+
"time": ("int", "time-millis"),
|
|
22
|
+
"date-time": ("long", "timestamp-millis"),
|
|
23
|
+
"uuid": ("string", "uuid"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# JSON Schema keywords we cannot faithfully express in Avro. (`$ref` is handled
|
|
27
|
+
# separately: internal pointers are inlined before conversion, see `_resolve_refs`.)
|
|
28
|
+
_UNSUPPORTED = ("oneOf", "anyOf", "allOf", "not")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_avro(
|
|
32
|
+
json_schema: dict[str, Any], *, name: str = "Record", namespace: str = ""
|
|
33
|
+
) -> AvroType:
|
|
34
|
+
"""Convert a JSON Schema into an Avro schema.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
json_schema: The JSON Schema to convert.
|
|
38
|
+
name: Name for the root Avro record/enum (a schema ``title`` overrides it).
|
|
39
|
+
namespace: Optional Avro namespace for named types.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The Avro schema as a JSON-compatible structure.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
AvroConversionError: If the schema uses unsupported constructs.
|
|
46
|
+
"""
|
|
47
|
+
return _convert(_resolve_refs(json_schema, json_schema, ()), name, namespace, set())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_refs(schema: Any, root: dict, stack: tuple[str, ...]) -> Any:
|
|
51
|
+
"""Inline internal ``$ref`` pointers (``#/...``) against the document root.
|
|
52
|
+
|
|
53
|
+
Avro has no reference mechanism, so a fragment ref is expanded in place before
|
|
54
|
+
conversion - e.g. ``{"$ref": "#/$defs/country"}`` becomes the ``country`` subschema.
|
|
55
|
+
External refs (anything not ``#/...``) and cyclic refs cannot be expressed and raise.
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(schema, list):
|
|
58
|
+
return [_resolve_refs(item, root, stack) for item in schema]
|
|
59
|
+
if not isinstance(schema, dict):
|
|
60
|
+
return schema
|
|
61
|
+
ref = schema.get("$ref")
|
|
62
|
+
if isinstance(ref, str):
|
|
63
|
+
return _resolve_ref(schema, ref, root, stack)
|
|
64
|
+
return {key: _resolve_refs(value, root, stack) for key, value in schema.items()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_ref(schema: dict, ref: str, root: dict, stack: tuple[str, ...]) -> Any:
|
|
68
|
+
"""Expand a single ``$ref`` node, overlaying any sibling keys onto the target."""
|
|
69
|
+
if not ref.startswith("#/"):
|
|
70
|
+
raise AvroConversionError(f"Cannot resolve external $ref: {ref!r}.")
|
|
71
|
+
if ref in stack:
|
|
72
|
+
raise AvroConversionError(f"Cannot express recursive $ref in Avro: {ref!r}.")
|
|
73
|
+
target = _resolve_refs(_deref(ref, root), root, (*stack, ref))
|
|
74
|
+
# JSON Schema allows keys beside `$ref`; overlay them onto the resolved object.
|
|
75
|
+
siblings = {key: value for key, value in schema.items() if key != "$ref"}
|
|
76
|
+
if siblings and isinstance(target, dict):
|
|
77
|
+
return {**target, **_resolve_refs(siblings, root, stack)}
|
|
78
|
+
return target
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _deref(ref: str, root: dict) -> Any:
|
|
82
|
+
"""Resolve a ``#/a/b`` JSON Pointer against ``root`` (with ~0/~1 unescaping)."""
|
|
83
|
+
node: Any = root
|
|
84
|
+
for raw in ref[2:].split("/"): # drop the leading '#/'
|
|
85
|
+
token = raw.replace("~1", "/").replace("~0", "~")
|
|
86
|
+
if not isinstance(node, dict) or token not in node:
|
|
87
|
+
raise AvroConversionError(f"Cannot resolve $ref: {ref!r}.")
|
|
88
|
+
node = node[token]
|
|
89
|
+
return node
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _convert(schema: Any, name: str, namespace: str, seen: set[str]) -> AvroType:
|
|
93
|
+
if not isinstance(schema, dict):
|
|
94
|
+
raise AvroConversionError(f"Expected a schema object, got {type(schema).__name__}.")
|
|
95
|
+
for keyword in _UNSUPPORTED:
|
|
96
|
+
if keyword in schema:
|
|
97
|
+
raise AvroConversionError(f"Unsupported JSON Schema keyword: {keyword!r}.")
|
|
98
|
+
|
|
99
|
+
if "enum" in schema:
|
|
100
|
+
return _enum(schema, name, namespace, seen)
|
|
101
|
+
|
|
102
|
+
json_type = schema.get("type")
|
|
103
|
+
if isinstance(json_type, list):
|
|
104
|
+
return _union(json_type, schema, name, namespace, seen)
|
|
105
|
+
if json_type == "object":
|
|
106
|
+
return _object(schema, name, namespace, seen)
|
|
107
|
+
if json_type == "array":
|
|
108
|
+
return _array(schema, name, namespace, seen)
|
|
109
|
+
if json_type == "string":
|
|
110
|
+
return _string(schema)
|
|
111
|
+
if json_type in _PRIMITIVES:
|
|
112
|
+
return _PRIMITIVES[json_type]
|
|
113
|
+
raise AvroConversionError(f"Unsupported or missing JSON Schema type: {json_type!r}.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _object(schema: dict, name: str, namespace: str, seen: set[str]) -> AvroType:
|
|
117
|
+
additional = schema.get("additionalProperties")
|
|
118
|
+
if not schema.get("properties") and isinstance(additional, dict):
|
|
119
|
+
return {"type": "map", "values": _convert(additional, name, namespace, seen)}
|
|
120
|
+
|
|
121
|
+
record_name = _unique(_pascal(schema.get("title") or name), seen)
|
|
122
|
+
required = set(schema.get("required", []))
|
|
123
|
+
fields = []
|
|
124
|
+
for prop_name, prop_schema in schema.get("properties", {}).items():
|
|
125
|
+
field_type = _convert(prop_schema, prop_name, namespace, seen)
|
|
126
|
+
field: dict[str, Any] = {"name": prop_name, "type": field_type}
|
|
127
|
+
if isinstance(prop_schema, dict) and prop_schema.get("description"):
|
|
128
|
+
field["doc"] = prop_schema["description"]
|
|
129
|
+
if prop_name not in required:
|
|
130
|
+
field["type"] = _nullable(field_type)
|
|
131
|
+
field["default"] = None
|
|
132
|
+
fields.append(field)
|
|
133
|
+
|
|
134
|
+
record: dict[str, Any] = {"type": "record", "name": record_name, "fields": fields}
|
|
135
|
+
if namespace:
|
|
136
|
+
record["namespace"] = namespace
|
|
137
|
+
if schema.get("description"):
|
|
138
|
+
record["doc"] = schema["description"]
|
|
139
|
+
return record
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _array(schema: dict, name: str, namespace: str, seen: set[str]) -> AvroType:
|
|
143
|
+
items = schema.get("items")
|
|
144
|
+
if not isinstance(items, dict):
|
|
145
|
+
raise AvroConversionError("Array schema must declare an 'items' object.")
|
|
146
|
+
return {"type": "array", "items": _convert(items, f"{_pascal(name)}Item", namespace, seen)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _string(schema: dict) -> AvroType:
|
|
150
|
+
fmt = schema.get("format")
|
|
151
|
+
if isinstance(fmt, str) and fmt in _STRING_FORMATS:
|
|
152
|
+
base, logical = _STRING_FORMATS[fmt]
|
|
153
|
+
return {"type": base, "logicalType": logical}
|
|
154
|
+
return "string"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _enum(schema: dict, name: str, namespace: str, seen: set[str]) -> AvroType:
|
|
158
|
+
symbols = schema["enum"]
|
|
159
|
+
valid = all(isinstance(s, str) and _is_avro_name(s) for s in symbols)
|
|
160
|
+
if valid and len(set(symbols)) == len(symbols):
|
|
161
|
+
enum: dict[str, Any] = {
|
|
162
|
+
"type": "enum",
|
|
163
|
+
"name": _unique(_pascal(name), seen),
|
|
164
|
+
"symbols": list(symbols),
|
|
165
|
+
}
|
|
166
|
+
if namespace:
|
|
167
|
+
enum["namespace"] = namespace
|
|
168
|
+
return enum
|
|
169
|
+
# Enums Avro can't represent (non-string or non-identifier values) degrade to string.
|
|
170
|
+
return "string"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _union(types: list, schema: dict, name: str, namespace: str, seen: set[str]) -> AvroType:
|
|
174
|
+
base = {key: value for key, value in schema.items() if key not in ("type", "enum")}
|
|
175
|
+
parts: list[AvroType] = []
|
|
176
|
+
for json_type in types:
|
|
177
|
+
member = _convert({**base, "type": json_type}, name, namespace, seen)
|
|
178
|
+
if member not in parts:
|
|
179
|
+
parts.append(member)
|
|
180
|
+
# Avro convention: null first so a `null` default is valid.
|
|
181
|
+
if "null" in parts:
|
|
182
|
+
parts = ["null", *(part for part in parts if part != "null")]
|
|
183
|
+
return parts
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _nullable(avro_type: AvroType) -> AvroType:
|
|
187
|
+
if isinstance(avro_type, list):
|
|
188
|
+
return avro_type if "null" in avro_type else ["null", *avro_type]
|
|
189
|
+
return ["null", avro_type]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _pascal(value: str) -> str:
|
|
193
|
+
parts = re.split(r"[^0-9a-zA-Z]+", value)
|
|
194
|
+
name = "".join(part[:1].upper() + part[1:] for part in parts if part)
|
|
195
|
+
return name or "Record"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _unique(name: str, seen: set[str]) -> str:
|
|
199
|
+
candidate, index = name, 1
|
|
200
|
+
while candidate in seen:
|
|
201
|
+
index += 1
|
|
202
|
+
candidate = f"{name}{index}"
|
|
203
|
+
seen.add(candidate)
|
|
204
|
+
return candidate
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _is_avro_name(value: str) -> bool:
|
|
208
|
+
return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value))
|