Browse Source

Initial revision

with-prefix
Jacob Rief 8 years ago
commit
648a7bbaa3
26 changed files with 1565 additions and 0 deletions
  1. +10
    -0
      .gitignore
  2. +24
    -0
      .travis-ci
  3. +22
    -0
      LICENSE.txt
  4. +31
    -0
      README.md
  5. +153
    -0
      docs/Makefile
  6. +242
    -0
      docs/conf.py
  7. +19
    -0
      docs/index.rst
  8. +54
    -0
      docs/installation.rst
  9. +1
    -0
      examples/chatserver/__init__.py
  10. +1
    -0
      examples/chatserver/models.py
  11. +59
    -0
      examples/chatserver/settings.py
  12. +34
    -0
      examples/chatserver/templates/chat.html
  13. +2
    -0
      examples/chatserver/tests/__init__.py
  14. +10
    -0
      examples/chatserver/tests/chatclient.py
  15. +8
    -0
      examples/chatserver/urls.py
  16. +12
    -0
      examples/manage.py
  17. +16
    -0
      examples/wsgi.py
  18. +37
    -0
      setup.py
  19. +3
    -0
      ws4redis/__init__.py
  20. +84
    -0
      ws4redis/django_runserver.py
  21. +27
    -0
      ws4redis/exceptions.py
  22. +8
    -0
      ws4redis/settings.py
  23. +128
    -0
      ws4redis/utf8validator.py
  24. +55
    -0
      ws4redis/uwsgi_runserver.py
  25. +387
    -0
      ws4redis/websocket.py
  26. +138
    -0
      ws4redis/wsgi_server.py

+ 10
- 0
.gitignore View File

@ -0,0 +1,10 @@
*.pyc
*.egg-info
*.coverage
*~
.tmp*
build
docs/_build
dist
htmlcov
/node_modules/

+ 24
- 0
.travis-ci View File

@ -0,0 +1,24 @@
language: python
python:
- 2.7
env:
- DJANGO=1.5.5
- DJANGO=1.6.0
branches:
except:
- devel
install:
- pip install -q Django==$DJANGO --use-mirrors
- pip install pyquery --use-mirrors
- python setup.py -q install
before_script:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
script:
- cd examples && ./manage.py test chatserver

+ 22
- 0
LICENSE.txt View File

@ -0,0 +1,22 @@
Copyright (c) 2013 Jacob Rief
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

+ 31
- 0
README.md View File

