Skip to content

Commit

Permalink
first commit
Browse files Browse the repository at this point in the history
  • Loading branch information
minenwerfer committed Aug 25, 2023
0 parents commit 348bc17
Show file tree
Hide file tree
Showing 12 changed files with 409 additions and 0 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/ci.yaml
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__pycache__
dist
build
*.egg-info
19 changes: 19 additions & 0 deletions LICENSE
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.
11 changes: 11 additions & 0 deletions Pipfile
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"
20 changes: 20 additions & 0 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions README.md
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).
24 changes: 24 additions & 0 deletions pyproject.toml
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' }
6 changes: 6 additions & 0 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"include": [
"src"
],
"typeCheckingMode": "strict"
}
1 change: 1 addition & 0 deletions src/micromodel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .micromodel import *
97 changes: 97 additions & 0 deletions src/micromodel/micromodel.py
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 added tests/__init__.py
Empty file.
Loading

0 comments on commit 348bc17

Please sign in to comment.