Skip to content

Commit

Permalink
init commit
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-yin committed Nov 13, 2023
1 parent 0b6047f commit 001d0ea
Show file tree
Hide file tree
Showing 16 changed files with 563 additions and 0 deletions.
37 changes: 37 additions & 0 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

name: Upload Python Package

on:
release:
types: [published]

permissions:
contents: read

jobs:
deploy:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install poetry
- name: Build package
run: poetry build
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
26 changes: 26 additions & 0 deletions .github/workflows/runtests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Runs tests

on:
pull_request:
push:
branches:
- main

jobs:
runtests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10' ]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -r requirements-dev.txt
- name: Run tests
run: |
tox
52 changes: 52 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
*.pyc
*.egg
*.egg-info

docs/build
dist
build

*.lock

*.sqlite3
*.db

*.DS_Store

.cache
__pycache__
.mypy_cache/
.pytest_cache/
.vscode/
.coverage
docs/build

node_modules/

*.bak

logs
*log
npm-debug.log*

# Translations
# *.mo
*.pot

# Django media/static dirs
media/
static/dist/
static/dev/

.ipython/
.env

celerybeat.pid
celerybeat-schedule

# Common typos
:w
'
.tox

/venv/
24 changes: 24 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
repos:
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 23.10.1
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
additional_dependencies:
- flake8-bugbear
- flake8-comprehensions
- flake8-no-pep420
- flake8-print
- flake8-tidy-imports
- flake8-typing-imports
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.6.1
hooks:
- id: mypy
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
build:
poetry build

publish:
poetry publish

# poetry config repositories.testpypi https://test.pypi.org/legacy/
publish-test:
poetry publish -r testpypi
22 changes: 22 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[tool.poetry]
name = "django-actioncable"
version = "1.0.1"
description = "This package provides Rails Action Cable support to Django"
authors = ["Michael Yin <[email protected]>"]
license = "MIT"
readme = "README.md"
packages = [{ include = "actioncable", from = "src" }]

[tool.poetry.dependencies]
python = ">=3.8"
django = ">=3.0"
channels = ">=3.0"

[tool.poetry.dev-dependencies]

[build-system]
requires = [
"setuptools >= 61.0.0",
"wheel"
]
build-backend = "setuptools.build_meta"
16 changes: 16 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
pre-commit==2.9.2
tox==4.11.3
tox-gh-actions==3.1.3

django==4.2 # for local tests
typing_extensions
pytest
pytest-django
pytest-xdist
pytest-mock
jinja2

channels
daphne
pytest-asyncio
channels_redis
22 changes: 22 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[flake8]
ignore = E203, E266, E501, W503, E231, E701, B950, B907
max-line-length = 88
max-complexity = 18
select = B,C,E,F,W,T4,B9

[isort]
profile = black

[mypy]
python_version = 3.10
check_untyped_defs = False
ignore_missing_imports = True
warn_unused_ignores = False
warn_redundant_casts = False
warn_unused_configs = False

[mypy-*.tests.*]
ignore_errors = True

[mypy-*.migrations.*]
ignore_errors = True
11 changes: 11 additions & 0 deletions src/actioncable/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from .utils import cable_broadcast
from .registry import cable_channel_register
from .consumer import ActionCableConsumer, CableChannel, compact_encode_json

__all__ = [
"cable_broadcast",
"cable_channel_register",
"ActionCableConsumer",
"CableChannel",
"compact_encode_json",
]
151 changes: 151 additions & 0 deletions src/actioncable/consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import json
import time
import logging

from collections import defaultdict
import actioncable.registry
import sys
import os

import asyncio
from channels.generic.websocket import (
AsyncJsonWebsocketConsumer,
)


if "pytest" in sys.argv[0] or "TOX_ENV_NAME" in os.environ:
TEST_ENV = True
else:
TEST_ENV = False


LOGGER = logging.getLogger(__name__)


def compact_encode_json(content):
# compact encoding
return json.dumps(content, separators=(",", ":"), sort_keys=True)


class ActionCableConsumer(AsyncJsonWebsocketConsumer):
"""
Make Django Channels compatible with Rails ActionCable JS client
"""
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.ping_task = None
# the channel name to channel_cable_class
self.channel_cls_dict = actioncable.registry.registered_classes
self.identifier_to_channel_instance_map = {}
self.group_channel_instance_map = defaultdict(set)

