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.
Files changed (30) hide show
  1. schematalog_core-0.1.0/.gitignore +234 -0
  2. schematalog_core-0.1.0/LICENSE +21 -0
  3. schematalog_core-0.1.0/PKG-INFO +69 -0
  4. schematalog_core-0.1.0/README.md +45 -0
  5. schematalog_core-0.1.0/pyproject.toml +50 -0
  6. schematalog_core-0.1.0/schematalog/common/__init__.py +1 -0
  7. schematalog_core-0.1.0/schematalog/common/avroform/__init__.py +15 -0
  8. schematalog_core-0.1.0/schematalog/common/avroform/exceptions.py +2 -0
  9. schematalog_core-0.1.0/schematalog/common/avroform/to_avro.py +208 -0
  10. schematalog_core-0.1.0/schematalog/common/avroform/to_json_schema.py +124 -0
  11. schematalog_core-0.1.0/schematalog/common/logging.py +141 -0
  12. schematalog_core-0.1.0/schematalog/common/models.py +12 -0
  13. schematalog_core-0.1.0/schematalog/common/validation.py +80 -0
  14. schematalog_core-0.1.0/schematalog/domain/__init__.py +8 -0
  15. schematalog_core-0.1.0/schematalog/domain/exceptions.py +13 -0
  16. schematalog_core-0.1.0/schematalog/domain/schema.py +465 -0
  17. schematalog_core-0.1.0/schematalog/testing/__init__.py +12 -0
  18. schematalog_core-0.1.0/schematalog/testing/conformance.py +259 -0
  19. schematalog_core-0.1.0/schematalog/testing/example_schema.json +66 -0
  20. schematalog_core-0.1.0/schematalog/testing/samples.py +28 -0
  21. schematalog_core-0.1.0/tests/__init__.py +0 -0
  22. schematalog_core-0.1.0/tests/conftest.py +20 -0
  23. schematalog_core-0.1.0/tests/unit/__init__.py +0 -0
  24. schematalog_core-0.1.0/tests/unit/avroform/__init__.py +0 -0
  25. schematalog_core-0.1.0/tests/unit/avroform/test_roundtrip.py +76 -0
  26. schematalog_core-0.1.0/tests/unit/avroform/test_to_avro.py +169 -0
  27. schematalog_core-0.1.0/tests/unit/avroform/test_to_json_schema.py +94 -0
  28. schematalog_core-0.1.0/tests/unit/domain/__init__.py +0 -0
  29. schematalog_core-0.1.0/tests/unit/domain/test_schema.py +241 -0
  30. 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,2 @@
1
+ class AvroConversionError(Exception):
2
+ """Raised when a schema cannot be converted between JSON Schema and Avro."""
@@ -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))