uzsms
An SMS-sending package for Django, built for the Playmobile broker used by Uzbek telecom operators. It provides a validated, pooled, retrying HTTP client (sync and async), a Django model that logs every outgoing message, an optional DRF endpoint, and an optional Celery task.
As of 2.0 the distribution and the importable package share one name:
pip install uzsms, import uzsms. 1.x was published as
django-sms-uz, which is now frozen at 1.0.1 and receives no further
releases.
Upgrading from 1.x? Read
UPGRADE.mdfirst. 2.0 is a breaking release, and its migration history is data-destroying for existing installs — do not runmigratebefore reading it.
Installation
Base install (sync sending only, no HTTP API, no Celery task):
pip install uzsms
With extras, as needed:
pip install "uzsms[drf]" # HTTP API (Django REST Framework)
pip install "uzsms[async]" # AsyncSmsClient / AsyncPlaymobileBackend (httpx)
pip install "uzsms[celery]" # uzsms.tasks.send_sms_task
pip install "uzsms[drf,async,celery]" # any combination
For contributing to this package itself:
pip install "uzsms[dev]"
Quickstart
Add the app:
INSTALLED_APPS = [
...,
"uzsms",
]
Configure the broker credentials (the only three required keys — see the full settings table below for everything else):
SMS_SETTINGS = {
"URL": "http://91.204.239.44/broker-api/send",
"LOGIN": "your-login",
"PASSWORD": "your-password",
}
Run migrations:
python manage.py migrate
Send a message:
from uzsms import SmsClient
client = SmsClient()
result = client.send("998901234567", "hello there")
result.ok # True/False
If you also want the HTTP endpoint, install the drf extra and include the
URLs:
# urls.py
from django.urls import include, path
urlpatterns = [
...,
path("sms/", include("uzsms.urls")),
]
This puts the send endpoint at /sms/send/ (see "HTTP API" below).
SMS_SETTINGS
All settings live under one dict, SMS_SETTINGS, in your Django settings
module. URL, LOGIN, and PASSWORD are required (no default); everything
else falls back to the default shown below. Defaults are read from
uzsms/conf.py.
| Key | Default | Meaning |
|---|---|---|
URL |
(required) | The broker's send endpoint URL. |
LOGIN |
(required) | Broker HTTP Basic Auth username. |
PASSWORD |
(required) | Broker HTTP Basic Auth password. |
BACKEND |
"uzsms.backends.playmobile.PlaymobileBackend" |
Dotted path to the backend class SmsClient uses to actually send messages. |
ASYNC_BACKEND |
"uzsms.backends.playmobile.AsyncPlaymobileBackend" |
Dotted path to the backend class AsyncSmsClient uses. A separate setting from BACKEND, so a project can use SmsClient and AsyncSmsClient side by side from one unmodified SMS_SETTINGS dict. |
ORIGINATOR |
"3700" |
The sms.originator value sent in every message envelope. |
TIMEOUT |
(5, 15) |
(connect, read) timeout in seconds, passed straight to requests/mapped onto httpx.Timeout. |
MAX_RETRIES |
3 |
Number of retries on a transient failure (connection error or a 429/500/502/503/504 response). Safe because every message carries a stable message_id the broker deduplicates on retry — see the docstring in uzsms/backends/playmobile.py. |
RETRY_BACKOFF |
0.5 |
Backoff factor between retries (exponential, per urllib3.util.Retry/the async backend's own backoff loop). |
POOL_MAXSIZE |
10 |
Max size of the pooled HTTP connection pool (requests.Session's adapter, or httpx.Limits). |
LOG_MESSAGES |
True |
Whether to persist a SmsLog row for every message sent. |
FAIL_SILENTLY |
False |
When True, a send failure returns a SendResult(ok=False, ...) instead of raising. |
MAX_MESSAGE_LENGTH |
918 |
Maximum allowed message length in characters; longer text raises SmsValidationError. |
PERMISSION_CLASSES |
["rest_framework.permissions.IsAuthenticated"] |
Dotted paths to DRF permission classes applied to the send endpoint. The endpoint requires authentication by default. |
THROTTLE_RATE |
"20/min" |
DRF throttle rate string applied to the send endpoint. |
Legacy key names SMS_URL, SMS_LOGIN, and SMS_PASSWORD (1.0.1's
spelling of URL/LOGIN/PASSWORD) still work, but emit a
DeprecationWarning. The new name always wins if both are present.
Sync usage
from uzsms import SmsClient
client = SmsClient()
# Single message
result = client.send("998901234567", "hello there")
# result: SendResult(message=SmsMessage(...), ok=True, provider_message_id=None,
# status_code=200, raw=..., error="", log_id=42)
# provider_message_id is None because no shipped backend populates it (see
# "The SmsLog model" below) — the id that matters is message.message_id,
# the one actually sent on the wire.
# Multiple messages, sent as one batched HTTP request
from uzsms import SmsMessage
results = client.send_bulk([
SmsMessage(phone_number="998901234567", text="hi"),
SmsMessage(phone_number="998907654321", text="hello"),
])
send/send_bulk validate every message (Uzbek phone format, non-empty
text under MAX_MESSAGE_LENGTH) before writing anything to the database,
create SmsLog rows in bulk (a single query for any number of messages,
unless LOG_MESSAGES is False), send through the configured backend, and
update those rows in bulk with the outcome.
Async usage
Requires the async extra (pip install "uzsms[async]", which
installs httpx):
from uzsms import AsyncSmsClient
client = AsyncSmsClient()
result = await client.send("998901234567", "hello there")
results = await client.send_bulk([...])
AsyncSmsClient has the same validation-before-write and constant-query
contract as SmsClient; its ORM calls go through
asgiref.sync.sync_to_async since Django's ORM is not async-safe.
Backends
Select the sync backend with SMS_SETTINGS["BACKEND"], and the async
backend AsyncSmsClient uses with SMS_SETTINGS["ASYNC_BACKEND"] — each a
dotted path to a class implementing uzsms.backends.base.BaseSmsBackend
(sync) or BaseAsyncSmsBackend (async), respectively:
| Backend | Path | Behavior |
|---|---|---|
| Playmobile (default) | uzsms.backends.playmobile.PlaymobileBackend |
Sends over a pooled, retrying requests.Session. |
| Async Playmobile | uzsms.backends.playmobile.AsyncPlaymobileBackend |
Same broker, over a cached httpx.AsyncClient. Used automatically by AsyncSmsClient. |
| Console | uzsms.backends.console.ConsoleBackend |
Prints each message to stdout; always reports success. No network I/O. Useful for local development. |
| LocMem | uzsms.backends.locmem.LocMemBackend (sync) / AsyncLocMemBackend (async) |
Appends each message to a module-level outbox list (uzsms.backends.locmem.outbox), like Django's django.core.mail.outbox. No network I/O. Useful for tests. |
| Dummy | uzsms.backends.dummy.DummyBackend |
Discards every message; always reports success. No network I/O. |
SMS_SETTINGS = {
...,
"BACKEND": "uzsms.backends.console.ConsoleBackend",
}
Writing a custom backend
Subclass uzsms.backends.base.BaseSmsBackend (or BaseAsyncSmsBackend) and
implement send_messages:
from collections.abc import Sequence
from uzsms.backends.base import BaseSmsBackend
from uzsms.dto import SendResult, SmsMessage
class MyBackend(BaseSmsBackend):
def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
# Must return exactly one SendResult per message, in the same order.
...
get_backend() (in uzsms/backends/__init__.py) resolves
SMS_SETTINGS["BACKEND"], and get_async_backend() resolves
SMS_SETTINGS["ASYNC_BACKEND"], each via
django.utils.module_loading.import_string and instantiates it; a backend
that doesn't subclass the expected base raises SmsConfigurationError.
open()/close() are no-op hooks you can override to acquire/release
resources (backends also work as a context manager via with backend: /
async with backend:).
Celery task
Requires the celery extra (pip install "uzsms[celery]").
Calling uzsms.tasks.send_sms_task without Celery installed raises
SmsConfigurationError naming the extra; importing the module is always
safe.
from uzsms.tasks import send_sms_task
send_sms_task.delay("998901234567", "hello there")
The task retries automatically on SmsTransportError (up to 3 times) and
returns a JSON-serializable summary dict (ok, message_id, log_id,
error) rather than a SendResult, since SendResult isn't JSON
serializable on its own.
HTTP API
Include the URLs under whatever prefix you like:
path("sms/", include("uzsms.urls"))
This resolves to POST /sms/send/ (route name send_sms). Importing
uzsms.urls without DRF installed raises SmsConfigurationError naming the
drf extra.
The endpoint requires authentication by default (SMS_SETTINGS["PERMISSION_CLASSES"]
defaults to ["rest_framework.permissions.IsAuthenticated"]). This is
deliberate: 1.0.1 shipped this endpoint as AllowAny, an open relay that let
anyone on the internet send SMS through your broker credentials. To relax
it, set PERMISSION_CLASSES explicitly, e.g. ["rest_framework.permissions.AllowAny"].
The endpoint is also throttled (THROTTLE_RATE, default "20/min").
Request body:
{"phone_number": "998901234567", "message": "hello there"}
Response envelope
Every response — success or failure — has exactly the same three top-level
keys: success, data, error. Exactly one of data/error is non-null.
HTTP status codes stay meaningful (201 created, 400 validation error,
401/403 unauthenticated/forbidden, 429 throttled, 502 upstream
broker failure).
Success (201 Created):
{
"success": true,
"data": {
"message_id": "b3f1...e2a9",
"log_id": 42,
"phone_number": "998901234567",
"status": "sent"
},
"error": null
}
message_id here is the client-generated correlation id (SmsMessage.message_id)
that identifies the request, not the broker's own id — it's assigned before
the send even happens. log_id is the SmsLog row's primary key, or null
when LOG_MESSAGES is False. status is "sent" or "failed".
Failure (e.g. 400 Bad Request, invalid phone number). message stays
the fixed string "Invalid request."; the field-level errors live in
error.detail, keyed by field name (DRF's own validation error shape):
{
"success": false,
"data": null,
"error": {
"code": "validation_error",
"message": "Invalid request.",
"detail": {
"phone_number": [
"'12345' is not a valid Uzbek phone number; expected '998' followed by nine digits."
]
}
}
}
A broker/upstream failure (502 Bad Gateway) never forwards the broker's
raw response body to the caller — only the upstream status code:
{
"success": false,
"data": null,
"error": {
"code": "provider_error",
"message": "The SMS provider rejected the request.",
"detail": {"status_code": 400}
}
}
DRF's own authentication, permission, throttling, and parsing errors are
re-shaped into this same envelope rather than DRF's bare {"detail": ...}
shape.
The SmsLog model
Every send (unless LOG_MESSAGES is False) writes a row to SmsLog
(table SMS_smslog, app label SMS — both pinned for backward
compatibility with 1.0.1's migration history location, even though the
Python package is now uzsms):
| Field | Type | Notes |
|---|---|---|
phone_number |
CharField(35) |
|
text |
TextField |
|
status |
CharField, choices |
"pending", "sent", or "failed" — see SmsLog.Status. |
created_at |
DateTimeField |
Auto-set on creation, indexed. |
sent_at |
DateTimeField, nullable |
Set when marked sent. |
message_id |
CharField(255) |
The client-generated correlation id (SmsMessage.message_id) that was sent on the wire. Only overwritten if the backend supplies its own SendResult.provider_message_id — no shipped backend currently does. Indexed. |
provider_response |
JSONField, nullable |
The broker's raw response body, when available. |
error |
TextField |
Error message on failure. |
is_active |
BooleanField, default False |
Deprecated, mirrors status == "sent". Scheduled for removal in 3.0 — read status instead. |
Rows go pending → sent/failed. A row that raises before the backend
even returns (e.g. a raised exception rather than a returned SendResult)
is still marked failed, with error set, rather than left pending
forever.
Troubleshooting
SmsConfigurationError: SMS_SETTINGS['URL'] is required but was not provided.You haven't setSMS_SETTINGS["URL"](or the legacySMS_URL). Same forLOGIN/PASSWORD.SmsConfigurationError: ... requires Django REST Framework ...when importinguzsms.urls. Install thedrfextra.SmsConfigurationError: ... requires httpx ...when usingAsyncSmsClient/AsyncPlaymobileBackend. Install theasyncextra.SmsConfigurationError: Celery is required ...when callingsend_sms_task. Install theceleryextra.- 401/403 from the send endpoint. Authentication is required by
default; either authenticate the request or relax
SMS_SETTINGS["PERMISSION_CLASSES"]. - 429 from the send endpoint. You've exceeded
SMS_SETTINGS["THROTTLE_RATE"]. - A send raises instead of returning
ok=False. SetSMS_SETTINGS["FAIL_SILENTLY"] = Trueif you'd rather inspectSendResult.ok/.errorthan catch exceptions. SmsBackendError: ... returned N result(s) for M message(s). A custom backend'ssend_messagesdidn't return exactly oneSendResultper message it was given — fix the backend.
Known limitations
These are accepted, deliberate tradeoffs, not oversights:
-
Partial-success responses are not detected.
PlaymobileBackend/AsyncPlaymobileBackendmap a single HTTP outcome onto every message in a batch: if the broker returns HTTP 200 for the whole batch but silently rejects individual recipients within it, those messages are recorded assentanyway. The broker does not document a per-message failure format within a 200 response, so this can't currently be detected. -
The cached async HTTP client has no event-loop affinity check.
get_async_client()inuzsms/backends/playmobile.pycaches onehttpx.AsyncClientper process, assuming a single, persistent event loop. Calling it from repeated separateasyncio.run()invocations in the same process can break the cached client. -
Resetting
SMS_SETTINGSleaks the async client.reset_async_client()drops the cachedhttpx.AsyncClientreference without callingaclose()on it (closing it is an async operation, and the reset runs from a synchronous Django signal handler) — everySMS_SETTINGSchange in a long-lived async process leaks one client.(Upgrading from 1.x and still calling the deprecated
SMS_Sender.create_sms_log? See thecreate_sms_logrow inUPGRADE.md→ "Before / after" for a behavior change you need to know about.)
License
MIT. See LICENSE.
Metadata
Release files for uzsms 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| uzsms-2.0.0.tar.gz | 62.8 kB | Without provenance | Details for uzsms-2.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| uzsms-2.0.0-py3-none-any.whl | Python 3 | none | any | Details for uzsms-2.0.0-py3-none-any.whl |
Total release size: 98.8 kB
Release files / uzsms-2.0.0.tar.gz
| Download URL | uzsms-2.0.0.tar.gz |
|---|---|
| Size | 62.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8e86a6cd4ff20a8717b0d4f0a11c8592d6f0d9d262bf6be030f1ff863724de3e
|
|
BLAKE2b-256 checksum How to use checksums |
7c11028b4f51bb431b7ce57008ad3d37784877b1abe18d695e54ae70c4126d6a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / uzsms-2.0.0-py3-none-any.whl
| Download URL | uzsms-2.0.0-py3-none-any.whl |
|---|---|
| Size | 36.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b9f733230edae9cd71919ed1d08c3d63a2fb2ac7e1ae6ec2ed1c9232d7c10c5e
|
|
BLAKE2b-256 checksum How to use checksums |
3365bcdaee9a4c80ef416e36ea555327cb6ab3fc91b0dcdf07541fd1d985e39a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Security for uzsms 2.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.