Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin committed May 3, 2017
0 parents commit a8e844d
Show file tree
Hide file tree
Showing 19 changed files with 409 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.idea/
env/
*.py[cod]
*.egg-info/
build/
dist/
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2017 Samuel Colvin

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.
41 changes: 41 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.PHONY: install
install:
pip install -U setuptools pip
pip install -U .
pip install -r tests/requirements.txt

.PHONY: isort
isort:
isort -rc -w 120 pydantic
isort -rc -w 120 tests

.PHONY: lint
lint:
python setup.py check -rms
flake8 pydantic/ tests/
pytest pydantic -p no:sugar -q --cache-clear

.PHONY: test
test:
pytest --cov=pydantic && coverage combine

.PHONY: testcov
testcov:
pytest --cov=pydantic && (echo "building coverage html"; coverage combine; coverage html)

.PHONY: all
all: testcov lint

.PHONY: clean
clean:
rm -rf `find . -name __pycache__`
rm -f `find . -type f -name '*.py[co]' `
rm -f `find . -type f -name '*~' `
rm -f `find . -type f -name '.*~' `
rm -rf .cache
rm -rf htmlcov
rm -rf *.egg-info
rm -f .coverage
rm -f .coverage.*
rm -rf build
python setup.py clean
4 changes: 4 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pydantic
========

Data validation and settings management using python 3.6 type hinting
1 change: 1 addition & 0 deletions pydantic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# from .settings import BaseSettings # noqa
27 changes: 27 additions & 0 deletions pydantic/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from typing import Any, Type


class BaseField:
def __init__(
self, *,
default: Any=None,
v_type: Type=None,
required: bool=False,
description: str=None):
if default and v_type:
raise RuntimeError('"default" and "v_type" cannot both be defined.')
elif default and required:
raise RuntimeError('It doesn\'t make sense to have "default" set and required=True.')
if default:
self.default = default
self.v_type = type(default)
else:
self.v_type = v_type
self.required = required
self.description = description


class EnvField(BaseField):
def __init__(self, *, env=None, **kwargs):
super().__init__(**kwargs)
self.env_var_name = env
53 changes: 53 additions & 0 deletions pydantic/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from collections import OrderedDict


class MetaModel(type):
@classmethod
def __prepare__(mcs, *args, **kwargs):
return OrderedDict()

def __new__(mcs, name, bases, namespace):
fields = OrderedDict()
for base in reversed(bases):
if issubclass(base, BaseModel) and base != BaseModel:
fields.update(base.fields)
annotations = namespace.get('__annotations__')
if annotations:
print(f'class {name}')
fields.update(annotations)
print(fields)
namespace.update(
fields=fields
)
return super().__new__(mcs, name, bases, namespace)


class BaseModel(metaclass=MetaModel):
def __init__(self, **custom_settings):
"""
:param custom_settings: Custom settings to override defaults, only attributes already defined can be set.
"""
self._dict = {
# **self._substitute_environ(custom_settings),
**self._get_custom_settings(custom_settings),
}
[setattr(self, k, v) for k, v in self._dict.items()]

@property
def dict(self):
return self._dict

def _get_custom_settings(self, custom_settings):
d = {}
for name, value in custom_settings.items():
if not hasattr(self, name):
raise TypeError('{} is not a valid setting name'.format(name))
d[name] = value
return d

def __iter__(self):
# so `dict(settings)` works
yield from self._dict.items()

def __repr__(self):
return '<{} {}>'.format(self.__class__.__name__, ' '.join('{}={!r}'.format(k, v) for k, v in self.dict.items()))
34 changes: 34 additions & 0 deletions pydantic/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import ast
import sys
from pathlib import Path


def find_fields(module, cls_name):

path = Path(sys.modules[module].__file__).resolve()

file_node = ast.parse(path.read_text(), filename=path.name)
cls_node = None
for n in file_node.body:
if isinstance(n, ast.ClassDef) and n.name == cls_name:
cls_node = n
break
if cls_name is None:
raise RuntimeError(f"can't find {cls_name} in {file_node}")
_expression = None
for n in cls_node.body:
if isinstance(n, ast.Expr) and isinstance(n.value, ast.Str):
_expression = n.value.s
continue
# print(ast.dump(n))
if not isinstance(n, (ast.AnnAssign, ast.Assign)):
_expression = None
continue
target = getattr(n, 'target', None) or n.targets[0]
name = target.id
if name.startswith('_'):
_expression = None
continue

yield name, _expression
_expression = None
68 changes: 68 additions & 0 deletions pydantic/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from .main import BaseModel


class BaseSettings(BaseModel):
"""
Base class for settings, any setting defined on inheriting classes here can be overridden by:
Setting the appropriate environment variable, eg. to override FOOBAR, `export APP_FOOBAR="whatever"`.
This is useful in production for secrets you do not wish to save in code and
also plays nicely with docker(-compose). Settings will attempt to convert environment variables to match the
type of the value here.
Or, passing the custom setting as a keyword argument when initialising settings (useful when testing)
"""
_ENV_PREFIX = 'APP_'