@ -0,0 +1,31 @@
django-websocket-redis
======================
Add Websocket support for Django using Redis for message queuing
----------------------------------------------------------------
You can find detailed documentation on [ReadTheDocs](http://django-websocket-redis.readthedocs.org/en/latest/).
Features
--------
* Largely scalable for Django applications with hundreds of open websocket connections.
* Runs in a cooperative concurrency model, thus only one thread/process is simultaneously required
for all open websockets.
* Full control over the main loop during development, so Django can be started as usual with
``./manage.py runserver``.
* No dependencies to any other micro-framework, such as Tornado, Flask or Bottle.
* The only additional requirement is a running instance of Redis.
Build status
------------
.. image:: https://travis-ci.org/jrief/django-websocket-redis.png
:target: https://travis-ci.org/jrief/django-websocket-redis
License
-------
Copyright (c) 2013 Jacob Rief
Licensed under the MIT license.
Release History
---------------
* 0.1.0 - initial revision

+ 153
- 0
docs/Makefile View File

@ -0,0 +1,153 @@
# Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = _build
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
clean:
-rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-websocket-redis.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-websocket-redis.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-websocket-redis"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-websocket-redis"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."

+ 242
- 0
docs/conf.py View File

@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
#
# django-websocket-redis documentation build configuration file, created by
# sphinx-quickstart on Fri Dec 13 09:43:01 2013.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'django-websocket-redis'
copyright = u'2013, Jacob Rief'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.1'
# The full version, including alpha/beta/rc tags.
release = '0.1.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-websocket-redisdoc'
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'django-websocket-redis.tex', u'django-websocket-redis Documentation',
u'Jacob Rief', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'django-websocket-redis', u'django-websocket-redis Documentation',
[u'Jacob Rief'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'django-websocket-redis', u'django-websocket-redis Documentation',
u'Jacob Rief', 'django-websocket-redis', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'

+ 19
- 0
docs/index.rst View File

@ -0,0 +1,19 @@
.. django-websocket-redis documentation master file
Welcome to django-websocket-redis documentation
===============================================
Contents:
.. toctree::
installation
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

+ 54
- 0
docs/installation.rst View File

@ -0,0 +1,54 @@
.. _installation_and_configuration:
Installation and Configuration
==============================
Getting the latest release
--------------------------
Installation
------------
If not already done, install Redis_, using your operation systems tools such as ``aptitude``,
``yum``, ``port`` or `install Redis from source`_.
Start the Redis service
The latest stable release as found on PyPI::
pip install django-websocket-redis
or the newest development from github::
pip install -e git+https://github.com/jrief/django-websocket-redis#egg=django-websocket-redis
Dependencies
------------
* `Django`_ >=1.5
* `Redis`_
Configuration
-------------
Add ``"djangular"`` to your project's ``INSTALLED_APPS`` setting, and make sure that static files
are found in external Django apps::
INSTALLED_APPS = (
...
'djangular',
...
)
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
...
)
.. note:: **django-angular** does not define any database models. It can therefore easily be
installed without any database synchronization.
.. _github: https://github.com/jrief/django-angular
.. _Django: http://djangoproject.com/
.. _AngularJS: http://angularjs.org/
.. _pip: http://pypi.python.org/pypi/pip

+ 1
- 0
examples/chatserver/__init__.py View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

+ 1
- 0
examples/chatserver/models.py View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

+ 59
- 0
examples/chatserver/settings.py View File

@ -0,0 +1,59 @@
# Django settings for unit test project.
DEBUG = True
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'test.sqlite',
},
}
SITE_ID = 1
ROOT_URLCONF = 'chatserver.urls'
SECRET_KEY = 'super.secret'
# Absolute path to the directory that holds media.
# Example: "/home/media/media.lawrence.com/"
MEDIA_ROOT = ''
# URL that handles the media served from MEDIA_ROOT. Make sure to use a
# trailing slash if there is a path component (optional in other cases).
# Examples: "http://media.lawrence.com", "http://example.com/media/"
MEDIA_URL = ''
# Absolute path to the directory that holds static files.
# Example: "/home/media/media.lawrence.com/static/"
STATIC_ROOT = '/home/static/'
# URL that handles the static files served from STATIC_ROOT.
# Example: "http://media.lawrence.com/static/"
STATIC_URL = '/static/'
# List of callables that know how to import templates from various sources.
TEMPLATE_LOADERS = (
'django.template.loaders.app_directories.Loader',
)
INSTALLED_APPS = (
'django.contrib.contenttypes',
'django.contrib.staticfiles',
'ws4redis',
'chatserver',
)
# These two middleware classes must be present, if messages sent or received through a websocket
# connection shall be delivered to an authenticated Django user.
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
)
# This setting is required to override the Django's main loop, when running in
# development mode, such as ./manage runserver
WSGI_APPLICATION = 'ws4redis.django_runserver.application'
# URL that distinguishes websocket connections from normal requests
WEBSOCKET_URL = '/ws/'

+ 34
- 0
examples/chatserver/templates/chat.html View File

@ -0,0 +1,34 @@
<html>
<head>
<script language="Javascript">
var ws = new WebSocket('{{ ws_url }}', ['Subscribe-User', 'Publish-User']);
ws.onopen = function() {
console.log("connected !!!");
ws.send("Hello Websocket");
};
ws.onmessage = function(e) {
var bb = document.getElementById('blackboard')
var html = bb.innerHTML;
bb.innerHTML = html + '<br/>' + e.data;
};
ws.onerror = function(e) {
console.error(e);
};
ws.onclose = function(e) {
console.log("connection closed");
}
function send_message() {
var value = document.getElementById('text_message').value;
ws.send(value);
}
</script>
</head>
<body>
<h1>Simple chat using django-websocket-redis</h1>
<input type="text" id="text_message" />
<input type="button" value="Send message" onClick="send_message();" />
<div id="blackboard"
style="width: 640px; height: 480px; background-color: black; color: white; border: solid 2px red; overflow: auto">
</div>
</body>
</html>

+ 2
- 0
examples/chatserver/tests/__init__.py View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from chatclient import *

+ 10
- 0
examples/chatserver/tests/chatclient.py View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from django.test import TestCase
class TestCase1(TestCase):
def setUp(self):
pass
def test_1(self):
self.assertTrue(True)

+ 8
- 0
examples/chatserver/urls.py View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from django.conf.urls import url, patterns
from django.shortcuts import render_to_response
urlpatterns = patterns('',
url(r'^chat/$', lambda r: render_to_response('chat.html', { 'ws_url': 'ws://localhost:8000/ws/foobar' })),
)

+ 12
- 0
examples/manage.py View File

