diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..eca5107 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,71 @@ +version: 2 +workflows: + version: 2 + test: + jobs: + - test-3.13 + - test-3.12 + - test-3.11 + - test-3.10 + - test-3.9 + - test-2.7 + - test-pypy3 + - test-pypy2 +jobs: + test-3.13: &test-template + docker: + - image: python:3.13 # We run one test in non-alpine environment, just in case + working_directory: ~/work + steps: + - run: + name: Ensure SSH + command: | + apk add --update openssh-client git || { + apt-get update && apt-get install -y openssh-client git + } + - checkout + - run: + name: Install dependencies + command: | + python -m venv venv || pypy -m venv venv || { + pip install virtualenv && virtualenv ./venv || + virtualenv -p "$(command -v python || command -v pypy)" ./venv; + } + - run: + name: Install project + command: | + . venv/bin/activate + pip install -e '.[tests]' + - run: + name: Run tests + command: | + . venv/bin/activate + pytest -v + test-3.12: + <<: *test-template + docker: + - image: python:3.12-alpine + test-3.11: + <<: *test-template + docker: + - image: python:3.11-alpine + test-3.10: + <<: *test-template + docker: + - image: python:3.10-alpine + test-3.9: + <<: *test-template + docker: + - image: python:3.9-alpine + test-2.7: + <<: *test-template + docker: + - image: python:2.7-alpine + test-pypy3: + <<: *test-template + docker: + - image: pypy:3-slim + test-pypy2: + <<: *test-template + docker: + - image: pypy:2-slim diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..7dd9a76 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: ["code-of-kpp", "eigenein"] diff --git a/.gitignore b/.gitignore index d2d6f36..7abb8ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,318 @@ + +# Created by https://www.gitignore.io/api/linux,macos,python,windows,virtualenv,sublimetext,intellij+all + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ *.py[cod] +*$py.class # C extensions *.so -# Packages -*.egg -*.egg-info -dist -build -eggs -parts -bin -var -sdist -develop-eggs +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ .installed.cfg -lib -lib64 +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec # Installer logs pip-log.txt +pip-delete-this-directory.txt # Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ .coverage -.tox +.coverage.* +.cache nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ # Translations *.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +### Python Patch ### +.venv/ + +### Python.VirtualEnv Stack ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + +### SublimeText ### +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + +### VirtualEnv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + -# Mr Developer -.mr.developer.cfg -.project -.pydevproject +# End of https://www.gitignore.io/api/linux,macos,python,windows,virtualenv,sublimetext,intellij+all diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..110b2b5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +### `2.2.1` + +* Fix: split message detection #182 #184 (@fpalamour) + +### `2.2.0` + +* New: allow customizing how error PDUs are handled (@davidshepherd7) +* New: ignoring unknown optional parameters (@davidshepherd7) +* New: add the option to create TLS/SSL sockets (@davidshepherd7) +* Fix: the max check should include the NULL terminator (Pedrum Mohageri) +* Fix: not always setting the socket timeout (@davidshepherd7) +* Fix: add mandatory parameters to GenericNack command (@stefanruijsenaars) +* Fix: handle errors on PDU payload retrieval (@stefanruijsenaars) + +### `2.1.0` + +* New: add option to not use UDHI when splitting long SMS +* New: add `query_sm` & `query_sm_resp` support +* New: argument to make automatic `enquire_link` optional +* New: make logger specific to each `Client` instance by @Lynesth +* Fix: incorrect `SMPP_UDHIEIE_PORT16` constant #81 +* Fix: `enquire_link_resp` now echo original sequence +* Fix: wait for the full PDU before parsing #82 +* Fix: add timeout to Client's properties #98 by @Lynesth +* Fix: `DataSM` param naming error: `alert_on_message_delivery` #108 by @nwnoga + +### `2.0.1` + +* Fix: don't use `%` operator in logging + +### `2.0` + +* Fix `TypeError` in `_generate_string_tlv` when encoding a value +* Support context manager interface, move `__del__` functionality to `__exit__` +* Change `callback_num` type to Octet String +* Add message state and network type constants +* Fix trailing NULL character in parsed octet strings +* Add optional fields for `deliver_sm` PDU (couldn't find them in specs but observed in real systems) +* Fix integers converted to strings +* Fix integer pack format for `size=4`, closes #51 +* Fix typos in `SMPP_INT_NOTIFICATION_*` constants +* Raise an error if `message_payload` is used together with `short_message` + +### `1.0.3` + +* Fix UCS-2 encoding: fixes #49 and #53 + +### `1.0.2` + +* Add `tox.ini`, support `2.6`, `2.7`, `3.4`, `3.5`, `3.6` and `3.7` +* Drop Python `3.2` and `3.3` support +* Improve PEP8-compliance in a few places +* Bump version to `1.0.2` and mark it as stable +* Add classifiers to `setup.py` +* Improve `.gitignore` with standard templates for popular environments +* Remove some dead code in comments diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..8fb6a51 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @eigenein @code-of-kpp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md index b445bf0..150c68d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,16 @@ -python-libsmpp -============== +`python-smpplib` +================ -SMPP library for Python. Forked from [google code](https://code.google.com/p/smpplib/). +[![Version](https://img.shields.io/pypi/v/smpplib.svg?style=flat)](https://pypi.org/project/smpplib/#history) +[![Python versions](https://img.shields.io/pypi/pyversions/smpplib.svg?style=flat)](https://pypi.org/project/smpplib/) +[![PyPI downloads](https://img.shields.io/pypi/dm/smpplib.svg?style=flat)](https://pypi.org/project/smpplib/#files) +![License](https://img.shields.io/pypi/l/smpplib.svg?style=flat) +[![CircleCI](https://circleci.com/gh/python-smpplib/python-smpplib.svg?style=svg)](https://circleci.com/gh/python-smpplib/python-smpplib) + +SMPP library for Python. Forked from [Google Code](https://code.google.com/p/smpplib/). Example: + ```python import logging import sys @@ -18,7 +25,7 @@ logging.basicConfig(level='DEBUG') # Two parts, UCS2, SMS with UDH parts, encoding_flag, msg_type_flag = smpplib.gsm.make_parts(u'Привет мир!\n'*10) -client = smpplib.client.Client('example.com', SOMEPORTNUMBER) +client = smpplib.client.Client('example.com', SOMEPORTNUMBER, allow_unknown_opt_params=True) # Print when obtain message_id client.set_message_sent_handler( @@ -47,6 +54,8 @@ for part in parts: registered_delivery=True, ) print(pdu.sequence) + +# Enters a loop, waiting for incoming PDUs client.listen() ``` You also may want to listen in a thread: @@ -55,10 +64,12 @@ from threading import Thread t = Thread(target=client.listen) t.start() ``` +**Note:** When listening, the client will automatically send an `enquire_link` command when the socket timeouts. You may override that behavior by passing `auto_send_enquire_link=False` as an argument to `listen()`. In that case, `socket.timeout` exceptions will bubble up. The client supports setting a custom generator that produces sequence numbers for the PDU packages. Per default a simple in memory generator is used which in conclusion is reset on (re)instantiation of the client, e.g. by an application restart. If you want to keep the sequence number to be persisted across restarts you can implement your own storage backed generator. Example: + ```python import smpplib.client diff --git a/setup.py b/setup.py index a2a4355..b12fc31 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,44 @@ -from setuptools import setup, find_packages -import sys +import io -extra = {} -if sys.version_info >= (3,): - extra['use_2to3'] = True +from setuptools import find_packages, setup -setup(name="python-smpplib", - version='1.0.1', - url='https://github.com/podshumok/python-smpplib', - description='SMPP library for python', - packages=find_packages(), - zip_safe=True, - classifiers=[ - 'Development Status :: 4 - Beta', +try: + long_description_kwd=dict( + long_description=io.open('README.md', 'rt', encoding='utf-8').read(), + long_description_content_type='text/markdown', + ) +except OSError: + long_description_kwd=dict() + +setup( + name='smpplib', + version='2.2.4', + url='https://github.com/python-smpplib/python-smpplib', + description='SMPP library for python', + packages=find_packages(), + install_requires=['six'], + extras_require=dict( + tests=('typing; python_version < "3.5"', 'pytest', 'mock'), + ), + zip_safe=True, + classifiers=( + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Telecommunications Industry', + 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.1', - 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python', 'Topic :: Communications :: Telephony', - 'Intended Audience :: Telecommunications Industry', - 'License :: OSI Approved', - ], - **extra + 'Topic :: Communications', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Software Development :: Libraries', + ), + **long_description_kwd ) diff --git a/smpplib/__init__.py b/smpplib/__init__.py index 27b6520..2749e6d 100644 --- a/smpplib/__init__.py +++ b/smpplib/__init__.py @@ -15,13 +15,5 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -# -# -# Modified by Yusuf Kaka -# Added support for Optional TLV's -from . import smpp -from . import pdu -from . import command -from . import client -from . import exceptions +from smpplib import client, command, exceptions, pdu, smpp diff --git a/smpplib/client.py b/smpplib/client.py index 712f2fa..a335d15 100644 --- a/smpplib/client.py +++ b/smpplib/client.py @@ -15,36 +15,31 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -# -# -# Modified by Yusuf Kaka -# Added support for Optional TLV's """SMPP client module""" -import socket -import struct import binascii import logging +import select +import socket +import struct +import warnings -from . import smpp -from . import exceptions -from . import consts +from smpplib import consts, exceptions, smpp -logger = logging.getLogger('smpplib.client') class SimpleSequenceGenerator(object): - + MIN_SEQUENCE = 0x00000001 MAX_SEQUENCE = 0x7FFFFFFF - + def __init__(self): self._sequence = self.MIN_SEQUENCE - + @property def sequence(self): return self._sequence - + def next_sequence(self): if self._sequence == self.MAX_SEQUENCE: self._sequence = self.MIN_SEQUENCE @@ -52,6 +47,7 @@ def next_sequence(self): self._sequence += 1 return self._sequence + class Client(object): """SMPP client class""" @@ -61,47 +57,85 @@ class Client(object): port = None vendor = None _socket = None + _ssl_context = None sequence_generator = None - def __init__(self, host, port, timeout=5, sequence_generator=None): - """Initialize""" - + def __init__( + self, + host, + port, + timeout=5, + sequence_generator=None, + logger_name=None, + ssl_context=None, + allow_unknown_opt_params=None, + ): self.host = host self.port = int(port) - self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.settimeout(timeout) - self.receiver_mode = False + self._ssl_context = ssl_context + self.timeout = timeout + self.logger = logging.getLogger(logger_name or 'smpp.Client.{}'.format(id(self))) if sequence_generator is None: sequence_generator = SimpleSequenceGenerator() self.sequence_generator = sequence_generator - def __del__(self): - """Disconnect when client object is destroyed""" + if allow_unknown_opt_params is None: + warnings.warn( + "Unknown optional parameters during PDU parsing will stop " + "causing an exception in a future smpplib version " + "(in order to comply with the SMPP spec). To switch behavior " + "now set allow_unknown_opt_params to True.", + DeprecationWarning, + ) + self.allow_unknown_opt_params = False + else: + self.allow_unknown_opt_params = allow_unknown_opt_params + + + self._socket = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): if self._socket is not None: try: self.unbind() - except (exceptions.PDUError, exceptions.ConnectionError), e: + except (exceptions.PDUError, exceptions.ConnectionError) as e: if len(getattr(e, 'args', tuple())) > 1: - logger.warning('(%d) %s. Ignored', e.args[1], e.args[0]) + self.logger.warning('(%d) %s. Ignored', e.args[1], e.args[0]) else: - logger.warning('%s. Ignored', e) + self.logger.warning('%s. Ignored', e) self.disconnect() + def __del__(self): + if self._socket is not None: + self.logger.warning('%s was not closed', self) + @property def sequence(self): return self.sequence_generator.sequence - + def next_sequence(self): return self.sequence_generator.next_sequence() + def _create_socket(self): + raw_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw_socket.settimeout(self.timeout) + + if self._ssl_context is None: + return raw_socket + + return self._ssl_context.wrap_socket(raw_socket) + def connect(self): """Connect to SMSC""" - logger.info('Connecting to %s:%s...', self.host, self.port) + self.logger.info('Connecting to %s:%s...', self.host, self.port) try: if self._socket is None: - self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket = self._create_socket() self._socket.connect((self.host, self.port)) self.state = consts.SMPP_CLIENT_STATE_OPEN except socket.error: @@ -109,8 +143,10 @@ def connect(self): def disconnect(self): """Disconnect from the SMSC""" - logger.info('Disconnecting...') + self.logger.info('Disconnecting...') + if self.state != consts.SMPP_CLIENT_STATE_OPEN: + self.logger.warning('%s is disconnecting in the bound state', self) if self._socket is not None: self._socket.close() self._socket = None @@ -120,10 +156,8 @@ def _bind(self, command_name, **kwargs): """Send bind_transmitter command to the SMSC""" if command_name in ('bind_receiver', 'bind_transceiver'): - logger.debug('Receiver mode') - self.receiver_mode = True + self.logger.debug('Receiver mode') - #smppinst = smpp.get_instance() p = smpp.make_pdu(command_name, client=self, **kwargs) self.send_pdu(p) @@ -132,9 +166,12 @@ def _bind(self, command_name, **kwargs): except socket.timeout: raise exceptions.ConnectionError() if resp.is_error(): - raise exceptions.PDUError( - '({}) {}: {}'.format(resp.status, resp.command, - consts.DESCRIPTIONS.get(resp.status, 'Unknown code')), int(resp.status)) + raise exceptions.PDUError('({}) {}: {}'.format( + resp.status, + resp.command, + consts.DESCRIPTIONS.get(resp.status, 'Unknown code')), + int(resp.status), + ) return resp def bind_transmitter(self, **kwargs): @@ -163,88 +200,99 @@ def unbind(self): def send_pdu(self, p): """Send PDU to the SMSC""" - if not self.state in consts.COMMAND_STATES[p.command]: - raise exceptions.PDUError("Command %s failed: %s" % - (p.command, consts.DESCRIPTIONS[consts.SMPP_ESME_RINVBNDSTS])) - - logger.debug('Sending %s PDU', p.command) + if self.state not in consts.COMMAND_STATES[p.command]: + raise exceptions.PDUError("Command %s failed: %s" % ( + p.command, + consts.DESCRIPTIONS[consts.SMPP_ESME_RINVBNDSTS], + )) + self.logger.debug('Sending %s PDU', p.command) generated = p.generate() + self.logger.debug('>>%s (%d bytes)', binascii.b2a_hex(generated), len(generated)) - logger.debug('>>%s (%d bytes)', binascii.b2a_hex(generated), - len(generated)) + try: + self._socket.sendall(generated) + except socket.error as e: + self.logger.warning(e) + raise exceptions.ConnectionError() - sent = 0 + return True - while sent < len(generated): - sent_last = 0 + def _recv_exact(self, exact_size): + """ + Keep reading from self._socket until exact_size bytes have been read + """ + parts = [] + received = 0 + while received < exact_size: try: - sent_last = self._socket.send(generated[sent:]) - except socket.error, e: - logger.warning(e) + part = self._socket.recv(exact_size - received) + except socket.timeout: + raise + except socket.error as e: + self.logger.warning(e) raise exceptions.ConnectionError() - if sent_last == 0: + if not part: raise exceptions.ConnectionError() - sent += sent_last - - return True + received += len(part) + parts.append(part) + return b"".join(parts) def read_pdu(self): """Read PDU from the SMSC""" - logger.debug('Waiting for PDU...') + self.logger.debug('Waiting for PDU...') - try: - raw_len = self._socket.recv(4) - except socket.timeout: - raise - except socket.error, e: - logger.warning(e) - raise exceptions.ConnectionError() - if not raw_len: - raise exceptions.ConnectionError() + raw_len = self._recv_exact(4) try: length = struct.unpack('>L', raw_len)[0] except struct.error: - logger.warning('Receive broken pdu... %s', repr(raw_len)) + self.logger.warning('Receive broken pdu... %s', repr(raw_len)) raise exceptions.PDUError('Broken PDU') - raw_pdu = self._socket.recv(length - 4) - raw_pdu = raw_len + raw_pdu + raw_pdu = raw_len + self._recv_exact(length - 4) - logger.debug('<<%s (%d bytes)', binascii.b2a_hex(raw_pdu), len(raw_pdu)) + self.logger.debug('<<%s (%d bytes)', binascii.b2a_hex(raw_pdu), len(raw_pdu)) - p = smpp.parse_pdu(raw_pdu, client=self) + pdu = smpp.parse_pdu( + raw_pdu, + client=self, + allow_unknown_opt_params=self.allow_unknown_opt_params, + ) - logger.debug('Read %s PDU', p.command) + self.logger.debug('Read %s PDU', pdu.command) - if p.is_error(): - return p + if pdu.is_error(): + return pdu - elif p.command in consts.STATE_SETTERS: - self.state = consts.STATE_SETTERS[p.command] + elif pdu.command in consts.STATE_SETTERS: + self.state = consts.STATE_SETTERS[pdu.command] - return p + return pdu def accept(self, obj): """Accept an object""" raise NotImplementedError('not implemented') - def _message_received(self, p): + def _message_received(self, pdu): """Handler for received message event""" - self.message_received_handler(pdu=p) - dsmr = smpp.make_pdu('deliver_sm_resp', client=self) - #, message_id=args['pdu'].sm_default_msg_id) - dsmr.sequence = p.sequence + status = self.message_received_handler(pdu=pdu) + if status is None: + status = consts.SMPP_ESME_ROK + dsmr = smpp.make_pdu('deliver_sm_resp', client=self, status=status) + dsmr.sequence = pdu.sequence self.send_pdu(dsmr) - def _enquire_link_received(self): + def _enquire_link_received(self, pdu): """Response to enquire_link""" ler = smpp.make_pdu('enquire_link_resp', client=self) - #, message_id=args['pdu'].sm_default_msg_id) + ler.sequence = pdu.sequence self.send_pdu(ler) - logger.debug("Link Enquiry...") + + def _alert_notification(self, pdu): + """Handler for alert notification event""" + self.message_received_handler(pdu=pdu) def set_message_received_handler(self, func): """Set new function to handle message receive event""" @@ -253,58 +301,97 @@ def set_message_received_handler(self, func): def set_message_sent_handler(self, func): """Set new function to handle message sent event""" self.message_sent_handler = func + + def set_query_resp_handler(self, func): + """Set new function to handle query resp event""" + self.query_resp_handler = func - @staticmethod - def message_received_handler(pdu, **kwargs): + def set_error_pdu_handler(self, func): + """Set new function to handle PDUs with an error status""" + self.error_pdu_handler = func + + def message_received_handler(self, pdu, **kwargs): """Custom handler to process received message. May be overridden""" + self.logger.warning('Message received handler (Override me)') - logger.warning('Message received handler (Override me)') + def message_sent_handler(self, pdu, **kwargs): + """ + Called when SMPP server accept message (SUBMIT_SM_RESP). + May be overridden + """ + self.logger.warning('Message sent handler (Override me)') + + def query_resp_handler(self, pdu, **kwargs): + """Custom handler to process response to queries. May be overridden""" + self.logger.warning('Query resp handler (Override me)') + + def error_pdu_handler(self, pdu): + raise exceptions.PDUError('({}) {}: {}'.format( + pdu.status, + pdu.command, + consts.DESCRIPTIONS.get(pdu.status, 'Unknown status')), + int(pdu.status), + ) + + def read_once(self, ignore_error_codes=None, auto_send_enquire_link=True): + """Read a PDU and act""" + + if ignore_error_codes is not None: + warnings.warn( + "ignore_error_codes is deprecated, use set_error_pdu_handler to " + "configure a custom error PDU handler instead.", + DeprecationWarning, + ) - @staticmethod - def message_sent_handler(pdu, **kwargs): - """Called when SMPP server accept message (SUBMIT_SM_RESP). - May be overridden""" - logger.warning('Message sent handler (Override me)') + try: + try: + pdu = self.read_pdu() + except socket.timeout: + if not auto_send_enquire_link: + raise + self.logger.debug('Socket timeout, listening again') + pdu = smpp.make_pdu('enquire_link', client=self) + self.send_pdu(pdu) + return + + if pdu.is_error(): + self.error_pdu_handler(pdu) + + if pdu.command == 'unbind': # unbind_res + self.logger.info('Unbind command received') + return + elif pdu.command == 'submit_sm_resp': + self.message_sent_handler(pdu=pdu) + elif pdu.command == 'deliver_sm': + self._message_received(pdu) + elif pdu.command == 'query_sm_resp': + self.query_resp_handler(pdu) + elif pdu.command == 'enquire_link': + self._enquire_link_received(pdu) + elif pdu.command == 'enquire_link_resp': + pass + elif pdu.command == 'alert_notification': + self._alert_notification(pdu) + else: + self.logger.warning('Unhandled SMPP command "%s"', pdu.command) + except exceptions.PDUError as e: + if ignore_error_codes and len(e.args) > 1 and e.args[1] in ignore_error_codes: + self.logger.warning('(%d) %s. Ignored.', e.args[1], e.args[0]) + else: + raise + + def poll(self, ignore_error_codes=None, auto_send_enquire_link=True): + """Act on available PDUs and return""" + while True: + readable, _writable, _exceptional = select.select([self._socket], [], [], 0) + if not readable: + break + self.read_once(ignore_error_codes, auto_send_enquire_link) - def listen(self, ignore_error_codes=None): + def listen(self, ignore_error_codes=None, auto_send_enquire_link=True): """Listen for PDUs and act""" - while True: - try: - try: - p = self.read_pdu() - except socket.timeout: - logger.debug('Socket timeout, listening again') - p = smpp.make_pdu('enquire_link', client=self) - self.send_pdu(p) - continue - - if p.is_error(): - raise exceptions.PDUError( - '({}) {}: {}'.format(p.status, p.command, - consts.DESCRIPTIONS.get(p.status, 'Unknown status')), int(p.status)) - - if p.command == 'unbind': # unbind_res - logger.info('Unbind command received') - break - elif p.command == 'submit_sm_resp': - self.message_sent_handler(pdu=p) - elif p.command == 'deliver_sm': - self._message_received(p) - elif p.command == 'enquire_link': - self._enquire_link_received() - elif p.command == 'enquire_link_resp': - pass - else: - logger.warning('Unhandled SMPP command "%s"', p.command) - except exceptions.PDUError, e: - if ignore_error_codes \ - and len(e.args) > 1 \ - and e.args[1] in ignore_error_codes: - logging.warning('(%d) %s. Ignored.' % - (e.args[1], e.args[0])) - else: - raise + self.read_once(ignore_error_codes, auto_send_enquire_link) def send_message(self, **kwargs): """Send message @@ -320,3 +407,17 @@ def send_message(self, **kwargs): ssm = smpp.make_pdu('submit_sm', client=self, **kwargs) self.send_pdu(ssm) return ssm + + def query_message(self, **kwargs): + """Query message state + + Required Arguments: + message_id -- SMSC assigned Message ID + source_addr_ton -- Original source address TON + source_addr_npi -- Original source address NPI + source_addr -- Original source address (string) + """ + + qsm = smpp.make_pdu('query_sm', client=self, **kwargs) + self.send_pdu(qsm) + return qsm diff --git a/smpplib/command.py b/smpplib/command.py index f6830ca..bde5840 100644 --- a/smpplib/command.py +++ b/smpplib/command.py @@ -15,20 +15,17 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -# -# Modified by Yusuf Kaka -# Added support for Optional TLV's """SMPP Commands module""" -import struct import logging +import struct + +import six -from . import pdu -from . import exceptions -from . import consts -from .ptypes import ostr, flag +from smpplib import consts, exceptions, pdu +from smpplib.ptypes import flag, ostr logger = logging.getLogger('smpplib.command') @@ -51,26 +48,27 @@ def factory(command_name, **kwargs): 'submit_sm_resp': SubmitSMResp, 'deliver_sm': DeliverSM, 'deliver_sm_resp': DeliverSMResp, + 'query_sm': QuerySM, + 'query_sm_resp': QuerySMResp, 'unbind': Unbind, 'unbind_resp': UnbindResp, 'enquire_link': EnquireLink, 'enquire_link_resp': EnquireLinkResp, + 'alert_notification': AlertNotification, }[command_name](command_name, **kwargs) except KeyError: - raise exceptions.UnknownCommandError( - 'Command "%s" is not supported' % command_name) + raise exceptions.UnknownCommandError('Command "%s" is not supported' % command_name) def get_optional_name(code): """Return optional_params name by given code. If code is unknown, raise UnkownCommandError exception""" - for key, value in consts.OPTIONAL_PARAMS.iteritems(): + for key, value in six.iteritems(consts.OPTIONAL_PARAMS): if value == code: return key - raise exceptions.UnknownCommandError( - 'Unknown SMPP command code "0x%x"' % code) + raise exceptions.UnknownCommandError('Unknown SMPP command code "0x%x"' % code) def get_optional_code(name): @@ -80,8 +78,11 @@ def get_optional_code(name): try: return consts.OPTIONAL_PARAMS[name] except KeyError: - raise exceptions.UnknownCommandError( - 'Unknown SMPP command name "%s"' % name) + raise exceptions.UnknownCommandError('Unknown SMPP command name "%s"' % name) + + +def unpack_short(data, pos): + return struct.unpack('>H', data[pos:pos+2])[0], pos + 2 class Command(pdu.PDU): @@ -89,26 +90,23 @@ class Command(pdu.PDU): params = {} - def __init__(self, command, need_sequence=True, **kwargs): - """Initialize""" - + def __init__(self, command, need_sequence=True, allow_unknown_opt_params=False, **kwargs): super(Command, self).__init__(**kwargs) + self.allow_unknown_opt_params = allow_unknown_opt_params + self.command = command if need_sequence and (kwargs.get('sequence') is None): self.sequence = self._next_seq() - self.status = consts.SMPP_ESME_ROK + if kwargs.get('status') is None: + self.status = consts.SMPP_ESME_ROK - #if self.is_vendor() and self.vdefs: - # self.defs = self.defs + self.vdefs - - #self.__dict__.update(**(args)) self._set_vars(**kwargs) def _set_vars(self, **kwargs): """set attributes accordingly to kwargs""" - for key, value in kwargs.iteritems(): + for key, value in six.iteritems(kwargs): if not hasattr(self, key) or getattr(self, key) is None: setattr(self, key, value) @@ -118,12 +116,10 @@ def generate_params(self): if hasattr(self, 'prep') and callable(self.prep): self.prep() - body = '' + body = consts.EMPTY_STRING for field in self.params_order: - #print field param = self.params[field] - #print param if self.field_is_optional(field): if param.type is int: value = self._generate_int_tlv(field) @@ -148,7 +144,6 @@ def generate_params(self): value = self._generate_ostring(field) if value: body += value - #print value return body def _generate_opt_header(self, field): @@ -159,12 +154,12 @@ def _generate_opt_header(self, field): def _generate_int(self, field): """Generate integer value""" - fmt = self._pack_format(field) + fmt = self._int_pack_format(field) data = getattr(self, field) if data: - return struct.pack(fmt, data) + return struct.pack(">" + fmt, data) else: - return chr(0) # null terminator + return consts.NULL_STRING def _generate_string(self, field): """Generate string value""" @@ -175,7 +170,7 @@ def _generate_string(self, field): size = self.params[field].size value = field_value.ljust(size, chr(0)) elif hasattr(self.params[field], 'max'): - if len(field_value or '') > self.params[field].max: + if len(field_value or '') >= self.params[field].max: field_value = field_value[0:self.params[field].max - 1] if field_value: @@ -184,7 +179,7 @@ def _generate_string(self, field): value = chr(0) setattr(self, field, field_value) - return value + return six.b(value) def _generate_ostring(self, field): """Generate octet string value (no null terminator)""" @@ -197,14 +192,13 @@ def _generate_ostring(self, field): def _generate_int_tlv(self, field): """Generate integer value""" - fmt = self._pack_format(field) + fmt = self._int_pack_format(field) data = getattr(self, field) field_code = get_optional_code(field) field_length = self.params[field].size value = None - if data: + if data is not None: value = struct.pack(">HH" + fmt, field_code, field_length, data) - #print binascii.b2a_hex(value) return value def _generate_string_tlv(self, field): @@ -222,13 +216,11 @@ def _generate_string_tlv(self, field): field_value = field_value[0:self.params[field].max - 1] if field_value: - field_length = len(field_value) fvalue = field_value + chr(0) - value = struct.pack(">HH", field_code, field_length) + fvalue - #print binascii.b2a_hex(value) + field_length = len(fvalue) + value = struct.pack(">HH", field_code, field_length) + fvalue.encode() else: value = None # chr(0) - #setattr(self, field, field_value) return value def _generate_ostring_tlv(self, field): @@ -243,55 +235,52 @@ def _generate_ostring_tlv(self, field): if field_value: field_length = len(field_value) value = struct.pack(">HH", field_code, field_length) + field_value - #print binascii.b2a_hex(value) return value - def _pack_format(self, field): + def _int_pack_format(self, field): """Return format type""" - - if self.params[field].size == 1: - return 'B' - elif self.params[field].size == 2: - return 'H' - elif self.params[field].size == 3: - return 'L' - return None + return consts.INT_PACK_FORMATS[self.params[field].size] def _parse_int(self, field, data, pos): - """Parse fixed-length chunk from a PDU. - Return (data, pos) tuple.""" + """ + Parse fixed-length chunk from a PDU. + Return (data, pos) tuple. + """ size = self.params[field].size - field_value = getattr(self, field) - unpacked_data = self._unpack(self._pack_format(field), - data[pos:pos + size]) - field_value = ''.join(map(str, unpacked_data)) + fmt = self._int_pack_format(field) + field_value, = struct.unpack(">" + fmt, data[pos:pos + size]) setattr(self, field, field_value) pos += size return data, pos - def _parse_string(self, field, data, pos): - """Parse variable-length string from a PDU. - Return (data, pos) tuple.""" + def _parse_string(self, field, data, pos, length=None): + """ + Parse variable-length string from a PDU. + Return (data, pos) tuple. + """ - end = data.find(chr(0), pos) - length = end - pos + if length is None: + end = data.find(consts.NULL_STRING, pos) + length = end - pos + else: + length -= 1 # length includes trailing NULL character - field_value = data[pos:pos + length] - setattr(self, field, field_value) + setattr(self, field, data[pos:pos + length]) pos += length + 1 return data, pos def _parse_ostring(self, field, data, pos, length=None): - """Parse an octet string from a PDU. - Return (data, pos) tuple.""" + """ + Parse an octet string from a PDU. + Return (data, pos) tuple. + """ if length is None: length_field = self.params[field].len_field length = int(getattr(self, length_field)) - #print length_field, type(length_field), length, type(length_field) setattr(self, field, data[pos:pos + length]) pos += length @@ -322,9 +311,7 @@ def parse_params(self, data): data, pos = self._parse_string(field, data, pos) elif param.type is ostr: data, pos = self._parse_ostring(field, data, pos) - #print pos,field,data if pos < dlen: - #None self.parse_optional_params(data[pos:]) def parse_optional_params(self, data): @@ -335,45 +322,27 @@ def parse_optional_params(self, data): * length (2 bytes) * value (variable, bytes) """ - - #print binascii.b2a_hex(data) - #print len(data) dlen = len(data) pos = 0 while pos < dlen: - #print pos - #unpacked_data1,unpacked_data2 = struct.unpack('2B', - # data[pos:pos+2]) - #pack = struct.pack(unpacked_data2,unpacked_data1) - #unpacked_data = struct.unpack('H', pack) - unpacked_data = struct.unpack('>H', data[pos:pos + 2]) - type_code = int(''.join(map(str, unpacked_data))) - - #print type_code - #field=None - field = get_optional_name(type_code) - #try: - # field = \ - # optional_params.keys()[\ - # optional_params.values().index(type_code)] - - #except ValueError: - # raise ValueError("Type '0x%x' not found" % type_code) - #print ("Type '0x%x' not found" % type_code) - - #if field != None: - pos += 2 - - length = int(''.join(map(str, struct.unpack('!H', - data[pos:pos + 2])))) - pos += 2 - param = self.params[field] + type_code, pos = unpack_short(data, pos) + length, pos = unpack_short(data, pos) + + try: + field = get_optional_name(type_code) + except exceptions.UnknownCommandError as e: + if self.allow_unknown_opt_params: + logger.warning("Unknown optional parameter type 0x%x, skipping", type_code) + pos += length + continue + raise + param = self.params[field] if param.type is int: data, pos = self._parse_int(field, data, pos) elif param.type is str: - data, pos = self._parse_string(field, data, pos) + data, pos = self._parse_string(field, data, pos, length) elif param.type is ostr: data, pos = self._parse_ostring(field, data, pos, length) @@ -384,7 +353,9 @@ def field_exists(self, field): def field_is_optional(self, field): """Return True if field is optional, False otherwise""" - if field in consts.OPTIONAL_PARAMS: + if hasattr(self, 'mandatory_fields') and field in self.mandatory_fields: + return False + elif field in consts.OPTIONAL_PARAMS: return True elif self.is_vendor(): # FIXME: No vendor support yet @@ -397,14 +368,11 @@ class Param(object): """Command parameter info class""" def __init__(self, **kwargs): - """Initialize""" - if 'type' not in kwargs: raise KeyError('Parameter Type not defined') if kwargs.get('type') not in (int, str, ostr, flag): - raise ValueError("Invalid parameter type: %s" - % kwargs.get('type')) + raise ValueError("Invalid parameter type: %s" % kwargs.get('type')) valid_keys = ('type', 'size', 'min', 'max', 'len_field') for k in kwargs: @@ -436,14 +404,13 @@ class BindTransmitter(Command): } # Order is important, but params dictionary is unordered - params_order = ('system_id', 'password', 'system_type', - 'interface_version', 'addr_ton', 'addr_npi', 'address_range') + params_order = ( + 'system_id', 'password', 'system_type', + 'interface_version', 'addr_ton', 'addr_npi', 'address_range', + ) def __init__(self, command, **kwargs): - """Initialize""" - - super(BindTransmitter, self).__init__(command, need_sequence=False, - **kwargs) + super(BindTransmitter, self).__init__(command, **kwargs) self._set_vars(**(dict.fromkeys(self.params))) self.interface_version = consts.SMPP_VERSION_34 @@ -452,14 +419,12 @@ def __init__(self, command, **kwargs): class BindReceiver(BindTransmitter): """Bind as a receiver command""" def __init__(self, command, **kwargs): - """Initialize""" super(BindReceiver, self).__init__(command, **kwargs) class BindTransceiver(BindTransmitter): - """Bind as reciever and transmitter command""" + """Bind as receiver and transmitter command""" def __init__(self, command, **kwargs): - """Initialize""" super(BindTransceiver, self).__init__(command, **kwargs) @@ -467,14 +432,13 @@ class BindTransmitterResp(Command): """Response for bind as a transmitter command""" params = { - 'system_id': Param(type=str), + 'system_id': Param(type=str, max=16), 'sc_interface_version': Param(type=int, size=1), } params_order = ('system_id', 'sc_interface_version') def __init__(self, command, **kwargs): - """Initialize""" super(BindTransmitterResp, self).__init__(command, need_sequence=False, **kwargs) @@ -484,14 +448,12 @@ def __init__(self, command, **kwargs): class BindReceiverResp(BindTransmitterResp): """Response for bind as a reciever command""" def __init__(self, command, **kwargs): - """Initialize""" super(BindReceiverResp, self).__init__(command, **kwargs) class BindTransceiverResp(BindTransmitterResp): """Response for bind as a transceiver command""" def __init__(self, command, **kwargs): - """Initialize""" super(BindTransceiverResp, self).__init__(command, **kwargs) @@ -533,7 +495,7 @@ class DataSM(Command): 'network_error_code': Param(type=ostr, size=3), 'user_message_reference': Param(type=int, size=2), 'privacy_indicator': Param(type=int, size=1), - 'callback_num': Param(type=str, min=4, max=19), + 'callback_num': Param(type=ostr, min=4, max=19), 'callback_num_pres_ind': Param(type=int, size=1), 'callback_num_atag': Param(type=str, max=65), 'source_subaddress': Param(type=str, min=2, max=23), @@ -544,15 +506,16 @@ class DataSM(Command): 'ms_validity': Param(type=int, size=1), 'ms_msg_wait_facilities': Param(type=int, size=1), 'number_of_messages': Param(type=int, size=1), - 'alert_on_msg_delivery': Param(type=flag), + 'alert_on_message_delivery': Param(type=flag), 'language_indicator': Param(type=int, size=1), 'its_reply_type': Param(type=int, size=1), - 'its_session_info': Param(type=int, size=2) + 'its_session_info': Param(type=int, size=2), } - params_order = ('service_type', 'source_addr_ton', 'source_addr_npi', + params_order = ( + 'service_type', 'source_addr_ton', 'source_addr_npi', 'source_addr', 'dest_addr_ton', 'dest_addr_npi', 'destination_addr', - 'esm_class', 'registered_delivery', 'data_coding' + 'esm_class', 'registered_delivery', 'data_coding', # Optional params: 'source_port', 'source_addr_subunit', 'source_network_type', @@ -567,39 +530,49 @@ class DataSM(Command): 'user_response_code', 'display_time', 'sms_signal', 'ms_validity', 'ms_msg_wait_facilities', 'number_of_messages', 'alert_on_message_delivery', 'language_indicator', 'its_reply_type', - 'its_session_info') + 'its_session_info', + ) def __init__(self, command, **kwargs): - """Initialize""" super(DataSM, self).__init__(command, **kwargs) self._set_vars(**(dict.fromkeys(self.params))) class DataSMResp(Command): """Reponse command for data_sm""" + params = { + 'message_id': Param(type=str, max=65), - message_id = None - delivery_failure_reason = None - network_error_code = None - additional_status_info_text = None - dpf_result = None + # Optional params: + #type size is implementation specific. + 'delivery_failure_reason': Param(type=str, max=256), + 'network_error_code': Param(type=str, max=3), + 'additional_status_info_text': Param(type=str, max=256), + 'dpf_result': Param(type=int, size=1), + } - def __init__(self, command, **kwargs): - """Initialize""" + params_order = ( + 'message_id', + + # Optional params: + 'delivery_failure_reason', 'network_error_code', 'additional_status_info_text', + 'dpf_result', + ) + def __init__(self, command, **kwargs): super(DataSMResp, self).__init__(command, **kwargs) + self._set_vars(**(dict.fromkeys(self.params))) class GenericNAck(Command): """General Negative Acknowledgement class""" + params = {} + params_order = () _defs = [] def __init__(self, command, **kwargs): - """Initialize""" - - super(GenericNAck, self).__init__(command, need_sequence=False, - **kwargs) + super(GenericNAck, self).__init__(command, need_sequence=False, **kwargs) class SubmitSM(Command): @@ -664,7 +637,7 @@ class SubmitSM(Command): # Encoding scheme of the short messaege data data_coding = None # SMPP_ENCODING_DEFAULT#ISO10646 - # Indicates the short message to send from a list of predefined + # Indicates the short message to send from a list of predefined # ('canned') short messages stored on the SMSC sm_default_msg_id = None @@ -694,8 +667,8 @@ class SubmitSM(Command): 'data_coding': Param(type=int, size=1), 'sm_default_msg_id': Param(type=int, size=1), 'sm_length': Param(type=int, size=1), - 'short_message': Param(type=ostr, max=254, - len_field='sm_length'), + 'short_message': Param(type=ostr, max=254, len_field='sm_length'), + # Optional params 'user_message_reference': Param(type=int, size=2), 'source_port': Param(type=int, size=2), @@ -709,7 +682,7 @@ class SubmitSM(Command): 'payload_type': Param(type=int, size=1), 'message_payload': Param(type=ostr, max=260), 'privacy_indicator': Param(type=int, size=1), - 'callback_num': Param(type=str, min=4, max=19), + 'callback_num': Param(type=ostr, min=4, max=19), 'callback_num_pres_ind': Param(type=int, size=1), 'source_subaddress': Param(type=str, min=2, max=23), 'dest_subaddress': Param(type=str, min=2, max=23), @@ -726,7 +699,8 @@ class SubmitSM(Command): 'ussd_service_op': Param(type=int, size=1), } - params_order = ('service_type', 'source_addr_ton', 'source_addr_npi', + params_order = ( + 'service_type', 'source_addr_ton', 'source_addr_npi', 'source_addr', 'dest_addr_ton', 'dest_addr_npi', 'destination_addr', 'esm_class', 'protocol_id', 'priority_flag', 'schedule_delivery_time', 'validity_period', 'registered_delivery', @@ -743,10 +717,10 @@ class SubmitSM(Command): 'sms_signal', 'ms_validity', 'ms_msg_wait_facilities', 'number_of_messages', 'alert_on_message_delivery', 'language_indicator', 'its_reply_type', 'its_session_info', - 'ussd_service_op') + 'ussd_service_op', + ) def __init__(self, command, **kwargs): - """Initialize""" super(SubmitSM, self).__init__(command, **kwargs) self._set_vars(**(dict.fromkeys(self.params))) @@ -754,9 +728,9 @@ def prep(self): """Prepare to generate binary data""" if self.short_message: + if getattr(self, 'message_payload', None): + raise ValueError('`message_payload` can not be used with `short_message`') self.sm_length = len(self.short_message) - if hasattr(self, 'message_payload'): - delattr(self, 'message_payload') else: self.sm_length = 0 @@ -765,15 +739,13 @@ class SubmitSMResp(Command): """Response command for submit_sm""" params = { - 'message_id': Param(type=str, max=65) + 'message_id': Param(type=str, max=65), } params_order = ('message_id',) def __init__(self, command, **kwargs): - """Initialize""" - super(SubmitSMResp, self).__init__(command, need_sequence=False, - **kwargs) + super(SubmitSMResp, self).__init__(command, need_sequence=False, **kwargs) self._set_vars(**(dict.fromkeys(self.params))) @@ -799,8 +771,7 @@ class DeliverSM(SubmitSM): 'data_coding': Param(type=int, size=1), 'sm_default_msg_id': Param(type=int, size=1), 'sm_length': Param(type=int, size=1), - 'short_message': Param(type=ostr, max=254, - len_field='sm_length'), + 'short_message': Param(type=ostr, max=254, len_field='sm_length'), # Optional params 'user_message_reference': Param(type=int, size=2), @@ -813,7 +784,7 @@ class DeliverSM(SubmitSM): 'privacy_indicator': Param(type=int, size=1), 'payload_type': Param(type=int, size=1), 'message_payload': Param(type=ostr, max=260), - 'callback_num': Param(type=str, min=4, max=19), + 'callback_num': Param(type=ostr, min=4, max=19), 'source_subaddress': Param(type=str, min=2, max=23), 'dest_subaddress': Param(type=str, min=2, max=23), 'language_indicator': Param(type=int, size=1), @@ -821,9 +792,13 @@ class DeliverSM(SubmitSM): 'network_error_code': Param(type=ostr, size=3), 'message_state': Param(type=int, size=1), 'receipted_message_id': Param(type=str, max=65), - } + 'source_network_type': Param(type=int, size=1), + 'dest_network_type': Param(type=int, size=1), + 'more_messages_to_send': Param(type=int, size=1), + } - params_order = ('service_type', 'source_addr_ton', 'source_addr_npi', + params_order = ( + 'service_type', 'source_addr_ton', 'source_addr_npi', 'source_addr', 'dest_addr_ton', 'dest_addr_npi', 'destination_addr', 'esm_class', 'protocol_id', 'priority_flag', 'schedule_delivery_time', 'validity_period', 'registered_delivery', @@ -837,11 +812,12 @@ class DeliverSM(SubmitSM): 'payload_type', 'message_payload', 'callback_num', 'source_subaddress', 'dest_subaddress', 'language_indicator', 'its_session_info', - 'network_error_code', 'message_state', 'receipted_message_id') + 'network_error_code', 'message_state', 'receipted_message_id', + 'source_network_type', 'dest_network_type', 'more_messages_to_send', + ) def __init__(self, command, **kwargs): - """Initialize""" - super(DeliverSM, self).__init__(command, need_sequence=False, **kwargs) + super(DeliverSM, self).__init__(command, **kwargs) self._set_vars(**(dict.fromkeys(self.params))) @@ -850,9 +826,72 @@ class DeliverSMResp(SubmitSMResp): message_id = None def __init__(self, command, **kwargs): - """Initialize""" super(DeliverSMResp, self).__init__(command, **kwargs) +class QuerySM(Command): + """query_sm command class + + This command is used by an ESME to query the state of a short message to the SMSC. + source_addr* values must match those supplied when the message was submitted.""" + + # Message ID of the message whose state is to be queried. + message_id = None + + # Type of Number for source address + source_addr_ton = None + + # Numbering Plan Indicator for source address + source_addr_npi = None + + # Address of SME which originated this message + source_addr = None + + # Optional are taken from params list and are set dynamically when + # __init__ is called. + params = { + 'message_id': Param(type=str, max=65), + 'source_addr_ton': Param(type=int, size=1), + 'source_addr_npi': Param(type=int, size=1), + 'source_addr': Param(type=str, max=21), + } + + params_order = ( + 'message_id', 'source_addr_ton', 'source_addr_npi', + 'source_addr', + ) + + def __init__(self, command, **kwargs): + super(QuerySM, self).__init__(command, **kwargs) + self._set_vars(**(dict.fromkeys(self.params))) + + def prep(self): + """Prepare to generate binary data""" + + if not self.message_id: + raise ValueError('`message_id` is mandatory') + + +class QuerySMResp(Command): + """Response command for query_sm""" + + mandatory_fields = ('message_state') + + params = { + 'message_id': Param(type=str, max=65), + 'final_date': Param(type=str, max=17), + 'message_state': Param(type=int, size=1), + 'error_code': Param(type=int, size=1), + } + + params_order = ( + 'message_id', 'final_date', 'message_state', + 'error_code', + ) + + def __init__(self, command, **kwargs): + super(QuerySMResp, self).__init__(command, need_sequence=False, **kwargs) + self._set_vars(**(dict.fromkeys(self.params))) + class Unbind(Command): """Unbind command""" @@ -861,8 +900,7 @@ class Unbind(Command): params_order = () def __init__(self, command, **kwargs): - """Initialize""" - super(Unbind, self).__init__(command, need_sequence=False, **kwargs) + super(Unbind, self).__init__(command, **kwargs) class UnbindResp(Command): @@ -872,9 +910,7 @@ class UnbindResp(Command): params_order = () def __init__(self, command, **kwargs): - """Initialize""" - super(UnbindResp, self).__init__(command, need_sequence=False, - **kwargs) + super(UnbindResp, self).__init__(command, need_sequence=False, **kwargs) class EnquireLink(Command): @@ -883,9 +919,7 @@ class EnquireLink(Command): params_order = () def __init__(self, command, **kwargs): - """Initialize""" - super(EnquireLink, self).__init__(command, need_sequence=False, - **kwargs) + super(EnquireLink, self).__init__(command, **kwargs) class EnquireLinkResp(Command): @@ -894,6 +928,53 @@ class EnquireLinkResp(Command): params_order = () def __init__(self, command, **kwargs): - """Initialize""" - super(EnquireLinkResp, self).__init__(command, need_sequence=False, - **kwargs) + super(EnquireLinkResp, self).__init__(command, need_sequence=False, **kwargs) + + +class AlertNotification(Command): + """`alert_notification` command class""" + + # Type of Number for source address + source_addr_ton = None + + # Numbering Plan Indicator for source address + source_addr_npi = None + + # Address of SME which originated this message + source_addr = None + + # TON for destination + esme_addr_ton = None + + # NPI for destination + esme_addr_npi = None + + # Destination address for this message + esme_addr = None + + # Optional are taken from params list and are set dynamically when + # __init__ is called. + params = { + 'source_addr_ton': Param(type=int, size=1), + 'source_addr_npi': Param(type=int, size=1), + 'source_addr': Param(type=str, max=21), + 'esme_addr_ton': Param(type=int, size=1), + 'esme_addr_npi': Param(type=int, size=1), + 'esme_addr': Param(type=str, max=21), + + # Optional params + 'ms_availability_status' : Param(type=int, size=1), + } + + params_order = ( + 'source_addr_ton', 'source_addr_npi', + 'source_addr', 'esme_addr_ton', 'esme_addr_npi', + 'esme_addr', + + # Optional params + 'ms_availability_status', + ) + + def __init__(self, command, **kwargs): + super(AlertNotification, self).__init__(command, **kwargs) + self._set_vars(**(dict.fromkeys(self.params))) diff --git a/smpplib/command_codes.py b/smpplib/command_codes.py index 113662f..eab6868 100644 --- a/smpplib/command_codes.py +++ b/smpplib/command_codes.py @@ -1,4 +1,6 @@ -from . import exceptions +import six + +from smpplib import exceptions # # SMPP commands map (human-readable -> numeric) @@ -30,28 +32,30 @@ 'submit_multi_resp': 0x80000021, 'alert_notification': 0x00000102, 'data_sm': 0x00000103, - 'data_sm_resp': 0x80000103 + 'data_sm_resp': 0x80000103, } def get_command_name(code): - """Return command name by given code. If code is unknown, raise - UnkownCommandError exception""" + """ + Return command name by given code. + If code is unknown, raise UnknownCommandError exception. + """ - for key, value in commands.iteritems(): + for key, value in six.iteritems(commands): if value == code: return key - raise exceptions.UnknownCommandError("Unknown SMPP command code " - "'0x%x'" % code) + raise exceptions.UnknownCommandError("Unknown SMPP command code '0x%x'" % code) def get_command_code(name): - """Return command code by given command name. If name is unknown, - raise UnknownCommandError exception""" + """ + Return command code by given command name. + If name is unknown, raise UnknownCommandError exception. + """ try: return commands[name] except KeyError: - raise exceptions.UnknownCommandError("Unknown SMPP command name '%s'" - % name) + raise exceptions.UnknownCommandError("Unknown SMPP command name '%s'" % name) diff --git a/smpplib/consts.py b/smpplib/consts.py index 04d5410..8218d93 100644 --- a/smpplib/consts.py +++ b/smpplib/consts.py @@ -1,13 +1,21 @@ -SEVENBIT_SIZE = 160 -EIGHTBIT_SIZE = 140 -UCS2_SIZE = 70 -SEVENBIT_MP_SIZE = SEVENBIT_SIZE - 7 -EIGHTBIT_MP_SIZE = EIGHTBIT_SIZE - 6 -UCS2_MP_SIZE = UCS2_SIZE - 3 - -# -# SMPP error codes: -# +EMPTY_STRING = b'' +NULL_STRING = b'\0' + + +# Message part lengths in different encodings. +# SMPP 3.4, 2.2.1.2 +SEVENBIT_LENGTH = 160 +EIGHTBIT_LENGTH = 140 +UCS2_LENGTH = 140 + +MULTIPART_HEADER_SIZE = 6 + +SEVENBIT_PART_SIZE = SEVENBIT_LENGTH - 7 # TODO: where does 7 come from? +EIGHTBIT_PART_SIZE = 140 - MULTIPART_HEADER_SIZE +UCS2_PART_SIZE = 140 - MULTIPART_HEADER_SIZE # must be an even number anyway + + +# SMPP error codes. SMPP_ESME_ROK = 0x00000000 SMPP_ESME_RINVMSGLEN = 0x00000001 SMPP_ESME_RINVCMDLEN = 0x00000002 @@ -58,9 +66,7 @@ SMPP_ESME_RUNKNOWNERR = 0x000000FF -# -# Status description strings: -# +# Status description strings. DESCRIPTIONS = { SMPP_ESME_ROK: 'No Error', SMPP_ESME_RINVMSGLEN: 'Message Length is invalid', @@ -84,8 +90,7 @@ SMPP_ESME_RINVNUMDESTS: 'Invalid number of destinations', SMPP_ESME_RINVDLNAME: 'Invalid Distribution List name', SMPP_ESME_RINVDESTFLAG: 'Invalid Destination Flag (submit_multi)', - SMPP_ESME_RINVSUBREP: 'Invalid Submit With Replace request ' - '(replace_if_present_flag set)', + SMPP_ESME_RINVSUBREP: 'Invalid Submit With Replace request (replace_if_present_flag set)', SMPP_ESME_RINVESMCLASS: 'Invalid esm_class field data', SMPP_ESME_RCNTSUBDL: 'Cannot submit to Distribution List', SMPP_ESME_RSUBMITFAIL: 'submit_sm or submit_multi failed', @@ -96,8 +101,7 @@ SMPP_ESME_RINVSYSTYP: 'Invalid system_type field', SMPP_ESME_RINVREPFLAG: 'Invalid replace_if_present flag', SMPP_ESME_RINVNUMMSGS: 'Invalid number of messages', - SMPP_ESME_RTHROTTLED: 'Throttling error (ESME has exceeded allowed ' - 'message limits)', + SMPP_ESME_RTHROTTLED: 'Throttling error (ESME has exceeded allowed message limits)', SMPP_ESME_RINVSCHED: 'Invalid Scheduled Delivery Time', SMPP_ESME_RINVEXPIRY: 'Invalid message validity period (Expiry Time)', SMPP_ESME_RINVDFTMSGID: 'Predefined Message is invalid or not found', @@ -111,18 +115,19 @@ SMPP_ESME_RMISSINGOPTPARAM: 'Expected Optional Parameter missing', SMPP_ESME_RINVOPTPARAMVAL: 'Invalid Optional Parameter Value', SMPP_ESME_RDELIVERYFAILURE: 'Delivery Failure (used data_sm_resp)', - SMPP_ESME_RUNKNOWNERR: 'Unknown Error' + SMPP_ESME_RUNKNOWNERR: 'Unknown Error', } + +# Internal client state. SMPP_CLIENT_STATE_CLOSED = 0 SMPP_CLIENT_STATE_OPEN = 1 SMPP_CLIENT_STATE_BOUND_TX = 2 SMPP_CLIENT_STATE_BOUND_RX = 3 SMPP_CLIENT_STATE_BOUND_TRX = 4 -# -# TON (Type Of Number) values -# + +# TON (Type Of Number) values. SMPP_TON_UNK = 0x00 SMPP_TON_INTL = 0x01 SMPP_TON_NATNL = 0x02 @@ -132,9 +137,7 @@ SMPP_TON_ABBREV = 0x06 -# -# NPI (Numbering Plan Indicator) values -# +# NPI (Numbering Plan Indicator) values. SMPP_NPI_UNK = 0x00 # Unknown SMPP_NPI_ISDN = 0x01 # ISDN (E163/E164) SMPP_NPI_DATA = 0x03 # Data (X.121) @@ -147,9 +150,7 @@ SMPP_NPI_WAP = 0x12 # WAP -# -# Encoding Types -# +# Encoding types. SMPP_ENCODING_DEFAULT = 0x00 # SMSC Default SMPP_ENCODING_IA5 = 0x01 # IA5 (CCITT T.50)/ASCII (ANSI X3.4) SMPP_ENCODING_BINARY = 0x02 # Octet unspecified (8-bit binary) @@ -165,9 +166,7 @@ SMPP_ENCODING_KSC5601 = 0x0E # KS C 5601 -# -# Language Types -# +# Language types. SMPP_LANG_DEFAULT = 0x00 SMPP_LANG_EN = 0x01 SMPP_LANG_FR = 0x02 @@ -175,49 +174,94 @@ SMPP_LANG_DE = 0x04 -# -# ESM class values -# +# ESM class values. SMPP_MSGMODE_DEFAULT = 0x00 # Default SMSC mode (e.g. Store and Forward) SMPP_MSGMODE_DATAGRAM = 0x01 # Datagram mode SMPP_MSGMODE_FORWARD = 0x02 # Forward (i.e. Transaction) mode -SMPP_MSGMODE_STOREFORWARD = 0x03 # Store and Forward mode (use this to - # select Store and Forward mode if Default - # mode is not Store and Forward) +SMPP_MSGMODE_STOREFORWARD = 0x03 # Explicit Store and Forward mode SMPP_MSGTYPE_DEFAULT = 0x00 # Default message type (i.e. normal message) -SMPP_MSGTYPE_DELIVERYACK = 0x08 # Message containts ESME Delivery - # Acknowledgement -SMPP_MSGTYPE_USERACK = 0x10 # Message containts ESME Manual/User - # Acknowledgement +SMPP_MSGTYPE_DELIVERYACK = 0x08 # Message containts ESME Delivery acknowledgement +SMPP_MSGTYPE_USERACK = 0x10 # Message containts ESME Manual/User acknowledgement + SMPP_GSMFEAT_NONE = 0x00 # No specific features selected SMPP_GSMFEAT_UDHI = 0x40 # UDHI Indicator (only relevant for MT msgs) SMPP_GSMFEAT_REPLYPATH = 0x80 # Set Reply Path (only relevant for GSM net) SMPP_GSMFEAT_UDHIREPLYPATH = 0xC0 # Set UDHI and Reply Path (for GSM net) -# -# SMPP Protocol ID -# + +# SMPP Protocol ID. SMPP_PID_DEFAULT = 0x00 # Default SMPP_PID_RIP = 0x41 # Replace if present on handset -# -# SMPP User Data Header Information Element Identifier -# + +# SMPP User Data Header Information Element Identifier. SMPP_UDHIEIE_CONCATENATED = 0x00 # Concatenated short message, 8-bit ref SMPP_UDHIEIE_SPECIAL = 0x01 SMPP_UDHIEIE_RESERVED = 0x02 SMPP_UDHIEIE_PORT8 = 0x04 -SMPP_UDHIEIE_PORT16 = 0x04 +SMPP_UDHIEIE_PORT16 = 0x05 +SMPP_UDHIEIE_CONCATENATED16 = 0x08 + + +# `ms_availability_status` parameter from `alert_notification` operation. +SMPP_MS_AVAILABILITY_STATUS_AVAILABLE = 0x00 +SMPP_MS_AVAILABILITY_STATUS_DENIED = 0x01 +SMPP_MS_AVAILABILITY_STATUS_UNAVAILABLE = 0x02 + + +# `registered_delivery` parameter used to request an SMSC delivery receipt and/or SME originated acknowledgements. +# SMSC Delivery Receipt (bits 1 and 0). +SMPP_SMSC_DELIVERY_RECEIPT_NONE = 0x00 # No SMSC Delivery Receipt requested (default) +SMPP_SMSC_DELIVERY_RECEIPT_BOTH = 0x01 # SMSC Delivery Receipt requested where final delivery outcome is delivery success or failure +SMPP_SMSC_DELIVERY_RECEIPT_FAILURE = 0x02 # SMSC Delivery Receipt requested where the final delivery outcome is delivery failure +SMPP_SMSC_DELIVERY_RECEIPT_BITMASK = 0x03 # Reserved. -# -# SMPP protocol versions -# + +# SME originated Acknowledgement (bits 3 and 2). +SMPP_SME_ACK_BITMASK = 0x0C # No recipient SME acknowledgment requested (default) +SMPP_SME_ACK_NONE = 0x00 # No recipient SME acknowledgment requested (default) +SMPP_SME_ACK_DELIVERY = 0x04 # SME Delivery Acknowledgement requested +SMPP_SME_ACK_MANUAL = 0x08 # SME Manual/User Acknowledgment requested +SMPP_SME_ACK_BOTH = 0x0C # Both Delivery and Manual/User Acknowledgment requested + + +# Intermediate Notification (bit 5). +SMPP_INT_NOTIFICATION_BITMASK = 0x10 +SMPP_INT_NOTIFICATION_NONE = 0x00 # No Intermediate notification requested (default) +SMPP_INT_NOTIFICATION_REQUESTED = 0x10 # Intermediate notification requested + + +# SMPP protocol versions. SMPP_VERSION_33 = 0x33 SMPP_VERSION_34 = 0x34 + +# Network types. +SMPP_NETWORK_TYPE_UNKNOWN = 0x00 +SMPP_NETWORK_TYPE_GSM = 0x01 +SMPP_NETWORK_TYPE_TDMA = 0x02 +SMPP_NETWORK_TYPE_CDMA = 0x03 +SMPP_NETWORK_TYPE_PDC = 0x04 +SMPP_NETWORK_TYPE_PHS = 0x05 +SMPP_NETWORK_TYPE_IDEN = 0x06 +SMPP_NETWORK_TYPE_AMPS = 0x07 +SMPP_NETWORK_TYPE_PAGING = 0x08 + + +# Message state. +SMPP_MESSAGE_STATE_ENROUTE = 1 +SMPP_MESSAGE_STATE_DELIVERED = 2 +SMPP_MESSAGE_STATE_EXPIRED = 3 +SMPP_MESSAGE_STATE_DELETED = 4 +SMPP_MESSAGE_STATE_UNDELIVERABLE = 5 +SMPP_MESSAGE_STATE_ACCEPTED = 6 +SMPP_MESSAGE_STATE_UNKNOWN = 7 +SMPP_MESSAGE_STATE_REJECTED = 8 + + COMMAND_STATES = { 'bind_transmitter': (SMPP_CLIENT_STATE_OPEN,), 'bind_transmitter_resp': (SMPP_CLIENT_STATE_OPEN,), @@ -226,60 +270,65 @@ 'bind_transceiver': (SMPP_CLIENT_STATE_OPEN,), 'bind_transceiver_resp': (SMPP_CLIENT_STATE_OPEN,), 'outbind': (SMPP_CLIENT_STATE_OPEN,), - 'unbind': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'unbind_resp': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'submit_sm': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'submit_sm_resp': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'submit_sm_multi': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'submit_sm_multi_resp': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'data_sm': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'data_sm_resp': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'deliver_sm': (SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'deliver_sm_resp': (SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'query_sm': (SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'query_sm_resp': (SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'cancel_sm': (SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'cancel_sm_resp': (SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), + 'unbind': ( + SMPP_CLIENT_STATE_BOUND_TX, + SMPP_CLIENT_STATE_BOUND_RX, + SMPP_CLIENT_STATE_BOUND_TRX, + ), + 'unbind_resp': ( + SMPP_CLIENT_STATE_BOUND_TX, + SMPP_CLIENT_STATE_BOUND_RX, + SMPP_CLIENT_STATE_BOUND_TRX, + ), + 'submit_sm': (SMPP_CLIENT_STATE_BOUND_TX, SMPP_CLIENT_STATE_BOUND_TRX), + 'submit_sm_resp': (SMPP_CLIENT_STATE_BOUND_TX, SMPP_CLIENT_STATE_BOUND_TRX), + 'submit_sm_multi': (SMPP_CLIENT_STATE_BOUND_TX, SMPP_CLIENT_STATE_BOUND_TRX), + 'submit_sm_multi_resp': (SMPP_CLIENT_STATE_BOUND_TX, SMPP_CLIENT_STATE_BOUND_TRX), + 'data_sm': ( + SMPP_CLIENT_STATE_BOUND_TX, + SMPP_CLIENT_STATE_BOUND_RX, + SMPP_CLIENT_STATE_BOUND_TRX, + ), + 'data_sm_resp': ( + SMPP_CLIENT_STATE_BOUND_TX, + SMPP_CLIENT_STATE_BOUND_RX, + SMPP_CLIENT_STATE_BOUND_TRX, + ), + 'deliver_sm': (SMPP_CLIENT_STATE_BOUND_RX, SMPP_CLIENT_STATE_BOUND_TRX), + 'deliver_sm_resp': (SMPP_CLIENT_STATE_BOUND_RX, SMPP_CLIENT_STATE_BOUND_TRX), + 'query_sm': (SMPP_CLIENT_STATE_BOUND_RX, SMPP_CLIENT_STATE_BOUND_TRX), + 'query_sm_resp': (SMPP_CLIENT_STATE_BOUND_RX, SMPP_CLIENT_STATE_BOUND_TRX), + 'cancel_sm': (SMPP_CLIENT_STATE_BOUND_RX, SMPP_CLIENT_STATE_BOUND_TRX,), + 'cancel_sm_resp': (SMPP_CLIENT_STATE_BOUND_RX, SMPP_CLIENT_STATE_BOUND_TRX,), 'replace_sm': (SMPP_CLIENT_STATE_BOUND_TX,), 'replace_sm_resp': (SMPP_CLIENT_STATE_BOUND_TX,), - 'enquire_link': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'enquire_link_resp': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'alert_notification': (SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,), - 'generic_nack': (SMPP_CLIENT_STATE_BOUND_TX, - SMPP_CLIENT_STATE_BOUND_RX, - SMPP_CLIENT_STATE_BOUND_TRX,) + 'enquire_link': ( + SMPP_CLIENT_STATE_BOUND_TX, + SMPP_CLIENT_STATE_BOUND_RX, + SMPP_CLIENT_STATE_BOUND_TRX, + ), + 'enquire_link_resp': ( + SMPP_CLIENT_STATE_BOUND_TX, + SMPP_CLIENT_STATE_BOUND_RX, + SMPP_CLIENT_STATE_BOUND_TRX, + ), + 'alert_notification': (SMPP_CLIENT_STATE_BOUND_RX, SMPP_CLIENT_STATE_BOUND_TRX), + 'generic_nack': ( + SMPP_CLIENT_STATE_BOUND_TX, + SMPP_CLIENT_STATE_BOUND_RX, + SMPP_CLIENT_STATE_BOUND_TRX, + ) } + STATE_SETTERS = { 'bind_transmitter_resp': SMPP_CLIENT_STATE_BOUND_TX, 'bind_receiver_resp': SMPP_CLIENT_STATE_BOUND_RX, 'bind_transceiver_resp': SMPP_CLIENT_STATE_BOUND_TRX, - 'unbind_resp': SMPP_CLIENT_STATE_OPEN + 'unbind_resp': SMPP_CLIENT_STATE_OPEN, } + OPTIONAL_PARAMS = { 'dest_addr_subunit': 0x0005, 'dest_network_type': 0x0006, @@ -324,5 +373,13 @@ 'ms_validity': 0x1204, 'alert_on_message_delivery': 0x130C, 'its_reply_type': 0x1380, - 'its_session_info': 0x1383 + 'its_session_info': 0x1383, +} + + +# Integer value struct formats for different sizes. +INT_PACK_FORMATS = { + 1: 'B', + 2: 'H', + 4: 'L', } diff --git a/smpplib/gsm.py b/smpplib/gsm.py index 6679541..9d70f98 100644 --- a/smpplib/gsm.py +++ b/smpplib/gsm.py @@ -1,70 +1,88 @@ # -*- coding: utf8 -*- -import binascii import random -from . import consts -from . import exceptions +import six +from smpplib import consts, exceptions -# from http://stackoverflow.com/questions/2452861/python-library-for-converting-plain-text-ascii-into-gsm-7-bit-character-set -gsm = (u"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>" - u"?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà") -ext = (u"````````````````````^```````````````````{}`````\\````````````[~]`" - u"|````````````````````````````````````€``````````````````````````") +def make_parts(text, encoding=consts.SMPP_ENCODING_DEFAULT, use_udhi=True): + """Returns tuple(parts, encoding, esm_class)""" + try: + # Try to encode with the user-defined encoding first. + encode, split_length, part_size = ENCODINGS[encoding] + encoded_text = encode(text) + except KeyError: + raise NotImplementedError('encoding is not supported: %s' % encoding) + except UnicodeError: + # Fallback to UCS-2. + encoding = consts.SMPP_ENCODING_ISO10646 + encode, split_length, part_size = ENCODINGS[encoding] + encoded_text = encode(text) -class EncodeError(ValueError): - """Raised if text cannot be represented in gsm 7-bit encoding""" + if len(encoded_text) > split_length: + if use_udhi: + # Split the text into well-formed parts. + esm_class = consts.SMPP_GSMFEAT_UDHI + # FIXME: 7-bit encoding has variable-length characters. + # FIXME: it means that a character may be broken by splitting. + parts = make_parts_encoded(encoded_text, part_size) + else: + # We will have to use SaR to send the message + esm_class = consts.SMPP_MSGTYPE_DEFAULT + parts = split_sequence(encoded_text, part_size) + if len(parts) > 255: + raise exceptions.MessageTooLong() + else: + # Normal message. + esm_class = consts.SMPP_MSGTYPE_DEFAULT + parts = [encoded_text] + return parts, encoding, esm_class -def gsm_encode(plaintext, hex=False): - """Replace non-GSM ASCII symbols""" - res = "" - for c in plaintext: - idx = gsm.find(c) - if idx != -1: - res += chr(idx) - continue - idx = ext.find(c) - if idx != -1: - res += chr(27) + chr(idx) - continue - raise EncodeError() - return binascii.b2a_hex(res) if hex else res +# Source: +# http://stackoverflow.com/questions/2452861/python-library-for-converting-plain-text-ascii-into-gsm-7-bit-character-set +GSM_CHARACTER_TABLE = ( + u"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>" + u"?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà" + u"````````````````````^```````````````````{}`````\\````````````[~]`" + u"|````````````````````````````````````€``````````````````````````" +) -def make_parts(text): - """Returns tuple(parts, encoding, esm_class)""" + +def gsm_encode(plaintext): + """Performs default GSM 7-bit encoding. Beware it's vendor-specific and not recommended for use.""" try: - text = gsm_encode(text) - encoding = consts.SMPP_ENCODING_DEFAULT - need_split = len(text) > consts.SEVENBIT_SIZE - partsize = consts.SEVENBIT_MP_SIZE - encode = lambda s: s - except EncodeError: - encoding = consts.SMPP_ENCODING_ISO10646 - need_split = len(text) > consts.UCS2_SIZE - partsize = consts.UCS2_MP_SIZE - encode = lambda s: s.encode('utf-16-be') - - esm_class = consts.SMPP_MSGTYPE_DEFAULT - - if need_split: - esm_class = consts.SMPP_GSMFEAT_UDHI - - starts = tuple(range(0, len(text), partsize)) - if len(starts) > 255: - raise exceptions.MessageTooLong() - - parts = [] - ipart = 1 - uid = random.randint(0, 255) - for start in starts: - parts.append(''.join(('\x05\x00\x03', chr(uid), - chr(len(starts)), chr(ipart), - encode(text[start:start + partsize])))) - ipart += 1 - else: - parts = (encode(text),) + return b''.join( + six.int2byte(index) if index < 0x80 else b'\x1B' + six.int2byte(index - 0x80) + for index in map(GSM_CHARACTER_TABLE.index, plaintext) + ) + except ValueError: + raise UnicodeError(plaintext) - return parts, encoding, esm_class + +# Map GSM encoding into a tuple of encode function, maximum single message size and a part size. +# Add new entry here should you need to use another encoding. +ENCODINGS = { + consts.SMPP_ENCODING_DEFAULT: (gsm_encode, consts.SEVENBIT_LENGTH, consts.SEVENBIT_PART_SIZE), + consts.SMPP_ENCODING_ISO88591: (lambda text: text.encode('iso-8859-1'), consts.EIGHTBIT_LENGTH, consts.EIGHTBIT_PART_SIZE), + consts.SMPP_ENCODING_ISO10646: (lambda text: text.encode('utf-16-be'), consts.UCS2_LENGTH, consts.UCS2_PART_SIZE), +} + + +def make_parts_encoded(encoded_text, part_size): + """Splits encoded text into SMS parts""" + chunks = split_sequence(encoded_text, part_size) + if len(chunks) > 255: + raise exceptions.MessageTooLong() + + uid = random.randint(0, 255) + header = b''.join((b'\x05\x00\x03', six.int2byte(uid), six.int2byte(len(chunks)))) + + return [b''.join((header, six.int2byte(i), chunk)) for i, chunk in enumerate(chunks, start=1)] + + +def split_sequence(sequence, part_size): + """Splits the sequence into equal parts""" + return [sequence[i:i + part_size] for i in range(0, len(sequence), part_size)] diff --git a/smpplib/pdu.py b/smpplib/pdu.py index 7b527cb..4557210 100644 --- a/smpplib/pdu.py +++ b/smpplib/pdu.py @@ -15,19 +15,13 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -# -# -# Modified by Yusuf Kaka -# Added support for Optional TLV's """PDU module""" import struct -from . import command_codes -from . import consts - -SMPP_ESME_ROK = 0x00000000 +from smpplib import command_codes, consts +from smpplib.consts import SMPP_ESME_ROK def extract_command(pdu): @@ -132,10 +126,6 @@ def parse(self, data): if len(data) > 16: self.parse_params(data[16:]) - def _unpack(self, fmt, data): - """Unpack values. Uses struct.unpack. TODO: remove this""" - return struct.unpack(fmt, data) - def generate(self): """Generate raw PDU""" @@ -145,7 +135,6 @@ def generate(self): command_code = command_codes.get_command_code(self.command) - header = struct.pack(">LLLL", self._length, command_code, - self.status, self.sequence) + header = struct.pack(">LLLL", self._length, command_code, self.status, self.sequence) return header + body diff --git a/smpplib/ptypes.py b/smpplib/ptypes.py index 50dde1c..0c08f98 100644 --- a/smpplib/ptypes.py +++ b/smpplib/ptypes.py @@ -15,10 +15,6 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -# -# -# Modified by Yusuf Kaka -# Added support for Optional TLV's """SMPP Command paramter types module""" diff --git a/smpplib/smpp.py b/smpplib/smpp.py index fc1864e..c93b3f5 100644 --- a/smpplib/smpp.py +++ b/smpplib/smpp.py @@ -15,15 +15,10 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -# -# -# Modified by Yusuf Kaka -# Added support for Optional TLV's """SMPP module""" -from . import pdu -from . import command +from smpplib import command, pdu def make_pdu(command_name, **kwargs): diff --git a/smpplib/tests/__init__.py b/smpplib/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smpplib/tests/test_client.py b/smpplib/tests/test_client.py new file mode 100644 index 0000000..a10666f --- /dev/null +++ b/smpplib/tests/test_client.py @@ -0,0 +1,46 @@ +import warnings +import pytest +from mock import Mock, call + +from smpplib.client import Client +from smpplib.smpp import make_pdu +from smpplib import consts +from smpplib import exceptions + + +def test_client_construction_allow_unknown_opt_params_warning(): + with warnings.catch_warnings(record=True) as w: + client = Client("localhost", 5679) + + assert len(w) == 1 + assert "optional parameters" in str(w[0].message) + assert not client.allow_unknown_opt_params + + +def test_client_error_pdu_default(): + client = Client("localhost", 5679) + error_pdu = make_pdu("submit_sm_resp") + error_pdu.status = consts.SMPP_ESME_RINVMSGLEN + client.read_pdu = Mock(return_value=error_pdu) + + with pytest.raises(exceptions.PDUError) as exec_info: + client.read_once() + + assert exec_info.value.args[1] == consts.SMPP_ESME_RINVMSGLEN + + # Should not raise + client.read_once(ignore_error_codes=[consts.SMPP_ESME_RINVMSGLEN]) + + +def test_client_error_pdu_custom_handler(): + client = Client("localhost", 5679) + error_pdu = make_pdu("submit_sm_resp") + error_pdu.status = consts.SMPP_ESME_RINVMSGLEN + client.read_pdu = Mock(return_value=error_pdu) + + mock_error_pdu_handler = Mock() + client.set_error_pdu_handler(mock_error_pdu_handler) + + client.read_once() + + assert mock_error_pdu_handler.mock_calls == [call(error_pdu)] diff --git a/smpplib/tests/test_command.py b/smpplib/tests/test_command.py new file mode 100644 index 0000000..0c47891 --- /dev/null +++ b/smpplib/tests/test_command.py @@ -0,0 +1,49 @@ +from smpplib import consts, exceptions +from smpplib.client import Client +from smpplib.command import DeliverSM + +import pytest + + +def test_parse_deliver_sm(): + client = Client("localhost", 5679) + pdu = DeliverSM('deliver_sm', client=client) + pdu.parse( + b"\x00\x00\x00\xcb\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x01\x00" + b"\x01\x0131600000000\x00\x05\x00XXX YYYY\x00\x04\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x0e\x00\x01\x01\x00\x06\x00\x01\x01\x00\x1e\x00" + b"\t1d305b4c\x00\x04'\x00\x01\x02\x04$\x00rid:0489708364 sub:001" + b" dlvrd:001 submit date:1810151907 done date:1810151907 stat:DELIVRD" + b" err:000 text:\x04\x1f\x04@\x048\x042\x045\x04B\x04&\x00\x01\x01" + ) + + assert pdu.source_addr_ton == consts.SMPP_TON_INTL + assert pdu.source_addr_npi == consts.SMPP_NPI_ISDN + assert pdu.source_addr == b'31600000000' + assert pdu.destination_addr == b'XXX YYYY' + assert pdu.receipted_message_id == b'1d305b4c' + assert pdu.source_network_type == consts.SMPP_NETWORK_TYPE_GSM + assert pdu.message_state == consts.SMPP_MESSAGE_STATE_DELIVERED + assert pdu.user_message_reference is None + + +def test_unrecognised_optional_parameters(): + client = Client("localhost", 5679) + pdu = DeliverSM("deliver_sm", client=client, allow_unknown_opt_params=True) + pdu.parse(b'\x00\x00\x00\xa8\x00\x00\x00\x05\x00\x00\x00\x00/p\xc6' + b'\x9a\x00\x00\x0022549909028\x00\x01\x00\x00\x04\x00\x00' + b'\x00\x00\x00\x00\x00\x00iid:795920026 sub:001 dlvrd:001 ' + b'submit date:200319131913 done date:200319131913 stat:DELIVRD err:000 text:' + b'\x14\x03\x00\x07(null)\x00\x14\x02\x00\x04612\x00' + ) + + # This is only to avoid a breaking change, at some point the other behaviour + # should become the default. + with pytest.raises(exceptions.UnknownCommandError): + pdu2 = DeliverSM("deliver_sm", client=client) + pdu2.parse(b'\x00\x00\x00\xa8\x00\x00\x00\x05\x00\x00\x00\x00/p\xc6' + b'\x9a\x00\x00\x0022549909028\x00\x01\x00\x00\x04\x00\x00' + b'\x00\x00\x00\x00\x00\x00iid:795920026 sub:001 dlvrd:001 ' + b'submit date:200319131913 done date:200319131913 stat:DELIVRD err:000 text:' + b'\x14\x03\x00\x07(null)\x00\x14\x02\x00\x04612\x00' + ) diff --git a/smpplib/tests/test_gsm.py b/smpplib/tests/test_gsm.py new file mode 100644 index 0000000..b6641fd --- /dev/null +++ b/smpplib/tests/test_gsm.py @@ -0,0 +1,62 @@ +# -*- coding: utf8 -*- + +import mock +from pytest import mark, raises + +from smpplib import consts +from smpplib.gsm import gsm_encode, make_parts, make_parts_encoded + + +@mark.parametrize('plaintext, encoded_text', [ + (u'@', b'\x00'), + (u'^', b'\x1B\x14'), +]) +def test_gsm_encode(plaintext, encoded_text): + assert gsm_encode(plaintext) == encoded_text + + +@mark.parametrize('plaintext', [ + (u'Ая',), +]) +def test_gsm_encode_unicode_error(plaintext): + with raises(UnicodeError): + gsm_encode(plaintext) + + +@mark.parametrize('plaintext, encoding, expected_parts, expected_encoding', [ + (u'@', consts.SMPP_ENCODING_DEFAULT, [b'\x00'], consts.SMPP_ENCODING_DEFAULT), + (u'Ая', consts.SMPP_ENCODING_DEFAULT, [b'\x04\x10\x04O'], consts.SMPP_ENCODING_ISO10646), + (u'é', consts.SMPP_ENCODING_ISO88591, [b'\xe9'], consts.SMPP_ENCODING_ISO88591), +]) +def test_make_parts_single(plaintext, encoding, expected_parts, expected_encoding): + assert make_parts(plaintext, encoding) == (expected_parts, expected_encoding, consts.SMPP_MSGTYPE_DEFAULT) + + +@mark.parametrize('plaintext, expected', [ + (u'@' * consts.SEVENBIT_PART_SIZE * 2, [ + b'\x05\x00\x03\x42\x02\x01' + b'\x00' * consts.SEVENBIT_PART_SIZE, + b'\x05\x00\x03\x42\x02\x02' + b'\x00' * consts.SEVENBIT_PART_SIZE, + ]), +]) +def test_make_parts_multiple(plaintext, expected): + with mock.patch('random.randint') as randint: + randint.return_value = 0x42 + assert make_parts(plaintext) == (expected, consts.SMPP_ENCODING_DEFAULT, consts.SMPP_GSMFEAT_UDHI) + + +@mark.parametrize('encoded_text, part_size, expected', [ + (b'12345', 5, [b'\x05\x00\x03\x42\x01\x0112345']), + (b'12345', 2, [b'\x05\x00\x03\x42\x03\x0112', b'\x05\x00\x03\x42\x03\x0234', b'\x05\x00\x03\x42\x03\x035']), +]) +def test_make_parts_encoded(encoded_text, part_size, expected): + with mock.patch('random.randint') as randint: + randint.return_value = 0x42 + assert make_parts_encoded(encoded_text, part_size) == expected + + +@mark.parametrize('text, expected', [ + (u'Привет мир!\n' * 10, 2), +]) +def test_part_number(text, expected): + parts, _, _ = make_parts(text) + assert len(parts) == expected