diff --git a/.gitignore b/.gitignore index f0db4e5..9542c55 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ docs/_templates/* hobbit_core.egg-info/* dist/* build/* + +tests/tst_app.sqlite diff --git a/docs/changelog.rst b/docs/changelog.rst index 5324b29..7374d44 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,9 +1,15 @@ Change history ============== +1.2.5a2 (2018-10-30) + +* Add ModelSchema(Auto generate load and dump func for EnumField). +* Add logging config file. +* Fix use_kwargs with fileds.missing=None. + 1.2.5a1 (2018-10-25) -* Added EnumExt implementation +* Add EnumExt implementation. 1.2.5a0 (2018-10-22) diff --git a/hobbit_core/__init__.py b/hobbit_core/__init__.py index ad51bc8..e7c4f9e 100644 --- a/hobbit_core/__init__.py +++ b/hobbit_core/__init__.py @@ -1 +1 @@ -VERSION = [1, 2, 5, 'a1'] +VERSION = [1, 2, 5, 'a2'] diff --git a/hobbit_core/flask_hobbit/db.py b/hobbit_core/flask_hobbit/db.py index 0c87094..496eb0b 100644 --- a/hobbit_core/flask_hobbit/db.py +++ b/hobbit_core/flask_hobbit/db.py @@ -115,10 +115,11 @@ def dump(cls, label, verbose=False): @classmethod def load(cls, val): - """Get label by key or value. + """Get label by key or value. Return val when val is label. Examples:: + TaskState.load('FINISHED') # 'FINISHED' TaskState.load(4) # 'FINISHED' TaskState.load('新建') # 'CREATED' @@ -126,6 +127,9 @@ def load(cls, val): str|None: Label. """ + if val in cls.__members__: + return val + pos = 1 if isinstance(val, six.string_types) else 0 for elem in cls: if elem.value[pos] == val: diff --git a/hobbit_core/flask_hobbit/schemas.py b/hobbit_core/flask_hobbit/schemas.py index f21ccf3..58f73c0 100644 --- a/hobbit_core/flask_hobbit/schemas.py +++ b/hobbit_core/flask_hobbit/schemas.py @@ -1,6 +1,10 @@ # -*- encoding: utf-8 -*- -from marshmallow import Schema, fields, post_load +import six + +from marshmallow import Schema, fields, pre_load, post_load, post_dump +from marshmallow_sqlalchemy.schema import ModelSchemaMeta from flask_marshmallow.sqla import ModelSchema +from marshmallow_enum import EnumField class ORMSchema(ModelSchema): @@ -87,3 +91,71 @@ class PagedUserSchema(PagedSchema): class Meta: strict = True + + +class EnumSetMeta(ModelSchemaMeta): + """EnumSetMeta is a metaclass that can be used to auto generate load and + dump func for EnumField. + """ + + @classmethod + def gen_func(cls, decorator, field_name, enum, verbose=True): + + @decorator + def wrapper(self, data): + if data.get(field_name) is None: + return data + + if decorator is pre_load: + data[field_name] = enum.load(data[field_name]) + elif decorator is post_dump: + data[field_name] = enum.dump(data[field_name], verbose) + else: + raise Exception( + 'hobbit_core: decorator `{}` not support'.format( + decorator)) + + return data + return wrapper + + def __new__(cls, name, bases, attrs): + schema = ModelSchemaMeta.__new__(cls, name, tuple(bases), attrs) + verbose = getattr(schema.Meta, 'verbose', True) + + setattr(schema.Meta, 'dateformat', '%Y-%m-%d %H:%M:%S') + + for field_name, declared in schema._declared_fields.items(): + if not isinstance(declared, EnumField): + continue + + setattr(schema, 'load_{}'.format(field_name), cls.gen_func( + pre_load, field_name, declared.enum)) + setattr(schema, 'dump_{}'.format(field_name), cls.gen_func( + post_dump, field_name, declared.enum, verbose=verbose)) + + return schema + + +@six.add_metaclass(EnumSetMeta) +class ModelSchema(ORMSchema, SchemaMixin): + """Base ModelSchema for ``class Model(db.SurrogatePK)``. + + * Auto generate load and dump func for EnumField. + * Auto dump_only for ``id``, ``created_at``, ``updated_at`` fields. + * Auto set dateformat to ``'%Y-%m-%d %H:%M:%S'``. + * Auto use verbose for dump EnumField. See ``db.EnumExt``. You can define + verbose in ``Meta``. + + Example:: + + class UserSchema(ModelSchema): + role = EnumField(RoleEnum) + + class Meta: + model = User + + data = UserSchema().dump(user).data + assert data['role'] == {'key': 1, 'label': 'admin', 'value': '管理员'} + + """ + pass diff --git a/hobbit_core/flask_hobbit/utils.py b/hobbit_core/flask_hobbit/utils.py index 0679be4..3c04c8e 100644 --- a/hobbit_core/flask_hobbit/utils.py +++ b/hobbit_core/flask_hobbit/utils.py @@ -130,7 +130,8 @@ def _get_init_args(instance, base_class): kwargs = {k: getattr(instance, k) for k in no_defaults if k != 'self' and hasattr(instance, k)} - kwargs.update({k: getattr(instance, k, argspec.defaults[i]) + kwargs.update({k: getattr(instance, k) if hasattr(instance, k) else + getattr(instance, k, argspec.defaults[i]) for i, k in enumerate(has_defaults)}) assert len(kwargs) == len(argspec.args) - 1, 'exclude `self`' @@ -169,7 +170,7 @@ def factory(request): only = parser.parse(argmap, request).keys() argmap_kwargs.update({ - 'partial': True, + 'partial': False, # fix missing=None not work 'only': only or None, 'context': {"request": request}, 'strict': True, diff --git a/pytest.ini b/pytest.ini index 93961b6..801df28 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,5 @@ [pytest] +env = + FLASK_APP=tests/run.py + FLASK_ENV=testing addopts = --cov hobbit_core --cov=tests --cov-report term-missing -s -x -vv -p no:warnings diff --git a/tests/__init__.py b/tests/__init__.py index e55889b..75a70f6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,11 +4,31 @@ import functools import pytest +from flask_sqlalchemy import model + +from .run import app, db class BaseTest(object): root_path = os.path.split(os.path.abspath(__name__))[0] + @classmethod + def setup_class(cls): + with app.app_context(): + db.create_all() + + @classmethod + def teardown_class(cls): + with app.app_context(): + db.drop_all() + + def teardown_method(self, method): + with app.app_context(): + for m in [m for m in db.Model._decl_class_registry.values() + if isinstance(m, model.DefaultMeta)]: + db.session.query(m).delete() + db.session.commit() + def rmdir(path): if os.path.exists(path): diff --git a/tests/models.py b/tests/models.py index 79be7ff..421575c 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,9 +1,17 @@ +# -*- encoding: utf-8 -*- from hobbit_core.flask_hobbit.db import Column, SurrogatePK +from hobbit_core.flask_hobbit.db import EnumExt from .exts import db +class RoleEnum(EnumExt): + admin = (1, '管理员') + normal = (2, '普通用户') + + class User(SurrogatePK, db.Model): username = Column(db.String(50), nullable=True, unique=True) email = Column(db.String(50), nullable=True, unique=True) password = Column(db.String(255), nullable=False, server_default='') + role = Column(db.Enum(RoleEnum), doc='角色') diff --git a/tests/test_db.py b/tests/test_db.py index 6fa3418..8764000 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -23,6 +23,7 @@ def test_dump(self, TaskState): assert {'key': 0, 'value': u'新建'} == TaskState.dump('CREATED') def test_load(self, TaskState): + assert 'FINISHED' == TaskState.load('FINISHED') assert 'FINISHED' == TaskState.load(1) assert 'CREATED' == TaskState.load(u'新建') assert TaskState.load(100) is None diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..0ba9464 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,47 @@ +# -*- encoding: utf-8 -*- +from marshmallow_enum import EnumField + +from hobbit_core.flask_hobbit.schemas import ModelSchema + +from .exts import db +from . import BaseTest +from .models import User, RoleEnum + + +class TestSchema(BaseTest): + + def test_model_schema(self, client): + + class UserSchema(ModelSchema): + role = EnumField(RoleEnum) + + class Meta: + model = User + + assert UserSchema.Meta.dateformat == '%Y-%m-%d %H:%M:%S' + + user = User(username='name', email='admin@test', role=RoleEnum.admin) + db.session.add(user) + db.session.commit() + + data = UserSchema().dump(user).data + assert data['role'] == {'key': 1, 'label': 'admin', 'value': '管理员'} + + class UserSchema(ModelSchema): + role = EnumField(RoleEnum) + + class Meta: + model = User + verbose = False + + data = UserSchema().dump(user).data + assert data['role'] == {'key': 1, 'value': '管理员'} + + payload = {'username': 'name', 'email': 'admin@test'} + for role in (RoleEnum.admin.name, RoleEnum.admin.value[0], + RoleEnum.admin.value[1]): + payload['role'] = role + assert UserSchema().load(payload).data == { + 'role': RoleEnum.admin, 'email': 'admin@test', + 'username': 'name', + } diff --git a/tests/test_utils.py b/tests/test_utils.py index 4b414cf..a9675d6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -49,17 +49,38 @@ def test_secure_filename_py3(self): 'i contain cool \xfcml\xe4uts.txt') == \ 'i_contain_cool_umlauts.txt' - def test_use_kwargs(self, client): + +class TestUseKwargs(BaseTest): + + def test_use_kwargs_with_partial(self, client): payload = {'username': 'username', 'email': 'email'} resp = client.post('/use_kwargs_with_partial/', json=payload) assert resp.json == payload + def test_use_kwargs_without_partial(self, client): + payload = {'username': 'username', 'email': 'email'} resp = client.post('/use_kwargs_without_partial/', json=payload) assert resp.json == payload + def test_use_kwargs_with_partial2(self, client): payload = {'username': 'username'} resp = client.post('/use_kwargs_with_partial/', json=payload) assert resp.json == payload + def test_use_kwargs_without_partial2(self, client): + payload = {'username': 'username'} resp = client.post('/use_kwargs_without_partial/', json=payload) - assert resp.json == {'username': 'username', 'email': None} + assert resp.json == {'username': 'username', 'email': 'missing'} + + def test_use_kwargs_dictargmap_partial(self, client): + resp = client.post('/use_kwargs_dictargmap_partial/', json={}) + assert resp.json == {'username': None} + + def test_use_kwargs_dictargmap_partial2(self, client): + resp = client.post('/use_kwargs_dictargmap_partial/', json={ + 'username': None}) + assert resp.json == {'username': None} + + def test_base_use_kwargs_dictargmap_whitout_partial(self, client): + resp = client.post('/base_use_kwargs_dictargmap_partial/', json={}) + assert resp.json == {'username': None, 'password': 'missing'} diff --git a/tests/views.py b/tests/views.py index 267a835..6aa442e 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,4 +1,7 @@ from flask import Blueprint, jsonify +from marshmallow import fields +from marshmallow.utils import missing +from webargs.flaskparser import use_kwargs as base_use_kwargs from hobbit_core.flask_hobbit.utils import use_kwargs @@ -7,13 +10,42 @@ bp = Blueprint('test', __name__) +def wrapper_kwargs(kwargs): + res = {} + for k, v in kwargs.items(): + if v is missing: + res[k] = 'missing' + continue + res[k] = v + return res + + @bp.route('/use_kwargs_with_partial/', methods=['POST']) -@use_kwargs(UserSchema(partial=True)) +@use_kwargs(UserSchema(partial=True, exclude=['role'])) def use_kwargs_with_partial(**kwargs): - return jsonify({k: v or None for k, v in kwargs.items()}) + return jsonify(wrapper_kwargs(kwargs)) @bp.route('/use_kwargs_without_partial/', methods=['POST']) -@use_kwargs(UserSchema()) +@use_kwargs(UserSchema(exclude=['role'])) def use_kwargs_without_partial(**kwargs): - return jsonify({k: v or None for k, v in kwargs.items()}) + return jsonify(wrapper_kwargs(kwargs)) + + +@bp.route('/use_kwargs_dictargmap_partial/', methods=['POST']) +@use_kwargs({ + 'username': fields.Str(missing=None), + 'password': fields.Str(allow_none=True), +}, schema_kwargs={'partial': True}) +def use_kwargs_dictargmap_partial(**kwargs): + print(kwargs) + return jsonify(wrapper_kwargs(kwargs)) + + +@bp.route('/base_use_kwargs_dictargmap_partial/', methods=['POST']) +@base_use_kwargs({ + 'username': fields.Str(missing=None), + 'password': fields.Str(allow_none=True), +}) +def base_use_kwargs_dictargmap_partial(**kwargs): + return jsonify(wrapper_kwargs(kwargs))