├── .github ├── dependabot.yml └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .readthedocs.yml ├── FLASK_LICENSE ├── LICENSE ├── Makefile ├── README.md ├── docs ├── Makefile └── source │ ├── api.rst │ ├── conf.py │ ├── configuration.rst │ ├── index.rst │ ├── testing.rst │ └── using_the_interfaces.rst ├── example └── example.py ├── pyproject.toml ├── sanic_session ├── __init__.py ├── aioredis.py ├── base.py ├── memcache.py ├── memory.py ├── mongodb.py ├── redis.py └── utils.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── test_in_memory_session_interface.py ├── test_memcache_session_interface.py ├── test_redis_session_interface.py └── test_utils.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .tox/ 3 | .vscode/ 4 | bin/ 5 | include/ 6 | lib/ 7 | .env 8 | *sublime* 9 | *.egg* 10 | .cache 11 | __pycache__ 12 | */build 13 | dist 14 | .idea/ 15 | [._]*.sw[a-p] 16 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | # Optionally build your docs in additional formats such as PDF and ePub 13 | formats: all 14 | 15 | # Optionally set the version of Python and requirements required to build your docs 16 | python: 17 | version: 3.7 18 | install: 19 | - method: pip 20 | path: . 21 | extra_requirements: 22 | - dev 23 | - method: setuptools 24 | path: . -------------------------------------------------------------------------------- /FLASK_LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 by Armin Ronacher and contributors. See AUTHORS 2 | for more details. 3 | 4 | Some rights reserved. 5 | 6 | Redistribution and use in source and binary forms of the software as well 7 | as documentation, with or without modification, are permitted provided 8 | that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above 14 | copyright notice, this list of conditions and the following 15 | disclaimer in the documentation and/or other materials provided 16 | with the distribution. 17 | 18 | * The names of the contributors may not be used to endorse or 19 | promote products derived from this software without specific 20 | prior written permission. 21 | 22 | THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND 23 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT 24 | NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 25 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER 26 | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 27 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 28 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 29 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 30 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 31 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 32 | SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 33 | DAMAGE. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Suby Raman 4 | Copyright (c) 2018 Mikhail Kashkin 5 | Copyright (c) 2022 Adam Hopkins 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | venv/bin/activate: 2 | python3 -m venv venv 3 | 4 | setup: ## project setup and run migrations 5 | pip install -e .[aioredis,redis,mongo,aiomcache,dev] 6 | 7 | release: ## create new release 8 | rm -rf dist 9 | python setup.py sdist bdist_wheel && twine upload dist/* 10 | rm -rf dist 11 | 12 | lint: ## run linter 13 | flake8 sanic_session/ tests 14 | isort sanic_session tests --check 15 | black sanic_session tests --check 16 | 17 | format: ## format code 18 | isort sanic_session tests 19 | black sanic_session tests 20 | 21 | pretty: format 22 | 23 | test: ## run tests 24 | py.test -vs --cov sanic_session/ tests 25 | 26 | help: ## Display this help screen 27 | @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sanic session management for humans 2 | [![ReadTheDocs](https://img.shields.io/readthedocs/sanic_session.svg)](https://sanic-session.readthedocs.io) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) 4 | [![PyPI version](https://img.shields.io/pypi/v/sanic_session.svg)](https://pypi.org/project/sanic_session/) 5 | 6 | 7 | `sanic-session` is session management extension for [Sanic](https://sanic.dev) that integrates server-backed sessions with most convenient API. 8 | 9 | `sanic-session` provides a number of *session interfaces* for you to store a client's session data. The interfaces available right now are: 10 | 11 | * Redis (supports both drivers `aioredis` and `asyncio_redis`) 12 | * Memcache (via `aiomcache`) 13 | * Mongodb (via `sanic_motor` and `pymongo`) 14 | * In-Memory (suitable for testing and development environments) 15 | 16 | ## Installation 17 | 18 | Install with `pip` (there is other options for different drivers, check documentation): 19 | 20 | `pip install sanic_session` 21 | 22 | 23 | ## Documentation 24 | 25 | Documentation is available at [sanic-session.readthedocs.io](http://sanic-session.readthedocs.io/en/latest/). 26 | 27 | Also, make sure you read [OWASP's Session Management Cheat Sheet](https://www.owasp.org/index.php/Session_Management_Cheat_Sheet) for some really useful info on session management. 28 | 29 | ## Example 30 | 31 | A simple example uses the in-memory session interface. 32 | 33 | ```python 34 | from sanic import Sanic 35 | from sanic.response import text 36 | from sanic_session import Session, InMemorySessionInterface 37 | 38 | app = Sanic(name="ExampleApp") 39 | session = Session(app, interface=InMemorySessionInterface()) 40 | 41 | @app.route("/") 42 | async def index(request): 43 | # interact with the session like a normal dict 44 | if not request.ctx.session.get('foo'): 45 | request.ctx.session['foo'] = 0 46 | 47 | request.ctx.session['foo'] += 1 48 | 49 | return text(str(request.ctx.session["foo"])) 50 | 51 | if __name__ == "__main__": 52 | app.run(host="0.0.0.0", port=8000) 53 | ``` 54 | 55 | Examples of using redis and memcache backed sessions can be found in the documentation, under [Using the Interfaces](http://sanic-session.readthedocs.io/en/latest/using_the_interfaces.html). 56 | 57 |

— ⭐️ —

