diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..d29f571 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,9 @@ +# file GENERATED by distutils, do NOT edit +setup.cfg +setup.py +sqlalchemy_serializer/__init__.py +sqlalchemy_serializer/serializer.py +sqlalchemy_serializer/lib/__init__.py +sqlalchemy_serializer/lib/rules.py +sqlalchemy_serializer/lib/timezones.py +sqlalchemy_serializer/lib/utils.py diff --git a/Makefile b/Makefile index 2c5ea01..8282b05 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ .PHONY: test +FILE = $(file) test: - docker-compose up --build --abort-on-container-exit + TEST_FILE=$(FILE) docker-compose up --build --abort-on-container-exit + diff --git a/README.md b/README.md index 6d741ac..e62a600 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,20 @@ # SQLAlchemy-serializer -Mixin for sqlalchemy-models serialization without pain. +Mixin for SQLAlchemy models serialization without pain. + +If you want to serialize SQLAlchemy model instances like this `item.to_dict()` +And be able to customize the output in any possible way, this mixin suits you. + +**Contents** +- [Installation](#Installation) +- [Usage](#Usage) +- [Advanced usage](#Advanced usage) +- [Customization](#Customization) +- [Troubleshooting](#Troubleshooting) +- [Tests](#Tests) ## Installation -``` +```bash pip install SQLAlchemy-serializer ``` @@ -25,11 +36,17 @@ So now you can do something like this: item = SomeModel.query.filter(.....).one() result = item.to_dict() ``` -You'll get values of all SQLAlchemy fields in the `result` var, even nested relationships +You get values of all SQLAlchemy fields in the `result` var, even nested relationships +In order to change the default output you shuld pass tuple of fieldnames as an argument + +- If you want to exclude or add some extra fields (not from dataase) + You should pass `rules` argument +- If you want to define the only fields to be presented in serializer's output + use `only` argument If you want to exclude a few fields for this exact item: ```python -result = item.to_dict(rules=('-somefield', '-anotherone.nested1.nested2')) +result = item.to_dict(rules=('-somefield', '-some_relation.nested_one.another_nested_one')) ``` If you want to add a field which is not defined as an SQLAlchemy field: @@ -42,7 +59,7 @@ class SomeModel(db.Model, SerializerMixin): result = item.to_dict(rules=('non_sql_field', 'method')) ``` -Note that method or a function should have no arguments except ***self***, +**Note** that method or a function should have no arguments except ***self***, in order to let serializer call it without hesitations. If you want to get exact fields: @@ -50,12 +67,15 @@ If you want to get exact fields: result = item.to_dict(only=('non_sql_field', 'method', 'somefield')) ``` -Note that if ***somefield*** is an SQLAlchemy instance, you get all it's -serializable fields. +**Note** that if ***somefield*** is an SQLAlchemy instance, you get all it's +serializable fields. So if you want to get only some of them, you should define it like below: +```python + +result = item.to_dict(only=('non_sql_field', 'method', 'somefield.id', 'somefield.etc')) +``` If you want to define schema for all instances of particular SQLAlchemy model, add serialize properties to model definition: - ```python class SomeModel(db.Model, SerializerMixin): serialize_only = ('somefield.id',) @@ -64,13 +84,13 @@ class SomeModel(db.Model, SerializerMixin): somefield = db.relationship('AnotherModel') result = item.to_dict() -{'somefield':[{'id':...}]} - ``` +So the `result` in this case will be `{'somefield': [{'id': some_id}]}` ***serialize_only*** and ***serialize_rules*** work the same way as ***to_dict's*** arguments -# Detailed example (For more examples see tests): +# Advanced usage +(For more examples see [tests](https://github.com/n0nSmoker/SQLAlchemy-serializer/tree/master/tests)): ```python class FlatModel(db.Model, SerializerMixin): @@ -94,6 +114,7 @@ class ComplexModel(db.Model, SerializerMixin): """ Schema is not defined so we will get all SQLAlchemy attributes of the instance by default + without `non_sqlalchemy_list` """ id = db.Column(db.Integer, primary_key=True) @@ -104,12 +125,12 @@ class ComplexModel(db.Model, SerializerMixin): rel = db.relationship('FlatModel') non_sqlalchemy_list = [dict(a=12, b=10), dict(a=123, b=12)] -instance = ComplexModel.query.first() +item = ComplexModel.query.first() # Now by default the result looks like this: +item.to_dict() -instance.to_dict() dict( id=1, string='Some string!', @@ -123,8 +144,7 @@ dict( # Extend schema - -instance.to_dict(rules=('-id', '-rel.id', 'rel.string', 'non_sqlalchemy_list')) +item.to_dict(rules=('-id', '-rel.id', 'rel.string', 'non_sqlalchemy_list')) dict( string='Some string!', @@ -135,12 +155,12 @@ dict( rel=dict( string='Some string!', non_sqlalchemy_dict=dict(qwerty=123) + ) ) # Exclusive schema - -instance.to_dict(only=('id', 'flat_id', 'rel.id', 'non_sqlalchemy_list.a')) +item.to_dict(only=('id', 'flat_id', 'rel.id', 'non_sqlalchemy_list.a')) dict( id=1, @@ -149,45 +169,87 @@ dict( rel=dict( id=1 ) - +) ``` # Customization -If you want to set default behavior for every model you should write -your own mixin class like above +If you want to change datetime/date/time formats for all models you should write +your own mixin class inherited from `SerializerMixin` like in example below. ```python from sqlalchemy_serializer import SerializerMixin +CUSTOM_DATE_FORMAT = '%s' # Unixtimestamp (seconds) +CUSTOM_DATE_TIME_FORMAT = '%Y %b %d %H:%M:%S.%f' +CUSTOM_TIME_FORMAT = '%H:%M.%f' + +class CustomSerializerMixin(SerializerMixin): + date_format = CUSTOM_DATE_FORMAT + datetime_format = CUSTOM_DATE_TIME_FORMAT + time_format = CUSTOM_TIME_FORMAT +``` +And later use it as usual: +```python +import sqlalchemy as sa +from some.lib.package import CustomSerializerMixin -class CustomMixin(SerializerMixin): - serialize_only = () # Define custom schema here if needed - serialize_rules = () # Define custom schema here if needed +class CustomSerializerModel(db.Model, CustomSerializerMixin): + __tablename__ = 'custom_table_name' + serialize_only = () + serialize_rules = () - date_format = '%Y-%m-%d' # Define custom format here if needed - datetime_format = '%Y-%m-%d %H:%M' # Define custom format here if needed - time_format = '%H:%M' # Define custom format here if needed + id = sa.Column(sa.Integer, primary_key=True) + date = sa.Column(sa.Date) + datetime = sa.Column(sa.DateTime) + time = sa.Column(sa.Time) - def get_tzinfo(self): - """ - Callback to make serializer aware of user's timezone. Should be redefined if needed - :return: datetime.tzinfo - """ - return None +``` +All `date/time/datetime` fields be serialized using your custom formats +To get **unixtimestamp** use `%s` format, others you can find [here](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior) + +# Timezones +To keep `datetimes` consistent its better to store it in the database normalized to **UTC**. +But when you return response, sometimes (mostly in web, mobile applications can do it themselves) +you need to convert all `datetimes` to user's timezone. +So you need to tell serializer what timezone to use. +There are two ways to do it: +- The simplest one is to pass timezone directly as an argument for `to_dict` function +```python +import pytz +item.to_dict(timezone=pytz.timezone('Europe/Moscow')) +``` +- But if you do not want to write this code in every function, you should define + timezone logic in your custom mixin (how to use customized mixin see [Castomization](#Castomization)) + ```python +import pytz +from sqlalchemy_serializer import SerializerMixin +from some.package import get_current_user + +class CustomSerializerMixin(SerializerMixin): + def get_tzinfo(self): + # you can write your own logic here, + # the example below will work if you store timezone + # in user's profile + return pytz.timezone(get_current_user()['timezone']) ``` # Troubleshooting -If you've faced with 'maximum recursion depth exceeded' exception, +If you've faced with **maximum recursion depth exceeded** exception, most likely serializer have found instance of the same class somewhere among model's relationships. You need to exclude it from schema or specify the exact properties to serialize. - # Tests To run tests and see tests coverage report just type the following command - -``` +```bash make test ``` +To run a particular test use +```bash +make test file=tests/some_file.py +make test file=tests/some_file.py::test_func +``` + +I will appreciate any help in improving this library, so feel free to submit issues or pull requests. diff --git a/dist/SQLAlchemy-serializer-0.8.tar.gz b/dist/SQLAlchemy-serializer-0.8.tar.gz new file mode 100644 index 0000000..d24af55 Binary files /dev/null and b/dist/SQLAlchemy-serializer-0.8.tar.gz differ diff --git a/dist/SQLAlchemy-serializer-0.9.tar.gz b/dist/SQLAlchemy-serializer-0.9.tar.gz new file mode 100644 index 0000000..785187c Binary files /dev/null and b/dist/SQLAlchemy-serializer-0.9.tar.gz differ diff --git a/dist/SQLAlchemy-serializer-0.91.tar.gz b/dist/SQLAlchemy-serializer-0.91.tar.gz new file mode 100644 index 0000000..0595d39 Binary files /dev/null and b/dist/SQLAlchemy-serializer-0.91.tar.gz differ diff --git a/dist/SQLAlchemy-serializer-1.1.1.tar.gz b/dist/SQLAlchemy-serializer-1.1.1.tar.gz new file mode 100644 index 0000000..5257989 Binary files /dev/null and b/dist/SQLAlchemy-serializer-1.1.1.tar.gz differ diff --git a/dist/SQLAlchemy-serializer-1.1.2.tar.gz b/dist/SQLAlchemy-serializer-1.1.2.tar.gz new file mode 100644 index 0000000..5814f8d Binary files /dev/null and b/dist/SQLAlchemy-serializer-1.1.2.tar.gz differ diff --git a/dist/SQLAlchemy-serializer-1.1.3.tar.gz b/dist/SQLAlchemy-serializer-1.1.3.tar.gz new file mode 100644 index 0000000..0287ee2 Binary files /dev/null and b/dist/SQLAlchemy-serializer-1.1.3.tar.gz differ diff --git a/dist/SQLAlchemy-serializer-1.1.4.tar.gz b/dist/SQLAlchemy-serializer-1.1.4.tar.gz new file mode 100644 index 0000000..dfca217 Binary files /dev/null and b/dist/SQLAlchemy-serializer-1.1.4.tar.gz differ diff --git a/dist/SQLAlchemy-serializer-1.1.tar.gz b/dist/SQLAlchemy-serializer-1.1.tar.gz new file mode 100644 index 0000000..8f791a9 Binary files /dev/null and b/dist/SQLAlchemy-serializer-1.1.tar.gz differ diff --git a/docker-compose.yml b/docker-compose.yml index 390dbe5..c1f905c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ x-environment: &environment services: tests: build: . - command: ["pytest", "--pylama", "--cov=sqlalchemy_serializer", "--cov-report", "term-missing"] + command: ["pytest", "--pylama", "--cov=sqlalchemy_serializer", "--cov-report", "term-missing", "$TEST_FILE"] depends_on: - db diff --git a/requirements.txt b/requirements.txt index aa3c484..5855935 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ SQLAlchemy==1.3.0 psycopg2==2.7.3.1 +pytz==2019.1 \ No newline at end of file diff --git a/sqlalchemy_serializer/lib/timezones.py b/sqlalchemy_serializer/lib/timezones.py index 5fc2bd3..f647743 100644 --- a/sqlalchemy_serializer/lib/timezones.py +++ b/sqlalchemy_serializer/lib/timezones.py @@ -1,8 +1,9 @@ + def to_local_time(dt, tzinfo=None): if not tzinfo: return dt - normalized = tzinfo.normalize(dt.astimezone(tzinfo)) + normalized = dt.astimezone(tzinfo) return normalized.replace(tzinfo=None) diff --git a/sqlalchemy_serializer/serializer.py b/sqlalchemy_serializer/serializer.py index b17095c..f6fd5c2 100644 --- a/sqlalchemy_serializer/serializer.py +++ b/sqlalchemy_serializer/serializer.py @@ -125,9 +125,6 @@ def serialize_date(self, value): :param value: :return: serialized value """ - tz = self.opts.get('tzinfo') - if tz: - value = to_local_time(dt=value, tzinfo=tz) return format_dt( tpl=self.opts.get('date_format'), dt=value @@ -219,6 +216,9 @@ class SerializerMixin(object): def get_tzinfo(self): """ Callback to make serializer aware of user's timezone. Should be redefined if needed + Example: + return pytz.timezone('Asia/Krasnoyarsk') + :return: datetime.tzinfo """ return None @@ -249,6 +249,6 @@ def to_dict(self, only=(), rules=(), date_format=None, datetime_format=None, tim date_format=date_format or self.date_format, datetime_format=datetime_format or self.datetime_format, time_format=time_format or self.time_format, - tzinfo=tzinfo + tzinfo=tzinfo or self.get_tzinfo() ) return s(self, only=only, extend=rules) diff --git a/tests/models.py b/tests/models.py index 788aff9..71722e5 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,4 +1,6 @@ from datetime import datetime +import pytz + import sqlalchemy as sa from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship @@ -67,3 +69,33 @@ def method(self): def _protected_method(self): return f'(NESTED)User defined protected method + {self.string}' + + +# Custom serializer +CUSTOM_TZINFO = pytz.timezone('Asia/Krasnoyarsk') +CUSTOM_DATE_FORMAT = '%s' # Unixtimestamp (seconds) +CUSTOM_DATE_TIME_FORMAT = '%Y %b %d %H:%M:%S.%f' +CUSTOM_TIME_FORMAT = '%H:%M.%f' + + +class CustomSerializerMixin(SerializerMixin): + + date_format = CUSTOM_DATE_FORMAT + datetime_format = CUSTOM_DATE_TIME_FORMAT + time_format = CUSTOM_TIME_FORMAT + + def get_tzinfo(self): + return CUSTOM_TZINFO + + +class CustomSerializerModel(Base, CustomSerializerMixin): + __tablename__ = 'custom_flat_model' + serialize_only = () + serialize_rules = () + + id = sa.Column(sa.Integer, primary_key=True) + string = sa.Column(sa.String(256), default='Some string with') + date = sa.Column(sa.Date, default=DATETIME) + datetime = sa.Column(sa.DateTime, default=DATETIME) + time = sa.Column(sa.Time, default=TIME) + bool = sa.Column(sa.Boolean, default=True) diff --git a/tests/test_custom_serializer.py b/tests/test_custom_serializer.py new file mode 100644 index 0000000..2459ae3 --- /dev/null +++ b/tests/test_custom_serializer.py @@ -0,0 +1,28 @@ +from .models import (CustomSerializerModel, DATETIME, TIME, DATE, + CUSTOM_TZINFO, CUSTOM_DATE_FORMAT, CUSTOM_TIME_FORMAT, CUSTOM_DATE_TIME_FORMAT) + + +def test_tzinfo_set_in_serializer(get_instance): + """ + Checks how serializer applies tzinfo for datetime objects + :param get_instance: + :return: + """ + i = get_instance(CustomSerializerModel) + data = i.to_dict() + + # Check time/date formats + assert 'date' in data + assert data['date'] == DATE.strftime(CUSTOM_DATE_FORMAT) + assert 'datetime' in data + assert 'time' in data + assert data['time'] == TIME.strftime(CUSTOM_TIME_FORMAT) + + # Timezone info affects only datetime objects + assert data['datetime'] == DATETIME.astimezone(CUSTOM_TZINFO).strftime(CUSTOM_DATE_TIME_FORMAT) + + # Check other fields + assert 'id' in data + assert 'string' in data + assert 'bool' in data + diff --git a/tests/test_tzinfo.py b/tests/test_tzinfo.py new file mode 100644 index 0000000..5062b9b --- /dev/null +++ b/tests/test_tzinfo.py @@ -0,0 +1,31 @@ +import pytz + +from .models import FlatModel, DATETIME, TIME, DATE + + +def test_tzinfo_set_directly(get_instance): + """ + Checks how serializer applies tzinfo for datetime objects + :param get_instance: + :return: + """ + i = get_instance(FlatModel) + + # Default formats + d_format = i.date_format + dt_format = i.datetime_format + t_format = i.time_format + tzinfo = pytz.timezone('Europe/Moscow') + + data = i.to_dict(tzinfo=tzinfo) + + # No change for time and date objects + assert 'date' in data + assert data['date'] == DATE.strftime(d_format) + assert 'datetime' in data + assert 'time' in data + assert data['time'] == TIME.strftime(t_format) + + # Timezone info affects only datetime objects + assert data['datetime'] == DATETIME.astimezone(tzinfo).strftime(dt_format) +