async def connect(self):
await self.accept("actioncable-v1-json")
await self.send(text_data='{"type": "welcome"}')

if not TEST_ENV:
# periodically send ping message to the client
# if the client did not receive the ping message within X seconds, it will try reconnect
loop = asyncio.get_event_loop()
self.ping_task = loop.create_task(self.send_ping())

async def disconnect(self, close_code):
if not TEST_ENV:
# Stop the ping task when disconnecting
self.ping_task.cancel()

await self.close()

async def send_ping(self):
while True:
ping_message = {"type": "ping", "message": int(time.time())}
await self.send_json(ping_message) # Send the ping message to the client
await asyncio.sleep(5) # Wait for 5 seconds

async def receive_json(self, content, **kwargs):
command = content.get("command", None)
identifier_dict = json.loads(content["identifier"])
unique_identifier_key = await self.encode_json(identifier_dict)

# get or create channel instance for the identifier
if unique_identifier_key in self.identifier_to_channel_instance_map:
channel_instance = self.identifier_to_channel_instance_map[unique_identifier_key]
else:
channel_cls = self.channel_cls_dict[identifier_dict.get("channel")]
channel_instance = channel_cls(
consumer=self,
identifier_key=unique_identifier_key,
params=identifier_dict
)
self.identifier_to_channel_instance_map[unique_identifier_key] = channel_instance

if command == "subscribe":
await channel_instance.subscribe()
await self.send_json(
{
"identifier": await self.encode_json(identifier_dict),
"type": "confirm_subscription",
}
)
elif command == "unsubscribe":
await channel_instance.unsubscribe()

async def message(self, event):
"""Send Turbo Stream HTML message back to the client"""
group_name = event['group']
data = event['data']

if group_name in self.group_channel_instance_map:
for channel_instance_unique_key in self.group_channel_instance_map[group_name]:
# send message to all cable channels which subscribe to this group
cable_channel_instance = self.identifier_to_channel_instance_map[channel_instance_unique_key]
await self.send_json({
'identifier': cable_channel_instance.identifier_key,
**data,
})
else:
LOGGER.warning("Group name %s not found in group_channel_instance_map", group_name)

async def subscribe_group(self, group_name, cable_channel_instance):
await self.channel_layer.group_add(group_name, self.channel_name)
self.group_channel_instance_map[group_name].add(cable_channel_instance.identifier_key)

async def unsubscribe_group(self, group_name, cable_channel_instance):
has_other_cable_channel_subscribe = False
if group_name in self.group_channel_instance_map:
subscribed_channel_instance_keys = self.group_channel_instance_map[group_name]
if len(subscribed_channel_instance_keys) > 1 and cable_channel_instance.identifier_key in subscribed_channel_instance_keys:
has_other_cable_channel_subscribe = True
self.group_channel_instance_map[group_name].discard(cable_channel_instance.identifier_key)

if not has_other_cable_channel_subscribe:
# if there is no other cable channel subscribe to this group
# then we can let the consumer leave the group entirely
await self.channel_layer.group_discard(group_name, self.channel_name)

try:
del self.identifier_to_channel_instance_map[cable_channel_instance.identifier_key]
except KeyError:
pass

@classmethod
async def encode_json(cls, content):
return compact_encode_json(content)


class CableChannel:

def __init__(self, consumer: ActionCableConsumer, identifier_key, params=None):
raise NotImplementedError("Please implement subscribe method")

async def subscribe(self):
"""
callback to run when received subscribe command from the client
"""
raise NotImplementedError("Please implement subscribe method")

async def unsubscribe(self):
"""
callback to run when received unsubscribe command from the client
"""
raise NotImplementedError("Please implement unsubscribe method")
13 changes: 13 additions & 0 deletions src/actioncable/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import logging

LOGGER = logging.getLogger(__name__)

registered_classes = {}


def cable_channel_register(cls):
class_name = cls.__name__
if class_name in registered_classes:
logging.warning(f"Class '{class_name}' is already registered in cable_channel_register and will be overwritten")
registered_classes[class_name] = cls
return cls
Loading

0 comments on commit 001d0ea

Please sign in to comment.