58 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = sanic_session 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API Documentation 4 | ===================== 5 | 6 | InMemorySessionInterface 7 | ------------------------------------------------ 8 | 9 | .. autoclass:: sanic_session.InMemorySessionInterface 10 | :members: 11 | 12 | MemcacheSessionInterface 13 | ----------------------------------------------- 14 | 15 | .. autoclass:: sanic_session.MemcacheSessionInterface 16 | :members: 17 | 18 | RedisSessionInterface 19 | ----------------------------------------------- 20 | 21 | .. autoclass:: sanic_session.RedisSessionInterface 22 | :members: 23 | 24 | 25 | AIORedisSessionInterface 26 | ----------------------------------------------- 27 | 28 | .. autoclass:: sanic_session.AIORedisSessionInterface 29 | :members: 30 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # sanic_session documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jan 10 00:43:29 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.autodoc', 'sphinxcontrib.fulltoc'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'sanic_session' 50 | copyright = '2017, Suby Raman' 51 | author = 'Suby Raman' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '0.5.0' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '0.5.0' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = [] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ---------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_title = "sanic_session" 87 | html_theme = 'alabaster' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | html_theme_options = { 94 | 'sidebar_width': '250px', 95 | 'logo_name': 'sanic_session', 96 | 'page_width': '1000px' 97 | } 98 | 99 | # Add any paths that contain custom static files (such as style sheets) here, 100 | # relative to this directory. They are copied after the builtin static files, 101 | # so a file named "default.css" will overwrite the builtin "default.css". 102 | html_static_path = ['_static'] 103 | 104 | 105 | # -- Options for HTMLHelp output ------------------------------------------ 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'sanic_sessiondoc' 109 | 110 | 111 | # -- Options for LaTeX output --------------------------------------------- 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | # 116 | # 'papersize': 'letterpaper', 117 | 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | 126 | # Latex figure (float) alignment 127 | # 128 | # 'figure_align': 'htbp', 129 | } 130 | 131 | # Grouping the document tree into LaTeX files. List of tuples 132 | # (source start file, target name, title, 133 | # author, documentclass [howto, manual, or own class]). 134 | latex_documents = [ 135 | (master_doc, 'sanic_session.tex', 'sanic\\_session Documentation', 136 | 'Suby Raman', 'manual'), 137 | ] 138 | 139 | 140 | # -- Options for manual page output --------------------------------------- 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [ 145 | (master_doc, 'sanic_session', 'sanic_session Documentation', 146 | [author], 1) 147 | ] 148 | 149 | 150 | # -- Options for Texinfo output ------------------------------------------- 151 | 152 | # Grouping the document tree into Texinfo files. List of tuples 153 | # (source start file, target name, title, author, 154 | # dir menu entry, description, category) 155 | texinfo_documents = [ 156 | (master_doc, 'sanic_session', 'sanic_session Documentation', 157 | author, 'sanic_session', 'One line description of project.', 158 | 'Miscellaneous'), 159 | ] 160 | 161 | 162 | def skip(app, what, name, obj, skip, options): 163 | if name == "__init__": 164 | return False 165 | return skip 166 | 167 | 168 | def setup(app): 169 | app.connect("autodoc-skip-member", skip) 170 | -------------------------------------------------------------------------------- /docs/source/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | Configuration 4 | ========================================= 5 | 6 | When initializing a session interface, you have a number of optional arguments for configuring your session. 7 | 8 | **domain** (str, optional): 9 | Optional domain which will be attached to the cookie. Defaults to None. 10 | **expiry** (int, optional): 11 | Seconds until the session should expire. Defaults to *2592000* (30 days). Setting this to 0 or None will set the session as permanent. 12 | **httponly** (bool, optional): 13 | Adds the `httponly` flag to the session cookie. Defaults to True. 14 | **cookie_name** (str, optional): 15 | Name used for the client cookie. Defaults to "session". 16 | **prefix** (str, optional): 17 | Storage keys will take the format of `prefix+`. Specify the prefix here. 18 | **sessioncookie** (bool, optional): 19 | If enabled the browser will be instructed to delete the cookie when the browser is closed. This is done by omitting the `max-age` and `expires` headers when sending the cookie. The `expiry` configuration option will still be honored on the server side. This is option is disabled by default. 20 | **samesite** (str, optional): 21 | One of 'strict' or 'lax'. Defaults to None https://www.owasp.org/index.php/SameSite 22 | **session_name** (str, optional): 23 | | Name of the session that will be accessible through the request. 24 | | e.g. If ``session_name`` is ``alt_session``, it should be accessed like that: ``request.ctx.alt_session`` 25 | | e.g. And if ``session_name`` is left to default, it should be accessed like that: ``request.ctx.session`` 26 | 27 | .. note:: 28 | 29 | If you choose to build your application using more than one session object, make sure that they have different: 30 | 31 | 1. ``cookie_name`` 32 | 2. ``prefix`` (Only if the two cookies share the same store) 33 | 3. And obviously, different: ``session_name`` 34 | 35 | 36 | **Example 1:** 37 | 38 | .. code-block:: python 39 | 40 | session_interface = InMemorySessionInterface( 41 | domain='.example.com', expiry=0, 42 | httponly=False, cookie_name="cookie", prefix="sessionprefix:", samesite="strict") 43 | 44 | Will result in a session that: 45 | 46 | - Will be valid only on *example.com*. 47 | - Will never expire. 48 | - Will be accessible by Javascript. 49 | - Will be named "cookie" on the client. 50 | - Will be named "sessionprefix:" in the session store. 51 | - Will prevent the cookie from being sent by the browser to the target site in all cross-site browsing context, even when following a regular link. 52 | 53 | **Example 2:** 54 | 55 | .. code-block:: python 56 | 57 | session_interface = InMemorySessionInterface( 58 | domain='.example.com', expiry=3600, sessioncookie=True, 59 | httponly=True, cookie_name="myapp", prefix="session:") 60 | 61 | Will result in a session that: 62 | 63 | - Will be valid only on *example.com*. 64 | - Will expire on the server side after 1 hour. 65 | - Will be deleted on the client when the user closes the browser. 66 | - Will *not* be accessible by Javascript. 67 | - Will be named "myapp" on the client. 68 | - Will be named "session:" in the session store. 69 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. sanic_session documentation master file, created by 2 | sphinx-quickstart on Tue Jan 10 00:43:29 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | sanic_session 7 | ========================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | 12 | self 13 | using_the_interfaces 14 | api 15 | configuration 16 | testing 17 | 18 | sanic_session is an extension for sanic that integrates server-backed sessions with a Flask-like API. 19 | 20 | Install it with pip: :code:`pip install sanic_session` 21 | 22 | sanic_session provides a number of *session interfaces* for you to store a client's session data. The interfaces available right now are: 23 | 24 | * Redis 25 | * Memcache 26 | * In-Memory (suitable for testing and development environments) 27 | 28 | See :ref:`using_the_interfaces` for instructions on using each. 29 | 30 | A simple example uses the in-memory session interface. 31 | 32 | .. code-block:: python 33 | 34 | from sanic import Sanic 35 | from sanic.response import text 36 | from sanic_session import Session 37 | 38 | app = Sanic() 39 | Session(app) 40 | 41 | @app.route("/") 42 | async def index(request): 43 | # interact with the session like a normal dict 44 | if not request.ctx.session.get('foo'): 45 | request.ctx.session['foo'] = 0 46 | 47 | request.ctx.session['foo'] += 1 48 | 49 | return text(request.ctx.session['foo']) 50 | 51 | if __name__ == "__main__": 52 | app.run(host="0.0.0.0", port=8000, debug=True) 53 | 54 | 55 | 56 | ================== 57 | 58 | * :ref:`using_the_interfaces` 59 | * :ref:`api` 60 | * :ref:`configuration` 61 | * :ref:`testing` 62 | 63 | 64 | -------------------------------------------------------------------------------- /docs/source/testing.rst: -------------------------------------------------------------------------------- 1 | .. _testing: 2 | 3 | Testing 4 | ===================== 5 | 6 | When building your application you'll eventually want to test that your sessions are behaving as expected. You can use the :code:`InMemorySessionInterface` for testing purposes. You'll want to insert some logic in your application so that in a testing environment, your application uses the :code:`InMemorySessionInterface`. An example is like follows: 7 | 8 | **main.py** 9 | 10 | .. code-block:: python 11 | 12 | import asyncio_redis 13 | import os 14 | 15 | from sanic import Sanic 16 | from sanic.response import text 17 | from sanic_session import ( 18 | RedisSessionInterface, 19 | InMemorySessionInterface 20 | ) 21 | 22 | 23 | app = Sanic() 24 | 25 | 26 | class Redis: 27 | _pool = None 28 | 29 | async def get_redis_pool(self): 30 | if not self._pool: 31 | self._pool = await asyncio_redis.Pool.create( 32 | host='localhost', port=6379, poolsize=10 33 | ) 34 | 35 | return self._pool 36 | 37 | 38 | redis = Redis() 39 | 40 | # If we are in the testing environment, use the in-memory session interface 41 | if os.environ.get('TESTING'): 42 | Session(app, interface = InMemorySessionInterface()) 43 | else: 44 | Session(app, interface = RedisSessionInterface(redis.get_redis_pool)) 45 | 46 | 47 | @app.route("/") 48 | async def index(request): 49 | if not request.ctx.session.get('foo'): 50 | request.ctx.session['foo'] = 0 51 | 52 | request.ctx.session['foo'] += 1 53 | 54 | response = text(request.ctx.session['foo']) 55 | 56 | return response 57 | 58 | if __name__ == "__main__": 59 | app.run(host="0.0.0.0", port=8000, debug=True) 60 | 61 | Let's say we want to test that the route :code:`/` does in fact increment a counter on subsequent requests. There's a few things to remember: 62 | 63 | - When a session is saved, a :code:`session` parameter is included in the response cookie. 64 | - Use this session ID to retrieve the server-stored session data from the :code:`session_interface`. 65 | - You can also use this session ID on future requests to reuse the same client session. 66 | 67 | An example is like follows: 68 | 69 | .. code-block:: python 70 | 71 | import os 72 | os.environ['TESTING'] = 'True' 73 | 74 | from main import app, session_interface 75 | 76 | import pytest 77 | import aiohttp 78 | from sanic.utils import sanic_endpoint_test 79 | 80 | 81 | def test_session_increments_counter(): 82 | request, response = sanic_endpoint_test(app, uri='/') 83 | 84 | # A session ID is passed in the response cookies, save that 85 | session_id = response.cookies['session'].value 86 | 87 | # retrieve the session data using the session_id 88 | session = session_interface.get_session(session_id) 89 | 90 | assert session['foo'] == 1, 'foo should initially equal 1' 91 | 92 | # use the session ID to test the endpoint against the same session 93 | request, response = sanic_endpoint_test( 94 | app, uri='/', cookies={'session': session_id}) 95 | 96 | # again retrieve the session data using the session_id 97 | session = session_interface.get_session(session_id) 98 | 99 | assert session['foo'] == 2, 'foo should increment on subsequent requests' 100 | -------------------------------------------------------------------------------- /docs/source/using_the_interfaces.rst: -------------------------------------------------------------------------------- 1 | .. _using_the_interfaces: 2 | 3 | Using the interfaces 4 | ===================== 5 | 6 | For now project has set of different interfaces. You can install each manually or using the extra parameters: 7 | 8 | :code:`pip install sanic_session[aioredis]` 9 | 10 | Other supported backend keywords: 11 | 12 | - :code:`aioredis` (dependency 'aioredis'), 13 | - :code:`redis` ('asyncio_redis'), 14 | - :code:`mongo` ('sanic_motor' and 'pymongo'), 15 | - :code:`aiomcache` ('aiomcache') 16 | 17 | 18 | Redis (asyncio_redis) 19 | ----------------- 20 | `Redis `_ is a popular and widely supported key-value store. In order to interface with redis, you will need to add :code:`asyncio_redis` to your project. Do so with pip: 21 | 22 | :code:`pip install asyncio_redis` or :code:`pip install sanic_session[redis]` 23 | 24 | To integrate Redis with :code:`sanic_session` you need to pass a getter method into the :code:`RedisSessionInterface` which returns a connection pool. This is required since there is no way to synchronously create a connection pool. An example is below: 25 | 26 | .. code-block:: python 27 | 28 | import asyncio_redis 29 | 30 | from sanic import Sanic 31 | from sanic.response import text 32 | from sanic_session import Session, RedisSessionInterface 33 | 34 | app = Sanic("Test") 35 | 36 | 37 | class Redis: 38 | """ 39 | A simple wrapper class that allows you to share a connection 40 | pool across your application. 41 | """ 42 | 43 | _pool = None 44 | 45 | async def get_redis_pool(self): 46 | if not self._pool: 47 | self._pool = await asyncio_redis.Pool.create( 48 | host="localhost", port=6379, poolsize=10 49 | ) 50 | 51 | return self._pool 52 | 53 | 54 | redis = Redis() 55 | 56 | Session(app, interface=RedisSessionInterface(redis.get_redis_pool)) 57 | 58 | 59 | @app.route("/") 60 | async def test(request): 61 | # interact with the session like a normal dict 62 | if not request.ctx.session.get("foo"): 63 | request.ctx.session["foo"] = 0 64 | 65 | request.ctx.session["foo"] += 1 66 | 67 | response = text(str(request.ctx.session["foo"])) 68 | 69 | return response 70 | 71 | if __name__ == "__main__": 72 | app.run(host="0.0.0.0", port=8000, debug=True) 73 | 74 | 75 | Redis (aioredis) 76 | ----------------- 77 | `aioredis` have little better syntax and more popular since it supported by `aiohttp` team. 78 | 79 | :code:`pip install asyncio_redis` or :code:`pip install sanic_session[aioredis]` 80 | 81 | This example shows little different approach. You can use classic Flask extensions approach with factory based initialization process. You can use it with different backends also. 82 | 83 | .. code-block:: python 84 | 85 | import aioredis 86 | 87 | from sanic import Sanic 88 | from sanic.response import text 89 | from sanic_session import Session, AIORedisSessionInterface 90 | 91 | app = Sanic("Test") 92 | # init extensions 93 | session = Session(app=app) 94 | app.config.REDIS_DSN = "redis://localhost:6379/0" 95 | 96 | 97 | @app.listener("before_server_start") 98 | async def server_init(app, loop): 99 | # For aioredis 1.x and older 100 | # app.redis = await aioredis.create_redis_pool(app.config['redis']) 101 | # For aioredis 2.x 102 | app.ctx.redis = aioredis.from_url( 103 | app.config.REDIS_DSN, decode_responses=True 104 | ) 105 | # init extensions fabrics 106 | session.init_app(app, interface=AIORedisSessionInterface(app.ctx.redis)) 107 | 108 | 109 | @app.route("/") 110 | async def test(request): 111 | # interact with the session like a normal dict 112 | if not request.ctx.session.get("foo"): 113 | request.ctx.session["foo"] = 0 114 | 115 | request.ctx.session["foo"] += 1 116 | 117 | response = text(str(request.ctx.session["foo"])) 118 | 119 | return response 120 | 121 | if __name__ == "__main__": 122 | app.run(host="0.0.0.0", port=8000, debug=True) 123 | 124 | 125 | Memcache 126 | ----------------- 127 | `Memcache `_ is another popular key-value storage system. In order to interface with memcache, you will need to add :code:`aiomcache` to your project. Do so with pip: 128 | 129 | :code:`pip install aiomcache` or :code:`pip install sanic_session[aiomcache]` 130 | 131 | To integrate memcache with :code:`sanic_session` you need to pass an :code:`aiomcache.Client` into the session interface, as follows: 132 | 133 | 134 | .. code-block:: python 135 | 136 | import aiomcache 137 | 138 | from sanic import Sanic 139 | from sanic.response import text 140 | from sanic_session import Session, MemcacheSessionInterface 141 | 142 | app = Sanic("Test") 143 | 144 | # create a memcache client 145 | client = aiomcache.Client("127.0.0.1", 11211) 146 | 147 | # pass the memcache client into the session 148 | session = Session(app, interface=MemcacheSessionInterface(client)) 149 | 150 | 151 | @app.route("/") 152 | async def test(request): 153 | # interact with the session like a normal dict 154 | if not request.ctx.session.get("foo"): 155 | request.ctx.session["foo"] = 0 156 | 157 | request.ctx.session["foo"] += 1 158 | 159 | response = text(str(request.ctx.session["foo"])) 160 | 161 | return response 162 | 163 | if __name__ == "__main__": 164 | app.run(host="0.0.0.0", port=8000, debug=True, loop=loop) 165 | 166 | In-Memory 167 | ----------------- 168 | 169 | :code:`sanic_session` comes with an in-memory interface which stores sessions in a Python dictionary available at :code:`session_interface.session_store`. This interface is meant for testing and development purposes only. **This interface is not suitable for production**. 170 | 171 | .. code-block:: python 172 | 173 | from sanic import Sanic 174 | from sanic.response import text 175 | from sanic_session import Session 176 | 177 | 178 | app = Sanic("Test") 179 | 180 | Session(app) # because InMemorySessionInterface used by default 181 | 182 | # of full syntax: 183 | # from sanic_session import InMemorySessionInterface 184 | # session = Session(app, interface=InMemorySessionInterface()) 185 | 186 | 187 | @app.route("/") 188 | async def index(request): 189 | # interact with the session like a normal dict 190 | if not request.ctx.session.get("foo"): 191 | request.ctx.session["foo"] = 0 192 | 193 | request.ctx.session["foo"] += 1 194 | 195 | return text(str(request.ctx.session["foo"])) 196 | 197 | if __name__ == "__main__": 198 | app.run(host="0.0.0.0", port=8000, debug=True) 199 | -------------------------------------------------------------------------------- /example/example.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text 3 | from sanic_session import Session, InMemorySessionInterface 4 | 5 | app = Sanic(name="ExampleApp") 6 | session = Session(app, interface=InMemorySessionInterface()) 7 | 8 | 9 | @app.route("/") 10 | async def index(request): 11 | # interact with the session like a normal dict 12 | if not request.ctx.session.get("foo"): 13 | request.ctx.session["foo"] = 0 14 | 15 | request.ctx.session["foo"] += 1 16 | 17 | return text(str(request.ctx.session["foo"])) 18 | 19 | 20 | if __name__ == "__main__": 21 | app.run(port=9999, dev=True) 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools<60.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 79 7 | target-version = ['py37','py38', 'py39', 'py310', 'py311'] 8 | 9 | [tool.isort] 10 | profile = "black" 11 | src_paths = ["sanic_session", "tests"] 12 | line_length = 79 13 | multi_line_output = 3 14 | include_trailing_comma = true 15 | -------------------------------------------------------------------------------- /sanic_session/__init__.py: -------------------------------------------------------------------------------- 1 | from .aioredis import AIORedisSessionInterface 2 | from .memcache import MemcacheSessionInterface 3 | from .memory import InMemorySessionInterface 4 | from .mongodb import MongoDBSessionInterface 5 | from .redis import RedisSessionInterface 6 | 7 | __all__ = ( 8 | "MemcacheSessionInterface", 9 | "RedisSessionInterface", 10 | "InMemorySessionInterface", 11 | "MongoDBSessionInterface", 12 | "AIORedisSessionInterface", 13 | "Session", 14 | ) 15 | 16 | 17 | class Session: 18 | def __init__(self, app=None, interface=None): 19 | self.interface = None 20 | if app: 21 | self.init_app(app, interface) 22 | 23 | def init_app(self, app, interface): 24 | self.interface = interface or InMemorySessionInterface() 25 | if not hasattr(app.ctx, "extensions"): 26 | app.ctx.extensions = {} 27 | 28 | app.ctx.extensions[ 29 | self.interface.session_name 30 | ] = self # session_name defaults to 'session' 31 | 32 | # @app.middleware('request') 33 | async def add_session_to_request(request): 34 | """Before each request initialize a session 35 | using the client's request.""" 36 | await self.interface.open(request) 37 | 38 | # @app.middleware('response') 39 | async def save_session(request, response): 40 | """After each request save the session, pass 41 | the response to set client cookies. 42 | """ 43 | await self.interface.save(request, response) 44 | 45 | app.request_middleware.appendleft(add_session_to_request) 46 | app.response_middleware.append(save_session) 47 | -------------------------------------------------------------------------------- /sanic_session/aioredis.py: -------------------------------------------------------------------------------- 1 | from sanic_session.base import BaseSessionInterface 2 | 3 | try: 4 | import aioredis 5 | except ImportError: 6 | aioredis = None 7 | 8 | 9 | class AIORedisSessionInterface(BaseSessionInterface): 10 | def __init__( 11 | self, 12 | redis, 13 | domain: str = None, 14 | expiry: int = 2592000, 15 | httponly: bool = True, 16 | cookie_name: str = "session", 17 | prefix: str = "session:", 18 | sessioncookie: bool = False, 19 | samesite: str = None, 20 | session_name: str = "session", 21 | secure: bool = False, 22 | ): 23 | """Initializes a session interface backed by Redis. 24 | 25 | Args: 26 | redis (Callable): 27 | aioredis connection or connection pool instance. 28 | domain (str, optional): 29 | Optional domain which will be attached to the cookie. 30 | expiry (int, optional): 31 | Seconds until the session should expire. 32 | httponly (bool, optional): 33 | Adds the `httponly` flag to the session cookie. 34 | cookie_name (str, optional): 35 | Name used for the client cookie. 36 | prefix (str, optional): 37 | Memcache keys will take the format of `prefix+session_id`; 38 | specify the prefix here. 39 | sessioncookie (bool, optional): 40 | Specifies if the sent cookie should be a 'session cookie', i.e 41 | no Expires or Max-age headers are included. Expiry is still 42 | fully tracked on the server side. Default setting is False. 43 | samesite (str, optional): 44 | Will prevent the cookie from being sent by the browser to the target 45 | site in all cross-site browsing context, even when following a regular link. 46 | One of ('lax', 'strict') 47 | Default: None 48 | session_name (str, optional): 49 | Name of the session that will be accessible through the request. 50 | e.g. If ``session_name`` is ``alt_session``, it should be 51 | accessed like that: ``request.ctx.alt_session`` 52 | e.g. And if ``session_name`` is left to default, it should be 53 | accessed like that: ``request.ctx.session`` 54 | Default: 'session' 55 | secure (bool, optional): 56 | Adds the `Secure` flag to the session cookie. 57 | """ 58 | if aioredis is None: 59 | raise RuntimeError( 60 | "Please install aioredis: pip install sanic_session[aioredis]" 61 | ) 62 | 63 | self.redis = redis 64 | 65 | super().__init__( 66 | expiry=expiry, 67 | prefix=prefix, 68 | cookie_name=cookie_name, 69 | domain=domain, 70 | httponly=httponly, 71 | sessioncookie=sessioncookie, 72 | samesite=samesite, 73 | session_name=session_name, 74 | secure=secure, 75 | ) 76 | 77 | async def _get_value(self, prefix, sid): 78 | return await self.redis.get(self.prefix + sid) 79 | 80 | async def _delete_key(self, key): 81 | await self.redis.delete(key) 82 | 83 | async def _set_value(self, key, data): 84 | await self.redis.setex(key, self.expiry, data) 85 | -------------------------------------------------------------------------------- /sanic_session/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import datetime 3 | import time 4 | import uuid 5 | 6 | import ujson 7 | 8 | from sanic_session.utils import CallbackDict 9 | 10 | 11 | def get_request_container(request): 12 | return request.ctx.__dict__ if hasattr(request, "ctx") else request 13 | 14 | 15 | class SessionDict(CallbackDict): 16 | def __init__(self, initial=None, sid=None): 17 | def on_update(self): 18 | self.modified = True 19 | 20 | super().__init__(initial, on_update) 21 | 22 | self.sid = sid 23 | self.modified = False 24 | 25 | 26 | class BaseSessionInterface(metaclass=abc.ABCMeta): 27 | # this flag show does this Interface need request/response middleware hooks 28 | 29 | def __init__( 30 | self, 31 | expiry, 32 | prefix, 33 | cookie_name, 34 | domain, 35 | httponly, 36 | sessioncookie, 37 | samesite, 38 | session_name, 39 | secure, 40 | ): 41 | self.expiry = expiry 42 | self.prefix = prefix 43 | self.cookie_name = cookie_name 44 | self.domain = domain 45 | self.httponly = httponly 46 | self.sessioncookie = sessioncookie 47 | self.samesite = samesite 48 | self.session_name = session_name 49 | self.secure = secure 50 | 51 | def _delete_cookie(self, request, response): 52 | req = get_request_container(request) 53 | response.cookies[self.cookie_name] = req[self.session_name].sid 54 | 55 | # We set expires/max-age even for session cookies to force expiration 56 | response.cookies[self.cookie_name][ 57 | "expires" 58 | ] = datetime.datetime.utcnow() 59 | response.cookies[self.cookie_name]["max-age"] = 0 60 | 61 | @staticmethod 62 | def _calculate_expires(expiry): 63 | expires = time.time() + expiry 64 | return datetime.datetime.fromtimestamp(expires) 65 | 66 | def _set_cookie_props(self, request, response): 67 | req = get_request_container(request) 68 | response.cookies[self.cookie_name] = req[self.session_name].sid 69 | response.cookies[self.cookie_name]["httponly"] = self.httponly 70 | 71 | # Set expires and max-age unless we are using session cookies 72 | if not self.sessioncookie: 73 | response.cookies[self.cookie_name][ 74 | "expires" 75 | ] = self._calculate_expires(self.expiry) 76 | response.cookies[self.cookie_name]["max-age"] = self.expiry 77 | 78 | if self.domain: 79 | response.cookies[self.cookie_name]["domain"] = self.domain 80 | 81 | if self.samesite is not None: 82 | response.cookies[self.cookie_name]["samesite"] = self.samesite 83 | 84 | if self.secure: 85 | response.cookies[self.cookie_name]["secure"] = True 86 | 87 | @abc.abstractmethod 88 | async def _get_value(self, prefix: str, sid: str): 89 | """ 90 | Get value from datastore. Specific implementation for each datastore. 91 | 92 | Args: 93 | prefix: 94 | A prefix for the key, useful to namespace keys. 95 | sid: 96 | a uuid in hex string 97 | """ 98 | raise NotImplementedError 99 | 100 | @abc.abstractmethod 101 | async def _delete_key(self, key: str): 102 | """Delete key from datastore""" 103 | raise NotImplementedError 104 | 105 | @abc.abstractmethod 106 | async def _set_value(self, key: str, data: SessionDict): 107 | """Set value for datastore""" 108 | raise NotImplementedError 109 | 110 | async def open(self, request) -> SessionDict: 111 | """ 112 | Opens a session onto the request. Restores the client's session 113 | from the datastore if one exists.The session data will be available on 114 | `request.session`. 115 | 116 | 117 | Args: 118 | request (sanic.request.Request): 119 | The request, which a sessionwill be opened onto. 120 | 121 | Returns: 122 | SessionDict: 123 | the client's session data, 124 | attached as well to `request.session`. 125 | """ 126 | sid = request.cookies.get(self.cookie_name) 127 | 128 | if not sid: 129 | sid = uuid.uuid4().hex 130 | session_dict = SessionDict(sid=sid) 131 | else: 132 | val = await self._get_value(self.prefix, sid) 133 | 134 | if val is not None: 135 | data = ujson.loads(val) 136 | session_dict = SessionDict(data, sid=sid) 137 | else: 138 | session_dict = SessionDict(sid=sid) 139 | 140 | # attach the session data to the request, return it for convenience 141 | req = get_request_container(request) 142 | req[self.session_name] = session_dict 143 | 144 | return session_dict 145 | 146 | async def save(self, request, response) -> None: 147 | """Saves the session to the datastore. 148 | 149 | Args: 150 | request (sanic.request.Request): 151 | The sanic request which has an attached session. 152 | response (sanic.response.Response): 153 | The Sanic response. Cookies with the appropriate expiration 154 | will be added onto this response. 155 | 156 | Returns: 157 | None 158 | """ 159 | req = get_request_container(request) 160 | if self.session_name not in req: 161 | return 162 | 163 | key = self.prefix + req[self.session_name].sid 164 | if not req[self.session_name]: 165 | await self._delete_key(key) 166 | 167 | if req[self.session_name].modified: 168 | self._delete_cookie(request, response) 169 | return 170 | 171 | val = ujson.dumps(dict(req[self.session_name])) 172 | await self._set_value(key, val) 173 | self._set_cookie_props(request, response) 174 | -------------------------------------------------------------------------------- /sanic_session/memcache.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from sanic_session.base import BaseSessionInterface 4 | 5 | try: 6 | import aiomcache 7 | except ImportError: # pragma: no cover 8 | aiomcache = None 9 | 10 | 11 | class MemcacheSessionInterface(BaseSessionInterface): 12 | def __init__( 13 | self, 14 | memcache_connection, 15 | domain: str = None, 16 | expiry: int = 2592000, 17 | httponly: bool = True, 18 | cookie_name: str = "session", 19 | prefix: str = "session:", 20 | sessioncookie: bool = False, 21 | samesite: str = None, 22 | session_name: str = "session", 23 | secure: bool = False, 24 | ): 25 | """Initializes the interface for storing client sessions in memcache. 26 | Requires a client object establised with `asyncio_memcache`. 27 | 28 | Args: 29 | memcache_connection (aiomccache.Client): 30 | The memcache client used for interfacing with memcache. 31 | domain (str, optional): 32 | Optional domain which will be attached to the cookie. 33 | expiry (int, optional): 34 | Seconds until the session should expire. 35 | httponly (bool, optional): 36 | Adds the `httponly` flag to the session cookie. 37 | cookie_name (str, optional): 38 | Name used for the client cookie. 39 | prefix (str, optional): 40 | Memcache keys will take the format of `prefix+session_id`; 41 | specify the prefix here. 42 | sessioncookie (bool, optional): 43 | Specifies if the sent cookie should be a 'session cookie', i.e 44 | no Expires or Max-age headers are included. Expiry is still 45 | fully tracked on the server side. Default setting is False. 46 | samesite (str, optional): 47 | Will prevent the cookie from being sent by the browser to 48 | the target site in all cross-site browsing context, even when 49 | following a regular link. One of ('lax', 'strict') 50 | Default: None 51 | session_name (str, optional): 52 | Name of the session that will be accessible through the 53 | request. 54 | e.g. If ``session_name`` is ``alt_session``, it should be 55 | accessed like that: ``request.ctx.alt_session`` 56 | e.g. And if ``session_name`` is left to default, it should be 57 | accessed like that: ``request.ctx.session`` 58 | Default: 'session' 59 | secure (bool, optional): 60 | Adds the `Secure` flag to the session cookie. 61 | """ 62 | if aiomcache is None: 63 | raise RuntimeError( 64 | "Please install aiomcache: pip install " 65 | "sanic_session[aiomcache]" 66 | ) 67 | 68 | self.memcache_connection = memcache_connection 69 | 70 | if expiry > 2592000: 71 | warnings.warn("Memcache has a maximum 30-day cache limit") 72 | expiry = 0 73 | 74 | super().__init__( 75 | expiry=expiry, 76 | prefix=prefix, 77 | cookie_name=cookie_name, 78 | domain=domain, 79 | httponly=httponly, 80 | sessioncookie=sessioncookie, 81 | samesite=samesite, 82 | session_name=session_name, 83 | secure=secure, 84 | ) 85 | 86 | async def _get_value(self, prefix, sid): 87 | key = (self.prefix + sid).encode() 88 | value = await self.memcache_connection.get(key) 89 | return value.decode() if value else None 90 | 91 | async def _delete_key(self, key): 92 | return await self.memcache_connection.delete(key.encode()) 93 | 94 | async def _set_value(self, key, data): 95 | return await self.memcache_connection.set( 96 | key.encode(), data.encode(), exptime=self.expiry 97 | ) 98 | -------------------------------------------------------------------------------- /sanic_session/memory.py: -------------------------------------------------------------------------------- 1 | from sanic_session.base import BaseSessionInterface 2 | from sanic_session.utils import ExpiringDict 3 | 4 | 5 | class InMemorySessionInterface(BaseSessionInterface): 6 | def __init__( 7 | self, 8 | domain: str = None, 9 | expiry: int = 2592000, 10 | httponly: bool = True, 11 | cookie_name: str = "session", 12 | prefix: str = "session:", 13 | sessioncookie: bool = False, 14 | samesite: str = None, 15 | session_name="session", 16 | secure: bool = False, 17 | ): 18 | 19 | super().__init__( 20 | expiry=expiry, 21 | prefix=prefix, 22 | cookie_name=cookie_name, 23 | domain=domain, 24 | httponly=httponly, 25 | sessioncookie=sessioncookie, 26 | samesite=samesite, 27 | session_name=session_name, 28 | secure=secure, 29 | ) 30 | self.session_store = ExpiringDict() 31 | 32 | async def _get_value(self, prefix, sid): 33 | return self.session_store.get(self.prefix + sid) 34 | 35 | async def _delete_key(self, key): 36 | if key in self.session_store: 37 | self.session_store.delete(key) 38 | 39 | async def _set_value(self, key, data): 40 | self.session_store.set(key, data, self.expiry) 41 | -------------------------------------------------------------------------------- /sanic_session/mongodb.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from datetime import datetime, timedelta 3 | 4 | from sanic_session.base import BaseSessionInterface 5 | 6 | try: 7 | from sanic_motor import BaseModel 8 | 9 | class _SessionModel(BaseModel): 10 | """Collection for session storing. 11 | 12 | Collection name (default session) 13 | 14 | Fields: 15 | sid 16 | expiry 17 | data: 18 | User's session data 19 | """ 20 | 21 | pass 22 | 23 | except ImportError: # pragma: no cover 24 | _SessionModel = None 25 | 26 | 27 | class MongoDBSessionInterface(BaseSessionInterface): 28 | def __init__( 29 | self, 30 | app, 31 | coll: str = "session", 32 | domain: str = None, 33 | expiry: int = 30 * 24 * 60 * 60, 34 | httponly: bool = True, 35 | cookie_name: str = "session", 36 | sessioncookie: bool = False, 37 | samesite: str = None, 38 | session_name: str = "session", 39 | secure: bool = False, 40 | ): 41 | """Initializes the interface for storing client sessions in MongoDB. 42 | 43 | Args: 44 | app (sanic.Sanic): 45 | Sanic instance to register listener('after_server_start') 46 | coll (str, optional): 47 | MongoDB collection name for session 48 | domain (str, optional): 49 | Optional domain which will be attached to the cookie. 50 | expiry (int, optional): 51 | Seconds until the session should expire. 52 | httponly (bool, optional): 53 | Adds the `httponly` flag to the session cookie. 54 | cookie_name (str, optional): 55 | Name used for the client cookie. 56 | sessioncookie (bool, optional): 57 | Specifies if the sent cookie should be a 'session cookie', i.e 58 | no Expires or Max-age headers are included. Expiry is still 59 | fully tracked on the server side. Default setting is False. 60 | samesite (str, optional): 61 | Will prevent the cookie from being sent by the browser to 62 | the target site in all cross-site browsing context, even when 63 | following a regular link. 64 | One of ('lax', 'strict') 65 | Default: None 66 | session_name (str, optional): 67 | Name of the session that will be accessible through the 68 | request. 69 | e.g. If ``session_name`` is ``alt_session``, it should be 70 | accessed like that: ``request.ctx.alt_session`` 71 | e.g. And if ``session_name`` is left to default, it should be 72 | accessed like that: ``request.ctx.session`` 73 | Default: 'session' 74 | secure (bool, optional): 75 | Adds the `Secure` flag to the session cookie. 76 | """ 77 | if _SessionModel is None: 78 | raise RuntimeError( 79 | "Please install Mongo dependencies: " 80 | "pip install sanic_session[mongo]" 81 | ) 82 | 83 | # prefix not needed for mongodb as mongodb uses uuid4 natively 84 | prefix = "" 85 | 86 | if httponly is not True: 87 | warnings.warn( 88 | """ 89 | httponly default arg has changed. 90 | To spare you some debugging time, httponly is currently 91 | hardcoded as True. This message will be removed with the 92 | next release. And ``httponly`` will no longer be hardcoded 93 | """, 94 | DeprecationWarning, 95 | ) 96 | 97 | super().__init__( 98 | expiry=expiry, 99 | prefix=prefix, 100 | cookie_name=cookie_name, 101 | domain=domain, 102 | # I'm gonna leave this as True because changing it might 103 | # be hazardous. But this should be changed to __init__'s 104 | # httponly kwarg instead of being hardcoded 105 | httponly=True, 106 | sessioncookie=sessioncookie, 107 | samesite=samesite, 108 | session_name=session_name, 109 | secure=secure, 110 | ) 111 | 112 | # set collection name 113 | _SessionModel.__coll__ = coll 114 | 115 | @app.listener("after_server_start") 116 | async def apply_session_indexes(app, loop): 117 | """Create indexes in session collection 118 | if doesn't exist. 119 | 120 | Indexes: 121 | sid: 122 | For faster lookup. 123 | expiry: 124 | For document expiration. 125 | """ 126 | await _SessionModel.create_index("sid") 127 | await _SessionModel.create_index("expiry", expireAfterSeconds=0) 128 | 129 | async def _get_value(self, prefix, key): 130 | value = await _SessionModel.find_one({"sid": key}, as_raw=True) 131 | return value["data"] if value else None 132 | 133 | async def _delete_key(self, key): 134 | await _SessionModel.delete_one({"sid": key}) 135 | 136 | async def _set_value(self, key, data): 137 | expiry = datetime.utcnow() + timedelta(seconds=self.expiry) 138 | await _SessionModel.replace_one( 139 | {"sid": key}, 140 | {"sid": key, "expiry": expiry, "data": data}, 141 | upsert=True, 142 | ) 143 | -------------------------------------------------------------------------------- /sanic_session/redis.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from sanic_session.base import BaseSessionInterface 4 | 5 | try: 6 | import asyncio_redis 7 | except ImportError: 8 | asyncio_redis = None 9 | 10 | 11 | class RedisSessionInterface(BaseSessionInterface): 12 | def __init__( 13 | self, 14 | redis_getter: Callable, 15 | domain: str = None, 16 | expiry: int = 2592000, 17 | httponly: bool = True, 18 | cookie_name: str = "session", 19 | prefix: str = "session:", 20 | sessioncookie: bool = False, 21 | samesite: str = None, 22 | session_name: str = "session", 23 | secure: bool = False, 24 | ): 25 | """Initializes a session interface backed by Redis. 26 | 27 | Args: 28 | redis_getter (Callable): 29 | Coroutine which should return an asyncio_redis connection pool 30 | (suggested) or an asyncio_redis Redis connection. 31 | domain (str, optional): 32 | Optional domain which will be attached to the cookie. 33 | expiry (int, optional): 34 | Seconds until the session should expire. 35 | httponly (bool, optional): 36 | Adds the `httponly` flag to the session cookie. 37 | cookie_name (str, optional): 38 | Name used for the client cookie. 39 | prefix (str, optional): 40 | Memcache keys will take the format of `prefix+session_id`; 41 | specify the prefix here. 42 | sessioncookie (bool, optional): 43 | Specifies if the sent cookie should be a 'session cookie', i.e 44 | no Expires or Max-age headers are included. Expiry is still 45 | fully tracked on the server side. Default setting is False. 46 | samesite (str, optional): 47 | Will prevent the cookie from being sent by the browser to the 48 | target site in all cross-site browsing context, even when 49 | following a regular link. 50 | One of ('lax', 'strict') 51 | Default: None 52 | session_name (str, optional): 53 | Name of the session that will be accessible through the 54 | request. 55 | e.g. If ``session_name`` is ``alt_session``, it should be 56 | accessed like that: ``request.ctx.alt_session`` 57 | e.g. And if ``session_name`` is left to default, it should be 58 | accessed like that: ``request.ctx.session`` 59 | Default: 'session' 60 | secure (bool, optional): 61 | Adds the `Secure` flag to the session cookie. 62 | """ 63 | if asyncio_redis is None: 64 | raise RuntimeError( 65 | "Please install asyncio_redis: pip install sanic_session[redis]" 66 | ) 67 | 68 | self.redis_getter = redis_getter 69 | 70 | super().__init__( 71 | expiry=expiry, 72 | prefix=prefix, 73 | cookie_name=cookie_name, 74 | domain=domain, 75 | httponly=httponly, 76 | sessioncookie=sessioncookie, 77 | samesite=samesite, 78 | session_name=session_name, 79 | secure=secure, 80 | ) 81 | 82 | async def _get_value(self, prefix, key): 83 | redis_connection = await self.redis_getter() 84 | return await redis_connection.get(prefix + key) 85 | 86 | async def _delete_key(self, key): 87 | redis_connection = await self.redis_getter() 88 | await redis_connection.delete([key]) 89 | 90 | async def _set_value(self, key, data): 91 | redis_connection = await self.redis_getter() 92 | await redis_connection.setex(key, self.expiry, data) 93 | -------------------------------------------------------------------------------- /sanic_session/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Union 3 | 4 | 5 | class _Missing(object): 6 | """ 7 | Copyright (c) 2015 by Armin Ronacher and contributors. See AUTHORS 8 | in FLASK_LICENSE for more details. 9 | """ 10 | 11 | def __repr__(self): 12 | return "no value" 13 | 14 | def __reduce__(self): 15 | return "_missing" 16 | 17 | 18 | _missing = _Missing() 19 | 20 | 21 | class UpdateDictMixin(object): 22 | """ 23 | Copyright (c) 2015 by Armin Ronacher and contributors. See AUTHORS 24 | in FLASK_LICENSE for more details. 25 | """ 26 | 27 | on_update = None 28 | 29 | def calls_update(name): 30 | def oncall(self, *args, **kw): 31 | rv = getattr(super(UpdateDictMixin, self), name)(*args, **kw) 32 | if self.on_update is not None: 33 | self.on_update(self) 34 | return rv 35 | 36 | oncall.__name__ = name 37 | return oncall 38 | 39 | def setdefault(self, key, default=None): 40 | modified = key not in self 41 | rv = super(UpdateDictMixin, self).setdefault(key, default) 42 | if modified and self.on_update is not None: 43 | self.on_update(self) 44 | return rv 45 | 46 | def pop(self, key, default=_missing): 47 | modified = key in self 48 | if default is _missing: 49 | rv = super(UpdateDictMixin, self).pop(key) 50 | else: 51 | rv = super(UpdateDictMixin, self).pop(key, default) 52 | if modified and self.on_update is not None: 53 | self.on_update(self) 54 | return rv 55 | 56 | __setitem__ = calls_update("__setitem__") 57 | __delitem__ = calls_update("__delitem__") 58 | clear = calls_update("clear") 59 | popitem = calls_update("popitem") 60 | update = calls_update("update") 61 | del calls_update 62 | 63 | 64 | class CallbackDict(UpdateDictMixin, dict): 65 | 66 | """A dict that calls a function passed every time something is changed. 67 | The function is passed the dict instance. 68 | 69 | Copyright (c) 2015 by Armin Ronacher and contributors. See AUTHORS 70 | in FLASK_LICENSE for more details. 71 | 72 | """ 73 | 74 | def __init__(self, initial=None, on_update=None): 75 | dict.__init__(self, initial or ()) 76 | self.on_update = on_update 77 | 78 | def __repr__(self): 79 | return "<%s %s>" % (self.__class__.__name__, dict.__repr__(self)) 80 | 81 | 82 | class ExpiringDict(dict): 83 | def __init__(self, prefix=""): 84 | self.prefix = prefix 85 | super().__init__() 86 | self.expiry_times = {} 87 | 88 | def set(self, key: Union[str, int], val: Any, expiry: int): 89 | self[key] = val 90 | self.expiry_times[key] = time.time() + expiry 91 | 92 | def get_by_sid(self, key: str): 93 | key = self.prefix + key 94 | return self.get(key) 95 | 96 | def get(self, key: Union[str, int]): 97 | data = dict(self).get(key) 98 | 99 | if not data: 100 | return None 101 | 102 | if time.time() > self.expiry_times[key]: 103 | del self[key] 104 | del self.expiry_times[key] 105 | return None 106 | 107 | return data 108 | 109 | def delete(self, key: Union[str, int]): 110 | del self[key] 111 | del self.expiry_times[key] 112 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | max-line-length=120 6 | 7 | [pep8] 8 | max-line-length=120 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | # Set requirements here 7 | requirements = ( 8 | "sanic", 9 | "ujson", 10 | ) 11 | 12 | extras_require = { 13 | "aioredis": ["aioredis>=1.0.0"], 14 | "redis": ["asyncio_redis"], 15 | "mongo": ["sanic_motor", "pymongo"], 16 | "aiomcache": ["aiomcache>=0.5.2"], 17 | "dev": [ 18 | "pytest", 19 | "aiohttp", 20 | "pytest-asyncio", 21 | "pytest-mock", 22 | "pytest-cov", 23 | "wheel", 24 | "black;python_version>='3.6'", 25 | "isort", 26 | "mypy", 27 | "sphinx", 28 | "sphinxcontrib-fulltoc", 29 | "flake8", 30 | ], 31 | } 32 | 33 | 34 | setup( 35 | name="sanic-session", 36 | version="0.8.0", 37 | description=( 38 | "Provides server-backed sessions for Sanic " 39 | "using Redis, Memcache and more." 40 | ), 41 | long_description=long_description, 42 | long_description_content_type="text/markdown", 43 | url="http://github.com/subyraman/sanic_session", 44 | author="Suby Raman, Mikhail Kashkin, Adam Hopkins", 45 | author_email="adam@amhopkins.com", 46 | license="MIT", 47 | packages=["sanic_session"], 48 | # Kludge: Specifying requirements for setup and install works around 49 | # problem with easyinstall finding sanic_motor instead of sanic. 50 | # See similar problem: 51 | # https://stackoverflow.com/questions/27497470/setuptools-finds-wrong-package-during-install 52 | # https://github.com/numpy/numpy/issues/2434 53 | setup_requires=requirements, 54 | install_requires=requirements, 55 | extras_require=extras_require, 56 | zip_safe=False, 57 | keywords=["sessions", "sanic", "redis", "memcache"], 58 | classifiers=[ 59 | "Framework :: AsyncIO", 60 | "Development Status :: 5 - Production/Stable", 61 | "License :: OSI Approved :: MIT License", 62 | "Programming Language :: Python :: 3.7", 63 | "Programming Language :: Python :: 3.8", 64 | "Programming Language :: Python :: 3.9", 65 | "Programming Language :: Python :: 3.10", 66 | "Programming Language :: Python :: 3.11", 67 | "Programming Language :: Python :: 3 :: Only", 68 | "Programming Language :: Python :: Implementation :: CPython", 69 | "Programming Language :: Python :: Implementation :: PyPy", 70 | "Topic :: Internet :: WWW/HTTP :: Session", 71 | ], 72 | ) 73 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahopkins/sanic-session/551de4b503ab1a595b3b2b07cbe08806508a043e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_in_memory_session_interface.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | import uuid 4 | 5 | import pytest 6 | import ujson 7 | from sanic.response import text 8 | 9 | from sanic_session.base import SessionDict 10 | from sanic_session.memory import InMemorySessionInterface 11 | 12 | SID = "5235262626" 13 | COOKIE_NAME = "cookie" 14 | COOKIES = {COOKIE_NAME: SID} 15 | 16 | 17 | @pytest.fixture 18 | def mock_dict(): 19 | class CtxMockDict(dict): 20 | pass 21 | 22 | class MockDict(dict): 23 | ctx = CtxMockDict() 24 | 25 | return MockDict 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_should_create_new_sid_if_no_cookie(mocker, mock_dict): 30 | request = mock_dict() 31 | request.cookies = {} 32 | 33 | mocker.spy(uuid, "uuid4") 34 | session_interface = InMemorySessionInterface() 35 | await session_interface.open(request) 36 | 37 | assert uuid.uuid4.call_count == 1, "should create a new SID with uuid" 38 | assert request.ctx.session == {}, "should return an empty dict as session" 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_should_return_data_from_session_store(mocker, mock_dict): 43 | request = mock_dict() 44 | 45 | request.cookies = COOKIES 46 | 47 | mocker.spy(uuid, "uuid4") 48 | data = {"foo": "bar"} 49 | 50 | session_interface = InMemorySessionInterface(cookie_name=COOKIE_NAME) 51 | session_interface.session_store.get = mocker.MagicMock( 52 | return_value=ujson.dumps(data) 53 | ) 54 | 55 | session = await session_interface.open(request) 56 | 57 | assert uuid.uuid4.call_count == 0, "should not create a new SID" 58 | assert ( 59 | session_interface.session_store.get.call_count == 1 60 | ), "should call on redis once" 61 | 62 | assert session_interface.session_store.get.call_args_list[0][0][ 63 | 0 64 | ] == "session:{}".format(SID), "should get from store with prefix + SID" 65 | 66 | assert session.get("foo") == "bar", "session data is pulled from store" 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_should_use_prefix_in_store_key(mocker, mock_dict): 71 | request = mock_dict() 72 | prefix = "differentprefix:" 73 | data = {"foo": "bar"} 74 | 75 | request.cookies = COOKIES 76 | 77 | session_interface = InMemorySessionInterface( 78 | cookie_name=COOKIE_NAME, prefix=prefix 79 | ) 80 | session_interface.session_store.get = mocker.MagicMock( 81 | return_value=ujson.dumps(data) 82 | ) 83 | await session_interface.open(request) 84 | 85 | assert session_interface.session_store.get.call_args_list[0][0][ 86 | 0 87 | ] == "{}{}".format(prefix, SID), "should call redis with prefix + SID" 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_should_use_return_empty_session_via_store(mocker, mock_dict): 92 | request = mock_dict() 93 | prefix = "differentprefix:" 94 | request.cookies = COOKIES 95 | 96 | session_interface = InMemorySessionInterface( 97 | cookie_name=COOKIE_NAME, prefix=prefix 98 | ) 99 | session_interface.session_store.get = mocker.MagicMock(return_value=None) 100 | 101 | session_interface = InMemorySessionInterface( 102 | cookie_name=COOKIE_NAME, prefix=prefix 103 | ) 104 | session = await session_interface.open(request) 105 | 106 | assert session == {} 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_should_attach_session_to_request(mocker, mock_dict): 111 | request = mock_dict() 112 | request.cookies = COOKIES 113 | 114 | session_interface = InMemorySessionInterface(cookie_name=COOKIE_NAME) 115 | session_interface.session_store.get = mocker.MagicMock(return_value=None) 116 | session = await session_interface.open(request) 117 | 118 | assert session == request.ctx.session 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_should_delete_session_from_store(mocker, mock_dict): 123 | request = mock_dict() 124 | request.cookies = COOKIES 125 | 126 | session_interface = InMemorySessionInterface(cookie_name=COOKIE_NAME) 127 | session_interface.session_store["session:{}".format(SID)] = "{foo:1}" 128 | session_interface.session_store.get = mocker.MagicMock(return_value=None) 129 | session_interface.session_store.delete = mocker.MagicMock() 130 | await session_interface.open(request) 131 | 132 | response = mocker.MagicMock() 133 | response.cookies = {} 134 | 135 | await session_interface.save(request, response) 136 | 137 | assert session_interface.session_store.delete.call_count == 1 138 | assert session_interface.session_store.delete.call_args_list[0][0][ 139 | 0 140 | ] == "session:{}".format(SID) 141 | assert response.cookies == {}, "should not change response cookies" 142 | 143 | 144 | @pytest.mark.asyncio 145 | async def test_should_expire_cookies_if_modified(mock_dict, mocker): 146 | request = mock_dict() 147 | request.cookies = COOKIES 148 | 149 | session_interface = InMemorySessionInterface(cookie_name=COOKIE_NAME) 150 | session_interface.session_store.get = mocker.MagicMock( 151 | return_value=ujson.dumps({"foo": "bar"}) 152 | ) 153 | session_interface.session_store.delete = mocker.MagicMock() 154 | 155 | await session_interface.open(request) 156 | response = text("foo") 157 | 158 | request.ctx.session.clear() 159 | await session_interface.save(request, response) 160 | assert response.cookies[COOKIE_NAME]["max-age"] == 0 161 | assert ( 162 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 163 | ) 164 | 165 | 166 | @pytest.mark.asyncio 167 | async def test_should_save_in_memory_for_time_specified(mock_dict, mocker): 168 | request = mock_dict() 169 | request.cookies = COOKIES 170 | 171 | session_interface = InMemorySessionInterface(cookie_name=COOKIE_NAME) 172 | session_interface.session_store.get = mocker.MagicMock( 173 | return_value=ujson.dumps({"foo": "bar"}) 174 | ) 175 | session_interface.session_store.set = mocker.MagicMock() 176 | 177 | await session_interface.open(request) 178 | response = text("foo") 179 | request.ctx.session["foo"] = "baz" 180 | await session_interface.save(request, response) 181 | 182 | session_interface.session_store.set.assert_called_with( 183 | "session:{}".format(SID), ujson.dumps(request.ctx.session), 2592000 184 | ) 185 | 186 | 187 | @pytest.mark.asyncio 188 | async def test_should_reset_cookie_expiry(mocker, mock_dict): 189 | response = text("foo") 190 | 191 | request = mock_dict() 192 | request.cookies = COOKIES 193 | mocker.patch("time.time") 194 | time.time.return_value = 1488576462.138493 195 | 196 | session_interface = InMemorySessionInterface(cookie_name=COOKIE_NAME) 197 | session_interface.session_store.get = mocker.MagicMock( 198 | return_value=ujson.dumps({"foo": "bar"}) 199 | ) 200 | session_interface.session_store.set = mocker.MagicMock() 201 | 202 | await session_interface.open(request) 203 | 204 | request.ctx.session["foo"] = "baz" 205 | await session_interface.save(request, response) 206 | 207 | assert response.cookies[COOKIE_NAME].value == SID 208 | assert response.cookies[COOKIE_NAME]["max-age"] == 2592000 209 | assert ( 210 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 211 | ) 212 | 213 | 214 | @pytest.mark.asyncio 215 | async def test_sessioncookie_should_omit_request_headers(mocker, mock_dict): 216 | response = text("foo") 217 | 218 | request = mock_dict() 219 | request.cookies = COOKIES 220 | 221 | session_interface = InMemorySessionInterface( 222 | cookie_name=COOKIE_NAME, sessioncookie=True 223 | ) 224 | session_interface.session_store.get = mocker.MagicMock( 225 | return_value=ujson.dumps({"foo": "bar"}) 226 | ) 227 | session_interface.session_store.set = mocker.MagicMock() 228 | 229 | await session_interface.open(request) 230 | await session_interface.save(request, response) 231 | 232 | assert "max-age" not in response.cookies[COOKIE_NAME] 233 | assert "expires" not in response.cookies[COOKIE_NAME] 234 | 235 | 236 | @pytest.mark.asyncio 237 | async def test_sessioncookie_delete_has_expiration_headers(mocker, mock_dict): 238 | response = text("foo") 239 | 240 | request = mock_dict() 241 | request.cookies = COOKIES 242 | 243 | session_interface = InMemorySessionInterface( 244 | cookie_name=COOKIE_NAME, sessioncookie=True 245 | ) 246 | session_interface.session_store.get = mocker.MagicMock( 247 | return_value=ujson.dumps({"foo": "bar"}) 248 | ) 249 | session_interface.session_store.set = mocker.MagicMock() 250 | 251 | await session_interface.open(request) 252 | await session_interface.save(request, response) 253 | request.ctx.session.clear() 254 | await session_interface.save(request, response) 255 | 256 | assert response.cookies[COOKIE_NAME]["max-age"] == 0 257 | assert ( 258 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 259 | ) 260 | 261 | 262 | @pytest.mark.asyncio 263 | async def test_samesite_dict_set_lax(mocker, mock_dict): 264 | SAMESITE = "lax" 265 | response = text("foo") 266 | 267 | request = mock_dict() 268 | request.cookies = COOKIES 269 | 270 | session_interface = InMemorySessionInterface( 271 | cookie_name=COOKIE_NAME, samesite=SAMESITE 272 | ) 273 | session_interface.session_store.get = mocker.MagicMock( 274 | return_value=ujson.dumps(dict(foo="bar")) 275 | ) 276 | session_interface.session_store.set = mocker.MagicMock() 277 | 278 | await session_interface.open(request) 279 | await session_interface.save(request, response) 280 | 281 | assert response.cookies[COOKIE_NAME]["samesite"] == SAMESITE 282 | 283 | 284 | @pytest.mark.asyncio 285 | async def test_samesite_dict_set_None(mocker, mock_dict): 286 | SAMESITE = None 287 | 288 | response = text("foo") 289 | 290 | request = mock_dict() 291 | request.cookies = COOKIES 292 | 293 | session_interface = InMemorySessionInterface( 294 | cookie_name=COOKIE_NAME, samesite=SAMESITE 295 | ) 296 | session_interface.session_store.get = mocker.MagicMock( 297 | return_value=ujson.dumps(dict(foo="bar")) 298 | ) 299 | session_interface.session_store.set = mocker.MagicMock() 300 | 301 | await session_interface.open(request) 302 | await session_interface.save(request, response) 303 | 304 | assert response.cookies[COOKIE_NAME].get("samesite") is SAMESITE 305 | 306 | 307 | @pytest.mark.asyncio 308 | async def test_two_sessions(mocker, mock_dict, event_loop): 309 | 310 | COOKIE_NAME_1 = "cookie_uno" 311 | COOKIE_NAME_2 = "cookie_dos" 312 | 313 | PREFIX_1 = "prefix_uno" 314 | PREFIX_2 = "prefix_dos" 315 | 316 | SESSION_NAME_1 = "session_uno" 317 | SESSION_NAME_2 = "session_dos" 318 | 319 | request = mock_dict() 320 | response = text("") 321 | request.cookies = {} 322 | 323 | session_interface_1 = InMemorySessionInterface( 324 | cookie_name=COOKIE_NAME_1, prefix=PREFIX_1, session_name=SESSION_NAME_1 325 | ) 326 | 327 | session_interface_2 = InMemorySessionInterface( 328 | cookie_name=COOKIE_NAME_2, prefix=PREFIX_2, session_name=SESSION_NAME_2 329 | ) 330 | 331 | await session_interface_1.open(request) 332 | await session_interface_1.save(request, response) 333 | await session_interface_2.open(request) 334 | await session_interface_2.save(request, response) 335 | 336 | assert isinstance(getattr(request.ctx, SESSION_NAME_1), SessionDict) 337 | assert isinstance(getattr(request.ctx, SESSION_NAME_2), SessionDict) 338 | 339 | assert getattr(request.ctx, SESSION_NAME_1) is not getattr( 340 | request.ctx, SESSION_NAME_2 341 | ) 342 | assert ( 343 | getattr(request.ctx, SESSION_NAME_1).sid 344 | != getattr(request.ctx, SESSION_NAME_2).sid 345 | ) 346 | -------------------------------------------------------------------------------- /tests/test_memcache_session_interface.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | import uuid 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | import ujson 8 | from sanic.response import text 9 | 10 | from sanic_session.memcache import MemcacheSessionInterface 11 | 12 | SID = "5235262626" 13 | COOKIE_NAME = "cookie" 14 | COOKIES = {COOKIE_NAME: SID} 15 | 16 | 17 | @pytest.fixture 18 | def mock_dict(): 19 | class CtxMockDict(dict): 20 | pass 21 | 22 | class MockDict(dict): 23 | ctx = CtxMockDict() 24 | 25 | return MockDict 26 | 27 | 28 | @pytest.fixture 29 | def mock_memcache(): 30 | class MockMemcacheConnection: 31 | pass 32 | 33 | return MockMemcacheConnection 34 | 35 | 36 | def mock_coroutine(return_value=None): 37 | async def mock_coro(*args, **kwargs): 38 | return return_value 39 | 40 | return Mock(wraps=mock_coro) 41 | 42 | 43 | async def get_interface_and_request(mocker, memcache_connection, data=None): 44 | request = mock_dict() 45 | request.cookies = COOKIES 46 | data = data or {} 47 | 48 | memcache_connection = mock_memcache() 49 | memcache_connection.get = mock_coroutine(ujson.dumps(data)) 50 | 51 | session_interface = MemcacheSessionInterface( 52 | memcache_connection, cookie_name=COOKIE_NAME 53 | ) 54 | await session_interface.open(request) 55 | 56 | return session_interface, request 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_memcache_should_create_new_sid_if_no_cookie( 61 | mocker, mock_memcache, mock_dict 62 | ): 63 | request = mock_dict() 64 | request.cookies = {} 65 | memcache_connection = mock_memcache() 66 | memcache_connection.get = mock_coroutine() 67 | 68 | mocker.spy(uuid, "uuid4") 69 | session_interface = MemcacheSessionInterface(memcache_connection) 70 | await session_interface.open(request) 71 | 72 | assert uuid.uuid4.call_count == 1, "should create a new SID with uuid" 73 | assert request.ctx.session == {}, "should return an empty dict as session" 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_should_return_data_from_memcache( 78 | mocker, mock_dict, mock_memcache 79 | ): 80 | request = mock_dict() 81 | 82 | request.cookies = COOKIES 83 | 84 | mocker.spy(uuid, "uuid4") 85 | data = {"foo": "bar"} 86 | 87 | memcache_connection = mock_memcache() 88 | memcache_connection.get = mock_coroutine(ujson.dumps(data).encode()) 89 | 90 | session_interface = MemcacheSessionInterface( 91 | memcache_connection, cookie_name=COOKIE_NAME 92 | ) 93 | session = await session_interface.open(request) 94 | 95 | assert uuid.uuid4.call_count == 0, "should not create a new SID" 96 | assert ( 97 | memcache_connection.get.call_count == 1 98 | ), "should call on memcache once" 99 | assert ( 100 | memcache_connection.get.call_args_list[0][0][0] 101 | == "session:{}".format(SID).encode() 102 | ), "should call memcache with prefix + SID" 103 | assert session.get("foo") == "bar", "session data is pulled from memcache" 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_should_use_prefix_in_memcache_key( 108 | mocker, mock_dict, mock_memcache 109 | ): 110 | request = mock_dict() 111 | prefix = "differentprefix:" 112 | data = {"foo": "bar"} 113 | 114 | request.cookies = COOKIES 115 | 116 | memcache_connection = mock_memcache 117 | memcache_connection.get = mock_coroutine(ujson.dumps(data).encode()) 118 | 119 | session_interface = MemcacheSessionInterface( 120 | memcache_connection, cookie_name=COOKIE_NAME, prefix=prefix 121 | ) 122 | await session_interface.open(request) 123 | 124 | assert ( 125 | memcache_connection.get.call_args_list[0][0][0] 126 | == "{}{}".format(prefix, SID).encode() 127 | ), "should call memcache with prefix + SID" 128 | 129 | 130 | @pytest.mark.asyncio 131 | async def test_should_use_return_empty_session_via_memcache( 132 | mock_memcache, mock_dict 133 | ): 134 | request = mock_dict() 135 | prefix = "differentprefix:" 136 | request.cookies = COOKIES 137 | 138 | memcache_connection = mock_memcache 139 | memcache_connection.get = mock_coroutine() 140 | 141 | session_interface = MemcacheSessionInterface( 142 | memcache_connection, cookie_name=COOKIE_NAME, prefix=prefix 143 | ) 144 | session = await session_interface.open(request) 145 | 146 | assert session == {} 147 | 148 | 149 | @pytest.mark.asyncio 150 | async def test_should_attach_session_to_request(mock_memcache, mock_dict): 151 | request = mock_dict() 152 | request.cookies = COOKIES 153 | 154 | memcache_connection = mock_memcache 155 | memcache_connection.get = mock_coroutine() 156 | 157 | session_interface = MemcacheSessionInterface( 158 | memcache_connection, cookie_name=COOKIE_NAME 159 | ) 160 | session = await session_interface.open(request) 161 | 162 | assert session == request.ctx.session 163 | 164 | 165 | @pytest.mark.asyncio 166 | async def test_should_delete_session_from_memcache( 167 | mocker, mock_memcache, mock_dict 168 | ): 169 | request = mock_dict() 170 | response = mock_dict() 171 | request.cookies = COOKIES 172 | response.cookies = {} 173 | 174 | memcache_connection = mock_memcache 175 | memcache_connection.get = mock_coroutine() 176 | memcache_connection.delete = mock_coroutine() 177 | 178 | session_interface = MemcacheSessionInterface( 179 | memcache_connection, cookie_name=COOKIE_NAME 180 | ) 181 | 182 | await session_interface.open(request) 183 | await session_interface.save(request, response) 184 | 185 | assert memcache_connection.delete.call_count == 1 186 | assert ( 187 | memcache_connection.delete.call_args_list[0][0][0] 188 | == "session:{}".format(SID).encode() 189 | ) 190 | assert response.cookies == {}, "should not change response cookies" 191 | 192 | 193 | @pytest.mark.asyncio 194 | async def test_should_expire_memcache_cookies_if_modified( 195 | mock_dict, mock_memcache 196 | ): 197 | request = mock_dict() 198 | response = text("foo") 199 | request.cookies = COOKIES 200 | 201 | memcache_connection = mock_memcache 202 | memcache_connection.get = mock_coroutine() 203 | memcache_connection.delete = mock_coroutine() 204 | 205 | session_interface = MemcacheSessionInterface( 206 | memcache_connection, cookie_name=COOKIE_NAME 207 | ) 208 | 209 | await session_interface.open(request) 210 | 211 | request.ctx.session.clear() 212 | await session_interface.save(request, response) 213 | assert response.cookies[COOKIE_NAME]["max-age"] == 0 214 | assert ( 215 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 216 | ) 217 | 218 | 219 | @pytest.mark.asyncio 220 | async def test_should_save_in_memcache_for_time_specified( 221 | mock_dict, mock_memcache 222 | ): 223 | request = mock_dict() 224 | request.cookies = COOKIES 225 | memcache_connection = mock_memcache 226 | memcache_connection.get = mock_coroutine( 227 | ujson.dumps({"foo": "bar"}).encode() 228 | ) 229 | memcache_connection.set = mock_coroutine() 230 | response = text("foo") 231 | 232 | session_interface = MemcacheSessionInterface( 233 | memcache_connection, cookie_name=COOKIE_NAME 234 | ) 235 | 236 | await session_interface.open(request) 237 | 238 | request.ctx.session["foo"] = "baz" 239 | await session_interface.save(request, response) 240 | 241 | memcache_connection.set.assert_called_with( 242 | "session:{}".format(SID).encode(), 243 | ujson.dumps(request.ctx.session).encode(), 244 | exptime=2592000, 245 | ) 246 | 247 | 248 | @pytest.mark.asyncio 249 | async def test_should_reset_cookie_expiry(mocker, mock_dict, mock_memcache): 250 | request = mock_dict() 251 | request.cookies = COOKIES 252 | memcache_connection = mock_memcache 253 | memcache_connection.get = mock_coroutine( 254 | ujson.dumps({"foo": "bar"}).encode() 255 | ) 256 | memcache_connection.set = mock_coroutine() 257 | response = text("foo") 258 | mocker.patch("time.time") 259 | time.time.return_value = 1488576462.138493 260 | 261 | session_interface = MemcacheSessionInterface( 262 | memcache_connection, cookie_name=COOKIE_NAME 263 | ) 264 | 265 | await session_interface.open(request) 266 | request.ctx.session["foo"] = "baz" 267 | await session_interface.save(request, response) 268 | 269 | assert response.cookies[COOKIE_NAME].value == SID 270 | assert response.cookies[COOKIE_NAME]["max-age"] == 2592000 271 | assert ( 272 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 273 | ) 274 | 275 | 276 | @pytest.mark.asyncio 277 | async def test_sessioncookie_should_omit_request_headers( 278 | mocker, mock_dict, mock_memcache 279 | ): 280 | request = mock_dict() 281 | request.cookies = COOKIES 282 | memcache_connection = mock_memcache 283 | memcache_connection.get = mock_coroutine( 284 | ujson.dumps({"foo": "bar"}).encode() 285 | ) 286 | memcache_connection.set = mock_coroutine() 287 | memcache_connection.delete = mock_coroutine() 288 | response = text("foo") 289 | 290 | session_interface = MemcacheSessionInterface( 291 | memcache_connection, cookie_name=COOKIE_NAME, sessioncookie=True 292 | ) 293 | 294 | await session_interface.open(request) 295 | await session_interface.save(request, response) 296 | 297 | assert "max-age" not in response.cookies[COOKIE_NAME] 298 | assert "expires" not in response.cookies[COOKIE_NAME] 299 | 300 | 301 | @pytest.mark.asyncio 302 | async def test_sessioncookie_delete_has_expiration_headers( 303 | mocker, mock_dict, mock_memcache 304 | ): 305 | request = mock_dict() 306 | request.cookies = COOKIES 307 | memcache_connection = mock_memcache 308 | memcache_connection.get = mock_coroutine( 309 | ujson.dumps({"foo": "bar"}).encode() 310 | ) 311 | memcache_connection.set = mock_coroutine() 312 | memcache_connection.delete = mock_coroutine() 313 | response = text("foo") 314 | 315 | session_interface = MemcacheSessionInterface( 316 | memcache_connection, cookie_name=COOKIE_NAME, sessioncookie=True 317 | ) 318 | 319 | await session_interface.open(request) 320 | await session_interface.save(request, response) 321 | request.ctx.session.clear() 322 | await session_interface.save(request, response) 323 | 324 | assert response.cookies[COOKIE_NAME]["max-age"] == 0 325 | assert ( 326 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 327 | ) 328 | -------------------------------------------------------------------------------- /tests/test_redis_session_interface.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | import uuid 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | import ujson 8 | from sanic.response import text 9 | 10 | from sanic_session.redis import RedisSessionInterface 11 | 12 | SID = "5235262626" 13 | COOKIE_NAME = "cookie" 14 | COOKIES = {COOKIE_NAME: SID} 15 | 16 | 17 | @pytest.fixture 18 | def mock_dict(): 19 | class CtxMockDict(dict): 20 | pass 21 | 22 | class MockDict(dict): 23 | ctx = CtxMockDict() 24 | 25 | return MockDict 26 | 27 | 28 | @pytest.fixture 29 | def mock_redis(): 30 | class MockRedisConnection: 31 | pass 32 | 33 | return MockRedisConnection 34 | 35 | 36 | def mock_coroutine(return_value=None): 37 | async def mock_coro(*args, **kwargs): 38 | return return_value 39 | 40 | return Mock(wraps=mock_coro) 41 | 42 | 43 | async def get_interface_and_request(mocker, redis_getter, data=None): 44 | request = mock_dict() 45 | request.cookies = COOKIES 46 | data = data or {} 47 | 48 | redis_connection = mock_redis() 49 | redis_connection.get = mock_coroutine(ujson.dumps(data)) 50 | redis_getter = mock_coroutine(redis_connection) 51 | 52 | session_interface = RedisSessionInterface( 53 | redis_getter, cookie_name=COOKIE_NAME 54 | ) 55 | await session_interface.open(request) 56 | 57 | return session_interface, request 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_redis_should_create_new_sid_if_no_cookie( 62 | mocker, mock_redis, mock_dict 63 | ): 64 | request = mock_dict() 65 | request.cookies = {} 66 | redis_connection = mock_redis() 67 | redis_connection.get = mock_coroutine() 68 | redis_getter = mock_coroutine(redis_connection) 69 | 70 | mocker.spy(uuid, "uuid4") 71 | session_interface = RedisSessionInterface(redis_getter) 72 | await session_interface.open(request) 73 | 74 | assert uuid.uuid4.call_count == 1, "should create a new SID with uuid" 75 | assert request.ctx.session == {}, "should return an empty dict as session" 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_should_return_data_from_redis(mocker, mock_dict, mock_redis): 80 | request = mock_dict() 81 | 82 | request.cookies = COOKIES 83 | 84 | mocker.spy(uuid, "uuid4") 85 | data = {"foo": "bar"} 86 | 87 | redis_connection = mock_redis() 88 | redis_connection.get = mock_coroutine(ujson.dumps(data)) 89 | redis_getter = mock_coroutine(redis_connection) 90 | 91 | session_interface = RedisSessionInterface( 92 | redis_getter, cookie_name=COOKIE_NAME 93 | ) 94 | session = await session_interface.open(request) 95 | 96 | assert uuid.uuid4.call_count == 0, "should not create a new SID" 97 | assert redis_connection.get.call_count == 1, "should call on redis once" 98 | assert redis_connection.get.call_args_list[0][0][0] == "session:{}".format( 99 | SID 100 | ), "should call redis with prefix + SID" 101 | assert session.get("foo") == "bar", "session data is pulled from redis" 102 | 103 | 104 | @pytest.mark.asyncio 105 | async def test_should_use_prefix_in_redis_key(mocker, mock_dict, mock_redis): 106 | request = mock_dict() 107 | prefix = "differentprefix:" 108 | data = {"foo": "bar"} 109 | 110 | request.cookies = COOKIES 111 | 112 | redis_connection = mock_redis 113 | redis_connection.get = mock_coroutine(ujson.dumps(data)) 114 | redis_getter = mock_coroutine(redis_connection) 115 | 116 | session_interface = RedisSessionInterface( 117 | redis_getter, cookie_name=COOKIE_NAME, prefix=prefix 118 | ) 119 | await session_interface.open(request) 120 | 121 | assert redis_connection.get.call_args_list[0][0][0] == "{}{}".format( 122 | prefix, SID 123 | ), "should call redis with prefix + SID" 124 | 125 | 126 | @pytest.mark.asyncio 127 | async def test_should_use_return_empty_session_via_redis( 128 | mock_redis, mock_dict 129 | ): 130 | request = mock_dict() 131 | prefix = "differentprefix:" 132 | request.cookies = COOKIES 133 | 134 | redis_connection = mock_redis 135 | redis_connection.get = mock_coroutine() 136 | redis_getter = mock_coroutine(redis_connection) 137 | 138 | session_interface = RedisSessionInterface( 139 | redis_getter, cookie_name=COOKIE_NAME, prefix=prefix 140 | ) 141 | session = await session_interface.open(request) 142 | 143 | assert session == {} 144 | 145 | 146 | @pytest.mark.asyncio 147 | async def test_should_attach_session_to_request(mock_redis, mock_dict): 148 | request = mock_dict() 149 | request.cookies = COOKIES 150 | 151 | redis_connection = mock_redis 152 | redis_connection.get = mock_coroutine() 153 | redis_getter = mock_coroutine(redis_connection) 154 | 155 | session_interface = RedisSessionInterface( 156 | redis_getter, redis_connection, cookie_name=COOKIE_NAME 157 | ) 158 | session = await session_interface.open(request) 159 | 160 | assert session == request.ctx.session 161 | 162 | 163 | @pytest.mark.asyncio 164 | async def test_should_delete_session_from_redis(mocker, mock_redis, mock_dict): 165 | request = mock_dict() 166 | response = mock_dict() 167 | request.cookies = COOKIES 168 | response.cookies = {} 169 | 170 | redis_connection = mock_redis 171 | redis_connection.get = mock_coroutine() 172 | redis_connection.delete = mock_coroutine() 173 | redis_getter = mock_coroutine(redis_connection) 174 | 175 | session_interface = RedisSessionInterface( 176 | redis_getter, cookie_name=COOKIE_NAME 177 | ) 178 | 179 | await session_interface.open(request) 180 | await session_interface.save(request, response) 181 | 182 | assert redis_connection.delete.call_count == 1 183 | assert redis_connection.delete.call_args_list[0][0][0] == [ 184 | "session:{}".format(SID) 185 | ] 186 | assert response.cookies == {}, "should not change response cookies" 187 | 188 | 189 | @pytest.mark.asyncio 190 | async def test_should_expire_redis_cookies_if_modified(mock_dict, mock_redis): 191 | request = mock_dict() 192 | response = text("foo") 193 | request.cookies = COOKIES 194 | 195 | redis_connection = mock_redis 196 | redis_connection.get = mock_coroutine() 197 | redis_connection.delete = mock_coroutine() 198 | redis_getter = mock_coroutine(redis_connection) 199 | 200 | session_interface = RedisSessionInterface( 201 | redis_getter, cookie_name=COOKIE_NAME 202 | ) 203 | 204 | await session_interface.open(request) 205 | 206 | request.ctx.session.clear() 207 | await session_interface.save(request, response) 208 | assert response.cookies[COOKIE_NAME]["max-age"] == 0 209 | assert ( 210 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 211 | ) 212 | 213 | 214 | @pytest.mark.asyncio 215 | async def test_should_save_in_redis_for_time_specified(mock_dict, mock_redis): 216 | request = mock_dict() 217 | request.cookies = COOKIES 218 | redis_connection = mock_redis 219 | redis_connection.get = mock_coroutine(ujson.dumps({"foo": "bar"})) 220 | redis_connection.setex = mock_coroutine() 221 | redis_getter = mock_coroutine(redis_connection) 222 | response = text("foo") 223 | 224 | session_interface = RedisSessionInterface( 225 | redis_getter, cookie_name=COOKIE_NAME 226 | ) 227 | 228 | await session_interface.open(request) 229 | 230 | request.ctx.session["foo"] = "baz" 231 | await session_interface.save(request, response) 232 | 233 | redis_connection.setex.assert_called_with( 234 | "session:{}".format(SID), 2592000, ujson.dumps(request.ctx.session) 235 | ) 236 | 237 | 238 | @pytest.mark.asyncio 239 | async def test_should_reset_cookie_expiry(mocker, mock_dict, mock_redis): 240 | request = mock_dict() 241 | request.cookies = COOKIES 242 | redis_connection = mock_redis 243 | redis_connection.get = mock_coroutine(ujson.dumps({"foo": "bar"})) 244 | redis_connection.setex = mock_coroutine() 245 | redis_getter = mock_coroutine(redis_connection) 246 | response = text("foo") 247 | mocker.patch("time.time") 248 | time.time.return_value = 1488576462.138493 249 | 250 | session_interface = RedisSessionInterface( 251 | redis_getter, cookie_name=COOKIE_NAME 252 | ) 253 | 254 | await session_interface.open(request) 255 | request.ctx.session["foo"] = "baz" 256 | await session_interface.save(request, response) 257 | 258 | assert response.cookies[COOKIE_NAME].value == SID 259 | assert response.cookies[COOKIE_NAME]["max-age"] == 2592000 260 | assert ( 261 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 262 | ) 263 | 264 | 265 | @pytest.mark.asyncio 266 | async def test_sessioncookie_should_omit_request_headers(mocker, mock_dict): 267 | request = mock_dict() 268 | request.cookies = COOKIES 269 | redis_connection = mock_redis 270 | redis_connection.get = mock_coroutine(ujson.dumps({"foo": "bar"})) 271 | redis_connection.delete = mock_coroutine() 272 | redis_connection.setex = mock_coroutine() 273 | redis_getter = mock_coroutine(redis_connection) 274 | response = text("foo") 275 | 276 | session_interface = RedisSessionInterface( 277 | redis_getter, cookie_name=COOKIE_NAME, sessioncookie=True 278 | ) 279 | 280 | await session_interface.open(request) 281 | await session_interface.save(request, response) 282 | 283 | assert response.cookies[COOKIE_NAME].value == SID 284 | assert "max-age" not in response.cookies[COOKIE_NAME] 285 | assert "expires" not in response.cookies[COOKIE_NAME] 286 | 287 | 288 | @pytest.mark.asyncio 289 | async def test_sessioncookie_delete_has_expiration_headers(mocker, mock_dict): 290 | request = mock_dict() 291 | request.cookies = COOKIES 292 | redis_connection = mock_redis 293 | redis_connection.get = mock_coroutine(ujson.dumps({"foo": "bar"})) 294 | redis_connection.delete = mock_coroutine() 295 | redis_connection.setex = mock_coroutine() 296 | redis_getter = mock_coroutine(redis_connection) 297 | response = text("foo") 298 | 299 | session_interface = RedisSessionInterface( 300 | redis_getter, cookie_name=COOKIE_NAME, sessioncookie=True 301 | ) 302 | 303 | await session_interface.open(request) 304 | await session_interface.save(request, response) 305 | request.ctx.session.clear() 306 | await session_interface.save(request, response) 307 | 308 | assert response.cookies[COOKIE_NAME]["max-age"] == 0 309 | assert ( 310 | response.cookies[COOKIE_NAME]["expires"] < datetime.datetime.utcnow() 311 | ) 312 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from sanic_session.utils import ExpiringDict 2 | 3 | 4 | def test_sets_expiry_internally(): 5 | e = ExpiringDict() 6 | e.set("foo", "bar", 300) 7 | assert e.expiry_times["foo"] is not None 8 | 9 | 10 | def test_returns_value_if_before_expiry(): 11 | e = ExpiringDict() 12 | e.set("foo", "bar", 300) 13 | assert e.get("foo") is not None 14 | 15 | 16 | def test_expires_value_if_after_expiry(): 17 | e = ExpiringDict() 18 | e.set("foo", "bar", 300) 19 | e.expiry_times["foo"] = 0 20 | 21 | assert e.get("foo") is None 22 | assert e.expiry_times.get("foo") is None 23 | 24 | 25 | def test_deletes_values(): 26 | e = ExpiringDict() 27 | e.set("foo", "bar", 300) 28 | e.delete("foo") 29 | 30 | assert e.get("foo") is None 31 | assert e.expiry_times.get("foo") is None 32 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py311, py310, py39, py38, py37 3 | 4 | [testenv:syntax] 5 | deps = 6 | flake8 7 | black 8 | whitelist_externals = make 9 | commands = 10 | make lint 11 | 12 | 13 | [testenv] 14 | deps= 15 | .[aiomcache] 16 | .[redis] 17 | .[memcached] 18 | .[msgpack] 19 | .[dev] 20 | whitelist_externals = 21 | make 22 | commands = 23 | make test 24 | --------------------------------------------------------------------------------