├── tests ├── __init__.py ├── test_pulse.py └── test_app.py ├── pulse ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── pulse.py ├── __init__.py └── wsgi.py ├── requirements ├── hard.txt └── dev.txt ├── notes ├── 0.1.md └── 0.2.md ├── manage.py ├── ci ├── install.sh └── tag.sh ├── MANIFEST.in ├── .coveragerc ├── djchat ├── __init__.py ├── urls.py ├── static │ └── djchat │ │ ├── chat.css │ │ └── chat.js ├── templates │ ├── base.html │ └── home.html ├── settings.py └── views.py ├── setup.cfg ├── Makefile ├── .gitignore ├── LICENSE.txt ├── setup.py ├── .circleci └── config.yml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pulse/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pulse/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/hard.txt: -------------------------------------------------------------------------------- 1 | pulsar 2 | django 3 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | codecov 3 | flake8 4 | twine 5 | -------------------------------------------------------------------------------- /pulse/__init__.py: -------------------------------------------------------------------------------- 1 | """django command to serve sites with pulsar asynchronous framework""" 2 | __version__ = '0.3.0' 3 | -------------------------------------------------------------------------------- /notes/0.1.md: -------------------------------------------------------------------------------- 1 | ## Ver. 0.1.1 - 2016-Aug-04 2 | 3 | * First release as independent package 4 | * Works with django 1.10 and pulsar 1.3 5 | 6 | -------------------------------------------------------------------------------- /notes/0.2.md: -------------------------------------------------------------------------------- 1 | ## Ver. 0.2.0 - 2017-Jan-21 2 | 3 | Full control of wsgi callable when ``WSGI_APPLICATION`` is specified in the 4 | settings file. 5 | 6 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Run the djchat example 3 | """ 4 | if __name__ == "__main__": 5 | from djchat import server 6 | server() 7 | -------------------------------------------------------------------------------- /ci/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pip install --upgrade pip wheel 4 | pip install --upgrade setuptools 5 | pip install -r requirements/hard.txt 6 | pip install -r requirements/dev.txt 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include LICENSE 3 | include README.rst 4 | include Makefile 5 | include manage.py 6 | graft requirements 7 | graft djchat 8 | graft tests 9 | graft pulse 10 | global-exclude *.pyc 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = pulse,tests,djchat 3 | concurrency = multiprocessing 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | (?i)# *pragma[: ]*no *cover 9 | raise NotImplementedError 10 | -------------------------------------------------------------------------------- /djchat/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from django.core.management import execute_from_command_line 5 | 6 | 7 | def server(argv=None): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djchat.settings") 9 | execute_from_command_line(argv or sys.argv) 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = clean sdist bdist_wheel upload 3 | test = pulsar_test 4 | 5 | [pep8] 6 | exclude = .git,.idea,.eggs,__pycache__,dist,build,venv 7 | 8 | [flake8] 9 | exclude = .git,.idea,.eggs,__pycache__,dist,build,venv 10 | 11 | [pulsar_test] 12 | test_modules = tests 13 | test_timeout = 60 14 | -------------------------------------------------------------------------------- /ci/tag.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | VERSION="$(python setup.py --version)" 4 | echo ${VERSION} 5 | 6 | git config --global user.email "bot@quantmind.com" 7 | git config --global user.username "qmbot" 8 | git config --global user.name "Quantmind Bot" 9 | git push 10 | git tag -am "Release $VERSION [ci skip]" ${VERSION} 11 | git push --tags 12 | -------------------------------------------------------------------------------- /djchat/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 4 | 5 | from .views import home 6 | 7 | 8 | urlpatterns = [ 9 | url(r'^$', home), 10 | url(r'^admin/', admin.site.urls), 11 | ] 12 | 13 | urlpatterns.extend(staticfiles_urlpatterns()) 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean test coverage 2 | 3 | 4 | PYTHON ?= python 5 | PIP ?= pip 6 | 7 | clean: 8 | rm -fr dist/ *.egg-info *.eggs .eggs build/ 9 | find . -name '__pycache__' | xargs rm -rf 10 | 11 | test: 12 | flake8 13 | $(PYTHON) -W ignore setup.py test -q --sequential 14 | 15 | coverage: 16 | $(PYTHON) -W ignore setup.py test --coverage -q 17 | -------------------------------------------------------------------------------- /tests/test_pulse.py: -------------------------------------------------------------------------------- 1 | """Tests the pulse Command.""" 2 | import unittest 3 | 4 | from pulsar.apps import wsgi 5 | from pulse.management.commands.pulse import Command 6 | 7 | 8 | class pulseCommandTest(unittest.TestCase): 9 | 10 | def test_pulse(self): 11 | cmnd = Command() 12 | hnd = cmnd.handle(dryrun=True) 13 | self.assertTrue(isinstance(hnd, wsgi.LazyWsgi)) 14 | -------------------------------------------------------------------------------- /djchat/static/djchat/chat.css: -------------------------------------------------------------------------------- 1 | .panel { 2 | background: #fff; 3 | border: solid 1px #E6E6E6; 4 | } 5 | 6 | .main { 7 | padding: 60px 0; 8 | } 9 | 10 | #users { 11 | height: 400px; 12 | } 13 | #messages { 14 | height: 100%; 15 | min-height: 620px; 16 | } 17 | #messages p { 18 | padding: 10px 5px; 19 | margin: 0; 20 | color: #666666; 21 | border-bottom: 1px solid #F2F2F2; 22 | } 23 | 24 | #input textarea { 25 | border: solid 1px #E6E6E6; 26 | } 27 | .layout { 28 | margin: 20px auto; 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyd 3 | *.so 4 | *.o 5 | *.def 6 | 7 | # Python 8 | __pycache__ 9 | build 10 | docs/build 11 | dist 12 | htmlcov 13 | htmlprof 14 | venv 15 | .coverage 16 | .coveralls-repo-token 17 | MANIFEST 18 | .settings 19 | .python-version 20 | release-notes.md 21 | extensions/lib/lib.c 22 | examples/tweets/settings.py 23 | 24 | # Javascript 25 | examples/node_modules 26 | examples/.grunt 27 | 28 | # IDE 29 | .DS_Store 30 | .project 31 | .pydevproject 32 | .idea 33 | 34 | # Extensions 35 | *~ 36 | .htmlprof 37 | test.pid 38 | *.egg-info 39 | .eggs 40 | .coveralls.yml 41 | *.sqlite3 42 | *.rdb 43 | *.log 44 | *.mp4 45 | /config.py 46 | -------------------------------------------------------------------------------- /djchat/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pulsar-django chat application 7 | 8 | 9 | 10 | 11 | {% block content %}{% endblock %} 12 | 13 | 14 | 15 | {% block script %}{% endblock %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /pulse/wsgi.py: -------------------------------------------------------------------------------- 1 | from pulsar.apps.wsgi import (LazyWsgi, WsgiHandler, 2 | wait_for_body_middleware, 3 | middleware_in_executor) 4 | from pulsar.utils.importer import module_attribute 5 | 6 | 7 | class Wsgi(LazyWsgi): 8 | '''The Wsgi middleware used by the django ``pulse`` command 9 | ''' 10 | cfg = None 11 | 12 | def setup(self, environ=None): 13 | '''Set up the :class:`.WsgiHandler` the first time this 14 | middleware is accessed. 15 | ''' 16 | from django.conf import settings 17 | from django.core.wsgi import get_wsgi_application 18 | # 19 | try: 20 | dotted = settings.WSGI_APPLICATION 21 | except AttributeError: # pragma nocover 22 | dotted = None 23 | if dotted: 24 | return module_attribute(dotted)() 25 | else: 26 | app = middleware_in_executor(get_wsgi_application()) 27 | return WsgiHandler((wait_for_body_middleware, app)) 28 | -------------------------------------------------------------------------------- /djchat/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 16 |
17 |
18 |
19 |
20 |
21 | 22 | 23 |
24 |

