Skip to content

Commit 4bb071a

Browse files
authored
chore: make release-ready for initial distribution (#27)
1 parent 8cd0926 commit 4bb071a

30 files changed

+843
-1961
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Upload package to PyPI
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
jobs:
8+
deploy:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- uses: actions/checkout@v2
13+
- uses: actions/setup-python@v2
14+
- name: Install dependencies
15+
run: |
16+
python3 -m pip install --upgrade pip
17+
pip install setuptools wheel twine
18+
- name: Build and publish
19+
env:
20+
TWINE_USERNAME: __token__
21+
TWINE_PASSWORD: ${{ secrets.PYPI }}
22+
run: |
23+
python3 setup.py sdist bdist_wheel
24+
twine upload dist/*

.pre-commit-config.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# See https://pre-commit.com for more information
2-
# See https://pre-commit.com/hooks.html for more hooks
31
repos:
42
- repo: https://github.com/pre-commit/pre-commit-hooks
53
rev: v1.4.0
@@ -9,8 +7,10 @@ repos:
97
rev: 22.3.0
108
hooks:
119
- id: black
12-
# It is recommended to specify the latest version of Python
13-
# supported by your project here, or alternatively use
14-
# pre-commit's default_language_version, see
15-
# https://pre-commit.com/#top_level-default_language_version
10+
args: ["--check"]
1611
language_version: python3.10
12+
- repo: https://github.com/pycqa/isort
13+
rev: 5.6.4
14+
hooks:
15+
- id: isort
16+
args: ["--profile", "black", "--check"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 James Stevenson
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.

README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<h1 align="center">
2+
Oxley: Pydantic classes from JSON schema
3+
</h1>
4+
5+
**Oxley** generates [Pydantic](https://github.com/samuelcolvin/pydantic) classes at runtime from user-provided JSON schema documents. Heavily indebted to packages like [Python-JSONschema-Objects](https://github.com/cwacek/python-jsonschema-objects), Oxley enables data validation pipelines to function dynamically, and with the help of Pydantic, interface directly with popular web frameworks such as [FastAPI](https://github.com/tiangolo/fastapi) and [Starlite](https://github.com/starlite-api/starlite).
6+
7+
## Quick start
8+
9+
Install from PIP:
10+
11+
```shell
12+
python3 -m pip install oxley
13+
```
14+
15+
Given a simple JSONschema document:
16+
17+
```json
18+
{
19+
"$id": "https://github.com/jsstevenson/oxley",
20+
"$schema": "https://json-schema.org/draft/2020-12/schema",
21+
"type": "object",
22+
"$defs": {
23+
"User": {
24+
"type": "object",
25+
"properties": {
26+
"username": {"type": "string"},
27+
"user_id": {"type": "number"}
28+
},
29+
"required": ["username", "user_id"]
30+
},
31+
"Post": {
32+
"type": "object",
33+
"properties": {
34+
"author": {"$ref": "#/$defs/User"},
35+
"content": {"type": "string"},
36+
"allow_responses": {"type": "boolean"}
37+
},
38+
"required": ["author", "content"]
39+
}
40+
}
41+
}
42+
```
43+
44+
Provide a schema and construct classes:
45+
46+
``` python
47+
from oxley import ClassBuilder
48+
schema_path = "path/to/my_jsonschema_document.json"
49+
cb = ClassBuilder(schema_path)
50+
User, Post = cb.build_classes()
51+
```
52+
53+
The resulting objects are functioning Pydantic classes, providing features like runtime data validation and matching schema output.
54+
55+
``` python
56+
dril = User(username="dril", user_id=99)
57+
post = Post(author=dril, content="should i learn Letters first? or choose the path of Numbers? a queston every baby must ask it self")
58+
another_post = Post(author=dril) # raises pydantic.ValidationError
59+
```
60+
61+
## Development
62+
63+
Clone and install dev and test dependencies:
64+
65+
``` shell
66+
git clone https://github.com/jsstevenson/oxley
67+
cd oxley
68+
# make virtual environment of your choosing
69+
python3 -m pip install ".[dev,test]"
70+
```
71+
72+
Install pre-commit hooks:
73+
74+
``` shell
75+
pre-commit install
76+
```
77+
78+
Run tests with tox:
79+
80+
```
81+
tox
82+
```

oxley/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Initialize module."""
2+
import logging
3+
import sys
4+
5+
from .class_builder import ClassBuilder
6+
from .version import __version__
7+
8+
__all__ = ["ClassBuilder"]
9+
10+
logging.basicConfig(
11+
handlers=[logging.FileHandler("oxley.log"), logging.StreamHandler()]
12+
)
Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,43 @@
11
"""Provide class construction tools."""
2+
import json
3+
import logging
4+
import re
5+
from enum import Enum
6+
from pathlib import Path
27
from typing import (
38
Any,
49
Callable,
5-
Literal,
6-
Optional,
710
Dict,
811
ForwardRef,
912
List,
13+
Literal,
14+
Optional,
1015
Set,
1116
Tuple,
1217
Union,
1318
)
14-
from enum import Enum
15-
from pathlib import Path
16-
import json
17-
import logging
18-
import re
1919

20-
from pydantic import create_model
20+
import requests
21+
from pydantic import create_model, validator
22+
from pydantic.class_validators import root_validator
2123
from pydantic.fields import Field, Undefined
2224
from pydantic.main import BaseModel
23-
import requests
24-
from respected_wizard.pydantic_utils import get_configs
2525

26-
from respected_wizard.schema_versions import SchemaVersion, resolve_schema_version
27-
from respected_wizard.typing import resolve_type
28-
from respected_wizard.exceptions import (
26+
from .exceptions import (
2927
InvalidReferenceException,
3028
SchemaConversionException,
3129
UnsupportedSchemaException,
3230
)
31+
from .pydantic_utils import get_configs
32+
from .schema_versions import SchemaVersion, resolve_schema_version
33+
from .types import resolve_type
3334

3435
logging.basicConfig(
3536
level=logging.INFO,
36-
handlers=[logging.FileHandler("respected_wizard.log"), logging.StreamHandler()],
37+
handlers=[
38+
logging.FileHandler("pydantic_jsonschema_objects.log"),
39+
logging.StreamHandler(),
40+
],
3741
)
3842

3943
logger = logging.getLogger(__name__)
@@ -65,8 +69,7 @@ def build_classes(self) -> List:
6569
for name, definition in self.schema[self.def_keyword].items():
6670
self._build_class(name, definition)
6771
while len(self.external_schemas) > 0:
68-
external_name: str = self.external_schemas[0][0]
69-
external_definition: Dict = self.external_schemas[0][1]
72+
external_name, external_definition = self.external_schemas[0]
7073
self._build_class(external_name, external_definition)
7174
self.external_schemas = self.external_schemas[1:]
7275

@@ -83,8 +86,17 @@ def _build_class(self, name: str, definition: Dict) -> None:
8386

8487
def _build_primitive_class(self, name: str, definition: Dict):
8588
"""
86-
Construct classes derived from basic primitives (eg strings).
89+
Construct classes derived from basic primitives (eg strings). Bare strings and
90+
numbers don't need to come through here, but any class that bounds their
91+
possible values will. Updates `self.local_ns` with completed class definition.
8792
Currently only supports strings.
93+
94+
Args:
95+
name: class name
96+
definition: class properties from schema
97+
98+
Raises:
99+
UnsupportedSchemaException: if non-string classes are provided.
88100
"""
89101
attributes = {}
90102
type_tuple: Tuple = ()
@@ -126,10 +138,6 @@ def validate(cls, v):
126138
model = type(name, type_tuple, attributes)
127139
self.local_ns[name] = model
128140

129-
def _handle_class_deprecation(self, name: str, definition: Dict):
130-
if definition.get("deprecated"):
131-
logger.warning(f"Class {name} is deprecated.")
132-
133141
def _build_object_class(self, name: str, definition: Dict):
134142
"""
135143
Construct object-based class.
@@ -141,13 +149,26 @@ def _build_object_class(self, name: str, definition: Dict):
141149
Args:
142150
name: name of the object class
143151
definition: dictionary containing class properties
152+
153+
Raise:
154+
SchemaConversionException:
155+
* if unsupported types are provided as consts
144156
"""
145157
fields: Dict[str, Union[Tuple[Any, Any], Callable]] = {}
146158
has_forward_ref = False
147159
required_fields = definition.get("required", set())
148160
allow_population_by_field_name = False
161+
validators: Dict = {}
162+
163+
if definition.get("deprecated") is True:
149164

150-
self._handle_class_deprecation(name, definition)
165+
def class_deprecation_warning(cls, values):
166+
logger.warning(f"Class {name} is deprecated.")
167+
return values
168+
169+
validators["class_deprecated"] = root_validator(pre=True, allow_reuse=True)(
170+
class_deprecation_warning
171+
)
151172

152173
for prop_name, prop_attrs in definition["properties"].items():
153174
if "$ref" in prop_attrs:
@@ -180,11 +201,22 @@ def dict(self):
180201

181202
fields["dict"] = dict
182203

204+
if prop_attrs.get("deprecated") is True:
205+
206+
def property_deprecated_warning(cls, v):
207+
logger.warning(f"Property {name}.{prop_name} is deprecated")
208+
return v
209+
210+
validators[f"{prop_name}_deprecated"] = validator(
211+
prop_name, allow_reuse=True
212+
)(property_deprecated_warning)
213+
183214
if "const" in prop_attrs:
184215
const_value = prop_attrs["const"]
185216
if not any(
186217
[isinstance(const_value, t) for t in (str, int, float, bool)]
187218
):
219+
# TODO -- construct complex object consts
188220
raise SchemaConversionException
189221
else:
190222
const_type = Literal[const_value] # type: ignore
@@ -195,14 +227,16 @@ def dict(self):
195227
elif "enum" in prop_attrs:
196228
vals = {str(p).upper(): p for p in prop_attrs["enum"]}
197229
enum_type = Enum(prop_name, vals, type=str) # type: ignore
230+
if prop_name not in required_fields:
231+
enum_type = Optional[enum_type] # type: ignore
198232
fields[prop_name] = (enum_type, Field(**field_args))
199233
else:
200234
if prop_name not in required_fields:
201235
field_type = Optional[field_type]
202236
fields[prop_name] = (field_type, Field(**field_args))
203237

204238
config = get_configs(name, definition, allow_population_by_field_name)
205-
model = create_model(__model_name=name, __config__=config, **fields) # type: ignore
239+
model = create_model(__model_name=name, __config__=config, __validators__=validators, **fields) # type: ignore
206240
if "description" in definition:
207241
model.__doc__ = definition["description"]
208242

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""Define miscellaneous utilities for working with Pydantic classes."""
22
import logging
3-
from typing import Type, Dict, Any
3+
from typing import Any, Dict, Type
44

55
from pydantic import BaseConfig
66
from pydantic.config import Extra
77

8-
98
logger = logging.getLogger(__name__)
109

1110

@@ -15,7 +14,7 @@ def get_configs(
1514
"""
1615
Set model configs from definition attributes. This part of Pydantic gets a little
1716
hairy, so lots of type check suppression is needed to ensure successful
18-
conformity to the necessary arg forms.
17+
conformity to the necessary arg structure.
1918
2019
Args:
2120
name: class name
@@ -49,9 +48,14 @@ def schema_extra_function(schema: Dict[str, Any], model: Type[name]) -> None: #
4948

5049
schema_extra_value = schema_extra_function # type: ignore
5150

52-
class ModifiedConfig(BaseConfig):
53-
extra: Extra = extra_value
54-
allow_population_by_field_name = allow_population_by_field_name_setting
55-
schema_extra = schema_extra_value # type: ignore
51+
ModifiedConfig = type(
52+
f"{name}Config",
53+
(BaseConfig,),
54+
{
55+
"extra": extra_value,
56+
"allow_population_by_field_name": allow_population_by_field_name_setting,
57+
"schema_extra": schema_extra_value,
58+
},
59+
)
5660

5761
return ModifiedConfig
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
from enum import Enum
44

5-
from respected_wizard.exceptions import UnsupportedSchemaException
5+
from .exceptions import UnsupportedSchemaException
66

77

88
class SchemaVersion(str, Enum):
@@ -14,7 +14,7 @@ class SchemaVersion(str, Enum):
1414

1515
SCHEMA_MATCH_PATTERNS = {
1616
re.compile(
17-
r"^http(s)?://(www\.)?json-schema.org/draft/2020-12/schema$"
17+
r"^https://(www\.)?json-schema.org/draft/2020-12/schema$"
1818
): SchemaVersion.DRAFT_2020_12,
1919
re.compile(
2020
r"^http(s)?://(www\.)?json-schema.org/draft-07/schema$"

0 commit comments

Comments
 (0)