@ -0,0 +1,12 @@
#!/usr/bin/env python
import os
import sys
sys.path.insert(0, os.path.abspath('./../'))
if __name__ == "__main__":
os.environ.setdefault('DJANGO_SETTINGS_MODULE', "chatserver.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

+ 16
- 0
examples/wsgi.py View File

@ -0,0 +1,16 @@
#! /usr/bin/env uwsgi --virtualenv path/to/virtualenv --http :9090 --gevent 100 --http-websockets --module wsgi
import os
from django.core.wsgi import get_wsgi_application
from django.conf import settings
from ws4redis.uwsgi_runserver import uWSGIWebsocketServer
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chatserver.settings')
_django_app = get_wsgi_application()
_websocket_app = uWSGIWebsocketServer()
def application(environ, start_response):
if environ.get('PATH_INFO').startswith(settings.WEBSOCKET_URL):
return _websocket_app(environ, start_response)
return _django_app(environ, start_response)

+ 37
- 0
setup.py View File

@ -0,0 +1,37 @@
import os
from setuptools import setup, find_packages
from ws4redis import __version__
DESCRIPTION = 'Websocket support for Django using Redis as datastore'
CLASSIFIERS = [
'Environment :: Web Environment',
'Framework :: Django',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Topic :: Software Development :: Libraries :: Python Modules',
'Development Status :: 4 - Beta',
]
def read(fname):
return open(os.path.join(os.path.dirname(__file__), fname)).read()
setup(
name='django-websocket-redis',
version=__version__,
author='Jacob Rief',
author_email='jacob.rief@gmail.com',
description=DESCRIPTION,
long_description=read('README.rst'),
url='https://github.com/jrief/django-websocket-redis',
license='MIT',
keywords = ['django', 'websocket', 'redis'],
platforms=['OS Independent'],
classifiers=CLASSIFIERS,
install_requires=['Django>=1.5'],
packages=find_packages(exclude=['examples', 'docs']),
include_package_data=True,
)

+ 3
- 0
ws4redis/__init__.py View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '0.1.0'
from ws4redis import settings

+ 84
- 0
ws4redis/django_runserver.py View File

@ -0,0 +1,84 @@
#-*- coding: utf-8 -*-
import base64
import select
from hashlib import sha1
from wsgiref import util
from django.core.wsgi import get_wsgi_application
from django.core.servers.basehttp import WSGIServer, WSGIRequestHandler
from django.core.handlers.wsgi import logger
from django.conf import settings
from django.core.management.commands import runserver
from django.utils.six.moves import socketserver
from django.utils.encoding import force_str
from djangular.ws4redis.websocket import WebSocket
from djangular.ws4redis.wsgi_server import WebsocketWSGIServer, HandshakeError, UpgradeRequiredError
util._hoppish = {}.__contains__
class WebsocketRunServer(WebsocketWSGIServer):
WS_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
WS_VERSIONS = ('13', '8', '7')
def upgrade_websocket(self, environ, start_response):
"""
Attempt to upgrade the socket environ['wsgi.input'] into a websocket enabled connection.
"""
websocket_version = environ.get('HTTP_SEC_WEBSOCKET_VERSION', '')
if not websocket_version:
raise UpgradeRequiredError
elif websocket_version not in self.WS_VERSIONS:
raise HandshakeError('Unsupported WebSocket Version: {0}'.format(websocket_version))
key = environ.get("HTTP_SEC_WEBSOCKET_KEY", '').strip()
if not key:
raise HandshakeError('Sec-WebSocket-Key header is missing/empty')
try:
key_len = len(base64.b64decode(key))
except TypeError:
raise HandshakeError('Invalid key: {0}'.format(key))
if key_len != 16:
# 5.2.1 (3)
raise HandshakeError('Invalid key: {0}'.format(key))
headers = [
('Upgrade', 'websocket'),
('Connection', 'Upgrade'),
('Sec-WebSocket-Accept', base64.b64encode(sha1(key + self.WS_GUID).digest())),
('Sec-WebSocket-Version', str(websocket_version)),
('Sec-WebSocket-Protocol', self.agreed_protocols[0])
]
logger.debug('WebSocket request accepted, switching protocols')
start_response(force_str('101 Switching Protocols'), headers)
start_response.im_self.finish_content()
return WebSocket(environ['wsgi.input'])
def select(self, rlist, wlist, xlist, timeout=None):
return select.select(rlist, wlist, xlist, timeout)
def run(addr, port, wsgi_handler, ipv6=False, threading=False):
"""
Function to monkey patch the internal Django command: manage.py runserver
"""
logger.info('Websocket support is enabled')
server_address = (addr, port)
if not threading:
raise Exception('Django\'s Websocket server must run with threading enabled')
httpd_cls = type(str('WSGIServer'), (socketserver.ThreadingMixIn, WSGIServer), { 'daemon_threads': True })
httpd = httpd_cls(server_address, WSGIRequestHandler, ipv6=ipv6)
httpd.set_app(wsgi_handler)
httpd.serve_forever()
runserver.run = run
_django_app = get_wsgi_application()
_websocket_app = WebsocketRunServer()
_websocket_url = getattr(settings, 'WEBSOCKET_URL')
def application(environ, start_response):
if _websocket_url and environ.get('PATH_INFO').startswith(_websocket_url):
return _websocket_app(environ, start_response)
return _django_app(environ, start_response)

+ 27
- 0
ws4redis/exceptions.py View File

@ -0,0 +1,27 @@
#-*- coding: utf-8 -*-
from socket import error as socket_error
from django.http import BadHeaderError
class WebSocketError(socket_error):
"""
Raised when an active websocket encounters a problem.
"""
class FrameTooLargeException(WebSocketError):
"""
Raised if a received frame is too large.
"""
class HandshakeError(BadHeaderError):
"""
Raised if an error occurs during protocol handshake.
"""
class UpgradeRequiredError(HandshakeError):
"""
Raised if protocol must be upgraded.
"""

+ 8
- 0
ws4redis/settings.py View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from django.conf import BaseSettings
from django.conf import settings as django_settings
WS4REDIS_HOST = 'localhost'
WS4REDIS_PORT = 6379

+ 128
- 0
ws4redis/utf8validator.py View File

@ -0,0 +1,128 @@
###############################################################################
##
## Copyright 2011-2013 Tavendo GmbH
##
## Note:
##
## This code is a Python implementation of the algorithm
##
## "Flexible and Economical UTF-8 Decoder"
##
## by Bjoern Hoehrmann
##
## bjoern@hoehrmann.de
## http://bjoern.hoehrmann.de/utf-8/decoder/dfa/
##
## Licensed under the Apache License, Version 2.0 (the "License");
## you may not use this file except in compliance with the License.
## You may obtain a copy of the License at
##
## http://www.apache.org/licenses/LICENSE-2.0
##
## Unless required by applicable law or agreed to in writing, software
## distributed under the License is distributed on an "AS IS" BASIS,
## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
## See the License for the specific language governing permissions and
## limitations under the License.
##
###############################################################################
## use Cython implementation of UTF8 validator if available
##
try:
from wsaccel.utf8validator import Utf8Validator
except:
## fallback to pure Python implementation
class Utf8Validator:
"""
Incremental UTF-8 validator with constant memory consumption (minimal
state).
Implements the algorithm "Flexible and Economical UTF-8 Decoder" by
Bjoern Hoehrmann (http://bjoern.hoehrmann.de/utf-8/decoder/dfa/).
"""
## DFA transitions
UTF8VALIDATOR_DFA = [
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, # 00..1f
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, # 20..3f
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, # 40..5f
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, # 60..7f
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, # 80..9f
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, # a0..bf
8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, # c0..df
0xa,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x4,0x3,0x3, # e0..ef
0xb,0x6,0x6,0x6,0x5,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8, # f0..ff
0x0,0x1,0x2,0x3,0x5,0x8,0x7,0x1,0x1,0x1,0x4,0x6,0x1,0x1,0x1,0x1, # s0..s0
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,1, # s1..s2
1,2,1,1,1,1,1,2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1, # s3..s4
1,2,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,3,1,3,1,1,1,1,1,1, # s5..s6
1,3,1,1,1,1,1,3,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1, # s7..s8
]
UTF8_ACCEPT = 0
UTF8_REJECT = 1
def __init__(self):
self.reset()
def decode(self, b):
"""
Eat one UTF-8 octet, and validate on the fly.
Returns UTF8_ACCEPT when enough octets have been consumed, in which case
self.codepoint contains the decoded Unicode code point.
Returns UTF8_REJECT when invalid UTF-8 was encountered.
Returns some other positive integer when more octets need to be eaten.
"""
type = Utf8Validator.UTF8VALIDATOR_DFA[b]
if self.state != Utf8Validator.UTF8_ACCEPT:
self.codepoint = (b & 0x3f) | (self.codepoint << 6)
else:
self.codepoint = (0xff >> type) & b
self.state = Utf8Validator.UTF8VALIDATOR_DFA[256 + self.state * 16 + type]
return self.state
def reset(self):
"""
Reset validator to start new incremental UTF-8 decode/validation.
"""
self.state = Utf8Validator.UTF8_ACCEPT
self.codepoint = 0
self.i = 0
def validate(self, ba):
"""
Incrementally validate a chunk of bytes provided as string.
Will return a quad (valid?, endsOnCodePoint?, currentIndex, totalIndex).
As soon as an octet is encountered which renders the octet sequence
invalid, a quad with valid? == False is returned. currentIndex returns
the index within the currently consumed chunk, and totalIndex the
index within the total consumed sequence that was the point of bail out.
When valid? == True, currentIndex will be len(ba) and totalIndex the
total amount of consumed bytes.
"""
l = len(ba)
for i in xrange(l):
## optimized version of decode(), since we are not interested in actual code points
self.state = Utf8Validator.UTF8VALIDATOR_DFA[256 + (self.state << 4) + Utf8Validator.UTF8VALIDATOR_DFA[ord(ba[i])]]
if self.state == Utf8Validator.UTF8_REJECT:
self.i += i
return False, False, i, self.i
self.i += l
return True, self.state == Utf8Validator.UTF8_ACCEPT, l, self.i

+ 55
- 0
ws4redis/uwsgi_runserver.py View File

@ -0,0 +1,55 @@
#-*- coding: utf-8 -*-
#!/Users/jrief/Workspace/virtualenvs/awesto/bin/uwsgi --virtualenv ~/Workspace/virtualenvs/awesto --http-socket :9090 --gevent 100 --module chat --http-websockets
#!./uwsgi --http-socket :9090 --gevent 100 --module tests.websocket_chat --gevent-monkey-patch
# uwsgi --virtualenv ~/Workspace/virtualenvs/awesto --http :9090 --http-raw-body --gevent 100 --module chat (läuft in ein timeout)
# uwsgi --virtualenv ~/Workspace/virtualenvs/awesto --http-socket :9090 --gevent 100 --module websocket --http-websockets (de-facto das gleiche wie --http)
# uwsgi --virtualenv ~/Workspace/virtualenvs/awesto --http :9090 --http-websockets --gevent 100 --module websocket
import uwsgi
import gevent.select
from djangular.ws4redis.exceptions import WebSocketError
from djangular.ws4redis.wsgi_server import WebsocketWSGIServer
class uWSGIWebsocket(object):
def __init__(self):
self._closed = False
def get_file_descriptor(self):
"""Return the file descriptor for the given websocket"""
try:
return uwsgi.connection_fd()
except IOError, e:
self.close()
raise WebSocketError(e)
@property
def closed(self):
return self._closed
def receive(self):
if self._closed:
raise WebSocketError("Connection is already closed")
try:
return uwsgi.websocket_recv_nb()
except IOError, e:
self.close()
raise WebSocketError(e)
def send(self, message, binary=None):
try:
uwsgi.websocket_send(message)
except IOError, e:
self.close()
raise WebSocketError(e)
def close(self):
self._closed = True
class uWSGIWebsocketServer(WebsocketWSGIServer):
def upgrade_websocket(self, environ, start_response):
uwsgi.websocket_handshake(environ['HTTP_SEC_WEBSOCKET_KEY'], environ.get('HTTP_ORIGIN', ''))
return uWSGIWebsocket()
def select(self, rlist, wlist, xlist, timeout=None):
return gevent.select.select(rlist, wlist, xlist, timeout)

+ 387
- 0
ws4redis/websocket.py View File

@ -0,0 +1,387 @@
#-*- coding: utf-8 -*-
# This code was generously pilfered from https://bitbucket.org/Jeffrey/gevent-websocket
# written by Jeffrey Gelens (http://noppo.pro/) and licensed under the Apache License, Version 2.0
import struct
from socket import error as socket_error
from utf8validator import Utf8Validator
from django.core.handlers.wsgi import logger
from djangular.ws4redis.exceptions import WebSocketError, FrameTooLargeException
class WebSocket(object):
__slots__ = ('_closed', 'stream', 'utf8validator', 'utf8validate_last')
OPCODE_CONTINUATION = 0x00
OPCODE_TEXT = 0x01
OPCODE_BINARY = 0x02
OPCODE_CLOSE = 0x08
OPCODE_PING = 0x09
OPCODE_PONG = 0x0a
def __init__(self, wsgi_input):
self._closed = False
self.stream = Stream(wsgi_input)
self.utf8validator = Utf8Validator()
def __del__(self):
try:
self.close()
except:
# close() may fail if __init__ didn't complete
pass
def _decode_bytes(self, bytestring):
"""
Internal method used to convert the utf-8 encoded bytestring into unicode.
If the conversion fails, the socket will be closed.
"""
if not bytestring:
return u''
try:
return bytestring.decode('utf-8')
except UnicodeDecodeError:
self.close(1007)
raise
def _encode_bytes(self, text):
"""
:returns: The utf-8 byte string equivalent of `text`.
"""
if isinstance(text, str):
return text
if not isinstance(text, unicode):
text = unicode(text or '')
return text.encode('utf-8')
def _is_valid_close_code(self, code):
"""
:returns: Whether the returned close code is a valid hybi return code.
"""
if code < 1000:
return False
if 1004 <= code <= 1006:
return False
if 1012 <= code <= 1016:
return False
if code == 1100:
# not sure about this one but the autobahn fuzzer requires it.
return False
if 2000 <= code <= 2999:
return False
return True
def get_file_descriptor(self):
"""Return the file descriptor for the given websocket"""
return self.stream.fileno
@property
def closed(self):
return self._closed
def handle_close(self, header, payload):
"""
Called when a close frame has been decoded from the stream.
:param header: The decoded `Header`.
:param payload: The bytestring payload associated with the close frame.
"""
if not payload:
self.close(1000, None)
return
if len(payload) < 2:
raise WebSocketError('Invalid close frame: {0} {1}'.format(header, payload))
code = struct.unpack('!H', str(payload[:2]))[0]
payload = payload[2:]
if payload:
validator = Utf8Validator()
val = validator.validate(payload)
if not val[0]:
raise UnicodeError
if not self._is_valid_close_code(code):
raise WebSocketError('Invalid close code {0}'.format(code))
self.close(code, payload)
def handle_ping(self, header, payload):
self.send_frame(payload, self.OPCODE_PONG)
def handle_pong(self, header, payload):
pass
def read_frame(self):
"""
Block until a full frame has been read from the socket.
This is an internal method as calling this will not cleanup correctly
if an exception is called. Use `receive` instead.
:return: The header and payload as a tuple.
"""
header = Header.decode_header(self.stream)
if header.flags:
raise WebSocketError
if not header.length:
return header, ''
try:
payload = self.stream.read(header.length)
except socket_error:
payload = ''
except Exception:
# TODO log out this exception
payload = ''
if len(payload) != header.length:
raise WebSocketError('Unexpected EOF reading frame payload')
if header.mask:
payload = header.unmask_payload(payload)
return header, payload
def validate_utf8(self, payload):
# Make sure the frames are decodable independently
self.utf8validate_last = self.utf8validator.validate(payload)
if not self.utf8validate_last[0]:
raise UnicodeError("Encountered invalid UTF-8 while processing "
"text message at payload octet index "
"{0:d}".format(self.utf8validate_last[3]))
def read_message(self):
"""
Return the next text or binary message from the socket.
This is an internal method as calling this will not cleanup correctly
if an exception is called. Use `receive` instead.
"""
opcode = None
message = ""
while True:
header, payload = self.read_frame()
f_opcode = header.opcode
if f_opcode in (self.OPCODE_TEXT, self.OPCODE_BINARY):
# a new frame
if opcode:
raise WebSocketError("The opcode in non-fin frame is expected to be zero, got {0!r}".format(f_opcode))
# Start reading a new message, reset the validator
self.utf8validator.reset()
self.utf8validate_last = (True, True, 0, 0)
opcode = f_opcode
elif f_opcode == self.OPCODE_CONTINUATION:
if not opcode:
raise WebSocketError("Unexpected frame with opcode=0")
elif f_opcode == self.OPCODE_PING:
self.handle_ping(header, payload)
continue
elif f_opcode == self.OPCODE_PONG:
self.handle_pong(header, payload)
continue
elif f_opcode == self.OPCODE_CLOSE:
self.handle_close(header, payload)
return
else:
raise WebSocketError("Unexpected opcode={0!r}".format(f_opcode))
if opcode == self.OPCODE_TEXT:
self.validate_utf8(payload)
message += payload
if header.fin:
break
if opcode == self.OPCODE_TEXT:
self.validate_utf8(message)
return message
else:
return bytearray(message)
def receive(self):
"""
Read and return a message from the stream. If `None` is returned, then
the socket is considered closed/errored.
"""
if self._closed:
raise WebSocketError("Connection is already closed")
try:
return self.read_message()
except UnicodeError:
self.close(1007)
except WebSocketError:
self.close(1002)
# except socket_error:
# raise
def send_frame(self, message, opcode):
"""
Send a frame over the websocket with message as its payload
"""
if self._closed:
raise WebSocketError("Connection is already closed")
if opcode == self.OPCODE_TEXT:
message = self._encode_bytes(message)
elif opcode == self.OPCODE_BINARY:
message = str(message)
header = Header.encode_header(True, opcode, '', len(message), 0)
try:
self.stream.write(header + message)
except socket_error:
raise WebSocketError("Socket is dead")
def send(self, message, binary=False):
"""
Send a frame over the websocket with message as its payload
"""
if binary is None:
binary = not isinstance(message, (str, unicode))
opcode = self.OPCODE_BINARY if binary else self.OPCODE_TEXT
try:
self.send_frame(message, opcode)
except WebSocketError:
raise WebSocketError("Socket is dead")
def close(self, code=1000, message=''):
"""
Close the websocket and connection, sending the specified code and
message. The underlying socket object is _not_ closed, that is the
responsibility of the initiator.
"""
try:
message = self._encode_bytes(message)
self.send_frame(
struct.pack('!H%ds' % len(message), code, message),
opcode=self.OPCODE_CLOSE)
except WebSocketError:
# Failed to write the closing frame but it's ok because we're
# closing the socket anyway.
logger.debug("Failed to write closing frame -> closing socket")
finally:
logger.debug("Closed WebSocket")
self._closed = True
self.stream = None
class Stream(object):
"""
Wraps the handler's socket/rfile attributes and makes it in to a file like
object that can be read from/written to by the lower level websocket api.
"""
__slots__ = ('read', 'write', 'fileno')
def __init__(self, wsgi_input):
self.read = wsgi_input._sock.recv
self.write = wsgi_input._sock.sendall
self.fileno = wsgi_input._sock.fileno()
class Header(object):
__slots__ = ('fin', 'mask', 'opcode', 'flags', 'length')
FIN_MASK = 0x80
OPCODE_MASK = 0x0f
MASK_MASK = 0x80
LENGTH_MASK = 0x7f
RSV0_MASK = 0x40
RSV1_MASK = 0x20
RSV2_MASK = 0x10
# bitwise mask that will determine the reserved bits for a frame header
HEADER_FLAG_MASK = RSV0_MASK | RSV1_MASK | RSV2_MASK
def __init__(self, fin=0, opcode=0, flags=0, length=0):
self.mask = ''
self.fin = fin
self.opcode = opcode
self.flags = flags
self.length = length
def mask_payload(self, payload):
payload = bytearray(payload)
mask = bytearray(self.mask)
for i in xrange(self.length):
payload[i] ^= mask[i % 4]
return str(payload)
# it's the same operation
unmask_payload = mask_payload
def __repr__(self):
return ("<Header fin={0} opcode={1} length={2} flags={3} at "
"0x{4:x}>").format(self.fin, self.opcode, self.length,
self.flags, id(self))
@classmethod
def decode_header(cls, stream):
"""
Decode a WebSocket header.
:param stream: A file like object that can be 'read' from.
:returns: A `Header` instance.
"""
read = stream.read
data = read(2)
if len(data) != 2:
raise WebSocketError("Unexpected EOF while decoding header")
first_byte, second_byte = struct.unpack('!BB', data)
header = cls(
fin=first_byte & cls.FIN_MASK == cls.FIN_MASK,
opcode=first_byte & cls.OPCODE_MASK,
flags=first_byte & cls.HEADER_FLAG_MASK,
length=second_byte & cls.LENGTH_MASK)
has_mask = second_byte & cls.MASK_MASK == cls.MASK_MASK
if header.opcode > 0x07:
if not header.fin:
raise WebSocketError('Received fragmented control frame: {0!r}'.format(data))
# Control frames MUST have a payload length of 125 bytes or less
if header.length > 125:
raise FrameTooLargeException('Control frame cannot be larger than 125 bytes: {0!r}'.format(data))
if header.length == 126:
# 16 bit length
data = read(2)
if len(data) != 2:
raise WebSocketError('Unexpected EOF while decoding header')
header.length = struct.unpack('!H', data)[0]
elif header.length == 127:
# 64 bit length
data = read(8)
if len(data) != 8:
raise WebSocketError('Unexpected EOF while decoding header')
header.length = struct.unpack('!Q', data)[0]
if has_mask:
mask = read(4)
if len(mask) != 4:
raise WebSocketError('Unexpected EOF while decoding header')
header.mask = mask
return header
@classmethod
def encode_header(cls, fin, opcode, mask, length, flags):
"""
Encodes a WebSocket header.
:param fin: Whether this is the final frame for this opcode.
:param opcode: The opcode of the payload, see `OPCODE_*`
:param mask: Whether the payload is masked.
:param length: The length of the frame.
:param flags: The RSV* flags.
:return: A bytestring encoded header.
"""
first_byte = opcode
second_byte = 0
extra = ''
if fin:
first_byte |= cls.FIN_MASK
if flags & cls.RSV0_MASK:
first_byte |= cls.RSV0_MASK
if flags & cls.RSV1_MASK:
first_byte |= cls.RSV1_MASK
if flags & cls.RSV2_MASK:
first_byte |= cls.RSV2_MASK
# now deal with length complexities
if length < 126:
second_byte += length
elif length <= 0xffff:
second_byte += 126
extra = struct.pack('!H', length)
elif length <= 0xffffffffffffffff:
second_byte += 127
extra = struct.pack('!Q', length)
else:
raise FrameTooLargeException
if mask:
second_byte |= cls.MASK_MASK
extra += mask
return chr(first_byte) + chr(second_byte) + extra

+ 138
- 0
ws4redis/wsgi_server.py View File

@ -0,0 +1,138 @@
#-*- coding: utf-8 -*-
import sys
import redis
from django.core.handlers.wsgi import WSGIRequest, logger, STATUS_CODE_TEXT
from django.conf import settings
from django.http import HttpResponse, HttpResponseServerError, HttpResponseBadRequest
from django.utils.encoding import force_str
from django.utils.importlib import import_module
from django.utils.functional import SimpleLazyObject
from djangular.ws4redis.exceptions import WebSocketError, HandshakeError, UpgradeRequiredError
class RedisContext(object):
subscription_protocols = ['subscribe-session', 'subscribe-user', 'subscribe-broadcast']
publish_protocols = ['publish-session', 'publish-user', 'publish-broadcast']
def __init__(self):
self._connection = redis.StrictRedis(host='localhost', port=6379, db=0)
self._subscription = None
def subscribe_channels(self, request, agreed_protocols):
def subscribe(prefix):
key = request.path_info.replace(settings.WEBSOCKET_URL, prefix, 1)
self._subscription.subscribe(key)
def publish_on(prefix):
key = request.path_info.replace(settings.WEBSOCKET_URL, prefix, 1)
self._publishers.add(key)
agreed_protocols = [p.lower() for p in agreed_protocols]
self._subscription = self._connection.pubsub()
self._publishers = set()
# subscribe to these Redis channels for outgoing messages
if 'subscribe-session' in agreed_protocols:
subscribe('{0}:'.format(request.COOKIES.get(settings.SESSION_COOKIE_NAME)))
if 'subscribe-user' in agreed_protocols and request.user:
subscribe('{0}:'.format(request.user))
if 'subscribe-broadcast' in agreed_protocols:
subscribe('broadcast:')
# publish incoming messages on these Redis channels
if 'publish-session' in agreed_protocols:
publish_on('{0}:'.format(request.COOKIES.get(settings.SESSION_COOKIE_NAME)))
if 'publish-user' in agreed_protocols and request.user:
publish_on('{0}:'.format(request.user))
if 'publish-broadcast' in agreed_protocols:
publish_on('broadcast:')
def publish_message(self, message):
"""Publish a message on the subscribed channels in the Redis database"""
if message:
for channel in self._publishers:
self._connection.publish(channel, message)
def parse_response(self):
"""Parse a message response sent by the Redis database on a subscribed channels"""
return self._subscription.parse_response()
def get_file_descriptor(self):
return self._subscription.connection._sock.fileno()
class WebsocketWSGIServer(object):
allowed_subprotocols = RedisContext.subscription_protocols + RedisContext.publish_protocols
def assure_protocol_requirements(self, environ):
if environ.get('REQUEST_METHOD') != 'GET':
raise HandshakeError('HTTP method must be a GET')
if environ.get('SERVER_PROTOCOL') != 'HTTP/1.1':
raise HandshakeError('HTTP server protocol must be 1.1')
if environ.get('HTTP_UPGRADE', '').lower() != 'websocket':
raise HandshakeError('Client does not wish to upgrade to a websocket')
requested_protocols = [p.strip() for p in environ.get('HTTP_SEC_WEBSOCKET_PROTOCOL', '').split(',')]
self.agreed_protocols = [p for p in requested_protocols if p.lower() in self.allowed_subprotocols]
if not self.agreed_protocols:
raise HandshakeError('Missing Subprotocol, must be one of {0}'.format(', '.join(self.allowed_subprotocols)))
def process_request(self, request):
if 'django.contrib.sessions.middleware.SessionMiddleware' and 'django.contrib.auth.middleware.AuthenticationMiddleware' in settings.MIDDLEWARE_CLASSES:
from django.contrib.auth import get_user
engine = import_module(settings.SESSION_ENGINE)
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME, None)
request.session = engine.SessionStore(session_key)
request.user = SimpleLazyObject(lambda: get_user(request))
else:
request.session = None
request.user = None
def __call__(self, environ, start_response):
""" Hijack the main loop from the original thread and listen on events on Redis and Websockets"""
websocket = None
redis_context = RedisContext()
try:
self.assure_protocol_requirements(environ)
request = WSGIRequest(environ)
self.process_request(request)
websocket = self.upgrade_websocket(environ, start_response)
redis_context.subscribe_channels(request, self.agreed_protocols)
websocket_fd = websocket.get_file_descriptor()
redis_fd = redis_context.get_file_descriptor()
while websocket and not websocket.closed:
ready = self.select([websocket_fd, redis_fd], [], [])[0]
for fd in ready:
if fd == websocket_fd:
message = websocket.receive()
redis_context.publish_message(message)
elif fd == redis_fd:
response = redis_context.parse_response()
if response[0] == 'message':
message = response[2]
websocket.send(message)
else:
logger.error('Invalid file descriptor: {0}'.format(fd))
except WebSocketError, excpt:
logger.warning('WebSocketError: ', exc_info=sys.exc_info())
response = HttpResponse(status=1001, content='Websocket Closed')
except UpgradeRequiredError:
logger.info('Websocket upgrade required')
response = HttpResponseBadRequest(status=426, content=excpt)
except HandshakeError, excpt:
logger.warning('HandshakeError: ', exc_info=sys.exc_info())
response = HttpResponseBadRequest(content=excpt)
except Exception, excpt:
logger.error('Other Exception: ', exc_info=sys.exc_info())
response = HttpResponseServerError(content=excpt)
else:
response = HttpResponse()
if websocket:
websocket.close(code=1001, message='Websocket Closed')
if not start_response.im_self.headers_sent:
status_text = STATUS_CODE_TEXT.get(response.status_code, 'UNKNOWN STATUS CODE')
status = '{0} {1}'.format(response.status_code, status_text)
start_response(force_str(status), response._headers.values())
return response

Loading…
Cancel
Save