DB_DATABASE = None
DB_USER = None
DB_PASSWORD = None
DB_HOST = 'localhost'
DB_PORT = '5432'
DB_DRIVER = 'postgres'

def _substitute_environ(self, custom_settings):
"""
Substitute environment variables into settings.
"""
d = {}
for attr_name in dir(self):
if attr_name.startswith('_') or attr_name.upper() != attr_name:
continue

orig_value = getattr(self, attr_name)

if isinstance(orig_value, Setting):
is_required = orig_value.required
default = orig_value.default
orig_type = orig_value.v_type
env_var_name = orig_value.env_var_name
else:
default = orig_value
is_required = False
orig_type = type(orig_value)
env_var_name = self._ENV_PREFIX + attr_name

env_var = os.getenv(env_var_name, None)
d[attr_name] = default

if env_var is not None:
if issubclass(orig_type, bool):
env_var = env_var.upper() in ('1', 'TRUE')
elif issubclass(orig_type, int):
env_var = int(env_var)
elif issubclass(orig_type, Path):
env_var = Path(env_var)
elif issubclass(orig_type, bytes):
env_var = env_var.encode()
elif issubclass(orig_type, str) and env_var.startswith('py::'):
env_var = self._import_string(env_var[4:])
elif issubclass(orig_type, (list, tuple, dict)):
# TODO more checks and validation
env_var = json.loads(env_var)
d[attr_name] = env_var
elif is_required and attr_name not in custom_settings:
raise RuntimeError('The required environment variable "{0}" is currently not set, '
'you\'ll need to set the environment variable with '
'`export {0}="<value>"`'.format(env_var_name))
return d
5 changes: 5 additions & 0 deletions pydantic/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
json
JsonList
JsonDict
"""
Empty file added pydantic/utils/__init__.py
Empty file.
51 changes: 51 additions & 0 deletions pydantic/utils/dsn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import re

from .settings import BaseSettings


def _rfc_1738_quote(text):
return re.sub(r'[:@/]', lambda m: '%{:X}'.format(ord(m.group(0))), text)


def make_settings_dsn(settings: BaseSettings, prefix='DB'):
kwargs = {
f: settings.dict['{}_{}'.format(prefix, f.upper())]
for f in ('name', 'password', 'host', 'port', 'user', 'driver')
}
return make_dsn(**kwargs)


def make_dsn(
driver: str = None,
user: str = None,
password: str = None,
host: str = None,
port: str = None,
name: str = None,
query: str = None):
"""
Create a DSN from from connection settings.
Stolen approximately from sqlalchemy/engine/url.py:URL.
"""
s = driver + '://'
if user is not None:
s += _rfc_1738_quote(user)
if password is not None:
s += ':' + _rfc_1738_quote(password)
s += '@'
if host is not None:
if ':' in host:
s += '[{}]'.format(host)
else:
s += host
if port is not None:
s += ':{}'.format(int(port))
if name is not None:
s += '/' + name
query = query or {}
if query:
keys = list(query)
keys.sort()
s += '?' + '&'.join('{}={}'.format(k, query[k]) for k in keys)
return s
15 changes: 15 additions & 0 deletions pydantic/utils/python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
def _import_string(cls, dotted_path):
"""
Stolen from django. Import a dotted module path and return the attribute/class designated by the
last name in the path. Raise ImportError if the import failed.
"""
try:
module_path, class_name = dotted_path.strip(' ').rsplit('.', 1)
except ValueError as e:
raise ImportError("{} doesn't look like a module path".format(dotted_path)) from e

module = import_module(module_path)
try:
return getattr(module, class_name)
except AttributeError as e:
raise ImportError('Module "{}" does not define a "{}" attribute'.format(module_path, class_name)) from e
5 changes: 5 additions & 0 deletions pydantic/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from distutils.version import StrictVersion

__all__ = ['VERSION']

VERSION = StrictVersion('0.0.1')
11 changes: 11 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[tool:pytest]
testpaths = tests
addopts = --isort
timeout = 10

[flake8]
max-line-length = 120
max-complexity = 10

[bdist_wheel]
python-tag = py36
41 changes: 41 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from importlib.machinery import SourceFileLoader
from pathlib import Path
from setuptools import setup

THIS_DIR = Path(__file__).resolve().parent
long_description = THIS_DIR.joinpath('README.rst').read_text()

# avoid loading the package before requirements are installed:
version = SourceFileLoader('version', 'pydantic/version.py').load_module()

setup(
name='pydantic',
version=str(version.VERSION),
description='Data validation and settings management using python 3.6 type hinting',
long_description=long_description,
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.6',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: MIT License',
'Operating System :: Unix',
'Operating System :: POSIX :: Linux',
'Environment :: MacOS X',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Internet',
],
author='Samuel Colvin',
author_email='s@muelcolvin.com',
url='https://github.com/samuelcolvin/pydantic',
license='MIT',
packages=[
'pydantic',
'pydantic.utils',
],
zip_safe=True,
)
Empty file added tests/__init__.py
Empty file.

0 comments on commit a8e844d

Please sign in to comment.