Skip to content

Commit

Permalink
Fixed timezone logic and improved README
Browse files Browse the repository at this point in the history
  • Loading branch information
n0nSmoker committed May 28, 2019
1 parent bf99984 commit 8c544a7
Show file tree
Hide file tree
Showing 18 changed files with 209 additions and 43 deletions.
9 changes: 9 additions & 0 deletions 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
4 changes: 3 additions & 1 deletion 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

134 changes: 98 additions & 36 deletions 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
```

Expand All @@ -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:
Expand All @@ -42,20 +59,23 @@ 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:
```python

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',)
Expand All @@ -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):
Expand All @@ -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)
Expand All @@ -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!',
Expand All @@ -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!',
Expand All @@ -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,
Expand All @@ -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.

Binary file added dist/SQLAlchemy-serializer-0.8.tar.gz
Binary file not shown.
Binary file added dist/SQLAlchemy-serializer-0.9.tar.gz
Binary file not shown.
Binary file added dist/SQLAlchemy-serializer-0.91.tar.gz
Binary file not shown.
Binary file added dist/SQLAlchemy-serializer-1.1.1.tar.gz
Binary file not shown.
Binary file added dist/SQLAlchemy-serializer-1.1.2.tar.gz
Binary file not shown.
Binary file added dist/SQLAlchemy-serializer-1.1.3.tar.gz
Binary file not shown.
Binary file added dist/SQLAlchemy-serializer-1.1.4.tar.gz
Binary file not shown.
Binary file added dist/SQLAlchemy-serializer-1.1.tar.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion docker-compose.yml
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
@@ -1,2 +1,3 @@
SQLAlchemy==1.3.0
psycopg2==2.7.3.1
pytz==2019.1
3 changes: 2 additions & 1 deletion 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)


Expand Down
8 changes: 4 additions & 4 deletions sqlalchemy_serializer/serializer.py
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
32 changes: 32 additions & 0 deletions 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
Expand Down Expand Up @@ -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)
28 changes: 28 additions & 0 deletions 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

0 comments on commit 8c544a7

Please sign in to comment.