-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 348bc17
Showing
12 changed files
with
409 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
name: Python package | ||
|
||
on: | ||
push: | ||
branches: | ||
- master | ||
|
||
jobs: | ||
build: | ||
runs-on: ubuntu-latest | ||
strategy: | ||
matrix: | ||
python-version: | ||
- 3.11 | ||
services: | ||
redis: | ||
image: redis | ||
ports: | ||
- 6379:6379 | ||
|
||
steps: | ||
- uses: actions/checkout@v3 | ||
- name: Set up Python ${{ matrix.python-version }} | ||
uses: actions/setup-python@v4 | ||
with: | ||
python-version: ${{ matrix.python-version }} | ||
cache: 'pip' | ||
- name: Install dependencies | ||
run: | | ||
python -m pip install --upgrade pip | ||
pip install pipenv | ||
pipenv run pip install pyright | ||
pipenv sync | ||
- name: Perform typechecking | ||
run: | | ||
pipenv run python -m pyright | ||
- name: Perform tests | ||
run: | | ||
pipenv run python -m unittest tests/*.py | ||
env: | ||
REDIS_HOST: localhost | ||
REDIS_PORT: 6379 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
__pycache__ | ||
dist | ||
build | ||
*.egg-info |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
Copyright 2023 João Santos ([email protected]) | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy of | ||
this software and associated documentation files (the “Software”), to deal in | ||
the Software without restriction, including without limitation the rights to | ||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies | ||
of the Software, and to permit persons to whom the Software is furnished to do | ||
so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
[[source]] | ||
url = "https://pypi.org/simple" | ||
verify_ssl = true | ||
name = "pypi" | ||
|
||
[packages] | ||
|
||
[dev-packages] | ||
|
||
[requires] | ||
python_version = "3.11" |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
# Micromodel | ||
|
||
Static and runtime dictionary validation. | ||
|
||
## Install | ||
|
||
```sh | ||
$ pip install micromodel | ||
``` | ||
|
||
## Why | ||
|
||
We had a HUGE Python code base which was using `pydantic` to provide a validation layer for MongoDB operations. The code was all messy but it worked fine, until we decided to switch the type checker config from "basic" to "strict", then over a thousand of dictionary-related errors popped up, not to mention the annoying conversions from `dict` to classes that were going on on every validation and that should be checked everytime. | ||
|
||
We then decided to make this validation in-loco using a more vanilla approach with only `TypedDict`s. Now our dictionaries containing MongoDB documents are consistently dicts that match with the static typing. | ||
|
||
## Usage | ||
|
||
```python | ||
import typing | ||
from micromodel import model | ||
|
||
Animal = typing.TypedDict('Animal', { | ||
'name': str, | ||
'specie': list[typing.Literal[ | ||
'dog', | ||
'cat', | ||
'bird' | ||
]] | ||
}) | ||
|
||
# even another TypedDicts can be used! | ||
Person = typing.TypedDict('Person', { | ||
'name': str, | ||
'age': int, | ||
'animal': Animal | ||
}) | ||
|
||
m = model(Person, { | ||
'Animal': Animal | ||
}) | ||
|
||
old_validate = m.validate | ||
def new_validate(target: Person): | ||
new = target.copy() | ||
new['name'] = new['name'].capitalize() | ||
return validate(Person, typing.cast(typing.Any, new)) | ||
|
||
# hooks can be implemented using monkeypatching | ||
m.validate = new_validate | ||
|
||
result = m.validate({ | ||
'name': 'joao', | ||
'animal': { | ||
'name': 'thor', | ||
'specie': [ | ||
'dog', | ||
# 'turtle' (this would produce both static and runtime errors) | ||
] | ||
} | ||
}) | ||
|
||
""" | ||
{ | ||
"name": "Joao", | ||
"animal": { | ||
"name": "thor", | ||
"specie": [ | ||
"dog" | ||
] | ||
} | ||
} | ||
""" | ||
print(result) | ||
``` | ||
|
||
## License | ||
|
||
This library is [MIT licensed](https://github.com/capsulbrasil/normalize-json/tree/master/LICENSE). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
[project] | ||
name = 'micromodel' | ||
version = '0.0.0' | ||
authors = [ | ||
{ name = 'João Gabriel Santos', email = '[email protected]' } | ||
] | ||
description = 'todo' | ||
readme = 'README.md' | ||
requires-python = '>=3.11' | ||
classifiers = [ | ||
'Programming Language :: Python :: 3', | ||
'License :: OSI Approved :: MIT License', | ||
'Operating System :: OS Independent' | ||
] | ||
|
||
[project.urls] | ||
'Homepage' = 'https://github.com/capsulbrasil/micromodel' | ||
|
||
[tool.setuptools] | ||
packages = [ | ||
'micromodel' | ||
] | ||
|
||
package-dir = { '' = 'src' } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"include": [ | ||
"src" | ||
], | ||
"typeCheckingMode": "strict" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .micromodel import * |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import typing | ||
import types | ||
from abc import ABCMeta | ||
|
||
T = typing.TypeVar('T') | ||
|
||
ValidationOptions = typing.TypedDict('ValidationOptions', { | ||
'allow_extraneous': typing.NotRequired[bool] | ||
}) | ||
|
||
class Model(typing.Generic[T]): | ||
def __init__(self, model_type: typing.Callable[[typing.Any], T], ct: dict[str, typing.Any] = {}): | ||
self.model_type = model_type | ||
self.ct = ct | ||
|
||
def cast(self, target: T): return target | ||
def validate(self, target: T, options: ValidationOptions = {}): return validate(self.model_type, typing.cast(typing.Any, target), options, self.ct) | ||
|
||
def raise_missing_key(k: int | str): | ||
raise TypeError('missing key: %s' % k) | ||
|
||
def raise_extraneous_key(k: int | str): | ||
raise TypeError('extraneous key: %s' % k) | ||
|
||
def raise_type_error(k: int | str, args: str, v: typing.Any): | ||
raise TypeError('incorrect type for %s: expected %s, got %s' % (k, args, v)) | ||
|
||
def unwrap_type(obj: dict[int | str, typing.Any] | list[typing.Any], k: int | str, v: typing.Any, ct: dict[str, typing.Any] = {}): | ||
origin = typing.get_origin(v) | ||
args = typing.get_args(v) | ||
|
||
if (isinstance(obj, dict) and not k in obj) or (isinstance(obj, list) and int(k) > len(obj)): | ||
if types.NoneType not in args: | ||
raise_missing_key(k) | ||
return | ||
|
||
value = obj[int(k)] \ | ||
if isinstance(obj, list) \ | ||
else obj[k] | ||
|
||
match origin: | ||
case _ if origin == list: | ||
for i in range(len(value)): | ||
unwrap_type(value, i, args[0], ct) | ||
|
||
case _ if origin == tuple: | ||
for i in range(len(value)): | ||
unwrap_type(value, i, args[i], ct) | ||
|
||
case typing.Literal: | ||
if value not in args: | ||
raise_type_error(k, str(v), value) | ||
|
||
case types.UnionType: | ||
for candidate in args: | ||
if isinstance(candidate(), type(value)): | ||
unwrap_type(obj, k, candidate, ct) | ||
break | ||
else: | ||
raise_type_error(k, str(args), type(value)) | ||
|
||
case None: | ||
if complex_type := ct.get(v.__name__): | ||
value = validate(complex_type, value) | ||
return value | ||
|
||
if not isinstance(value, v): | ||
raise_type_error(k, str(v), type(value)) | ||
case _: | ||
if not isinstance(value, origin): | ||
raise_type_error(k, str(args), type(value)) | ||
|
||
return value | ||
|
||
def validate(model_type: typing.Callable[[typing.Any], T], target: dict[str, typing.Any], options: ValidationOptions = {}, ct: dict[str, typing.Any] = {}) -> T: | ||
obj: dict[int | str, typing.Any] = {} | ||
hints = get_hints(typing.cast(ABCMeta, model_type)) | ||
|
||
for k, v in target.items(): | ||
if k not in hints: | ||
if options.get('allow_extraneous'): | ||
continue | ||
raise_extraneous_key(k) | ||
obj[k] = v | ||
|
||
for k, v in hints.items(): | ||
obj[k] = unwrap_type(obj, k, v, ct) | ||
|
||
return typing.cast(T, obj) | ||
|
||
def get_hints(model_type: ABCMeta): | ||
hints = typing.get_type_hints(model_type) | ||
return hints | ||
|
||
def model(model_type: typing.Callable[[typing.Any], T], ct: dict[str, typing.Any] = {}) -> Model[T]: | ||
return Model(model_type, ct) | ||
|
Empty file.
Oops, something went wrong.