Skip to content

Commit 02bd2d5

Browse files
authored
More endpoints (#65)
* Rename some internal methods. * Implement get_recording * Test for get_recording * Add submit_sms_conversion and some logging. * Add submit_sms_conversion docs to the README * Use pytz timezone. * It's important to add tests. * Attempt to publish coverage info. * Get Travis to run with coverage on. * Add coverage badge to README
1 parent eae9cb2 commit 02bd2d5

File tree

9 files changed

+124
-28
lines changed

9 files changed

+124
-28
lines changed

.travis.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ install:
1010
- make install
1111

1212
script:
13-
- make test
13+
- make coverage
14+
15+
after_success:
16+
- coveralls

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
.PHONY: test install requirements release release-test
2+
3+
coverage:
4+
pytest -v --cov
5+
26
test:
37
pytest -v
48

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
Nexmo Client Library for Python
22
===============================
33

4-
[![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) [![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python)
4+
[![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo)
5+
[![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python)
6+
[![Coverage Status](https://coveralls.io/repos/github/Nexmo/nexmo-python/badge.svg?branch=master)](https://coveralls.io/github/Nexmo/nexmo-python?branch=master)
57

68
This is the Python client library for Nexmo's API. To use it you'll
79
need a Nexmo account. Sign up [for free at nexmo.com][signup].
@@ -78,6 +80,15 @@ else:
7880

7981
Docs: [https://docs.nexmo.com/messaging/sms-api/api-reference#request](https://docs.nexmo.com/messaging/sms-api/api-reference#request?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library)
8082

83+
### Tell Nexmo the SMS was received
84+
85+
The following submits a successful conversion to Nexmo with the current timestamp. This feature must
86+
be enabled on your account first.
87+
88+
```python
89+
response = client.submit_sms_conversion(message_id)
90+
```
91+
8192

8293
## Voice API
8394

nexmo/__init__.py

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
from datetime import datetime
2+
import logging
13
from platform import python_version
24

35
import hashlib
46
import hmac
57
import jwt
68
import os
9+
import pytz
710
import requests
811
import sys
912
import time
@@ -12,11 +15,15 @@
1215

1316
if sys.version_info[0] == 3:
1417
string_types = (str, bytes)
18+
from urllib.parse import urlparse
1519
else:
20+
from urlparse import urlparse
1621
string_types = (unicode, str)
1722

1823
__version__ = '2.0.0'
1924

25+
logger = logging.getLogger('nexmo')
26+
2027

2128
class Error(Exception):
2229
pass
@@ -133,6 +140,24 @@ def send_ussd_prompt_message(self, params=None, **kwargs):
133140
def send_2fa_message(self, params=None, **kwargs):
134141
return self.post(self.host, '/sc/us/2fa/json', params or kwargs)
135142

143+
def submit_sms_conversion(self, message_id, delivered=True, timestamp=None):
144+
"""
145+
Notify Nexmo that an SMS was successfully received.
146+
147+
:param message_id: The `message-id` str returned by the send_message call.
148+
:param delivered: A `bool` indicating that the message was or was not successfully delivered.
149+
:param timestamp: A `datetime` object containing the time the SMS arrived.
150+
:return: The parsed response from the server. On success, the bytestring b'OK'
151+
"""
152+
params = {
153+
'message-id': message_id,
154+
'delivered': delivered,
155+
'timestamp': timestamp or datetime.now(pytz.utc),
156+
}
157+
# Ensure timestamp is a string:
158+
_format_date_param(params, 'timestamp')
159+
return self.post(self.api_host, '/conversions/sms', params)
160+
136161
def send_event_alert_message(self, params=None, **kwargs):
137162
return self.post(self.host, '/sc/us/alert/json', params or kwargs)
138163

@@ -226,31 +251,35 @@ def delete_application(self, application_id):
226251
return self.delete(self.api_host, '/v1/applications/' + application_id)
227252

228253
def create_call(self, params=None, **kwargs):
229-
return self.__post('/v1/calls', params or kwargs)
254+
return self._jwt_signed_post('/v1/calls', params or kwargs)
230255

231256
def get_calls(self, params=None, **kwargs):
232-
return self.__get('/v1/calls', params or kwargs)
257+
return self._jwt_signed_get('/v1/calls', params or kwargs)
233258

234259
def get_call(self, uuid):
235-
return self.__get('/v1/calls/' + uuid)
260+
return self._jwt_signed_get('/v1/calls/' + uuid)
236261

237262
def update_call(self, uuid, params=None, **kwargs):
238-
return self.__put('/v1/calls/' + uuid, params or kwargs)
263+
return self._jwt_signed_put('/v1/calls/' + uuid, params or kwargs)
239264

240265
def send_audio(self, uuid, params=None, **kwargs):
241-
return self.__put('/v1/calls/' + uuid + '/stream', params or kwargs)
266+
return self._jwt_signed_put('/v1/calls/' + uuid + '/stream', params or kwargs)
242267

243268
def stop_audio(self, uuid):
244-
return self.__delete('/v1/calls/' + uuid + '/stream')
269+
return self._jwt_signed_delete('/v1/calls/' + uuid + '/stream')
245270

246271
def send_speech(self, uuid, params=None, **kwargs):
247-
return self.__put('/v1/calls/' + uuid + '/talk', params or kwargs)
272+
return self._jwt_signed_put('/v1/calls/' + uuid + '/talk', params or kwargs)
248273

249274
def stop_speech(self, uuid):
250-
return self.__delete('/v1/calls/' + uuid + '/talk')
275+
return self._jwt_signed_delete('/v1/calls/' + uuid + '/talk')
251276

252277
def send_dtmf(self, uuid, params=None, **kwargs):
253-
return self.__put('/v1/calls/' + uuid + '/dtmf', params or kwargs)
278+
return self._jwt_signed_put('/v1/calls/' + uuid + '/dtmf', params or kwargs)
279+
280+
def get_recording(self, url):
281+
hostname = urlparse(url).hostname
282+
return self.parse(hostname, requests.get(url, headers=self._headers()))
254283

255284
def check_signature(self, params):
256285
params = dict(params)
@@ -286,28 +315,28 @@ def get(self, host, request_uri, params=None):
286315
uri = 'https://' + host + request_uri
287316

288317
params = dict(params or {}, api_key=self.api_key, api_secret=self.api_secret)
289-
318+
logger.debug("GET to %r with params %r", uri, params)
290319
return self.parse(host, requests.get(uri, params=params, headers=self.headers))
291320

292321
def post(self, host, request_uri, params):
293322
uri = 'https://' + host + request_uri
294323

295324
params = dict(params, api_key=self.api_key, api_secret=self.api_secret)
296-
325+
logger.debug("POST to %r with params %r", uri, params)
297326
return self.parse(host, requests.post(uri, data=params, headers=self.headers))
298327

299328
def put(self, host, request_uri, params):
300329
uri = 'https://' + host + request_uri
301330

302331
params = dict(params, api_key=self.api_key, api_secret=self.api_secret)
303-
332+
logger.debug("PUT to %r with params %r", uri, params)
304333
return self.parse(host, requests.put(uri, json=params, headers=self.headers))
305334

306335
def delete(self, host, request_uri):
307336
uri = 'https://' + host + request_uri
308337

309338
params = dict(api_key=self.api_key, api_secret=self.api_secret)
310-
339+
logger.debug("DELETE to %r with params %r", uri, params)
311340
return self.parse(host, requests.delete(uri, params=params, headers=self.headers))
312341

313342
def parse(self, host, response):
@@ -316,37 +345,40 @@ def parse(self, host, response):
316345
elif response.status_code == 204:
317346
return None
318347
elif 200 <= response.status_code < 300:
319-
return response.json()
348+
if response.headers.get('content-type') == 'application/json':
349+
return response.json()
350+
else:
351+
return response.content
320352
elif 400 <= response.status_code < 500:
353+
logger.warn("Client error: %s %r", response.status_code, response.content)
321354
message = "{code} response from {host}".format(code=response.status_code, host=host)
322-
323355
raise ClientError(message)
324356
elif 500 <= response.status_code < 600:
357+
logger.warn("Server error: %s %r", response.status_code, response.content)
325358
message = "{code} response from {host}".format(code=response.status_code, host=host)
326-
327359
raise ServerError(message)
328360

329-
def __get(self, request_uri, params=None):
361+
def _jwt_signed_get(self, request_uri, params=None):
330362
uri = 'https://' + self.api_host + request_uri
331363

332-
return self.parse(self.api_host, requests.get(uri, params=params or {}, headers=self.__headers()))
364+
return self.parse(self.api_host, requests.get(uri, params=params or {}, headers=self._headers()))
333365

334-
def __post(self, request_uri, params):
366+
def _jwt_signed_post(self, request_uri, params):
335367
uri = 'https://' + self.api_host + request_uri
336368

337-
return self.parse(self.api_host, requests.post(uri, json=params, headers=self.__headers()))
369+
return self.parse(self.api_host, requests.post(uri, json=params, headers=self._headers()))
338370

339-
def __put(self, request_uri, params):
371+
def _jwt_signed_put(self, request_uri, params):
340372
uri = 'https://' + self.api_host + request_uri
341373

342-
return self.parse(self.api_host, requests.put(uri, json=params, headers=self.__headers()))
374+
return self.parse(self.api_host, requests.put(uri, json=params, headers=self._headers()))
343375

344-
def __delete(self, request_uri):
376+
def _jwt_signed_delete(self, request_uri):
345377
uri = 'https://' + self.api_host + request_uri
346378

347-
return self.parse(self.api_host, requests.delete(uri, headers=self.__headers()))
379+
return self.parse(self.api_host, requests.delete(uri, headers=self._headers()))
348380

349-
def __headers(self):
381+
def _headers(self):
350382
iat = int(time.time())
351383

352384
payload = dict(self.auth_params)
@@ -358,3 +390,19 @@ def __headers(self):
358390
token = jwt.encode(payload, self.private_key, algorithm='RS256')
359391

360392
return dict(self.headers, Authorization=b'Bearer ' + token)
393+
394+
395+
def _format_date_param(params, key, format='%Y-%m-%d %H:%M:%S'):
396+
"""
397+
Utility function to convert datetime values to strings.
398+
399+
If the value is already a str, or is not in the dict, no change is made.
400+
401+
:param params: A `dict` of params that may contain a `datetime` value.
402+
:param key: The datetime value to be converted to a `str`
403+
:param format: The `strftime` format to be used to format the date. The default value is '%Y-%m-%d %H:%M:%S'
404+
"""
405+
if key in params:
406+
param = params[key]
407+
if hasattr(param, 'strftime'):
408+
params[key] = param.strftime(format)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
pytest==3.1.2
33
pytest-cov==2.5.1
44
responses==0.5.1
5+
coveralls

setup.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
license='MIT',
1616
packages=['nexmo'],
1717
platforms=['any'],
18-
install_requires=['requests', 'PyJWT[crypto]'],
18+
install_requires=[
19+
'requests',
20+
'PyJWT[crypto]',
21+
'pytz',
22+
],
1923
tests_require=['cryptography'],
2024
classifiers=[
2125
'Programming Language :: Python',

tests/test_nexmo.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
import nexmo
88
from util import *
99

10+
import sys
11+
if sys.version_info[0] == 3:
12+
bytes_type = bytes
13+
else:
14+
bytes_type = str
15+
1016

1117
@responses.activate
1218
def test_send_ussd_push_message(client, dummy_data):
@@ -171,3 +177,11 @@ def test_client_can_make_application_requests_without_api_key(dummy_data):
171177

172178
client = nexmo.Client(application_id='myid', private_key=dummy_data.private_key)
173179
client.create_call("123455")
180+
181+
182+
@responses.activate
183+
def test_get_recording(client, dummy_data):
184+
stub_bytes(responses.GET, 'https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957')
185+
186+
assert isinstance(client.get_recording('https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957'), bytes_type)
187+
assert request_user_agent() == dummy_data.user_agent

tests/test_sms.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,11 @@ def test_server_error(client):
3939
with pytest.raises(nexmo.ServerError) as excinfo:
4040
client.send_message({})
4141
excinfo.match(r'500 response from rest.nexmo.com')
42+
43+
@responses.activate
44+
def test_submit_sms_conversion(client):
45+
responses.add(responses.POST, 'https://api.nexmo.com/conversions/sms', status=200, body=b'OK')
46+
47+
client.submit_sms_conversion('a-message-id')
48+
assert 'message-id=a-message-id' in request_body()
49+
assert 'timestamp' in request_body()

tests/util.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ def stub(method, url):
3434
responses.add(method, url, body='{"key":"value"}', status=200, content_type='application/json')
3535

3636

37+
def stub_bytes(method, url):
38+
responses.add(method, url, body=b'THISISANMP3', status=200)
39+
3740
def assert_re(pattern, string):
3841
__tracebackhide__ = True
3942
if not re.search(pattern, string):

0 commit comments

Comments
 (0)