├── .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 | [](https://sanic-session.readthedocs.io)
3 | [](https://opensource.org/licenses/MIT)
4 | [](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 |
--------------------------------------------------------------------------------