In the chatroom

25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 |
33 |
34 | {% endblock content %} 35 | 36 | {% block script %} 37 | 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Quantmind 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, 7 | this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, 9 | this list of conditions and the following disclaimer in the documentation 10 | and/or other materials provided with the distribution. 11 | * Neither the name of the author nor the names of its contributors 12 | may be used to endorse or promote products derived from this software without 13 | specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 18 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 19 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 20 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 21 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 22 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 23 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 24 | OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /djchat/static/djchat/chat.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | "use strict"; 3 | $.wschat = function (ws) { 4 | var messages = $('#messages'), 5 | users = $('#users'), 6 | message = $('#message'), 7 | user_label, 8 | who_ami, 9 | wall = function (user, message) { 10 | messages.prepend('

' + user + ' ' + message + '

'); 11 | }; 12 | 13 | ws.onmessage = function (e) { 14 | var data = $.parseJSON(e.data), 15 | label = 'info">@', 16 | user = data.user, 17 | userid = 'chatuser-' + user; 18 | if (!data.authenticated) { 19 | label = 'inverse">'; 20 | } 21 | if (!who_ami) { 22 | who_ami = user; 23 | } 24 | user_label = '' + user_label + '

'); 31 | wall(user_label, 'joined the chat'); 32 | } 33 | } else { 34 | wall(user_label, 'left the chat'); 35 | users.find('#' + userid).remove(); 36 | } 37 | } 38 | }; 39 | $('#publish').click(function () { 40 | var msg = message.val(); 41 | ws.send(msg); 42 | message.val(''); 43 | }); 44 | }; 45 | 46 | }(jQuery)); 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from setuptools import setup, find_packages 5 | 6 | import pulse 7 | 8 | 9 | def read(name): 10 | filename = os.path.join(os.path.dirname(__file__), name) 11 | with open(filename) as fp: 12 | return fp.read() 13 | 14 | 15 | def requirements(name): 16 | install_requires = [] 17 | dependency_links = [] 18 | 19 | for line in read(name).split('\n'): 20 | if line.startswith('-e '): 21 | link = line[3:].strip() 22 | if link == '.': 23 | continue 24 | dependency_links.append(link) 25 | line = link.split('=')[1] 26 | line = line.strip() 27 | if line: 28 | install_requires.append(line) 29 | 30 | return install_requires, dependency_links 31 | 32 | 33 | meta = dict( 34 | version=pulse.__version__, 35 | description=pulse.__doc__, 36 | name='pulsar-django', 37 | author='Luca Sbardella', 38 | author_email="luca@quantmind.com", 39 | maintainer_email="luca@quantmind.com", 40 | url="https://github.com/quantmind/pulsar-django", 41 | license="BSD", 42 | long_description=read('README.rst'), 43 | packages=find_packages(include=['pulse', 'pulse.*']), 44 | include_package_data=True, 45 | zip_safe=False, 46 | setup_requires=['pulsar', 'wheel'], 47 | install_requires=requirements('requirements/hard.txt')[0], 48 | classifiers=[ 49 | 'Development Status :: 4 - Beta', 50 | 'Environment :: Web Environment', 51 | 'Intended Audience :: Developers', 52 | 'License :: OSI Approved :: BSD License', 53 | 'Operating System :: OS Independent', 54 | 'Programming Language :: Python', 55 | 'Programming Language :: Python :: 3.5', 56 | 'Programming Language :: Python :: 3.6', 57 | 'Topic :: Utilities' 58 | ] 59 | ) 60 | 61 | 62 | if __name__ == '__main__': 63 | try: 64 | from pulsar import cmds 65 | meta['cmdclass'] = dict(pypi=cmds.PyPi) 66 | except ImportError: 67 | pass 68 | setup(**meta) 69 | -------------------------------------------------------------------------------- /pulse/management/commands/pulse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 - 2 | import sys 3 | 4 | import pulsar 5 | from pulsar.api import Setting, Config 6 | from pulsar.apps.wsgi import WSGIServer 7 | 8 | from pulse.wsgi import Wsgi 9 | 10 | from django.core.management.base import ( 11 | BaseCommand, CommandError, OutputWrapper, handle_default_options 12 | ) 13 | 14 | 15 | class PulseAppName(Setting): 16 | section = "Django Pulse" 17 | app = "pulse" 18 | name = "pulse_app_name" 19 | flags = ["--pulse-app-name"] 20 | default = 'django_pulsar' 21 | desc = """\ 22 | Name for the django pulse application 23 | """ 24 | 25 | 26 | class Command(BaseCommand): 27 | help = "Starts a fully-functional Web server using pulsar." 28 | args = 'pulse --help for usage' 29 | 30 | def handle(self, *args, **options): 31 | if args: 32 | raise CommandError('pulse --help for usage') 33 | app_name = options.get('pulse_app_name') 34 | callable = Wsgi() 35 | if options.pop('dryrun', False) is True: # used for testing 36 | return callable 37 | # callable.setup() 38 | cfg = Config(apps=['socket', 'pulse'], 39 | server_software=pulsar.SERVER_SOFTWARE, 40 | **options) 41 | server = WSGIServer(callable=callable, name=app_name, cfg=cfg, 42 | parse_console=False) 43 | callable.cfg = server.cfg 44 | server.start() 45 | 46 | def get_version(self): 47 | return pulsar.__version__ 48 | 49 | def create_parser(self, prog_name, subcommand): 50 | parser = super().create_parser(prog_name, subcommand) 51 | cfg = Config(apps=['socket', 'pulse'], 52 | exclude=['debug'], 53 | description=self.help, 54 | version=self.get_version()) 55 | return cfg.add_to_parser(parser) 56 | 57 | def run_from_argv(self, argv): 58 | parser = self.create_parser(argv[0], argv[1]) 59 | options = parser.parse_args(argv[2:]) 60 | handle_default_options(options) 61 | try: 62 | self.execute(**options.__dict__) 63 | except Exception as e: 64 | if options.traceback or not isinstance(e, CommandError): 65 | raise 66 | 67 | # self.stderr is not guaranteed to be set here 68 | stderr = getattr(self, 'stderr', 69 | OutputWrapper(sys.stderr, self.style.ERROR)) 70 | stderr.write('%s: %s' % (e.__class__.__name__, e)) 71 | sys.exit(1) 72 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | main: 4 | working_directory: ~/main 5 | docker: 6 | - image: python:3.6.3 7 | steps: 8 | - checkout 9 | - run: 10 | name: install packages 11 | command: ci/install.sh 12 | - run: 13 | name: test 14 | command: make test 15 | coverage: 16 | working_directory: ~/coverage 17 | docker: 18 | - image: python:3.6.3 19 | steps: 20 | - checkout 21 | - run: 22 | name: install packages 23 | command: ci/install.sh 24 | - run: 25 | name: run tests for coverage 26 | command: make coverage 27 | - run: 28 | name: upload coverage stats 29 | command: codecov 30 | legacy: 31 | working_directory: ~/legacy 32 | docker: 33 | - image: python:3.5.4 34 | steps: 35 | - checkout 36 | - run: 37 | name: install packages 38 | command: ci/install.sh 39 | - run: 40 | name: test 41 | command: make test 42 | deploy-release: 43 | working_directory: ~/deploy 44 | docker: 45 | - image: python:3.6.3 46 | steps: 47 | - checkout 48 | - run: 49 | name: install packages 50 | command: ci/install.sh 51 | - run: 52 | name: check version 53 | command: python setup.py pypi --final 54 | - run: 55 | name: create source distribution 56 | command: python setup.py sdist 57 | - run: 58 | name: release source distribution 59 | command: twine upload dist/* --username lsbardel --password $PYPI_PASSWORD 60 | - run: 61 | name: tag 62 | command: ci/tag.sh 63 | 64 | workflows: 65 | version: 2 66 | build-deploy: 67 | jobs: 68 | - main: 69 | filters: 70 | branches: 71 | ignore: release 72 | tags: 73 | ignore: /.*/ 74 | - coverage: 75 | filters: 76 | branches: 77 | ignore: release 78 | tags: 79 | ignore: /.*/ 80 | - legacy: 81 | filters: 82 | branches: 83 | ignore: release 84 | tags: 85 | ignore: /.*/ 86 | - deploy-release: 87 | filters: 88 | branches: 89 | only: release 90 | tags: 91 | ignore: /.*/ 92 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | """Tests django chat application""" 2 | import unittest 3 | import asyncio 4 | import json 5 | 6 | from pulsar.api import send, get_application 7 | from pulsar.apps import http, ws 8 | 9 | from djchat import server 10 | 11 | 12 | async def start_server(actor, name, argv): 13 | server(argv) 14 | await asyncio.sleep(0.5) 15 | app = await get_application(name) 16 | return app.cfg 17 | 18 | 19 | class MessageHandler(ws.WS): 20 | 21 | def __init__(self, loop): 22 | self.queue = asyncio.Queue(loop=loop) 23 | 24 | def get(self): 25 | return self.queue.get() 26 | 27 | def on_message(self, websocket, message): 28 | return self.queue.put(message) 29 | 30 | 31 | class TestDjangoChat(unittest.TestCase): 32 | concurrency = 'process' 33 | app_cfg = None 34 | 35 | @classmethod 36 | async def setUpClass(cls): 37 | name = cls.__name__.lower() 38 | argv = [__file__, 'pulse', 39 | '-b', '127.0.0.1:0', 40 | '--concurrency', cls.concurrency, 41 | '--pulse-app-name', name, 42 | '--data-store', 'pulsar://127.0.0.1:6410/1'] 43 | cls.app_cfg = await send('arbiter', 'run', start_server, name, argv) 44 | addr = cls.app_cfg.addresses[0] 45 | cls.uri = 'http://{0}:{1}'.format(*addr) 46 | cls.ws = 'ws://{0}:{1}/message'.format(*addr) 47 | cls.http = http.HttpClient() 48 | 49 | @classmethod 50 | def tearDownClass(cls): 51 | if cls.app_cfg: 52 | return send('arbiter', 'kill_actor', cls.app_cfg.name) 53 | 54 | async def test_home(self): 55 | result = await self.http.get(self.uri) 56 | self.assertEqual(result.status_code, 200) 57 | self.assertEqual(result.headers['content-type'], 58 | 'text/html; charset=utf-8') 59 | 60 | async def test_404(self): 61 | result = await self.http.get('%s/bsjdhcbjsdh' % self.uri) 62 | self.assertEqual(result.status_code, 404) 63 | 64 | async def test_websocket(self): 65 | c = self.http 66 | ws = await c.get(self.ws, websocket_handler=MessageHandler(c._loop)) 67 | response = ws.handshake 68 | self.assertEqual(response.status_code, 101) 69 | self.assertEqual(response.headers['upgrade'], 'websocket') 70 | self.assertEqual(response.connection, ws.connection) 71 | self.assertTrue(ws.connection) 72 | self.assertIsInstance(ws.handler, MessageHandler) 73 | # send a message 74 | ws.write('Hi there!') 75 | message = await ws.handler.queue.get() 76 | self.assertTrue(message) 77 | data = json.loads(message) 78 | self.assertEqual(data['message'], 'Hi there!') 79 | self.assertEqual(data['channel'], 'webchat') 80 | self.assertFalse(data['authenticated']) 81 | # 82 | # close connection 83 | ws.write_close() 84 | await ws.connection.event('connection_lost').waiter() 85 | -------------------------------------------------------------------------------- /djchat/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for djchat project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | APP_DIR = os.path.dirname(__file__) 14 | BASE_DIR = os.path.dirname(APP_DIR) 15 | 16 | 17 | # Quick-start development settings - unsuitable for production 18 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 19 | 20 | # SECURITY WARNING: keep the secret key used in production secret! 21 | SECRET_KEY = 'fux9z2i)6ab$b_5*^z@96hdtqfj5=ct7b)m6_6cfrr5g%x#=81' 22 | 23 | # SECURITY WARNING: don't run with debug turned on in production! 24 | DEBUG = True 25 | 26 | ALLOWED_HOSTS = [] 27 | 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = ( 32 | 'django.contrib.admin', 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | 'django.contrib.sessions', 36 | 'django.contrib.messages', 37 | 'django.contrib.staticfiles', 38 | 'pulse', 39 | 'djchat' 40 | ) 41 | 42 | TEMPLATES = [ 43 | { 44 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 45 | 'DIRS': [os.path.join(APP_DIR, 'templates')], 46 | 'APP_DIRS': True, 47 | 'OPTIONS': { 48 | 'context_processors': [ 49 | 'django.template.context_processors.debug', 50 | 'django.template.context_processors.request', 51 | 'django.contrib.auth.context_processors.auth', 52 | 'django.contrib.messages.context_processors.messages' 53 | ] 54 | } 55 | } 56 | ] 57 | 58 | MIDDLEWARE_CLASSES = ( 59 | 'django.contrib.sessions.middleware.SessionMiddleware', 60 | 'django.middleware.common.CommonMiddleware', 61 | 'django.middleware.csrf.CsrfViewMiddleware', 62 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 63 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 64 | 'django.contrib.messages.middleware.MessageMiddleware', 65 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 66 | 'djchat.views.middleware' 67 | ) 68 | 69 | ROOT_URLCONF = 'djchat.urls' 70 | 71 | 72 | # Database 73 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 74 | 75 | DATABASES = { 76 | 'default': { 77 | 'ENGINE': 'django.db.backends.sqlite3', 78 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 79 | } 80 | } 81 | 82 | # Internationalization 83 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 84 | 85 | LANGUAGE_CODE = 'en-us' 86 | 87 | TIME_ZONE = 'UTC' 88 | 89 | USE_I18N = True 90 | 91 | USE_L10N = True 92 | 93 | USE_TZ = True 94 | 95 | 96 | # Static files (CSS, JavaScript, Images) 97 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 98 | 99 | STATIC_URL = '/static/' 100 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Asynchronous Django 2 | ========================= 3 | 4 | :Badges: |license| |pyversions| |status| |pypiversion| 5 | :CI: |circleci| |coverage| 6 | :Documentation: https://github.com/quantmind/pulsar-django 7 | :Downloads: http://pypi.python.org/pypi/pulsar-django 8 | :Source: https://github.com/quantmind/pulsar-django 9 | :Keywords: asynchronous, django, wsgi, websocket, redis 10 | 11 | 12 | The `pulse` module is a django_ application 13 | for running a django web site with pulsar_. 14 | Add it to the list of your ``INSTALLED_APPS``: 15 | 16 | .. code:: python 17 | 18 | INSTALLED_APPS = ( 19 | ..., 20 | 'pulse', 21 | ... 22 | ) 23 | 24 | and run the site via the ``pulse`` command:: 25 | 26 | python manage.py pulse 27 | 28 | Check the django chat example ``djchat`` for a django chat 29 | application served by a multiprocessing pulsar server. 30 | 31 | By default, the ``pulse`` command creates a ``Wsgi`` middleware which 32 | runs the django application in a separate thread of execution from the 33 | main event loop. 34 | This is a standard programming pattern when using asyncio with blocking 35 | functions. 36 | To control the number of thread workers in the event loop executor (which 37 | is a pool of threads) one uses the 38 | ``thread-workers`` option. For example, the 39 | following command:: 40 | 41 | python manage.py pulse -w 4 --thread-workers 20 42 | 43 | will run four process based actors, each with 44 | an executor with up to 20 threads. 45 | 46 | Greenlets 47 | =============== 48 | 49 | It is possible to run django in fully asynchronous mode, i.e. without 50 | running the middleware in the event loop executor. 51 | Currently, this is available when using PostgreSql backend 52 | only, and it requires the greenlet_ library. 53 | 54 | To run django using greenlet support:: 55 | 56 | python manage.py pulse -w 4 --greenlet 57 | 58 | By default it will run the django middleware on a pool of 100 greenlets (and 59 | therefore approximately 100 separate database connections per actor). To 60 | adjust this number:: 61 | 62 | python manage.py pulse -w 4 --greenlet 200 63 | 64 | 65 | Django Chat Example 66 | ======================= 67 | 68 | This is a web chat application which illustrates how to run a django 69 | site with pulsar and how to include pulsar asynchronous request middleware 70 | into django. 71 | 72 | To run:: 73 | 74 | python manage.py pulse 75 | 76 | If running for the first time, issue the:: 77 | 78 | python manage.py migrate 79 | 80 | command and create the super user:: 81 | 82 | python manage.py createsuperuser 83 | 84 | 85 | Message and data backend 86 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 87 | 88 | By default, messages from connected (websocket) clients are synchronised via 89 | the pulsar data store which starts when the django 90 | site starts. 91 | 92 | It is possible to specify a different data store via the 93 | ``data-store`` option. For example, it is possible 94 | to use redis_ as an alternative data store 95 | by issuing the following start up command:: 96 | 97 | python manage.py pulse --data-store redis://127.0.0.1:6379/3 98 | 99 | 100 | 101 | .. _redis: http://redis.io/ 102 | .. _django: https://docs.djangoproject.com/en/1.9/ref/applications/ 103 | .. _pulsar: https://github.com/quantmind/pulsar 104 | .. _greenlet: https://greenlet.readthedocs.io 105 | .. |pypiversion| image:: https://badge.fury.io/py/pulsar-django.svg 106 | :target: https://pypi.python.org/pypi/pulsar-django 107 | .. |pyversions| image:: https://img.shields.io/pypi/pyversions/pulsar-django.svg 108 | :target: https://pypi.python.org/pypi/pulsar-django 109 | .. |license| image:: https://img.shields.io/pypi/l/pulsar-django.svg 110 | :target: https://pypi.python.org/pypi/pulsar-django 111 | .. |status| image:: https://img.shields.io/pypi/status/pulsar-django.svg 112 | :target: https://pypi.python.org/pypi/pulsar-django 113 | .. |coverage| image:: https://codecov.io/gh/quantmind/pulsar-django/branch/master/graph/badge.svg 114 | :target: https://codecov.io/gh/quantmind/pulsar-django 115 | .. |circleci| image:: https://circleci.com/gh/quantmind/pulsar-django.svg?style=svg 116 | :target: https://circleci.com/gh/quantmind/pulsar-django 117 | -------------------------------------------------------------------------------- /djchat/views.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.shortcuts import render_to_response 4 | from django.http import HttpResponse 5 | 6 | from pulsar.api import HttpException 7 | from pulsar.apps import ws 8 | from pulsar.apps.data import PubSubClient, create_store 9 | from pulsar.utils.system import json 10 | from pulsar.utils.string import random_string 11 | 12 | 13 | def home(request): 14 | return render_to_response('home.html', { 15 | 'HOST': request.get_host() 16 | }) 17 | 18 | 19 | class ChatClient(PubSubClient): 20 | 21 | def __init__(self, websocket): 22 | self.joined = time.time() 23 | self.websocket = websocket 24 | self.websocket._chat_client = self 25 | 26 | def __call__(self, channel, message): 27 | # The message is an encoded JSON string 28 | self.websocket.write(message, opcode=1) 29 | 30 | 31 | class Chat(ws.WS): 32 | ''':class:`.WS` handler managing the chat application.''' 33 | _store = None 34 | _pubsub = None 35 | _client = None 36 | 37 | async def get_pubsub(self, websocket): 38 | '''Create the pubsub handler if not already available''' 39 | if not self._store: 40 | cfg = websocket.cfg 41 | self._store = create_store(cfg.data_store) 42 | self._client = self._store.client() 43 | self._pubsub = self._store.pubsub() 44 | webchat = '%s:webchat' % cfg.exc_id 45 | chatuser = '%s:chatuser' % cfg.exc_id 46 | await self._pubsub.subscribe(webchat, chatuser) 47 | return self._pubsub 48 | 49 | async def on_open(self, websocket): 50 | '''A new websocket connection is established. 51 | 52 | Add it to the set of clients listening for messages. 53 | ''' 54 | pubsub = await self.get_pubsub(websocket) 55 | pubsub.add_client(ChatClient(websocket)) 56 | user, _ = self.user(websocket) 57 | users_key = 'webchatusers:%s' % websocket.cfg.exc_id 58 | # add counter to users 59 | registered = await self._client.hincrby(users_key, user, 1) 60 | if registered == 1: 61 | await self.publish(websocket, 'chatuser', 'joined') 62 | 63 | async def on_close(self, websocket): 64 | '''Leave the chat room 65 | ''' 66 | user, _ = self.user(websocket) 67 | users_key = 'webchatusers:%s' % websocket.cfg.exc_id 68 | registered = await self._client.hincrby(users_key, user, -1) 69 | pubsub = await self.get_pubsub(websocket) 70 | pubsub.remove_client(websocket._chat_client) 71 | if not registered: 72 | await self.publish(websocket, 'chatuser', 'gone') 73 | if registered <= 0: 74 | await self._client.hdel(users_key, user) 75 | 76 | def on_message(self, websocket, msg): 77 | '''When a new message arrives, it publishes to all listening clients. 78 | ''' 79 | if msg: 80 | lines = [] 81 | for li in msg.split('\n'): 82 | li = li.strip() 83 | if li: 84 | lines.append(li) 85 | msg = ' '.join(lines) 86 | if msg: 87 | return self.publish(websocket, 'webchat', msg) 88 | 89 | def user(self, websocket): 90 | user = websocket.handshake.get('django.user') 91 | if user.is_authenticated(): 92 | return user.username, True 93 | else: 94 | session = websocket.handshake.get('django.session') 95 | user = session.get('chatuser') 96 | if not user: 97 | user = 'an_%s' % random_string(length=6).lower() 98 | session['chatuser'] = user 99 | return user, False 100 | 101 | def publish(self, websocket, channel, message=''): 102 | user, authenticated = self.user(websocket) 103 | msg = {'message': message, 104 | 'user': user, 105 | 'authenticated': authenticated, 106 | 'channel': channel} 107 | channel = '%s:%s' % (websocket.cfg.exc_id, channel) 108 | return self._pubsub.publish(channel, json.dumps(msg)) 109 | 110 | 111 | class middleware: 112 | '''Django middleware for serving the Chat websocket.''' 113 | def __init__(self): 114 | self._web_socket = ws.WebSocket('/message', Chat()) 115 | 116 | def process_request(self, request): 117 | environ = request.META.copy() 118 | environ['django.user'] = request.user 119 | environ['django.session'] = request.session 120 | try: 121 | response = self._web_socket(environ) 122 | except HttpException as e: 123 | return HttpResponse(status=e.status) 124 | if response is not None: 125 | # we have a response, this is the websocket upgrade. 126 | # Convert to django response 127 | resp = HttpResponse(status=response.status_code, 128 | content_type=response.content_type) 129 | for header, value in response.headers.items(): 130 | resp[header] = value 131 | return resp 132 | --------------------------------------------------------------------------------