diff --git a/README.md b/README.md index aec0226..4ca218d 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,18 @@ ![Python Version](https://img.shields.io/pypi/pyversions/lona-redis.svg) ![Latest Version](https://img.shields.io/pypi/v/lona-redis.svg) +lona-redis uses Redis as a key-value store to store server side cookies. + +lona-redis also allows direct access to the Redis connection for direct execution of Redis commands. ## Installation -lona-picocss can be installed using pip +lona-redis can be installed using pip ``` pip install lona-redis ``` - ## Using Sessions ```python @@ -23,3 +25,85 @@ MIDDLEWARES = [ 'lona_redis.middlewares.RedisSessionMiddleware', ] ``` + +## Start up Redis +Using Docker +``` +docker run -p 6379:6379 -it redis:latest --requirepass "abcd1234" + +# without password +docker run -p 6379:6379 -it redis:latest +``` + +## Example lona script +```python +from lona import App, View +from lona.html import H1, HTML + +app = App(__file__) + +app.settings.SESSIONS = True +app.settings.MIDDLEWARES = [ + "lona_redis.middlewares.RedisSessionMiddleware", +] +app.settings.REDIS_CONNECTION = {"password": "abcd1234"} + +@app.route("/") +class Index(View): + def handle_request(self, request): + session = request.user.session + + session.set("foo", 123) # set key, value + session.exists("foo") # returns True + foo = session.get("foo") # given key, get value + session.delete("foo") # returns True + session.delete("foo") # returns False, "foo" was already deleted + session.delete("bar") # returns False, "bar" doesn't exist + + return HTML( + H1("Hello World"), + ) +``` + +## Store other data types +Any data type that can be pickled can be stored +```python +session.set("str_var", "hello world") +session.set("list_var", [1, 2.222, "hello world"]) +session.set("int_var", 123) +session.set("float_var", 123.456) +session.set("tuple_var", (1, 2, 3)) +session.set("dict_var", {"a": 1, "b": 2}) +session.set("boolean_var", True) +session.set( + "mixed_types_var", + { + "a": [1, 2, 3], + "b": {"a": 1, "b": 2}, + "c": (4, 5, 6), + }, +) +``` +## Store key-values that expire +All options are passed through to Redis set() command + +https://redis.readthedocs.io/en/latest/commands.html#redis.commands.core.CoreCommands.set +```python +# "foo" will expire in 5 seconds +session.set("foo", 123, ex=5) +``` +## Use Redis commands directly +**Always access the key with session.redis_key()** + +```python +# Set the value of key name to value if key doesn’t exist +# https://redis.readthedocs.io/en/latest/commands.html#redis.commands.core.CoreCommands.setnx +session.r.setnx(session.redis_key("count"), 1) + +# Increments the value of key by amount. If no key exists, the value will be initialized as amount +# https://redis.readthedocs.io/en/latest/commands.html#redis.commands.core.CoreCommands.incr +count = session.r.incr(session.redis_key("count"), amount=1) + +# when using r.get, value returned is in bytes - need to manage this yourself +session.r.get(session.redis_key("count")) +``` \ No newline at end of file diff --git a/example_redis.py b/example_redis.py new file mode 100644 index 0000000..5b07df6 --- /dev/null +++ b/example_redis.py @@ -0,0 +1,129 @@ +""" + example_redis.py + + test redis middleware +""" + +from lona import App, View +from lona.html import H1, HTML, P + +from loguru import logger + + +# NOTE start Redis before starting this lona script, eg: +# docker run -p 6379:6379 -it redis:latest --requirepass "abcd1234" + +app = App(__file__) + + +# app.settings.SESSIONS = True +app.settings.MIDDLEWARES = [ + "lona_redis.middlewares.RedisSessionMiddleware", +] + +# Redis connection settings https://redis.readthedocs.io/en/latest/connections.html +app.settings.REDIS_CONNECTION = {"password": "abcd1234"} +# app.settings.REDIS_CONNECTION = {} + + +@app.route("/") +class Index(View): + def handle_request(self, request): + # + # NOTE THE "EASY" WAY + # NOTE examples of using request.user.session.set, request.user.session.get + # + session = request.user.session + session.set("foo", 123) + + # NOTE set + session.set("foo", 123) + session.set("foo", 999, ex=5) + + # NOTE exists + logger.debug(f"{session.exists('foo')=}") + logger.debug(f"{session.exists('bar')=}") + + # NOTE get + logger.debug(f"{session.get('foo')=}") + logger.debug(f"{session.get('bar')=}") + + # NOTE delete + logger.debug(f"{session.delete('foo')=}") + logger.debug(f"{session.delete('foo')=}") + logger.debug(f"{session.delete('bar')=}") + + # NOTE store various types of values + session.set("str_var", "hello world") + session.set("list_var", [1, 2.222, "hello world"]) + session.set("int_var", 123) + session.set("float_var", 123.456) + session.set("tuple_var", (1, 2, 3)) + session.set("dict_var", {"a": 1, "b": 2}) + session.set("boolean_var", True) + # session.set( + # "mixed_var_1", + # [ + # True, + # {"a": 1, "b": 2}, + # (1, 2, 3), + # 123.456, + # 123, + # [1, 2.222, "hello world"], + # "hello world", + # ], + # ) + # request.user.session.set( + # "mixed_var_2", + # { + # "a": [1, 2, 3], + # "b": {"a": 1, "b": 2}, + # "c": (4, 5, 6), + # }, + # ) + + # logger.debug(f"{session.get('str_var')=}") + # logger.debug(f"{session.get('list_var')=}") + # logger.debug(f"{session.get('int_var')=}") + # logger.debug(f"{session.get('float_var')=}") + # logger.debug(f"{session.get('tuple_var')=}") + # logger.debug(f"{session.get('dict_var')=}") + # logger.debug(f"{session.get('boolean_var')=}") + # logger.debug(f"{session.get('mixed_var_1')=}") + # logger.debug(f"{session.get('mixed_var_2')=}") + + # + # NOTE examples of using Redis commands directly + # https://redis.readthedocs.io/en/latest/commands.html#core-commands + # + + # NOTE set key directly in Redis + # NOTE just an example, DON'T DO THIS + # should always use session.redis_key() + # Otherwise another session will overwrite this key + # session.r.set("myKey", "thevalueofmykey") + # myKey = session.r.get("myKey") + + # NOTE Set the value of key name to value if key doesn’t exist + session.r.setnx(session.redis_key("count"), 1) + + # Increments the value of key by amount. If no key exists, the value will be initialized as amount + count = session.r.incr(session.redis_key("count"), amount=1) + logger.debug(f"{count=}") + + # NOTE when using r.get, value returned is in bytes - need to manage this yourself + count = session.r.get(session.redis_key("count")) + logger.debug(f"{count=}") + + # NOTE show all keys + logger.debug(f"{session.r.keys()=}") + + # getset + # count = session.set("count", 0, get=True) + + return HTML( + H1("Hello World"), + ) + + +app.run() diff --git a/lona_redis/middlewares.py b/lona_redis/middlewares.py index ffa1e8f..652b0e4 100644 --- a/lona_redis/middlewares.py +++ b/lona_redis/middlewares.py @@ -1,26 +1,180 @@ +import pickle +import sys + +import redis + + class RedisSession: - def __init__(self, redis_user): - self.redis_user = redis_user + # FIXME this isn't used - can be removed? + # def __init__(self, redis_user): + # self.redis_user = redis_user + + def __init__(self, *args, **kwargs): + """ + initalize redis.Redis() + + https://redis.readthedocs.io/en/latest/#quickly-connecting-to-redis + + kwargs is app.settings.REDIS_CONNECTION + + pass through all kwargs to redis-py + kwargs: + https://redis.readthedocs.io/en/latest/connections.html + """ + + # called from RedisSessionMiddleware.on_startup() + + # .r is available by user for direct access to redis-py commands + # eg. all_keys = request.user.session.r.keys() + self.r = redis.Redis(**kwargs) - def get(self, *args, **kwargs): - raise NotImplementedError() + def redis_key(self, user_key): + """ + combine self.user_request_session_key with user's key + so that the ACTUAL key used to store value in Redis is unique + """ + + COMBINE_CHR = ":" + return self.user_request_session_key + COMBINE_CHR + user_key def set(self, *args, **kwargs): - raise NotImplementedError() + """ + user should call this to easily set values + eg. request.user.session.set("foo", 123) + pickle all values so that Redis can store any pickle-able Python value + + pass through all kwargs to redis-py .set() + kwargs: + https://redis.readthedocs.io/en/latest/commands.html#redis.commands.core.CoreCommands.set + """ + + if len(args) == 2: + redis_key = self.redis_key(args[0]) + value = pickle.dumps(args[1]) + self.r.set(redis_key, value, **kwargs) + + else: + class_name = self.__class__.__name__ + function_name = sys._getframe().f_code.co_name + raise TypeError( + f"{__name__}.{class_name}.{function_name} expected 2 arguments, got {len(args)}" + ) + + def get(self, *args): + """ + user should call this to easily get values + eg. request.user.session.get("foo") + un-pickle all values that were retrieved from Redis + + if key does not exist, return None + + emulate Redis get() + https://redis.readthedocs.io/en/latest/commands.html#redis.commands.core.CoreCommands.get + """ + + if len(args) == 1: + user_key = args[0] + if self.exists(user_key): + redis_key = self.redis_key(user_key) + return pickle.loads(self.r.get(redis_key)) + else: + return None + + else: + class_name = self.__class__.__name__ + function_name = sys._getframe().f_code.co_name + raise TypeError( + f"{__name__}.{class_name}.{function_name} expected 1 argument, got {len(args)}" + ) + def exists(self, user_key): + """ + check if user_key exists + eg. request.user.session.exists("foo") -class RedisUser: - def __init__(self, connection): - self.connection = connection - self.session = RedisSession(self) + return True/False - def __eq__(self, other): - raise NotImplementedError() + as compared to: + https://redis.readthedocs.io/en/latest/commands.html#redis.commands.core.CoreCommands.exists + + only accept 1 argument, return True/False + """ + + redis_key = self.redis_key(user_key) + return self.r.exists(redis_key) == 1 + + def delete(self, user_key): + """ + delete user_key + eg. request.user.session.delete("foo") + + return + True if key existed and was deleted + False otherwise + + as compared to: + https://redis.readthedocs.io/en/latest/commands.html#redis.commands.core.CoreCommands.delete + + only accept 1 argument, return True/False + """ + + redis_key = self.redis_key(user_key) + return self.r.delete(redis_key) == 1 + + +# FIXME this isn't used - can be removed? +# class RedisUser: +# def __init__(self, connection): +# # self.connection = connection +# # self.session = RedisSession(self) +# pass +# +# def __eq__(self, other): +# raise NotImplementedError() class RedisSessionMiddleware: - def handle_connection(self, data): - connection.user = RedisUser(data.connection) + async def on_startup(self, data): + """ + initalize Redis connection + + get settings from app.settings.REDIS_CONNECTION + """ + + settings = data.server.settings + + self.redis_session = RedisSession(**settings.REDIS_CONNECTION) + + # initalize this here, but to be set in handle_request() + # just prior to user calling request.user.session.get(), request.user.session.set() + self.user_request_session_key = None return data + # FIXME this isn't used - can be removed? + # def handle_connection(self, data): + # # connection.user = RedisUser(data.connection) + # + # return data + + def handle_request(self, data): + # server = data.server + # connection = data.connection + request = data.request + # view = data.view + + # set self.user_request_session_key to request.user.session_key + # so that subsequent calls by the user to + # self.redis_session.set, self.redis_session.get + # ie. in app->View->handle_request() : request.user.session.set(), request.user.session.get() + # will have request.user.session_key available + # we NEED this so that each Redis key is unique to request.user.session_key + self.redis_session.user_request_session_key = request.user.session_key + + # eg. user will call request.user.session.get() + # request.user.session is set to self.redis_session + # which is initalised to RedisSession(**settings.REDIS_CONNECTION) + # where self.r = redis.Redis(**kwargs), kwargs=settings.REDIS_CONNECTION + request.user.session = self.redis_session + + return data