Skip to content

Commit

Permalink
feat: add sms.backends.filebased.SmsBackend (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
roaldnefs committed Jan 20, 2021
1 parent 4697d8f commit 9eeb988
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 10 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Changelog

All notable changes in **django-sms** are documented below.

## [Unreleased]
### Added
- The file backend that writes text messages to a file ([#1](https://github.com/roaldnefs/django-sms/pull/1)).

## [0.1.0] (2021-01-15)
### Added
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [SMS backends](#sms-backends)
- [Obtaining an instance of an SMS backend](#obtaining-an-instance-of-an-sms-backend)
- [Console backend](#console-backend)
- [File backend](#file-backend)
- [In-memory backend](#in-memory-backend)
- [Dummy backend](#dummy-backend)
- [Defining a custom SMS backend](#defining-a-custom-sms-backend)
Expand Down Expand Up @@ -150,14 +151,27 @@ django-sms ships with several SMS sending backends. Some of these backends are o

#### Console backend

Instead of sending out real emails the console backend just writes the text messages that would be sent to the standard output. By default, the console backend writes to **stdout**. You can use a different stream-like object by providing the **stream** keyword argument when constructing the connection.
Instead of sending out real text messages the console backend just writes the text messages that would be sent to the standard output. By default, the console backend writes to **stdout**. You can use a different stream-like object by providing the **stream** keyword argument when constructing the connection.

```python
SMS_BACKEND = 'sms.backends.console.SmsBackend'
```

This backend is not intended for use in production - it is provided as a convenience that can be used during development.

#### File backend

The file backend writes text messages to a file. A new file is created for each session that is opened on this backend. The directory to which the files are written is either taken from the **SMS_FILE_PATH** setting or file the **file_path** keyword when creating a connection with **get_connection()**.

To specify this backend, put the following in your settings:

```python
SMS_BACKEND = 'sms.backends.filebased.SmsBackend'
SMS_FILE_PATH = '/tmp/app-messages' # change this to a proper location
```

This backend is not intended for use in production - it is provided as a convenience that can be used during development.

#### In-memory backend

The **'locmen'** backend stores text messages in a special attribute of the **sms** module. The **outbox** attribute is created when the first message is sent. It's a list with an **Message** instance of each text message that would be sent.
Expand Down
4 changes: 2 additions & 2 deletions sms/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def __init__(
) -> None:
self.fail_silently = fail_silently

def open(self) -> None:
def open(self) -> bool:
"""
Open a network connection.
Expand All @@ -40,7 +40,7 @@ def open(self) -> None:
The default implementation does nothing.
"""
pass
return True

def close(self) -> None:
"""Close a network connection."""
Expand Down
5 changes: 4 additions & 1 deletion sms/backends/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(self, *args, **kwargs) -> None:
self._lock = threading.RLock()
super().__init__(*args, **kwargs)

def write_message(self, message) -> int:
def write_message(self, message: Message) -> int:
msg_count = 0
for to in message.to:
msg_data = (
Expand All @@ -37,10 +37,13 @@ def send_messages(self, messages: List[Message]) -> int:
return msg_count
with self._lock:
try:
stream_created = self.open()
for message in messages:
count = self.write_message(message)
self.stream.flush() # flush after each message
msg_count += count
if stream_created:
self.close()
except Exception:
if not self.fail_silently:
raise
Expand Down
85 changes: 85 additions & 0 deletions sms/backends/filebased.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""
SMS backend that writes messages to a file.
"""
import datetime
import os

from typing import Optional

from django.conf import settings # type: ignore
from django.core.exceptions import ImproperlyConfigured # type: ignore

from sms.backends.console import SmsBackend as BaseSmsBackend
from sms.message import Message


class SmsBackend(BaseSmsBackend):
def __init__(
self,
*args,
file_path: Optional[str] = None,
**kwargs
) -> None:
self._fname: Optional[str] = None
if file_path is not None:
self.file_path = file_path
else:
self.file_path = getattr(settings, 'SMS_FILE_PATH', None)
self.file_path = os.path.abspath(self.file_path)
try:
os.makedirs(self.file_path, exist_ok=True)
except FileExistsError:
raise ImproperlyConfigured((
'Path for saving text messages exists, but is not a '
f'directory: {self.file_path}'
))
except OSError as exc:
raise ImproperlyConfigured((
'Could not create directory for saving text messages: '
f'{self.file_path} ({exc})'
))
# Make sure that self.file_path is writable.
if not os.access(self.file_path, os.W_OK):
raise ImproperlyConfigured(
f'Could not write to directory: {self.file_path}'
)
# Finally, call super().
# Since we're using the console-based backend as a base,
# force the stream to be None, so we don't default to stdout
kwargs['stream'] = None
super().__init__(*args, **kwargs)

def write_message(self, message: Message) -> int:
msg_count = 0
for to in message.to:
msg_data = (
f"from: {message.from_phone}\n"
f"to: {to}\n"
f"{message.body}"
)
self.stream.write(f'{msg_data}\n'.encode())
self.stream.write(b'-' * 79)
self.stream.write(b'\n')
msg_count += 1
return msg_count

def _get_filename(self) -> str:
"""Return a unique file name."""
if self._fname is None:
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
fname = "%s-%s.log" % (timestamp, abs(id(self)))
self._fname = os.path.join(self.file_path, fname)
return self._fname

def open(self) -> bool:
if self.stream is None:
self.stream = open(self._get_filename(), 'ab')
return True
return False

def close(self) -> None:
try:
if self.stream is not None:
self.stream.close()
finally:
self.stream = None
40 changes: 40 additions & 0 deletions sms/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import re

from sms.message import Message


header_RE = re.compile('^(?:from|to): .*')
header_from_RE = re.compile('^from: (.*)$', re.MULTILINE)
header_to_RE = re.compile('^to: (.*)$', re.MULTILINE)


def message_from_binary_file(fp) -> Message:
"""Parse a binary file into a Message object model."""
return message_from_bytes(fp.read())


def message_from_bytes(s: bytes) -> Message:
"""Parse a bytes string into a Message object model."""
text = s.decode('ASCII', errors='surrogateescape')

body = ''
for line in text.splitlines():
if not header_RE.match(line):
if body is not None:
body += '\n' + line
else:
body = line

from_result = header_from_RE.search(text)
if from_result:
from_phone = from_result.group(1)
else:
raise ValueError

to_result = header_to_RE.search(text)
if to_result:
to = [to_result.group(1)]
else:
raise ValueError

return Message(body, from_phone, to)
1 change: 1 addition & 0 deletions tests/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
settings.configure(
DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3'}},
SECRET_KEY="it's a secret to everyone",
SMS_BACKEND='sms.backends.locmem.SmsBackend',
)


Expand Down
95 changes: 90 additions & 5 deletions tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
from typing import List, Type
import os
import sys
import shutil
import tempfile

from typing import List, Type, Optional
from io import StringIO

from django.test import SimpleTestCase, override_settings # type: ignore

import sms
from sms import send_sms
from sms.backends import dummy, locmem
from sms.backends import dummy, locmem, filebased
from sms.backends.base import BaseSmsBackend
from sms.message import Message
from sms.utils import message_from_bytes, message_from_binary_file


class BaseSmsBackendTests:
sms_backend: Optional[str] = None

def setUp(self) -> None:
self.settings_override = override_settings(
SMS_BACKEND=self.sms_backend
)
self.settings_override.enable()

def tearDown(self) -> None:
self.settings_override.disable()


class SmsTests(SimpleTestCase):
Expand All @@ -25,11 +44,30 @@ def test_dummy_backend(self) -> None:
)

def test_backend_arg(self) -> None:
"""Test backend argument of mail.get_connection()."""
"""Test backend argument of sms.get_connection()."""
self.assertIsInstance(
sms.get_connection('sms.backends.dummy.SmsBackend'),
dummy.SmsBackend
)
with tempfile.TemporaryDirectory() as tmp_dir:
self.assertIsInstance(
sms.get_connection(
'sms.backends.filebased.SmsBackend',
file_path=tmp_dir
),
filebased.SmsBackend
)
if sys.platform == 'win32':
msg = ('_getfullpathname: path should be string, bytes or '
'os.PathLike, not object')
else:
msg = 'expected str, bytes or os.PathLike object, not object'
with self.assertRaisesMessage(TypeError, msg):
sms.get_connection(
'sms.backends.filebased.SmsBackend',
file_path=object()
)
self.assertIsInstance(sms.get_connection(), locmem.SmsBackend)

def test_custom_backend(self) -> None:
"""Test cutoms backend defined in this suite."""
Expand All @@ -50,7 +88,7 @@ def test_send_sms(self) -> None:
self.assertIsInstance(sms.outbox[0].to, list) # type: ignore


class LocmemBackendTests(SimpleTestCase):
class LocmemBackendTests(BaseSmsBackendTests, SimpleTestCase):
sms_backend: str = 'sms.backends.locmem.SmsBackend'

def flush_mailbox(self) -> None:
Expand All @@ -74,7 +112,7 @@ def test_locmem_shared_messages(self) -> None:
self.assertEqual(len(sms.outbox), 2) # type: ignore


class ConsoleBackendTests(SimpleTestCase):
class ConsoleBackendTests(BaseSmsBackendTests, SimpleTestCase):
sms_backend: str = 'sms.backends.console.SmsBackend'

def test_console_stream_kwarg(self) -> None:
Expand All @@ -90,3 +128,50 @@ def test_console_stream_kwarg(self) -> None:
connection.send_messages([message]) # type: ignore
messages = stream.getvalue().split('\n' + ('-' * 79) + '\n')
self.assertIn('from: ', messages[0])


class FileBasedBackendTests(BaseSmsBackendTests, SimpleTestCase):
sms_backend = 'sms.backends.filebased.SmsBackend'

def setUp(self) -> None:
super().setUp()
self.tmp_dir = self.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmp_dir)
self._settings_override = override_settings(SMS_FILE_PATH=self.tmp_dir)
self._settings_override.enable()

def tearDown(self) -> None:
self._settings_override.disable()
super().tearDown()

def mkdtemp(self) -> str:
return tempfile.mkdtemp()

def flush_mailbox(self) -> None:
for filename in os.listdir(self.tmp_dir):
os.unlink(os.path.join(self.tmp_dir, filename))

def get_mailbox_content(self) -> List[Message]:
messages: List[Message] = []
for filename in os.listdir(self.tmp_dir):
with open(os.path.join(self.tmp_dir, filename), 'rb') as fp:
session = fp.read().split(b'\n' + (b'-' * 79) + b'\n')
messages.extend(message_from_bytes(m) for m in session if m)
return messages

def test_file_sessions(self) -> None:
"""Make sure opening a connection creates a new file"""
message = Message(
'Here is the message',
'+12065550100',
['+441134960000']
)
connection = sms.get_connection()
connection.send_messages([message]) # type: ignore

self.assertEqual(len(os.listdir(self.tmp_dir)), 1)
tmp_file = os.path.join(self.tmp_dir, os.listdir(self.tmp_dir)[0])
with open(tmp_file, 'rb') as fp:
message = message_from_binary_file(fp)
self.assertEqual(message.from_phone, '+12065550100')
self.assertEqual(message.to, ['+441134960000'])

0 comments on commit 9eeb988

Please sign in to comment.