Source code for redpipe.fields

# -*- coding: utf-8 -*-

"""
A module for marshalling data in and out of redis and back into the python
data type we expect.

Used extensively in the `redpipe.keyspaces` module for type-casting keys and
values.

"""

import json
import re
import typing

# python 3.7 compatibility change
from typing import (TypeVar, Generic, Optional, Union)
try:
    from typing import Protocol
except ImportError:
    from typing_extensions import Protocol  # type: ignore


from .exceptions import InvalidValue

__all__ = [
    'IntegerField',
    'FloatField',
    'TextField',
    'AsciiField',
    'BinaryField',
    'BooleanField',
    'ListField',
    'DictField',
    'StringListField',
    'Field'
]

T = TypeVar('T')


[docs]class Field(Protocol, Generic[T]):
[docs] @classmethod def encode(cls, value: T) -> bytes: ...
[docs] @classmethod def decode(cls, value: Optional[bytes]) -> Optional[T]: ...
[docs]class BooleanField(object): """ Used for boolean fields. """
[docs] @classmethod def is_true(cls, val): if val is True: return True if val is False: return False strval = str(val).lower() if strval in ['true', '1']: return True if strval in ['false', '0', 'none', '']: return False return True if val else False
[docs] @classmethod def encode(cls, value: bool) -> bytes: """ convert a boolean value into something we can persist to redis. An empty string is the representation for False. :param value: bool :return: bytes """ return b'1' if cls.is_true(value) else b''
[docs] @classmethod def decode(cls, value: Optional[bytes]) -> Optional[bool]: """ convert from redis bytes into a boolean value :param value: bytes :return: bool """ return None if value is None else bool(value)
[docs]class FloatField(object): """ Numeric field that supports integers and floats (values are turned into floats on load from persistence). """
[docs] @classmethod def decode(cls, value: Optional[bytes]) -> Optional[float]: """ decode the bytes from redis back into a float :param value: bytes :return: float """ return None if value is None else float(value)
[docs] @classmethod def encode(cls, value: float) -> bytes: """ encode a floating point number to bytes in redis :param value: float :return: bytes """ try: coerced = float(value) except (TypeError, ValueError): raise InvalidValue('not a float') response = repr(coerced) if response.endswith('.0'): response = response[:-2] return response.encode()
[docs]class IntegerField(object): """ Used for integer numeric fields. """
[docs] @classmethod def decode(cls, value: Optional[bytes]) -> Optional[int]: """ read bytes from redis and turn it back into an integer. :param value: bytes :return: int """ return None if value is None else int(float(value))
[docs] @classmethod def encode(cls, value: int) -> bytes: """ take an integer and turn it into a string representation to write into redis. :param value: int :return: str """ try: return repr(int(float(value))).encode() except (TypeError, ValueError): raise InvalidValue('not an int')
[docs]class TextField(object): """ A unicode string field. Encoded via utf-8 before writing to persistence. """ _encoding = 'utf-8'
[docs] @classmethod def encode(cls, value: str) -> bytes: """ take a valid unicode string and turn it into utf-8 bytes :param value: unicode, str :return: bytes """ coerced = str(value) if coerced == value: return coerced.encode(cls._encoding) raise InvalidValue('not text')
[docs] @classmethod def decode(cls, value: Optional[bytes]) -> Optional[str]: """ take bytes from redis and turn them into unicode string :param value: :return: """ return None if value is None else str(value.decode(cls._encoding))
[docs]class AsciiField(TextField): """ Used for ascii-only text """ PATTERN = re.compile('^([ -~]+)?$')
[docs] @classmethod def encode(cls, value: str) -> bytes: """ take a list of strings and turn it into utf-8 byte-string :param value: :return: """ coerced = str(value) if coerced == value and cls.PATTERN.match(coerced): return coerced.encode(cls._encoding) raise InvalidValue('not ascii')
[docs]class BinaryField(object): """ A bytes field. Not encoded. """
[docs] @classmethod def encode(cls, value: bytes) -> bytes: """ write binary data into redis without encoding it. :param value: bytes :return: bytes """ try: coerced = bytes(value) if coerced == value: return coerced except (TypeError, UnicodeError): pass raise InvalidValue('not binary')
[docs] @classmethod def decode(cls, value: Optional[bytes]) -> Optional[bytes]: """ read binary data from redis and pass it on through. :param value: bytes :return: bytes """ return None if value is None else bytes(value)
[docs]class ListField(object): """ A list field. Marshalled in and out of redis via json. Values of the list can be any arbitrary data. """ _encoding = 'utf-8'
[docs] @classmethod def encode(cls, value: list) -> bytes: """ take a list and turn it into a utf-8 encoded byte-string for redis. :param value: list :return: bytes """ try: coerced = list(value) if coerced == value: return json.dumps(coerced).encode(cls._encoding) except TypeError: pass raise InvalidValue('not a list')
[docs] @classmethod def decode(cls, value: Union[bytes, None, list]) -> Optional[list]: """ take a utf-8 encoded byte-string from redis and turn it back into a list :param value: bytes :return: list """ try: return None if value is None else \ list(json.loads(value.decode(cls._encoding))) # type: ignore except (TypeError, AttributeError): return list(value) # type: ignore
[docs]class DictField(object): _encoding = 'utf-8'
[docs] @classmethod def encode(cls, value: dict) -> bytes: """ encode the dict as a json string to be written into redis. :param value: dict :return: bytes """ try: coerced = dict(value) if coerced == value: return json.dumps(coerced).encode(cls._encoding) except (TypeError, ValueError): pass raise InvalidValue('not a dict')
[docs] @classmethod def decode(cls, value: Union[bytes, None, dict]) -> Optional[dict]: """ decode the data from a json string in redis back into a dict object. :param value: bytes :return: dict """ try: return None if value is None else \ dict(json.loads(value.decode(cls._encoding))) # type: ignore except (TypeError, AttributeError): return dict(value) # type: ignore
[docs]class StringListField(object): """ Used for storing a list of strings, serialized as a comma-separated list. """ _encoding = 'utf-8'
[docs] @classmethod def decode(cls, value: Union[bytes, None, typing.List[str]] ) -> Optional[typing.List[str]]: """ decode the data from redis. :param value: bytes :return: list """ if value is None or isinstance(value, list): return value try: data = [v for v in value.decode(cls._encoding).split(',') if v != ''] return data if data else None except AttributeError: return None
[docs] @classmethod def encode(cls, value: typing.List[str]) -> bytes: """ encode the list it so it can be stored in redis. :param value: list :return: bytes """ try: coerced = [str(v) for v in value] if coerced == value: return ",".join(coerced).encode(cls._encoding) if len( value) > 0 else b'' except TypeError: pass raise InvalidValue('not a list of strings')