Skip to content

Commit 50cf702

Browse files
manztnvictus
andauthored
Tidy up JSON schema generation (#17)
Co-authored-by: Nezar Abdennur <nabdennur@gmail.com>
1 parent a14c66a commit 50cf702

File tree

5 files changed

+101
-75
lines changed

5 files changed

+101
-75
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ dependencies = ["pydantic>=2.0", "rich>=13.0.0"]
1515
[project.urls]
1616
homepage = "https://github.com/higlass/higlass-schema"
1717

18-
[tool.uv]
19-
dev-dependencies = ["black", "pytest", "ruff"]
18+
[dependency-groups]
19+
dev = ["black", "pytest", "ruff"]
2020

2121
[project.scripts]
2222
higlass-schema = "higlass_schema.cli:main"

src/higlass_schema/cli.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
console = Console()
1010

1111

12-
def export(_: argparse.Namespace) -> None:
13-
console.print_json(schema_json(indent=2))
12+
def export(args: argparse.Namespace) -> None:
13+
print(schema_json(indent=args.indent))
1414

1515

1616
def check(args: argparse.Namespace) -> None:
@@ -30,7 +30,8 @@ def check(args: argparse.Namespace) -> None:
3030
console.print_exception()
3131

3232
console.print(
33-
f"{msg} Run [white]`hgschema check --verbose`[/white] for more details.",
33+
f"{msg} Run [white]`higlass-schema check --verbose`[/white] for "
34+
"more details.",
3435
style="yellow",
3536
)
3637
sys.exit(1)
@@ -43,6 +44,7 @@ def main():
4344

4445
# export
4546
parser_export = subparsers.add_parser("export")
47+
parser_export.add_argument("--indent", type=int)
4648
parser_export.set_defaults(func=export)
4749

4850
# check

src/higlass_schema/schema.py

Lines changed: 9 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
from collections import OrderedDict
32
from typing import (
43
Any,
54
Dict,
@@ -14,18 +13,14 @@
1413

1514
from pydantic import BaseModel as PydanticBaseModel
1615
from pydantic import ConfigDict, Field, RootModel, model_validator
17-
from pydantic.json_schema import GenerateJsonSchema
1816
from typing_extensions import Annotated, Literal, TypedDict
1917

20-
from .utils import exclude_properties_titles, get_schema_of, simplify_enum_schema
18+
from .utils import _GenerateJsonSchema, get_schema_of
2119

2220

2321
# Override Basemodel
2422
class BaseModel(PydanticBaseModel):
25-
model_config = ConfigDict(
26-
validate_assignment=True,
27-
json_schema_extra=lambda s, _: exclude_properties_titles(s),
28-
)
23+
model_config = ConfigDict(validate_assignment=True)
2924

3025
# nice repr if printing with rich
3126
def __rich_repr__(self):
@@ -116,14 +111,13 @@ class Overlay(BaseModel):
116111

117112

118113
# We'd rather have tuples in our final model, because a
119-
# __root__ model is clunky from a python user perspective.
114+
# RootModel is clunky from a python user perspective.
120115
# We create this class to get validation for free in `root_validator`
121116
class _LockEntryModel(RootModel[LockEntry]):
122117
pass
123118

124119

125120
def _lock_schema_extra(schema: Dict[str, Any], _: Any) -> None:
126-
exclude_properties_titles(schema)
127121
schema["additionalProperties"] = get_schema_of(LockEntry)
128122

129123

@@ -160,7 +154,6 @@ class _ValueScaleLockEntryModel(RootModel[ValueScaleLockEntry]):
160154

161155

162156
def _value_scale_lock_schema_extra(schema: Dict[str, Any], _: Any) -> None:
163-
exclude_properties_titles(schema)
164157
schema["additionalProperties"] = get_schema_of(ValueScaleLockEntry)
165158

166159

@@ -191,14 +184,7 @@ def validate_locks(cls, values: Dict[str, Any]):
191184
return values
192185

193186

194-
def _axis_specific_lock_schema_extra(schema: Dict[str, Any], _: Any) -> None:
195-
exclude_properties_titles(schema)
196-
schema["properties"]["axis"] = simplify_enum_schema(schema["properties"]["axis"])
197-
198-
199187
class AxisSpecificLock(BaseModel):
200-
model_config = ConfigDict(json_schema_extra=_axis_specific_lock_schema_extra)
201-
202188
axis: Literal["x", "y"]
203189
lock: str
204190

@@ -249,15 +235,8 @@ class Data(BaseModel):
249235
tiles: Optional[Tile] = None
250236

251237

252-
def _base_track_schema_extra(schema, _):
253-
exclude_properties_titles(schema)
254-
props = schema["properties"]
255-
if "enum" in props["type"] or "allOf" in props["type"]:
256-
props["type"] = simplify_enum_schema(props["type"])
257-
258-
259238
class BaseTrack(BaseModel, Generic[TrackTypeT]):
260-
model_config = ConfigDict(extra="allow", json_schema_extra=_base_track_schema_extra)
239+
model_config = ConfigDict(extra="allow")
261240

262241
type: TrackTypeT
263242
uid: Optional[str] = None
@@ -500,11 +479,7 @@ class View(BaseModel, Generic[TrackT]):
500479
class Viewconf(BaseModel, Generic[ViewT]):
501480
"""Root object describing a HiGlass visualization."""
502481

503-
model_config = ConfigDict(
504-
extra="forbid",
505-
title="HiGlass viewconf",
506-
json_schema_extra=lambda s, _: exclude_properties_titles(s),
507-
)
482+
model_config = ConfigDict(extra="forbid")
508483

509484
editable: Optional[bool] = True
510485
viewEditable: Optional[bool] = True
@@ -521,21 +496,10 @@ class Viewconf(BaseModel, Generic[ViewT]):
521496

522497

523498
def schema():
524-
root = Viewconf.model_json_schema()
525-
526-
# remove titles in defintions
527-
for d in root["$defs"].values():
528-
d.pop("title", None)
529-
530-
# nice ordering, insert additional metadata
531-
ordered_root = OrderedDict(
532-
[
533-
("$schema", GenerateJsonSchema.schema_dialect),
534-
*root.items(),
535-
]
536-
)
537-
538-
return dict(ordered_root)
499+
json_schema = Viewconf.model_json_schema(schema_generator=_GenerateJsonSchema)
500+
json_schema["$schema"] = _GenerateJsonSchema.schema_dialect
501+
json_schema["title"] = "HiGlass viewconf"
502+
return json_schema
539503

540504

541505
def schema_json(**kwargs):

src/higlass_schema/utils.py

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,101 @@
1-
from typing import Any, Dict, TypeVar
1+
from __future__ import annotations
22

3+
from typing import TYPE_CHECKING, Any, TypeVar, Union
4+
5+
import pydantic_core.core_schema as core_schema
36
from pydantic import BaseModel, TypeAdapter
7+
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaValue
48

9+
if TYPE_CHECKING:
10+
from typing import TypeGuard
511

6-
def simplify_schema(root_schema: Dict[str, Any]) -> Dict[str, Any]:
7-
"""Lift defintion reference to root if only definition"""
8-
# type of root is not a reference to a definition
9-
if "$ref" not in root_schema:
10-
return root_schema
12+
### Vendored from pydantic._internal._core_utils
1113

12-
defs = list(root_schema["$defs"].values())
13-
if len(defs) != 1:
14-
return root_schema
14+
CoreSchemaField = Union[
15+
core_schema.ModelField,
16+
core_schema.DataclassField,
17+
core_schema.TypedDictField,
18+
core_schema.ComputedField,
19+
]
1520

16-
return defs[0]
21+
CoreSchemaOrField = Union[core_schema.CoreSchema, CoreSchemaField]
1722

23+
_CORE_SCHEMA_FIELD_TYPES = {
24+
"typed-dict-field",
25+
"dataclass-field",
26+
"model-field",
27+
"computed-field",
28+
}
1829

19-
# Schema modifiers
20-
ModelT = TypeVar("ModelT", bound=BaseModel)
2130

31+
def is_core_schema(
32+
schema: CoreSchemaOrField,
33+
) -> TypeGuard[core_schema.CoreSchema]:
34+
return schema["type"] not in _CORE_SCHEMA_FIELD_TYPES
35+
36+
37+
### End vendored code
38+
39+
40+
class _GenerateJsonSchema(GenerateJsonSchema):
41+
def field_title_should_be_set(self, schema: CoreSchemaOrField) -> bool:
42+
"""Check if the title should be set for a field.
43+
44+
Override the default implementation to not set the title for core
45+
schemas. Makes the final schema more readable by removing
46+
redundant titles. Explicit Field(title=...) can still be used.
47+
"""
48+
return_value = super().field_title_should_be_set(schema)
49+
if return_value and is_core_schema(schema):
50+
return False
51+
return return_value
52+
53+
def nullable_schema(self, schema: core_schema.NullableSchema) -> JsonSchemaValue:
54+
"""Generate a JSON schema for a nullable schema.
2255
23-
def exclude_properties_titles(schema: Dict[str, Any]) -> None:
24-
"""Remove automatically generated tiles for pydantic classes."""
25-
for prop in schema.get("properties", {}).values():
26-
prop.pop("title", None)
56+
This overrides the default implementation to ignore the nullable
57+
and generate a more simple schema. All the Optional[T] fields
58+
are converted to T (instead of the
59+
default {"anyOf": [{"type": "null"}, {"type": "T"}]}).
60+
"""
61+
return self.generate_inner(schema["schema"])
62+
63+
def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue:
64+
"""Generate a JSON schema for a schema with a default value.
65+
66+
Similar to above, this overrides the default implementation to
67+
not explicity set {"default": null} in the schema when the field
68+
is Optional[T] = None.
69+
"""
70+
if schema.get("default") is None:
71+
return self.generate_inner(schema["schema"])
72+
return super().default_schema(schema)
73+
74+
def generate(
75+
self, schema: core_schema.CoreSchema, mode: JsonSchemaMode = "validation"
76+
) -> JsonSchemaValue:
77+
"""Generate a JSON schema.
78+
79+
This overrides the default implementation to remove the titles
80+
from the definitions. This makes the final schema more readable.
81+
"""
82+
83+
json_schema = super().generate(schema, mode=mode)
84+
# clear the titles from the definitions
85+
for d in json_schema.get("$defs", {}).values():
86+
d.pop("title", None)
87+
return json_schema
88+
89+
90+
# Schema modifiers
91+
ModelT = TypeVar("ModelT", bound=BaseModel)
2792

2893

29-
def get_schema_of(type_: Any):
30-
schema = TypeAdapter(type_).json_schema()
31-
schema = simplify_schema(schema)
32-
exclude_properties_titles(schema)
33-
# remove autogenerated title
34-
schema.pop("title", None)
35-
return schema
94+
def get_schema_of(type_: object):
95+
return TypeAdapter(type_).json_schema(schema_generator=_GenerateJsonSchema)
3696

3797

38-
def simplify_enum_schema(schema: Dict[str, Any]):
98+
def simplify_enum_schema(schema: dict[str, Any]):
3999
# reduce union of enums into single enum
40100
if "anyOf" in schema:
41101
enum = []

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)